Last updated

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 throws No 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:

  1. Queries the SVG for your elements (e.g. rect.bar, circle.dot, g.box) using the selector you provide.
  2. Extracts __data__ from each element, resolving values via your accessors (x: 'day', y: (d) => d.count, etc.).
  3. 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.
  4. Builds the MAIDR JSON schema (axes, legends, selectors, data points) and either writes it as a maidr-data attribute (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
});

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.05 is the finding, so pass significanceDirection: '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:

// 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/z accessor. 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:

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>:

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 - y0 for d3.stack() output; only supply your own y accessor 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 x matches 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 with domOrder if 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 undefined leaves 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 lanes when 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 and lanes does 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 names for 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
});

dimensions is 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
});

density is 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 your d3.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-hexbin bins the projected points, so bin.x is 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 (use x: 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/lat are d3.geoCentroid, never d3.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:

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.