The plotdown block
Spec version 0.1 · Observable Plot 0.6.19 · Schema: plot.schema.json · Examples: fixtures/
A plotdown block is a fenced code block of Markdown that holds a chart as strict JSON: the options of Observable Plot's Plot.plot(), with marks and transforms written as data instead of function calls. A renderer turns the block into the same chart that the equivalent JavaScript draws.
```plot
{
"title": "Penguin body mass",
"data": {"penguins": {"csv": "species,body_mass_g\nAdelie,3750\nGentoo,5000\nChinstrap,3500\n"}},
"color": {"legend": true},
"y": {"grid": true},
"marks": [
{"mark": "rectY", "data": "penguins", "x": "body_mass_g", "fill": "species",
"transform": [{"binX": {"y": "count"}}]},
{"mark": "ruleY", "data": [0]}
]
}
```
is the JSON form of
Plot.plot({
title: "Penguin body mass", color: {legend: true}, y: {grid: true},
marks: [
Plot.rectY(penguins, Plot.binX({y: "count"}, {x: "body_mass_g", fill: "species"})),
Plot.ruleY([0])
]
})
The words MUST, MUST NOT, SHOULD and MAY are used as in RFC 2119. "Renderer" means an implementation of this spec; the reference renderer is the npm package @devlooped/plotdown.
1. Versions
- This document is spec version 0.1. The schema's
$idcarries it:https://plotdown.cc/schema/0.1/plot.schema.json. The schema of the current version is also at a URL that does not change,https://plotdown.cc/schema/plot.schema.json, for catalogs (SchemaStore) and editors that follow the latest spec. - The option names and values are those of Observable Plot 0.6.19. A renderer pins one Plot version; the allowlists of this spec (marks, transforms, option keys) are generated from that version's type declarations, so they change only with a new spec version.
- A block MAY name the schema it was written against with a top-level
"$schema"key. Renderers ignore its value.
2. Fences
A block is a fenced code block whose info string starts with one of these words, in any case:
| Fence | Use |
|---|---|
plot |
Canonical. GitHub highlights it as gnuplot (Linguist maps .plot to Gnuplot). |
oplot |
Same meaning; not highlighted by GitHub. Short for Observable Plot. |
observable-plot |
Same meaning; not highlighted by GitHub. |
Anything after the first word of the info string is ignored.
A block MAY also live in a file of its own, outside Markdown: its name ends in .plotdown.json (such as penguins.plotdown.json), and its content is the JSON of the block, with no fence. The name lets editors and catalogs such as SchemaStore apply the schema to the file without being told; a "$schema" key (section 1) does the same for a file named otherwise.
Because a plot fence may also hold a gnuplot script, a renderer that finds blocks in rendered HTML (where the fence name may be lost or ambiguous) SHOULD render a block only when its text, trimmed, starts with {, ends with }, parses as JSON and is an object with a marks array. The same test tells a complete block from one that is still streaming. A block that passes this test but fails validation is shown with its error (section 10), not left as code silently.
3. The block
The block is one JSON object (RFC 8259): no comments, no trailing commas, no NaN, no functions. Its keys are:
| Key | Value |
|---|---|
marks |
Required. An array of marks (section 4), drawn in order. |
data |
An object of named datasets (section 7) that marks refer to by name. |
$schema |
Optional URL of the schema. |
| Every other key | An option of Plot.plot(), with the same name and meaning (section 8). |
Every key that is not in this spec is an error (section 10). A typo is an error rather than a chart that silently differs.
4. Marks
A mark is an object:
{"mark": "dot", "data": "penguins", "x": "flipper_length_mm", "y": "body_mass_g", "fill": "species", "transform": [ … ]}
mark(required) is the name of a Plot mark function:"dot"meansPlot.dot.datais the mark's data (section 7).transformis an array of transforms (section 6).- Every other key is an option of that mark in Plot, with the same name and value.
The mark is called as Plot.<mark>(data, options), where options holds every key but mark, data and transform, after the transforms are applied to it.
4.1 The marks
| Family | Marks |
|---|---|
| area | area, areaX, areaY |
| arrow | arrow |
| auto | auto |
| axis and grid | axisX, axisY, axisFx, axisFy, gridX, gridY, gridFx, gridFy |
| bar | barX, barY |
| bollinger | bollingerX, bollingerY |
| box | boxX, boxY |
| cell | cell, cellX, cellY |
| contour | contour |
| crosshair | crosshair, crosshairX, crosshairY |
| delaunay | delaunayLink, delaunayMesh, hull, voronoi, voronoiMesh |
| density | density |
| difference | differenceX, differenceY |
| dot | dot, dotX, dotY, circle, hexagon |
| frame | frame |
| geo | geo, sphere, graticule |
| hexgrid | hexgrid |
| image | image |
| line | line, lineX, lineY |
| linear regression | linearRegressionX, linearRegressionY |
| link | link |
| raster | raster |
| rect | rect, rectX, rectY |
| rule | ruleX, ruleY |
| text | text, textX, textY |
| tick | tickX, tickY |
| tip | tip |
| tree | tree, cluster |
| vector | vector, vectorX, vectorY, spike |
| waffle | waffleX, waffleY |
No other name is a mark: not plot, legend, marks, autoSpec, nor any helper of Plot.
4.2 How each mark takes data
frame,sphere,graticuleandhexgridtake no data: adatakey is an error. They are calledPlot.<mark>(options).- The axis and grid marks,
contourandrastertake optional data. Withoutdatathey are calledPlot.<mark>(options). - Every other mark is called
Plot.<mark>(data, options); withoutdata,dataisundefined.
4.3 Options
A mark accepts the options Plot declares for it (its …Options interface in Plot's type declarations), except those that only take a function: transform, initializer and render. Most marks share Plot's common mark options (fill, stroke, strokeWidth, opacity, title, href, tip, fx, fy, sort, filter, dx, dy, clip, className, …); the schema lists, per mark, the keys it accepts.
One name differs from Plot: the auto mark's own mark option ("area", "bar", "dot", "line", "rule") is written autoMark, since mark names the plotdown mark.
{"mark": "auto", "data": "penguins", "x": "species", "y": {"value": "body_mass_g", "reduce": "mean"}, "autoMark": "bar"}
Options that hold names in Plot hold the same names here: curves ("curve": "catmull-rom"), symbols ("symbol": "triangle"), markers ("markerEnd": "arrow"), reducers, intervals ("interval": "month"), color schemes ("scheme": "blues"), projections ("projection": "albers-usa"), number and time formats ("tickFormat": "$.2f", "tickFormat": "%b %Y", d3-format and d3-time-format strings).
5. Channels
A channel is a mark option that varies per datum: x, y, x1, x2, y1, y2, z, fill, stroke, r, text, title, href, src, symbol, fx, fy, fontSize, rotate, length, opacity, strokeWidth, fillOpacity, strokeOpacity, weight, width, height, geometry, path, and the like. Its value is one of:
| Value | Meaning | Example |
|---|---|---|
| string | The name of a field of each row | "x": "body_mass_g" |
string that is a CSS color, for fill and stroke |
A constant color (Plot's own rule) | "fill": "steelblue", "stroke": "#e45" |
number, boolean, null |
A constant | "r": 3, "fillOpacity": 0.5, "stroke": null |
| array | One value per datum | "x": [1, 2, 3] |
| object | {"value": <field, constant or array>, "label": "…", "scale": "…" | true | false | null}, plus interval, thresholds, domain, cumulative where Plot accepts them (a bin input such as {"value": "weight", "thresholds": 20}) and reduce, zero, color for the auto mark |
"x": {"value": "date", "label": "Date"} |
A field name that is also a CSS color cannot be referenced for fill or stroke (as in Plot without an accessor). Fields of nested objects cannot be reached: a field name is looked up on the row as is ("a.b" is the key a.b).
The channels option (extra channels for the tip) is an object of channel values. The tip option is true, false, "x", "y", "xy" or an object of tip options (the options of the tip mark, plus pointer: "x", "y" or "xy"). The mark-level sort option is a field name, {"channel": "-y", "order": …}, or a domain sort such as {"x": "-y", "reduce": "sum", "limit": 10}.
6. Transforms
A transform is an object with exactly one key, the name of a Plot transform, whose value is the transform's argument:
"transform": [{"filter": "sex"}, {"groupX": {"y": "count"}}]
6.1 Order
Transforms apply in array order: the first one transforms the mark's options, the next one transforms the result, and so on. [A, B] is Plot.B(argB, Plot.A(argA, options)); the example above is
Plot.groupX({y: "count"}, Plot.filter("sex", options))
that is, the first entry is the innermost call, as Plot composes transforms: rows are filtered, then grouped. Marks that stack implicitly (barY, areaY, rectY, waffleY and their X forms) still do so after the listed transforms, as in Plot.
6.2 Arguments
| Transforms | Argument | Call |
|---|---|---|
bin, binX, binY |
Outputs: channel name → reducer. Also data, filter, sort (reducers), reverse, z, and the bin options cumulative, domain, thresholds, interval |
Plot.binX(arg, options) |
group, groupX, groupY, groupZ |
Outputs: channel name → reducer. Also data, filter, sort, reverse, z |
Plot.groupX(arg, options) |
hexbin |
Outputs: channel name → reducer | Plot.hexbin(arg, options) |
map |
Outputs: channel name → "cumsum", "rank" or "quantile" |
Plot.map(arg, options) |
mapX, mapY |
"cumsum", "rank" or "quantile" |
Plot.mapY(arg, options) |
normalizeX, normalizeY |
A basis ("first", "last", "min", "max", "mean", "median", "sum", "extent", "deviation", "pNN"), {"basis": …}, or true for the default |
Plot.normalizeY(arg, options) |
windowX, windowY |
k (a positive integer) or {"k", "reduce", "anchor", "shift", "strict"} |
Plot.windowY(arg, options) |
stackX, stackX1, stackX2, stackY, stackY1, stackY2 |
{"offset", "order", "reverse", "z"} or true for the defaults |
Plot.stackY(arg, options) |
dodgeX, dodgeY |
An anchor, {"anchor", "padding", "r"} or true |
Plot.dodgeY(arg, options) |
select |
"first", "last", or {"<channel>": "min" | "max"} |
Plot.select(arg, options) |
selectFirst, selectLast, selectMinX, selectMinY, selectMaxX, selectMaxY |
true, or {"z": …} |
Plot.selectLast({...options, ...arg}) |
shiftX, shiftY |
An interval: a number or a time interval such as "5 days" |
Plot.shiftX(arg, options) |
filter |
A channel: a field name (rows where it is truthy) or an array of booleans | Plot.filter(arg, options) |
sort |
A channel (field name), or {"channel": "-y"}, {"value": …, "order": "ascending" | "descending"} |
Plot.sort(arg, options) |
reverse |
true |
Plot.reverse(options) |
shuffle |
true or {"seed": n} |
Plot.shuffle({...options, ...arg}) |
centroid, geoCentroid |
true or {"geometry": …} |
Plot.centroid({...options, ...arg}) |
treeNode, treeLink |
true or {"path", "delimiter", "treeAnchor"} |
Plot.treeNode({...options, ...arg}) |
pointer, pointerX, pointerY |
true or {"maxRadius", "px", "py"} |
Plot.pointerX({...options, ...arg}) |
Reducers are names: "count", "distinct", "sum", "proportion", "proportion-facet", "min", "min-index", "max", "max-index", "mean", "median", "mode", "variance", "deviation", "first", "last", "identity", a percentile "p00"…"p99"; bin outputs also take "x", "x1", "x2", "y", "y1", "y2", "z", and group outputs "x", "y", "z". An output may be {"reduce": <name>, "scale": …, "label": …} or null. Window reducers are "mean", "median", "sum", "min", "max", "mode", "variance", "deviation", "difference", "ratio", "first", "last" and percentiles.
6.3 Transform options on the mark
Some transforms read options from the mark's options, as in Plot: the bin transforms read thresholds, interval, domain and cumulative; hexbin reads binWidth; the pointer transforms read maxRadius, px, py; the tree transforms read path, delimiter, treeAnchor; centroid reads geometry; shuffle reads seed; the select transforms read z. These keys are valid on a mark only when it lists a transform that reads them; otherwise they are errors that name the transform.
{"mark": "rectY", "data": "penguins", "x": "body_mass_g", "thresholds": 20, "transform": [{"binX": {"y": "count"}}]}
Not supported: Plot's transform and initializer helpers, the find reducer, Plot.normalize/Plot.window/Plot.bollinger as map functions, custom reducers, maps, selectors, intervals or thresholds given as functions.
7. Data
7.1 Forms
The data of a mark (and the data of the facet option) is one of:
| Form | Example |
|---|---|
| Inline rows: an array of objects, of values, or of arrays | [{"x": 1, "y": 2}, {"x": 2, "y": 5}], [0] |
The name of a dataset of the top-level data map |
"penguins" |
| CSV text with a header row | {"csv": "x,y\n1,2\n2,5\n"} |
| TSV text with a header row | {"tsv": "x\ty\n1\t2\n"} |
| A remote file (off by default, 7.4) | {"url": "https://example.com/data.csv", "format": "csv"} |
| A GeoJSON object | {"type": "FeatureCollection", "features": [ … ]} |
null |
no data |
The top-level data map holds named datasets in any of these forms but a name: "data": {"penguins": {"csv": "…"}}. Dataset names start with a letter or _ and hold letters, digits, _ and -. Marks that name the same dataset receive the same array, so Plot's facets (which match data by identity) work across marks:
{"facet": {"data": "penguins", "x": "sex"}, "marks": [{"mark": "frame"}, {"mark": "dot", "data": "penguins", "x": "flipper_length_mm", "y": "body_mass_g"}]}
A GeoJSON object is recognized by its type: Feature, FeatureCollection, Point, MultiPoint, LineString, MultiLineString, Polygon, MultiPolygon, GeometryCollection or Sphere. Plot reads a FeatureCollection as its features.
7.2 CSV and TSV
CSV follows RFC 4180: fields separated by , (TSV: tab), quoted with ", a "" inside quotes is a quote, records end with LF or CRLF, blank lines are skipped. The first record names the columns; a duplicate name is an error, and the names __proto__, constructor and prototype are rejected. Each cell is typed:
| Cell (after trimming spaces) | Value |
|---|---|
| empty | null |
true, false |
boolean |
a decimal number: -12, 3.5, .5, 1e6 |
number |
| anything else | the text as written |
Hexadecimal, NaN and Infinity stay text. Then the date rule applies to each column.
7.3 Dates
JSON has no dates; this rule makes them:
An array of values, or a column of rows, in which every value that is not
nullis an ISO 8601 date string, and there is at least one, becomes Date values (andnullstaysnull).
An ISO 8601 date string here is YYYY-MM, YYYY-MM-DD, or YYYY-MM-DDTHH:MM, with optional :SS and .sss (up to 9 digits, read to milliseconds), and an optional Z or offset ±HH:MM / ±HHMM. A six-digit year with a sign (+YYYYYY) is allowed. Values must be real dates (2023-02-30 is not one). A date-time without an offset is UTC, so that a chart renders the same in every time zone. A bare year ("2024") is not a date, and a column with one string that is not a date stays text.
The rule applies to: inline rows and CSV/TSV rows (per field of object rows, per index of array rows), arrays of values given as data ("data": ["2024-03-01"]), and every array of values in the options, such as a channel given as an array, a scale's domain, ticks or grid, a bin domain. It does not apply inside GeoJSON objects or to single strings (a single string in a channel is a field name).
7.4 Remote data and images
A {"url": …} dataset and the src of an image mark load from the network. Renderers MUST NOT load them unless the host allows it, and SHOULD let the host allow nothing (the default), everything, or a list of origins. Rules:
- Only
httpandhttpsURLs. A relative URL resolves against the host's base URL; with a list of origins it needs one. formatis"csv","tsv"or"json"; without it, the file extension (.csv,.tsv,.json,.geojson) decides, and an unknown extension is an error. A JSON file holds rows (an array) or a GeoJSON object.- Requests carry no credentials and no referrer. A redirect to an origin outside the list is an error.
- The loaded data is checked exactly like inline data (limits, links, CSS).
- An
imagesrcthat is adata:image/…URL needs no network and is always allowed;file:andblob:are never allowed.
The src of an image is a constant when it starts with ./, ../, / or a scheme (Plot's rule), and a field name otherwise.
8. Plot options
The top-level keys other than marks, data and $schema are those of Plot.plot():
- Layout:
width,height,aspectRatio,margin,marginTop,marginRight,marginBottom,marginLeft,inset…,style,className,clip,figure. - Text:
title,subtitle,caption(strings: HTML elements in Plot are not available),ariaLabel,ariaDescription. - Scales:
x,y,fx,fy,r,color,opacity,symbol,length: objects of scale options (type,domain,range,scheme,interpolateby name,nice,zero,reverse,label,grid,ticks,tickFormat,tickRotate,percent,legend, …).color,opacityandsymbolalso take their legend options (legend,width,columns,swatchSize, …). The scale defaults (grid,label,axis,nice,zero,round,clamp,padding,align,inset…) are also valid at the top level, as in Plot. - Projection:
projection: a name ("albers-usa","equal-earth","mercator","orthographic", …) or{"type", "domain" (GeoJSON), "rotate", "parallels", "precision", "clip", "inset"…}. - Facets:
facet:{"data", "x", "y", "margin"…, "grid", "label"}; or thefxandfychannels of marks.
The style option is a string of CSS or an object of CSS properties with string or number values; it MUST NOT contain backslash escapes, @import, or the CSS functions url() (other than url(#id)), image(), image-set(), cross-fade(), element(), expression().
9. Security
A block may come from anyone, including a language model, and renderers run it in the reader's browser, in extensions and on servers. A conforming renderer:
- Evaluates nothing. The block is parsed as JSON; mark and transform names are looked up in fixed allowlists (sections 4.1 and 6.2) as own properties, never on prototypes; no string becomes code.
- Rejects function-like strings in options: a string that starts like a JavaScript function —
function,async,(a, b) =>,d =>— is an error, since Plot would read it as a field name and draw the wrong chart. Free text is exempt: the top-leveltitle,subtitle,caption,ariaLabel,ariaDescription, anylabel, and data values. - Limits links. Every value of an
hrefchannel (on the mark or in its tip), resolved over the mark's data, is anhttp,httpsormailtoURL or a relative one. Whitespace and control characters inside a scheme (java\tscript:) are ignored when the scheme is read. - Loads nothing from the network unless the host allows it (7.4).
- Rejects CSS
url()that is not a#fragment, in any string of the block, data included: Plot acceptsurl(…)as a color, and an SVG paint server or filter can load a remote file. - Rejects the keys
__proto__,constructorandprototypeanywhere in the block, data included. - Applies limits (section 10).
- Checks its own output (recommended): no
<script>,<foreignObject>or other active element, noon…attribute, no unsafe link, no remoteurl(), images only as allowed.
10. Errors and limits
These are errors:
- text that is not JSON, or JSON that is not an object; a missing
marksarray; - a key that is not a Plot option of its place (top level, scale, projection, facet, mark, tip, channel object, transform argument, output), with the rules of 4.3 and 6.3;
- an unknown mark, transform, reducer or map name; a transform object without exactly one key;
dataon a mark that takes none; a dataset name that is not in thedatamap;- the security rules of section 9 and the remote rules of 7.4;
- the limits below;
- whatever Plot itself rejects when the chart is built (an unknown curve, an invalid scale type…).
The reference renderer's limits (a host may change them):
| Limit | Default |
|---|---|
| Source text | 524,288 characters |
| Nesting of arrays and objects | 64 levels |
| Marks | 64 |
| Rows of one dataset, or values of one channel array | 100,000 |
width, height |
10,000 px |
| Thresholds given as a count (bins, contours, density) | 10,000 |
pixelSize of raster and contour |
at least 0.5 |
binWidth of hexbin and hexgrid |
at least 1 |
| Remote file | 5,242,880 characters, 10 s |
An error names the JSON path of the offending value, written $ for the block, .key for a key that is an identifier, ["key"] for any other key, and [n] for an index: $.marks[1].transform[0].binX.y. An error in a value of a dataset names the cell ($.data.links[3].url) or, for CSV and TSV text, the text with the row and column in the message. A JSON syntax error also carries a line and a column. A renderer that cannot render a block SHOULD leave the block as code and show the error next to it.
11. Conformance
plot.schema.json(JSON Schema 2020-12) describes the block. It accepts on every mark the transform options of 6.3 whether or not the transform is present, and it does not check links, URLs, dates, function-like strings, CSS or limits: a valid block MUST pass the schema; passing the schema is not enough.fixtures/holds 30 blocks ported from the Plot documentation, one per mark family, covering every transform family. A renderer conforms when each fixture renders the same SVG as the equivalent hand-writtenPlot.plot()call with the pinned Plot version (ids of clip paths, markers, patterns and filters aside).
12. Writing blocks (for people and models)
- Start from the JavaScript you would write and replace each call:
Plot.dot(data, {…})→{"mark": "dot", "data": …, …};Plot.binX({y: "count"}, {…})→"transform": [{"binX": {"y": "count"}}]; nested transforms → the innermost first. - Name fields with strings; never write
d => …. For a derived value, use a transform (normalizeY,mapY,windowY,binX,groupX…) or compute it in the data. - Put data inline: rows for small tables,
{"csv": …}for more; give it a name indatawhen several marks use it. Write dates as ISO 8601 strings. - Constant colors are CSS colors (
"fill": "steelblue"); other strings are field names. - Validate against
plot.schema.json(with JSON Schema 2020-12 support) before emitting a block.
Example: a line with a moving average and a tip
{
"y": {"grid": true, "label": "Close ($)"},
"data": {"aapl": [
{"date": "2024-03-01", "close": 129.6}, {"date": "2024-03-02", "close": 130.2},
{"date": "2024-03-03", "close": 128.9}, {"date": "2024-03-04", "close": 131.5},
{"date": "2024-03-05", "close": 133.1}, {"date": "2024-03-06", "close": 132.4}
]},
"marks": [
{"mark": "lineY", "data": "aapl", "x": "date", "y": "close", "strokeOpacity": 0.3},
{"mark": "lineY", "data": "aapl", "x": "date", "y": "close", "transform": [{"windowY": {"k": 3, "reduce": "mean"}}]},
{"mark": "tip", "data": "aapl", "x": "date", "y": "close", "transform": [{"pointerX": true}]}
]
}
13. The reference renderer
plotdown (npm) implements this spec in TypeScript with @observablehq/plot as its only dependency; its core runs in browsers, Manifest V3 extensions, workers and Node (with a jsdom or linkedom document).
import {render, renderAsync, validate, parse, isPlotSpec, isPlotFence, FENCE_NAMES, PlotdownError} from "@devlooped/plotdown";
const figure = render(source, {document, allowRemote: false, baseUrl}); // sync; throws PlotdownError {path, message}
const figure2 = await renderAsync(source, {allowRemote: ["https://data.example.com"], fetch}); // loads data URLs first
render is synchronous and refuses a block with a data URL (even when remote data is allowed) with an error that points to renderAsync, or to resolveRemoteData, which returns a copy of the block with the remote data inlined. toPlotOptions returns the options of Plot.plot() for hosts that call Plot themselves.
plotdown is not affiliated with Observable, Inc. Observable Plot is © Observable, Inc., ISC license.