This document describes the JSON data schema used to define plots in maidr.
Your JSON schema should be a single maidr object with the following properties, or an array of objects if multiple plots exist on the page.
A single plot:
// a single plot
<script>
var maidr = {
id: "barplot_1",
subplots: [
[
{
id: "barplot_1", //add the same id to the svg component
layers: [
{
id: "bar_layer1",
type: "bar",
title: "Sample Bar plot",
axes: {
x: { label: "Category" },
y: { label: "Value" }
},
data: [
{
"x": "A",
"y": 10
},
{
"x": "B",
"y": 24
},
{
"x": "C",
"y": 15
},
{
"x": "D",
"y": 7
}
]
}
]
}
]
]
}
</script>Or multiple plots:
<script>
var maidr = {
"id": "multipanel_plot",
"subplots": [
[
{
"id": "line1",
"layers": [
{
"id": "line_layer",
"type": "line",
"title": "Line Plot: Random Data",
"axes": {
"x": { "label": "X-axis" },
"y": { "label": "Values" }
},
"data": [
[]
],
}
]
}
],
[
{
"id": "bar1",
"layers": [
{
"id": "bar1_layer",
"type": "bar",
"title": "Bar Plot: Random Values",
"axes": {
"x": { "label": "Categories" },
"y": { "label": "Values" }
},
"data": []
}
]
}
],
[
{
"id": "bar2",
"layers": [
{
"id": "bar2_layer",
"type": "bar",
"title": "Bar Plot 2: Random Values",
"axes": {
"x": { "label": "Categories" },
"y": { "label": "Values" }
},
"data": []
}
]
}
]
]
}
</script>Use the following to define the object properties:
type: the type of plot. The declarable types arealluvial,area,bar,box,boxen,bump,candlestick,chord,choropleth,contour,diverging_bar,dodged_bar,dot,dumbbell,error_bar,forest,funnel,gantt,gauge,heat,hexbin,hist,icicle,line,lollipop,manhattan,mosaic,network,pack,parallel_coordinates,pie,point,polar_area,radar,ridgeline,sankey,smooth,stacked_area,stacked_bar,stacked_normalized_area,stacked_normalized_bar,step,sunburst,sunflower,survival,tree,treemap,violin_box,violin_kde,volcano,waterfall,word_cloud.TraceTypeinsrc/type/grammar.tsis the source of truth;candlestick_deltaappears there but is built at runtime from a candlestick and a reference line, so it is not something a page declares. Not all of them are equally settled — see Trace type stability.
candlestick_deltahas no example page, on purpose. It should never be given one: it is a reading mode the model derives at runtime from acandlesticklayer (src/model/candlestickDelta.ts, reached with Alt+L), not a value a page declares.TraceFactory.createinsrc/model/factory.tshas no case for it, so a layer declaring it throwsInvalid trace type: candlestick_deltaand the figure never binds — an example would document something that does not exist. An audit of example coverage that finds this gap has found the correct state of affairs.It is the only deliberate gap. Other declarable types that lack a hand-authored example are genuinely missing one, and adding it is a normal contribution. The last three —
alluvial,areaandmanhattan— were filled byexamples/alluvial.html,examples/area-overlapping.htmlandexamples/manhattan.html, so every declarable type now has a page carrying its JSON. Note thatexamples/area.htmldeclaresstacked_area, notarea;examples/area-overlapping.htmlis the plain one, and the pair is written to be read together, since the difference between independent bands and bands that add up is the thing the missing page had left ambiguous.
id: the id that you added as an attribute of your main SVG.title: the title of the plot. (optional)axes: axes info for your plot. Each axis is a per-axis object:maidr.axes.x,maidr.axes.y, and (when used)maidr.axes.z. Supported properties per axis:label(string),min/max(number bounds),tickStep(number), andformat(anAxisFormatobject controlling numeric / categorical rendering).labelis optional and defaults toX,Y, orLevelfor the respective axis. Bare string values for axes are no longer accepted.data: the main data for your plot. See below.
The top-level maidr object also accepts optional figure-wide metadata that
applies across all subplots:
-
title,subtitle,caption(string): figure-level text. In a multi-panel figure,l tin the lobby announces the figuretitle, falling back to the focused subplot's own title when no figure title is authored. -
axes(object): figure-wide axis labels shared by every subplot — e.g. a facet grid whose panels all sit on one common X and Y axis. Onlylabelis honored at the figure level (the type isPick<AxisConfig, 'label'>, so a layer'smin/max/tickStep/formathave no figure-wide meaning):var maidr = { id: "facet_grid", title: "Sales by Region", axes: { x: { label: "Year" }, y: { label: "Revenue" } }, subplots: [ /* ... */ ] };
In the multi-panel lobby,
l x/l yannounce the figure-wide label when authored ("Figure X label is Year"); otherwise they fall back to the focused subplot's own axis ("Subplot 2, X label is ..."). Omittingaxeskeeps the existing behavior, so this is fully backward compatible. Onlyxandyare read at the figure level — there is no figure-widez, since the Z axis is inherently per-trace, sol zin the lobby always reports the focused subplot's own Z label.
The top-level maidr object accepts two optional properties for realtime/streaming scenarios (see the Live & Streaming Data guide):
live(boolean): enables live mode — in-place data updates viawindow.maidrLive.setData()/appendData()and the M monitor-mode key.maxWidth(number): sliding window size; appending a point beyond this width drops the oldest point(s), keeping at mostmaxWidthpoints per series.
var maidr = {
id: "live_chart",
live: true,
maxWidth: 50,
subplots: [ /* ... */ ]
};Not every declarable type carries the same promise, and that is not visible from the list above.
Fifteen of them predate the chart-type coverage roadmap (#814). The other thirty-seven were added by it, most of them inside about two weeks, and none of the thirty-seven has been through a user study.
bar, box, candlestick, dodged_bar, heat, hist, line, pie,
point, smooth, stacked_bar, stacked_normalized_bar, step,
violin_box, violin_kde
These are what MAIDR was built around. Their readings, their announcements and their keyboard model have been exercised by real users over real charts, and a change to any of them changes behaviour people already depend on.
alluvial, area, boxen, bump, chord, choropleth, contour,
diverging_bar, dot, dumbbell, error_bar, forest, funnel,
gantt, gauge, hexbin, icicle, lollipop, manhattan, mosaic,
network, pack, parallel_coordinates, polar_area, radar,
ridgeline, sankey, stacked_area, stacked_normalized_area,
sunburst, sunflower, survival, tree, treemap, volcano,
waterfall, word_cloud
These are prototypes. Treat them as prototypes.
- Under active development. They are being changed as they are used, not maintained against a settled specification.
- Unstable. Point shapes, field names, announcement wording and navigation semantics may change without a deprecation period, including in a patch release. A producer that emits one of these is pinned to the maidr version it was written against.
- Not validated. Each was measured against the drawing it reads, which is what the issues and the tests record. But measuring that a reading is faithful to the chart is a different claim from establishing that it is useful to a reader. Nobody has asked a blind or low-vision reader whether navigating a sunburst by depth, or hearing each parallel-coordinates axis on its own scale, is the right way to read one. Until that happens these are proposals about how a chart could be read, implemented and measured, not answers.
- Not a support commitment. A bug in one of these is worth reporting and is not a promise to keep the current behaviour.
If you are building something that has to keep working, build it on the stable set.
It is the diff of TraceType against 84d9003, the last commit on main
before #814 was filed:
git show 84d9003:src/type/grammar.ts | grep -oE "= '[a-z_0-9]+'"test/scripts/schemaStability.test.ts fails if a declarable type appears in
neither list or in both, so a new trace type has to be placed deliberately
rather than inherit either promise by being forgotten.
The data property is defined as a list of objects where each object is a record with fields x and y.
let maidr;
// barplot maidr.data structure: a simple array of values
maidr = {
"data": [
{
"x": "A",
"y": 5.982192824845484
},
{
"x": "B",
"y": 9.309858198175455
},
{
"x": "C",
"y": 7.3531284491571505
},
]
};
// boxplot maidr.data structure: an array of objects with properties lower_outlier, min, q1, q2, q3, max, and upper_outlier
maidr = {
"data": [
{
"lowerOutliers": [
40.0,
50.0
],
"min": 71.35451232573614,
"q1": 92.62315416457983,
"q2": 99.64912548800726,
"q3": 107.6684972253361,
"max": 118.19391634772752,
"upperOutliers": [
150.0,
160.0
],
"fill": "Group 1"
},
],
"orientation": "vert" //vert for vertical box plots, horz for horizontal bar plots
}
// boxen (letter-value) maidr.data structure: one object per distribution,
// each with a median and a ladder of quantile pairs. A box plot is this
// shape with exactly one rung; the point of a boxen is that a larger sample
// earns more of them, so the depth varies per distribution and between them.
maidr = {
"type": "boxen",
"data": [
{
"z": "Group 1",
"median": 99.64912548800726,
// Ordered outward from the median. `p` is the *tail*
// probability, which is how letter-value plots are defined
// and how the libraries drawing them report it: p = 0.25 is
// the rung spanning the middle half, p = 0.125 the middle
// three quarters, and so on. `lo` is the p quantile and `hi`
// is the 1 - p quantile.
//
// Getting this backwards is the easy mistake -- a producer
// that sends the *coverage* (0.5, 0.75, 0.875) rather than
// the tail will have every rung announced as the wrong
// percentile while the values stay right. The trace sorts by
// p rather than trusting the order sent, so a ladder built
// inward-first still reads correctly.
//
// 0.5 is out of range rather than a way of naming the median:
// it would put two positions labelled "50th percentile"
// either side of the one already called "median". Rungs
// outside (0, 0.5) are dropped.
"levels": [
{ "p": 0.0625, "lo": 71.35, "hi": 118.19 },
{ "p": 0.125, "lo": 80.11, "hi": 112.44 },
{ "p": 0.25, "lo": 92.62, "hi": 107.67 }
],
// Whatever fell beyond the deepest rung. Optional; a ladder
// drawn to full depth has none.
"lowerOutliers": [40.0, 50.0],
"upperOutliers": [150.0, 160.0]
}
],
"orientation": "vert" // horz when the distributions run across the page
}
//candlestick
maidr = {
"data":[
{
'value': '2023-02-16',
'open': 151.61,
'high': 151.82,
'low': 151.59,
'close': 151.8,
'volume': 0
},
]
}
//dodged_bar
maidr = {
"data":[
[
{
"x":"Adelie",
"fill":"Below",
"y":70
}
],
[ {
"x":"Adelie",
"fill":"Above",
"y":90
}]
]
}
// heatmap maidr.data structure: a 2D array of values
maidr = {
"data": {
"points": [
[ 60.5, 86.7, 89.3 ],
[ 18.6, 67.6, 83.9 ],
[ 18.5, 65.4, 78.7 ],
],
"x": [
"CoLA",
"MNLI",
"MRPC",
],
"y": [
"BERT",
"BiLSTM",
"BiLSTM+Attn",
]
}
}
//histogram
maidr = {
"data":[
{
"y": 4.0,
"x": 1.1475,
"xMin": 1.0,
"xMax": 1.295,
"yMin": 0,
"yMax": 4.0
}
]
}
//line
//
//`label` is optional and names the ordinal level that a numeric `y`
//encodes, for a line whose y axis is a category rather than a magnitude
//(a sleep stage, a Likert response, a severity grade). When present it is
//announced INSTEAD of the number, so the reader hears "Sleep stage is
//REM" rather than "Sleep stage is 4"; `y` stays NUMERIC either way,
//because it drives sonification, braille and the min/max range. Omit it
//for the continuous y most line charts have.
maidr = {
"data":[
[
{
"x": 1.0,
"y": 2.0
},
{
"x": 2.0,
"y": 4.0
},
]
//add multiple arrays for multiline plots
]
}
//step: piecewise-constant data — the value is HELD across an interval and
//then jumps, rather than being interpolated the way a line implies.
//Data is nested exactly like `line`: one inner array per series.
//
//`y` stays NUMERIC — it drives sonification, braille and the min/max range.
//`label` is the same optional per-point ordinal name documented under
//`line` above, and is read identically here; a hypnogram is its canonical
//case, which is why the example below carries one on every point.
//
//`stepDirection` is a layer-level property (a sibling of `axes` and
//`data`), not a per-point one. It says where the jump happens between two
//consecutive samples:
// "hv" hold y[i] until x[i+1], then jump (matplotlib 'steps-post',
// ggplot2 direction 'hv')
// "vh" jump at x[i], then hold (matplotlib 'steps-pre')
// "mid" jump midway between the two x values (matplotlib 'steps-mid')
//Omit it entirely when the producing library does not report one — the
//description only names a direction the data actually authored.
maidr = {
"type": "step",
"stepDirection": "hv",
"data":[
[
{
"x": 0.0,
"y": 5,
"label": "Awake"
},
{
"x": 0.5,
"y": 3,
"label": "N1"
},
{
"x": 1.0,
"y": 2,
"label": "N2"
}
]
//add multiple arrays for multiple step series
]
}
//pie: a FLAT array, one object per slice, in the order the slices are
//drawn. Never the nested array the bar-family types use — a pie is one
//row of N slices.
//
//`x` is the slice label and `y` its magnitude. `y` is strictly NUMERIC
//(unlike a bar's), because it is both the sonified value and the
//numerator of the slice's percentage.
//
//There is deliberately no `percentage` field. MAIDR derives the share of
//the whole as `y / sum(y) * 100`, so an authored percentage can never
//disagree with the values it is supposedly derived from. There is no
//`orientation` either: slices sit around a circle, not along an axis.
//
//`axes` here names what the two dimensions mean rather than any drawn
//axis: `x` what the slice labels are, `y` what their values measure.
//`selectors` must resolve to exactly N elements in slice order, so data
//index k and element k are the same wedge; a different count is treated
//as addressing something other than the wedges and the layer is left
//without highlighting rather than highlighting the wrong slice.
//A doughnut is the same layer — the hole is a visual detail.
maidr = {
"type": "pie",
"axes": {
"x": { "label": "Fruit" },
"y": { "label": "Units" }
},
"selectors": "#chart path.slice",
"data":[
{
"x": "Apples",
"y": 30
},
{
"x": "Bananas",
"y": 50
},
{
"x": "Cherries",
"y": 20
}
]
}
// scatterplot
maidr = {
data: [
{
"x": 1.0,
"y": 2.0
},
],
};
// scatterplot on a category axis: `xLabel` / `yLabel` name the category the
// coordinate is a *position* for. A strip plot, a swarm plot and a jittered
// `geom_point` all draw this shape.
//
// The coordinate stays numeric. The trace sorts on it, measures distance
// with it, and resolves the column index that stereo panning uses from it,
// and `'a' - 'b'` is NaN -- a string in `x` alone would give an unstable
// sort, a broken column index and a highlight that lands nowhere. Emit the
// tick position, not the drawn coordinate: on a strip plot the drawn one is
// the jitter, a precise number for a quantity that does not exist.
//
// Either axis may carry the names -- `stripplot(x='g', y='v')` puts them on
// x and `stripplot(y='g', x='v')` puts them on y -- so name the one that
// applies and leave the other off. An empty string counts as absent.
//
// One name per numeric slot: every point sharing an `x` must carry the same
// `xLabel`, since the slot *is* the category. A second, different name for
// the same `x` is dropped rather than splitting the category into two
// columns.
maidr = {
data: [
{ "x": 0, "xLabel": "a", "y": 1.4 },
{ "x": 0, "xLabel": "a", "y": 2.1 },
{ "x": 1, "xLabel": "b", "y": 3.0 }
],
};
// smooth line maidr.data: an object containing x and y properties, each with an array of float values
// note that data is an array here as scatterplots are often combine with line plots
maidr = {
"data":[
[
{
"x": 4.7,
"y": 3.12,
"svg_x": 404.51,
"svg_y": 390.012
},
]
]
}
// violin_box: summary statistics overlay for violin plots
// data is an array of BoxPoint objects, one per violin
maidr = {
"type": "violin_box",
"data": [
{
"fill": "Ideal",
"lowerOutliers": [],
"min": 326,
"q1": 878,
"q2": 1810,
"q3": 4678,
"max": 18806,
"upperOutliers": [18806],
"mean": 3458
},
{
"fill": "Premium",
"lowerOutliers": [],
"min": 326,
"q1": 1046,
"q2": 3185,
"q3": 6296,
"max": 18823,
"upperOutliers": []
}
]
}
// violin_kde: KDE density curve for violin plots
// data is a 2D array: data[violinIndex][curvePosition] = ViolinKdePoint
// points come in left/right pairs at each Y level (do NOT deduplicate)
maidr = {
"type": "violin_kde",
"data": [
[
{ "x": "Ideal", "y": -501.7, "svg_x": 100.41, "svg_y": 281.84, "width": 0.044 },
{ "x": "Ideal", "y": -501.7, "svg_x": 103.84, "svg_y": 281.84, "width": 0.044 },
{ "x": "Ideal", "y": -294.2, "svg_x": 98.25, "svg_y": 279.41, "width": 0.100 },
{ "x": "Ideal", "y": -294.2, "svg_x": 105.99, "svg_y": 279.41, "width": 0.100 }
],
[
{ "x": "Premium", "y": -400.0, "svg_x": 200.0, "svg_y": 270.0, "width": 0.035 },
{ "x": "Premium", "y": -400.0, "svg_x": 205.0, "svg_y": 270.0, "width": 0.035 }
]
]
}If multiple plots are overlaid on the same SVG, provide the data corresponding to every plot in the layers array.
maidr = {
"id": "multilayer_plot",
"subplots": [
[
{
"id": "445f4f08-b8a5-4204-8c55-0851eda7daec",
"layers": [
{
"id": "f548e01f-ed13-469c-9e0a-cea420ec8b3f",
"type": "bar",
"title": "",
"axes": {
"x": { "label": "X values" },
"y": { "label": "Bar values" }
},
"data": [
{
"x": "0",
"y": 3.0
},
{
"x": "1",
"y": 5.0
},
],
},
{
"id": "f022d8e9-4aff-4ab0-9959-904fd07c9bd2",
"type": "line",
"title": "Multilayer Plot Example",
"axes": {
"x": { "label": "X values" },
"y": { "label": "Line values" }
},
"data": [
[
{
"x": 0.0,
"y": 10.0,
"fill": "Line Data"
},
{
"x": 1.0,
"y": 8.0,
"fill": "Line Data"
},
]
],
}
]
}
]
]
}Violin plots use two layers in the same subplot: violin_box for summary statistics and violin_kde for the KDE density curve. Put violin_box first so it is the default view. Users switch between layers with PageUp/PageDown.
For the full data contract and field reference, see VIOLIN_PLOT_SPEC.md.
maidr = {
"id": "violin_plot",
"subplots": [
[
{
"layers": [
{
"id": "box-layer",
"type": "violin_box",
"title": "Diamond Price Distribution by Cut Quality",
"axes": {
"x": { "label": "Cut Quality" },
"y": { "label": "Price (USD)" }
},
"selectors": [
{
"lowerOutliers": [],
"min": "#box1 .whisker-min",
"iq": "#box1 .iqr-rect",
"q2": "#box1 .median-line",
"max": "#box1 .whisker-max",
"upperOutliers": []
}
],
"data": [
{
"fill": "Ideal",
"lowerOutliers": [],
"min": 326,
"q1": 878,
"q2": 1810,
"q3": 4678,
"max": 18806,
"upperOutliers": []
}
]
},
{
"id": "kde-layer",
"type": "violin_kde",
"title": "Diamond Price Distribution by Cut Quality",
"axes": {
"x": { "label": "Cut Quality" },
"y": { "label": "Price (USD)" }
},
"selectors": [
"#violin-group-1 path"
],
"data": [
[
{ "x": "Ideal", "y": -501.7, "svg_x": 100.4, "svg_y": 281.8, "width": 0.044 },
{ "x": "Ideal", "y": -501.7, "svg_x": 103.8, "svg_y": 281.8, "width": 0.044 },
{ "x": "Ideal", "y": -294.2, "svg_x": 98.3, "svg_y": 279.4, "width": 0.100 },
{ "x": "Ideal", "y": -294.2, "svg_x": 106.0, "svg_y": 279.4, "width": 0.100 }
]
]
}
]
}
]
]
}