ECharts — option-tree charting substrate¶
Candor up front: Anthropic's training data has deep coverage of D3 and matplotlib and good coverage of Plotly/Chart.js, but ECharts is under-represented. When generating ECharts code, we get it ~right structurally but routinely hallucinate field names, miss the
markAreatwo-element pair shape, mis-nestaxisLine.lineStyle.color, or invent option keys that don't exist. Verify against the upstream docs every time: echarts.apache.org/en/option.html. Don't ship ECharts code untested.
When to reach for it. ECharts shines when you need a declarative time-series / scatter / radar chart on a dark-theme dashboard, with multiple overlaid series, brushable selection, and built-in tooltip + legend + zoom out of the box. It's the lightest substrate when:
- The piece is interactive (live data, hover, brush, drilldown) — D3 lets you do everything but you re-implement the chrome each time
- The chart already has more than one series and a tooltip on every datapoint would be useful — Chart.js handles single-series-with-tooltip well but multi-series tooltips are clumsy
- The chart is part of a larger UI (dashboard, report, studio surface) that already loads a JS framework — ECharts ships as a single
~1MBminified file - You want time-segmented overlays (
markArea) and live cursor sync (markLine) without writing your own SVG primitives — D3 makes you build them; ECharts has them as first-class
Don't reach for it when:
- The output is a static SVG paper figure → use matplotlib via
channels/science.md, or D3+SVG viachannels/svg.md. ECharts' SVG renderer exists but the DX is canvas-first. - The chart is a one-off table-replacement (e.g. a single sparkline) →
channels/terminal.mdor plain SVG. - The piece needs strict Tufte / 8:1 chrome enforcement → start from
channels/charts.md, which has the per-library anti-pattern table. ECharts defaults violate most of that table; the configs below override them. - You're going to ship the chart as a sharable PNG → render via headless screenshot or use matplotlib directly. ECharts is interactive-first.
Version and licensing¶
- Pin to
echarts@^5.4(current stable line). CDN:https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js— gzipped ≈ 350 KB. - Apache-2.0 license — safe to vendor.
- For offline use (studio surfaces, file:// pages), download the dist file and reference it locally. Don't rely on the CDN when the artifact has to survive a network outage.
Dark-mode baseline (the studio pattern)¶
ECharts' defaults are tuned for light pages: white background, dark #666 axis text, white tooltip with subtle border, light gray gridlines. On a dark surface they read as blown-out chrome. The fix is to override five blocks in setOption:
chart.setOption({
// 1. Inherit page background — don't paint a white box behind the chart.
backgroundColor: 'transparent',
// 2. Set base text color so legend, axis labels, tooltip inherit it.
// Use the brand's primary foreground; ECharts cascades textStyle through children.
textStyle: {
color: '#e8e4f0',
fontFamily: 'system-ui, sans-serif',
},
// 3. Axes — line color must be visible on dark, splitLine should be subtle.
// (Repeat for yAxis.)
xAxis: {
axisLine: { lineStyle: { color: '#5a5470' } }, // visible but quiet
splitLine: { lineStyle: { color: '#2a2638' } }, // even quieter — guides the eye, doesn't compete
},
// 4. Tooltip — the white default is the worst offender on dark themes.
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(20, 18, 26, 0.95)',
borderColor: '#3a3450',
textStyle: { color: '#e8e4f0' },
},
// 5. Legend — explicit text color (ECharts ignores parent textStyle here in some versions).
legend: { textStyle: { color: '#b8b3c8' } },
series: [/* … */],
});
This is enough for any single-chart dashboard panel. For multi-chart pages, wrap the above in a theme object and echarts.registerTheme('studio-dark', theme); then echarts.init(el, 'studio-dark'). But registering a custom theme is more ceremony than most studio pieces need — inline overrides are fine for ≤3 chart instances.
Time-segmented overlays (markArea)¶
The most powerful ECharts feature for asserted-arc-style timelines: rectangular bands behind a time series, each spanning a labeled time range. Used in psychodeli-audio-lab/studio to overlay state-target phases (relaxed / focused / open-monitoring / hypnagogic) on a 48-second analyzer-feature timeline.
The shape (commonly mis-written): markArea.data is a list of pairs. Each pair is [startObj, endObj], where the first object specifies the leading edge + visual style and the second specifies the trailing edge. Both objects use xAxis (or yAxis) for the coordinate.
series: [{
type: 'line',
data: tSec.map((t, i) => [t, values[i]]),
markArea: {
silent: true, // don't intercept hover on data points
label: { position: 'top' }, // label sits above the chart, not inside the band
data: [
[
{ name: 'drone_solo', xAxis: 0,
itemStyle: { color: 'rgba(155,110,200,0.18)',
borderColor: '#9b6ec8', borderWidth: 1 } },
{ xAxis: 10 }
],
[
{ name: 'chant_call', xAxis: 10,
itemStyle: { color: 'rgba(155,110,200,0.18)' } },
{ xAxis: 20 }
],
]
}
}]
Three traps I hit:
- The hallucinated single-object form. Plenty of generated code writes
data: [{ name, xAxis: [t0, t1], itemStyle }]. Wrong shape — ECharts silently renders nothing. Always the pair. - Missing
silent: true. Without it, the band's invisible bounding box intercepts mouse events meant for the data series — tooltips stop firing on hover over the band region. - Label position default is
'inside'which paints the segment name over your data.position: 'top'floats it above the chart frame and usually reads cleaner.
The series color contract — the bug I burned a round-trip on¶
Setting lineStyle.color is not enough. ECharts treats color at the series root as the "series base color" — it drives the legend marker, the tooltip dot, and the default symbol fill. lineStyle.color only paints the line itself. If you set only lineStyle.color, the line draws in your color but the legend marker (and tooltip dot) keeps using ECharts' default 9-color palette:
default_palette: ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', '#3ba272', '#fc8452', '#9a60b4', '#ea7ccc']
Caught the live in psychodeli-audio-lab/studio: I'd set five lineStyle.color: BRAND.X overrides and the lines drew correctly, but the legend showed #5470c6 #91cc75 #fac858 #ee6666 #73c0de — every legend marker pulled the default palette in index order. Looked correct in isolation, was 100% lying about which line was which.
The reliable pattern: set BOTH top-level color: [] AND series-root color for every series with brand colors:
chart.setOption({
// Series-cycle palette — drives any series without an explicit `color`.
color: [BRAND.accent, BRAND.stateRelaxed, BRAND.stateFocused, BRAND.stateHypnagogic, BRAND.fail],
series: [
{
name: 'centroid (Hz)',
type: 'line',
color: BRAND.accent, // legend marker + tooltip dot
lineStyle: { width: 1.8, color: BRAND.accent }, // line stroke
data: /* … */,
},
// … same shape per series
],
});
Why both? The top-level color: [] array is the categorical palette ECharts cycles through. Per-series color is an explicit override. Setting both makes the contract explicit (no positional dependency on series order) and survives series-list reordering during refactor.
Other color-family fields that have their own narrow purpose:
itemStyle.color— fill color for symbols, bars, pie slices. For line charts withshowSymbol: false, irrelevant.areaStyle.color— for stacked area charts; fill below the line.emphasis.itemStyle.color— hover state. Defaults to the series base color brightened ~30%; explicit override only if the default doesn't read on your background.
If you see the legend in default-palette colors while the lines render correctly, that's this bug. Fix: set color at series root.
Playhead sync gotchas¶
Pattern from studio: bind a markLine to the audio element's currentTime. Don't drive it from timeupdate alone — programmatic audio.currentTime = X while paused does NOT fire timeupdate. That event is playback-locked (fires ~4Hz only while the audio is playing). For seek-to-segment UX or any non-playback playhead movement, subscribe to seeked AND loadedmetadata as well:
const player = document.getElementById('player');
const drawPlayhead = () => {
chart.setOption({
series: [{
markLine: {
symbol: 'none', silent: true, animation: false,
lineStyle: { color: BRAND.fg, width: 1.5, opacity: 0.85 },
label: { show: false },
data: [{ xAxis: player.currentTime }],
}
}]
});
};
player.addEventListener('timeupdate', drawPlayhead); // playback tick
player.addEventListener('seeked', drawPlayhead); // programmatic + scrubbed seek
player.addEventListener('loadedmetadata', drawPlayhead); // initial position after load
Caught live: a segment-card click handler did player.currentTime = t0 without calling play(), expecting the playhead to follow. It didn't, because timeupdate is playback-locked. The user wanted seek-without-play (preview the segment context, then optionally hit space to play) — adding seeked to the event list fixes the UX without forcing playback.
preload="metadata" is unreliable for showing duration at page load. Chromium defers metadata fetch — even after audio.load() + 8 second wait, readyState may still be 0 (HAVE_NOTHING) and duration is NaN. If the player chrome MUST show duration immediately, use preload="auto". Cost: full audio fetch on page load. For studio surfaces serving local-net WAVs this is invisible; for public web pages with multi-megabyte audio it's not.
(Note: Chrome extension contexts sometimes block media auto-fetch even with preload="auto" — verify against a regular browser tab before declaring it broken in production.)
ECharts diffs the option tree (don't fight it)¶
Pattern from studio: bind a markLine to the audio element's currentTime, update on timeupdate event. ECharts diffs the option tree, so we only pay for the line move, not a full redraw.
const player = document.getElementById('player');
player.addEventListener('timeupdate', () => {
chart.setOption({
series: [{
markLine: {
symbol: 'none',
silent: true,
lineStyle: { color: '#fff', width: 1, opacity: 0.8 },
data: [{ xAxis: player.currentTime }]
}
}]
});
});
timeupdatefires ~4Hz on Chromium, ~250ms throttle. Not animation-smooth, but the eye reads it as "playing." For animation-smooth, userequestAnimationFrameand readplayer.currentTimeeach tick.- Don't use
notMerge: truehere — you want ECharts to diff the partial option, not blow away the series config and re-render from scratch every frame. - For seek-by-click on segment labels, the reverse direction works the same:
player.currentTime = segment.t[0]and the nexttimeupdatetriggers the playhead move. No need to manually drive both.
Multi-axis discipline (don't lie with scaling)¶
The scaling temptation: you have spectralCentroid (0–12000 Hz), energy bands (0–2), and onsetStrength (0–1) on the same chart, so you multiply the small ones (×200, ×1000) to make them visible against the centroid scale. Don't. The legend then reports "onsetStrength × 1000" and the viewer has to do mental math.
Use multiple yAxis with yAxisIndex per series:
yAxis: [
{ type: 'value', name: 'Hz', position: 'left' },
{ type: 'value', name: 'energy', position: 'right' },
],
series: [
{ name: 'centroid (Hz)', yAxisIndex: 0, type: 'line', data: /* … */ },
{ name: 'energy', yAxisIndex: 1, type: 'line', data: /* … */ },
],
Two y-axes for two unit families is honest. Four series sharing one axis with three of them rescaled is not.
(This is the one legitimate use of dual y-axes — different unit dimensions. The Tufte/charts.md anti-pattern about dual y-axes is specifically about same unit, different scale dual axes that fake correlation. Different units, properly labeled, is fine.)
Data-shape contract¶
ECharts time-series wants [[x, y], [x, y], …] arrays. Not {x, y} objects. Build the array explicitly; avoid dataset + encode for simple cases — they add indirection without much benefit until you're sharing one dataset across multiple series.
// ✓ Correct
data: tSec.map((t, i) => [t, values[i]])
// ✗ Hallucinated — ECharts won't parse this for line type without dataset config
data: tSec.map((t, i) => ({ x: t, y: values[i] }))
Renderer: canvas vs SVG¶
Default canvas. Faster for >500 points, no DOM bloat, transparent backgrounds work. Switch to SVG (echarts.init(el, theme, { renderer: 'svg' })) only when:
- You need DOM-level a11y on individual marks (screen reader landmarks)
- You're exporting the chart as a static SVG asset (rare — usually better to export PNG from canvas)
- The chart is part of a larger SVG composition you want to inspect with devtools
For audio-reactive / playhead-driven charts, canvas is the right choice — SVG redraws cost more on every setOption.
Worked exemplar¶
psychodeli-audio-lab/studio/meditate-001.html (generated by tools/studio-build.py). The single-page studio surface:
- Dark-theme baseline (overrides above)
- 5 series on shared y-axis (acceptable here only because energy bands and onset are dimensionless ratios on the same 0–1 logical scale; centroid is plotted in Hz on the same axis as a deliberate visual-density tradeoff — see the "Multi-axis discipline" caveat above; this file currently violates that rule and is a candidate for refactor to dual yAxis)
markAreasegments per asserted-arc phase, colored by state-target taxonomymarkLineplayhead synced to the<audio>element- Click-on-segment → seek the audio → ECharts updates on next
timeupdate
When in doubt, copy that file's setOption block as a starting point rather than generating from memory.
What I don't trust myself to write without checking the docs¶
When generating ECharts options from a prompt, my failure modes (the ECharts-specific ones, beyond the dark-theme defaults above):
- The series color contract — confirmed live, see section above.
lineStyle.color≠ legend marker color. Always also set series-rootcolor(and/or top-levelcolor: []palette). markArea.datashape — pairs of[startObj, endObj], not single objects withxAxis: [t0, t1]. Confirmed live; muriel example uses the pair shape.timeupdatevsseeked— confirmed live;timeupdateis playback-locked,seekedis the companion event for programmatic seeks.visualMap— the piecewise/continuous distinction, theinRange.colorarray length matching the data range. I usually get the structure right but the field names drift.dataZoom—insidevsslidertypes, thestartandendpercentage semantics,xAxisIndexlinkage.graphic— custom overlay shapes (text, rect, line annotations not tied to a series). I tend to mis-nestelements[].stylevselements[].shape.brush— the cross-series selection mode. Most generated code under-specifiestoolbox.feature.brushand the brush UI never appears.series.largeperformance flag for >5k points — the threshold and the trade-off (loses some interactive precision) is easy to mis-state.animationdefaults vary by chart type and ECharts version; disable explicitly for headless rendering (animation: false) rather than trusting a global config.
When you hit any of these, open the option reference, Cmd-F the field name, and verify against the v5 schema. Don't ship from memory.
Heatmap + visualMap (the "3D projected grid" pattern)¶
When you have a third data dimension that doesn't fit on a 2D line chart, the cheapest projection is a heatmap with visualMap: two categorical axes (e.g. time × band) and color encoding the third dimension's magnitude. It's the 2D shadow of a 3D landscape — the canonical spectrogram move — and ECharts ships it natively without echarts-gl.
Worked exemplar from psychodeli-audio-lab/studio: a (time × frequency-band × energy) heatmap rendered below the 1D timeline, sharing the time axis so the eye can correlate "where the centroid line spikes" with "which band is hot at that moment."
chart.setOption({
// … brand theme tokens …
xAxis: {
type: 'category', // category, not value — heatmap cells align to indices
data: timeLabels, // ['0.0', '0.2', '0.4', …]
name: 'sec', nameLocation: 'middle',
// Heuristic: 10–12 visible labels max. interval is a SKIP count
// (n-1), not a "1 in n" ratio. interval: 24 → labels at index 0, 25, 50…
axisLabel: { interval: Math.floor(timeLabels.length / 10) },
},
yAxis: {
type: 'category',
data: ['low', 'mid', 'high'], // first item renders at BOTTOM of axis
// (ECharts inverts y-category by convention —
// matches spectrogram intuition: low @ floor)
},
visualMap: {
min: 0, max: bandMax,
orient: 'vertical', right: 16, top: 'center', // vertical on the right
// reads cleaner than horizontal bottom
// for a wide heatmap
itemWidth: 14, itemHeight: 140,
calculable: true, // adds drag handles for range filter
inRange: { color: [BG, COOL, MID, WARM, HOT] }, // 4–5 color stops, low→high
outOfRange: { color: [BG] }, // pair with calculable so dragging dims outside
// GOTCHA: text is [upperLabel, lowerLabel] — FIRST string labels MAX value.
// Mnemonic: reads top-down on a vertical visualMap.
text: ['3.8', '0'],
},
series: [{
type: 'heatmap',
// Each cell is a [xIdx, yIdx, value] TRIPLE — not a structured object.
// xIdx/yIdx are integer indices into the category arrays.
data: [[0, 0, 1.2], [0, 1, 0.4], [0, 2, 0.1], [1, 0, 1.5], …],
progressive: 1000, // chunked render for >1000 cells
emphasis: { // hover state
itemStyle: { borderColor: BRAND.fg, borderWidth: 1 },
},
}],
});
The non-obvious bits:
-
visualMap.textis[upperLabel, lowerLabel]— first element labels the TOP of the vertical slider (the max value), second labels the BOTTOM (min). Mnemonic: it reads top-down. Counterintuitive enough that everyone gets it wrong the first time. Baretext: ['max', 'min']is the default-ish form; for numeric labels you typically want the actual numbers as strings. -
visualMap.inRange.coloris a list of color stops ECharts interpolates between — order goes low → high. 4–5 stops covers most thermal maps without banding. For a brand-themed thermal:[bg, cool-accent, brand-primary, warm-amber, hot-salmon]. -
visualMap.calculable: trueadds drag handles. Pair withoutOfRange.color: [bg]so dragging the handles visually filters the heatmap to the active range — incredibly useful for "show me only cells above 2.0" exploration. -
xAxis.axisLabel.intervalis a SKIP count, not a ratio.interval: 24produces labels at indices0, 25, 50, …— show one, skip 24. Easy to misread as "1 in 24" (it's 1 in 25). Common heuristic:Math.floor(arr.length / 10)gives roughly 10 visible labels. -
Category y-axis renders first-item-at-bottom by default (ECharts inverts y-categories). So
data: ['low', 'mid', 'high']putslowat the floor — perfect spectrogram convention. If you want top-down rendering (taxonomic lists, ranking), setyAxis.inverse: true. -
Heatmap data is
[xIdx, yIdx, value]triples, not objects. The tuple positions are coordinates → color-mapped value. Tooltip formatter receivesp.dataas the same triple — index into it:formatter: p => \${p.data[2].toFixed(2)}``. -
For sparse data (cells you don't want rendered), just omit them from the data array. ECharts doesn't render a cell for an absent (x, y) coordinate. No need for null fills.
-
progressive: Nchunks the render — useful when you have >1000 cells. For a 249 × 3 = 747 cell grid it's overkill but harmless. Above ~10k cells, drop the chunk size to 500 to avoid first-paint stutter. -
For TRUE 3D (perspective, rotatable, depth) — that's
echarts-gl, a separate package (echarts-gl@^2.0, ~600 KB minified). Series types:bar3D,scatter3D,surface,line3D. Reach for it when the magnitude axis needs to be VIEWED in 3D (rotation, parallax) rather than projected to color. For dashboard / report surfaces, the 2D heatmap projection is almost always the right call — color is read faster than depth, and you don't need to manage a camera.
Live-verification protocol¶
When wiring up an ECharts surface, before declaring it done:
- Open in a real Chrome tab. Not just a screenshot, not just devtools — a live tab where you can probe the chart instance.
- Probe the resolved option tree via
echarts.getInstanceByDom(el).getOption(). ECharts merges your input with defaults — what you see ingetOption()is what's actually painting. Check: opt.color— is the palette you intended? (If you didn't set it, it's the default 9 colors — caught the legend-marker bug this way.)opt.series[i].colorvsopt.series[i].lineStyle.color— series-root color drives legend.opt.yAxis[i].min/max— did yourmin: 0, max: Nsurvive merge, or did auto-scaling override?opt.series[i].markArea.data— is the two-pair shape preserved after merge?- Trigger the interactions you wired (audio seek, hover, legend click) and re-probe. State changes that should affect the option tree are easy to assert; state changes that don't are easy to miss.
- Test programmatic state changes, not just user-driven ones.
player.currentTime = Xfrom a JS console probes whether yourseekedhandler is wired (thetimeupdate-only bug doesn't show under "play and watch").
This caught two real bugs in 15 minutes on the studio page that wouldn't have shown in static screenshots: the legend color mismatch and the timeupdate-only playhead.
See also¶
channels/charts.md— the strict-mode Tufte enforcement layer with per-library anti-pattern tables. ECharts defaults violate ~half of that table; the cheat row there is the minimum override set, the section above is the dark-theme dashboard set.channels/interactive.md— single-file interactive demo scaffolds. ECharts loaded from CDN is a common substrate for marginalia demos with one chart.vocabularies/pixijs.md— when you need per-pixel control or particle counts beyond ECharts' batching (10k+ marks).