D3.js Integration
MAIDR ships a dedicated adapter for D3.js, the most widely used low-level SVG-based data visualization library on the web. The adapter turns any D3-rendered chart into an accessible, non-visual experience — audio sonification, text descriptions, braille output, and keyboard navigation — without forcing you to hand-write the MAIDR JSON schema.
Because D3 gives you full control over the DOM, MAIDR cannot auto-detect a chart the way it does for Plotly. Instead, you tell MAIDR which SVG elements represent your data points (via a CSS selector) and how to read the values out of D3's bound __data__ property. The binder handles the rest.
Installation
CDN (vanilla HTML)
<!-- 1. D3 itself -->
<script src="https://d3js.org/d3.v7.min.js"></script>
<!-- 2. MAIDR core runtime -->
<script src="https://cdn.jsdelivr.net/npm/maidr/dist/maidr.js"></script>
<!-- 3. MAIDR D3 adapter (exposes window.maidrD3) -->
<script src="https://cdn.jsdelivr.net/npm/maidr/dist/d3.js"></script>
npm (bundlers / React)
npm install maidr d3
The adapter ships two entry points:
| Import | When to use |
|---|---|
import { bindD3Bar, ... } from 'maidr/d3' |
Vanilla JS or non-React bundler setups. Pure binder functions. |
import { MaidrD3, useD3Adapter } from 'maidr/react' |
React apps. Component wrapper and lower-level hook. |
Quick Start
Vanilla JS
<svg id="my-chart"></svg>
<script>
// 1. Draw your D3 chart exactly as you would today.
const svg = d3.select('#my-chart').attr('width', 500).attr('height', 300);
const data = [
{ day: 'Mon', count: 20 },
{ day: 'Tue', count: 14 },
{ day: 'Wed', count: 23 },
{ day: 'Thu', count: 25 },
{ day: 'Fri', count: 22 },
];
const x = d3.scaleBand().domain(data.map(d => d.day)).range([40, 480]).padding(0.2);
const y = d3.scaleLinear().domain([0, 30]).range([280, 20]);
svg.selectAll('rect.bar')
.data(data)
.join('rect')
.attr('class', 'bar')
.attr('x', d => x(d.day))
.attr('y', d => y(d.count))
.attr('width', x.bandwidth())
.attr('height', d => 280 - y(d.count))
.attr('fill', '#4C78A8');
// 2. After D3 has finished drawing, bind MAIDR.
maidrD3.bindD3Bar(document.getElementById('my-chart'), {
selector: 'rect.bar',
title: 'Tips by Day',
axes: { x: 'Day', y: 'Count' },
x: 'day',
y: 'count',
});
</script>
The binder writes a maidr-data attribute onto the SVG and MAIDR's runtime activates on focus. That's it — click the chart or Tab to it and start pressing arrow keys.
Timing matters. Always call the binder after
selectAll(...).data(...).join(...)has run. Calling it on an empty SVG throwsNo elements found for selector ….
React
The <MaidrD3> component handles the D3-draws-first / MAIDR-binds-second dance for you:
import { useEffect, useRef } from 'react';
import * as d3 from 'd3';
import { MaidrD3 } from 'maidr/react';
function AccessibleBarChart({ data }) {
const svgRef = useRef<SVGSVGElement>(null);
useEffect(() => {
if (!svgRef.current) return;
const svg = d3.select(svgRef.current);
// ... your D3 drawing code using svg ...
}, [data]);
return (
<MaidrD3
svgRef={svgRef}
chartType="bar"
config={{
selector: 'rect.bar',
title: 'Tips by Day',
axes: { x: 'Day', y: 'Count' },
x: 'day',
y: 'count',
}}
deps={[data]}
>
<svg ref={svgRef} width={500} height={300} />
</MaidrD3>
);
}
Prefer composing with <Maidr> yourself? Use the hook:
import { Maidr, useD3Adapter } from 'maidr/react';
function AccessibleBarChart({ data }) {
const svgRef = useRef<SVGSVGElement>(null);
useEffect(() => { /* D3 drawing */ }, [data]);
const { maidrData } = useD3Adapter(svgRef, {
chartType: 'bar',
config: { selector: 'rect.bar', title: 'Tips by Day', x: 'day', y: 'count' },
}, [data]);
if (!maidrData) return <svg ref={svgRef} width={500} height={300} />;
return (
<Maidr data={maidrData}>
<svg ref={svgRef} width={500} height={300} />
</Maidr>
);
}
How It Works
D3 binds the source data for each DOM element to a hidden __data__ property during .data() joins. The adapter:
- Queries the SVG for your elements (e.g.
rect.bar,circle.dot,g.box) using theselectoryou provide. - Extracts
__data__from each element, resolving values via your accessors (x: 'day',y: (d) => d.count, etc.). - Stamps MAIDR-owned
data-maidr-*attributes onto the elements so the visual highlight layer can locate them later, even after React re-renders or D3 data joins reshuffle the DOM. - Builds the MAIDR JSON schema (axes, legends, selectors, data points) and either writes it as a
maidr-dataattribute (autoApply: true, default) or returns it for you to pass to<Maidr>.
The React bindings force autoApply: false internally so that <Maidr> stays the single source of truth.
Configuration Options
All binder functions accept a configuration object that extends a common base:
| Option | Type | Default | Description |
|---|---|---|---|
selector |
string |
required | CSS selector for the data-bearing elements. |
id |
string |
auto-generated | Stable id for the MAIDR chart. |
title |
string |
— | Chart title (announced in text descriptions). |
subtitle |
string |
— | Chart subtitle. |
caption |
string |
— | Chart caption. |
axes |
{ x?, y?, fill? } |
— | Axis labels and options. Each axis may be a plain string (label shorthand) or a full AxisConfig. |
format |
AxisFormat |
— | Global number/date format fallback for axes without their own format. |
autoApply |
boolean |
true |
When true, writes the generated schema to svg[maidr-data]. React forces this to false internally. |
Axis configuration
axes: {
x: 'Height (cm)', // shorthand
y: { label: 'Weight (kg)', min: 40, max: 100, tickStep: 5 }, // full config
fill: 'Species', // heatmap/segmented only
}
Grid-navigation shortcuts (scatter) and tick-aware announcements rely on the full form — provide min, max, and tickStep when you want them.
Data accessors
Every per-axis accessor is either a property key or a function:
// Property name — the default for most common cases
x: 'day'
// Function — for d3.stack() tuples or nested objects
y: (d) => d[1] - d[0]
When you omit an accessor, the binder tries a sensible default (x, y, fill, value, …) and falls back to a small list of aliases (category, label, name, count, amount, …).
Supported Chart Types
| Binder | Chart type | Selector targets | Example |
|---|---|---|---|
bindD3Bar |
Bar | <rect> per bar |
Bar chart |
bindD3Dot |
Cleveland dot plot | <circle> per category |
Dot plot |
bindD3Lollipop |
Lollipop | <circle> head per category |
Lollipop |
bindD3Funnel |
Funnel / stage chart | <path> or <rect> per stage |
Funnel |
bindD3Line |
Line / multi-line, or a step chart with stepDirection |
<path> per series, optional <circle> points |
Line chart |
bindD3Area |
Area / stacked area / 100% stacked area | <path> per series |
Area chart |
bindD3Bump |
Bump / rank chart | <path> per competitor |
Bump chart |
bindD3Scatter |
Scatter | <circle> per point |
Scatter plot |
bindD3Manhattan |
Manhattan plot | <circle> per marker |
Manhattan |
bindD3Volcano |
Volcano plot | <circle> per gene |
Volcano |
bindD3Box |
Box plot | <g> per box (containing <rect> + <line> + <circle>) |
Box plot |
bindD3Violin |
Violin plot | <path> per category, plus an optional <g> box overlay |
— |
bindD3ErrorBar |
Error bar / point range | <g> per estimate (containing <line> + marker) |
Error bar |
bindD3Forest |
Forest / meta-analysis | <g> per study, plus pooledSelector for the diamond |
Forest |
bindD3Boxen |
Boxen / letter-value | <g> per distribution (the stack of rungs) |
Boxen |
bindD3Dumbbell |
Dumbbell / connected dot | <line> connector per row |
Dumbbell |
bindD3Waterfall |
Waterfall / bridge | <rect> per step |
Waterfall |
bindD3Histogram |
Histogram | <rect> per bin |
Histogram |
bindD3Heatmap |
Heatmap | <rect> per cell |
Heatmap |
bindD3Candlestick |
OHLC candlestick | <rect> per candle |
Candlestick |
bindD3Segmented |
Stacked / dodged / normalized bars | <rect> per segment |
Stacked, Dodged |
bindD3Diverging |
Diverging bars / population pyramid | <rect> per bar |
Pyramid |
bindD3Mosaic |
Mosaic / marimekko | <rect> per cell |
Mosaic |
bindD3WordCloud |
Word cloud | <text> per term |
Word cloud |
bindD3Gantt |
Gantt / timeline / swimlane | <rect> per interval |
Gantt |
bindD3Gauge |
Gauge / bullet chart | the needle or value arc | Gauge |
bindD3Smooth |
Smooth / regression curve | <circle> per sample |
Smooth curve |
bindD3Pie |
Pie / doughnut | <path> per wedge |
Pie chart |
bindD3PolarArea |
Polar area / coxcomb / rose | <path> per wedge |
Polar area |
bindD3Radar |
Radar / spider | <path> per series |
Radar |
bindD3Network |
Force-directed network | <line> per link |
Network |
bindD3Treemap |
Treemap | <rect> per node |
Treemap |
bindD3Sunburst |
Sunburst | <path> arc per node |
Sunburst |
bindD3Icicle |
Icicle | <rect> band per node |
Icicle |
bindD3Sankey |
Sankey | <path> ribbon per link |
Sankey |
bindD3Alluvial |
Alluvial | <path> ribbon per link |
Alluvial |
bindD3Chord |
Chord | <path> ribbon per chord |
Chord |
bindD3Survival |
Kaplan-Meier survival curve | <path> per arm, plus censoredSelector for the ticks |
Survival |
bindD3Parallel |
Parallel coordinates | <path> or <polyline> per observation |
Parallel |
bindD3Ridgeline |
Ridgeline / joy plot | <path> density curve per group |
Ridgeline |
bindD3Hexbin |
Hexbin density | <path> hexagon per bin |
Hexbin |
bindD3Contour |
Contour / density field | <path> per level |
Contour |
bindD3Choropleth |
Choropleth map | <path> per region |
Choropleth |
Multi-Panel Charts
D3 has no declarative facet API — the community idiom is one translated <g> per panel inside a single <svg>. Two binders turn that idiom into a navigable MAIDR figure: arrow keys move between panels, ENTER drills into a panel, ESC returns.
bindD3Facets — homogeneous small multiples
Use when every panel repeats the same chart type (the d3.groups() + one-<g>-per-group pattern). You provide a panelSelector; each matched panel element becomes the extraction root for the per-type binder, so config.selector only needs to match marks within a panel:
// One <g class="panel"> per d3.groups() entry, each holding rect.bar marks
svg.selectAll('g.panel')
.data(d3.groups(flat, d => d.region))
.join('g')
.attr('class', 'panel')
.attr('transform', (d, i) => `translate(${(i % 2) * w}, ${Math.floor(i / 2) * h})`);
// ... draw rect.bar marks inside each panel ...
maidrD3.bindD3Facets(svgElement, {
panelSelector: 'g.panel',
chartType: 'bar', // any of the single-chart types
config: { // the usual per-type config;
selector: 'rect.bar', // resolved inside each panel
title: 'Sales by Day, faceted by Region', // figure title
axes: { x: 'Day', y: 'Sales' },
x: 'day',
y: 'sales',
},
panelTitle: d => `Region: ${d[0]}`, // resolved against the panel's __data__
// layout: 'row' | 'column' | { columns: 2 } // optional; geometry-inferred by default
});
- Panel titles. Each panel's display name (announced during panel navigation) comes from
panelTitle, resolved against the panel element's D3-bound__data__. Function accessors are invoked even when a panel has no bound datum (their first argument is thenundefined), so index-based accessors like(_d, i) => keys[i]work for panels appended without a data join. Panels joined fromd3.groups()([key, values]tuples) or nest-style{ key, values }objects get their group key automatically; the fallback isPanel <n>. - Grid shape. An explicit
layoutalways wins. Otherwise the grid is inferred from panel geometry (bounding-box centers clustered into rows, left-to-right within a row), falling back totransform="translate(x,y)"values, then to a single row in DOM order. Ragged grids (e.g. 5 panels as 2+2+1) are supported.
bindD3Subplots — heterogeneous grids
Use when panels hold different chart types, each drawn independently. Every entry names a chart type, its usual binder config, and the panel's DOM root (an element or a selector resolved against the container):
maidrD3.bindD3Subplots(svgElement, {
title: 'Quarterly Performance', // figure title
subplots: [[ // 2D row-major grid (ragged rows OK)…
{ chartType: 'bar', root: 'g.revenue', config: { selector: 'rect.bar', title: 'Revenue' } },
{ chartType: 'line', root: 'g.growth', config: { selector: 'path.line', title: 'Growth' } },
]],
// …or a flat array plus layout: 'row' | 'column' | { columns: 2 }
});
Each entry's config.title is that panel's display name; figure-level fields (id, title, subtitle, caption, autoApply) live on the spec itself.
How panel isolation works
MAIDR resolves selectors page-globally, so the binders stamp each panel element with data-maidr-panel="<i>" and emit every layer selector as #<svgId> [data-maidr-panel="<i>"] <yourSelector> — panel A's selector can never highlight panel B's marks. Panels that are anonymous <g> elements also receive id="axes_<svgId>_<i>" so MAIDR can outline the active panel; user-assigned ids are never overwritten (stamping is skipped entirely in that case — navigation order and arrow directions still resolve from the rendered panel geometry, only the visual panel outline is lost). Each panel root must be its own element: bindD3Subplots throws if two entries resolve to the same root.
Both binders return { maidr, layers } (D3MultiPanelResult) — one layer per panel in reading order — and auto-apply maidr-data to the SVG unless autoApply: false.
React
<MaidrD3> and useD3Adapter accept the multi-panel binders via chartType: 'facets' and chartType: 'subplots':
<MaidrD3
svgRef={svgRef}
chartType="facets"
config={{
panelSelector: 'g.panel',
chartType: 'bar',
config: { selector: 'rect.bar', x: 'day', y: 'sales' },
}}
deps={[data]}
>
<svg ref={svgRef} />
</MaidrD3>
See examples/d3-bindfacets.html and examples/d3-bindsubplots.html for complete runnable pages.
Data Examples by Chart Type
Bar Chart
const data = [
{ day: 'Mon', count: 20 },
{ day: 'Tue', count: 14 },
{ day: 'Wed', count: 23 },
];
svg.selectAll('rect.bar')
.data(data).join('rect')
.attr('class', 'bar')
.attr('x', d => x(d.day))
.attr('y', d => y(d.count))
.attr('width', x.bandwidth())
.attr('height', d => height - y(d.count));
maidrD3.bindD3Bar(svgElement, {
selector: 'rect.bar',
title: 'Tips by Day',
axes: { x: 'Day', y: 'Count' },
x: 'day',
y: 'count',
});
Dot Plot, Lollipop, and Funnel
Three marks that carry the same { category, value } datum a bar does, so they take the same config — only the binder differs, and with it the chart MAIDR announces:
// Cleveland dot plot: categories down the page, so x is the value.
svg.selectAll('circle.dot')
.data(endpoints).join('circle')
.attr('class', 'dot')
.attr('cx', d => x(d.ms))
.attr('cy', d => y(d.endpoint));
maidrD3.bindD3Dot(svgElement, {
selector: 'circle.dot',
title: 'Median Response Time',
orientation: 'horz',
axes: { x: 'Milliseconds', y: 'Endpoint' },
x: 'ms',
y: 'endpoint',
});
For a lollipop, point selector at the heads (one <circle> per category) or at a <g> wrapping each head-and-stem pair — never at a selector that also matches the <line> stems, which would double every category.
A funnel is read for its drop-offs: the trace pitches the retention between adjacent stages rather than the raw counts, so stage order is load-bearing. The binder keeps the elements in the order D3 joined them, so draw them in funnel order:
maidrD3.bindD3Funnel(svgElement, {
selector: 'path.stage',
title: 'Checkout Funnel',
axes: { x: 'Stage', y: 'People' },
x: 'stage',
y: 'count',
});
Scatter Plot
Pair your scatter binder with explicit axis min/max/tickStep to enable grid-style navigation (Ctrl/Cmd + arrow jumps a tick at a time):
svg.selectAll('circle.dot')
.data(points).join('circle')
.attr('class', 'dot')
.attr('cx', d => x(d.height))
.attr('cy', d => y(d.weight))
.attr('r', 5);
maidrD3.bindD3Scatter(svgElement, {
selector: 'circle.dot',
title: 'Height vs Weight',
axes: {
x: { label: 'Height (cm)', min: 155, max: 195, tickStep: 5 },
y: { label: 'Weight (kg)', min: 50, max: 95, tickStep: 5 },
},
x: 'height',
y: 'weight',
});
Manhattan Plot
A Manhattan plot is a scatter read almost entirely through a threshold, so the parts a reader cannot see without them — what each point is, which chromosome it sits on, and where the significance line was drawn — are what this binder adds:
svg.selectAll('circle.snp')
.data(markers).join('circle')
.attr('class', 'snp')
.attr('cx', d => x(d.pos))
.attr('cy', d => y(d.logP))
.attr('r', 2);
maidrD3.bindD3Manhattan(svgElement, {
selector: 'circle.snp',
title: 'Genome-wide Association',
axes: { x: 'Position', y: '-log10(p)', fill: 'Chromosome' },
x: 'pos',
y: 'logP',
label: 'snp', // what the point IS — the answer the chart is read for
group: 'chromosome',
significance: 7.3, // no default: the conventions differ by field
});
A raw p axis runs the other way. There
p <= 0.05is the finding, so passsignificanceDirection: 'below'— fixed to the default, the reading would select exactly the markers that failed to reach significance and announce them as the result.
Volcano Plot
A volcano is a Manhattan plot with a second cutoff: a gene matters when its change is both large and significant, so effect joins significance.
maidrD3.bindD3Volcano(svgElement, {
selector: 'circle.gene',
title: 'Differential Expression',
axes: { x: 'log2 fold change', y: '-log10(p)' },
x: 'lfc',
y: 'logP',
label: 'gene', // the gene name IS the payload here
significance: 1.3,
effect: 1, // applied to the magnitude — a volcano is symmetric
});
The binder warns (rather than throwing) when no point resolves a label: the chart still reads as a scatter with a cutoff, but a reader told "x is 2.3, y is 14.1" has been given the two numbers whose shape they can already hear and withheld the one thing they came for.
Line Chart (single and multi-line)
The adapter supports two layouts:
- Path-only — each
<path>has an array of{x,y}points bound to it. Just give the binder the path selector. - Path + points — each series also has per-point
<circle>markers. ProvidepointSelectorso MAIDR can highlight individual points.
// Each line path gets its own `data-maidr-line-index` stamp so highlights
// keep working across React re-renders and D3 data joins.
maidrD3.bindD3Line(svgElement, {
selector: 'path.line',
pointSelector: 'circle.point',
title: 'Temperature by City',
axes: { x: 'Month', y: 'Temperature (°F)' },
x: 'month',
y: 'temp',
fill: 'city',
});
A line drawn with one of d3's step curves is a step chart, and says so with
stepDirection: 'hv' for d3.curveStepAfter, which holds the level and jumps
at the next sample, 'vh' for d3.curveStepBefore, which jumps at the current
one, and 'mid' for d3.curveStep, which jumps halfway between the two. The
chart is then navigated by transition rather than by sample and described in
terms of its runs.
maidrD3.bindD3Line(svgElement, {
selector: 'path.stage',
title: 'Sleep Stage Through the Night',
axes: { x: 'Hour', y: 'Stage' },
x: 'hour',
y: 'stage',
stepDirection: 'hv', // d3.curveStepAfter
});
Declare it rather than expecting it to be detected: this binder reads your data
off __data__ and never looks at the path, and the path would not settle it
anyway — a staircase is indistinguishable from a line whose samples happen to
land on one, and which curve you used is your own choice. Leave stepDirection
out and the chart is read as the line it was, with no direction claimed; that
is a default, not a limit, and a chart drawn with any of the three curves has a
value to pass. bindD3Area takes the same field and stays an area: its trace
reads the convention to tell the risers of a stepped band from its samples.
Multi-line tip. The binder infers legend labels from the
fill/zaccessor. If all series share the same parent<g>, the binder groups points by fill automatically; if each line has its own parent<g>, it scopes the point query per parent.
Violin Plot
A violin is two layers over one set of marks — the KDE curve a reader walks
along, and the five-number summary the box overlay draws — so one bind produces
both, in one subplot. selector matches the mirrored d3.area() path per
category, with that category's bins bound to it; boxSelector matches the
overlay's groups, when the chart draws one.
maidrD3.bindD3Violin(svgElement, {
selector: 'path.violin',
boxSelector: 'g.box', // omit when the chart draws no overlay
title: 'Sepal length by species',
axes: { x: 'Species', y: 'Sepal length' },
fill: 'species',
value: 'v', // a bin's position on the value axis
density: 'estimate', // its density there
});
The bins may be bound to the path directly — what
.data(groups.map(g => g.bins)) leaves — or wrapped in an object alongside the
category name, in which case kde names the field holding them. Either way the
category comes from fill, read off the wrapper when there is one and off a bin
when there is not.
The result carries both layers: result.layer is the KDE, and
result.layers is [kde, box]. Every other binder returns layers too, as a
one-element array, so a caller that wants "what did this bind produce" can read
the same field everywhere.
The summary is never derived from the curve. Quartiles can be computed off a density estimate, and they would be the smoothing bandwidth's rather than the data's — a reader told "Q1 is 4.2" has no way to tell it was inferred. A violin whose overlay states no quantiles, or which draws none, is read as its KDE curves alone. The same goes for a partial summary: if some categories state one and others do not, the box layer is dropped entirely rather than emitted with its highlights one category out of step.
Box Plot
Each box must be a <g> group containing the IQR <rect>, the median and whisker <line> elements, and optional outlier <circle> elements as direct children:
groups.append('rect') // IQR body
.attr('y', d => y(d.q3))
.attr('height', d => y(d.q1) - y(d.q3));
groups.append('line') // median (horizontal in a vertical boxplot)
.attr('x1', 0).attr('x2', x.bandwidth())
.attr('y1', d => y(d.q2)).attr('y2', d => y(d.q2));
groups.append('line') // lower whisker (vertical)
.attr('x1', bw/2).attr('x2', bw/2)
.attr('y1', d => y(d.min)).attr('y2', d => y(d.q1));
groups.append('line') // upper whisker (vertical)
.attr('x1', bw/2).attr('x2', bw/2)
.attr('y1', d => y(d.q3)).attr('y2', d => y(d.max));
// One <circle> per outlier value, as a direct child of the group.
groups.each(function (d) {
const g = d3.select(this);
[...d.lowerOutliers, ...d.upperOutliers].forEach(v => {
g.append('circle').attr('cx', bw/2).attr('cy', y(v)).attr('r', 3);
});
});
maidrD3.bindD3Box(svgElement, {
selector: 'g.box',
title: 'Distribution by Group',
axes: { x: 'Group', y: 'Value' },
});
The binder classifies siblings by geometry:
<rect>becomes the IQR (iq).- Horizontal
<line>= median; vertical<line>above/below the rect centre = upper/lower whisker. Orientation flips for horizontal boxplots. <circle>above the rect centre = upper outlier; below = lower outlier.
Each sub-part is stamped with data-maidr-box-part="iq|q2|lower-whisker|upper-whisker|lower-outlier|upper-outlier" so visual highlighting works for every box region.
Boxen / Letter-Value Plot
A boxen is a box plot whose ladder keeps going: a larger sample gets more rungs, so its tails stay legible instead of collapsing into a whisker and a scatter of dots. Point selector at one element per distribution — the <g> holding that category's stack of rungs — and let the datum carry the ladder:
maidrD3.bindD3Boxen(svgElement, {
selector: 'g.boxen',
title: 'Response Time by Group',
axes: { x: 'Group', y: 'Milliseconds' },
x: 'group',
median: 'median',
levels: 'letterValues', // [{ p, lo, hi }, …], deepest last
upperOutliers: 'beyond',
});
Each rung is a tail probability p with the pair of quantiles it cuts, and the trace sorts them outward from the median itself — so a producer emitting them inward-first is read correctly either way. A rung whose three numbers are not all finite is dropped rather than announced as a percentile the data never computed.
The ladder is read from your datum and never measured off the rendered rungs: a boxen computes its quantiles before it draws them, and a height in pixels is a layout fact. Every rung highlights the whole distribution's group, because a chart does not draw an element per quantile that MAIDR could pair up positionally.
Histogram
svg.selectAll('rect.bar')
.data(bins).join('rect')
.attr('class', 'bar')
.attr('x', d => x(d.x0))
.attr('y', d => y(d.length))
.attr('width', d => Math.max(0, x(d.x1) - x(d.x0) - 1))
.attr('height', d => height - y(d.length));
maidrD3.bindD3Histogram(svgElement, {
selector: 'rect.bar',
title: 'Distribution of Sepal Width',
axes: { x: 'Sepal Width (cm)', y: 'Count' },
x: 'x0',
y: 'length',
xMin: 'x0',
xMax: 'x1',
});
Heatmap
svg.selectAll('rect.cell')
.data(cells).join('rect')
.attr('class', 'cell')
.attr('x', d => x(d.day))
.attr('y', d => y(d.hour))
.attr('width', x.bandwidth())
.attr('height', y.bandwidth())
.attr('fill', d => color(d.value));
maidrD3.bindD3Heatmap(svgElement, {
selector: 'rect.cell',
title: 'Activity Heatmap',
axes: { x: 'Day', y: 'Hour', fill: 'Activity' },
x: 'day',
y: 'hour',
value: 'value',
});
Candlestick
maidrD3.bindD3Candlestick(svgElement, {
selector: 'rect.candle',
title: 'Stock Price',
axes: { x: 'Date', y: 'Price ($)' },
value: 'date',
open: 'open',
high: 'high',
low: 'low',
close: 'close',
});
trend is auto-computed from open vs close when not supplied.
Area / Stacked Area / 100% Stacked Area
bindD3Area handles all three variants through type: 'area' | 'stacked_area' | 'stacked_normalized_area' ('area' is the default). It reads the two shapes D3 produces, telling them apart from the datum bound to the first <path>:
- Plain point arrays — one
{x, y}array per<path>, exactly asbindD3Linereads them (pointSelectorworks here too). d3.stack()output — the datum bound to each<path>is the series array itself, carrying.key, whose items are[y0, y1]tuples with a.databack-reference to the row. The binder unwraps that shape for you:xis read from the row,yis the band's own height (y1 - y0), and.keynames the series.
const series = d3.stack().keys(['Subscriptions', 'Services'])(rows);
svg.selectAll('path.area')
.data(series).join('path')
.attr('class', 'area')
.attr('fill', d => color(d.key))
.attr('d', d3.area()
.x(d => x(d.data.year))
.y0(d => y(d[0]))
.y1(d => y(d[1])));
maidrD3.bindD3Area(svgElement, {
selector: 'path.area',
type: 'stacked_area',
title: 'Revenue by Product',
axes: { x: 'Year', y: 'Revenue ($K)', fill: 'Product' },
x: 'year', // a key on the stacked ROW, not on the [y0, y1] tuple
});
In that shape the two accessors address different objects, because that is where the two values live: x is resolved against the row — function accessors included, so write d => d.year, not d => d.data.year — while an explicit y is resolved against the tuple, keeping d => d[1] - d[0] expressible.
Give the band's own value, not the top edge. The trace re-derives the running total by summing the series it is given, so it announces the stack height at each x and the point's share of it. The binder already emits
y1 - y0ford3.stack()output; only supply your ownyaccessor if you stacked the data by hand.
A streamgraph (d3.stackOffsetWiggle) is 'stacked_area'; a 100% stacked area (d3.stackOffsetExpand) is 'stacked_normalized_area'.
Bump / Rank Chart
A bump chart is a multi-line layer whose y values are ranks — 1 is the best position and the smallest number. Bind the ranks you already plotted; BumpTrace inverts the pitch so first place is the highest note, and announces the places gained or lost at each period:
maidrD3.bindD3Bump(svgElement, {
selector: 'path.rank-line',
title: 'League Table by Round',
axes: { x: 'Round', y: 'Rank', fill: 'Team' },
x: 'round',
y: 'rank',
fill: 'team',
});
A slope graph of values is a line chart with two samples, not this.
Radar / Spider Chart
A radar is a multi-line layer wrapped around a circle: one closed d3.lineRadial() <path> per series, x naming the spoke and fill naming the series. RadarTrace pans each spoke by its angle — 12 o'clock centre, 3 o'clock hard right — so the chart sounds like a circle rather than a row of bars.
maidrD3.bindD3Radar(svgElement, {
selector: 'path.radar-area',
title: 'Model Comparison',
axes: { x: 'Attribute', y: 'Score', fill: 'Model' },
x: 'attribute',
y: 'score',
fill: 'model',
});
The closing vertex is dropped for you. A closed outline is drawn by repeating the first vertex at the end; that repeat is how the polygon shuts, not a spoke, so a trailing sample whose
xmatches the first one is removed. Left in, the chart would announce one spoke more than it has and every angle would rotate off the mark.
Polar Area / Coxcomb
A polar area draws one wedge per category and encodes the value as the radius rather than the angle. The wedges are d3.arc() paths exactly as a pie's are — including over d3.pie() output, which the binder unwraps — so the config is the pie's:
maidrD3.bindD3PolarArea(svgElement, {
selector: 'path.wedge',
title: 'Causes of Mortality',
axes: { x: 'Month', y: 'Deaths' },
x: 'month',
});
The values are read as one series around the spokes rather than as shares of a whole, so nothing announces a percentage. Like a pie, it has no fill axis.
Stacked / Dodged / Normalized Bars
bindD3Segmented handles the three common multi-series bar variants. Pass type: 'stacked_bar' | 'dodged_bar' | 'normalized_bar':
// d3.stack() pattern
for (const s of stack(stackData)) {
svg.selectAll(`rect.bar.${s.key}`)
.data(s.map((d, i) => ({ x: quarters[i], y: d[1] - d[0], fill: s.key })))
.join('rect')
.attr('class', 'bar')
.attr('x', d => x(d.x))
.attr('y', d => y(d.y))
.attr('width', x.bandwidth())
.attr('height', d => height - y(d.y))
.attr('fill', d => colors[d.fill]);
}
maidrD3.bindD3Segmented(svgElement, {
selector: 'rect.bar',
type: 'stacked_bar',
title: 'Revenue by Region',
axes: { x: 'Quarter', y: 'Revenue ($K)', fill: 'Region' },
x: 'x',
y: 'y',
fill: 'fill',
});
DOM order. The binder auto-detects whether your
<rect>s are interleaved by category (subject-major, typical for dodged bars) or grouped by series (series-major, typical for stacked bars). Override withdomOrderif your drawing pattern is unusual.
Diverging Bars / Population Pyramid
A diverging chart is two series drawn back to back rather than one on top of the other. It is the segmented extraction with a different reading, so bindD3Diverging takes the same config:
maidrD3.bindD3Diverging(svgElement, {
selector: 'rect.band',
title: 'Population by Age Band',
orientation: 'horz', // a pyramid is drawn on its side
axes: { x: 'People, thousands', y: 'Age band', fill: 'Sex' },
x: 'people', // negative for the side drawn left
y: 'band',
fill: 'sex',
});
Emit the values as the chart draws them. The sign is a side, not a magnitude: the trace pitches the magnitude and announces which side the bar is on, and the balance it reports between the two sides is that subtraction. Handed unsigned data it has no way to tell the sides apart.
Mosaic / Marimekko
A mosaic is a stacked bar whose column widths also encode data — each category's share of all observations. That share is the one thing the segmented extraction does not already read:
maidrD3.bindD3Mosaic(svgElement, {
selector: 'rect.cell',
title: 'Survival by Passenger Class',
axes: { x: 'Class', y: 'Proportion', fill: 'Outcome' },
x: 'klass',
y: 'share',
fill: 'outcome',
width: 'columnShare', // the column's share of all observations, 0–1
count: 'n', // the cell's own count, when you have the table
});
Every cell then announces its column's share alongside its own proportion. Both extra fields are optional and neither is invented: the width is read from your datum and never measured off the rendered <rect>, because a drawn width is padding and scale as much as data.
Error Bars / Point Range
One <g> per estimate, holding the interval's <line> and the estimate's marker:
maidrD3.bindD3ErrorBar(svgElement, {
selector: 'g.estimate',
title: 'Mean Response by Dose',
axes: { x: 'Group', y: 'Response' },
x: 'group',
y: 'mean',
yMin: d => d.mean - 1.96 * d.se, // ABSOLUTE bounds, not half-widths
yMax: d => d.mean + 1.96 * d.se,
});
The bounds are absolute positions on the value axis, and they are independently optional — a one-sided interval is a real chart, and a datum carrying neither still emits its estimate.
Forest Plot
A forest plot is the point-range chart above with the three things a meta-analysis is read by: the null line, each study's weight, and the pooled diamond at the bottom.
maidrD3.bindD3Forest(svgElement, {
selector: 'g.study',
pooledSelector: 'path.pooled', // the diamond is a DIFFERENT mark
title: 'Effect of the Intervention',
orientation: 'horz',
axes: { x: 'Odds ratio', y: 'Study' },
x: 'study',
y: 'or',
yMin: 'ciLow',
yMax: 'ciHigh',
weight: 'weight', // a fraction of one
nullValue: 1, // 1 for a ratio, 0 for a difference
});
Declare
nullValue. Whether a study's interval crosses it is the result for that study, and there is deliberately no default: a ratio chart guessed at 0 would report every study as not crossing, which is a confident wrong answer given to every row. Omit it and the reader gets the estimates, the intervals and the weights, and no claim about significance.
The pooled row is selected separately because it is drawn separately, and its rows are appended after the studies so the selector list stays parallel to the payload. A chart that draws the summary like any other row can mark it with the pooled accessor instead.
Dumbbell / Connected Dot
Point selector at the connectors, one <line> per row — not at the dots, which a chart draws two of per row:
maidrD3.bindD3Dumbbell(svgElement, {
selector: 'line.connector',
title: 'Life Expectancy, 1990 against 2020',
orientation: 'horz',
axes: { x: 'Years', y: 'Country' },
x: 'country',
start: 'y1990',
end: 'y2020',
startLabel: '1990', // what the two ends are CALLED
endLabel: '2020',
});
The end labels belong to the chart rather than to any row, which is why they are config. Without them a reader is told they are on the "start" dot, which is the one thing they already knew.
Waterfall / Bridge
Each step is a bar floating between the running total before it and the running total after it, so those two numbers are what the binder reads; the contribution is derived:
maidrD3.bindD3Waterfall(svgElement, {
selector: 'rect.step',
title: 'Quarterly Budget Bridge',
axes: { x: 'Step', y: 'Amount (thousands)' },
x: 'label',
kind: d => (d.isTotal ? 'total' : undefined),
});
Mark the totals. An opening, closing or subtotal bar is drawn exactly like a step but contributes nothing, and nothing in the numbers reveals which bars those are. An accessor returning
undefinedleaves every other bar classified from the sign of its contribution.
Gantt / Timeline / Swimlane
One <rect> per booked interval on a band scale of lanes. The binder groups the rects into lanes itself, so a flat .data(rows) join sorted by start date is fine:
maidrD3.bindD3Gantt(svgElement, {
selector: 'rect.task',
title: 'Project Schedule',
axes: { x: 'Day', y: 'Phase' },
x: 'phase', // the LANE
start: 'from',
end: 'to',
label: 'task', // what this interval is called
lanes: ['Design', 'Build', 'Review', 'Launch'],
unit: 'days',
});
Declare
laneswhen a lane can be empty. A lane with nothing booked has no element in the DOM at all, so the binder cannot discover it — and an empty row is a real statement about a schedule, and the one row a reader can navigate onto and be told nothing about. Populated lanes name themselves and need no entry; anything the binder finds andlanesdoes not declare is appended after it.
Dates are coerced to epoch milliseconds so lengths can be measured at all — pass format: { type: 'date' } so the ends are announced as dates rather than as timestamps. unit names what a length is counted in, which a bare number cannot say.
The highlight selectors are stamped per interval, in the payload's lane-grouped order, rather than emitted as one selector: the trace slices a flat element list lane by lane, and a chart that drew its rects in any other order would highlight one task while announcing another — with the counts still matching, so nothing would look wrong.
Word Cloud
The defaults are d3-cloud's own datum keys, so a cloud laid out with the plugin needs no accessors — and call the binder inside on('end', …), since the words are placed asynchronously:
cloud()
.words(terms.map(t => ({ text: t.term, size: t.count })))
.on('end', (words) => {
svg.selectAll('text.term').data(words).join('text')/* … */;
maidrD3.bindD3WordCloud(svgElement, {
selector: 'text.term',
title: 'Terms in the Abstracts',
axes: { x: 'Term', y: 'Occurrences' },
});
})
.start();
Where a term landed is a packing artefact rather than data, so the layout coordinates are deliberately dropped. The trace reads the terms heaviest first and permutes the glyphs to match, so their DOM order does not have to mean anything.
Sankey / Alluvial / Chord
All three are the same weighted graph drawn three ways, so one config reads them. Point selector at the ribbons, one <path> per link — not at the node rectangles, which are derived from the links:
// d3-sankey: the layout replaces each link's source/target with node objects,
// and the binder reads their names for you.
maidrD3.bindD3Sankey(svgElement, {
selector: 'path.ribbon',
title: 'Energy flow',
axes: { x: 'Node', y: 'Petajoules' },
});
// An alluvial is a sankey whose node columns repeat.
maidrD3.bindD3Alluvial(svgElement, { selector: 'path.ribbon' });
// A chord is computed from a MATRIX, so its ends are row indices.
maidrD3.bindD3Chord(svgElement, {
selector: 'path.chord',
title: 'Migration between regions',
axes: { x: 'Region', y: 'People' },
names: ['Africa', 'Americas', 'Asia', 'Europe'],
});
Declare
namesfor a chord.d3.chord()binds{ index, value, … }to each end because a matrix has no names in it, and the labels a sighted reader takes from the ring are nowhere in the data. Without them the chart announces "0 to 3" — true, and useless. The magnitude is read off the ends too, since a chord carries none of its own.
The ribbons are emitted in the order they were declared, and the trace keys its selectors to that order: from a node, arrow keys follow the largest flow out of it and the rotor steps through the rest.
Kaplan-Meier Survival Curve
A survival curve is a step line, so selector, x, y and fill are the line binder's. What a survival figure adds is the two things it is read for — which times were censored, and how wide the confidence band is:
maidrD3.bindD3Survival(svgElement, {
selector: 'path.km',
censoredSelector: 'line.censor', // the ticks, drawn by their own join
title: 'Overall Survival',
axes: { x: 'Months', y: 'Survival probability', fill: 'Arm' },
x: 'time',
y: 'surv',
fill: 'arm',
yMin: 'lower',
yMax: 'upper',
});
Censoring marks are drawn as ticks precisely because censoring does not change the estimate — so they come from a separate join whose times are not, in general, vertices of the curve. The binder merges each tick into the arm its fill names: flagging the vertex already at that time, or inserting one carrying the probability the curve holds across the interval it falls in. On a chart whose own samples already carry a censored column, leave censoredSelector out and the censored accessor reads it (true, 1, '1' and 'true' count; nothing else does, because '0' is truthy and censors nobody).
The trace derives median survival itself and offers a rotor over the censored times, so nothing here computes either.
The step convention is 'hv' without being asked, because a Kaplan-Meier
estimate holds until an event drops it — d3.curveStepAfter. Pass
stepDirection: 'vh' for a curve drawn the other way round.
Parallel Coordinates
One <path> per observation crossing several per-variable scales, with the whole observation bound to it. The binder transposes that into one point per axis, and dimensions is the axis order — the same list you built one scale per:
maidrD3.bindD3Parallel(svgElement, {
selector: 'path.observation',
title: 'Car Characteristics',
axes: { x: 'Variable', y: 'Value', fill: 'Car' },
dimensions: ['mpg', 'hp', 'weight'],
label: 'name', // what this observation is called
// value: (d, dimension) => d.values[dimension], // when the numbers nest
});
dimensionsis required. An object's key order is not an axis order, and the order the axes are drawn in is the order a reader arrows through them. An observation short of a declared dimension is an error rather than a gap: dropping it would shift every axis after it, announcing each value under the next variable's name.
The trace derives each axis' range from the data and sonifies every value against its own axis, so no per-axis minimum is needed in the payload — a car with the best economy and the worst power sounds like exactly that, rather than like the units the two variables happen to be measured in.
Ridgeline / Joy Plot
One d3.area() density curve per group, offset down the page. The three per-sample fields are named for what they mean rather than for the payload keys they land on, because a ridgeline's value axis is usually the drawn x:
maidrD3.bindD3Ridgeline(svgElement, {
selector: 'path.ridge',
title: 'Delivery Time by Cohort',
axes: { x: 'Days', y: 'Cohort', fill: 'Cohort' },
group: 'cohort', // names the ridge
value: 'days', // position along the value axis
density: 'density', // the curve's OWN half-width
});
densityis never the drawn y. A ridgeline is drawn by adding each group's baseline to its density, and that baseline is layout: fed to MAIDR it would make every group's loudness a function of where it happened to be stacked, and the lowest ridge the loudest chart-wide. Pass the kernel-density array you computed before offsetting it — the number inside the offset arithmetic in yourd3.area().y1(...), not its result. The binder cannot detect the mistake; it can only refuse when there is no density at all.
Each path's datum supplies its group's samples: the array itself, a d3.groups() tuple, or a values / samples / points / curve property holding one. One element per group is exactly what the trace wants — it lights that ridge's whole curve from any of its samples — so a single scoped selector is emitted.
Hexbin Density
The d3-hexbin plugin returns one bin per occupied hexagon, each an array of the points that fell in it carrying .x/.y (the centre) and .length (the count) — which is what the defaults read:
const bins = hexbin(points);
svg.selectAll('path.hexagon').data(bins).join('path').attr('d', hexbin.hexagon());
maidrD3.bindD3Hexbin(svgElement, {
selector: 'path.hexagon',
title: 'Point Density',
axes: { x: 'Carat', y: 'Price', fill: 'Count' },
x: d => xScale.invert(d.x), // the centres come out in PIXELS
y: d => yScale.invert(d.y),
});
Pass the inverse scales.
d3-hexbinbins the projected points, sobin.xis a screen coordinate. Left as it is, every bin announces its position in pixels — which sounds entirely plausible and is entirely wrong.
The lattice rows are assembled by the binder: bins grouped by their y, ordered from the lowest upward, each row ordered left to right. An empty bin is not drawn at all, so the rows do not hold the same number of bins and no rectangular grid could be laid over them. Supply row only if your y centres do not come out identical within a row. The highlight selectors are stamped per bin in that lattice order, because a bare selector would resolve in the order the plugin generated the bins in — with the counts still matching, so the trace could not tell.
Contour / Density Field
d3.contours() and d3.contourDensity() emit one GeoJSON MultiPolygon per threshold, carrying the threshold as .value. Bind those objects to the paths — not the d strings you drew them with:
const levels = d3.contours().size([n, m])(values);
svg.selectAll('path.contour').data(levels).join('path').attr('d', d3.geoPath());
maidrD3.bindD3Contour(svgElement, {
selector: 'path.contour',
title: 'Density Field',
axes: { x: 'X', y: 'Y', fill: 'Density' },
x: column => x0 + column * cellWidth, // contours() emits GRID INDICES
y: row => y0 + row * cellHeight,
});
The coordinates are not in data space.
d3.contours()walks a grid and emits indices;d3.contourDensity()bins projected points and emits pixels (usex: px => xScale.invert(px)for that one). Neither is a position on an axis.
The level is carried on every point of its curve, which is where the grammar has room for it, and the trace announces it on the z axis — the field's own — rather than as a series name. A level drawn as several disjoint rings is flattened into one curve in ring order, since a payload row is a single polyline: every point announced is real, but the jump from one ring to the next is inaudible. Rows keep the order the paths were drawn in, which for d3.contours() is ascending threshold — the order the trace measures the gap to the adjacent level in.
Choropleth Map
d3.geoPath() draws one <path> per GeoJSON feature, and the feature is what is bound to it — so a string accessor names a key on the feature or in its properties, which is where a place name and a joined value live on every real map:
svg.selectAll('path.region')
.data(topojson.feature(us, us.objects.states).features)
.join('path')
.attr('d', d3.geoPath(projection));
maidrD3.bindD3Choropleth(svgElement, {
selector: 'path.region',
title: 'Unemployment by State',
axes: { x: 'State', y: 'Rate' },
value: d => rateByFips.get(d.id), // the join, keyed by the feature's id
lon: d => d3.geoCentroid(d)[0], // DEGREES
lat: d => d3.geoCentroid(d)[1],
});
lon/latared3.geoCentroid, neverd3.geoPath().centroid. The latter returns the centre of the drawn shape in projected pixels. Fed those, the arrows follow the projection's paper layout instead of the compass — north and south come out swapped on a south-up projection and meaningless on an interrupted one. A coordinate outside ±180°/±90° is dropped rather than converted by guesswork, with a warning naming the count.
The centroids are what make this more than a bar chart whose categories happen to be places: with them the regions are banded by latitude and ordered west to east inside each band, so Up is north and Right is east. Without them the map is read as a region list in drawn order — a poorer reading, but the one the data supports. It is all or nothing, so a map that resolves centroids for some of its regions and not the rest warns and falls back to the list.
A region the value join missed is left out of the payload — it is drawn in the "no data" colour and carries no number, and a y of 0 would sonify it as the lowest region on the map. Its path is left out of the highlight selectors with it, so the announced region and the lit shape stay in step; the selectors are stamped per region for exactly that reason.
neighbors is not read. Adjacency is not recoverable from rendered paths, and deriving it needs shared-border topology (topojson.neighbors) that this package does not depend on. A layer that declares none keeps the spatial walk and is simply told nothing about borders — to get the border readings (the neighbour rotor, the sharpest borders, the cluster of high regions), author the layer's JSON directly with a neighbors array per region.
Gauge / Bullet Chart
A drawn gauge binds one number, so the rest of the reading is config — and it is the reading: "73" means nothing without the range it sits in, the target it was aiming at, and the band it lands in:
maidrD3.bindD3Gauge(svgElement, {
selector: 'rect.measure', // the mark that moves with the value
title: 'Conversion Rate against Target',
axes: { x: 'Measure', y: 'Percent' },
label: 'Conversion',
min: 0,
max: 100,
target: 80,
bands: [{ to: 50, label: 'poor' }, { to: 75, label: 'ok' }, { to: 100, label: 'good' }],
});
Smooth / Regression Curve
maidrD3.bindD3Smooth(svgElement, {
selector: 'circle.smooth',
title: 'LOESS Fit',
axes: { x: 'Height (cm)', y: 'Weight (kg)' },
x: 'x',
y: 'y',
svgX: 'svg_x',
svgY: 'svg_y',
});
Pie / Doughnut Chart
The canonical D3 pie is d3.pie() + d3.arc() drawn as one <path> per wedge. d3.pie() wraps each of your data objects in an arc object, and the binder unwraps it for you, so accessors address your own datum ('fruit'), not the layout's ('data.fruit'):
const data = [
{ fruit: 'Apples', units: 30 },
{ fruit: 'Bananas', units: 50 },
{ fruit: 'Cherries', units: 20 },
];
svg.selectAll('path.slice')
.data(d3.pie().value(d => d.units)(data)).join('path')
.attr('class', 'slice')
.attr('d', d3.arc().innerRadius(0).outerRadius(150));
maidrD3.bindD3Pie(svgElement, {
selector: 'path.slice',
title: 'Fruit Sales',
axes: { x: 'Fruit', y: 'Units' },
x: 'fruit',
// No `y`: the magnitude defaults to the value d3.pie() itself computed,
// which is what the drawn angle is proportional to. Supply one only for a
// pie drawn without the layout.
});
A doughnut is the same layout drawn with a non-zero innerRadius, and reads identically. Left and Right move between slices; Up and Down are out of bounds, since a pie is a single row. Each slice announces its label, its value, and its share of the whole — "Fruit is Apples, Units is 30, Percentage is 30.0%". There is no fill axis: the share is derived from the values themselves, so there is nothing for a third axis to name.
TypeScript Types
All public types are exported from the maidr/d3 entry (and re-exported from maidr/react for the React wrapper):
import type {
// Per-binder configs
D3BarConfig, // also bindD3Dot / bindD3Lollipop / bindD3Funnel
D3LineConfig, // also bindD3Bump / bindD3Radar
D3AreaConfig,
D3ScatterConfig,
D3VolcanoConfig,
D3BoxConfig,
D3BoxenConfig,
D3ErrorBarConfig,
D3ForestConfig,
D3FlowConfig, // bindD3Sankey / bindD3Alluvial / bindD3Chord
D3GanttConfig,
D3HistogramConfig,
D3HeatmapConfig,
D3CandlestickConfig,
D3ChoroplethConfig,
D3ContourConfig,
D3HexbinConfig,
D3ParallelConfig,
D3RidgelineConfig,
D3SurvivalConfig, // extends D3LineConfig
D3SegmentedConfig,
D3MosaicConfig,
D3NetworkConfig,
D3TreemapConfig, // also bindD3Sunburst / bindD3Icicle
D3SmoothConfig,
D3PieConfig,
D3PolarAreaConfig,
// Multi-panel
D3FacetsConfig,
D3SubplotsConfig,
D3SubplotEntry,
D3PanelChartSpec,
D3PanelLayout,
D3MultiPanelResult,
// Shared
D3BinderConfig,
D3BinderResult,
DataAccessor,
AreaTraceType,
BarMarkTraceType,
LineMarkTraceType,
FlowTraceType,
D3GridTransform, // (gridCoordinate: number) => number, for bindD3Contour
ScatterMarkTraceType,
SegmentedTraceType,
TreemapTraceType,
// MAIDR schema re-exports
MaidrData,
MaidrLayer,
MaidrSubplot,
Orientation,
TraceType,
} from 'maidr/d3';
The React wrapper adds:
import type {
MaidrD3Props, // props for <MaidrD3>
D3AdapterSpec, // { chartType, config } discriminated union
D3ChartType, // 'bar' | 'line' | 'scatter' | …
UseD3AdapterResult,// { maidrData, error }
} from 'maidr/react';
React Integration
See the live React demo for runnable versions of the D3 bar and scatter examples below, or browse the source in examples/react-app/.
<MaidrD3> — convenience wrapper
The component renders its children bare until the first successful bind, then re-renders them wrapped in <Maidr>. Because the wrapping causes a remount, your D3 drawing effect must re-run on mount — the typical useEffect(() => {...}, [data]) pattern is sufficient.
<MaidrD3
svgRef={svgRef}
chartType="scatter"
config={{ selector: 'circle.dot', x: 'height', y: 'weight' }}
deps={[data]} // re-bind when `data` changes
>
<svg ref={svgRef} width={600} height={400} />
</MaidrD3>
useD3Adapter — lower-level hook
Use the hook when you need access to maidrData outside the <Maidr> wrapper — e.g. to log the extracted schema, merge multiple sources, or conditionally mount.
const { maidrData, error } = useD3Adapter(svgRef, spec, deps);
The hook:
- Reads
svgRef.currentinsideuseEffect, so it always runs after React has committed the SVG. - Re-runs the binder whenever
depschange. - Ignores object identity of
spec— you don't need to memoize it. - Catches binder errors and returns them via
errorso your UI can surface failures without crashing.
Keyboard Controls
Once a chart is focused, use the standard MAIDR shortcuts:
| Function | Key (Windows) | Key (Mac) |
|---|---|---|
| Move between data points | Arrow keys | Arrow keys |
| Go to extremes | Ctrl + Arrow | Cmd + Arrow |
| Jump by tick (scatter grid) | Ctrl + Arrow | Cmd + Arrow |
| Toggle Sonification | S | S |
| Toggle Braille Mode | B | B |
| Toggle Text Mode | T | T |
| Toggle Review Mode | R | R |
| Auto-play | Ctrl + Shift + Arrow | Cmd + Shift + Arrow |
| Stop Auto-play | Ctrl | Cmd |
For the full list, see the Keyboard Controls reference.
Integration Comparison
| Feature | D3 Adapter | Recharts Component | Plotly Adapter |
|---|---|---|---|
| Rendering library | D3.js (imperative SVG) | Recharts (declarative React) | Plotly.js (high-level) |
| Setup | Binder call after draw | Wrap chart in <Maidr> |
Just add <script> |
| Chart selectors | CSS selector you provide | Auto from component tree | Auto-generated |
| Data source | D3's __data__ |
Recharts props | Plotly internals |
| Chart types | 9 D3 patterns | Recharts set | 9 Plotly types |
| Dynamic updates | Via deps (React) or re-call (vanilla) |
React lifecycle | Auto-detected |
API Documentation
For the complete TypeScript API reference — every binder, every config field, every type — see the API Documentation.