Apache ECharts
MAIDR reads a rendered Apache ECharts chart and makes it navigable with audio sonification, text descriptions, braille output and keyboard navigation.
Measured against echarts 6.1.0.
Quick start
<script src="https://cdn.jsdelivr.net/npm/echarts@6/dist/echarts.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/maidr/dist/maidr.js"></script>
<script src="https://cdn.jsdelivr.net/npm/maidr/dist/echarts.js"></script>
<div id="chart" style="width: 600px; height: 400px"></div>
<script>
const container = document.querySelector('#chart');
const chart = echarts.init(container, null, { renderer: 'svg' });
chart.setOption({
title: { text: 'Daily Visitors' },
xAxis: { type: 'category', data: ['Mon', 'Tue', 'Wed'], name: 'Day' },
yAxis: { name: 'Visitors' },
series: [{ type: 'bar', name: 'Visitors', data: [120, 240, 180] }],
});
chart.on('finished', function once() {
chart.off('finished', once);
const maidr = maidrECharts.createMaidrFromEChart(chart, container);
container.setAttribute('maidr', JSON.stringify(maidr));
});
</script>
Two things this example does on purpose:
renderer: 'svg'. ECharts defaults to canvas, which draws no elements to point at. A canvas chart still reads — audio, text and braille all come from the chart's model — but it highlights nothing.- Calling after
finished. The adapter locates marks in the drawn SVG, so it has to run after ECharts has drawn.
Supported series types
ECharts series.type |
Read as | Notes |
|---|---|---|
bar |
bar |
One series. yAxis: {type: 'category'} makes it horizontal |
bar ×N sharing a stack |
stacked_bar |
Each point carries its series name |
bar ×N without a stack |
dodged_bar |
|
line |
line |
|
line + areaStyle |
area |
The fill is what makes it an area |
line + step |
line + stepDirection |
'start' → vh, 'end'/'middle' → hv |
scatter |
point |
A symbolSize reading a third column becomes ScatterPoint.z, which is audible |
Not yet supported: boxplot, radar, treemap, sunburst, sankey,
graph, parallel, themeRiver, pictorialBar. Each of these is refused
by name rather than mapped onto whichever trace is closest — most have a MAIDR
trace waiting for them, and each wants its own measured layout first. See
#1195 for the tiers.
Orientation note: ECharts has no "horizontal" option. A bar chart is turned on its side by making the y axis the categorical one, and that is the only thing that says so — so the adapter reads the orientation from which axis is
type: 'category', and moves the magnitude onto the axis that carries it.
Gap note: a datum with no value draws nothing. In a bar chart that category is left out of the reading entirely, because there is no bar to announce or outline; in a line chart it is kept, with no reading, so the samples either side stay in their places rather than the series closing over the hole.
Single-value charts
A pie, a funnel and a gauge sit on no grid and own the whole chart, and all
three report data.dimensions as ['value'] with the label on getName(i) —
so the reading is that pair.
| ECharts | read as | highlighted |
|---|---|---|
pie |
pie |
yes — one selector naming every slice |
funnel |
funnel |
yes — one selector per stage |
gauge |
gauge |
no — see below |
A gauge draws two filled marks for its one datum: the track and the
progress arc, measured as #e8ebf0 and the series colour. The count check
every other reading here relies on has nothing to check, and naming either
mark would be a guess about which one the reader should be shown, so the
gauge is read without an outline.
min and max come off the series rather than being defaulted here.
getModel() resolves ECharts' own 0 and 100 when the author wrote
neither, so the dial the reader is told about is the dial that was drawn.
Grid-value charts
A heat grid and a price chart sit on the same cartesian axes the bar family uses, and differ from it in the same way: a datum is not one magnitude but a set of them, and ECharts has already worked them out. Measured on 6.1.0 the model reports them under named dimensions, so nothing is recovered from the drawing:
| ECharts | data.dimensions |
read as | highlighted |
|---|---|---|---|
heatmap |
['x', 'y', 'value'] |
heat |
yes — a selector per cell |
candlestick |
['base', 'open', 'close', 'lowest', 'highest'] |
candlestick |
yes — a selector per candle |
Both draw exactly one filled mark per datum — a cell, a candle body with its wick — which is the same shape the bar reading already counts and stamps.
A heatmap numbers a cell by axis index, not by name, and a category y axis
runs bottom-up: a 2×2 grid reports its cells as [0,0], [0,1], [1,0],
[1,1] with y = 0 along the bottom. HeatmapData is top-first, so the rows
are turned over on the way out — and the selector grid is built by the same
walk that places the values, so a cell's outline and its reading cannot
disagree.
A cell the chart drew nothing at stays empty rather than becoming a zero. A grid is a rectangle and the data need not fill it, so a missing cell reads as missing (#1191) and carries no selector.
A candlestick's volatility is the day's range (high - low), matching
every other candlestick producer in this tree. A period missing any of the four
prices draws no candle, so it is left out of the reading entirely — counting it
would expect a mark that was never drawn.
Highlighting
ECharts gives a reading almost nothing to address by. Measured on 6.1.0: the
SVG is one flat <g> with no ids, no data-* attributes and only
generated zr0-cls-N classes; dataIndex is not set on the traversed zrender
elements in the production build; and the element-to-DOM-node mapping is not
exposed by any public property.
What the drawing does give is a clean separation by paint, with marks contiguous in data order:
| what | fill | stroke |
|---|---|---|
| gridline, axis line | none |
grey, unweighted |
| bar / scatter mark | the series colour | — |
| line | none |
the series colour, stroke-width: 2 |
| area fill | the series colour | none |
| hover symbol | white | the series colour |
| border artefact, axis-name background | black | — |
So the adapter finds marks by their paint, checks the count against what the model says was drawn, stamps them, and builds selectors from the stamps. If the counts disagree it drops the highlighting for that chart rather than outlining the wrong datum — the same discipline the Google Charts Calendar uses.
One consequence worth knowing: a series painted pure black loses its highlighting. Its marks are indistinguishable from the furniture, so they are excluded, the count check fails, and the chart reads without an outline. That is the conservative failure, not a silent wrong one.
Which shape each layer's selectors take
A selector that resolves the right element is only half of it: each trace class accepts a different shape, and a layer whose selectors are individually right and collectively the wrong shape resolves nothing at all, silently.
| layer | shape | what reads it |
|---|---|---|
bar |
one selector per bar | AbstractBarPlot's array branch |
stacked, dodged |
a row of selectors per series | SegmentedTrace.mapGridToSvgElements |
scatter |
one selector naming the whole series | ScatterTrace, which casts layer.selectors to string |
line, area |
one selector per series | LineTrace.mapToSvgElements |
heat |
a row of selectors per grid row, bottom-first | Heatmap, which indexes by its own row |
candlestick |
{ body: [...] }, one per candle |
Candlestick.mapToSvgElements |
So the marks are stamped twice in one pass — once per mark and once per series — and each layer takes the address its own trace can use.
A scatter needs one more thing from the model. ECharts does not move its
symbols into place; it scales them there, with
transform="matrix(s, 0, 0, s, e, f)" over a unit shape whose d always
begins M1 0. ScatterTrace recovers a mark's position from the DOM rather
than from the data's order, and it now reads that matrix — without it every
symbol reported the same coordinate and the whole scatter grouped into one
column (#1197).