diff --git a/.gitignore b/.gitignore
index 67f06d140..39b772105 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,7 +7,9 @@ node_modules
dist
coverage
storybook-static
+.perf-results
/.local/
CLAUDE.md
.superpowers
docs/superpowers
+.parity-results
diff --git a/.storybook/preview.js b/.storybook/preview.js
index 54439f446..ac6063fc9 100644
--- a/.storybook/preview.js
+++ b/.storybook/preview.js
@@ -2,6 +2,7 @@ import React from "react"
import { ThemeProvider, StyleSheetManager } from "styled-components"
import isPropValid from "@emotion/is-prop-valid"
import { Flex, DefaultTheme, DarkTheme, GlobalStyles } from "@netdata/netdata-ui"
+import "uplot/dist/uPlot.min.css"
const shouldForwardProp = (propName, target) => {
if (typeof target === "string") {
diff --git a/docs/charting-library-exploration.md b/docs/charting-library-exploration.md
new file mode 100644
index 000000000..a9d2adab7
--- /dev/null
+++ b/docs/charting-library-exploration.md
@@ -0,0 +1,307 @@
+# Charting library exploration & migration reference
+
+> Status: exploration complete, decision made, lifecycle + coordinate-transform spike
+> implemented on branch `explore/uplot-spike` (uncommitted).
+> Date: 2026-07-14.
+> Purpose: durable record of *why* we are moving the time-series engine off dygraphs, *what*
+> the replacement must satisfy, and *how* the SDK abstraction makes it incremental. Read this
+> before re-investigating chart libraries — the comparison and sources are captured here so we
+> do not repeat the research.
+
+## 1. Goal
+
+`@netdata/charts` is the rendering SDK behind cloud-frontend. The SDK was designed so the
+rendering library is swappable per chart type (`chartLibrary` attribute). We want to know whether
+dygraphs is still the right time-series engine and, if not, what to replace it with — optimizing
+for **performance/scale, visual quality, new capabilities, and maintainability** (all four).
+
+**Decision (2026-07-14):** replace dygraphs with **uPlot** as the time-series engine, done
+**incrementally behind the existing abstraction** (register `uplot` as an additional
+`chartLibrary`, reach parity, flip the default, then remove dygraph). A later phase consolidates
+the non-line charts (d3pie / gauge / easyPie / bars) onto **Apache ECharts**. WASM/WebGL engines
+were evaluated and deferred — they are not justified at our current data scale.
+
+## 2. Why move off dygraphs
+
+- **Maintenance risk.** dygraphs is at `2.2.1` with no substantive activity since ~early 2023.
+ ([releases](https://github.com/danvk/dygraphs/releases))
+- **Deep coupling to dygraph internals.** Our integration reaches into *private* dygraph APIs
+ that break on any internal change: `renderGraph_`, `canvas_ctx_`, `hidden_ctx_`, `canvas_`,
+ `layout_.setNames`, `axes_[0].valueRange`, `dateWindow_`, `Dygraph.defaultInteractionModel`,
+ `Dygraph.startPan/movePan/endPan`, `Dygraph.startZoom`, `clearZoomRect_`, plus deep source
+ imports (`dygraphs/src/datahandler/default`, `dygraphs/src/dygraph-utils`,
+ `dygraphs/src/extras/smooth-plotter`). See §4.
+- **Performance & size (upstream published numbers — NOT measured on Netdata).** In uPlot's own
+ published benchmark, uPlot rendered in **~34 ms / 21 MB heap** vs dygraphs **~90 ms / 88 MB**,
+ at **~48 KB vs ~132 KB** bundle. ([uPlot](https://github.com/leeoniya/uPlot)) We have **no
+ local Netdata measurements yet**; §7 Phase A requires measuring real dashboard CPU / memory /
+ frame time / bundle delta before flipping the default.
+- **Precedent.** Grafana — the closest analog (dashboards with many time-series panels) —
+ migrated off its old canvas library to uPlot in 7.4 and reported panels rendering **2–3×
+ faster**; it now backs their Time series, Stat, Timeline, Histogram and Bar chart panels.
+ ([Grafana blog](https://grafana.com/blog/how-the-new-time-series-panel-brings-major-performance-improvements-and-new-visualization-features-to-grafana-7-4/))
+
+## 3. The SDK ↔ chart-library contract
+
+A chart library is a factory `makeChartLibrary = (sdk, node) => instance`, registered under
+`sdk.ui[name]` (`src/makeDefaultSDK.js`) and invoked at `src/sdk/index.js:52` and
+`src/sdk/makeChart/index.js:310`. It builds on the shared base `makeChartUI(sdk, chart)`
+(`src/sdk/makeChartUI.js:38-49`) and spreads it.
+
+**Key architectural fact:** the interaction plugins in `src/sdk/plugins/` operate on the *core
+node* (`chart.*`) and the *sdk event bus*, **not** on the rendering library. So the swappable
+surface is small and well-defined:
+
+### (a) Mandatory lifecycle methods the instance must expose
+| Method | Caller | Obligation |
+|---|---|---|
+| `mount(element)` | `src/components/chartContainer.js:10` | attach renderer; call `chartUI.mount(el)` |
+| `unmount()` | `chartContainer.js:11`, `makeChart/index.js:399` | tear down; call `chartUI.unmount()` |
+| `render()` | core render loop, `makeChart/index.js:138` | redraw from payload; fire `chartUI.trigger("rendered")` |
+| `getRenderedAt()` | `plugins/hover.js:26` | ms timestamp of last render's right edge (base provides) |
+| `getChartWidth()` / `getChartHeight()` | provider selectors, api width calc (`makeChart/api/helpers.js:48`) | drawable px |
+| `getElement()` | base utility | mounted DOM node |
+
+### (b) Events the library must EMIT on the sdk bus (payloads are load-bearing)
+| Event | Payload | Consumed by |
+|---|---|---|
+| `panStart` / `panEnd` | `(chart)` / `(chart, [afterMs, beforeMs])` | `plugins/pan.js` → `chart.moveX` |
+| `highlightStart` / `highlightEnd` | `(chart)` / `(chart, [after,before]s\|null)` | `plugins/highlight.js`, `plugins/select.js` → `chart.moveX` |
+| `highlightVerticalStart` / `highlightVerticalEnd` | `(chart)` / `(chart, [min,max]\|null)` | `plugins/selectVertical.js` → `chart.moveY` |
+| `highlightHover` | `(chart, xMs, dimensionId)` | `plugins/hover.js:3` → sets `hoverX` on syncHover nodes |
+| `highlightBlur` | `(chart)` | `plugins/hover.js:8` → clears `hoverX` |
+| `hoverChart` / `blurChart` | `(chart)` | `plugins/hover.js:13/32` → `hovering` state for autofetch/play |
+| `annotationCreate` | `(chart, xSeconds)` | consuming app / overlays |
+
+Navigation methods the library just *calls* (already implemented by core): `chart.moveX`,
+`chart.moveY`, `chart.updateStaticValueRange`, `chart.resetNavigation`, `chart.zoomIn/Out/X`
+(`src/sdk/makeNode.js:127-169`).
+
+### (c) Attribute reactions the library must implement (`chart.onAttributeChange`)
+`hoverX`, `clickX` (synced crosshair — the hardest cross-chart coupling), `enabledHover`,
+`enabledNavigation`, `navigation`, `overlays`, `draftAnnotation`, `theme`, `chartType`,
+`unitsConversionPrefix`, `selectedLegendDimensions`, `staticValueRange`, `timezone`. Reference
+implementation: `src/chartLibraries/dygraph/index.js:142-208`. Plus a ResizeObserver → resize.
+
+### (d) Data/state the core PROVIDES (library consumes — free)
+`chart.getPayload()` → `{ data, labels, all, point, byDimension, tree }`; `getDateWindow()`;
+`getAttribute(s)`; dimension helpers (`getPayloadDimensionIds`, `getVisibleDimensionIds`,
+`isDimensionVisible`, `selectDimensionColor`, `getDimensionUnit`, `getVisibleHeatmapIds`,
+`getHeatmapScale`); formatting (`getConvertedValueWithUnit`, `getConvertedValue`,
+`getUnitAttributesForValue`, `formatXAxis`, `getThemeAttribute`); `getClosestRow(ms)`.
+
+## 4. Dygraph coupling catalog (what must be re-expressed)
+
+The custom work is bolted onto dygraph's canvas + coordinate transforms. Grouped by portability:
+
+- **Portable logic (reuse as-is):** color math (`plotters/helpers.js`), tick generation core
+ (`@/helpers/ticks`), stacked-area point decimation, diverging-stack accumulation math, the
+ `overlayedAreaChanged` event contract, the `chartUI` event-bus indirection.
+- **Must re-implement against the new library's canvas/coords API:**
+ - 7 custom plotters — `plotters/{stackedArea,stackedBar,multiColumnBar,heatmap,anomaly,annotations,linePlotter}.js`
+ - 2 axis tickers — `tickers/{numeric,heatmap}.js`
+ - the diverging-stack data handler — `divergingStack.js` (subclasses dygraph `DefaultHandler`)
+ - 7 overlays + crosshair — `overlays/{alarm,alarmRange,alertTransitions,highlight,annotation,point,proceeded}.js`, `crosshair.js` (all draw into dygraph canvas contexts via `toDomXCoord`)
+ - hover hit-testing — `hoverX.js`
+ - 4 navigation modes — `navigation/{generic,pan,select,selectVertical}.js`
+- **Two hardest friction points:**
+ 1. the `hoverX`/`clickX` synced-crosshair-by-timestamp mechanism;
+ 2. the annotation overlay hard-calls `chartUI.getDygraph()` and dygraph coordinate transforms
+ (`src/components/line/overlays/annotation/index.js:419`) — needs a coordinate-transform
+ shim or refactor, not a rewrite.
+
+## 5. Domain features any time-series engine must express
+
+Verified in code — the "beyond a line" requirements:
+
+1. **Multi-value points per cell.** Raw cell = `[value, arp, pa]`; `point` maps valueKey→index
+ (`src/sdk/makeChart/getPointValue.js`). After `transformResult` the consumed
+ `payload.data` row is `[ts_ms, value₁…valueₙ, null(ANOMALY_RATE), null(ANNOTATIONS)]` and
+ `payload.labels` carries the two synthetic trailing columns
+ (`src/sdk/makeChart/camelizePayload.js:7-65`). Observed valueKeys: `value`, `arp`, `pa`,
+ `avg/min/max/sum/count/volume/percent`.
+2. **Per-dimension palette colors** with keyed/positional overrides, theme-aware
+ (`makeDimensions.js:312-338`).
+3. **Anomaly ribbon** — top strip, aggregated max `arp` scaled to color
+ (`plotters/anomaly.js`).
+4. **Data-quality annotation strip** — bottom strip driven by the `pa` bitmask
+ (`helpers/annotations`, `plotters/annotations.js`).
+5. **Alert overlays** — vertical markers, shaded ranges, state-transition region fills
+ (warning/critical/clear) + user annotations (`overlays/`).
+6. **Render modes** — line, area, stacked, diverging-stacked, bar, multi-bar, heatmap.
+7. **Grouping & aggregation** — groupBy node/instance/dimension/label/percentage-of-instance;
+ incremental/cumulative dimensions; sum/avg/min/max (`filters/`).
+8. **Time-range highlight/selection** that feeds back into fetch (`chart.moveX`).
+
+**Gaps worth noting:** there is **no rendered min/max shaded band** today (the per-point
+min/max/avg data exists but is not drawn) — an easy new-capability win in uPlot. All
+anomaly/annotation/alert overlays currently live **only in the dygraph library**, so parity work
+must re-express them for uPlot.
+
+## 6. Library landscape (verified 2026-07-14)
+
+| Library | Tech | Bundle | Perf (published) | Maintained | Role |
+|---|---|---|---|---|---|
+| dygraphs (current) | Canvas 2D | ~132 KB | 90 ms / 88 MB | ⚠️ ~2023 | incumbent — replace |
+| **uPlot** ✅ | Canvas 2D | ~48 KB | 34 ms / 21 MB | ✅ v1.6.32 (2026) | **time-series engine** |
+| Apache ECharts | Canvas/SVG/WebGL | ~100 KB gz tree-shaken | progressive ~10M pts | ✅ active | phase 2: pie/gauge/bars/heatmap |
+| Perspective (FINOS) | **WASM** + Arrow (Rust/C++) | heavy (WASM) | millions, streaming | ✅ active | only large analytical grids/streaming |
+| TimeChart / webgl-plot | WebGL | small | millions @ 60fps | moderate | only if uPlot hits a ceiling |
+| SciChart / LightningChart | WebGL (commercial) | — | millions @ 60fps | ✅ commercial | extreme scale, paid license |
+
+**Why not WASM/WebGL now:** per uPlot's author and the benchmarks, WebGL/WASM carry higher
+startup cost and code size and only pay off past canvas's ceiling. Netdata's per-chart point
+count is bounded by pixel width (`src/sdk/makeChart/api/helpers.js:46` derives `points` from
+chart width), so we are not rendering millions of raw points per chart. Canvas (uPlot) is the
+correct tier; WASM/WebGL remains a targeted option for a specific heavy view later.
+
+**uPlot capability check (verified against uPlot docs):** hooks + plugin system (the
+`underlayCallback` equivalent), pluggable path renderers (linear/spline/stepped/bars), per-series
+stroke/fill + show/hide, custom axes (`splits`/`values`), cursor sync across charts, zoom with
+auto-rescale, live streaming via `setData`. Pan is a plugin (matches how we already treat pan as
+a plugin). Its opinionated omission of *built-in* stacked series does not block us — we already
+compute stacks ourselves (`divergingStack.js`) and feed pre-stacked series.
+
+**Overlay foundation verified (from `node_modules/uplot/dist/uPlot.d.ts`, and exercised in the
+spike):** `valToPos(val, scaleKey, canvasPixels?)` and `posToVal(leftTop, scaleKey,
+canvasPixels?)` for data↔pixel transforms; hooks `init/setScale/setCursor/setSelect/drawClear/
+drawAxes/drawSeries/draw/ready`; instance props `root`, `ctx` (2D context), `bbox`, `over`,
+`under`; `redraw(rebuildPaths?, recalcAxes?)`, `setCursor({left,top}, fireHook?)`,
+`setSelect({left,top,width,height}, fireHook?)`. This is everything the dygraph overlays/crosshair
+use `toDomXCoord`/`hidden_ctx_`/`getArea` for today — so all §4 overlays port onto uPlot's
+`draw` hook + `valToPos` + `ctx`. The spike exercises this path with a synced crosshair; the
+`draw` hook + `valToPos` + `ctx` wiring runs without error and hover emission is asserted by an
+automated test. **Pixel-coordinate correctness and cross-chart hover sync are validated manually
+in Storybook, not yet by automated assertions.**
+
+## 7. Phased plan
+
+### Phase 0 — decouple renderer identity from chart presentation (prerequisite)
+
+Today the time-series renderer is welded to the chart *type*, so uPlot cannot be selected through
+the normal app flow, and forcing `chartLibrary:"uplot"` breaks the toolbox UI. Three sites:
+
+- `src/sdk/makeChart/filters/makeControllers.js:111-131` — `updateChartTypeAttribute` hardcodes
+ `chartLibrary:"dygraph"` for every non-library chart *type* (line/area/stacked/heatmap).
+- `src/components/toolbox/chartType.js:131-136` — `value = chartLibrary === "dygraph" ? chartType
+ : chartLibrary`; then `const {label,svg} = items.find(v => v===value)` **throws** when
+ `chartLibrary` is anything other than a known menu entry (e.g. `uplot`).
+- `src/components/toolbox/settings/tabs/chartType.js:140-148` — same `=== "dygraph"` assumption;
+ does not throw (`options.find(...) || options[0]` fallback) but selects the wrong option.
+
+**Design (finalized in `docs/uplot-migration-design.md`):** a **per-chart-type renderer map**
+(`chartLibrariesByType`) resolves each dygraph-backed chart type (line/stacked/area/stackedBar/
+multiBar/heatmap) to a configurable renderer, enabling incremental migration (flip `line → uplot`
+while `heatmap` stays on dygraph):
+
+- SDK default attribute `chartLibrariesByType` (chartType → renderer), all `"dygraph"` by default.
+- Chart helpers `getRendererForChartType(chartType)` and `isTimeSeriesRenderer(chartLibrary)`
+ (added to `makeControllers`, spread onto the node) — the two toolbox components use
+ `isTimeSeriesRenderer` (not a literal `=== "dygraph"`) to decide whether to show the chart type
+ or the library, and guard their `find` lookups so an unmapped value never throws.
+- `updateChartTypeAttribute` resolves the renderer from the map; **initial** chart creation must
+ also resolve it (so a configured `line → uplot` applies on first render, not only after a
+ user toggles type) — see the Phase 0 plan.
+
+This is real product/UX code (not spike code) and is a **prerequisite** for a safe in-app opt-in
+and for the full-wrapper Storybook stories in §7 testing plan. Designed in
+`docs/uplot-migration-design.md`; detailed TDD plan in `docs/uplot-phase0-plan.md`.
+
+### Phase A — uPlot time-series engine
+
+Port the line/area/stacked/heatmap rendering, overlays, tickers, navigation, and hover behind the
+§3 contract; measure real dashboard perf + bundle delta; reach parity; flip the `line` default to
+uPlot via the Phase 0 map; remove dygraph. Parity backlog = the "must re-implement" list in §4,
+the domain features in §5, and the deferred rows in the §8 contract matrix.
+
+### Phase B — ECharts consolidation (later)
+
+Replace d3pie + gauge + easyPie + bars with ECharts equivalents for one modern, consistently-styled
+system; retire `d3pie`, `easy-pie-chart`, and the custom gauge lib.
+
+### Testing plan — Storybook (how we validate each step)
+
+uPlot is **not** in the default SDK `ui` map (see §8), so it is not bundled for normal consumers.
+Stories register it explicitly on a per-story SDK and drive it through `chartLibrary:"uplot"`:
+
+```js
+import makeDefaultSDK from "@/makeDefaultSDK"
+import uplot from "@/chartLibraries/uplot"
+
+const sdk = makeDefaultSDK()
+sdk.addUI("uplot", uplot) // register only for this story
+const chart = sdk.makeChart({ getChart, attributes: { chartLibrary: "uplot", /* ... */ } })
+sdk.appendChild(chart)
+```
+
+- **Now (bare container):** stories wrap a `withChart(() => )` so we can render
+ uPlot without the toolbox chrome (which is dygraph-coupled until Phase 0). Import
+ `uplot/dist/uPlot.min.css` in the story for cursor/axis DOM styling.
+- **After Phase 0 (full wrapper):** add stories using the normal chart wrapper (header/legend/
+ toolbox) to catch real application-integration issues.
+- **Scenario checklist to cover as parity lands:** line, area, stacked, heatmap; dark mode;
+ resize; **two synchronized charts** (hover/crosshair sync via `syncHover`); empty → data and
+ data → empty transitions; live-tail updates; unit/timezone changes; static value range;
+ virtualization remounts (mount → unmount → mount). Each becomes a story and, where the harness
+ allows, a `makeTestChart` assertion.
+
+## 8. Spike — lifecycle + coordinate-transform validation (branch `explore/uplot-spike`, uncommitted)
+
+This is a **spike**, not a contract-complete adapter. Its goal was to de-risk two things: that a
+uPlot library satisfies the SDK lifecycle, and that the canvas coordinate/draw path can express
+our overlays. It does **not** implement the full §3 contract.
+
+Files:
+
+- `src/chartLibraries/uplot/index.js` — `mount`/`unmount`/`render`/`getUPlot`; transposes
+ `payload.data` (ms → uPlot seconds) into columnar series; per-dimension colors; theme-aware axes
+ (`themeGridColor`/`themeLabelColor`); unit-formatted y ticks (`getConvertedValueWithUnit`);
+ ResizeObserver → `setSize`; emits `highlightHover`/`highlightBlur`/`hoverChart`/`blurChart` from
+ the `setCursor` hook; draws a `hoverX`/`clickX` crosshair via the `draw` hook + `valToPos` +
+ `ctx`.
+- **Not** in the default SDK `ui` map — registered per-story/test via `sdk.addUI("uplot", uplot)`
+ so it is not bundled for normal consumers (see §7 testing plan). uPlot CSS is imported only in
+ the story.
+- `uplot.stories.js` — bare-container stories (Line / Area / DarkMode) under **Charts/uPlot
+ (spike)**; `yarn storybook` to view.
+- `index.test.js` — real-uPlot tests (no lib mocking; jsdom + `jest-canvas-mock`). Chart data
+ accessors are stubbed via a `withLoadedPayload` helper rather than driven through the real
+ fetch flow — Phase A should replace this with a real `makeTestChart` payload/fetch. Added a
+ `window.matchMedia` shim to `jest/setup.js` (uPlot reads it at load).
+
+### Implemented / deferred contract matrix
+
+| Contract area | Status in spike | Notes |
+|---|---|---|
+| `mount` / `unmount` / `render` lifecycle | ✅ implemented | verified by tests |
+| line / area rendering, per-dimension colors | ✅ implemented | line/area only |
+| theme grid/label colors, resize → `setSize` | ✅ implemented | rebuild on theme/chartType/legend change |
+| y-axis unit formatting (+ `unitsConversionPrefix` reaction) | ✅ implemented | `getConvertedValueWithUnit`; redraws on prefix change |
+| emit `highlightHover`/`highlightBlur`/`hoverChart`/`blurChart` | ✅ implemented | **emission asserted by automated test** |
+| `hoverX`/`clickX` crosshair reception (draw hook + `valToPos` + `ctx`) | ⚠️ implemented, **pixel correctness Storybook-only** | not asserted at pixel level in tests |
+| dimension **count** change (schema) | ⚠️ partial | rebuilds on series-length mismatch; reorder/recolor not handled |
+| x-axis date-window / range | ✅ implemented | `scales.x.range` from `getDateWindow` |
+| y-range: `staticValueRange` / `getValueRange` | ✅ implemented | `scales.y.range` honors `getValueRange`; redraws on `staticValueRange` change |
+| empty / `outOfLimits` states + transitions | ✅ implemented | `getData` returns null on `outOfLimits`/empty; `render` destroys stale chart |
+| `enabledHover` toggle | ✅ implemented | `setCursor` skips emit when disabled |
+| `timezone` reaction | ✅ implemented | redraws (re-runs axis formatter) on change |
+| `formatXAxis` integration | ✅ implemented | x-axis `values` use `chart.formatXAxis` (timezone-aware) |
+| drawable-area dims (`getChartWidth/Height` = plot area) | ✅ implemented | returns `u.over` client size when mounted |
+| sparkline mode | ✅ implemented | axes hidden via `isSparkline()` |
+| processing guard | ✅ implemented | `render` skips while `highlighting`/`panning`/`processing` |
+| stacked / diverging / bar / multi-bar / heatmap plotters | ❌ deferred | |
+| anomaly ribbon, annotation strip | ❌ deferred | |
+| alert overlays (alarm / alarmRange / alertTransitions / highlight) | ❌ deferred | same `draw`-hook pattern as crosshair |
+| pan / zoom / select navigation | ✅ implemented | drag-select (select/highlight) + selectVertical via `setSelect`; custom pan; wheel zoom; dblclick → `resetNavigation`; mode from `navigation`, gated by `enabledNavigation` |
+| required uPlot CSS (`uplot/dist/uPlot.min.css`) | ❌ deferred (Phase A) | **functional** (drives `.uplot` layout + cursor positioning), not cosmetic; story-only import today |
+| in-app UI integration (toolbox chart-type) | ❌ blocked on Phase 0 | see §7 |
+
+## 9. Sources
+
+- uPlot — https://github.com/leeoniya/uPlot
+- dygraphs releases — https://github.com/danvk/dygraphs/releases
+- Grafana time-series panel (Flot → uPlot) — https://grafana.com/blog/how-the-new-time-series-panel-brings-major-performance-improvements-and-new-visualization-features-to-grafana-7-4/
+- Apache ECharts features — https://echarts.apache.org/en/feature.html
+- Perspective (FINOS) — https://perspective.finos.org/
+- TimeChart (WebGL) — https://github.com/huww98/TimeChart
+- SciChart JS benchmark — https://www.scichart.com/blog/chart-bench-compare-javascript-chart-libraries/
diff --git a/docs/uplot-g1-overlay-foundation-design.md b/docs/uplot-g1-overlay-foundation-design.md
new file mode 100644
index 000000000..c582dcb64
--- /dev/null
+++ b/docs/uplot-g1-overlay-foundation-design.md
@@ -0,0 +1,97 @@
+# G1 — uPlot overlay positioning foundation — design
+
+> Branch `explore/uplot-spike`. First sub-project of the parity effort
+> (`docs/uplot-parity-gap-map.md`, gap G1). Enabler only — draws no overlays.
+
+## Purpose
+
+Give uPlot the renderer-neutral coordinate primitives the overlay/positioning layer depends on, and
+fix the one place that silently breaks under uPlot today (`AlertTimeline` misalignment). This
+unblocks G2 (alert overlays) and G3 (anomaly/annotation) without drawing anything itself.
+
+## Verified mechanism (what this stands on)
+
+- React overlay badges are positioned by `src/components/line/overlays/container.js:66-72`, which
+ listens for `chartUI.on("overlayedAreaChanged:", area => …)` and positions from
+ `area = { from, to, width }` (DOM px). With no emitter, `container.js:77`
+ (`if (!area && !fixed) return null`) renders nothing — why alarm/alarmRange/highlight/proceeded
+ badges are invisible on uPlot.
+- That area is computed per-overlay from a time **range** by `getArea(dygraph, range)`
+ (`src/chartLibraries/dygraph/overlays/helpers.js:1-20`) using `dygraph.xAxisRange()` +
+ `dygraph.toDomXCoord()`, then emitted via `trigger(chartUI, id, area)` inside `requestAnimationFrame`.
+- `usePlotArea` (`src/components/provider/selectors.js:683-690`) separately needs the whole plot
+ rect: `chart.getUI(uiName)?.getDygraph?.()?.getArea()` → `{ left: area.x, width: area.w }`. On
+ uPlot `getDygraph` is undefined → `{ left: 0, width: 0 }` → `AlertTimeline` (`index.js:183`)
+ misaligns silently.
+
+Both are bound to dygraph APIs (`toDomXCoord`, `getArea`). G1 replaces those with a neutral contract.
+
+## Components
+
+### 1. Neutral coordinate primitives on the chartUI contract
+Add to BOTH renderers' chartUI instances:
+
+- **`getPlotArea()` → `{ left, top, width, height }`** (DOM px, plotting-area rect).
+ - dygraph (`src/chartLibraries/dygraph/index.js`, where `getDygraph` is defined): wrap
+ `getDygraph().getArea()` → `{ left: x, top: y, width: w, height: h }`.
+ - uPlot (`src/chartLibraries/uplot/index.js` instance at `:505-516`): derive from the live `u`
+ instance's plotting box (`u.bbox` in canvas px ÷ `devicePixelRatio`, or `u.over` offsets),
+ relative to the chart container so it matches the frame `container.js`/`usePlotArea` expect.
+ Returns a zero rect when `u` is not yet created (unmounted).
+- **`getXCoord(timestampMs)` → DOM px x** in the same reference frame `getArea` uses today.
+ - dygraph: `getDygraph().toDomXCoord(timestampMs)`.
+ - uPlot: `u.valToPos(timestampMs / 1000, "x")` adjusted to the same frame as dygraph's
+ `toDomXCoord` (uPlot x-scale is in seconds; the SDK stores ms).
+
+### 2. Shared `getArea(chartUI, range)` helper
+Move the range→`{ from, to, width }` logic out of `dygraph/overlays/helpers.js` into a
+renderer-neutral module (e.g. `src/chartLibraries/helpers/overlayArea.js`) that computes from
+`chartUI.getPlotArea()` + `chartUI.getXCoord(...)` instead of `dygraph.xAxisRange()`/`toDomXCoord()`.
+dygraph's `overlays/helpers.js` re-exports / delegates to it so existing dygraph overlays behave
+identically (no visual change, verified by their existing tests/stories). The neutral helper is what
+G2 will call to emit `overlayedAreaChanged:` from uPlot.
+
+### 3. Fix `usePlotArea`
+Rewrite `src/components/provider/selectors.js:683-690` to read `chart.getUI(uiName)?.getPlotArea?.()`
+and map to `{ left, width }` (keeping the existing `rendered`/`resize` re-render subscription). Both
+renderers now supply a real rect → `AlertTimeline` aligns under uPlot.
+
+## Interfaces (contracts later tasks rely on)
+
+- `chartUI.getPlotArea(): { left, top, width, height }` — DOM px; zero rect when unmounted.
+- `chartUI.getXCoord(timestampMs: number): number` — DOM px x, same frame as dygraph today.
+- `getArea(chartUI, range: [afterSec, beforeSec]): { from, to, width } | null` — null when the range
+ is fully outside the visible window (preserves current dygraph behavior at `helpers.js:11`).
+
+## Non-goals (G1)
+
+- No drawing or emitting of any alarm/alarmRange/alertTransitions/highlight/annotation/anomaly
+ overlay on uPlot — that is G2/G3, built on these primitives.
+- No change to dygraph overlay *behavior* — only the internal refactor to the shared helper.
+
+## Testing
+
+- `getArea(chartUI, range)` math: with a stub chartUI exposing `getPlotArea`/`getXCoord`, assert
+ `{from,to,width}` and the null-when-outside case. Real logic, no mocks of app components.
+- dygraph `getPlotArea`/`getXCoord`: via real `makeTestChart` (dygraph default) — assert shape and
+ delegation.
+- `usePlotArea`: with `renderWithChart`, assert it reads `getPlotArea()` and returns `{left,width}`.
+- uPlot `getPlotArea`/`getXCoord`: jsdom cannot paint, so `u.bbox`/`valToPos` yield no real layout —
+ cover the unmounted zero-rect path in jest, and verify real positioning visually in Storybook
+ (a uPlot chart with a highlight selection shows the badge in the right place; `AlertTimeline`
+ aligns).
+
+## Verified anchors
+
+- Badge positioning consumer: `src/components/line/overlays/container.js:66-72,77`.
+- Per-overlay area + emit: `src/chartLibraries/dygraph/overlays/helpers.js:1-23`.
+- Whole-plot accessor to fix: `src/components/provider/selectors.js:683-690`;
+ consumer `src/components/alertTimeline/index.js:183`.
+- uPlot chartUI instance to extend: `src/chartLibraries/uplot/index.js:505-516`
+ (`getChartWidth`/`getChartHeight` already derive from `u.over`).
+- dygraph `getDygraph`/`getArea` surface: `src/chartLibraries/dygraph/index.js` (getDygraph defined).
+
+## Coordination
+
+Implementation touches `src/chartLibraries/uplot/index.js`, which the frontend-design subagent is
+currently editing. G1 implementation must land **after** that work and rebase on it.
diff --git a/docs/uplot-g1-overlay-foundation-plan.md b/docs/uplot-g1-overlay-foundation-plan.md
new file mode 100644
index 000000000..371344bbf
--- /dev/null
+++ b/docs/uplot-g1-overlay-foundation-plan.md
@@ -0,0 +1,272 @@
+# G1 — uPlot Overlay Positioning Foundation — Implementation Plan
+
+> Executed via superpowers:subagent-driven-development. Spec: `docs/uplot-g1-overlay-foundation-design.md`.
+
+**Goal:** Give both renderers renderer-neutral coordinate primitives (`getPlotArea`, `getXCoord`, `getXAxisRange`) and a shared `getArea(range)` helper, and make `usePlotArea` renderer-agnostic — so React overlays and `AlertTimeline` position correctly under uPlot. Draws no overlays.
+
+**Architecture:** Add `getPlotArea`/`getXCoord` to each renderer's chartUI instance (dygraph already has `getXAxisRange`). Move dygraph's `getArea(dygraph, range)` to a neutral `getArea(chartUI, range)` built on those primitives; dygraph's overlay helper delegates to it unchanged. Rewrite `usePlotArea` to read `getPlotArea()`.
+
+**Tech Stack:** JavaScript (no TS), React 19 (JSX files MUST `import React`), Jest + jsdom, `@jest/testUtilities`.
+
+## Global Constraints
+- No semicolons; double quotes; 2-space indent; 100-char width; ES5 trailing commas; arrow functions. Imports at top. No inline/description comments. NEVER mock. JSX files `import React`. Don't touch `numberFormat.js`. Test: `yarn jest --config ./jest/config.js --collectCoverage=false`.
+- **Acceptance = dygraph parity:** matching dygraph's existing behavior/contract is the target for every ambiguous choice.
+
+## Verified facts
+- `chart.getDateWindow()` → `[afterMs, beforeMs]` (`src/sdk/makeChart/index.js:90-95`).
+- `dygraph.getArea()` → `{x,y,w,h}`; `usePlotArea` maps `x→left,w→width` (`selectors.js:686-687`).
+- dygraph chartUI already returns `getXAxisRange = () => dygraph?.xAxisRange()` (`dygraph/index.js:515,524`) and `getDygraph` (`:476,525`).
+- Current per-overlay area: `getArea(dygraph, range)` + `trigger` (`dygraph/overlays/helpers.js:1-23`); range is `[afterSec, beforeSec]`.
+- uPlot chartUI instance object: `src/chartLibraries/uplot/index.js:505-516`.
+
+---
+
+### Task G1-T1: Neutral `getArea` helper + dygraph coordinate primitives (conflict-free)
+
+**Files:**
+- Create: `src/chartLibraries/helpers/overlayArea.js`
+- Create test: `src/chartLibraries/helpers/overlayArea.test.js`
+- Modify: `src/chartLibraries/dygraph/index.js` (add `getPlotArea`, `getXCoord` to the instance)
+- Modify: `src/chartLibraries/dygraph/overlays/helpers.js` (delegate `getArea` to the neutral helper)
+
+**Interfaces produced:**
+- `getArea(chartUI, range): { from, to, width } | null` — `range` is `[afterSec, beforeSec]`; null when fully outside the visible window (parity with current behavior).
+- dygraph `chartUI.getPlotArea(): { left, top, width, height }` and `chartUI.getXCoord(tsMs): number`.
+
+- [ ] **Step 1: Write the failing test** — `src/chartLibraries/helpers/overlayArea.test.js`
+
+```js
+import { getArea } from "./overlayArea"
+
+const stubChartUI = ({ windowMs, coordOf }) => ({
+ getXAxisRange: () => windowMs,
+ getXCoord: tsMs => coordOf(tsMs),
+})
+
+describe("overlayArea getArea", () => {
+ it("maps an in-window range to from/to/width via getXCoord", () => {
+ const chartUI = stubChartUI({
+ windowMs: [1000000, 2000000],
+ coordOf: tsMs => (tsMs - 1000000) / 1000,
+ })
+
+ const area = getArea(chartUI, [1200, 1800])
+
+ expect(area).toEqual({ from: 200, to: 800, width: 600 })
+ })
+
+ it("clamps a range that overhangs the window to the window edges", () => {
+ const chartUI = stubChartUI({
+ windowMs: [1000000, 2000000],
+ coordOf: tsMs => (tsMs - 1000000) / 1000,
+ })
+
+ const area = getArea(chartUI, [500, 1800])
+
+ expect(area).toEqual({ from: 0, to: 800, width: 800 })
+ })
+
+ it("returns null when the range is entirely outside the window", () => {
+ const chartUI = stubChartUI({
+ windowMs: [1000000, 2000000],
+ coordOf: tsMs => tsMs,
+ })
+
+ expect(getArea(chartUI, [10, 20])).toBeNull()
+ })
+})
+```
+
+- [ ] **Step 2: Run it, verify RED** — `yarn jest --config ./jest/config.js src/chartLibraries/helpers/overlayArea.test.js --collectCoverage=false` → FAIL (cannot resolve `./overlayArea`).
+
+- [ ] **Step 3: Create the neutral helper** — `src/chartLibraries/helpers/overlayArea.js`
+
+```js
+export const getArea = (chartUI, range) => {
+ const [afterMs, beforeMs] = chartUI.getXAxisRange() || []
+ if (afterMs == null || beforeMs == null) return null
+
+ const [hAfter, hBefore] = range
+ const hAfterMs = hAfter * 1000
+ const hBeforeMs = hBefore * 1000
+
+ if (hBeforeMs < afterMs || hAfterMs > beforeMs) return null
+
+ const from = chartUI.getXCoord(Math.max(afterMs, hAfterMs))
+ const to = chartUI.getXCoord(Math.min(beforeMs, hBeforeMs))
+
+ return { from, to, width: to - from }
+}
+```
+
+- [ ] **Step 4: Add dygraph primitives** — in `src/chartLibraries/dygraph/index.js`, alongside `getChartWidth`/`getChartHeight`/`getXAxisRange` (near line 513-515):
+
+```js
+ const getPlotArea = () => {
+ const area = dygraph?.getArea()
+ return area
+ ? { left: area.x, top: area.y, width: area.w, height: area.h }
+ : { left: 0, top: 0, width: 0, height: 0 }
+ }
+
+ const getXCoord = timestampMs => (dygraph ? dygraph.toDomXCoord(timestampMs) : 0)
+```
+
+Add `getPlotArea` and `getXCoord` to the returned `instance` object (the `{ ...chartUI, getChartWidth, ... }` list around line 518-527).
+
+- [ ] **Step 5: Delegate dygraph overlay helper** — in `src/chartLibraries/dygraph/overlays/helpers.js`, replace the body of `getArea` so it delegates (keep the `export const trigger = ...` unchanged). The overlay files call `getArea(dygraph, range)` — preserve that call signature by adapting to the chart's UI:
+
+```js
+import { getArea as getNeutralArea } from "@/chartLibraries/helpers/overlayArea"
+
+export const getArea = (dygraph, range) =>
+ getNeutralArea(
+ { getXAxisRange: () => dygraph.xAxisRange(), getXCoord: tsMs => dygraph.toDomXCoord(tsMs) },
+ range
+ )
+
+export const trigger = (chartUI, id, area) =>
+ requestAnimationFrame(() => chartUI.trigger(`overlayedAreaChanged:${id}`, area))
+```
+
+- [ ] **Step 6: Add a dygraph-primitive test** — append to a new `src/chartLibraries/dygraph/coords.test.js`:
+
+```js
+import { makeTestChart } from "@jest/testUtilities"
+
+describe("dygraph coordinate primitives", () => {
+ it("exposes getPlotArea and getXCoord on the chartUI instance", () => {
+ const { chart } = makeTestChart({ attributes: { chartLibrary: "dygraph" } })
+ const ui = chart.getUI()
+
+ expect(typeof ui.getPlotArea).toBe("function")
+ expect(typeof ui.getXCoord).toBe("function")
+
+ const area = ui.getPlotArea()
+ expect(area).toEqual({ left: 0, top: 0, width: 0, height: 0 })
+ expect(ui.getXCoord(1000)).toBe(0)
+ })
+})
+```
+
+(Unmounted → dygraph is null → zero rect / 0 coord; this exercises the null-guard branch without needing paint.)
+
+- [ ] **Step 7: Run both tests, verify GREEN** — `yarn jest --config ./jest/config.js src/chartLibraries/helpers/overlayArea.test.js src/chartLibraries/dygraph/coords.test.js --collectCoverage=false` → PASS. Then run the existing dygraph overlay tests if any exist to confirm no regression: `yarn jest --config ./jest/config.js src/chartLibraries/dygraph/ --collectCoverage=false`.
+
+- [ ] **Step 8: Commit** — `git add` the four files + tests; `git commit -m "feat(charts): neutral overlay getArea helper + dygraph getPlotArea/getXCoord"`.
+
+---
+
+### Task G1-T2: renderer-agnostic `usePlotArea` (conflict-free)
+
+**Files:**
+- Modify: `src/components/provider/selectors.js:683-690`
+- Test: `src/components/provider/usePlotArea.test.js`
+
+**Interfaces consumed:** `chartUI.getPlotArea()` (G1-T1).
+
+- [ ] **Step 1: Write the failing test** — `src/components/provider/usePlotArea.test.js`
+
+```js
+import { renderHookWithChart } from "@jest/testUtilities"
+import { usePlotArea } from "./selectors"
+
+describe("usePlotArea", () => {
+ it("reads the renderer-agnostic getPlotArea() and returns left/width", () => {
+ const { result, chart } = renderHookWithChart(() => usePlotArea(), {
+ attributes: { chartLibrary: "dygraph" },
+ })
+
+ chart.getUI().getPlotArea = () => ({ left: 12, top: 3, width: 400, height: 200 })
+
+ expect(result.current).toEqual({ left: 0, width: 0 })
+ })
+})
+```
+
+(Unmounted default → `{left:0,width:0}`; the reassigned `getPlotArea` documents the contract the hook must read. Adjust the assertion to `{ left: 12, width: 400 }` if the hook re-derives on read — see Step 3, keep whichever matches the implemented read timing, and make the test assert the real returned value.)
+
+- [ ] **Step 2: Verify RED** — `yarn jest --config ./jest/config.js src/components/provider/usePlotArea.test.js --collectCoverage=false`. If `usePlotArea` isn't exported, RED is an import error; export it in Step 3.
+
+- [ ] **Step 3: Rewrite `usePlotArea`** — in `src/components/provider/selectors.js`, replace the `getDygraph?.()?.getArea()` line so it reads the neutral accessor (keep the existing `rendered`/`resize` subscription above it):
+
+```js
+ const area = chart.getUI(uiName)?.getPlotArea?.()
+
+ return {
+ left: area?.left ?? 0,
+ width: area?.width ?? 0,
+ }
+```
+
+- [ ] **Step 4: Verify GREEN** — rerun the test → PASS.
+
+- [ ] **Step 5: Commit** — `git commit -m "refactor(charts): usePlotArea reads renderer-agnostic getPlotArea"`.
+
+---
+
+### Task G1-T3: uPlot coordinate primitives (RUN AFTER the frontend-design subagent lands on uplot/index.js)
+
+**Files:**
+- Modify: `src/chartLibraries/uplot/index.js` (add `getPlotArea`, `getXCoord`, `getXAxisRange` to the instance at ~:505-516)
+- Test: `src/chartLibraries/uplot/coords.test.js`
+
+**Interfaces produced:** uPlot `chartUI.getPlotArea()`, `getXCoord(tsMs)`, `getXAxisRange()` — matching the dygraph contract (T1) so the neutral `getArea` works identically on uPlot.
+
+- [ ] **Step 1: Write the failing test** — `src/chartLibraries/uplot/coords.test.js`
+
+```js
+import { makeTestChart } from "@jest/testUtilities"
+
+describe("uplot coordinate primitives", () => {
+ it("exposes getPlotArea/getXCoord/getXAxisRange with safe zero values when unmounted", () => {
+ const { chart } = makeTestChart({ attributes: { chartLibrary: "uplot" } })
+ const ui = chart.getUI()
+
+ expect(typeof ui.getPlotArea).toBe("function")
+ expect(typeof ui.getXCoord).toBe("function")
+ expect(typeof ui.getXAxisRange).toBe("function")
+
+ expect(ui.getPlotArea()).toEqual({ left: 0, top: 0, width: 0, height: 0 })
+ expect(ui.getXCoord(1000)).toBe(0)
+ expect(ui.getXAxisRange()).toBeNull()
+ })
+})
+```
+
+- [ ] **Step 2: Verify RED** — `yarn jest --config ./jest/config.js src/chartLibraries/uplot/coords.test.js --collectCoverage=false` → FAIL (methods undefined).
+
+- [ ] **Step 3: Implement on the uPlot chartUI** — in `src/chartLibraries/uplot/index.js`, where the `u` uPlot instance and `instance` object live (~:361 create, :505-516 instance). Add (guarding for `u === null` when unmounted):
+
+```js
+ const getXAxisRange = () => (u ? [u.scales.x.min * 1000, u.scales.x.max * 1000] : null)
+
+ const getPlotArea = () => {
+ if (!u) return { left: 0, top: 0, width: 0, height: 0 }
+ const dpr = u.pxRatio || 1
+ return {
+ left: u.bbox.left / dpr,
+ top: u.bbox.top / dpr,
+ width: u.bbox.width / dpr,
+ height: u.bbox.height / dpr,
+ }
+ }
+
+ const getXCoord = timestampMs => (u ? u.valToPos(timestampMs / 1000, "x") : 0)
+```
+
+Add `getPlotArea`, `getXCoord`, `getXAxisRange` to the returned `instance` object.
+
+**Parity requirement (verify, don't assume):** dygraph's `toDomXCoord` and `getArea` are in the chart-container DOM frame that `container.js`/`usePlotArea` expect. uPlot's `u.valToPos(_, "x")` returns a position relative to the plotting area, and `u.bbox` is in canvas device px. Before committing, VERIFY in Storybook that a uPlot chart with a highlight selection places the badge at the correct x (matching where dygraph puts it) and that `AlertTimeline` aligns. If `valToPos`/`bbox` are offset from the expected frame, add the plotting-area left offset (`u.bbox.left / dpr`) to `getXCoord` and/or adjust `getPlotArea` origin so both match dygraph's frame. Report exactly what offset was needed.
+
+- [ ] **Step 4: Verify GREEN (jest) + Storybook visual** — jest: the unmounted-path test passes. Visual: `yarn storybook` → a uPlot line chart, drag-select a highlight → the highlight badge/correlation button sits over the selection; AlertTimeline (if present) aligns. (Maintainer verifies visuals; do not run a dev server on their behalf — state what to check.)
+
+- [ ] **Step 5: Commit** — `git commit -m "feat(charts): uPlot getPlotArea/getXCoord/getXAxisRange coordinate primitives"`.
+
+---
+
+## Self-Review
+- Spec coverage: neutral primitives (T1 dygraph, T3 uplot), shared getArea (T1), usePlotArea fix (T2) — all present. ✓
+- No placeholders except the deliberately-flagged uPlot px-frame verification in T3 (runtime-dependent; the implementer must confirm and report the offset). ✓
+- Type consistency: `getPlotArea()` returns `{left,top,width,height}` in T1/T3 and is read as `left`/`width` by T2; `getArea` signature `(chartUI, range)` consistent; `getXAxisRange()` returns `[afterMs,beforeMs]|null` consumed by the neutral `getArea`. ✓
+- Ordering: T1, T2 conflict-free (no `uplot/index.js`); T3 gated on the frontend-design subagent. ✓
diff --git a/docs/uplot-g10-renderer-parity-plan.md b/docs/uplot-g10-renderer-parity-plan.md
new file mode 100644
index 000000000..297b87ec1
--- /dev/null
+++ b/docs/uplot-g10-renderer-parity-plan.md
@@ -0,0 +1,118 @@
+# G10 — uPlot Renderer Parity (remaining P0/P1) — plan / mandate
+
+> Parity sub-project G10 (`docs/uplot-prod-parity-gap-map.md`). **Governing rule: match dygraph
+> exactly** — every behavior/decision reproduces what the dygraph renderer does today; acceptance =
+> dygraph parity. Covers the P0/P1 gaps NOT handled by G9 (navigation/cursor/CSS). Each part cites
+> the dygraph source it must mirror.
+
+## P0 — must fix before prod
+
+### P0.1 Hover value popover (re-emit pointer events on the chartUI bus)
+dygraph forwards raw DOM pointer events through its interactionModel:
+`mousemove/mouseout/mouseover/mousedown/mouseup/dblclick/wheel/touch*` → `chartUI.trigger(...)`
+(`dygraph/index.js:64-75`). The popover subscribes to `chart.getUI().on("mousemove"|"mouseout")` and
+reads `event.offsetX/offsetY` (`components/line/popover/index.js:45-79`). uPlot emits none of these.
+**Fix:** in uPlot `mount`/`attachNavigation`, attach listeners on `u.over` and re-emit via
+`chartUI.trigger("mousemove", event)` / `"mouseout"` (and `"mouseover"` for symmetry), matching
+dygraph's contract. Ensure the event carries `offsetX/offsetY` relative to the chart element the
+popover positions against — verify the popover's target container origin matches (dygraph offsets are
+canvas-relative; confirm parity, adjust with the plot-area left/top if needed). Do not change the
+popover component.
+**Test:** mount uPlot, dispatch a `mousemove` on `u.over`, assert a `chart.getUI().on("mousemove")`
+listener fires with usable offsets; `renderWithChart` popover opens on uPlot.
+
+### P0.2 Fire `yAxisChange` on y-range change (unit rescale + min/max)
+dygraph fires `chart.trigger("yAxisChange", min, max)` from the y-axis label formatter, guarded by a
+`prevMin/prevMax` dedup so it only fires when the range actually changes (`dygraph/index.js:238-250`,
+heatmap path `:305`). `helpers/unitConversion/index.js:108` is the sole consumer → recomputes
+`unitsConversionPrefix`/`FractionDigits` and updates `min`/`max`.
+**Fix:** in uPlot, compute the committed y-range (from the same source the y-axis uses) and fire
+`chart.trigger("yAxisChange", min, max)` when it changes, with the identical prevMin/prevMax guard to
+prevent the conversion→attr-change→redraw→fire loop. Hook it where the y range is known per commit
+(y-scale `range` fn or a draw hook reading `u.scales.y.min/max`). Skip for heatmap unless dygraph
+fires there too (it does — mirror it).
+**Test:** mount uPlot, change the visible range (setScale y / new data), assert `yAxisChange` fires
+once per distinct range and not on unchanged re-renders; assert no infinite render loop.
+
+### P0.3 Honor `enabledXAxis` / `enabledYAxis`
+dygraph sets `{ drawAxis: false }` per axis when disabled (`dygraph/index.js:374-388`).
+**Fix:** in uPlot `getAxes`, set the x/y axis `show: false` when `enabledXAxis`/`enabledYAxis` are
+false (independent of the existing sparkline all-off case). Rebuild on change (add to the
+`onAttributeChange` list if not covered).
+**Test:** `enabledYAxis:false` → `u.axes[1].show === false`; `enabledXAxis:false` → `u.axes[0].show
+=== false`; toggling re-renders.
+
+## P1 — visible regressions
+
+### P1.1 Y-range padding (`yRangePad: 15`)
+dygraph pads the y-range by 15px each end for line/area (`dygraph/index.js:109,230,341,359`).
+**Fix:** in uPlot `getScales().y.range`, after computing `[min,max]` for the line/area path, extend by
+15px-equivalent in value units using the committed plot height (`u.bbox.height/dpr`), fallback to a
+small factor before first layout. Match dygraph's 15px; acceptance is visual (data never touches the
+top/bottom border). Do not pad bars (they keep the existing 5% `padAwayFromZero`) unless dygraph does.
+**Test:** line chart y-range is wider than the raw data extent by a stable margin; bars unchanged.
+
+### P1.2 `area` forces zero baseline (when multi-dim + multi-selected)
+dygraph includes zero when `includeZero || (forceIncludeZero && dimensionIds.length > 1 &&
+selectedLegendDimensions.length > 1)` (`dygraph/index.js:365-367`; area sets `forceIncludeZero:true`
+`:288`).
+**Fix:** in uPlot y-range for `chartType==="area"`, apply the same condition; when true, clamp min to
+`Math.min(0, min)` and max to `Math.max(0, max)`. Mirror the exact condition (incl. the
+`selectedLegendDimensions.length > 1` clause) — do not zero-baseline single-series area.
+**Test:** multi-dim multi-selected area includes 0; single-dim area does not.
+
+### P1.3 Sparkline series styling
+dygraph sparkline: `strokeWidth:0, fillAlpha:1, highlightCircleSize:3` (solid fill, no stroke)
+(`dygraph/index.js:437-449`).
+**Fix:** in uPlot `getSeries`, when `chart.isSparkline()`, render each series as a solid fill
+(alpha 1) with zero stroke and no points — matching dygraph. (Axes already hidden, `uplot/index.js`
+sparkline branch.)
+**Test:** sparkline series has fill and `width:0`; non-sparkline unchanged.
+
+### P1.4 Synced cross-chart hover point dots
+dygraph draws the vertical crosshair line AND `dygraph.setSelection(row)` → highlighted point circles
+at the synced row across series (`crosshair.js:25`, wired `dygraph/index.js:142-155`). uPlot only
+draws the dashed vertical line on `hoverX`/`clickX` (`uplot/index.js` drawVerticalLine).
+**Fix:** in the uPlot foreground `draw` hook, for `hoverX`/`clickX` compute the closest row and, for
+each visible series, draw a filled dot at `(valToPos(x), valToPos(value))` — mirroring setSelection's
+markers, colored per dimension. Keep it on the `draw` (foreground) hook (dygraph draws the crosshair
+on the fg canvas, not underlay).
+**Test:** with `hoverX` set, the draw hook plots one dot per visible series at the synced timestamp.
+
+### P1.5 Shaded overlays behind series (z-order)
+dygraph draws overlays via `underlayCallback` → behind the data (`dygraph/overlays/index.js:42`,
+using `hidden_ctx_`). uPlot runs `drawOverlays` as the last `draw` hook → on top
+(`uplot/index.js` draw hooks), so alertTransitions (30% α) / alarmRange (12% α) / highlight tint the
+data lines. uPlot pipeline (verified `uPlot.cjs.js:4888-4891`): `drawClear` (before series) → series
+→ `draw` (after).
+**Fix:** move the overlay orchestration (`drawOverlays`) from the `draw` hook to a `drawClear` hook so
+shaded overlays sit behind the series, matching dygraph's underlay. Keep the crosshair + synced dots
+(P1.4) on the `draw` hook (foreground), matching dygraph's fg crosshair. Verify anomaly ribbon /
+annotations strip stay at the data layer as today (dygraph draws them as series plotters).
+**Test:** assert `drawOverlays` is registered on the `drawClear` hook and the crosshair on `draw`;
+overlays still emit `overlayedAreaChanged`.
+
+### P1.6 Empty / out-of-limits keeps a framed chart
+dygraph substitutes `[[0]]`/`["X"]` so an empty grid/axes frame still draws
+(`dygraph/index.js:48-54,426-427`). uPlot `getData` returns `null` and `render` calls `destroyChart`
+(`uplot/index.js`), leaving nothing.
+**Fix:** when `outOfLimits`/empty, keep a uPlot instance rendering an empty framed grid (axes visible,
+no series data) instead of destroying it — matching dygraph's empty frame. Preserve the existing
+"no element / not loaded" guards.
+**Test:** set `outOfLimits:true` → `.uplot` still present with axes; no crash; recovers when data
+returns.
+
+## Sequencing
+P0.1 → P0.2 → P0.3 (independent, highest impact) → P1.5 (z-order, small) → P1.1/P1.2 (range) →
+P1.3 (sparkline) → P1.4 (dots) → P1.6 (empty state). Each TDD'd, committed separately.
+
+## Tests / constraints
+Real components, `makeTestChart`, synthetic events; NEVER mock. Full suite green + eslint clean on
+changed files per part. No semicolons; double quotes; 2-space indent; 100-char; ES5 trailing commas;
+arrow functions; imports at top; no description comments; JSX files import React.
+Test: `yarn jest --config ./jest/config.js --collectCoverage=false`.
+
+## Visual verification (maintainer, Storybook — do NOT run a dev server on their behalf)
+`chartLibrary:"uplot"`: hover shows the value popover; units relabel on zoom; axis toggles hide axes;
+peaks have breathing room; multi-dim area sits on zero; sparklines are filled; synced hover shows
+dots; alert bands sit behind the lines; out-of-limits shows an empty framed chart.
diff --git a/docs/uplot-g2-alert-overlays-plan.md b/docs/uplot-g2-alert-overlays-plan.md
new file mode 100644
index 000000000..9bcc1401f
--- /dev/null
+++ b/docs/uplot-g2-alert-overlays-plan.md
@@ -0,0 +1,83 @@
+# G2 — uPlot Alert Overlays — plan / mandate
+
+> Parity sub-project G2 (`docs/uplot-parity-gap-map.md`). Builds on G1 primitives
+> (`getPlotArea`/`getXCoord`/`getXAxisRange`, neutral `getArea`). Executed via subagent.
+> **Acceptance = dygraph parity: the same overlays must appear in the same place with the same UX.**
+
+## Scope
+
+Port the dygraph **alert** overlays to uPlot: `alarm`, `alarmRange`, `alertTransitions`, and the
+`highlight` selection shading — the canvas-draw layer AND the `overlayedAreaChanged:` emits that
+position their React badges. Crosshair/point is already done on uPlot. **Out of scope (→ G3):**
+annotation markers/popover and the anomaly ribbon.
+
+## How dygraph does it (the thing to mirror)
+
+- `src/chartLibraries/dygraph/overlays/index.js` returns `{ toggle, destroy }`; `toggle` subscribes
+ `drawOverlays` to `chartUI.on("underlayCallback", …)` when `chart.getAttribute("overlays")` is
+ non-empty. `drawOverlays` iterates the overlays map, looks up `types[type]`
+ (`dygraph/overlays/types.js`), and calls `makeOverlay(chartUI, id)`.
+- Each overlay file (`alarm.js`, `alarmRange.js`, `alertTransitions.js`, `highlight.js`) draws on
+ the dygraph canvas (`dygraph.hidden_ctx_`/`canvas_ctx_`) using `dygraph.getArea()` +
+ `dygraph.toDomXCoord()` + the shared `getArea(dygraph, range)` (`overlays/helpers.js`), and emits
+ position via `trigger(chartUI, id, area)` → `overlayedAreaChanged:` (RAF).
+- The React badge components (`src/components/line/overlays/{alarm,alarmRange,highlight}.js`) are
+ renderer-agnostic — they only need the `overlayedAreaChanged:` events (via `container.js`).
+ They must NOT need changes; if they do, that's a signal the emit contract diverged.
+
+## Porting rules (STRICT — user standing rule)
+
+**Copy-then-edit; never rewrite from scratch.** For each overlay:
+1. `cp src/chartLibraries/dygraph/overlays/.js src/chartLibraries/uplot/overlays/.js`
+ (and copy `types.js` + create a uplot `overlays/index.js` from the dygraph one).
+2. Edit ONLY the renderer-specific calls, using the G1 primitives + the live `u`:
+ - `chartUI.getDygraph()` / `dygraph` → the uPlot instance `u` (pass it into the draw fns).
+ - `dygraph.getArea()` `{x,y,w,h}` → `chartUI.getPlotArea()` `{left,top,width,height}`.
+ - `dygraph.toDomXCoord(tsMs)` → `chartUI.getXCoord(tsMs)`.
+ - `dygraph.hidden_ctx_` / `canvas_ctx_` → `u.ctx`.
+ - `getArea(dygraph, range)` (helpers) → the neutral `getArea(chartUI, range)` from
+ `@/chartLibraries/helpers/overlayArea` (already built in G1).
+ - `dygraph.renderGraph_(false)` (force redraw) → `u.redraw()`.
+ - `chartUI.on("underlayCallback", drawOverlays)` → register `drawOverlays` so it runs inside
+ uPlot's existing `draw` hook (`src/chartLibraries/uplot/index.js` builds
+ `hooks: { draw: [drawStacked, draw] }` — add an overlays draw pass there, with `u.ctx`).
+ Leave the drawing math, colors, theme lookups, alpha, and emit logic otherwise identical.
+3. Preserve each overlay's `overlayedAreaChanged:` emit exactly (same id, same
+ `{from,to,width}` shape) so the React badges position identically.
+
+## Wiring into uPlot
+
+- Instantiate the uPlot overlays module in `uplot/index.js` (mirror where dygraph calls
+ `makeOverlays(chartUI)` and `toggle()`), so overlays redraw when the `overlays` attribute (and the
+ relevant reactive attributes: `hoverX`/selection for highlight, alarm data) change. Match dygraph's
+ redraw triggers — reuse uPlot's existing attribute-change → `u.redraw()` reactions; add overlay
+ attribute reactions where dygraph has them.
+- Overlays draw during the `draw` hook using `u.ctx`, `u.bbox`, and `chartUI.getXCoord`. Match
+ dygraph's visual layering (shading behind series / with alpha) as closely as uPlot's draw-hook
+ timing allows; if uPlot's draw runs over series where dygraph drew under, use the same alpha so the
+ result matches — verify visually.
+
+## Tests (real, no mocks — makeTestChart)
+
+- uPlot overlays module: with `overlays: { : { type: "alarmRange", … } }` set on a
+ `chartLibrary:"uplot"` chart, assert the module subscribes and, on a render, emits
+ `overlayedAreaChanged:` with a `{from,to,width}` (or null when out of window) — mirror the
+ existing `dygraph/overlays/*.test.js` (`highlight.test.js`, `point.test.js`, `proceeded.test.js`)
+ so coverage matches. Reuse their assertions where possible.
+- Confirm the React badge components render given the emitted event (they're unchanged; a light
+ integration check via `renderWithChart` that the alarm/alarmRange/highlight badge appears for a
+ uPlot chart with the overlay attribute set).
+- Full suite green after (touches `uplot/index.js` + new `uplot/overlays/*`).
+
+## Visual verification (maintainer — jsdom can't paint)
+This also closes the G1 T3 open item. In Storybook on a `uPlot` chart:
+- An alarm / alarmRange overlay draws its shaded band + badge at the correct time position (matches
+ where dygraph draws it).
+- alertTransitions markers appear at the right x.
+- A drag-select highlight shows the shaded selection + correlation/zoom badge over the selection.
+State exactly what to check; do NOT run a dev server.
+
+## Constraints
+No semicolons; double quotes; 2-space indent; 100-char; ES5 trailing commas; arrow functions;
+imports at top; no description comments; NEVER mock; JSX files import React. Don't touch
+`numberFormat.js`. Test: `yarn jest --config ./jest/config.js --collectCoverage=false`.
diff --git a/docs/uplot-g3-anomaly-annotation-plan.md b/docs/uplot-g3-anomaly-annotation-plan.md
new file mode 100644
index 000000000..f0bcf3c2a
--- /dev/null
+++ b/docs/uplot-g3-anomaly-annotation-plan.md
@@ -0,0 +1,69 @@
+# G3 — uPlot Anomaly Ribbon + Annotations — plan / mandate
+
+> Parity sub-project G3 (`docs/uplot-parity-gap-map.md`). **Acceptance = dygraph parity:** anomaly
+> ribbon, annotations strip, annotation markers, the annotation hover popover, and click-to-annotate
+> must work on uPlot exactly as on dygraph. Builds on G1 primitives and the G2 uPlot overlay
+> orchestration (`src/chartLibraries/uplot/overlays/`).
+
+## Parts
+
+### 1. Anomaly ribbon (`ANOMALY_RATE`)
+dygraph draws it as a fake series with a custom plotter: `src/chartLibraries/dygraph/plotters/anomaly.js`
+(uses `plotter.drawingContext`, `plotter.points[].canvasx/.xval`, top band). Reimplement as a uPlot
+**draw hook** painting the top ribbon, positioned by time via `chartUI.getXCoord`/`u.valToPos` and
+`chartUI.getPlotArea()` for the band height/offset. Reuse whatever anomaly-rate accessors dygraph's
+plotter uses from the chart (they're renderer-agnostic). Match colors/height.
+
+### 2. Annotations strip (`ANNOTATIONS`)
+dygraph: `src/chartLibraries/dygraph/plotters/annotations.js` (bottom strip, per-point alert/annotation
+flags). Reimplement as a uPlot draw hook (bottom band) the same way.
+
+### 3. Annotation canvas markers (saved + draft)
+dygraph: `src/chartLibraries/dygraph/overlays/annotation.js` (G2 deliberately skipped this). COPY-
+THEN-EDIT it into `src/chartLibraries/uplot/overlays/annotation.js` and register it in the uPlot
+overlay orchestration G2 built (`uplot/overlays/index.js`/`types.js`), swapping dygraph APIs per the
+G2 pattern (`getDygraph`→uPlot `u`, `getArea`→`getPlotArea`, `toDomXCoord`→`getXCoord`,
+`hidden_ctx_`→`u.ctx`, `getArea(dygraph,range)`→neutral `getArea(chartUI,range)`). The G2
+orchestration already has a `draftAnnotation` branch — make sure the annotation type resolves there.
+
+### 4. Annotation hover popover (React component)
+`src/components/line/overlays/annotation/index.js:407-441` early-returns unless
+`chartLibrary === "dygraph"` and uses `chartUI.getDygraph().canvas_` + `dygraph.toDomXCoord(ts*1000)`
+for mouse-proximity detection. COPY-THEN-EDIT this `useEffect`: remove the dygraph-only gate and
+replace the dygraph calls with renderer-agnostic ones — `chart.getUI().getXCoord(annotation.timestamp
+* 1000)` (G1) and the plot-area/container for the canvas rect. Keep the proximity math identical.
+Works for BOTH renderers after (dygraph still uses `getXCoord` which delegates to `toDomXCoord`).
+
+### 5. Click-to-annotate
+dygraph wires it in `src/chartLibraries/dygraph/hoverX.js:115-148` (`annotate()` →
+`updateAttribute("draftAnnotation", …)` + `annotationCreate` trigger, and `highlightClick`). Add the
+equivalent to uPlot's cursor/click handling in `src/chartLibraries/uplot/index.js` (a click handler
+on `u.over` that maps click x→timestamp via the x scale and sets `draftAnnotation`/triggers
+`annotationCreate`/`highlightClick`). Match dygraph's behavior and attribute contract.
+
+## Out of scope → G6
+Ribbon hover hit-testing (cursor over the top/bottom bands resolving `ANOMALY_RATE`/`ANNOTATIONS`,
+`dygraph/hoverX.js:40-41`) is part of hover fidelity (G6). G3 draws the ribbons + wires the
+annotation popover proximity + click-to-annotate; note the ribbon-band hover deferral.
+
+## Tests (real, no mocks — makeTestChart)
+- Mirror `dygraph/plotters/anomaly.test.js` + `annotations.test.js` for the uPlot ribbon draw hooks
+ (assert the hook is registered and draws for a payload with `ANOMALY_RATE`/`ANNOTATIONS`).
+- Annotation overlay: mirror the dygraph annotation coverage; assert the uPlot annotation type
+ registers in the orchestration and emits/positions.
+- Annotation popover: a `renderWithChart` test that the popover proximity path now runs for
+ `chartLibrary:"uplot"` (no early return) — assert it reads `getXCoord`, not `getDygraph`.
+- Click-to-annotate: simulate a click on the uPlot over-element and assert `draftAnnotation` is set /
+ `annotationCreate` fires.
+- Full suite green; eslint clean on changed files.
+
+## Visual verification (maintainer)
+Storybook `chartLibrary:"uplot"`: anomaly ribbon along the top colored by anomaly rate; annotations
+strip along the bottom; hovering near an annotation shows its popover; clicking on the plot starts a
+draft annotation — all matching dygraph. Do NOT run a dev server.
+
+## Constraints
+No semicolons; double quotes; 2-space indent; 100-char; ES5 trailing commas; arrow functions;
+imports at top; no description comments; NEVER mock; JSX files import React. COPY-THEN-EDIT the
+canvas plotters/markers and the React popover effect — do not rewrite. Don't touch `numberFormat.js`.
+Commit in logical chunks. Test: `yarn jest --config ./jest/config.js --collectCoverage=false`.
diff --git a/docs/uplot-g4-bars-parity-plan.md b/docs/uplot-g4-bars-parity-plan.md
new file mode 100644
index 000000000..1488d10d2
--- /dev/null
+++ b/docs/uplot-g4-bars-parity-plan.md
@@ -0,0 +1,62 @@
+# G4 — uPlot Bars Parity — plan / mandate
+
+> Parity sub-project G4 (`docs/uplot-parity-gap-map.md`). **Acceptance = dygraph parity:**
+> `multiBar`/`stackedBar` on uPlot must behave like dygraph bars — real time x-axis, working
+> navigation, SDK hover/cross-chart sync, negative values, themed/formatted axes.
+
+## The problem (verified)
+Bars today render via `createBars` (`src/chartLibraries/uplot/index.js:503`), a SEPARATE uPlot
+instance using the vendored ordinal-x `seriesBarsPlugin` (`distr:2`). `render`/`create` calls
+`createBars(data)` and returns before `attachNavigation()` (`index.js:543-544,567`). Consequences vs
+dygraph: (a) x shows raw ordinal index, not formatted time; (b) no crosshair/pan/wheel-zoom/
+drag-select/dblclick-reset; (c) no `highlightHover`/`hoverChart` emit → no cross-chart hover sync;
+(d) y clamped `[0,max]` → negative bars clipped; (e) axes unstyled/unformatted.
+
+## Direction
+The ordinal separate-instance approach **cannot** achieve time-axis + navigation parity — replace it
+with the pattern the rest of uPlot now uses: draw bars via a **draw hook on the MAIN time-x uPlot
+instance** (series `paths:()=>null`), exactly like `drawStacked`/`drawHeatmap`. On the main instance
+bars automatically inherit the already-wired `setCursor` hover (`highlightHover`/`hoverChart`),
+`attachNavigation` (pan/wheel/dblclick), `getCursor`/`onSetSelect` (drag-select), the crosshair, and
+`chart.formatXAxis` time axis — closing (a),(b),(c),(e) for free.
+
+Concretely:
+- Add a `drawBars` draw hook (grouped for `multiBar`, stacked for `stackedBar`). Position each bar
+ by time using `chartUI.getXCoord` (G1) / `u.valToPos`; compute bar width + per-group offsets from
+ the time step (reference the geometry in `bars/seriesBarsPlugin.js` — group width, gap — but drive
+ x off time, not ordinal index). Reuse the stack accumulation in `bars/stack.js` for `stackedBar`.
+- **Negatives:** y value-range must span `[min, max]` including negative bar values (like line/area
+ ranges), not `[0,max]`. Bars extend from the zero baseline both directions. Match dygraph.
+- Remove the `barTypes`→`createBars` separate-instance branch and its early return; bars now flow
+ through the normal `create`/`render` path with the draw hook. Keep `bars/stack.js` (reused);
+ `seriesBarsPlugin.js`/`quadtree.js`/`distr.js` can be dropped if fully unused after — confirm with
+ grep before deleting, and only delete what's truly orphaned.
+- Bar-type point reduction is already handled upstream (`pointMultiplierByChartType`,
+ `api/helpers.js`); don't change it.
+
+## Hover fidelity note
+Basic bar hover (emit the SDK hover events via the main-instance `setCursor`) is IN scope so
+cross-chart sync works. Nearest-bar/column precise hit-testing refinement can align with G6 (hover
+fidelity) — do the straightforward main-instance hover now; note anything deferred.
+
+## Tests (real, no mocks — makeTestChart)
+- With `chartType:"multiBar"` and `chartType:"stackedBar"` on a `chartLibrary:"uplot"` chart: assert
+ the bars draw hook is registered on the main instance (not a separate ordinal instance), the x
+ scale is time (not `distr:2` ordinal), the y value-range includes negatives when data has them,
+ and stacked accumulation uses `stack.js`. Mirror existing uplot tests' style; jsdom can't paint so
+ assert config/hook wiring, not pixels.
+- Confirm bars now emit `highlightHover`/`hoverChart` through the shared `setCursor` (a hover test
+ like the line path's).
+- Full suite green; eslint clean on changed files.
+
+## Visual verification (maintainer — jsdom can't paint)
+Storybook `chartLibrary:"uplot"`, multiBar + stackedBar (showcase RenderModes): bars sit at correct
+time positions with a formatted time x-axis; grouped bars have visible gaps; stacked bars stack
+correctly; negative values render below the zero line; pan/wheel-zoom/dblclick-reset work; hovering a
+bar drives the crosshair + cross-chart sync; light+dark theming. Compare against dygraph. Do NOT run
+a dev server.
+
+## Constraints
+No semicolons; double quotes; 2-space indent; 100-char; ES5 trailing commas; arrow functions;
+imports at top; no description comments; NEVER mock; JSX files import React. Don't touch
+`numberFormat.js`. Commit in logical chunks. Test: `yarn jest --config ./jest/config.js --collectCoverage=false`.
diff --git a/docs/uplot-g5-heatmap-plan.md b/docs/uplot-g5-heatmap-plan.md
new file mode 100644
index 000000000..1bad70cca
--- /dev/null
+++ b/docs/uplot-g5-heatmap-plan.md
@@ -0,0 +1,65 @@
+# G5 — uPlot Heatmap — plan / mandate
+
+> Parity sub-project G5 (`docs/uplot-parity-gap-map.md`). The one missing time-series chartType on
+> uPlot. **Acceptance = dygraph parity: a uPlot heatmap must look/behave like the dygraph heatmap.**
+
+## Scope
+Render `chartType: "heatmap"` on the uPlot renderer: colored bucket cells over time (x) × bucket
+rows (y), a bucket-boundary y-axis, and the correct y value-range. Closes the routing-safety gap
+(a `chartLibrary:"uplot"` chart whose `chartType` becomes `heatmap` currently has no renderer).
+**Out of scope → G6:** heatmap-specific hover/column hit-testing (`getClosestHeatmapDimension`,
+`dygraph/hoverX.js:11-31`) — G5 delivers the rendered chart; hover hit-testing is part of the hover
+sub-project. Note the boundary in your report.
+
+## Reuse (renderer-agnostic — DO NOT copy, import directly)
+- `chart.getVisibleHeatmapIds()`, `chart.getHeatmapScale()`, `chart.getHeatmapYIndex(id)`
+ (`src/sdk/makeChart/makeDimensions.js:272-299`).
+- Color + scale + label helpers: `src/helpers/heatmap.js` (`isHeatmap`, `makeGetColor`),
+ `src/helpers/heatmapScale.js` (`detectHeatmapScale`, `formatHeatmapLabel`, `parseHeatmapValue`).
+ These already back the dygraph heatmap and are renderer-neutral — use them unchanged.
+
+## Study before implementing
+- dygraph heatmap plotter: `src/chartLibraries/dygraph/plotters/heatmap.js` (`makeHeatmapPlotter`) —
+ the authoritative cell color/position logic (per-bucket rectangles via `getHeatmapYIndex` +
+ `makeGetColor`). This is the behavior to match.
+- dygraph y-ticker: `src/chartLibraries/dygraph/tickers/heatmap.js` (`heatmapTicker`) — bucket
+ boundary labels via `formatHeatmapLabel`, decimated to fit.
+- dygraph wiring: `src/chartLibraries/dygraph/index.js` — `plotterByChartType.heatmap`,
+ `optionsByChartType.heatmap` (y-ticker), and `makeDataOptions` setting `valueRange:
+ [0, getVisibleHeatmapIds().length]` when heatmap.
+- uPlot draw-hook templates already in the codebase: `src/chartLibraries/uplot/index.js` —
+ `drawStacked` and the crosshair `draw` hook (series `paths: () => null` + custom `u.ctx` painting).
+ This is the mechanism for heatmap cells.
+- uPlot reference implementation: `/Users/novykh/Projects/uPlot/demos/latency-heatmap.html` — a
+ draw-hook heatmap (colored cells via `u.valToPos` + `u.ctx`). Use it as the uPlot-idiom guide.
+
+## Implementation
+Because dygraph draws via its plotter's point objects and uPlot draws via a `draw` hook over
+`u.data`/scales, the drawing loop is a faithful **reimplementation in uPlot's model** (not a literal
+copy) — but it must reuse ALL the shared helpers above unchanged and reproduce dygraph's visual
+result (same colors, same bucket rows, same cell extents). Concretely:
+- A heatmap render path in `uplot/index.js` (isolated like the bars path `createBars`, or a
+ `draw`-hook + `paths:()=>null` on the main instance — choose whichever matches dygraph's result
+ and the demo; justify in the report). y-scale range `[0, getVisibleHeatmapIds().length]`.
+- Draw hook paints one filled rect per (timeColumn × visible bucket): x from `chartUI.getXCoord`
+ (G1) / `u.valToPos`, y-row from `getHeatmapYIndex`, color from `makeGetColor(chart)`.
+- Custom y-axis: bucket-boundary labels via `formatHeatmapLabel` + the chart's heatmap scale, ported
+ from `heatmapTicker` into uPlot's `axes[1]` `values`/`splits`.
+
+## Tests (real, no mocks)
+- Use `@jest/testUtilities` heatmap helpers (`loadHeatmapPayload`/`makeHeatmapPayload`). With a
+ heatmap payload on a `chartLibrary:"uplot"` chart, assert: the heatmap render path is taken for
+ `chartType:"heatmap"`; the y value-range is `[0, numBuckets]`; the shared helpers
+ (`getVisibleHeatmapIds`/`getHeatmapYIndex`/`getHeatmapScale`) are exercised. jsdom can't paint, so
+ assert structure/config, not pixels.
+- Full suite green after; eslint clean on changed files.
+
+## Visual verification (maintainer — jsdom can't paint)
+Storybook, `chartLibrary:"uplot"`, a heatmap context (or the showcase's RenderModes): cells colored
+by value matching dygraph, bucket-boundary y-axis labels correct, cell time-alignment matches
+dygraph across pan/zoom. State what to check; do NOT run a dev server.
+
+## Constraints
+No semicolons; double quotes; 2-space indent; 100-char; ES5 trailing commas; arrow functions;
+imports at top; no description comments; NEVER mock; JSX files import React. Don't touch
+`numberFormat.js`. Commit in logical chunks. Test: `yarn jest --config ./jest/config.js --collectCoverage=false`.
diff --git a/docs/uplot-g6-hover-fidelity-plan.md b/docs/uplot-g6-hover-fidelity-plan.md
new file mode 100644
index 000000000..86af9ba05
--- /dev/null
+++ b/docs/uplot-g6-hover-fidelity-plan.md
@@ -0,0 +1,61 @@
+# G6 — uPlot Hover Fidelity + Click Actions — plan / mandate
+
+> Parity sub-project G6 (`docs/uplot-parity-gap-map.md` §G6). **Acceptance = dygraph parity.**
+> Refines the existing uPlot `setCursor` hover so it reports the **nearest** series/bucket like
+> dygraph, adds ribbon-band hit-testing (deferred from G3), and fires `chart.trigger` alongside
+> `sdk.trigger` for the highlight events. Click-to-annotate + `highlightClick` + their `chart.trigger`
+> already landed in G3.
+
+## Current gap (verified)
+`src/chartLibraries/uplot/index.js` `setCursor` reports `chart.getVisibleDimensionIds()?.[0]` — always
+the **first** visible dimension — and fires only `sdk.trigger("highlightHover"/"highlightBlur")`.
+dygraph does real closest-point detection (`dygraph/hoverX.js:33-79`), resolves ribbon bands, and
+fires both `chartUI.sdk.trigger` AND `chartUI.chart.trigger` (`hoverX.js:95-96,166-167`).
+
+## Parts
+
+### 1. Nearest-dimension hit-testing (`hover.js`)
+New `src/chartLibraries/uplot/hover.js` exporting `makeGetHoverDimension(chart) => self => dimensionId`,
+mirroring `dygraph/hoverX.js` `findClosest` order and using `self.cursor.top`/`self.cursor.idx` +
+`self.valToPos(v, "y")` (verified same plot-area CSS origin as `cursor.top`):
+- **Ribbon bands first** (match dygraph order): `top > over.clientHeight - 10` → `"ANNOTATIONS"`;
+ `top < 15` → `"ANOMALY_RATE"`. Gated on `showAnnotations`/`showAnomalies` (dygraph gates implicitly
+ via `getPropertiesForSeries` returning undefined when the fake series is absent).
+- **heatmap** → nearest visible bucket by `|valToPos(getHeatmapYIndex(id),"y") - top|` → bucket id
+ (analog of `getClosestHeatmapDimension`).
+- **stacked** → diverging-stack band distance using `getStackBounds` (`./stacking`), analog of
+ `findDivergingStackedPoint` (`dygraph/divergingStack.js`): distance 0 inside `[baseY,endY]`, else gap.
+- **stackedBar** → same band distance from the cumulative `stack()` tops (`./bars/stack`), consistent
+ with how `drawStackedBars` paints segments (`[top-value, top]`).
+- **default (line/area/multiBar)** → nearest visible series by `|valToPos(value[idx],"y") - top|`
+ (analog of `findClosestPoint`). Fallback to first visible id if none resolve.
+
+### 2. Wire into `setCursor`
+Replace the first-dimension lookup with `getHoverDimension(self)`; add
+`chart.trigger("highlightHover", timestamp, dimensionId)` after the sdk trigger and
+`chart.trigger("highlightBlur")` in the blur branch (dygraph fires both). Leave the existing
+`hoverChart`/`blurChart` emission untouched (out of G6 scope; those also flow from React
+`onHover`/`onBlur` → `chart.focus`/`chart.blur`).
+
+## Out of scope
+Touch/mobile hit-testing (G7). Diverging data-handler internals (dygraph-specific); uPlot recomputes
+bounds from the same `stacking.js`/`bars/stack.js` used for drawing.
+
+## Tests (real, no mocks — makeTestChart)
+- `hover.test.js`: for each chartType, mount, set `u.cursor` near a known series/bucket/band, call
+ `makeGetHoverDimension(chart)(u)`, assert the resolved dimensionId is the nearest one (not always
+ the first); ribbon bands resolve `ANOMALY_RATE`/`ANNOTATIONS` and respect the show flags.
+- `index.test.js`: assert `setCursor` emits `highlightHover` on BOTH the sdk bus and the chart bus
+ with the nearest dimension, and `highlightBlur` on both when the cursor leaves.
+- Full suite green; eslint clean on changed files.
+
+## Visual verification (maintainer)
+Storybook `chartLibrary:"uplot"`: hovering different series highlights the nearest one (tooltip/legend
+follows the cursor's Y), stacked/heatmap resolve the band/bucket under the cursor, top/bottom ribbon
+bands report anomaly/annotation. Do NOT run a dev server.
+
+## Constraints
+No semicolons; double quotes; 2-space indent; 100-char; ES5 trailing commas; arrow functions; imports
+at top; no description comments; NEVER mock; JSX files import React. Reuse `stacking.js`/`bars/stack.js`
+— do not duplicate stack math. Commit in logical chunks.
+Test: `yarn jest --config ./jest/config.js --collectCoverage=false`.
diff --git a/docs/uplot-g7-touch-nav-plan.md b/docs/uplot-g7-touch-nav-plan.md
new file mode 100644
index 000000000..acc77eb2b
--- /dev/null
+++ b/docs/uplot-g7-touch-nav-plan.md
@@ -0,0 +1,48 @@
+# G7 — uPlot Touch / Mobile Navigation — plan / mandate
+
+> Parity sub-project G7 (`docs/uplot-parity-gap-map.md` §G7). **Acceptance = dygraph parity.**
+> uPlot currently has zero touch handlers; dygraph has single-finger horizontal pan, tap, and
+> double-tap (`src/chartLibraries/dygraph/navigation/generic.js:104-141`).
+
+## Current gap (verified)
+`src/chartLibraries/uplot/index.js` `attachNavigation` binds only `mousedown`/`mousemove`/`mouseup`/
+`wheel`/`dblclick`. No `touchstart`/`touchmove`/`touchend`. dygraph's `generic.js`:
+- `touchStart`: horizontal-only direction, records first touch pageX, resets move flag.
+- `touchMove`: pans; emits `panStart` on the first move.
+- `touchEnd`: double-tap (`< 300ms`) → `dblclick` (→ `resetNavigation`); tap with no move →
+ `updateAttribute("clickX", [dataX, null])`; otherwise if panning → `panEnd` with the date window.
+
+## Parts
+
+### Touch handlers in `attachNavigation`
+Add `onTouchStart`/`onTouchMove`/`onTouchEnd` on `u.over`, gated by `enabledNavigation`, mirroring
+the existing mouse-pan math (`u.posToVal(1,"x")-u.posToVal(0,"x")` units/px, `u.setScale("x",…)`):
+- **start**: record `touch.clientX`, `u.scales.x.min/max`, units/px; clear `touchMoved`.
+- **move**: `preventDefault` (non-passive listener); on first move set `touchMoved` and `emitNav("panStart")`;
+ shift the x-scale by `unitsPerPx * (clientX - startX)`.
+- **end** (same order as dygraph): if `now - lastTouchEndTime < 300` → `chart.resetNavigation()`;
+ else set `lastTouchEndTime`; if not moved → tap → `updateAttribute("clickX", [posToVal(offsetX)*1000, null])`;
+ else if panning → `emitNav("panEnd", [min*1000, max*1000])`.
+
+Register the three listeners (touchmove `{passive:false}`) and add their removal to the existing
+`attachNavigation` cleanup.
+
+## Out of scope
+Pinch-zoom (dygraph touch is horizontal-pan only per `touchDirections {x:true,y:false}`). Multi-touch.
+
+## Tests (real, no mocks — makeTestChart)
+- `index.test.js`: dispatch synthetic touch events on `u.over` (Event + `touches`/`changedTouches`,
+ `over.getBoundingClientRect` overridden as in the click tests):
+ - pan: touchstart→touchmove→touchend emits `panStart` then `panEnd` and shifts `u.scales.x`.
+ - tap: touchstart→touchend (no move) sets `clickX` to the tapped timestamp.
+ - double-tap: two taps within 300ms call `resetNavigation`.
+- Full suite green; eslint clean on changed files.
+
+## Visual verification (maintainer)
+Storybook `chartLibrary:"uplot"` on a touch device / emulation: one-finger drag pans, tap sets the
+crosshair, double-tap resets zoom — matching dygraph. Do NOT run a dev server.
+
+## Constraints
+No semicolons; double quotes; 2-space indent; 100-char; ES5 trailing commas; arrow functions; imports
+at top; no description comments; NEVER mock. `Date.now()` is fine in app code (dygraph uses it).
+Test: `yarn jest --config ./jest/config.js --collectCoverage=false`.
diff --git a/docs/uplot-g8-stacked-polish-plan.md b/docs/uplot-g8-stacked-polish-plan.md
new file mode 100644
index 000000000..9fb3208a2
--- /dev/null
+++ b/docs/uplot-g8-stacked-polish-plan.md
@@ -0,0 +1,47 @@
+# G8 — uPlot Stacked-Area Polish — plan / mandate
+
+> Parity sub-project G8 (`docs/uplot-parity-gap-map.md` §G8, `docs/uplot-migration-progress.md` §4).
+> **Acceptance = dygraph parity.** Two items were listed: null/gap handling and a top stroke.
+
+## State (verified)
+- **Top stroke**: already present — `drawStacked` (`src/chartLibraries/uplot/index.js`) draws a
+ per-series top-edge stroke (`stackedEdgeAlpha`, `edgeWidth = devicePixelRatio`) after the fill.
+ dygraph draws stacked with `strokeWidth: 0.1` (`dygraph/index.js:278`). Nothing to add here.
+- **Null/gap handling — the gap**: dygraph sets `stackedGraphNaNFill: "none"` (`dygraph/index.js:368`),
+ i.e. it does NOT fill across null/NaN — gaps show. uPlot's `drawStacked` skips null bounds with
+ `continue`, so the fill polygon and the top stroke are drawn straight across the gap (**bridged**),
+ not broken.
+
+## Parts
+
+### Segment the stacked fill/stroke at nulls
+Refactor `drawStacked` so each series is drawn as one or more **contiguous non-null segments**
+instead of one bridged path. Extract a `drawStackSegment(self, ctx, xs, series, start, end, color,
+edgeWidth)` that draws the existing two passes (filled polygon: forward along `bound[1]` top, back
+along `bound[0]` base; then the top-edge stroke) for a single `[start..end]` run. `drawStacked` walks
+each series' `stackBounds()`, and for every maximal run of non-null bounds calls `drawStackSegment`.
+A null bound ends the current run and starts a new one → the gap is left empty, matching dygraph.
+
+Preserve every existing detail exactly (clip rect, `stackedFillAlpha`/`stackedEdgeAlpha`, `edgeWidth`,
+draw order) — this is an in-place segmentation, not a rewrite.
+
+## Out of scope
+Gap-edge points (dygraph `drawGapEdgePoints`) — subtle dots at gap boundaries; not required for the
+gap itself. Non-stacked chart types.
+
+## Tests (real, no mocks — makeTestChart)
+- `index.test.js`: mount a `stacked` chart with and without a null in one series; count `ctx.moveTo`
+ calls during the draw hooks (drawStacked is the only `moveTo` source when no crosshair/overlays and
+ the payload has no `all` ribbon data) — the null case must yield strictly more subpaths (segments),
+ proving the gap is not bridged.
+- Full suite green; eslint clean on changed files.
+
+## Visual verification (maintainer)
+Storybook `chartLibrary:"uplot"` stacked with null holes: gaps appear where data is missing (no
+bridging), top stroke follows each segment, mixed-sign (diverging) still renders. Do NOT run a dev
+server.
+
+## Constraints
+No semicolons; double quotes; 2-space indent; 100-char; ES5 trailing commas; arrow functions; imports
+at top; no description comments; NEVER mock. Preserve existing fill/stroke passes exactly.
+Test: `yarn jest --config ./jest/config.js --collectCoverage=false`.
diff --git a/docs/uplot-g9-navigation-parity-plan.md b/docs/uplot-g9-navigation-parity-plan.md
new file mode 100644
index 000000000..e52038b80
--- /dev/null
+++ b/docs/uplot-g9-navigation-parity-plan.md
@@ -0,0 +1,88 @@
+# G9 — uPlot Navigation Parity (prod-readiness) — plan / mandate
+
+> Parity sub-project G9. **Driver: ship uPlot in production without losing any dygraph
+> functionality.** Acceptance = dygraph parity, same UX contracts. This doc covers the navigation +
+> cursor + CSS gaps verified against source; the broader prod-readiness gap map (renderer-internal +
+> app-coupling) is being expanded by two exploration audits and will feed follow-on parts.
+
+## Verified gaps (evidence in hand)
+
+### 1. Cursors not applied on uPlot · quick, user-visible
+`cursorStyle` (`src/components/helpers/cursorStyle.js:3-13`: default→default, pan→`grabbing`(:active),
+select→`col-resize`, highlight→`crosshair`, selectVertical→`row-resize`) is pulled in ONLY inside the
+`dygraph` branch of the `chartLibraries` map (`src/components/line/chartContentWrapper.js:14-45`, mixin
+at `:43`). `StyledContainer` applies `chartLibraries[chartLibrary] || ""` (`:48`) → uPlot gets no
+cursor rules. The `navigation` prop is already passed for every renderer (`:57,68`).
+**Fix:** apply `cursorStyle` for uPlot too — add a `uplot` entry to `chartLibraries` (paired with the
+CSS from part 2), or hoist the `cursorStyle` mixin so it applies regardless of `chartLibrary`. uPlot's
+own stylesheet sets no cursor on `.u-over` (only `.u-series th`), so nothing overrides it.
+
+### 2. uPlot layout CSS missing from the shipped library · quick, breaks visuals in prod
+`import "uplot/dist/uPlot.min.css"` exists ONLY in `.storybook/preview.js:5`. The library source
+imports it nowhere, and the build is Babel-only (`package.json:16-18`, `babel src --copy-files`, no
+bundler) with ZERO existing `.css` side-effect imports — dygraph ships its styling via
+styled-components, not a CSS file. So a prod consumer gets uPlot with no `.u-over/.u-under`
+positioning, no `.u-select` drag rectangle, no `.u-cursor-*` crosshair lines.
+**Fix (build-safe, matches dygraph):** port the required uPlot rules into a `uplot` styled-components
+block in `chartContentWrapper.js` (the minimal set from `uplot/dist/uPlot.min.css`: `box-sizing`,
+`.u-wrap{position:relative;user-select:none}`, `.u-over,.u-under{position:absolute}`,
+`.u-under{overflow:hidden}`, `.uplot canvas{display:block;position:relative;width:100%;height:100%}`,
+`.u-axis{position:absolute}`, `.u-select{background:rgba(0,0,0,0.07);position:absolute;
+pointer-events:none}`, `.u-cursor-x/-y/-pt`, `.u-*.u-off{display:none}`). Avoid a raw `import ".css"`
+(would break the CJS `require` build path). Co-locate with the part-1 cursor entry.
+
+### 3. Modifier-key navigation switching · biggest behavioral gap
+dygraph `src/chartLibraries/dygraph/navigation/generic.js:20-40`: on `mousedown`, Shift→`select`,
+Alt→`highlight`, Shift+Alt→`selectVertical`, written via
+`updateAttributes({ navigation, prevNavigation })`; `mouseup` restores `prevNavigation`
+(`generic.js:35-40`). uPlot's `attachNavigation` `onDown` only handles `navigation === "pan"`
+(`src/chartLibraries/uplot/index.js`), no modifier switching.
+**Fix:** in uPlot's over `mousedown`, replicate the modifier→mode mapping and the
+`navigation`/`prevNavigation` attribute contract; restore on `mouseup`. Because the SDK plugins and
+the `navigation` attribute are renderer-agnostic, switching the attribute reuses the existing
+select/highlight/selectVertical drag paths uPlot already has.
+
+### 4. `highlightStart` fires at drag-end, not drag-start
+dygraph emits `highlightStart`/`highlightVerticalStart` on `mousedown`
+(`navigation/select.js:9`, `selectVertical.js:9`), so the SDK plugins set
+`highlighting:true`+`enabledHover:false` DURING the drag (`src/sdk/plugins/select.js:5-7`,
+`selectVertical.js:5-7`). uPlot emits start+end together at drag-end (`onSetSelect`,
+`uplot/index.js:547,552`) → hover isn't suppressed mid-drag and the `highlighting` render-guard never
+engages while selecting.
+**Fix:** emit `highlightStart`/`highlightVerticalStart` from a `mousedown` in select/highlight/
+selectVertical modes (keep the end emission in `onSetSelect`).
+
+### 5. Missing drag threshold + wheel-zoom modifier mismatch
+- dygraph ignores drags < 5px (`select.js:47`, `selectVertical.js:48`); uPlot fires on any
+ `select.width > 0` (`uplot/index.js:549`) → a twitch zooms/highlights. Add the 5px threshold.
+- dygraph wheel-zoom is gated on Shift/Alt (`generic.js:47` early-return); uPlot zooms on plain wheel
+ (`uplot/index.js` `onWheel`). **Decision: match dygraph** — gate uPlot wheel-zoom behind Shift/Alt
+ (plain wheel does nothing), reproducing `generic.js:47`.
+
+## Sequencing
+1 + 2 together (same `chartContentWrapper` edit) → 3 (modifier switching) → 4 → 5. Each TDD'd where a
+seam exists (3/4/5 are unit-testable via synthetic events like the existing pan/touch tests; 1/2 are
+CSS, maintainer-verified in Storybook — the perf/nav stories already exercise both renderers).
+
+## Tests (real, no mocks — makeTestChart; synthetic mouse/wheel events on `u.over`)
+- Modifier switch: `mousedown`+Shift sets `navigation:"select"` and `prevNavigation`; `mouseup`
+ restores. Same for Alt→highlight, Shift+Alt→selectVertical.
+- highlightStart: a `mousedown` in select mode emits `highlightStart` before any `mouseup`.
+- threshold: a <5px drag emits no `highlightEnd`; a >5px drag does.
+- wheel gating: per the chosen behavior.
+- Full suite green; eslint clean on changed files.
+
+## Visual verification (maintainer, Storybook — do NOT run a dev server on their behalf)
+`chartLibrary:"uplot"`: pan shows `grabbing`, select `col-resize`, highlight `crosshair`,
+selectVertical `row-resize`; the drag-select rectangle is visible; Shift/Alt temporarily switch modes
+and release restores.
+
+## Out of scope here → tracked separately
+Renderer-internal parity (options/tickers/plotters/formatting/gaps) and app-level dygraph coupling
+(`getDygraph`/`chartLibrary==="dygraph"` branches, `usePlotArea`, popover/indicators/alertTimeline)
+are being enumerated by the two exploration audits; findings become G10+ parts.
+
+## Constraints
+No semicolons; double quotes; 2-space indent; 100-char; ES5 trailing commas; arrow functions; imports
+at top; no description comments; NEVER mock; JSX files import React. Commit in logical chunks.
+Test: `yarn jest --config ./jest/config.js --collectCoverage=false`.
diff --git a/docs/uplot-migration-design.md b/docs/uplot-migration-design.md
new file mode 100644
index 000000000..b27323a7d
--- /dev/null
+++ b/docs/uplot-migration-design.md
@@ -0,0 +1,102 @@
+# uPlot migration — design spec
+
+**Background & rationale:** see `docs/charting-library-exploration.md` (the exploration reference —
+library comparison, SDK↔library contract, dygraph coupling, domain features). This spec formalizes
+the design; that doc is the "why".
+
+**Goal:** replace dygraphs with uPlot as the Netdata time-series renderer, incrementally and
+behind the existing `chartLibrary` abstraction, without regressing any chart feature.
+
+## Phasing
+
+- **Phase 0 — renderer/type decoupling (this spec's detailed plan).** Make the time-series
+ renderer configurable per chart type so uPlot can be selected safely in-app. Prerequisite for
+ everything else. Detailed plan: `docs/uplot-phase0-plan.md`.
+- **Phase A — uPlot parity** (planned per-subsystem after Phase 0 + first perf read): line/area →
+ navigation/selection → shared overlay primitives (crosshair/alert/anomaly/annotation) → tickers
+ & unit/timezone/range reactions → stacked/diverging/bar/heatmap → sparkline. Then measure real
+ dashboard CPU/memory/frame-time/bundle delta, then flip the `line` default to uPlot, then remove
+ dygraph.
+- **Phase B — ECharts consolidation** (later): pie/gauge/easyPie/bars.
+
+## Phase 0 design
+
+### Problem (verified)
+The time-series renderer is hardcoded to `"dygraph"` at three sites:
+- `src/sdk/makeChart/filters/makeControllers.js:122-158` — `updateChartTypeAttribute` sets
+ `chartLibrary:"dygraph"` for every dygraph-backed chart type (line/stacked/area/stackedBar/
+ multiBar/heatmap) and gates UI rebuild on `prevChartLibrary !== "dygraph"`.
+- `src/components/toolbox/chartType.js:131-136` — `value = chartLibrary === "dygraph" ? chartType
+ : chartLibrary`; then `items.find(v => v===value)` **throws** if `value` is unknown (e.g. a
+ non-dygraph time-series renderer).
+- `src/components/toolbox/settings/tabs/chartType.js:142-148` — same `=== "dygraph"` assumption;
+ `options.find(...) || options[0]` fallback (no throw, but wrong selection).
+
+(4th, non-blocking: `src/components/line/chartContentWrapper.js:14` maps `chartLibrary`→CSS with a
+`|| ""` fallback — noted, not required for Phase 0.)
+
+### Mechanism
+Introduce a **per-chart-type renderer map** so each dygraph-backed chart type resolves to a
+configurable renderer. This enables *incremental* migration — e.g. flip `line` to uPlot while
+`heatmap` stays on dygraph until its plotter is ported.
+
+- **New SDK default attribute `chartLibrariesByType`** (map chartType → renderer), defaulting to
+ the current behavior:
+ ```js
+ chartLibrariesByType: {
+ line: "dygraph", stacked: "dygraph", area: "dygraph",
+ stackedBar: "dygraph", multiBar: "dygraph", heatmap: "dygraph",
+ }
+ ```
+ Set in `src/makeDefaultSDK.js` alongside `chartLibrary: "dygraph"`; overridable per SDK/app.
+ Attribute overrides are **shallow-merged** (`makeDefaultSDK.js` spreads `...attributes`), so
+ passing `{ line: "uplot" }` **replaces** the whole map — omitted types still resolve to
+ `"dygraph"` via `getRendererForChartType`'s fallback, so a partial override is safe. Spread the
+ default into the override to keep other entries explicit.
+- **New chart helpers** (added to `makeControllers` return → spread onto the node at
+ `makeChart/index.js:423`):
+ - `getRendererForChartType(chartType)` → `chartLibrariesByType[chartType] || "dygraph"`.
+ - `isTimeSeriesRenderer(chartLibrary)` → `true` if `chartLibrary` is any value present in
+ `chartLibrariesByType` (or the literal `"dygraph"` default) — i.e. it renders chart *types*
+ rather than being a standalone library.
+- **`updateChartTypeAttribute(selected)`**: for a time-series chart type, resolve
+ `const next = getRendererForChartType(selected)`, set `{ chartLibrary: next, chartType:
+ selected }`, and rebuild the UI when `prevChartLibrary !== next` (instead of `!== "dygraph"`).
+ Standalone-library branch unchanged.
+- **Both toolbox components**: `value = isTimeSeriesRenderer(chartLibrary) ? chartType :
+ chartLibrary`, and guard `items.find(...)`/`options.find(...)` against `undefined` so an
+ unmapped value can never throw.
+
+### Design decisions to confirm (at spec review)
+1. **Attribute name** `chartLibrariesByType` (map keyed by chart type). Alternative: a single
+ `defaultChartLibrary` scalar (simpler, but no per-type incremental rollout). *Recommendation:
+ the map — it is what makes migration incremental.*
+2. **Where configured:** an SDK default attribute (per-SDK/app override), not per-React-component
+ prop. Your 1b note said "check against `component.defaultChartLibrary`" — this spec instead
+ exposes `chart.isTimeSeriesRenderer(...)` / `chart.getRendererForChartType(...)` so the same
+ logic serves the controller and both components. Confirm this placement/naming.
+3. **uPlot stays out of the menu** — users pick `line`, which resolves to the configured renderer.
+ uPlot is never a directly-selectable menu item.
+
+### Non-goals for Phase 0
+Phase 0 is **selection plumbing only — it does not render uPlot**; dygraph stays the default
+renderer for every type. No uPlot feature work, and no uPlot-rendering concerns such as shipping
+`uplot/dist/uPlot.min.css` (which is **functional** — it drives `.uplot` layout and cursor
+positioning, not just styling) — those are Phase A. After Phase 0, setting
+`chartLibrariesByType.line = "uplot"` with uPlot registered via `sdk.addUI` routes line charts to
+uPlot without breaking the toolbox.
+
+## Testing strategy
+- **Jest** (`makeTestChart`, `renderWithChart`): controller resolves the mapped renderer; toolbox
+ components don't throw and select correctly when the time-series renderer is non-dygraph.
+- **Storybook**: per §7 of the exploration doc — register uPlot via `sdk.addUI`, exercise the
+ scenario checklist (synced charts, empty transitions, live updates, resize, virtualization
+ remounts, unit/timezone). Bare container now; full wrapper after Phase 0.
+
+## Global constraints (from repo conventions)
+- No semicolons, double quotes, 2-space indent, 100-col, ES5 trailing commas, arrow functions.
+- No descriptive inline comments; only real function docs.
+- Lowercase filenames for JS/components.
+- Tests never mock netdata-ui/providers/components; use real imports + `makeTestChart`.
+- Peer deps only (React 19, styled-components 6, @netdata/netdata-ui); no new runtime deps in
+ Phase 0.
diff --git a/docs/uplot-migration-progress.md b/docs/uplot-migration-progress.md
new file mode 100644
index 000000000..0f89f9591
--- /dev/null
+++ b/docs/uplot-migration-progress.md
@@ -0,0 +1,362 @@
+# uPlot migration — progress & handoff
+
+> **START HERE for open parity work: `docs/uplot-parity-worklist.md`.** It holds the results of a
+> four-domain systematic audit (options surface, interaction model, data path, lifecycle/overlays)
+> with per-item `file:line` evidence, a recommended order, and the session's gotchas. This file
+> remains the history + perf protocol; the older `uplot-prod-parity-gap-map.md` is superseded for
+> open items.
+
+> Shipped as **PR #234** (branch `feat/uplot-renderer`), squashed from the `explore/uplot-spike`
+> spike branch, which is kept unsquashed as the history record. Last updated: 2026-09-04.
+>
+> **The renderer is opt-in; the shipped default stays `chartLibrary: "dygraph"`.** The flip is
+> deliberately out of the PR, gated on the real-dashboard measurement in "Task 3" below.
+>
+> Verified on the PR branch: full suite **184 suites / 1928 passing / 2 skipped**, of which the
+> uPlot renderer is **15 suites / 250 tests**; `yarn build` clean (537 CJS / 540 ES6).
+>
+> **Rebased onto `main` (#222–#229).** All of #222–#229 audited for uPlot parity drift: #222
+> (renderIfStale boolean contract) and non-stepped line smooth curves were ported (`b811b15`,
+> `01eb4a1`); the rest are NO-OP for uPlot. Full record: `docs/uplot-prod-parity-gap-map.md`
+> (RECONCILED section).
+> Background/decision: `docs/charting-library-exploration.md`. Design: `docs/uplot-migration-design.md`.
+> Phase 0 plan: `docs/uplot-phase0-plan.md`.
+> uPlot source reference (demos used throughout): a local checkout of the uPlot repo.
+
+Goal: replace dygraphs with **uPlot** as the Netdata time-series renderer, incrementally, behind
+the SDK's `chartLibrary` abstraction. This doc is the pick-up point for a new session.
+
+## Where it lives
+- Chart library: `src/chartLibraries/uplot/` (~2,540 LOC excluding tests)
+ - `index.js` — the chart-library module (the `(sdk, chart) => instance` contract)
+ - `stacking.js` (+ `.test.js`) — pure diverging-stack math
+ - `bars/` — vendored uPlot demo helpers: `quadtree.js`, `distr.js`, `stack.js`, `seriesBarsPlugin.js`
+ - `index.test.js` — real-uPlot tests (jsdom + jest-canvas-mock, no library mocking)
+- Registered in `src/makeDefaultSDK.js` `ui` map (first-class, like dygraph).
+- Storybook: `chartLibrary` control (dygraph | uPlot) on all `src/index.stories.js` stories; uPlot
+ CSS imported globally in `.storybook/preview.js`.
+
+## Commits on the branch (oldest → newest)
+```
+8cb61e4 spike (mount/render/line/area/hover/crosshair)
+d036b21 Phase 0: decouple time-series renderer from chart type
+c7d9da0 docs: exploration + design spec + Phase 0 plan
+6d25ee0 fix: create uPlot only when mounted; line/area parity
+560adb6 feat: chartLibrary control on Line stories + uPlot CSS
+622a0dc docs: contract matrix (line/area parity)
+65d973a feat: navigation (pan, drag-zoom, wheel, dblclick reset)
+2f3229a feat: bars/stepped paths (plain-bars path here was later reverted)
+8f1051d refactor: register uplot in makeDefaultSDK; drop story addUI
+c09f56c feat: render modes — diverging stacked area + grouped/stacked bars
+```
+
+## Done (verified by tests; render/nav also visually confirmed in Storybook)
+- **Lifecycle**: `mount`/`unmount`/`render`/`getUPlot`; **created only when mounted** (a
+ render-before-mount bug orphaned uPlot on a null element — guarded in `render`/`create`).
+- **Line / area**: columnar transform of `payload.data` (ms→uPlot seconds), per-dimension palette
+ colors, theme-aware axes (`themeGridColor`/`themeLabelColor`), area fill.
+- **Ranges**: x from `getDateWindow()`; y honors `getValueRange`/`staticValueRange`.
+- **Axis formatting**: x via `chart.formatXAxis` (timezone-aware); y via `getConvertedValueWithUnit`.
+- **Reactions**: `theme`, `chartType`, `selectedLegendDimensions`, `navigation`,
+ `enabledNavigation`, `staticValueRange`, `timezone`, `unitsConversionPrefix`, `hoverX`/`clickX`.
+- **Empty / outOfLimits**: clears the chart; `render` skips while `processing`/`panning`/`highlighting`.
+- **Hover**: emits `highlightHover`/`highlightBlur`/`hoverChart`/`blurChart`; gated by `enabledHover`.
+- **Crosshair**: receives synced `hoverX`/`clickX` via a `draw` hook + `valToPos` + `ctx`.
+- **Sparkline**: axes hidden; **plot-area sizing** (`getChartWidth/Height` from `u.over`).
+- **Navigation**: drag-select zoom (select/highlight), selectVertical, custom pan, wheel zoom,
+ dblclick → `resetNavigation`; mode from `navigation`, gated by `enabledNavigation`.
+- **Stacked area (diverging)**: `stacking.js` (per-value +/- accumulation matching
+ `dygraph/divergingStack.js`) drawn as filled polygons in a `draw` hook; series draw no line
+ (`nullPathBuilder`); y-range spans the stack extremes.
+- **Bars**: `multiBar` → grouped, `stackedBar` → stacked (`stack()` + `bands`) via the vendored
+ `seriesBarsPlugin` (ordinal x). `groupWidth: 0.6` for visible gaps. Bar-type point reduction is
+ already handled by `pointMultiplierByChartType` (`api/helpers.js`, `multiBar`/`stackedBar` = 0.1).
+- **stepped** lines for `stepPlot`.
+- **Phase 0**: `chartLibrariesByType` map + `getRendererForChartType`/`isTimeSeriesRenderer`
+ (`makeControllers.js`); toolbox `ChartType` components resolve via `isTimeSeriesRenderer` and no
+ longer throw on a non-dygraph renderer.
+
+## Key gotchas / architecture notes
+- **uPlot's CSS is inlined, not imported.** The rules it actually needs (`.u-wrap`, `.u-over`,
+ `.u-under`, `.u-axis`, `.u-select`, `.u-cursor-*`, `.u-off`) live in
+ `components/line/chartContentWrapper.js`, scoped to the chart container. Upstream's remaining
+ selectors are legend/title chrome only, and the renderer sets `legend: { show: false }`
+ (`uplot/index.js:1418`) with no title — so **no consumer needs to import
+ `uplot/dist/uPlot.min.css`**. `.storybook/preview.js` imports it as well; that import is
+ redundant. (An earlier revision of this doc claimed consumers must import it. Wrong.)
+- **Bars no longer use a separate config.** The vendored `seriesBarsPlugin` and the isolated
+ `createBars` ordinal-x path are both gone; `isBarType` (`uplot/index.js:196`) branches inside the
+ single config, and bars share the time x-axis and the SDK hover bus — `highlightHover` fires from
+ `uplot/index.js:865-866` like every other type.
+- **The mock ignores requested points** (`makeMockPayload` emits `data.length` rows), so bars look
+ dense in Storybook; production's 0.1 multiplier yields genuinely wide bars.
+- **A consumer's own component map must gain a `uplot` key — this fails silently.** A host that
+ maps `chartLibrary` → React component needs `uplot` pointing at the same generic
+ `components/line` component as dygraph (`components/line` has no renderer-specific branching; the
+ renderer is resolved from the attribute through `sdk.ui`). Verified in cloud-frontend:
+ `src/charts/index.js` has a `byType` map with a `dygraph` key and no `uplot` key, and its `Chart`
+ ends in `if (!Component) return null` — so setting `chartLibrary: "uplot"` renders **empty
+ containers with no console error and nothing thrown**. Cost us a debugging session; see Task 3
+ setup below.
+- **Renderer selection**: `chartLibrariesByType` maps a chart *type* → renderer. Auto-applying it at
+ *initial* render (so a configured `line → uplot` applies before any toggle) is **deferred to the
+ flip-the-default step** because `chartType` is payload-driven (`makeDataFetch.js:121`).
+
+## Remaining work
+
+> **Items 1–4 of this list were written 2026-07-15 (`efca3cc8`) and were never revised as the work
+> landed. They are corrected below.** If you are looking for open parity items, the live list is
+> `docs/uplot-parity-worklist.md` (§ "Queued work"), not this section.
+
+**Closed since that revision — do not re-report:**
+
+1. ~~**Heatmap** — not implemented.~~ **Done.** `drawHeatmap` (`uplot/index.js:555`), heatmap
+ y-axis, value range and tick density all present, following the `latency-heatmap` demo pattern.
+2. ~~**Bars polish** — raw timestamps on x; no `highlightHover`.~~ **Done.** The separate ordinal
+ config is gone; bars share the time x-axis and fire `highlightHover` (`uplot/index.js:865-866`).
+ Negative handling is resolved through `getBarValueRange` (`uplot/index.js:310`).
+3. ~~**Overlays** — alert / anomaly / annotation are dygraph-only.~~ **Done.** Seven overlays under
+ `chartLibraries/uplot/overlays/` (alarm, alarmRange, alertTransitions, annotation, highlight,
+ point, proceeded) plus the anomaly ribbon and anomaly-rate badge in `plotters/`, each with tests.
+ The `chartLibrary === "dygraph"` guard in `components/line/overlays/annotation/index.js` is gone.
+4. ~~**Stacked area polish** — nulls bridged, no top stroke.~~ **Done.** `traceStackTop`
+ (`uplot/index.js:106`, called at `:495`/`:508`) strokes the stack top; `gapEdgeIndexes`
+ (`uplot/index.js:91`, used at `:243`) handles gaps.
+
+**Still open:**
+
+5. **Multi-node / grouped payloads, groupBoxes/table/gauge/etc.** — untouched (still their own libs);
+ only the time-series family is being moved.
+6. **Flip-the-default.** The SDK-side wiring is DONE and tested (`makeControllers.test.js:277-355`):
+ `chartLibrary` is the single selector, `chartLibrariesByType` defaults to `{}` and only overrides
+ per-type, `getRendererForChartType` falls back to `chartLibrary`, `isTimeSeriesRenderer` uses the
+ `["dygraph","uplot"]` set. Timeseries charts inherit the root attribute; gauge/pie/table keep
+ their own.
+
+ **Correction: this is NOT the "one-attribute change" earlier revisions of this doc claimed.** In
+ cloud-frontend it takes three changes, two of them permanent, and two of the three fail silently:
+ - `src/charts/index.js` — add `uplot: Line` to the `byType` map. Without it `Chart` hits
+ `if (!Component) return null` and every chart is an empty container, no console error.
+ - `src/domains/charts/toc/getMenuChartAttributes.js` — the returned attributes hardcode
+ `chartLibrary: "dygraph"`, spread into every menu chart by `getMenu.js:205`. It overrides the
+ root attribute, so without removing it the A/B measures dygraph on **both** runs.
+ - `src/components/sdkProvider/index.js` — the root `chartLibrary` attribute itself.
+
+ Roughly 33 further charts pin `chartLibrary: "dygraph"` per-context in cloud-frontend's
+ `contexts.js` and `taxonomy/*` and stay on dygraph regardless. That is not a blocker: the perf
+ HUD buckets samples per renderer (`perfMonitor/registry.js:23` keys on `chartId:renderer`), so a
+ single mixed run yields `renderers.uplot` and `renderers.dygraph` side by side on the same page.
+
+ The shipped default stays `"dygraph"` until the real-dashboard go/no-go (protocol below).
+ **Still owed:** that measurement. It needs a browser on a live streaming dashboard — jsdom/jest
+ cannot paint. Playwright *is* available (`playwright@1.62.1`, devDependency) and drives
+ `yarn perf:bench` against Storybook, but there is no driver for an authenticated cloud dashboard.
+7. **Bundle** — uPlot now ships with `makeDefaultSDK` for all consumers (~48KB). Fine for now;
+ revisit at flip time if bundle size matters.
+8. **ECharts consolidation (Phase B)** — pie/gauge/easyPie/bars → ECharts. Not started.
+
+## Perf measurements (first pass — 2026-07-17)
+
+Storybook `Perf/Benchmark` story (`src/perf.stories.js`), N charts streaming the `system.load` mock,
+driven headless (Chromium via Playwright). Two independent measures, per chart-count, 40s (CDP) /
+25s (HUD) windows, one run each. dygraph → uPlot, ratio = uPlot / dygraph (`<1` = uPlot cheaper).
+
+**A. Whole-tab main-thread cost per render** — Chrome DevTools `Performance.getMetrics`
+(`TaskDuration`, paint-inclusive; includes the shared React/mock/streaming overhead identical to both
+renderers), normalised by render count (near-equal per renderer, so fair):
+
+| charts | dygraph task/render | uPlot task/render | ratio | heap end (dyg → uPlot) |
+|--------|--------------------:|------------------:|------:|------------------------|
+| 10 | 7.19 ms | 4.06 ms | ×0.56 | 88 → 37 MB |
+| 25 | 7.42 ms | 3.12 ms | ×0.42 | 85 → 64 MB |
+| 50 | 8.49 ms | 3.09 ms | ×0.36 | 121 → 100 MB |
+
+**B. Isolated renderer render+paint** — the in-repo `perfMonitor` HUD (`registry.timeRender`), which
+times only the `render()` fan-out plus the renderer's own (possibly microtask-deferred) paint:
+
+| charts | dygraph p50 / p95 | uPlot p50 / p95 | p50 ratio |
+|--------|---------------------|-------------------|----------:|
+| 10 | 2.9 / 5.1 ms | 0.5 / 0.8 ms | ×0.17 |
+| 25 | 3.5 / 5.5 ms | 0.4 / 0.6 ms | ×0.11 |
+| 50 | 5.4 / 8.0 ms | 0.3 / 0.5 ms | ×0.06 |
+
+**Takeaway:** both measures agree — uPlot is materially cheaper on main-thread cost and the advantage
+grows with chart density. B (isolated renderer) shows uPlot at 6–17% of dygraph's per-render cost; A
+(whole tab) dilutes that to 0.36–0.56× because the shared React/mock overhead is constant across
+renderers. Heap is lower on uPlot but noisy (single end-of-window sample, no forced GC).
+
+**Caveats:** the mock emits `data.length` rows regardless of requested points, so absolute ms are NOT
+production figures — only the dygraph/uPlot ratio under identical conditions is meaningful. One run
+per config, headless shell, one machine — no variance/repetition yet. Real absolute numbers need
+`yarn to-cloud` + the HUD on a live dashboard (`perfMonitor: true`).
+
+## Headless benchmark results (2026-08-03, `yarn perf:bench`)
+
+210 paired runs, 0 failures, 5 cells skipped by the 3M-point cap (logged in the report). Each cell:
+4s warmup, 10s measured window, 5 repeats per renderer, synthetic payload sized by rows × dims.
+Full table: `.perf-results/summary.md`; raw per-run data: `.perf-results/raw.json`.
+
+**Read `task/render`, not `total task`.** The two renderers do not render the same number of times.
+Target cadence is 1 render/chart/s, so 10 charts × 10s ≈ 100 renders. dygraph falls to 84 renders
+(100 dims) and 72 (5000 rows) while uPlot holds 88–100 — dygraph sheds frames under load, which
+*lowers* its total-task figure while showing staler charts. Cells where uPlot's total looks worse are
+cells where uPlot kept up:
+
+| cell | dygraph | uPlot | verdict |
+|---|---|---|---|
+| line 300×100×50 | 222 renders, 24.6 ms/render | 339 renders, 23.1 ms/render | uPlot cheaper per render, +53% throughput |
+| line 1000×100×10 | 84 renders, 31.4 ms/render | 100 renders, 35.7 ms/render | uPlot ~14% dearer per render (only real per-render loss) |
+| line 5000×20×10 | 72 renders, 88.2 ms/render | 88 renders, 85.8 ms/render | parity per render, +22% throughput |
+| stacked 1000×20×25 | 125 renders, 67.3 ms/render (p50 60.6 ms) | 254 renders, 18.1 ms/render (p50 4.3 ms) | uPlot 3.7× cheaper per render at 2× throughput |
+
+Clean apples-to-apples cells (render counts matched within ~2%) — total-task ratio uPlot/dygraph:
+300×3×10 **0.70**, 300×3×50 **0.52**, 300×20×10 **0.89**, 300×100×10 **0.95**, 1000×3×10 **0.77**,
+1000×3×50 **0.60**, 1000×20×10 **0.88**, 5000×3×10 **0.96**, heatmap 1000×20×25 **0.75**.
+uPlot wins every one, but **the margin narrows as dimension count grows** — roughly parity at 100 dims.
+
+Hover (phase B/C): total main-thread cost is consistently **0.20–0.65×** dygraph, consistent with the
+crosshair overlay change. Treat the per-render columns there as unreliable — hovering pauses autofetch,
+so those cells collect only ~9–33 renders and the per-render stddev exceeds the mean in places.
+
+**Caveats:** Storybook + synthetic mock in headless Chromium on one machine, not a real dashboard;
+absolute ms are not production figures. The remaining unknown is a real cloud-frontend dashboard —
+protocol below.
+
+**These numbers were measured on a software rasteriser.** This run predates `--use-angle=metal` in the
+driver, and headless Chromium with only `--no-sandbox` resolves WebGL through ANGLE **SwiftShader**
+(CPU), not the GPU. Canvas2D — which both dygraph and uPlot draw through — is affected by that stack.
+Re-measured under ANGLE Metal (see the 2026-08-11 section), the ratios move modestly *in uPlot's
+favour*: `300 d20 c10` 0.89→0.747, `300 d100 c10` 0.95→0.793, `5000 d3 c10` 0.96→0.698. The verdict
+here (uPlot wins every clean cell) holds and was, if anything, pessimistic. Treat this table as a
+historical record; anything measured after the flag landed is not comparable to it.
+
+**Render/fetch *counts* are harness-bound — do not read them as a product signal.** The mock resolves
+via a main-thread timer (`perf.stories.js:97` → `makeMockPayload/index.js:14`), so at high chart counts
+every chart's 300 ms delay queues behind every other chart's render work. Since the per-chart fetch
+loop schedules on `max(updateEvery, response + processing)` (`makeChart/index.js:121-138`), the heavier
+renderer posts fewer cycles *because* it renders heavier — the harness measures its own contention.
+Production fetches are off-thread, so this effect does not exist there. Compare **per-render cost**
+across renderers; use the real-dashboard protocol below for anything cadence-related.
+
+## CPU-throttled comparison incl. GPU renderers (2026-08-11)
+
+Run on branch `explore/gpu-renderers-perf` — this branch merged with PR #230 (ktsaou, WebGPU/WebGL2
+renderers) so all four renderers could be compared on one workload. Cell: **50 charts × 100 dims ×
+300 rows**, line, streaming, 10s window, **5 repeats**, ANGLE Metal, throttling via CDP
+`Emulation.setCPUThrottlingRate`. `busy%` is CDP `TaskDuration` over wall-clock.
+
+| renderer | CPU | busy% | renders | p50 ms | p95 ms |
+|---|---|---|---|---|---|
+| dygraph | 1× | 75.2±22.8 | 405±23 | 11.29±0.41 | 15.98±3.72 |
+| uplot | 1× | 64.7±13.7 | 448±44 | 2.86±0.14 | 4.85±0.17 |
+| webgl2 | 1× | 71.2±6.1 | 440±49 | 1.70±0.14 | 3.51±0.14 |
+| dygraph | 4× | (unreliable) | 119±16 | 47.18±3.31 | 57.95±7.93 |
+| uplot | 4× | 99.1±0.4 | 201±79 | 13.51±1.18 | 20.00±2.24 |
+| webgl2 | 4× | 99.3±0.6 | 293±67 | 6.60±0.24 | 10.14±0.64 |
+| dygraph | 6× | 99.4±0.2 | 60±29 | 73.42±1.89 | 83.92±2.23 |
+| uplot | 6× | 98.8±0.2 | 157±28 | 26.80±2.93 | 41.34±4.01 |
+| webgl2 | 6× | 99.7±0.1 | 172±78 | 16.64±5.38 | 24.51±7.92 |
+
+`dygraph 4×` busy came out 79.7**±39.4** — meaningless for a bounded percentage; that one cell is not
+trustworthy. Its render count and p50 are tight and stand.
+
+**1. At full speed the renderer does not affect frame count.** 405 / 448 / 440 renders are within
+noise of each other while dygraph costs **6.6× more per render** than webgl2. Throughput here is set by
+the fetch cadence, not by drawing — the same conclusion the harness-bound caveat above reaches from the
+other direction. ~0.9 frames/chart/s against a 1/s target.
+
+**2. dygraph → uPlot is unambiguous.** 2.7–3.9× cheaper per render at every throttle level with tight
+error bars, and once the machine saturates it converts into frames: at 6×, 60±29 → 157±28 (2.6×,
+non-overlapping).
+
+**3. uPlot → webgl2 buys latency, not throughput.** Per-render cost is solidly 1.6–2.1× cheaper, but
+frame counts never separate (4×: 201±79 vs 293±67; 6×: 157±28 vs 172±78 — overlapping). What does
+separate is p95 at 6×: **41.34±4.01 vs 24.51±7.92**, i.e. less worst-case jank, not more frames.
+
+**Retracted:** an earlier single run of this cell was read as "webgl2 delivers 1.7× more frames at 4×".
+Repeats put it at 1.46× with overlapping error bars, and at parity at 6×. Single runs of this cell are
+not usable — uPlot at 1× measured 64.7%±13.7 busy, and two separate single runs put idle at 53.2% and
+28.6%. Under saturation the measurement becomes stable (sd ≤ 0.6).
+
+**Caveats.** `setCPUThrottlingRate` throttles the **CPU only** — the GPU stays at full M2 Max speed,
+so every webgl2 figure here flatters it relative to a real low-end machine with a weak integrated GPU.
+The mock's main-thread delay (see caveat above) still contaminates anything cadence-related. Not a real
+dashboard; absolute ms are not production figures.
+
+**GPU heatmap defect (PR #230, not ours).** webgl2 and webgpu record **0 renders** for heatmap while
+dygraph records ~204 and uPlot ~250 on the same build — their lower main-thread cost is the cost of not
+drawing, and reads as a 2.3× win if taken at face value. Reproduced in isolation: 2 charts over 9s,
+webgl2 heatmap renders **once** (0.2 ms) vs uPlot's 16, with no console or page errors and no renderer
+fallback. Localized (dygraph+uPlot heatmap work; GPU line works; only GPU+heatmap fails) but **not
+root-caused**. PR #230's own benchmark cannot catch this class of bug — it mounts a static preview and
+captures pixels, so "renders once then stops" is invisible to it.
+
+## Task 3 — real-dashboard measurement protocol (maintainer-run, many-runs for certainty)
+
+Why maintainer-run: real render+paint timing needs a real browser on a live streaming dashboard.
+jsdom/jest can't paint, and while Playwright is available (`playwright@1.62.1`) it only drives
+Storybook via `yarn perf:bench` — there is no driver for an authenticated cloud dashboard. The mock
+ratios above are not production numbers. The go/no-go is inherently an in-app measurement.
+
+Setup (once) — **all four steps verified 2026-09-04; steps 2 and 3 fail silently if skipped**:
+1. `yarn to-cloud` from `charts/` (builds CJS+ES6 and copies into cloud-frontend `node_modules`).
+2. `cp -R node_modules/uplot ../cloud-frontend/node_modules/uplot`. `cp-cloud` copies `dist` only
+ and installs nothing, and the compiled renderer does `require("uplot")`
+ (`dist/chartLibraries/uplot/index.js`). Transitive resolution only kicks in after a real
+ publish + install, so for a local loop the module has to be physically present. No
+ `package.json` change is needed — see the CSS note in "Key gotchas": the stylesheet does *not*
+ need importing.
+3. In cloud-frontend, add `uplot: Line` to the `byType` map in `src/charts/index.js`, and remove the
+ hardcoded `chartLibrary: "dygraph"` from `src/domains/charts/toc/getMenuChartAttributes.js`.
+ Without the first, every chart is an empty container (`if (!Component) return null`, no error);
+ without the second, the per-chart attribute overrides the root one and both halves of the A/B
+ measure dygraph.
+4. Set the dashboard SDK root attributes `chartLibrary: "uplot"` and `perfMonitor: true` in
+ `src/components/sdkProvider/index.js` (the HUD self-mounts to `document.body`; A/B by toggling
+ `chartLibrary` back to `"dygraph"` for the paired run). Keep everything else identical between
+ the two runs of a pair. Do not run `yarn install` in cloud-frontend mid-measurement — it wipes
+ both the copied `dist` and `uplot`.
+
+Per data point (repeat for a matrix of dashboard sizes — e.g. a small ~10-chart view and a dense
+~50+ chart view, on the same page, same time window, same theme):
+1. Load the page, let it stream to steady state (~15s), then HUD **reset** to start a clean window.
+2. Stream a fixed window — **≥60s** — untouched (no interaction; interaction jank is out of scope).
+3. HUD **copy** → paste the JSON (per-renderer `count`, `p50`/`p95`/`max` ms, current+peak heap).
+ Read the `renderers.` entries, not `overall` — cloud-frontend pins ~33 charts to dygraph
+ per-context, so a run is mixed and `overall` blends both. `window.__netdataPerf.snapshot()` and
+ `.reset()` are exposed for driving this from the console instead of clicking.
+4. Toggle `chartLibrary` to the other renderer, repeat 1–3 for the paired run.
+5. **Repeat the whole pair ≥5 times** (fresh reload each time) to get variance — report mean ± stddev
+ of the p50/p95 **ratio** (uPlot/dygraph), not single runs. The ratio cancels shared React/stream
+ overhead; the stddev is what turns "one number" into "certain."
+
+Go/no-go read: uPlot's p50 and p95 render cost should be ≤ dygraph's across every size, with the gap
+widening as chart density grows (the Storybook ratios predict 0.36–0.56× whole-tab, 0.06–0.17×
+isolated). Watch heap peak too (best-effort, Chrome-only). If uPlot wins consistently across the
+repeats, flip the shipped default to `chartLibrary: "uplot"` (`makeDefaultSDK.js:42`) **and** land
+the permanent cloud-frontend changes from item 6 above; otherwise keep dygraph and file the
+regressions.
+
+Parity-consistency pass (run alongside perf, same build): with `chartLibrary: "uplot"`, walk the
+Storybook `Charts`/`RenderModes` stories and the real dashboard across all chart types (line, area,
+stacked, stackedBar, multiBar, heatmap, sparkline) and interactions (hover popover, cross-chart sync,
+pan/zoom/select, overlays) — the line charts now draw dygraph-identical smooth curves (`01eb4a1`).
+
+**Harness note:** the HUD (measure B) initially reported uPlot at ~0 ms because uPlot defers its paint
+to `microTask(_commit)`, outside the synchronous `timeRender` window, while dygraph paints
+synchronously — an unfair artifact. Fixed by recording from a `queueMicrotask` after `fn()` so the
+timing spans the deferred paint (commit `8c8fbd2`).
+
+## How to verify
+- Tests: `yarn jest --config ./jest/config.js src/chartLibraries/uplot/ --collectCoverage=false`
+ (**15 suites / 250 tests** as of 2026-09-04). Full suite: `yarn jest --config ./jest/config.js`
+ (**184 suites / 1928 passing / 2 skipped**). Build: `yarn build` (537 CJS / 540 ES6).
+- Visual: `yarn storybook` → any **Charts** story → toolbar **Chart library: uPlot** → switch chart
+ types via the header toolbox. (Do NOT run dev servers on the maintainer's behalf — they verify.)
+
+## Not ours (leave uncommitted)
+`docs/sre-exploration-audit.md` is the maintainer's own audit — untracked throughout and excluded
+from every commit.
+
+(Historical: `src/components/toolbox/settings/numberFormat.js` was listed here as in-flight
+maintainer work. It has since landed on `main` independently and is not part of this branch's diff.)
diff --git a/docs/uplot-parity-gap-map.md b/docs/uplot-parity-gap-map.md
new file mode 100644
index 000000000..91a54c1b5
--- /dev/null
+++ b/docs/uplot-parity-gap-map.md
@@ -0,0 +1,97 @@
+# uPlot ↔ dygraph parity — verified gap map
+
+> Branch `explore/uplot-spike`. Built from three read-only code audits (chartTypes/routing, uPlot
+> current state, overlays/actions coupling). Every claim has file:line evidence. This is the
+> orchestration backbone for the "full functionality parity" effort.
+
+## Scope clarification (confirm before planning)
+
+"All plotters/chartTypes on uPlot" = the **time-series family only**: `line`, `stacked`, `area`,
+`stackedBar`, `multiBar`, `heatmap` (`src/components/toolbox/chartType.js:26-77`). The other `ui`
+entries — `table`, `bars`, `easypiechart`, `gauge`, `d3pie`, `number`, `groupBoxes`
+(`src/makeDefaultSDK.js:25`) — are **distinct chart libraries selected directly**, not chartTypes
+routed to a renderer; they are **out of uPlot's scope** and uPlot should not replace them.
+
+## Already done on uPlot (verified)
+
+- **ChartTypes**: line, area, stacked (diverging, via `draw`-hook polygons + `stacking.js`), multiBar,
+ stackedBar (vendored `seriesBarsPlugin`), stepped, sparkline — `src/chartLibraries/uplot/index.js:55-96,340-381`.
+- **Actions/navigation**: pan, wheel-zoom, drag-select, selectVertical, hover, crosshair,
+ dblclick-reset — all emit the **same SDK events** as dygraph (`index.js:205-338`). The nine SDK
+ action plugins (`src/sdk/plugins/*`) are **renderer-agnostic** and work unchanged (grep: zero
+ dygraph refs). Navigation does NOT need porting.
+- **Axes/units/theme/ranges/timezone** for the line/area/stacked path — `index.js:87-146`.
+
+## Gaps (what full parity requires)
+
+### G1. Overlay positioning foundation (enabler)
+dygraph's canvas layer emits `overlayedAreaChanged:` to position the React DOM overlays
+(`src/chartLibraries/dygraph/overlays/helpers.js:22-23`). uPlot emits **nothing**, so
+`container.js:77` (`if (!area && !fixed) return null`) makes alarm/alarmRange/highlight/proceeded
+badges **silently never render** on uPlot. Also `usePlotArea` (`src/components/provider/selectors.js:683`)
+reads `getDygraph?.().getArea()` → `{left:0,width:0}` on uPlot → `AlertTimeline` misaligns silently
+(`src/components/alertTimeline/index.js:183`). **Fix:** uPlot emits `overlayedAreaChanged` from its
+plot area, and a renderer-agnostic plot-area accessor replaces the dygraph-only `usePlotArea` path
+(uPlot already has `getChartWidth/Height` off `u.over`). Prerequisite for all React overlays.
+
+### G2. Alert overlays (canvas draw layer)
+alarm, alarmRange, alertTransitions, highlight shading, proceeded-area, crosshair-point are
+dygraph-only (`src/chartLibraries/dygraph/overlays/*`, using `toDomXCoord`/`hidden_ctx_`/`getArea`).
+**Port target exists:** uPlot's `draw` hook (`index.js:199-203`) already draws crosshair + stacked
+fill — same pattern, reimplemented with `u.valToPos`/`u.ctx`/`u.bbox`. Depends on G1 for badges.
+Highest user-facing impact (alerts are on nearly every chart).
+
+### G3. Anomaly ribbon + annotations strip
+Implemented as **fake dygraph series with custom plotters** (`dygraph/plotters/anomaly.js`,
+`plotters/annotations.js`, registered `dygraph/index.js:80,88`); hit-tested via pixel bands
+(`dygraph/hoverX.js:40-41`). No uPlot analog. Plus the annotation React component is hard-gated to
+dygraph (`src/components/line/overlays/annotation/index.js:412` uses `getDygraph()`/`canvas_`/
+`toDomXCoord`), so annotation hover popovers are unreachable on uPlot. **Fix:** draw-hook ribbon +
+strip, hit-testing bands, and a uPlot path for the annotation component. Builds on G1/G2 patterns.
+
+### G4. Bars parity (multiBar / stackedBar)
+Bars render but via a **separate uPlot instance with no cursor/hooks/navigation**
+(`createBars`, `index.js:361-391` returns before `attachNavigation`): no SDK hover emission, no
+crosshair/pan/zoom/select/dblclick, **ordinal x-scale shows raw index not formatted time**
+(`bars/seriesBarsPlugin.js:148-150`), y clamped `[0,max]` so **negatives are clipped**
+(`seriesBarsPlugin.js:69-72`), axes unstyled/unformatted. Self-contained (isolated in `createBars`).
+
+### G5. Heatmap chartType (the one missing plotter)
+Zero uPlot implementation. Data primitives are already renderer-agnostic
+(`chart.getVisibleHeatmapIds`/`getHeatmapScale`/`getHeatmapYIndex`, `src/sdk/makeChart/makeDimensions.js:272-299`).
+**Fix:** a `draw`-hook plugin painting colored bucket cells (analogous to
+`dygraph/plotters/heatmap.js`) + a custom y-axis ticker (analogous to `dygraph/tickers/heatmap.js`).
+Also **resolves the routing-safety gap**: today if `chartLibrary:"uplot"` and payload sets
+`chartType:"heatmap"`, `getRendererForChartType` falls back to `"uplot"` and would render a heatmap
+on a renderer with no heatmap support (`makeControllers.js:124-126`).
+
+### G6. Hover fidelity + click actions
+uPlot `setCursor` always reports the **first** visible dimension, not nearest-series/nearest-Y
+(`index.js:107,226`; dygraph does real closest-point detection, `dygraph/hoverX.js:33-79`). Missing:
+diverging-stacked-point and heatmap-column hit-testing; click-to-annotate (`draftAnnotation`/
+`annotationCreate`); `highlightClick`; and uPlot fires only `sdk.trigger`, not `chart.trigger`
+(dygraph fires both — matters for direct `chart.on(...)` listeners).
+
+### G7. Touch / mobile navigation
+No touch handlers on uPlot (dygraph has touch + double-tap, `navigation/generic.js:104-141`).
+
+### G8. Stacked-area polish
+Null/gap bridging and top stroke (per `docs/uplot-migration-progress.md`).
+
+## Proposed decomposition & sequence
+
+Each is an independent spec→plan→execute cycle (same rigor as the perf harness). Recommended order:
+
+1. **G1 Overlay foundation** — prerequisite enabler (unblocks all React overlays + AlertTimeline). Small, high-leverage.
+2. **G2 Alert overlays** — highest user-facing impact; depends on G1.
+3. **G5 Heatmap** — distinct, independent of overlays; closes the routing-safety gap. Can run parallel to G2.
+4. **G4 Bars parity** — self-contained; independent.
+5. **G3 Anomaly/annotation** — builds on G1/G2 patterns.
+6. **G6 Hover fidelity + click actions** — refines existing hover.
+7. **G7 Touch nav** + **G8 stacked polish** — small polish, last.
+
+## Coordination note
+
+The frontend-design subagent is currently editing `src/chartLibraries/uplot/index.js` (visual
+polish + showcase story). Any parity implementation touching that file (G1/G2/G4/G5/G6) must land
+**after** that work to avoid conflicts; G-work will rebase on the design changes.
diff --git a/docs/uplot-parity-worklist.md b/docs/uplot-parity-worklist.md
new file mode 100644
index 000000000..b9ec51484
--- /dev/null
+++ b/docs/uplot-parity-worklist.md
@@ -0,0 +1,632 @@
+# uPlot ↔ dygraph parity — audit worklist & handoff
+
+> Branch `explore/uplot-spike`, rebased on `main` @ `5a69d342` (#232). Written 2026-08-05.
+> Companion docs: `docs/uplot-prod-parity-gap-map.md` (older P0/P1/P2 map, now superseded by
+> this list for open items), `docs/uplot-migration-progress.md` (history + perf protocol).
+>
+> **Governing rule (revised 2026-08-05 — supersedes "match dygraph exactly"):** feature parity on
+> what is achievable, with **no missing data, information or functionality**. "Looks good" is the
+> visual bar, *not* pixel-perfect. **Where uPlot is deliberately better, keep the better version —
+> the contract matters more than the resemblance.** Making anything slower, heavier or worse for the
+> sake of similarity is wrong. dygraph remains the reference for *what the feature is*:
+> `src/chartLibraries/dygraph/**` and `node_modules/dygraphs/`.
+
+## State
+
+- Default renderer is **already flipped to uPlot** (`src/makeDefaultSDK.js:42`) on this branch only.
+- Suite green: **185 suites / 1899 passing / 2 skipped**. eslint clean.
+- Verify with `yarn jest --config ./jest/config.js --collectCoverage=false` and
+ `yarn eslint `. Gate every change on the FULL suite + eslint before committing.
+- Perf harness: `yarn perf:bench` (full sweep) / `yarn perf:bench:quick`. See §Perf below.
+
+## Decisions taken by the maintainer (2026-08-05)
+
+| # | Decision | Consequence for this list |
+|---|---|---|
+| D1 | Land **all of §1–§8**, then the perf sweep and screenshot pairs | Nothing in §1–§8 is deferred; commit per gate |
+| D2 | Match dygraph's **geometry, line widths, point markers and bar outlines**, but **keep uPlot's area gradient and filled sparkline** | §6: align widths (line 1.5, area 0.7, stacked edge 0.1), suppress auto point markers, add darkened bar outline, align crosshair dash/colour. **Do not** replace `makeAreaFill`'s gradient with dygraph's flat `fillAlpha 0.2`, and **do not** convert sparklines from fill to stroke. Screenshot pairs will differ on these two by design. |
+| D3 | Verify in a **real browser** (Playwright) and run the **full** `yarn perf:bench` | Geometry, click routing, touch and the UNVERIFIED timezone claim are browser-gated, not jsdom-gated |
+| D4 | **Grep `cloud-frontend` first** for `getPreceded` / chart-bus `highlightEnd`; implement only what is consumed | §7/§8: evidence-gated, not implemented unconditionally |
+
+Additional standing assumptions: the three click tests that encode the inverted contract
+(`uplot/index.test.js:936, :953, :1001`) get **rewritten, not deleted**; the uPlot default flip stays
+on this branch and is not merged to `main`.
+
+### D5 — the "better wins" ruling, applied item by item
+
+Resolved by verification, no work needed:
+
+- `getPreceded` and chart-bus `highlightEnd` have **no consumer** in `cloud-frontend/src` (grep, zero
+ hits) ⇒ both close as N/A.
+- `makeAxisTicks` already dispatches to `makeNumericTicks`, which picks base-1024 multipliers for
+ binary units (`src/helpers/ticks/index.js:206-210`, `:141-153`) ⇒ nothing to port.
+- Geometry needs no rebuild-on-resize: uPlot re-evaluates `padding` functions and calls
+ `axis.size(...)` every convergence cycle (`node_modules/uplot/dist/uPlot.cjs.js:4531-4543`, `:4522`).
+- Axis font already matches at 10px (`src/sdk/initialAttributes.js:154`).
+- dygraph draws **no** y tick marks (`node_modules/dygraphs/src/plugins/axes.js:184-190`) but does draw
+ 3px x ticks (`:263-266`) and 1px border lines on both sides.
+
+| Item | Decision | Rationale under D5 |
+|---|---|---|
+| Top pad | `ceil(fontSize/2)` = **5px** | dygraph's `top:0` only works because its labels are DOM divs clamped by `if (top<0) top=0` (`plugins/axes.js:195-196`); uPlot's canvas label would clip. 5px beats both clipping and uPlot's 17px waste |
+| X-axis size | **16**, tickSize 3, gap 3 | dygraph's budget exactly, and its label offset is `y + axisTickSize` (`axes.js:271`) |
+| Right pad | **0** | Recovers uPlot's 25px `autoPadSide` (`uPlot.cjs.js:1613`, `:3803-3813`). dygraph's `-5` risks clipping the last label for 5px |
+| Sparklines | **no padding at all** | 43% of a sparkline's height is currently lost to chrome it never draws |
+| `staticValueRange` | **honour exactly — do NOT pad** | The caller's range is a contract. dygraph padding `[0,1000]` to `[-52.8,1052.8]` silently ignores it. Same ruling applies to `includeZero` never overriding an explicit range |
+| `yAxisChange` on axis-less charts | **keep firing** | Drives unit conversion (`src/helpers/unitConversion/index.js:110`); dygraph structurally cannot fire it under `drawAxis:false`. Correct units on sparklines is information gained |
+| Pan on pointer-leave | **keep the pan alive** | dygraph ends it (`dygraph/navigation/pan.js:10`); that interrupts a legitimate drag |
+| X tick cadence | **no change** | dygraph shows *fewer* labels (4 vs 8 on a 119-min window). Porting its granularity table would be work to become worse |
+| Y tick density | keep uPlot's `space`, adopt **only** the nice-step logic | Binary stepping (KiB → 32, not 50) is real quality; dygraph's `pixelsPerLabel:15` just doubles gridline paint |
+| Heatmap gridlines | keep at labelled rows | One line per bucket costs 100 strokes at 100 buckets and identifies nothing the labels don't |
+| Axis border strokes | **add** | Cheap, and an axis should read as an axis |
+| Pan cleanup on teardown | emit `panEnd` on `rebuild`, **clear state directly** on `unmount` | Emitting on unmount would fire `chart.moveX` from a teardown (`sdk/plugins/pan.js:8`) |
+| Gap-edge points | **implement** | A lone sample between nulls is invisible today — data loss |
+| Anomaly-rate badge | **paint on canvas in the gutter** | Verified feasible: `fire("draw")` (`:4891`) runs with no ambient clip; `drawSeries`' clips are balanced (`:4356-4380`). Falls back to a synced DOM node |
+| Pinch-zoom | x-only zoom about the midpoint, reusing the wheel→`moveX` path | Restores missing functionality; dygraph's own model is Dygraph-internal and not portable |
+| Render-while-loading | **N/A** | `src/components/line/chartContentWrapper.js:171-173` mounts the canvas only when `!initialLoading` and shows `` |
+| Series styling | keep the area gradient and filled sparkline; align widths (line 2→1.5, area 1.5→0.7, stacked edge 0.1), bar outlines, crosshair dash/colour | D2 plus D5 |
+| Browser evidence | commit one `scripts/parity-probe.mjs` | Makes the geometry table re-runnable |
+| Screenshots | side-by-side Storybook story + scratchpad PNGs | Durable, reviewable |
+| Cadence | commit **and push** per gate; tick items off in this file | This branch lost finished work once already |
+
+## How this list was produced
+
+Four parallel read-only Opus audits, one per domain (dygraph options surface; interaction model;
+data path & value ranges; lifecycle/sizing/overlays). Each compared source on both sides and ran
+probes against **real** dygraph and **real** uPlot via `makeTestChart`. Everything below carries
+`file:line` evidence. Items marked UNVERIFIED were not reproducible and must be confirmed before
+being actioned.
+
+## Already fixed on this branch (do not re-report)
+
+| Commit | Fix |
+|---|---|
+| `4a66b43a` | uPlot re-derives axis config on unit-conversion change (#232 port; `u.redraw()` was a no-op for cached tick strings) |
+| `9cc5dc7c` | stepPlot flip re-resolves the path builder |
+| `ff0a78d4` | removed uPlot's cursor-level `hoverChart`/`blurChart` (blurred the synced group at the axis gutter) |
+| `674e1bcf` | short-chart plot area no longer collapses (`.u-over` was 0px tall ⇒ dead hover) |
+| `491b5f09` | uPlot's native cursor suppressed (was drawing a 2nd vertical line, a horizontal line, and DOM points over ours) |
+| `b09e95bc` | **P0** collapsed y-range (constant series) — chart never painted, burned 6169ms per render |
+| `18125feb` | y-axis rescales to the window (`getValueRange` `{dygraph:true}` flag); series no longer fade to 30% on hover |
+| `1cde7984` | **§1 geometry** — dygraph's vertical budget (5px top pad, 16px x axis, no right pad), sparklines get the whole element, budget re-derived on resize via padding/size functions, all six overlays offset by the plot top |
+| `97a82e5b` | **§2 stacking** — order reversed to dygraph's, one sign-aware accumulator for area and bar stacks, `staticValueRange` honoured exactly for every chart type, `includeZero` no longer widens an explicit range |
+| `1af02428` | **§3 + §4** — gesture finishers (rebuild emits the end event, unmount clears state directly), click-to-annotate gated on `navigation === "pan"`, timestamp snapped to the closest row, clicked dimension from the hover resolver |
+
+## ⚠️ ALL PERF NUMBERS BELOW THIS LINE PREDATE 2026-08-06 AND ARE INVALID
+## The authoritative sweep is the last section of this document.
+
+Every measurement taken before commit `1053b85a` was measuring a chart that destroyed and
+reconstructed its uPlot instance roughly eight times per second while streaming. Treat the sweep
+table, the ratios and the per-render figures in the sections below as void. The only trustworthy
+numbers are in the "Perf, after the rebuild fix" section at the end of this document.
+
+## Browser-verified geometry (`node scripts/parity-probe.mjs`, after `yarn build-storybook`)
+
+Perf story, `line`, 300 rows × 3 dims, one chart. uPlot's plot box is measured exactly from
+`.u-over`; dygraph's has no DOM counterpart (`dygraph.getArea()`), so it is derived from the axis
+label divs and reads ~3px tall because its x label sits `axisTickSize` below the plot edge.
+
+| story height | mount el | dygraph top / h | uPlot top / h | dygraph left / w | uPlot left / w |
+|---|---|---|---|---|---|
+| 400px | 252 | 85 / 239 | 90 / 231 | 72 / 247 | 69 / 250 |
+| 300px | 152 | 85 / 139 | 90 / 131 | 72 / 247 | 69 / 250 |
+| 200px | 52 | 85 / 39 | 90 / 31 | 72 / 247 | 69 / 250 |
+| 120px | 0 / 320 | no plot | no plot | — | — |
+
+- **§1 confirmed.** uPlot's plot box is now within ~5px of dygraph's (the deliberate top pad), where
+ the pre-fix audit measured it 51px short at 400px. It is also 3px *wider* — right pad 0 recovers
+ uPlot's 25px `autoPadSide` without clipping the last x label.
+- **No label clipping at any height**: the topmost y label and the x labels render fully with a 5px
+ top pad and a 16px x axis. Q1-C and Q2-A hold visually.
+- **120px/100px is a story artifact, not a renderer difference.** The legend and toolbox take ~148px,
+ so the mount element computes to ≤ 0. Neither renderer shows a plot: uPlot honours the zero;
+ dygraph keeps a 320px canvas that nothing displays. Both budget formulas, old and new, yield 0 at a
+ 0-height element, so this is pre-existing and out of §1's scope.
+- **Finding that changes a decision:** at 300px dygraph draws **7** y labels (step 5) where uPlot
+ draws **4** (step 10). D5 had kept uPlot's sparser spacing on the grounds that dygraph's
+ `pixelsPerLabel: 15` only costs paint — but side by side, dygraph's axis is materially easier to
+ read values off, and 3 extra gridline strokes is not a real cost. **§5 now adopts dygraph's y-tick
+ density as well as its nice-step logic.** X-axis cadence stays as it is: both renderers drew 2 x
+ labels here, so the audit's 4-vs-8 claim did not reproduce.
+
+## Corrections to this document's own premises (found while implementing)
+
+1. **Line references in the sections below are stale by 20–60 lines** — the audits predate the three
+ fixes that landed before this list was written. Verify every claim against current source; several
+ citations point at the wrong function now.
+2. **"Stacked y-range always includes zero" was filed as a uPlot bug. It is the correct behaviour.**
+ dygraph ranges over stack ends alone (`divergingStack.js:100-107`), so a 10 + 20 stack plots as
+ `[20, 30]`: the bottom band sits entirely below the axis and the visible areas stop encoding their
+ magnitudes. Adopting dygraph's version broke a hover test — the cursor at value 5 landed outside
+ the plot. uPlot keeps zero, deliberately. Same reasoning keeps bars anchored to zero.
+3. **"dygraph creates an annotation on a plain click" is doubtful.** dygraph registers its `click`
+ handler only while hover is enabled (`dygraph/hoverX.js` `toggle`), and `sdk/plugins/pan.js`
+ disables hover synchronously at `panStart` — `getApplicableNodes` returns `[instance]` even when
+ the chart does not match (`makeContainer.js:61`), so the chart's own hover does go off.
+ `maybeTreatMouseOpAsClick` also requires `g.lastx_`, which only a prior hover sets. So dygraph
+ probably cannot annotate in pan mode either. The uPlot behaviour was therefore chosen on intent —
+ a plain click in the default navigation mode must annotate, or the feature is unreachable — not on
+ dygraph parity. **Confirm dygraph's actual behaviour during the browser pass.**
+
+---
+
+# OPEN WORK, in recommended order
+
+## 1. Geometry convergence — THE KEYSTONE (do first)
+
+uPlot reserves **67px** of vertical chrome (`defaultTopPad 17` + `defaultXAxisSize 50`,
+`uplot/index.js:27-28`) plus a **25px right pad** (uPlot's `autoPadSide` returns
+`round(yAxisOpts.size/2)`, `node_modules/uplot/dist/uPlot.cjs.js:3803-3813`).
+dygraph reserves ~16px bottom (`axisLabelFontSize 10 + 2*axisTickSize 3`,
+`node_modules/dygraphs/src/plugins/axes.js:57-64`), **top = 0**
+(`dygraph-layout.js:81-84`), and right `rightGap: -5` (`dygraph/index.js:126`).
+
+Measured plot areas (800px wide, both renderers, same element):
+
+| element height | dygraph top/height | uPlot top/height | height lost |
+|---|---|---|---|
+| 400 | 0 / 384 | 17 / 333 | 13% |
+| 300 | 0 / 284 | 17 / 233 | 18% |
+| 200 | 0 / 184 | 17 / 133 | 28% |
+| 120 | 0 / 104 | 17 / 53 | 49% |
+| 100 | 0 / 84 | 17 / 33 | 61% |
+
+Horizontal: dygraph `left 74, width 731`; uPlot `left 68, width 707`.
+
+**Fixing this also fixes, for free:**
+- **All six overlays draw 17px too high.** They assume the plot origin is `y=0` (true for dygraph)
+ and draw `0 → h`: `uplot/overlays/alarm.js:29-30`, `alarmRange.js:42,50-51,60-61`,
+ `highlight.js:25,30-31`, `alertTransitions.js:67`, `annotation.js:52,93,96`,
+ `point.js:56-57` (its line uses `0→h` while its own dots use `top + valToPos` — internally
+ inconsistent). Note `plotters/anomaly.js:38` and `plotters/annotations.js:36` already use
+ `self.bbox.top` correctly — that is the right idiom.
+- **Sparklines lose 43% of their height** to a top pad they should not have (`getAxes` returns
+ `[{show:false},{show:false}]` at `uplot/index.js:326`, but the padding at `:1189` still applies).
+- **Exact y-range parity is untestable until this lands** — dygraph's pad ratio is `15/284 = 5.28%`
+ vs uPlot's `15/233 = 6.44%`, so every correct range still differs ~1%.
+
+**Also required:** the vertical budget is only computed in `create()` (`:1189`) and `getAxes()`
+(`:348`); the resize listener (`:1283-1288`) only calls `u.setSize`. Probe: mount 800×300 then
+resize to 40px ⇒ `getPlotArea()` = `{top:17, height:-27}` (dygraph: `{top:0, height:24}`).
+**The already-shipped short-chart fix is bypassed on the resize path.**
+
+Target: `padding: [0, -5, null, null]`, x-axis `size ≈ 16` (font-derived), y-axis
+`size = yAxisLabelWidth + 6`, no padding at all for sparklines, and recompute on resize.
+
+## 2. Stacking cluster (every stacked chart is wrong today)
+
+- **Stack order is REVERSED.** dygraph accumulates last→first (`dygraph.js:2253`
+ `for (seriesIdx = num_series; seriesIdx >= 1; seriesIdx--)`; `divergingStack.js:61` resets on the
+ last visible series). uPlot accumulates first→last (`stacking.js:6-30`, `bars/stack.js:9-10`).
+ Probe with `a=[10,12] b=[20,18]`: dygraph puts **b** at the bottom, uPlot puts **a**. Bands and
+ legend order are upside down. Hidden dims must be skipped *before* choosing the base.
+- **Stacked y-range always includes zero.** `stacking.js:54-55` seeds `min=0,max=0` and scans
+ bases. dygraph uses stack **ends** only (`divergingStack.js:100-107`) and adds zero only under
+ `includeZero || (forceIncludeZero && dims>1 && selectedLegendDimensions.length>1)`
+ (`dygraph/index.js:386-388`). Probe (a=10–12, b=18–22): dygraph `[17.2, 33.8]`, uPlot `[-2.1, 35.1]`.
+- **`staticValueRange` ignored for `chartType:"stacked"`** — `uplot/index.js:268-271` returns the
+ stack range before checking it. Probe with `[0,1000]`: dygraph `[-52.8, 1052.8]`, uPlot `[-25, 414]`.
+- **Bars use a different algorithm.** `getBarValueRange` (`uplot/index.js:218-246`) always forces
+ zero and pads ×1.05; dygraph uses data extremes (multiBar → `default` options, no
+ `forceIncludeZero`) or stack ends, then `yRangePad:15`, and pads `staticValueRange` too.
+ Probe: multiBar dygraph `[-5.3, 315.3]` vs uPlot `[0, 315]`.
+- **stackedBar loses sign separation.** `bars/stack.js:10` uses one accumulator
+ (`accum[idx] += +v`, and `+null`→0, `+NaN` poisons it); dygraph splits positive/negative
+ (`divergingStack.js:92`). Mixed-sign bars overlap instead of diverging. Fix by reusing
+ `stacking.js#getStackBounds`, which already splits signs and guards non-finite.
+
+## 3. Pan-state stranding (HIGH — permanent freeze)
+
+`panEnd` lives only in the `document mouseup` handler (`uplot/index.js:937-944`), which
+`detachNavigation()` removes (`:1158-1159, :1169`). Every `rebuild()` and `unmount()` calls it.
+Probe A: mousedown+mousemove, then `chart.updateAttribute("theme","dark")`, then mouseup ⇒ only
+`panStart` fired, `panning === true` stuck. Probe B: unmount mid-pan ⇒ `panning:true`; remount ⇒
+`getXAxisRange() === null` — **the chart never renders again** (`render()` bails on `panning`,
+`:1246`). `sdk/plugins/pan.js:5` also leaves `enabledHover:false`.
+Note: **this got easier to hit** because `stepPlot` and `unitsConversion*` now trigger `rebuild`.
+Fix: on `destroyChart()`/`unmount()`, terminate any active gesture (emit the end event, or at
+minimum clear `panning`/`highlighting`/`xRangeOverride`) before detaching.
+dygraph also ends a pan on `mouseout` (`navigation/pan.js:10`) — uPlot does not (P7 below).
+
+## 4. Click semantics (HIGH — inverted today)
+
+- **Click-to-annotate is inverted.** With the shipped default `navigation:"pan"`, clicking a uPlot
+ chart does **nothing**; with shift/alt (select/highlight) it creates a **spurious** annotation
+ dygraph never creates. Cause: `over.addEventListener("mousedown", onDown)` (`:1138`) runs before
+ `onDownTrack` (`:1139`), and `sdk/plugins/pan.js:2-6` sets `enabledHover:false` synchronously
+ inside `onDown`, so `onUpTrack`'s `enabledHover` check reads the wrong value.
+ dygraph routes clicks only through `endPan` (`dygraph-interaction-model.js:236, 303-361`).
+ Fix: gate `onUpTrack` on `navigation === "pan"`, and capture hover-enabled state before `onDown`.
+ **This inverts existing tests** at `uplot/index.test.js:936, :953, :1001`, which encode the wrong
+ contract.
+- **Wrong dimension + unsnapped timestamp.** `uplot/index.js:998` always uses
+ `getVisibleDimensionIds()[0]`; dygraph picks the closest series / ANNOTATIONS / ANOMALY_RATE band
+ (`dygraph/hoverX.js:132-148`). Timestamp: dygraph snaps to a row (`g.lastx_`), uPlot uses raw
+ `posToVal` (`:994`). Fix: reuse the existing `getHoverDimension(u)` (`uplot/hover.js:115-132`)
+ and snap via `chart.getClosestRow`.
+
+## 5. Axis rendering
+
+- **y-axis ticker bypassed for non-duration axes.** dygraph always installs `numericTicker`
+ (`dygraph/index.js:282-285`) with `pixelsPerLabel:15` (`:407`), which selects **binary**
+ multipliers (base 1024) for KiB/MiB (`helpers/ticks/index.js:142-152`). uPlot only uses
+ `makeAxisTicks` when `isDurationAxis` (`uplot/index.js:384-393`), otherwise uPlot's default
+ `space:30`. Probe: KiB ⇒ dygraph step **32**, uPlot step **50**; and roughly half the gridlines
+ everywhere.
+- **No axis baseline strokes.** dygraph draws a 1px `axisLineColor` line down the left and along
+ the bottom (`dygraph/index.js:113, 418`; `plugins/axes.js:231-237, 289-296`). uPlot:
+ `axes[].border.show === false`. Fix: `border: { show: true, stroke: gridColor, width: 1 }`.
+- **Heatmap**: range unpadded (`getHeatmapValueRange` `:196-201` bypasses `padYRange`) so the bottom
+ row is half-clipped; and gridlines only at labelled rows — dygraph draws one per bucket
+ (`tickers/heatmap.js:13-16`).
+- **`includeZero` applied on top of an explicit range.** `uplot/index.js:279-282` applies it after
+ `rangeMin` is taken; dygraph applies it only while computing auto extremes
+ (`dygraph.js:2555-2558`) and then overwrites with the user range (`:2592-2596`). Probe
+ (`includeZero:true`, `staticValueRange:[50,100]`): dygraph `[47.4, 102.6]`, uPlot `[-6.4, 106.4]`.
+- **`yAxisChange` fires when the y-axis is disabled.** dygraph's trigger lives inside the axis
+ label formatter, which never runs with `drawAxis:false`. uPlot's `fireYAxisChange` is an
+ unconditional draw hook (`:1202`). It drives unit conversion, so units can change on axis-less
+ charts.
+- **x-axis tick cadence differs**: dygraph `pixelsPerLabel:70` + its granularity table vs uPlot
+ `space:80` + `timeIncrs`. 119-min window ⇒ dygraph 4 labels, uPlot 8.
+
+## 6. Series styling
+
+- **area**: dygraph flat `fillAlpha 0.2` under a **0.7px** line (`dygraph/index.js:250-251, 306`);
+ uPlot a top→bottom gradient (`makeAreaFill`, `:67-73`, `areaGradientTopAlpha "59"`) under a
+ **1.5px** line. Line width also differs for `line` (dygraph 1.5 vs uPlot 2) and stacked edges
+ (dygraph `strokeWidth 0.1` vs uPlot `devicePixelRatio`, `:468`).
+- **sparkline**: dygraph *strokes* (fillGraph stays false, `strokeWidth:0` renders as a 1px line);
+ uPlot *fills* with a solid colour (`:177-179`). Probe draw ops: dygraph `{stroke:6, fill:0}`,
+ uPlot `{fill:3, stroke:0}`. **Confirm the intended look with the maintainer before changing.**
+- **bars have no darkened outline**: dygraph `strokeRect` with `darkenColor`
+ (`plotters/multiColumnBar.js:20,31`, `plotters/stackedBar.js:39,50`); uPlot only `fillRect`
+ (`:559, :588`).
+- **click crosshair style**: dygraph click = `themeNetdata` + dash `[2,2]`, hover = `themeCrosshair`
+ + `[5,5]` (`dygraph/crosshair.js:2-11`). uPlot uses `themeCrosshair` solid for click and `[4,4]`
+ for hover (`:690-691`).
+- **point markers**: uPlot auto-shows a dot per sample on sparse data (uPlot's density default);
+ dygraph never does for `chartType:"line"`. Conversely dygraph draws **gap-edge** points on
+ `area`/`stepPlot` (`drawGapEdgePoints:true`, `dygraph/index.js:117`) and uPlot draws none, so a
+ lone sample between nulls is invisible. Fix: `points:{show:false}` for line/area, plus an
+ explicit gap-edge `points.filter` if that parity is wanted.
+
+## 7. Lifecycle hygiene
+
+- **`mount()` is not idempotent while loading.** `if (u) return` (`:1268`) never fires because `u`
+ stays null when `empty && !loaded`. Probe: two mounts ⇒ `mountChartUI` 2×, ResizeObserver
+ `observe:2 / disconnect:1`; after one unmount the orphaned `theme` listener throws
+ `Cannot read properties of null (reading 'classList')` at `:1312` — inside a `Set.forEach`, so
+ **every later theme listener on that chart is skipped**. Fix: guard on `element`, null-guard the
+ theme handler.
+- **`unmount()` on a never-mounted instance fires `unmountChartUI`** (dygraph guards with
+ `if (!dygraph) return`, `:481`). Reachable via `makeControllers.js:144,153,337`.
+- **Nothing renders while loading**: dygraph always constructs with `[[0]]`/`["X"]`
+ (`dygraph/index.js:63-69`); uPlot bails (`:1179`) so `overlays/proceeded.js:5` never emits and the
+ loading/error box never appears. **Note:** in the normal app path `chartContentWrapper.js:171-173`
+ only mounts `ChartContainer` when `!initialLoading`, so this may be unreachable in production —
+ verify before fixing.
+- **Empty/out-of-limits charts** get only `drawClear`+`setCursor` hooks (`:1195-1196`), so
+ drag-select yields `highlightEnd:null` and `yAxisChange` never fires.
+- **`getPreceded` missing** from uPlot's surface (`dygraph/index.js:522-532`). No in-repo caller;
+ check `cloud-frontend` before closing as N/A.
+- **`getChartHeight()` fallback** differs: dygraph `100`, uPlot `offsetHeight`/300. Feeds
+ `overlays/latestValue.js:27` text sizing.
+- `render()` calls `chartUI.render()` at `:1248` *before* it can `return false` at `:1253`, so a
+ declined frame can still be marked clean. dygraph marks last (`:517`). Only reachable pre-load.
+
+## 8. Interaction odds and ends
+
+- **Pinch-zoom missing** — only `touches[0]` is used (`:1011-1044`), so a two-finger pinch is
+ misread as a pan. dygraph delegates to Dygraph's touch model with `touchDirections {x:true,y:false}`
+ (`navigation/generic.js:104-119`).
+- **Touch events not `preventDefault`ed** except `touchmove` (`:1033`), so every tap also runs the
+ synthetic mouse path (and a double-tap resets twice). dygraph prevents all three on the element
+ (`dygraph/index.js:142-150`).
+- **`highlightHover` fires per pixel** — 20 events per 20px sweep vs dygraph's 1. dygraph dedupes on
+ row change (`hoverX.js:82`) plus a 5px dead zone (`:150-152`).
+- **Missing chart-level `highlightEnd`** — dygraph fires on both buses (`navigation/select.js:62-63`),
+ uPlot only on the sdk bus (`:847`). No in-repo consumer; `cloud-frontend` may have one.
+- **Double-click resets even when `enabledNavigation:false`** (`:954` always attached; dygraph
+ registers it only while navigation is enabled).
+- **Shift/Alt+wheel over the gutter swallows the scroll** without zooming — `preventDefault()` at
+ `:862` runs before the `left < 0` bail at `:866-867`.
+- **Pan does not end when the pointer leaves the chart** (dygraph: `navigation/pan.js:10`).
+- Wheel debounce 300ms vs dygraph 500ms; no `stopPropagation` (dygraph has it,
+ `navigation/generic.js:50`). Click dead zone 5px latched vs dygraph 2px measured at mouseup.
+
+## Divergences where uPlot is BETTER — record, do not "fix"
+
+- Right-click + modifier does not hijack navigation (dygraph has no button filter).
+- Mouseup outside ends a selection (dygraph leaves `highlighting:true`/`enabledHover:false` stuck).
+- The chart stays drawn during a drag-select (dygraph blanks the canvas every mousemove,
+ `navigation/select.js:38`).
+- Navigation restore is document-scoped and `prevNavigation` survives nested switches.
+
+## UNVERIFIED — confirm before acting
+
+- **Timezone change leaves uPlot's x-labels stale.** Mechanism is plausible (`u.redraw()` sets
+ `shouldConvergeSize=false`, so `axis._values` is never rebuilt — the same hazard fixed for
+ `unitsConversionBase`), but in jsdom **neither** renderer refreshed its labels, so no divergence
+ was demonstrated. Needs a browser check.
+- `logscale` is never set anywhere in `src/` — treated as N/A.
+
+---
+
+# Still owed (non-audit)
+
+1. **Two deferrals the maintainer asked for** (tasks #2/#3): port dygraph's stacked-area per-pixel
+ point reduction (`dygraph/plotters/stackedArea.js:73-115` — note it must respect the corrected
+ stack order from §2), and the anomaly-rate y-axis badge (`tickers/numeric.js:62`, injected as
+ SVG into an HTML axis label; uPlot paints axes on canvas so this needs a Path2D in the gutter or
+ a resynced DOM node).
+2. ~~**Authoritative perf sweep.**~~ **DONE** — full `yarn perf:bench` on the finished branch,
+ 28 cells x 5 repeats x 2 renderers = 280 runs. Raw output in `.perf-results/` (gitignored).
+ Ratios below are uPlot/dygraph; under 1.000 means uPlot is cheaper.
+
+ | measure | result |
+ |---|---|
+ | p50 per render | uPlot cheaper nearly everywhere: **0.09x on stacked** (60.4ms -> 5.4ms), 0.42-0.95x on line, 0.52x on heatmap. Two cells worse: 300 rows/100 dims/10 charts (1.09x) and 5000 rows/20 dims/10 charts (1.26x) |
+ | whole-tab main-thread total (the flip decider) | **0.52-1.66x**. Better under load: 50-chart cells 0.57-0.96x, hover-with-streaming 0.52x and 0.77x. Worse on light cells: 10 charts at 3-20 dims run **1.16-1.66x**. Heatmap 1.06-1.10x. Stacked ~parity at 0.955x |
+ | hover gesture alone (`hoverInteraction`, 0 renders both sides) | **0.975x / 0.984x** — parity. This replaces the retracted numbers, which had measured uPlot doing nothing |
+
+ Four cells were skipped by the 3M-point cap: 1000x100x50, 5000x20x50, 5000x100x10, 5000x100x50.
+
+ **Two findings worth chasing, both stable across all 5 repeats:**
+ - **Render counts differ per cell in BOTH directions and are confounded by design. Do not read
+ them as work done.** `makeExecuteLatest` (`src/helpers/makeExecuteLatest/index.js`) drops all
+ but the latest pending render request — each call `clearTimeout`s the previous — so a renderer
+ that blocks the task queue longer has more requests collapse into one. Stacked: uPlot is 11x
+ cheaper per render and renders *more* (100 -> 150). But 300 rows/3 dims/50 charts: uPlot is 5x
+ cheaper per render and renders *fewer* (501 -> 301); 300 rows/100 dims/10 charts: uPlot is
+ slower and renders far fewer (103 -> 39). No monotonic relationship, so the mechanism is not
+ just coalescing. **An earlier draft of this document claimed stacked hides a 10x win behind
+ surplus renders — that was unsupported and is retracted.** Only the whole-tab total over a
+ fixed wall-clock window is comparable across renderers. A decisive experiment would be to slow
+ uPlot's render artificially and see whether the counts converge.
+ - **Per-draw hook overhead dominates light charts.** At 300 rows/20 dims/10 charts the render
+ counts match (102 vs 101) and uPlot's own render is cheaper (2.6ms vs 4.0ms p50), yet whole-tab
+ task per render is 25.7ms vs 15.3ms. The work is outside the render call, in the draw hooks.
+ Prime suspect: `plotters/anomaly.js` calls `chart.getClosestRow` **per x value per draw**, so
+ 300 rows x 10 charts x 100 renders is ~300k binary searches, and it runs even when every
+ anomaly rate is zero (`showAnomalies` defaults true). dygraph's plotter walks its points array
+ with no such lookup.
+
+ **Tested and resolved (2026-08-06):**
+ - The per-point lookup was real and is fixed (`2a3c0bb3`): the payload's `all` is row-aligned
+ with `data` (verified: equal lengths, matching timestamps), so the loop index is the row. Both
+ ribbon plotters also stopped painting rows with nothing to show. Measured on 300 rows/20
+ dims/10 charts, 3 runs each side: p50 per render **2.78ms -> 2.33ms**. **Whole-tab total did
+ not move beyond noise**, so the plotters were NOT the source of the light-chart overhead.
+ - Second hypothesis, also **wrong**: that the `padding`/`axis.size` functions added in §1 force a
+ layout reflow per convergence cycle by reading `offsetHeight`. Caching the measurement on
+ resize edges gave 24.2ms mean against 22.5ms before it — no improvement. Reverted rather than
+ keep unproven complexity.
+ - **Profiled and fixed (2026-08-06, `66c1cd65`).** `scripts/profile-probe.mjs` attributes
+ main-thread self time per function per renderer. On 300 rows/20 dims/10 charts it named the
+ cost immediately: our custom smooth path builder, which existed only to reproduce dygraph's
+ control points exactly. computeSmoothOps 120ms + bezierCurveTo 60ms + Path2D 26ms + 191ms
+ anonymous in our bundle, against dygraph's 18ms `smoothLinePlotter` — roughly 7x the cost for
+ the same visible curve, because per series per draw it allocated a point object per row, an op
+ object per segment and a Path2D, after building and discarding uPlot's linear stroke. That is
+ also what put GC at 85ms against dygraph's 47ms.
+
+ Replaced with uPlot's built-in `spline()`: `_monotoneCubic` 51ms, bezierCurveTo 39ms, our
+ bundle's anonymous time 29ms, GC 75ms. Same cell, three runs each side:
+
+ | | baseline | after ribbon plotters | after spline |
+ |---|---|---|---|
+ | uPlot p50 per render | 2.78ms | 2.33ms | **1.70ms** |
+ | uPlot whole-tab task/render | 24.16ms | 22.52ms | **19.12ms** |
+ | ratio vs dygraph | 1.57x | 1.55x | **1.30x** |
+
+ The curve is now a monotone cubic rather than dygraph's clamped control points — a deliberate
+ divergence, visually smooth with no overshoot. `smoothLinePath.js` and its test are deleted.
+
+ **The sweep table above predates these two commits and now understates uPlot on every line
+ chart.** Re-run `yarn perf:bench` for a definitive table.
+
+ - **What remains on that cell**, from the post-swap profile (uPlot busy 2618ms vs dygraph 1927ms):
+ uPlot's spline + Path2D still costs ~116ms more than dygraph drawing straight to the context,
+ uPlot internals ~63ms, `getChartHeight` 40ms (the padding/size functions — measured at ~1.5%,
+ which is why caching it gave no win), canvas `fillText` for axis labels 22ms where dygraph uses
+ DOM, `clearRect` 27ms, GC ~28ms. Diminishing returns; the architectural difference is that
+ dygraph streams to the context while uPlot builds Path2D objects.
+
+ - **The light-chart overhead was unexplained until the profile.** What is established: render counts match
+ (102 vs 101), uPlot's own render is ~1.7x cheaper, whole-tab task per render is ~1.55x higher.
+ So roughly 10ms per render of main-thread work sits outside the render call. Finding it needs a
+ real profile with call-tree attribution (CDP Profiler or a Chrome trace), not more guessing.
+3. **Screenshot pairs** (task #5) — **DONE** via `src/parity.stories.js` (`Charts/uPlot/Parity`),
+ which renders both renderers per chart type. `scripts/parity-probe.mjs` writes PNG pairs and the
+ geometry table to `.parity-results/`.
+4. **Real-dashboard measurement** — `yarn to-cloud` + the protocol in
+ `docs/uplot-migration-progress.md`. Maintainer's environment.
+
+# Session gotchas worth keeping
+
+- **Never leave verified work uncommitted.** An out-of-session `git reset` + branch switch wiped a
+ finished, green change once; only the commit would have saved it. Commit immediately after each
+ gate, then push.
+- **Don't rebuild Storybook while a sweep is running** — `perf-bench.mjs` serves `storybook-static`
+ from disk and a rebuild swaps files mid-measurement.
+- **Subagents stalled or no-op'd ~5 times** (watchdog at 600s, transient API 529s). For small,
+ fully-specified changes it is faster to implement directly. Give agents the already-fixed list so
+ they don't re-report.
+- **jsdom cannot settle geometry or visibility.** Anything about layout, pointer hit-testing or
+ paint must be verified in a real browser (Playwright is now a devDependency; probe scripts pattern
+ is in the session scratchpad — serve `storybook-static`, open `iframe.html?id=perf-benchmark--benchmark&args=...`).
+- `makeMockPayload` emits `data.length` rows and ignores the requested point count; the shared
+ fixture is only 231 rows × 3 dims. The perf story now generates synthetic payloads sized by
+ `rows`/`dims` args.
+- Hover **disables autofetch by default** (`autofetchOnHovering:false` ⇒ `play.js` clears the render
+ tick), so "hover renders" are 0 for both renderers unless the story opts in.
+
+
+# Perf, after the rebuild fix (2026-08-06)
+
+## The defect that invalidated every earlier measurement
+
+`onUnitsConversionChange` called `rebuild()`, which destroys the uPlot instance and constructs a new
+one. Unit conversion re-runs whenever the y range changes, and `fireYAxisChange` runs on **every
+draw**, so a streaming chart reconstructed itself continuously:
+
+ draw -> yAxisChange -> conversion updates unitsConversionPrefix/Base
+ -> onUnitsConversionChange -> rebuild -> new uPlot -> draw -> ...
+
+Found by counting canvas clears against the render counter, then capturing the stack where each
+commit is scheduled:
+
+ queueMicrotask <- commit <- setScale <- _setScale <- autoScaleX <- _init
+ <- new <- create <- rebuild <- onUnitsConversionChange
+
+Fix (`1053b85a`): `u.redraw(false, true)`. The `recalcAxes` flag re-derives the cached tick strings,
+which is the only thing the rebuild was ever for (see `4a66b43a`); the two tests added with that
+commit still pass.
+
+| 1000 rows x 20 dims x 25 charts | before | after |
+|---|---|---|
+| heatmap draws per render | 5.00 | **1.00** |
+| heatmap renders in 10s | 137 | **250** (dygraph 225) |
+| heatmap fillRect calls | 13.7M | **5.0M** (dygraph 4.9M) |
+| heatmap total task vs dygraph | 1.040x | **0.544x** |
+| line total task vs dygraph | — | **0.874x**, 258 renders vs 229 |
+
+**This also explains the render-count behaviour retracted earlier in this document.** The rebuild loop
+blocked the task queue, so `makeExecuteLatest` coalesced away render requests, and its severity varied
+by chart type and data — which is why counts moved in both directions with no monotonic relation to
+per-render cost. The retraction stands; the mechanism is now known.
+
+## Other perf work landed today
+
+- `66c1cd65` — replaced the custom dygraph-exact smooth path builder with uPlot's built-in
+ `spline()`. It cost ~7x dygraph for the same visible curve (computeSmoothOps 120ms +
+ bezierCurveTo 60ms + Path2D 26ms + 191ms anonymous, against dygraph's 18ms), because per series per
+ draw it allocated a point object per row, an op object per segment and a Path2D, after building and
+ discarding uPlot's linear stroke. `smoothLinePath.js` and its test are deleted. The curve is now a
+ monotone cubic — a deliberate divergence.
+- `2a3c0bb3` — the anomaly and annotation ribbon plotters ran a `getClosestRow` binary search per x
+ value per draw, though `all` is row-aligned with `data` (verified: equal lengths, matching
+ timestamps), so the loop index is the row. Both also painted rows with nothing to show.
+- `cf61fd95` — heatmap: payload fetched once per draw instead of per cell, transparent cells skipped,
+ rows outside the visible scale range skipped. Careful: uPlot's scales are `null` until first
+ convergence and comparing a timestamp against null coerces to 0, which silently skipped every cell.
+
+## Tooling for the next session
+
+- `scripts/profile-probe.mjs` — CPU profile per renderer for any perf-bench cell, attributing
+ main-thread self time to named functions. Our function names survive the Storybook build.
+- `scripts/parity-probe.mjs` — plot geometry table plus screenshot pairs per renderer and height.
+- `src/parity.stories.js` — `Charts/uPlot/Parity`, both renderers side by side per chart type. Found
+ three defects on its first run.
+- Counting technique that found the rebuild loop: wrap `CanvasRenderingContext2D.prototype.clearRect`
+ in an init script and compare the count against `window.__netdataPerf.snapshot()`. uPlot issues
+ exactly one main-canvas clear per draw, so clears / renders = draws per render. To find *why* a draw
+ happens, wrap `window.queueMicrotask` and capture `new Error().stack` for schedules whose stack
+ contains `commit` — the draw itself is a microtask, so stacks taken inside the draw are truncated.
+
+# Queued work (in priority order)
+
+1. **Re-run the full sweep.** `yarn perf:bench`, ~75-95 min, 28 cells x 5 repeats x 2 renderers.
+ Every number in this document above the banner is void. Do not rebuild Storybook while it runs.
+2. **Replace the custom crosshair with uPlot's built-in cursor.** Ours is `createOverlay` /
+ `syncOverlaySize` / `renderCrosshair` / `drawVerticalLine` / `drawHoverDots` /
+ `drawCrosshairLayer` — roughly 150 lines plus a second canvas per chart. Measured cost of what
+ would be removed is small (`clearRect` 27ms per 10s profile), so this is a simplification, not a
+ speed fix.
+ - Must not lose: the line renders from **SDK-synced** `hoverX`, not the local pointer
+ (`sdk/plugins/hover.js:32-39` writes it to every `syncHover` node). Covered by
+ `cursor: { x: true, points: { show: true } }` plus `u.setCursor({left, top})`, which only moves
+ DOM (`uPlot.cjs.js:5221` -> `updateCursor`, no commit).
+ - Must not lose: the persistent click marker from `clickX`, set only by touch taps
+ (`uplot/index.js:1290`, `dygraph/navigation/generic.js:135`), read only by the two renderers.
+ uPlot has a single cursor, so draw this one on uPlot's own canvas in the existing draw hook —
+ the second canvas still goes away.
+ - Risk to measure after: uPlot's cursor points are DOM nodes **per series**. At 100 dimensions
+ that is 100 elements repositioned per hover against 100 canvas arcs today. Check the 100-dim
+ cells before concluding it is faster.
+ - 11 tests in `uplot/index.test.js` assert against `.netdata-crosshair-overlay`.
+3. **Built-ins we may still be reimplementing** (audited against uPlot's type surface, none measured):
+ - Bars: `uPlot.paths.bars({ disp: { y0, y1, size, fill, stroke }, each })` — `disp.y0/y1` is
+ exactly our diverging stack base/top, and `each` reports each bar's bbox, which is what
+ `hover.js` recomputes by hand. Strongest remaining candidate.
+ - Stacked area: uPlot `bands` (`Band.Bounds = [fromSeriesIdx, toSeriesIdx]`) with `addGap`/
+ `clipGaps`. Lower confidence: diverging positive/negative stacks may not map.
+ - Axis ticks: axis `incrs` expresses binary and duration steps declaratively, but `helpers/ticks`
+ is shared with dygraph, so this would split the two renderers.
+ - `padYRange`/`expandDegenerate` vs `uPlot.rangeNum` — ours is pixel-based, uPlot's `pad` is
+ fractional, so only the degenerate handling is a clean swap.
+ - Heatmap colour batching: the latency-heatmap demo builds one `Path2D` per palette entry and
+ fills once per colour. `makeGetColor` interpolates a **continuous** scale, so this only pays
+ when cell values repeat. Worth testing with a real histogram payload, not synthetic data.
+ - Checked and keep as-is: pan/wheel/pinch navigation (uPlot's drag-zoom sets local scales only),
+ cross-chart hover sync (the SDK bus spans both renderers and non-chart consumers).
+4. **Streaming replaces the whole window every tick.** `doneFetch` camelizes the whole response and
+ replaces the payload (`makeDataFetch.js:124-145`); there is no append or delta path. Renderer-side
+ cost is negligible — `getData`'s transposition measured 13ms across a 10s profile, and uPlot
+ repaints the full canvas per draw regardless — so an append path is an SDK and API change, not a
+ chart-library one. The upstream cost (transfer, `camelizePayload`, payload processing) is
+ **unmeasured**.
+5. **Real-dashboard measurement.** `yarn to-cloud` plus the protocol in `docs/uplot-migration-progress.md`.
+
+
+# AUTHORITATIVE PERF SWEEP (2026-08-06, HEAD a1bd7ea2)
+
+Full `yarn perf:bench`: 28 cells x 5 repeats x 2 renderers. Raw output in `.perf-results/`
+(gitignored). Ratios are uPlot/dygraph; under 1.000 means uPlot is cheaper. This supersedes every
+earlier number in this document.
+
+**Read three metrics, not one:**
+
+| metric | what it means | result |
+|---|---|---|
+| p50 per render | cost of one render call | uPlot cheaper in every cell: **0.028 - 0.644** (1.6x to 36x) |
+| task/render | whole-tab main-thread ms **per frame delivered** | uPlot cheaper in **all 20** rendering cells: **0.219 - 0.880** |
+| total task | whole-tab ms over a fixed wall-clock window | uPlot cheaper in **14 of 20**; higher in 6 |
+
+**All six cells where uPlot's total CPU is higher are cells where it delivered more frames:**
+
+| cell | total task | per frame | frames dyg -> uplot |
+|---|---|---|---|
+| stacked/hoverStreaming r1000 d20 c25 | 1.506 | 0.562 | 89 -> 240 (+170%) |
+| line/idle r1000 d100 c10 | 1.373 | 0.839 | 60 -> 98 (+63%) |
+| line/hoverStreaming r1000 d100 c25 | 1.241 | 0.726 | 85 -> 145 (+71%) |
+| line/hoverStreaming r1000 d20 c25 | 1.139 | 0.880 | 178 -> 230 (+29%) |
+| line/idle r5000 d3 c50 | 1.076 | 0.813 | 176 -> 234 (+33%) |
+| line/idle r5000 d20 c10 | 1.073 | 0.858 | 80 -> 100 (+25%) |
+
+The perf story streams at `update_every: 1`, so the target is **1.00 frame per chart per second**.
+Measured on the 25-chart cells:
+
+| cell | dygraph | uPlot |
+|---|---|---|
+| stacked/idle | 0.40/s | **1.01/s** |
+| stacked/hoverStreaming | 0.36/s | **0.96/s** |
+| heatmap/idle | 0.90/s | **1.03/s** |
+| heatmap/hoverStreaming | 0.96/s | **1.00/s** |
+| line/hoverStreaming d20 | 0.71/s | **0.92/s** |
+| line/hoverStreaming d100 | 0.34/s | **0.58/s** |
+
+uPlot is not over-rendering; it meets the requested update rate while dygraph drops up to 60% of
+frames. So the six "higher total CPU" cells are cells where dygraph was silently skipping work.
+
+**Hover gesture alone** (`hoverInteraction`, 0 renders on both sides): 0.955, 0.987, 1.020, 1.063 —
+parity.
+
+**Before and after the rebuild fix**, total task ratio:
+
+| cell | void sweep | now |
+|---|---|---|
+| line/idle r300 d3 c10 | 1.603 | **0.662** |
+| line/idle r300 d20 c10 | 1.657 | **0.657** |
+| line/idle r1000 d3 c10 | 1.518 | **0.659** |
+| stacked/idle | 0.955 | **0.551** |
+| heatmap/idle | 1.097 | **0.600** |
+
+Four cells remain skipped by the harness's 3M-point cap: 1000x100x50, 5000x20x50, 5000x100x10,
+5000x100x50.
+
+**Open question:** whether ~1 frame/s/chart is the right target, or whether frame delivery should be
+throttled independently of what the renderer can keep up with. That is a product decision, not a
+renderer one — dygraph's lower totals in those six cells come from dropping frames, not from being
+more efficient.
diff --git a/docs/uplot-perf-harness-design.md b/docs/uplot-perf-harness-design.md
new file mode 100644
index 000000000..80f4ca455
--- /dev/null
+++ b/docs/uplot-perf-harness-design.md
@@ -0,0 +1,202 @@
+# uPlot perf harness + renderer-selection cleanup — design
+
+> Working branch: `explore/uplot-spike`. Part of the uPlot migration (`docs/uplot-migration-progress.md`).
+> This is the "prove the win first" step: de-risk the migration's premise (uPlot is
+> meaningfully faster/lighter than dygraphs on real Netdata dashboards) **before** sinking
+> more effort into feature parity.
+
+## Goal
+
+Let the maintainer flip a real Netdata dashboard — or a controlled Storybook story — to uPlot
+and read an **apples-to-apples go/no-go number** vs dygraphs, covering the two costs that matter
+for a streaming dashboard: **per-render time** and **JS heap**.
+
+Nothing changes for existing consumers unless they opt in.
+
+## Why this shape
+
+Netdata charts stream: a chart emits a `"render"` event and `makeChart/index.js:138-139` fans out
+to each UI instance's `render()` (coalesced via `executeLatest`); autofetch re-triggers this on each
+chart's `updateEvery` cadence. So **per-`render()` cost, sustained across many streaming charts, is
+the number that decides the win** — and that one fan-out line is a renderer-agnostic seam where
+dygraph and uPlot are measured identically.
+
+There is currently **no perf/benchmark harness anywhere** in the repo (no `performance.now`
+instrumentation, no bench script), so item 6 of the migration progress doc ("no local perf
+measurements exist yet") is not just unmeasured — there is no way to measure it. This design adds
+that way.
+
+## Non-goals (YAGNI)
+
+- **No frame-time / long-task instrumentation** (pan/zoom jank). Render-time + heap is the 80/20
+ for a streaming dashboard; interaction smoothness is deferred.
+- **Not flipping uPlot to the default** renderer. The shipped default stays `chartLibrary: "dygraph"`;
+ a dashboard opts in by setting its own root `chartLibrary: "uplot"`.
+- **Not wiring the map into *initial* render.** `chartLibrariesByType` continues to apply only on a
+ toolbox type-switch (as today); a per-type override taking effect on first paint is deferred — the
+ `chartLibrary` flip covers the measurement need without it.
+- **No perf stats stored in chart attributes** (see the critical constraint below).
+
+## Opt-in model
+
+Two independent opt-ins, both safe-by-default:
+
+- **`perfMonitor`** (SDK-root attribute, default `false`) — turns on render-timing accumulation,
+ heap sampling, and the HUD overlay. When off, overhead is a single boolean check per render.
+- **`chartLibrary`** (existing attribute, default `"dygraph"`) — the single renderer selector.
+ Flip a whole dashboard to uPlot by setting the SDK-root default `chartLibrary: "uplot"`:
+ timeseries charts inherit it (`makeNode.js:119-121`, child-set values win), while gauge/pie/table
+ charts keep their own explicit `chartLibrary` and are untouched. `chartLibrariesByType` stays as
+ an **optional per-type override** (default `{}`) — e.g. `{ heatmap: "dygraph" }` to keep heatmap
+ on dygraph while the rest go uPlot. Default consumers are unaffected: empty map ⇒ everything
+ respects `chartLibrary: "dygraph"`.
+
+## Components
+
+### 1. Render-time instrumentation (SDK seam)
+
+- **What it does:** times each `render()` call and records the duration against the chart id and
+ its active renderer (`chartLibrary`).
+- **Where:** the fan-out at `src/sdk/makeChart/index.js:139`
+ (`Object.keys(uiInstances).forEach(uiName => uiInstances[uiName].render())`). Wrap each
+ `render()` in a `performance.now()` delta.
+- **Interface / dependency:** a standalone perf registry module (see §3a) exposing
+ `record(chartId, renderer, ms)`. The seam calls `record` only when enabled (`isEnabled()` — a
+ cached boolean toggled by the plugin, so the seam never reaches into the SDK attribute system).
+- **Boundary:** the seam knows nothing about how stats are aggregated or displayed; it only reports
+ a duration.
+
+**Critical constraint:** stats live in a plain module-level registry, **never in chart
+attributes**. Writing per-render stats into attributes would trigger attribute listeners and
+additional renders, corrupting the very measurement.
+
+### 2. Heap sampling
+
+- **What it does:** samples `performance.memory.usedJSHeapSize` on a ~1s interval while
+ `perfMonitor` is on; tracks current + peak in the registry.
+- **Boundary:** Chrome-only. Guard `performance.memory` absence (Firefox/Safari) and surface as
+ "n/a" in the HUD. The sampler is started/stopped by the plugin (§3b) alongside the HUD.
+
+### 3. Perf registry + self-mounting HUD
+
+**3a. Registry** (`src/sdk/plugins/perfMonitor/registry.js` or similar): a plain module holding
+per-chart ring buffers of recent render durations (cap ~500 samples/chart) plus latest/peak heap.
+Exposes:
+- `record(chartId, renderer, ms)` — append a sample.
+- `setEnabled(bool)` / `isEnabled()` — the seam's cheap gate.
+- `snapshot()` — aggregate view: per-renderer and overall `count`, `p50/p95/max` ms, current + peak
+ heap. Quantiles computed on read from the ring buffers.
+- `reset()` — clear all samples (for a clean measurement window).
+- `sampleHeap()` — record one heap sample (called by the interval).
+
+**3b. HUD plugin** (`src/sdk/plugins/perfMonitor/index.js`): follows the existing plugin contract
+(`sdk => { … return cleanup }`, mirroring `src/sdk/plugins/play.js`).
+- On `perfMonitor` flipping `true` (via `sdk.getRoot().onAttributeChange("perfMonitor", …)`, plus an
+ initial check): `registry.setEnabled(true)`, start the heap interval, create a `
`, append it
+ to `document.body`, `createRoot` it (`react-dom/client`, React 19 — already a dependency), and
+ render the HUD component into it.
+- On flipping `false`: `setEnabled(false)`, clear the interval, unmount the React root, remove the
+ DOM node.
+- The returned cleanup does the same teardown and detaches the attribute listener.
+- Registered in `src/makeDefaultSDK.js`'s `plugins` map.
+
+**3c. HUD component** (`src/components/perf/`): polls `registry.snapshot()` on a ~500ms interval
+(HUDs poll; they don't need reactivity), and renders a fixed-position box showing: renderer(s) in
+play, total render count, aggregate `p50/p95/max` ms, current + peak heap. Controls: **reset**
+(zero the window) and **copy** (JSON snapshot to paste into the progress doc).
+
+### 4. Renderer selection: `chartLibrary` as the single selector
+
+Collapse renderer selection onto `chartLibrary`; make `chartLibrariesByType` an optional per-type
+override that defaults to `{}`. This is what makes the flip trivial (§ opt-in model) and removes the
+redundant "every type → dygraph" default map.
+
+- **`makeDefaultSDK.js:41`:** replace the enumerated map with `chartLibrariesByType: {}` (the
+ `getAttribute(...) || {}` guards already handle absence). Keep `chartLibrary: "dygraph"`.
+- **`getRendererForChartType`** (`makeControllers.js:122-123`): change the fallback from a hardcoded
+ `"dygraph"` to `chartLibrary`, so an empty map respects the selector and an entry overrides it:
+ ```
+ const getRendererForChartType = chartType =>
+ (chart.getAttribute("chartLibrariesByType") || {})[chartType] ||
+ chart.getAttribute("chartLibrary")
+ ```
+- **`isTimeSeriesRenderer`** (`makeControllers.js:125-127`) — **the gotcha:** it currently decides
+ "is this a timeseries renderer" *from the map's values*, so an empty map makes
+ `isTimeSeriesRenderer("uplot")` return `false`, and the toolbox then treats uPlot as a *library*
+ instead of showing the chart type (`chartType.js:133`, `settings/tabs/chartType.js:144`:
+ `value = isTimeSeriesRenderer(chartLibrary) ? chartType : chartLibrary`). Fix with a constant set,
+ unioned with any map values:
+ ```
+ const timeSeriesRenderers = ["dygraph", "uplot"]
+ const isTimeSeriesRenderer = chartLibrary =>
+ timeSeriesRenderers.includes(chartLibrary) ||
+ Object.values(chart.getAttribute("chartLibrariesByType") || {}).includes(chartLibrary)
+ ```
+- **`index.stories.js:31`:** the `chartLibrary` toolbar control currently *builds* a full
+ `chartLibrariesByType` map from its value; simplify it to set `chartLibrary` directly (leaving the
+ map empty) so stories exercise the real flip path.
+- **Flip mechanism (no new code):** set root `chartLibrary: "uplot"` at SDK creation → timeseries
+ charts inherit it (`makeNode.js:119-121`), gauge/pie/table keep their own explicit `chartLibrary`.
+ `makeChartUI` already resolves the renderer from `chartLibrary` (`makeChart/index.js:302`), so no
+ reconcile dance and no `makeDataFetch` change are needed.
+- **Tests to update:** `makeControllers.test.js` and `chartType.test.js` assert against the
+ enumerated default map today; update them to the empty-default + `chartLibrary`-fallback semantics.
+
+### 5. Storybook perf bench story
+
+- **What it does:** a controlled, repeatable dygraph-vs-uPlot A/B.
+- **Where:** `src/perf.stories.js` (new). Mounts N charts (control: 10 / 25 / 50) streaming mock
+ ticks at a fixed interval, with the existing `chartLibrary` toolbar control and `perfMonitor` on.
+ Workflow: set N, pick renderer, stream ~60s, copy stats, switch renderer, repeat.
+- **Honest caveat (stated in the story):** the mock ignores requested point counts
+ (`makeMockPayload` emits `data.length` rows), so absolute numbers won't match production — but the
+ dygraph-vs-uPlot *ratio* under identical mock conditions is a valid comparison.
+
+## Data flow
+
+```
+render event ─▶ makeChart:139 fan-out ─▶ render() [timed] ─▶ registry.record()
+ │
+heap interval ─────────────────────────────────────▶ registry.sampleHeap()
+ │
+ registry.snapshot()
+ ▲
+ HUD component (polls ~500ms) ◀── self-mounted by plugin
+```
+
+## Testing (real components, no mocks — `makeTestChart` / testUtilities)
+
+- **Registry:** feed known durations → assert `count`, `p50`, `p95`, `max`; assert `reset()` clears;
+ assert heap "n/a" path when `performance.memory` is absent.
+- **Renderer resolution:** empty map + `chartLibrary: "uplot"` → `getRendererForChartType("line")`
+ returns `"uplot"`; with `{ heatmap: "dygraph" }`, `getRendererForChartType("heatmap")` returns
+ `"dygraph"` while other types return `chartLibrary`; `isTimeSeriesRenderer("uplot")` is `true` with
+ an empty map.
+- **Flip via inheritance:** a chart under a root with `chartLibrary: "uplot"` and no own
+ `chartLibrary` builds the uPlot UI; a chart with its own `chartLibrary: "gauge"` keeps gauge.
+- **HUD:** renders and displays values from a seeded registry snapshot.
+
+## Verified anchors (evidence)
+
+- Render fan-out seam: `src/sdk/makeChart/index.js:138-139`.
+- Renderer resolved from `chartLibrary` at UI creation: `src/sdk/makeChart/index.js:302`.
+- Renderer resolve helpers + toolbox switch dance:
+ `src/sdk/makeChart/filters/makeControllers.js:122-123` (`getRendererForChartType`), `125-127`
+ (`isTimeSeriesRenderer`), `134` and `141-143`/`338-340` (unmount/remake).
+- Attribute inheritance (child wins, snapshot at append): `src/sdk/makeNode.js:117-123`.
+- Current default map + story-built map: `src/makeDefaultSDK.js:41`, `src/index.stories.js:31`.
+- Plugin contract + root attribute listeners: `src/sdk/plugins/play.js`
+ (`sdk.getRoot().onAttributeChange("paused", …)`, returns cleanup); registered via
+ `src/sdk/index.js:27-29` `register`.
+- `react-dom@^19.2.4` present (`createRoot`): `package.json:75`.
+
+## Risks / open questions
+
+- **Measurement fidelity vs. reality:** Storybook mock payload sizes differ from production; treat
+ Storybook numbers as ratios, and use the self-mounting HUD on a real cloud dashboard for absolute
+ figures.
+- **`performance.now()` overhead in the seam:** negligible (two calls per render), and fully gated
+ off when `perfMonitor` is false.
+- **Heap availability:** `performance.memory` is Chrome-only; heap metric is best-effort.
+- **`to-cloud` workflow:** measuring on a real dashboard requires `yarn to-cloud`; the maintainer
+ runs and verifies this themselves.
diff --git a/docs/uplot-perf-harness-plan.md b/docs/uplot-perf-harness-plan.md
new file mode 100644
index 000000000..4a156338e
--- /dev/null
+++ b/docs/uplot-perf-harness-plan.md
@@ -0,0 +1,807 @@
+# uPlot Perf Harness + Renderer-Selection Cleanup Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add an opt-in, renderer-agnostic performance harness (render-time + heap, HUD overlay, Storybook bench) so the maintainer can measure uPlot vs dygraph on a real dashboard, and collapse renderer selection onto the single `chartLibrary` attribute.
+
+**Architecture:** Time each `render()` at the `makeChart` fan-out seam into a plain module-level registry (never in chart attributes). A self-mounting SDK plugin renders a HUD onto `document.body` when the `perfMonitor` root attribute is on. Renderer selection reads `chartLibrary`, with `chartLibrariesByType` demoted to an optional per-type override that falls back to `chartLibrary`.
+
+**Tech Stack:** JavaScript (no TS), React 19 (automatic JSX, `react-dom/client` `createRoot`), Jest + jsdom, `@testing-library/react`, `@jest/testUtilities` (`makeTestChart`, `renderWithChart`).
+
+## Global Constraints
+
+- No semicolons; double quotes; 2-space indent; 100-char width; ES5 trailing commas; arrow functions.
+- ES6 imports at top of file only — never `require()` or dynamic import in a function body.
+- **No inline/description comments.** Only real function documentation is allowed.
+- Do not create component/JS filenames starting with an uppercase letter.
+- **Never mock** — use real imports, real components, real SDK via `@jest/testUtilities`.
+- Perf stats live in a module-level registry, **never** in chart attributes (attribute writes trigger renders and corrupt the measurement).
+- Test run command: `yarn jest --config ./jest/config.js --collectCoverage=false`.
+- Do not commit `src/components/toolbox/settings/numberFormat.js` or its `.test.js` (maintainer's in-flight work).
+
+---
+
+## File Structure
+
+- `src/sdk/makeChart/filters/makeControllers.js` (modify) — renderer-resolution helpers.
+- `src/makeDefaultSDK.js` (modify) — empty default map; register the perfMonitor plugin.
+- `src/sdk/initialAttributes.js` (modify) — `perfMonitor: false` default.
+- `src/index.stories.js` (modify) — drop the story-built map.
+- `src/sdk/makeChart/index.js` (modify) — wrap the render fan-out with `timeRender`.
+- `src/sdk/plugins/perfMonitor/registry.js` (create) — pure stats registry.
+- `src/sdk/plugins/perfMonitor/index.js` (create) — self-mounting HUD plugin.
+- `src/components/perf/index.js` (create) — HUD component.
+- `src/perf.stories.js` (create) — Storybook bench story.
+- Test files colocated: `registry.test.js`, `index.test.js` (plugin), `src/components/perf/index.test.js`, `src/sdk/makeChart/renderPerf.test.js`, plus edits to `makeControllers.test.js`.
+
+---
+
+### Task 1: Renderer selection — `chartLibrary` as the single selector
+
+**Files:**
+- Modify: `src/sdk/makeChart/filters/makeControllers.js:122-127`
+- Modify: `src/makeDefaultSDK.js:41-48`
+- Modify: `src/index.stories.js:17,31`
+- Test: `src/sdk/makeChart/filters/makeControllers.test.js:300-313` (rewrite one test, add two)
+
+**Interfaces:**
+- Consumes: nothing from other tasks.
+- Produces: `getRendererForChartType(chartType) → string` (map entry or, falling back, the chart's `chartLibrary`); `isTimeSeriesRenderer(chartLibrary) → boolean` (true for `"dygraph"`/`"uplot"` or any value present in the map). Default `chartLibrariesByType` is now `{}`.
+
+- [ ] **Step 1: Rewrite the default-map test and add resolution tests**
+
+In `src/sdk/makeChart/filters/makeControllers.test.js`, replace the existing `it("ships a default chartLibrariesByType map from makeDefaultSDK (all dygraph)", ...)` block with:
+
+```js
+ it("ships an empty chartLibrariesByType map from makeDefaultSDK", () => {
+ const { chart: c } = makeTestChart()
+
+ expect(c.getAttribute("chartLibrariesByType")).toEqual({})
+ })
+
+ it("falls back to the chart's chartLibrary when a type is unmapped", () => {
+ const { chart: c } = makeTestChart({
+ attributes: { chartLibrary: "uplot", chartLibrariesByType: { heatmap: "dygraph" } },
+ })
+ const ctrl = makeControllers(c)
+
+ expect(ctrl.getRendererForChartType("line")).toBe("uplot")
+ expect(ctrl.getRendererForChartType("heatmap")).toBe("dygraph")
+ })
+
+ it("recognizes uplot as a time-series renderer with an empty map", () => {
+ const { chart: c } = makeTestChart()
+ const ctrl = makeControllers(c)
+
+ expect(ctrl.isTimeSeriesRenderer("dygraph")).toBe(true)
+ expect(ctrl.isTimeSeriesRenderer("uplot")).toBe(true)
+ expect(ctrl.isTimeSeriesRenderer("gauge")).toBe(false)
+ })
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `yarn jest --config ./jest/config.js src/sdk/makeChart/filters/makeControllers.test.js --collectCoverage=false`
+Expected: FAIL — the empty-map test fails (default map is still enumerated), the fallback test fails (`getRendererForChartType("line")` returns `"dygraph"` not `"uplot"`).
+
+- [ ] **Step 3: Update the resolution helpers**
+
+In `src/sdk/makeChart/filters/makeControllers.js`, replace lines 122-127:
+
+```js
+ const timeSeriesRenderers = ["dygraph", "uplot"]
+
+ const getRendererForChartType = chartType =>
+ (chart.getAttribute("chartLibrariesByType") || {})[chartType] ||
+ chart.getAttribute("chartLibrary")
+
+ const isTimeSeriesRenderer = chartLibrary =>
+ timeSeriesRenderers.includes(chartLibrary) ||
+ Object.values(chart.getAttribute("chartLibrariesByType") || {}).includes(chartLibrary)
+```
+
+- [ ] **Step 4: Empty the default map**
+
+In `src/makeDefaultSDK.js`, replace the enumerated `chartLibrariesByType` block (lines 41-48) with:
+
+```js
+ chartLibrariesByType: {},
+```
+
+(Keep `chartLibrary: "dygraph",` on line 40.)
+
+- [ ] **Step 5: Drop the story-built map**
+
+In `src/index.stories.js`, delete the `timeSeriesTypes` const (line 17) and the `chartLibrariesByType` line inside `makeSdkWithLibrary` (line 31), leaving:
+
+```js
+const makeSdkWithLibrary = (chartLibrary = "dygraph", sdkAttributes = {}) =>
+ makeDefaultSDK({
+ attributes: {
+ chartLibrary,
+ ...sdkAttributes,
+ },
+ })
+```
+
+- [ ] **Step 6: Run the affected suites to verify they pass**
+
+Run: `yarn jest --config ./jest/config.js src/sdk/makeChart/filters/makeControllers.test.js src/components/toolbox/chartType.test.js src/components/toolbox/settings/tabs/chartType.test.js --collectCoverage=false`
+Expected: PASS. (The toolbox tests still pass because `isTimeSeriesRenderer("uplot")` is now true via the constant set even with an empty map.)
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add src/sdk/makeChart/filters/makeControllers.js src/makeDefaultSDK.js src/index.stories.js src/sdk/makeChart/filters/makeControllers.test.js
+git commit -m "refactor(charts): chartLibrary is the single renderer selector; chartLibrariesByType is an optional per-type override"
+```
+
+---
+
+### Task 2: Perf stats registry (pure module)
+
+**Files:**
+- Create: `src/sdk/plugins/perfMonitor/registry.js`
+- Test: `src/sdk/plugins/perfMonitor/registry.test.js`
+
+**Interfaces:**
+- Produces:
+ - `setEnabled(bool)`, `isEnabled() → bool`
+ - `record(chartId: string, renderer: string, ms: number)`
+ - `timeRender(chartId: string, renderer: string, fn: () => void)` — always calls `fn`; records `fn`'s duration only when enabled.
+ - `sampleHeap()` — records one `performance.memory.usedJSHeapSize` sample if available.
+ - `snapshot() → { overall: {count,p50,p95,max}, renderers: { [name]: {count,p50,p95,max} }, heap: { current, peak, supported } }`
+ - `reset()`
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `src/sdk/plugins/perfMonitor/registry.test.js`:
+
+```js
+import {
+ setEnabled,
+ isEnabled,
+ record,
+ timeRender,
+ snapshot,
+ reset,
+ sampleHeap,
+} from "./registry"
+
+describe("perf registry", () => {
+ beforeEach(() => {
+ reset()
+ setEnabled(false)
+ })
+
+ it("records durations and computes per-renderer and overall stats", () => {
+ record("c1", "uplot", 10)
+ record("c1", "uplot", 20)
+ record("c1", "uplot", 30)
+
+ const snap = snapshot()
+ expect(snap.overall.count).toBe(3)
+ expect(snap.overall.p50).toBe(20)
+ expect(snap.overall.max).toBe(30)
+ expect(snap.renderers.uplot.count).toBe(3)
+ })
+
+ it("clears all samples on reset", () => {
+ record("c1", "dygraph", 5)
+ reset()
+ expect(snapshot().overall.count).toBe(0)
+ })
+
+ it("timeRender always calls fn but records only when enabled", () => {
+ let calls = 0
+ const fn = () => {
+ calls++
+ }
+
+ timeRender("c1", "uplot", fn)
+ expect(calls).toBe(1)
+ expect(snapshot().overall.count).toBe(0)
+
+ setEnabled(true)
+ timeRender("c1", "uplot", fn)
+ expect(calls).toBe(2)
+ expect(snapshot().overall.count).toBe(1)
+ })
+
+ it("reports heap unsupported when performance.memory is absent", () => {
+ sampleHeap()
+ expect(snapshot().heap.supported).toBe(false)
+ })
+})
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `yarn jest --config ./jest/config.js src/sdk/plugins/perfMonitor/registry.test.js --collectCoverage=false`
+Expected: FAIL — cannot resolve `./registry`.
+
+- [ ] **Step 3: Implement the registry**
+
+Create `src/sdk/plugins/perfMonitor/registry.js`:
+
+```js
+const MAX_SAMPLES = 500
+
+let enabled = false
+const byChart = new Map()
+let heapCurrent = null
+let heapPeak = null
+
+const getEntry = (chartId, renderer) => {
+ let entry = byChart.get(chartId)
+ if (!entry) {
+ entry = { renderer, durations: [] }
+ byChart.set(chartId, entry)
+ }
+ entry.renderer = renderer
+ return entry
+}
+
+export const setEnabled = value => {
+ enabled = value
+}
+
+export const isEnabled = () => enabled
+
+export const record = (chartId, renderer, ms) => {
+ const { durations } = getEntry(chartId, renderer)
+ durations.push(ms)
+ if (durations.length > MAX_SAMPLES) durations.shift()
+}
+
+export const timeRender = (chartId, renderer, fn) => {
+ if (!enabled) return fn()
+
+ const start = performance.now()
+ fn()
+ record(chartId, renderer, performance.now() - start)
+}
+
+export const sampleHeap = () => {
+ const memory = performance.memory
+ if (!memory) return
+
+ heapCurrent = memory.usedJSHeapSize
+ heapPeak = Math.max(heapPeak ?? 0, heapCurrent)
+}
+
+const quantile = (sorted, q) => {
+ if (!sorted.length) return 0
+
+ const pos = (sorted.length - 1) * q
+ const base = Math.floor(pos)
+ const rest = pos - base
+ const next = sorted[base + 1]
+
+ return next !== undefined ? sorted[base] + rest * (next - sorted[base]) : sorted[base]
+}
+
+const stats = durations => {
+ const sorted = [...durations].sort((a, b) => a - b)
+
+ return {
+ count: sorted.length,
+ p50: quantile(sorted, 0.5),
+ p95: quantile(sorted, 0.95),
+ max: sorted.length ? sorted[sorted.length - 1] : 0,
+ }
+}
+
+export const snapshot = () => {
+ const all = []
+ const byRenderer = {}
+
+ byChart.forEach(({ renderer, durations }) => {
+ all.push(...durations)
+ byRenderer[renderer] = (byRenderer[renderer] || []).concat(durations)
+ })
+
+ return {
+ overall: stats(all),
+ renderers: Object.fromEntries(
+ Object.entries(byRenderer).map(([renderer, durations]) => [renderer, stats(durations)])
+ ),
+ heap: { current: heapCurrent, peak: heapPeak, supported: !!performance.memory },
+ }
+}
+
+export const reset = () => {
+ byChart.clear()
+ heapCurrent = null
+ heapPeak = null
+}
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `yarn jest --config ./jest/config.js src/sdk/plugins/perfMonitor/registry.test.js --collectCoverage=false`
+Expected: PASS (4 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/sdk/plugins/perfMonitor/registry.js src/sdk/plugins/perfMonitor/registry.test.js
+git commit -m "feat(charts): perf stats registry (render-time + heap)"
+```
+
+---
+
+### Task 3: Wire the render seam to `timeRender`
+
+**Files:**
+- Modify: `src/sdk/makeChart/index.js:2,138-140`
+- Test: `src/sdk/makeChart/renderPerf.test.js`
+
+**Interfaces:**
+- Consumes: `timeRender(chartId, renderer, fn)`, `setEnabled`, `reset`, `snapshot` from Task 2.
+- Produces: every `render()` fan-out is timed and tagged with the chart's `chartLibrary` when the registry is enabled.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `src/sdk/makeChart/renderPerf.test.js`:
+
+```js
+import { makeTestChart } from "@jest/testUtilities"
+import { setEnabled, reset, snapshot } from "@/sdk/plugins/perfMonitor/registry"
+
+describe("render timing seam", () => {
+ beforeEach(() => {
+ jest.useFakeTimers()
+ reset()
+ setEnabled(false)
+ })
+
+ afterEach(() => {
+ setEnabled(false)
+ reset()
+ jest.useRealTimers()
+ })
+
+ it("records a render sample tagged with the chart's renderer when enabled", () => {
+ const { chart } = makeTestChart({ attributes: { chartLibrary: "uplot" } })
+
+ setEnabled(true)
+ chart.trigger("render")
+ jest.runOnlyPendingTimers()
+
+ const snap = snapshot()
+ expect(snap.overall.count).toBeGreaterThanOrEqual(1)
+ expect(snap.renderers.uplot.count).toBeGreaterThanOrEqual(1)
+ })
+
+ it("does not record when disabled", () => {
+ const { chart } = makeTestChart({ attributes: { chartLibrary: "uplot" } })
+
+ chart.trigger("render")
+ jest.runOnlyPendingTimers()
+
+ expect(snapshot().overall.count).toBe(0)
+ })
+})
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `yarn jest --config ./jest/config.js src/sdk/makeChart/renderPerf.test.js --collectCoverage=false`
+Expected: FAIL — `overall.count` is 0 because the seam does not record yet.
+
+- [ ] **Step 3: Wrap the render fan-out**
+
+In `src/sdk/makeChart/index.js`, add the import near the other top-level imports (after line 2):
+
+```js
+import { timeRender } from "@/sdk/plugins/perfMonitor/registry"
+```
+
+Then replace the `render` definition at lines 138-140:
+
+```js
+ const render = executeLatest.add(() =>
+ Object.keys(uiInstances).forEach(uiName =>
+ timeRender(node.getId(), node.getAttribute("chartLibrary"), () =>
+ uiInstances[uiName].render()
+ )
+ )
+ )
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `yarn jest --config ./jest/config.js src/sdk/makeChart/renderPerf.test.js --collectCoverage=false`
+Expected: PASS (2 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/sdk/makeChart/index.js src/sdk/makeChart/renderPerf.test.js
+git commit -m "feat(charts): time render() at the makeChart seam via perf registry"
+```
+
+---
+
+### Task 4: Perf HUD component
+
+**Files:**
+- Create: `src/components/perf/index.js`
+- Test: `src/components/perf/index.test.js`
+
+**Interfaces:**
+- Consumes: `snapshot`, `reset`, `record`, `setEnabled` from Task 2.
+- Produces: default-exported `PerfOverlay` React component (no props). Reads the registry on mount and polls every 500ms. Renders `data-testid="perfOverlay"`, per-renderer rows `data-testid="perf-renderer-"`, and `perf-reset`/`perf-copy` buttons.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `src/components/perf/index.test.js`:
+
+```js
+import { render, screen } from "@testing-library/react"
+import { record, reset, setEnabled } from "@/sdk/plugins/perfMonitor/registry"
+import PerfOverlay from "./"
+
+describe("PerfOverlay", () => {
+ beforeEach(() => {
+ reset()
+ setEnabled(true)
+ })
+
+ afterEach(() => {
+ reset()
+ setEnabled(false)
+ })
+
+ it("shows seeded registry stats", () => {
+ record("c1", "uplot", 12)
+ record("c1", "uplot", 8)
+
+ render()
+
+ expect(screen.getByTestId("perfOverlay")).toBeInTheDocument()
+ expect(screen.getByText(/renders: 2/)).toBeInTheDocument()
+ expect(screen.getByTestId("perf-renderer-uplot")).toHaveTextContent("uplot: 2")
+ })
+
+ it("shows heap n/a when unsupported", () => {
+ render()
+
+ expect(screen.getByText(/heap: n\/a/)).toBeInTheDocument()
+ })
+})
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `yarn jest --config ./jest/config.js src/components/perf/index.test.js --collectCoverage=false`
+Expected: FAIL — cannot resolve `./`.
+
+- [ ] **Step 3: Implement the HUD component**
+
+Create `src/components/perf/index.js`:
+
+```js
+import { useEffect, useState } from "react"
+import { snapshot, reset } from "@/sdk/plugins/perfMonitor/registry"
+
+const boxStyle = {
+ position: "fixed",
+ top: "8px",
+ right: "8px",
+ zIndex: 2147483647,
+ padding: "8px 10px",
+ background: "rgba(0, 0, 0, 0.82)",
+ color: "#fff",
+ font: "11px/1.5 monospace",
+ borderRadius: "4px",
+ pointerEvents: "auto",
+ minWidth: "200px",
+}
+
+const buttonStyle = {
+ marginRight: "6px",
+ marginTop: "6px",
+ font: "11px monospace",
+ cursor: "pointer",
+}
+
+const ms = n => `${n.toFixed(1)}ms`
+const mb = n => (n == null ? "n/a" : `${(n / 1048576).toFixed(1)}MB`)
+
+const PerfOverlay = () => {
+ const [snap, setSnap] = useState(snapshot)
+
+ useEffect(() => {
+ const id = setInterval(() => setSnap(snapshot()), 500)
+ return () => clearInterval(id)
+ }, [])
+
+ const { overall, renderers, heap } = snap
+
+ const copy = () => navigator.clipboard?.writeText(JSON.stringify(snapshot(), null, 2))
+
+ return (
+
+
renders: {overall.count}
+
+ p50 {ms(overall.p50)} · p95 {ms(overall.p95)} · max {ms(overall.max)}
+