Charts and graphs
docs/authoring-a-tool.md Edit this page on GitHub
Charts
{ kind: "series", chart: { title: "Wait time against utilisation", x: { label: "Utilisation", min: 0, max: 1, format: "fixed2" }, y: { label: "Wait", unit: "ms", format: "compact" }, series: [ { name: "M/M/1", shape: "line", points: [{ x: 0.1, y: 11 }, { x: 0.5, y: 100 }] }, { name: "Simulated", shape: "line", axis: "right", points: [/* ... */] }, ], annotations: [{ x: 0.8, label: "knee" }],}}shapeis"line","area","bar"or, from contract version 4,"points".xcan be text, from contract version 4:["Mesh", "MCU", "SFU"]. Text values are categories, drawn evenly spaced in the order you give them, never sorted, and labelled with their text (cut short if the slot is narrow; the full text is in the data table and the readout). A bar chart's axis always starts at zero, since a bar's length is its value.- When x is a set of separate items (frames, requests, runs) rather than a continuous quantity, a line between two of them invents a value that does not exist. Use
markers: trueon a line or area to draw every real point, orshape: "points"to draw only the points. Each series gets its own marker shape, so series with equal values stay distinguishable, and the legend keys each one by its marker. Both need"sdk": 4. - Every chart answers a pointer and the keyboard with a readout: a crosshair at the nearest x and a card of every series' value there. You get it for free. To say what a point means, set
readout.titles(a heading per x) andseries.notes(the text for one series at one point, instead of its value),nullwherever the default is right.readout.mode: "point"shows only the nearest series, and"none"turns it off. Contract version 4. - Link a chart to tables.
readout.highlightTable: truelights the chart's own data-table row as the readout moves. To link atablepart in the same result, give the chart anidand the tablelink: { chart: "<id>", keys }, wherekeys[i]is the x that rowidescribes (nullfor a row with none, a total say). Pointing at the chart then lights the matching row, and pointing at a row moves the chart's readout to it. Links never reach outside one result. thresholds: [{ y, label, tone }]draws a labelled horizontal rule at a y value: a budget, an SLO, a limit. It is not a series, so it adds no legend entry, no table column and no readout row, and the scale widens to show it.toneiswarn,badorgoodfor a limit that should read as one.stack: "<id>"on bar series stacks them at each x, in series order, bottom to top: each part and the total in one bar. Positive and negative values stack away from zero separately, unstacked bars sit beside the stack, and the data table gains a total column per stack.orientation: "horizontal"draws bars growing rightward, one row per x: for long names, and for a budget read left to right. Grouping, stacking, thresholds (as vertical rules) and the readout all carry over, and the down arrow steps through the rows. Bars only; other shapes are not drawn there.shape: "step"holds each value until the next x and then moves straight to the next one: for state sampled on events, a buffer depth or a configured rate, where a sloped line invents a gradual change.markers: truemarks the start of each run.yScale: "log"orxScale: "log"draws that axis in powers of ten, for values across orders of magnitude. A log axis cannot show zero or a negative, so such points are left out and the data table says how many; bars are not drawn on a log y axis.shape: "band"withlowerfills the region betweenlowerandpoints, the upper edge: a p5 to p95 spread, a min-to-max envelope. It is drawn beneath every other series, a null in either edge breaks it, the readout reads it as a range, and the data table gives it low and high columns.xon a series gives it its own x values, one per point, for measurements that share no x: a scatter. Use it withshape: "points". The x scale covers every series, the readout snaps to the nearest point rather than the nearest x, and the data table lists one row per point.shape: "box"withboxesdraws a box plot per x: whiskerslowtohigh, a boxq1toq3, themedian, andoutliers. You compute the summary, so the whiskers mean what you say they mean; put it in the axis label. Setpointsto the medians. The readout and the data table show all five numbers.mirror: trueon a horizontal bar chart draws series given as negative values to the left and writes every number as its magnitude: back-to-back comparisons and pyramids. Put both halves in onestackso they share a row.sizeson apointsseries draws each point as a bubble whose area is its size, scaled across the whole chart, with a key of reference sizes underneath.sizeLabelnames the measure. By area, not radius, so a value twice as large looks twice as large.axis: "right"for a second scale. The renderer reserves the extra padding for it, so labels are not clipped.formatis per axis, not per value. A mix of20, 15, 10, 5.00on one axis is a formatting bug, and the reason formatting is chosen once per axis.- Six series colours, then they repeat. A chart needing seven series needs rethinking.
- Every chart also renders as a table inside a
<details>, automatically. A chart is an image, and a screen reader gets nothing from an image. You do not have to do anything to get this, but it is worth knowing that your axis labels and series names are read aloud.
Heatmaps and pies
Two more chart kinds, from contract version 4, each its own output kind and its own lazily loaded chunk, so a page fetches the code for the kinds it draws and no others. Declare them in kinds.
{ kind: "heatmap", heatmap: { xLabel: "Hour", yLabel: "Day", label: "Packet loss", unit: "%", x: ["00:00", "02:00", /* ... */], y: ["Mon", "Tue", /* ... */], values: [[0.2, 0.3, /* ... */], /* one row per y */], // null for a cell with no value scale: "sequential", // or "diverging", with midpoint}} { kind: "pie", pie: { label: "One voice stream, 48 kb/s", unit: "kb/s", slices: [{ label: "Opus audio", value: 32 }, { label: "Headers", value: 16 }], donut: true, total: "48 kb/s",}}- A heatmap's colour is its encoding, so it is never the only channel: a key shows the scale, every cell answers the readout and the arrow keys, and the data table has every value.
nullcells are hatched.sequentialis one hue for "how much";divergingis two hues either side ofmidpoint, for "which side of a baseline". Hosts theme the ramps with--tb-heat-lo,--tb-heat-hi,--tb-heat-neg,--tb-heat-midand--tb-heat-pos. - A pie is drawn the way pies are hard to misread: in the order you give the slices, from 12 o'clock clockwise, every slice labelled with its share. More than six fold into "Other" and the data table says so; a negative value or a zero total draws a message instead of a chart. If your parts are many, or close in size, a bar chart reads better.
explode: trueon a slice pulls it out a little, to point at the part your text is about. A slice pulled out looks bigger than it is, so use it on one slice, not for decoration.
Radar charts, treemaps and Venn diagrams
Three more kinds, each its own output and its own chunk, from contract version 4:
{ kind: "radar", radar: { label, axes: [{ label: "Latency" }, /* ... */], series: [{ label: "Fibre", values: [9, /* ... */] }] } }{ kind: "treemap", treemap: { label, unit, root: { label: "All", children: [{ label: "Media", children: [/* ... */] }] } } }{ kind: "venn", venn: { label, sets: [{ label: "Web", size: 100 }, { label: "Mobile", size: 100 }], overlaps: [{ sets: [0, 1], size: 50 }] } }- Radar: one spoke per axis, clockwise from 12 o'clock, on one scale from zero unless an axis gives its own
max. At most four series are drawn; anullbreaks a shape rather than pulling it to the centre. - Treemap: each rectangle's area is its value; a branch's value is the sum of its children. Labels draw where they fit, and every node is in the readout and the data table. Three levels are drawn at most.
- Venn: two or three sets. Two are drawn exactly. Three cannot in general be drawn exactly with circles, so every region carries its count and the caption says the areas are approximate. Give each overlap as the full intersection:
[0, 1]includes what is also in set 2.
Graphs
Contract version 7 ("sdk": 7) adds graph: you say what is connected, and the runtime decides where everything goes. tools/build-order is the example.
{ kind: "graph", graph: { label: "Package dependencies", // the figure's name, and the table's caption direction: "BT", // LR (default), RL, TB or BT nodes: [{ id: "core", badge: "1" }, { id: "http", badge: "2" }, { id: "app", badge: "3" }], edges: [{ from: "core", to: "http" }, { from: "http", to: "app", label: "uses" }], highlight: ["core", "http", "app"], // a path to emphasise, as node ids in order} }What you choose, with its default:
layout:layered(ranks alongdirection, for anything with a flow),treeandradialfor a hierarchy,forcefor a network with no direction,manualfor nodes you place withxandy.routing:orthogonalfor layered and tree,straightfor force and manual,splinefor radial. A straight line that would cross a node is drawn as a polyline instead.- Nodes:
label(the id if you give none), a mutednote, ashape(roundedby default;rect,pill,circle,ellipse,diamond,hexagon,parallelogram,cylinder,document,point, orrecordwithrows), atonefor status or acolorfor category, a shortbadge, agroup, and in a layered layout alaneor a fixedrank. - Edges: a
labeland where it sits (labelAt),line(dashed,dotted),width(thin,thick),toneorcolor, a marker at each end (head,tail: arrows, UML ends, or crow's feet for entity-relationship diagrams), a side to leave or arrive on (fromSide,toSide), and on a record the row (fromPort,toPort). - Groups nest through
parent, and a reader can close one (collapsed: truestarts it closed); lanes are straight bands across a layered graph.merge: "source"lets the edges leaving one node share a trunk, the bus of an org chart; otherwise no two edges ever share a stretch of line.
What the runtime does for you, so you do not have to:
- Every edge is also a row in a table, under the picture in a
<details>, and a node with no edges is a row saying so. A reader who cannot see the picture still has the whole graph. - The picture is one tab stop. The arrow along the flow follows an edge out of the focused node, the arrow against it goes back, the arrows across it choose among the edges, and a live region says where focus is. Space on two nodes highlights the shortest path between them.
- The same graph gives the same picture in every engine and in Node, so a screenshot or a test never depends on the browser.
- A graph past a hundred nodes is laid out off the main thread when the host configured a worker: its table shows at once and the picture follows.
The structural rules hold in your fixtures as well as on the page: ids are unique, every edge, port, group, lane and highlighted node names something that exists, groups do not nest in a circle, and a group's members share one lane. A case whose output breaks one fails, because the runtime would list that graph rather than draw it. Nothing about size is refused unless you say so: limits: { nodes: 200 } makes a larger graph a table and a sentence saying why.