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)} +
+ {Object.entries(renderers).map(([name, s]) => ( +
+ {name}: {s.count} · p95 {ms(s.p95)} +
+ ))} +
heap: {heap.supported ? `${mb(heap.current)} (peak ${mb(heap.peak)})` : "n/a"}
+ + +
+ ) +} + +export default PerfOverlay +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `yarn jest --config ./jest/config.js src/components/perf/index.test.js --collectCoverage=false` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/components/perf/index.js src/components/perf/index.test.js +git commit -m "feat(charts): perf HUD overlay component" +``` + +--- + +### Task 5: Self-mounting perfMonitor plugin + +**Files:** +- Create: `src/sdk/plugins/perfMonitor/index.js` +- Test: `src/sdk/plugins/perfMonitor/index.test.js` +- Modify: `src/makeDefaultSDK.js` (import + `plugins` map) +- Modify: `src/sdk/initialAttributes.js` (add `perfMonitor: false`) + +**Interfaces:** +- Consumes: `PerfOverlay` (Task 4); `setEnabled`, `sampleHeap`, `reset` (Task 2); the plugin contract `sdk => cleanup` and `sdk.getRoot().onAttributeChange(name, handler) → off` (from `makeNode`, mirroring `src/sdk/plugins/play.js`). +- Produces: when root `perfMonitor` is `true`, a `
` appended to `document.body` hosting a React root that renders `PerfOverlay`, the registry enabled, and a 1s heap sampler running; all torn down when `perfMonitor` is `false` or the plugin is unregistered. + +- [ ] **Step 1: Write the failing test** + +Create `src/sdk/plugins/perfMonitor/index.test.js`: + +```js +import { makeTestChart } from "@jest/testUtilities" +import { isEnabled, reset } from "./registry" + +describe("perfMonitor plugin", () => { + afterEach(() => { + document.body.innerHTML = "" + reset() + }) + + it("mounts the overlay and enables the registry when perfMonitor turns on, and tears down when off", () => { + const { sdk } = makeTestChart() + + expect(document.querySelector("[data-testid='perfOverlay-root']")).toBeNull() + expect(isEnabled()).toBe(false) + + sdk.getRoot().updateAttributes({ perfMonitor: true }) + + expect(document.querySelector("[data-testid='perfOverlay-root']")).not.toBeNull() + expect(isEnabled()).toBe(true) + + sdk.getRoot().updateAttributes({ perfMonitor: false }) + + expect(document.querySelector("[data-testid='perfOverlay-root']")).toBeNull() + expect(isEnabled()).toBe(false) + }) +}) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `yarn jest --config ./jest/config.js src/sdk/plugins/perfMonitor/index.test.js --collectCoverage=false` +Expected: FAIL — the plugin is not registered, so nothing mounts (`perfOverlay-root` stays null after enabling). + +- [ ] **Step 3: Implement the plugin** + +Create `src/sdk/plugins/perfMonitor/index.js`: + +```js +import { createRoot } from "react-dom/client" +import PerfOverlay from "@/components/perf" +import { setEnabled, sampleHeap, reset } from "./registry" + +export default sdk => { + let container = null + let root = null + let heapId = null + + const mount = () => { + if (container) return + + reset() + setEnabled(true) + heapId = setInterval(sampleHeap, 1000) + + container = document.createElement("div") + container.setAttribute("data-testid", "perfOverlay-root") + document.body.appendChild(container) + + root = createRoot(container) + root.render() + } + + const unmount = () => { + setEnabled(false) + + if (heapId) { + clearInterval(heapId) + heapId = null + } + if (root) { + root.unmount() + root = null + } + if (container) { + container.remove() + container = null + } + } + + const off = sdk.getRoot().onAttributeChange("perfMonitor", value => (value ? mount() : unmount())) + + if (sdk.getRoot().getAttribute("perfMonitor")) mount() + + return () => { + off() + unmount() + } +} +``` + +- [ ] **Step 4: Register the plugin and add the default attribute** + +In `src/makeDefaultSDK.js`, add the import after line 19: + +```js +import perfMonitor from "./sdk/plugins/perfMonitor" +``` + +Then add `perfMonitor` to the `plugins` map (after `fullscreen,` on line 36): + +```js + fullscreen, + perfMonitor, +``` + +In `src/sdk/initialAttributes.js`, add `perfMonitor: false,` to the exported defaults object (next to `autofetchOnHovering: false,` on line 18): + +```js + autofetchOnHovering: false, + perfMonitor: false, +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `yarn jest --config ./jest/config.js src/sdk/plugins/perfMonitor/index.test.js --collectCoverage=false` +Expected: PASS (1 test). + +- [ ] **Step 6: Run the full suite to confirm no regressions** + +Run: `yarn jest --config ./jest/config.js --collectCoverage=false` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add src/sdk/plugins/perfMonitor/index.js src/sdk/plugins/perfMonitor/index.test.js src/makeDefaultSDK.js src/sdk/initialAttributes.js +git commit -m "feat(charts): self-mounting perfMonitor plugin (HUD on document.body)" +``` + +--- + +### Task 6: Storybook perf bench story (visual) + +**Files:** +- Create: `src/perf.stories.js` + +**Interfaces:** +- Consumes: `makeDefaultSDK`, `Line`, `makeMockPayload`, the `perfMonitor` attribute (Task 5), and `chartLibrary` selection (Task 1). +- Produces: a `Perf/Benchmark` story mounting `count` streaming line charts under one SDK with `perfMonitor: true`; the HUD self-mounts. + +> This task has no unit test — Storybook cannot paint in jsdom. Verification is visual by the maintainer (`yarn storybook`). Do not run a dev server on their behalf. + +- [ ] **Step 1: Create the story** + +Create `src/perf.stories.js`: + +```js +import { useMemo } from "react" +import { ThemeProvider } from "styled-components" +import { Flex, DefaultTheme } from "@netdata/netdata-ui" +import Line from "@/components/line" +import makeMockPayload from "@/helpers/makeMockPayload" +import makeDefaultSDK from "./makeDefaultSDK" +import systemLoadLine from "../fixtures/systemLoadLine" + +const getChart = makeMockPayload(systemLoadLine[0], { delay: 600 }) + +export const Benchmark = ({ chartLibrary, count }) => { + const charts = useMemo(() => { + const sdk = makeDefaultSDK({ attributes: { chartLibrary, perfMonitor: true } }) + + return Array.from({ length: count }, () => { + const chart = sdk.makeChart({ getChart, attributes: { contextScope: ["system.load"] } }) + sdk.appendChild(chart) + return chart + }) + }, [chartLibrary, count]) + + return ( + + + {charts.map(chart => ( + + ))} + + + ) +} + +Benchmark.args = { chartLibrary: "dygraph", count: 25 } +Benchmark.argTypes = { + chartLibrary: { name: "Chart library", control: "select", options: ["dygraph", "uplot"] }, + count: { name: "Chart count", control: "select", options: [10, 25, 50] }, +} + +export default { + title: "Perf/Benchmark", + component: Benchmark, + parameters: { + docs: { + description: { + component: + "Streaming dygraph-vs-uPlot A/B. Set library + count, let it stream ~60s, use the HUD's copy button. The mock ignores requested point counts, so absolute numbers differ from production — compare the dygraph/uPlot ratio under identical settings.", + }, + }, + }, +} +``` + +- [ ] **Step 2: Verify the build is not broken** + +Run: `yarn jest --config ./jest/config.js --collectCoverage=false` +Expected: PASS (the new story file must not break test collection). + +- [ ] **Step 3: Commit** + +```bash +git add src/perf.stories.js +git commit -m "feat(charts): Storybook perf bench story (streaming N charts, dygraph vs uPlot)" +``` + +--- + +## Self-Review + +**1. Spec coverage:** +- §1 render-time instrumentation → Task 3 (seam) + Task 2 (registry `record`/`timeRender`). ✓ +- §2 heap sampling → Task 2 (`sampleHeap`, `snapshot().heap`) + Task 5 (1s interval). ✓ +- §3a registry → Task 2. §3b self-mounting plugin → Task 5. §3c HUD component → Task 4. ✓ +- §4 renderer-selection cleanup (empty map, `|| chartLibrary` fallback, `isTimeSeriesRenderer` constant set, story simplification) → Task 1. ✓ +- §5 Storybook bench story → Task 6. ✓ +- Opt-in model (`perfMonitor` default false; flip via root `chartLibrary`) → Task 5 (`initialAttributes`) + Task 1. ✓ +- Constraint "no perf stats in attributes" → registry is a module singleton (Task 2). ✓ + +**2. Placeholder scan:** No TBD/TODO; every code step shows complete code; commands have expected output. ✓ + +**3. Type consistency:** `record`, `timeRender`, `snapshot`, `setEnabled`, `isEnabled`, `sampleHeap`, `reset` are defined in Task 2 and consumed with identical signatures in Tasks 3, 4, 5. `snapshot()` shape (`overall`/`renderers`/`heap`) matches the HUD reads in Task 4 and the registry tests in Task 2. `perfOverlay-root` (plugin container) and `perfOverlay` (component root) test ids are distinct and used consistently. ✓ diff --git a/docs/uplot-phase0-plan.md b/docs/uplot-phase0-plan.md new file mode 100644 index 000000000..8d2c87ea7 --- /dev/null +++ b/docs/uplot-phase0-plan.md @@ -0,0 +1,458 @@ +# uPlot Phase 0 — Renderer/Type Decoupling 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:** Make the Netdata time-series renderer configurable per chart type so uPlot (or any future renderer) can be selected in-app without breaking the toolbox, while dygraph stays the default for every type. + +**Architecture:** Add an SDK attribute `chartLibrariesByType` (chartType → renderer library) plus two chart helpers (`getRendererForChartType`, `isTimeSeriesRenderer`) on `makeControllers`. `updateChartTypeAttribute` resolves the renderer from the map instead of the literal `"dygraph"`; the two chart-type UI components decide whether to display the chart *type* or the *library* via `isTimeSeriesRenderer` and guard their `find` lookups so an unmapped value never throws. + +**Tech Stack:** React 19, styled-components 6, @netdata/netdata-ui; Jest + @testing-library/react (jsdom); `@jest/testUtilities` (`makeTestChart`, `renderWithChart`). + +**Spec:** `docs/uplot-migration-design.md`. Background: `docs/charting-library-exploration.md`. + +> **Status (2026-07-14): implemented inline on branch `explore/uplot-spike` (uncommitted).** +> Full suite 142 suites / 1366 passing / 0 failing; lint clean. Deviations from the draft below, +> chosen during implementation and reflected in the code: +> - Tests use **`table`** (an already-registered library) as the stand-in renderer, **not** the +> spike `uplot` — Phase 0 does not depend on the spike. +> - The toolbox `ChartType` button is icon-only (its `title` becomes a hover tooltip via +> `withTooltip`), so its test proves the fix via **no-throw**; the positive selected-label proof +> lives in the **settings** `ChartType` test (react-select renders the label as text) and in the +> controller resolution tests. +> - **Initial/auto-selection boundary:** `chartType` is payload-driven (set post-fetch in +> `makeDataFetch.js:121`), so *auto*-applying the map at initial render would require reacting to +> payload-driven `chartType` changes and rebuilding the UI. That is **deferred to the +> "flip the default" step at the end of Phase A** (it needs uPlot parity first). Phase 0 delivers +> selection via the type toggle and explicit `chartLibrary`, which is what dev/test/stories need. + +## Global Constraints + +- No semicolons; double quotes; 2-space indent; 100-col width; ES5 trailing commas; arrow functions. +- No descriptive inline comments (only real function docs). Lowercase JS filenames. +- Tests never mock netdata-ui/providers/components; use real imports + `makeTestChart`/`renderWithChart`. +- No new runtime dependencies in Phase 0. +- Run tests with: `yarn jest --config ./jest/config.js --collectCoverage=false`. +- dygraph remains the default renderer for every chart type after Phase 0. + +--- + +### Task 1: Renderer map + resolver helpers + +**Files:** +- Modify: `src/makeDefaultSDK.js` (attributes block, ~line 39) +- Modify: `src/sdk/makeChart/filters/makeControllers.js` (add helpers near the `chartLibraries` map at line 111; add to return object at ~line 345) +- Test: `src/sdk/makeChart/filters/makeControllers.test.js` + +**Interfaces:** +- Produces: `getRendererForChartType(chartType: string) => string` and `isTimeSeriesRenderer(chartLibrary: string) => boolean` on the controllers object (and therefore on the chart node, spread at `makeChart/index.js:423`). +- Consumes: `chart.getAttribute("chartLibrariesByType")` (a `{ [chartType]: library }` map). + +- [ ] **Step 1: Write the failing test** + +Add to `src/sdk/makeChart/filters/makeControllers.test.js`: + +```js +describe("renderer resolution", () => { + it("resolves the configured renderer for a chart type, defaulting to dygraph", () => { + const { chart: c } = makeTestChart({ + attributes: { chartLibrariesByType: { line: "table", heatmap: "dygraph" } }, + }) + const ctrl = makeControllers(c) + + expect(ctrl.getRendererForChartType("line")).toBe("table") + expect(ctrl.getRendererForChartType("heatmap")).toBe("dygraph") + expect(ctrl.getRendererForChartType("area")).toBe("dygraph") + }) + + it("identifies which libraries are time-series renderers", () => { + const { chart: c } = makeTestChart({ + attributes: { chartLibrariesByType: { line: "table" } }, + }) + const ctrl = makeControllers(c) + + expect(ctrl.isTimeSeriesRenderer("dygraph")).toBe(true) + expect(ctrl.isTimeSeriesRenderer("table")).toBe(true) + expect(ctrl.isTimeSeriesRenderer("gauge")).toBe(false) + }) + + it("ships a default chartLibrariesByType map from makeDefaultSDK (all dygraph)", () => { + const { chart: c } = makeTestChart() + + expect(c.getAttribute("chartLibrariesByType")).toEqual({ + line: "dygraph", + stacked: "dygraph", + area: "dygraph", + stackedBar: "dygraph", + multiBar: "dygraph", + heatmap: "dygraph", + }) + }) +}) +``` + +This last test fails until Step 3 adds the default map — Task 1's helper tests alone would pass +even with the map omitted, so this asserts the default actually exists. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `yarn jest --config ./jest/config.js src/sdk/makeChart/filters/makeControllers.test.js --collectCoverage=false` +Expected: FAIL — `c.getRendererForChartType is not a function`. + +- [ ] **Step 3: Add the default map to `src/makeDefaultSDK.js`** + +In the `attributes` object, insert the following block on the line **immediately below** the +existing `chartLibrary: "dygraph",` line (do **not** duplicate that line): + +```js + chartLibrariesByType: { + line: "dygraph", + stacked: "dygraph", + area: "dygraph", + stackedBar: "dygraph", + multiBar: "dygraph", + heatmap: "dygraph", + }, +``` + +Note: attribute overrides are **shallow-merged** (`makeDefaultSDK.js` spreads `...attributes`; +`sdk/index.js:19` spreads `...defaultAttributes`). Passing `chartLibrariesByType: { line: "uplot" }` +therefore **replaces** this whole map — omitted types resolve to `"dygraph"` via +`getRendererForChartType`'s fallback, so a partial override is safe but is a replace, not a +deep-merge. To keep other entries explicitly, spread the default in the override. + +- [ ] **Step 4: Add the helpers in `src/sdk/makeChart/filters/makeControllers.js`** + +Immediately after the `chartLibraries` object (ends line 120) add: + +```js + const getRendererForChartType = chartType => + (chart.getAttribute("chartLibrariesByType") || {})[chartType] || "dygraph" + + const isTimeSeriesRenderer = chartLibrary => + chartLibrary === "dygraph" || + Object.values(chart.getAttribute("chartLibrariesByType") || {}).includes(chartLibrary) +``` + +Then add both to the returned object (the `return { ... }` near line 345), e.g. after `updateChartTypeAttribute,`: + +```js + updateChartTypeAttribute, + getRendererForChartType, + isTimeSeriesRenderer, +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `yarn jest --config ./jest/config.js src/sdk/makeChart/filters/makeControllers.test.js --collectCoverage=false` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/makeDefaultSDK.js src/sdk/makeChart/filters/makeControllers.js src/sdk/makeChart/filters/makeControllers.test.js +git commit -m "feat(charts): add per-chart-type renderer map and resolver helpers" +``` + +--- + +### Task 2: `updateChartTypeAttribute` resolves the mapped renderer + +**Files:** +- Modify: `src/sdk/makeChart/filters/makeControllers.js:122-144` (`updateChartTypeAttribute`) +- Test: `src/sdk/makeChart/filters/makeControllers.test.js` + +**Interfaces:** +- Consumes: `getRendererForChartType` (Task 1). +- Behavior: for a time-series chart type, sets `chartLibrary` to the resolved renderer and `chartType` to the selection; rebuilds the UI only when the renderer actually changed. + +- [ ] **Step 1: Write the failing test** + +Add to `src/sdk/makeChart/filters/makeControllers.test.js`: + +```js +describe("updateChartTypeAttribute renderer resolution", () => { + it("uses the configured renderer for a time-series chart type", () => { + const { chart: c } = makeTestChart({ + attributes: { chartLibrariesByType: { line: "table" } }, + }) + const ctrl = makeControllers(c) + + ctrl.updateChartTypeAttribute("line") + + expect(c.getAttribute("chartLibrary")).toBe("table") + expect(c.getAttribute("chartType")).toBe("line") + }) + + it("defaults an unmapped time-series type to dygraph", () => { + const { chart: c } = makeTestChart() + const ctrl = makeControllers(c) + + ctrl.updateChartTypeAttribute("area") + + expect(c.getAttribute("chartLibrary")).toBe("dygraph") + expect(c.getAttribute("chartType")).toBe("area") + }) + + it("rebuilds the chart UI when the renderer changes", () => { + const { chart: c } = makeTestChart({ + attributes: { chartLibrariesByType: { line: "table" } }, + }) + const ctrl = makeControllers(c) + + const before = c.getUI() + ctrl.updateChartTypeAttribute("line") + + expect(c.getUI()).not.toBe(before) + }) + + it("keeps the same chart UI when switching types that share a renderer", () => { + const { chart: c } = makeTestChart({ attributes: { chartType: "line" } }) + const ctrl = makeControllers(c) + + const before = c.getUI() + ctrl.updateChartTypeAttribute("area") + + expect(c.getUI()).toBe(before) + }) +}) +``` + +(`table` is an already-registered library used as a stand-in renderer — the point is that a +time-series chart type resolves to a configured library and the UI rebuilds on renderer change.) + +Use an already-registered library as the stand-in renderer (`table`) so Phase 0 does not depend on +the uncommitted spike — no extra import or `addUI` is needed; `table` is in the default `ui` map. +Component tests (Tasks 3–4) can set `chartLibrary` to the plain string `"uplot"` after creation +(the components never mount a UI, so no library needs registering). + +- [ ] **Step 2: Run test to verify it fails** + +Run: `yarn jest --config ./jest/config.js src/sdk/makeChart/filters/makeControllers.test.js --collectCoverage=false` +Expected: FAIL — `chartLibrary` is `"dygraph"`, expected `"table"`. + +- [ ] **Step 3: Update `updateChartTypeAttribute`** + +Replace the `if (!chartLibraries[selected]) { ... }` branch (lines 126-135) with: + +```js + if (!chartLibraries[selected]) { + const nextChartLibrary = getRendererForChartType(selected) + chart.updateAttributes({ + chartLibrary: nextChartLibrary, + chartType: selected, + processing: true, + }) + if (prevChartLibrary !== nextChartLibrary) { + chart.getUI().unmount() + chart.setUI({ ...chart.sdk.makeChartUI(chart), ...(chart.ui || {}) }, "default") + } + } else { +``` + +(The `else` branch and everything after line 144 stay unchanged.) + +- [ ] **Step 4: Run test to verify it passes** + +Run: `yarn jest --config ./jest/config.js src/sdk/makeChart/filters/makeControllers.test.js --collectCoverage=false` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/sdk/makeChart/filters/makeControllers.js src/sdk/makeChart/filters/makeControllers.test.js +git commit -m "feat(charts): resolve time-series renderer from chartLibrariesByType on type change" +``` + +--- + +### Task 3: Toolbox `ChartType` uses `isTimeSeriesRenderer` and cannot throw + +**Files:** +- Modify: `src/components/toolbox/chartType.js:131-136` +- Test: `src/components/toolbox/chartType.test.js` + +**Interfaces:** +- Consumes: `chart.isTimeSeriesRenderer` (Task 1). + +- [ ] **Step 1: Write the failing test** + +Add to `src/components/toolbox/chartType.test.js`: + +```js +it("does not throw when the time-series renderer is non-dygraph", () => { + const { chart } = makeTestChart({ + attributes: { chartLibrariesByType: { line: "uplot" } }, + }) + chart.updateAttributes({ chartLibrary: "uplot", chartType: "line" }) + + expect(() => renderWithChart(, { chart })).not.toThrow() + expect(screen.getByTestId("chartHeaderToolbox-chartType")).toBeInTheDocument() +}) +``` + +The toolbox button is icon-only (`title` becomes a hover tooltip via `withTooltip`, and the jest +svg transform renders every icon identically), so **no-throw is the meaningful proof here** — the +old code threw on the `items.find(...)` destructure. The positive selected-label proof lives in +Task 4 (settings, react-select renders the label as text) and in Task 1's resolution tests. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `yarn jest --config ./jest/config.js src/components/toolbox/chartType.test.js --collectCoverage=false` +Expected: FAIL — `TypeError: Cannot destructure property 'label' of ... as it is undefined` (`items.find` returns `undefined` for value `"uplot"`). + +- [ ] **Step 3: Update the component** + +In `src/components/toolbox/chartType.js`, replace lines 131 and 133 and 136: + +```js + const chartLibrary = useAttributeValue("chartLibrary") || "dygraph" + const chartType = useAttributeValue("chartType") || "line" + const value = chart.isTimeSeriesRenderer(chartLibrary) ? chartType : chartLibrary + + const items = useItems(chart) + const { label, svg } = items.find(({ value: v }) => v === value) || {} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `yarn jest --config ./jest/config.js src/components/toolbox/chartType.test.js --collectCoverage=false` +Expected: PASS (all existing tests in the file still pass). + +- [ ] **Step 5: Commit** + +```bash +git add src/components/toolbox/chartType.js src/components/toolbox/chartType.test.js +git commit -m "fix(charts): toolbox ChartType resolves renderer via isTimeSeriesRenderer" +``` + +--- + +### Task 4: Settings `ChartType` uses `isTimeSeriesRenderer` + +**Files:** +- Modify: `src/components/toolbox/settings/tabs/chartType.js:142-148` +- Test: `src/components/toolbox/settings/tabs/chartType.test.js` (create if absent) + +**Interfaces:** +- Consumes: `chart.isTimeSeriesRenderer` (Task 1). + +- [ ] **Step 1: Write the failing test** + +Create `src/components/toolbox/settings/tabs/chartType.test.js`: + +```js +import React from "react" +import { screen } from "@testing-library/react" +import "@testing-library/jest-dom" +import { renderWithChart, makeTestChart } from "@jest/testUtilities" +import ChartType from "./chartType" + +describe("settings ChartType", () => { + it("shows the chart type as the selected value for the dygraph renderer", () => { + const { chart } = makeTestChart({ + attributes: { chartLibrary: "dygraph", chartType: "area" }, + }) + + renderWithChart(, { chart }) + + expect(screen.getByText("Area")).toBeInTheDocument() + }) + + it("resolves to the chart type when the time-series renderer is non-dygraph", () => { + const { chart } = makeTestChart({ + attributes: { chartLibrariesByType: { line: "uplot" } }, + }) + chart.updateAttributes({ chartLibrary: "uplot", chartType: "line" }) + + renderWithChart(, { chart }) + + expect(screen.getByText("Line")).toBeInTheDocument() + }) +}) +``` + +react-select renders the selected value's label as text, so `getByText("Line")` is a real +selection assertion (unlike the icon-only toolbox button). With the old `=== "dygraph"` code the +second test's selection resolves wrong (`value` is `"uplot"`, `current` falls back to `options[0]` += Line by coincidence); the fix makes it resolve `value` to the chart type deterministically. + +- [ ] **Step 3: Update the component** + +In `src/components/toolbox/settings/tabs/chartType.js`, replace line 144: + +```js + const value = chart.isTimeSeriesRenderer(chartLibrary) ? chartType : chartLibrary +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `yarn jest --config ./jest/config.js src/components/toolbox/settings/tabs/chartType.test.js --collectCoverage=false` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/components/toolbox/settings/tabs/chartType.js src/components/toolbox/settings/tabs/chartType.test.js +git commit -m "fix(charts): settings ChartType resolves renderer via isTimeSeriesRenderer" +``` + +--- + +### Task 5: Full-suite + lint gate + +**Files:** none (verification only). + +- [ ] **Step 1: Run the full test suite** + +Run: `yarn jest --config ./jest/config.js --collectCoverage=false` +Expected: all suites pass (baseline 141 suites / 1356 passing before this plan; new tests add to that). + +- [ ] **Step 2: Lint the changed files** + +Run: `yarn eslint src/makeDefaultSDK.js 'src/sdk/makeChart/filters/*.js' 'src/components/toolbox/**/*.js'` +Expected: no errors. + +- [ ] **Step 3: Commit any lint fixes (if needed)** + +```bash +git add -A +git commit -m "chore(charts): lint fixes for phase 0" +``` + +--- + +## Post-plan: enabling uPlot line charts (manual verification, not part of Phase 0 commits) + +With Phase 0 merged, a consumer/story enables uPlot for line charts by registering it and setting +the map — no product-code edits: + +```js +const sdk = makeDefaultSDK({ attributes: { chartLibrariesByType: { line: "uplot" } } }) +sdk.addUI("uplot", uplot) +``` + +Verify in Storybook against the scenario checklist in `docs/charting-library-exploration.md` §7. + +## Scope: selection plumbing only + +Phase 0 **does not render uPlot** — dygraph stays the default renderer for every chart type. It +only decouples renderer *selection* so a consumer can point a chart type at a different renderer. +Two rendering concerns are therefore explicitly **Phase A**, not Phase 0: + +- **uPlot's own stylesheet is functional, not cosmetic.** `uplot/dist/uPlot.min.css` drives the + `.uplot` layout, overlay/cursor positioning and axis DOM; uPlot will not lay out correctly + without it. Phase A must decide how that CSS ships (bundled by the library vs imported by the + consumer). Today only the spike story imports it. +- The `chartContentWrapper.js:14` map is a **netdata-side** CSS-class hook keyed by + `chartLibrary` (with a `|| ""` fallback) — separate from uPlot's stylesheet; it needs a `uplot` + entry in Phase A when uPlot renders in-app. + +## Self-review notes + +- **Spec coverage:** Phase 0 design items (map, resolver, `updateChartTypeAttribute`, both + components, guards) each map to Task 1–4, and the default-map + UI-identity behaviors are + asserted in Tasks 1–2. +- **Type consistency:** `getRendererForChartType`/`isTimeSeriesRenderer` names are used + identically in Tasks 1–4. +- **Deferred to Phase A:** all uPlot feature parity (rendering modes, overlays, navigation, + ranges, unit/timezone reactions, perf measurement, flipping the default). diff --git a/docs/uplot-prod-parity-gap-map.md b/docs/uplot-prod-parity-gap-map.md new file mode 100644 index 000000000..95eaa25a6 --- /dev/null +++ b/docs/uplot-prod-parity-gap-map.md @@ -0,0 +1,122 @@ +# uPlot Prod-Parity Gap Map — dygraph → uPlot, zero functionality loss + +> Master checklist for switching production dashboards from dygraph to uPlot without losing any +> current functionality. Built from two source audits (renderer-internal + app-coupling) plus the +> navigation work. Status: `✓` = verified against source this session (file:line shown); `⧖` = +> audit-reported, individual spot-check still pending. + +## STATUS: COMPLETE — all P0/P1 landed; P2 done except two documented deferrals; rebased onto main (#222–#229) with #222 + smooth-line ported (see RECONCILED section) + +Every P0 and P1 item is implemented, unit-tested (no mocks), adversarially reviewed, and gated by +the full suite (1575 passing, 167 suites) with the maintainer's concurrent WIP files never touched. +Commits on `explore/uplot-spike`: + +- **P0.1** popover hover events — `4ce1aa6` (uPlot re-emits `mousemove`/`mouseout`/`mouseover` on the + chartUI bus with chart-root-relative offsets). +- **P0.2** `yAxisChange` (unit rescale) — `a9190db`, corrected source in `cf12de5` + `6007434` + (fires `getValueRange(chart,{dygraph:true})`, heatmap `min/max`, prevMin/prevMax guard). +- **P0.3** axis show/hide + gridlines-kept — `a9190db` + `cf12de5`. +- **P0.4** cursors / **P0.5** uPlot layout CSS — `3bf1050`. +- **P1.1** yRangePad / **P1.2** area zero / **P1.3** sparkline — `cf12de5` (+ stacked pad, line + includeZero in `6007434`). +- **P1.4** synced hover dots / **P1.5** overlay z-order (drawClear) — `4ce1aa6`. +- **P1.6** empty/out-of-limits framed chart — `59e9e1f` + `eb1f02d`. +- Navigation: modifier switch (mousedown + deferred restore) `6e9b2fd` → `3aafde3`; wheel-zoom math + + threshold + debounce-cancel `180d2de`; pan x-range override `7092e92`; wheel gate/threshold + `bca9c9b`. +- Overlays: `proceeded` + `point` types `303514d`; point-overlay markers `584b18e`. +- P2: y-axis label width/font + per-tick units + duration-nice ticks `20dbd38`; stepped stacked + interior `f06e677`. + +**Deferred (documented, non-blocking):** +- **Stacked-area point-reduction** (dygraph `plotters/stackedArea.js:73-115`): perf-only, NOT + pixel-identical (drops vertices), and uPlot is already cheaper per render than dygraph (see + `docs/uplot-migration-progress.md` perf measurements) — no perf need, and porting risks visual + drift in the diverging polygons. Skip unless a real perf issue appears. +- **Anomaly-rate y-axis icon** (dygraph `tickers/numeric.js:61-65`): a decorative SVG hexagon badge + dygraph injects as an HTML axis label. uPlot paints axis labels on canvas and can't host an + SVG/DOM node cleanly; both port routes (hand-drawn Path2D in the gutter, or a resynced DOM overlay) + are fragile hacks for a purely cosmetic glyph. The functional anomaly **ribbon** is implemented. + +**Remaining before flipping the default renderer:** visual verification in the app is the +maintainer's (per their workflow); the Storybook `Charts`/`RenderModes` stories exercise +`chartLibrary:"uplot"` across all chart types and interactions. + +## RECONCILED ONTO main (#222–#229) + drift re-audit + +`explore/uplot-spike` was rebased onto `main` (fork point `b8f3d08`; main had added #222–#229). +Conflicts resolved in `jest/setup.js` (matchMedia stub kept inside main's window guard), `package.json` +(uplot dep + version), and `src/sdk/makeChart/index.js` (perf `timeRender` seam nested inside main's +`renderIfStale` render loop). `timeRender` now forwards the wrapped render's return value so the +freshness bookkeeping still sees a renderer's `false` "declined" signal (`8ea77ba`). + +Each of #222–#229 was audited for dygraph behavior the uPlot renderer must also match: + +- **#222** (bound high-cardinality render) — **PORTED** `b811b15`. The new `renderIfStale` contract + requires `render()` to return `false` when declining to draw (stay stale) and `true` on success. + dygraph honored it; uPlot returned `undefined`, so declining under highlighting/panning/processing + marked the UI fresh and could skip a redraw dygraph would perform. Now mirrors dygraph's returns. +- **line-chart smooth curves** — **PORTED** `01eb4a1`. A pre-existing gap this doc had NOT captured: + dygraph draws non-stepped `chartType:"line"` as smoothed bezier curves (`plotters/linePlotter.js`, + applied for `chartType==="line" && !stepPlot`); uPlot drew straight polylines. Real payloads carry + `chart_type:"line"`, so this was the dominant visible case. `smoothLinePath.js` ports dygraph's + control-point math line-for-line and an oracle test drives dygraph's real `smoothLinePlotter` to + assert byte-identical op sequences. +- **#223** (reuse dygraph line data), **#224** (popover dimension windowing), **#225** (lightweight + SDK queries + correlate sparklines), **#226** (playback recovery / frozen-window gaps), **#227** + (skip hidden dygraph series extraction), **#228** (smooth-plotter allocation reduction), **#229** + (gauge value thresholds) — all **NO-OP for uPlot**: dygraph-internal perf, renderer-agnostic SDK + changes that apply to both renderers automatically, self-gated gauge/settings-only code, or an + optimization whose output is byte-identical (#228 changed no curve geometry — the parity target is + unchanged). Evidence recorded per-commit in the session audit. + +--- + + +## P0 — must fix before any prod switch (affects every / most charts, or a silent functional loss) + +| # | Gap | Evidence | Status | Plan | +|---|-----|----------|--------|------| +| P0.1 | **Hover value popover dead on uPlot.** Popover subscribes to chartUI `mousemove`/`mouseout` (`components/line/popover/index.js:45,76`); dygraph feeds that bus via its interactionModel (`dygraph/index.js:66-69`); uPlot emits no such chartUI events (only `rendered`/`resize`). Listeners are dead → tooltip silently never opens. | verified | G10 | +| P0.2 | **`yAxisChange` never fired → stale units + min/max.** dygraph fires it on every y-axis redraw (`dygraph/index.js:249,305`); `unitConversion` is the sole consumer that rescales prefix/precision + updates `min`/`max` (`helpers/unitConversion/index.js:108`); uPlot never fires it. After any zoom/pan/auto-range, unit prefix/precision and value formatting go stale. Needs a prevMin/prevMax dedup guard to avoid render loops. | verified | G10 | +| P0.3 | **X/Y axis show-hide toggles inert.** dygraph maps `enabledXAxis`/`enabledYAxis`→`drawAxis:false` (`dygraph/index.js:374-388`); uPlot's `getAxes` reads neither (`uplot/index.js:205-238`). Display-tab switches do nothing. | verified | G10 | +| P0.4 | **Navigation cursors missing on uPlot.** `cursorStyle` gated to the dygraph branch (`chartContentWrapper.js:43,48`); uPlot gets no cursor. | verified | G9.1 | +| P0.5 | **uPlot layout CSS not shipped by the library.** `uplot/dist/uPlot.min.css` imported only in `.storybook/preview.js:5`; Babel-only build, no bundler; inject rules via styled-components like dygraph. Without it: no `.u-select` rectangle, broken `.u-over/.u-under` positioning for consumers. | verified | G9.2 | + +## P1 — visible regressions on common charts (fix before/with prod) + +| # | Gap | Evidence | Status | Plan | +|---|-----|----------|--------|------| +| P1.1 | **No y-range padding.** dygraph `yRangePad:15` (`dygraph/index.js:109,230`); uPlot none for line/area → peaks/troughs flush against the plot border. | verified | G10 | +| P1.2 | **`area` doesn't force zero baseline.** dygraph `forceIncludeZero` for area (`dygraph/index.js:288,365-367`); uPlot area falls through to plain range (`uplot/index.js:172-174`). | verified | G10 | +| P1.3 | **Modifier-key nav switching missing** (Shift→select, Alt→highlight, Shift+Alt→selectVertical; restore on mouseup). dygraph `navigation/generic.js:20-40`; uPlot handles only pan. | verified | G9.3 | +| P1.4 | **`highlightStart` fires at drag-end not drag-start** → hover not suppressed during select. dygraph `navigation/select.js:9`; uPlot `onSetSelect` `uplot/index.js:547,552`. | verified | G9.4 | +| P1.5 | **No 5px drag threshold; wheel-zoom modifier mismatch.** dygraph `select.js:47`, wheel gated `generic.js:47`; uPlot fires on any width, zooms on plain wheel. | verified | G9.5 | +| P1.6 | **Sparkline series styling** (solid fill, zero stroke). dygraph `makeSparklineOptions` (`dygraph/index.js:437-449`); uPlot hides axes but `getSeries` ignores `isSparkline` (`uplot/index.js:93-116`). | ⧖ | G10 | +| P1.7 | **Synced cross-chart hover point dots lost.** dygraph `setSelection` draws dots (`dygraph/index.js:142-155`, `crosshair.js`); uPlot only redraws the vertical line (`uplot/index.js:861-862`). | ⧖ | G10 | +| P1.8 | **Shaded overlays paint on top of series, not behind.** dygraph draws on `underlayCallback` (`dygraph/overlays/index.js:42`); uPlot `drawOverlays` is the last draw hook (`uplot/index.js:791`) → alertTransitions/alarmRange tint the data lines. | ⧖ | G10 | +| P1.9 | **Empty / out-of-limits renders blank.** dygraph substitutes `[[0]]`/`["X"]` to keep a framed empty grid (`dygraph/index.js:48-54`); uPlot destroys the chart (`uplot/index.js` getData→null→destroyChart). | ⧖ | G10 | + +## P2 — cosmetic / edge / perf / no current consumer (schedule after P0–P1) + +| # | Gap | Evidence | Status | Plan | +|---|-----|----------|--------|------| +| P2.1 | Y-axis duration-aware tick placement + per-tick unit selection. dygraph `tickers/numeric.js:18-57` + per-tick unit (`dygraph/index.js:251-261`); uPlot uses default linear splits + one global unit (`uplot/index.js:234-235`). | ⧖ | G11 | +| P2.2 | `yAxisLabelWidth` / `axisLabelFontSize` ignored (hardcoded size 60, 11px — `uplot/index.js:19,228,232`). | ⧖ | G11 | +| P2.3 | `stepPlot` not applied to stacked/area interior (`uplot/index.js:84-90`, drawStacked straight segments). | ⧖ | G11 | +| P2.4 | Anomaly-rate y-axis indicator icon missing (dygraph `tickers/numeric.js:61-65`). | ⧖ | G11 | +| P2.5 | `proceeded` overlay type unimplemented on uPlot (`uplot/overlays/types.js`) — but masked by the independent `Processing` render path (`chartContentWrapper.js:92`), so user-visible effect ≈ nil. | ⧖ | G11 | +| P2.6 | `point` overlay type unimplemented — no shared consumer creates one today; only an external/API risk. | ⧖ | G11 | +| P2.7 | Stacked-area per-pixel point reduction (dense-data perf) not ported (`dygraph/plotters/stackedArea.js:73-115`). Output identical; perf only. | ⧖ | G11 | + +## Decisions +- **Governing rule: match dygraph exactly.** Wherever uPlot and dygraph differ, reproduce dygraph's + behavior/decision — no new UX. +- **Wheel-zoom:** RESOLVED → match dygraph (Shift/Alt-gated; plain wheel does nothing). Drives G9.5. +- **P2 scope:** still open — which P2 items are required for the target dashboards vs droppable. + +## Notes +- App-coupling audit found **no crash-level** gaps: every chartUI method shared code calls is present + on uPlot's `getUI()` surface or optional-chained. The only functional app-coupling loss is P0.1. +- Proposed grouping: **G9** = navigation/cursor/CSS (P0.4–5, P1.3–5; plan written). **G10** = the + remaining P0/P1 renderer + popover regressions. **G11** = P2 polish. diff --git a/jest/setup.js b/jest/setup.js index 26944b898..c7af58849 100644 --- a/jest/setup.js +++ b/jest/setup.js @@ -15,6 +15,19 @@ class ResizeObserver { if (typeof window !== "undefined") { window.ResizeObserver = ResizeObserver + if (!window.matchMedia) { + window.matchMedia = query => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }) + } + Element.prototype.getBoundingClientRect = jest.fn(() => { return { width: 120, diff --git a/package.json b/package.json index 3769e8957..3b26343aa 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,8 @@ "to-cloud": "yarn build:cjs && yarn build:es6 && yarn cp-cloud", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", + "perf:bench": "yarn build-storybook --quiet && node scripts/perf-bench.mjs", + "perf:bench:quick": "node scripts/perf-bench.mjs --quick", "format": "prettier --write \"**/*.{js,mjs}\"" }, "keywords": [ @@ -69,6 +71,7 @@ "jest": "^30.2.0", "jest-canvas-mock": "^2.5.2", "jest-environment-jsdom": "^30.2.0", + "playwright": "^1.62.1", "prettier": "^3.8.1", "raw-loader": "^4.0.2", "react": "^19.2.4", @@ -90,6 +93,7 @@ "jspdf": "4", "md5": "^2.3.0", "throttle-debounce": "^5.0.2", + "uplot": "^1.6.32", "uuid": "13" } } diff --git a/scripts/parity-probe.mjs b/scripts/parity-probe.mjs new file mode 100644 index 000000000..0eff430ec --- /dev/null +++ b/scripts/parity-probe.mjs @@ -0,0 +1,186 @@ +// yarn build-storybook && node scripts/parity-probe.mjs +// PARITY_HEIGHTS=300,120 node scripts/parity-probe.mjs +import http from "node:http" +import fs from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" +import { chromium } from "playwright" + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const staticDir = path.resolve(__dirname, "../storybook-static") +const outDir = path.resolve(__dirname, "../.parity-results") + +const STORY_ID = "perf-benchmark--benchmark" +const HEIGHTS = (process.env.PARITY_HEIGHTS || "400,300,200,120,100").split(",").map(Number) +const CHART_TYPE = process.env.PARITY_CHART_TYPE || "line" +const DIMS = Number(process.env.PARITY_DIMS || 3) +const ROWS = Number(process.env.PARITY_ROWS || 300) +const SETTLE_MS = Number(process.env.PARITY_SETTLE_MS || 2500) + +const mimeTypes = { + ".html": "text/html", + ".js": "text/javascript", + ".mjs": "text/javascript", + ".css": "text/css", + ".json": "application/json", + ".map": "application/json", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", +} + +const serveStatic = () => + new Promise(resolve => { + const server = http.createServer((req, res) => { + const urlPath = decodeURIComponent(req.url.split("?")[0]) + const relative = urlPath === "/" ? "/index.html" : urlPath + const filePath = path.join(staticDir, path.normalize(relative)) + + if (!filePath.startsWith(staticDir) || !fs.existsSync(filePath)) { + res.writeHead(404) + res.end("not found") + return + } + + res.writeHead(200, { "content-type": mimeTypes[path.extname(filePath)] || "text/plain" }) + fs.createReadStream(filePath).pipe(res) + }) + + server.listen(0, "127.0.0.1", () => resolve({ server, port: server.address().port })) + }) + +const storyUrl = (port, chartLibrary, height) => { + const args = [ + `chartLibrary:${chartLibrary}`, + "count:1", + `rows:${ROWS}`, + `dims:${DIMS}`, + `chartType:${CHART_TYPE}`, + `height:${height}px`, + "streaming:!false", + "autofetchOnHovering:!false", + ].join(";") + + return `http://127.0.0.1:${port}/iframe.html?id=${STORY_ID}&viewMode=story&args=${encodeURIComponent(args)}` +} + +const measure = async page => + page.evaluate(() => { + const container = document.querySelector("[data-testid='perfBenchmark']") + if (!container) return { error: "story container not found" } + + const base = container.getBoundingClientRect() + const rel = r => ({ + left: Math.round((r.left - base.left) * 10) / 10, + top: Math.round((r.top - base.top) * 10) / 10, + width: Math.round(r.width * 10) / 10, + height: Math.round(r.height * 10) / 10, + }) + + const over = container.querySelector(".u-over") + if (over) { + const root = container.querySelector(".uplot") + return { + source: "exact (.u-over)", + plot: rel(over.getBoundingClientRect()), + mountHeight: Math.round(root?.parentElement?.getBoundingClientRect().height ?? -1), + } + } + + const canvas = container.querySelector("canvas") + if (!canvas) return { error: "no canvas rendered" } + + const canvasRect = canvas.getBoundingClientRect() + const xLabels = [...container.querySelectorAll(".dygraph-axis-label-x")] + const yLabels = [...container.querySelectorAll(".dygraph-axis-label-y")] + + const plotBottom = xLabels.length + ? Math.min(...xLabels.map(el => el.getBoundingClientRect().top)) + : canvasRect.bottom + const plotLeft = yLabels.length + ? Math.max(...yLabels.map(el => el.getBoundingClientRect().right)) + : canvasRect.left + + return { + source: "derived (axis label divs)", + mountHeight: Math.round(canvas.parentElement?.getBoundingClientRect().height ?? -1), + plot: rel({ + left: plotLeft, + top: canvasRect.top, + width: canvasRect.right - plotLeft, + height: plotBottom - canvasRect.top, + }), + xLabels: xLabels.length, + yLabels: yLabels.length, + } + }) + +const run = async () => { + if (!fs.existsSync(staticDir)) { + console.error(`missing ${staticDir} - run "yarn build-storybook" first`) + process.exit(1) + } + + fs.mkdirSync(outDir, { recursive: true }) + + const { server, port } = await serveStatic() + const browser = await chromium.launch({ args: ["--no-sandbox"] }) + const context = await browser.newContext({ viewport: { width: 1600, height: 1000 } }) + const results = [] + + for (const height of HEIGHTS) { + for (const chartLibrary of ["dygraph", "uplot"]) { + const page = await context.newPage() + + try { + await page.goto(storyUrl(port, chartLibrary, height), { waitUntil: "load" }) + await page.waitForSelector("[data-testid='perfBenchmark'] canvas", { + state: "attached", + timeout: 30000, + }) + await page.waitForTimeout(SETTLE_MS) + + const measured = await measure(page) + results.push({ chartLibrary, height, ...measured }) + + const file = path.join(outDir, `${CHART_TYPE}-${height}px-${chartLibrary}.png`) + await page.locator("[data-testid='perfBenchmark']").screenshot({ path: file }) + } catch (error) { + results.push({ chartLibrary, height, error: error.message }) + } finally { + await page.close() + } + } + } + + await browser.close() + server.close() + + const rows = results.map(r => ({ + height: `${r.height}px`, + library: r.chartLibrary, + mount: r.mountHeight ?? "-", + top: r.plot?.top ?? "-", + plotHeight: r.plot?.height ?? "-", + left: r.plot?.left ?? "-", + plotWidth: r.plot?.width ?? "-", + source: r.source || r.error, + })) + + console.table(rows) + + const jsonFile = path.join(outDir, `geometry-${CHART_TYPE}.json`) + fs.writeFileSync( + jsonFile, + JSON.stringify({ chartType: CHART_TYPE, rows: ROWS, dims: DIMS, results }, null, 2) + ) + console.log(`\nscreenshots + json: ${outDir}`) +} + +run().catch(error => { + console.error(error) + process.exit(1) +}) diff --git a/scripts/perf-bench.mjs b/scripts/perf-bench.mjs new file mode 100644 index 000000000..dfcf6ca06 --- /dev/null +++ b/scripts/perf-bench.mjs @@ -0,0 +1,384 @@ +import http from "node:http" +import fs from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" +import { chromium } from "playwright" + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const staticDir = path.resolve(__dirname, "../storybook-static") +const outDir = path.resolve(__dirname, "../.perf-results") + +const STORY_ID = "perf-benchmark--benchmark" +const WARMUP_MS = Number(process.env.PERF_WARMUP_MS || 4000) +const MEASURE_MS = Number(process.env.PERF_MEASURE_MS || 10000) +const REPEATS = Number(process.env.PERF_REPEATS || 5) +const MAX_POINTS = Number(process.env.PERF_MAX_POINTS || 3000000) +const RUN_TIMEOUT_MS = Number(process.env.PERF_RUN_TIMEOUT_MS || 120000) +const HOVER_SETTLE_MS = Number(process.env.PERF_HOVER_SETTLE_MS || 2500) +const MIN_SAMPLES = Number(process.env.PERF_MIN_SAMPLES || 30) + +const mimeTypes = { + ".html": "text/html", + ".js": "text/javascript", + ".mjs": "text/javascript", + ".css": "text/css", + ".json": "application/json", + ".map": "application/json", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", +} + +const serveStatic = () => + new Promise(resolve => { + const server = http.createServer((req, res) => { + const urlPath = decodeURIComponent(req.url.split("?")[0]) + const relative = urlPath === "/" ? "/index.html" : urlPath + const filePath = path.join(staticDir, path.normalize(relative)) + + if (!filePath.startsWith(staticDir) || !fs.existsSync(filePath)) { + res.writeHead(404) + res.end("not found") + return + } + + res.writeHead(200, { "content-type": mimeTypes[path.extname(filePath)] || "text/plain" }) + fs.createReadStream(filePath).pipe(res) + }) + + server.listen(0, "127.0.0.1", () => resolve({ server, port: server.address().port })) + }) + +const storyUrl = (port, config) => { + const { chartLibrary, count, rows, dims, chartType, scenario } = config + // hoverInteraction isolates the cost of the gesture itself, with no streaming underneath + const streaming = scenario !== "hoverInteraction" + const autofetchOnHovering = scenario === "hoverStreaming" + + const args = [ + `chartLibrary:${chartLibrary}`, + `count:${count}`, + `rows:${rows}`, + `dims:${dims}`, + `chartType:${chartType}`, + `height:${config.height || "300px"}`, + `streaming:!${streaming}`, + `autofetchOnHovering:!${autofetchOnHovering}`, + ].join(";") + + return `http://127.0.0.1:${port}/iframe.html?id=${STORY_ID}&viewMode=story&args=${encodeURIComponent(args)}` +} + +const taskDuration = async client => { + const { metrics } = await client.send("Performance.getMetrics") + const entry = metrics.find(metric => metric.name === "TaskDuration") + return entry ? entry.value : 0 +} + +// sweep strictly inside the plot rect: crossing the axis gutter changes hover semantics +// read the DOM directly: locator.boundingBox auto-waits, and dygraph has no .u-over +const getPlotRect = async page => + page.evaluate(() => { + const rectOf = el => { + const r = el.getBoundingClientRect() + return { x: r.x, y: r.y, width: r.width, height: r.height } + } + + const over = document.querySelector(".u-over") + if (over) { + const r = rectOf(over) + if (r.height > 0 && r.width > 0) return r + } + + const canvas = document.querySelector("[data-testid='perfBenchmark'] canvas") + if (!canvas) return null + + const r = rectOf(canvas) + if (r.height <= 0 || r.width <= 0) return null + + // dygraph: inset to stay clear of its axis labels + return { + x: r.x + 60, + y: r.y + 8, + width: Math.max(10, r.width - 70), + height: Math.max(10, r.height - 24), + } + }) + +const sweepHover = async (page, rect, deadline) => { + const y = rect.y + rect.height / 2 + let step = 0 + + while (Date.now() < deadline) { + const ratio = (Math.sin(step / 8) + 1) / 2 + await page.mouse.move(rect.x + 4 + ratio * Math.max(1, rect.width - 8), y) + await page.waitForTimeout(32) + step++ + } +} + +// one page reused across runs: a cold context per run spends ~80s re-parsing the bundle +const runOnce = async ({ page, client }, port, config, scenario) => { + try { + await page.goto(storyUrl(port, config), { waitUntil: "load", timeout: RUN_TIMEOUT_MS }) + await page.waitForFunction(() => !!window.__netdataPerf, null, { timeout: RUN_TIMEOUT_MS }) + await page.waitForFunction(() => window.__netdataPerf.snapshot().overall.count > 0, null, { + timeout: RUN_TIMEOUT_MS, + }) + + await page.waitForTimeout(WARMUP_MS) + + const hovering = scenario === "hoverStreaming" || scenario === "hoverInteraction" + let rect = null + + if (hovering) { + rect = await getPlotRect(page) + if (!rect) return { ok: false, error: "no plot rect to hover" } + + // settle hover BEFORE the window opens, else we only measure the onset transient + await sweepHover(page, rect, Date.now() + HOVER_SETTLE_MS) + } + + await page.evaluate(() => window.__netdataPerf.reset()) + const taskBefore = await taskDuration(client) + const startedAt = Date.now() + + if (hovering) await sweepHover(page, rect, startedAt + MEASURE_MS) + else await page.waitForTimeout(MEASURE_MS) + + const elapsedMs = Date.now() - startedAt + const taskAfter = await taskDuration(client) + const snapshot = await page.evaluate(() => window.__netdataPerf.snapshot()) + + const renderer = snapshot.renderers[config.chartLibrary] || { count: 0, p50: 0, p95: 0, max: 0 } + + return { + ok: true, + elapsedMs, + renders: renderer.count, + p50: renderer.p50, + p95: renderer.p95, + max: renderer.max, + heapPeak: snapshot.heap.peak, + taskMs: (taskAfter - taskBefore) * 1000, + taskPerRender: renderer.count ? ((taskAfter - taskBefore) * 1000) / renderer.count : null, + } + } catch (error) { + return { ok: false, error: error.message.split("\n")[0] } + } +} + +const mean = values => values.reduce((sum, value) => sum + value, 0) / (values.length || 1) + +const stddev = values => { + if (values.length < 2) return 0 + const avg = mean(values) + return Math.sqrt(mean(values.map(value => (value - avg) ** 2))) +} + +const buildCells = () => { + const cells = [] + + // A: scaling curve on the common case + for (const rows of [300, 1000, 5000]) + for (const dims of [3, 20, 100]) + for (const count of [10, 50]) + cells.push({ phase: "A-scaling", rows, dims, count, chartType: "line", scenario: "idle" }) + + // B: synced hover. hoverStreaming keeps fetching while hovered (stress); hoverInteraction + // measures the gesture alone, which is what the crosshair/overlay work changed + for (const scenario of ["hoverStreaming", "hoverInteraction"]) + for (const dims of [20, 100]) + cells.push({ phase: "B-hover", rows: 1000, dims, count: 25, chartType: "line", scenario }) + + // C: expensive geometry chart types + for (const chartType of ["stacked", "heatmap"]) + for (const scenario of ["idle", "hoverStreaming", "hoverInteraction"]) + cells.push({ phase: "C-types", rows: 1000, dims: 20, count: 25, chartType, scenario }) + + return cells +} + +const main = async () => { + if (!fs.existsSync(staticDir)) { + console.error(`missing ${staticDir} — run \`yarn build-storybook\` first`) + process.exit(1) + } + + fs.mkdirSync(outDir, { recursive: true }) + + const { server, port } = await serveStatic() + // without an explicit ANGLE backend headless Chromium resolves WebGL through SwiftShader (CPU), + // which also drags down the Canvas2D path both renderers draw on + const browser = await chromium.launch({ + args: ["--no-sandbox", `--use-angle=${process.env.PERF_ANGLE || "metal"}`], + }) + const context = await browser.newContext({ viewport: { width: 1600, height: 1200 } }) + const page = await context.newPage() + const client = await context.newCDPSession(page) + await client.send("Performance.enable") + const session = { page, client } + + const quick = process.argv.includes("--quick") + const allCells = quick + ? [ + { + phase: "quick", + rows: Number(process.env.PERF_QUICK_ROWS || 1000), + dims: Number(process.env.PERF_QUICK_DIMS || 20), + count: Number(process.env.PERF_QUICK_COUNT || 10), + chartType: process.env.PERF_QUICK_TYPE || "line", + scenario: process.env.PERF_QUICK_SCENARIO || "idle", + }, + ] + : buildCells() + + const repeats = quick ? 1 : REPEATS + const results = [] + const skipped = [] + + for (const cell of allCells) { + const points = cell.rows * cell.dims * cell.count + if (points > MAX_POINTS) { + skipped.push({ ...cell, points, reason: `exceeds PERF_MAX_POINTS (${MAX_POINTS})` }) + console.log(`SKIP ${JSON.stringify(cell)} — ${points.toLocaleString()} points`) + continue + } + + for (let repeat = 0; repeat < repeats; repeat++) { + for (const chartLibrary of ["dygraph", "uplot"]) { + const config = { ...cell, chartLibrary } + const run = await runOnce(session, port, config, cell.scenario) + results.push({ ...config, repeat, ...run }) + + console.log( + `${cell.phase} ${cell.chartType}/${cell.scenario} r${cell.rows} d${cell.dims} c${cell.count} ` + + `[${chartLibrary}] #${repeat} ${ + run.ok + ? `renders=${run.renders} p50=${run.p50.toFixed(2)}ms p95=${run.p95.toFixed(2)}ms task/render=${ + run.taskPerRender == null ? "n/a" : run.taskPerRender.toFixed(2) + }ms` + : `FAILED ${run.error}` + }` + ) + + fs.writeFileSync(path.join(outDir, "raw.json"), JSON.stringify({ results, skipped }, null, 2)) + } + } + } + + await browser.close() + server.close() + + const key = row => `${row.phase}|${row.chartType}|${row.scenario}|${row.rows}|${row.dims}|${row.count}` + const groups = new Map() + + results.filter(row => row.ok).forEach(row => { + if (!groups.has(key(row))) groups.set(key(row), []) + groups.get(key(row)).push(row) + }) + + const summary = [] + + groups.forEach((rows, groupKey) => { + const [phase, chartType, scenario, rowCount, dims, count] = groupKey.split("|") + const byRepeat = new Map() + + rows.forEach(row => { + if (!byRepeat.has(row.repeat)) byRepeat.set(row.repeat, {}) + byRepeat.get(row.repeat)[row.chartLibrary] = row + }) + + const p50Ratios = [] + const taskRatios = [] + const totalTaskRatios = [] + const dygraphRenders = [] + const uplotRenders = [] + + byRepeat.forEach(pair => { + if (!pair.dygraph || !pair.uplot) return + if (pair.dygraph.p50 > 0) p50Ratios.push(pair.uplot.p50 / pair.dygraph.p50) + if (pair.dygraph.taskPerRender > 0) + taskRatios.push(pair.uplot.taskPerRender / pair.dygraph.taskPerRender) + // total main-thread cost over a fixed window: the number that decides the flip + if (pair.dygraph.taskMs > 0) totalTaskRatios.push(pair.uplot.taskMs / pair.dygraph.taskMs) + dygraphRenders.push(pair.dygraph.renders) + uplotRenders.push(pair.uplot.renders) + }) + + // too few renders to characterise a distribution; report the total-window cost only + const lowSample = + mean(dygraphRenders) < MIN_SAMPLES || mean(uplotRenders) < MIN_SAMPLES + + summary.push({ + phase, + chartType, + scenario, + lowSample, + rows: Number(rowCount), + dims: Number(dims), + count: Number(count), + pairs: p50Ratios.length, + dygraphRenders: mean(dygraphRenders), + uplotRenders: mean(uplotRenders), + p50RatioMean: mean(p50Ratios), + p50RatioSd: stddev(p50Ratios), + taskRatioMean: mean(taskRatios), + taskRatioSd: stddev(taskRatios), + totalTaskRatioMean: mean(totalTaskRatios), + totalTaskRatioSd: stddev(totalTaskRatios), + }) + }) + + summary.sort((a, b) => a.phase.localeCompare(b.phase) || a.rows - b.rows || a.dims - b.dims) + + const lines = [ + "Ratios are uPlot/dygraph — below 1.000 means uPlot is cheaper.", + "`total task` is whole-tab main-thread ms over the same wall-clock window (the flip decider).", + "Render counts are shown because the renderers do not always render the same number of times.", + "", + "| phase | type | scenario | rows | dims | charts | pairs | renders dyg→uplot | p50 ratio | task/render ratio | total task ratio |", + "|---|---|---|---|---|---|---|---|---|---|---|", + ...summary.map( + row => + `| ${row.phase} | ${row.chartType} | ${row.scenario} | ${row.rows} | ${row.dims} | ${row.count} | ${row.pairs} | ` + + `${row.dygraphRenders.toFixed(0)}→${row.uplotRenders.toFixed(0)} | ` + + `${row.lowSample ? "n/a (low sample)" : `${row.p50RatioMean.toFixed(3)} ± ${row.p50RatioSd.toFixed(3)}`} | ` + + `${row.lowSample ? "n/a (low sample)" : `${row.taskRatioMean.toFixed(3)} ± ${row.taskRatioSd.toFixed(3)}`} | ` + + `${row.totalTaskRatioMean.toFixed(3)} ± ${row.totalTaskRatioSd.toFixed(3)} |` + ), + "", + `Cells marked low sample had under ${MIN_SAMPLES} renders for a renderer, so per-render`, + "statistics are not meaningful there; only the window-based total task ratio is reported.", + ] + + if (skipped.length) { + lines.push("", "Skipped cells (too large):") + skipped.forEach(cell => + lines.push(`- ${cell.chartType}/${cell.scenario} rows=${cell.rows} dims=${cell.dims} charts=${cell.count} (${cell.points.toLocaleString()} points)`) + ) + } + + const failures = results.filter(row => !row.ok) + if (failures.length) { + lines.push("", `Failed runs: ${failures.length}`) + failures.slice(0, 10).forEach(row => + lines.push(`- ${row.chartType}/${row.scenario} r${row.rows} d${row.dims} c${row.count} [${row.chartLibrary}]: ${row.error}`) + ) + } + + const report = lines.join("\n") + fs.writeFileSync(path.join(outDir, "summary.md"), `${report}\n`) + fs.writeFileSync(path.join(outDir, "summary.json"), JSON.stringify(summary, null, 2)) + + console.log(`\n${report}\n`) + console.log(`raw: ${path.join(outDir, "raw.json")}`) +} + +main().catch(error => { + console.error(error) + process.exit(1) +}) diff --git a/scripts/profile-probe.mjs b/scripts/profile-probe.mjs new file mode 100644 index 000000000..e885f1c92 --- /dev/null +++ b/scripts/profile-probe.mjs @@ -0,0 +1,165 @@ +// Attributes main-thread time to functions for one perf-bench cell, per renderer. +// +// yarn build-storybook && node scripts/profile-probe.mjs +// PROFILE_ROWS=300 PROFILE_DIMS=20 PROFILE_COUNT=10 node scripts/profile-probe.mjs +import http from "node:http" +import fs from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" +import { chromium } from "playwright" + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const staticDir = path.resolve(__dirname, "../storybook-static") +const outDir = path.resolve(__dirname, "../.perf-results") + +const STORY_ID = "perf-benchmark--benchmark" +const ROWS = Number(process.env.PROFILE_ROWS || 300) +const DIMS = Number(process.env.PROFILE_DIMS || 20) +const COUNT = Number(process.env.PROFILE_COUNT || 10) +const CHART_TYPE = process.env.PROFILE_TYPE || "line" +const SCENARIO = process.env.PROFILE_SCENARIO || "idle" +const WARMUP_MS = Number(process.env.PROFILE_WARMUP_MS || 4000) +const MEASURE_MS = Number(process.env.PROFILE_MEASURE_MS || 10000) +const TOP = Number(process.env.PROFILE_TOP || 25) + +const mimeTypes = { + ".html": "text/html", + ".js": "text/javascript", + ".mjs": "text/javascript", + ".css": "text/css", + ".json": "application/json", + ".map": "application/json", + ".svg": "image/svg+xml", + ".png": "image/png", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", +} + +const serveStatic = () => + new Promise(resolve => { + const server = http.createServer((req, res) => { + const urlPath = decodeURIComponent(req.url.split("?")[0]) + const relative = urlPath === "/" ? "/index.html" : urlPath + const filePath = path.join(staticDir, path.normalize(relative)) + + if (!filePath.startsWith(staticDir) || !fs.existsSync(filePath)) { + res.writeHead(404) + res.end("not found") + return + } + + res.writeHead(200, { "content-type": mimeTypes[path.extname(filePath)] || "text/plain" }) + fs.createReadStream(filePath).pipe(res) + }) + + server.listen(0, "127.0.0.1", () => resolve({ server, port: server.address().port })) + }) + +const storyUrl = (port, chartLibrary) => { + const streaming = SCENARIO !== "hoverInteraction" + const args = [ + `chartLibrary:${chartLibrary}`, + `count:${COUNT}`, + `rows:${ROWS}`, + `dims:${DIMS}`, + `chartType:${CHART_TYPE}`, + "height:300px", + `streaming:!${streaming}`, + "autofetchOnHovering:!false", + ].join(";") + + return `http://127.0.0.1:${port}/iframe.html?id=${STORY_ID}&viewMode=story&args=${encodeURIComponent(args)}` +} + +const selfTimes = profile => { + const byNode = new Map(profile.nodes.map(node => [node.id, node])) + const totals = new Map() + let measured = 0 + + profile.samples.forEach((nodeId, index) => { + const delta = profile.timeDeltas[index] || 0 + if (delta <= 0) return + + const node = byNode.get(nodeId) + if (!node) return + + const { functionName, url, lineNumber } = node.callFrame + const file = url ? url.split("/").pop() : "(native)" + const key = `${functionName || "(anonymous)"} @ ${file}:${lineNumber + 1}` + + totals.set(key, (totals.get(key) || 0) + delta) + measured += delta + }) + + return { totals, measured } +} + +const profileLibrary = async (context, port, chartLibrary) => { + const page = await context.newPage() + const client = await context.newCDPSession(page) + + await page.goto(storyUrl(port, chartLibrary), { waitUntil: "load" }) + await page.waitForSelector("[data-testid='perfBenchmark'] canvas", { + state: "attached", + timeout: 30000, + }) + await page.waitForTimeout(WARMUP_MS) + + await client.send("Profiler.enable") + await client.send("Profiler.setSamplingInterval", { interval: 100 }) + await client.send("Profiler.start") + await page.waitForTimeout(MEASURE_MS) + const { profile } = await client.send("Profiler.stop") + + const { totals, measured } = selfTimes(profile) + const rows = [...totals.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, TOP) + .map(([key, us]) => ({ + fn: key, + ms: Math.round(us / 1000), + pct: `${((us / measured) * 100).toFixed(1)}%`, + })) + + await page.close() + + return { chartLibrary, totalMeasuredMs: Math.round(measured / 1000), rows } +} + +const run = async () => { + if (!fs.existsSync(staticDir)) { + console.error(`missing ${staticDir} - run "yarn build-storybook" first`) + process.exit(1) + } + + fs.mkdirSync(outDir, { recursive: true }) + + const { server, port } = await serveStatic() + const browser = await chromium.launch({ args: ["--no-sandbox"] }) + const context = await browser.newContext({ viewport: { width: 1600, height: 1200 } }) + const results = [] + + for (const chartLibrary of ["dygraph", "uplot"]) { + const result = await profileLibrary(context, port, chartLibrary) + results.push(result) + + console.log( + `\n=== ${chartLibrary} — ${result.totalMeasuredMs}ms of samples ` + + `(${CHART_TYPE}/${SCENARIO} r${ROWS} d${DIMS} c${COUNT}) ===` + ) + console.table(result.rows) + } + + await browser.close() + server.close() + + const file = path.join(outDir, `profile-${CHART_TYPE}-${SCENARIO}-r${ROWS}-d${DIMS}-c${COUNT}.json`) + fs.writeFileSync(file, JSON.stringify({ rows: ROWS, dims: DIMS, count: COUNT, results }, null, 2)) + console.log(`\nprofile json: ${file}`) +} + +run().catch(error => { + console.error(error) + process.exit(1) +}) diff --git a/src/chartLibraries/dygraph/coords.test.js b/src/chartLibraries/dygraph/coords.test.js new file mode 100644 index 000000000..47c62223c --- /dev/null +++ b/src/chartLibraries/dygraph/coords.test.js @@ -0,0 +1,15 @@ +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) + }) +}) diff --git a/src/chartLibraries/dygraph/index.js b/src/chartLibraries/dygraph/index.js index ddde9223c..6f091660a 100644 --- a/src/chartLibraries/dygraph/index.js +++ b/src/chartLibraries/dygraph/index.js @@ -519,32 +519,30 @@ export default (sdk, chart) => { return true } - const getPreceded = () => { - if (!dygraph) return -1 - - const firstEntryMs = chart.getFirstEntry() * 1000 - const [after] = dygraph.xAxisRange() - - if (firstEntryMs < after) return -1 - - const [afterExtreme] = dygraph.xAxisExtremes() - return dygraph.toDomXCoord(afterExtreme) - } - const getChartWidth = () => (dygraph ? dygraph.getArea().w : chartUI.getChartWidth()) const getChartHeight = () => (dygraph ? dygraph.getArea().h : 100) const getXAxisRange = () => dygraph?.xAxisRange() + 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) + const instance = { ...chartUI, getChartWidth, getChartHeight, - getPreceded, mount, unmount, getDygraph, getXAxisRange, + getPlotArea, + getXCoord, render, } diff --git a/src/chartLibraries/dygraph/overlays/helpers.js b/src/chartLibraries/dygraph/overlays/helpers.js index a1d49eb40..c56bc62b3 100644 --- a/src/chartLibraries/dygraph/overlays/helpers.js +++ b/src/chartLibraries/dygraph/overlays/helpers.js @@ -1,23 +1,10 @@ -export const getArea = (dygraph, range) => { - const [after, before] = dygraph.xAxisRange() - const afterTimestamp = after - const beforeTimestamp = before +import { getArea as getNeutralArea } from "@/chartLibraries/helpers/overlayArea" - const [hAfter, hBefore] = range - const hAfterTimestamp = hAfter * 1000 - const hBeforeTimestamp = hBefore * 1000 - - if (hBeforeTimestamp < afterTimestamp || hAfterTimestamp > beforeTimestamp) return null - - const fromX = Math.max(afterTimestamp, hAfterTimestamp) - const toX = Math.min(beforeTimestamp, hBeforeTimestamp) - - const from = dygraph.toDomXCoord(fromX) - const to = dygraph.toDomXCoord(toX) - const width = to - from - - return { from, to, width } -} +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)) diff --git a/src/chartLibraries/dygraph/plotters/multiColumnBar.js b/src/chartLibraries/dygraph/plotters/multiColumnBar.js index 34ae4af1a..8c4dd0ddf 100644 --- a/src/chartLibraries/dygraph/plotters/multiColumnBar.js +++ b/src/chartLibraries/dygraph/plotters/multiColumnBar.js @@ -1,4 +1,4 @@ -import { darkenColor } from "./helpers" +import { darkenColor } from "@/chartLibraries/helpers/color" export default () => plotter => { if (plotter.seriesIndex !== 0) return diff --git a/src/chartLibraries/dygraph/plotters/stackedBar.js b/src/chartLibraries/dygraph/plotters/stackedBar.js index 1a951f441..e914990f5 100644 --- a/src/chartLibraries/dygraph/plotters/stackedBar.js +++ b/src/chartLibraries/dygraph/plotters/stackedBar.js @@ -1,4 +1,4 @@ -import { darkenColor } from "./helpers" +import { darkenColor } from "@/chartLibraries/helpers/color" import { getDivergingStackBounds } from "../divergingStack" const getBarWidth = ({ points, plotArea }) => { @@ -39,9 +39,7 @@ export default () => plotter => { ctx.strokeStyle = darkenColor(plotter.color) points.forEach(p => { - const rect = getDivergingBarRect(p, barWidth, value => - plotter.dygraph.toDomYCoord(value) - ) + const rect = getDivergingBarRect(p, barWidth, value => plotter.dygraph.toDomYCoord(value)) if (!rect) return p.canvasy = plotter.dygraph.toDomYCoord(getDivergingStackBounds(p).end) diff --git a/src/chartLibraries/dygraph/plotters/helpers.js b/src/chartLibraries/helpers/color.js similarity index 100% rename from src/chartLibraries/dygraph/plotters/helpers.js rename to src/chartLibraries/helpers/color.js diff --git a/src/chartLibraries/dygraph/plotters/helpers.test.js b/src/chartLibraries/helpers/color.test.js similarity index 95% rename from src/chartLibraries/dygraph/plotters/helpers.test.js rename to src/chartLibraries/helpers/color.test.js index e06ae1930..9e2e397a7 100644 --- a/src/chartLibraries/dygraph/plotters/helpers.test.js +++ b/src/chartLibraries/helpers/color.test.js @@ -1,4 +1,4 @@ -import { darkenColor } from "./helpers" +import { darkenColor } from "./color" describe("darkenColor", () => { it("darkens RGB color strings", () => { diff --git a/src/chartLibraries/helpers/dimensionVisibility.js b/src/chartLibraries/helpers/dimensionVisibility.js new file mode 100644 index 000000000..017a1cae6 --- /dev/null +++ b/src/chartLibraries/helpers/dimensionVisibility.js @@ -0,0 +1,2 @@ +export const isVisibleDimension = (chart, id) => + chart.getAttribute("selectedLegendDimensions")?.length ? chart.isDimensionVisible(id) : true diff --git a/src/chartLibraries/helpers/dimensionVisibility.test.js b/src/chartLibraries/helpers/dimensionVisibility.test.js new file mode 100644 index 000000000..cd9d8260a --- /dev/null +++ b/src/chartLibraries/helpers/dimensionVisibility.test.js @@ -0,0 +1,23 @@ +import { isVisibleDimension } from "./dimensionVisibility" + +const makeChart = (selection, visible) => ({ + getAttribute: () => selection, + isDimensionVisible: id => visible.includes(id), +}) + +describe("isVisibleDimension", () => { + it("treats every dimension as visible while no legend selection exists", () => { + expect(isVisibleDimension(makeChart([], []), "a")).toBe(true) + }) + + it("defers to the chart once a legend selection exists", () => { + const chart = makeChart(["b"], ["b"]) + + expect(isVisibleDimension(chart, "a")).toBe(false) + expect(isVisibleDimension(chart, "b")).toBe(true) + }) + + it("treats a missing selection attribute as no selection", () => { + expect(isVisibleDimension(makeChart(undefined, []), "a")).toBe(true) + }) +}) diff --git a/src/chartLibraries/helpers/overlayArea.js b/src/chartLibraries/helpers/overlayArea.js new file mode 100644 index 000000000..189ef3325 --- /dev/null +++ b/src/chartLibraries/helpers/overlayArea.js @@ -0,0 +1,15 @@ +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 } +} diff --git a/src/chartLibraries/helpers/overlayArea.test.js b/src/chartLibraries/helpers/overlayArea.test.js new file mode 100644 index 000000000..9606fae28 --- /dev/null +++ b/src/chartLibraries/helpers/overlayArea.test.js @@ -0,0 +1,39 @@ +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() + }) +}) diff --git a/src/chartLibraries/uplot/coords.test.js b/src/chartLibraries/uplot/coords.test.js new file mode 100644 index 000000000..c4b47db46 --- /dev/null +++ b/src/chartLibraries/uplot/coords.test.js @@ -0,0 +1,16 @@ +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() + }) +}) diff --git a/src/chartLibraries/uplot/hover.js b/src/chartLibraries/uplot/hover.js new file mode 100644 index 000000000..f90234746 --- /dev/null +++ b/src/chartLibraries/uplot/hover.js @@ -0,0 +1,109 @@ +import { isVisibleDimension } from "@/chartLibraries/helpers/dimensionVisibility" +import { getSeriesStackBounds, getStackBounds } from "./stacking" + +const anomalyBand = 15 +const annotationBand = 10 + +const bandDistance = (top, aY, bY) => { + const hi = Math.min(aY, bY) + const lo = Math.max(aY, bY) + return top < hi ? hi - top : top > lo ? top - lo : 0 +} + +const getNearestSeries = (chart, self, top, idx) => { + const dimensionIds = chart.getPayloadDimensionIds() + + let closestId + let closestDistance = Infinity + + dimensionIds.forEach((id, index) => { + if (!isVisibleDimension(chart, id)) return + + const value = self.data[index + 1]?.[idx] + if (value == null) return + + const y = self.valToPos(value, "y") + if (!Number.isFinite(y)) return + + const distance = Math.abs(y - top) + if (distance < closestDistance) { + closestDistance = distance + closestId = id + } + }) + + return closestId ?? chart.getVisibleDimensionIds()?.[0] +} + +const getStackedBounds = chart => + getStackBounds(chart.getPayload().data, chart.getPayloadDimensionIds(), id => + isVisibleDimension(chart, id) + ) + +const getStackedBarBounds = (chart, self) => { + const dimensionIds = chart.getPayloadDimensionIds() + return getSeriesStackBounds(self.data, index => isVisibleDimension(chart, dimensionIds[index])) +} + +const getNearestBandSeries = (chart, self, top, idx, bounds) => { + let closestId + let closestDistance = Infinity + + chart.getPayloadDimensionIds().forEach((id, index) => { + const bound = bounds[index]?.[idx] + if (!bound) return + + const distance = bandDistance(top, self.valToPos(bound[0], "y"), self.valToPos(bound[1], "y")) + if (distance < closestDistance) { + closestDistance = distance + closestId = id + } + }) + + return closestId ?? chart.getVisibleDimensionIds()?.[0] +} + +const getNearestHeatmapBucket = (chart, self, top) => { + const ids = chart.getVisibleHeatmapIds?.() + if (!ids?.length) return undefined + + let closestId + let closestDistance = Infinity + + ids.forEach(id => { + const yIndex = chart.getHeatmapYIndex(id) + if (yIndex === -1) return + + const y = self.valToPos(yIndex, "y") + if (!Number.isFinite(y)) return + + const distance = Math.abs(y - top) + if (distance < closestDistance) { + closestDistance = distance + closestId = id + } + }) + + return closestId +} + +export default chart => self => { + const { top, idx } = self.cursor + if (idx == null) return chart.getVisibleDimensionIds()?.[0] + + if (top != null) { + if (chart.getAttribute("showAnnotations") && top > self.over.clientHeight - annotationBand) + return "ANNOTATIONS" + if (chart.getAttribute("showAnomalies") && top < anomalyBand) return "ANOMALY_RATE" + } + + const chartType = chart.getAttribute("chartType") + + if (chartType === "heatmap") return getNearestHeatmapBucket(chart, self, top) + if (chartType === "stacked") + return getNearestBandSeries(chart, self, top, idx, getStackedBounds(chart)) + if (chartType === "stackedBar") + return getNearestBandSeries(chart, self, top, idx, getStackedBarBounds(chart, self)) + + return getNearestSeries(chart, self, top, idx) +} diff --git a/src/chartLibraries/uplot/hover.test.js b/src/chartLibraries/uplot/hover.test.js new file mode 100644 index 000000000..a8eaca0fb --- /dev/null +++ b/src/chartLibraries/uplot/hover.test.js @@ -0,0 +1,205 @@ +import { makeTestChart, loadHeatmapPayload } from "@jest/testUtilities" +import uplotChart from "./index" +import makeGetHoverDimension from "./hover" + +const after = 1617946860 +const before = 1617947760 + +const withPayload = (chart, data, dims) => { + chart.getPayload = () => ({ data, labels: ["time", ...dims] }) + chart.getPayloadDimensionIds = () => dims + chart.getVisibleDimensionIds = () => dims + chart.isDimensionVisible = () => true + chart.selectDimensionColor = () => "#3366CC" + chart.getThemeAttribute = () => "#E4E8E8" + chart.getConvertedValueWithUnit = value => `${value}` +} + +const mount = async (chartType, data, dims, attributes = {}) => { + const { sdk, chart } = makeTestChart({ + attributes: { loaded: true, chartType, chartLibrary: "uplot", after, before, ...attributes }, + }) + withPayload(chart, data, dims) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + await Promise.resolve() + await Promise.resolve() + + return { + chart, + instance, + u: instance.getUPlot(), + teardown: () => (instance.unmount(), document.body.removeChild(element)), + } +} + +const lineData = [ + [after * 1000, 10, 90], + [(after + 5) * 1000, 12, 88], + [(after + 10) * 1000, 11, 92], +] + +describe("uplot hover dimension resolution", () => { + it("resolves the nearest visible series by cursor Y, not always the first", async () => { + const { chart, u, teardown } = await mount("line", lineData, ["low", "high"], { + staticValueRange: [0, 100], + }) + const getDim = makeGetHoverDimension(chart) + + u.cursor = { left: 100, top: u.valToPos(90, "y"), idx: 0 } + expect(getDim(u)).toBe("high") + + u.cursor = { left: 100, top: u.valToPos(10, "y"), idx: 0 } + expect(getDim(u)).toBe("low") + + teardown() + }) + + it("resolves ANOMALY_RATE at the top band and ANNOTATIONS at the bottom band", async () => { + const { chart, u, teardown } = await mount("line", lineData, ["low", "high"], { + staticValueRange: [0, 100], + }) + const getDim = makeGetHoverDimension(chart) + + u.cursor = { left: 100, top: 5, idx: 0 } + expect(getDim(u)).toBe("ANOMALY_RATE") + + u.cursor = { left: 100, top: u.over.clientHeight - 2, idx: 0 } + expect(getDim(u)).toBe("ANNOTATIONS") + + teardown() + }) + + it("ignores the ribbon bands when showAnomalies/showAnnotations are off", async () => { + const { chart, u, teardown } = await mount("line", lineData, ["low", "high"], { + staticValueRange: [0, 100], + showAnomalies: false, + showAnnotations: false, + }) + const getDim = makeGetHoverDimension(chart) + + u.cursor = { left: 100, top: 5, idx: 0 } + expect(getDim(u)).toBe("high") + + u.cursor = { left: 100, top: u.over.clientHeight - 2, idx: 0 } + expect(getDim(u)).toBe("low") + + teardown() + }) + + it("resolves the stacked series whose band contains the cursor Y", async () => { + const stackedData = [ + [after * 1000, 10, 20], + [(after + 5) * 1000, 10, 20], + [(after + 10) * 1000, 10, 20], + ] + const { chart, u, teardown } = await mount("stacked", stackedData, ["a", "b"]) + const getDim = makeGetHoverDimension(chart) + + u.cursor = { left: 100, top: u.valToPos(25, "y"), idx: 0 } + expect(getDim(u)).toBe("a") + + u.cursor = { left: 100, top: u.valToPos(5, "y"), idx: 0 } + expect(getDim(u)).toBe("b") + + teardown() + }) + + it("resolves the stacked-bar segment under the cursor Y", async () => { + const barData = [ + [after * 1000, 10, 20], + [(after + 5) * 1000, 10, 20], + [(after + 10) * 1000, 10, 20], + ] + const { chart, u, teardown } = await mount("stackedBar", barData, ["a", "b"], { + staticValueRange: [0, 30], + }) + const getDim = makeGetHoverDimension(chart) + + u.cursor = { left: 100, top: u.valToPos(25, "y"), idx: 0 } + expect(getDim(u)).toBe("a") + + u.cursor = { left: 100, top: u.valToPos(5, "y"), idx: 0 } + expect(getDim(u)).toBe("b") + + teardown() + }) + + it("falls back to the first visible dimension when the cursor has no index", async () => { + const { chart, u, teardown } = await mount("line", lineData, ["low", "high"], { + staticValueRange: [0, 100], + }) + const getDim = makeGetHoverDimension(chart) + + u.cursor = { left: null, top: null, idx: null } + expect(getDim(u)).toBe("low") + + teardown() + }) +}) + +describe("uplot hover dimension resolution — heatmap", () => { + const heatmapIds = ["0", "1", "2", "3", "4", "5", "6"] + const heatmapRows = [ + [0, 0, 1, 0, 2, 0, 0], + [0, 0, 0, 3, 1, 0, 0], + [0, 0, 2, 0, 0, 0, 0], + ] + + it("resolves the heatmap bucket nearest the cursor Y", async () => { + const { sdk, chart } = makeTestChart({ + attributes: { + loaded: true, + chartType: "heatmap", + chartLibrary: "uplot", + context: "prometheus.test.histogram", + groupBy: ["dimension"], + selectedLegendDimensions: [], + showAnomalies: false, + showAnnotations: false, + viewDimensions: { + ids: heatmapIds, + names: heatmapIds, + priorities: heatmapIds.map((_, index) => index), + units: heatmapIds.map(() => ""), + contexts: heatmapIds.map(() => ""), + grouped: ["dimension"], + }, + }, + }) + + await loadHeatmapPayload(chart, heatmapIds, heatmapRows, { timestamp: 1617946860000 }) + chart.getDateWindow = () => [1617946860000, 1617947750000] + chart.formatXAxis = x => x.toString() + chart.getThemeAttribute = () => "#333" + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + Object.defineProperty(element, "offsetWidth", { configurable: true, value: 800 }) + Object.defineProperty(element, "offsetHeight", { configurable: true, value: 300 }) + document.body.appendChild(element) + instance.mount(element) + await Promise.resolve() + await Promise.resolve() + + const u = instance.getUPlot() + const getDim = makeGetHoverDimension(chart) + const visibleIds = chart.getVisibleHeatmapIds() + + visibleIds.forEach(id => { + const yIndex = chart.getHeatmapYIndex(id) + u.cursor = { left: 100, top: u.valToPos(yIndex, "y"), idx: 0 } + expect(getDim(u)).toBe(id) + }) + + instance.unmount() + document.body.removeChild(element) + }) +}) diff --git a/src/chartLibraries/uplot/index.js b/src/chartLibraries/uplot/index.js new file mode 100644 index 000000000..b6163a9ee --- /dev/null +++ b/src/chartLibraries/uplot/index.js @@ -0,0 +1,1609 @@ +import uPlot from "uplot" +import { debounce } from "throttle-debounce" +import makeChartUI from "@/sdk/makeChartUI" +import { makeAxisTicks } from "@/helpers/ticks" +import { unregister } from "@/helpers/makeListeners" +import makeResizeObserver from "@/helpers/makeResizeObserver" +import limitRange from "@/helpers/limitRange" +import { makeGetColor, withoutPrefix } from "@/helpers/heatmap" +import { darkenColor } from "@/chartLibraries/helpers/color" +import { isVisibleDimension } from "@/chartLibraries/helpers/dimensionVisibility" +import { formatHeatmapLabel } from "@/helpers/heatmapScale" +import { + getSeriesStackBounds, + getStackBounds, + getStackSegments, + getStackValueRange, + selectStackRows, +} from "./stacking" +import makeOverlays from "./overlays" +import makeAnomaly from "./plotters/anomaly" +import makeAnomalyBadge from "./plotters/anomalyBadge" +import makeAnnotations from "./plotters/annotations" +import makeGetHoverDimension from "./hover" + +const barGroupWidth = 0.6 + +const doubleTapDelay = 300 +const minDragPx = 5 + +const axisFontFamily = "'IBM Plex Sans', sans-serif" +const defaultAxisFontSize = 11 +const defaultYAxisSize = 60 +const minPlotHeight = 20 +const yPixelsPerLabel = 15 +const tickSize = 4 +const axisGap = 6 +const xTickSize = 3 +const xAxisGap = 3 +const rightPad = 0 +const xTickSpace = 80 +const heatmapPixelsPerLabel = 15 +const heatmapRowPad = 0.5 + +const lineWidth = 1.5 +const areaLineWidth = 0.7 +const hoverDotRadius = 4 +const sparklineHoverDotRadius = 3 +const areaGradientTopAlpha = "59" +const areaGradientBottomAlpha = "00" +const stackedFillAlpha = "CC" +const stackedEdgeAlpha = "E6" + +const yRangePadPx = 15 +const yRangePadFallbackRatio = 0.05 + +const hiddenAxisSize = 0 + +const steppedPathBuilder = uPlot.paths.stepped && uPlot.paths.stepped({ align: 1 }) +const splinePathBuilder = uPlot.paths.spline && uPlot.paths.spline() +const nullPathBuilder = () => null + +const makeAxisFont = fontSize => `${fontSize}px ${axisFontFamily}` + +const getSplitGranularity = (splits, index) => { + const value = splits[index] + const previous = splits[index - 1] + const next = splits[index + 1] + const previousStep = typeof previous === "number" ? Math.abs(value - previous) : Infinity + const nextStep = typeof next === "number" ? Math.abs(next - value) : Infinity + const step = Math.min(previousStep, nextStep) + + return Number.isFinite(step) ? step : 0 +} + +const makeAreaFill = color => self => { + const { ctx, bbox } = self + const gradient = ctx.createLinearGradient(0, bbox.top, 0, bbox.top + bbox.height) + gradient.addColorStop(0, `${color}${areaGradientTopAlpha}`) + gradient.addColorStop(1, `${color}${areaGradientBottomAlpha}`) + return gradient +} + +const makeSolidFill = color => () => color + +const defaultRows = (start, end) => { + const rows = new Array(end - start + 1) + for (let i = 0; i < rows.length; i++) rows[i] = start + i + return rows +} + +const gapEdgeIndexes = (self, seriesIdx) => { + const values = self.data[seriesIdx] + if (!values) return null + + const last = values.length - 1 + const indexes = [] + + for (let i = 0; i <= last; i++) { + if (values[i] == null) continue + if ((i > 0 && values[i - 1] == null) || (i < last && values[i + 1] == null)) indexes.push(i) + } + + return indexes.length ? indexes : null +} + +const traceStackTop = (self, ctx, xs, series, rows, stepped) => { + for (let i = 0; i < rows.length; i++) { + const row = rows[i] + const x = self.valToPos(xs[row], "x", true) + const y = self.valToPos(series[row][1], "y", true) + + if (i === 0) ctx.moveTo(x, y) + else if (stepped) { + ctx.lineTo(x, self.valToPos(series[rows[i - 1]][1], "y", true)) + ctx.lineTo(x, y) + } else ctx.lineTo(x, y) + } +} + +// dygraph.js:2612 — a collapsed range has no sense of scale, so centre on the sole value. +// Left collapsed, uPlot's tick search never converges (seconds of spin) and valToPos is infinite. +const expandDegenerate = (min, max) => { + if (min !== max) return [min, max] + if (min === 0) return [0, 1] + + const delta = Math.abs(min / 10) + return [min - delta, max + delta] +} + +const padYRange = (self, rawMin, rawMax) => { + if (!Number.isFinite(rawMin) || !Number.isFinite(rawMax)) return [rawMin, rawMax] + + const [min, max] = expandDegenerate(rawMin, rawMax) + + const span = max - min + if (span <= 0) return [min, max] + + const height = self && self.bbox ? self.bbox.height / (self.pxRatio || 1) : 0 + const ratio = height > 0 ? yRangePadPx / height : yRangePadFallbackRatio + const pad = span * ratio + + return [min - pad, max + pad] +} + +export default (sdk, chart) => { + const chartUI = makeChartUI(sdk, chart) + let u = null + let overlayCanvas = null + let overlayCtx = null + let element = null + let listeners + let resizeObserver + let hovering = false + let lastHoverTimestamp = null + let lastHoverDimension = null + let detachNavigation = null + let overlays = null + let xRangeOverride = null + let selectEnded = false + let prevYMin + let prevYMax + + const getData = () => { + const { data } = chart.getPayload() + const dimensionIds = chart.getPayloadDimensionIds() + + if (chart.getAttribute("outOfLimits") || !data?.length || !dimensionIds.length) return null + + const rows = data.length + const x = new Array(rows) + const series = dimensionIds.map(() => new Array(rows)) + + for (let r = 0; r < rows; r++) { + const row = data[r] + x[r] = row[0] / 1000 + + for (let d = 0; d < dimensionIds.length; d++) { + const value = row[d + 1] + series[d][r] = value == null ? null : value + } + } + + return [x, ...series] + } + + const isVisible = id => isVisibleDimension(chart, id) + + const stackBounds = () => + getStackBounds(chart.getPayload().data, chart.getPayloadDimensionIds(), isVisible) + + const seriesStackBounds = self => { + const dimensionIds = chart.getPayloadDimensionIds() + return getSeriesStackBounds(self.data, index => isVisible(dimensionIds[index])) + } + + const isBarType = chartType => chartType === "multiBar" || chartType === "stackedBar" + + const getPaths = () => { + const chartType = chart.getAttribute("chartType") + if (chartType === "stacked" || chartType === "heatmap" || isBarType(chartType)) + return nullPathBuilder + if (chart.getAttribute("stepPlot")) return steppedPathBuilder + if (chartType === "line") return splinePathBuilder + + return undefined + } + + const getSeries = () => { + const chartType = chart.getAttribute("chartType") + const sparkline = chart.isSparkline() + const filled = chartType === "area" + const heatmap = chartType === "heatmap" + const bar = isBarType(chartType) + const stacked = chartType === "stacked" + const paths = getPaths() + + return [ + {}, + ...chart.getPayloadDimensionIds().map(id => { + // a sparkline's synthetic dimension has no palette entry, and uPlot paints nothing without + // a colour, where dygraph falls back to its own palette + const color = chart.selectDimensionColor(id) || chart.getThemeAttribute("themeNetdata") + + if (sparkline) + return { + label: id, + show: isVisible(id), + stroke: color, + width: 0, + fill: makeSolidFill(color), + points: { show: false }, + ...(paths && { paths }), + } + + return { + label: id, + show: isVisible(id), + stroke: color, + width: filled ? areaLineWidth : lineWidth, + ...(paths && { paths }), + ...(filled && { fill: makeAreaFill(color) }), + points: + heatmap || bar || stacked ? { show: false } : { show: false, filter: gapEdgeIndexes }, + } + }), + ] + } + + const getHeatmapValueRange = () => { + const staticValueRange = chart.getAttribute("staticValueRange") + if (staticValueRange) return [Math.ceil(staticValueRange[0]), Math.ceil(staticValueRange[1])] + + const count = chart.getVisibleHeatmapIds().length || 1 + return [-heatmapRowPad, count - heatmapRowPad] + } + + const getEmptyValueRange = () => { + const staticValueRange = chart.getAttribute("staticValueRange") + if (staticValueRange) return staticValueRange + + if (chart.getAttribute("chartType") === "heatmap") return getHeatmapValueRange() + + const [min, max] = chart.getAttribute("getValueRange")(chart) + if (Number.isFinite(min) && Number.isFinite(max) && min !== max) return [min, max] + + return [0, 1] + } + + const padAwayFromZero = value => (value === 0 ? 0 : value * 1.05) + + const getBarValueRange = (self, chartType, dataMin, dataMax) => { + if (chartType === "stackedBar") { + const [stackMin, stackMax] = getStackValueRange(seriesStackBounds(self)) + + return [ + padAwayFromZero(Math.min(0, stackMin == null ? dataMin : stackMin)), + padAwayFromZero(Math.max(0, stackMax == null ? dataMax : stackMax)), + ] + } + + return [padAwayFromZero(Math.min(0, dataMin)), padAwayFromZero(Math.max(0, dataMax))] + } + + const forceIncludesZero = () => { + if (chart.getAttribute("includeZero")) return true + + const dimensionIds = chart.getPayloadDimensionIds() + const selectedLegendDimensions = chart.getAttribute("selectedLegendDimensions") + return dimensionIds.length > 1 && selectedLegendDimensions.length > 1 + } + + const getScales = () => ({ + x: { + time: true, + range: () => { + if (xRangeOverride) return xRangeOverride + const [after, before] = chart.getDateWindow() + return [after / 1000, before / 1000] + }, + }, + y: { + range: (self, dataMin, dataMax) => { + const chartType = chart.getAttribute("chartType") + + if (chartType === "heatmap") return getHeatmapValueRange() + + const staticValueRange = chart.getAttribute("staticValueRange") + if (staticValueRange) return staticValueRange + + if (isBarType(chartType)) return getBarValueRange(self, chartType, dataMin, dataMax) + + let min + let max + + if (chartType === "stacked") { + ;[min, max] = getStackValueRange(stackBounds()) + } else { + // the dygraph flag yields [null, null] when the range should not pin the axis, which + // is what lets dataMin/dataMax (uPlot's in-window extremes) take over, as dygraph does + const [rangeMin, rangeMax] = chart.getAttribute("getValueRange")(chart, { dygraph: true }) + min = rangeMin == null ? dataMin : rangeMin + max = rangeMax == null ? dataMax : rangeMax + } + + if (chartType === "area" ? forceIncludesZero() : chart.getAttribute("includeZero")) { + min = Math.min(0, min) + max = Math.max(0, max) + } + + const padded = padYRange(self, min, max) + + // an all-positive stack cannot reach below its baseline, so padding there is dead space + if (min === 0 && padded[0] < 0) padded[0] = 0 + + return padded + }, + }, + }) + + const getHeatmapYAxis = (gridColor, labelColor, font, size) => ({ + font, + stroke: labelColor, + grid: { stroke: gridColor, width: 1 }, + ticks: { stroke: gridColor, width: 1, size: tickSize }, + size, + gap: axisGap, + splits: self => { + const count = chart.getVisibleHeatmapIds().length + if (!count) return [] + + const heightPx = self.bbox.height / (self.pxRatio || 1) + const maxTicks = Math.max(1, Math.floor(heightPx / heatmapPixelsPerLabel)) + const step = Math.max(1, Math.ceil(count / Math.max(1, maxTicks - 1))) + + const splits = [] + for (let i = 0; i < count; i++) if (i % step === 0) splits.push(i) + return splits + }, + values: (self, splits) => { + const ids = chart.getVisibleHeatmapIds() + const scale = chart.getHeatmapScale() + return splits.map(index => formatHeatmapLabel(withoutPrefix(ids[index]), scale)) + }, + }) + + // left to uPlot's defaults the chrome is a fixed 67px, so a short chart gets a negative + // plot height and a zero-height overlay that never receives pointer events + const getVerticalBudget = () => { + if (chart.isSparkline()) return { topPad: 0, xAxisSize: 0 } + + const height = chartUI.getChartHeight() + const fontSize = chart.getAttribute("axisLabelFontSize") || defaultAxisFontSize + const topPad = Math.min(Math.ceil(fontSize / 2), Math.max(0, height - minPlotHeight)) + const xAxisSize = Math.min( + fontSize + xTickSize + xAxisGap, + Math.max(0, height - topPad - minPlotHeight) + ) + + return { topPad, xAxisSize } + } + + const getAxes = () => { + if (chart.isSparkline()) return [{ show: false }, { show: false }] + + const enabledXAxis = chart.getAttribute("enabledXAxis") !== false + const enabledYAxis = chart.getAttribute("enabledYAxis") !== false + const gridColor = chart.getThemeAttribute("themeGridColor") + const labelColor = chart.getThemeAttribute("themeLabelColor") + const visibleDimensionIds = chart.getVisibleDimensionIds() || [] + const dimensionId = visibleDimensionIds[0] + + const axisFont = makeAxisFont(chart.getAttribute("axisLabelFontSize") || defaultAxisFontSize) + const yAxisSize = chart.getAttribute("yAxisLabelWidth") || defaultYAxisSize + const secondsAsTime = chart.getAttribute("secondsAsTime") + const units = visibleDimensionIds.map(id => chart.getDimensionUnit(id)) + + const border = { show: true, stroke: gridColor, width: 1 } + + const xAxis = { + show: true, + font: axisFont, + stroke: labelColor, + grid: { stroke: gridColor, width: 1 }, + border, + space: xTickSpace, + gap: xAxisGap, + ...(enabledXAxis + ? { + ticks: { stroke: gridColor, width: 1, size: xTickSize }, + size: () => getVerticalBudget().xAxisSize, + values: (self, splits) => + getVerticalBudget().xAxisSize > 0 + ? splits.map(value => chart.formatXAxis(new Date(value * 1000))) + : [], + } + : { ticks: { show: false }, values: () => [], size: hiddenAxisSize }), + } + + if (chart.getAttribute("chartType") === "heatmap") { + const heatmapYAxis = getHeatmapYAxis(gridColor, labelColor, axisFont, yAxisSize) + return [ + xAxis, + enabledYAxis + ? { show: true, ...heatmapYAxis } + : { + show: true, + ...heatmapYAxis, + ticks: { show: false }, + values: () => [], + size: hiddenAxisSize, + }, + ] + } + + const yAxis = { + show: true, + font: axisFont, + stroke: labelColor, + grid: { stroke: gridColor, width: 1 }, + border, + gap: axisGap, + ...(enabledYAxis + ? { + ticks: { stroke: gridColor, width: 1, size: tickSize }, + size: yAxisSize, + splits: (self, axisIdx, scaleMin, scaleMax) => + makeAxisTicks({ + min: scaleMin, + max: scaleMax, + pixels: self.bbox.height / (self.pxRatio || 1), + pixelsPerTick: yPixelsPerLabel, + units, + secondsAsTime, + }).map(tick => tick.v), + values: (self, splits) => + splits.map((value, index) => { + const tickStep = getSplitGranularity(splits, index) + const range = tickStep ? { min: value, max: value + tickStep } : {} + const unitAttributes = chart.getUnitAttributesForValue(value, { + dimensionId, + ...range, + }) + return chart.getConvertedValueWithUnit(value, { dimensionId, unitAttributes }) + }), + } + : { ticks: { show: false }, values: () => [], size: hiddenAxisSize }), + } + + return [xAxis, yAxis] + } + + const drawVerticalLine = (self, ctx, dimensions, color, dash) => { + if (!Array.isArray(dimensions)) return + + const timestamp = dimensions[0] + if (timestamp == null) return + + const left = self.valToPos(timestamp / 1000, "x", true) + const { top, height } = self.bbox + + ctx.save() + ctx.beginPath() + if (dash) ctx.setLineDash(dash) + ctx.strokeStyle = color + ctx.lineWidth = 1 + ctx.moveTo(left, top) + ctx.lineTo(left, top + height) + ctx.stroke() + ctx.restore() + } + + const drawStackSegment = (self, ctx, xs, series, rows, color, edgeWidth, stepped) => { + ctx.beginPath() + + traceStackTop(self, ctx, xs, series, rows, stepped) + + for (let i = rows.length - 1; i >= 0; i--) { + const row = rows[i] + ctx.lineTo(self.valToPos(xs[row], "x", true), self.valToPos(series[row][0], "y", true)) + } + + ctx.closePath() + ctx.fillStyle = `${color}${stackedFillAlpha}` + ctx.fill() + + ctx.beginPath() + + traceStackTop(self, ctx, xs, series, rows, stepped) + + ctx.lineWidth = edgeWidth + ctx.strokeStyle = `${color}${stackedEdgeAlpha}` + ctx.stroke() + } + + const drawStacked = self => { + if (chart.getAttribute("chartType") !== "stacked") return + + const dimensionIds = chart.getPayloadDimensionIds() + const bounds = stackBounds() + const xs = self.data[0] + const { ctx } = self + const stepped = chart.getAttribute("stepPlot") + + ctx.save() + ctx.beginPath() + ctx.rect(self.bbox.left, self.bbox.top, self.bbox.width, self.bbox.height) + ctx.clip() + + const edgeWidth = window.devicePixelRatio || 1 + const plotWidth = self.bbox.width / (self.pxRatio || 1) + const visibleColumns = bounds.filter(Boolean) + const xPositions = new Array(xs.length) + const getX = row => { + if (xPositions[row] === undefined) xPositions[row] = self.valToPos(xs[row], "x", true) + return xPositions[row] + } + + dimensionIds.forEach((id, index) => { + const series = bounds[index] + if (!series) return + + const color = chart.selectDimensionColor(id) + + getStackSegments(series, xs.length).forEach(([start, end]) => { + const selected = selectStackRows(visibleColumns, getX, start, end, plotWidth) + const rows = selected || defaultRows(start, end) + + drawStackSegment(self, ctx, xs, series, rows, color, edgeWidth, stepped) + }) + }) + + ctx.restore() + } + + const drawHeatmap = self => { + if (chart.getAttribute("chartType") !== "heatmap") return + + const dimensionIds = chart.getPayloadDimensionIds() + const xs = self.data[0] + if (!xs || !xs.length) return + + const { ctx } = self + const getColor = makeGetColor(chart) + + let minWidthSep = Infinity + for (let i = 1; i < xs.length; i++) { + const sep = self.valToPos(xs[i], "x", true) - self.valToPos(xs[i - 1], "x", true) + if (sep < minWidthSep) minWidthSep = sep + } + + const barWidth = Number.isFinite(minWidthSep) ? Math.floor(minWidthSep) : self.bbox.width + const rowHeight = Math.abs(self.valToPos(1, "y", true) - self.valToPos(0, "y", true)) + const { all } = chart.getPayload() + if (!all) return + + const { min: xMin, max: xMax } = self.scales.x + // the scales are null until uPlot's first convergence, and comparing against null coerces to 0 + const clipToWindow = Number.isFinite(xMin) && Number.isFinite(xMax) + + ctx.save() + ctx.beginPath() + ctx.rect(self.bbox.left, self.bbox.top, self.bbox.width, self.bbox.height) + ctx.clip() + + dimensionIds.forEach(id => { + const yIndex = chart.getHeatmapYIndex(id) + if (yIndex === -1) return + + const yTop = self.valToPos(yIndex, "y", true) - rowHeight / 2 + + for (let row = 0; row < xs.length; row++) { + const x = xs[row] + if (clipToWindow && (x < xMin || x > xMax)) continue + + const value = chart.getRowDimensionValue(id, all[row], { allowNull: true }) + const color = getColor(value) + if (color === "transparent") continue + + ctx.fillStyle = color + ctx.fillRect(self.valToPos(x, "x", true) - barWidth / 2, yTop, barWidth, rowHeight) + } + }) + + ctx.restore() + } + + const getBarSlotWidth = self => { + const xs = self.data[0] + + let minSep = Infinity + for (let i = 1; i < xs.length; i++) { + const sep = self.valToPos(xs[i], "x", true) - self.valToPos(xs[i - 1], "x", true) + if (sep < minSep) minSep = sep + } + + return Number.isFinite(minSep) ? minSep : self.bbox.width + } + + const drawGroupedBars = (self, dimensionIds, groupWidth) => { + const xs = self.data[0] + const { ctx } = self + const y0 = self.valToPos(0, "y", true) + + const visibleIds = dimensionIds.filter(isVisible) + const barCount = visibleIds.length || 1 + const barWidth = groupWidth / barCount + + dimensionIds.forEach((id, index) => { + if (!isVisible(id)) return + + const values = self.data[index + 1] + const barIndex = visibleIds.indexOf(id) + const color = chart.selectDimensionColor(id) + ctx.fillStyle = color + ctx.strokeStyle = darkenColor(color) + ctx.lineWidth = self.pxRatio || 1 + + for (let row = 0; row < xs.length; row++) { + const value = values[row] + if (value == null) continue + + const valuePos = self.valToPos(value, "y", true) + const left = self.valToPos(xs[row], "x", true) - groupWidth / 2 + barIndex * barWidth + const top = Math.min(y0, valuePos) + const height = Math.abs(valuePos - y0) + + ctx.fillRect(left, top, barWidth, height) + ctx.strokeRect(left, top, barWidth, height) + } + }) + } + + const drawStackedBars = (self, dimensionIds, groupWidth) => { + const xs = self.data[0] + const { ctx } = self + + const bounds = seriesStackBounds(self) + + dimensionIds.forEach((id, index) => { + const columnBounds = bounds[index] + if (!columnBounds) return + + const color = chart.selectDimensionColor(id) + ctx.fillStyle = color + ctx.strokeStyle = darkenColor(color) + ctx.lineWidth = self.pxRatio || 1 + + for (let row = 0; row < xs.length; row++) { + const bound = columnBounds[row] + if (!bound) continue + + const topPos = self.valToPos(bound[1], "y", true) + const basePos = self.valToPos(bound[0], "y", true) + const left = self.valToPos(xs[row], "x", true) - groupWidth / 2 + + const top = Math.min(topPos, basePos) + const height = Math.abs(topPos - basePos) + + ctx.fillRect(left, top, groupWidth, height) + ctx.strokeRect(left, top, groupWidth, height) + } + }) + } + + const drawBars = self => { + const chartType = chart.getAttribute("chartType") + if (!isBarType(chartType)) return + + const xs = self.data[0] + if (!xs || !xs.length) return + + const dimensionIds = chart.getPayloadDimensionIds() + const groupWidth = Math.max(1, getBarSlotWidth(self) * barGroupWidth) + const { ctx } = self + + ctx.save() + ctx.beginPath() + ctx.rect(self.bbox.left, self.bbox.top, self.bbox.width, self.bbox.height) + ctx.clip() + + if (chartType === "stackedBar") drawStackedBars(self, dimensionIds, groupWidth) + else drawGroupedBars(self, dimensionIds, groupWidth) + + ctx.restore() + } + + const drawAnomaly = makeAnomaly(chartUI) + const drawAnomalyBadge = makeAnomalyBadge(chartUI) + const drawAnnotations = makeAnnotations(chartUI) + const getHoverDimension = makeGetHoverDimension(chart) + + const getYAxisValueRange = () => { + if (chart.getAttribute("chartType") === "heatmap") + return [chart.getAttribute("min"), chart.getAttribute("max")] + + const [min, max] = chart.getAttribute("getValueRange")(chart, { dygraph: true }) + return [ + min === null ? chart.getAttribute("min") : min, + max === null ? chart.getAttribute("max") : max, + ] + } + + const fireYAxisChange = () => { + const [min, max] = getYAxisValueRange() + if (min == null || max == null) return + if (min === prevYMin && max === prevYMax) return + + prevYMin = min + prevYMax = max + chart.trigger("yAxisChange", min, max) + } + + const drawHoverDots = (self, ctx, dimensions) => { + if (!Array.isArray(dimensions)) return + + const timestamp = dimensions[0] + if (timestamp == null) return + if (chart.getAttribute("chartType") === "heatmap") return + + const row = chart.getClosestRow(timestamp) + if (row === -1) return + + const xs = self.data[0] + if (!xs || xs[row] == null) return + + const x = self.valToPos(xs[row], "x", true) + if (!Number.isFinite(x)) return + + const dpr = self.pxRatio || 1 + const radius = (chart.isSparkline() ? sparklineHoverDotRadius : hoverDotRadius) * dpr + const dimensionIds = chart.getPayloadDimensionIds() + + ctx.save() + + dimensionIds.forEach((id, index) => { + if (!isVisible(id)) return + + const series = self.data[index + 1] + const value = series && series[row] + if (value == null) return + + const y = self.valToPos(value, "y", true) + if (!Number.isFinite(y)) return + + ctx.beginPath() + ctx.fillStyle = chart.selectDimensionColor(id) + ctx.arc(x, y, radius, 0, 2 * Math.PI) + ctx.fill() + }) + + ctx.restore() + } + + const renderCrosshair = () => { + if (!u || !overlayCtx) return + + overlayCtx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height) + + const hoverX = chart.getAttribute("hoverX") + const clickX = chart.getAttribute("clickX") + + drawVerticalLine(u, overlayCtx, hoverX, chart.getThemeAttribute("themeCrosshair"), [5, 5]) + drawVerticalLine(u, overlayCtx, clickX, chart.getThemeAttribute("themeNetdata"), [2, 2]) + drawHoverDots(u, overlayCtx, hoverX) + drawHoverDots(u, overlayCtx, clickX) + } + + const createOverlay = () => { + if (!u) return + + const mainCanvas = u.ctx.canvas + overlayCanvas = document.createElement("canvas") + overlayCanvas.className = "netdata-crosshair-overlay" + overlayCanvas.width = mainCanvas.width + overlayCanvas.height = mainCanvas.height + overlayCanvas.style.width = mainCanvas.style.width + overlayCanvas.style.height = mainCanvas.style.height + overlayCanvas.style.position = "absolute" + overlayCanvas.style.top = mainCanvas.style.top || "0" + overlayCanvas.style.left = mainCanvas.style.left || "0" + overlayCanvas.style.pointerEvents = "none" + overlayCtx = overlayCanvas.getContext("2d") + mainCanvas.parentNode.appendChild(overlayCanvas) + } + + const syncOverlaySize = () => { + if (!u || !overlayCanvas) return + + const mainCanvas = u.ctx.canvas + + // reassigning width/height clears the layer, so only touch it on a real size change + if (overlayCanvas.width !== mainCanvas.width) overlayCanvas.width = mainCanvas.width + if (overlayCanvas.height !== mainCanvas.height) overlayCanvas.height = mainCanvas.height + + overlayCanvas.style.width = mainCanvas.style.width + overlayCanvas.style.height = mainCanvas.style.height + } + + // uPlot defers scale/size convergence to its commit cycle, so the crosshair layer is + // re-derived from the draw hook (after convergence) rather than at the call sites. + const drawCrosshairLayer = () => { + syncOverlaySize() + renderCrosshair() + } + + const destroyOverlay = () => { + if (overlayCanvas && overlayCanvas.parentNode) + overlayCanvas.parentNode.removeChild(overlayCanvas) + overlayCanvas = null + overlayCtx = null + } + + const drawOverlays = self => overlays && overlays.draw(self) + + const setCursor = self => { + if (!chart.getAttribute("enabledHover")) return + + const { left, idx } = self.cursor + const outside = left == null || left < 0 || idx == null + + if (outside) { + lastHoverTimestamp = null + lastHoverDimension = null + + if (!hovering) return + + hovering = false + sdk.trigger("highlightBlur", chart) + chart.trigger("highlightBlur") + return + } + + // hoverChart/blurChart belong to the container's element-scoped hover (like dygraph); + // firing them from the cursor blurs the synced group when it crosses the axis gutter + if (!hovering) hovering = true + + const timestamp = self.data[0][idx] * 1000 + const dimensionId = getHoverDimension(self) + + if (timestamp === lastHoverTimestamp && dimensionId === lastHoverDimension) return + + lastHoverTimestamp = timestamp + lastHoverDimension = dimensionId + + sdk.trigger("highlightHover", chart, timestamp, dimensionId) + chart.trigger("highlightHover", timestamp, dimensionId) + } + + const emitNav = (name, ...args) => sdk.trigger(name, chart, ...args) + + const clearPanState = () => + chart + .getApplicableNodes({ syncPanning: true }) + .forEach(node => node.updateAttributes({ enabledHover: true, panning: false })) + + const clearHighlightState = () => + chart + .getApplicableNodes({ syncHighlight: true }) + .forEach(node => node.updateAttributes({ enabledHover: true, highlighting: false })) + + const isNearAnnotation = offsetX => { + const overlays = chart.getAttribute("overlays") + + for (const overlayId in overlays) { + const overlay = overlays[overlayId] + if (overlay.type !== "annotation") continue + + const annotationX = u.valToPos(overlay.timestamp, "x") + if (Math.abs(offsetX - annotationX) < 10) return true + } + + return false + } + + const annotate = (offsetX, xMs) => { + if (isNearAnnotation(offsetX)) return + + const existingDraft = chart.getAttribute("draftAnnotation") + if (existingDraft && existingDraft.status === "editing") return + + chart.updateAttribute("draftAnnotation", { + timestamp: xMs / 1000, + createdAt: new Date(), + status: "draft", + }) + + emitNav("annotationCreate", xMs / 1000) + chart.trigger("annotationCreate", xMs / 1000) + } + + const getCursor = () => { + const nav = chart.getAttribute("enabledNavigation") ? chart.getAttribute("navigation") : null + const drag = + nav === "selectVertical" + ? { x: false, y: true, setScale: false } + : nav === "select" || nav === "highlight" + ? { x: true, y: false, setScale: false } + : { x: false, y: false } + + // dygraph draws one themed vertical line and its own dots; uPlot's native cursor would + // stack a second (hardcoded #607d8b) vertical line, a horizontal line and DOM points on top + // alpha 1 because dygraph sets highlightSeriesBackgroundAlpha:1; uPlot would otherwise + // fade every non-focused series to its 0.3 default on hover + return { + focus: { prox: 16, alpha: 1 }, + drag, + x: false, + y: false, + points: { show: false }, + } + } + + const updateCursorDrag = () => { + if (!u) return + + const { drag } = getCursor() + u.cursor.drag.x = !!drag.x + u.cursor.drag.y = !!drag.y + u.cursor.drag.setScale = false + u.redraw(false, false) + } + + const onSetSelect = self => { + if (!chart.getAttribute("enabledNavigation")) return + + const nav = chart.getAttribute("navigation") + const vertical = nav === "selectVertical" + if (nav !== "select" && nav !== "highlight" && !vertical) return + if (selectEnded) return + selectEnded = true + + const { select } = self + + if (vertical) { + const range = + select.height >= minDragPx + ? [self.posToVal(select.top + select.height, "y"), self.posToVal(select.top, "y")] + : null + emitNav("highlightVerticalEnd", range) + } else { + const range = + select.width >= minDragPx + ? [ + Math.round(self.posToVal(select.left, "x")), + Math.round(self.posToVal(select.left + select.width, "x")), + ] + : null + emitNav("highlightEnd", range) + } + + self.setSelect({ left: 0, top: 0, width: 0, height: 0 }, false) + } + + const moveXDebounced = debounce(300, (after, before) => { + chart.moveX(after, before) + xRangeOverride = null + }) + + const onWheel = event => { + if (!chart.getAttribute("enabledNavigation")) return + if (!event.shiftKey && !event.altKey) return + if (event.deltaY === 0) return + + const left = u.cursor.left + if (left == null || left < 0) return + + event.preventDefault() + event.stopPropagation() + + const rect = u.over.getBoundingClientRect() + const bias = rect.width === 0 ? 0 : left / rect.width + + const normalDef = + typeof event.wheelDelta === "number" && !Number.isNaN(event.wheelDelta) + ? event.wheelDelta / 40 + : event.deltaY * -1.2 + const normal = event.detail ? event.detail * -1 : normalDef + const percentage = normal / 50 + + const afterAxis = u.scales.x.min * 1000 + const beforeAxis = u.scales.x.max * 1000 + + const delta = beforeAxis - afterAxis + const increment = delta * percentage + const afterIncrement = increment * bias + const beforeIncrement = increment * (1 - bias) + + const afterSeconds = Math.round((afterAxis + afterIncrement) / 1000) + const beforeSeconds = Math.round((beforeAxis - beforeIncrement) / 1000) + + const { fixedAfter, fixedBefore } = limitRange({ after: afterSeconds, before: beforeSeconds }) + + if (fixedAfter * 1000 === afterAxis && fixedBefore * 1000 === beforeAxis) return + + xRangeOverride = [fixedAfter, fixedBefore] + u.setScale("x", { min: fixedAfter, max: fixedBefore }) + moveXDebounced(fixedAfter, fixedBefore) + } + + const emitPointer = name => event => { + const rect = u.over.getBoundingClientRect() + const dpr = u.pxRatio || 1 + const offsetX = event.clientX - rect.left + u.bbox.left / dpr + const offsetY = event.clientY - rect.top + u.bbox.top / dpr + chartUI.trigger(name, { + offsetX, + offsetY, + layerX: offsetX, + layerY: offsetY, + clientX: event.clientX, + clientY: event.clientY, + pageX: event.pageX, + pageY: event.pageY, + }) + } + + const attachNavigation = () => { + const over = u.over + let detachDoc = null + + const activeGestures = new Set() + + const finishActiveGestures = emit => { + const gestures = [...activeGestures] + activeGestures.clear() + gestures.forEach(finish => finish(emit)) + } + + const onOverMove = emitPointer("mousemove") + const onOverOut = emitPointer("mouseout") + const onOverOver = emitPointer("mouseover") + + const onDown = event => { + if (event.button !== 0) return + if (!chart.getAttribute("enabledNavigation")) return + if (chart.getAttribute("navigation") !== "pan") return + + event.preventDefault() + + moveXDebounced.cancel({ upcomingOnly: true }) + + const left0 = event.clientX + const min0 = u.scales.x.min + const max0 = u.scales.x.max + const unitsPerPx = u.posToVal(1, "x") - u.posToVal(0, "x") + + emitNav("panStart") + + const onMove = ev => { + const dx = unitsPerPx * (ev.clientX - left0) + xRangeOverride = [min0 - dx, max0 - dx] + u.setScale("x", { min: min0 - dx, max: max0 - dx }) + } + + const finish = emit => { + document.removeEventListener("mousemove", onMove) + document.removeEventListener("mouseup", onUp) + detachDoc = null + activeGestures.delete(finish) + + const [rangeMin, rangeMax] = xRangeOverride || [u.scales.x.min, u.scales.x.max] + xRangeOverride = null + + if (emit) emitNav("panEnd", [rangeMin * 1000, rangeMax * 1000]) + else clearPanState() + } + + const onUp = () => finish(true) + + activeGestures.add(finish) + document.addEventListener("mousemove", onMove) + document.addEventListener("mouseup", onUp) + detachDoc = () => { + document.removeEventListener("mousemove", onMove) + document.removeEventListener("mouseup", onUp) + } + } + + const onDblClick = () => { + if (!chart.getAttribute("enabledNavigation")) return + + chart.resetNavigation() + } + + let downX = null + let downY = null + let dragged = false + let downedOnOver = false + + const onDownTrack = event => { + if (event.button !== 0) return + downX = event.clientX + downY = event.clientY + dragged = false + downedOnOver = true + } + + const onMoveTrack = event => { + if (!downedOnOver) return + if ( + Math.abs(event.clientX - downX) >= minDragPx || + Math.abs(event.clientY - downY) >= minDragPx + ) + dragged = true + } + + const onUpTrack = event => { + if (!downedOnOver) return + + const wasDrag = dragged + downedOnOver = false + dragged = false + + if (wasDrag) return + if (!chart.getAttribute("enabledNavigation")) return + if (chart.getAttribute("navigation") !== "pan") return + + const rect = over.getBoundingClientRect() + const offsetX = event.clientX - rect.left + if (offsetX < 0 || offsetX > rect.width) return + + const rawMs = u.posToVal(offsetX, "x") * 1000 + const row = chart.getClosestRow(rawMs) + const snappedX = row === -1 ? null : u.data[0]?.[row] + const xMs = snappedX == null ? rawMs : snappedX * 1000 + + annotate(offsetX, xMs) + + const dimensionId = getHoverDimension(u) + emitNav("highlightClick", xMs, dimensionId) + chart.trigger("highlightClick", xMs, dimensionId) + } + + let lastTouchEndTime = 0 + let touchMoved = false + let touchPanning = false + let touchStartX = 0 + let touchMin0 = 0 + let touchMax0 = 0 + let touchUnitsPerPx = 0 + let pinching = false + let pinchStartDistance = 0 + let pinchAnchor = 0 + + const touchSpread = touches => Math.abs(touches[1].clientX - touches[0].clientX) + + const setXRange = (after, before) => { + const { fixedAfter, fixedBefore } = limitRange({ + after: Math.round(after), + before: Math.round(before), + }) + if (fixedBefore - fixedAfter < 1) return + + xRangeOverride = [fixedAfter, fixedBefore] + u.setScale("x", { min: fixedAfter, max: fixedBefore }) + moveXDebounced(fixedAfter, fixedBefore) + } + + const startPinch = touches => { + pinching = true + pinchStartDistance = touchSpread(touches) + const rect = over.getBoundingClientRect() + const midX = (touches[0].clientX + touches[1].clientX) / 2 + pinchAnchor = u.posToVal(midX - rect.left, "x") + touchMin0 = u.scales.x.min + touchMax0 = u.scales.x.max + } + + const finishTouchPan = emit => { + touchPanning = false + activeGestures.delete(finishTouchPan) + + const [rangeMin, rangeMax] = xRangeOverride || [u.scales.x.min, u.scales.x.max] + xRangeOverride = null + + if (emit) emitNav("panEnd", [rangeMin * 1000, rangeMax * 1000]) + else clearPanState() + } + + const onTouchStart = event => { + if (!chart.getAttribute("enabledNavigation")) return + + const touch = event.touches[0] + if (!touch) return + + event.preventDefault() + + moveXDebounced.cancel({ upcomingOnly: true }) + + touchMoved = false + touchPanning = false + pinching = false + + if (event.touches.length > 1) { + startPinch(event.touches) + return + } + + touchStartX = touch.clientX + touchMin0 = u.scales.x.min + touchMax0 = u.scales.x.max + touchUnitsPerPx = u.posToVal(1, "x") - u.posToVal(0, "x") + } + + const onTouchMove = event => { + if (!chart.getAttribute("enabledNavigation")) return + + const touch = event.touches[0] + if (!touch) return + + event.preventDefault() + + if (event.touches.length > 1) { + if (!pinching) startPinch(event.touches) + + touchMoved = true + const spread = touchSpread(event.touches) + if (!spread || !pinchStartDistance) return + + const scale = pinchStartDistance / spread + setXRange( + pinchAnchor - (pinchAnchor - touchMin0) * scale, + pinchAnchor + (touchMax0 - pinchAnchor) * scale + ) + return + } + + if (pinching) return + + if (!touchMoved) { + touchMoved = true + touchPanning = true + emitNav("panStart") + activeGestures.add(finishTouchPan) + } + + const dx = touchUnitsPerPx * (touch.clientX - touchStartX) + xRangeOverride = [touchMin0 - dx, touchMax0 - dx] + u.setScale("x", { min: touchMin0 - dx, max: touchMax0 - dx }) + } + + const onTouchEnd = event => { + if (!chart.getAttribute("enabledNavigation")) return + + event.preventDefault() + + if (pinching) { + if (event.touches.length < 2) pinching = false + lastTouchEndTime = Date.now() + return + } + + const now = Date.now() + + if (now - lastTouchEndTime < doubleTapDelay) { + lastTouchEndTime = now + xRangeOverride = null + chart.resetNavigation() + return + } + + lastTouchEndTime = now + + if (!touchMoved) { + const touch = event.changedTouches?.[0] + if (!touch) return + + const rect = over.getBoundingClientRect() + const offsetX = touch.clientX - rect.left + chart.updateAttribute("clickX", [u.posToVal(offsetX, "x") * 1000, null]) + return + } + + if (touchPanning) finishTouchPan(true) + } + + let detachSelectUp = null + + const onSelectDown = event => { + if (event.button !== 0) return + if (!chart.getAttribute("enabledNavigation")) return + + const nav = chart.getAttribute("navigation") + const vertical = nav === "selectVertical" + if (nav !== "select" && nav !== "highlight" && !vertical) return + + selectEnded = false + emitNav(vertical ? "highlightVerticalStart" : "highlightStart") + + const finish = emit => { + document.removeEventListener("mouseup", onSelectUp) + detachSelectUp = null + activeGestures.delete(finish) + if (selectEnded) return + selectEnded = true + + if (emit) emitNav(vertical ? "highlightVerticalEnd" : "highlightEnd", null) + else clearHighlightState() + } + + const onSelectUp = () => finish(true) + + activeGestures.add(finish) + document.addEventListener("mouseup", onSelectUp) + detachSelectUp = () => document.removeEventListener("mouseup", onSelectUp) + } + + const modifierNavigation = event => { + if (event.shiftKey && event.altKey) return "selectVertical" + if (event.altKey) return "highlight" + if (event.shiftKey) return "select" + return null + } + + const isSelectNavigation = navigation => + navigation === "select" || navigation === "highlight" || navigation === "selectVertical" + + const onModifierDown = event => { + if (event.button !== 0) return + if (!chart.getAttribute("enabledNavigation")) return + + const navigation = modifierNavigation(event) + if (!navigation) return + + const current = chart.getAttribute("navigation") + if (current === navigation) return + + const prevNavigation = chart.getAttribute("prevNavigation") || current + if (isSelectNavigation(navigation)) selectEnded = false + chart.updateAttributes({ navigation, prevNavigation }) + } + + const restoreNavigation = () => { + const prevNavigation = chart.getAttribute("prevNavigation") + if (prevNavigation) + chart.updateAttributes({ navigation: prevNavigation, prevNavigation: null }) + } + + const onModifierUp = () => setTimeout(restoreNavigation) + + const switchTarget = over.parentNode || over + + switchTarget.addEventListener("mousedown", onModifierDown, true) + over.addEventListener("mousedown", onDown) + over.addEventListener("mousedown", onDownTrack) + over.addEventListener("mousedown", onSelectDown) + document.addEventListener("mousemove", onMoveTrack) + document.addEventListener("mouseup", onUpTrack) + document.addEventListener("mouseup", onModifierUp) + over.addEventListener("wheel", onWheel, { passive: false }) + over.addEventListener("dblclick", onDblClick) + over.addEventListener("touchstart", onTouchStart, { passive: false }) + over.addEventListener("touchmove", onTouchMove, { passive: false }) + over.addEventListener("touchend", onTouchEnd) + over.addEventListener("mousemove", onOverMove) + over.addEventListener("mouseout", onOverOut) + over.addEventListener("mouseover", onOverOver) + + return ({ emitGestureEnd = false } = {}) => { + finishActiveGestures(emitGestureEnd) + + switchTarget.removeEventListener("mousedown", onModifierDown, true) + over.removeEventListener("mousedown", onDown) + over.removeEventListener("mousedown", onDownTrack) + over.removeEventListener("mousedown", onSelectDown) + document.removeEventListener("mousemove", onMoveTrack) + document.removeEventListener("mouseup", onUpTrack) + document.removeEventListener("mouseup", onModifierUp) + over.removeEventListener("touchstart", onTouchStart) + over.removeEventListener("touchmove", onTouchMove) + over.removeEventListener("touchend", onTouchEnd) + over.removeEventListener("wheel", onWheel) + over.removeEventListener("dblclick", onDblClick) + over.removeEventListener("mousemove", onOverMove) + over.removeEventListener("mouseout", onOverOut) + over.removeEventListener("mouseover", onOverOver) + if (detachDoc) detachDoc() + if (detachSelectUp) detachSelectUp() + } + } + + const create = () => { + if (!element) return + + const data = getData() + const empty = !data + if (empty && !chart.getAttribute("loaded")) return + + const scales = getScales() + if (empty) scales.y = { range: () => getEmptyValueRange() } + + u = new uPlot( + { + width: chartUI.getChartWidth(), + height: chartUI.getChartHeight(), + // null sides keep uPlot's autoPadSide behaviour + padding: [() => getVerticalBudget().topPad, () => rightPad, null, null], + legend: { show: false }, + cursor: getCursor(), + scales, + series: empty ? [{}] : getSeries(), + axes: getAxes(), + hooks: { + setCursor: [setCursor], + drawClear: [drawOverlays], + setSelect: [onSetSelect], + draw: empty + ? [fireYAxisChange] + : [ + fireYAxisChange, + drawStacked, + drawHeatmap, + drawBars, + drawAnomaly, + drawAnomalyBadge, + drawAnnotations, + drawCrosshairLayer, + ], + }, + }, + empty ? [[0]] : data, + element + ) + + createOverlay() + renderCrosshair() + + detachNavigation = attachNavigation() + } + + const destroyChart = ({ emitGestureEnd = false } = {}) => { + if (!u) return + + if (detachNavigation) { + detachNavigation({ emitGestureEnd }) + detachNavigation = null + } + + destroyOverlay() + u.destroy() + u = null + } + + const rebuild = () => { + destroyChart({ emitGestureEnd: true }) + create() + } + + // recalcAxes re-derives the cached tick strings, which a plain redraw leaves alone; rebuilding + // the instance for this reconstructed every chart on every streaming tick + const onUnitsConversionChange = () => u && u.redraw(false, true) + + const render = () => { + if (!element) return false + + const { highlighting, panning, processing } = chart.getAttributes() + if (highlighting || panning || processing) return false + + const data = getData() + if (!data && !chart.getAttribute("loaded")) { + destroyChart() + return false + } + + const frameData = data || [[0]] + + if (!u) create() + else if (u.series.length !== frameData.length) rebuild() + else u.setData(frameData) + + chartUI.render() + renderCrosshair() + chartUI.trigger("rendered") + return true + } + + const mount = el => { + if (element) return + + element = el + chartUI.mount(el) + element.classList.add(chart.getAttribute("theme")) + + resizeObserver = makeResizeObserver( + element, + () => chartUI.trigger("resize"), + () => chartUI.trigger("resize") + ) + + const { loaded } = chart.getAttributes() + + listeners = unregister( + chartUI.on("resize", () => { + if (!u) return + u.setSize({ width: chartUI.getChartWidth(), height: chartUI.getChartHeight() }) + syncOverlaySize() + renderCrosshair() + }), + chart.onAttributeChange("hoverX", () => renderCrosshair()), + chart.onAttributeChange("clickX", () => renderCrosshair()), + chart.onAttributeChange("overlays", overlays.toggle), + chart.onAttributeChange("draftAnnotation", overlays.toggle), + chart.onAttributeChange("selectedLegendDimensions", rebuild), + chart.onAttributeChange("chartType", rebuild), + // the path builder is resolved at create time, so a mid-session flip needs a rebuild + chart.onAttributeChange("stepPlot", rebuild), + chart.onAttributeChange("navigation", updateCursorDrag), + chart.onAttributeChange("enabledNavigation", rebuild), + chart.onAttributeChange("enabledXAxis", rebuild), + chart.onAttributeChange("enabledYAxis", rebuild), + chart.onAttributeChange("staticValueRange", () => { + if (!u) return + u.setData(u.data, true) + renderCrosshair() + }), + chart.onAttributeChange("timezone", () => u && u.redraw()), + // axis config (duration ticks, units) is captured at create time, and a plain + // redraw reuses cached tick strings, so re-derive it like dygraph does + chart.onAttributeChange("unitsConversionPrefix", onUnitsConversionChange), + chart.onAttributeChange("unitsConversionBase", onUnitsConversionChange), + chart.onAttributeChange("theme", (next, prev) => { + if (!element) return + + element.classList.remove(prev) + element.classList.add(next) + rebuild() + }), + !loaded && chart.onceAttributeChange("loaded", render) + ) + + render() + } + + const unmount = () => { + if (!element) return + + if (listeners) listeners() + if (resizeObserver) resizeObserver() + + destroyChart() + hovering = false + lastHoverTimestamp = null + lastHoverDimension = null + element = null + chartUI.unmount() + } + + const getUPlot = () => u + + const getChartWidth = () => (u ? u.over.clientWidth : chartUI.getChartWidth()) + + const getChartHeight = () => (u ? u.over.clientHeight : chartUI.getChartHeight()) + + 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 => { + if (!u) return 0 + const dpr = u.pxRatio || 1 + return u.bbox.left / dpr + u.valToPos(timestampMs / 1000, "x") + } + + const instance = { + ...chartUI, + getChartWidth, + getChartHeight, + mount, + unmount, + render, + getUPlot, + getXAxisRange, + getPlotArea, + getXCoord, + } + + overlays = makeOverlays(instance) + + return instance +} diff --git a/src/chartLibraries/uplot/index.test.js b/src/chartLibraries/uplot/index.test.js new file mode 100644 index 000000000..7faee312c --- /dev/null +++ b/src/chartLibraries/uplot/index.test.js @@ -0,0 +1,4560 @@ +import React from "react" +import { render, screen, act } from "@testing-library/react" +import { ThemeProvider } from "styled-components" +import { Flex, DefaultTheme } from "@netdata/netdata-ui" +import { makeTestChart, loadHeatmapPayload, renderWithChart } from "@jest/testUtilities" +import ChartContainer from "@/components/chartContainer" +import withChart from "@/components/hocs/withChart" +import makeMockPayload from "@/helpers/makeMockPayload" +import Popover from "@/components/line/popover" +import makeDefaultSDK from "../../makeDefaultSDK" +import systemLoadLine from "../../../fixtures/systemLoadLine" +import uplotChart from "./index" +import { getStackBounds, getStackValueRange } from "./stacking" + +const withLoadedPayload = chart => { + chart.getPayload = () => ({ + data: [ + [1617946860000, 10, 20, 30], + [1617946865000, 12, 18, 28], + [1617946870000, 11, 22, 31], + ], + labels: ["time", "load1", "load5", "load15", "ANOMALY_RATE", "ANNOTATIONS"], + }) + chart.getPayloadDimensionIds = () => ["load1", "load5", "load15"] + chart.getVisibleDimensionIds = () => ["load1", "load5", "load15"] + chart.isDimensionVisible = () => true + chart.selectDimensionColor = () => "#3366CC" + chart.getThemeAttribute = () => "#E4E8E8" + chart.getConvertedValueWithUnit = value => `${value}` +} + +describe("uplotChart", () => { + it("creates a chart instance exposing the lifecycle contract", () => { + const { sdk, chart } = makeTestChart() + + const instance = uplotChart(sdk, chart) + + expect(typeof instance.mount).toBe("function") + expect(typeof instance.unmount).toBe("function") + expect(typeof instance.render).toBe("function") + }) + + it("mounts without a uPlot instance when there is no data", () => { + const { sdk, chart } = makeTestChart({ attributes: { loaded: false } }) + chart.getPayload = () => ({ data: [] }) + chart.getPayloadDimensionIds = () => [] + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + + expect(() => instance.mount(element)).not.toThrow() + expect(element.querySelector(".uplot")).toBeNull() + }) + + it("renders a real uPlot chart when data is available", () => { + const { sdk, chart } = makeTestChart({ attributes: { loaded: true, chartType: "line" } }) + withLoadedPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + + expect(() => instance.mount(element)).not.toThrow() + expect(element.querySelector(".uplot")).not.toBeNull() + + document.body.removeChild(element) + }) + + it("configures date-window x-range, value y-range, and formatted x-axis labels", () => { + const { sdk, chart } = makeTestChart({ + attributes: { + loaded: true, + chartType: "line", + after: 1617946860, + before: 1617947760, + staticValueRange: [5, 40], + }, + }) + withLoadedPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + + const u = instance.getUPlot() + expect(u.scales.x.range()).toEqual([1617946860, 1617947760]) + + expect(u.scales.y.range(u, 0, 100)).toEqual([5, 40]) + + const labels = u.axes[0].values(u, [1617946860]) + expect(labels).toHaveLength(1) + expect(typeof labels[0]).toBe("string") + + instance.unmount() + document.body.removeChild(element) + }) + + it("does not emit hover events when enabledHover is false", () => { + const { sdk, chart } = makeTestChart({ + attributes: { loaded: true, chartType: "line", enabledHover: false }, + }) + withLoadedPayload(chart) + + const hovered = [] + sdk.on("highlightHover", (c, x) => hovered.push(x)) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + + instance.getUPlot().setCursor({ left: 400, top: 100 }, true) + expect(hovered).toHaveLength(0) + + instance.unmount() + document.body.removeChild(element) + }) + + it("keeps a framed empty chart when data goes out of limits and recovers when valid data returns", () => { + const { sdk, chart } = makeTestChart({ attributes: { loaded: true, chartType: "line" } }) + withLoadedPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + + const full = instance.getUPlot() + expect(full).not.toBeNull() + expect(full.series).toHaveLength(4) + + chart.updateAttribute("outOfLimits", true) + expect(() => instance.render()).not.toThrow() + + const framed = instance.getUPlot() + expect(framed).not.toBeNull() + expect(element.querySelector(".uplot")).not.toBeNull() + expect(framed.axes[0].show).toBe(true) + expect(framed.axes[1].show).toBe(true) + expect(framed.series).toHaveLength(1) + + chart.updateAttribute("outOfLimits", false) + expect(() => instance.render()).not.toThrow() + + const recovered = instance.getUPlot() + expect(recovered).not.toBeNull() + expect(recovered.series).toHaveLength(4) + + instance.unmount() + document.body.removeChild(element) + }) + + it("mounts a framed empty chart when the payload has no dimensions, without throwing", () => { + const { sdk, chart } = makeTestChart({ attributes: { loaded: true, chartType: "line" } }) + chart.getPayload = () => ({ data: [], labels: ["time"] }) + chart.getPayloadDimensionIds = () => [] + chart.getVisibleDimensionIds = () => [] + chart.isDimensionVisible = () => true + chart.selectDimensionColor = () => "#3366CC" + chart.getThemeAttribute = () => "#E4E8E8" + chart.getConvertedValueWithUnit = value => `${value}` + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + + expect(() => instance.mount(element)).not.toThrow() + + const u = instance.getUPlot() + expect(u).not.toBeNull() + expect(element.querySelector(".uplot")).not.toBeNull() + expect(u.axes[0].show).toBe(true) + expect(u.axes[1].show).toBe(true) + expect(u.series).toHaveLength(1) + + instance.unmount() + document.body.removeChild(element) + }) + + it("renders nothing until loaded, then draws the framed empty chart once loaded", () => { + const { sdk, chart } = makeTestChart({ attributes: { loaded: false, chartType: "line" } }) + chart.getPayload = () => ({ data: [], labels: ["time"] }) + chart.getPayloadDimensionIds = () => [] + chart.getVisibleDimensionIds = () => [] + chart.isDimensionVisible = () => true + chart.selectDimensionColor = () => "#3366CC" + chart.getThemeAttribute = () => "#E4E8E8" + chart.getConvertedValueWithUnit = value => `${value}` + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + + instance.mount(element) + expect(instance.getUPlot()).toBeNull() + expect(element.querySelector(".uplot")).toBeNull() + + chart.updateAttribute("loaded", true) + + expect(instance.getUPlot()).not.toBeNull() + expect(element.querySelector(".uplot")).not.toBeNull() + + instance.unmount() + document.body.removeChild(element) + }) + + it("reacts to staticValueRange, timezone and units changes without throwing", () => { + const { sdk, chart } = makeTestChart({ attributes: { loaded: true, chartType: "line" } }) + withLoadedPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + + expect(() => chart.updateAttribute("staticValueRange", [1, 9])).not.toThrow() + expect(() => chart.updateAttribute("timezone", "Asia/Tokyo")).not.toThrow() + expect(() => chart.updateAttribute("unitsConversionPrefix", "milli")).not.toThrow() + + instance.unmount() + document.body.removeChild(element) + }) + + const mountWithLabelCounter = async () => { + const { sdk, chart } = makeTestChart({ attributes: { loaded: true, chartType: "line" } }) + withLoadedPayload(chart) + + const counter = { calls: 0, suffix: "" } + chart.getConvertedValueWithUnit = value => { + counter.calls += 1 + return `${value}${counter.suffix}` + } + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + await Promise.resolve() + await Promise.resolve() + + return { + chart, + counter, + teardown: () => (instance.unmount(), document.body.removeChild(element)), + } + } + + it("re-renders y-axis labels when a conversable conversion changes the base unit", async () => { + const { chart, counter, teardown } = await mountWithLabelCounter() + + const before = counter.calls + expect(before).toBeGreaterThan(0) + + counter.suffix = "[degF]" + chart.updateAttribute("unitsConversionBase", ["[degF]"]) + await Promise.resolve() + await Promise.resolve() + + expect(counter.calls).toBeGreaterThan(before) + + teardown() + }) + + it("re-renders y-axis labels when a scalable conversion changes the prefix", async () => { + const { chart, counter, teardown } = await mountWithLabelCounter() + + const before = counter.calls + expect(before).toBeGreaterThan(0) + + counter.suffix = "milli" + chart.updateAttribute("unitsConversionPrefix", "milli") + await Promise.resolve() + await Promise.resolve() + + expect(counter.calls).toBeGreaterThan(before) + + teardown() + }) + + it("emits highlightHover and highlightBlur on the sdk bus as the cursor moves", () => { + const { sdk, chart } = makeTestChart({ attributes: { loaded: true, chartType: "line" } }) + withLoadedPayload(chart) + + const hovered = [] + const blurred = [] + sdk.on("highlightHover", (c, x) => hovered.push(x)) + sdk.on("highlightBlur", () => blurred.push(true)) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + + instance.getUPlot().setCursor({ left: 400, top: 100 }, true) + expect(hovered.length).toBeGreaterThan(0) + expect(hovered[0]).toBeGreaterThanOrEqual(1617946860000) + expect(hovered[0]).toBeLessThanOrEqual(1617946870000) + + instance.getUPlot().setCursor({ left: -10, top: -10 }, true) + expect(blurred.length).toBeGreaterThan(0) + + instance.unmount() + document.body.removeChild(element) + }) + + it("leaves hoverChart/blurChart to the container, like dygraph, when the cursor exits the plot", () => { + const { sdk, chart } = makeTestChart({ attributes: { loaded: true, chartType: "line" } }) + withLoadedPayload(chart) + + const hoverCharts = [] + const blurCharts = [] + const blurred = [] + sdk.on("hoverChart", () => hoverCharts.push(true)) + sdk.on("blurChart", () => blurCharts.push(true)) + sdk.on("highlightBlur", () => blurred.push(true)) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + + instance.getUPlot().setCursor({ left: 400, top: 100 }, true) + instance.getUPlot().setCursor({ left: -10, top: -10 }, true) + + expect(blurred.length).toBeGreaterThan(0) + expect(hoverCharts).toHaveLength(0) + expect(blurCharts).toHaveLength(0) + + instance.unmount() + document.body.removeChild(element) + }) + + it("emits highlightHover and highlightBlur on the chart bus too, with the nearest dimension", () => { + const { sdk, chart } = makeTestChart({ attributes: { loaded: true, chartType: "line" } }) + withLoadedPayload(chart) + + const chartHovered = [] + const chartBlurred = [] + chart.on("highlightHover", (x, dimensionId) => chartHovered.push([x, dimensionId])) + chart.on("highlightBlur", () => chartBlurred.push(true)) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + + instance.getUPlot().setCursor({ left: 400, top: 100 }, true) + expect(chartHovered.length).toBeGreaterThan(0) + expect(["load1", "load5", "load15"]).toContain(chartHovered[0][1]) + + instance.getUPlot().setCursor({ left: -10, top: -10 }, true) + expect(chartBlurred.length).toBeGreaterThan(0) + + instance.unmount() + document.body.removeChild(element) + }) + + it("mounts through ChartContainer via the SDK provider path without throwing", () => { + const sdk = makeDefaultSDK() + sdk.addUI("uplot", uplotChart) + const chart = sdk.makeChart({ + getChart: makeMockPayload(systemLoadLine[0], { delay: 0 }), + attributes: { contextScope: ["system.load"], chartLibrary: "uplot", chartType: "line" }, + }) + sdk.appendChild(chart) + + const UplotChart = withChart(({ uiName }) => ) + + expect(() => + render( + + + + + + ) + ).not.toThrow() + }) + + it("hides axes in sparkline mode", () => { + const { sdk, chart } = makeTestChart({ + attributes: { loaded: true, chartType: "line", sparkline: true }, + }) + withLoadedPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "200px" + element.style.height = "40px" + document.body.appendChild(element) + instance.mount(element) + + const u = instance.getUPlot() + expect(u.axes[0].show).toBe(false) + expect(u.axes[1].show).toBe(false) + + instance.unmount() + document.body.removeChild(element) + }) + + it("reports plot-area dimensions once mounted", () => { + const { sdk, chart } = makeTestChart({ attributes: { loaded: true, chartType: "line" } }) + withLoadedPayload(chart) + + const instance = uplotChart(sdk, chart) + expect(typeof instance.getChartWidth()).toBe("number") + + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + + expect(typeof instance.getChartWidth()).toBe("number") + expect(typeof instance.getChartHeight()).toBe("number") + + instance.unmount() + document.body.removeChild(element) + }) + + it("renders the stacked type with a null series path and a diverging fill draw hook", () => { + const { sdk, chart } = makeTestChart({ attributes: { loaded: true, chartType: "stacked" } }) + withLoadedPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + + expect(() => instance.mount(element)).not.toThrow() + + const u = instance.getUPlot() + expect(element.querySelector(".uplot")).not.toBeNull() + expect(u.series[1].paths()).toBeNull() + expect(() => u.redraw()).not.toThrow() + + instance.unmount() + document.body.removeChild(element) + }) + + it("does not create an orphaned uPlot when render runs before mount", () => { + const { sdk, chart } = makeTestChart({ attributes: { loaded: true, chartType: "line" } }) + withLoadedPayload(chart) + + const instance = uplotChart(sdk, chart) + + instance.render() + expect(instance.getUPlot()).toBeNull() + + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + + const u = instance.getUPlot() + expect(u).not.toBeNull() + expect(u.root.isConnected).toBe(true) + expect(element.querySelector(".uplot")).not.toBeNull() + + instance.unmount() + document.body.removeChild(element) + }) + + it("redraws a crosshair when hoverX changes without throwing", () => { + const { sdk, chart } = makeTestChart({ attributes: { loaded: true, chartType: "line" } }) + withLoadedPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + + expect(() => chart.updateAttribute("hoverX", [1617946865000])).not.toThrow() + expect(() => chart.updateAttribute("hoverX", null)).not.toThrow() + + instance.unmount() + document.body.removeChild(element) + }) + + it("emits highlightEnd on drag-select in select mode", () => { + const { sdk, chart } = makeTestChart({ + attributes: { loaded: true, chartType: "line", navigation: "select" }, + }) + withLoadedPayload(chart) + + const ends = [] + sdk.on("highlightEnd", (c, range) => ends.push(range)) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + + instance.getUPlot().setSelect({ left: 100, top: 0, width: 200, height: 0 }, true) + expect(ends).toHaveLength(1) + expect(ends[0]).toHaveLength(2) + + instance.unmount() + document.body.removeChild(element) + }) + + it("does not emit highlightEnd when navigation is pan", () => { + const { sdk, chart } = makeTestChart({ + attributes: { loaded: true, chartType: "line", navigation: "pan" }, + }) + withLoadedPayload(chart) + + const ends = [] + sdk.on("highlightEnd", (c, range) => ends.push(range)) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + + instance.getUPlot().setSelect({ left: 100, top: 0, width: 200, height: 0 }, true) + expect(ends).toHaveLength(0) + + instance.unmount() + document.body.removeChild(element) + }) + + it("resets navigation on double-click", () => { + const { sdk, chart } = makeTestChart({ + attributes: { loaded: true, chartType: "line", navigation: "pan" }, + }) + withLoadedPayload(chart) + + const spy = jest.spyOn(chart, "resetNavigation") + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + + instance.getUPlot().over.dispatchEvent(new MouseEvent("dblclick", { bubbles: true })) + expect(spy).toHaveBeenCalled() + + instance.unmount() + document.body.removeChild(element) + }) + + it("unmounts and destroys the uPlot instance cleanly", () => { + const { sdk, chart } = makeTestChart({ attributes: { loaded: true, chartType: "line" } }) + withLoadedPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + + instance.mount(element) + expect(() => instance.unmount()).not.toThrow() + expect(element.querySelector(".uplot")).toBeNull() + + document.body.removeChild(element) + }) +}) + +describe("uplotChart heatmap", () => { + const heatmapIds = ["0", "1", "2", "3", "4", "5", "6"] + const heatmapRows = [ + [0, 0, 1, 0, 2, 0, 0], + [0, 0, 0, 3, 1, 0, 0], + [0, 0, 2, 0, 0, 0, 0], + ] + + const mountHeatmap = async (extraAttributes = {}) => { + const { sdk, chart } = makeTestChart({ + attributes: { + loaded: true, + chartType: "heatmap", + context: "prometheus.test.histogram", + groupBy: ["dimension"], + selectedLegendDimensions: [], + viewDimensions: { + ids: heatmapIds, + names: heatmapIds, + priorities: heatmapIds.map((_, index) => index), + units: heatmapIds.map(() => ""), + contexts: heatmapIds.map(() => ""), + grouped: ["dimension"], + }, + ...extraAttributes, + }, + }) + + await loadHeatmapPayload(chart, heatmapIds, heatmapRows, { timestamp: 1617946860000 }) + chart.getDateWindow = () => [1617946860000, 1617947750000] + chart.formatXAxis = x => x.toString() + chart.getThemeAttribute = () => "#333" + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + Object.defineProperty(element, "offsetWidth", { configurable: true, value: 800 }) + Object.defineProperty(element, "offsetHeight", { configurable: true, value: 300 }) + document.body.appendChild(element) + + return { sdk, chart, instance, element } + } + + const cleanup = (instance, element) => { + instance.unmount() + document.body.removeChild(element) + } + + it("takes the heatmap render path with null series paths for chartType heatmap", async () => { + const { chart, instance, element } = await mountHeatmap() + + expect(chart.getAttribute("chartType")).toBe("heatmap") + + instance.mount(element) + const u = instance.getUPlot() + + expect(element.querySelector(".uplot")).not.toBeNull() + expect(u.series[1].paths()).toBeNull() + expect(() => u.hooks.draw.forEach(hook => hook(u))).not.toThrow() + + cleanup(instance, element) + }) + + it("straddles the outer half rows so no bucket is clipped", async () => { + const { chart, instance, element } = await mountHeatmap() + + instance.mount(element) + const u = instance.getUPlot() + + const numBuckets = chart.getVisibleHeatmapIds().length + expect(numBuckets).toBe(5) + expect(u.scales.y.range(u, 0, 100)).toEqual([-0.5, numBuckets - 0.5]) + + cleanup(instance, element) + }) + + it("exercises the shared heatmap accessors while rendering", async () => { + const { chart, instance, element } = await mountHeatmap() + + const visibleSpy = jest.spyOn(chart, "getVisibleHeatmapIds") + const yIndexSpy = jest.spyOn(chart, "getHeatmapYIndex") + const scaleSpy = jest.spyOn(chart, "getHeatmapScale") + const valueSpy = jest.spyOn(chart, "getRowDimensionValue") + + instance.mount(element) + const u = instance.getUPlot() + + u.hooks.draw.forEach(hook => hook(u)) + u.axes[1].values(u, u.axes[1].splits(u, 1, 0, chart.getVisibleHeatmapIds().length)) + + expect(visibleSpy).toHaveBeenCalled() + expect(yIndexSpy).toHaveBeenCalled() + expect(scaleSpy).toHaveBeenCalled() + expect(valueSpy).toHaveBeenCalledWith(expect.any(String), expect.anything(), { + allowNull: true, + }) + + visibleSpy.mockRestore() + yIndexSpy.mockRestore() + scaleSpy.mockRestore() + valueSpy.mockRestore() + + cleanup(instance, element) + }) + + it("labels the y-axis with bucket boundaries decimated to fit", async () => { + const { chart, instance, element } = await mountHeatmap() + + instance.mount(element) + const u = instance.getUPlot() + + const numBuckets = chart.getVisibleHeatmapIds().length + const splits = u.axes[1].splits(u, 1, 0, numBuckets) + + expect(splits.length).toBeGreaterThan(0) + splits.forEach(split => { + expect(Number.isInteger(split)).toBe(true) + expect(split).toBeGreaterThanOrEqual(0) + expect(split).toBeLessThan(numBuckets) + }) + + const values = u.axes[1].values(u, splits) + expect(values).toHaveLength(splits.length) + values.forEach(value => expect(typeof value).toBe("string")) + + cleanup(instance, element) + }) +}) + +describe("uplotChart bars", () => { + const positiveData = [ + [1617946860000, 10, 20, 30], + [1617946865000, 12, 18, 28], + [1617946870000, 11, 22, 31], + ] + + const negativeData = [ + [1617946860000, 10, -20, 5], + [1617946865000, -12, 18, -8], + [1617946870000, 11, -22, 6], + ] + + const withBarPayload = (chart, data) => { + chart.getPayload = () => ({ + data, + labels: ["time", "reads", "writes", "other"], + }) + chart.getPayloadDimensionIds = () => ["reads", "writes", "other"] + chart.getVisibleDimensionIds = () => ["reads", "writes", "other"] + chart.isDimensionVisible = () => true + chart.selectDimensionColor = () => "#3366CC" + chart.getThemeAttribute = () => "#E4E8E8" + chart.getConvertedValueWithUnit = value => `${value}` + } + + const mountBars = (chartType, data) => { + const { sdk, chart } = makeTestChart({ attributes: { loaded: true, chartType } }) + withBarPayload(chart, data) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + + return { sdk, chart, instance, element } + } + + const cleanup = (instance, element) => { + instance.unmount() + document.body.removeChild(element) + } + + it.each(["multiBar", "stackedBar"])( + "draws %s on the main time-x instance, not a separate ordinal instance", + chartType => { + const { instance, element } = mountBars(chartType, positiveData) + const u = instance.getUPlot() + + expect(element.querySelector(".uplot")).not.toBeNull() + expect(u.scales.x.time).toBe(true) + expect(u.scales.x.distr).not.toBe(2) + expect(u.series[1].paths()).toBeNull() + expect(() => u.hooks.draw.forEach(hook => hook(u))).not.toThrow() + + cleanup(instance, element) + } + ) + + it.each(["multiBar", "stackedBar"])( + "keeps the time x-range aligned with the chart date window for %s", + chartType => { + const { chart, instance, element } = mountBars(chartType, positiveData) + const u = instance.getUPlot() + + const [after, before] = chart.getDateWindow() + expect(u.scales.x.range()).toEqual([after / 1000, before / 1000]) + + cleanup(instance, element) + } + ) + + it("spans the multiBar y-range from the zero baseline to the tallest bar", () => { + const { instance, element } = mountBars("multiBar", positiveData) + const u = instance.getUPlot() + + const [min, max] = u.scales.y.range(u, 10, 31) + expect(min).toBe(0) + expect(max).toBeGreaterThanOrEqual(31) + + cleanup(instance, element) + }) + + it("includes negative values below the zero line in the multiBar y-range", () => { + const { instance, element } = mountBars("multiBar", negativeData) + const u = instance.getUPlot() + + const [min, max] = u.scales.y.range(u, -22, 18) + expect(min).toBeLessThan(0) + expect(max).toBeGreaterThan(0) + + cleanup(instance, element) + }) + + it("stacks stackedBar dimensions cumulatively via stack.js", () => { + const { instance, element } = mountBars("stackedBar", positiveData) + const u = instance.getUPlot() + + const [min, max] = u.scales.y.range(u, 10, 31) + expect(min).toBe(0) + expect(max).toBeGreaterThanOrEqual(60) + + cleanup(instance, element) + }) + + it("includes a negative cumulative total below the zero line in the stackedBar y-range", () => { + const { instance, element } = mountBars("stackedBar", negativeData) + const u = instance.getUPlot() + + const [min] = u.scales.y.range(u, -22, 18) + expect(min).toBeLessThan(0) + + cleanup(instance, element) + }) + + it.each(["multiBar", "stackedBar"])( + "emits highlightHover through the shared setCursor for %s, leaving hoverChart to the container", + chartType => { + const { sdk, instance, element } = mountBars(chartType, positiveData) + + const hovered = [] + const hoverCharts = [] + sdk.on("highlightHover", (c, x) => hovered.push(x)) + sdk.on("hoverChart", () => hoverCharts.push(true)) + + instance.getUPlot().setCursor({ left: 400, top: 100 }, true) + expect(hovered.length).toBeGreaterThan(0) + expect(hovered[0]).toBeGreaterThanOrEqual(1617946860000) + expect(hovered[0]).toBeLessThanOrEqual(1617946870000) + expect(hoverCharts).toHaveLength(0) + + cleanup(instance, element) + } + ) +}) + +describe("uplotChart click-to-annotate", () => { + const withLoadedClickPayload = chart => { + chart.getPayload = () => ({ + data: [ + [1617946860000, 10, 20, 30], + [1617946865000, 12, 18, 28], + [1617946870000, 11, 22, 31], + ], + labels: ["time", "load1", "load5", "load15"], + }) + chart.getPayloadDimensionIds = () => ["load1", "load5", "load15"] + chart.getVisibleDimensionIds = () => ["load1", "load5", "load15"] + chart.isDimensionVisible = () => true + chart.selectDimensionColor = () => "#3366CC" + chart.getThemeAttribute = () => "#E4E8E8" + chart.getConvertedValueWithUnit = value => `${value}` + chart.getClosestRow = timestamp => { + const { data } = chart.getPayload() + let closest = 0 + data.forEach((row, index) => { + if (Math.abs(row[0] - timestamp) < Math.abs(data[closest][0] - timestamp)) closest = index + }) + return closest + } + } + + const mount = async (attributes = {}) => { + const { sdk, chart } = makeTestChart({ + attributes: { + loaded: true, + chartType: "line", + chartLibrary: "uplot", + after: 1617946860, + before: 1617946870, + ...attributes, + }, + }) + withLoadedClickPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + await Promise.resolve() + await Promise.resolve() + + const u = instance.getUPlot() + u.over.getBoundingClientRect = () => ({ + left: 0, + top: 0, + width: 800, + height: 300, + right: 800, + bottom: 300, + }) + + const click = clientX => { + u.over.dispatchEvent(new MouseEvent("mousedown", { clientX, clientY: 100, button: 0 })) + document.dispatchEvent(new MouseEvent("mouseup", { clientX, clientY: 100 })) + } + + const dragClick = (fromX, toX) => { + u.over.dispatchEvent(new MouseEvent("mousedown", { clientX: fromX, clientY: 100, button: 0 })) + document.dispatchEvent(new MouseEvent("mousemove", { clientX: toX, clientY: 100 })) + document.dispatchEvent(new MouseEvent("mouseup", { clientX: toX, clientY: 100 })) + } + + return { + sdk, + chart, + instance, + u, + click, + dragClick, + teardown: () => (instance.unmount(), document.body.removeChild(element)), + } + } + + it("sets a draft annotation and fires annotationCreate on a plain click", async () => { + const { sdk, chart, click, teardown } = await mount({ navigation: "pan" }) + + const created = [] + sdk.on("annotationCreate", (c, ts) => created.push(ts)) + + click(400) + + const draft = chart.getAttribute("draftAnnotation") + expect(draft).toBeTruthy() + expect(draft.status).toBe("draft") + expect(typeof draft.timestamp).toBe("number") + expect(created.length).toBe(1) + + teardown() + }) + + it("fires highlightClick on the sdk bus on a plain click", async () => { + const { sdk, click, teardown } = await mount({ navigation: "pan" }) + + const clicks = [] + sdk.on("highlightClick", (c, ts) => clicks.push(ts)) + + click(400) + + expect(clicks.length).toBe(1) + expect(clicks[0]).toBeGreaterThanOrEqual(1617946860000) + expect(clicks[0]).toBeLessThanOrEqual(1617946870000) + + teardown() + }) + + it("snaps the click timestamp to the closest row", async () => { + const { sdk, chart, click, teardown } = await mount({ navigation: "pan" }) + + const clicks = [] + sdk.on("highlightClick", (c, ts) => clicks.push(ts)) + + click(401) + + const rows = chart.getPayload().data.map(row => row[0]) + expect(rows).toContain(clicks[0]) + expect(rows).toContain(chart.getAttribute("draftAnnotation").timestamp * 1000) + + teardown() + }) + + it("does not annotate when the click follows a drag", async () => { + const { chart, dragClick, teardown } = await mount({ navigation: "pan" }) + + dragClick(100, 300) + + expect(chart.getAttribute("draftAnnotation")).toBeFalsy() + + teardown() + }) + + it("does not annotate while selecting", async () => { + const { chart, click, teardown } = await mount({ navigation: "select" }) + + click(400) + + expect(chart.getAttribute("draftAnnotation")).toBeFalsy() + + teardown() + }) + + it("does not annotate while highlighting", async () => { + const { chart, click, teardown } = await mount({ navigation: "highlight" }) + + click(400) + + expect(chart.getAttribute("draftAnnotation")).toBeFalsy() + + teardown() + }) + + it("does not annotate when navigation is disabled", async () => { + const { chart, click, teardown } = await mount({ + navigation: "pan", + enabledNavigation: false, + }) + + click(400) + + expect(chart.getAttribute("draftAnnotation")).toBeFalsy() + + teardown() + }) + + it("does not create a draft when the click lands on an existing annotation", async () => { + const { chart, u, click, teardown } = await mount({ + navigation: "pan", + overlays: { "ann-1": { type: "annotation", timestamp: 1617946865 } }, + }) + + click(u.valToPos(1617946865, "x")) + + expect(chart.getAttribute("draftAnnotation")).toBeFalsy() + + teardown() + }) + + it("annotates on a sub-5px pointer wobble (no 4px dead zone)", async () => { + const { chart, dragClick, teardown } = await mount({ navigation: "pan" }) + + dragClick(100, 104) + + expect(chart.getAttribute("draftAnnotation")).toBeTruthy() + + teardown() + }) + + it("does not annotate on a movement at the 5px drag threshold", async () => { + const { chart, dragClick, teardown } = await mount({ navigation: "pan" }) + + dragClick(100, 105) + + expect(chart.getAttribute("draftAnnotation")).toBeFalsy() + + teardown() + }) +}) + +describe("uplotChart touch navigation", () => { + const withTouchPayload = chart => { + chart.getPayload = () => ({ + data: [ + [1617946860000, 10, 20, 30], + [1617946865000, 12, 18, 28], + [1617946870000, 11, 22, 31], + ], + labels: ["time", "load1", "load5", "load15"], + }) + chart.getPayloadDimensionIds = () => ["load1", "load5", "load15"] + chart.getVisibleDimensionIds = () => ["load1", "load5", "load15"] + chart.isDimensionVisible = () => true + chart.selectDimensionColor = () => "#3366CC" + chart.getThemeAttribute = () => "#E4E8E8" + chart.getConvertedValueWithUnit = value => `${value}` + } + + const touchEvent = (type, x) => { + const event = new Event(type, { bubbles: true, cancelable: true }) + const touch = { clientX: x, clientY: 100, pageX: x, pageY: 100 } + event.touches = type === "touchend" ? [] : [touch] + event.changedTouches = [touch] + return event + } + + const mount = async (attributes = {}) => { + const { sdk, chart } = makeTestChart({ + attributes: { + loaded: true, + chartType: "line", + chartLibrary: "uplot", + after: 1617946860, + before: 1617946870, + ...attributes, + }, + }) + withTouchPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + await Promise.resolve() + await Promise.resolve() + + const u = instance.getUPlot() + u.over.getBoundingClientRect = () => ({ + left: 0, + top: 0, + width: 800, + height: 300, + right: 800, + bottom: 300, + }) + + return { + sdk, + chart, + instance, + u, + teardown: () => (instance.unmount(), document.body.removeChild(element)), + } + } + + it("pans the x-scale on a single-finger drag, emitting panStart and panEnd", async () => { + const { sdk, u, teardown } = await mount() + + let panStarts = 0 + let panEnds = 0 + sdk.on("panStart", () => panStarts++) + sdk.on("panEnd", () => panEnds++) + + const setScaleSpy = jest.spyOn(u, "setScale") + + u.over.dispatchEvent(touchEvent("touchstart", 400)) + u.over.dispatchEvent(touchEvent("touchmove", 300)) + + expect(panStarts).toBe(1) + expect(setScaleSpy).toHaveBeenCalledWith( + "x", + expect.objectContaining({ min: expect.any(Number), max: expect.any(Number) }) + ) + + u.over.dispatchEvent(touchEvent("touchend", 300)) + expect(panEnds).toBe(1) + + setScaleSpy.mockRestore() + teardown() + }) + + it("sets clickX to the tapped timestamp on a tap with no movement", async () => { + const { chart, u, teardown } = await mount() + + u.over.dispatchEvent(touchEvent("touchstart", 400)) + u.over.dispatchEvent(touchEvent("touchend", 400)) + + const clickX = chart.getAttribute("clickX") + expect(Array.isArray(clickX)).toBe(true) + expect(clickX[0]).toBeGreaterThanOrEqual(1617946860000) + expect(clickX[0]).toBeLessThanOrEqual(1617946870000) + expect(clickX[1]).toBeNull() + + teardown() + }) + + it("resets navigation on a double-tap within the double-tap delay", async () => { + const { chart, u, teardown } = await mount() + + const resetSpy = jest.spyOn(chart, "resetNavigation") + + u.over.dispatchEvent(touchEvent("touchstart", 400)) + u.over.dispatchEvent(touchEvent("touchend", 400)) + u.over.dispatchEvent(touchEvent("touchstart", 400)) + u.over.dispatchEvent(touchEvent("touchend", 400)) + + expect(resetSpy).toHaveBeenCalled() + + resetSpy.mockRestore() + teardown() + }) + + it("does not handle touch when navigation is disabled", async () => { + const { chart, u, teardown } = await mount({ enabledNavigation: false }) + + u.over.dispatchEvent(touchEvent("touchstart", 400)) + u.over.dispatchEvent(touchEvent("touchend", 400)) + + expect(chart.getAttribute("clickX")).toBeFalsy() + + teardown() + }) +}) + +describe("uplotChart stacked gap handling", () => { + const withStackedPayload = (chart, data) => { + chart.getPayload = () => ({ data, labels: ["time", "a", "b"] }) + chart.getPayloadDimensionIds = () => ["a", "b"] + chart.getVisibleDimensionIds = () => ["a", "b"] + chart.isDimensionVisible = () => true + chart.selectDimensionColor = () => "#3366CC" + chart.getThemeAttribute = () => "#E4E8E8" + chart.getConvertedValueWithUnit = value => `${value}` + } + + const mountStacked = async data => { + const { sdk, chart } = makeTestChart({ + attributes: { + loaded: true, + chartType: "stacked", + chartLibrary: "uplot", + after: 1617946860, + before: 1617946870, + }, + }) + withStackedPayload(chart, data) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + await Promise.resolve() + await Promise.resolve() + + return { instance, teardown: () => (instance.unmount(), document.body.removeChild(element)) } + } + + it("draws a stacked series containing null gaps without throwing", async () => { + const { instance, teardown } = await mountStacked([ + [1617946860000, 10, 20], + [1617946865000, 10, null], + [1617946870000, 10, 20], + ]) + const u = instance.getUPlot() + + expect(() => u.hooks.draw.forEach(hook => hook(u))).not.toThrow() + + teardown() + }) +}) + +describe("uplotChart stepped stacked (dygraph parity)", () => { + const withSteppedPayload = chart => { + chart.getPayload = () => ({ + data: [ + [1617946860000, 10], + [1617946865000, 30], + [1617946870000, 20], + ], + labels: ["time", "a"], + }) + chart.getPayloadDimensionIds = () => ["a"] + chart.getVisibleDimensionIds = () => ["a"] + chart.isDimensionVisible = () => true + chart.selectDimensionColor = () => "#3366CC" + chart.getThemeAttribute = () => "#E4E8E8" + chart.getConvertedValueWithUnit = value => `${value}` + } + + const mountStepped = async stepPlot => { + const { sdk, chart } = makeTestChart({ + attributes: { + loaded: true, + chartType: "stacked", + chartLibrary: "uplot", + after: 1617946860, + before: 1617946870, + stepPlot, + }, + }) + withSteppedPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + await Promise.resolve() + await Promise.resolve() + + return { instance, teardown: () => (instance.unmount(), document.body.removeChild(element)) } + } + + it("holds each value horizontally then steps vertically on the top edge when stepPlot is true", async () => { + const { instance, teardown } = await mountStepped(true) + const u = instance.getUPlot() + + const moveTo = jest.spyOn(u.ctx, "moveTo") + const lineTo = jest.spyOn(u.ctx, "lineTo") + moveTo.mockClear() + lineTo.mockClear() + + u.hooks.draw.forEach(hook => hook(u)) + + expect(moveTo).toHaveBeenCalled() + const [mx, my] = moveTo.mock.calls[0] + const [l0x, l0y] = lineTo.mock.calls[0] + const [l1x, l1y] = lineTo.mock.calls[1] + + expect(l0y).toBeCloseTo(my, 5) + expect(l0x).not.toBeCloseTo(mx, 5) + + expect(l1x).toBeCloseTo(l0x, 5) + expect(l1y).not.toBeCloseTo(l0y, 5) + + moveTo.mockRestore() + lineTo.mockRestore() + teardown() + }) + + it("draws straight top edges (no horizontal hold) for a stacked chart by default", async () => { + const { instance, teardown } = await mountStepped(false) + const u = instance.getUPlot() + + const moveTo = jest.spyOn(u.ctx, "moveTo") + const lineTo = jest.spyOn(u.ctx, "lineTo") + moveTo.mockClear() + lineTo.mockClear() + + u.hooks.draw.forEach(hook => hook(u)) + + expect(moveTo).toHaveBeenCalled() + const [mx, my] = moveTo.mock.calls[0] + const [l0x, l0y] = lineTo.mock.calls[0] + + expect(l0x).not.toBeCloseTo(mx, 5) + expect(l0y).not.toBeCloseTo(my, 5) + + moveTo.mockRestore() + lineTo.mockRestore() + teardown() + }) +}) + +describe("uplotChart wheel gating + drag threshold (dygraph parity)", () => { + const withPayload = chart => { + chart.getPayload = () => ({ + data: [ + [1617946860000, 10, 20, 30], + [1617946865000, 12, 18, 28], + [1617946870000, 11, 22, 31], + ], + labels: ["time", "load1", "load5", "load15"], + }) + chart.getPayloadDimensionIds = () => ["load1", "load5", "load15"] + chart.getVisibleDimensionIds = () => ["load1", "load5", "load15"] + chart.isDimensionVisible = () => true + chart.selectDimensionColor = () => "#3366CC" + chart.getThemeAttribute = () => "#E4E8E8" + chart.getConvertedValueWithUnit = value => `${value}` + } + + const mount = async (attributes = {}) => { + const { sdk, chart } = makeTestChart({ + attributes: { + loaded: true, + chartType: "line", + chartLibrary: "uplot", + after: 1617946860, + before: 1617947760, + ...attributes, + }, + }) + withPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + await Promise.resolve() + await Promise.resolve() + + const u = instance.getUPlot() + u.over.getBoundingClientRect = () => ({ + left: 0, + top: 0, + width: 800, + height: 300, + right: 800, + bottom: 300, + }) + + return { + sdk, + chart, + instance, + u, + teardown: () => (instance.unmount(), document.body.removeChild(element)), + } + } + + const wheel = (u, extra) => { + const event = new Event("wheel", { bubbles: true, cancelable: true }) + event.deltaY = -100 + Object.assign(event, extra) + u.over.dispatchEvent(event) + } + + it("ignores a plain wheel (dygraph gates zoom behind Shift/Alt)", async () => { + const { u, teardown } = await mount() + u.setCursor({ left: 400, top: 100 }, true) + + const spy = jest.spyOn(u, "setScale") + wheel(u, {}) + expect(spy).not.toHaveBeenCalled() + + spy.mockRestore() + teardown() + }) + + it("zooms on Shift+wheel", async () => { + const { u, teardown } = await mount() + u.setCursor({ left: 400, top: 100 }, true) + + const spy = jest.spyOn(u, "setScale") + wheel(u, { shiftKey: true }) + expect(spy).toHaveBeenCalledWith("x", expect.objectContaining({ min: expect.any(Number) })) + + spy.mockRestore() + teardown() + }) + + it("zooms on Alt+wheel", async () => { + const { u, teardown } = await mount() + u.setCursor({ left: 400, top: 100 }, true) + + const spy = jest.spyOn(u, "setScale") + wheel(u, { altKey: true }) + expect(spy).toHaveBeenCalled() + + spy.mockRestore() + teardown() + }) + + it("ends a sub-5px drag-select with a null range (dygraph parity, no zoom)", async () => { + const { sdk, u, teardown } = await mount({ navigation: "select" }) + + const ends = [] + sdk.on("highlightEnd", (c, range) => ends.push(range)) + + u.setSelect({ left: 100, top: 0, width: 3, height: 0 }, true) + expect(ends).toHaveLength(1) + expect(ends[0]).toBeNull() + + teardown() + }) + + it("emits highlightEnd for a >=5px drag-select", async () => { + const { sdk, u, teardown } = await mount({ navigation: "select" }) + + const ends = [] + sdk.on("highlightEnd", (c, range) => ends.push(range)) + + u.setSelect({ left: 100, top: 0, width: 50, height: 0 }, true) + expect(ends).toHaveLength(1) + + teardown() + }) + + it("zooms IN to a narrower window on Shift+wheel with deltaY<0", async () => { + const { u, teardown } = await mount() + u.setCursor({ left: 400, top: 100 }, true) + + const range0 = u.scales.x.max - u.scales.x.min + const spy = jest.spyOn(u, "setScale") + + wheel(u, { shiftKey: true, deltaY: -100 }) + + expect(spy).toHaveBeenCalledWith("x", expect.any(Object)) + const { min, max } = spy.mock.calls[0][1] + expect(max - min).toBeLessThan(range0) + + spy.mockRestore() + teardown() + }) + + it("zooms OUT to a wider window on wheel with deltaY>0", async () => { + const { u, teardown } = await mount() + u.setCursor({ left: 400, top: 100 }, true) + + const range0 = u.scales.x.max - u.scales.x.min + const spy = jest.spyOn(u, "setScale") + + wheel(u, { shiftKey: true, deltaY: 100 }) + + const { min, max } = spy.mock.calls[0][1] + expect(max - min).toBeGreaterThan(range0) + + spy.mockRestore() + teardown() + }) + + it("biases the zoom toward the cursor position", async () => { + const { u, teardown } = await mount() + u.setCursor({ left: 40, top: 100 }, true) + + const min0 = u.scales.x.min + const max0 = u.scales.x.max + const spy = jest.spyOn(u, "setScale") + + wheel(u, { shiftKey: true, deltaY: -100 }) + + const { min, max } = spy.mock.calls[0][1] + expect(min - min0).toBeLessThan(max0 - max) + + spy.mockRestore() + teardown() + }) + + it("is a no-op at the limitRange bound (unchanged early-return)", async () => { + const { u, teardown } = await mount({ after: 1617946860, before: 1617946920 }) + u.setCursor({ left: 400, top: 100 }, true) + + expect(u.scales.x.min).toBe(1617946860) + expect(u.scales.x.max).toBe(1617946920) + + const spy = jest.spyOn(u, "setScale") + wheel(u, { shiftKey: true, deltaY: -100 }) + expect(spy).not.toHaveBeenCalled() + + spy.mockRestore() + teardown() + }) + + it("is a no-op for a deltaY===0 wheel", async () => { + const { u, teardown } = await mount() + u.setCursor({ left: 400, top: 100 }, true) + + const spy = jest.spyOn(u, "setScale") + wheel(u, { shiftKey: true, deltaY: 0 }) + expect(spy).not.toHaveBeenCalled() + + spy.mockRestore() + teardown() + }) + + it("cancels the pending wheel moveX when a mouse pan gesture starts", async () => { + const { chart, u, teardown } = await mount({ navigation: "pan" }) + u.setCursor({ left: 400, top: 100 }, true) + + const moveXSpy = jest.spyOn(chart, "moveX") + jest.useFakeTimers() + + wheel(u, { shiftKey: true, deltaY: -100 }) + + u.over.dispatchEvent(new MouseEvent("mousedown", { button: 0, clientX: 400, clientY: 100 })) + + jest.advanceTimersByTime(500) + jest.useRealTimers() + + expect(moveXSpy).not.toHaveBeenCalled() + + document.dispatchEvent(new MouseEvent("mouseup", { clientX: 400, clientY: 100 })) + moveXSpy.mockRestore() + teardown() + }) + + it("cancels the pending wheel moveX when a touch gesture starts", async () => { + const { chart, u, teardown } = await mount() + u.setCursor({ left: 400, top: 100 }, true) + + const moveXSpy = jest.spyOn(chart, "moveX") + jest.useFakeTimers() + + wheel(u, { shiftKey: true, deltaY: -100 }) + + const touchStart = new Event("touchstart", { bubbles: true, cancelable: true }) + touchStart.touches = [{ clientX: 400, clientY: 100 }] + touchStart.changedTouches = [{ clientX: 400, clientY: 100 }] + u.over.dispatchEvent(touchStart) + + jest.advanceTimersByTime(500) + jest.useRealTimers() + + expect(moveXSpy).not.toHaveBeenCalled() + + moveXSpy.mockRestore() + teardown() + }) +}) + +describe("uplotChart mouse pan navigation", () => { + const after = 1617946860 + const before = 1617947760 + + const withPanPayload = chart => { + chart.getPayload = () => ({ + data: [ + [after * 1000, 10, 20, 30], + [(after + 450) * 1000, 12, 18, 28], + [before * 1000, 11, 22, 31], + ], + labels: ["time", "load1", "load5", "load15"], + }) + chart.getPayloadDimensionIds = () => ["load1", "load5", "load15"] + chart.getVisibleDimensionIds = () => ["load1", "load5", "load15"] + chart.isDimensionVisible = () => true + chart.selectDimensionColor = () => "#3366CC" + chart.getThemeAttribute = () => "#E4E8E8" + chart.getConvertedValueWithUnit = value => `${value}` + } + + const mount = async () => { + const { sdk, chart } = makeTestChart({ + attributes: { + loaded: true, + chartType: "line", + chartLibrary: "uplot", + navigation: "pan", + after, + before, + }, + }) + withPanPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + await Promise.resolve() + await Promise.resolve() + + return { + sdk, + chart, + instance, + u: instance.getUPlot(), + teardown: () => (instance.unmount(), document.body.removeChild(element)), + } + } + + it("shifts the live x-scale while dragging instead of snapping back to the window", async () => { + const { u, teardown } = await mount() + + const min0 = u.scales.x.min + + u.over.dispatchEvent(new MouseEvent("mousedown", { button: 0, clientX: 400, clientY: 100 })) + document.dispatchEvent(new MouseEvent("mousemove", { clientX: 200, clientY: 100 })) + await Promise.resolve() + + expect(u.scales.x.min).not.toBeCloseTo(min0, 5) + + document.dispatchEvent(new MouseEvent("mouseup", { clientX: 200, clientY: 100 })) + teardown() + }) + + it("reports a moved window on panEnd so moveX is not a no-op", async () => { + const { sdk, u, teardown } = await mount() + + const min0 = u.scales.x.min + + let panEndRange + sdk.on("panEnd", (c, range) => (panEndRange = range)) + + u.over.dispatchEvent(new MouseEvent("mousedown", { button: 0, clientX: 400, clientY: 100 })) + document.dispatchEvent(new MouseEvent("mousemove", { clientX: 200, clientY: 100 })) + document.dispatchEvent(new MouseEvent("mouseup", { clientX: 200, clientY: 100 })) + + expect(panEndRange).toBeDefined() + expect(panEndRange[0]).not.toBeCloseTo(min0 * 1000, 0) + expect(panEndRange[0]).toBeGreaterThan(min0 * 1000) + + teardown() + }) + + it("hands the committed window back to getDateWindow after the drag ends", async () => { + const { chart, u, teardown } = await mount() + + u.over.dispatchEvent(new MouseEvent("mousedown", { button: 0, clientX: 400, clientY: 100 })) + document.dispatchEvent(new MouseEvent("mousemove", { clientX: 200, clientY: 100 })) + document.dispatchEvent(new MouseEvent("mouseup", { clientX: 200, clientY: 100 })) + + const [winAfter] = chart.getDateWindow() + expect(winAfter).toBeGreaterThan(after * 1000) + + teardown() + }) +}) + +describe("uplotChart modifier-key navigation switching (dygraph parity)", () => { + const withPayload = chart => { + chart.getPayload = () => ({ + data: [ + [1617946860000, 10, 20, 30], + [1617946865000, 12, 18, 28], + [1617946870000, 11, 22, 31], + ], + labels: ["time", "load1", "load5", "load15"], + }) + chart.getPayloadDimensionIds = () => ["load1", "load5", "load15"] + chart.getVisibleDimensionIds = () => ["load1", "load5", "load15"] + chart.isDimensionVisible = () => true + chart.selectDimensionColor = () => "#3366CC" + chart.getThemeAttribute = () => "#E4E8E8" + chart.getConvertedValueWithUnit = value => `${value}` + } + + const mount = async (attributes = {}) => { + const { sdk, chart } = makeTestChart({ + attributes: { + loaded: true, + chartType: "line", + chartLibrary: "uplot", + navigation: "pan", + after: 1617946860, + before: 1617947760, + ...attributes, + }, + }) + withPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + await Promise.resolve() + await Promise.resolve() + + const u = instance.getUPlot() + u.over.getBoundingClientRect = () => ({ + left: 0, + top: 0, + width: 800, + height: 300, + right: 800, + bottom: 300, + }) + + return { + sdk, + chart, + instance, + u, + teardown: () => (instance.unmount(), document.body.removeChild(element)), + } + } + + const enter = u => u.over.dispatchEvent(new MouseEvent("mouseenter")) + const down = (u, mods = {}) => + u.over.dispatchEvent( + new MouseEvent("mousedown", { button: 0, clientX: 100, clientY: 100, ...mods }) + ) + const up = (clientX = 100) => + document.dispatchEvent(new MouseEvent("mouseup", { button: 0, clientX, clientY: 100 })) + const flushTimers = () => new Promise(resolve => setTimeout(resolve)) + + it("switches to select on Shift+mousedown while the pointer is over the chart", async () => { + const { chart, u, teardown } = await mount() + enter(u) + + down(u, { shiftKey: true }) + expect(chart.getAttribute("navigation")).toBe("select") + expect(chart.getAttribute("prevNavigation")).toBe("pan") + + up() + await flushTimers() + expect(chart.getAttribute("navigation")).toBe("pan") + expect(chart.getAttribute("prevNavigation")).toBeNull() + + teardown() + }) + + it("switches to highlight on Alt+mousedown, restoring on mouseup", async () => { + const { chart, u, teardown } = await mount() + enter(u) + + down(u, { altKey: true }) + expect(chart.getAttribute("navigation")).toBe("highlight") + expect(chart.getAttribute("prevNavigation")).toBe("pan") + + up() + await flushTimers() + expect(chart.getAttribute("navigation")).toBe("pan") + + teardown() + }) + + it("switches to selectVertical on Shift+Alt+mousedown, restoring on mouseup", async () => { + const { chart, u, teardown } = await mount() + enter(u) + + down(u, { shiftKey: true, altKey: true }) + expect(chart.getAttribute("navigation")).toBe("selectVertical") + expect(chart.getAttribute("prevNavigation")).toBe("pan") + + up() + await flushTimers() + expect(chart.getAttribute("navigation")).toBe("pan") + + teardown() + }) + + it("captures the real base navigation in prevNavigation across a second switch", async () => { + const { chart, u, teardown } = await mount() + enter(u) + + down(u, { shiftKey: true }) + expect(chart.getAttribute("navigation")).toBe("select") + expect(chart.getAttribute("prevNavigation")).toBe("pan") + + down(u, { shiftKey: true, altKey: true }) + expect(chart.getAttribute("navigation")).toBe("selectVertical") + expect(chart.getAttribute("prevNavigation")).toBe("pan") + + up() + await flushTimers() + expect(chart.getAttribute("navigation")).toBe("pan") + + teardown() + }) + + it("does not switch on a plain mousedown with no modifier", async () => { + const { chart, u, teardown } = await mount() + enter(u) + + down(u) + expect(chart.getAttribute("navigation")).toBe("pan") + expect(chart.getAttribute("prevNavigation")).toBeFalsy() + + up() + teardown() + }) + + it("switches on a modifier mousedown without a preceding mouseenter", async () => { + const { chart, u, teardown } = await mount() + + down(u, { shiftKey: true }) + expect(chart.getAttribute("navigation")).toBe("select") + expect(chart.getAttribute("prevNavigation")).toBe("pan") + + up() + await flushTimers() + expect(chart.getAttribute("navigation")).toBe("pan") + + teardown() + }) + + it("does not switch when navigation is disabled", async () => { + const { chart, u, teardown } = await mount({ enabledNavigation: false }) + enter(u) + + down(u, { shiftKey: true }) + expect(chart.getAttribute("navigation")).toBe("pan") + + up() + teardown() + }) + + it("does not change navigation on keydown (no typing hijack)", async () => { + const { chart, u, teardown } = await mount() + enter(u) + + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Shift", shiftKey: true })) + expect(chart.getAttribute("navigation")).toBe("pan") + expect(chart.getAttribute("prevNavigation")).toBeFalsy() + + document.dispatchEvent(new KeyboardEvent("keyup", { key: "Shift" })) + expect(chart.getAttribute("navigation")).toBe("pan") + + teardown() + }) + + it("defers the mouseup restore past the end-plugin chain, leaving hover enabled", async () => { + const { chart, u, teardown } = await mount() + enter(u) + + down(u, { shiftKey: true }) + expect(chart.getAttribute("navigation")).toBe("select") + expect(chart.getAttribute("enabledHover")).toBe(false) + expect(chart.getAttribute("highlighting")).toBe(true) + + document.dispatchEvent(new MouseEvent("mousemove", { clientX: 300, clientY: 100 })) + u.setSelect({ left: 100, top: 0, width: 200, height: 0 }, true) + expect(chart.getAttribute("enabledHover")).toBe(true) + expect(chart.getAttribute("highlighting")).toBe(false) + + up(300) + expect(chart.getAttribute("navigation")).toBe("select") + + await flushTimers() + expect(chart.getAttribute("navigation")).toBe("pan") + expect(chart.getAttribute("prevNavigation")).toBeNull() + expect(chart.getAttribute("enabledHover")).toBe(true) + expect(chart.getAttribute("highlighting")).toBe(false) + + teardown() + }) + + it("runs the capture-phase switch before uPlot's mousedown so the gesture selects", async () => { + const { sdk, chart, u, teardown } = await mount() + enter(u) + + const starts = [] + sdk.on("highlightStart", () => starts.push(true)) + + down(u, { shiftKey: true }) + + expect(chart.getAttribute("navigation")).toBe("select") + expect(u.cursor.drag.x).toBe(true) + expect(starts).toHaveLength(1) + + up() + await flushTimers() + teardown() + }) + + it("keeps the same uPlot instance while mutating cursor.drag in place on switch", async () => { + const { chart, instance, u, teardown } = await mount() + enter(u) + + expect(u.cursor.drag.x).toBe(false) + expect(u.cursor.drag.y).toBe(false) + + down(u, { shiftKey: true }) + expect(chart.getAttribute("navigation")).toBe("select") + expect(instance.getUPlot()).toBe(u) + expect(u.cursor.drag.x).toBe(true) + expect(u.cursor.drag.y).toBe(false) + + up() + await flushTimers() + + down(u, { shiftKey: true, altKey: true }) + expect(chart.getAttribute("navigation")).toBe("selectVertical") + expect(instance.getUPlot()).toBe(u) + expect(u.cursor.drag.x).toBe(false) + expect(u.cursor.drag.y).toBe(true) + + up() + await flushTimers() + teardown() + }) +}) + +describe("uplotChart select gesture start/end (dygraph parity)", () => { + const withPayload = chart => { + chart.getPayload = () => ({ + data: [ + [1617946860000, 10, 20, 30], + [1617946865000, 12, 18, 28], + [1617946870000, 11, 22, 31], + ], + labels: ["time", "load1", "load5", "load15"], + }) + chart.getPayloadDimensionIds = () => ["load1", "load5", "load15"] + chart.getVisibleDimensionIds = () => ["load1", "load5", "load15"] + chart.isDimensionVisible = () => true + chart.selectDimensionColor = () => "#3366CC" + chart.getThemeAttribute = () => "#E4E8E8" + chart.getConvertedValueWithUnit = value => `${value}` + } + + const mount = async (attributes = {}) => { + const { sdk, chart } = makeTestChart({ + attributes: { + loaded: true, + chartType: "line", + chartLibrary: "uplot", + after: 1617946860, + before: 1617947760, + ...attributes, + }, + }) + withPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + await Promise.resolve() + await Promise.resolve() + + const u = instance.getUPlot() + u.over.getBoundingClientRect = () => ({ + left: 0, + top: 0, + width: 800, + height: 300, + right: 800, + bottom: 300, + }) + + return { + sdk, + chart, + instance, + u, + teardown: () => (instance.unmount(), document.body.removeChild(element)), + } + } + + const down = u => + u.over.dispatchEvent(new MouseEvent("mousedown", { button: 0, clientX: 100, clientY: 100 })) + const up = clientX => + document.dispatchEvent(new MouseEvent("mouseup", { button: 0, clientX, clientY: 100 })) + + it("emits highlightStart on mousedown in select mode, before any mouseup", async () => { + const { sdk, u, teardown } = await mount({ navigation: "select" }) + + const starts = [] + sdk.on("highlightStart", () => starts.push(true)) + + down(u) + expect(starts).toHaveLength(1) + + up(100) + teardown() + }) + + it("emits highlightVerticalStart on mousedown in selectVertical mode", async () => { + const { sdk, u, teardown } = await mount({ navigation: "selectVertical" }) + + const starts = [] + sdk.on("highlightVerticalStart", () => starts.push(true)) + + down(u) + expect(starts).toHaveLength(1) + + up(100) + teardown() + }) + + it("does not emit highlightStart on mousedown in pan mode", async () => { + const { sdk, u, teardown } = await mount({ navigation: "pan" }) + + const starts = [] + sdk.on("highlightStart", () => starts.push(true)) + + down(u) + expect(starts).toHaveLength(0) + + up(100) + teardown() + }) + + it("emits highlightEnd with a numeric range for a >=5px setSelect", async () => { + const { sdk, u, teardown } = await mount({ navigation: "select" }) + + const ends = [] + sdk.on("highlightEnd", (c, range) => ends.push(range)) + + u.setSelect({ left: 100, top: 0, width: 200, height: 0 }, true) + expect(ends).toHaveLength(1) + expect(ends[0]).toHaveLength(2) + expect(typeof ends[0][0]).toBe("number") + + teardown() + }) + + it("ends a pure click (mousedown+mouseup, no setSelect) with highlightEnd(null) exactly once", async () => { + const { sdk, u, teardown } = await mount({ navigation: "select" }) + + const ends = [] + sdk.on("highlightEnd", (c, range) => ends.push(range)) + + down(u) + up(100) + + expect(ends).toHaveLength(1) + expect(ends[0]).toBeNull() + + teardown() + }) + + it("fires highlightEnd exactly once when a real setSelect precedes the mouseup", async () => { + const { sdk, u, teardown } = await mount({ navigation: "select" }) + + const ends = [] + sdk.on("highlightEnd", (c, range) => ends.push(range)) + + down(u) + u.setSelect({ left: 100, top: 0, width: 200, height: 0 }, true) + up(300) + + expect(ends).toHaveLength(1) + expect(ends[0]).toHaveLength(2) + + teardown() + }) +}) + +describe("uplotChart yAxisChange (unit rescaling parity)", () => { + const setup = (attributes = {}) => { + const { sdk, chart } = makeTestChart({ + attributes: { loaded: true, chartType: "line", min: 5, max: 40, ...attributes }, + }) + withLoadedPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + + return { + sdk, + chart, + instance, + u: instance.getUPlot(), + teardown: () => (instance.unmount(), document.body.removeChild(element)), + } + } + + it("fires yAxisChange with the getValueRange data range, not the rendered scale", () => { + const { chart, u, teardown } = setup() + + const ranges = [] + chart.on("yAxisChange", (min, max) => ranges.push([min, max])) + + u.scales.y.min = 999 + u.scales.y.max = 1234 + u.hooks.draw.forEach(hook => hook(u)) + + expect(ranges).toEqual([[5, 40]]) + expect(chart.getAttribute("getValueRange")(chart)).toEqual([5, 40]) + + teardown() + }) + + it("does not re-fire for an unchanged range but fires again once it changes", () => { + const { chart, u, teardown } = setup() + + const ranges = [] + chart.on("yAxisChange", (min, max) => ranges.push([min, max])) + + u.hooks.draw.forEach(hook => hook(u)) + u.hooks.draw.forEach(hook => hook(u)) + expect(ranges).toEqual([[5, 40]]) + + chart.updateAttribute("max", 50) + u.hooks.draw.forEach(hook => hook(u)) + expect(ranges).toEqual([ + [5, 40], + [5, 50], + ]) + + teardown() + }) + + it("keeps the fired range independent of yRangePad and fires only once", () => { + const { chart, u, teardown } = setup() + + const ranges = [] + chart.on("yAxisChange", (min, max) => ranges.push([min, max])) + + const [renderedMin, renderedMax] = u.scales.y.range(u, 5, 40) + expect(renderedMin).toBeLessThan(5) + expect(renderedMax).toBeGreaterThan(40) + + u.hooks.draw.forEach(hook => hook(u)) + u.hooks.draw.forEach(hook => hook(u)) + + expect(ranges).toEqual([[5, 40]]) + + teardown() + }) + + it("drives the real unitConversion consumer without looping or overflowing the stack", async () => { + const { chart, u, teardown } = setup() + + let fires = 0 + chart.on("yAxisChange", () => fires++) + + expect(() => { + u.hooks.draw.forEach(hook => hook(u)) + }).not.toThrow() + + expect(chart.getAttribute("min")).toBe(5) + expect(chart.getAttribute("max")).toBe(40) + expect(fires).toBe(1) + + await Promise.resolve() + await Promise.resolve() + + u.hooks.draw.forEach(hook => hook(u)) + expect(fires).toBe(1) + + teardown() + }) + + it("falls back to min/max when the dygraph internal gate fails, not the raw valueRange", () => { + const { chart, u, teardown } = setup({ valueRange: [2, 50], groupBy: ["label"] }) + + const ranges = [] + chart.on("yAxisChange", (min, max) => ranges.push([min, max])) + + const raw = chart.getAttribute("getValueRange")(chart) + expect(raw).toEqual([2, 50]) + expect(chart.getAttribute("getValueRange")(chart, { dygraph: true })).toEqual([null, null]) + + u.hooks.draw.forEach(hook => hook(u)) + u.hooks.draw.forEach(hook => hook(u)) + + expect(ranges).toEqual([[5, 40]]) + expect(ranges[0]).not.toEqual(raw) + + teardown() + }) + + it("fires the clamped dygraph valueRange when the internal gate passes", () => { + const { chart, u, teardown } = setup({ valueRange: [2, 50] }) + + const ranges = [] + chart.on("yAxisChange", (min, max) => ranges.push([min, max])) + + u.hooks.draw.forEach(hook => hook(u)) + u.hooks.draw.forEach(hook => hook(u)) + + expect(ranges).toEqual([[2, 50]]) + + teardown() + }) +}) + +describe("uplotChart axis visibility (enabledXAxis / enabledYAxis parity)", () => { + const mountWith = attributes => { + const { sdk, chart } = makeTestChart({ + attributes: { loaded: true, chartType: "line", ...attributes }, + }) + withLoadedPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + + return { + chart, + instance, + teardown: () => (instance.unmount(), document.body.removeChild(element)), + } + } + + it("shows both axes with ticks by default", () => { + const { instance, teardown } = mountWith({}) + const u = instance.getUPlot() + + expect(u.axes[0].show).toBe(true) + expect(u.axes[1].show).toBe(true) + expect(u.axes[0].ticks.show).not.toBe(false) + expect(u.axes[1].ticks.show).not.toBe(false) + + teardown() + }) + + it("keeps y-axis gridlines but hides its ticks when enabledYAxis is false", () => { + const { instance, teardown } = mountWith({ enabledYAxis: false }) + const u = instance.getUPlot() + + expect(u.axes[1].show).toBe(true) + expect(u.axes[1].grid.show).toBe(true) + expect(u.axes[1].ticks.show).toBe(false) + expect(u.axes[0].ticks.show).not.toBe(false) + + teardown() + }) + + it("keeps x-axis gridlines but hides its ticks when enabledXAxis is false", () => { + const { instance, teardown } = mountWith({ enabledXAxis: false }) + const u = instance.getUPlot() + + expect(u.axes[0].show).toBe(true) + expect(u.axes[0].grid.show).toBe(true) + expect(u.axes[0].ticks.show).toBe(false) + expect(u.axes[1].ticks.show).not.toBe(false) + + teardown() + }) + + it("re-renders and restores y-axis ticks when the attribute toggles", () => { + const { chart, instance, teardown } = mountWith({ enabledYAxis: false }) + expect(instance.getUPlot().axes[1].ticks.show).toBe(false) + expect(instance.getUPlot().axes[1].grid.show).toBe(true) + + chart.updateAttribute("enabledYAxis", true) + + expect(instance.getUPlot().axes[1].ticks.show).not.toBe(false) + + teardown() + }) +}) + +describe("uplotChart yRangePad (dygraph parity)", () => { + const mountLine = attributes => { + const { sdk, chart } = makeTestChart({ + attributes: { loaded: true, chartType: "line", min: 10, max: 31, ...attributes }, + }) + withLoadedPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + + return { + instance, + u: instance.getUPlot(), + teardown: () => (instance.unmount(), document.body.removeChild(element)), + } + } + + it("pads a line chart y-range beyond the raw data extent at both ends", () => { + const { u, teardown } = mountLine() + + const [min, max] = u.scales.y.range(u, 10, 31) + expect(min).toBeLessThan(10) + expect(max).toBeGreaterThan(31) + + teardown() + }) + + it("leaves the multiBar zero-based y-range unpadded", () => { + const { sdk, chart } = makeTestChart({ attributes: { loaded: true, chartType: "multiBar" } }) + chart.getPayload = () => ({ + data: [ + [1617946860000, 10, 20, 30], + [1617946865000, 12, 18, 28], + [1617946870000, 11, 22, 31], + ], + labels: ["time", "reads", "writes", "other"], + }) + chart.getPayloadDimensionIds = () => ["reads", "writes", "other"] + chart.getVisibleDimensionIds = () => ["reads", "writes", "other"] + chart.isDimensionVisible = () => true + chart.selectDimensionColor = () => "#3366CC" + chart.getThemeAttribute = () => "#E4E8E8" + chart.getConvertedValueWithUnit = value => `${value}` + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + + const u = instance.getUPlot() + const [min] = u.scales.y.range(u, 10, 31) + expect(min).toBe(0) + + instance.unmount() + document.body.removeChild(element) + }) + + it("pads a stacked chart above the stack but not below its zero baseline", () => { + const { sdk, chart } = makeTestChart({ attributes: { loaded: true, chartType: "stacked" } }) + withLoadedPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + + const u = instance.getUPlot() + const bounds = getStackBounds( + chart.getPayload().data, + chart.getPayloadDimensionIds(), + () => true + ) + const [rawMin, rawMax] = getStackValueRange(bounds) + + const [min, max] = u.scales.y.range(u, 0, 0) + expect(rawMin).toBe(0) + expect(min).toBe(0) + expect(max).toBeGreaterThan(rawMax) + + instance.unmount() + document.body.removeChild(element) + }) +}) + +describe("uplotChart line includeZero (dygraph parity)", () => { + const mountLine = attributes => { + const { sdk, chart } = makeTestChart({ + attributes: { loaded: true, chartType: "line", min: 10, max: 31, ...attributes }, + }) + withLoadedPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + + return { + u: instance.getUPlot(), + teardown: () => (instance.unmount(), document.body.removeChild(element)), + } + } + + it("clamps the y-range to include zero when includeZero is true", () => { + const { u, teardown } = mountLine({ includeZero: true }) + + const [min] = u.scales.y.range(u, 10, 31) + expect(min).toBeLessThanOrEqual(0) + + teardown() + }) + + it("keeps a positive minimum when includeZero is unset (default)", () => { + const { u, teardown } = mountLine() + + const [min] = u.scales.y.range(u, 10, 31) + expect(min).toBeGreaterThan(0) + + teardown() + }) +}) + +describe("uplotChart area zero baseline (dygraph parity)", () => { + const mountArea = selectedLegendDimensions => { + const { sdk, chart } = makeTestChart({ + attributes: { + loaded: true, + chartType: "area", + min: 10, + max: 31, + selectedLegendDimensions, + }, + }) + withLoadedPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + + return { + instance, + u: instance.getUPlot(), + teardown: () => (instance.unmount(), document.body.removeChild(element)), + } + } + + it("includes zero for multi-dimension, multi-selected area", () => { + const { u, teardown } = mountArea(["load1", "load5"]) + + const [min, max] = u.scales.y.range(u, 10, 31) + expect(min).toBeLessThanOrEqual(0) + expect(max).toBeGreaterThanOrEqual(31) + + teardown() + }) + + it("does not include zero when only one dimension is selected", () => { + const { u, teardown } = mountArea(["load1"]) + + const [min] = u.scales.y.range(u, 10, 31) + expect(min).toBeGreaterThan(0) + + teardown() + }) +}) + +describe("uplotChart sparkline series styling (dygraph parity)", () => { + it("renders each sparkline series as a solid fill with a zero-width stroke and no points", () => { + const { sdk, chart } = makeTestChart({ + attributes: { loaded: true, chartType: "line", sparkline: true }, + }) + withLoadedPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "200px" + element.style.height = "40px" + document.body.appendChild(element) + instance.mount(element) + + const u = instance.getUPlot() + for (let i = 1; i < u.series.length; i++) { + expect(u.series[i].width).toBe(0) + expect(u.series[i].fill(u, i)).toBe("#3366CC") + expect(u.series[i].points.show(u, i)).toBe(false) + } + + instance.unmount() + document.body.removeChild(element) + }) + + it("keeps non-sparkline line series stroked and unfilled", () => { + const { sdk, chart } = makeTestChart({ attributes: { loaded: true, chartType: "line" } }) + withLoadedPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + + const u = instance.getUPlot() + expect(u.series[1].width).toBe(1.5) + expect(u.series[1].fill(u, 1)).toBeNull() + + instance.unmount() + document.body.removeChild(element) + }) +}) + +describe("uplotChart framed-empty overlays + y-range (dygraph parity)", () => { + const nextFrame = () => new Promise(resolve => requestAnimationFrame(resolve)) + + const withOutOfLimitsPayload = chart => { + chart.getPayload = () => ({ + data: [ + [1617946860000, 10, 20, 30], + [1617946865000, 12, 18, 28], + [1617946870000, 11, 22, 31], + ], + labels: ["time", "load1", "load5", "load15"], + }) + chart.getPayloadDimensionIds = () => ["load1", "load5", "load15"] + chart.getVisibleDimensionIds = () => ["load1", "load5", "load15"] + chart.isDimensionVisible = () => true + chart.selectDimensionColor = () => "#3366CC" + chart.getThemeAttribute = () => "#E4E8E8" + chart.getConvertedValueWithUnit = value => `${value}` + } + + const mountEmpty = attributes => { + const { sdk, chart } = makeTestChart({ + attributes: { + loaded: true, + chartType: "line", + chartLibrary: "uplot", + after: 1617946860, + before: 1617947760, + outOfLimits: true, + ...attributes, + }, + }) + withOutOfLimitsPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + + return { + sdk, + chart, + instance, + teardown: () => (instance.unmount(), document.body.removeChild(element)), + } + } + + it("emits overlayedAreaChanged:proceeded when the framed empty chart re-renders", async () => { + const { instance, teardown } = mountEmpty({ + firstEntry: 1617946865, + error: false, + overlays: { proceeded: { type: "proceeded" } }, + }) + + let called = false + instance.on("overlayedAreaChanged:proceeded", () => (called = true)) + + expect(() => instance.render()).not.toThrow() + await nextFrame() + + expect(called).toBe(true) + + teardown() + }) + + it("emits a positioned proceeded area through the empty-frame draw hook when the first entry is in view", async () => { + const { sdk, chart } = makeTestChart({ + attributes: { + loaded: true, + chartType: "line", + chartLibrary: "uplot", + after: 1617946860, + before: 1617947760, + outOfLimits: false, + error: false, + firstEntry: 1617946865, + overlays: { proceeded: { type: "proceeded" } }, + }, + }) + chart.getPayload = () => ({ data: [], labels: ["time"] }) + chart.getPayloadDimensionIds = () => [] + chart.getVisibleDimensionIds = () => [] + chart.isDimensionVisible = () => true + chart.selectDimensionColor = () => "#3366CC" + chart.getThemeAttribute = () => "#E4E8E8" + chart.getConvertedValueWithUnit = value => `${value}` + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + + expect(instance.getUPlot().series).toHaveLength(1) + + let area + instance.on("overlayedAreaChanged:proceeded", next => (area = next)) + + instance.getUPlot().redraw() + await nextFrame() + + expect(area).toEqual({ + from: expect.any(Number), + to: expect.any(Number), + width: expect.any(Number), + }) + expect(Number.isFinite(area.from)).toBe(true) + + instance.unmount() + document.body.removeChild(element) + }) + + it("draws the framed empty overlay set with the proceeded default without throwing", () => { + const { instance, teardown } = mountEmpty({ + firstEntry: 1617946865, + error: false, + overlays: { proceeded: { type: "proceeded" } }, + }) + + const u = instance.getUPlot() + expect(u.series).toHaveLength(1) + expect(u.hooks.draw).toHaveLength(1) + expect(() => u.hooks.drawClear.forEach(hook => hook(u))).not.toThrow() + expect(() => u.hooks.draw.forEach(hook => hook(u))).not.toThrow() + + teardown() + }) + + it("frames the empty y-range with the configured staticValueRange, not [0, 1]", () => { + const { instance, teardown } = mountEmpty({ staticValueRange: [5, 40] }) + + const u = instance.getUPlot() + expect(u.series).toHaveLength(1) + expect(u.scales.y.range(u, 0, 100)).toEqual([5, 40]) + + teardown() + }) + + it("frames the empty y-range with finite, non-collapsed bounds when unconfigured", () => { + const { instance, teardown } = mountEmpty({}) + + const u = instance.getUPlot() + const [min, max] = u.scales.y.range(u, 0, 100) + expect(Number.isFinite(min)).toBe(true) + expect(Number.isFinite(max)).toBe(true) + expect(min).toBeLessThan(max) + + teardown() + }) +}) + +describe("uplotChart hover popover events (dygraph mouse-forwarding parity)", () => { + const mountLine = (attributes = {}) => { + const { sdk, chart } = makeTestChart({ + attributes: { loaded: true, chartType: "line", ...attributes }, + }) + withLoadedPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + + return { + sdk, + chart, + instance, + u: instance.getUPlot(), + teardown: () => (instance.unmount(), document.body.removeChild(element)), + } + } + + it("re-emits pointer moves on the chartUI bus with chart-element-relative offsets", () => { + const { instance, u, teardown } = mountLine() + + const dpr = u.pxRatio || 1 + const plotLeft = u.bbox.left / dpr + const plotTop = u.bbox.top / dpr + expect(plotLeft).toBeGreaterThan(0) + + u.over.getBoundingClientRect = () => ({ + left: plotLeft, + top: plotTop, + width: 800, + height: 300, + right: plotLeft + 800, + bottom: plotTop + 300, + }) + + const moves = [] + instance.on("mousemove", event => moves.push(event)) + + u.over.dispatchEvent(new MouseEvent("mousemove", { clientX: 400, clientY: 150, bubbles: true })) + + expect(moves).toHaveLength(1) + const { offsetX, offsetY, layerX, layerY } = moves[0] + expect(offsetX).toBeCloseTo(400, 5) + expect(offsetY).toBeCloseTo(150, 5) + expect(layerX).toBe(offsetX) + expect(layerY).toBe(offsetY) + expect(offsetX).not.toBeCloseTo(400 - plotLeft, 5) + + teardown() + }) + + it("re-emits mouseout on the chartUI bus so the popover can close", () => { + const { instance, u, teardown } = mountLine() + + const outs = [] + instance.on("mouseout", () => outs.push(true)) + + u.over.dispatchEvent(new MouseEvent("mouseout", { clientX: 400, clientY: 150, bubbles: true })) + + expect(outs).toHaveLength(1) + + teardown() + }) + + it("opens the hover Popover on a real uPlot pointer move through the shared UI bus", () => { + const { chart, instance, u, teardown } = mountLine() + chart.getUI = () => instance + + const dpr = u.pxRatio || 1 + u.over.getBoundingClientRect = () => ({ + left: u.bbox.left / dpr, + top: u.bbox.top / dpr, + width: 800, + height: 300, + right: u.bbox.left / dpr + 800, + bottom: u.bbox.top / dpr + 300, + }) + + renderWithChart(, { chart }) + + expect(screen.queryByTestId("drop")).toBeNull() + + act(() => { + u.over.dispatchEvent( + new MouseEvent("mousemove", { clientX: 400, clientY: 150, bubbles: true }) + ) + }) + + expect(screen.queryByTestId("drop")).not.toBeNull() + + act(() => { + u.over.dispatchEvent( + new MouseEvent("mouseout", { clientX: 400, clientY: 150, bubbles: true }) + ) + }) + + expect(screen.queryByTestId("drop")).toBeNull() + + teardown() + }) + + it("positions the hover Popover at the cursor when the chart is offset in the page", () => { + const { chart, instance, u, teardown } = mountLine() + chart.getUI = () => instance + + const dpr = u.pxRatio || 1 + const chartLeft = 500 + const chartTop = 200 + u.over.getBoundingClientRect = () => ({ + left: chartLeft + u.bbox.left / dpr, + top: chartTop + u.bbox.top / dpr, + width: 800, + height: 300, + right: chartLeft + u.bbox.left / dpr + 800, + bottom: chartTop + u.bbox.top / dpr + 300, + }) + + renderWithChart(, { chart }) + + act(() => { + u.over.dispatchEvent( + new MouseEvent("mousemove", { clientX: 640, clientY: 360, bubbles: true }) + ) + }) + + const drop = screen.queryByTestId("drop") + expect(drop).not.toBeNull() + expect(drop.style.transform).toBe("translate3d(640px, 360px, 0)") + + teardown() + }) +}) + +describe("uplotChart crosshair overlay (separate canvas, no main redraw)", () => { + const withOverlayPayload = chart => { + chart.getPayload = () => ({ + data: [ + [1617946860000, 10, 20, 30], + [1617946865000, 12, 18, 28], + [1617946870000, 11, 22, 31], + ], + labels: ["time", "load1", "load5", "load15"], + }) + chart.getPayloadDimensionIds = () => ["load1", "load5", "load15"] + chart.getVisibleDimensionIds = () => ["load1", "load5", "load15"] + chart.isDimensionVisible = () => true + chart.selectDimensionColor = () => "#3366CC" + chart.getThemeAttribute = () => "#E4E8E8" + chart.getConvertedValueWithUnit = value => `${value}` + chart.getClosestRow = tsMs => chart.getPayload().data.findIndex(row => row[0] === tsMs) + } + + const mount = async (chartType, attributes = {}) => { + const { sdk, chart } = makeTestChart({ + attributes: { + loaded: true, + chartType, + after: 1617946860, + before: 1617946870, + min: 10, + max: 31, + ...attributes, + }, + }) + withOverlayPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + await Promise.resolve() + await Promise.resolve() + + return { + chart, + instance, + element, + u: instance.getUPlot(), + teardown: () => (instance.unmount(), document.body.removeChild(element)), + } + } + + it("does not redraw the stacked main canvas when hoverX changes", async () => { + const { chart, u, teardown } = await mount("stacked") + + const redrawSpy = jest.spyOn(u, "redraw") + chart.updateAttribute("hoverX", [1617946865000]) + + expect(redrawSpy).not.toHaveBeenCalled() + + redrawSpy.mockRestore() + teardown() + }) + + it("draws the line crosshair on the overlay canvas, not the main canvas", async () => { + const { chart, u, element, teardown } = await mount("line") + + const octx = element.querySelector(".netdata-crosshair-overlay").getContext("2d") + const overlayLineToSpy = jest.spyOn(octx, "lineTo") + const overlayArcSpy = jest.spyOn(octx, "arc") + const mainArcSpy = jest.spyOn(u.ctx, "arc") + + chart.updateAttribute("hoverX", [1617946865000]) + + expect(overlayLineToSpy).toHaveBeenCalled() + expect(overlayArcSpy).toHaveBeenCalledTimes(3) + expect(mainArcSpy).not.toHaveBeenCalled() + + overlayLineToSpy.mockRestore() + overlayArcSpy.mockRestore() + mainArcSpy.mockRestore() + teardown() + }) + + it("sizes the overlay backing store to match the main canvas once uPlot converges", async () => { + const { u, element, teardown } = await mount("line") + + const overlay = element.querySelector(".netdata-crosshair-overlay") + + expect(overlay.width).toBe(u.ctx.canvas.width) + expect(overlay.height).toBe(u.ctx.canvas.height) + + teardown() + }) + + it("draws the crosshair at the converged x position after a render shifts the window", async () => { + const { chart, instance, element, u, teardown } = await mount("line") + + chart.updateAttribute("clickX", [1617946865000, null]) + await Promise.resolve() + await Promise.resolve() + + chart.getPayload = () => ({ + data: [ + [1617946865000, 12, 18, 28], + [1617946870000, 11, 22, 31], + [1617946875000, 13, 19, 29], + ], + labels: ["time", "load1", "load5", "load15"], + }) + chart.updateAttributes({ after: 1617946865, before: 1617946875 }) + + const octx = element.querySelector(".netdata-crosshair-overlay").getContext("2d") + const moveToSpy = jest.spyOn(octx, "moveTo") + + instance.render() + await Promise.resolve() + await Promise.resolve() + + const expectedX = u.valToPos(1617946865, "x", true) + const drawnX = moveToSpy.mock.calls.map(call => call[0]) + + expect(drawnX).toContain(expectedX) + + moveToSpy.mockRestore() + teardown() + }) + + it("re-renders the overlay crosshair dots when staticValueRange rescales the y-axis", async () => { + const { chart, element, teardown } = await mount("line") + + chart.updateAttribute("hoverX", [1617946865000]) + + const octx = element.querySelector(".netdata-crosshair-overlay").getContext("2d") + const overlayArcSpy = jest.spyOn(octx, "arc") + + chart.updateAttribute("staticValueRange", [0, 100]) + + expect(overlayArcSpy).toHaveBeenCalled() + + overlayArcSpy.mockRestore() + teardown() + }) +}) + +describe("uplotChart hover dots (dygraph highlight-circle parity)", () => { + const mountLine = async (attributes = {}) => { + const { sdk, chart } = makeTestChart({ + attributes: { + loaded: true, + chartType: "line", + after: 1617946860, + before: 1617946870, + min: 10, + max: 31, + ...attributes, + }, + }) + withLoadedPayload(chart) + chart.getClosestRow = tsMs => chart.getPayload().data.findIndex(row => row[0] === tsMs) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + await Promise.resolve() + await Promise.resolve() + + return { + sdk, + chart, + instance, + u: instance.getUPlot(), + teardown: () => (instance.unmount(), document.body.removeChild(element)), + } + } + + it("plots one filled dot per visible dimension at the hovered row, at radius 4", async () => { + const { chart, u, teardown } = await mountLine() + + const octx = u.root.querySelector(".netdata-crosshair-overlay").getContext("2d") + const arcSpy = jest.spyOn(octx, "arc") + const fillSpy = jest.spyOn(octx, "fill") + + chart.updateAttribute("hoverX", [1617946865000]) + + expect(arcSpy).toHaveBeenCalledTimes(3) + expect(fillSpy).toHaveBeenCalled() + expect(arcSpy.mock.calls[0][2]).toBe(4) + + arcSpy.mockRestore() + fillSpy.mockRestore() + teardown() + }) + + it("draws a dot for every dimension while no legend selection is active", async () => { + const { chart, u, teardown } = await mountLine() + chart.isDimensionVisible = () => false + + const octx = u.root.querySelector(".netdata-crosshair-overlay").getContext("2d") + const arcSpy = jest.spyOn(octx, "arc") + chart.updateAttribute("hoverX", [1617946865000]) + + expect(arcSpy).toHaveBeenCalledTimes(3) + + arcSpy.mockRestore() + teardown() + }) + + it("draws no dots when hoverX is null", async () => { + const { chart, u, teardown } = await mountLine() + + const octx = u.root.querySelector(".netdata-crosshair-overlay").getContext("2d") + const arcSpy = jest.spyOn(octx, "arc") + chart.updateAttribute("hoverX", null) + + expect(arcSpy).not.toHaveBeenCalled() + + arcSpy.mockRestore() + teardown() + }) + + it("plots the dots for the clicked row too", async () => { + const { chart, u, teardown } = await mountLine() + + const octx = u.root.querySelector(".netdata-crosshair-overlay").getContext("2d") + const arcSpy = jest.spyOn(octx, "arc") + chart.updateAttribute("clickX", [1617946860000]) + + expect(arcSpy).toHaveBeenCalledTimes(3) + + arcSpy.mockRestore() + teardown() + }) + + it("uses the smaller sparkline dot radius of 3", async () => { + const { chart, u, teardown } = await mountLine({ sparkline: true }) + + const octx = u.root.querySelector(".netdata-crosshair-overlay").getContext("2d") + const arcSpy = jest.spyOn(octx, "arc") + chart.updateAttribute("hoverX", [1617946865000]) + + expect(arcSpy).toHaveBeenCalledTimes(3) + expect(arcSpy.mock.calls[0][2]).toBe(3) + + arcSpy.mockRestore() + teardown() + }) + + it("draws no hover dots for a heatmap, matching the dygraph heatmap crosshair", async () => { + const heatmapIds = ["0", "1", "2", "3", "4", "5", "6"] + const heatmapRows = [ + [0, 0, 1, 0, 2, 0, 0], + [0, 0, 0, 3, 1, 0, 0], + [0, 0, 2, 0, 0, 0, 0], + ] + + const { sdk, chart } = makeTestChart({ + attributes: { + loaded: true, + chartType: "heatmap", + context: "prometheus.test.histogram", + groupBy: ["dimension"], + selectedLegendDimensions: [], + viewDimensions: { + ids: heatmapIds, + names: heatmapIds, + priorities: heatmapIds.map((_, index) => index), + units: heatmapIds.map(() => ""), + contexts: heatmapIds.map(() => ""), + grouped: ["dimension"], + }, + }, + }) + + await loadHeatmapPayload(chart, heatmapIds, heatmapRows, { timestamp: 1617946860000 }) + chart.getDateWindow = () => [1617946860000, 1617947750000] + chart.formatXAxis = x => x.toString() + chart.getThemeAttribute = () => "#333" + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + Object.defineProperty(element, "offsetWidth", { configurable: true, value: 800 }) + Object.defineProperty(element, "offsetHeight", { configurable: true, value: 300 }) + document.body.appendChild(element) + instance.mount(element) + + const u = instance.getUPlot() + const octx = u.root.querySelector(".netdata-crosshair-overlay").getContext("2d") + const arcSpy = jest.spyOn(octx, "arc") + chart.updateAttribute("hoverX", [1617946860000]) + + expect(arcSpy).not.toHaveBeenCalled() + + arcSpy.mockRestore() + instance.unmount() + document.body.removeChild(element) + }) +}) + +describe("uplotChart overlay z-order (drawClear behind series, dygraph underlay parity)", () => { + const nextFrame = () => new Promise(resolve => requestAnimationFrame(resolve)) + + const mountLine = async (attributes = {}) => { + const { sdk, chart } = makeTestChart({ + attributes: { + loaded: true, + chartType: "line", + after: 1617946860, + before: 1617947760, + min: 10, + max: 31, + ...attributes, + }, + }) + withLoadedPayload(chart) + chart.getClosestRow = tsMs => chart.getPayload().data.findIndex(row => row[0] === tsMs) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + await Promise.resolve() + await Promise.resolve() + + return { + sdk, + chart, + instance, + u: instance.getUPlot(), + teardown: () => (instance.unmount(), document.body.removeChild(element)), + } + } + + it("registers overlays on drawClear and keeps crosshair/dots on draw for a normal frame", async () => { + const { chart, u, teardown } = await mountLine({ + firstEntry: 1617946865, + error: false, + overlays: { proceeded: { type: "proceeded" } }, + }) + + expect(u.hooks.drawClear).toHaveLength(1) + expect(Array.isArray(u.hooks.draw)).toBe(true) + expect(u.hooks.draw).not.toContain(u.hooks.drawClear[0]) + + const octx = u.root.querySelector(".netdata-crosshair-overlay").getContext("2d") + const overlayArcSpy = jest.spyOn(octx, "arc") + const mainArcSpy = jest.spyOn(u.ctx, "arc") + + chart.updateAttribute("hoverX", [1617946865000]) + expect(overlayArcSpy).toHaveBeenCalledTimes(3) + + mainArcSpy.mockClear() + u.hooks.drawClear.forEach(hook => hook(u)) + expect(mainArcSpy).not.toHaveBeenCalled() + + mainArcSpy.mockClear() + u.hooks.draw.forEach(hook => hook(u)) + expect(mainArcSpy).not.toHaveBeenCalled() + + overlayArcSpy.mockRestore() + mainArcSpy.mockRestore() + teardown() + }) + + it("keeps overlays on drawClear with no draw hook for the framed-empty branch", async () => { + const { sdk, chart } = makeTestChart({ + attributes: { + loaded: true, + chartType: "line", + after: 1617946860, + before: 1617947760, + outOfLimits: true, + error: false, + firstEntry: 1617946865, + overlays: { proceeded: { type: "proceeded" } }, + }, + }) + withLoadedPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + + const u = instance.getUPlot() + expect(u.series).toHaveLength(1) + expect(Array.isArray(u.hooks.drawClear)).toBe(true) + expect(u.hooks.draw).toHaveLength(1) + + const areas = [] + instance.on("overlayedAreaChanged:proceeded", () => areas.push(true)) + + u.hooks.drawClear.forEach(hook => hook(u)) + await nextFrame() + expect(areas.length).toBeGreaterThan(0) + + instance.unmount() + document.body.removeChild(element) + }) + + it("still emits overlayedAreaChanged through a real redraw of a normal frame", async () => { + const { instance, u, teardown } = await mountLine({ + firstEntry: 1617946865, + error: false, + overlays: { proceeded: { type: "proceeded" } }, + }) + + const areas = [] + instance.on("overlayedAreaChanged:proceeded", () => areas.push(true)) + + u.redraw() + await nextFrame() + expect(areas.length).toBeGreaterThan(0) + + teardown() + }) +}) + +describe("uplotChart y-axis label width + font size (dygraph parity)", () => { + const mountLine = attributes => { + const { sdk, chart } = makeTestChart({ + attributes: { loaded: true, chartType: "line", ...attributes }, + }) + withLoadedPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + + return { + u: instance.getUPlot(), + teardown: () => (instance.unmount(), document.body.removeChild(element)), + } + } + + it("derives the y-axis size from a configured yAxisLabelWidth", () => { + const { u, teardown } = mountLine({ yAxisLabelWidth: 120 }) + + expect(u.axes[1].size(u, null, 1, 0)).toBe(120) + + teardown() + }) + + it("derives the y-axis label font size from a configured axisLabelFontSize", () => { + const { u, teardown } = mountLine({ axisLabelFontSize: 16 }) + + expect(u.axes[1].font[2]).toBe(16) + expect(u.axes[1].font[0]).toContain("IBM Plex Sans") + + teardown() + }) + + it("falls back to the default size and font when both attributes are unset", () => { + const { u, teardown } = mountLine({ yAxisLabelWidth: null, axisLabelFontSize: null }) + + expect(u.axes[1].size(u, null, 1, 0)).toBe(60) + expect(u.axes[1].font[2]).toBe(11) + + teardown() + }) +}) + +describe("uplotChart y-axis per-tick unit selection (dygraph parity)", () => { + const withUnitPayload = (chart, unit) => { + chart.getPayload = () => ({ + data: [ + [1617946860000, 10, 20, 30], + [1617946865000, 12, 18, 28], + [1617946870000, 11, 22, 31], + ], + labels: ["time", "load1", "load5", "load15"], + }) + chart.getPayloadDimensionIds = () => ["load1", "load5", "load15"] + chart.getVisibleDimensionIds = () => ["load1"] + chart.isDimensionVisible = () => true + chart.selectDimensionColor = () => "#3366CC" + chart.getThemeAttribute = () => "#E4E8E8" + chart.getDimensionUnit = () => unit + } + + const mountUnit = unit => { + const { sdk, chart } = makeTestChart({ + attributes: { loaded: true, chartType: "line", units: [unit] }, + }) + withUnitPayload(chart, unit) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + + return { + chart, + u: instance.getUPlot(), + teardown: () => (instance.unmount(), document.body.removeChild(element)), + } + } + + const unitToken = label => String(label).trim().split(/\s+/).pop() + + it("computes per-tick unitAttributes from the tick-local value range, matching dygraph", () => { + const { chart, u, teardown } = mountUnit("By") + const dimensionId = "load1" + const step = 268435456 + const splits = [0, step, step * 2, step * 3, step * 4] + + const spy = jest.spyOn(chart, "getUnitAttributesForValue") + const labels = u.axes[1].values(u, splits) + expect(spy).toHaveBeenCalledTimes(splits.length) + + splits.forEach(value => + expect(spy).toHaveBeenCalledWith( + value, + expect.objectContaining({ dimensionId, min: value, max: value + step }) + ) + ) + + labels.forEach((label, index) => { + const value = splits[index] + const unitAttributes = chart.getUnitAttributesForValue(value, { + dimensionId, + min: value, + max: value + step, + }) + expect(label).toBe(chart.getConvertedValueWithUnit(value, { dimensionId, unitAttributes })) + }) + + spy.mockRestore() + teardown() + }) + + it("selects different units across ticks that span unit boundaries", () => { + const { u, teardown } = mountUnit("By") + const step = 268435456 + const splits = [0, step, step * 2, step * 3, step * 4] + + const labels = u.axes[1].values(u, splits) + const distinctUnits = new Set(labels.map(unitToken)) + + expect(distinctUnits.size).toBeGreaterThan(1) + + teardown() + }) +}) + +describe("uplotChart duration-nice y-axis splits (dygraph parity)", () => { + const durationSteps = [1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 2700, 3600] + + const withDurationPayload = chart => { + chart.getPayload = () => ({ + data: [ + [1617946860000, 60, 1800, 3600], + [1617946865000, 120, 2400, 3000], + [1617946870000, 90, 1200, 3300], + ], + labels: ["time", "load1", "load5", "load15"], + }) + chart.getPayloadDimensionIds = () => ["load1", "load5", "load15"] + chart.getVisibleDimensionIds = () => ["load1"] + chart.isDimensionVisible = () => true + chart.selectDimensionColor = () => "#3366CC" + chart.getThemeAttribute = () => "#E4E8E8" + chart.getConvertedValueWithUnit = value => `${value}` + chart.getDimensionUnit = () => "s" + } + + const mountDuration = () => { + const { sdk, chart } = makeTestChart({ + attributes: { loaded: true, chartType: "line", secondsAsTime: true, units: ["s"] }, + }) + withDurationPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + + return { + u: instance.getUPlot(), + teardown: () => (instance.unmount(), document.body.removeChild(element)), + } + } + + it("places y-axis splits on duration-nice boundaries for a seconds axis", () => { + const { u, teardown } = mountDuration() + + const splits = u.axes[1].splits(u, 1, 0, 3600) + expect(splits.length).toBeGreaterThan(1) + + const step = splits[1] - splits[0] + expect(durationSteps).toContain(step) + expect(splits[0]).toBe(0) + + for (let i = 1; i < splits.length; i++) { + expect(splits[i] - splits[i - 1]).toBeCloseTo(step, 6) + } + + teardown() + }) +}) + +describe("uplotChart render freshness contract (dygraph parity)", () => { + const mountLoaded = () => { + const { sdk, chart } = makeTestChart({ attributes: { loaded: true, chartType: "line" } }) + withLoadedPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + + return { + sdk, + chart, + instance, + teardown: () => (instance.unmount(), document.body.removeChild(element)), + } + } + + it("returns true from render when mounted, loaded and unblocked", () => { + const { instance, teardown } = mountLoaded() + + expect(instance.render()).toBe(true) + + teardown() + }) + + it("returns false from render while highlighting, panning or processing", () => { + const { chart, instance, teardown } = mountLoaded() + + chart.updateAttribute("highlighting", true) + expect(instance.render()).toBe(false) + chart.updateAttribute("highlighting", false) + + chart.updateAttribute("panning", true) + expect(instance.render()).toBe(false) + chart.updateAttribute("panning", false) + + chart.updateAttribute("processing", true) + expect(instance.render()).toBe(false) + chart.updateAttribute("processing", false) + + teardown() + }) + + it("returns false from render before mount when there is no element", () => { + const { sdk, chart } = makeTestChart({ attributes: { loaded: true, chartType: "line" } }) + withLoadedPayload(chart) + + const instance = uplotChart(sdk, chart) + + expect(instance.render()).toBe(false) + }) + + it("stays stale after a blocked render and goes fresh once the block clears", () => { + const { chart, instance, teardown } = mountLoaded() + + chart.updateAttribute("processing", true) + instance.invalidateRender() + expect(instance.renderIfStale(instance.render)).toBe(false) + expect(instance.isRenderStale()).toBe(true) + + chart.updateAttribute("processing", false) + expect(instance.renderIfStale(instance.render)).toBe(true) + expect(instance.isRenderStale()).toBe(false) + + teardown() + }) +}) + +describe("uplotChart smooth line paths (dygraph bezier parity)", () => { + const mount = async attributes => { + const { sdk, chart } = makeTestChart({ + attributes: { + loaded: true, + chartType: "line", + after: 1617946860, + before: 1617946870, + ...attributes, + }, + }) + withLoadedPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + await Promise.resolve() + await Promise.resolve() + + return { + chart, + instance, + u: instance.getUPlot(), + teardown: () => (instance.unmount(), document.body.removeChild(element)), + } + } + + const lastIdx = u => u.data[0].length - 1 + + it("wires a bezier-curve path builder for a non-stepped line chart", async () => { + const { u, teardown } = await mount() + + const builder = u.series[1].paths + expect(typeof builder).toBe("function") + expect(u.series[1].paths).toBe(u.series[2].paths) + + const paths = builder(u, 1, 0, lastIdx(u)) + expect(paths.stroke.moveTo.mock.calls.length).toBeGreaterThan(0) + expect(paths.stroke.bezierCurveTo.mock.calls.length).toBeGreaterThan(0) + expect(paths.stroke.lineTo.mock.calls.length).toBe(0) + + teardown() + }) + + it("does not smooth a stepped line chart (straight steps, no beziers)", async () => { + const line = await mount() + const smoothBuilder = line.u.series[1].paths + line.teardown() + + const { u, teardown } = await mount({ stepPlot: true }) + + expect(u.series[1].paths).not.toBe(smoothBuilder) + + const paths = u.series[1].paths(u, 1, 0, lastIdx(u)) + expect(paths.stroke.bezierCurveTo.mock.calls.length).toBe(0) + expect(paths.stroke.lineTo.mock.calls.length).toBeGreaterThan(0) + + teardown() + }) + + it("does not smooth a stacked chart (null path builder)", async () => { + const { u, teardown } = await mount({ chartType: "stacked" }) + + expect(u.series[1].paths()).toBeNull() + + teardown() + }) + + it("does not smooth an area chart (linear builder, no beziers)", async () => { + const line = await mount() + const smoothBuilder = line.u.series[1].paths + line.teardown() + + const { u, teardown } = await mount({ chartType: "area" }) + + expect(u.series[1].paths).not.toBe(smoothBuilder) + + const paths = u.series[1].paths(u, 1, 0, lastIdx(u)) + expect(paths.stroke.bezierCurveTo.mock.calls.length).toBe(0) + + teardown() + }) + + it("switches to the stepped builder when stepPlot turns on mid-session", async () => { + const { chart, instance, u, teardown } = await mount() + + const smoothBuilder = u.series[1].paths + const smoothPaths = smoothBuilder(u, 1, 0, lastIdx(u)) + expect(smoothPaths.stroke.bezierCurveTo.mock.calls.length).toBeGreaterThan(0) + expect(smoothPaths.stroke.lineTo.mock.calls.length).toBe(0) + + chart.updateAttribute("stepPlot", true) + await Promise.resolve() + await Promise.resolve() + + const next = instance.getUPlot() + expect(next.series[1].paths).not.toBe(smoothBuilder) + + const steppedPaths = next.series[1].paths(next, 1, 0, lastIdx(next)) + expect(steppedPaths.stroke.bezierCurveTo.mock.calls.length).toBe(0) + expect(steppedPaths.stroke.lineTo.mock.calls.length).toBeGreaterThan(0) + + teardown() + }) + + it("restores the smooth builder when stepPlot turns off mid-session", async () => { + const { chart, instance, u, teardown } = await mount({ stepPlot: true }) + + const steppedBuilder = u.series[1].paths + const steppedPaths = steppedBuilder(u, 1, 0, lastIdx(u)) + expect(steppedPaths.stroke.bezierCurveTo.mock.calls.length).toBe(0) + expect(steppedPaths.stroke.lineTo.mock.calls.length).toBeGreaterThan(0) + + chart.updateAttribute("stepPlot", false) + await Promise.resolve() + await Promise.resolve() + + const next = instance.getUPlot() + expect(next.series[1].paths).not.toBe(steppedBuilder) + + const smoothPaths = next.series[1].paths(next, 1, 0, lastIdx(next)) + expect(smoothPaths.stroke.bezierCurveTo.mock.calls.length).toBeGreaterThan(0) + expect(smoothPaths.stroke.lineTo.mock.calls.length).toBe(0) + + teardown() + }) +}) + +describe("uplotChart short-chart plot area (dygraph interaction parity)", () => { + const mountAtHeight = async (height, attributes = {}) => { + const { sdk, chart } = makeTestChart({ + attributes: { loaded: true, chartType: "line", ...attributes }, + }) + withLoadedPayload(chart) + + const hovered = [] + sdk.on("highlightHover", (c, x) => hovered.push(x)) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = height + document.body.appendChild(element) + instance.mount(element) + await Promise.resolve() + await Promise.resolve() + + return { + chart, + hovered, + element, + u: instance.getUPlot(), + instance, + teardown: () => (instance.unmount(), document.body.removeChild(element)), + } + } + + it("keeps a usable plot area on a short chart", async () => { + const { u, teardown } = await mountAtHeight("52px") + + expect(u.bbox.height).toBeGreaterThan(0) + + teardown() + }) + + it("shrinks the x-axis budget instead of collapsing the plot on a short chart", async () => { + const { u, teardown } = await mountAtHeight("30px") + + expect(u.axes[0]._size).toBeLessThan(16) + expect(u.bbox.height).toBeGreaterThan(0) + + teardown() + }) + + it("still emits hover on a short chart", async () => { + const { u, hovered, teardown } = await mountAtHeight("52px") + + u.setCursor({ left: 400, top: 10 }, true) + expect(hovered.length).toBeGreaterThan(0) + + teardown() + }) + + it("gives a normal-height chart dygraph's vertical budget", async () => { + const { u, instance, teardown } = await mountAtHeight("300px") + + expect(u.axes[0]._size).toBe(16) + expect(instance.getPlotArea()).toEqual( + expect.objectContaining({ top: 5, height: 300 - 5 - 16 }) + ) + + teardown() + }) + + it("re-derives the vertical budget on resize", async () => { + const { u, instance, element, teardown } = await mountAtHeight("300px") + + element.style.height = "40px" + instance.trigger("resize") + await Promise.resolve() + await Promise.resolve() + + const { top, height } = instance.getPlotArea() + + expect(u.axes[0]._size).toBe(15) + expect(top).toBe(5) + expect(height).toBe(20) + + teardown() + }) + + it("gives a sparkline the whole element", async () => { + const { instance, teardown } = await mountAtHeight("300px", { sparkline: true }) + + expect(instance.getPlotArea()).toEqual(expect.objectContaining({ top: 0, height: 300 })) + + teardown() + }) +}) + +describe("uplotChart native cursor suppression (dygraph crosshair parity)", () => { + const mount = async () => { + const { sdk, chart } = makeTestChart({ attributes: { loaded: true, chartType: "line" } }) + withLoadedPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + await Promise.resolve() + await Promise.resolve() + + return { + u: instance.getUPlot(), + teardown: () => (instance.unmount(), document.body.removeChild(element)), + } + } + + it("draws no uPlot cursor lines, so only the themed overlay crosshair shows", async () => { + const { u, teardown } = await mount() + + expect(u.over.querySelector(".u-cursor-x")).toBeNull() + expect(u.over.querySelector(".u-cursor-y")).toBeNull() + + teardown() + }) + + it("draws no uPlot cursor points, so only the canvas hover dots show", async () => { + const { u, teardown } = await mount() + + expect(u.over.querySelectorAll(".u-cursor-pt")).toHaveLength(0) + + teardown() + }) +}) + +describe("uplotChart stacked value range", () => { + const mountStacked = async (chartType, rows, attributes = {}) => { + const { sdk, chart } = makeTestChart({ + attributes: { loaded: true, chartType, ...attributes }, + }) + chart.getPayload = () => ({ data: rows, labels: ["time", "a", "b"] }) + chart.getPayloadDimensionIds = () => ["a", "b"] + chart.getVisibleDimensionIds = () => ["a", "b"] + chart.isDimensionVisible = () => true + chart.selectDimensionColor = () => "#3366CC" + chart.getThemeAttribute = () => "#E4E8E8" + chart.getConvertedValueWithUnit = v => `${v}` + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + await Promise.resolve() + await Promise.resolve() + + return { + u: instance.getUPlot(), + teardown: () => (instance.unmount(), document.body.removeChild(element)), + } + } + + const positiveRows = [ + [1617946860000, 10, 20], + [1617946865000, 12, 18], + ] + + it("honours staticValueRange instead of returning the stack range", async () => { + const { u, teardown } = await mountStacked("stacked", positiveRows, { + staticValueRange: [0, 1000], + }) + + expect(u.scales.y.range(u, 0, 100)).toEqual([0, 1000]) + + teardown() + }) + + it("spans the whole stack from zero", async () => { + const { u, teardown } = await mountStacked("stacked", positiveRows) + + const [min, max] = u.scales.y.range(u, 0, 100) + expect(min).toBeLessThanOrEqual(0) + expect(max).toBeGreaterThanOrEqual(30) + + teardown() + }) + + it("diverges mixed-sign stacked bars around zero", async () => { + const { u, teardown } = await mountStacked("stackedBar", [ + [1617946860000, 10, -20], + [1617946865000, 12, -18], + ]) + + const [min, max] = u.scales.y.range(u, 0, 100) + expect(min).toBeLessThanOrEqual(-20) + expect(max).toBeGreaterThanOrEqual(12) + + teardown() + }) +}) + +describe("uplotChart degenerate value range (dygraph parity)", () => { + const mountConstant = async value => { + const { sdk, chart } = makeTestChart({ + attributes: { loaded: true, chartType: "line", min: value, max: value }, + }) + chart.getPayload = () => ({ + data: [ + [1617946860000, value], + [1617946865000, value], + [1617946870000, value], + ], + labels: ["time", "a"], + }) + chart.getPayloadDimensionIds = () => ["a"] + chart.getVisibleDimensionIds = () => ["a"] + chart.isDimensionVisible = () => true + chart.selectDimensionColor = () => "#3366CC" + chart.getThemeAttribute = () => "#E4E8E8" + chart.getConvertedValueWithUnit = v => `${v}` + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + await Promise.resolve() + await Promise.resolve() + + return { + u: instance.getUPlot(), + teardown: () => (instance.unmount(), document.body.removeChild(element)), + } + } + + it("centres a constant non-zero series instead of collapsing the scale", async () => { + const { u, teardown } = await mountConstant(5) + + expect(u.scales.y.min).toBeLessThan(5) + expect(u.scales.y.max).toBeGreaterThan(5) + expect(Number.isFinite(u.valToPos(5, "y"))).toBe(true) + + teardown() + }) + + it("gives a constant all-zero series a plottable range", async () => { + const { u, teardown } = await mountConstant(0) + + expect(u.scales.y.max).toBeGreaterThan(0) + expect(Number.isFinite(u.valToPos(0, "y"))).toBe(true) + + teardown() + }) +}) + +describe("uplotChart auto y-range source (dygraph parity)", () => { + it("ignores a valueRange that dygraph discards for grouped, non-avg aggregation", async () => { + const { sdk, chart } = makeTestChart({ + attributes: { + loaded: true, + chartType: "line", + valueRange: [0, 100], + groupBy: ["node"], + aggregationMethod: "sum", + }, + }) + withLoadedPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + await Promise.resolve() + await Promise.resolve() + + const u = instance.getUPlot() + const [min, max] = u.scales.y.range(u, 10, 31) + + // dygraph gets [null, null] here and autoscales to the data, so the axis must not + // stretch to the discarded 0..100 range + expect(max).toBeLessThan(40) + expect(min).toBeGreaterThan(-10) + + instance.unmount() + document.body.removeChild(element) + }) +}) + +describe("uplotChart series focus (dygraph parity)", () => { + it("does not fade non-hovered series, matching highlightSeriesBackgroundAlpha 1", async () => { + const { sdk, chart } = makeTestChart({ attributes: { loaded: true, chartType: "line" } }) + withLoadedPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + await Promise.resolve() + await Promise.resolve() + + const u = instance.getUPlot() + expect(u.focus.alpha).toBe(1) + + u.setSeries(1, { focus: true }) + const dataSeriesAlphas = u.series.slice(1).map(series => series.alpha) + expect(dataSeriesAlphas).toEqual(dataSeriesAlphas.map(() => 1)) + + instance.unmount() + document.body.removeChild(element) + }) +}) + +describe("uplotChart gesture teardown", () => { + const mountPannable = async () => { + const { sdk, chart } = makeTestChart({ + attributes: { + loaded: true, + chartType: "line", + chartLibrary: "uplot", + navigation: "pan", + after: 1617946860, + before: 1617946870, + }, + }) + chart.getPayload = () => ({ + data: [ + [1617946860000, 10], + [1617946865000, 12], + [1617946870000, 11], + ], + labels: ["time", "load1"], + }) + chart.getPayloadDimensionIds = () => ["load1"] + chart.getVisibleDimensionIds = () => ["load1"] + chart.isDimensionVisible = () => true + chart.selectDimensionColor = () => "#3366CC" + chart.getThemeAttribute = () => "#E4E8E8" + chart.getConvertedValueWithUnit = value => `${value}` + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + await Promise.resolve() + await Promise.resolve() + + const events = [] + sdk.on("panStart", () => events.push("panStart")) + sdk.on("panEnd", () => events.push("panEnd")) + + const startPan = () => { + instance + .getUPlot() + .over.dispatchEvent(new MouseEvent("mousedown", { clientX: 100, clientY: 100, button: 0 })) + document.dispatchEvent(new MouseEvent("mousemove", { clientX: 200, clientY: 100 })) + } + + return { + sdk, + chart, + instance, + events, + startPan, + teardown: () => (instance.unmount(), document.body.removeChild(element)), + } + } + + it("ends an in-flight pan through panEnd when the chart rebuilds", async () => { + const { chart, events, startPan, teardown } = await mountPannable() + + startPan() + expect(chart.getAttribute("panning")).toBe(true) + + chart.updateAttribute("theme", "dark") + + expect(events).toEqual(["panStart", "panEnd"]) + expect(chart.getAttribute("panning")).toBe(false) + expect(chart.getAttribute("enabledHover")).toBe(true) + + teardown() + }) + + it("clears pan state without emitting panEnd when the chart unmounts", async () => { + const { chart, instance, events, startPan } = await mountPannable() + + startPan() + instance.unmount() + + expect(events).toEqual(["panStart"]) + expect(chart.getAttribute("panning")).toBe(false) + expect(chart.getAttribute("enabledHover")).toBe(true) + }) + + it("renders again after being unmounted mid-pan", async () => { + const { instance, startPan } = await mountPannable() + + startPan() + instance.unmount() + + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + await Promise.resolve() + await Promise.resolve() + + expect(instance.getUPlot()).not.toBeNull() + expect(instance.getXAxisRange()).not.toBeNull() + + instance.unmount() + document.body.removeChild(element) + }) +}) + +describe("uplotChart series styling (dygraph parity)", () => { + const mountStyled = async (chartType, data, dims, attributes = {}) => { + const { sdk, chart } = makeTestChart({ + attributes: { + loaded: true, + chartType, + chartLibrary: "uplot", + after: 1617946860, + before: 1617946875, + ...attributes, + }, + }) + chart.getPayload = () => ({ data, labels: ["time", ...dims] }) + chart.getPayloadDimensionIds = () => dims + chart.getVisibleDimensionIds = () => dims + chart.isDimensionVisible = () => true + chart.selectDimensionColor = () => "#3366CC" + chart.getThemeAttribute = () => "#E4E8E8" + chart.getConvertedValueWithUnit = value => `${value}` + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + await Promise.resolve() + await Promise.resolve() + + return { + chart, + u: instance.getUPlot(), + teardown: () => (instance.unmount(), document.body.removeChild(element)), + } + } + + const lineRows = [ + [1617946860000, 10], + [1617946865000, 12], + [1617946870000, 11], + ] + + it("strokes an area edge thinner than a line, at dygraph's widths", async () => { + const line = await mountStyled("line", lineRows, ["a"]) + expect(line.u.series[1].width).toBe(1.5) + line.teardown() + + const area = await mountStyled("area", lineRows, ["a"]) + expect(area.u.series[1].width).toBe(0.7) + area.teardown() + }) + + it("marks samples adjacent to a gap and leaves the series edges alone", async () => { + const { u, teardown } = await mountStyled( + "line", + [ + [1617946860000, 10], + [1617946865000, null], + [1617946870000, 12], + [1617946875000, 13], + ], + ["a"] + ) + + expect(u.series[1].points.show(u, 1)).toBe(false) + expect(u.series[1].points.filter(u, 1)).toEqual([0, 2]) + + teardown() + }) + + it("draws no gap markers when the series has no gaps", async () => { + const { u, teardown } = await mountStyled("line", lineRows, ["a"]) + + expect(u.series[1].points.filter(u, 1)).toBeNull() + + teardown() + }) + + it("outlines bars with a darkened stroke", async () => { + const { u, teardown } = await mountStyled("multiBar", lineRows, ["a"]) + + const strokeSpy = jest.spyOn(u.ctx, "strokeRect") + u.redraw() + + expect(strokeSpy).toHaveBeenCalled() + expect(u.ctx.strokeStyle).not.toBe("#3366cc") + + strokeSpy.mockRestore() + teardown() + }) +}) + +describe("uplotChart mount lifecycle", () => { + const makeUnloaded = (attributes = {}) => + makeTestChart({ + attributes: { loaded: false, chartType: "line", chartLibrary: "uplot", ...attributes }, + }) + + const attach = () => { + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + return element + } + + it("mounts once even while the chart is still loading", () => { + const { sdk, chart } = makeUnloaded() + const mounts = [] + sdk.on("mountChartUI", () => mounts.push(true)) + + const instance = uplotChart(sdk, chart) + const element = attach() + + instance.mount(element) + instance.mount(element) + + expect(instance.getUPlot()).toBeNull() + expect(mounts).toHaveLength(1) + + instance.unmount() + document.body.removeChild(element) + }) + + it("ignores unmount on an instance that never mounted", () => { + const { sdk, chart } = makeUnloaded() + const unmounts = [] + sdk.on("unmountChartUI", () => unmounts.push(true)) + + uplotChart(sdk, chart).unmount() + + expect(unmounts).toHaveLength(0) + }) + + it("survives a theme change after unmount", () => { + const { sdk, chart } = makeUnloaded() + const instance = uplotChart(sdk, chart) + const element = attach() + + instance.mount(element) + instance.mount(element) + instance.unmount() + + expect(() => chart.updateAttribute("theme", "dark")).not.toThrow() + + document.body.removeChild(element) + }) + + it("emits highlightEnd from an empty chart", async () => { + const { sdk, chart } = makeTestChart({ + attributes: { + loaded: true, + chartType: "line", + chartLibrary: "uplot", + navigation: "select", + after: 1617946860, + before: 1617946870, + }, + }) + chart.getPayload = () => ({ data: [], labels: [] }) + chart.getPayloadDimensionIds = () => [] + chart.getThemeAttribute = () => "#E4E8E8" + + const instance = uplotChart(sdk, chart) + const element = attach() + instance.mount(element) + await Promise.resolve() + await Promise.resolve() + + const u = instance.getUPlot() + expect(u).not.toBeNull() + expect(u.series).toHaveLength(1) + + const ranges = [] + sdk.on("highlightEnd", (c, range) => ranges.push(range)) + + u.setSelect({ left: 100, top: 0, width: 200, height: 200 }, true) + + expect(ranges).toHaveLength(1) + + instance.unmount() + document.body.removeChild(element) + }) +}) + +describe("uplotChart interaction hygiene (dygraph parity)", () => { + const mountInteractive = async (attributes = {}) => { + const { sdk, chart } = makeTestChart({ + attributes: { + loaded: true, + chartType: "line", + chartLibrary: "uplot", + after: 1617946860, + before: 1617946870, + ...attributes, + }, + }) + chart.getPayload = () => ({ + data: [ + [1617946860000, 10, 20], + [1617946865000, 12, 18], + [1617946870000, 11, 22], + ], + labels: ["time", "load1", "load5"], + }) + chart.getPayloadDimensionIds = () => ["load1", "load5"] + chart.getVisibleDimensionIds = () => ["load1", "load5"] + chart.isDimensionVisible = () => true + chart.selectDimensionColor = () => "#3366CC" + chart.getThemeAttribute = () => "#E4E8E8" + chart.getConvertedValueWithUnit = value => `${value}` + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + await Promise.resolve() + await Promise.resolve() + + return { + sdk, + chart, + instance, + u: instance.getUPlot(), + teardown: () => (instance.unmount(), document.body.removeChild(element)), + } + } + + const touchEventWith = (type, xs) => { + const event = new Event(type, { bubbles: true, cancelable: true }) + const touches = xs.map(x => ({ clientX: x, clientY: 100, pageX: x, pageY: 100 })) + event.touches = type === "touchend" ? [] : touches + event.changedTouches = touches + return event + } + + it("emits one hover per row, not per pixel", async () => { + const { sdk, u, teardown } = await mountInteractive() + + const hovers = [] + sdk.on("highlightHover", (c, timestamp) => hovers.push(timestamp)) + + for (let left = 300; left < 320; left++) u.setCursor({ left, top: 100 }, true) + + expect(hovers.length).toBeGreaterThan(0) + expect(new Set(hovers).size).toBe(hovers.length) + + teardown() + }) + + it("re-emits when the row is unchanged but the hovered dimension changes", async () => { + const { sdk, chart, u, teardown } = await mountInteractive() + + const hovers = [] + sdk.on("highlightHover", (c, timestamp, dimensionId) => hovers.push(dimensionId)) + + u.setCursor({ left: 300, top: u.valToPos(20, "y") }, true) + u.setCursor({ left: 300, top: u.valToPos(12, "y") }, true) + + expect(new Set(hovers).size).toBeGreaterThan(1) + expect(chart.getAttribute("chartType")).toBe("line") + + teardown() + }) + + it("ignores a double click when navigation is disabled", async () => { + const { chart, u, teardown } = await mountInteractive({ enabledNavigation: false }) + + const resets = [] + chart.resetNavigation = () => resets.push(true) + + u.over.dispatchEvent(new MouseEvent("dblclick", { bubbles: true })) + + expect(resets).toHaveLength(0) + + teardown() + }) + + it("zooms the x range about the pinch midpoint on a two-finger gesture", async () => { + const { u, teardown } = await mountInteractive({ before: 1617946860 + 600 }) + + const span = u.scales.x.max - u.scales.x.min + + u.over.dispatchEvent(touchEventWith("touchstart", [300, 500])) + u.over.dispatchEvent(touchEventWith("touchmove", [200, 600])) + await Promise.resolve() + await Promise.resolve() + + expect(u.scales.x.max - u.scales.x.min).toBeLessThan(span) + + u.over.dispatchEvent(touchEventWith("touchend", [])) + teardown() + }) + + it("does not pan while pinching", async () => { + const { sdk, u, teardown } = await mountInteractive() + + const pans = [] + sdk.on("panStart", () => pans.push(true)) + + u.over.dispatchEvent(touchEventWith("touchstart", [300, 500])) + u.over.dispatchEvent(touchEventWith("touchmove", [280, 520])) + + expect(pans).toHaveLength(0) + + u.over.dispatchEvent(touchEventWith("touchend", [])) + teardown() + }) + + it("prevents the default on every touch event it handles", async () => { + const { u, teardown } = await mountInteractive() + + const start = touchEventWith("touchstart", [300]) + u.over.dispatchEvent(start) + expect(start.defaultPrevented).toBe(true) + + const end = touchEventWith("touchend", []) + u.over.dispatchEvent(end) + expect(end.defaultPrevented).toBe(true) + + teardown() + }) + + it("leaves a wheel over the axis gutter to the page", async () => { + const { u, teardown } = await mountInteractive() + + u.setCursor({ left: -10, top: 100 }, true) + + const wheel = new WheelEvent("wheel", { deltaY: 120, shiftKey: true, cancelable: true }) + u.over.dispatchEvent(wheel) + + expect(wheel.defaultPrevented).toBe(false) + + teardown() + }) +}) + +describe("uplotChart anomaly axis badge", () => { + const mountBadged = async (attributes = {}) => { + const { sdk, chart } = makeTestChart({ + attributes: { + loaded: true, + chartType: "line", + chartLibrary: "uplot", + after: 1617946860, + before: 1617946870, + ...attributes, + }, + }) + chart.getPayload = () => ({ + data: [ + [1617946860000, 10], + [1617946865000, 12], + [1617946870000, 11], + ], + labels: ["time", "load1"], + }) + chart.getPayloadDimensionIds = () => ["load1"] + chart.getVisibleDimensionIds = () => ["load1"] + chart.isDimensionVisible = () => true + chart.selectDimensionColor = () => "#3366CC" + chart.getThemeAttribute = () => "#E4E8E8" + chart.getConvertedValueWithUnit = value => `${value}` + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + await Promise.resolve() + await Promise.resolve() + + return { + chart, + u: instance.getUPlot(), + teardown: () => (instance.unmount(), document.body.removeChild(element)), + } + } + + const countPathFills = u => { + const spy = jest.spyOn(u.ctx, "fill") + u.redraw() + const calls = spy.mock.calls.filter(([shape]) => shape instanceof Path2D) + spy.mockRestore() + return calls + } + + it("fills the badge inside the y-axis gutter", async () => { + const { u, teardown } = await mountBadged({ showAnomalies: true }) + + const calls = countPathFills(u) + + expect(calls).toHaveLength(1) + expect(calls[0][1]).toBe("evenodd") + + teardown() + }) + + it("draws no badge when anomalies are off", async () => { + const { u, teardown } = await mountBadged({ showAnomalies: false }) + + expect(countPathFills(u)).toHaveLength(0) + + teardown() + }) + + it("draws no badge when the y axis is hidden", async () => { + const { u, teardown } = await mountBadged({ showAnomalies: true, enabledYAxis: false }) + + expect(countPathFills(u)).toHaveLength(0) + + teardown() + }) +}) + +describe("uplotChart dimension visibility (dygraph parity)", () => { + const mountVisible = async ({ attributes = {}, visibleIds = [], color = "#3366CC" } = {}) => { + const { sdk, chart } = makeTestChart({ + attributes: { + loaded: true, + chartType: "line", + chartLibrary: "uplot", + after: 1617946860, + before: 1617946870, + ...attributes, + }, + }) + chart.getPayload = () => ({ + data: [ + [1617946860000, 10, 20], + [1617946865000, 12, 18], + ], + labels: ["time", "load1", "load5"], + }) + chart.getPayloadDimensionIds = () => ["load1", "load5"] + chart.getVisibleDimensionIds = () => ["load1", "load5"] + chart.isDimensionVisible = id => visibleIds.includes(id) + chart.selectDimensionColor = () => color + chart.getThemeAttribute = () => "#E4E8E8" + chart.getConvertedValueWithUnit = value => `${value}` + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + await Promise.resolve() + await Promise.resolve() + + return { + u: instance.getUPlot(), + teardown: () => (instance.unmount(), document.body.removeChild(element)), + } + } + + it("shows every series while no legend selection exists", async () => { + const { u, teardown } = await mountVisible({ visibleIds: [] }) + + expect(u.series.slice(1).map(s => s.show)).toEqual([true, true]) + + teardown() + }) + + it("hides the deselected series once a legend selection exists", async () => { + const { u, teardown } = await mountVisible({ + attributes: { selectedLegendDimensions: ["load5"] }, + visibleIds: ["load5"], + }) + + expect(u.series.slice(1).map(s => s.show)).toEqual([false, true]) + + teardown() + }) + + it("paints a sparkline whose dimension has no palette colour", async () => { + const { u, teardown } = await mountVisible({ + attributes: { sparkline: true }, + visibleIds: [], + color: null, + }) + + const series = u.series[1] + const stroke = typeof series.stroke === "function" ? series.stroke(u, 1) : series.stroke + const fill = typeof series.fill === "function" ? series.fill(u, 1) : series.fill + + expect(stroke).toBeTruthy() + expect(fill).toBeTruthy() + + teardown() + }) + + it("shows a sparkline whose synthetic dimension is not in the visible set", async () => { + const { u, teardown } = await mountVisible({ + attributes: { sparkline: true }, + visibleIds: [], + }) + + expect(u.series.slice(1).every(s => s.show)).toBe(true) + + teardown() + }) +}) diff --git a/src/chartLibraries/uplot/overlays/alarm.js b/src/chartLibraries/uplot/overlays/alarm.js new file mode 100644 index 000000000..ad2aab124 --- /dev/null +++ b/src/chartLibraries/uplot/overlays/alarm.js @@ -0,0 +1,39 @@ +import { trigger, getArea } from "./helpers" + +const textColorMap = { + warning: "#F9A825", + critical: "#FF4136", + clear: "#00AB44", +} + +export default (chartUI, id) => { + const overlays = chartUI.chart.getAttribute("overlays") + const { when, status } = overlays[id] + + const u = chartUI.getUPlot() + + const { top, height: h } = chartUI.getPlotArea() + const { ctx } = u + + const area = getArea(chartUI, [when, when]) + + if (!area) return trigger(chartUI, id) + + const lineWidth = 2 + const { from } = area + + trigger(chartUI, id, area) + + ctx.save() + ctx.beginPath() + ctx.moveTo(from - lineWidth / 2, top) + ctx.lineTo(from - lineWidth / 2, top + h) + ctx.globalAlpha = 1 + ctx.lineWidth = lineWidth + ctx.setLineDash([4, 4]) + ctx.strokeStyle = textColorMap[status] + ctx.stroke() + + ctx.closePath() + ctx.restore() +} diff --git a/src/chartLibraries/uplot/overlays/alarm.test.js b/src/chartLibraries/uplot/overlays/alarm.test.js new file mode 100644 index 000000000..f18ae7eff --- /dev/null +++ b/src/chartLibraries/uplot/overlays/alarm.test.js @@ -0,0 +1,99 @@ +import { makeTestChart } from "@jest/testUtilities" +import uplotChart from "../index" +import alarm from "./alarm" + +const after = 1617946860 +const before = 1617947760 + +const withLoadedPayload = chart => { + chart.getPayload = () => ({ + data: [ + [after * 1000, 10, 20, 30], + [(after + 5) * 1000, 12, 18, 28], + [(after + 10) * 1000, 11, 22, 31], + ], + labels: ["time", "load1", "load5", "load15"], + }) + chart.getPayloadDimensionIds = () => ["load1", "load5", "load15"] + chart.getVisibleDimensionIds = () => ["load1", "load5", "load15"] + chart.isDimensionVisible = () => true + chart.selectDimensionColor = () => "#3366CC" + chart.getThemeAttribute = () => "#E4E8E8" + chart.getConvertedValueWithUnit = value => `${value}` +} + +const nextFrame = () => new Promise(resolve => requestAnimationFrame(resolve)) + +const mountUplot = async overlays => { + const { sdk, chart } = makeTestChart({ + attributes: { + loaded: true, + chartType: "line", + chartLibrary: "uplot", + after, + before, + staticValueRange: [5, 40], + overlays, + }, + }) + withLoadedPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + await Promise.resolve() + await Promise.resolve() + + return { + chart, + instance, + teardown: () => (instance.unmount(), document.body.removeChild(element)), + } +} + +describe("uplot alarm overlay", () => { + it("emits overlayedAreaChanged with a positioned area when the alarm is in view", async () => { + const { instance, teardown } = await mountUplot({ + "alarm-1": { type: "alarm", when: after + 60, status: "critical", value: 42 }, + }) + + let area + instance.on("overlayedAreaChanged:alarm-1", next => (area = next)) + + alarm(instance, "alarm-1") + await nextFrame() + + expect(area).toEqual({ + from: expect.any(Number), + to: expect.any(Number), + width: expect.any(Number), + }) + expect(Number.isFinite(area.from)).toBe(true) + + teardown() + }) + + it("emits a null area when the alarm falls outside the visible window", async () => { + const { instance, teardown } = await mountUplot({ + "alarm-1": { type: "alarm", when: after - 5000, status: "warning", value: 1 }, + }) + + let called = false + let area = "unset" + instance.on("overlayedAreaChanged:alarm-1", next => { + called = true + area = next + }) + + alarm(instance, "alarm-1") + await nextFrame() + + expect(called).toBe(true) + expect(area).toBeUndefined() + + teardown() + }) +}) diff --git a/src/chartLibraries/uplot/overlays/alarmRange.js b/src/chartLibraries/uplot/overlays/alarmRange.js new file mode 100644 index 000000000..46e18d77a --- /dev/null +++ b/src/chartLibraries/uplot/overlays/alarmRange.js @@ -0,0 +1,67 @@ +import { trigger, getArea } from "./helpers" + +const borderColorMap = { + warning: "#FFF8E1", + critical: "#FFEBEF", + clear: "#E5F5E8", +} + +const fillColorMap = { + warning: "#FFC300", + critical: "#F59B9B", + clear: "#68C47D", +} + +const textColorMap = { + warning: "#F9A825", + critical: "#FF4136", + clear: "#00AB44", +} + +const getNow = () => Math.floor(new Date().getTime() / 1000) + +export default (chartUI, id) => { + const overlays = chartUI.chart.getAttribute("overlays") + const { whenTriggered, whenLast = getNow(), status } = overlays[id] + + const u = chartUI.getUPlot() + + const { top, height: h } = chartUI.getPlotArea() + const { ctx } = u + + const area = getArea(chartUI, [whenTriggered, whenLast]) + + if (!area) return trigger(chartUI, id) + + const { from, width, to } = area + trigger(chartUI, id, area) + + ctx.save() + ctx.beginPath() + + ctx.rect(from, top, width, h - 1) + ctx.fillStyle = fillColorMap[status] + ctx.globalAlpha = 0.1 + ctx.fill() + + const borderWidth = 2 + // left border + ctx.beginPath() + ctx.moveTo(from, top) + ctx.lineTo(from, top + h) + ctx.globalAlpha = 1 + ctx.lineWidth = borderWidth + ctx.setLineDash([4, 4]) + ctx.strokeStyle = borderColorMap[status] + ctx.stroke() + + // right border + ctx.beginPath() + ctx.moveTo(to - borderWidth, top) + ctx.lineTo(to - borderWidth, top + h) + ctx.strokeStyle = textColorMap[status] + ctx.stroke() + + ctx.closePath() + ctx.restore() +} diff --git a/src/chartLibraries/uplot/overlays/alarmRange.test.js b/src/chartLibraries/uplot/overlays/alarmRange.test.js new file mode 100644 index 000000000..6e37f5325 --- /dev/null +++ b/src/chartLibraries/uplot/overlays/alarmRange.test.js @@ -0,0 +1,111 @@ +import { makeTestChart } from "@jest/testUtilities" +import uplotChart from "../index" +import alarmRange from "./alarmRange" + +const after = 1617946860 +const before = 1617947760 + +const withLoadedPayload = chart => { + chart.getPayload = () => ({ + data: [ + [after * 1000, 10, 20, 30], + [(after + 5) * 1000, 12, 18, 28], + [(after + 10) * 1000, 11, 22, 31], + ], + labels: ["time", "load1", "load5", "load15"], + }) + chart.getPayloadDimensionIds = () => ["load1", "load5", "load15"] + chart.getVisibleDimensionIds = () => ["load1", "load5", "load15"] + chart.isDimensionVisible = () => true + chart.selectDimensionColor = () => "#3366CC" + chart.getThemeAttribute = () => "#E4E8E8" + chart.getConvertedValueWithUnit = value => `${value}` +} + +const nextFrame = () => new Promise(resolve => requestAnimationFrame(resolve)) + +const mountUplot = async overlays => { + const { sdk, chart } = makeTestChart({ + attributes: { + loaded: true, + chartType: "line", + chartLibrary: "uplot", + after, + before, + staticValueRange: [5, 40], + overlays, + }, + }) + withLoadedPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + await Promise.resolve() + await Promise.resolve() + + return { + chart, + instance, + teardown: () => (instance.unmount(), document.body.removeChild(element)), + } +} + +describe("uplot alarmRange overlay", () => { + it("emits a positioned {from,to,width} area for a range within the window", async () => { + const { instance, teardown } = await mountUplot({ + "range-1": { + type: "alarmRange", + whenTriggered: after + 60, + whenLast: after + 300, + status: "warning", + valueTriggered: 5, + }, + }) + + let area + instance.on("overlayedAreaChanged:range-1", next => (area = next)) + + alarmRange(instance, "range-1") + await nextFrame() + + expect(area).toEqual({ + from: expect.any(Number), + to: expect.any(Number), + width: expect.any(Number), + }) + expect(area.width).toBeGreaterThan(0) + + teardown() + }) + + it("emits a null area when the range is out of the visible window", async () => { + const { instance, teardown } = await mountUplot({ + "range-1": { + type: "alarmRange", + whenTriggered: after - 9000, + whenLast: after - 8000, + status: "critical", + valueTriggered: 9, + }, + }) + + let called = false + let area = "unset" + instance.on("overlayedAreaChanged:range-1", next => { + called = true + area = next + }) + + alarmRange(instance, "range-1") + await nextFrame() + + expect(called).toBe(true) + expect(area).toBeUndefined() + + teardown() + }) +}) diff --git a/src/chartLibraries/uplot/overlays/alertTransitions.js b/src/chartLibraries/uplot/overlays/alertTransitions.js new file mode 100644 index 000000000..ed3f53a5f --- /dev/null +++ b/src/chartLibraries/uplot/overlays/alertTransitions.js @@ -0,0 +1,76 @@ +import { trigger } from "./helpers" + +const fillColorMap = { + WARNING: "#FFC300", + CRITICAL: "#FF4136", + CLEAR: "#00AB44", +} + +const OVERLAY_ALPHA = 0.3 + +const getArea = (chartUI, startMs, endMs) => { + const [viewStart, viewEnd] = chartUI.getXAxisRange() + + if (endMs < viewStart || startMs > viewEnd) return null + + const fromX = Math.max(viewStart, startMs) + const toX = Math.min(viewEnd, endMs) + + const from = chartUI.getXCoord(fromX) + const to = chartUI.getXCoord(toX) + const width = to - from + + return { from, to, width } +} + +const parseTimestamp = timestamp => { + if (typeof timestamp === "number") return timestamp * 1000 + return new Date(timestamp).getTime() +} + +export default (chartUI, id) => { + const overlays = chartUI.chart.getAttribute("overlays") + const { transitions = [], showCleared = true } = overlays[id] + + if (!transitions.length) return trigger(chartUI, id) + + const u = chartUI.getUPlot() + const { top, height: h } = chartUI.getPlotArea() + const { ctx } = u + const [, viewEnd] = chartUI.getXAxisRange() + + const sortedTransitions = [...transitions].sort( + (a, b) => parseTimestamp(a.timestamp) - parseTimestamp(b.timestamp) + ) + + ctx.save() + ctx.globalAlpha = OVERLAY_ALPHA + + sortedTransitions.forEach((transition, index) => { + const startMs = parseTimestamp(transition.timestamp) + const nextTransition = sortedTransitions[index + 1] + const endMs = nextTransition ? parseTimestamp(nextTransition.timestamp) : viewEnd + + const toState = transition.to.toUpperCase() + const toColor = fillColorMap[toState] + + if (!toColor) return + if (!showCleared && toState === "CLEAR") return + + const area = getArea(chartUI, startMs, endMs) + + if (!area) return + + const { from, width } = area + + ctx.beginPath() + ctx.rect(from, top, width, h) + ctx.fillStyle = toColor + ctx.fill() + ctx.closePath() + }) + + ctx.restore() + + trigger(chartUI, id) +} diff --git a/src/chartLibraries/uplot/overlays/alertTransitions.test.js b/src/chartLibraries/uplot/overlays/alertTransitions.test.js new file mode 100644 index 000000000..6c7ca7889 --- /dev/null +++ b/src/chartLibraries/uplot/overlays/alertTransitions.test.js @@ -0,0 +1,101 @@ +import { makeTestChart } from "@jest/testUtilities" +import uplotChart from "../index" +import alertTransitions from "./alertTransitions" + +const after = 1617946860 +const before = 1617947760 + +const withLoadedPayload = chart => { + chart.getPayload = () => ({ + data: [ + [after * 1000, 10, 20, 30], + [(after + 5) * 1000, 12, 18, 28], + [(after + 10) * 1000, 11, 22, 31], + ], + labels: ["time", "load1", "load5", "load15"], + }) + chart.getPayloadDimensionIds = () => ["load1", "load5", "load15"] + chart.getVisibleDimensionIds = () => ["load1", "load5", "load15"] + chart.isDimensionVisible = () => true + chart.selectDimensionColor = () => "#3366CC" + chart.getThemeAttribute = () => "#E4E8E8" + chart.getConvertedValueWithUnit = value => `${value}` +} + +const nextFrame = () => new Promise(resolve => requestAnimationFrame(resolve)) + +const mountUplot = async overlays => { + const { sdk, chart } = makeTestChart({ + attributes: { + loaded: true, + chartType: "line", + chartLibrary: "uplot", + after, + before, + staticValueRange: [5, 40], + overlays, + }, + }) + withLoadedPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + await Promise.resolve() + await Promise.resolve() + + return { + chart, + instance, + teardown: () => (instance.unmount(), document.body.removeChild(element)), + } +} + +describe("uplot alertTransitions overlay", () => { + it("draws transition bands and emits without throwing", async () => { + const { instance, teardown } = await mountUplot({ + transitions: { + type: "alertTransitions", + transitions: [ + { timestamp: after + 60, to: "warning" }, + { timestamp: after + 300, to: "critical" }, + { timestamp: after + 600, to: "clear" }, + ], + }, + }) + + let called = false + let area = "unset" + instance.on("overlayedAreaChanged:transitions", next => { + called = true + area = next + }) + + expect(() => alertTransitions(instance, "transitions")).not.toThrow() + await nextFrame() + + expect(called).toBe(true) + expect(area).toBeUndefined() + + teardown() + }) + + it("emits without throwing when there are no transitions", async () => { + const { instance, teardown } = await mountUplot({ + transitions: { type: "alertTransitions", transitions: [] }, + }) + + let called = false + instance.on("overlayedAreaChanged:transitions", () => (called = true)) + + expect(() => alertTransitions(instance, "transitions")).not.toThrow() + await nextFrame() + + expect(called).toBe(true) + + teardown() + }) +}) diff --git a/src/chartLibraries/uplot/overlays/annotation.js b/src/chartLibraries/uplot/overlays/annotation.js new file mode 100644 index 000000000..0fc819db0 --- /dev/null +++ b/src/chartLibraries/uplot/overlays/annotation.js @@ -0,0 +1,103 @@ +import { trigger, getArea } from "./helpers" + +const getTimestampPosition = (chartUI, timestamp) => { + const range = chartUI.getXAxisRange() + if (!range) return null + + const [after, before] = range + const timestampMs = timestamp * 1000 + if (timestampMs < after || timestampMs > before) return null + + const x = chartUI.getXCoord(timestampMs) + return { x, timestampMs } +} + +const drawAnnotationLine = (ctx, x, top, bottom, color, isDraft = false, isSynced = false) => { + ctx.beginPath() + if (isDraft || isSynced) ctx.setLineDash([5, 5]) + ctx.moveTo(x, top) + ctx.lineTo(x, bottom) + ctx.lineWidth = 1 + ctx.strokeStyle = color + ctx.globalAlpha = isSynced ? 0.7 : 1 + ctx.stroke() + ctx.globalAlpha = 1 + if (isDraft || isSynced) ctx.setLineDash([]) +} + +export default (chartUI, id) => { + const draftAnnotation = chartUI.chart.getAttribute("draftAnnotation") + + if (id === "draftAnnotation" && draftAnnotation) { + const { timestamp } = draftAnnotation + const color = "#888888" + + if (!timestamp) return + + const u = chartUI.getUPlot() + if (!u) return + + const { top, height: h } = chartUI.getPlotArea() + const { ctx } = u + + const pos = getTimestampPosition(chartUI, timestamp) + if (!pos) return + + const { x } = pos + const area = { from: x, to: x, width: 0 } + + trigger(chartUI, id, area) + + ctx.save() + drawAnnotationLine(ctx, x, top, top + h, color, true) + + ctx.beginPath() + ctx.arc(x, top, 2, 0, 1 * Math.PI) + ctx.strokeStyle = color + ctx.lineWidth = 1 + ctx.stroke() + + ctx.restore() + return + } + + const overlays = chartUI.chart.getAttribute("overlays") + const annotation = overlays[id] + + if (!annotation || annotation.type !== "annotation") return + + const { timestamp, color = "#ff6b6b", position = "top", originallyFrom } = annotation + const isSynced = !!originallyFrom + + if (!timestamp) return + + const u = chartUI.getUPlot() + if (!u) return + + const { top, height: h } = chartUI.getPlotArea() + const { ctx } = u + + const pos = getTimestampPosition(chartUI, timestamp) + if (!pos) return trigger(chartUI, id) + + const area = getArea(chartUI, [timestamp, timestamp]) + + if (!area) return trigger(chartUI, id) + + trigger(chartUI, id, area) + + const { x } = pos + + ctx.save() + + drawAnnotationLine(ctx, x, top, top + h, color, false, isSynced) + + ctx.beginPath() + ctx.arc(x, position === "top" ? top : top + h, 2, 0, 1 * Math.PI) + ctx.fillStyle = color + ctx.globalAlpha = isSynced ? 0.7 : 1 + ctx.fill() + ctx.globalAlpha = 1 + + ctx.restore() +} diff --git a/src/chartLibraries/uplot/overlays/annotation.test.js b/src/chartLibraries/uplot/overlays/annotation.test.js new file mode 100644 index 000000000..422bbd50a --- /dev/null +++ b/src/chartLibraries/uplot/overlays/annotation.test.js @@ -0,0 +1,140 @@ +import { makeTestChart } from "@jest/testUtilities" +import uplotChart from "../index" +import types from "./types" +import annotation from "./annotation" + +const after = 1617946860 +const before = 1617947760 + +const withLoadedPayload = chart => { + chart.getPayload = () => ({ + data: [ + [after * 1000, 10, 20, 30], + [(after + 5) * 1000, 12, 18, 28], + [(after + 10) * 1000, 11, 22, 31], + ], + labels: ["time", "load1", "load5", "load15"], + }) + chart.getPayloadDimensionIds = () => ["load1", "load5", "load15"] + chart.getVisibleDimensionIds = () => ["load1", "load5", "load15"] + chart.isDimensionVisible = () => true + chart.selectDimensionColor = () => "#3366CC" + chart.getThemeAttribute = () => "#E4E8E8" + chart.getConvertedValueWithUnit = value => `${value}` +} + +const nextFrame = () => new Promise(resolve => requestAnimationFrame(resolve)) + +const mountUplot = async attributes => { + const { sdk, chart } = makeTestChart({ + attributes: { + loaded: true, + chartType: "line", + chartLibrary: "uplot", + after, + before, + staticValueRange: [5, 40], + ...attributes, + }, + }) + withLoadedPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + await Promise.resolve() + await Promise.resolve() + + return { + chart, + instance, + teardown: () => (instance.unmount(), document.body.removeChild(element)), + } +} + +describe("uplot annotation overlay", () => { + it("is registered in the overlay orchestration types", () => { + expect(typeof types.annotation).toBe("function") + }) + + it("emits overlayedAreaChanged with a positioned area for an in-view annotation", async () => { + const { instance, teardown } = await mountUplot({ + overlays: { "ann-1": { type: "annotation", timestamp: after + 60, color: "#ff0000" } }, + }) + + let area + instance.on("overlayedAreaChanged:ann-1", next => (area = next)) + + annotation(instance, "ann-1") + await nextFrame() + + expect(area).toEqual({ + from: expect.any(Number), + to: expect.any(Number), + width: expect.any(Number), + }) + expect(Number.isFinite(area.from)).toBe(true) + + teardown() + }) + + it("emits a null area when the annotation falls outside the visible window", async () => { + const { instance, teardown } = await mountUplot({ + overlays: { "ann-1": { type: "annotation", timestamp: after - 5000, color: "#ff0000" } }, + }) + + let called = false + let area = "unset" + instance.on("overlayedAreaChanged:ann-1", next => { + called = true + area = next + }) + + annotation(instance, "ann-1") + await nextFrame() + + expect(called).toBe(true) + expect(area).toBeUndefined() + + teardown() + }) + + it("draws a dashed draft marker and emits a zero-width area for draftAnnotation", async () => { + const { chart, instance, teardown } = await mountUplot({}) + chart.updateAttribute("draftAnnotation", { timestamp: after + 60, status: "draft" }) + + const u = instance.getUPlot() + const strokeSpy = jest.spyOn(u.ctx, "stroke") + + let area + instance.on("overlayedAreaChanged:draftAnnotation", next => (area = next)) + + annotation(instance, "draftAnnotation") + await nextFrame() + + expect(strokeSpy).toHaveBeenCalled() + expect(area).toEqual({ from: expect.any(Number), to: expect.any(Number), width: 0 }) + + strokeSpy.mockRestore() + teardown() + }) + + it("ignores overlays whose type is not annotation", async () => { + const { instance, teardown } = await mountUplot({ + overlays: { "x-1": { type: "point", timestamp: after + 60 } }, + }) + + let called = false + instance.on("overlayedAreaChanged:x-1", () => (called = true)) + + annotation(instance, "x-1") + await nextFrame() + + expect(called).toBe(false) + + teardown() + }) +}) diff --git a/src/chartLibraries/uplot/overlays/badge.test.js b/src/chartLibraries/uplot/overlays/badge.test.js new file mode 100644 index 000000000..8389dfd87 --- /dev/null +++ b/src/chartLibraries/uplot/overlays/badge.test.js @@ -0,0 +1,65 @@ +import React from "react" +import { screen, act } from "@testing-library/react" +import "@testing-library/jest-dom" +import { renderWithChart } from "@jest/testUtilities" +import overlayComponents from "@/components/line/overlays/types" + +const { + alarm: AlarmOverlay, + alarmRange: AlarmRangeOverlay, + highlight: HighlightOverlay, +} = overlayComponents + +const emit = (chart, id, area) => + act(() => { + chart.getUI().trigger(`overlayedAreaChanged:${id}`, area) + }) + +describe("uplot overlay badges", () => { + it("renders the alarm badge for a uPlot chart once its area is emitted", () => { + const { chart } = renderWithChart(, { + attributes: { + chartLibrary: "uplot", + overlays: { "alarm-1": { type: "alarm", status: "critical", value: 42 } }, + }, + }) + + expect(screen.queryByText(/Triggered value:/)).toBeNull() + + emit(chart, "alarm-1", { from: 10, to: 50, width: 40 }) + + expect(screen.getByText(/Triggered value:/)).toBeInTheDocument() + expect(screen.getByText("42")).toBeInTheDocument() + }) + + it("renders the alarmRange badge for a uPlot chart once its area is emitted", () => { + const { chart } = renderWithChart(, { + attributes: { + chartLibrary: "uplot", + overlays: { + "range-1": { type: "alarmRange", status: "warning", valueTriggered: 7 }, + }, + }, + }) + + emit(chart, "range-1", { from: 10, to: 120, width: 110 }) + + expect(screen.getByText(/Triggered value:/)).toBeInTheDocument() + expect(screen.getByText("7")).toBeInTheDocument() + }) + + it("renders the highlight badge for a focused uPlot chart once its area is emitted", () => { + const { chart } = renderWithChart(, { + attributes: { + chartLibrary: "uplot", + focused: true, + hasCorrelation: false, + overlays: { highlight: { type: "highlight", range: [1617946920, 1617947160] } }, + }, + }) + + emit(chart, "highlight", { from: 100, to: 300, width: 200 }) + + expect(screen.getByText("Range")).toBeInTheDocument() + }) +}) diff --git a/src/chartLibraries/uplot/overlays/helpers.js b/src/chartLibraries/uplot/overlays/helpers.js new file mode 100644 index 000000000..a07091ce9 --- /dev/null +++ b/src/chartLibraries/uplot/overlays/helpers.js @@ -0,0 +1,6 @@ +import { getArea as getNeutralArea } from "@/chartLibraries/helpers/overlayArea" + +export const getArea = (chartUI, range) => getNeutralArea(chartUI, range) + +export const trigger = (chartUI, id, area) => + requestAnimationFrame(() => chartUI.trigger(`overlayedAreaChanged:${id}`, area)) diff --git a/src/chartLibraries/uplot/overlays/highlight.js b/src/chartLibraries/uplot/overlays/highlight.js new file mode 100644 index 000000000..6d1f696e4 --- /dev/null +++ b/src/chartLibraries/uplot/overlays/highlight.js @@ -0,0 +1,41 @@ +import { trigger, getArea } from "./helpers" + +export default (chartUI, id) => { + const overlays = chartUI.chart.getAttribute("overlays") + const { range } = overlays[id] + + if (!range) return + + const u = chartUI.getUPlot() + + const { top, height: h } = chartUI.getPlotArea() + const { ctx } = u + + const area = getArea(chartUI, range) + + if (!area) return trigger(chartUI, id) + + const { from, width } = area + + trigger(chartUI, id, area) + + ctx.save() + ctx.beginPath() + + ctx.rect(from, top, width, h - 1) + ctx.fillStyle = "rgba(207, 213, 218, 0.12)" + ctx.fill() + + ctx.beginPath() + ctx.rect(from, top, 0, h - 1) + ctx.rect(from + width, top, 0, h - 1) + ctx.fill() + ctx.setLineDash([2, 7]) + ctx.lineWidth = 1 + ctx.strokeStyle = "#CFD5DA" + ctx.stroke() + + ctx.stroke() + ctx.closePath() + ctx.restore() +} diff --git a/src/chartLibraries/uplot/overlays/highlight.test.js b/src/chartLibraries/uplot/overlays/highlight.test.js new file mode 100644 index 000000000..12ed8744d --- /dev/null +++ b/src/chartLibraries/uplot/overlays/highlight.test.js @@ -0,0 +1,136 @@ +import { makeTestChart } from "@jest/testUtilities" +import uplotChart from "../index" +import highlight from "./highlight" + +const after = 1617946860 +const before = 1617947760 + +const withLoadedPayload = chart => { + chart.getPayload = () => ({ + data: [ + [after * 1000, 10, 20, 30], + [(after + 5) * 1000, 12, 18, 28], + [(after + 10) * 1000, 11, 22, 31], + ], + labels: ["time", "load1", "load5", "load15"], + }) + chart.getPayloadDimensionIds = () => ["load1", "load5", "load15"] + chart.getVisibleDimensionIds = () => ["load1", "load5", "load15"] + chart.isDimensionVisible = () => true + chart.selectDimensionColor = () => "#3366CC" + chart.getThemeAttribute = () => "#E4E8E8" + chart.getConvertedValueWithUnit = value => `${value}` +} + +const nextFrame = () => new Promise(resolve => requestAnimationFrame(resolve)) + +const mountUplot = async overlays => { + const { sdk, chart } = makeTestChart({ + attributes: { + loaded: true, + chartType: "line", + chartLibrary: "uplot", + after, + before, + staticValueRange: [5, 40], + overlays, + }, + }) + withLoadedPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + await Promise.resolve() + await Promise.resolve() + + return { + chart, + instance, + teardown: () => (instance.unmount(), document.body.removeChild(element)), + } +} + +describe("uplot highlight overlay", () => { + it("emits a positioned {from,to,width} area for a selection within the window", async () => { + const { instance, teardown } = await mountUplot({ + highlight: { type: "highlight", range: [after + 60, after + 300] }, + }) + + let area + instance.on("overlayedAreaChanged:highlight", next => (area = next)) + + highlight(instance, "highlight") + await nextFrame() + + expect(area).toEqual({ + from: expect.any(Number), + to: expect.any(Number), + width: expect.any(Number), + }) + expect(area.width).toBeGreaterThan(0) + + teardown() + }) + + it("reports x coordinates in element space, including the axis gutter", async () => { + const { instance, teardown } = await mountUplot({ + highlight: { type: "highlight", range: [after, after + 300] }, + }) + + let area + instance.on("overlayedAreaChanged:highlight", next => (area = next)) + + highlight(instance, "highlight") + await nextFrame() + + const { left: plotLeft, width: plotWidth } = instance.getPlotArea() + expect(plotLeft).toBeGreaterThan(0) + + expect(area.from).toBeCloseTo(plotLeft, 0) + expect(area.to).toBeGreaterThan(plotLeft) + expect(area.to).toBeLessThanOrEqual(plotLeft + plotWidth + 1) + + teardown() + }) + + it("emits a null area when the selection is out of the visible window", async () => { + const { instance, teardown } = await mountUplot({ + highlight: { type: "highlight", range: [after - 9000, after - 8000] }, + }) + + let called = false + let area = "unset" + instance.on("overlayedAreaChanged:highlight", next => { + called = true + area = next + }) + + highlight(instance, "highlight") + await nextFrame() + + expect(called).toBe(true) + expect(area).toBeUndefined() + + teardown() + }) + + it("does not emit or throw when the range is missing", async () => { + const { instance, teardown } = await mountUplot({ + highlight: { type: "highlight" }, + }) + + let called = false + instance.on("overlayedAreaChanged:highlight", () => (called = true)) + + expect(() => highlight(instance, "highlight")).not.toThrow() + await nextFrame() + + expect(called).toBe(false) + + teardown() + }) +}) diff --git a/src/chartLibraries/uplot/overlays/index.js b/src/chartLibraries/uplot/overlays/index.js new file mode 100644 index 000000000..38b14f05d --- /dev/null +++ b/src/chartLibraries/uplot/overlays/index.js @@ -0,0 +1,41 @@ +import types from "./types" + +export default chartUI => { + const drawOverlay = id => { + const overlays = chartUI.chart.getAttribute("overlays") + const { type } = overlays[id] + const makeOverlay = types[type] + if (!makeOverlay) return + makeOverlay(chartUI, id) + } + + const draw = u => { + const overlays = chartUI.chart.getAttribute("overlays") + const ids = Object.keys(overlays || {}) + const draftAnnotation = chartUI.chart.getAttribute("draftAnnotation") + + if (!ids.length && !draftAnnotation) return + + const dpr = u.pxRatio || 1 + u.ctx.save() + u.ctx.scale(dpr, dpr) + + ids.forEach(drawOverlay) + + if (draftAnnotation) { + const makeOverlay = types["annotation"] + if (makeOverlay) makeOverlay(chartUI, "draftAnnotation") + } + + u.ctx.restore() + } + + const render = () => { + const u = chartUI.getUPlot() + if (u) u.redraw() + } + + const toggle = () => render() + + return { toggle, draw } +} diff --git a/src/chartLibraries/uplot/overlays/index.test.js b/src/chartLibraries/uplot/overlays/index.test.js new file mode 100644 index 000000000..9f7e779f7 --- /dev/null +++ b/src/chartLibraries/uplot/overlays/index.test.js @@ -0,0 +1,123 @@ +import { makeTestChart } from "@jest/testUtilities" +import uplotChart from "../index" +import makeOverlays from "./index" + +const after = 1617946860 +const before = 1617947760 + +const withLoadedPayload = chart => { + chart.getPayload = () => ({ + data: [ + [after * 1000, 10, 20, 30], + [(after + 5) * 1000, 12, 18, 28], + [(after + 10) * 1000, 11, 22, 31], + ], + labels: ["time", "load1", "load5", "load15"], + }) + chart.getPayloadDimensionIds = () => ["load1", "load5", "load15"] + chart.getVisibleDimensionIds = () => ["load1", "load5", "load15"] + chart.isDimensionVisible = () => true + chart.selectDimensionColor = () => "#3366CC" + chart.getThemeAttribute = () => "#E4E8E8" + chart.getConvertedValueWithUnit = value => `${value}` +} + +const nextFrame = () => new Promise(resolve => requestAnimationFrame(resolve)) + +const mountUplot = async overlays => { + const { sdk, chart } = makeTestChart({ + attributes: { + loaded: true, + chartType: "line", + chartLibrary: "uplot", + after, + before, + staticValueRange: [5, 40], + overlays, + }, + }) + withLoadedPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + await Promise.resolve() + await Promise.resolve() + + return { + chart, + instance, + teardown: () => (instance.unmount(), document.body.removeChild(element)), + } +} + +describe("uplot overlays orchestration", () => { + it("exposes toggle and draw", () => { + const { sdk, chart } = makeTestChart({ attributes: { chartLibrary: "uplot" } }) + const overlays = makeOverlays(uplotChart(sdk, chart)) + + expect(typeof overlays.toggle).toBe("function") + expect(typeof overlays.draw).toBe("function") + }) + + it("draws overlays through the uPlot draw hook and emits positions", async () => { + const { instance, teardown } = await mountUplot({ + "alarm-1": { type: "alarm", when: after + 60, status: "critical", value: 42 }, + }) + + let area + instance.on("overlayedAreaChanged:alarm-1", next => (area = next)) + + instance.getUPlot().redraw() + await nextFrame() + + expect(area).toEqual({ + from: expect.any(Number), + to: expect.any(Number), + width: expect.any(Number), + }) + + teardown() + }) + + it("redraws and emits when the overlays attribute changes", async () => { + const { chart, instance, teardown } = await mountUplot({}) + + let area = "unset" + instance.on("overlayedAreaChanged:alarm-late", next => (area = next)) + + chart.updateAttribute("overlays", { + "alarm-late": { type: "alarm", when: after + 120, status: "warning", value: 7 }, + }) + await nextFrame() + + expect(area).toEqual({ + from: expect.any(Number), + to: expect.any(Number), + width: expect.any(Number), + }) + + teardown() + }) + + it("does not throw drawing an empty overlays map", async () => { + const { instance, teardown } = await mountUplot({}) + + expect(() => instance.getUPlot().redraw()).not.toThrow() + + teardown() + }) + + it("ignores overlay ids whose type has no renderer", async () => { + const { instance, teardown } = await mountUplot({ + unknown: { type: "does-not-exist" }, + }) + + expect(() => instance.getUPlot().redraw()).not.toThrow() + + teardown() + }) +}) diff --git a/src/chartLibraries/uplot/overlays/point.js b/src/chartLibraries/uplot/overlays/point.js new file mode 100644 index 000000000..9ca071594 --- /dev/null +++ b/src/chartLibraries/uplot/overlays/point.js @@ -0,0 +1,66 @@ +import { isVisibleDimension } from "@/chartLibraries/helpers/dimensionVisibility" + +const hoverDotRadius = 4 +const sparklineHoverDotRadius = 3 + +const drawMarkers = (chartUI, u, x, row) => { + const { chart } = chartUI + + if (chart.getAttribute("chartType") === "heatmap") return + if (!Number.isFinite(x)) return + + const { top } = chartUI.getPlotArea() + const radius = chart.isSparkline() ? sparklineHoverDotRadius : hoverDotRadius + const dimensionIds = chart.getPayloadDimensionIds() + const { ctx } = u + + ctx.save() + + dimensionIds.forEach((id, index) => { + if (!isVisibleDimension(chart, id)) return + + const series = u.data[index + 1] + const value = series && series[row] + if (value == null) return + + const y = top + u.valToPos(value, "y") + if (!Number.isFinite(y)) return + + ctx.beginPath() + ctx.fillStyle = chart.selectDimensionColor(id) + ctx.arc(x, y, radius, 0, 2 * Math.PI) + ctx.fill() + }) + + ctx.restore() +} + +export default (chartUI, id) => { + const overlays = chartUI.chart.getAttribute("overlays") + const { row } = overlays[id] + + const rowData = chartUI.chart.getPayload().data[row] + + if (!Array.isArray(rowData)) return + + const u = chartUI.getUPlot() + if (!u) return + + const { top, height: h } = chartUI.getPlotArea() + const { ctx } = u + + const x = chartUI.getXCoord(rowData[0]) + + ctx.save() + ctx.beginPath() + ctx.setLineDash([2, 2]) + ctx.strokeStyle = chartUI.chart.getThemeAttribute("themeNetdata") + ctx.moveTo(x, top) + ctx.lineTo(x, top + h) + + ctx.stroke() + ctx.closePath() + ctx.restore() + + drawMarkers(chartUI, u, x, row) +} diff --git a/src/chartLibraries/uplot/overlays/point.test.js b/src/chartLibraries/uplot/overlays/point.test.js new file mode 100644 index 000000000..7e66e8e9e --- /dev/null +++ b/src/chartLibraries/uplot/overlays/point.test.js @@ -0,0 +1,109 @@ +import { makeTestChart } from "@jest/testUtilities" +import uplotChart from "../index" +import types from "./types" +import point from "./point" + +const after = 1617946860 +const before = 1617947760 + +const withLoadedPayload = chart => { + chart.getPayload = () => ({ + data: [ + [after * 1000, 10, 20, 30], + [(after + 5) * 1000, 12, 18, 28], + [(after + 10) * 1000, 11, 22, 31], + ], + labels: ["time", "load1", "load5", "load15"], + }) + chart.getPayloadDimensionIds = () => ["load1", "load5", "load15"] + chart.getVisibleDimensionIds = () => ["load1", "load5", "load15"] + chart.isDimensionVisible = () => true + chart.selectDimensionColor = () => "#3366CC" + chart.getThemeAttribute = () => "#E4E8E8" + chart.getConvertedValueWithUnit = value => `${value}` +} + +const nextFrame = () => new Promise(resolve => requestAnimationFrame(resolve)) + +const mountUplot = async overlays => { + const { sdk, chart } = makeTestChart({ + attributes: { + loaded: true, + chartType: "line", + chartLibrary: "uplot", + after, + before, + staticValueRange: [5, 40], + overlays, + }, + }) + withLoadedPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + await Promise.resolve() + await Promise.resolve() + + return { + chart, + instance, + teardown: () => (instance.unmount(), document.body.removeChild(element)), + } +} + +describe("uplot point overlay", () => { + it("is registered in the overlay orchestration types", () => { + expect(typeof types.point).toBe("function") + }) + + it("draws a crosshair without throwing for an in-view row and emits no area", async () => { + const { instance, teardown } = await mountUplot({ "point-1": { type: "point", row: 1 } }) + + const u = instance.getUPlot() + const strokeSpy = jest.spyOn(u.ctx, "stroke") + + let called = false + instance.on("overlayedAreaChanged:point-1", () => (called = true)) + + expect(() => point(instance, "point-1")).not.toThrow() + await nextFrame() + + expect(strokeSpy).toHaveBeenCalled() + expect(called).toBe(false) + + strokeSpy.mockRestore() + teardown() + }) + + it("draws a marker per visible dimension at the configured row", async () => { + const { instance, teardown } = await mountUplot({ "point-1": { type: "point", row: 1 } }) + + const u = instance.getUPlot() + const arcSpy = jest.spyOn(u.ctx, "arc") + arcSpy.mockClear() + + point(instance, "point-1") + + expect(arcSpy).toHaveBeenCalledTimes(3) + + arcSpy.mockRestore() + teardown() + }) + + it("does not draw markers when the row is out of range", async () => { + const { instance, teardown } = await mountUplot({ "point-1": { type: "point", row: 999 } }) + + const u = instance.getUPlot() + const arcSpy = jest.spyOn(u.ctx, "arc") + + expect(() => point(instance, "point-1")).not.toThrow() + expect(arcSpy).not.toHaveBeenCalled() + + arcSpy.mockRestore() + teardown() + }) +}) diff --git a/src/chartLibraries/uplot/overlays/proceeded.js b/src/chartLibraries/uplot/overlays/proceeded.js new file mode 100644 index 000000000..ebed08a55 --- /dev/null +++ b/src/chartLibraries/uplot/overlays/proceeded.js @@ -0,0 +1,20 @@ +import { trigger, getArea } from "./helpers" + +export default (chartUI, id) => { + const u = chartUI.getUPlot() + if (!u) return + + const [, before] = chartUI.getXAxisRange() + const beforeSecs = before / 1000 + + const firstEntry = chartUI.chart.getFirstEntry() + const { outOfLimits, error } = chartUI.chart.getAttributes() + + if (!outOfLimits && (!firstEntry || firstEntry > beforeSecs) && !error) return + + const range = outOfLimits || error ? [before, before] : [firstEntry, firstEntry] + + const area = getArea(chartUI, range) + + trigger(chartUI, id, area) +} diff --git a/src/chartLibraries/uplot/overlays/proceeded.test.js b/src/chartLibraries/uplot/overlays/proceeded.test.js new file mode 100644 index 000000000..af23fe1d6 --- /dev/null +++ b/src/chartLibraries/uplot/overlays/proceeded.test.js @@ -0,0 +1,126 @@ +import { makeTestChart } from "@jest/testUtilities" +import uplotChart from "../index" +import types from "./types" +import proceeded from "./proceeded" + +const after = 1617946860 +const before = 1617947760 + +const withLoadedPayload = chart => { + chart.getPayload = () => ({ + data: [ + [after * 1000, 10, 20, 30], + [(after + 5) * 1000, 12, 18, 28], + [(after + 10) * 1000, 11, 22, 31], + ], + labels: ["time", "load1", "load5", "load15"], + }) + chart.getPayloadDimensionIds = () => ["load1", "load5", "load15"] + chart.getVisibleDimensionIds = () => ["load1", "load5", "load15"] + chart.isDimensionVisible = () => true + chart.selectDimensionColor = () => "#3366CC" + chart.getThemeAttribute = () => "#E4E8E8" + chart.getConvertedValueWithUnit = value => `${value}` +} + +const nextFrame = () => new Promise(resolve => requestAnimationFrame(resolve)) + +const mountUplot = async attributes => { + const { sdk, chart } = makeTestChart({ + attributes: { + loaded: true, + chartType: "line", + chartLibrary: "uplot", + after, + before, + staticValueRange: [5, 40], + ...attributes, + }, + }) + withLoadedPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + await Promise.resolve() + await Promise.resolve() + + return { + chart, + instance, + teardown: () => (instance.unmount(), document.body.removeChild(element)), + } +} + +describe("uplot proceeded overlay", () => { + it("is registered in the overlay orchestration types", () => { + expect(typeof types.proceeded).toBe("function") + }) + + it("emits overlayedAreaChanged with a positioned area when the first entry is in view", async () => { + const { instance, teardown } = await mountUplot({ + firstEntry: after + 5, + outOfLimits: false, + error: false, + overlays: { proceeded: { type: "proceeded" } }, + }) + + let area + instance.on("overlayedAreaChanged:proceeded", next => (area = next)) + + proceeded(instance, "proceeded") + await nextFrame() + + expect(area).toEqual({ + from: expect.any(Number), + to: expect.any(Number), + width: expect.any(Number), + }) + expect(Number.isFinite(area.from)).toBe(true) + + teardown() + }) + + it("does not emit when there is no first entry in view and no error", async () => { + const { instance, teardown } = await mountUplot({ + firstEntry: before + 5000, + outOfLimits: false, + error: false, + overlays: { proceeded: { type: "proceeded" } }, + }) + + let called = false + instance.on("overlayedAreaChanged:proceeded", () => (called = true)) + + proceeded(instance, "proceeded") + await nextFrame() + + expect(called).toBe(false) + + teardown() + }) + + it("keeps a framed uPlot instance and emits when out of limits, matching dygraph", async () => { + const { instance, teardown } = await mountUplot({ + firstEntry: after + 5, + outOfLimits: true, + error: false, + overlays: { proceeded: { type: "proceeded" } }, + }) + + expect(instance.getUPlot()).not.toBeNull() + + let called = false + instance.on("overlayedAreaChanged:proceeded", () => (called = true)) + + expect(() => proceeded(instance, "proceeded")).not.toThrow() + await nextFrame() + + expect(called).toBe(true) + + teardown() + }) +}) diff --git a/src/chartLibraries/uplot/overlays/types.js b/src/chartLibraries/uplot/overlays/types.js new file mode 100644 index 000000000..035a49048 --- /dev/null +++ b/src/chartLibraries/uplot/overlays/types.js @@ -0,0 +1,9 @@ +import alarm from "./alarm" +import alarmRange from "./alarmRange" +import alertTransitions from "./alertTransitions" +import highlight from "./highlight" +import proceeded from "./proceeded" +import point from "./point" +import annotation from "./annotation" + +export default { alarm, alarmRange, alertTransitions, highlight, proceeded, point, annotation } diff --git a/src/chartLibraries/uplot/plotters/annotations.js b/src/chartLibraries/uplot/plotters/annotations.js new file mode 100644 index 000000000..e3c8a21fe --- /dev/null +++ b/src/chartLibraries/uplot/plotters/annotations.js @@ -0,0 +1,72 @@ +import { enums, parts, check, colors, priorities } from "@/helpers/annotations" +import { getRowPointValue } from "@/sdk/makeChart/getPointValue" +import { isVisibleDimension } from "@/chartLibraries/helpers/dimensionVisibility" + +const annotationLineAlpha = 0.45 +const stripHeight = 4 + +export default chartUI => self => { + if (!chartUI) return + + const { chart } = chartUI + if (!chart.getAttribute("showAnnotations")) return + + const xs = self.data[0] + if (!xs || !xs[1]) return + + const dpr = self.pxRatio || 1 + const ctx = self.ctx + + const minSep = self.valToPos(xs[1], "x", true) - self.valToPos(xs[0], "x", true) + 1 + const barWidth = Math.floor(minSep) + + const columns = chart + .getPayloadDimensionIds() + .reduce((acc, id, index) => (isVisibleDimension(chart, id) ? acc.concat(index + 1) : acc), []) + + const { all, point } = chart.getPayload() + if (!all) return + + const height = stripHeight * dpr + const top = self.bbox.top + self.bbox.height - height + + ctx.save() + + // the loop index is the row: all is row-aligned with the payload data + for (let row = 0; row < xs.length; row++) { + const pointData = all[row] + if (!pointData) continue + + let valueSet = null + + for (let i = 0; i < columns.length; i++) { + const annotation = getRowPointValue(pointData, columns[i], point, "pa") + if (!annotation) continue + + parts.forEach(a => { + if (!check(annotation, enums[a])) return + if (!valueSet) valueSet = new Set() + valueSet.add(a) + }) + } + + if (!valueSet) continue + + const centerX = self.valToPos(xs[row], "x", true) + const values = [...valueSet].sort((a, b) => priorities[a] < priorities[b]) + const previousAlpha = ctx.globalAlpha ?? 1 + + ctx.globalAlpha = annotationLineAlpha + + values.forEach(val => { + ctx.strokeStyle = ctx.fillStyle = colors[val] || "transparent" + + ctx.fillRect(centerX - barWidth / 2, top, barWidth, height) + ctx.strokeRect(centerX - barWidth / 2, top, barWidth, height) + }) + + ctx.globalAlpha = previousAlpha + } + + ctx.restore() +} diff --git a/src/chartLibraries/uplot/plotters/annotations.test.js b/src/chartLibraries/uplot/plotters/annotations.test.js new file mode 100644 index 000000000..ce5130877 --- /dev/null +++ b/src/chartLibraries/uplot/plotters/annotations.test.js @@ -0,0 +1,138 @@ +import { makeTestChart } from "@jest/testUtilities" +import uplotChart from "../index" +import makeAnnotations from "./annotations" + +const after = 1617946860 +const before = 1617947760 + +const withAnnotationsPayload = chart => { + chart.getPayload = () => ({ + data: [ + [after * 1000, 10, 20, 30], + [(after + 5) * 1000, 12, 18, 28], + [(after + 10) * 1000, 11, 22, 31], + ], + all: [ + [after * 1000, { pa: 1 }, { pa: 2 }, { pa: 4 }], + [(after + 5) * 1000, { pa: 0 }, { pa: 0 }, { pa: 0 }], + [(after + 10) * 1000, { pa: 1 }, { pa: 0 }, { pa: 2 }], + ], + point: {}, + labels: ["time", "load1", "load5", "load15", "ANOMALY_RATE", "ANNOTATIONS"], + }) + chart.getPayloadDimensionIds = () => ["load1", "load5", "load15"] + chart.getVisibleDimensionIds = () => ["load1", "load5", "load15"] + chart.isDimensionVisible = () => true + chart.selectDimensionColor = () => "#3366CC" + chart.getThemeAttribute = () => "#E4E8E8" + chart.getConvertedValueWithUnit = value => `${value}` + chart.getClosestRow = tsMs => chart.getPayload().data.findIndex(row => row[0] === tsMs) +} + +const mountUplot = (attributes = {}) => { + const { sdk, chart } = makeTestChart({ + attributes: { + loaded: true, + chartType: "line", + chartLibrary: "uplot", + after, + before, + staticValueRange: [5, 40], + ...attributes, + }, + }) + withAnnotationsPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + + return { + chart, + instance, + teardown: () => (instance.unmount(), document.body.removeChild(element)), + } +} + +describe("uplot annotations strip draw hook", () => { + it("is registered in the uPlot draw hooks", () => { + const { instance, teardown } = mountUplot() + const u = instance.getUPlot() + + expect(() => u.hooks.draw.forEach(hook => hook(u))).not.toThrow() + + teardown() + }) + + it("draws a colored strip per flagged annotation part", () => { + const { instance, teardown } = mountUplot() + const u = instance.getUPlot() + + const fillSpy = jest.spyOn(u.ctx, "fillRect") + const strokeSpy = jest.spyOn(u.ctx, "strokeRect") + + makeAnnotations(instance)(u) + + const points = 3 + expect(fillSpy.mock.calls.length).toBeGreaterThan(points) + expect(strokeSpy).toHaveBeenCalled() + + fillSpy.mockRestore() + strokeSpy.mockRestore() + teardown() + }) + + it("draws nothing for rows carrying no annotation", () => { + const { chart, instance, teardown } = mountUplot() + const u = instance.getUPlot() + const payload = chart.getPayload() + chart.getPayload = () => ({ + ...payload, + all: payload.data.map(row => [row[0], {}, {}, {}]), + }) + + const fillSpy = jest.spyOn(u.ctx, "fillRect") + + makeAnnotations(instance)(u) + + expect(fillSpy).not.toHaveBeenCalled() + + fillSpy.mockRestore() + teardown() + }) + + it("draws the colored strips with reduced intensity and restores the alpha", () => { + const { instance, teardown } = mountUplot() + const u = instance.getUPlot() + + const alphas = [] + jest.spyOn(u.ctx, "fillRect").mockImplementation(() => { + alphas.push(u.ctx.globalAlpha) + }) + + makeAnnotations(instance)(u) + + expect(alphas).toContain(0.45) + expect(u.ctx.globalAlpha).toBe(1) + + u.ctx.fillRect.mockRestore() + teardown() + }) + + it("does not draw when showAnnotations is disabled", () => { + const { instance, teardown } = mountUplot({ showAnnotations: false }) + const u = instance.getUPlot() + + const fillSpy = jest.spyOn(u.ctx, "fillRect") + + makeAnnotations(instance)(u) + + expect(fillSpy).not.toHaveBeenCalled() + + fillSpy.mockRestore() + teardown() + }) +}) diff --git a/src/chartLibraries/uplot/plotters/anomaly.js b/src/chartLibraries/uplot/plotters/anomaly.js new file mode 100644 index 000000000..e08d1fc51 --- /dev/null +++ b/src/chartLibraries/uplot/plotters/anomaly.js @@ -0,0 +1,61 @@ +import { scaleLinear } from "d3-scale" +import { getRowPointValue } from "@/sdk/makeChart/getPointValue" +import { isVisibleDimension } from "@/chartLibraries/helpers/dimensionVisibility" + +const ribbonHeight = 15 + +export default chartUI => self => { + if (!chartUI) return + + const { chart } = chartUI + if (!chart.getAttribute("showAnomalies")) return + + const xs = self.data[0] + if (!xs || !xs[1]) return + + const dpr = self.pxRatio || 1 + const ctx = self.ctx + + const minSep = self.valToPos(xs[1], "x", true) - self.valToPos(xs[0], "x", true) + 1 + const barWidth = Math.floor(minSep) + + const getColor = scaleLinear() + .domain([0, 100]) + .range(["transparent", chart.getThemeAttribute("themeAnomalyScaleColor")]) + + const columns = chart + .getPayloadDimensionIds() + .reduce((acc, id, index) => (isVisibleDimension(chart, id) ? acc.concat(index + 1) : acc), []) + + const { all, point } = chart.getPayload() + if (!all) return + + const top = self.bbox.top + const height = ribbonHeight * dpr + + ctx.save() + + // all is row-aligned with the payload data, and getData maps every row into xs, so the loop + // index is the row - a per-point getClosestRow binary search was pure overhead + for (let row = 0; row < xs.length; row++) { + const pointData = all[row] + if (!pointData) continue + + let value = 0 + + for (let i = 0; i < columns.length; i++) { + const anomalyRate = getRowPointValue(pointData, columns[i], point, "arp") || 0 + if (anomalyRate > value) value = anomalyRate + } + + if (value === 0) continue + + const centerX = self.valToPos(xs[row], "x", true) + + ctx.strokeStyle = ctx.fillStyle = getColor(value) + ctx.fillRect(centerX - barWidth / 2, top, barWidth, height) + ctx.strokeRect(centerX - barWidth / 2, top, barWidth, height) + } + + ctx.restore() +} diff --git a/src/chartLibraries/uplot/plotters/anomaly.test.js b/src/chartLibraries/uplot/plotters/anomaly.test.js new file mode 100644 index 000000000..ba684058f --- /dev/null +++ b/src/chartLibraries/uplot/plotters/anomaly.test.js @@ -0,0 +1,145 @@ +import { makeTestChart } from "@jest/testUtilities" +import uplotChart from "../index" +import makeAnomaly from "./anomaly" + +const after = 1617946860 +const before = 1617947760 + +const withAnomalyPayload = chart => { + chart.getPayload = () => ({ + data: [ + [after * 1000, 10, 20, 30], + [(after + 5) * 1000, 12, 18, 28], + [(after + 10) * 1000, 11, 22, 31], + ], + all: [ + [after * 1000, { arp: 25 }, { arp: 50 }, { arp: 10 }], + [(after + 5) * 1000, { arp: 75 }, { arp: 100 }, { arp: 0 }], + [(after + 10) * 1000, { arp: 0 }, { arp: 5 }, { arp: 90 }], + ], + point: {}, + labels: ["time", "load1", "load5", "load15", "ANOMALY_RATE", "ANNOTATIONS"], + }) + chart.getPayloadDimensionIds = () => ["load1", "load5", "load15"] + chart.getVisibleDimensionIds = () => ["load1", "load5", "load15"] + chart.isDimensionVisible = () => true + chart.selectDimensionColor = () => "#3366CC" + chart.getThemeAttribute = () => "#9F75F9" + chart.getConvertedValueWithUnit = value => `${value}` + chart.getClosestRow = tsMs => chart.getPayload().data.findIndex(row => row[0] === tsMs) +} + +const mountUplot = (attributes = {}) => { + const { sdk, chart } = makeTestChart({ + attributes: { + loaded: true, + chartType: "line", + chartLibrary: "uplot", + after, + before, + staticValueRange: [5, 40], + ...attributes, + }, + }) + withAnomalyPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + + return { + chart, + instance, + teardown: () => (instance.unmount(), document.body.removeChild(element)), + } +} + +describe("uplot anomaly ribbon draw hook", () => { + it("is registered in the uPlot draw hooks", () => { + const { instance, teardown } = mountUplot() + const u = instance.getUPlot() + + expect(u.hooks.draw.length).toBeGreaterThan(0) + expect(() => u.hooks.draw.forEach(hook => hook(u))).not.toThrow() + + teardown() + }) + + it("fills a ribbon rect per data point when showAnomalies is on", () => { + const { chart, instance, teardown } = mountUplot() + const u = instance.getUPlot() + + const fillSpy = jest.spyOn(u.ctx, "fillRect") + const strokeSpy = jest.spyOn(u.ctx, "strokeRect") + + makeAnomaly(instance)(u) + + const points = chart.getPayload().data.length + expect(fillSpy).toHaveBeenCalledTimes(points) + expect(strokeSpy).toHaveBeenCalledTimes(points) + + fillSpy.mockRestore() + strokeSpy.mockRestore() + teardown() + }) + + it("colors the ribbon using the max anomaly rate across selected dimensions", () => { + const { instance, teardown } = mountUplot() + const u = instance.getUPlot() + + const fills = [] + const colorSpy = jest.spyOn(u.ctx, "fillRect").mockImplementation(() => { + fills.push(u.ctx.fillStyle) + }) + + makeAnomaly(instance)(u) + + // row maxima are 50, 100 and 90, so each row gets its own shade and none is transparent + expect(fills).toHaveLength(3) + expect(new Set(fills).size).toBe(3) + fills.forEach(fill => expect(fill).not.toMatch(/^(transparent|rgba\(0, 0, 0, 0\))$/)) + + colorSpy.mockRestore() + teardown() + }) + + it("skips rows whose every selected dimension reports no anomaly", () => { + const { chart, instance, teardown } = mountUplot() + const u = instance.getUPlot() + const payload = chart.getPayload() + chart.getPayload = () => ({ + ...payload, + all: [ + [after * 1000, { arp: 0 }, { arp: 0 }, { arp: 0 }], + [(after + 5) * 1000, { arp: 0 }, { arp: 40 }, { arp: 0 }], + [(after + 10) * 1000, { arp: 0 }, { arp: 0 }, { arp: 0 }], + ], + }) + + const fillSpy = jest.spyOn(u.ctx, "fillRect") + + makeAnomaly(instance)(u) + + expect(fillSpy).toHaveBeenCalledTimes(1) + + fillSpy.mockRestore() + teardown() + }) + + it("does not draw when showAnomalies is disabled", () => { + const { instance, teardown } = mountUplot({ showAnomalies: false }) + const u = instance.getUPlot() + + const fillSpy = jest.spyOn(u.ctx, "fillRect") + + makeAnomaly(instance)(u) + + expect(fillSpy).not.toHaveBeenCalled() + + fillSpy.mockRestore() + teardown() + }) +}) diff --git a/src/chartLibraries/uplot/plotters/anomalyBadge.js b/src/chartLibraries/uplot/plotters/anomalyBadge.js new file mode 100644 index 000000000..adf80ac95 --- /dev/null +++ b/src/chartLibraries/uplot/plotters/anomalyBadge.js @@ -0,0 +1,50 @@ +const badgePath = + "M13.228 3.29597L8.522 0.578973C8.167 0.373973 7.771 0.271973 7.375 0.271973C6.979 0.271973 6.583 0.373973 6.228 0.578973L1.522 3.29597C0.812 3.70597 0.375 4.46297 0.375 5.28297V10.718C0.375 11.537 0.812 12.295 1.522 12.704L6.228 15.421C6.583 15.626 6.979 15.728 7.375 15.728C7.771 15.728 8.167 15.626 8.522 15.421L13.228 12.704C13.938 12.294 14.375 11.537 14.375 10.718V5.28297C14.375 4.46297 13.938 3.70597 13.228 3.29597ZM7.97949 4.76094L7.37505 3.23265L6.7706 4.76094L4.93313 9.40688H4.37505H1.37505V10.7069H4.37505H5.37505H5.81696L5.97949 10.2959L7.37505 6.76735L8.7706 10.2959L9.26618 11.549L9.93839 10.3811L10.375 9.62253L10.8117 10.3811L10.9992 10.7069H11.375H13.375V9.40688H11.7509L10.9384 7.99531L10.375 7.01662L9.8117 7.99531L9.48391 8.56479L7.97949 4.76094Z" + +const badgeWidth = 15 +const badgeHeight = 16 +const badgeScale = 0.6 +const badgeGap = 4 +const badgeColor = "#B596F8" +const badgeAlpha = 0.4 + +let path = null + +const getPath = () => { + if (path) return path + if (typeof Path2D === "undefined") return null + + path = new Path2D(badgePath) + return path +} + +export default chartUI => self => { + const { chart } = chartUI + + if (!chart.getAttribute("showAnomalies")) return + if (chart.getAttribute("enabledYAxis") === false) return + if (chart.isSparkline()) return + if (chart.getAttribute("chartType") === "heatmap") return + + const shape = getPath() + if (!shape) return + + const dpr = self.pxRatio || 1 + const scale = badgeScale * dpr + const width = badgeWidth * scale + const left = self.bbox.left - width - badgeGap * dpr + + if (left < 0) return + + const { ctx } = self + + ctx.save() + ctx.translate(left, self.bbox.top) + ctx.scale(scale, scale) + ctx.globalAlpha = badgeAlpha + ctx.fillStyle = badgeColor + ctx.fill(shape, "evenodd") + ctx.restore() +} + +export const badgeSize = { width: badgeWidth, height: badgeHeight, scale: badgeScale } diff --git a/src/chartLibraries/uplot/stacking.js b/src/chartLibraries/uplot/stacking.js new file mode 100644 index 000000000..99c9e1981 --- /dev/null +++ b/src/chartLibraries/uplot/stacking.js @@ -0,0 +1,172 @@ +const accumulate = ({ columns, rows, getValue, isVisible }) => { + const positive = new Array(rows).fill(0) + const negative = new Array(rows).fill(0) + const bounds = new Array(columns).fill(null) + + for (let index = columns - 1; index >= 0; index--) { + if (isVisible && !isVisible(index)) continue + + const columnBounds = new Array(rows) + + for (let row = 0; row < rows; row++) { + const value = getValue(row, index) + + if (value == null || !Number.isFinite(value)) { + columnBounds[row] = null + continue + } + + const negativeValue = value < 0 + const base = negativeValue ? negative[row] : positive[row] + const end = base + value + + if (negativeValue) negative[row] = end + else positive[row] = end + + columnBounds[row] = [base, end] + } + + bounds[index] = columnBounds + } + + return bounds +} + +export const getStackBounds = (data, columns, isVisible) => + accumulate({ + columns: columns.length, + rows: data.length, + getValue: (row, index) => data[row][index + 1], + isVisible: isVisible && (index => isVisible(columns[index], index)), + }) + +export const getSeriesStackBounds = (seriesData, isVisible) => + accumulate({ + columns: seriesData.length - 1, + rows: seriesData[0]?.length || 0, + getValue: (row, index) => seriesData[index + 1][row], + isVisible, + }) + +export const getStackSegments = (series, length) => { + const segments = [] + let start = 0 + + while (start < length) { + if (!series[start]) { + start++ + continue + } + + let end = start + while (end + 1 < length && series[end + 1]) end++ + + segments.push([start, end]) + start = end + 1 + } + + return segments +} + +export const getStackValueRange = stackBounds => { + let min = 0 + let max = 0 + + stackBounds.forEach(bounds => { + if (!bounds) return + + bounds.forEach(bound => { + if (!bound) return + + const end = bound[1] + if (end < min) min = end + if (end > max) max = end + }) + }) + + return [min, max] +} + +const maxRowsPerPixel = 6 + +const chordDeviation = (columns, index, first, last, progress) => { + let deviation = 0 + + for (let c = 0; c < columns.length; c++) { + const bounds = columns[c] + const from = bounds?.[first] + const at = bounds?.[index] + const to = bounds?.[last] + + if (!from || !at || !to) return Infinity + + const expectedBase = from[0] + (to[0] - from[0]) * progress + const expectedEnd = from[1] + (to[1] - from[1]) * progress + + deviation = Math.max(deviation, Math.abs(at[0] - expectedBase), Math.abs(at[1] - expectedEnd)) + } + + return deviation +} + +const reduceBucket = (target, bucket, columns) => { + if (!bucket.length) return + + if (bucket.length <= maxRowsPerPixel) { + for (let i = 0; i < bucket.length; i++) target.push(bucket[i]) + return + } + + const first = bucket[0] + const last = bucket[bucket.length - 1] + const kept = new Set([0, bucket.length - 1]) + const candidates = [] + + for (let i = 1; i < bucket.length - 1; i++) { + const progress = (bucket[i] - first) / (last - first) + candidates.push({ i, deviation: chordDeviation(columns, bucket[i], first, last, progress) }) + } + + candidates + .sort((a, b) => b.deviation - a.deviation || a.i - b.i) + .slice(0, maxRowsPerPixel - kept.size) + .forEach(({ i }) => kept.add(i)) + + Array.from(kept) + .sort((a, b) => a - b) + .forEach(i => target.push(bucket[i])) +} + +export const selectStackRows = (columns, getX, start, end, plotWidth) => { + const count = end - start + 1 + if (!Number.isFinite(plotWidth) || plotWidth <= 0 || count <= plotWidth * 2) return null + + const rows = [] + let bucket = [] + let pixel = null + + for (let row = start; row <= end; row++) { + const x = getX(row) + + if (!Number.isFinite(x)) { + reduceBucket(rows, bucket, columns) + bucket = [] + pixel = null + rows.push(row) + continue + } + + const nextPixel = Math.round(x) + if (pixel !== null && nextPixel !== pixel) { + reduceBucket(rows, bucket, columns) + bucket = [] + } + + pixel = nextPixel + bucket.push(row) + } + + reduceBucket(rows, bucket, columns) + + return rows +} diff --git a/src/chartLibraries/uplot/stacking.test.js b/src/chartLibraries/uplot/stacking.test.js new file mode 100644 index 000000000..55a17a1fb --- /dev/null +++ b/src/chartLibraries/uplot/stacking.test.js @@ -0,0 +1,140 @@ +import { + getSeriesStackBounds, + getStackBounds, + getStackSegments, + getStackValueRange, + selectStackRows, +} from "./stacking" + +describe("getStackBounds", () => { + it("accumulates the last column from a zero base", () => { + const data = [ + [0, 10, 20, 30], + [1, 1, 2, 3], + ] + const bounds = getStackBounds(data, ["a", "b", "c"]) + + expect(bounds[2][0]).toEqual([0, 30]) + expect(bounds[1][0]).toEqual([30, 50]) + expect(bounds[0][0]).toEqual([50, 60]) + }) + + it("stacks negative values in a separate downward accumulator", () => { + const data = [[0, 5, -3, -2, 4]] + const bounds = getStackBounds(data, ["a", "b", "c", "d"]) + + expect(bounds[3][0]).toEqual([0, 4]) + expect(bounds[2][0]).toEqual([0, -2]) + expect(bounds[1][0]).toEqual([-2, -5]) + expect(bounds[0][0]).toEqual([4, 9]) + }) + + it("skips hidden columns and leaves gaps for null values", () => { + const data = [[0, 5, null, 7]] + const bounds = getStackBounds(data, ["a", "b", "c"], column => column !== "b") + + expect(bounds[2][0]).toEqual([0, 7]) + expect(bounds[1]).toBeNull() + expect(bounds[0][0]).toEqual([7, 12]) + }) +}) + +describe("getSeriesStackBounds", () => { + it("matches the row-major bounds for the same values", () => { + const seriesData = [ + [0, 1], + [5, 5], + [-3, -3], + [4, 4], + ] + const bounds = getSeriesStackBounds(seriesData, () => true) + + expect(bounds[2][0]).toEqual([0, 4]) + expect(bounds[1][0]).toEqual([0, -3]) + expect(bounds[0][0]).toEqual([4, 9]) + }) + + it("leaves a gap instead of stacking a null as zero", () => { + const seriesData = [[0], [5], [null]] + const bounds = getSeriesStackBounds(seriesData, () => true) + + expect(bounds[1][0]).toBeNull() + expect(bounds[0][0]).toEqual([0, 5]) + }) +}) + +describe("getStackValueRange", () => { + it("spans the stack ends of both directions", () => { + const data = [[0, 5, -3, -2, 4]] + const bounds = getStackBounds(data, ["a", "b", "c", "d"]) + + expect(getStackValueRange(bounds)).toEqual([-5, 9]) + }) + + it("keeps zero in range so the bottom band stays visible", () => { + const data = [[0, 10, 18]] + const bounds = getStackBounds(data, ["a", "b"]) + + expect(getStackValueRange(bounds)).toEqual([0, 28]) + }) + + it("collapses to zero when nothing is stacked", () => { + expect(getStackValueRange([null, null])).toEqual([0, 0]) + }) +}) + +describe("getStackSegments", () => { + const bound = [0, 1] + + it("returns a single full-span segment when there are no gaps", () => { + expect(getStackSegments([bound, bound, bound], 3)).toEqual([[0, 2]]) + }) + + it("splits into separate segments around an interior null, leaving the gap empty", () => { + expect(getStackSegments([bound, null, bound], 3)).toEqual([ + [0, 0], + [2, 2], + ]) + }) + + it("handles multiple gaps and contiguous runs", () => { + const series = [bound, bound, null, bound, null, bound, bound] + expect(getStackSegments(series, 7)).toEqual([ + [0, 1], + [3, 3], + [5, 6], + ]) + }) + + it("skips leading and trailing nulls", () => { + expect(getStackSegments([null, bound, bound, null], 4)).toEqual([[1, 2]]) + }) + + it("returns no segments when every value is null", () => { + expect(getStackSegments([null, null, null], 3)).toEqual([]) + }) +}) + +describe("selectStackRows", () => { + const flat = rows => [Array.from({ length: rows }, () => [0, 10])] + + it("keeps every row when the data is no denser than the plot", () => { + expect(selectStackRows(flat(10), row => row, 0, 9, 800)).toBeNull() + }) + + it("thins a dense segment to at most six rows per pixel", () => { + const rows = selectStackRows(flat(100), row => Math.floor(row / 10), 0, 99, 2) + + expect(rows).not.toBeNull() + expect(rows.length).toBeLessThanOrEqual(60) + expect(rows[0]).toBe(0) + expect(rows[rows.length - 1]).toBe(99) + }) + + it("keeps the row deviating most from the bucket chord", () => { + const bounds = Array.from({ length: 20 }, () => [0, 10]) + bounds[7] = [0, 500] + + expect(selectStackRows([bounds], () => 0, 0, 19, 2)).toContain(7) + }) +}) diff --git a/src/components/chartContainer.test.js b/src/components/chartContainer.test.js index d6f45063e..1bffb7a98 100644 --- a/src/components/chartContainer.test.js +++ b/src/components/chartContainer.test.js @@ -38,8 +38,10 @@ describe("ChartContainer", () => { expect(container).toHaveAttribute("height", "300px") }) + // chartContentWrapper only mounts the renderer once loaded, so the pre-load + // state this used to assert is one the app never shows it("renders chart canvas when mounted", () => { - renderWithChart() + renderWithChart(, { attributes: { loaded: true } }) const container = screen.getByTestId("chartContent") const canvas = container.querySelector("canvas") diff --git a/src/components/line/chartContentWrapper.js b/src/components/line/chartContentWrapper.js index 10af430a5..6ca8d8cac 100644 --- a/src/components/line/chartContentWrapper.js +++ b/src/components/line/chartContentWrapper.js @@ -40,6 +40,89 @@ const chartLibraries = { } } + ${cursorStyle} + `, + uplot: css` + & { + .uplot, + .uplot *, + .uplot *::before, + .uplot *::after { + box-sizing: border-box; + } + + .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(128, 128, 128, 0.3); + position: absolute; + pointer-events: none; + } + + .u-cursor-x, + .u-cursor-y { + position: absolute; + left: 0; + top: 0; + pointer-events: none; + will-change: transform; + } + + .u-hz .u-cursor-x, + .u-vt .u-cursor-y { + height: 100%; + border-right: 1px dashed #607d8b; + } + + .u-hz .u-cursor-y, + .u-vt .u-cursor-x { + width: 100%; + border-bottom: 1px dashed #607d8b; + } + + .u-cursor-pt { + position: absolute; + top: 0; + left: 0; + border-radius: 50%; + border: 0 solid; + pointer-events: none; + will-change: transform; + background-clip: padding-box !important; + } + + .u-axis.u-off, + .u-select.u-off, + .u-cursor-x.u-off, + .u-cursor-y.u-off, + .u-cursor-pt.u-off { + display: none; + } + } + ${cursorStyle} `, } diff --git a/src/components/line/overlays/annotation/index.js b/src/components/line/overlays/annotation/index.js index 4c6ddde10..700ca5fc5 100644 --- a/src/components/line/overlays/annotation/index.js +++ b/src/components/line/overlays/annotation/index.js @@ -231,7 +231,7 @@ const AnnotationActions = memo(({ id, annotation, onEdit }) => { event: "annotation_url_copied", annotationId: id, }) - } catch (err) { + } catch { makeLog(chart)({ event: "annotation_url_copy_failed", annotationId: id, @@ -405,38 +405,32 @@ const Annotation = ({ id }) => { const isSynced = !!annotation?.originallyFrom useEffect(() => { - if ( - !annotation || - !annotation.timestamp || - !chart || - chart.getAttribute("chartLibrary") !== "dygraph" - ) - return + if (!annotation || !annotation.timestamp || !chart) return const chartUI = chart.getUI() if (!chartUI) return - const dygraph = chartUI.getDygraph() - if (!dygraph) return + const element = chartUI.getElement() + if (!element) return const handleMouseMove = event => { - const canvas = dygraph.canvas_ - const rect = canvas.getBoundingClientRect() + const rect = element.getBoundingClientRect() const offsetX = event.clientX - rect.left - const annotationX = dygraph.toDomXCoord(annotation.timestamp * 1000) + const annotationX = chartUI.getXCoord(annotation.timestamp * 1000) const isNearAnnotation = Math.abs(offsetX - annotationX) < hoverTolerance setMouseHovered(isNearAnnotation) } - const canvas = dygraph.canvas_ - canvas.addEventListener("mousemove", handleMouseMove) - canvas.addEventListener("mouseleave", () => setMouseHovered(false)) + const handleMouseLeave = () => setMouseHovered(false) + + element.addEventListener("mousemove", handleMouseMove) + element.addEventListener("mouseleave", handleMouseLeave) return () => { - canvas.removeEventListener("mousemove", handleMouseMove) - canvas.removeEventListener("mouseleave", () => setMouseHovered(false)) + element.removeEventListener("mousemove", handleMouseMove) + element.removeEventListener("mouseleave", handleMouseLeave) } }, [annotation, annotation?.timestamp, chart, id]) diff --git a/src/components/line/overlays/annotation/index.test.js b/src/components/line/overlays/annotation/index.test.js new file mode 100644 index 000000000..122e40a89 --- /dev/null +++ b/src/components/line/overlays/annotation/index.test.js @@ -0,0 +1,95 @@ +import React from "react" +import { fireEvent } from "@testing-library/react" +import { makeTestChart, renderWithChart } from "@jest/testUtilities" +import uplotChart from "@/chartLibraries/uplot" +import Annotation from "./index" + +const after = 1617946860 +const before = 1617947760 +const timestamp = after + 5 + +const withLoadedPayload = chart => { + chart.getPayload = () => ({ + data: [ + [after * 1000, 10, 20, 30], + [(after + 5) * 1000, 12, 18, 28], + [(after + 10) * 1000, 11, 22, 31], + ], + labels: ["time", "load1", "load5", "load15"], + }) + chart.getPayloadDimensionIds = () => ["load1", "load5", "load15"] + chart.getVisibleDimensionIds = () => ["load1", "load5", "load15"] + chart.isDimensionVisible = () => true + chart.selectDimensionColor = () => "#3366CC" + chart.getThemeAttribute = () => "#E4E8E8" + chart.getConvertedValueWithUnit = value => `${value}` +} + +const setup = async () => { + const { sdk, chart } = makeTestChart({ + attributes: { + loaded: true, + chartType: "line", + chartLibrary: "uplot", + after, + before, + staticValueRange: [5, 40], + overlays: { + "ann-1": { type: "annotation", timestamp, text: "deploy", color: "#ff0000" }, + }, + }, + }) + withLoadedPayload(chart) + + const instance = uplotChart(sdk, chart) + const element = document.createElement("div") + element.style.width = "800px" + element.style.height = "300px" + document.body.appendChild(element) + instance.mount(element) + chart.setUI(instance) + await Promise.resolve() + await Promise.resolve() + + return { + sdk, + chart, + instance, + element, + teardown: () => (instance.unmount(), document.body.removeChild(element)), + } +} + +describe("Annotation popover proximity (renderer-agnostic)", () => { + it("shows the popover for chartLibrary uplot when the cursor is near the annotation", async () => { + const { chart, instance, element, teardown } = await setup() + + const getXCoordSpy = jest.spyOn(instance, "getXCoord") + + const { queryByText } = renderWithChart(, { chart }) + + expect(queryByText("deploy")).toBeNull() + + const annotationX = instance.getXCoord(timestamp * 1000) + fireEvent.mouseMove(element, { clientX: annotationX, clientY: 50 }) + + expect(getXCoordSpy).toHaveBeenCalled() + expect(queryByText("deploy")).not.toBeNull() + + getXCoordSpy.mockRestore() + teardown() + }) + + it("keeps the popover hidden when the cursor is far from the annotation", async () => { + const { chart, instance, element, teardown } = await setup() + + const { queryByText } = renderWithChart(, { chart }) + + const annotationX = instance.getXCoord(timestamp * 1000) + fireEvent.mouseMove(element, { clientX: annotationX + 200, clientY: 50 }) + + expect(queryByText("deploy")).toBeNull() + + teardown() + }) +}) diff --git a/src/components/perf/index.js b/src/components/perf/index.js new file mode 100644 index 000000000..024f52687 --- /dev/null +++ b/src/components/perf/index.js @@ -0,0 +1,62 @@ +import React, { 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)} +
+ {Object.entries(renderers).map(([name, s]) => ( +
+ {name}: {s.count} · p95 {ms(s.p95)} +
+ ))} +
heap: {heap.supported ? `${mb(heap.current)} (peak ${mb(heap.peak)})` : "n/a"}
+ + +
+ ) +} + +export default PerfOverlay diff --git a/src/components/perf/index.test.js b/src/components/perf/index.test.js new file mode 100644 index 000000000..3e7e6f2c1 --- /dev/null +++ b/src/components/perf/index.test.js @@ -0,0 +1,34 @@ +import React from "react" +import { render, screen } from "@testing-library/react" +import "@testing-library/jest-dom" +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() + }) +}) diff --git a/src/components/provider/selectors.js b/src/components/provider/selectors.js index a9586be50..28c6a9da6 100644 --- a/src/components/provider/selectors.js +++ b/src/components/provider/selectors.js @@ -687,10 +687,10 @@ export const usePlotArea = (uiName = "default") => { [uiName, chart] ) - const area = chart.getUI(uiName)?.getDygraph?.()?.getArea() + const area = chart.getUI(uiName)?.getPlotArea?.() return { - left: area?.x ?? 0, - width: area?.w ?? 0, + left: area?.left ?? 0, + width: area?.width ?? 0, } } diff --git a/src/components/provider/usePlotArea.test.js b/src/components/provider/usePlotArea.test.js new file mode 100644 index 000000000..ed7226217 --- /dev/null +++ b/src/components/provider/usePlotArea.test.js @@ -0,0 +1,14 @@ +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 }) + }) +}) diff --git a/src/components/toolbox/chartType.js b/src/components/toolbox/chartType.js index ed32ac42e..68bfc2d8a 100644 --- a/src/components/toolbox/chartType.js +++ b/src/components/toolbox/chartType.js @@ -130,10 +130,10 @@ const ChartType = ({ disabled }) => { const chart = useChart() const chartLibrary = useAttributeValue("chartLibrary") || "dygraph" const chartType = useAttributeValue("chartType") || "line" - const value = chartLibrary === "dygraph" ? chartType : chartLibrary + const value = chart.isTimeSeriesRenderer(chartLibrary) ? chartType : chartLibrary const items = useItems(chart) - const { label, svg } = items.find(({ value: v }) => v === value) + const { label, svg } = items.find(({ value: v }) => v === value) || {} return ( { expect(chart.getHeatmapType).toBeDefined() }) + it("does not throw when the time-series renderer is non-dygraph", () => { + const { chart } = makeTestChart({ + attributes: { chartLibrariesByType: { line: "uplot" } }, + }) + chart.updateAttributes({ chartLibrary: "uplot", chartType: "line" }) + + expect(() => renderWithChart(, { chart })).not.toThrow() + expect(screen.getByTestId("chartHeaderToolbox-chartType")).toBeInTheDocument() + }) + it("excludes heatmap option when disabled", () => { renderWithChart(, { attributes: { diff --git a/src/components/toolbox/settings/tabs/chartType.js b/src/components/toolbox/settings/tabs/chartType.js index e8a0fef69..b68d5a496 100644 --- a/src/components/toolbox/settings/tabs/chartType.js +++ b/src/components/toolbox/settings/tabs/chartType.js @@ -141,7 +141,7 @@ const ChartType = () => { const chart = useChart() const chartLibrary = useAttributeValue("chartLibrary") || "dygraph" const chartType = useAttributeValue("chartType") || "line" - const value = chartLibrary === "dygraph" ? chartType : chartLibrary + const value = chart.isTimeSeriesRenderer(chartLibrary) ? chartType : chartLibrary const options = useOptions(chart) const grouped = useMemo(() => groupedOptions(options), [options]) diff --git a/src/components/toolbox/settings/tabs/chartType.test.js b/src/components/toolbox/settings/tabs/chartType.test.js new file mode 100644 index 000000000..58be1e8ec --- /dev/null +++ b/src/components/toolbox/settings/tabs/chartType.test.js @@ -0,0 +1,28 @@ +import React from "react" +import { screen } from "@testing-library/react" +import "@testing-library/jest-dom" +import { renderWithChart, makeTestChart } from "@jest/testUtilities" +import ChartType from "./chartType" + +describe("settings ChartType", () => { + it("shows the chart type as the selected value for the dygraph renderer", () => { + const { chart } = makeTestChart({ + attributes: { chartLibrary: "dygraph", chartType: "area" }, + }) + + renderWithChart(, { chart }) + + expect(screen.getByText("Area")).toBeInTheDocument() + }) + + it("resolves to the chart type when the time-series renderer is non-dygraph", () => { + const { chart } = makeTestChart({ + attributes: { chartLibrariesByType: { line: "uplot" } }, + }) + chart.updateAttributes({ chartLibrary: "uplot", chartType: "line" }) + + renderWithChart(, { chart }) + + expect(screen.getByText("Line")).toBeInTheDocument() + }) +}) diff --git a/src/helpers/deepMerge/index.js b/src/helpers/deepMerge/index.js deleted file mode 100644 index 24f0eaee8..000000000 --- a/src/helpers/deepMerge/index.js +++ /dev/null @@ -1,39 +0,0 @@ -import { filter } from "@/helpers/deepEqual" - -const deepMergeArray = (arrA, arrB, options) => { - const filteredB = filter(arrA, options) - - return filteredB.reduce((h, element, index) => { - h.push(deepMerge(element, filteredB[index])) - return h - }, []) -} - -const deepMergeObject = (objA, objB, options) => { - const keysB = filter(Object.keys(objB), options) - - const aHasOwnProperty = Object.prototype.hasOwnProperty.bind(objA) - - keysB.reduce((h, key) => { - if (!aHasOwnProperty(key)) { - h[key] = objB[key] - } else { - h[key] = deepMerge(objA[key], objB[key]) - } - - return h - }, {}) -} - -const deepMerge = (objA, objB, options = {}) => { - if (objA === objB) return objB - - if (typeof objA !== "object" || objA === null || typeof objB !== "object" || objB === null) - return objB - - return Array.isArray(objB) - ? deepMergeArray(objA, objB, options) - : deepMergeObject(objA, objB, options) -} - -export default deepMerge diff --git a/src/helpers/deepMerge/index.test.js b/src/helpers/deepMerge/index.test.js deleted file mode 100644 index 3ef3bc575..000000000 --- a/src/helpers/deepMerge/index.test.js +++ /dev/null @@ -1,46 +0,0 @@ -import deepMerge from "." - -describe("deepMerge", () => { - it("returns objB when objA equals objB", () => { - const obj = { a: 1 } - expect(deepMerge(obj, obj)).toBe(obj) - }) - - it("returns objB when objA is null", () => { - const objB = { a: 1 } - expect(deepMerge(null, objB)).toBe(objB) - }) - - it("returns objB when objB is null", () => { - const objA = { a: 1 } - expect(deepMerge(objA, null)).toBeNull() - }) - - it("returns objB when objA is not an object", () => { - expect(deepMerge("string", { a: 1 })).toEqual({ a: 1 }) - expect(deepMerge(42, { a: 1 })).toEqual({ a: 1 }) - }) - - it("returns objB when objB is not an object", () => { - expect(deepMerge({ a: 1 }, "string")).toBe("string") - expect(deepMerge({ a: 1 }, 42)).toBe(42) - }) - - it("detects implementation bug in deepMergeObject", () => { - // This test documents that deepMergeObject doesn't return its result - const objA = { a: 1, b: 2 } - const objB = { b: 3, c: 4 } - const result = deepMerge(objA, objB) - - expect(result).toBeUndefined() - }) - - it("detects implementation bug in deepMergeArray", () => { - // This test documents current behavior - arrays return original arrA - const arrA = [1, 2, 3] - const arrB = [4, 5] - const result = deepMerge(arrA, arrB) - - expect(result).toEqual([1, 2, 3]) - }) -}) diff --git a/src/index.stories.js b/src/index.stories.js index 7abca2871..2449cbcf5 100644 --- a/src/index.stories.js +++ b/src/index.stories.js @@ -14,13 +14,32 @@ import systemLoadLine from "../fixtures/systemLoadLine" const getChart = makeMockPayload(systemLoadLine[0], { delay: 600 }) -export const Simple = () => { - const sdk = makeDefaultSDK() - const chart = sdk.makeChart({ - getChart, - attributes: { contextScope: ["system.load"] }, +const chartLibraryArgTypes = { + chartLibrary: { + name: "Chart library", + control: "select", + options: ["dygraph", "uplot"], + }, +} + +const makeSdkWithLibrary = (chartLibrary = "dygraph", sdkAttributes = {}) => + makeDefaultSDK({ + attributes: { + chartLibrary, + ...sdkAttributes, + }, }) - sdk.appendChild(chart) + +export const Simple = ({ chartLibrary }) => { + const chart = useMemo(() => { + const sdk = makeSdkWithLibrary(chartLibrary) + const chart = sdk.makeChart({ + getChart, + attributes: { contextScope: ["system.load"] }, + }) + sdk.appendChild(chart) + return chart + }, [chartLibrary]) return ( @@ -29,17 +48,17 @@ export const Simple = () => { ) } -export const Width = () => { +export const Width = ({ chartLibrary }) => { const [width, setWidth] = useState(false) const chart = useMemo(() => { - const sdk = makeDefaultSDK() + const sdk = makeSdkWithLibrary(chartLibrary) const chart = sdk.makeChart({ getChart, attributes: { contextScope: ["system.load"], navigation: "selectVertical" }, }) sdk.appendChild(chart) return chart - }, []) + }, [chartLibrary]) return ( @@ -53,10 +72,13 @@ export const Width = () => { ) } -export const SimpleDark = () => { - const sdk = makeDefaultSDK({ attributes: { contextScope: ["system.load"], theme: "dark" } }) - const chart = sdk.makeChart({ getChart }) - sdk.appendChild(chart) +export const SimpleDark = ({ chartLibrary }) => { + const chart = useMemo(() => { + const sdk = makeSdkWithLibrary(chartLibrary, { contextScope: ["system.load"], theme: "dark" }) + const chart = sdk.makeChart({ getChart }) + sdk.appendChild(chart) + return chart + }, [chartLibrary]) return ( @@ -69,13 +91,16 @@ export const SimpleDark = () => { SimpleDark.parameters = { netdataTheme: "dark" } -export const NoData = () => { - const sdk = makeDefaultSDK() - const chart = sdk.makeChart({ - getChart: () => new Promise(r => setTimeout(() => r(noData), 600)), - attributes: { contextScope: ["system.load"] }, - }) - sdk.appendChild(chart) +export const NoData = ({ chartLibrary }) => { + const chart = useMemo(() => { + const sdk = makeSdkWithLibrary(chartLibrary) + const chart = sdk.makeChart({ + getChart: () => new Promise(r => setTimeout(() => r(noData), 600)), + attributes: { contextScope: ["system.load"] }, + }) + sdk.appendChild(chart) + return chart + }, [chartLibrary]) return ( @@ -99,9 +124,9 @@ const TimezonePicker = withChartProvider(() => { ) }) -export const Timezone = () => { +export const Timezone = ({ chartLibrary }) => { const chart = useMemo(() => { - const sdk = makeDefaultSDK() + const sdk = makeSdkWithLibrary(chartLibrary) const chart = sdk.makeChart({ getChart, attributes: { contextScope: ["system.load"], timezone: "Pacific/Honolulu" }, @@ -109,7 +134,7 @@ export const Timezone = () => { sdk.appendChild(chart) return chart - }, []) + }, [chartLibrary]) return ( @@ -158,14 +183,14 @@ const TimePicker = withChartProvider(() => { ) }) -export const Timepicker = () => { +export const Timepicker = ({ chartLibrary }) => { const chart = useMemo(() => { - const sdk = makeDefaultSDK() + const sdk = makeSdkWithLibrary(chartLibrary) const chart = sdk.makeChart({ getChart, attributes: { contextScope: ["system.load"] } }) sdk.appendChild(chart) return chart - }, []) + }, [chartLibrary]) return ( @@ -177,16 +202,19 @@ export const Timepicker = () => { ) } -export const SelectedDimensions = () => { - const sdk = makeDefaultSDK() - const chart = sdk.makeChart({ - getChart, - attributes: { - contextScope: ["system.load"], - selectedDimensions: ["load5", "load15"], - }, - }) - sdk.appendChild(chart) +export const SelectedDimensions = ({ chartLibrary }) => { + const chart = useMemo(() => { + const sdk = makeSdkWithLibrary(chartLibrary) + const chart = sdk.makeChart({ + getChart, + attributes: { + contextScope: ["system.load"], + selectedDimensions: ["load5", "load15"], + }, + }) + sdk.appendChild(chart) + return chart + }, [chartLibrary]) return ( @@ -195,8 +223,8 @@ export const SelectedDimensions = () => { ) } -export const AlertInTimeWindow = () => { - const sdk = makeDefaultSDK() +export const AlertInTimeWindow = ({ chartLibrary }) => { + const sdk = makeSdkWithLibrary(chartLibrary) const chart = sdk.makeChart({ getChart, @@ -221,8 +249,8 @@ export const AlertInTimeWindow = () => { ) } -export const AlertTransitions = () => { - const sdk = makeDefaultSDK() +export const AlertTransitions = ({ chartLibrary }) => { + const sdk = makeSdkWithLibrary(chartLibrary) const now = Math.floor(Date.now() / 1000) const chart = sdk.makeChart({ @@ -270,8 +298,8 @@ export const AlertTransitions = () => { ) } -export const AlertTransitionsDark = () => { - const sdk = makeDefaultSDK({ attributes: { theme: "dark" } }) +export const AlertTransitionsDark = ({ chartLibrary }) => { + const sdk = makeSdkWithLibrary(chartLibrary, { theme: "dark" }) const now = Math.floor(Date.now() / 1000) const chart = sdk.makeChart({ @@ -318,8 +346,8 @@ const alertTransitionsData = [ { timestamp: -3 * 60, from: "WARNING", to: "CLEAR", value: 45.0 }, ] -export const AlertTransitionsWithTimeline = () => { - const sdk = makeDefaultSDK({ attributes: { theme: "dark", syncHover: true } }) +export const AlertTransitionsWithTimeline = ({ chartLibrary }) => { + const sdk = makeSdkWithLibrary(chartLibrary, { theme: "dark", syncHover: true }) const now = Math.floor(Date.now() / 1000) const transitions = alertTransitionsData.map(t => ({ @@ -354,8 +382,8 @@ export const AlertTransitionsWithTimeline = () => { AlertTransitionsWithTimeline.parameters = { netdataTheme: "dark" } -export const HighlightInTimeWindow = () => { - const sdk = makeDefaultSDK() +export const HighlightInTimeWindow = ({ chartLibrary }) => { + const sdk = makeSdkWithLibrary(chartLibrary) const chart = sdk.makeChart({ getChart, @@ -378,9 +406,9 @@ export const HighlightInTimeWindow = () => { ) } -export const Timeout = () => { +export const Timeout = ({ chartLibrary }) => { let requests = 0 - const sdk = makeDefaultSDK() + const sdk = makeSdkWithLibrary(chartLibrary) const chart = sdk.makeChart({ getChart: params => { if (requests++ % 2 === 1) @@ -400,9 +428,9 @@ export const Timeout = () => { ) } -export const Error = () => { +export const Error = ({ chartLibrary }) => { let requests = 0 - const sdk = makeDefaultSDK() + const sdk = makeSdkWithLibrary(chartLibrary) const chart = sdk.makeChart({ getChart: params => { if (requests++ % 2 === 1) @@ -422,8 +450,8 @@ export const Error = () => { ) } -export const InitialLoading = () => { - const sdk = makeDefaultSDK() +export const InitialLoading = ({ chartLibrary }) => { + const sdk = makeSdkWithLibrary(chartLibrary) const chart = sdk.makeChart({ getChart: () => new Promise(() => {}), attributes: { contextScope: ["system.load"] }, @@ -449,8 +477,8 @@ export const InitialLoading = () => { ) } -export const Multiple = () => { - const sdk = makeDefaultSDK() +export const Multiple = ({ chartLibrary }) => { + const sdk = makeSdkWithLibrary(chartLibrary) const charts = Array.from(Array(10)).map((v, index) => { const chart = sdk.makeChart({ @@ -473,8 +501,8 @@ export const Multiple = () => { ) } -export const Sync = () => { - const sdk = makeDefaultSDK() +export const Sync = ({ chartLibrary }) => { + const sdk = makeSdkWithLibrary(chartLibrary) const charts = Array.from(Array(3)).map((v, index) => { const chart = sdk.makeChart({ @@ -496,8 +524,8 @@ export const Sync = () => { ) } -export const WithAnnotations = () => { - const sdk = makeDefaultSDK() +export const WithAnnotations = ({ chartLibrary }) => { + const sdk = makeSdkWithLibrary(chartLibrary) const chart = sdk.makeChart({ getChart, @@ -544,8 +572,8 @@ export const WithAnnotations = () => { ) } -export const AnnotationCreation = () => { - const sdk = makeDefaultSDK() +export const AnnotationCreation = ({ chartLibrary }) => { + const sdk = makeSdkWithLibrary(chartLibrary) const chart = sdk.makeChart({ getChart, @@ -568,8 +596,8 @@ export const AnnotationCreation = () => { ) } -export const CrossChartAnnotationSync = () => { - const sdk = makeDefaultSDK() +export const CrossChartAnnotationSync = ({ chartLibrary }) => { + const sdk = makeSdkWithLibrary(chartLibrary) const charts = Array.from(Array(3)).map((v, index) => { const chart = sdk.makeChart({ @@ -623,4 +651,6 @@ export const CrossChartAnnotationSync = () => { export default { title: "Charts", component: Simple, + argTypes: chartLibraryArgTypes, + args: { chartLibrary: "dygraph" }, } diff --git a/src/makeDefaultSDK.js b/src/makeDefaultSDK.js index 85602cb00..7cc31e87f 100644 --- a/src/makeDefaultSDK.js +++ b/src/makeDefaultSDK.js @@ -1,4 +1,5 @@ import dygraph from "./chartLibraries/dygraph" +import uplot from "./chartLibraries/uplot" import easypiechart from "./chartLibraries/easyPie" import gauge from "./chartLibraries/gauge" import number from "./chartLibraries/number" @@ -16,12 +17,13 @@ import selectVertical from "./sdk/plugins/selectVertical" import play from "./sdk/plugins/play" import annotationSync from "./sdk/plugins/annotationSync" import fullscreen from "./sdk/plugins/fullscreen" +import perfMonitor from "./sdk/plugins/perfMonitor" const minutes15 = 15 * 60 export default ({ attributes, ...options } = {}) => makeSDK({ - ui: { dygraph, easypiechart, gauge, groupBoxes, number, d3pie, bars, table }, + ui: { dygraph, uplot, easypiechart, gauge, groupBoxes, number, d3pie, bars, table }, plugins: { // order matters move, @@ -33,10 +35,12 @@ export default ({ attributes, ...options } = {}) => play, annotationSync, fullscreen, + perfMonitor, }, attributes: { _v: "v3", chartLibrary: "dygraph", + chartLibrariesByType: {}, navigation: "pan", after: -1 * minutes15, overlays: { proceeded: { type: "proceeded" } }, diff --git a/src/parity.stories.js b/src/parity.stories.js new file mode 100644 index 000000000..bf4f04019 --- /dev/null +++ b/src/parity.stories.js @@ -0,0 +1,90 @@ +import React, { useEffect, useMemo } from "react" +import { ThemeProvider } from "styled-components" +import { Flex, DefaultTheme, TextSmall, TextMicro } from "@netdata/netdata-ui" +import Line from "@/components/line" +import makeMockPayload from "@/helpers/makeMockPayload" +import makeDefaultSDK from "./makeDefaultSDK" +import systemLoadLine from "../fixtures/systemLoadLine" + +const [payload] = systemLoadLine + +const libraries = ["dygraph", "uplot"] + +const rows = [ + { chartType: "line", label: "line" }, + { chartType: "line", label: "line, stepped", attributes: { stepPlot: true } }, + { chartType: "area", label: "area" }, + { chartType: "stacked", label: "stacked" }, + { chartType: "stackedBar", label: "stackedBar" }, + { chartType: "multiBar", label: "multiBar" }, + { + chartType: "line", + label: "sparkline", + attributes: { sparkline: true }, + height: "60px", + bare: true, + }, +] + +const useParityCharts = (chartType, attributes) => { + const charts = useMemo(() => { + const sdk = makeDefaultSDK({ + attributes: { theme: "default", navigation: "pan", expandable: false }, + }) + + const made = libraries.map(chartLibrary => { + const chart = sdk.makeChart({ + getChart: makeMockPayload(payload, { delay: 0 }), + attributes: { chartLibrary, chartType, ...attributes }, + }) + sdk.appendChild(chart) + return chart + }) + + return made + }, [chartType, JSON.stringify(attributes)]) + + useEffect(() => () => charts.forEach(chart => chart.destroy()), [charts]) + + return charts +} + +const ParityRow = ({ chartType, label, attributes, height = "220px", bare = false }) => { + const charts = useParityCharts(chartType, attributes) + + return ( + + {label} + + {charts.map((chart, index) => ( + + {libraries[index]} + + + + + ))} + + + ) +} + +export const SideBySide = () => ( + + + {rows.map(row => ( + + ))} + + +) + +export default { + title: "Charts/uPlot/Parity", + parameters: { layout: "fullscreen" }, +} diff --git a/src/perf.stories.js b/src/perf.stories.js new file mode 100644 index 000000000..8f671e3e5 --- /dev/null +++ b/src/perf.stories.js @@ -0,0 +1,172 @@ +import React, { useMemo } from "react" +import { ThemeProvider } from "styled-components" +import { Flex, DefaultTheme } from "@netdata/netdata-ui" +import Line from "@/components/line" +import makeMockPayload from "@/helpers/makeMockPayload" +import makeDefaultSDK from "./makeDefaultSDK" +import systemLoadLine from "../fixtures/systemLoadLine" + +const [basePayload] = systemLoadLine + +// deterministic, so repeated benchmark runs compare like for like +const pseudoRandom = seed => { + const value = Math.sin(seed) * 10000 + return value - Math.floor(value) +} + +const makeSeries = (rows, dims) => { + const data = new Array(rows) + const startMs = basePayload.result.data[0][0] + + for (let row = 0; row < rows; row++) { + const point = new Array(dims + 1) + point[0] = startMs + row * 1000 + + for (let dim = 0; dim < dims; dim++) { + const wave = Math.sin((row / 30) * (1 + dim * 0.1)) * (10 + dim) + point[dim + 1] = 20 + dim * 2 + wave + pseudoRandom(row * (dim + 1)) * 4 + } + + data[row] = point + } + + return data +} + +const makeSyntheticPayload = (rows, dims, chartType) => { + const ids = Array.from({ length: dims }, (v, index) => `dim${index}`) + const fill = value => ids.map(() => value) + + return { + ...basePayload, + view: { + ...basePayload.view, + update_every: 1, + chart_type: chartType === "heatmap" ? "line" : chartType, + dimensions: { + grouped_by: ["dimension"], + ids, + names: ids, + units: fill("load"), + priorities: ids.map((id, index) => index), + aggregated: fill(1), + sts: { + min: fill(0), + max: fill(60), + avg: fill(25), + arp: fill(0), + con: fill(100 / dims), + }, + }, + }, + summary: { + ...basePayload.summary, + dimensions: ids.map((id, index) => ({ + id, + ds: { sl: 1, qr: 1 }, + sts: { min: 0, max: 60, avg: 25, con: 100 / dims }, + pri: index, + })), + }, + result: { + ...basePayload.result, + labels: ["time", ...ids], + data: makeSeries(rows, dims), + }, + } +} + +export const Benchmark = ({ + chartLibrary, + count, + rows, + dims, + chartType, + height, + streaming, + autofetchOnHovering, +}) => { + const isStreaming = streaming !== false && streaming !== "false" + const hoverKeepsFetching = autofetchOnHovering === true || autofetchOnHovering === "true" + + const charts = useMemo(() => { + const numericCount = Number(count) + + const getChart = makeMockPayload( + makeSyntheticPayload(Number(rows), Number(dims), chartType), + { delay: 300 } + ) + + const sdk = makeDefaultSDK({ + attributes: { + chartLibrary, + perfMonitor: true, + syncHover: true, + autofetchOnHovering: hoverKeepsFetching, + }, + }) + + return Array.from({ length: numericCount }, () => { + const chart = sdk.makeChart({ + getChart, + attributes: { + contextScope: ["system.load"], + chartType, + syncHover: true, + autofetch: isStreaming, + after: -600, + }, + }) + sdk.appendChild(chart) + return chart + }) + }, [chartLibrary, count, rows, dims, chartType, isStreaming, hoverKeepsFetching]) + + return ( + + + {charts.map(chart => ( + + ))} + + + ) +} + +Benchmark.args = { + chartLibrary: "dygraph", + count: 25, + rows: 300, + dims: 3, + chartType: "line", + height: "300px", + streaming: true, + autofetchOnHovering: false, +} +Benchmark.argTypes = { + chartLibrary: { name: "Chart library", control: "select", options: ["dygraph", "uplot"] }, + count: { name: "Chart count", control: "number" }, + rows: { name: "Rows per chart", control: "number" }, + dims: { name: "Dimensions per chart", control: "number" }, + chartType: { + name: "Chart type", + control: "select", + options: ["line", "stacked", "heatmap", "stackedBar", "multiBar", "area"], + }, + height: { name: "Chart height", control: "text" }, + streaming: { name: "Autofetch (streaming)", control: "boolean" }, + autofetchOnHovering: { name: "Keep fetching while hovered", control: "boolean" }, +} + +export default { + title: "Perf/Benchmark", + component: Benchmark, + parameters: { + docs: { + description: { + component: + "Streaming dygraph-vs-uPlot A/B on synthetic data. rows/dims size the payload, so absolute numbers are meaningful at a chosen scale; compare the dygraph/uPlot ratio under identical settings. Driven headlessly by `yarn perf:bench`.", + }, + }, + }, +} diff --git a/src/sdk/initialAttributes.js b/src/sdk/initialAttributes.js index e65e1038d..075d5f1ae 100644 --- a/src/sdk/initialAttributes.js +++ b/src/sdk/initialAttributes.js @@ -17,6 +17,7 @@ export default { min: 0, max: 0, autofetchOnHovering: false, + perfMonitor: false, pristineStaticValueRange: undefined, valueRange: null, diff --git a/src/sdk/makeChart/filters/makeControllers.js b/src/sdk/makeChart/filters/makeControllers.js index 3a45e89ff..6cf15d85e 100644 --- a/src/sdk/makeChart/filters/makeControllers.js +++ b/src/sdk/makeChart/filters/makeControllers.js @@ -119,17 +119,28 @@ export default chart => { table: true, } + 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) + const updateChartTypeAttribute = selected => { const prevChartLibrary = chart.getAttribute("chartLibrary") const prevGroupBy = chart.getAttribute("groupBy") if (!chartLibraries[selected]) { + const nextChartLibrary = getRendererForChartType(selected) chart.updateAttributes({ - chartLibrary: "dygraph", + chartLibrary: nextChartLibrary, chartType: selected, processing: true, }) - if (prevChartLibrary !== "dygraph") { + if (prevChartLibrary !== nextChartLibrary) { chart.getUI().unmount() chart.setUI({ ...chart.sdk.makeChartUI(chart), ...(chart.ui || {}) }, "default") } @@ -346,6 +357,8 @@ export default chart => { updateGroupByAttribute, updatePostGroupByAttribute, updateChartTypeAttribute, + getRendererForChartType, + isTimeSeriesRenderer, updateNodesAttribute, updateInstancesAttribute, updateDimensionsAttribute, diff --git a/src/sdk/makeChart/filters/makeControllers.test.js b/src/sdk/makeChart/filters/makeControllers.test.js index bae506441..812807928 100644 --- a/src/sdk/makeChart/filters/makeControllers.test.js +++ b/src/sdk/makeChart/filters/makeControllers.test.js @@ -273,4 +273,102 @@ describe("makeControllers", () => { expect(triggerSpy).toHaveBeenCalledWith("fetch", { processing: true }) }) }) + + describe("renderer resolution", () => { + it("resolves the configured renderer for a chart type, falling back to chartLibrary", () => { + const { chart: c } = makeTestChart({ + attributes: { + chartLibrary: "uplot", + chartLibrariesByType: { line: "table", heatmap: "dygraph" }, + }, + }) + const ctrl = makeControllers(c) + + expect(ctrl.getRendererForChartType("line")).toBe("table") + expect(ctrl.getRendererForChartType("heatmap")).toBe("dygraph") + expect(ctrl.getRendererForChartType("area")).toBe("uplot") + }) + + it("identifies which libraries are time-series renderers", () => { + const { chart: c } = makeTestChart({ + attributes: { chartLibrariesByType: { line: "table" } }, + }) + const ctrl = makeControllers(c) + + expect(ctrl.isTimeSeriesRenderer("dygraph")).toBe(true) + expect(ctrl.isTimeSeriesRenderer("table")).toBe(true) + expect(ctrl.isTimeSeriesRenderer("gauge")).toBe(false) + }) + + 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) + }) + }) + + describe("updateChartTypeAttribute renderer resolution", () => { + it("uses the configured renderer for a time-series chart type", () => { + const { chart: c } = makeTestChart({ + attributes: { chartLibrariesByType: { line: "table" } }, + }) + const ctrl = makeControllers(c) + + ctrl.updateChartTypeAttribute("line") + + expect(c.getAttribute("chartLibrary")).toBe("table") + expect(c.getAttribute("chartType")).toBe("line") + }) + + it("defaults an unmapped time-series type to the configured chartLibrary", () => { + const { chart: c } = makeTestChart({ attributes: { chartLibrary: "uplot" } }) + const ctrl = makeControllers(c) + + ctrl.updateChartTypeAttribute("area") + + expect(c.getAttribute("chartLibrary")).toBe("uplot") + expect(c.getAttribute("chartType")).toBe("area") + }) + + it("rebuilds the chart UI when the renderer changes", () => { + const { chart: c } = makeTestChart({ + attributes: { chartLibrariesByType: { line: "table" } }, + }) + const ctrl = makeControllers(c) + + const before = c.getUI() + ctrl.updateChartTypeAttribute("line") + + expect(c.getUI()).not.toBe(before) + }) + + it("keeps the same chart UI when switching types that share a renderer", () => { + const { chart: c } = makeTestChart({ attributes: { chartType: "line" } }) + const ctrl = makeControllers(c) + + const before = c.getUI() + ctrl.updateChartTypeAttribute("area") + + expect(c.getUI()).toBe(before) + }) + }) }) diff --git a/src/sdk/makeChart/index.js b/src/sdk/makeChart/index.js index 1b010768b..f3adf7e9e 100644 --- a/src/sdk/makeChart/index.js +++ b/src/sdk/makeChart/index.js @@ -1,5 +1,6 @@ import makeKeyboardListener from "@/helpers/makeKeyboardListener" import makeExecuteLatest from "@/helpers/makeExecuteLatest" +import { timeRender } from "@/sdk/plugins/perfMonitor/registry" import formatNumber from "@/helpers/formatNumber" import convert from "@/helpers/units" import unitConversion from "@/helpers/unitConversion" @@ -149,12 +150,15 @@ export default ({ const chartUI = uiInstances[uiName] if (!chartUI?.render) return + const timedRender = () => + timeRender(node.getId(), node.getAttribute("chartLibrary"), () => chartUI.render()) + if (chartUI.renderIfStale) { - chartUI.renderIfStale(chartUI.render) + chartUI.renderIfStale(timedRender) return } - chartUI.render() + timedRender() }) }) diff --git a/src/sdk/makeChart/renderPerf.test.js b/src/sdk/makeChart/renderPerf.test.js new file mode 100644 index 000000000..0d1609f10 --- /dev/null +++ b/src/sdk/makeChart/renderPerf.test.js @@ -0,0 +1,58 @@ +import { makeTestChart } from "@jest/testUtilities" +import { setEnabled, reset, snapshot } from "@/sdk/plugins/perfMonitor/registry" +import uplotChart from "@/chartLibraries/uplot" + +const setupMountedChart = () => { + const { sdk, chart } = makeTestChart({ attributes: { chartLibrary: "uplot" } }) + const ui = uplotChart(sdk, chart) + chart.setUI(ui, "default") + const element = document.createElement("div") + document.body.appendChild(element) + ui.mount(element) + return { chart, ui, element } +} + +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", async () => { + const { chart, ui, element } = setupMountedChart() + + setEnabled(true) + chart.invalidateRender() + chart.trigger("render") + jest.runOnlyPendingTimers() + await Promise.resolve() + + const snap = snapshot() + expect(snap.overall.count).toBeGreaterThanOrEqual(1) + expect(snap.renderers.uplot.count).toBeGreaterThanOrEqual(1) + + ui.unmount() + document.body.removeChild(element) + }) + + it("does not record when disabled", async () => { + const { chart, ui, element } = setupMountedChart() + + chart.invalidateRender() + chart.trigger("render") + jest.runOnlyPendingTimers() + await Promise.resolve() + + expect(snapshot().overall.count).toBe(0) + + ui.unmount() + document.body.removeChild(element) + }) +}) diff --git a/src/sdk/plugins/perfMonitor/index.js b/src/sdk/plugins/perfMonitor/index.js new file mode 100644 index 000000000..94118c90f --- /dev/null +++ b/src/sdk/plugins/perfMonitor/index.js @@ -0,0 +1,56 @@ +import React from "react" +import { createRoot } from "react-dom/client" +import PerfOverlay from "@/components/perf" +import { setEnabled, sampleHeap, reset, snapshot } from "./registry" + +export default sdk => { + let container = null + let root = null + let heapId = null + + const mount = () => { + if (container) return + + reset() + setEnabled(true) + heapId = setInterval(sampleHeap, 1000) + + // benchmark drivers read exact stats from here; the HUD only renders rounded values + if (typeof window !== "undefined") window.__netdataPerf = { snapshot, reset } + + container = document.createElement("div") + container.setAttribute("data-testid", "perfOverlay-root") + document.body.appendChild(container) + + root = createRoot(container) + root.render() + } + + const unmount = () => { + setEnabled(false) + + if (typeof window !== "undefined") delete window.__netdataPerf + + if (heapId) { + clearInterval(heapId) + heapId = null + } + if (root) { + root.unmount() + root = null + } + if (container) { + container.remove() + container = null + } + } + + const off = sdk.getRoot().onAttributeChange("perfMonitor", value => (value ? mount() : unmount())) + + if (sdk.getRoot().getAttribute("perfMonitor")) mount() + + return () => { + off() + unmount() + } +} diff --git a/src/sdk/plugins/perfMonitor/index.test.js b/src/sdk/plugins/perfMonitor/index.test.js new file mode 100644 index 000000000..8b46d5559 --- /dev/null +++ b/src/sdk/plugins/perfMonitor/index.test.js @@ -0,0 +1,56 @@ +import { act } from "@testing-library/react" +import { makeTestChart } from "@jest/testUtilities" +import { isEnabled, reset, record } from "./registry" + +describe("perfMonitor plugin", () => { + afterEach(() => { + document.body.innerHTML = "" + reset() + }) + + it("exposes a snapshot handle on window for automated benchmarks while enabled", () => { + const { sdk } = makeTestChart() + + expect(window.__netdataPerf).toBeUndefined() + + act(() => { + sdk.getRoot().updateAttributes({ perfMonitor: true }) + }) + + expect(typeof window.__netdataPerf.snapshot).toBe("function") + expect(typeof window.__netdataPerf.reset).toBe("function") + + record("c1", "uplot", 12) + expect(window.__netdataPerf.snapshot().renderers.uplot.count).toBe(1) + + window.__netdataPerf.reset() + expect(window.__netdataPerf.snapshot().overall.count).toBe(0) + + act(() => { + sdk.getRoot().updateAttributes({ perfMonitor: false }) + }) + + expect(window.__netdataPerf).toBeUndefined() + }) + + it("mounts the overlay and enables the registry when perfMonitor turns on, and tears down when off", () => { + const { sdk } = makeTestChart() + + expect(document.querySelector("[data-testid='perfOverlay-root']")).toBeNull() + expect(isEnabled()).toBe(false) + + act(() => { + sdk.getRoot().updateAttributes({ perfMonitor: true }) + }) + + expect(document.querySelector("[data-testid='perfOverlay-root']")).not.toBeNull() + expect(isEnabled()).toBe(true) + + act(() => { + sdk.getRoot().updateAttributes({ perfMonitor: false }) + }) + + expect(document.querySelector("[data-testid='perfOverlay-root']")).toBeNull() + expect(isEnabled()).toBe(false) + }) +}) diff --git a/src/sdk/plugins/perfMonitor/registry.js b/src/sdk/plugins/perfMonitor/registry.js new file mode 100644 index 000000000..de03f3897 --- /dev/null +++ b/src/sdk/plugins/perfMonitor/registry.js @@ -0,0 +1,91 @@ +const MAX_SAMPLES = 500 + +let enabled = false +const byChart = new Map() +let heapCurrent = null +let heapPeak = null + +const getEntry = (chartId, renderer) => { + const key = `${chartId}:${renderer}` + let entry = byChart.get(key) + if (!entry) { + entry = { renderer, durations: [] } + byChart.set(key, entry) + } + 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() + const result = fn() + queueMicrotask(() => record(chartId, renderer, performance.now() - start)) + return result +} + +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 +} diff --git a/src/sdk/plugins/perfMonitor/registry.test.js b/src/sdk/plugins/perfMonitor/registry.test.js new file mode 100644 index 000000000..b5965383f --- /dev/null +++ b/src/sdk/plugins/perfMonitor/registry.test.js @@ -0,0 +1,101 @@ +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", async () => { + let calls = 0 + const fn = () => { + calls++ + } + + timeRender("c1", "uplot", fn) + expect(calls).toBe(1) + expect(isEnabled()).toBe(false) + expect(snapshot().overall.count).toBe(0) + + setEnabled(true) + expect(isEnabled()).toBe(true) + timeRender("c1", "uplot", fn) + expect(calls).toBe(2) + + await Promise.resolve() + expect(snapshot().overall.count).toBe(1) + }) + + it("captures paint a renderer defers to a microtask during fn (batching renderers)", async () => { + setEnabled(true) + + const busyWait = ms => { + const until = performance.now() + ms + while (performance.now() < until) { + /* spin */ + } + } + + timeRender("c1", "uplot", () => { + queueMicrotask(() => busyWait(5)) + }) + + await Promise.resolve() + await Promise.resolve() + + const snap = snapshot() + expect(snap.renderers.uplot.count).toBe(1) + expect(snap.renderers.uplot.max).toBeGreaterThanOrEqual(5) + }) + + it("reports heap unsupported when performance.memory is absent", () => { + sampleHeap() + expect(snapshot().heap.supported).toBe(false) + }) + + it("keeps stats separate when a chart id is recorded under two renderers", () => { + record("c1", "dygraph", 40) + record("c1", "uplot", 10) + + const snap = snapshot() + expect(snap.renderers.dygraph.count).toBe(1) + expect(snap.renderers.dygraph.max).toBe(40) + expect(snap.renderers.uplot.count).toBe(1) + expect(snap.renderers.uplot.max).toBe(10) + expect(snap.overall.count).toBe(2) + }) + + it("caps stored samples per chart+renderer at 500 with FIFO eviction", () => { + Array.from({ length: 550 }, (_, i) => record("c1", "uplot", i)) + + const snap = snapshot() + expect(snap.renderers.uplot.count).toBe(500) + expect(snap.renderers.uplot.max).toBe(549) + }) +}) diff --git a/src/showcase.stories.js b/src/showcase.stories.js new file mode 100644 index 000000000..47bc4db2e --- /dev/null +++ b/src/showcase.stories.js @@ -0,0 +1,683 @@ +import React, { useEffect, useMemo } from "react" +import styled, { ThemeProvider } from "styled-components" +import { + Flex, + TextHuge, + TextMicro, + TextSmall, + getSizeBy, + DefaultTheme, + DarkTheme, +} from "@netdata/netdata-ui" +import Line from "@/components/line" +import { + useChart, + useVisibleDimensionIds, + useLatestDisplayValueWithUnit, + withChartProvider, +} from "@/components/provider" +import makeMockPayload from "@/helpers/makeMockPayload" +import makeDefaultSDK from "./makeDefaultSDK" + +const now = 1729763970000 +const points = 97 +const updateEvery = 5 + +const range = values => ({ + min: Math.min(...values), + max: Math.max(...values), + avg: values.reduce((sum, value) => sum + value, 0) / values.length, +}) + +const makeWave = ({ center, amplitude, cycles = 2.5, phase = 0, ripple = 0.08 }) => + Array.from({ length: points }, (_, index) => { + const progress = index / (points - 1) + const primary = Math.sin(progress * Math.PI * 2 * cycles + phase) + const secondary = Math.sin(progress * Math.PI * 2 * cycles * 0.37 + phase * 0.5) + + return center + amplitude * primary + amplitude * ripple * secondary + }) + +const makeAnomalyRates = seed => + Array.from({ length: points }, (_, index) => + (index + seed * 7) % 47 === 0 ? 35 + ((index * 17 + seed * 11) % 65) : 0 + ) + +const makeDimensionStats = dimensions => + dimensions.reduce( + (stats, dimension) => { + const values = range(dimension.values) + const anomalyRates = dimension.anomalyRates || [] + + stats.min.push(values.min) + stats.max.push(values.max) + stats.avg.push(values.avg) + stats.arp.push(anomalyRates.length ? range(anomalyRates).avg : 0) + stats.con.push(0) + return stats + }, + { min: [], max: [], avg: [], arp: [], con: [] } + ) + +const makePayload = ({ context, title, unit, dimensions, chartType = "line" }) => { + const normalizedDimensions = dimensions.map((dimension, index) => ({ + ...dimension, + anomalyRates: dimension.anomalyRates || makeAnomalyRates(index + 1), + })) + const allValues = normalizedDimensions.flatMap(dimension => dimension.values) + const values = range(allValues) + const dimensionStats = makeDimensionStats(normalizedDimensions) + const ids = normalizedDimensions.map(dimension => dimension.id) + const names = normalizedDimensions.map(dimension => dimension.name || dimension.id) + const dimensionUnits = normalizedDimensions.map(dimension => dimension.unit || unit) + const units = [...new Set(dimensionUnits)] + const rows = Array.from({ length: points }, (_, index) => [ + now - (points - index - 1) * updateEvery * 1000, + ...normalizedDimensions.map(dimension => [dimension.values[index], dimension.anomalyRates[index], 0]), + ]) + + return { + api: 2, + versions: {}, + summary: { + nodes: [], + contexts: [{ id: context, sts: { ...values, con: 100 } }], + instances: [], + dimensions: normalizedDimensions.map((dimension, index) => ({ + id: dimension.id, + pri: index, + sts: { + min: dimensionStats.min[index], + max: dimensionStats.max[index], + avg: dimensionStats.avg[index], + con: 0, + }, + })), + labels: [], + alerts: [], + }, + totals: {}, + functions: [], + result: { + labels: ["time", ...names], + point: { value: 0, arp: 1, pa: 2 }, + data: rows, + }, + db: { + tiers: 1, + update_every: updateEvery, + first_entry: Math.floor(rows[0][0] / 1000), + last_entry: Math.floor(rows[rows.length - 1][0] / 1000), + units, + dimensions: { ids, units: dimensionUnits, sts: dimensionStats }, + per_tier: [], + }, + view: { + title, + update_every: updateEvery, + after: Math.floor(rows[0][0] / 1000), + before: Math.floor(rows[rows.length - 1][0] / 1000), + units, + chart_type: chartType, + dimensions: { + grouped_by: ["dimension"], + ids, + names, + units: dimensionUnits, + priorities: normalizedDimensions.map((_, index) => index), + aggregated: normalizedDimensions.map(() => 1), + sts: dimensionStats, + }, + ...values, + }, + } +} + +const cpuContext = "showcase.system.cpu" +const loadContext = "showcase.system.load" +const netContext = "showcase.net.traffic" +const diskContext = "showcase.disk.ops" +const memoryContext = "showcase.system.ram" +const requestsContext = "showcase.web.requests" +const latencyContext = "showcase.app.latency" + +const cpuPayload = chartType => + makePayload({ + context: cpuContext, + title: "CPU utilization", + unit: "%", + chartType, + dimensions: [ + { id: "user", name: "user", values: makeWave({ center: 26, amplitude: 9, cycles: 2.2, phase: 0.3 }) }, + { id: "system", name: "system", values: makeWave({ center: 13, amplitude: 5, cycles: 2.6, phase: 1.1 }) }, + { id: "iowait", name: "iowait", values: makeWave({ center: 6, amplitude: 3.4, cycles: 3.1, phase: 0.6 }) }, + { id: "softirq", name: "softirq", values: makeWave({ center: 3.2, amplitude: 1.8, cycles: 3.4, phase: 2 }) }, + ], + }) + +const loadPayload = chartType => + makePayload({ + context: loadContext, + title: "System load average", + unit: "load", + chartType, + dimensions: [ + { id: "load1", name: "load1", values: makeWave({ center: 2.4, amplitude: 0.7, cycles: 2.1, phase: 0.4 }) }, + { id: "load5", name: "load5", values: makeWave({ center: 2, amplitude: 0.42, cycles: 1.8, phase: 1 }) }, + { id: "load15", name: "load15", values: makeWave({ center: 1.7, amplitude: 0.24, cycles: 1.5, phase: 1.7 }) }, + ], + }) + +const netPayload = chartType => + makePayload({ + context: netContext, + title: "Network traffic", + unit: "kilobits/s", + chartType, + dimensions: [ + { id: "received", name: "received", values: makeWave({ center: 9000, amplitude: 3500, cycles: 2.3, phase: 0.2 }) }, + { id: "sent", name: "sent", values: makeWave({ center: -6500, amplitude: 2800, cycles: 2.7, phase: 1.3 }) }, + ], + }) + +const diskPayload = chartType => + makePayload({ + context: diskContext, + title: "Disk operations", + unit: "operations/s", + chartType, + dimensions: [ + { id: "reads", name: "reads", values: makeWave({ center: 320, amplitude: 120, cycles: 2.5, phase: 0.5 }) }, + { id: "writes", name: "writes", values: makeWave({ center: 210, amplitude: 90, cycles: 2.9, phase: 1.6 }) }, + ], + }) + +const memoryPayload = chartType => + makePayload({ + context: memoryContext, + title: "Memory allocation", + unit: "MiB", + chartType, + dimensions: [ + { id: "used", name: "used", values: makeWave({ center: 5200, amplitude: 780, cycles: 2, phase: 0.2 }) }, + { id: "cached", name: "cached", values: makeWave({ center: 2600, amplitude: 460, cycles: 2.4, phase: 1 }) }, + { id: "buffers", name: "buffers", values: makeWave({ center: 640, amplitude: 150, cycles: 3, phase: 1.8 }) }, + { id: "free", name: "free", values: makeWave({ center: 1400, amplitude: 380, cycles: 2.2, phase: 2.4 }) }, + ], + }) + +const requestsPayload = chartType => + makePayload({ + context: requestsContext, + title: "Web requests", + unit: "requests/s", + chartType, + dimensions: [ + { id: "success", name: "success", values: makeWave({ center: 1250, amplitude: 420, cycles: 2.4, phase: 0.8 }) }, + ], + }) + +const latencyBuckets = [ + { id: "1", center: 5, amplitude: 4, phase: 0.2 }, + { id: "2.5", center: 11, amplitude: 6, phase: 0.6 }, + { id: "5", center: 19, amplitude: 9, phase: 1 }, + { id: "10", center: 27, amplitude: 11, phase: 1.5 }, + { id: "25", center: 21, amplitude: 9, phase: 2.1 }, + { id: "50", center: 13, amplitude: 6, phase: 2.6 }, + { id: "100", center: 7, amplitude: 4, phase: 3 }, + { id: "+Inf", center: 3, amplitude: 2, phase: 3.4 }, +] + +const latencyPayload = chartType => + makePayload({ + context: latencyContext, + title: "Request latency distribution", + unit: "requests/s", + chartType, + dimensions: latencyBuckets.map(bucket => ({ + id: bucket.id, + name: bucket.id, + values: makeWave({ + center: bucket.center, + amplitude: bucket.amplitude, + cycles: 2.3, + phase: bucket.phase, + }).map(value => Math.max(0, Math.round(value))), + })), + }) + +const singleMetric = ({ context, id, unit, center, amplitude, cycles = 2.4, phase = 0.3, chartType }) => + makePayload({ + context, + title: id, + unit, + chartType, + dimensions: [{ id, name: id, values: makeWave({ center, amplitude, cycles, phase }) }], + }) + +const createChart = ({ context, payload, chartType, theme, sparkline, colors, attributes = {} }) => { + const sdk = makeDefaultSDK({ + attributes: { + theme, + containerWidth: 1200, + navigation: "pan", + expandable: false, + }, + }) + + const chart = sdk.makeChart({ + getChart: makeMockPayload(payload), + attributes: { + chartLibrary: "uplot", + chartType, + contextScope: [context], + ...(sparkline && { sparkline: true }), + ...(colors && { colors }), + ...attributes, + }, + }) + + sdk.appendChild(chart) + return chart +} + +const useChartInstance = config => { + const chart = useMemo(() => createChart(config), []) + useEffect(() => () => chart.destroy(), [chart]) + return chart +} + +const Page = styled(Flex).attrs({ + column: true, + gap: 8, + padding: [8, 6, 12], + width: { min: "0px", max: getSizeBy(150), base: "100%" }, +})` + box-sizing: border-box; +` + +const Eyebrow = styled(TextMicro).attrs({ color: "primary" })` + text-transform: uppercase; + letter-spacing: 0.22em; + font-weight: 700; +` + +const Display = styled(TextHuge).attrs({ color: "text" })` + font-size: 34px; + line-height: 1.08; + letter-spacing: -0.02em; + font-weight: 700; +` + +const Stat = styled(TextHuge).attrs({ color: "text" })` + font-variant-numeric: tabular-nums; + letter-spacing: -0.01em; + line-height: 1; + font-weight: 700; +` + +const Dot = styled.span` + width: 9px; + height: 9px; + border-radius: 3px; + flex-shrink: 0; + background: ${props => props.color}; +` + +const Card = styled(Flex).attrs(props => ({ + column: true, + gap: 3, + padding: [4], + round: 2, + border: { side: "all", color: "borderSecondary" }, + background: "mainChartBg", + ...props, +}))` + box-sizing: border-box; +` + +const Grid = styled(Flex).attrs({ gap: 4, width: "100%" })` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(${getSizeBy(70)}, 1fr)); +` + +const StatStrip = styled(Flex).attrs({ gap: 4, width: "100%" })` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(${getSizeBy(42)}, 1fr)); +` + +const Legend = withChartProvider(() => { + const chart = useChart() + const ids = useVisibleDimensionIds() + + if (!ids.length) return null + + return ( + + {ids.map(id => ( + + + {chart.getDimensionName(id) || id} + + ))} + + ) +}) + +const Caption = ({ title, subtitle, unit }) => ( + + + + {title} + + {subtitle && {subtitle}} + + {unit && ( + + {unit} + + )} + +) + +const ChartCard = ({ config, title, subtitle, unit, height = "220px", legend = true }) => { + const chart = useChartInstance(config) + + return ( + + + + + + {legend && } + + ) +} + +const StatTile = withChartProvider(({ label, dimensionId }) => { + const chart = useChart() + const { convertedValue, convertedUnit } = useLatestDisplayValueWithUnit(dimensionId) + + return ( + + {label} + + {convertedValue} + {convertedUnit} + + + + + + ) +}) + +const StatTileCard = ({ config, label, dimensionId }) => { + const chart = useChartInstance(config) + return +} + +const Masthead = ({ theme }) => ( + + Netdata charts · uPlot renderer + Time-series, rendered with intent. + + One theme-driven canvas across every mode — lines, gradient areas, diverging stacks and bars. + Hairline grids, tabular axes and a calm crosshair, tuned for the {theme} theme. + + + +) + +const SectionHeader = ({ title, description }) => ( + + + + + {title} + + + + {description} + + +) + +const statTiles = theme => [ + { + label: "Requests", + dimensionId: "req", + config: { + context: "showcase.kpi.req", + chartType: "area", + theme, + sparkline: true, + colors: ["#00AB44"], + payload: singleMetric({ + context: "showcase.kpi.req", + id: "req", + unit: "requests/s", + center: 1250, + amplitude: 420, + chartType: "area", + }), + }, + }, + { + label: "CPU", + dimensionId: "cpu", + config: { + context: "showcase.kpi.cpu", + chartType: "line", + theme, + sparkline: true, + colors: ["#3366CC"], + payload: singleMetric({ + context: "showcase.kpi.cpu", + id: "cpu", + unit: "%", + center: 34, + amplitude: 12, + cycles: 3, + chartType: "line", + }), + }, + }, + { + label: "Egress", + dimensionId: "egress", + config: { + context: "showcase.kpi.egress", + chartType: "area", + theme, + sparkline: true, + colors: ["#FF9900"], + payload: singleMetric({ + context: "showcase.kpi.egress", + id: "egress", + unit: "megabits/s", + center: 640, + amplitude: 220, + phase: 1.2, + chartType: "area", + }), + }, + }, + { + label: "P95 latency", + dimensionId: "latency", + config: { + context: "showcase.kpi.latency", + chartType: "line", + theme, + sparkline: true, + colors: ["#994499"], + payload: singleMetric({ + context: "showcase.kpi.latency", + id: "latency", + unit: "milliseconds", + center: 82, + amplitude: 26, + cycles: 3.6, + phase: 2, + chartType: "line", + }), + }, + }, +] + +const dashboardCards = theme => [ + { + title: "System load average", + subtitle: "load1 · load5 · load15", + unit: "load", + config: { context: loadContext, chartType: "line", theme, payload: loadPayload("line") }, + }, + { + title: "Web requests", + subtitle: "successful responses", + unit: "requests/s", + config: { context: requestsContext, chartType: "area", theme, payload: requestsPayload("area") }, + legend: false, + }, + { + title: "Network traffic", + subtitle: "received vs. sent, diverging stack", + unit: "kilobits/s", + config: { context: netContext, chartType: "stacked", theme, payload: netPayload("stacked") }, + }, + { + title: "Disk operations", + subtitle: "reads vs. writes", + unit: "operations/s", + config: { context: diskContext, chartType: "multiBar", theme, payload: diskPayload("multiBar") }, + }, + { + title: "Memory allocation", + subtitle: "used · cached · buffers · free", + unit: "MiB", + config: { context: memoryContext, chartType: "stackedBar", theme, payload: memoryPayload("stackedBar") }, + }, +] + +const renderModes = theme => [ + { title: "Line", subtitle: "system.load", config: { context: loadContext, chartType: "line", theme, payload: loadPayload("line") } }, + { title: "Area", subtitle: "gradient fill", config: { context: requestsContext, chartType: "area", theme, payload: requestsPayload("area") }, legend: false }, + { title: "Stacked area", subtitle: "cpu utilization", config: { context: cpuContext, chartType: "stacked", theme, payload: cpuPayload("stacked") } }, + { title: "Diverging stack", subtitle: "net in / out", config: { context: netContext, chartType: "stacked", theme, payload: netPayload("stacked") } }, + { title: "Multi bar", subtitle: "disk ops", config: { context: diskContext, chartType: "multiBar", theme, payload: diskPayload("multiBar") } }, + { title: "Stacked bar", subtitle: "memory", config: { context: memoryContext, chartType: "stackedBar", theme, payload: memoryPayload("stackedBar") } }, + { title: "Heatmap", subtitle: "latency buckets", config: { context: latencyContext, chartType: "heatmap", theme, payload: latencyPayload("heatmap") }, legend: false }, +] + +const Showcase = ({ theme }) => { + const heroChart = useChartInstance({ + context: cpuContext, + chartType: "stacked", + theme, + payload: cpuPayload("stacked"), + }) + + return ( + + + + + + + {statTiles(theme).map(tile => ( + + ))} + + + + + + + + + + + + + + + + + + {dashboardCards(theme).map(card => ( + + ))} + + + + ) +} + +const ThemedShowcase = ({ theme }) => { + const uiTheme = theme === "dark" ? DarkTheme : DefaultTheme + + return ( + + + + + + ) +} + +export const Overview = () => + +Overview.parameters = { netdataTheme: "dark" } + +export const LightTheme = () => + +export const RenderModes = ({ theme = "dark" }) => { + const uiTheme = theme === "dark" ? DarkTheme : DefaultTheme + + return ( + + + + + + + + {renderModes(theme).map(card => ( + + ))} + + + + + + ) +} + +RenderModes.parameters = { netdataTheme: "dark" } + +export default { + title: "Charts/uPlot/Showcase", + parameters: { + layout: "fullscreen", + }, +} diff --git a/yarn.lock b/yarn.lock index fecdfe6ad..173974208 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7003,16 +7003,16 @@ fs.realpath@^1.0.0: resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= +fsevents@2.3.2, fsevents@~2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + fsevents@^2.3.3: version "2.3.3" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== -fsevents@~2.3.2: - version "2.3.2" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" - integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== - function-bind@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" @@ -9767,6 +9767,20 @@ pkg-up@^3.1.0: dependencies: find-up "^3.0.0" +playwright-core@1.62.1: + version "1.62.1" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.62.1.tgz#120f67a19181bfd183c60fa903c0d99330b56785" + integrity sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw== + +playwright@^1.62.1: + version "1.62.1" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.62.1.tgz#8447b6755e8aec85a3cb7207c823e3ed2fc66700" + integrity sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg== + dependencies: + playwright-core "1.62.1" + optionalDependencies: + fsevents "2.3.2" + polished@^4.3.1: version "4.3.1" resolved "https://registry.yarnpkg.com/polished/-/polished-4.3.1.tgz#5a00ae32715609f83d89f6f31d0f0261c6170548" @@ -12017,6 +12031,11 @@ update-browserslist-db@^1.2.0: escalade "^3.2.0" picocolors "^1.1.1" +uplot@^1.6.32: + version "1.6.32" + resolved "https://registry.yarnpkg.com/uplot/-/uplot-1.6.32.tgz#c800a63b432bad692d6d746f44f0882aa73a49ae" + integrity sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw== + uri-js@^4.2.2: version "4.4.1" resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e"