> ## Documentation Index
> Fetch the complete documentation index at: https://prism.ntecdev.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# ADR-37: Dashboard live-time-series charts use canvas (uPlot), not SVG

> Live time-series charts in the dashboard render to HTML5 canvas via uPlot, not SVG via Recharts. Drops the redraw-throttle layer; charts subscribe directly to data slices.

<Info>**Status:** `accepted` · **ADR-37** · Filed 2026-04-30</Info>

## Decision

Live time-series charts in the dashboard render to **HTML5 canvas via uPlot**, not to SVG via Recharts. A thin React wrapper (`UPlotChart.tsx`) creates one uPlot instance per mount and calls `setData(newData)` on every push. No throttling layer between the data store and the chart.

The React Flow topology graph stays on SVG (interactive, infrequent updates). Static / sparkline / one-shot charts may use either, but should default to uPlot when the chart re-renders more often than every few seconds.

## Rationale

Two pressures pushed us off Recharts:

1. **SVG path rebuild on every update is the primary flicker source.** Recharts re-renders the chart's full path tree from React props on each data change. With the dashboard pushing metrics every 10 s and probes every 15 s, the SVG-rebuild-per-tick produced visible flicker on the live cards. uPlot maintains the chart instance across renders and updates by writing pixels to the existing canvas — no DOM mutation, no path swap.

2. **The throttle layer added to mask SVG cost was load-bearing for a problem that should not exist.** The original implementation throttled redraws to every Nth push via a scalar (`chartTick`) that charts subscribed to instead of the data. Charts read `metricsHistory` via `getState()` only when the tick advanced. This coupling is fine when redraws are expensive; it is a leak when they are not. With uPlot the layer is unjustified, and removing it eliminated a pre-existing correctness bug in the throttle math (`(cur+1) % N === 0 ? cur+1 : cur` could not advance from `cur=0`).

Two reasons to prefer uPlot specifically over other canvas libraries:

* **Designed for streaming.** `setData(newData, false)` exists for deferred-redraw pipelining; the API is built around the live-append case rather than retrofitted.
* **\~45 KB raw / \~12 KB gzip.** Negligible bundle impact next to React + Vite's per-chunk floor.

## Consequences

**Positive:**

* No flicker on live updates. Confirmed: 7 puppeteer frames over 1.5 s show identical canvas dims and contents during the steady-state push cadence.
* Throttle scaffolding deleted: `chartTick`, `healthChartTick`, the subscribe-to-tick / `getState()`-data pattern, and `CHART_REDRAW_EVERY` constant are gone.
* Charts subscribe directly to their data slice (`metricsHistory`, `healthHistory`). Re-render cadence equals push cadence; uPlot redraw is cheap.
* Bundle: charts chunk \~110 KB gzip (down from \~120 with Recharts despite adding uPlot).

**Negative / accepted costs:**

* Custom tooltip plugin instead of Recharts' declarative `<Tooltip>`. Lives in `dashboard/web/src/components/charts/uplotShared.ts`. Cursor-driven HTML overlay; updates imperatively from `setCursor` hook to avoid React re-renders on hover.
* Stacked area requires manual cumulative pre-computation (uPlot does not stack natively). Encapsulated in `ThroughputChart.tsx`.
* Two layers (uPlot's intrinsic `width: min-content` outer + canvas inside) require a wrapper CSS rule (`.uplot-wrap > .uplot { position: absolute; inset: 0 }`) to keep the canvas anchored when uPlot adjusts internal layout.
* Dual-axis charts use uPlot's `scales: { left, right }` plus per-series `scale` assignment — slightly more wiring than Recharts' `<YAxis yAxisId="left" />`.

**Neutral:**

* StrictMode-safe. uPlot's `destroy()` calls `root.remove()` synchronously, so the dev-mode mount → cleanup → mount cycle leaves no DOM residue.

## Alternatives Considered

* **Keep Recharts, add a double-buffer canvas swap.** Rejected. Papers over the redraw cost rather than removing it; doubles the work; doesn't future-proof if we drop the throttle to true 10 s live.
* **Chart.js / ECharts.** Both work but are heavier (Chart.js \~50 KB gzip; ECharts \~150 KB gzip). uPlot is purpose-built for streaming time-series and is what Grafana / Netdata use for the same job.
* **Plain canvas with d3-scale.** More code than uPlot, no built-in cursor / legend / axis features. Worth considering only if the chart needs become exotic enough that uPlot's plugin API can't cover them.

## Implementation Notes

* Wrapper: `dashboard/web/src/components/charts/UPlotChart.tsx`. ResizeObserver gates `setSize` to only fire on actual dim changes. Mount-only effect with a stable `[]` dep array; series-structure changes are signalled by the parent bumping a `key` prop, which deterministically remounts.
* Shared: `dashboard/web/src/components/charts/uplotShared.ts`. Tooltip plugin (with `valueAt` hook for stacked charts that need to display un-cumulated values), default cursor, palette tokens.
* Charts: `ThroughputChart.tsx` (stacked area), `LatencyChart.tsx` (multi-line w/ `spanGaps: true`), `ErrorsChart.tsx` (overlay areas), `SaturationChart.tsx` (dual-axis area + dashed line).

## References

* `c8578a0 feat(dashboard): swap golden-signal charts to uPlot canvas`
* Retro: `context/retrospectives/006-retro-dashboard-uplot-port.md`
* Library: [https://github.com/leeoniya/uPlot](https://github.com/leeoniya/uPlot) (MIT, \~1.6.32)
