Last updated

MAIDR Data Schema

This document describes the JSON data schema used to define plots in maidr.

Schema Structure

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>

Object Properties

Use the following to define the object properties:

candlestick_delta has no example page, on purpose. It should never be given one: it is a reading mode the model derives at runtime from a candlestick layer (src/model/candlestickDelta.ts, reached with Alt+L), not a value a page declares. TraceFactory.create in src/model/factory.ts has no case for it, so a layer declaring it throws Invalid trace type: candlestick_delta and 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, area and manhattan — were filled by examples/alluvial.html, examples/area-overlapping.html and examples/manhattan.html, so every declarable type now has a page carrying its JSON. Note that examples/area.html declares stacked_area, not area; examples/area-overlapping.html is 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.

Top-Level Figure Properties

The top-level maidr object also accepts optional figure-wide metadata that applies across all subplots:

Pointer Interaction

Top-Level Properties for Live Charts

The top-level maidr object accepts two optional properties for realtime/streaming scenarios (see the Live & Streaming Data guide):

var maidr = {
  id: "live_chart",
  live: true,
  maxWidth: 50,
  subplots: [ /* ... */ ]
};

Trace type stability

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). Thirty-seven were added by it, most of them inside about two weeks, and rug (#1132), roc, pr_curve (#1349), percentile_band (#1348), directed_graph (#1347), and sequence and venn (for Mermaid's sequence and Venn diagrams) after it. None of the forty-four has been through a user study.

Elsewhere in these docs — the braille guide, the integration guides' supported-type tables and the examples gallery — an experimental type is marked [experimental] after its name; a type with no mark is stable. Wherever they enumerate chart types, the two are also kept apart rather than interleaved: the stable part comes first, under its own heading or in its own table or list, and the experimental part follows it — the same order as the two lists below. py-maidr (Plot Type Stability) and maidr for R (Supported plot types) mark their own docs the same way.

Stable

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.

Experimental

alluvial, area, boxen, bump, chord, choropleth, contour, directed_graph, diverging_bar, dot, dumbbell, error_bar, forest, funnel, gantt, gauge, hexbin, icicle, lollipop, manhattan, mosaic, network, pack, parallel_coordinates, percentile_band, polar_area, pr_curve, radar, ridgeline, roc, rug, sankey, sequence, stacked_area, stacked_normalized_area, sunburst, sunflower, survival, tree, treemap, venn, volcano, waterfall, word_cloud

These are prototypes. Treat them as prototypes.

If you are building something that has to keep working, build it on the stable set.

How the split is derived

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.

Shapes deliberately not read

A chart no trace type describes is declined rather than forced into the nearest shape, because a reading that sounds confident and says something false is worse than no reading — the lesson #814 recorded for every chart it added. A decline that is a decision rather than an omission is written down here, once, and an adapter that meets the chart points at this section rather than carrying an argument of its own. Three adapters had each written their own reason for the same chart, and the reasons did not agree (#1190).

Set-overlap diagrams (Venn, Euler)

No longer declined: read by the venn trace, which the Mermaid, Highcharts (venn, euler), Chart.js (chartjs-chart-venn) and amCharts (am5venn.Venn) adapters produce. The section stays so the history of the decision is findable from the adapters that used to point here (#1190).

The objection was never the data. Every one of those producers carries the author's own declaration of a region — sets: ['A', 'B'], value: 2. What was missing was a trace whose navigation is set membership: a flat list of A, B, A∩B says how big each region is, and a reader stepping through it by index is walking a layout rather than a membership. venn is that trace: a MovableGraph over regions addressed as (set count, index among regions of that count), where up and down move to regions of fewer or more sets and left and right move between the regions sharing that count.

Two earlier reasons stay retired. That a Venn's areas are drawn approximately is true of the layout and not of what is read — the producers declare the size, and the reading announces the declaration, not the drawing. That a Venn has no axis is true, and no more of an obstacle than it was for the treemap or the sankey.

Selectors

selectors tells MAIDR which drawn element each point of a layer is, so it can outline the one being read. It is the only part of a layer MAIDR matches against the rendered chart rather than reading as data, and its shape means different things to different layer types.

A layer whose selectors resolve to nothing still announces every point, sounds every value and moves the braille cursor. It loses only the outline a sighted or low-vision reader follows, which nobody who only listens will notice. MAIDR therefore says so in the browser console, once per layer:

[MAIDR] Layer "…" (bar): `selectors` is set, but it resolved to no element this layer can highlight, …

Three things cause it: a selector that matches nothing in the rendered SVG, a shape the layer type does not read (the table below), and a payload attached before the chart finished drawing. The last one happens to a charting library that draws asynchronously, such as Plotly: attach the payload once the library reports the chart drawn, not when its first <svg> appears.

Where a selector is matched

A selector is matched inside its own chart: the element carrying the maidr or maidr-data attribute, or the children of <Maidr>, and for a Plotly chart the layers Plotly stacks over it, where its titles, legend and gauges are drawn. Two charts on one page that share ids, such as one output shown twice, or whose selectors name no chart at all, as Plotly's do, each find their own marks. The selector is still tested against the whole document, so a part naming something round the chart -- #vl-1 svg rect, [id="svg-id"] path -- matches as it always did.

When nothing inside the chart matches, the rest of the page is searched, leaving out every other chart MAIDR has bound, and the console says so once per selector:

[MAIDR] Selector "…" matches nothing inside its chart, so the elements it matches elsewhere on the page were used. …

Name elements inside the chart; the search beyond it is kept only so that a page which drew a chart's marks elsewhere keeps its highlight.

The shapes

A named shape -- a <rect>, <path>, <circle> and so on -- is drawn over: a dark one in the highlight colour, a light one in a darker shade of its own colour, at least 3:1 against it, keeping an edge it draws in a colour of its own. In high contrast mode, where the reader's palette paints every mark, each is drawn in the highlight colour. A label the chart draws over the shape, as <text> after it, that would read below 4.5:1 on the highlight is shown in black or white while the highlight is -- in high contrast mode, in the reader's light or dark colour -- whichever reads better on it; the label itself is left as drawn. A named group, or any element holding such shapes, is outlined through them instead: each shape inside it that draws anything is stroked in the highlight colour, wider than drawn, over whatever paint its own attributes or the page's stylesheet give it, and left unfilled, so the group shows through; its text keeps its colour. The outline is drawn last in the group's parent, above the marks drawn after it.

By layer type

Layer types Shapes read How the matches pair with the data
bar, hist, dot, lollipop, funnel string; list with one entry per bar (one row only) A string pairs in document order; when fewer elements than bars match, bars whose value is 0 are the ones taken to be undrawn.
dodged_bar, stacked_bar, stacked_normalized_bar, mosaic, diverging_bar string; grid [series][category] A string is walked series by series. domMapping.order: 'column' walks it category by category instead, each category from its last series unless domMapping.groupDirection is 'forward' (diverging_bar runs forward by default). A grid is in payload order.
point, sunflower, volcano, manhattan string The n-th match is the mark of the n-th point, so the string should match the marks in the order the points are listed. Each navigation outlines the marks of the points it reads: every point of a row, a column or a grid cell, or the one point it is on. When the number of matches is not the number of points, rows and columns are found by grouping the marks by the position each is drawn at, with a larger y read as lower on the page, and nothing else is outlined.
pie string Exactly one element per slice, in drawn order; reversed when direction is 'counterclockwise'.
heat string; grid [row][column] A string names one element per cell: <rect> cells are read column by column (domMapping.order: 'row' reads them row by row), <path> cells row by row from the top, and a single <image> gets an overlay. A grid's rows run bottom first, the reverse of data.points.
line, step, survival, smooth, area, stacked_area, stacked_normalized_area, bump, radar, polar_area, parallel_coordinates, roc, pr_curve, contour list with one entry per series (a string is one series) A selector matching one element per point pairs them in document order; otherwise the vertices of the <path>, <polyline> or <polygon> it matches are the points. When a series matches several drawn elements -- a line broken by missing values, which gridSVG writes as sibling polylines -- their vertices are joined in document order and each reading is placed by its x; a missing reading gets no marker. A single element whose subpaths skip the missing values, with one vertex per reading, has its vertices paired with the readings in order. A step layer's staircase may run on to a flat vertex before its first sample and after its last, as MUI X Charts draws one on a category axis. Elements inside <defs>, <clipPath>, <marker>, <symbol>, <pattern> or <mask> (matplotlib's marker templates) are not pieces of the series. domMapping.pointOrder: 'reverse' says the chart draws them the other way round.
percentile_band list with one entry per band, outermost first, and optionally one more for the median's line; or list with one entry per quantile, lowest first The two lengths never coincide, so the count decides. One per band: entry k names the filled element between the k-th lowest and k-th highest quantile (for TensorBoard's nine levels, four polygons: minimum to maximum, then 6.68th to 93.32nd, 15.87th to 84.13th, 30.85th to 69.15th), and a point of either bound is marked where that bound's edge of the element is drawn at the point's x; the optional extra entry names the median's line, which a marker walks along, and without it the median is marked inside the innermost band, as far between its edges as its value is between the band's bounds -- a place worked out from the values, not one the chart draws, so it is approximate. An entry that matches one path, polyline, polygon or <use> of one is marked this way: at its vertex for each point's x, or, where the renderer dropped vertices a straight run did not need, as Plotly does, at the place the point's x or its position puts it along the shape, when every vertex kept stands at one of those places. One that matches several elements, or another kind, outlines all of them at every point, and so, with a console warning, does a shape whose vertices say neither -- one drawn on a log axis with vertices dropped, or a smoothed curve with more vertices than points. One per quantile: each entry is that quantile's line, read as a line series is (row above), so a marker walks along the bound. Hovering finds a point only in the per-quantile shape. With no selectors, or none that resolve, every point is still announced, sounded and brailled.
box, violin_box BoxSelector[], one per box See the box and violin plot types below.
violin_kde string; list with one entry per violin Markers are drawn at each point's svg_x/svg_y; the list names the curve each violin belongs to.
candlestick string; CandlestickSelector A string pairs one element per candle in document order.
rug string; list with one entry per tick Ticks are walked by position.
boxen, ridgeline, dumbbell, error_bar, forest, gantt, hexbin, waterfall, word_cloud, gauge, alluvial, chord, sankey, network, choropleth, treemap, sunburst, icicle, tree, pack string; list of strings A list's entries are each resolved and the results concatenated. The total must be one element per item the layer declares -- distribution, group, pair, sample, interval, bin, step, term, flow, link, region or node -- in declared order; gauge uses the first match. A ridgeline's element per group is its curve, and each sample is marked on top of the curve, where the sample's value is drawn; a curve that is not one path, polyline, polygon or <use> of one is outlined whole at every sample, and so, with a console warning, is one whose vertices do not say where each sample is drawn, as a percentile_band's shape is read.
directed_graph string; list of strings As the row above: one element per declared node, in declared order. A scope is derived from the nodes' paths rather than declared, so it has no element of its own; while the reader is on a closed scope, every declared node inside it is outlined. Edges are not highlighted. A count that does not match drops the highlight for the layer.
sequence string; list of strings One element per message, notes included, in declared order -- the arrow or the note. A count that does not match drops the highlight for the layer.
venn string; list of strings One element per declared region, in declared order. A set named only in an overlap has nothing drawn of its own and is not outlined. A count that does not match drops the highlight for the layer.

Lists written for maidr.js before 4.0

Before 4.0, a list that reached a layer type reading one string was handed to querySelectorAll as it was, and the DOM turned it into a comma-joined selector list: ["#bars rect"] worked as "#bars rect". Producers relied on that for years without knowing it, and 4.0 read those lists as nothing (#750) or, for bars, as one selector per bar (#991).

That meaning is kept for them. For the bar, segmented, point, pie and heat rows above, a list of strings that is not one entry per bar is joined into one selector list and read as a string, and the console says so. A segmented list that declares no domMapping.order and whose marks are <rect> is walked category by category, as it was before #1135 made series by series the default. Emit a string instead; the list form is read only so that charts made by an older producer keep their highlight.

Changing this contract

What a layer type reads from selectors and domMapping is the one part of the schema every producer matches against its own rendering, and most producers -- r-maidr, py-maidr, hand-written pages -- are not in this repository, so no test here can see them break. A change to it therefore keeps the shape producers emit today working, with a console warning, for at least one major version; carries ! and a BREAKING CHANGE: footer naming the shape that changed; and updates this table. e2e_tests/specs/bindingOutput.spec.ts drives real r-maidr and py-maidr output through the build, so a change that breaks one of them fails before it is released.

Data Formats by Plot Type

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
  }

  // Optional, on the box layer: "whiskerQuantiles": [0.1, 0.9] says the whiskers
  // end at those quantiles (here the 10th and 90th percentiles) rather than at
  // the data's extremes or 1.5 IQR. `min` and `max` are then announced as
  // "10th percentile" and "90th percentile" instead of "Minimum" and "Maximum".
  // Omit it, or give [0, 1], when the whisker ends are the minimum and maximum.
  // Each end is read on its own: in [0.05, 1] the upper end stays "Maximum".

  //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
    //
    //A binned axis that declares `"format": { "type": "date" }` carries epoch
    //milliseconds: the edges are read as dates, and `Bin width` as a span of
    //time ("1 day", "2 weeks") rather than as a count of milliseconds.
    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
      ]
    }

    //stacked_area and stacked_normalized_area: nested exactly like `line`, one
    //inner array per band. Each point carries its band's OWN value, not the
    //height its top edge is drawn at; MAIDR sums the running total itself.
    //List the bands from the bottom of the stack up: the first is the bottom
    //band, Up moves to the next one, drawn on top of it, and Control + Up to
    //the top band. Where the bands' highlight can be measured, MAIDR reads
    //which end of the list is drawn on top, so a list in legend order (top
    //band first, as ggplot2 stacks it) is walked the same way; a chart drawn
    //on a canvas, with nothing to measure, is read bottom band first.
    maidr = {
      "type": "stacked_area",
      "data":[
        [ { "x": "2019", "y": 10, "z": "Subscriptions" } ], //the bottom band
        [ { "x": "2019", "y": 5, "z": "Services" } ]        //drawn on top of it
      ]
    }

    //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
      ]
    }

    //bump: nested exactly like `line`, one inner array per competitor, each
    //`y` the competitor's RANK in that period, 1 for first place. Emit the
    //true ranks: MAIDR makes first place the highest note itself.
    //
    //`firstPlace` is an optional layer-level property saying where the chart
    //draws first place: "top", as a bump chart is usually drawn with its rank
    //axis running down the page, or "bottom", on a chart drawn the ordinary
    //way up. Up and Down follow the drawing, so on the usual chart Up moves to
    //the competitor one place better and Control + Up to first place; `]`
    //goes to a competitor's best place and `[` to its worst either way. Left
    //out, MAIDR reads where first place is drawn from the highlighted marks;
    //with nothing to measure (a canvas, no highlight), Up moves to the next
    //larger rank number, as on a line. Say it when the drawing cannot be
    //measured; a layer left without it whose drawing turns the keys round from
    //that rule logs a console warning asking for it.
    maidr = {
      "type": "bump",
      "firstPlace": "top",
      "data":[
        [ { "x": "R1", "y": 1, "z": "Ash" }, { "x": "R2", "y": 2, "z": "Ash" } ],
        [ { "x": "R1", "y": 2, "z": "Birch" }, { "x": "R2", "y": 1, "z": "Birch" } ]
      ]
    }

    //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 walks a pie CLOCKWISE: Right steps to the next slice round the
    //dial the way a clock hand goes, the audio pans each slice to where it
    //sits -- and the boundary tone of a move it refuses on that slice with
    //it -- and `p` names its clock position. Two optional keys say how the
    //slices were laid out so that reading matches the drawing:
    //
    //`startAngle`: where the first slice begins, in degrees clockwise from
    //12 o'clock. Defaults to 0. matplotlib's `startangle=0` (3 o'clock) is
    //90 here, and Chart.js's `rotation` carries over unchanged. Plotly's
    //`rotation` is clockwise from 12 too, but only a clockwise plotly pie
    //starts there: a counterclockwise one (plotly's default) ends its FIRST
    //wedge at `rotation` and draws it clockwise of there, so the ring starts
    //at `rotation` plus that wedge's sweep -- 50 of 100 at `rotation: 0`
    //means `startAngle: 180`.
    //
    //`direction`: `"clockwise"` (the default) or `"counterclockwise"`, the
    //way successive slices follow one another from `startAngle`. Declare
    //`"counterclockwise"` for a library that draws that way -- matplotlib,
    //base R's `pie()` and plotly all do by default -- and MAIDR walks `data`
    //in reverse so Right still moves clockwise, with the highlight, the pan
    //and the clock position on the slice being read. `data` and `selectors`
    //stay in drawn order either way; only the walk changes.
    maidr = {
      "type": "pie",
      "axes": {
        "x": { "label": "Fruit" },
        "y": { "label": "Units" }
      },
      "selectors": "#chart path.slice",
      "startAngle": 0,
      "direction": "clockwise",
      "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 }
              ]
            ]
   }

   // --- Experimental types ---

  // boxen (letter-value) [experimental] 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
  }

   // pr_curve [experimental]: a precision-recall curve, one array of points per
   // classifier (or class, or run). `x` is the recall and `y` the precision,
   // both fractions of one; `threshold` is the decision threshold the point
   // was scored at, and `z` names the curve as it names a line. `prevalence`
   // is the share of positives in the data the curve was scored on -- the
   // precision a classifier that guesses keeps at every recall, and so the
   // height of the chart's horizontal chance baseline -- and `ap` is the
   // average precision the producer computed; each is read from the first
   // point of the curve that carries it. When no point carries `ap`, it is
   // measured from the points as `sklearn.metrics.average_precision_score`
   // computes it: over each rise in recall, the rise times the precision at
   // its top. When no point carries `prevalence`, the baseline is not said.
   // The points may be listed in either order; `precision_recall_curve`
   // returns them from a recall of 1 down to 0, the last without a threshold.
   // Either way the curve is read from its lowest recall up, as it is drawn:
   // Right moves toward a higher recall, and Control + Left and Right go to
   // its two ends. `selectors` pairs the marks with the points in the order
   // the points are listed.
   //
   // The pitch is the precision on the unit interval for every curve, and
   // the pan follows the recall, the boundary tone of a refused move as well
   // as the point's own. Each point announces its threshold and how
   // far its precision sits above (or below) the baseline; the description
   // gives each curve's average precision, the baseline and where each
   // average precision stands against it, and the point with the best F1,
   // which the extrema dialog also offers.
   maidr = {
     type: 'pr_curve',
     axes: { x: { label: 'Recall' }, y: { label: 'Precision' } },
     data: [
       [
         { "x": 1, "y": 0.3, "threshold": 0.05, "z": "Logistic", "prevalence": 0.3, "ap": 0.76 },
         { "x": 0.8, "y": 0.7, "threshold": 0.4, "z": "Logistic" },
         { "x": 0, "y": 1, "z": "Logistic" }
       ],
       [
         { "x": 1, "y": 0.3, "threshold": 0.1, "z": "Random forest", "prevalence": 0.3 },
         { "x": 0.6, "y": 0.56, "threshold": 0.5, "z": "Random forest" },
         { "x": 0, "y": 1, "z": "Random forest" }
       ]
     ],
   };

   // roc [experimental]: a receiver operating characteristic curve, one array of operating
   // points per classifier. `x` is the false positive rate and `y` the true
   // positive rate, both fractions of one; `threshold` is the decision
   // threshold the point was scored at, and `z` names the curve as it names
   // a line. `auc` is the area the producer computed, read from the first
   // point of the curve that carries one; when no point does, the area is the
   // trapezoid rule over the curve's own points, which is what
   // `sklearn.metrics.auc` and `pROC::auc` compute. The points may be listed
   // in either order; the curve is read from its lowest false positive rate
   // up, as it is drawn, and `selectors` pairs the marks with the points in
   // the order the points are listed.
   //
   // The pitch is the true positive rate on the unit interval for every
   // curve, so two classifiers are comparable by ear; the pan follows the
   // false positive rate, the boundary tone of a refused move as well as the
   // point's own. Each point announces its threshold and how far it
   // sits above (or below) the chance diagonal, and the description gives
   // the area under each curve and the best operating point.
   maidr = {
     type: 'roc',
     axes: { x: { label: 'False positive rate' }, y: { label: 'True positive rate' } },
     data: [
       [
         { "x": 0, "y": 0, "threshold": 1, "z": "Logistic", "auc": 0.896 },
         { "x": 0.1, "y": 0.75, "threshold": 0.6, "z": "Logistic" },
         { "x": 1, "y": 1, "threshold": 0, "z": "Logistic" }
       ],
       [
         { "x": 0, "y": 0, "threshold": 1, "z": "Random forest" },
         { "x": 0.25, "y": 0.6, "threshold": 0.5, "z": "Random forest" },
         { "x": 1, "y": 1, "threshold": 0, "z": "Random forest" }
       ]
     ],
   };

   // percentile_band [experimental]: a distribution at each x drawn as nested
   // shaded bands around its median -- TensorBoard's distributions, a forecast
   // fan chart, a quantile band. One point per x, in drawn order; `x` is a
   // number or a category, and `quantiles` lists the distribution's quantiles
   // at that x, each a `level` from 0 to 1 (0 the minimum, 0.5 the median, 1
   // the maximum) and its `value` (`null` for a gap). The layer's quantiles
   // are every level any point declares; one a point leaves out is a gap
   // there. They pair into nested bands from the outside in -- lowest with
   // highest -- around the median, so declare them symmetric about 0.5.
   //
   // The reader enters on the median, walks x with left and right, and steps
   // from quantile to quantile with up and down; every quantile is pitched
   // against the whole band's range. Each point announces the median and the
   // band around it ("Middle 68% is -0.31 to 0.35"), and the description says
   // where the band nearest one standard deviation is widest and narrowest,
   // and where it widens and narrows most. `axes.x` labels the position,
   // `axes.y` the value; `selectors` is one polygon per band, outermost first,
   // and optionally the median's line (see "Selectors").
   maidr = {
     type: 'percentile_band',
     axes: { x: { label: 'Step' }, y: { label: 'Weight' } },
     selectors: [
       "g#bands > polygon:nth-of-type(1)",
       "g#bands > polygon:nth-of-type(2)",
       "g#bands > polygon:nth-of-type(3)",
       "g#bands > polygon:nth-of-type(4)",
       "polyline#median"
     ],
     data: [
       {
         "x": 100,
         "quantiles": [
           { "level": 0, "value": -0.92 },
           { "level": 0.0668, "value": -0.61 },
           { "level": 0.1587, "value": -0.31 },
           { "level": 0.3085, "value": -0.12 },
           { "level": 0.5, "value": 0.02 },
           { "level": 0.6915, "value": 0.16 },
           { "level": 0.8413, "value": 0.35 },
           { "level": 0.9332, "value": 0.64 },
           { "level": 1, "value": 0.97 }
         ]
       },
       {
         "x": 200,
         "quantiles": [
           { "level": 0, "value": -1.5 },
           { "level": 0.0668, "value": -1.1 },
           { "level": 0.1587, "value": -0.8 },
           { "level": 0.3085, "value": -0.3 },
           { "level": 0.5, "value": 0.05 },
           { "level": 0.6915, "value": 0.4 },
           { "level": 0.8413, "value": 0.9 },
           { "level": 0.9332, "value": 1.2 },
           { "level": 1, "value": 1.6 }
         ]
       }
     ],
   };

   // rug [experimental]: observations marked as ticks along one axis. One position per
   // observation and nothing else -- the chart is drawn to show where the
   // observations fall and where they bunch up. `x` for a vertical rug (the
   // ticks stand on the x axis, the default), `y` for a horizontal one
   // (`orientation: 'horz'`); the trace reads the field the orientation
   // names and ignores the other, so a producer that already emits a rug as
   // a `point` layer with a constant on the other axis switches by changing
   // `type`. Listed in any order: the observations are walked from the
   // lowest position up, and a flat selector pairs elements in this order.
   //
   // The pitch and the stereo pan follow the position on the axis, read
   // against `axes.x.min` / `max` when they cover the data and the data's
   // own span otherwise. The braille is the observation count per bin along
   // the axis: `tickStep` sets the bin width when declared, so the strip
   // lines up with the ticks the chart draws, and the axis is cut into
   // `ceil(sqrt(n))` equal bins when it is not.
   maidr = {
     type: 'rug',
     axes: { x: { label: 'Seconds', min: 0, max: 10, tickStep: 2.5 } },
     data: [
       { "x": 2.2 },
       { "x": 1.5 },
       { "x": 9.4 }
     ],
   };

Sankey, alluvial and chord (sankey, alluvial, chord) [experimental]

Weighted flows between nodes. data is one entry per flow; the nodes are the names the flows give their two ends, so there is no node list to keep in step with them:

{
  type: 'sankey',
  axes: { y: { label: 'Petajoules' } },  // what a value measures; defaults to "Value"
  selectors: '#chart path.link',          // optional: one element per flow, in declared order
  flowOptions: {                          // optional: where the nodes were drawn
    columns: [['Gas', 'Solar'], ['Electricity'], ['Heating', 'Homes']],
  },
  data: [
    { source: 'Solar', target: 'Electricity', value: 40 },
    { source: 'Gas', target: 'Electricity', value: 60 },
    { source: 'Gas', target: 'Heating', value: 45 },
    { source: 'Electricity', target: 'Homes', value: 100 },
  ],
}

Right follows the largest flow out of a node and left the largest flow into it, each step announcing the flow it went along; up and down walk the nodes of the column. The rotor's Outgoing and Incoming units page through the rest of a node's flows. Control + Right and Control + Left follow the largest flows to where they run out; where they go round in a cycle, there is no end to go to, and the jump says so (see Controls).

Directed graph (directed_graph) [experimental]

A computation graph -- a neural network's architecture as TensorBoard's Graphs dashboard or Keras' plot_model draws it. data is one entry per node; the edges are declared on the node they arrive at, as inputs, and scopes as a path, the way a treemap declares its hierarchy:

{
  type: 'directed_graph',
  axes: { x: { label: 'Layer' } },        // what a node is called; defaults to "Node"
  selectors: '#graph g.node',              // optional: one element per node, in declared order
  data: [
    { id: 'input_1', attributes: { 'Layer type': 'InputLayer', 'Output shape': '(None, 32, 32, 3)' } },
    { id: 'block1/conv', label: 'conv', path: ['block1'], inputs: ['input_1'],
      attributes: { 'Layer type': 'Conv2D', 'Parameters': 896 } },
    { id: 'block1/add', label: 'add', path: ['block1'], inputs: ['block1/conv', 'input_1'] },
    { id: 'dense', inputs: ['block1/add'], attributes: { 'Layer type': 'Dense' } },
  ],
}

The reader enters on the first top-level item with every scope closed. Left and right walk the items of one scope in topological order, input to output; down opens a scope onto its first member and up closes it again; Ctrl with an arrow goes to the first or last item of the scope, out to the top-level item, or down to the deepest first member, and plays the edge signal on an item that is already there. The rotor's Outputs and Inputs units follow the edges downstream and upstream, each press taking the next edge of the item the walk started from; an arrow move ends the walk, so the next press follows the edges of the item the reader is on. An edge to a node inside a closed scope arrives at the scope. Each move announces the node, its connections as 2 inputs, 1 output, whether it is a graph input or output or a branch or merge point, the names on either side, its attributes, and its place in the scope's order. A scope is announced as a scope rather than by the x axis's noun for a node -- Scope is block1, Size is 3 nodes inside -- and is called not connected only when nothing inside it has an edge.

The description speaks of the whole graph at once. Its table lists every node's own inputs and outputs, as declared, but for the copies of one drawn edge (inputEdges) that reach a node from one scope or leave it for one, listed once as that scope. Its branch and merge points are the items a move announces as one and the object list's filters gather, each seen from its scope, and are named so each can be found: the skip connection's input_1 above has two edges of its own, as the table lists, but both enter the closed block1, so a move there hears one output and it is no branch point -- Branch points: 0, Merge points: 1: block1 > add.

Sequence diagram (sequence) [experimental]

Participants and the messages between them, in the order they happen. data is an object, because a participant no message reaches is still a lifeline:

{
  type: 'sequence',
  selectors: ['#seq [data-id="m0"]', '#seq [data-id="m1"]'],  // optional: one element per message
  data: {
    participants: [
      { id: 'B', label: 'Browser', kind: 'actor' },
      { id: 'S', label: 'Server' },
    ],
    messages: [
      { from: 'B', to: 'S', text: 'GET /', kind: 'sync', number: 1 },
      { from: 'S', to: 'B', text: '200 OK', kind: 'reply', fragments: ['alt cached'] },
    ],
  },
}

Left and right walk the messages in time. Down moves from the row of every message onto the first participant's lifeline, and on to the next participant, keeping the moment the reader was at; up comes back. The rotor's Fragments unit jumps to where the next loop or alternative opens or closes. Each move announces the message, its route (Browser to Server), its kind, its fragment and its number. Pitch and pan are the participant the message arrives at, and the tone glides up for an arrow pointing right and down for one pointing left.

Venn diagram (venn) [experimental]

Sets and their overlaps. data is one entry per region, which names the sets it is the overlap of:

{
  type: 'venn',
  data: [
    { sets: ['Desirable'], size: 10 },
    { sets: ['Feasible'], size: 10 },
    { sets: ['Desirable', 'Feasible'], size: 2.5, label: 'Buildable', items: ['MVP'] },
  ],
}

The first row is the sets, the next every overlap of two, then of three. Left and right walk a row; down goes into the first overlap the region is part of, and up goes back out the way the reader came down: onto the region they went down from, while the overlap they are on is made of it, and otherwise onto the first set it is made of. Control + Up returns to the set they started down from. A set announces the sets it overlaps; an overlap announces its name, its size and its share of the smallest set it joins.

Multilayer Plots

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 Plot (Multilayer)

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 }
              ]
            ]
          }
        ]
      }
    ]
  ]
}