diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index da5b09698..14113405f 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -2,67 +2,86 @@
name: tests
on:
+ pull_request:
+ branches:
+ - main
push:
branches:
- main
workflow_dispatch:
+permissions:
+ contents: read
+
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
- tests:
- name: tests
+ quality:
+ name: tests, lint, builds, and Storybook
runs-on: ubuntu-latest
steps:
- - name: checkout
- uses: actions/checkout@v4
+ - name: Checkout
+ uses: actions/checkout@v7
+ with:
+ fetch-depth: 0
- - uses: actions/setup-node@v4
+ - name: Set up Node.js
+ uses: actions/setup-node@v7
with:
node-version: "22.22.0"
- cache: "yarn"
+ cache: yarn
- name: Install dependencies
- run: yarn install --legacy-peer-deps --prefer-offline
-
- - name: Run Tests
- if: github.event_name != 'pull_request'
- continue-on-error: true
- id: run-tests
- run: yarn test --coverage --collectCoverageFrom="./src/**" > /tmp/coverage_report
-
- - name: Post Test Coverage Report in PR
- uses: ArtiomTr/jest-coverage-report-action@v2
- continue-on-error: true
- if: github.event_name == 'pull_request'
- with:
- test-script: yarn test --collectCoverageFrom="./src/**"
- annotations: all
+ run: yarn install --frozen-lockfile --prefer-offline
- - name: Gain access to test-reports bucket
- if: (steps.run-tests.outcome == 'failure' || steps.run-tests.outcome == 'success') && github.ref == 'refs/heads/develop'
- uses: google-github-actions/setup-gcloud@v0
- with:
- project_id: "netdata-cloud-testing"
- service_account_key: ${{ secrets.TEST_AUTOMATION_SERVICE_ACCOUNT }}
- export_default_credentials: true
+ - name: Run tests with coverage
+ run: yarn test --runInBand
- - name: Upload report to test-reports bucket
- if: (steps.run-tests.outcome == 'failure' || steps.run-tests.outcome == 'success') && github.ref == 'refs/heads/develop'
+ - name: Lint changed JavaScript
+ env:
+ BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
run: |
- gsutil -h "Cache-Control: max-age=0, no-store" cp \
- /tmp/coverage_report gs://${{ secrets.TEST_AUTOMATION_STORAGE_BUCKET }}/${{ github.event.repository.name }}/coverage_report
- gsutil acl set project-private gs://${{ secrets.TEST_AUTOMATION_STORAGE_BUCKET }}/${{ github.event.repository.name }}/coverage_report
+ if [[ -z "${BASE_SHA}" || "${BASE_SHA}" =~ ^0+$ ]]; then
+ BASE_SHA="HEAD^"
+ fi
+ git diff --name-only --diff-filter=ACMR "${BASE_SHA}"...HEAD -- \
+ '*.js' '*.mjs' '*.cjs' > /tmp/eslint-files
+ if [[ -s /tmp/eslint-files ]]; then
+ xargs --delimiter='\n' yarn eslint < /tmp/eslint-files
+ fi
+
+ - name: Build CommonJS and ES6 distributions
+ run: yarn build
- - name: Publish test coverage report
- if: (steps.run-tests.outcome == 'failure' || steps.run-tests.outcome == 'success') && github.ref == 'refs/heads/develop'
- uses: aurelien-baudet/workflow-dispatch@v2
+ - name: Build Storybook
+ run: yarn build-storybook
+
+ browser-correctness:
+ name: real-browser WebGL2 correctness
+ needs: quality
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v7
with:
- repo: netdata/cloud-workflows
- ref: refs/heads/main
- workflow: test_coverage_publisher.yml
- token: ${{ secrets.TEST_AUTOMATION_TOKEN }}
- inputs: '{ "service-name": "${{ github.event.repository.name }}"}'
+ node-version: "22.22.0"
+ cache: yarn
+
+ - name: Install dependencies
+ run: yarn install --frozen-lockfile --prefer-offline
+
+ - name: Install pinned Chromium
+ run: yarn playwright-core install --with-deps chromium
+
+ - name: Validate every visualization in Chromium
+ env:
+ BENCHMARK_CORRECTNESS_ONLY: "1"
+ BENCHMARK_RENDERERS: webgl2
+ run: yarn benchmark:time-series
diff --git a/.storybook/highCardinality.stories.js b/.storybook/highCardinality.stories.js
index d103bf4c4..d9ca65f02 100644
--- a/.storybook/highCardinality.stories.js
+++ b/.storybook/highCardinality.stories.js
@@ -148,8 +148,9 @@ const installFixtureTransport = () => {
}
}
-const createChart = action => {
+const createChart = (action, renderer = "dygraph") => {
const sdk = makeDefaultSDK({
+ rendererPolicy: () => renderer,
attributes: {
after: fixtureWindow.after,
before: fixtureWindow.before,
@@ -182,9 +183,9 @@ const createChart = action => {
return chart
}
-const HighCardinalityChart = ({ action }) => {
+const HighCardinalityChart = ({ action, renderer }) => {
useLayoutEffect(installFixtureTransport, [])
- const chart = useMemo(() => createChart(action), [action])
+ const chart = useMemo(() => createChart(action, renderer), [action, renderer])
useLayoutEffect(() => () => chart.destroy(), [chart])
@@ -199,6 +200,7 @@ export const Values = () =>
export const DrillDown = () =>
export const Compare = () =>
export const Correlate = () =>
+export const WebGPUValues = () =>
const meta = {
title: "Performance/Local high-cardinality expanded chart",
diff --git a/README.md b/README.md
index d3b4775ea..272bf3891 100644
--- a/README.md
+++ b/README.md
@@ -26,32 +26,32 @@ yarn:
## Build
-There are 3 different distributions. The following command creates the different distributions in the `./dist` folder
+The package builds CommonJS and ES6 distributions into `./dist`.
```shell
$ yarn build
```
-Build the distributions isolated
+Build either distribution independently:
-**UMD** produces a single file `./dist/sdk.min.js` what contains the entire bundle
-
-```shell
- $ yarn build:umd
-```
-
-**ES6** Builds the files using ES Modules in `./dist/es6/*` folder
+**ES6** builds files using ES Modules in `./dist/es6/*`.
```shell
$ yarn build:es6
```
-**cjs** Builds the files using CommonJS in `./dist/*` folder
+**CommonJS** builds files using CommonJS in `./dist/*`.
```shell
$ yarn build:cjs
```
+## GPU renderer development
+
+The accelerated renderer architecture, ownership rules, extension contract, fallback behavior, diagnostics, and validation requirements are documented in [`docs/gpu-renderers.md`](docs/gpu-renderers.md).
+
+The deterministic browser harness is documented in [`benchmarks/time-series-renderers/README.md`](benchmarks/time-series-renderers/README.md).
+
## Testing
```shell
diff --git a/benchmarks/time-series-renderers/README.md b/benchmarks/time-series-renderers/README.md
new file mode 100644
index 000000000..3e0720759
--- /dev/null
+++ b/benchmarks/time-series-renderers/README.md
@@ -0,0 +1,102 @@
+# Time-series renderer benchmark
+
+This task-specific comparator measures legacy renderers and selected production GPU backends from the same checkout with identical deterministic Cartesian and radial data. It covers Line, Area, Multi Column, diverging Stacked, Stacked Bar, Heatmap, EasyPie/Circle, Gauge, and D3 Pie. WebGPU is preferred; WebGL2 is the accelerated compatibility fallback.
+
+## Run
+
+Prerequisites:
+
+- Chromium at `/usr/bin/chromium`, or set `CHROMIUM_EXECUTABLE`.
+- The pinned `playwright-core` development dependency installed with `yarn install`.
+- A physical WebGPU adapter for performance results.
+
+```bash
+BENCHMARK_HEADED=1 yarn benchmark:time-series
+```
+
+When Chromium needs a particular Linux display backend to expose the hardware adapter:
+
+```bash
+BENCHMARK_HEADED=1 CHROMIUM_OZONE_PLATFORM=wayland yarn benchmark:time-series
+```
+
+Evaluate WebGL2 on the browser's normal physical graphics path:
+
+```bash
+BENCHMARK_HEADED=1 BENCHMARK_RENDERERS=webgl2 yarn benchmark:time-series
+```
+
+Compare both GPU candidates in one browser run. The harness keeps one context, one window, and the same page for the entire suite; resets navigate that page without closing or replacing it:
+
+```bash
+BENCHMARK_HEADED=1 BENCHMARK_RENDERERS=webgpu,webgl2 \
+ CHROMIUM_OZONE_PLATFORM=wayland yarn benchmark:time-series
+```
+
+Measure Area, Multi Column, Stacked, or Stacked Bar against Dygraphs while retaining all implemented visualization correctness checks:
+
+```bash
+BENCHMARK_HEADED=1 BENCHMARK_VISUALIZATION=area \
+ BENCHMARK_RENDERERS=webgpu,webgl2 CHROMIUM_OZONE_PLATFORM=wayland \
+ yarn benchmark:time-series
+
+BENCHMARK_HEADED=1 BENCHMARK_VISUALIZATION=multiBar \
+ BENCHMARK_RENDERERS=webgpu,webgl2 CHROMIUM_OZONE_PLATFORM=wayland \
+ yarn benchmark:time-series
+
+BENCHMARK_HEADED=1 BENCHMARK_VISUALIZATION=stacked \
+ BENCHMARK_RENDERERS=webgpu,webgl2 CHROMIUM_OZONE_PLATFORM=wayland \
+ yarn benchmark:time-series
+
+BENCHMARK_HEADED=1 BENCHMARK_VISUALIZATION=stackedBar \
+ BENCHMARK_RENDERERS=webgpu,webgl2 CHROMIUM_OZONE_PLATFORM=wayland \
+ yarn benchmark:time-series
+```
+
+Run only radial parity and fallback checks, without Cartesian performance workloads:
+
+```bash
+BENCHMARK_HEADED=1 BENCHMARK_RADIAL_ONLY=1 \
+ BENCHMARK_RENDERERS=webgpu,webgl2 CHROMIUM_OZONE_PLATFORM=wayland \
+ yarn benchmark:time-series
+```
+
+Headless Chromium can run correctness without enforcing physical performance gates:
+
+```bash
+BENCHMARK_CORRECTNESS_ONLY=1 BENCHMARK_RENDERERS=webgl2 \
+ yarn benchmark:time-series
+```
+
+A software WebGPU adapter may be requested explicitly when the installed Chromium exposes one:
+
+```bash
+BENCHMARK_CORRECTNESS_ONLY=1 WEBGPU_SOFTWARE=1 \
+ yarn benchmark:time-series
+```
+
+Software-adapter results are correctness evidence only and are never valid performance evidence.
+
+The command prints JSON and exits non-zero unless every selected GPU candidate reaches both feasibility gates:
+
+- 100,000 values: prewarmed mount and update present within one measured display frame, GPU work completes within that budget, and synchronous/main-thread work is at least 3x lower than Dygraphs.
+- 1,000,000 values: median prewarmed frame-settled mount and repeated full-data updates are at least 5x faster than Dygraphs.
+- Each GPU workload exports a non-empty PNG data URL and mounts, updates, and tears down four charts without leaking WebGPU runtime leases or WebGL2 contexts.
+
+Both GPU backends use the same production visualization/data/interaction model, including precision-normalized values, exact null gaps, line step/smooth geometry, Area baseline trapezoids, Multi Column grouped rectangles, diverging Stacked base/end bands, Stacked Bar rectangles, Heatmap cells, analytic EasyPie and Gauge geometry, D3 Pie wedges, axes, text, overlays, and interactions. Filled-line correctness requires one exact band per adjacent source pair, a fully empty null-gap band, and distinct regular/step pixels. Multi Column and Stacked Bar require one instance per source value, exact null omission, and no response to line step mode. Pixel probes require Dygraphs RGBA parity for Area reverse overlap/baseline behavior, Multi Column historical grouped overlap and visibility reflow, Stacked reverse-order positive/negative bands, and Stacked Bar range, width, fill, subpixel border, and empty pixels. WebGL2 owns only its GLSL shaders, textures/buffers, shared context/program runtime, presentation surface, and context-loss handling.
+
+The one-frame gate allows 25% browser scheduling tolerance around the measured refresh interval. A frame-settled ratio is not used when both renderers already present on the same refresh boundary. Cold adapter/device initialization and first pipeline creation are reported separately.
+
+## Method
+
+- Canvas: 1600x500 CSS pixels at device-pixel ratio 1.
+- Workloads: 100 dimensions x 1,000 points and 1,000 dimensions x 1,000 points.
+- Geometry: every visible series and adjacent pair; Area and Stacked add one exact fill band per pair, while Multi Column and Stacked Bar add one exact rectangle per source value; no LOD, sampling, or aggregation.
+- Data: two pre-generated deterministic row-major revisions, alternated during updates.
+- Samples: 3 mounts, 2 warm-up updates, 10 measured updates, 3 seconds of sustained updates, and one four-chart shared-runtime lifecycle.
+- Timing: synchronous adapter time, GPU queue completion, measured display refresh interval, and wall time through the next animation frame.
+- Memory: Chromium heap before mounting, sampled peak, post-teardown retained delta, and allocated GPU buffer bytes.
+- Browser task, script, and layout durations are collected through the Chromium DevTools protocol.
+- WebGL2 uses one SDK-owned context/program cache and copies each completed shared-context frame into the chart's visible canvas. Its reported buffer bytes cover per-chart value/color textures and primitive buffers.
+
+This benchmark is not production instrumentation and is not included in package distributions.
diff --git a/benchmarks/time-series-renderers/build.cjs b/benchmarks/time-series-renderers/build.cjs
new file mode 100644
index 000000000..56f2937f1
--- /dev/null
+++ b/benchmarks/time-series-renderers/build.cjs
@@ -0,0 +1,14 @@
+const webpack = require("webpack")
+const config = require("./webpack.config.cjs")
+
+webpack(config, (error, stats) => {
+ if (error) {
+ console.error(error)
+ process.exitCode = 1
+ return
+ }
+
+ const output = stats.toString({ colors: false, chunks: false, modules: false })
+ if (output) console.log(output)
+ if (stats.hasErrors()) process.exitCode = 1
+})
diff --git a/benchmarks/time-series-renderers/entry.js b/benchmarks/time-series-renderers/entry.js
new file mode 100644
index 000000000..67499cad4
--- /dev/null
+++ b/benchmarks/time-series-renderers/entry.js
@@ -0,0 +1,760 @@
+import makeDefaultSDK from "@/makeDefaultSDK"
+import {
+ disposeWebGPURuntime,
+ getWebGPURuntime,
+} from "@/chartLibraries/webgpu/engine/runtime"
+import {
+ disposeWebGL2Runtime,
+ getActiveWebGL2Contexts,
+ getWebGL2Runtime,
+} from "@/chartLibraries/webgl2/engine/runtime"
+
+const width = 1600
+const height = 500
+const intervalMs = 1000
+
+const getActiveRenderer = chart =>
+ chart.getRendererState?.().active || chart.getAttribute("chartLibrary")
+
+const quantile = (values, fraction) => {
+ const sorted = [...values].sort((a, b) => a - b)
+ return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * fraction))]
+}
+
+const summarize = values => ({
+ count: values.length,
+ min: Math.min(...values),
+ median: quantile(values, 0.5),
+ p95: quantile(values, 0.95),
+ max: Math.max(...values),
+})
+
+const nextFrame = () => new Promise(resolve => requestAnimationFrame(resolve))
+const measureFrameInterval = async () => {
+ const timestamps = []
+ for (let index = 0; index < 8; index++) {
+ timestamps.push(await new Promise(resolve => requestAnimationFrame(resolve)))
+ }
+ return quantile(
+ timestamps.slice(1).map((timestamp, index) => timestamp - timestamps[index]),
+ 0.5
+ )
+}
+const collectMemory = () => performance.memory?.usedJSHeapSize ?? null
+const setStatus = message => {
+ const status = document.getElementById("benchmark-status")
+ if (status) status.textContent = message
+}
+
+const forceGc = async () => {
+ if (typeof window.gc === "function") window.gc()
+ await new Promise(resolve => setTimeout(resolve, 25))
+ if (typeof window.gc === "function") window.gc()
+}
+
+const makeData = (dimensions, points, revision, profile) => {
+ const start = 1783630694000
+ return Array.from({ length: points }, (_, pointIndex) => {
+ const row = new Array(dimensions + 1)
+ row[0] = start + pointIndex * intervalMs
+
+ for (let dimensionIndex = 0; dimensionIndex < dimensions; dimensionIndex++) {
+ if (profile === "area-overlap") {
+ row[dimensionIndex + 1] = dimensionIndex === 0 ? 75 : 50
+ continue
+ }
+ if (profile === "stacked-diverging") {
+ row[dimensionIndex + 1] = [2, -1, 0.5][dimensionIndex % 3]
+ continue
+ }
+ if (profile === "multi-bar") {
+ row[dimensionIndex + 1] = [2, 1, -1][dimensionIndex % 3]
+ continue
+ }
+ if (profile === "heatmap") {
+ row[dimensionIndex + 1] = [90, 45, 0][dimensionIndex % 3]
+ continue
+ }
+ if (profile === "easy-pie") {
+ row[dimensionIndex + 1] = [75, -25][dimensionIndex % 2]
+ continue
+ }
+ if (profile === "easy-pie-negative") {
+ row[dimensionIndex + 1] = [-75, 25][dimensionIndex % 2]
+ continue
+ }
+ if (profile === "d3-pie") {
+ row[dimensionIndex + 1] = [7, 1, 6, 2, 5, 3, 4][dimensionIndex % 7]
+ continue
+ }
+ const phase = pointIndex * 0.017 + dimensionIndex * 0.031 + revision * 0.13
+ row[dimensionIndex + 1] = Math.sin(phase) * 70 + Math.cos(phase * 0.37) * 20
+ }
+ return row
+ })
+}
+
+const makeChart = state => {
+ const chart = state.sdk.makeChart()
+ chart.on("rendererFallback", (failedRenderer, error) => {
+ state.fallbackErrors.push(`${failedRenderer} fallback: ${error?.stack || error}`)
+ })
+ state.sdk.appendChild(chart)
+
+ let revision = 0
+ const payloads = state.datasets.map(data => ({
+ labels: ["time", ...state.ids],
+ data,
+ all: data,
+ tree: {},
+ }))
+ chart.getPayload = () => payloads[revision]
+ if (new Set(["d3pie", "heatmap"]).has(state.visualization))
+ chart.getDimensionValue = (id, index, options) =>
+ chart.getRowDimensionValue(id, state.datasets[revision][index], options)
+ chart.updateAttributes({
+ chartType: state.visualization,
+ loaded: true,
+ loading: false,
+ processing: false,
+ panning: false,
+ highlighting: false,
+ outOfLimits: false,
+ min: state.range[0],
+ max: state.range[1],
+ valueRange: state.range,
+ ...(state.colors && { colors: state.colors }),
+ ...state.chartAttributes,
+ viewDimensions: {
+ ids: state.ids,
+ names: state.ids,
+ count: state.ids.length,
+ priorities: state.ids.map((_, index) => index),
+ grouped: state.ids.map(() => "dimension"),
+ units: state.ids.map(() => "units"),
+ contexts: state.ids.map(() => "benchmark.context"),
+ sts: state.ids.map(() => 0),
+ algorithm: "absolute",
+ },
+ })
+ chart.updateDimensions()
+ chart.reconcileRenderer()
+
+ if (getActiveRenderer(chart) !== state.renderer) {
+ throw new Error(`Expected ${state.renderer} but routed to ${getActiveRenderer(chart)}`)
+ }
+
+ const element = document.createElement("div")
+ element.dataset.benchmarkChart = "true"
+ element.style.width = `${width}px`
+ element.style.height = `${height}px`
+ document.body.appendChild(element)
+
+ return {
+ chart,
+ element,
+ get ui() {
+ return chart.getUI()
+ },
+ setRevision: nextRevision => {
+ revision = nextRevision
+ },
+ destroy: () => {
+ chart.getUI().unmount()
+ state.sdk.removeChild(chart.getId())
+ element.remove()
+ },
+ }
+}
+
+const settle = async (instance, startedAt = performance.now()) => {
+ await instance.ui.whenReady?.()
+ if (getActiveRenderer(instance.chart) !== prepared.renderer) {
+ const runtimeFailure = prepared.runtime?.lastFailure
+ throw new Error(
+ prepared.fallbackErrors.at(-1) ||
+ (runtimeFailure && `${runtimeFailure.reason}: ${runtimeFailure.message}`) ||
+ `Renderer fell back to ${getActiveRenderer(instance.chart)}`
+ )
+ }
+ await instance.ui.getQueueDone?.()
+ const workCompletionMs = performance.now() - startedAt
+ await nextFrame()
+ return { workCompletionMs, frameMs: performance.now() - startedAt }
+}
+
+let prepared = null
+let preview = null
+
+const prepare = async ({
+ renderer,
+ dimensions,
+ points,
+ gaps = false,
+ visualization = "line",
+ profile = "wave",
+ range = [-90, 90],
+ colors = null,
+ ids = null,
+ chartAttributes = null,
+}) => {
+ if (prepared) throw new Error("Benchmark state already prepared")
+ if (!new Set(["d3pie", "dygraph", "easypiechart", "gauge", "webgpu", "webgl2"]).has(renderer))
+ throw new Error("Unknown renderer")
+ if (
+ !new Set([
+ "line",
+ "area",
+ "d3pie",
+ "easypiechart",
+ "gauge",
+ "heatmap",
+ "multiBar",
+ "stacked",
+ "stackedBar",
+ ]).has(visualization)
+ )
+ throw new Error("Unknown visualization")
+
+ setStatus(`Preparing ${renderer} ${visualization}: ${dimensions * points} values`)
+ const datasets = [
+ makeData(dimensions, points, 0, profile),
+ makeData(dimensions, points, 1, profile),
+ ]
+ if (gaps && points > 2) {
+ const gapIndex = Math.floor(points / 2)
+ datasets.forEach(data => {
+ data[gapIndex][1] = null
+ })
+ }
+ const fallbackErrors = []
+ const sdk = makeDefaultSDK({
+ rendererPolicy: () => renderer,
+ on: {
+ rendererFallback: (chart, failedRenderer, error) => {
+ const message = `${failedRenderer} fallback: ${error?.stack || error}`
+ fallbackErrors.push(message)
+ console.error(message)
+ },
+ },
+ attributes: {
+ autofetch: false,
+ after: datasets[0][0][0] / 1000,
+ before: datasets[0][points - 1][0] / 1000,
+ themeGridColor: ["transparent", "transparent"],
+ },
+ })
+ prepared = {
+ renderer,
+ visualization,
+ dimensions,
+ points,
+ gaps,
+ profile,
+ range,
+ colors,
+ ids: ids || Array.from({ length: dimensions }, (_, index) => `series-${index}`),
+ chartAttributes,
+ datasets,
+ sdk,
+ runtime: null,
+ runtimeLease: false,
+ fallbackErrors,
+ }
+
+ let coldRuntimeMs = null
+ let adapterInfo = null
+ if (renderer === "webgpu") {
+ prepared.runtime = getWebGPURuntime(sdk)
+ const startedAt = performance.now()
+ await prepared.runtime.acquire()
+ coldRuntimeMs = performance.now() - startedAt
+ prepared.runtimeLease = true
+ const { info = {} } = prepared.runtime.adapter
+ adapterInfo = {
+ vendor: info.vendor || null,
+ architecture: info.architecture || null,
+ device: info.device || null,
+ description: info.description || null,
+ }
+ } else if (renderer === "webgl2") {
+ prepared.runtime = getWebGL2Runtime(sdk)
+ const startedAt = performance.now()
+ await prepared.runtime.acquire()
+ coldRuntimeMs = performance.now() - startedAt
+ prepared.runtimeLease = true
+ adapterInfo = prepared.runtime.info
+ }
+
+ await forceGc()
+ const displayFrameIntervalMs = await measureFrameInterval()
+ return {
+ renderer,
+ visualization,
+ dimensions,
+ points,
+ values: dimensions * points,
+ coldRuntimeMs,
+ adapterInfo,
+ displayFrameIntervalMs,
+ memoryBefore: collectMemory(),
+ }
+}
+
+const measureMultiChart = async (count = 4) => {
+ if (prepared.renderer === "dygraph") return null
+ const instances = Array.from({ length: count }, () => makeChart(prepared))
+ const mountStartedAt = performance.now()
+ instances.forEach(instance => instance.ui.mount(instance.element))
+ await Promise.all(instances.map(instance => instance.ui.whenReady()))
+ if (
+ instances.some(
+ instance => getActiveRenderer(instance.chart) !== prepared.renderer
+ )
+ )
+ throw new Error(`A multi-chart ${prepared.renderer} instance failed`)
+ await Promise.all(instances.map(instance => instance.ui.getQueueDone()))
+ await nextFrame()
+ const mountMs = performance.now() - mountStartedAt
+ const resourceReferencesDuring = prepared.runtime.references
+ const sharedResourceBytes = prepared.runtime.getResourceBytes?.() || 0
+
+ const updateStartedAt = performance.now()
+ instances.forEach(instance => {
+ instance.setRevision(1)
+ instance.ui.invalidateRender()
+ instance.ui.render()
+ })
+ await Promise.all(instances.map(instance => instance.ui.getQueueDone()))
+ await nextFrame()
+ const updateMs = performance.now() - updateStartedAt
+ const gpuBufferBytes = instances.reduce(
+ (total, instance) => total + (instance.ui.getBufferBytes?.() || 0),
+ 0
+ )
+
+ instances.forEach(instance => instance.destroy())
+ const resourceReferencesAfter = prepared.runtime.references
+ return {
+ count,
+ mountMs,
+ updateMs,
+ gpuBufferBytes,
+ sharedResourceBytes,
+ resourceReferencesDuring,
+ resourceReferencesAfter,
+ }
+}
+
+const measure = async ({ mountSamples = 3, updateSamples = 10, sustainedMs = 3000 } = {}) => {
+ if (!prepared) throw new Error("Benchmark state has not been prepared")
+ setStatus(
+ `Running ${prepared.renderer} ${prepared.visualization}: ${
+ prepared.dimensions * prepared.points
+ } values`
+ )
+
+ let pipelineWarmupMs = null
+ if (prepared.renderer !== "dygraph") {
+ const warmup = makeChart(prepared)
+ const startedAt = performance.now()
+ warmup.ui.mount(warmup.element)
+ await settle(warmup)
+ pipelineWarmupMs = performance.now() - startedAt
+ warmup.destroy()
+ await forceGc()
+ }
+
+ const mountSync = []
+ const mountWorkCompletion = []
+ const mountFrame = []
+ for (let index = 0; index < mountSamples; index++) {
+ const instance = makeChart(prepared)
+ await nextFrame()
+ const startedAt = performance.now()
+ instance.ui.mount(instance.element)
+ mountSync.push(performance.now() - startedAt)
+ const settlement = await settle(instance, startedAt)
+ mountWorkCompletion.push(settlement.workCompletionMs)
+ mountFrame.push(settlement.frameMs)
+ instance.destroy()
+ await forceGc()
+ }
+
+ const instance = makeChart(prepared)
+ instance.ui.mount(instance.element)
+ await settle(instance)
+ let peakMemory = collectMemory()
+
+ for (let index = 0; index < 2; index++) {
+ instance.setRevision((index + 1) % 2)
+ instance.ui.invalidateRender()
+ instance.ui.render()
+ await settle(instance)
+ }
+
+ const updateSync = []
+ const updateWorkCompletion = []
+ const updateFrame = []
+ for (let index = 0; index < updateSamples; index++) {
+ instance.setRevision(index % 2)
+ instance.ui.invalidateRender()
+ const startedAt = performance.now()
+ instance.ui.render()
+ updateSync.push(performance.now() - startedAt)
+ const settlement = await settle(instance, startedAt)
+ updateWorkCompletion.push(settlement.workCompletionMs)
+ updateFrame.push(settlement.frameMs)
+ peakMemory = Math.max(peakMemory || 0, collectMemory() || 0)
+ }
+
+ const sustainedDurations = []
+ const sustainedStartedAt = performance.now()
+ let sustainedUpdates = 0
+ while (performance.now() - sustainedStartedAt < sustainedMs) {
+ instance.setRevision(sustainedUpdates % 2)
+ instance.ui.invalidateRender()
+ const updateStartedAt = performance.now()
+ instance.ui.render()
+ await settle(instance)
+ sustainedDurations.push(performance.now() - updateStartedAt)
+ sustainedUpdates += 1
+ peakMemory = Math.max(peakMemory || 0, collectMemory() || 0)
+ }
+ const sustainedElapsedMs = performance.now() - sustainedStartedAt
+ const gpuBufferBytes = instance.ui.getBufferBytes?.() || 0
+ const exportCanvas = instance.ui.getCanvas?.() || instance.element.querySelector("canvas")
+ const exportDataUrlBytes = exportCanvas?.toDataURL("image/png").length || 0
+
+ instance.destroy()
+ const multiChart = await measureMultiChart()
+ await forceGc()
+ const retainedMemory = collectMemory()
+
+ setStatus(
+ `Completed ${prepared.renderer} ${prepared.visualization}: ${
+ prepared.dimensions * prepared.points
+ } values`
+ )
+ return {
+ renderer: prepared.renderer,
+ visualization: prepared.visualization,
+ dimensions: prepared.dimensions,
+ points: prepared.points,
+ values: prepared.dimensions * prepared.points,
+ canvas: { width, height, devicePixelRatio: window.devicePixelRatio },
+ pipelineWarmupMs,
+ mountSyncMs: summarize(mountSync),
+ mountWorkCompletionMs: summarize(mountWorkCompletion),
+ mountFrameMs: summarize(mountFrame),
+ updateSyncMs: summarize(updateSync),
+ updateWorkCompletionMs: summarize(updateWorkCompletion),
+ updateFrameMs: summarize(updateFrame),
+ sustained: {
+ elapsedMs: sustainedElapsedMs,
+ updates: sustainedUpdates,
+ updatesPerSecond: (sustainedUpdates * 1000) / sustainedElapsedMs,
+ updateFrameMs: summarize(sustainedDurations),
+ missedFrameBudget: sustainedDurations.filter(value => value > 1000 / 60).length,
+ },
+ gpuBufferBytes,
+ exportDataUrlBytes,
+ multiChart,
+ peakMemory,
+ retainedMemory,
+ }
+}
+
+const mountPreview = async ({
+ stepped = false,
+ visibleDimensionIds = null,
+ enabledXAxis,
+ enabledYAxis,
+ width: previewWidth,
+ height: previewHeight,
+} = {}) => {
+ if (!prepared) throw new Error("Benchmark state has not been prepared")
+ if (preview) throw new Error("Preview is already mounted")
+
+ preview = makeChart(prepared)
+ if (previewWidth) preview.element.style.width = `${previewWidth}px`
+ if (previewHeight) preview.element.style.height = `${previewHeight}px`
+ if (prepared.renderer === "gauge") preview.element.appendChild(document.createElement("canvas"))
+ preview.chart.updateAttribute("stepPlot", stepped)
+ if (enabledXAxis !== undefined) preview.chart.updateAttribute("enabledXAxis", enabledXAxis)
+ if (enabledYAxis !== undefined) preview.chart.updateAttribute("enabledYAxis", enabledYAxis)
+ if (visibleDimensionIds) {
+ preview.chart.updateAttribute("selectedLegendDimensions", visibleDimensionIds)
+ }
+ preview.ui.mount(preview.element)
+ await settle(preview)
+ return {
+ renderer: getActiveRenderer(preview.chart),
+ canvas: preview.ui.getCanvas?.()?.dataset.renderer || "dygraph",
+ runtimeReferences: prepared.runtime?.references || 0,
+ }
+}
+
+const inspectPreview = () => {
+ if (!preview) throw new Error("A preview is required")
+ const xAxisRange = preview.ui.getXAxisRange?.() || null
+ return {
+ plotArea: preview.ui.getPlotArea?.() || null,
+ xAxisRange,
+ xCoords: xAxisRange?.map(value => preview.ui.getXCoord?.(value)) || null,
+ hoverX: preview.chart.getAttribute("hoverX"),
+ clickX: preview.chart.getAttribute("clickX"),
+ navigation: preview.chart.getAttribute("navigation"),
+ panning: preview.chart.getAttribute("panning"),
+ enabledHover: preview.chart.getAttribute("enabledHover"),
+ }
+}
+
+const capturePreview = async ({ samples = [] } = {}) => {
+ if (!preview) throw new Error("A preview is required")
+ const canvas = preview.ui.getCanvas?.() || preview.element.querySelector("canvas")
+ const svg = !canvas && preview.element.querySelector("svg")
+ if (!canvas && !svg) throw new Error("The preview has no graphical surface")
+ let imageUrl
+ let revokeImageUrl = false
+ if (canvas) imageUrl = canvas.toDataURL("image/png")
+ else {
+ const graphicalSvg = svg.cloneNode(true)
+ Array.from(graphicalSvg.children).forEach(child => {
+ if (!child.getAttribute("class")?.endsWith("pieChart")) child.remove()
+ })
+ const source = new XMLSerializer().serializeToString(graphicalSvg)
+ imageUrl = URL.createObjectURL(new Blob([source], { type: "image/svg+xml" }))
+ revokeImageUrl = true
+ }
+ const image = new Image()
+ image.src = imageUrl
+ await image.decode()
+ const copy = document.createElement("canvas")
+ copy.width = canvas?.width || Number(svg.getAttribute("width"))
+ copy.height = canvas?.height || Number(svg.getAttribute("height"))
+ const context = copy.getContext("2d")
+ context.drawImage(image, 0, 0)
+ if (revokeImageUrl) URL.revokeObjectURL(imageUrl)
+ const dataUrl = copy.toDataURL("image/png")
+ const pixels = context.getImageData(0, 0, copy.width, copy.height).data
+ let nonTransparentPixels = 0
+ for (let offset = 3; offset < pixels.length; offset += 4) {
+ if (pixels[offset]) nonTransparentPixels += 1
+ }
+
+ const dpr = window.devicePixelRatio || 1
+ const plot = preview.ui.getPlotArea?.() || {
+ left: 0,
+ top: 0,
+ width: copy.width / dpr,
+ height: copy.height / dpr,
+ }
+ const gapIndex = Math.floor(prepared.points / 2)
+ const gapX = plot
+ ? Math.round((plot.left + (plot.width * gapIndex) / (prepared.points - 1)) * dpr)
+ : null
+ const spacing = plot ? (plot.width * dpr) / Math.max(prepared.points - 1, 1) : 0
+ const gapHalfWidth = Math.max(1, Math.floor(spacing * 0.35))
+ let gapBandNonTransparentPixels = null
+ if (gapX !== null) {
+ gapBandNonTransparentPixels = 0
+ for (let y = 0; y < copy.height; y += 1) {
+ for (
+ let x = Math.max(0, gapX - gapHalfWidth);
+ x <= Math.min(copy.width - 1, gapX + gapHalfWidth);
+ x += 1
+ ) {
+ if (pixels[(y * copy.width + x) * 4 + 3]) gapBandNonTransparentPixels += 1
+ }
+ }
+ }
+
+ const samplePixels = Object.fromEntries(
+ samples.map(({ name, xRatio, yRatio, xOffset = 0 }) => {
+ const x = Math.max(
+ 0,
+ Math.min(
+ copy.width - 1,
+ Math.round((plot.left + plot.width * xRatio + xOffset) * dpr)
+ )
+ )
+ const y = Math.max(
+ 0,
+ Math.min(copy.height - 1, Math.round((plot.top + plot.height * yRatio) * dpr))
+ )
+ const offset = (y * copy.width + x) * 4
+ return [name, Array.from(pixels.slice(offset, offset + 4))]
+ })
+ )
+
+ const sampleRuns = Object.fromEntries(
+ samples.map(({ name, xRatio, yRatio, xOffset = 0 }) => {
+ const x = Math.max(
+ 0,
+ Math.min(
+ copy.width - 1,
+ Math.round((plot.left + plot.width * xRatio + xOffset) * dpr)
+ )
+ )
+ const y = Math.max(
+ 0,
+ Math.min(copy.height - 1, Math.round((plot.top + plot.height * yRatio) * dpr))
+ )
+ if (!pixels[(y * copy.width + x) * 4 + 3]) return [name, { width: 0 }]
+ let left = x
+ let right = x
+ while (left > 0 && pixels[(y * copy.width + left - 1) * 4 + 3]) left -= 1
+ while (
+ right + 1 < copy.width &&
+ pixels[(y * copy.width + right + 1) * 4 + 3]
+ )
+ right += 1
+ return [name, { left, right, width: right - left + 1 }]
+ })
+ )
+
+ const sampleVerticalRuns = Object.fromEntries(
+ samples.map(({ name, xRatio, yRatio, xOffset = 0 }) => {
+ const x = Math.max(
+ 0,
+ Math.min(
+ copy.width - 1,
+ Math.round((plot.left + plot.width * xRatio + xOffset) * dpr)
+ )
+ )
+ const y = Math.max(
+ 0,
+ Math.min(copy.height - 1, Math.round((plot.top + plot.height * yRatio) * dpr))
+ )
+ if (!pixels[(y * copy.width + x) * 4 + 3]) return [name, { height: 0 }]
+ let top = y
+ let bottom = y
+ while (top > 0 && pixels[((top - 1) * copy.width + x) * 4 + 3]) top -= 1
+ while (
+ bottom + 1 < copy.height &&
+ pixels[((bottom + 1) * copy.width + x) * 4 + 3]
+ )
+ bottom += 1
+ return [name, { top, bottom, height: bottom - top + 1 }]
+ })
+ )
+
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(dataUrl))
+ const sha256 = Array.from(new Uint8Array(digest), byte =>
+ byte.toString(16).padStart(2, "0")
+ ).join("")
+
+ const dygraph = preview.ui.getDygraph?.()
+ const yAxisRange = dygraph?.yAxisRange?.() || preview.ui.getDrawStats?.()?.valueRange || null
+
+ return {
+ dataUrlBytes: dataUrl.length,
+ sha256,
+ width: copy.width,
+ height: copy.height,
+ nonTransparentPixels,
+ gapBandNonTransparentPixels,
+ gapBandWidth: gapHalfWidth * 2 + 1,
+ samplePixels,
+ sampleRuns,
+ sampleVerticalRuns,
+ yAxisRange,
+ drawStats: preview.ui.getDrawStats?.() || null,
+ semanticLabels: Array.from(preview.element.querySelectorAll("svg text"), element =>
+ element.textContent.trim()
+ ),
+ connectorCount: preview.element.querySelectorAll("svg g[class$='lineGroup'] path").length,
+ segmentTransforms: Array.from(
+ preview.element.querySelectorAll("svg path[data-index]"),
+ element => element.getAttribute("transform") || ""
+ ),
+ segmentFills: Array.from(
+ preview.element.querySelectorAll("svg path[data-index]"),
+ element => element.style.fill || element.getAttribute("fill") || ""
+ ),
+ segmentClasses: Array.from(
+ preview.element.querySelectorAll("svg path[data-index]"),
+ element => element.getAttribute("class") || ""
+ ),
+ }
+}
+
+const exerciseDeviceLossFallback = async () => {
+ if (!preview || !prepared?.runtime?.device) throw new Error("A WebGPU preview is required")
+
+ prepared.runtime.device.destroy()
+ const deadline = performance.now() + 2000
+ while (getActiveRenderer(preview.chart) === "webgpu" && performance.now() < deadline) {
+ await new Promise(resolve => setTimeout(resolve, 10))
+ }
+
+ return {
+ renderer: getActiveRenderer(preview.chart),
+ hasWebGL2: getActiveRenderer(preview.chart) === "webgl2",
+ hasDygraph: Boolean(preview.chart.getUI().getDygraph?.()),
+ }
+}
+
+const exerciseWebGL2ContextLossFallback = async () => {
+ const runtime = prepared?.sdk ? getWebGL2Runtime(prepared.sdk) : null
+ if (!preview || getActiveRenderer(preview.chart) !== "webgl2" || !runtime?.gl)
+ throw new Error("A WebGL2 preview is required")
+ const extension = runtime.gl.getExtension("WEBGL_lose_context")
+ if (!extension) throw new Error("WEBGL_lose_context is unavailable")
+
+ extension.loseContext()
+ const deadline = performance.now() + 2000
+ while (getActiveRenderer(preview.chart) === "webgl2" && performance.now() < deadline) {
+ await new Promise(resolve => setTimeout(resolve, 10))
+ }
+ return {
+ renderer: getActiveRenderer(preview.chart),
+ hasDygraph: Boolean(preview.chart.getUI().getDygraph?.()),
+ }
+}
+
+const exerciseInitializationUnmount = async () => {
+ if (!prepared || prepared.renderer === "dygraph")
+ throw new Error("An accelerated renderer must be prepared")
+
+ const instance = makeChart(prepared)
+ instance.ui.mount(instance.element)
+ const ready = instance.ui.whenReady()
+ instance.destroy()
+ await ready
+ await nextFrame()
+
+ return {
+ elementConnected: instance.element.isConnected,
+ canvasConnected: Boolean(instance.ui.getCanvas?.()?.isConnected),
+ resourceReferences: prepared.runtime.references,
+ }
+}
+
+const cleanup = async () => {
+ preview?.destroy()
+ preview = null
+ if (prepared?.runtimeLease) prepared.runtime.release()
+ if (prepared?.sdk) {
+ disposeWebGPURuntime(prepared.sdk)
+ disposeWebGL2Runtime(prepared.sdk)
+ }
+ prepared = null
+ document.querySelectorAll("[data-benchmark-chart]").forEach(element => element.remove())
+ setStatus("Renderer benchmark is idle")
+ await forceGc()
+}
+
+window.__NETDATA_RENDERER_BENCHMARK__ = {
+ prepare,
+ measure,
+ mountPreview,
+ inspectPreview,
+ capturePreview,
+ exerciseDeviceLossFallback,
+ exerciseWebGL2ContextLossFallback,
+ exerciseInitializationUnmount,
+ getActiveWebGL2Contexts,
+ cleanup,
+}
diff --git a/benchmarks/time-series-renderers/index.html b/benchmarks/time-series-renderers/index.html
new file mode 100644
index 000000000..e0b2d2bf3
--- /dev/null
+++ b/benchmarks/time-series-renderers/index.html
@@ -0,0 +1,25 @@
+
+
+
+
+
+ Time-series renderer benchmark
+
+
+
+ Renderer benchmark is loading…
+
+
+
diff --git a/benchmarks/time-series-renderers/run.cjs b/benchmarks/time-series-renderers/run.cjs
new file mode 100644
index 000000000..d43a4ee47
--- /dev/null
+++ b/benchmarks/time-series-renderers/run.cjs
@@ -0,0 +1,176 @@
+const http = require("node:http")
+const fs = require("node:fs")
+const path = require("node:path")
+const os = require("node:os")
+const { chromium } = require("playwright-core")
+
+const root = path.join(__dirname, "dist")
+const indexPath = path.join(__dirname, "index.html")
+const workloads = [
+ {
+ dimensions: 100,
+ points: 1000,
+ gate: "single-frame",
+ requiredMainThreadSpeedup: 3,
+ },
+ {
+ dimensions: 1000,
+ points: 1000,
+ gate: "relative",
+ requiredFrameSpeedup: 5,
+ },
+]
+const supportedCandidates = new Set(["webgpu", "webgl2"])
+const candidateRenderers = [
+ ...new Set(
+ (process.env.BENCHMARK_RENDERERS || "webgpu")
+ .split(",")
+ .map(value => value.trim())
+ .filter(value => value && value !== "dygraph")
+ ),
+]
+if (
+ !candidateRenderers.length ||
+ candidateRenderers.some(value => !supportedCandidates.has(value))
+)
+ throw new Error("BENCHMARK_RENDERERS must select webgpu and/or webgl2")
+const renderers = ["dygraph", ...candidateRenderers]
+const visualization = process.env.BENCHMARK_VISUALIZATION || "line"
+const radialOnly = process.env.BENCHMARK_RADIAL_ONLY === "1"
+const correctnessOnly = process.env.BENCHMARK_CORRECTNESS_ONLY === "1"
+if (
+ !new Set(["line", "area", "heatmap", "multiBar", "stacked", "stackedBar"]).has(
+ visualization
+ )
+)
+ throw new Error(
+ "BENCHMARK_VISUALIZATION must select line, area, heatmap, multiBar, stacked, or stackedBar"
+ )
+
+const server = http.createServer((request, response) => {
+ const file = request.url === "/benchmark.js" ? path.join(root, "benchmark.js") : indexPath
+ if (!fs.existsSync(file)) {
+ response.writeHead(404)
+ response.end("not found")
+ return
+ }
+
+ response.writeHead(200, {
+ "content-type": file.endsWith(".js") ? "text/javascript" : "text/html",
+ "cache-control": "no-store",
+ })
+ fs.createReadStream(file).pipe(response)
+})
+
+const listen = () =>
+ new Promise(resolve => {
+ server.listen(0, "127.0.0.1", () => resolve(server.address().port))
+ })
+
+const close = () => new Promise(resolve => server.close(resolve))
+const createPageHarness = require("./runner/browser.cjs")
+const runSuite = require("./runner/suite.cjs")
+
+const makeBrowserArgs = () => {
+ const args = [
+ "--enable-precise-memory-info",
+ "--js-flags=--expose-gc",
+ "--disable-background-timer-throttling",
+ "--disable-renderer-backgrounding",
+ ]
+ if (process.env.CHROMIUM_OZONE_PLATFORM)
+ args.push(`--ozone-platform=${process.env.CHROMIUM_OZONE_PLATFORM}`)
+ if (process.env.WEBGPU_UNSAFE === "1") args.push("--enable-unsafe-webgpu")
+ if (process.env.WEBGPU_VULKAN === "1")
+ args.push(
+ "--enable-unsafe-webgpu",
+ "--ozone-platform=wayland",
+ "--use-angle=vulkan",
+ "--enable-features=Vulkan,VulkanFromANGLE"
+ )
+ if (process.env.WEBGPU_SOFTWARE === "1")
+ args.push("--enable-unsafe-webgpu", "--use-angle=swiftshader-webgl")
+ return args
+}
+
+const getChromiumExecutable = () => {
+ const systemChromium = "/usr/bin/chromium"
+ return (
+ process.env.CHROMIUM_EXECUTABLE ||
+ (fs.existsSync(systemChromium) ? systemChromium : chromium.executablePath())
+ )
+}
+
+const run = async () => {
+ const port = await listen()
+ let browser
+ let context
+ let browserVersion
+ let suite
+
+ try {
+ browser = await chromium.launch({
+ headless: process.env.BENCHMARK_HEADED !== "1",
+ executablePath: getChromiumExecutable(),
+ args: makeBrowserArgs(),
+ })
+ browserVersion = browser.version()
+ context = await browser.newContext({
+ viewport: { width: 1600, height: 500 },
+ deviceScaleFactor: 1,
+ })
+ const page = await context.newPage()
+ const harness = createPageHarness({ context, page })
+ suite = await runSuite({
+ harness,
+ port,
+ config: {
+ workloads,
+ renderers,
+ candidateRenderers,
+ visualization,
+ radialOnly,
+ correctnessOnly,
+ },
+ })
+ } finally {
+ await context?.close()
+ await browser?.close()
+ await close()
+ }
+
+ const output = {
+ generatedAt: new Date().toISOString(),
+ runtime: {
+ platform: process.platform,
+ architecture: process.arch,
+ node: process.version,
+ chromium: browserVersion,
+ cpuModel: os.cpus()[0]?.model,
+ logicalCpus: os.cpus().length,
+ },
+ method: {
+ correctnessOnly,
+ renderers: `Dygraphs and ${candidateRenderers.join(
+ ", "
+ )} rendering ${visualization} from the same @netdata/charts checkout`,
+ data: "deterministic row-major values; two pre-generated revisions alternated",
+ canvas: "1600x500 CSS pixels at devicePixelRatio 1",
+ browser:
+ "one shared Chromium context, window, and persistent page; state reset by navigation",
+ samples: "3 mounts, 2 warm-up updates, 10 measured updates, 3 seconds sustained updates",
+ primaryLatency:
+ "100k uses measured one-frame presentation/work completion plus synchronous speedup; 1M uses median frame-settled speedup",
+ memory: "Chromium usedJSHeapSize delta after forced GC; peak sampled after settled draws",
+ },
+ ...suite,
+ }
+
+ process.stdout.write(`${JSON.stringify(output, null, 2)}\n`)
+ if (!suite.passed) process.exitCode = 1
+}
+
+run().catch(error => {
+ console.error(error)
+ process.exitCode = 1
+})
diff --git a/benchmarks/time-series-renderers/runner/bars.cjs b/benchmarks/time-series-renderers/runner/bars.cjs
new file mode 100644
index 000000000..59e2e0d07
--- /dev/null
+++ b/benchmarks/time-series-renderers/runner/bars.cjs
@@ -0,0 +1,134 @@
+const captureMultiBar = async (
+ harness,
+ port,
+ renderer,
+ visibleDimensionIds = null
+) => {
+ const scope = await harness.openScope({
+ viewport: { width: 1600, height: 500 },
+ deviceScaleFactor: 1,
+ })
+ const { page } = scope
+ try {
+ await page.goto(`http://127.0.0.1:${port}`, { waitUntil: "load" })
+ await page.waitForFunction(() => Boolean(window.__NETDATA_RENDERER_BENCHMARK__))
+ await page.evaluate(input => window.__NETDATA_RENDERER_BENCHMARK__.prepare(input), {
+ renderer,
+ visualization: "multiBar",
+ dimensions: 3,
+ points: 101,
+ profile: "multi-bar",
+ range: [-3, 3],
+ colors: {
+ "series-0": "#ff0000",
+ "series-1": "#00ff00",
+ "series-2": "#0000ff",
+ },
+ })
+ await page.evaluate(
+ input => window.__NETDATA_RENDERER_BENCHMARK__.mountPreview(input),
+ {
+ enabledXAxis: false,
+ enabledYAxis: false,
+ visibleDimensionIds,
+ }
+ )
+ const capture = await page.evaluate(() =>
+ window.__NETDATA_RENDERER_BENCHMARK__.capturePreview({
+ samples: [
+ { name: "topRed", xRatio: 0.5, xOffset: -4, yRatio: 0.25 },
+ { name: "lowerRed", xRatio: 0.5, xOffset: -4, yRatio: 0.42 },
+ { name: "lowerGreen", xRatio: 0.5, xOffset: -2, yRatio: 0.42 },
+ { name: "negativeBlue", xRatio: 0.5, xOffset: 1, yRatio: 0.58 },
+ { name: "redBorder", xRatio: 0.5, xOffset: -5, yRatio: 0.25 },
+ { name: "outside", xRatio: 0.5, xOffset: 5, yRatio: 0.42 },
+ ],
+ })
+ )
+ await page.evaluate(() => window.__NETDATA_RENDERER_BENCHMARK__.cleanup())
+ return capture
+ } finally {
+ await scope.close()
+ }
+}
+
+const validateMultiBarParity = async (
+ harness,
+ port,
+ renderer,
+ dygraphCapture,
+ dygraphReflowCapture
+) => {
+ const capture = await captureMultiBar(harness, port, renderer)
+ const reflowCapture = await captureMultiBar(harness, port, renderer, [
+ "series-0",
+ "series-2",
+ ])
+ const makeDeltas = (reference, candidate) =>
+ Object.fromEntries(
+ Object.keys(reference.samplePixels).map(name => [
+ name,
+ Math.max(
+ ...reference.samplePixels[name].map((value, index) =>
+ Math.abs(value - candidate.samplePixels[name][index])
+ )
+ ),
+ ])
+ )
+ const deltas = makeDeltas(dygraphCapture, capture)
+ const reflowDeltas = makeDeltas(dygraphReflowCapture, reflowCapture)
+ const samples = capture.samplePixels
+ const reflowSamples = reflowCapture.samplePixels
+ const barRunWidthDelta = Math.abs(
+ capture.sampleRuns.topRed.width - dygraphCapture.sampleRuns.topRed.width
+ )
+ const barVerticalHeightDelta = Math.abs(
+ capture.sampleVerticalRuns.topRed.height -
+ dygraphCapture.sampleVerticalRuns.topRed.height
+ )
+ const semanticsPassed = Boolean(
+ samples.topRed[0] > samples.topRed[1] &&
+ samples.lowerGreen[1] > samples.lowerGreen[0] &&
+ samples.negativeBlue[2] > samples.negativeBlue[0] &&
+ samples.outside[3] === 0 &&
+ reflowSamples.lowerGreen[0] > reflowSamples.lowerGreen[1] &&
+ barRunWidthDelta <= 1 &&
+ barVerticalHeightDelta <= 2 &&
+ JSON.stringify(capture.yAxisRange) ===
+ JSON.stringify(dygraphCapture.yAxisRange)
+ )
+ const passed = Boolean(
+ semanticsPassed &&
+ Object.values(deltas).every(delta => delta <= 3) &&
+ Object.values(reflowDeltas).every(delta => delta <= 3)
+ )
+ const portablePassed = Boolean(
+ semanticsPassed &&
+ Object.values(deltas).every(delta => delta <= 32) &&
+ Object.values(reflowDeltas).every(delta => delta <= 32)
+ )
+ return {
+ renderer,
+ samples,
+ reflowSamples,
+ dygraphSamples: dygraphCapture.samplePixels,
+ dygraphReflowSamples: dygraphReflowCapture.samplePixels,
+ deltas,
+ reflowDeltas,
+ barRunWidth: capture.sampleRuns.topRed.width,
+ dygraphBarRunWidth: dygraphCapture.sampleRuns.topRed.width,
+ barRunWidthDelta,
+ barVerticalHeight: capture.sampleVerticalRuns.topRed.height,
+ dygraphBarVerticalHeight: dygraphCapture.sampleVerticalRuns.topRed.height,
+ barVerticalHeightDelta,
+ yAxisRange: capture.yAxisRange,
+ dygraphYAxisRange: dygraphCapture.yAxisRange,
+ portablePassed,
+ passed,
+ }
+}
+
+module.exports = {
+ captureMultiBar,
+ validateMultiBarParity,
+}
diff --git a/benchmarks/time-series-renderers/runner/browser.cjs b/benchmarks/time-series-renderers/runner/browser.cjs
new file mode 100644
index 000000000..42a35125a
--- /dev/null
+++ b/benchmarks/time-series-renderers/runner/browser.cjs
@@ -0,0 +1,18 @@
+module.exports = ({ context, page }) => ({
+ openScope: async ({ viewport } = {}) => {
+ if (viewport) await page.setViewportSize(viewport)
+ const sessions = []
+
+ return {
+ page,
+ newCDPSession: async () => {
+ const session = await context.newCDPSession(page)
+ sessions.push(session)
+ return session
+ },
+ close: async () =>
+ Promise.all(sessions.map(session => session.detach())),
+ }
+ },
+ resetPage: () => page.goto("about:blank"),
+})
diff --git a/benchmarks/time-series-renderers/runner/compare.cjs b/benchmarks/time-series-renderers/runner/compare.cjs
new file mode 100644
index 000000000..121ca45c9
--- /dev/null
+++ b/benchmarks/time-series-renderers/runner/compare.cjs
@@ -0,0 +1,71 @@
+const { validateLifecycle } = require("./lifecycle.cjs")
+
+const speedup = (baseline, candidate) => baseline / Math.max(candidate, Number.EPSILON)
+
+const compare = (results, { workloads, candidateRenderers }) =>
+ workloads.flatMap(workload => {
+ const values = workload.dimensions * workload.points
+ const dygraph = results.find(result => result.renderer === "dygraph" && result.values === values)
+
+ return candidateRenderers.map(candidateRenderer => {
+ const candidate = results.find(
+ result => result.renderer === candidateRenderer && result.values === values
+ )
+ const multiChartPassed = validateLifecycle(candidate)
+ const speedups = {
+ mountSync: speedup(dygraph.mountSyncMs.median, candidate.mountSyncMs.median),
+ mountFrame: speedup(dygraph.mountFrameMs.median, candidate.mountFrameMs.median),
+ updateSync: speedup(dygraph.updateSyncMs.median, candidate.updateSyncMs.median),
+ updateFrame: speedup(dygraph.updateFrameMs.median, candidate.updateFrameMs.median),
+ }
+
+ if (workload.gate === "single-frame") {
+ const frameBudgetMs = candidate.displayFrameIntervalMs * 1.25
+ const mountPassed =
+ speedups.mountSync >= workload.requiredMainThreadSpeedup &&
+ candidate.mountWorkCompletionMs.median <= frameBudgetMs &&
+ candidate.mountFrameMs.median <= frameBudgetMs
+ const updatePassed =
+ speedups.updateSync >= workload.requiredMainThreadSpeedup &&
+ candidate.updateWorkCompletionMs.median <= frameBudgetMs &&
+ candidate.updateFrameMs.median <= frameBudgetMs
+
+ return {
+ candidateRenderer,
+ values,
+ gate: workload.gate,
+ measuredDisplayFrameMs: candidate.displayFrameIntervalMs,
+ allowedFrameBudgetMs: frameBudgetMs,
+ requiredMainThreadSpeedup: workload.requiredMainThreadSpeedup,
+ speedups,
+ candidateWorkCompletionMs: {
+ mount: candidate.mountWorkCompletionMs.median,
+ update: candidate.updateWorkCompletionMs.median,
+ },
+ candidateFrameMs: {
+ mount: candidate.mountFrameMs.median,
+ update: candidate.updateFrameMs.median,
+ },
+ mountPassed,
+ updatePassed,
+ exportPassed: candidate.exportDataUrlBytes > 1000,
+ multiChartPassed,
+ }
+ }
+
+ return {
+ candidateRenderer,
+ values,
+ gate: workload.gate,
+ requiredFrameSpeedup: workload.requiredFrameSpeedup,
+ speedups,
+ mountPassed: speedups.mountFrame >= workload.requiredFrameSpeedup,
+ updatePassed: speedups.updateFrame >= workload.requiredFrameSpeedup,
+ exportPassed: candidate.exportDataUrlBytes > 1000,
+ multiChartPassed,
+ }
+ })
+ })
+
+
+module.exports = compare
diff --git a/benchmarks/time-series-renderers/runner/fallback.cjs b/benchmarks/time-series-renderers/runner/fallback.cjs
new file mode 100644
index 000000000..b011f1093
--- /dev/null
+++ b/benchmarks/time-series-renderers/runner/fallback.cjs
@@ -0,0 +1,93 @@
+const legacyRendererByVisualization = {
+ d3pie: "d3pie",
+ easypiechart: "easypiechart",
+ gauge: "gauge",
+}
+
+const getLegacyRenderer = visualization =>
+ legacyRendererByVisualization[visualization] || "dygraph"
+
+const prepare = async (page, port, renderer, visualization) => {
+ await page.goto(`http://127.0.0.1:${port}`, { waitUntil: "load" })
+ await page.waitForFunction(() =>
+ Boolean(window.__NETDATA_RENDERER_BENCHMARK__)
+ )
+ await page.evaluate(
+ input => window.__NETDATA_RENDERER_BENCHMARK__.prepare(input),
+ { renderer, visualization, dimensions: 1, points: 100 }
+ )
+ await page.evaluate(() =>
+ window.__NETDATA_RENDERER_BENCHMARK__.mountPreview()
+ )
+}
+
+const cleanup = async page => {
+ await page.evaluate(() => window.__NETDATA_RENDERER_BENCHMARK__.cleanup())
+ return page.evaluate(() =>
+ window.__NETDATA_RENDERER_BENCHMARK__.getActiveWebGL2Contexts()
+ )
+}
+
+const validateFallbackChain = async (harness, port, visualization) => {
+ const scope = await harness.openScope({
+ viewport: { width: 1600, height: 500 },
+ deviceScaleFactor: 1,
+ })
+ const { page } = scope
+ try {
+ await prepare(page, port, "webgpu", visualization)
+ const deviceLoss = await page.evaluate(() =>
+ window.__NETDATA_RENDERER_BENCHMARK__.exerciseDeviceLossFallback()
+ )
+ const contextLoss = await page.evaluate(() =>
+ window.__NETDATA_RENDERER_BENCHMARK__.exerciseWebGL2ContextLossFallback()
+ )
+ const activeWebGL2Contexts = await cleanup(page)
+ const legacyRenderer = getLegacyRenderer(visualization)
+ return {
+ deviceLoss,
+ contextLoss,
+ activeWebGL2Contexts,
+ passed:
+ deviceLoss.renderer === "webgl2" &&
+ deviceLoss.hasWebGL2 &&
+ !deviceLoss.hasDygraph &&
+ contextLoss.renderer === legacyRenderer &&
+ contextLoss.hasDygraph === (legacyRenderer === "dygraph") &&
+ activeWebGL2Contexts === 0,
+ }
+ } finally {
+ await scope.close()
+ }
+}
+
+const validateWebGL2Fallback = async (harness, port, visualization) => {
+ const scope = await harness.openScope({
+ viewport: { width: 1600, height: 500 },
+ deviceScaleFactor: 1,
+ })
+ const { page } = scope
+ try {
+ await prepare(page, port, "webgl2", visualization)
+ const contextLoss = await page.evaluate(() =>
+ window.__NETDATA_RENDERER_BENCHMARK__.exerciseWebGL2ContextLossFallback()
+ )
+ const activeWebGL2Contexts = await cleanup(page)
+ const legacyRenderer = getLegacyRenderer(visualization)
+ return {
+ contextLoss,
+ activeWebGL2Contexts,
+ passed:
+ contextLoss.renderer === legacyRenderer &&
+ contextLoss.hasDygraph === (legacyRenderer === "dygraph") &&
+ activeWebGL2Contexts === 0,
+ }
+ } finally {
+ await scope.close()
+ }
+}
+
+module.exports = {
+ validateFallbackChain,
+ validateWebGL2Fallback,
+}
diff --git a/benchmarks/time-series-renderers/runner/filled.cjs b/benchmarks/time-series-renderers/runner/filled.cjs
new file mode 100644
index 000000000..c11f747d6
--- /dev/null
+++ b/benchmarks/time-series-renderers/runner/filled.cjs
@@ -0,0 +1,305 @@
+const validateFilledVisualization = async (
+ harness,
+ port,
+ renderer,
+ visualizationId
+) => {
+ const scope = await harness.openScope({
+ viewport: { width: 1600, height: 500 },
+ deviceScaleFactor: 1,
+ })
+ const { page } = scope
+ const cases = [
+ { name: "regular", gaps: false, stepped: false },
+ { name: "step", gaps: false, stepped: true },
+ { name: "gap", gaps: true, stepped: false },
+ ]
+ const captures = {}
+
+ try {
+ await page.goto(`http://127.0.0.1:${port}`, { waitUntil: "load" })
+ await page.waitForFunction(() => Boolean(window.__NETDATA_RENDERER_BENCHMARK__))
+ for (const benchmarkCase of cases) {
+ await page.evaluate(
+ input => window.__NETDATA_RENDERER_BENCHMARK__.prepare(input),
+ {
+ renderer,
+ visualization: visualizationId,
+ dimensions: 1,
+ points: 100,
+ gaps: benchmarkCase.gaps,
+ }
+ )
+ await page.evaluate(
+ input => window.__NETDATA_RENDERER_BENCHMARK__.mountPreview(input),
+ {
+ stepped: benchmarkCase.stepped,
+ enabledXAxis: false,
+ enabledYAxis: false,
+ }
+ )
+ captures[benchmarkCase.name] = await page.evaluate(() =>
+ window.__NETDATA_RENDERER_BENCHMARK__.capturePreview({
+ samples: [{ name: "gapCenter", xRatio: 50 / 99, yRatio: 0.75 }],
+ })
+ )
+ await page.evaluate(() => window.__NETDATA_RENDERER_BENCHMARK__.cleanup())
+ }
+ } finally {
+ await scope.close()
+ }
+
+ const regularStats = captures.regular.drawStats
+ const stepStats = captures.step.drawStats
+ const gapStats = captures.gap.drawStats
+ const isBar = new Set(["heatmap", "multiBar", "stackedBar"]).has(
+ visualizationId
+ )
+ const exactDraws = isBar
+ ? [regularStats, stepStats, gapStats].every(
+ stats =>
+ stats?.barInstanceCount === 100 &&
+ stats.fillInstanceCount === 100 &&
+ stats.strokeInstanceCount === 0 &&
+ stats.instanceCount === 100
+ )
+ : Boolean(
+ regularStats?.sourcePairs === 99 &&
+ regularStats.fillInstanceCount === 99 &&
+ regularStats.strokeInstanceCount === 99 &&
+ regularStats.instanceCount === 198 &&
+ stepStats?.sourcePairs === 99 &&
+ stepStats.fillInstanceCount === 99 &&
+ stepStats.strokeInstanceCount === 198 &&
+ stepStats.instanceCount === 297 &&
+ gapStats?.sourcePairs === 99 &&
+ gapStats.fillInstanceCount === 99 &&
+ gapStats.strokeInstanceCount === 99
+ )
+ const stepPassed = isBar
+ ? captures.regular.sha256 === captures.step.sha256
+ : captures.regular.sha256 !== captures.step.sha256
+ const heatmapGapPassed =
+ visualizationId !== "heatmap" ||
+ (captures.regular.samplePixels.gapCenter[3] > 0 &&
+ captures.gap.samplePixels.gapCenter[3] === 0)
+ const passed = Boolean(
+ exactDraws &&
+ captures.regular.nonTransparentPixels > 0 &&
+ captures.step.nonTransparentPixels > 0 &&
+ captures.gap.nonTransparentPixels > 0 &&
+ heatmapGapPassed &&
+ stepPassed &&
+ captures.gap.gapBandNonTransparentPixels === 0
+ )
+
+ return { renderer, visualization: visualizationId, captures, exactDraws, passed }
+}
+
+const captureAreaOverlap = async (harness, port, renderer) => {
+ const scope = await harness.openScope({
+ viewport: { width: 1600, height: 500 },
+ deviceScaleFactor: 1,
+ })
+ const { page } = scope
+ try {
+ await page.goto(`http://127.0.0.1:${port}`, { waitUntil: "load" })
+ await page.waitForFunction(() => Boolean(window.__NETDATA_RENDERER_BENCHMARK__))
+ await page.evaluate(input => window.__NETDATA_RENDERER_BENCHMARK__.prepare(input), {
+ renderer,
+ visualization: "area",
+ dimensions: 2,
+ points: 100,
+ profile: "area-overlap",
+ range: [20, 100],
+ colors: { "series-0": "#ff0000", "series-1": "#0000ff" },
+ })
+ await page.evaluate(() =>
+ window.__NETDATA_RENDERER_BENCHMARK__.mountPreview({
+ enabledXAxis: false,
+ enabledYAxis: false,
+ })
+ )
+ const capture = await page.evaluate(() =>
+ window.__NETDATA_RENDERER_BENCHMARK__.capturePreview({
+ samples: [
+ { name: "empty", xRatio: 0.5, yRatio: 0.1 },
+ { name: "firstSeriesOnly", xRatio: 0.5, yRatio: 0.45 },
+ { name: "overlap", xRatio: 0.5, yRatio: 0.7 },
+ { name: "nearBaseline", xRatio: 0.5, yRatio: 0.9 },
+ ],
+ })
+ )
+ await page.evaluate(() => window.__NETDATA_RENDERER_BENCHMARK__.cleanup())
+ return capture
+ } finally {
+ await scope.close()
+ }
+}
+
+const validateAreaParity = async (harness, port, renderer, dygraphCapture) => {
+ const capture = await captureAreaOverlap(harness, port, renderer)
+ const deltas = Object.fromEntries(
+ Object.keys(dygraphCapture.samplePixels).map(name => [
+ name,
+ Math.max(
+ ...dygraphCapture.samplePixels[name].map((value, index) =>
+ Math.abs(value - capture.samplePixels[name][index])
+ )
+ ),
+ ])
+ )
+ const samples = capture.samplePixels
+ const passed = Boolean(
+ samples.empty[3] === 0 &&
+ samples.firstSeriesOnly[3] > 0 &&
+ samples.overlap[3] > samples.firstSeriesOnly[3] &&
+ samples.nearBaseline[3] > 0 &&
+ Object.values(deltas).every(delta => delta <= 3)
+ )
+ return { renderer, samples: capture.samplePixels, deltas, passed }
+}
+
+const captureStackedDiverging = async (
+ harness,
+ port,
+ renderer,
+ visualizationId = "stacked"
+) => {
+ const scope = await harness.openScope({
+ viewport: { width: 1600, height: 500 },
+ deviceScaleFactor: 1,
+ })
+ const { page } = scope
+ try {
+ await page.goto(`http://127.0.0.1:${port}`, { waitUntil: "load" })
+ await page.waitForFunction(() => Boolean(window.__NETDATA_RENDERER_BENCHMARK__))
+ await page.evaluate(input => window.__NETDATA_RENDERER_BENCHMARK__.prepare(input), {
+ renderer,
+ visualization: visualizationId,
+ dimensions: 3,
+ points: 101,
+ profile: "stacked-diverging",
+ range: [-3, 3],
+ colors: {
+ "series-0": "#ff0000",
+ "series-1": "#00ff00",
+ "series-2": "#0000ff",
+ },
+ })
+ await page.evaluate(() =>
+ window.__NETDATA_RENDERER_BENCHMARK__.mountPreview({
+ enabledXAxis: false,
+ enabledYAxis: false,
+ })
+ )
+ const capture = await page.evaluate(
+ isBar =>
+ window.__NETDATA_RENDERER_BENCHMARK__.capturePreview({
+ samples: [
+ { name: "topPositive", xRatio: 0.5, yRatio: 0.25 },
+ { name: "bottomPositive", xRatio: 0.5, yRatio: 0.47 },
+ { name: "negative", xRatio: 0.5, yRatio: 0.58 },
+ { name: "empty", xRatio: 0.5, yRatio: 0.85 },
+ ...(isBar
+ ? [
+ { name: "barBorder", xRatio: 0.5, xOffset: 4, yRatio: 0.25 },
+ { name: "barEdge", xRatio: 0.5, xOffset: 5, yRatio: 0.25 },
+ { name: "barOutside", xRatio: 0.5, xOffset: 7, yRatio: 0.25 },
+ ]
+ : []),
+ ],
+ }),
+ visualizationId === "stackedBar"
+ )
+ await page.evaluate(() => window.__NETDATA_RENDERER_BENCHMARK__.cleanup())
+ return capture
+ } finally {
+ await scope.close()
+ }
+}
+
+const validateStackedParity = async (
+ harness,
+ port,
+ renderer,
+ visualizationId,
+ dygraphCapture
+) => {
+ const capture = await captureStackedDiverging(
+ harness,
+ port,
+ renderer,
+ visualizationId
+ )
+ const deltas = Object.fromEntries(
+ Object.keys(dygraphCapture.samplePixels).map(name => [
+ name,
+ Math.max(
+ ...dygraphCapture.samplePixels[name].map((value, index) =>
+ Math.abs(value - capture.samplePixels[name][index])
+ )
+ ),
+ ])
+ )
+ const samples = capture.samplePixels
+ const barRunWidth = capture.sampleRuns.topPositive?.width || 0
+ const dygraphBarRunWidth = dygraphCapture.sampleRuns.topPositive?.width || 0
+ const barRunWidthDelta = Math.abs(barRunWidth - dygraphBarRunWidth)
+ const barVerticalHeight = capture.sampleVerticalRuns.topPositive?.height || 0
+ const dygraphBarVerticalHeight =
+ dygraphCapture.sampleVerticalRuns.topPositive?.height || 0
+ const barVerticalHeightDelta = Math.abs(
+ barVerticalHeight - dygraphBarVerticalHeight
+ )
+ const barPixelsPassed =
+ visualizationId !== "stackedBar" ||
+ (samples.barBorder[0] > 0 &&
+ samples.barBorder[1] > 0 &&
+ samples.barOutside[3] === 0 &&
+ barRunWidth > 0 &&
+ barRunWidthDelta <= 1 &&
+ barVerticalHeight > 0 &&
+ barVerticalHeightDelta <= 2)
+ const semanticsPassed = Boolean(
+ samples.topPositive[0] > samples.topPositive[2] &&
+ samples.bottomPositive[2] > samples.bottomPositive[0] &&
+ samples.negative[1] > samples.negative[0] &&
+ samples.empty[3] === 0 &&
+ barPixelsPassed
+ )
+ const passed = Boolean(
+ semanticsPassed && Object.values(deltas).every(delta => delta <= 3)
+ )
+ const portablePassed = Boolean(
+ semanticsPassed && Object.values(deltas).every(delta => delta <= 32)
+ )
+ return {
+ renderer,
+ visualization: visualizationId,
+ samples,
+ deltas,
+ ...(visualizationId === "stackedBar" && {
+ dygraphBarBorder: dygraphCapture.samplePixels.barBorder,
+ }),
+ nonTransparentPixels: capture.nonTransparentPixels,
+ barRunWidth,
+ dygraphBarRunWidth,
+ barRunWidthDelta,
+ barVerticalHeight,
+ dygraphBarVerticalHeight,
+ barVerticalHeightDelta,
+ yAxisRange: capture.yAxisRange,
+ dygraphYAxisRange: dygraphCapture.yAxisRange,
+ portablePassed,
+ passed,
+ }
+}
+
+module.exports = {
+ validateFilledVisualization,
+ captureAreaOverlap,
+ validateAreaParity,
+ captureStackedDiverging,
+ validateStackedParity,
+}
diff --git a/benchmarks/time-series-renderers/runner/heatmap.cjs b/benchmarks/time-series-renderers/runner/heatmap.cjs
new file mode 100644
index 000000000..be3065fa1
--- /dev/null
+++ b/benchmarks/time-series-renderers/runner/heatmap.cjs
@@ -0,0 +1,92 @@
+const captureHeatmap = async (harness, port, renderer) => {
+ const scope = await harness.openScope({
+ viewport: { width: 1600, height: 500 },
+ deviceScaleFactor: 1,
+ })
+ const { page } = scope
+ try {
+ await page.goto(`http://127.0.0.1:${port}`, { waitUntil: "load" })
+ await page.waitForFunction(() => Boolean(window.__NETDATA_RENDERER_BENCHMARK__))
+ await page.evaluate(input => window.__NETDATA_RENDERER_BENCHMARK__.prepare(input), {
+ renderer,
+ visualization: "heatmap",
+ dimensions: 3,
+ points: 101,
+ profile: "heatmap",
+ range: [0, 90],
+ ids: ["+Inf", "0.3", "2"],
+ })
+ await page.evaluate(() =>
+ window.__NETDATA_RENDERER_BENCHMARK__.mountPreview({
+ enabledXAxis: false,
+ enabledYAxis: false,
+ })
+ )
+ const capture = await page.evaluate(() =>
+ window.__NETDATA_RENDERER_BENCHMARK__.capturePreview({
+ samples: [
+ { name: "top", xRatio: 0.5, yRatio: 0.34 },
+ { name: "middle", xRatio: 0.5, yRatio: 0.66 },
+ { name: "bottom", xRatio: 0.5, yRatio: 0.97 },
+ { name: "outside", xRatio: 0.5, xOffset: 10, yRatio: 0.34 },
+ ],
+ })
+ )
+ await page.evaluate(() => window.__NETDATA_RENDERER_BENCHMARK__.cleanup())
+ return capture
+ } finally {
+ await scope.close()
+ }
+}
+
+const validateHeatmapParity = async (
+ harness,
+ port,
+ renderer,
+ dygraphCapture
+) => {
+ const capture = await captureHeatmap(harness, port, renderer)
+ const deltas = Object.fromEntries(
+ Object.keys(dygraphCapture.samplePixels).map(name => [
+ name,
+ Math.max(
+ ...dygraphCapture.samplePixels[name].map((value, index) =>
+ Math.abs(value - capture.samplePixels[name][index])
+ )
+ ),
+ ])
+ )
+ const samples = capture.samplePixels
+ const horizontalDelta = Math.abs(
+ capture.sampleRuns.top.width - dygraphCapture.sampleRuns.top.width
+ )
+ const verticalDelta = Math.abs(
+ capture.sampleVerticalRuns.top.height -
+ dygraphCapture.sampleVerticalRuns.top.height
+ )
+ const passed = Boolean(
+ samples.top[3] > 0 &&
+ samples.middle[3] === 0 &&
+ samples.bottom[3] > 0 &&
+ Object.values(deltas).every(delta => delta <= 3) &&
+ horizontalDelta <= 1 &&
+ verticalDelta <= 2 &&
+ JSON.stringify(capture.yAxisRange) === JSON.stringify(dygraphCapture.yAxisRange)
+ )
+ return {
+ renderer,
+ samples,
+ dygraphSamples: dygraphCapture.samplePixels,
+ deltas,
+ horizontalDelta,
+ verticalDelta,
+ yAxisRange: capture.yAxisRange,
+ dygraphYAxisRange: dygraphCapture.yAxisRange,
+ passed,
+ }
+}
+
+module.exports = {
+ captureHeatmap,
+ validateHeatmapParity,
+}
diff --git a/benchmarks/time-series-renderers/runner/lifecycle.cjs b/benchmarks/time-series-renderers/runner/lifecycle.cjs
new file mode 100644
index 000000000..27b5989cb
--- /dev/null
+++ b/benchmarks/time-series-renderers/runner/lifecycle.cjs
@@ -0,0 +1,53 @@
+const validateLifecycle = result => {
+ const expectedReferencesAfter = 1
+ const expectedReferencesDuring =
+ result.multiChart.count + expectedReferencesAfter
+
+ return Boolean(
+ result.multiChart &&
+ result.multiChart.resourceReferencesDuring === expectedReferencesDuring &&
+ result.multiChart.resourceReferencesAfter === expectedReferencesAfter &&
+ result.multiChart.gpuBufferBytes > 0 &&
+ result.multiChart.sharedResourceBytes > 0
+ )
+}
+
+const validateCorrectnessMeasurement = result =>
+ result.exportDataUrlBytes > 1000 && validateLifecycle(result)
+
+const validateInitializationUnmount = async (harness, port, renderer) => {
+ const scope = await harness.openScope({
+ viewport: { width: 1600, height: 500 },
+ deviceScaleFactor: 1,
+ })
+ const { page } = scope
+ try {
+ await page.goto(`http://127.0.0.1:${port}`, { waitUntil: "load" })
+ await page.waitForFunction(() =>
+ Boolean(window.__NETDATA_RENDERER_BENCHMARK__)
+ )
+ await page.evaluate(
+ input => window.__NETDATA_RENDERER_BENCHMARK__.prepare(input),
+ { renderer, visualization: "line", dimensions: 10, points: 100 }
+ )
+ const result = await page.evaluate(() =>
+ window.__NETDATA_RENDERER_BENCHMARK__.exerciseInitializationUnmount()
+ )
+ await page.evaluate(() => window.__NETDATA_RENDERER_BENCHMARK__.cleanup())
+ return {
+ ...result,
+ passed:
+ !result.elementConnected &&
+ !result.canvasConnected &&
+ result.resourceReferences === 1,
+ }
+ } finally {
+ await scope.close()
+ }
+}
+
+module.exports = {
+ validateLifecycle,
+ validateCorrectnessMeasurement,
+ validateInitializationUnmount,
+}
diff --git a/benchmarks/time-series-renderers/runner/measurements.cjs b/benchmarks/time-series-renderers/runner/measurements.cjs
new file mode 100644
index 000000000..030d9b5f5
--- /dev/null
+++ b/benchmarks/time-series-renderers/runner/measurements.cjs
@@ -0,0 +1,117 @@
+const metricsByName = metrics =>
+ Object.fromEntries(metrics.map(({ name, value }) => [name, value]))
+const measureCase = async (harness, port, benchmarkCase) => {
+ const scope = await harness.openScope({
+ viewport: { width: 1600, height: 500 },
+ deviceScaleFactor: 1,
+ })
+ const { page } = scope
+
+ try {
+ await page.goto(`http://127.0.0.1:${port}`, { waitUntil: "load" })
+ await page.waitForFunction(() => Boolean(window.__NETDATA_RENDERER_BENCHMARK__))
+
+ const prepared = await page.evaluate(
+ input => window.__NETDATA_RENDERER_BENCHMARK__.prepare(input),
+ benchmarkCase
+ )
+ const session = await scope.newCDPSession()
+ await session.send("Performance.enable")
+ const before = metricsByName((await session.send("Performance.getMetrics")).metrics)
+ const wallStartedAt = Date.now()
+ const measured = await page.evaluate(() =>
+ window.__NETDATA_RENDERER_BENCHMARK__.measure({
+ mountSamples: 3,
+ updateSamples: 10,
+ sustainedMs: 3000,
+ })
+ )
+ const wallElapsedMs = Date.now() - wallStartedAt
+ const after = metricsByName((await session.send("Performance.getMetrics")).metrics)
+
+ await page.evaluate(() => window.__NETDATA_RENDERER_BENCHMARK__.cleanup())
+
+ return {
+ ...prepared,
+ ...measured,
+ wallElapsedMs,
+ cdp: {
+ taskDurationMs: (after.TaskDuration - before.TaskDuration) * 1000,
+ scriptDurationMs: (after.ScriptDuration - before.ScriptDuration) * 1000,
+ layoutDurationMs: (after.LayoutDuration - before.LayoutDuration) * 1000,
+ },
+ peakHeapDelta:
+ measured.peakMemory == null || prepared.memoryBefore == null
+ ? null
+ : measured.peakMemory - prepared.memoryBefore,
+ retainedHeapDelta:
+ measured.retainedMemory == null || prepared.memoryBefore == null
+ ? null
+ : measured.retainedMemory - prepared.memoryBefore,
+ }
+ } finally {
+ await scope.close()
+ }
+}
+
+const validateLine = async (harness, port, renderer) => {
+ const scope = await harness.openScope({
+ viewport: { width: 1600, height: 500 },
+ deviceScaleFactor: 1,
+ })
+ const { page } = scope
+ const cases = [
+ { name: "smooth", gaps: false, stepped: false },
+ { name: "step", gaps: false, stepped: true },
+ { name: "gap", gaps: true, stepped: false },
+ ]
+ const captures = {}
+
+ try {
+ await page.goto(`http://127.0.0.1:${port}`, { waitUntil: "load" })
+ await page.waitForFunction(() => Boolean(window.__NETDATA_RENDERER_BENCHMARK__))
+ for (const benchmarkCase of cases) {
+ await page.evaluate(
+ input => window.__NETDATA_RENDERER_BENCHMARK__.prepare(input),
+ { renderer, dimensions: 1, points: 100, gaps: benchmarkCase.gaps }
+ )
+ await page.evaluate(
+ input => window.__NETDATA_RENDERER_BENCHMARK__.mountPreview(input),
+ {
+ stepped: benchmarkCase.stepped,
+ enabledXAxis: false,
+ enabledYAxis: false,
+ }
+ )
+ captures[benchmarkCase.name] = await page.evaluate(() =>
+ window.__NETDATA_RENDERER_BENCHMARK__.capturePreview()
+ )
+ await page.evaluate(() => window.__NETDATA_RENDERER_BENCHMARK__.cleanup())
+ }
+ } finally {
+ await scope.close()
+ }
+
+ const exactDraws = cases.map(({ name }) => captures[name]).every(
+ capture =>
+ capture.drawStats.sourcePairs === 99 &&
+ capture.drawStats.instanceCount ===
+ capture.drawStats.sourcePairs * capture.drawStats.segmentsPerPair
+ )
+ const passed = Boolean(
+ exactDraws &&
+ captures.smooth.nonTransparentPixels > 0 &&
+ captures.step.nonTransparentPixels > 0 &&
+ captures.gap.nonTransparentPixels > 0 &&
+ captures.smooth.sha256 !== captures.step.sha256 &&
+ captures.step.drawStats.segmentsPerPair === 2 &&
+ captures.gap.gapBandNonTransparentPixels === 0
+ )
+
+ return { renderer, captures, exactDraws, passed }
+}
+
+module.exports = {
+ measureCase,
+ validateLine,
+}
diff --git a/benchmarks/time-series-renderers/runner/radial.cjs b/benchmarks/time-series-renderers/runner/radial.cjs
new file mode 100644
index 000000000..e1ed8f343
--- /dev/null
+++ b/benchmarks/time-series-renderers/runner/radial.cjs
@@ -0,0 +1,289 @@
+const captureEasyPie = async (harness, port, renderer, profile) => {
+ const scope = await harness.openScope({
+ viewport: { width: 700, height: 700 },
+ deviceScaleFactor: 1,
+ })
+ const { page } = scope
+ try {
+ await page.goto(`http://127.0.0.1:${port}`, { waitUntil: "load" })
+ await page.waitForFunction(() => Boolean(window.__NETDATA_RENDERER_BENCHMARK__))
+ await page.evaluate(input => window.__NETDATA_RENDERER_BENCHMARK__.prepare(input), {
+ renderer,
+ visualization: "easypiechart",
+ dimensions: 2,
+ points: 10,
+ profile,
+ range: [0, 100],
+ })
+ await page.evaluate(() =>
+ window.__NETDATA_RENDERER_BENCHMARK__.mountPreview({
+ width: 500,
+ height: 500,
+ })
+ )
+ const capture = await page.evaluate(() =>
+ window.__NETDATA_RENDERER_BENCHMARK__.capturePreview({
+ samples: [
+ { name: "top", xRatio: 0.5, yRatio: 0.07 },
+ { name: "right", xRatio: 0.93, yRatio: 0.5 },
+ { name: "bottom", xRatio: 0.5, yRatio: 0.93 },
+ { name: "left", xRatio: 0.07, yRatio: 0.5 },
+ { name: "center", xRatio: 0.5, yRatio: 0.5 },
+ ],
+ })
+ )
+ await page.evaluate(() => window.__NETDATA_RENDERER_BENCHMARK__.cleanup())
+ return capture
+ } finally {
+ await scope.close()
+ }
+}
+
+const validateEasyPieParity = async (harness, port, renderer, references) => {
+ const positive = await captureEasyPie(harness, port, renderer, "easy-pie")
+ const negative = await captureEasyPie(harness, port, renderer, "easy-pie-negative")
+ const makeDeltas = (reference, candidate) =>
+ Object.fromEntries(
+ Object.keys(reference.samplePixels).map(name => [
+ name,
+ Math.max(
+ ...reference.samplePixels[name].map((value, index) =>
+ Math.abs(value - candidate.samplePixels[name][index])
+ )
+ ),
+ ])
+ )
+ const positiveDeltas = makeDeltas(references.positive, positive)
+ const negativeDeltas = makeDeltas(references.negative, negative)
+ const runWidthDelta = Math.abs(
+ positive.sampleRuns.right.width - references.positive.sampleRuns.right.width
+ )
+ const pixelCountDelta = Math.abs(
+ positive.nonTransparentPixels - references.positive.nonTransparentPixels
+ )
+ const passed = Boolean(
+ positive.samplePixels.top[3] > 0 &&
+ positive.samplePixels.right[3] > 0 &&
+ positive.samplePixels.bottom[3] > 0 &&
+ positive.samplePixels.left[3] > 0 &&
+ positive.samplePixels.center[3] === 0 &&
+ negative.samplePixels.left[3] > 0 &&
+ negative.samplePixels.right[3] > 0 &&
+ Object.values(positiveDeltas).every(delta => delta <= 3) &&
+ Object.values(negativeDeltas).every(delta => delta <= 3) &&
+ runWidthDelta <= 2 &&
+ pixelCountDelta <= 1000
+ )
+ return {
+ renderer,
+ positiveSamples: positive.samplePixels,
+ negativeSamples: negative.samplePixels,
+ referencePositiveSamples: references.positive.samplePixels,
+ referenceNegativeSamples: references.negative.samplePixels,
+ positiveDeltas,
+ negativeDeltas,
+ runWidthDelta,
+ pixelCountDelta,
+ drawStats: positive.drawStats,
+ passed,
+ }
+}
+
+const captureGauge = async (harness, port, renderer) => {
+ const scope = await harness.openScope({
+ viewport: { width: 700, height: 700 },
+ deviceScaleFactor: 1,
+ })
+ const { page } = scope
+ try {
+ await page.goto(`http://127.0.0.1:${port}`, { waitUntil: "load" })
+ await page.waitForFunction(() => Boolean(window.__NETDATA_RENDERER_BENCHMARK__))
+ await page.evaluate(input => window.__NETDATA_RENDERER_BENCHMARK__.prepare(input), {
+ renderer,
+ visualization: "gauge",
+ dimensions: 2,
+ points: 10,
+ profile: "easy-pie",
+ range: [0, 100],
+ })
+ await page.evaluate(() =>
+ window.__NETDATA_RENDERER_BENCHMARK__.mountPreview({ width: 500, height: 500 })
+ )
+ const capture = await page.evaluate(() =>
+ window.__NETDATA_RENDERER_BENCHMARK__.capturePreview({
+ samples: [
+ { name: "progress", xRatio: 0.112, yRatio: 0.39 },
+ { name: "track", xRatio: 0.884, yRatio: 0.39 },
+ { name: "pointerBody", xRatio: 0.5, yRatio: 0.1 },
+ { name: "pointerCenter", xRatio: 0.5, yRatio: 0.586 },
+ { name: "empty", xRatio: 0.5, yRatio: 0.95 },
+ ],
+ })
+ )
+ await page.evaluate(() => window.__NETDATA_RENDERER_BENCHMARK__.cleanup())
+ return capture
+ } finally {
+ await scope.close()
+ }
+}
+
+const validateGaugeParity = async (harness, port, renderer, reference) => {
+ const capture = await captureGauge(harness, port, renderer)
+ const deltas = Object.fromEntries(
+ Object.keys(reference.samplePixels).map(name => [
+ name,
+ Math.max(
+ ...reference.samplePixels[name].map((value, index) =>
+ Math.abs(value - capture.samplePixels[name][index])
+ )
+ ),
+ ])
+ )
+ const pixelCountDelta = Math.abs(
+ capture.nonTransparentPixels - reference.nonTransparentPixels
+ )
+ const passed = Boolean(
+ capture.samplePixels.progress[3] > 0 &&
+ capture.samplePixels.track[3] > 0 &&
+ capture.samplePixels.pointerBody[3] > 0 &&
+ capture.samplePixels.pointerCenter[3] > 0 &&
+ capture.samplePixels.empty[3] === 0 &&
+ Object.values(deltas).every(delta => delta <= 4) &&
+ pixelCountDelta <= 1500
+ )
+ return {
+ renderer,
+ samples: capture.samplePixels,
+ referenceSamples: reference.samplePixels,
+ deltas,
+ pixelCountDelta,
+ drawStats: capture.drawStats,
+ passed,
+ }
+}
+
+const captureD3Pie = async (harness, port, renderer) => {
+ const scope = await harness.openScope({
+ viewport: { width: 700, height: 700 },
+ deviceScaleFactor: 1,
+ })
+ const { page } = scope
+ try {
+ await page.goto(`http://127.0.0.1:${port}`, { waitUntil: "load" })
+ await page.waitForFunction(() => Boolean(window.__NETDATA_RENDERER_BENCHMARK__))
+ await page.mouse.move(690, 690)
+ await page.evaluate(input => window.__NETDATA_RENDERER_BENCHMARK__.prepare(input), {
+ renderer,
+ visualization: "d3pie",
+ dimensions: 7,
+ points: 10,
+ profile: "d3-pie",
+ range: [0, 7],
+ })
+ await page.evaluate(() =>
+ window.__NETDATA_RENDERER_BENCHMARK__.mountPreview({ width: 500, height: 500 })
+ )
+ const sampleOptions = {
+ samples: [
+ { name: "series0", xRatio: 0.69, yRatio: 0.31 },
+ { name: "series2", xRatio: 0.706, yRatio: 0.674 },
+ { name: "series4", xRatio: 0.408, yRatio: 0.754 },
+ { name: "series5", xRatio: 0.246, yRatio: 0.592 },
+ { name: "series6", xRatio: 0.256, yRatio: 0.308 },
+ { name: "grouped", xRatio: 0.408, yRatio: 0.246 },
+ { name: "center", xRatio: 0.5, yRatio: 0.5 },
+ { name: "outside", xRatio: 0.5, yRatio: 0.05 },
+ ],
+ }
+ const initial = await page.evaluate(
+ options => window.__NETDATA_RENDERER_BENCHMARK__.capturePreview(options),
+ sampleOptions
+ )
+ const firstSegment = page.locator(
+ "[data-benchmark-chart] svg path[data-index='0']"
+ )
+ await firstSegment.hover({ force: true })
+ await page.waitForTimeout(50)
+ const hovered = await page.evaluate(
+ options => window.__NETDATA_RENDERER_BENCHMARK__.capturePreview(options),
+ sampleOptions
+ )
+ await firstSegment.click({ force: true })
+ await page.waitForTimeout(600)
+ const expanded = await page.evaluate(
+ options => window.__NETDATA_RENDERER_BENCHMARK__.capturePreview(options),
+ sampleOptions
+ )
+ await page.evaluate(() => window.__NETDATA_RENDERER_BENCHMARK__.cleanup())
+ return { initial, hovered, expanded }
+ } finally {
+ await scope.close()
+ }
+}
+
+const validateD3PieParity = async (harness, port, renderer, reference) => {
+ const capture = await captureD3Pie(harness, port, renderer)
+ const deltas = Object.fromEntries(
+ Object.keys(reference.initial.samplePixels).map(name => [
+ name,
+ Math.max(
+ ...reference.initial.samplePixels[name].map((value, index) =>
+ Math.abs(value - capture.initial.samplePixels[name][index])
+ )
+ ),
+ ])
+ )
+ const hoverDelta = Math.max(
+ ...reference.hovered.samplePixels.series0.map((value, index) =>
+ Math.abs(value - capture.hovered.samplePixels.series0[index])
+ )
+ )
+ const pixelCountDelta = Math.abs(
+ capture.initial.nonTransparentPixels - reference.initial.nonTransparentPixels
+ )
+ const labelsMatch =
+ JSON.stringify(capture.initial.semanticLabels) ===
+ JSON.stringify(reference.initial.semanticLabels)
+ const expanded = capture.expanded.segmentTransforms.some(Boolean)
+ const gpuOffset =
+ renderer === "d3pie" ||
+ capture.expanded.drawStats?.expandedOffsetPixels > 0
+ const passed = Boolean(
+ capture.initial.samplePixels.center[3] === 0 &&
+ capture.initial.samplePixels.outside[3] === 0 &&
+ Object.values(deltas).every(delta => delta <= 4) &&
+ hoverDelta <= 4 &&
+ pixelCountDelta <= 1500 &&
+ labelsMatch &&
+ capture.initial.connectorCount === reference.initial.connectorCount &&
+ expanded &&
+ gpuOffset
+ )
+ return {
+ renderer,
+ samples: capture.initial.samplePixels,
+ referenceSamples: reference.initial.samplePixels,
+ deltas,
+ hoverDelta,
+ hoverFill: capture.hovered.segmentFills[0],
+ referenceHoverFill: reference.hovered.segmentFills[0],
+ pixelCountDelta,
+ labelsMatch,
+ connectorCount: capture.initial.connectorCount,
+ expanded,
+ expandedTransforms: capture.expanded.segmentTransforms,
+ expandedClasses: capture.expanded.segmentClasses,
+ drawStats: capture.expanded.drawStats,
+ passed,
+ }
+}
+
+
+module.exports = {
+ captureEasyPie,
+ validateEasyPieParity,
+ captureGauge,
+ validateGaugeParity,
+ captureD3Pie,
+ validateD3PieParity,
+}
diff --git a/benchmarks/time-series-renderers/runner/suite.cjs b/benchmarks/time-series-renderers/runner/suite.cjs
new file mode 100644
index 000000000..951f6fabe
--- /dev/null
+++ b/benchmarks/time-series-renderers/runner/suite.cjs
@@ -0,0 +1,328 @@
+const { measureCase, validateLine } = require("./measurements.cjs")
+const {
+ validateFilledVisualization,
+ captureAreaOverlap,
+ validateAreaParity,
+ captureStackedDiverging,
+ validateStackedParity,
+} = require("./filled.cjs")
+const {
+ captureMultiBar,
+ validateMultiBarParity,
+} = require("./bars.cjs")
+const {
+ captureHeatmap,
+ validateHeatmapParity,
+} = require("./heatmap.cjs")
+const {
+ captureEasyPie,
+ validateEasyPieParity,
+ captureGauge,
+ validateGaugeParity,
+ captureD3Pie,
+ validateD3PieParity,
+} = require("./radial.cjs")
+const {
+ validateFallbackChain,
+ validateWebGL2Fallback,
+} = require("./fallback.cjs")
+const {
+ validateCorrectnessMeasurement,
+ validateInitializationUnmount,
+} = require("./lifecycle.cjs")
+const compare = require("./compare.cjs")
+
+const makeCorrectness = () => ({
+ line: {},
+ area: {},
+ areaParity: {},
+ heatmap: {},
+ heatmapParity: {},
+ multiBar: {},
+ multiBarParity: {},
+ stacked: {},
+ stackedParity: {},
+ stackedBar: {},
+ stackedBarParity: {},
+ d3PieParity: {},
+ easyPieParity: {},
+ gaugeParity: {},
+ fallbackChain: null,
+ d3PieFallbackChain: null,
+ easyPieFallbackChain: null,
+ gaugeFallbackChain: null,
+ webGL2Fallbacks: {},
+ initializationUnmount: {},
+})
+
+const measureResults = async ({
+ harness,
+ port,
+ radialOnly,
+ correctnessOnly,
+ workloads,
+ renderers,
+ candidateRenderers,
+ visualization,
+}) => {
+ if (radialOnly) return []
+ const results = []
+ if (correctnessOnly) {
+ for (const renderer of candidateRenderers)
+ results.push(
+ await measureCase(harness, port, {
+ dimensions: 10,
+ points: 100,
+ renderer,
+ visualization,
+ })
+ )
+ return results
+ }
+
+ for (const workload of workloads)
+ for (const renderer of renderers)
+ results.push(
+ await measureCase(harness, port, {
+ ...workload,
+ renderer,
+ visualization,
+ })
+ )
+ return results
+}
+
+const captureReferences = async ({ harness, port, radialOnly }) => {
+ const references = {}
+ if (!radialOnly) {
+ references.area = await captureAreaOverlap(harness, port, "dygraph")
+ references.heatmap = await captureHeatmap(harness, port, "dygraph")
+ references.multiBar = await captureMultiBar(harness, port, "dygraph")
+ references.multiBarReflow = await captureMultiBar(
+ harness,
+ port,
+ "dygraph",
+ ["series-0", "series-2"]
+ )
+ references.stacked = await captureStackedDiverging(
+ harness,
+ port,
+ "dygraph"
+ )
+ references.stackedBar = await captureStackedDiverging(
+ harness,
+ port,
+ "dygraph",
+ "stackedBar"
+ )
+ }
+ references.d3Pie = await captureD3Pie(harness, port, "d3pie")
+ references.easyPie = {
+ positive: await captureEasyPie(
+ harness,
+ port,
+ "easypiechart",
+ "easy-pie"
+ ),
+ negative: await captureEasyPie(
+ harness,
+ port,
+ "easypiechart",
+ "easy-pie-negative"
+ ),
+ }
+ references.gauge = await captureGauge(harness, port, "gauge")
+ return references
+}
+
+const validateCartesian = async ({
+ harness,
+ port,
+ renderer,
+ references,
+ correctness,
+}) => {
+ correctness.line[renderer] = await validateLine(harness, port, renderer)
+ for (const visualization of ["area", "heatmap", "multiBar", "stacked", "stackedBar"])
+ correctness[visualization][renderer] = await validateFilledVisualization(
+ harness,
+ port,
+ renderer,
+ visualization
+ )
+ correctness.areaParity[renderer] = await validateAreaParity(
+ harness,
+ port,
+ renderer,
+ references.area
+ )
+ correctness.heatmapParity[renderer] = await validateHeatmapParity(
+ harness,
+ port,
+ renderer,
+ references.heatmap
+ )
+ correctness.multiBarParity[renderer] = await validateMultiBarParity(
+ harness,
+ port,
+ renderer,
+ references.multiBar,
+ references.multiBarReflow
+ )
+ correctness.stackedParity[renderer] = await validateStackedParity(
+ harness,
+ port,
+ renderer,
+ "stacked",
+ references.stacked
+ )
+ correctness.stackedBarParity[renderer] = await validateStackedParity(
+ harness,
+ port,
+ renderer,
+ "stackedBar",
+ references.stackedBar
+ )
+}
+
+const validateRenderer = async ({
+ harness,
+ port,
+ renderer,
+ radialOnly,
+ references,
+ correctness,
+}) => {
+ await harness.resetPage()
+ if (!radialOnly)
+ await validateCartesian({
+ harness,
+ port,
+ renderer,
+ references,
+ correctness,
+ })
+ correctness.d3PieParity[renderer] = await validateD3PieParity(
+ harness,
+ port,
+ renderer,
+ references.d3Pie
+ )
+ correctness.easyPieParity[renderer] = await validateEasyPieParity(
+ harness,
+ port,
+ renderer,
+ references.easyPie
+ )
+ correctness.gaugeParity[renderer] = await validateGaugeParity(
+ harness,
+ port,
+ renderer,
+ references.gauge
+ )
+ correctness.initializationUnmount[renderer] =
+ await validateInitializationUnmount(harness, port, renderer)
+}
+
+const validateFallbacks = async ({
+ harness,
+ port,
+ radialOnly,
+ candidateRenderers,
+ visualization,
+ correctness,
+}) => {
+ if (candidateRenderers.includes("webgl2")) {
+ await harness.resetPage()
+ for (const visualizationId of ["line", "d3pie", "easypiechart", "gauge"])
+ correctness.webGL2Fallbacks[visualizationId] =
+ await validateWebGL2Fallback(harness, port, visualizationId)
+ }
+ if (!candidateRenderers.includes("webgpu")) return
+
+ await harness.resetPage()
+ if (!radialOnly)
+ correctness.fallbackChain = await validateFallbackChain(
+ harness,
+ port,
+ visualization
+ )
+ correctness.d3PieFallbackChain = await validateFallbackChain(
+ harness,
+ port,
+ "d3pie"
+ )
+ correctness.easyPieFallbackChain = await validateFallbackChain(
+ harness,
+ port,
+ "easypiechart"
+ )
+ correctness.gaugeFallbackChain = await validateFallbackChain(
+ harness,
+ port,
+ "gauge"
+ )
+}
+
+const didPass = ({ correctness, correctnessOnly, results, comparisons }) => {
+ const accept = result =>
+ result.passed || (correctnessOnly && result.portablePassed)
+ const resultGroups = Object.entries(correctness)
+ .filter(([name]) => !name.toLowerCase().includes("fallback"))
+ .map(([, resultsByRenderer]) => resultsByRenderer)
+
+ return (
+ resultGroups.every(group => Object.values(group).every(accept)) &&
+ [
+ correctness.fallbackChain,
+ correctness.d3PieFallbackChain,
+ correctness.easyPieFallbackChain,
+ correctness.gaugeFallbackChain,
+ ].every(result => !result || result.passed) &&
+ Object.values(correctness.webGL2Fallbacks).every(result => result.passed) &&
+ (!correctnessOnly || results.every(validateCorrectnessMeasurement)) &&
+ comparisons.every(
+ result =>
+ result.mountPassed &&
+ result.updatePassed &&
+ result.exportPassed &&
+ result.multiChartPassed
+ )
+ )
+}
+
+module.exports = async ({ harness, port, config }) => {
+ const correctness = makeCorrectness()
+ const results = await measureResults({ harness, port, ...config })
+ const references = await captureReferences({
+ harness,
+ port,
+ radialOnly: config.radialOnly,
+ })
+ for (const renderer of config.candidateRenderers)
+ await validateRenderer({
+ harness,
+ port,
+ renderer,
+ radialOnly: config.radialOnly,
+ references,
+ correctness,
+ })
+ await validateFallbacks({ harness, port, correctness, ...config })
+
+ const comparisons =
+ config.radialOnly || config.correctnessOnly
+ ? []
+ : compare(results, config)
+ return {
+ results,
+ correctness,
+ comparisons,
+ passed: didPass({
+ correctness,
+ correctnessOnly: config.correctnessOnly,
+ results,
+ comparisons,
+ }),
+ }
+}
diff --git a/benchmarks/time-series-renderers/webpack.config.cjs b/benchmarks/time-series-renderers/webpack.config.cjs
new file mode 100644
index 000000000..4b45f8a5d
--- /dev/null
+++ b/benchmarks/time-series-renderers/webpack.config.cjs
@@ -0,0 +1,34 @@
+const path = require("node:path")
+
+const repo = path.resolve(__dirname, "../..")
+
+module.exports = {
+ mode: "production",
+ context: repo,
+ entry: path.resolve(__dirname, "entry.js"),
+ output: {
+ path: path.resolve(__dirname, "dist"),
+ filename: "benchmark.js",
+ clean: true,
+ },
+ devtool: false,
+ resolve: {
+ alias: { "@": path.join(repo, "src") },
+ extensions: [".js", ".mjs"],
+ },
+ module: {
+ rules: [
+ {
+ test: /\.m?js$/,
+ exclude: /node_modules/,
+ use: {
+ loader: require.resolve("babel-loader", { paths: [repo] }),
+ options: { configFile: path.join(repo, "babel.config.js") },
+ },
+ },
+ { test: /\.svg$/, type: "asset/source" },
+ { test: /\.(png|jpg|jpeg|gif|webp)$/, type: "asset/resource" },
+ ],
+ },
+ performance: { hints: false },
+}
diff --git a/docs/gpu-renderers.md b/docs/gpu-renderers.md
new file mode 100644
index 000000000..80d2fdb65
--- /dev/null
+++ b/docs/gpu-renderers.md
@@ -0,0 +1,109 @@
+# GPU renderer architecture
+
+## Purpose
+
+The GPU engine accelerates deterministic chart pixels without changing the chart API, React component tree, payload semantics, interactions, or public chart identity. WebGPU is preferred internally, WebGL2 is the accelerated compatibility backend, and each visualization's existing implementation is the final fallback.
+
+GPU rendering remains disabled by default while browser and device validation is incomplete.
+
+## Consumer contract
+
+Rendering backend selection is private:
+
+- `chartLibrary` retains its established value such as `dygraph`, `gauge`, `d3pie`, or `easypiechart`.
+- A caller renders the same React component regardless of the active backend.
+- Backend initialization, replacement, loss, and fallback never require caller dispatch or subscription.
+- `ChartContainer` follows internal `chartUIChanged` events and remounts the replacement adapter on the existing element.
+- Payloads, timestamps, chart types, attributes, events, exports, and interactions retain their existing meaning.
+
+`makeDefaultSDK({ acceleratedRendering: true })` is the temporary rollout-level opt-in. It expresses a policy, not a backend choice. Backend forcing through `rendererPolicy` exists for tests and benchmark isolation and is not a consumer API.
+
+## Code map
+
+```text
+src/chartLibraries/gpu/
+ engine/ shared renderer lifecycle
+ text/ browser-shaped text and bounded cache policy
+ visualizations/ backend-neutral data, geometry, axes, interactions
+
+src/chartLibraries/webgpu/
+ engine/ device, pipeline, shared-resource, surface ownership
+ primitives/ WebGPU rectangle and circle layers
+ text/ shared WebGPU text atlas and sprite layer
+ visualizations/ WebGPU resources, kernels, and WGSL
+
+src/chartLibraries/webgl2/
+ engine/ shared context, programs, resources, surfaces, uniforms
+ primitives/ WebGL2 primitive layers
+ text/ runtime-shared WebGL2 text atlas and sprite layer
+ visualizations/ WebGL2 resources, kernels, and GLSL
+
+src/sdk/makeChart/renderers/
+ metadata.js canonical visualization/public/legacy metadata
+ makeController.js private selection, active state, fallback, replacement
+```
+
+The neutral `gpu` layer must not import either backend. Backends must not import one another. Existing renderers must not depend on GPU code.
+
+## Ownership
+
+- One chart owns one visible canvas while an accelerated backend is active.
+- One SDK owns the shared WebGPU runtime and one shared WebGL2 context.
+- Runtime resources such as pipelines, programs, and text atlases are destroyed by the runtime after its idle lease expires.
+- Per-chart surfaces, textures, buffers, and layers are destroyed by the chart adapter.
+- Asynchronous initialization is generation-checked so an unmounted chart cannot attach late resources.
+- A stale React container unmounts an adapter only when that adapter still owns the same DOM element.
+
+WebGPU presents directly to the visible canvas. WebGL2 renders through the SDK-owned shared context and copies the completed frame to the chart's visible Canvas2D surface.
+
+## Renderer contract
+
+A backend registry maps a semantic visualization ID to a visualization factory. The shared renderer lifecycle expects the visualization to provide:
+
+- `mount({ render, canvas })`
+- `unmount()`
+- `createResources(runtime, canvas)`
+- `attachResources(resources)`
+- `render({ width, height, dpr })`
+
+Optional geometry, queue, resource, and draw-stat methods are forwarded by the renderer adapter. Backend resources expose `destroy()` and backend-specific drawing methods consumed only by their surface.
+
+## Fallback
+
+The private chain is:
+
+```text
+WebGPU -> WebGL2 -> visualization-specific legacy implementation
+```
+
+Fallback is allowed only for unsupported capability/configuration, initialization or shader/pipeline failure, uncaptured GPU errors, device loss, context loss, or rendering failure. Frame duration never changes the backend.
+
+A fallback replaces only the package-owned chart UI. Public `chartLibrary`, visualization identity, and the caller's React component remain unchanged.
+
+Optional diagnostics are available through `chart.getRendererState()` and `sdk.getRendererDiagnostics()`. They are for debugging and validation; rendering must never depend on a consumer reading them.
+
+## Adding a visualization
+
+1. Add or verify its canonical metadata in `sdk/makeChart/renderers/metadata.js`.
+2. Implement exact backend-neutral data, frame, interaction, range, and visual semantics under `chartLibraries/gpu/visualizations/`.
+3. Add backend resource factories, kernels, and shaders independently to WebGPU and WebGL2.
+4. Register the visualization in both backend registries only after each backend is complete.
+5. Add pure model tests without mocks.
+6. Add real-browser rendering, export, lifecycle, and fallback validation.
+7. Add the visualization to the deterministic Storybook renderer gallery.
+8. Validate the installed package against unmodified consumer source.
+
+Do not add speculative primitives or empty adapters. Do not sample, aggregate, or approximate source data.
+
+## Validation
+
+Every change to the GPU engine must pass:
+
+- Jest and repository-configured ESLint
+- CommonJS and ES6 builds
+- Storybook, including the renderer gallery
+- headless real-browser WebGL2 correctness for every visualization
+- physical WebGPU and WebGL2 parity, export, update, multi-chart, teardown, and forced-loss checks
+- unmodified Cloud Frontend build and installed-dashboard testing
+
+`benchmarks/time-series-renderers/` owns browser correctness and physical performance evidence. Software adapters provide correctness evidence only; physical adapters are mandatory for performance claims.
diff --git a/package.json b/package.json
index 3fcc38873..1c69d5fd8 100644
--- a/package.json
+++ b/package.json
@@ -24,6 +24,7 @@
"to-cloud": "yarn build:cjs && yarn build:es6 && yarn cp-cloud",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
+ "benchmark:time-series": "node benchmarks/time-series-renderers/build.cjs && node benchmarks/time-series-renderers/run.cjs",
"format": "prettier --write \"**/*.{js,mjs}\""
},
"keywords": [
@@ -69,6 +70,7 @@
"jest": "^30.2.0",
"jest-canvas-mock": "^2.5.2",
"jest-environment-jsdom": "^30.2.0",
+ "playwright-core": "1.62.1",
"prettier": "^3.8.1",
"raw-loader": "^4.0.2",
"react": "^19.2.4",
diff --git a/src/chartLibraries/d3pie/data.js b/src/chartLibraries/d3pie/data.js
new file mode 100644
index 000000000..3535e911c
--- /dev/null
+++ b/src/chartLibraries/d3pie/data.js
@@ -0,0 +1,74 @@
+import { shortForLength } from "@/helpers/shorten"
+
+export const getD3PieRowIndex = chart => {
+ const { data } = chart.getPayload()
+ const hoverX = chart.getAttribute("hoverX")
+ const index = hoverX ? chart.getClosestRow(hoverX[0]) : -1
+ return index === -1 ? data.length - 1 : index
+}
+
+export const groupD3PieContent = (content, color) => {
+ const sorted = [...content].sort((a, b) =>
+ a.label.toLowerCase() > b.label.toLowerCase() ? 1 : -1
+ )
+ const priorities = sorted
+ .map((row, index) => ({ index, value: row.value }))
+ .sort((a, b) => (a.value < b.value ? 1 : -1))
+ priorities.forEach(({ index }, priority) => {
+ sorted[index].smallSegmentPriority = priority
+ })
+
+ const visible = []
+ const grouped = []
+ let groupedValue = 0
+ sorted.forEach(row => {
+ if (row.smallSegmentPriority >= 5) {
+ grouped.push(row)
+ groupedValue += row.value
+ } else {
+ row.isGrouped = false
+ visible.push(row)
+ }
+ })
+ if (grouped.length) {
+ visible.push({
+ enabled: true,
+ valueType: "count",
+ label: `[smaller ${grouped.length}]`,
+ caption: "rest of dimensions",
+ color,
+ value: groupedValue,
+ isGrouped: true,
+ groupedData: grouped,
+ })
+ }
+ return visible
+}
+
+export const makeD3PieContent = (chart, chartUI) => {
+ const index = getD3PieRowIndex(chart)
+ const values = chart
+ .getVisibleDimensionIds()
+ .map(id => {
+ const signedValue = chart.getDimensionValue(id, index, { abs: false })
+ return {
+ label: shortForLength(id, 30),
+ value: Math.abs(signedValue),
+ signedValue,
+ color: chart.selectDimensionColor(id),
+ caption: id,
+ id,
+ }
+ })
+ .filter(({ value }) => Boolean(value))
+
+ return values.length
+ ? values
+ : [
+ {
+ label: "No data",
+ value: 1,
+ color: chartUI.chart.getThemeAttribute("themeD3pieSmallColor"),
+ },
+ ]
+}
diff --git a/src/chartLibraries/d3pie/data.test.js b/src/chartLibraries/d3pie/data.test.js
new file mode 100644
index 000000000..4baeaf659
--- /dev/null
+++ b/src/chartLibraries/d3pie/data.test.js
@@ -0,0 +1,59 @@
+import { makeTestChart } from "@jest/testUtilities"
+import { groupD3PieContent, makeD3PieContent } from "./data"
+
+const makeChart = values => {
+ const ids = values.map((_, index) => `dimension-${index}`)
+ const { chart } = makeTestChart({ attributes: { hoverX: null } })
+ chart.getPayload = () => ({ data: [[1000, ...values]] })
+ chart.getVisibleDimensionIds = () => ids
+ chart.getDimensionValue = id => values[ids.indexOf(id)]
+ chart.selectDimensionColor = id => `#00000${ids.indexOf(id) + 1}`
+ return chart
+}
+
+const chartUI = chart => ({ chart })
+
+describe("D3 Pie data", () => {
+ it("preserves absolute wedge size and signed label values", () => {
+ const chart = makeChart([-20, 0, 30])
+ expect(makeD3PieContent(chart, chartUI(chart))).toEqual([
+ expect.objectContaining({ id: "dimension-0", value: 20, signedValue: -20 }),
+ expect.objectContaining({ id: "dimension-2", value: 30, signedValue: 30 }),
+ ])
+ })
+
+ it("preserves label ordering and exact top-five grouping", () => {
+ const content = [7, 1, 6, 2, 5, 3, 4].map((value, index) => ({
+ label: `dimension-${6 - index}`,
+ value,
+ id: `dimension-${6 - index}`,
+ }))
+ const grouped = groupD3PieContent(content, "#abcdef")
+
+ expect(grouped).toHaveLength(6)
+ expect(grouped.slice(0, 5).map(({ id }) => id)).toEqual([
+ "dimension-0",
+ "dimension-1",
+ "dimension-2",
+ "dimension-4",
+ "dimension-6",
+ ])
+ expect(grouped[5]).toEqual(
+ expect.objectContaining({
+ label: "[smaller 2]",
+ caption: "rest of dimensions",
+ color: "#abcdef",
+ value: 3,
+ isGrouped: true,
+ })
+ )
+ })
+
+ it("preserves the legacy no-data segment", () => {
+ const chart = makeChart([0])
+ chart.getThemeAttribute = () => "#536775"
+ expect(makeD3PieContent(chart, chartUI(chart))).toEqual([
+ { label: "No data", value: 1, color: "#536775" },
+ ])
+ })
+})
diff --git a/src/chartLibraries/d3pie/index.js b/src/chartLibraries/d3pie/index.js
index c5190e480..f40b59944 100644
--- a/src/chartLibraries/d3pie/index.js
+++ b/src/chartLibraries/d3pie/index.js
@@ -2,8 +2,8 @@ import makeChartUI from "@/sdk/makeChartUI"
import { unregister } from "@/helpers/makeListeners"
import makeResizeObserver from "@/helpers/makeResizeObserver"
import makeExecuteLatest from "@/helpers/makeExecuteLatest"
-import { shortForLength } from "@/helpers/shorten"
import d3pie from "./library"
+import { makeD3PieContent } from "./data"
import getInitialOptions from "./getInitialOptions"
export default (sdk, chart) => {
@@ -63,31 +63,11 @@ export default (sdk, chart) => {
const getMinMax = () => chart.getAttribute("getValueRange")(chart)
const render = () => {
- const { hoverX, loaded } = chart.getAttributes()
+ const { loaded } = chart.getAttributes()
if (!pie || !loaded) return false
- const { data } = chart.getPayload()
-
- let index = hoverX ? chart.getClosestRow(hoverX[0]) : -1
- index = index === -1 ? data.length - 1 : index
-
- const dimensionIds = chart.getVisibleDimensionIds()
-
- const values = dimensionIds
- .map(id => {
- const signedValue = chart.getDimensionValue(id, index, { abs: false })
-
- return {
- label: shortForLength(id, 30),
- value: Math.abs(signedValue),
- signedValue,
- color: chart.selectDimensionColor(id),
- caption: id,
- id,
- }
- })
- .filter(v => !!v.value)
+ const values = makeD3PieContent(chart, chartUI)
let [min, max] = getMinMax()
@@ -98,15 +78,7 @@ export default (sdk, chart) => {
prevMin = min
prevMax = max
- pie.options.data.content = values.length
- ? values
- : [
- {
- label: "No data",
- value: 1,
- color: chartUI.chart.getThemeAttribute("themeD3pieSmallColor"),
- },
- ]
+ pie.options.data.content = values
pie.options.labels = getInitialOptions(chartUI).labels
window.requestAnimationFrame(() => {
diff --git a/src/chartLibraries/dygraph/index.js b/src/chartLibraries/dygraph/index.js
index 47b32161a..1eba966e4 100644
--- a/src/chartLibraries/dygraph/index.js
+++ b/src/chartLibraries/dygraph/index.js
@@ -533,6 +533,14 @@ export default (sdk, chart) => {
const getXAxisRange = () => dygraph?.xAxisRange()
+ const getPlotArea = () => {
+ if (!dygraph) return { left: 0, top: 0, width: 0, height: 0 }
+ const { x, y, w, h } = dygraph.getArea()
+ return { left: x, top: y, width: w, height: h }
+ }
+
+ const getXCoord = timestampMs => (dygraph ? dygraph.toDomXCoord(timestampMs) : 0)
+
const instance = {
...chartUI,
getChartWidth,
@@ -542,6 +550,8 @@ export default (sdk, chart) => {
unmount,
getDygraph,
getXAxisRange,
+ getPlotArea,
+ getXCoord,
render,
}
diff --git a/src/chartLibraries/dygraph/overlays/alarm.js b/src/chartLibraries/dygraph/overlays/alarm.js
index fe712906f..394e7204f 100644
--- a/src/chartLibraries/dygraph/overlays/alarm.js
+++ b/src/chartLibraries/dygraph/overlays/alarm.js
@@ -15,7 +15,7 @@ export default (chartUI, id) => {
const { h } = dygraph.getArea()
const { hidden_ctx_: ctx } = dygraph
- const area = getArea(dygraph, [when, when])
+ const area = getArea(chartUI, [when, when])
if (!area) return trigger(chartUI, id)
diff --git a/src/chartLibraries/dygraph/overlays/alarmRange.js b/src/chartLibraries/dygraph/overlays/alarmRange.js
index d29c764f5..380c50ce1 100644
--- a/src/chartLibraries/dygraph/overlays/alarmRange.js
+++ b/src/chartLibraries/dygraph/overlays/alarmRange.js
@@ -29,7 +29,7 @@ export default (chartUI, id) => {
const { h } = dygraph.getArea()
const { hidden_ctx_: ctx } = dygraph
- const area = getArea(dygraph, [whenTriggered, whenLast])
+ const area = getArea(chartUI, [whenTriggered, whenLast])
if (!area) return trigger(chartUI, id)
diff --git a/src/chartLibraries/dygraph/overlays/annotation.js b/src/chartLibraries/dygraph/overlays/annotation.js
index 503cb2203..ddc1b7257 100644
--- a/src/chartLibraries/dygraph/overlays/annotation.js
+++ b/src/chartLibraries/dygraph/overlays/annotation.js
@@ -74,7 +74,7 @@ export default (chartUI, id) => {
const pos = getTimestampPosition(dygraph, timestamp)
if (!pos) return trigger(chartUI, id)
- const area = getArea(dygraph, [timestamp, timestamp])
+ const area = getArea(chartUI, [timestamp, timestamp])
if (!area) return trigger(chartUI, id)
diff --git a/src/chartLibraries/dygraph/overlays/helpers.js b/src/chartLibraries/dygraph/overlays/helpers.js
index a1d49eb40..a07091ce9 100644
--- a/src/chartLibraries/dygraph/overlays/helpers.js
+++ b/src/chartLibraries/dygraph/overlays/helpers.js
@@ -1,23 +1,6 @@
-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 = (chartUI, range) => getNeutralArea(chartUI, range)
export const trigger = (chartUI, id, area) =>
requestAnimationFrame(() => chartUI.trigger(`overlayedAreaChanged:${id}`, area))
diff --git a/src/chartLibraries/dygraph/overlays/highlight.js b/src/chartLibraries/dygraph/overlays/highlight.js
index e0956e704..1126e71a0 100644
--- a/src/chartLibraries/dygraph/overlays/highlight.js
+++ b/src/chartLibraries/dygraph/overlays/highlight.js
@@ -11,7 +11,7 @@ export default (chartUI, id) => {
const { h } = dygraph.getArea()
const { hidden_ctx_: ctx } = dygraph
- const area = getArea(dygraph, range)
+ const area = getArea(chartUI, range)
if (!area) return trigger(chartUI, id)
diff --git a/src/chartLibraries/dygraph/overlays/proceeded.js b/src/chartLibraries/dygraph/overlays/proceeded.js
index 514c16545..865a59ce0 100644
--- a/src/chartLibraries/dygraph/overlays/proceeded.js
+++ b/src/chartLibraries/dygraph/overlays/proceeded.js
@@ -11,9 +11,9 @@ export default (chartUI, id) => {
if (!outOfLimits && (!firstEntry || firstEntry > beforeSecs) && !error) return
- const range = outOfLimits || error ? [before, before] : [firstEntry, firstEntry]
+ const range = outOfLimits || error ? [beforeSecs, beforeSecs] : [firstEntry, firstEntry]
- const area = getArea(dygraph, range)
+ const area = getArea(chartUI, range)
trigger(chartUI, id, area)
}
diff --git a/src/chartLibraries/gauge/index.js b/src/chartLibraries/gauge/index.js
index 7a0f7b18c..abd3ac109 100644
--- a/src/chartLibraries/gauge/index.js
+++ b/src/chartLibraries/gauge/index.js
@@ -32,12 +32,20 @@ export default (sdk, chart) => {
let prevMin
let prevMax
let resizeObserver
+ let gaugeCanvas = null
+ let ownsCanvas = false
const mount = element => {
if (gauge) return
chartUI.mount(element)
+ gaugeCanvas = element.firstElementChild?.tagName === "CANVAS"
+ ? element.firstElementChild
+ : document.createElement("canvas")
+ ownsCanvas = !gaugeCanvas.parentNode
+ if (ownsCanvas) element.appendChild(gaugeCanvas)
+
const { color, strokeColor } = makeThemingOptions()
const { staticZones, gaugeLineWidth, gaugeGradient, gaugeThresholds } = chart.getAttributes()
const hasThresholds = Array.isArray(gaugeThresholds) && gaugeThresholds.length > 0
@@ -67,7 +75,7 @@ export default (sdk, chart) => {
}),
})
- gauge = new Gauge(element.firstChild).setOptions(makeGaugeOptions())
+ gauge = new Gauge(gaugeCanvas).setOptions(makeGaugeOptions())
gauge.maxValue = 100
gauge.animationSpeed = Number.MAX_VALUE
@@ -78,11 +86,11 @@ export default (sdk, chart) => {
() => {
const minWidth = element.clientWidth
const height = (element.clientHeight > minWidth ? minWidth : element.clientHeight) * 0.9
- element.firstChild.G__height = height
- element.firstChild.style.height = `${height}px`
+ gaugeCanvas.G__height = height
+ gaugeCanvas.style.height = `${height}px`
const width = minWidth
- element.firstChild.G__width = width
- element.firstChild.style.width = `${width}px`
+ gaugeCanvas.G__width = width
+ gaugeCanvas.style.width = `${width}px`
gauge.setOptions({})
gauge.update(true)
@@ -97,6 +105,7 @@ export default (sdk, chart) => {
chart.onAttributeChange("hoverX", render),
!loaded && chart.onceAttributeChange("loaded", render),
chart.onAttributeChange("gaugeThresholds", applyThresholds),
+ chart.onAttributeChange("staticZones", () => chart.reconcileRenderer()),
chart.onAttributeChange("theme", () => {
const { color, strokeColor } = makeThemingOptions()
const updatedDimensionColor = chart.selectDimensionColor()
@@ -119,11 +128,11 @@ export default (sdk, chart) => {
const minWidth = element.clientWidth
const height = (element.clientHeight > minWidth ? minWidth : element.clientHeight) * 0.9
- element.firstChild.G__height = height
- element.firstChild.style.height = `${height}px`
+ gaugeCanvas.G__height = height
+ gaugeCanvas.style.height = `${height}px`
const width = minWidth
- element.firstChild.G__width = width
- element.firstChild.style.width = `${width}px`
+ gaugeCanvas.G__width = width
+ gaugeCanvas.style.width = `${width}px`
gauge.setOptions({})
render()
@@ -208,6 +217,9 @@ export default (sdk, chart) => {
if (resizeObserver) resizeObserver()
gauge = null
+ if (ownsCanvas) gaugeCanvas?.remove()
+ gaugeCanvas = null
+ ownsCanvas = false
prevMin = null
prevMax = null
diff --git a/src/chartLibraries/gpu/color.js b/src/chartLibraries/gpu/color.js
new file mode 100644
index 000000000..3fcbaff33
--- /dev/null
+++ b/src/chartLibraries/gpu/color.js
@@ -0,0 +1,45 @@
+const hexToByte = value => Number.parseInt(value, 16)
+let colorContext = null
+
+const resolveBrowserColor = value => {
+ if (typeof document === "undefined") return null
+ if (!colorContext) colorContext = document.createElement("canvas").getContext("2d")
+ if (!colorContext) return null
+ const sentinel = "rgba(1, 2, 3, 0.123)"
+ colorContext.fillStyle = sentinel
+ colorContext.fillStyle = value
+ return colorContext.fillStyle === sentinel ? null : colorContext.fillStyle
+}
+
+export const parseColor = (value, resolve = true) => {
+ if (typeof value !== "string") return [0, 0, 0, 1]
+ if (value.trim().toLowerCase() === "transparent") return [0, 0, 0, 0]
+
+ const hex = value.trim().match(/^#([\da-f]{3,8})$/i)?.[1]
+ if (hex) {
+ const expanded = hex.length <= 4 ? [...hex].map(part => `${part}${part}`).join("") : hex
+ if (expanded.length === 6 || expanded.length === 8) {
+ return [
+ hexToByte(expanded.slice(0, 2)) / 255,
+ hexToByte(expanded.slice(2, 4)) / 255,
+ hexToByte(expanded.slice(4, 6)) / 255,
+ expanded.length === 8 ? hexToByte(expanded.slice(6, 8)) / 255 : 1,
+ ]
+ }
+ }
+
+ const rgb = value
+ .trim()
+ .match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/i)
+ if (rgb) {
+ return [
+ Number(rgb[1]) / 255,
+ Number(rgb[2]) / 255,
+ Number(rgb[3]) / 255,
+ rgb[4] == null ? 1 : Number(rgb[4]),
+ ]
+ }
+
+ const resolved = resolve ? resolveBrowserColor(value) : null
+ return resolved ? parseColor(resolved, false) : [0, 0, 0, 1]
+}
diff --git a/src/chartLibraries/gpu/color.test.js b/src/chartLibraries/gpu/color.test.js
new file mode 100644
index 000000000..c7a1c4322
--- /dev/null
+++ b/src/chartLibraries/gpu/color.test.js
@@ -0,0 +1,11 @@
+import { parseColor } from "./color"
+
+describe("WebGPU color conversion", () => {
+ it("preserves transparent CSS colors", () => {
+ expect(parseColor("transparent")).toEqual([0, 0, 0, 0])
+ })
+
+ it("resolves browser-shaped CSS colors outside the packed fast paths", () => {
+ expect(parseColor("rebeccapurple")).toEqual([102 / 255, 51 / 255, 153 / 255, 1])
+ })
+})
diff --git a/src/chartLibraries/gpu/engine/createResourceSet.js b/src/chartLibraries/gpu/engine/createResourceSet.js
new file mode 100644
index 000000000..c02bc7d6d
--- /dev/null
+++ b/src/chartLibraries/gpu/engine/createResourceSet.js
@@ -0,0 +1,27 @@
+export default async (surface, factories) => {
+ const entries = Object.entries(factories)
+ const settled = await Promise.allSettled(
+ entries.map(([, create]) => Promise.resolve().then(create))
+ )
+ const failed = settled.find(result => result.status === "rejected")
+
+ if (failed) {
+ settled.forEach(
+ result => result.status === "fulfilled" && result.value.destroy?.()
+ )
+ surface.destroy()
+ throw failed.reason
+ }
+
+ const resources = Object.fromEntries(
+ entries.map(([name], index) => [name, settled[index].value])
+ )
+ return {
+ surface,
+ ...resources,
+ destroy: () => {
+ Object.values(resources).forEach(resource => resource.destroy())
+ surface.destroy()
+ },
+ }
+}
diff --git a/src/chartLibraries/gpu/engine/createResourceSet.test.js b/src/chartLibraries/gpu/engine/createResourceSet.test.js
new file mode 100644
index 000000000..141454933
--- /dev/null
+++ b/src/chartLibraries/gpu/engine/createResourceSet.test.js
@@ -0,0 +1,33 @@
+import createResourceSet from "./createResourceSet"
+
+describe("GPU resource-set ownership", () => {
+ it("destroys every owned layer before its surface", async () => {
+ const order = []
+ const resources = await createResourceSet(
+ { destroy: () => order.push("surface") },
+ {
+ first: () => ({ destroy: () => order.push("first") }),
+ second: () => ({ destroy: () => order.push("second") }),
+ }
+ )
+
+ resources.destroy()
+
+ expect(order).toEqual(["first", "second", "surface"])
+ })
+
+ it("cleans fulfilled layers when another factory fails", async () => {
+ const order = []
+ await expect(
+ createResourceSet(
+ { destroy: () => order.push("surface") },
+ {
+ first: () => ({ destroy: () => order.push("first") }),
+ failed: () => Promise.reject(new Error("failed")),
+ }
+ )
+ ).rejects.toThrow("failed")
+
+ expect(order).toEqual(["first", "surface"])
+ })
+})
diff --git a/src/chartLibraries/gpu/engine/makeRenderer.js b/src/chartLibraries/gpu/engine/makeRenderer.js
new file mode 100644
index 000000000..a676eb771
--- /dev/null
+++ b/src/chartLibraries/gpu/engine/makeRenderer.js
@@ -0,0 +1,177 @@
+import makeChartUI from "@/sdk/makeChartUI"
+import makeGPUResizeObserver from "@/chartLibraries/gpu/resize"
+
+const makeCanvas = (element, rendererId) => {
+ const canvas = document.createElement("canvas")
+ canvas.dataset.renderer = rendererId
+ canvas.style.display = "block"
+ canvas.style.width = "100%"
+ canvas.style.height = "100%"
+ canvas.style.touchAction = "none"
+ element.appendChild(canvas)
+ return canvas
+}
+
+export default ({
+ sdk,
+ chart,
+ makeVisualization,
+ visualizationId,
+ rendererId,
+ fallbackRenderer,
+ getRuntime,
+ isRuntimeSupported = () => true,
+ makeLossError,
+}) => {
+ const chartUI = makeChartUI(sdk, chart)
+ const visualization = makeVisualization({ sdk, chart, chartUI })
+ let element = null
+ let canvas = null
+ let runtime = null
+ let resizeObserver = null
+ let offLost = null
+ let leaseHeld = false
+ let generation = 0
+ let failureHandled = false
+ let ready = Promise.resolve(false)
+
+ const releaseRuntime = () => {
+ if (!leaseHeld) return
+ leaseHeld = false
+ runtime?.release()
+ }
+
+ const fallbackChart = (target, error) => {
+ const replaced = target.fallbackRenderer?.(
+ rendererId,
+ fallbackRenderer,
+ error
+ )
+ sdk.trigger("rendererFallback", target, rendererId, error)
+ target.trigger("rendererFallback", rendererId, error)
+ return replaced
+ }
+
+ const propagateRuntimeFallback = error => {
+ if (isRuntimeSupported(sdk)) return
+
+ sdk.getNodes()
+ .filter(target =>
+ target !== chart &&
+ target.type === "chart" &&
+ target.getRendererState?.().active === rendererId
+ )
+ .forEach(target => fallbackChart(target, error))
+ }
+
+ const fallback = error => {
+ if (failureHandled) return false
+ failureHandled = true
+ const replaced = fallbackChart(chart, error)
+ if (replaced) propagateRuntimeFallback(error)
+ return replaced
+ }
+
+ const renderFrame = () => {
+ if (!element || !canvas) return false
+
+ const rendered = visualization.render({
+ width: chartUI.getChartWidth(),
+ height: chartUI.getChartHeight(),
+ dpr: window.devicePixelRatio || 1,
+ })
+ if (!rendered) return false
+
+ chartUI.render()
+ chartUI.trigger("rendered")
+ return true
+ }
+
+ const render = () => {
+ try {
+ return renderFrame()
+ } catch (error) {
+ fallback(error)
+ return false
+ }
+ }
+
+ const initialize = currentGeneration => {
+ runtime = getRuntime(sdk)
+ ready = runtime
+ .acquire()
+ .then(async () => {
+ leaseHeld = true
+ if (currentGeneration !== generation || !canvas) {
+ releaseRuntime()
+ return false
+ }
+
+ offLost = runtime.onLost(info => fallback(makeLossError(info)))
+ const resource = await visualization.createResources(runtime, canvas)
+ if (currentGeneration !== generation || !canvas) {
+ resource.destroy?.()
+ releaseRuntime()
+ return false
+ }
+
+ try {
+ visualization.attachResources(resource)
+ } catch (error) {
+ resource.destroy?.()
+ throw error
+ }
+ render()
+ return true
+ })
+ .catch(error => {
+ if (currentGeneration === generation && element) fallback(error)
+ return false
+ })
+ return ready
+ }
+
+ const mount = node => {
+ if (element) return
+
+ generation += 1
+ const currentGeneration = generation
+ failureHandled = false
+ element = node
+ chartUI.mount(node)
+ canvas = makeCanvas(element, rendererId)
+ visualization.mount({ render, canvas })
+ resizeObserver = makeGPUResizeObserver(element, render)
+ initialize(currentGeneration)
+ }
+
+ const unmount = () => {
+ generation += 1
+ resizeObserver?.()
+ resizeObserver = null
+ offLost?.()
+ offLost = null
+ visualization.unmount()
+ canvas?.remove()
+ canvas = null
+ releaseRuntime()
+ element = null
+ chartUI.unmount()
+ }
+
+ return {
+ ...chartUI,
+ mount,
+ unmount,
+ render,
+ getPlotArea: (...args) => visualization.getPlotArea?.(...args),
+ getXAxisRange: (...args) => visualization.getXAxisRange?.(...args),
+ getXCoord: (...args) => visualization.getXCoord?.(...args),
+ getCanvas: () => canvas,
+ getQueueDone: () => visualization.getQueueDone?.() || ready,
+ getBufferBytes: () => visualization.getBufferBytes?.() || 0,
+ getDrawStats: () => visualization.getDrawStats?.() || null,
+ getVisualizationId: () => visualizationId,
+ whenReady: () => ready,
+ }
+}
diff --git a/src/chartLibraries/gpu/engine/makeResourceCache.js b/src/chartLibraries/gpu/engine/makeResourceCache.js
new file mode 100644
index 000000000..b1ff2bfff
--- /dev/null
+++ b/src/chartLibraries/gpu/engine/makeResourceCache.js
@@ -0,0 +1,33 @@
+export default () => {
+ const records = new Map()
+
+ const get = (key, create) => {
+ if (!records.has(key)) {
+ const record = { value: null, promise: null }
+ record.promise = Promise.resolve()
+ .then(create)
+ .then(value => {
+ record.value = value
+ return value
+ })
+ records.set(key, record)
+ }
+ return records.get(key).promise
+ }
+
+ const getBytes = () =>
+ [...records.values()].reduce(
+ (total, record) => total + (record.value?.getGPUBytes?.() || 0),
+ 0
+ )
+
+ const destroy = () => {
+ records.forEach(record => {
+ if (record.value) record.value.destroy?.()
+ else record.promise.then(value => value.destroy?.(), () => {})
+ })
+ records.clear()
+ }
+
+ return { get, getBytes, destroy }
+}
diff --git a/src/chartLibraries/gpu/engine/makeResourceCache.test.js b/src/chartLibraries/gpu/engine/makeResourceCache.test.js
new file mode 100644
index 000000000..d2a97716a
--- /dev/null
+++ b/src/chartLibraries/gpu/engine/makeResourceCache.test.js
@@ -0,0 +1,34 @@
+import makeResourceCache from "./makeResourceCache"
+
+describe("GPU runtime resource cache", () => {
+ it("creates a shared resource once and reports its bytes", async () => {
+ const cache = makeResourceCache()
+ let creations = 0
+ const create = () => {
+ creations += 1
+ return { getGPUBytes: () => 4096, destroy: () => {} }
+ }
+
+ const first = await cache.get("atlas", create)
+ const second = await cache.get("atlas", create)
+
+ expect(second).toBe(first)
+ expect(creations).toBe(1)
+ expect(cache.getBytes()).toBe(4096)
+ })
+
+ it("destroys resolved resources", async () => {
+ const cache = makeResourceCache()
+ let destroyed = false
+ await cache.get("atlas", () => ({
+ destroy: () => {
+ destroyed = true
+ },
+ }))
+
+ cache.destroy()
+
+ expect(destroyed).toBe(true)
+ expect(cache.getBytes()).toBe(0)
+ })
+})
diff --git a/src/chartLibraries/gpu/errors.js b/src/chartLibraries/gpu/errors.js
new file mode 100644
index 000000000..415c9b4a9
--- /dev/null
+++ b/src/chartLibraries/gpu/errors.js
@@ -0,0 +1,9 @@
+export class UnsupportedVisualizationConfigurationError extends Error {
+ constructor(message) {
+ super(message)
+ this.name = "UnsupportedVisualizationConfigurationError"
+ }
+}
+
+export const isUnsupportedVisualizationConfiguration = error =>
+ error instanceof UnsupportedVisualizationConfigurationError
diff --git a/src/chartLibraries/gpu/resize.js b/src/chartLibraries/gpu/resize.js
new file mode 100644
index 000000000..1d3ea63a2
--- /dev/null
+++ b/src/chartLibraries/gpu/resize.js
@@ -0,0 +1,5 @@
+export default (element, action) => {
+ const observer = new ResizeObserver(action)
+ observer.observe(element)
+ return () => observer.disconnect()
+}
diff --git a/src/chartLibraries/gpu/text/cache.js b/src/chartLibraries/gpu/text/cache.js
new file mode 100644
index 000000000..65763b835
--- /dev/null
+++ b/src/chartLibraries/gpu/text/cache.js
@@ -0,0 +1,18 @@
+export default limit => {
+ const values = new Map()
+
+ const get = key => values.get(key)
+ const set = (key, value) => values.set(key, value)
+ const clear = () => values.clear()
+ const isFullFor = key => !values.has(key) && values.size >= limit
+
+ return {
+ get,
+ set,
+ clear,
+ isFullFor,
+ get size() {
+ return values.size
+ },
+ }
+}
diff --git a/src/chartLibraries/gpu/text/cache.test.js b/src/chartLibraries/gpu/text/cache.test.js
new file mode 100644
index 000000000..36cfadc7d
--- /dev/null
+++ b/src/chartLibraries/gpu/text/cache.test.js
@@ -0,0 +1,17 @@
+import makeBoundedCache from "./cache"
+
+describe("WebGPU text atlas cache", () => {
+ it("signals a generation reset before admitting more unique shaped strings", () => {
+ const cache = makeBoundedCache(2)
+ cache.set("1|10px sans-serif|first", { id: 1 })
+ cache.set("1|10px sans-serif|second", { id: 2 })
+
+ expect(cache.isFullFor("1|10px sans-serif|first")).toBe(false)
+ expect(cache.isFullFor("2|10px sans-serif|first")).toBe(true)
+ expect(cache.size).toBe(2)
+
+ cache.clear()
+ expect(cache.size).toBe(0)
+ expect(cache.isFullFor("2|10px sans-serif|first")).toBe(false)
+ })
+})
diff --git a/src/chartLibraries/gpu/text/index.js b/src/chartLibraries/gpu/text/index.js
new file mode 100644
index 000000000..3f314732c
--- /dev/null
+++ b/src/chartLibraries/gpu/text/index.js
@@ -0,0 +1,62 @@
+const ATLAS_PADDING = 2
+
+export const makeRasterCanvas = () => {
+ if (typeof document !== "undefined") {
+ const canvas = document.createElement("canvas")
+ canvas.width = 1
+ canvas.height = 1
+ return canvas
+ }
+ return new OffscreenCanvas(1, 1)
+}
+
+const getFontSize = font => Number(font.match(/([\d.]+)px/)?.[1]) || 10
+
+export const makeTextCacheKey = ({ text, font, dpr }) => `${dpr}|${font}|${text}`
+
+export const placeText = ({ x, y, width, height, align = "left", verticalAlign = "top" }) => ({
+ x: align === "center" ? x - width / 2 : align === "right" ? x - width : x,
+ y: verticalAlign === "middle" ? y - height / 2 : verticalAlign === "bottom" ? y - height : y,
+ width,
+ height,
+})
+
+export const placeRasterizedText = ({ label, entry, dpr }) => {
+ const placement = placeText({
+ x: label.x * dpr,
+ y: label.y * dpr,
+ width: entry.pixelWidth,
+ height: entry.pixelHeight,
+ align: label.align,
+ verticalAlign: label.verticalAlign,
+ })
+ return { ...placement, x: Math.round(placement.x), y: Math.round(placement.y) }
+}
+
+export const rasterizeText = (canvas, { text, font, dpr }) => {
+ let context = canvas.getContext("2d")
+ if (!context) return null
+ context.font = font
+ const metrics = context.measureText(text)
+ const fontSize = getFontSize(font)
+ const ascent = Math.ceil(metrics.actualBoundingBoxAscent || fontSize)
+ const descent = Math.ceil(metrics.actualBoundingBoxDescent || fontSize * 0.3)
+ const width = Math.max(1, Math.ceil(metrics.width) + ATLAS_PADDING * 2)
+ const height = Math.max(1, ascent + descent + ATLAS_PADDING * 2)
+ const pixelWidth = Math.max(1, Math.ceil(width * dpr))
+ const pixelHeight = Math.max(1, Math.ceil(height * dpr))
+
+ canvas.width = pixelWidth
+ canvas.height = pixelHeight
+ context = canvas.getContext("2d")
+ if (!context) return null
+ context.setTransform(dpr, 0, 0, dpr, 0, 0)
+ context.clearRect(0, 0, width, height)
+ context.font = font
+ context.textAlign = "left"
+ context.textBaseline = "alphabetic"
+ context.fillStyle = "#ffffff"
+ context.fillText(text, ATLAS_PADDING, ATLAS_PADDING + ascent)
+
+ return { width, height, pixelWidth, pixelHeight }
+}
diff --git a/src/chartLibraries/gpu/text/index.test.js b/src/chartLibraries/gpu/text/index.test.js
new file mode 100644
index 000000000..935f1f0a0
--- /dev/null
+++ b/src/chartLibraries/gpu/text/index.test.js
@@ -0,0 +1,48 @@
+import { placeRasterizedText, placeText } from "."
+
+describe("GPU text placement", () => {
+ it("places centered and bottom-aligned shaped strings", () => {
+ expect(
+ placeText({
+ x: 50,
+ y: 40,
+ width: 20,
+ height: 10,
+ align: "center",
+ verticalAlign: "bottom",
+ })
+ ).toEqual({ x: 40, y: 30, width: 20, height: 10 })
+ })
+
+ it("keeps top-left placement unchanged", () => {
+ expect(placeText({ x: 4, y: 8, width: 12, height: 6 })).toEqual({
+ x: 4,
+ y: 8,
+ width: 12,
+ height: 6,
+ })
+ })
+
+ it.each([1, 1.25, 1.5, 2])(
+ "places exact atlas pixels at DPR %s without fractional scaling",
+ dpr => {
+ const pixelWidth = Math.ceil(43 * dpr)
+ const pixelHeight = Math.ceil(15 * dpr)
+ const placement = placeRasterizedText({
+ label: {
+ x: 100,
+ y: 40,
+ align: "right",
+ verticalAlign: "middle",
+ },
+ entry: { pixelWidth, pixelHeight },
+ dpr,
+ })
+
+ expect(placement.width).toBe(pixelWidth)
+ expect(placement.height).toBe(pixelHeight)
+ expect(Number.isInteger(placement.x)).toBe(true)
+ expect(Number.isInteger(placement.y)).toBe(true)
+ }
+ )
+})
diff --git a/src/chartLibraries/gpu/visualizations/cartesian/area/index.js b/src/chartLibraries/gpu/visualizations/cartesian/area/index.js
new file mode 100644
index 000000000..3d4857309
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/cartesian/area/index.js
@@ -0,0 +1,18 @@
+import makeLineVisualization from "../line"
+
+export const makeAreaStyle = chart => {
+ const sparkline = chart.isSparkline()
+ return {
+ fillAlpha: sparkline ? 1 : 0.2,
+ lineWidth: sparkline ? 0 : 0.7,
+ smooth: false,
+ stepped: chart.getAttribute("stepPlot"),
+ }
+}
+
+export default options =>
+ makeLineVisualization({
+ ...options,
+ forceIncludeZero: true,
+ makeSeriesStyle: makeAreaStyle,
+ })
diff --git a/src/chartLibraries/gpu/visualizations/cartesian/area/index.test.js b/src/chartLibraries/gpu/visualizations/cartesian/area/index.test.js
new file mode 100644
index 000000000..5037f6508
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/cartesian/area/index.test.js
@@ -0,0 +1,39 @@
+import { makeTestChart } from "../../../../../../jest/testUtilities"
+import { makeAreaStyle } from "."
+
+describe("GPU area visualization", () => {
+ it("uses Dygraphs area fill and stroke semantics", () => {
+ const { chart } = makeTestChart({
+ attributes: { chartType: "area", sparkline: false, stepPlot: false },
+ })
+
+ expect(makeAreaStyle(chart)).toEqual({
+ fillAlpha: 0.2,
+ lineWidth: 0.7,
+ smooth: false,
+ stepped: false,
+ })
+
+ chart.updateAttribute("stepPlot", true)
+
+ expect(makeAreaStyle(chart)).toEqual({
+ fillAlpha: 0.2,
+ lineWidth: 0.7,
+ smooth: false,
+ stepped: true,
+ })
+ })
+
+ it("renders sparklines as an opaque fill without a stroke", () => {
+ const { chart } = makeTestChart({
+ attributes: { chartType: "area", sparkline: true },
+ })
+
+ expect(makeAreaStyle(chart)).toEqual({
+ fillAlpha: 1,
+ lineWidth: 0,
+ smooth: false,
+ stepped: false,
+ })
+ })
+})
diff --git a/src/chartLibraries/gpu/visualizations/cartesian/axes.js b/src/chartLibraries/gpu/visualizations/cartesian/axes.js
new file mode 100644
index 000000000..05727a441
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/cartesian/axes.js
@@ -0,0 +1,151 @@
+import Dygraph from "dygraphs"
+import { makeAxisTicks } from "@/helpers/ticks"
+
+const X_AXIS_HEIGHT = 16
+const Y_AXIS_GUTTER = 6
+const RIGHT_PLOT_OVERFLOW = 5
+const DEFAULT_Y_AXIS_WIDTH = 68
+const Y_RANGE_PAD = 15
+
+const getTickGranularity = (ticks, index) => {
+ const value = ticks[index].v
+ const previous = ticks[index - 1]?.v
+ const next = ticks[index + 1]?.v
+ 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 isFinite(step) ? step : 0
+}
+
+export const makePlotArea = (chart, width, height) => {
+ const sparkline = chart.isSparkline()
+ const enabledXAxis = !sparkline && chart.getAttribute("enabledXAxis")
+ const enabledYAxis = !sparkline && chart.getAttribute("enabledYAxis")
+ const left = enabledYAxis
+ ? (chart.getAttribute("yAxisLabelWidth") || DEFAULT_Y_AXIS_WIDTH) + Y_AXIS_GUTTER
+ : 0
+ const bottom = enabledXAxis ? X_AXIS_HEIGHT : 0
+
+ return {
+ left,
+ top: 0,
+ width: Math.max(1, width + RIGHT_PLOT_OVERFLOW - left),
+ height: Math.max(1, height - bottom),
+ }
+}
+
+export const padValueRange = (min, max, plotHeight) => {
+ if (!Number.isFinite(min) || !Number.isFinite(max)) return [-1, 1]
+ if (min === max) {
+ const padding = Math.abs(min || 1) * 0.01
+ return [min - padding, max + padding]
+ }
+ const padding = ((max - min) * Y_RANGE_PAD) / Math.max(1, plotHeight)
+ return [min - padding, max + padding]
+}
+
+const xPosition = (value, min, max, plot) =>
+ plot.left + ((value - min) / Math.max(max - min, 1e-20)) * plot.width
+
+const yPosition = (value, min, max, plot) =>
+ plot.top + (1 - (value - min) / Math.max(max - min, 1e-20)) * plot.height
+
+const makeYLabel = (chart, value, granularity) => {
+ const dimensionId = chart.getVisibleDimensionIds()?.[0]
+ const range = granularity
+ ? {
+ min: Math.min(value, value + granularity),
+ max: Math.max(value, value + granularity),
+ }
+ : {}
+ const unitAttributes = chart.getUnitAttributesForValue(value, { dimensionId, ...range })
+ return chart.getConvertedValueWithUnit(value, { dimensionId, unitAttributes })
+}
+
+const makeXTicks = (chart, min, max, pixels) =>
+ Dygraph.dateTicker(
+ min,
+ max,
+ pixels,
+ key => {
+ if (key === "pixelsPerLabel") return 60
+ if (key === "axisLabelFormatter") return chart.formatXAxis
+ if (key === "labelsUTC") return false
+ return undefined
+ },
+ null
+ )
+
+export const makeCartesianAxes = ({
+ chart,
+ width,
+ height,
+ min,
+ max,
+ afterMs,
+ beforeMs,
+ yTicks,
+}) => {
+ const plot = makePlotArea(chart, width, height)
+ const [domainMin, domainMax] = padValueRange(min, max, plot.height)
+ const sparkline = chart.isSparkline()
+ const enabledXAxis = !sparkline && chart.getAttribute("enabledXAxis")
+ const enabledYAxis = !sparkline && chart.getAttribute("enabledYAxis")
+ const gridColor = chart.getThemeAttribute("themeGridColor")
+ const labelColor = chart.getThemeAttribute("themeLabelColor")
+ const font = `${chart.getAttribute("axisLabelFontSize") || 10}px sans-serif`
+ const rects = []
+ const labels = []
+
+ if (enabledYAxis) {
+ const units = chart.getVisibleDimensionIds().map(id => chart.getDimensionUnit(id))
+ const resolvedYTicks = (
+ yTicks ||
+ makeAxisTicks({
+ min,
+ max,
+ pixels: plot.height,
+ pixelsPerTick: 15,
+ units,
+ secondsAsTime: chart.getAttribute("secondsAsTime"),
+ })
+ ).filter(tick => tick.v >= min && tick.v <= max)
+
+ resolvedYTicks.forEach((tick, index) => {
+ const y = yPosition(tick.v, domainMin, domainMax, plot)
+ rects.push({ x: plot.left, y, width: plot.width, height: 1, color: gridColor })
+ const text =
+ "label" in tick
+ ? tick.label
+ : makeYLabel(chart, tick.v, getTickGranularity(resolvedYTicks, index))
+ if (text === null) return
+ labels.push({
+ text,
+ x: plot.left + 2,
+ y,
+ align: "right",
+ verticalAlign: "middle",
+ color: labelColor,
+ font,
+ })
+ })
+ }
+
+ if (enabledXAxis) {
+ makeXTicks(chart, afterMs, beforeMs, plot.width).forEach(tick => {
+ const x = xPosition(tick.v, afterMs, beforeMs, plot)
+ rects.push({ x, y: plot.top, width: 1, height: plot.height, color: gridColor })
+ labels.push({
+ text: tick.label,
+ x,
+ y: plot.top + plot.height + 1,
+ align: "center",
+ verticalAlign: "top",
+ color: labelColor,
+ font,
+ })
+ })
+ }
+
+ return { plot, domain: [domainMin, domainMax], rects, labels }
+}
diff --git a/src/chartLibraries/gpu/visualizations/cartesian/axes.test.js b/src/chartLibraries/gpu/visualizations/cartesian/axes.test.js
new file mode 100644
index 000000000..2cef0e8a0
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/cartesian/axes.test.js
@@ -0,0 +1,86 @@
+import { makeTestChart } from "@jest/testUtilities"
+import { makeCartesianAxes, makePlotArea, padValueRange } from "./axes"
+
+describe("GPU Cartesian axes", () => {
+ it("reserves plot space only for enabled axes", () => {
+ const { chart } = makeTestChart({
+ attributes: { enabledXAxis: true, enabledYAxis: true, yAxisLabelWidth: 72 },
+ })
+
+ expect(makePlotArea(chart, 800, 400)).toEqual({
+ left: 78,
+ top: 0,
+ width: 727,
+ height: 384,
+ })
+
+ chart.updateAttributes({ enabledXAxis: false, enabledYAxis: false })
+ expect(makePlotArea(chart, 800, 400)).toEqual({
+ left: 0,
+ top: 0,
+ width: 805,
+ height: 400,
+ })
+ })
+
+ it("suppresses both axes for sparklines even when the axis flags are enabled", () => {
+ const { chart } = makeTestChart({
+ attributes: { sparkline: true, enabledXAxis: true, enabledYAxis: true },
+ })
+ const [afterMs, beforeMs] = chart.getDateWindow()
+ const axes = makeCartesianAxes({
+ chart,
+ width: 800,
+ height: 400,
+ min: -90,
+ max: 90,
+ afterMs,
+ beforeMs,
+ })
+
+ expect(axes.plot).toEqual({ left: 0, top: 0, width: 805, height: 400 })
+ expect(axes.rects).toEqual([])
+ expect(axes.labels).toEqual([])
+ })
+
+ it("pads a finite value domain by the Dygraphs line padding", () => {
+ const [min, max] = padValueRange(-90, 90, 484)
+
+ expect(min).toBeCloseTo(-90 - (180 * 15) / 484)
+ expect(max).toBeCloseTo(90 + (180 * 15) / 484)
+ })
+
+ it("builds deterministic GPU grid and shaped text layers", () => {
+ const { chart } = makeTestChart()
+ const [afterMs, beforeMs] = chart.getDateWindow()
+ const axes = makeCartesianAxes({
+ chart,
+ width: 800,
+ height: 400,
+ min: -90,
+ max: 90,
+ afterMs,
+ beforeMs,
+ })
+
+ expect(axes.rects.length).toBeGreaterThan(0)
+ expect(axes.labels.length).toBe(axes.rects.length)
+ expect(axes.labels.every(label => label.font === "10px sans-serif")).toBe(true)
+ expect(axes.labels.every(label => typeof label.text === "string")).toBe(true)
+
+ chart.updateAttributes({ theme: "dark", axisLabelFontSize: 12 })
+ const darkAxes = makeCartesianAxes({
+ chart,
+ width: 800,
+ height: 400,
+ min: -90,
+ max: 90,
+ afterMs,
+ beforeMs,
+ })
+ expect(darkAxes.labels.every(label => label.font === "12px sans-serif")).toBe(true)
+ expect(
+ darkAxes.labels.every(label => label.color === chart.getThemeAttribute("themeLabelColor"))
+ ).toBe(true)
+ })
+})
diff --git a/src/chartLibraries/gpu/visualizations/cartesian/heatmap/axes.js b/src/chartLibraries/gpu/visualizations/cartesian/heatmap/axes.js
new file mode 100644
index 000000000..e8dc46812
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/cartesian/heatmap/axes.js
@@ -0,0 +1,15 @@
+import { makeCartesianAxes, makePlotArea } from "../axes"
+
+export const makeHeatmapAxes = options => {
+ const { chart, width, height } = options
+ const plot = makePlotArea(chart, width, height)
+ const ids = chart.getVisibleHeatmapIds()
+ const maxTicks = Math.floor(plot.height / 15)
+ const hiddenStep = Math.ceil(ids.length / (maxTicks - 1))
+ const yTicks = ids.map((id, index) => ({
+ v: index,
+ label: index % hiddenStep === 0 ? chart.getDimensionName(id) : null,
+ }))
+
+ return makeCartesianAxes({ ...options, yTicks })
+}
diff --git a/src/chartLibraries/gpu/visualizations/cartesian/heatmap/colors.js b/src/chartLibraries/gpu/visualizations/cartesian/heatmap/colors.js
new file mode 100644
index 000000000..f86cf480c
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/cartesian/heatmap/colors.js
@@ -0,0 +1,13 @@
+export const makeHeatmapMetadata = chart => {
+ const dimensionIds = chart.getPayloadDimensionIds()
+ const visibleIds = chart.getVisibleHeatmapIds()
+ const visibleRanks = new Map(visibleIds.map((id, rank) => [id, rank]))
+ const metadata = new Float32Array(dimensionIds.length * 4)
+
+ dimensionIds.forEach((id, index) => {
+ const rank = visibleRanks.get(id)
+ metadata.set([rank ?? -1, visibleIds.length, 0, rank === undefined ? 0 : 1], index * 4)
+ })
+
+ return metadata
+}
diff --git a/src/chartLibraries/gpu/visualizations/cartesian/heatmap/data.js b/src/chartLibraries/gpu/visualizations/cartesian/heatmap/data.js
new file mode 100644
index 000000000..ab975375e
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/cartesian/heatmap/data.js
@@ -0,0 +1,91 @@
+import { isIncremental } from "@/helpers/heatmap"
+import { makePointValueReader } from "../line/data"
+
+export const packHeatmapData = (chart, rows, dimensionIds, point) => {
+ const pointCount = rows.length
+ const seriesCount = dimensionIds.length
+ const xOriginMs = pointCount ? rows[0][0] : 0
+ const x = new Float32Array(pointCount)
+ const y = new Float32Array(pointCount * seriesCount)
+ const incremental = isIncremental(chart)
+ const readValue = makePointValueReader(point)
+
+ for (let pointIndex = 0; pointIndex < pointCount; pointIndex++) {
+ const row = rows[pointIndex]
+ x[pointIndex] = (row[0] - xOriginMs) / 1000
+ for (let seriesIndex = 0; seriesIndex < seriesCount; seriesIndex++) {
+ const value = incremental
+ ? chart.getRowDimensionValue(dimensionIds[seriesIndex], row, {
+ allowNull: true,
+ })
+ : readValue(row[seriesIndex + 1])
+ const resolved =
+ value === null || value === undefined || !Number.isFinite(value)
+ ? 0
+ : incremental
+ ? value
+ : Math.abs(value)
+ y[pointIndex * seriesCount + seriesIndex] = resolved
+ }
+ }
+
+ return {
+ sourceRows: rows,
+ point,
+ xOriginMs,
+ yOrigin: 0,
+ yScale: 1,
+ x,
+ y,
+ pointCount,
+ seriesCount,
+ gapEdgeIndexes: [],
+ byteLength: x.byteLength + y.byteLength,
+ }
+}
+
+export default chart => {
+ let source = null
+ let dimensionKey = null
+ let pointSchema = null
+ let visibilityKey = null
+ let heatmapType = null
+ let packed = null
+
+ const get = () => {
+ const { all, point } = chart.getPayload()
+ const dimensionIds = chart.getPayloadDimensionIds()
+ if (chart.getAttribute("outOfLimits") || !all?.length || !dimensionIds.length) return null
+
+ const nextDimensionKey = dimensionIds.join("\u0000")
+ const nextVisibilityKey = chart.getVisibleDimensionIds().join("\u0000")
+ const nextHeatmapType = chart.getHeatmapType()
+ if (
+ source === all &&
+ dimensionKey === nextDimensionKey &&
+ pointSchema === point &&
+ visibilityKey === nextVisibilityKey &&
+ heatmapType === nextHeatmapType
+ )
+ return packed
+
+ source = all
+ dimensionKey = nextDimensionKey
+ pointSchema = point
+ visibilityKey = nextVisibilityKey
+ heatmapType = nextHeatmapType
+ packed = packHeatmapData(chart, all, dimensionIds, point)
+ return packed
+ }
+
+ const clear = () => {
+ source = null
+ dimensionKey = null
+ pointSchema = null
+ visibilityKey = null
+ heatmapType = null
+ packed = null
+ }
+
+ return { get, clear }
+}
diff --git a/src/chartLibraries/gpu/visualizations/cartesian/heatmap/data.test.js b/src/chartLibraries/gpu/visualizations/cartesian/heatmap/data.test.js
new file mode 100644
index 000000000..3fbb18484
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/cartesian/heatmap/data.test.js
@@ -0,0 +1,45 @@
+import { loadHeatmapPayload, makeTestChart } from "@jest/testUtilities"
+import { makeHeatmapMetadata } from "./colors"
+import makeHeatmapData from "./data"
+
+describe("GPU heatmap data", () => {
+ it("packs public all-rows with absolute values and sorted bucket metadata", async () => {
+ const { chart } = makeTestChart({ attributes: { chartType: "heatmap", groupBy: [] } })
+ await loadHeatmapPayload(chart, ["+Inf", "0.3", "2"], [[-1, 2, 0]])
+ const packed = makeHeatmapData(chart).get()
+
+ expect(Array.from(packed.y)).toEqual([1, 2, 0])
+ expect(Array.from(makeHeatmapMetadata(chart))).toEqual([2, 3, 0, 1, 0, 3, 0, 1, 1, 3, 0, 1])
+ })
+
+ it("reflows visible ranks while retaining hidden bucket metadata", async () => {
+ const { chart } = makeTestChart({ attributes: { chartType: "heatmap", groupBy: [] } })
+ await loadHeatmapPayload(chart, ["1", "2", "3"], [[1, 2, 3]])
+ chart.updateAttribute("selectedLegendDimensions", ["1", "3"])
+
+ expect(Array.from(makeHeatmapMetadata(chart))).toEqual([0, 2, 0, 1, -1, 2, 0, 0, 1, 2, 0, 1])
+ })
+
+ it("packs values row-major for direct instance access", async () => {
+ const { chart } = makeTestChart({ attributes: { chartType: "heatmap", groupBy: [] } })
+ await loadHeatmapPayload(
+ chart,
+ ["1", "2"],
+ [
+ [1, 2],
+ [3, 4],
+ ]
+ )
+
+ expect(Array.from(makeHeatmapData(chart).get().y)).toEqual([1, 2, 3, 4])
+ })
+
+ it("preserves cumulative-to-incremental bucket semantics", async () => {
+ const { chart } = makeTestChart({ attributes: { chartType: "heatmap", groupBy: [] } })
+ await loadHeatmapPayload(chart, ["1", "2", "3", "4"], [[2, 5, 5, 3]])
+ chart.updateAttribute("heatmapType", "incremental")
+ const packed = makeHeatmapData(chart).get()
+
+ expect(Array.from(packed.y)).toEqual([2, 3, 0, -2])
+ })
+})
diff --git a/src/chartLibraries/gpu/visualizations/cartesian/heatmap/index.js b/src/chartLibraries/gpu/visualizations/cartesian/heatmap/index.js
new file mode 100644
index 000000000..cfde402e1
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/cartesian/heatmap/index.js
@@ -0,0 +1,48 @@
+import { getVisibleSeriesIndexes } from "../multiBar/colors"
+import { getFirstReducedSeparation } from "../multiBar"
+import makeLineVisualization from "../line"
+import { makeHeatmapAxes } from "./axes"
+import { makeHeatmapMetadata } from "./colors"
+import makeHeatmapData from "./data"
+import { findClosestHeatmapDimension } from "./interaction"
+
+export const getHeatmapValueRange = ({ chart }) =>
+ chart.getAttribute("staticValueRange") || [0, chart.getVisibleHeatmapIds().length]
+
+export const getHeatmapNotificationRange = ({ chart }) => [
+ chart.getAttribute("min"),
+ chart.getAttribute("max"),
+]
+
+export const makeHeatmapStyle = (chart, { packed, frame }) => {
+ const separation = getFirstReducedSeparation({
+ packed,
+ visibleSeriesIndexes: getVisibleSeriesIndexes(chart),
+ afterMs: frame.afterMs,
+ beforeMs: frame.beforeMs,
+ plotWidth: frame.plot.width,
+ })
+
+ return {
+ barWidth: separation === null ? 0 : Math.floor(separation),
+ fillAlpha: 1,
+ heatmapMax: Number(chart.getAttribute("max")),
+ lineWidth: 0,
+ smooth: false,
+ stepped: false,
+ }
+}
+
+export default options =>
+ makeLineVisualization({
+ ...options,
+ findDimension: findClosestHeatmapDimension,
+ getAxisDimensionIds: chart => chart.getVisibleHeatmapIds(),
+ getValueRangeOverride: getHeatmapValueRange,
+ getYAxisNotificationRange: getHeatmapNotificationRange,
+ makeAxes: makeHeatmapAxes,
+ makeColors: makeHeatmapMetadata,
+ makeMarkers: () => [],
+ makePackedData: makeHeatmapData,
+ makeSeriesStyle: makeHeatmapStyle,
+ })
diff --git a/src/chartLibraries/gpu/visualizations/cartesian/heatmap/index.test.js b/src/chartLibraries/gpu/visualizations/cartesian/heatmap/index.test.js
new file mode 100644
index 000000000..7e943cf1c
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/cartesian/heatmap/index.test.js
@@ -0,0 +1,82 @@
+import { loadHeatmapPayload, makeTestChart } from "@jest/testUtilities"
+import { makeHeatmapAxes } from "./axes"
+import { findClosestHeatmapDimension } from "./interaction"
+import { getHeatmapNotificationRange, getHeatmapValueRange, makeHeatmapStyle } from "."
+
+describe("GPU heatmap visualization", () => {
+ it("builds sorted categorical rows and labels", async () => {
+ const { chart } = makeTestChart({
+ attributes: {
+ chartType: "heatmap",
+ enabledXAxis: false,
+ enabledYAxis: true,
+ groupBy: [],
+ },
+ })
+ await loadHeatmapPayload(chart, ["+Inf", "0.3", "2"], [[1, 2, 3]])
+ const axes = makeHeatmapAxes({
+ chart,
+ width: 800,
+ height: 400,
+ min: 0,
+ max: 3,
+ afterMs: 1000000,
+ beforeMs: 1001000,
+ })
+
+ expect(axes.rects).toHaveLength(3)
+ expect(axes.labels.map(label => label.text)).toEqual(["0.3", "2", "+Inf"])
+ })
+
+ it("uses cropped bucket count for range and nearest-row hover", async () => {
+ const { chart } = makeTestChart({ attributes: { chartType: "heatmap", groupBy: [] } })
+ await loadHeatmapPayload(chart, ["1", "2", "3"], [[1, 2, 3]])
+
+ expect(getHeatmapValueRange({ chart })).toEqual([0, 3])
+ chart.updateAttribute("max", 90)
+ expect(getHeatmapValueRange({ chart })).toEqual([0, 3])
+ expect(getHeatmapNotificationRange({ chart })).toEqual([1, 90])
+ expect(
+ findClosestHeatmapDimension({
+ chart,
+ y: 48,
+ domain: [-0.1, 3.1],
+ plot: { top: 0, height: 96 },
+ })
+ ).toBe("2")
+ })
+
+ it("uses the cropped visible bucket count for its categorical domain", async () => {
+ const { chart } = makeTestChart({ attributes: { chartType: "heatmap", groupBy: [] } })
+ await loadHeatmapPayload(chart, ["1", "2", "3", "4", "5", "6", "7"], [[0, 0, 1, 2, 3, 0, 0]])
+
+ expect(chart.getVisibleHeatmapIds()).toEqual(["2", "3", "4", "5", "6"])
+ expect(getHeatmapValueRange({ chart })).toEqual([0, 5])
+ })
+
+ it("uses exact reduced-window width and color maximum", async () => {
+ const { chart } = makeTestChart({ attributes: { chartType: "heatmap", groupBy: [] } })
+ await loadHeatmapPayload(chart, ["1"], [[1], [2]], { timestamp: 0 })
+ const packed = {
+ sourceRows: chart.getPayload().all,
+ point: chart.getPayload().point,
+ }
+ const style = makeHeatmapStyle(chart, {
+ packed,
+ frame: {
+ afterMs: 0,
+ beforeMs: 1000,
+ plot: { width: 100 },
+ },
+ })
+
+ expect(style).toEqual({
+ barWidth: 100,
+ fillAlpha: 1,
+ heatmapMax: 2,
+ lineWidth: 0,
+ smooth: false,
+ stepped: false,
+ })
+ })
+})
diff --git a/src/chartLibraries/gpu/visualizations/cartesian/heatmap/interaction.js b/src/chartLibraries/gpu/visualizations/cartesian/heatmap/interaction.js
new file mode 100644
index 000000000..f6f76f069
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/cartesian/heatmap/interaction.js
@@ -0,0 +1,16 @@
+import { valueToY } from "../line/interactions"
+
+export const findClosestHeatmapDimension = ({ chart, y, domain, plot }) => {
+ const ids = chart.getVisibleHeatmapIds()
+ let closestId = null
+ let closestDistance = Infinity
+
+ ids.forEach((id, index) => {
+ const distance = Math.abs(valueToY(index, domain, plot) - y)
+ if (distance >= closestDistance) return
+ closestId = id
+ closestDistance = distance
+ })
+
+ return closestId
+}
diff --git a/src/chartLibraries/gpu/visualizations/cartesian/interaction.js b/src/chartLibraries/gpu/visualizations/cartesian/interaction.js
new file mode 100644
index 000000000..ea94cdfae
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/cartesian/interaction.js
@@ -0,0 +1,34 @@
+const xPosition = (timestampMs, afterMs, beforeMs, plot) =>
+ plot.left + ((timestampMs - afterMs) / Math.max(beforeMs - afterMs, 1e-20)) * plot.width
+
+export const makeVerticalDashRects = ({ x, plot, color, dash = [5, 5] }) => {
+ const [draw, skip] = dash
+ const rects = []
+ const bottom = plot.top + plot.height
+ for (let y = plot.top; y < bottom; y += draw + skip) {
+ rects.push({ x, y, width: 1, height: Math.min(draw, bottom - y), color })
+ }
+ return rects
+}
+
+export const makeCrosshairRects = (chart, frame) => {
+ const selections = [
+ { dimensions: chart.getAttribute("clickX"), click: true },
+ { dimensions: chart.getAttribute("hoverX"), click: false },
+ ]
+
+ for (const { dimensions, click } of selections) {
+ if (!Array.isArray(dimensions) || !Number.isFinite(dimensions[0])) continue
+ const x = xPosition(dimensions[0], frame.afterMs, frame.beforeMs, frame.plot)
+ if (x < frame.plot.left || x > frame.plot.left + frame.plot.width) continue
+
+ return makeVerticalDashRects({
+ x,
+ plot: frame.plot,
+ color: chart.getThemeAttribute(click ? "themeNetdata" : "themeCrosshair"),
+ dash: click ? [2, 2] : [5, 5],
+ })
+ }
+
+ return []
+}
diff --git a/src/chartLibraries/gpu/visualizations/cartesian/interaction.test.js b/src/chartLibraries/gpu/visualizations/cartesian/interaction.test.js
new file mode 100644
index 000000000..8416d6941
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/cartesian/interaction.test.js
@@ -0,0 +1,63 @@
+import { makeTestChart } from "@jest/testUtilities"
+import { makeCrosshairRects, makeVerticalDashRects } from "./interaction"
+
+describe("WebGPU Cartesian interaction layer", () => {
+ it("builds a bounded dashed crosshair from synchronized hover state", () => {
+ const { chart } = makeTestChart()
+ chart.updateAttributes({ clickX: [null, null], hoverX: [1500, "dimension"] })
+ const frame = {
+ afterMs: 1000,
+ beforeMs: 2000,
+ plot: { left: 10, top: 5, width: 100, height: 20 },
+ }
+
+ const rects = makeCrosshairRects(chart, frame)
+ expect(rects).toEqual([
+ { x: 60, y: 5, width: 1, height: 5, color: "#536775" },
+ { x: 60, y: 15, width: 1, height: 5, color: "#536775" },
+ ])
+ })
+
+ it("gives a visible click selection priority over hover", () => {
+ const { chart } = makeTestChart()
+ chart.updateAttributes({ clickX: [1250, "dimension"], hoverX: [1750, "dimension"] })
+ const frame = {
+ afterMs: 1000,
+ beforeMs: 2000,
+ plot: { left: 10, top: 5, width: 100, height: 4 },
+ }
+
+ expect(makeCrosshairRects(chart, frame)).toEqual([
+ { x: 35, y: 5, width: 1, height: 2, color: "#00AB44" },
+ ])
+ })
+
+ it("uses hover when a stale click selection is outside the visible window", () => {
+ const { chart } = makeTestChart()
+ chart.updateAttributes({ clickX: [500, "dimension"], hoverX: [1750, "dimension"] })
+ const frame = {
+ afterMs: 1000,
+ beforeMs: 2000,
+ plot: { left: 10, top: 5, width: 100, height: 20 },
+ }
+
+ expect(makeCrosshairRects(chart, frame)).toEqual([
+ { x: 85, y: 5, width: 1, height: 5, color: "#536775" },
+ { x: 85, y: 15, width: 1, height: 5, color: "#536775" },
+ ])
+ })
+
+ it("clips the last dash to the plot boundary", () => {
+ expect(
+ makeVerticalDashRects({
+ x: 3,
+ plot: { left: 0, top: 0, width: 10, height: 12 },
+ color: "#ffffff",
+ dash: [5, 2],
+ })
+ ).toEqual([
+ { x: 3, y: 0, width: 1, height: 5, color: "#ffffff" },
+ { x: 3, y: 7, width: 1, height: 5, color: "#ffffff" },
+ ])
+ })
+})
diff --git a/src/chartLibraries/gpu/visualizations/cartesian/line/colors.js b/src/chartLibraries/gpu/visualizations/cartesian/line/colors.js
new file mode 100644
index 000000000..0d4b8cd2c
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/cartesian/line/colors.js
@@ -0,0 +1,15 @@
+import { parseColor } from "@/chartLibraries/gpu/color"
+
+export { parseColor }
+
+export const makeSeriesColors = chart => {
+ const colors = new Float32Array(chart.getPayloadDimensionIds().length * 4)
+
+ chart.getPayloadDimensionIds().forEach((id, index) => {
+ const rgba = parseColor(chart.selectDimensionColor(id))
+ if (!chart.isDimensionVisible(id)) rgba[3] = 0
+ colors.set(rgba, index * 4)
+ })
+
+ return colors
+}
diff --git a/src/chartLibraries/gpu/visualizations/cartesian/line/colors.test.js b/src/chartLibraries/gpu/visualizations/cartesian/line/colors.test.js
new file mode 100644
index 000000000..7e4af9091
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/cartesian/line/colors.test.js
@@ -0,0 +1,13 @@
+import { parseColor } from "./colors"
+
+describe("WebGPU series colors", () => {
+ it("packs short and full hexadecimal colors", () => {
+ expect(parseColor("#369")).toEqual([0.2, 0.4, 0.6, 1])
+ expect(parseColor("#33669980")).toEqual([0.2, 0.4, 0.6, 128 / 255])
+ })
+
+ it("packs rgb and rgba colors", () => {
+ expect(parseColor("rgb(51, 102, 153)")).toEqual([0.2, 0.4, 0.6, 1])
+ expect(parseColor("rgba(51, 102, 153, 0.5)")).toEqual([0.2, 0.4, 0.6, 0.5])
+ })
+})
diff --git a/src/chartLibraries/gpu/visualizations/cartesian/line/config.js b/src/chartLibraries/gpu/visualizations/cartesian/line/config.js
new file mode 100644
index 000000000..fe2a9d34d
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/cartesian/line/config.js
@@ -0,0 +1,20 @@
+export const makeLineStyle = chart => {
+ const stepped = chart.getAttribute("stepPlot")
+ return {
+ fillAlpha: 0,
+ lineWidth: 1.5,
+ smooth: !stepped,
+ stepped,
+ }
+}
+
+export const shouldIncludeZero = ({
+ includeZero,
+ forceIncludeZero,
+ dimensionCount,
+ selectedDimensionCount,
+}) =>
+ Boolean(
+ includeZero ||
+ (forceIncludeZero && dimensionCount > 1 && selectedDimensionCount > 1)
+ )
diff --git a/src/chartLibraries/gpu/visualizations/cartesian/line/config.test.js b/src/chartLibraries/gpu/visualizations/cartesian/line/config.test.js
new file mode 100644
index 000000000..5706c9a16
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/cartesian/line/config.test.js
@@ -0,0 +1,41 @@
+import { shouldIncludeZero } from "./config"
+
+describe("GPU Cartesian series range policy", () => {
+ it("honors an explicit include-zero request", () => {
+ expect(
+ shouldIncludeZero({
+ includeZero: true,
+ forceIncludeZero: false,
+ dimensionCount: 1,
+ selectedDimensionCount: 0,
+ })
+ ).toBe(true)
+ })
+
+ it("matches Dygraphs multi-series forced include-zero behavior", () => {
+ expect(
+ shouldIncludeZero({
+ includeZero: false,
+ forceIncludeZero: true,
+ dimensionCount: 3,
+ selectedDimensionCount: 2,
+ })
+ ).toBe(true)
+ expect(
+ shouldIncludeZero({
+ includeZero: false,
+ forceIncludeZero: true,
+ dimensionCount: 3,
+ selectedDimensionCount: 1,
+ })
+ ).toBe(false)
+ expect(
+ shouldIncludeZero({
+ includeZero: false,
+ forceIncludeZero: true,
+ dimensionCount: 1,
+ selectedDimensionCount: 3,
+ })
+ ).toBe(false)
+ })
+})
diff --git a/src/chartLibraries/gpu/visualizations/cartesian/line/data.js b/src/chartLibraries/gpu/visualizations/cartesian/line/data.js
new file mode 100644
index 000000000..aae64c377
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/cartesian/line/data.js
@@ -0,0 +1,145 @@
+import { getPointValue } from "@/sdk/makeChart/getPointValue"
+
+export const RANGE_BLOCK_SIZE = 32
+
+export const makePointValueReader = point => {
+ const valueIndex = point?.value
+ return typeof valueIndex === "number"
+ ? cell => (Array.isArray(cell) ? cell[valueIndex] : getPointValue(cell, point))
+ : cell => (cell !== null && typeof cell === "object" ? cell.value : cell)
+}
+
+const getFiniteRange = (rows, seriesCount, point, range) => {
+ let min = Number.isFinite(range?.[0]) ? range[0] : Infinity
+ let max = Number.isFinite(range?.[1]) ? range[1] : -Infinity
+ if (Number.isFinite(min) && Number.isFinite(max)) return [min, max]
+
+ min = Infinity
+ max = -Infinity
+ for (const row of rows) {
+ for (let seriesIndex = 0; seriesIndex < seriesCount; seriesIndex++) {
+ const value = getPointValue(row[seriesIndex + 1], point)
+ if (!Number.isFinite(value)) continue
+ min = Math.min(min, value)
+ max = Math.max(max, value)
+ }
+ }
+ return Number.isFinite(min) && Number.isFinite(max) ? [min, max] : [0, 1]
+}
+
+export const packAlignedData = (
+ rows,
+ seriesCount,
+ point,
+ range,
+ { trackGapEdges = true } = {}
+) => {
+ const pointCount = rows.length
+ const xOriginMs = pointCount ? rows[0][0] : 0
+ const [yOrigin, yMax] = getFiniteRange(rows, seriesCount, point, range)
+ const yScale = yMax === yOrigin ? Math.abs(yOrigin || 1) : yMax - yOrigin
+ const x = new Float32Array(pointCount)
+ const y = new Float32Array(pointCount * seriesCount)
+ const rangeBlockCount = Math.ceil(pointCount / RANGE_BLOCK_SIZE)
+ const gapEdgeIndexes = trackGapEdges
+ ? Array.from({ length: seriesCount }, () => [])
+ : []
+ const previousValid = trackGapEdges ? new Uint8Array(seriesCount) : null
+ const readValue = makePointValueReader(point)
+ let dataMin = Infinity
+ let dataMax = -Infinity
+
+ for (let pointIndex = 0; pointIndex < pointCount; pointIndex++) {
+ const row = rows[pointIndex]
+ x[pointIndex] = (row[0] - xOriginMs) / 1000
+
+ for (let seriesIndex = 0; seriesIndex < seriesCount; seriesIndex++) {
+ const value = readValue(row[seriesIndex + 1])
+ const valid = Number.isFinite(value)
+ y[seriesIndex * pointCount + pointIndex] = valid ? (value - yOrigin) / yScale : NaN
+ if (trackGapEdges) {
+ if (pointIndex > 0) {
+ if (valid && !previousValid[seriesIndex])
+ gapEdgeIndexes[seriesIndex].push(pointIndex)
+ else if (!valid && previousValid[seriesIndex])
+ gapEdgeIndexes[seriesIndex].push(pointIndex - 1)
+ }
+ previousValid[seriesIndex] = valid ? 1 : 0
+ }
+ if (valid) {
+ dataMin = Math.min(dataMin, value)
+ dataMax = Math.max(dataMax, value)
+ }
+ }
+ }
+
+ return {
+ sourceRows: rows,
+ point,
+ xOriginMs,
+ yOrigin,
+ yScale,
+ x,
+ y,
+ pointCount,
+ seriesCount,
+ rangeBlockSize: RANGE_BLOCK_SIZE,
+ rangeBlockCount,
+ rangeMin: null,
+ rangeMax: null,
+ rangeIndexedSeries: null,
+ dataMin,
+ dataMax,
+ gapEdgeIndexes,
+ byteLength: x.byteLength + y.byteLength,
+ }
+}
+
+export default (chart, options) => {
+ let source = null
+ let dimensionKey = null
+ let pointSchema = null
+ let rangeKey = null
+ let packed = null
+
+ const get = () => {
+ const { data, point } = chart.getPayload()
+ const dimensionIds = chart.getPayloadDimensionIds()
+ if (chart.getAttribute("outOfLimits") || !data?.length || !dimensionIds.length) return null
+
+ const nextDimensionKey = dimensionIds.join("\u0000")
+ const min = chart.getAttribute("min")
+ const max = chart.getAttribute("max")
+ const nextRangeKey = `${min}\u0000${max}`
+ if (
+ source === data &&
+ dimensionKey === nextDimensionKey &&
+ pointSchema === point &&
+ rangeKey === nextRangeKey
+ )
+ return packed
+
+ source = data
+ dimensionKey = nextDimensionKey
+ pointSchema = point
+ rangeKey = nextRangeKey
+ packed = packAlignedData(
+ data,
+ dimensionIds.length,
+ point,
+ [min, max],
+ options
+ )
+ return packed
+ }
+
+ const clear = () => {
+ source = null
+ dimensionKey = null
+ pointSchema = null
+ rangeKey = null
+ packed = null
+ }
+
+ return { get, clear }
+}
diff --git a/src/chartLibraries/gpu/visualizations/cartesian/line/data.test.js b/src/chartLibraries/gpu/visualizations/cartesian/line/data.test.js
new file mode 100644
index 000000000..634876477
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/cartesian/line/data.test.js
@@ -0,0 +1,129 @@
+import { packAlignedData } from "./data"
+
+describe("WebGPU aligned payload packing", () => {
+ it("packs shared timestamp offsets and dimension-major values", () => {
+ const rows = [
+ [1783630694000, 1, 10],
+ [1783630695000, 2, 20],
+ [1783630696000, 3, 30],
+ ]
+
+ const packed = packAlignedData(rows, 2, undefined, [0, 1])
+
+ expect(packed.xOriginMs).toBe(1783630694000)
+ expect(packed.yOrigin).toBe(0)
+ expect(packed.yScale).toBe(1)
+ expect([...packed.x]).toEqual([0, 1, 2])
+ expect([...packed.y]).toEqual([1, 2, 3, 10, 20, 30])
+ expect(packed.pointCount).toBe(3)
+ expect(packed.seriesCount).toBe(2)
+ expect(packed.byteLength).toBe(packed.x.byteLength + packed.y.byteLength)
+ expect(packed.rangeMin).toBeNull()
+ expect(packed.rangeMax).toBeNull()
+ })
+
+ it("preserves millisecond timestamp precision after removing the epoch origin", () => {
+ const packed = packAlignedData(
+ [
+ [1783630694000, 1],
+ [1783630694001, 2],
+ [1783630694002, 3],
+ ],
+ 1
+ )
+
+ expect(packed.x[1]).toBeCloseTo(0.001, 7)
+ expect(packed.x[2]).toBeCloseTo(0.002, 7)
+ })
+
+ it("extracts values from compact JSON2 point-schema cells", () => {
+ const packed = packAlignedData(
+ [
+ [1000, [1, 10], { value: 3, arp: 30 }],
+ [2000, [2, 20], { value: 4, arp: 40 }],
+ ],
+ 2,
+ { value: 0, arp: 1 },
+ [0, 1]
+ )
+
+ expect([...packed.y]).toEqual([1, 2, 3, 4])
+ })
+
+ it("encodes null and undefined values as GPU gap markers", () => {
+ const packed = packAlignedData(
+ [
+ [1000, null, 1],
+ [2000, undefined, 2],
+ ],
+ 2,
+ undefined,
+ [0, 1]
+ )
+
+ expect(Number.isNaN(packed.y[0])).toBe(true)
+ expect(Number.isNaN(packed.y[1])).toBe(true)
+ expect([...packed.y.slice(2)]).toEqual([1, 2])
+ })
+
+ it("indexes both visible edges of null gaps", () => {
+ const packed = packAlignedData(
+ [
+ [1000, 1],
+ [2000, null],
+ [3000, null],
+ [4000, 4],
+ ],
+ 1,
+ undefined,
+ [0, 4]
+ )
+
+ expect(packed.gapEdgeIndexes).toEqual([[0, 3]])
+ })
+
+ it("skips gap-edge residency for bar adapters without changing nulls", () => {
+ const packed = packAlignedData(
+ [
+ [1000, 1],
+ [2000, null],
+ ],
+ 1,
+ null,
+ null,
+ { trackGapEdges: false }
+ )
+
+ expect(packed.gapEdgeIndexes).toEqual([])
+ expect(Number.isNaN(packed.y[1])).toBe(true)
+ })
+
+ it("preserves visible variation around a large baseline", () => {
+ const baseline = 1_000_000_000_000_000
+ const packed = packAlignedData(
+ [
+ [1000, baseline + 1],
+ [2000, baseline + 2],
+ ],
+ 1,
+ undefined,
+ [baseline + 1, baseline + 2]
+ )
+
+ expect([...packed.y]).toEqual([0, 1])
+ expect(packed.yOrigin).toBe(baseline + 1)
+ expect(packed.yScale).toBe(1)
+ })
+
+ it("does not mutate the public row-major payload", () => {
+ const rows = [
+ [1000, 1],
+ [2000, 2],
+ ]
+ const original = rows.map(row => [...row])
+
+ packAlignedData(rows, 1)
+
+ expect(rows).toEqual(original)
+ })
+})
diff --git a/src/chartLibraries/gpu/visualizations/cartesian/line/decorations.js b/src/chartLibraries/gpu/visualizations/cartesian/line/decorations.js
new file mode 100644
index 000000000..cfcff43be
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/cartesian/line/decorations.js
@@ -0,0 +1,104 @@
+import { enums, parts, check, colors, priorities } from "@/helpers/annotations"
+import { getRowPointValue } from "@/sdk/makeChart/getPointValue"
+import { parseColor } from "@/chartLibraries/gpu/color"
+
+const xPosition = (timestampMs, frame) =>
+ frame.plot.left +
+ ((timestampMs - frame.afterMs) / Math.max(frame.beforeMs - frame.afterMs, 1e-20)) *
+ frame.plot.width
+
+const colorWithAlpha = (color, alpha) => {
+ const [r, g, b] = parseColor(color).map(value => Math.round(value * 255))
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`
+}
+
+const getVisibleIndexes = chart => {
+ const selected = chart.getAttribute("selectedLegendDimensions")
+ return chart.getPayloadDimensionIds().reduce((indexes, id, index) => {
+ if (!selected.length || chart.isDimensionVisible(id)) indexes.push(index)
+ return indexes
+ }, [])
+}
+
+export const makeGapEdgeCircles = ({ chart, packed, frame }) => {
+ const dimensionIds = chart.getPayloadDimensionIds()
+ const circles = []
+ dimensionIds.forEach((id, seriesIndex) => {
+ if (!chart.isDimensionVisible(id)) return
+ packed.gapEdgeIndexes[seriesIndex].forEach(pointIndex => {
+ const timestampMs = packed.xOriginMs + packed.x[pointIndex] * 1000
+ const valueOffset =
+ packed.layout === "row-major"
+ ? pointIndex * packed.seriesCount + seriesIndex
+ : seriesIndex * packed.pointCount + pointIndex
+ const value = packed.yOrigin + packed.y[valueOffset] * packed.yScale
+ const x = xPosition(timestampMs, frame)
+ const y =
+ frame.plot.top +
+ (1 - (value - frame.domain[0]) / (frame.domain[1] - frame.domain[0])) *
+ frame.plot.height
+ if (x < frame.plot.left || x > frame.plot.left + frame.plot.width) return
+ circles.push({ x, y, radius: 2, color: chart.selectDimensionColor(id) })
+ })
+ })
+ return circles
+}
+
+export const makeDataDecorationRects = ({ chart, frame }) => {
+ const { data, all, point } = chart.getPayload()
+ if (data?.length < 2 || !all || all.length !== data.length) return []
+
+ const visibleIndexes = getVisibleIndexes(chart)
+ const firstX = xPosition(data[0][0], frame)
+ const secondX = xPosition(data[1][0], frame)
+ const barWidth = Math.max(1, Math.floor(secondX - firstX + 1))
+ const anomalyColor = chart.getThemeAttribute("themeAnomalyScaleColor")
+ const rects = []
+
+ data.forEach((row, rowIndex) => {
+ const centerX = xPosition(row[0], frame)
+ if (centerX + barWidth / 2 < frame.plot.left) return
+ if (centerX - barWidth / 2 > frame.plot.left + frame.plot.width) return
+
+ if (chart.getAttribute("showAnomalies")) {
+ let anomalyRate = 0
+ visibleIndexes.forEach(index => {
+ anomalyRate = Math.max(
+ anomalyRate,
+ getRowPointValue(all[rowIndex], index + 1, point, "arp") || 0
+ )
+ })
+ if (anomalyRate > 0) {
+ rects.push({
+ x: centerX - barWidth / 2,
+ y: frame.plot.top,
+ width: barWidth,
+ height: 15,
+ color: colorWithAlpha(anomalyColor, Math.min(1, anomalyRate / 100)),
+ })
+ }
+ }
+
+ if (chart.getAttribute("showAnnotations")) {
+ const values = new Set()
+ visibleIndexes.forEach(index => {
+ const annotation = getRowPointValue(all[rowIndex], index + 1, point, "pa")
+ if (annotation) parts.forEach(part => check(annotation, enums[part]) && values.add(part))
+ })
+ const sortedValues = [...values].sort((a, b) => priorities[a] < priorities[b])
+ sortedValues.forEach(value => {
+ const color = colors[value]
+ if (!color) return
+ rects.push({
+ x: centerX - barWidth / 2,
+ y: frame.plot.top + frame.plot.height - 4,
+ width: barWidth,
+ height: 4,
+ color: colorWithAlpha(color, 0.45),
+ })
+ })
+ }
+ })
+
+ return rects
+}
diff --git a/src/chartLibraries/gpu/visualizations/cartesian/line/geometry.js b/src/chartLibraries/gpu/visualizations/cartesian/line/geometry.js
new file mode 100644
index 000000000..ba9f1c702
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/cartesian/line/geometry.js
@@ -0,0 +1,30 @@
+export const makeCurveSegments = ({ pointCount, plotWidth, targetPixels = 2 }) => {
+ const pairs = Math.max(1, pointCount - 1)
+ const spacing = Math.max(0, plotWidth) / pairs
+ return Math.max(1, Math.ceil(spacing / targetPixels))
+}
+
+export const makeDrawLayout = ({
+ pointCount,
+ seriesCount,
+ stepped,
+ smooth = false,
+ curveSegments = 1,
+ filled = false,
+ stroke = true,
+}) => {
+ const pairsPerSeries = Math.max(0, pointCount - 1)
+ const segmentsPerPair = stepped ? 2 : smooth ? Math.max(1, curveSegments) : 1
+ const segmentsPerSeries = pairsPerSeries * segmentsPerPair
+ const fillInstanceCount = filled ? pairsPerSeries * seriesCount : 0
+ const strokeInstanceCount = stroke ? segmentsPerSeries * seriesCount : 0
+
+ return {
+ pairsPerSeries,
+ segmentsPerPair,
+ segmentsPerSeries,
+ fillInstanceCount,
+ strokeInstanceCount,
+ instanceCount: fillInstanceCount + strokeInstanceCount,
+ }
+}
diff --git a/src/chartLibraries/gpu/visualizations/cartesian/line/geometry.test.js b/src/chartLibraries/gpu/visualizations/cartesian/line/geometry.test.js
new file mode 100644
index 000000000..88dcc179a
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/cartesian/line/geometry.test.js
@@ -0,0 +1,91 @@
+import { makeCurveSegments, makeDrawLayout } from "./geometry"
+
+describe("WebGPU aligned line draw layout", () => {
+ it("draws one segment per adjacent pair for every linear series", () => {
+ expect(makeDrawLayout({ pointCount: 3, seriesCount: 2, stepped: false })).toEqual({
+ pairsPerSeries: 2,
+ segmentsPerPair: 1,
+ segmentsPerSeries: 2,
+ fillInstanceCount: 0,
+ strokeInstanceCount: 4,
+ instanceCount: 4,
+ })
+ })
+
+ it("draws horizontal and vertical segments for each stepped pair", () => {
+ expect(makeDrawLayout({ pointCount: 3, seriesCount: 2, stepped: true })).toEqual({
+ pairsPerSeries: 2,
+ segmentsPerPair: 2,
+ segmentsPerSeries: 4,
+ fillInstanceCount: 0,
+ strokeInstanceCount: 8,
+ instanceCount: 8,
+ })
+ })
+
+ it("tessellates smooth pairs according to physical point spacing", () => {
+ const curveSegments = makeCurveSegments({ pointCount: 100, plotWidth: 1532 })
+ expect(curveSegments).toBe(8)
+ expect(
+ makeDrawLayout({
+ pointCount: 100,
+ seriesCount: 3,
+ smooth: true,
+ curveSegments,
+ })
+ ).toEqual({
+ pairsPerSeries: 99,
+ segmentsPerPair: 8,
+ segmentsPerSeries: 792,
+ fillInstanceCount: 0,
+ strokeInstanceCount: 2376,
+ instanceCount: 2376,
+ })
+ })
+
+ it("draws one exact fill trapezoid per pair before optional strokes", () => {
+ expect(
+ makeDrawLayout({
+ pointCount: 3,
+ seriesCount: 2,
+ stepped: false,
+ filled: true,
+ })
+ ).toEqual({
+ pairsPerSeries: 2,
+ segmentsPerPair: 1,
+ segmentsPerSeries: 2,
+ fillInstanceCount: 4,
+ strokeInstanceCount: 4,
+ instanceCount: 8,
+ })
+ expect(
+ makeDrawLayout({
+ pointCount: 3,
+ seriesCount: 2,
+ stepped: true,
+ filled: true,
+ stroke: false,
+ })
+ ).toEqual({
+ pairsPerSeries: 2,
+ segmentsPerPair: 2,
+ segmentsPerSeries: 4,
+ fillInstanceCount: 4,
+ strokeInstanceCount: 0,
+ instanceCount: 4,
+ })
+ })
+
+ it("keeps dense and sparse smooth-pair subdivisions within screen error", () => {
+ expect(makeCurveSegments({ pointCount: 1000, plotWidth: 1532 })).toBe(1)
+ const sparseSegments = makeCurveSegments({ pointCount: 2, plotWidth: 1532 })
+ expect(sparseSegments).toBe(766)
+ expect(1532 / sparseSegments).toBeLessThanOrEqual(2)
+ })
+
+ it("does not submit geometry without adjacent points or visible series", () => {
+ expect(makeDrawLayout({ pointCount: 1, seriesCount: 3, stepped: false }).instanceCount).toBe(0)
+ expect(makeDrawLayout({ pointCount: 3, seriesCount: 0, stepped: false }).instanceCount).toBe(0)
+ })
+})
diff --git a/src/chartLibraries/gpu/visualizations/cartesian/line/index.js b/src/chartLibraries/gpu/visualizations/cartesian/line/index.js
new file mode 100644
index 000000000..d57281611
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/cartesian/line/index.js
@@ -0,0 +1,176 @@
+import { unregister } from "@/helpers/makeListeners"
+import { makeCartesianAxes, makePlotArea } from "../axes"
+import makeInteractions from "./interactions"
+import { makeGapEdgeCircles } from "./decorations"
+import makeDefaultPackedData from "./data"
+import { getVisibleRange as getDefaultVisibleRange } from "./range"
+import { makeSeriesColors } from "./colors"
+import { makeLineStyle } from "./config"
+import makeFrameRenderer from "./makeFrameRenderer"
+
+export default ({
+ chart,
+ chartUI,
+ makeResources,
+ forceIncludeZero = false,
+ makeSeriesStyle = makeLineStyle,
+ makePackedData = makeDefaultPackedData,
+ getPackedVisibleRange = getDefaultVisibleRange,
+ findDimension,
+ makeMarkers = makeGapEdgeCircles,
+ makeColors = makeSeriesColors,
+ makeAxes = makeCartesianAxes,
+ getValueRangeOverride,
+ getAxisDimensionIds = targetChart => targetChart.getVisibleDimensionIds(),
+ getYAxisNotificationRange = ({ min, max }) => [min, max],
+}) => {
+ const packedData = makePackedData(chart)
+ let resource = null
+ let listeners = null
+ let offInteractions = null
+ let localDateWindow = null
+ let selectionRect = null
+
+ const frameRenderer = makeFrameRenderer({
+ chart,
+ chartUI,
+ packedData,
+ getResource: () => resource,
+ getLocalDateWindow: () => localDateWindow,
+ getSelectionRect: () => selectionRect,
+ forceIncludeZero,
+ makeSeriesStyle,
+ getPackedVisibleRange,
+ makeMarkers,
+ makeColors,
+ makeAxes,
+ getValueRangeOverride,
+ getAxisDimensionIds,
+ getYAxisNotificationRange,
+ })
+
+ const markColorsDirty = render => () => {
+ frameRenderer.markColorsDirty()
+ render()
+ }
+
+ const mount = ({ render, canvas }) => {
+ offInteractions = makeInteractions({
+ chart,
+ chartUI,
+ canvas,
+ getFrame: frameRenderer.getFrame,
+ setDateWindow: dateWindow => {
+ localDateWindow = dateWindow
+ render()
+ },
+ clearDateWindow: () => {
+ localDateWindow = null
+ render()
+ },
+ setSelectionRect: rect => {
+ selectionRect = rect
+ render()
+ },
+ findDimension,
+ })
+ listeners = unregister(
+ chart.on("visibleDimensionsChanged", markColorsDirty(render)),
+ chart.onAttributeChange("selectedLegendDimensions", markColorsDirty(render)),
+ chart.onAttributeChange("colors", markColorsDirty(render)),
+ chart.onAttributeChange("theme", markColorsDirty(render)),
+ chart.onAttributeChange("stepPlot", render),
+ chart.onAttributeChange("heatmapType", render),
+ chart.onAttributeChange("staticValueRange", render),
+ chart.onAttributeChange("valueRange", render),
+ chart.onAttributeChange("getValueRange", render),
+ chart.onAttributeChange("min", render),
+ chart.onAttributeChange("max", render),
+ chart.onAttributeChange("includeZero", render),
+ chart.onAttributeChange("sparkline", render),
+ chart.onAttributeChange("enabledXAxis", render),
+ chart.onAttributeChange("enabledYAxis", render),
+ chart.onAttributeChange("yAxisLabelWidth", render),
+ chart.onAttributeChange("axisLabelFontSize", render),
+ chart.onAttributeChange("timezone", render),
+ chart.onAttributeChange("locale", render),
+ chart.onAttributeChange("secondsAsTime", render),
+ chart.onAttributeChange("desiredUnits", render),
+ chart.onAttributeChange("staticFractionDigits", render),
+ chart.onAttributeChange("unitsConversionMethod", render),
+ chart.onAttributeChange("showAnomalies", render),
+ chart.onAttributeChange("showAnnotations", render),
+ chart.onAttributeChange("outOfLimits", render),
+ chart.onAttributeChange("error", render),
+ chart.onAttributeChange("processing", render),
+ chart.onAttributeChange("hoverX", render),
+ chart.onAttributeChange("clickX", render),
+ chart.onAttributeChange("overlays", render),
+ chart.onAttributeChange("draftAnnotation", render),
+ chart.onAttributeChange("after", () => {
+ localDateWindow = null
+ render()
+ }),
+ chart.onAttributeChange("before", () => {
+ localDateWindow = null
+ render()
+ })
+ )
+ }
+
+ const unmount = () => {
+ listeners?.()
+ listeners = null
+ offInteractions?.()
+ offInteractions = null
+ resource?.destroy()
+ resource = null
+ packedData.clear()
+ frameRenderer.reset()
+ localDateWindow = null
+ selectionRect = null
+ }
+
+ const createResources = (runtime, canvas, onLost) =>
+ makeResources(runtime, canvas, onLost)
+
+ const attachResources = nextResource => {
+ resource?.destroy()
+ resource = nextResource
+ }
+
+ const render = frameRenderer.render
+
+ const getPlotArea = () =>
+ makePlotArea(chart, chartUI.getChartWidth(), chartUI.getChartHeight())
+ const getXAxisRange = () => localDateWindow || chart.getDateWindow()
+ const getXCoord = timestampMs => {
+ const [after, before] = getXAxisRange()
+ const plot = getPlotArea()
+ return before === after
+ ? plot.left
+ : plot.left + ((timestampMs - after) / (before - after)) * plot.width
+ }
+
+ return {
+ mount,
+ unmount,
+ createResources,
+ attachResources,
+ render,
+ getPlotArea,
+ getXAxisRange,
+ getXCoord,
+ getQueueDone: () => resource?.surface.getQueueDone(),
+ getDrawStats: () => resource?.line.getDrawStats?.() || null,
+ getBufferBytes: () =>
+ resource
+ ? resource.grid.getBufferBytes() +
+ resource.interaction.getBufferBytes() +
+ resource.overlay.getBufferBytes() +
+ resource.line.getBufferBytes() +
+ resource.marker.getBufferBytes() +
+ resource.text.getBufferBytes()
+ : 0,
+ }
+}
diff --git a/src/chartLibraries/gpu/visualizations/cartesian/line/interactions.js b/src/chartLibraries/gpu/visualizations/cartesian/line/interactions.js
new file mode 100644
index 000000000..f1313ef9d
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/cartesian/line/interactions.js
@@ -0,0 +1,447 @@
+import limitRange from "@/helpers/limitRange"
+import { getPointValue } from "@/sdk/makeChart/getPointValue"
+
+export const eventToCanvasPoint = (event, canvas) => {
+ const rect = canvas.getBoundingClientRect()
+ return { x: event.clientX - rect.left, y: event.clientY - rect.top }
+}
+
+export const valueToY = (value, domain, plot) =>
+ plot.top + (1 - (value - domain[0]) / Math.max(domain[1] - domain[0], 1e-20)) * plot.height
+
+const yToValue = (y, domain, plot) =>
+ domain[1] - ((y - plot.top) / Math.max(plot.height, 1)) * (domain[1] - domain[0])
+
+const xToTimestamp = (x, frame) =>
+ frame.afterMs +
+ ((x - frame.plot.left) / Math.max(frame.plot.width, 1)) *
+ (frame.beforeMs - frame.afterMs)
+
+export const getClosestRow = (data, timestamp) => {
+ if (!data.length) return -1
+ if (timestamp <= data[0][0]) return 0
+ if (timestamp >= data[data.length - 1][0]) return data.length - 1
+
+ let start = 0
+ let end = data.length - 1
+ let closest = 0
+ while (start <= end) {
+ const middle = Math.floor((start + end) / 2)
+ if (Math.abs(data[middle][0] - timestamp) < Math.abs(data[closest][0] - timestamp))
+ closest = middle
+ if (data[middle][0] === timestamp) return middle
+ if (data[middle][0] < timestamp) start = middle + 1
+ else end = middle - 1
+ }
+ return closest
+}
+
+export const findClosestDimension = ({ chart, row, y, domain, plot }) => {
+ let dimensionId = null
+ let distance = Infinity
+ const payload = chart.getPayload()
+ const rowData = payload.data[row]
+ const dimensionIndexes = new Map(
+ chart.getPayloadDimensionIds().map((id, index) => [id, index])
+ )
+
+ chart.getVisibleDimensionIds().forEach(id => {
+ const seriesIndex = dimensionIndexes.get(id)
+ const value =
+ seriesIndex === undefined ? null : getPointValue(rowData?.[seriesIndex + 1], payload.point)
+ if (!Number.isFinite(value)) return
+ const nextDistance = Math.abs(valueToY(value, domain, plot) - y)
+ if (nextDistance >= distance) return
+ distance = nextDistance
+ dimensionId = id
+ })
+
+ return dimensionId
+}
+
+const isNearAnnotation = (chart, timestampMs, thresholdMs) =>
+ Object.values(chart.getAttribute("overlays") || {}).some(
+ overlay =>
+ overlay.type === "annotation" &&
+ Math.abs(overlay.timestamp * 1000 - timestampMs) < thresholdMs
+ )
+
+const createAnnotation = (chart, timestampMs, thresholdMs) => {
+ const existingDraft = chart.getAttribute("draftAnnotation")
+ if (existingDraft?.status === "editing") return
+ if (isNearAnnotation(chart, timestampMs, thresholdMs)) return
+
+ chart.updateAttribute("draftAnnotation", {
+ timestamp: timestampMs / 1000,
+ createdAt: new Date(),
+ status: "draft",
+ })
+ chart.sdk.trigger("annotationCreate", chart, timestampMs / 1000)
+ chart.trigger("annotationCreate", timestampMs / 1000)
+}
+
+const getNavigationMode = (event, chart) => {
+ if (event.shiftKey && event.altKey) return "selectVertical"
+ if (event.altKey) return "highlight"
+ if (event.shiftKey) return "select"
+ return chart.getAttribute("navigation") || "pan"
+}
+
+const makeSelectionRect = ({ mode, start, end, frame }) => {
+ if (mode === "selectVertical") {
+ return {
+ x: frame.plot.left,
+ y: Math.min(start.y, end.y),
+ width: frame.plot.width,
+ height: Math.abs(end.y - start.y),
+ color: "rgba(128, 128, 128, 0.3)",
+ }
+ }
+ return {
+ x: Math.min(start.x, end.x),
+ y: frame.plot.top,
+ width: Math.abs(end.x - start.x),
+ height: frame.plot.height,
+ color: "rgba(128, 128, 128, 0.3)",
+ }
+}
+
+export default ({
+ chart,
+ chartUI,
+ canvas,
+ getFrame,
+ setDateWindow,
+ clearDateWindow,
+ setSelectionRect,
+ findDimension = findClosestDimension,
+}) => {
+ let lastX = null
+ let lastY = null
+ let drag = null
+ let hovering = false
+ let suppressClick = false
+ let moveTimer = null
+ let touch = null
+ let lastTouchEnd = 0
+
+ const getClosest = event => {
+ const frame = getFrame()
+ if (!frame) return null
+ const point = eventToCanvasPoint(event, canvas)
+ const { plot, domain } = frame
+ if (
+ point.x < plot.left ||
+ point.x > plot.left + plot.width ||
+ point.y < plot.top ||
+ point.y > plot.top + plot.height
+ )
+ return null
+
+ const data = chart.getPayload().data
+ const row = getClosestRow(data, xToTimestamp(point.x, frame))
+ const rowData = data[row]
+ if (!Array.isArray(rowData)) return null
+ const dimensionId = findDimension({ chart, row, y: point.y, domain, plot })
+ if (!dimensionId) return null
+
+ return { point, timestampMs: rowData[0], dimensionId }
+ }
+
+ const mousemove = event => {
+ chartUI.trigger("mousemove", event)
+ if (drag) return
+ if (!chart.getAttribute("enabledHover")) return
+ const point = eventToCanvasPoint(event, canvas)
+ if (lastX !== null && Math.abs(point.x - lastX) < 5 && Math.abs(point.y - lastY) < 5)
+ return
+ lastX = point.x
+ lastY = point.y
+
+ const closest = getClosest(event)
+ if (!closest) {
+ if (hovering) mouseleave()
+ return
+ }
+ hovering = true
+ chart.sdk.trigger("highlightHover", chart, closest.timestampMs, closest.dimensionId)
+ chart.trigger("highlightHover", closest.timestampMs, closest.dimensionId)
+ }
+
+ const mouseleave = event => {
+ if (drag) return
+ chartUI.trigger("mouseout", event)
+ lastX = null
+ lastY = null
+ hovering = false
+ chart.sdk.trigger("highlightBlur", chart)
+ chart.trigger("highlightBlur")
+ }
+
+ const click = event => {
+ if (suppressClick) {
+ suppressClick = false
+ return
+ }
+ const closest = getClosest(event)
+ if (!closest) return
+ const frame = getFrame()
+ const thresholdMs = frame
+ ? ((frame.beforeMs - frame.afterMs) / Math.max(frame.plot.width, 1)) * 10
+ : 0
+ createAnnotation(chart, closest.timestampMs, thresholdMs)
+ chart.sdk.trigger("highlightClick", chart, closest.timestampMs, closest.dimensionId)
+ chart.trigger("highlightClick", closest.timestampMs, closest.dimensionId)
+ }
+
+ const restoreNavigation = () => {
+ const previous = chart.getAttribute("prevNavigation")
+ if (!previous) return
+ chart.updateAttributes({ navigation: previous, prevNavigation: null })
+ }
+
+ const endDrag = event => {
+ if (!drag) return
+ const current = eventToCanvasPoint(event, canvas)
+ const distance = Math.hypot(current.x - drag.start.x, current.y - drag.start.y)
+ const moved = drag.moved || distance >= 5
+ suppressClick = moved
+ setSelectionRect(null)
+
+ if (drag.mode === "pan") {
+ if (drag.panning) {
+ const frame = getFrame()
+ chart.sdk.trigger("panEnd", chart, [frame.afterMs, frame.beforeMs])
+ clearDateWindow()
+ }
+ } else if (drag.mode === "selectVertical") {
+ const range =
+ distance < 5
+ ? null
+ : [
+ yToValue(drag.start.y, drag.frame.domain, drag.frame.plot),
+ yToValue(current.y, drag.frame.domain, drag.frame.plot),
+ ].sort((a, b) => a - b)
+ chart.sdk.trigger("highlightVerticalEnd", chart, range)
+ } else {
+ const range =
+ distance < 5
+ ? null
+ : [
+ Math.round(xToTimestamp(drag.start.x, drag.frame) / 1000),
+ Math.round(xToTimestamp(current.x, drag.frame) / 1000),
+ ].sort((a, b) => a - b)
+ chart.sdk.trigger("highlightEnd", chart, range)
+ chart.trigger("highlightEnd", range)
+ }
+
+ drag = null
+ window.removeEventListener("mousemove", dragMove)
+ window.removeEventListener("mouseup", endDrag)
+ setTimeout(restoreNavigation)
+ }
+
+ const dragMove = event => {
+ if (!drag) return
+ const current = eventToCanvasPoint(event, canvas)
+ if (drag.mode === "pan") {
+ const distance = Math.hypot(current.x - drag.start.x, current.y - drag.start.y)
+ if (distance < 5 && !drag.panning) return
+ if (!drag.panning) {
+ drag.panning = true
+ chart.sdk.trigger("panStart", chart)
+ }
+ drag.moved = true
+ const delta =
+ ((drag.start.x - current.x) / Math.max(drag.frame.plot.width, 1)) *
+ (drag.frame.beforeMs - drag.frame.afterMs)
+ setDateWindow([drag.frame.afterMs + delta, drag.frame.beforeMs + delta])
+ return
+ }
+ drag.moved =
+ drag.moved || Math.hypot(current.x - drag.start.x, current.y - drag.start.y) >= 5
+ setSelectionRect(makeSelectionRect({ ...drag, end: current }))
+ }
+
+ const mousedown = event => {
+ if (event.button !== 0 || !chart.getAttribute("enabledNavigation")) return
+ const frame = getFrame()
+ if (!frame) return
+ const start = eventToCanvasPoint(event, canvas)
+ if (
+ start.x < frame.plot.left ||
+ start.x > frame.plot.left + frame.plot.width ||
+ start.y < frame.plot.top ||
+ start.y > frame.plot.top + frame.plot.height
+ )
+ return
+ const mode = getNavigationMode(event, chart)
+ const previous = chart.getAttribute("navigation")
+ if (mode !== previous) chart.updateAttributes({ navigation: mode, prevNavigation: previous })
+ drag = { mode, start, frame, moved: false, panning: false }
+ event.preventDefault()
+
+ if (mode === "selectVertical") chart.sdk.trigger("highlightVerticalStart", chart)
+ else if (mode !== "pan") chart.sdk.trigger("highlightStart", chart)
+
+ window.addEventListener("mousemove", dragMove)
+ window.addEventListener("mouseup", endDrag)
+ }
+
+ const wheel = event => {
+ if (!chart.getAttribute("enabledNavigation") || (!event.shiftKey && !event.altKey)) return
+ const frame = getFrame()
+ if (!frame) return
+ event.preventDefault()
+ event.stopPropagation()
+
+ const point = eventToCanvasPoint(event, canvas)
+ const bias = (point.x - frame.plot.left) / Math.max(frame.plot.width, 1)
+ const normal =
+ typeof event.wheelDelta === "number" && !Number.isNaN(event.wheelDelta)
+ ? event.wheelDelta / 40
+ : event.deltaY * -1.2
+ const percentage = (event.detail ? event.detail * -1 : normal) / 50
+ const increment = (frame.beforeMs - frame.afterMs) * percentage
+ const after = frame.afterMs + increment * bias
+ const before = frame.beforeMs - increment * (1 - bias)
+ const limited = limitRange({ after: after / 1000, before: before / 1000 })
+ const dateWindow = [limited.fixedAfter * 1000, limited.fixedBefore * 1000]
+ setDateWindow(dateWindow)
+
+ clearTimeout(moveTimer)
+ moveTimer = setTimeout(() => {
+ chart.moveX(limited.fixedAfter, limited.fixedBefore)
+ clearDateWindow()
+ }, 500)
+ }
+
+ const dblclick = event => {
+ event.preventDefault()
+ chart.resetNavigation()
+ }
+
+ const getTouchPoint = source => {
+ const rect = canvas.getBoundingClientRect()
+ return { x: source.clientX - rect.left, y: source.clientY - rect.top }
+ }
+
+ const touchstart = event => {
+ if (!chart.getAttribute("enabledNavigation") || !event.touches.length) return
+ const frame = getFrame()
+ if (!frame) return
+ event.preventDefault()
+ const points = Array.from(event.touches).map(getTouchPoint)
+ const midpoint =
+ points.length > 1
+ ? { x: (points[0].x + points[1].x) / 2, y: (points[0].y + points[1].y) / 2 }
+ : points[0]
+ if (
+ midpoint.x < frame.plot.left ||
+ midpoint.x > frame.plot.left + frame.plot.width ||
+ midpoint.y < frame.plot.top ||
+ midpoint.y > frame.plot.top + frame.plot.height
+ )
+ return
+ touch = {
+ frame,
+ points,
+ midpoint,
+ distance:
+ points.length > 1
+ ? Math.hypot(points[1].x - points[0].x, points[1].y - points[0].y)
+ : 0,
+ moved: false,
+ panning: false,
+ }
+ }
+
+ const touchmove = event => {
+ if (!touch || !event.touches.length) return
+ event.preventDefault()
+ const points = Array.from(event.touches).map(getTouchPoint)
+ if (!touch.panning) {
+ touch.panning = true
+ chart.sdk.trigger("panStart", chart)
+ }
+ touch.moved = true
+
+ if (points.length > 1 && touch.points.length > 1 && touch.distance > 0) {
+ const distance = Math.max(
+ 1,
+ Math.hypot(points[1].x - points[0].x, points[1].y - points[0].y)
+ )
+ const span = (touch.frame.beforeMs - touch.frame.afterMs) * (touch.distance / distance)
+ const bias =
+ (touch.midpoint.x - touch.frame.plot.left) / Math.max(touch.frame.plot.width, 1)
+ const center = xToTimestamp(touch.midpoint.x, touch.frame)
+ setDateWindow([center - span * bias, center + span * (1 - bias)])
+ return
+ }
+
+ const delta =
+ ((touch.points[0].x - points[0].x) / Math.max(touch.frame.plot.width, 1)) *
+ (touch.frame.beforeMs - touch.frame.afterMs)
+ setDateWindow([touch.frame.afterMs + delta, touch.frame.beforeMs + delta])
+ }
+
+ const touchend = event => {
+ if (!touch || event.touches.length) return
+ event.preventDefault()
+ const now = Date.now()
+ if (touch.moved) {
+ const frame = getFrame()
+ chart.sdk.trigger("panEnd", chart, [frame.afterMs, frame.beforeMs])
+ clearDateWindow()
+ } else if (now - lastTouchEnd < 300) {
+ chart.resetNavigation()
+ } else {
+ const timestamp = xToTimestamp(touch.points[0].x, touch.frame)
+ const data = chart.getPayload().data
+ const row = getClosestRow(data, timestamp)
+ if (row !== -1) chart.updateAttribute("clickX", [data[row][0], null])
+ }
+ lastTouchEnd = now
+ touch = null
+ }
+
+ canvas.addEventListener("mousemove", mousemove)
+ canvas.addEventListener("mouseleave", mouseleave)
+ canvas.addEventListener("click", click)
+ canvas.addEventListener("mousedown", mousedown)
+ canvas.addEventListener("wheel", wheel, { passive: false })
+ canvas.addEventListener("dblclick", dblclick)
+ canvas.addEventListener("touchstart", touchstart, { passive: false })
+ canvas.addEventListener("touchmove", touchmove, { passive: false })
+ canvas.addEventListener("touchend", touchend, { passive: false })
+ canvas.addEventListener("touchcancel", touchend, { passive: false })
+
+ return () => {
+ clearTimeout(moveTimer)
+ if (drag?.panning || touch?.panning) {
+ chart
+ .getApplicableNodes({ syncPanning: true })
+ .forEach(node => node.updateAttributes({ enabledHover: true, panning: false }))
+ }
+ if (drag && drag.mode !== "pan") {
+ chart
+ .getApplicableNodes({ syncHighlight: true })
+ .forEach(node => node.updateAttributes({ enabledHover: true, highlighting: false }))
+ }
+ restoreNavigation()
+ drag = null
+ touch = null
+ window.removeEventListener("mousemove", dragMove)
+ window.removeEventListener("mouseup", endDrag)
+ canvas.removeEventListener("mousemove", mousemove)
+ canvas.removeEventListener("mouseleave", mouseleave)
+ canvas.removeEventListener("click", click)
+ canvas.removeEventListener("mousedown", mousedown)
+ canvas.removeEventListener("wheel", wheel)
+ canvas.removeEventListener("dblclick", dblclick)
+ canvas.removeEventListener("touchstart", touchstart)
+ canvas.removeEventListener("touchmove", touchmove)
+ canvas.removeEventListener("touchend", touchend)
+ canvas.removeEventListener("touchcancel", touchend)
+ }
+}
diff --git a/src/chartLibraries/gpu/visualizations/cartesian/line/interactions.test.js b/src/chartLibraries/gpu/visualizations/cartesian/line/interactions.test.js
new file mode 100644
index 000000000..ba1f3f513
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/cartesian/line/interactions.test.js
@@ -0,0 +1,126 @@
+import { makeTestChart } from "@jest/testUtilities"
+import makeChartUI from "@/sdk/makeChartUI"
+import makeInteractions, { getClosestRow } from "./interactions"
+
+describe("GPU line interactions", () => {
+ const data = [
+ [1000, 1],
+ [2000, 2],
+ [3000, 3],
+ ]
+
+ it("finds the nearest aligned row without scanning every point", () => {
+ expect(getClosestRow(data, 100)).toBe(0)
+ expect(getClosestRow(data, 1700)).toBe(1)
+ expect(getClosestRow(data, 2600)).toBe(2)
+ expect(getClosestRow(data, 4000)).toBe(2)
+ })
+
+ it("reports no row for empty data", () => {
+ expect(getClosestRow([], 1000)).toBe(-1)
+ })
+
+ it("does not start or commit a pan without crossing the drag threshold", () => {
+ const { sdk, chart } = makeTestChart()
+ const chartUI = makeChartUI(sdk, chart)
+ const canvas = document.createElement("canvas")
+ const events = []
+ const dateWindows = []
+ sdk.on("panStart", () => events.push("start"))
+ sdk.on("panEnd", () => events.push("end"))
+ sdk.on("highlightStart", () => events.push("highlight"))
+
+ const destroy = makeInteractions({
+ chart,
+ chartUI,
+ canvas,
+ getFrame: () => ({
+ afterMs: 1000,
+ beforeMs: 2000,
+ domain: [0, 10],
+ plot: { left: 0, top: 0, width: 100, height: 100 },
+ }),
+ setDateWindow: range => dateWindows.push(range),
+ clearDateWindow: () => events.push("clear"),
+ setSelectionRect: () => {},
+ })
+
+ canvas.dispatchEvent(new MouseEvent("mousedown", { button: 0, clientX: 50, clientY: 50 }))
+ window.dispatchEvent(new MouseEvent("mousemove", { clientX: 53, clientY: 50 }))
+ window.dispatchEvent(new MouseEvent("mouseup", { clientX: 53, clientY: 50 }))
+
+ expect(events).toEqual([])
+ expect(dateWindows).toEqual([])
+ expect(chart.getAttribute("after")).toBe(-900)
+ expect(chart.getAttribute("before")).toBe(0)
+ expect(chart.getAttribute("panning")).toBe(false)
+ destroy()
+ })
+
+ it("starts and commits a pan after crossing the drag threshold", () => {
+ const { sdk, chart } = makeTestChart()
+ const chartUI = makeChartUI(sdk, chart)
+ const canvas = document.createElement("canvas")
+ const events = []
+ const dateWindows = []
+ sdk.on("panStart", () => events.push("start"))
+ sdk.on("panEnd", () => events.push("end"))
+
+ const destroy = makeInteractions({
+ chart,
+ chartUI,
+ canvas,
+ getFrame: () => ({
+ afterMs: 1000,
+ beforeMs: 2000,
+ domain: [0, 10],
+ plot: { left: 0, top: 0, width: 100, height: 100 },
+ }),
+ setDateWindow: range => dateWindows.push(range),
+ clearDateWindow: () => events.push("clear"),
+ setSelectionRect: () => {},
+ })
+
+ canvas.dispatchEvent(new MouseEvent("mousedown", { button: 0, clientX: 50, clientY: 50 }))
+ window.dispatchEvent(new MouseEvent("mousemove", { clientX: 60, clientY: 50 }))
+ window.dispatchEvent(new MouseEvent("mouseup", { clientX: 60, clientY: 50 }))
+
+ expect(events).toEqual(["start", "end", "clear"])
+ expect(dateWindows).toEqual([[900, 1900]])
+ expect(chart.getAttribute("panning")).toBe(false)
+ destroy()
+ })
+
+ it("forwards native canvas hover events through chartUI", () => {
+ const { sdk, chart } = makeTestChart()
+ const chartUI = makeChartUI(sdk, chart)
+ const canvas = document.createElement("canvas")
+ const received = []
+ chartUI.on("mousemove", event => received.push(["mousemove", event]))
+ chartUI.on("mouseout", event => received.push(["mouseout", event]))
+
+ const destroy = makeInteractions({
+ chart,
+ chartUI,
+ canvas,
+ getFrame: () => null,
+ setDateWindow: () => {},
+ clearDateWindow: () => {},
+ setSelectionRect: () => {},
+ })
+ const move = new MouseEvent("mousemove", { clientX: 20, clientY: 30 })
+ const leave = new MouseEvent("mouseleave", { clientX: 40, clientY: 50 })
+
+ canvas.dispatchEvent(move)
+ canvas.dispatchEvent(leave)
+
+ expect(received).toEqual([
+ ["mousemove", move],
+ ["mouseout", leave],
+ ])
+
+ destroy()
+ canvas.dispatchEvent(move)
+ expect(received).toHaveLength(2)
+ })
+})
diff --git a/src/chartLibraries/gpu/visualizations/cartesian/line/makeFrameRenderer.js b/src/chartLibraries/gpu/visualizations/cartesian/line/makeFrameRenderer.js
new file mode 100644
index 000000000..546c7c25b
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/cartesian/line/makeFrameRenderer.js
@@ -0,0 +1,246 @@
+import { makeCrosshairRects } from "../interaction"
+import { makeOverlayRects } from "../overlays"
+import { makeDataDecorationRects } from "./decorations"
+import { shouldIncludeZero } from "./config"
+
+export default ({
+ chart,
+ chartUI,
+ packedData,
+ getResource,
+ getLocalDateWindow,
+ getSelectionRect,
+ forceIncludeZero,
+ makeSeriesStyle,
+ getPackedVisibleRange,
+ makeMarkers,
+ makeColors,
+ makeAxes,
+ getValueRangeOverride,
+ getAxisDimensionIds,
+ getYAxisNotificationRange,
+}) => {
+ let lastPacked = null
+ let lastFrame = null
+ let lastAxesKey = null
+ let lastAxes = null
+ let lastOverlayKey = null
+ let lastYAxisRange = null
+ let colors = null
+ let colorsDirty = true
+
+ const markColorsDirty = () => {
+ colorsDirty = true
+ }
+
+ const reset = () => {
+ lastPacked = null
+ lastFrame = null
+ lastAxesKey = null
+ lastAxes = null
+ lastOverlayKey = null
+ lastYAxisRange = null
+ colors = null
+ colorsDirty = true
+ }
+
+ const getValueRange = (packed, afterMs, beforeMs) => {
+ if (getValueRangeOverride)
+ return getValueRangeOverride({ chart, packed, afterMs, beforeMs })
+
+ const getRange = chart.getAttribute("getValueRange")
+ const range = typeof getRange === "function" ? getRange(chart) : null
+ let min = range?.[0] ?? chart.getAttribute("min")
+ let max = range?.[1] ?? chart.getAttribute("max")
+ const { staticValueRange, valueRange } = chart.getAttributes()
+ const autoRange =
+ !staticValueRange &&
+ (!valueRange || (valueRange[0] === null && valueRange[1] === null))
+
+ if (autoRange) {
+ const indexes = new Map(
+ chart.getPayloadDimensionIds().map((id, index) => [id, index])
+ )
+ const seriesIndexes = chart
+ .getVisibleDimensionIds()
+ .map(id => indexes.get(id))
+ .filter(index => index !== undefined)
+ const visibleRange = getPackedVisibleRange({
+ packed,
+ afterMs,
+ beforeMs,
+ seriesIndexes,
+ })
+ if (visibleRange) [min, max] = visibleRange
+ }
+ const includeZero = shouldIncludeZero({
+ includeZero: chart.getAttribute("includeZero"),
+ forceIncludeZero,
+ dimensionCount: chart.getPayloadDimensionIds().length,
+ selectedDimensionCount: chart.getAttribute("selectedLegendDimensions").length,
+ })
+ if (includeZero) {
+ min = Math.min(0, min)
+ max = Math.max(0, max)
+ }
+ return [min, max]
+ }
+
+ const getAxesKey = ({ width, height, dpr, min, max, afterMs, beforeMs }) => {
+ const attributes = chart.getAttributes()
+ const dimensionIds = getAxisDimensionIds(chart)
+ return JSON.stringify([
+ width,
+ height,
+ dpr,
+ min,
+ max,
+ afterMs,
+ beforeMs,
+ attributes.sparkline,
+ attributes.enabledXAxis,
+ attributes.enabledYAxis,
+ attributes.yAxisLabelWidth,
+ attributes.axisLabelFontSize,
+ attributes.theme,
+ attributes.timezone,
+ attributes.locale,
+ attributes.secondsAsTime,
+ attributes.desiredUnits,
+ attributes.staticFractionDigits,
+ attributes.unitsConversionMethod,
+ dimensionIds,
+ dimensionIds.map(id => chart.getDimensionUnit(id)),
+ ])
+ }
+
+ const notifyYAxisRange = ({ packed, min, max }) => {
+ const [notificationMin, notificationMax] = getYAxisNotificationRange({
+ chart,
+ packed,
+ min,
+ max,
+ })
+ if (
+ lastYAxisRange &&
+ lastYAxisRange[0] === notificationMin &&
+ lastYAxisRange[1] === notificationMax
+ )
+ return
+
+ lastYAxisRange = [notificationMin, notificationMax]
+ chart.trigger("yAxisChange", notificationMin, notificationMax)
+ }
+
+ const render = ({ width, height, dpr }) => {
+ const resource = getResource()
+ if (!resource || chart.getAttribute("processing")) return false
+
+ const packed = packedData.get()
+ if (!packed) {
+ resource.surface.draw([], { width, height, dpr })
+ return true
+ }
+
+ const dataChanged = packed !== lastPacked
+ if (dataChanged) colorsDirty = true
+ if (colorsDirty) colors = makeColors(chart)
+
+ const [afterMs, beforeMs] = getLocalDateWindow() || chart.getDateWindow()
+ const [min, max] = getValueRange(packed, afterMs, beforeMs)
+ const axesKey = getAxesKey({ width, height, dpr, min, max, afterMs, beforeMs })
+ const axesChanged = axesKey !== lastAxesKey
+ const axes = axesChanged
+ ? makeAxes({ chart, width, height, min, max, afterMs, beforeMs })
+ : lastAxes
+
+ notifyYAxisRange({ packed, min, max })
+ if (axesChanged)
+ resource.grid.update({ rects: axes.rects, width, height, dpr })
+ if (axesChanged || resource.text.needsUpdate())
+ resource.text.update({ labels: axes.labels, width, height, dpr })
+
+ lastFrame = { plot: axes.plot, domain: axes.domain, afterMs, beforeMs }
+ const overlayKey = JSON.stringify([
+ chart.getAttribute("overlays"),
+ chart.getAttribute("draftAnnotation"),
+ chart.getAttribute("showAnomalies"),
+ chart.getAttribute("showAnnotations"),
+ chart.getAttribute("selectedLegendDimensions"),
+ chart.getAttribute("theme"),
+ chart.getAttribute("outOfLimits"),
+ chart.getAttribute("error"),
+ chart.getFirstEntry(),
+ lastFrame,
+ ])
+ if (dataChanged || overlayKey !== lastOverlayKey)
+ resource.overlay.update({
+ rects: [
+ ...makeOverlayRects({ chart, chartUI, frame: lastFrame }),
+ ...makeDataDecorationRects({ chart, frame: lastFrame }),
+ ],
+ width,
+ height,
+ dpr,
+ })
+
+ resource.interaction.update({
+ rects: [
+ ...makeCrosshairRects(chart, lastFrame),
+ ...(getSelectionRect() ? [getSelectionRect()] : []),
+ ],
+ width,
+ height,
+ dpr,
+ })
+ if (dataChanged || axesChanged || colorsDirty)
+ resource.marker.update({
+ circles: makeMarkers({ chart, packed, frame: lastFrame }),
+ width,
+ height,
+ dpr,
+ plot: axes.plot,
+ })
+
+ resource.line.update({
+ packed,
+ colors,
+ dataChanged,
+ colorsChanged: colorsDirty,
+ afterMs,
+ beforeMs,
+ min: axes.domain[0],
+ max: axes.domain[1],
+ width,
+ height,
+ dpr,
+ plot: axes.plot,
+ ...makeSeriesStyle(chart, { packed, frame: lastFrame }),
+ })
+ resource.surface.draw(
+ [
+ resource.grid,
+ resource.overlay,
+ resource.line,
+ resource.marker,
+ resource.interaction,
+ resource.text,
+ ],
+ { width, height, dpr }
+ )
+
+ lastPacked = packed
+ lastAxesKey = axesKey
+ lastAxes = axes
+ lastOverlayKey = overlayKey
+ colorsDirty = false
+ return true
+ }
+
+ return {
+ getFrame: () => lastFrame,
+ markColorsDirty,
+ render,
+ reset,
+ }
+}
diff --git a/src/chartLibraries/gpu/visualizations/cartesian/line/range.js b/src/chartLibraries/gpu/visualizations/cartesian/line/range.js
new file mode 100644
index 000000000..b10fc06f5
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/cartesian/line/range.js
@@ -0,0 +1,103 @@
+import { getPointValue } from "@/sdk/makeChart/getPointValue"
+
+const lowerBound = (values, target) => {
+ let low = 0
+ let high = values.length
+ while (low < high) {
+ const middle = Math.floor((low + high) / 2)
+ if (values[middle] < target) low = middle + 1
+ else high = middle
+ }
+ return low
+}
+
+const upperBound = (values, target) => {
+ let low = 0
+ let high = values.length
+ while (low < high) {
+ const middle = Math.floor((low + high) / 2)
+ if (values[middle] <= target) low = middle + 1
+ else high = middle
+ }
+ return low - 1
+}
+
+export const ensureRangeIndex = (packed, seriesIndexes) => {
+ if (!packed.rangeMin || !packed.rangeMax) {
+ packed.rangeMin = new Float64Array(packed.seriesCount * packed.rangeBlockCount)
+ packed.rangeMax = new Float64Array(packed.seriesCount * packed.rangeBlockCount)
+ packed.rangeIndexedSeries = new Uint8Array(packed.seriesCount)
+ packed.rangeMin.fill(Infinity)
+ packed.rangeMax.fill(-Infinity)
+ packed.byteLength += packed.rangeMin.byteLength + packed.rangeMax.byteLength
+ }
+
+ seriesIndexes.forEach(seriesIndex => {
+ if (packed.rangeIndexedSeries[seriesIndex]) return
+ packed.rangeIndexedSeries[seriesIndex] = 1
+ const blockOffset = seriesIndex * packed.rangeBlockCount
+ for (let pointIndex = 0; pointIndex < packed.pointCount; pointIndex++) {
+ const value = getPointValue(
+ packed.sourceRows[pointIndex]?.[seriesIndex + 1],
+ packed.point
+ )
+ if (!Number.isFinite(value)) continue
+ const blockIndex = blockOffset + Math.floor(pointIndex / packed.rangeBlockSize)
+ packed.rangeMin[blockIndex] = Math.min(packed.rangeMin[blockIndex], value)
+ packed.rangeMax[blockIndex] = Math.max(packed.rangeMax[blockIndex], value)
+ }
+ })
+}
+
+export const getVisibleRange = ({ packed, afterMs, beforeMs, seriesIndexes }) => {
+ if (!packed?.pointCount || !seriesIndexes.length) return null
+ const after = (Math.min(afterMs, beforeMs) - packed.xOriginMs) / 1000
+ const before = (Math.max(afterMs, beforeMs) - packed.xOriginMs) / 1000
+ const first = lowerBound(packed.x, after)
+ const last = upperBound(packed.x, before)
+ if (first > last || first >= packed.pointCount || last < 0) return null
+
+ const start = Math.max(0, first)
+ const end = Math.min(packed.pointCount - 1, last)
+ if (
+ start === 0 &&
+ end === packed.pointCount - 1 &&
+ seriesIndexes.length === packed.seriesCount &&
+ Number.isFinite(packed.dataMin) &&
+ Number.isFinite(packed.dataMax)
+ )
+ return [packed.dataMin, packed.dataMax]
+
+ ensureRangeIndex(packed, seriesIndexes)
+ let min = Infinity
+ let max = -Infinity
+
+ seriesIndexes.forEach(seriesIndex => {
+ const firstBlock = Math.floor(start / packed.rangeBlockSize)
+ const lastBlock = Math.floor(end / packed.rangeBlockSize)
+ for (let block = firstBlock; block <= lastBlock; block++) {
+ const blockStart = block * packed.rangeBlockSize
+ const blockEnd = Math.min(packed.pointCount - 1, blockStart + packed.rangeBlockSize - 1)
+ if (blockStart >= start && blockEnd <= end) {
+ const blockIndex = seriesIndex * packed.rangeBlockCount + block
+ min = Math.min(min, packed.rangeMin[blockIndex])
+ max = Math.max(max, packed.rangeMax[blockIndex])
+ continue
+ }
+
+ const edgeStart = Math.max(start, blockStart)
+ const edgeEnd = Math.min(end, blockEnd)
+ for (let pointIndex = edgeStart; pointIndex <= edgeEnd; pointIndex++) {
+ const value = getPointValue(
+ packed.sourceRows[pointIndex]?.[seriesIndex + 1],
+ packed.point
+ )
+ if (!Number.isFinite(value)) continue
+ min = Math.min(min, value)
+ max = Math.max(max, value)
+ }
+ }
+ })
+
+ return Number.isFinite(min) && Number.isFinite(max) ? [min, max] : null
+}
diff --git a/src/chartLibraries/gpu/visualizations/cartesian/line/range.test.js b/src/chartLibraries/gpu/visualizations/cartesian/line/range.test.js
new file mode 100644
index 000000000..64b0dc921
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/cartesian/line/range.test.js
@@ -0,0 +1,72 @@
+import { packAlignedData } from "./data"
+import { getVisibleRange } from "./range"
+
+describe("WebGPU visible-window range index", () => {
+ const rows = Array.from({ length: 70 }, (_, index) => [
+ 1000 + index * 1000,
+ index,
+ 100 - index,
+ ])
+ let packed
+ beforeEach(() => {
+ packed = packAlignedData(rows, 2, undefined, [0, 100])
+ })
+
+ it("uses the exact global range without eagerly building the window index", () => {
+ const fullPacked = packAlignedData(rows, 2, undefined, [0, 100])
+
+ expect(
+ getVisibleRange({
+ packed: fullPacked,
+ afterMs: 1000,
+ beforeMs: 70000,
+ seriesIndexes: [0, 1],
+ })
+ ).toEqual([0, 100])
+ expect(fullPacked.rangeMin).toBeNull()
+ expect(fullPacked.rangeMax).toBeNull()
+ })
+
+ it("queries exact edge values and indexed full blocks", () => {
+ expect(
+ getVisibleRange({
+ packed,
+ afterMs: 11000,
+ beforeMs: 50000,
+ seriesIndexes: [0],
+ })
+ ).toEqual([10, 49])
+ expect([...packed.rangeIndexedSeries]).toEqual([1, 0])
+ })
+
+ it("combines only visible series", () => {
+ expect(
+ getVisibleRange({
+ packed,
+ afterMs: 11000,
+ beforeMs: 50000,
+ seriesIndexes: [1],
+ })
+ ).toEqual([51, 90])
+ expect([...packed.rangeIndexedSeries]).toEqual([0, 1])
+ expect(
+ getVisibleRange({
+ packed,
+ afterMs: 11000,
+ beforeMs: 50000,
+ seriesIndexes: [0, 1],
+ })
+ ).toEqual([10, 90])
+ })
+
+ it("returns no range outside packed data", () => {
+ expect(
+ getVisibleRange({
+ packed,
+ afterMs: 100000,
+ beforeMs: 110000,
+ seriesIndexes: [0],
+ })
+ ).toBeNull()
+ })
+})
diff --git a/src/chartLibraries/gpu/visualizations/cartesian/line/renderState.js b/src/chartLibraries/gpu/visualizations/cartesian/line/renderState.js
new file mode 100644
index 000000000..84d382fdc
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/cartesian/line/renderState.js
@@ -0,0 +1,107 @@
+import { makeCurveSegments, makeDrawLayout } from "./geometry"
+
+const normalizeRange = (minimum, maximum) => {
+ if (!Number.isFinite(minimum) || !Number.isFinite(maximum)) return [-1, 1]
+ if (minimum !== maximum) return [minimum, maximum]
+ const padding = Math.abs(minimum || 1) * 0.01
+ return [minimum - padding, maximum + padding]
+}
+
+export default ({
+ packed,
+ fillMode,
+ afterMs,
+ beforeMs,
+ minimum,
+ maximum,
+ width,
+ height,
+ dpr,
+ plot = { left: 0, top: 0, width, height },
+ fillAlpha = 0,
+ lineWidth,
+ barWidth = 0,
+ heatmapMaximum = 0,
+ stepped,
+ smooth,
+}) => {
+ const isMultiBar = fillMode === "multiBar"
+ const isHeatmap = fillMode === "heatmap"
+ const isBar = fillMode === "stackedBar" || isMultiBar || isHeatmap
+ const usesStackedData = fillMode === "stacked" || fillMode === "stackedBar"
+ const canvas = {
+ width: Math.max(1, Math.round(width * dpr)),
+ height: Math.max(1, Math.round(height * dpr)),
+ lineWidth: lineWidth * dpr,
+ mode: stepped ? 1 : smooth ? 2 : 0,
+ }
+ const physicalPlot = {
+ left: Math.max(0, Math.round(plot.left * dpr)),
+ top: Math.max(0, Math.round(plot.top * dpr)),
+ width: Math.max(1, Math.round(plot.width * dpr)),
+ height: Math.max(1, Math.round(plot.height * dpr)),
+ }
+ const drawLayout = isBar
+ ? {
+ instanceCount: packed.pointCount * packed.seriesCount,
+ fillInstanceCount: packed.pointCount * packed.seriesCount,
+ strokeInstanceCount: 0,
+ segmentsPerPair: 0,
+ segmentsPerSeries: 0,
+ }
+ : makeDrawLayout({
+ pointCount: packed.pointCount,
+ seriesCount: packed.seriesCount,
+ stepped,
+ smooth,
+ curveSegments: makeCurveSegments({
+ pointCount: packed.pointCount,
+ plotWidth: physicalPlot.width,
+ }),
+ filled: Boolean(fillMode && fillAlpha > 0),
+ stroke: lineWidth > 0,
+ })
+ const [rangeMinimum, rangeMaximum] = normalizeRange(minimum, maximum)
+
+ return {
+ canvas,
+ plot: physicalPlot,
+ domain: {
+ after: (afterMs - packed.xOriginMs) / 1000,
+ before: (beforeMs - packed.xOriginMs) / 1000,
+ minimum: (rangeMinimum - packed.yOrigin) / packed.yScale,
+ maximum: (rangeMaximum - packed.yOrigin) / packed.yScale,
+ },
+ fill: {
+ baseline: isBar
+ ? barWidth * dpr
+ : (0 - packed.yOrigin) / packed.yScale,
+ opacity: isMultiBar
+ ? (0 - packed.yOrigin) / packed.yScale
+ : fillAlpha,
+ mode: usesStackedData ? 1 : isMultiBar ? 2 : isHeatmap ? 3 : 0,
+ heatmapMaximum: isHeatmap ? heatmapMaximum : 0,
+ },
+ counts: {
+ points: packed.pointCount,
+ series: packed.seriesCount,
+ segmentsPerPair: drawLayout.segmentsPerPair,
+ segmentsPerSeries: drawLayout.segmentsPerSeries,
+ },
+ drawLayout,
+ ...drawLayout,
+ drawStats: {
+ pointCount: packed.pointCount,
+ seriesCount: packed.seriesCount,
+ sourcePairs: Math.max(0, packed.pointCount - 1) * packed.seriesCount,
+ barInstanceCount: isBar
+ ? packed.pointCount * packed.seriesCount
+ : 0,
+ barWidth: isBar ? barWidth : null,
+ valueRange: [minimum, maximum],
+ ...drawLayout,
+ },
+ flags: { isBar, isHeatmap, isMultiBar, usesStackedData },
+ fillPass: fillMode === "stacked" ? 3 : isBar ? 4 : 2,
+ }
+}
diff --git a/src/chartLibraries/gpu/visualizations/cartesian/line/renderState.test.js b/src/chartLibraries/gpu/visualizations/cartesian/line/renderState.test.js
new file mode 100644
index 000000000..9841a9a29
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/cartesian/line/renderState.test.js
@@ -0,0 +1,70 @@
+import makeRenderState from "./renderState"
+
+const packed = {
+ pointCount: 3,
+ seriesCount: 2,
+ xOriginMs: 1000,
+ yOrigin: 10,
+ yScale: 2,
+}
+
+const makeState = options =>
+ makeRenderState({
+ packed,
+ fillMode: null,
+ afterMs: 2000,
+ beforeMs: 4000,
+ minimum: 6,
+ maximum: 14,
+ width: 100,
+ height: 50,
+ dpr: 2,
+ lineWidth: 1,
+ stepped: false,
+ smooth: false,
+ ...options,
+ })
+
+describe("GPU Cartesian render state", () => {
+ it("normalizes semantic values into backend-neutral physical state", () => {
+ const state = makeState()
+
+ expect(state.canvas).toEqual({
+ width: 200,
+ height: 100,
+ lineWidth: 2,
+ mode: 0,
+ })
+ expect(state.domain).toEqual({
+ after: 1,
+ before: 3,
+ minimum: -2,
+ maximum: 2,
+ })
+ expect(state.drawStats.sourcePairs).toBe(4)
+ expect(state.flags.isBar).toBe(false)
+ })
+
+ it("builds exact bar counts and fill metadata", () => {
+ const state = makeState({
+ fillMode: "multiBar",
+ barWidth: 4,
+ heatmapMaximum: 9,
+ })
+
+ expect(state.drawLayout).toEqual({
+ instanceCount: 6,
+ fillInstanceCount: 6,
+ strokeInstanceCount: 0,
+ segmentsPerPair: 0,
+ segmentsPerSeries: 0,
+ })
+ expect(state.fill).toEqual({
+ baseline: 8,
+ opacity: -5,
+ mode: 2,
+ heatmapMaximum: 0,
+ })
+ expect(state.fillPass).toBe(4)
+ })
+})
diff --git a/src/chartLibraries/gpu/visualizations/cartesian/multiBar/colors.js b/src/chartLibraries/gpu/visualizations/cartesian/multiBar/colors.js
new file mode 100644
index 000000000..27132580c
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/cartesian/multiBar/colors.js
@@ -0,0 +1,33 @@
+import { darkenColor } from "@/chartLibraries/dygraph/plotters/helpers"
+import { parseColor } from "../line/colors"
+
+export const getVisibleSeriesIndexes = chart => {
+ const selected = chart.getAttribute("selectedLegendDimensions") || []
+ return chart.getPayloadDimensionIds().reduce((indexes, id, index) => {
+ if (!selected.length || chart.isDimensionVisible(id)) indexes.push(index)
+ return indexes
+ }, [])
+}
+
+export const makeMultiBarColors = chart => {
+ const dimensionIds = chart.getPayloadDimensionIds()
+ const visibleIndexes = getVisibleSeriesIndexes(chart)
+ const visibleRanks = new Map(visibleIndexes.map((index, rank) => [index, rank]))
+ const colors = new Float32Array(dimensionIds.length * 12)
+
+ dimensionIds.forEach((id, index) => {
+ const source = chart.selectDimensionColor(id)
+ const fill = parseColor(source)
+ const stroke = parseColor(darkenColor(source))
+ const rank = visibleRanks.get(index)
+ if (rank === undefined) {
+ fill[3] = 0
+ stroke[3] = 0
+ }
+ colors.set(fill, index * 12)
+ colors.set(stroke, index * 12 + 4)
+ colors.set([rank ?? -1, visibleIndexes.length, 0, 0], index * 12 + 8)
+ })
+
+ return colors
+}
diff --git a/src/chartLibraries/gpu/visualizations/cartesian/multiBar/index.js b/src/chartLibraries/gpu/visualizations/cartesian/multiBar/index.js
new file mode 100644
index 000000000..f9f348cc1
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/cartesian/multiBar/index.js
@@ -0,0 +1,115 @@
+import makeLineVisualization from "../line"
+import makeLineData, { makePointValueReader } from "../line/data"
+import { getVisibleSeriesIndexes, makeMultiBarColors } from "./colors"
+
+const lowerBound = (rows, target) => {
+ let low = 0
+ let high = rows.length
+ while (low < high) {
+ const middle = Math.floor((low + high) / 2)
+ if (rows[middle][0] < target) low = middle + 1
+ else high = middle
+ }
+ return low
+}
+
+const upperBound = (rows, target) => {
+ let low = 0
+ let high = rows.length
+ while (low < high) {
+ const middle = Math.floor((low + high) / 2)
+ if (rows[middle][0] <= target) low = middle + 1
+ else high = middle
+ }
+ return low - 1
+}
+
+export const getReducedWindowBounds = ({
+ rows,
+ point,
+ seriesIndex,
+ afterMs,
+ beforeMs,
+}) => {
+ if (!rows.length) return null
+ const low = Math.min(afterMs, beforeMs)
+ const high = Math.max(afterMs, beforeMs)
+ const readValue = makePointValueReader(point)
+ let first = lowerBound(rows, low)
+ let last = upperBound(rows, high)
+ if (first >= rows.length) first = 0
+ if (last < 0) last = rows.length - 1
+
+ let seek = true
+ while (seek && first > 0) {
+ first -= 1
+ seek = readValue(rows[first][seriesIndex + 1]) === null
+ }
+ seek = true
+ while (seek && last < rows.length - 1) {
+ last += 1
+ seek = readValue(rows[last][seriesIndex + 1]) === null
+ }
+
+ return { first, last }
+}
+
+export const getFirstReducedSeparation = ({
+ packed,
+ visibleSeriesIndexes,
+ afterMs,
+ beforeMs,
+ plotWidth,
+}) => {
+ const domainWidth = beforeMs - afterMs
+ if (!domainWidth) return null
+ let minimumSeparation = Infinity
+
+ visibleSeriesIndexes.forEach(seriesIndex => {
+ const bounds = getReducedWindowBounds({
+ rows: packed.sourceRows,
+ point: packed.point,
+ seriesIndex,
+ afterMs,
+ beforeMs,
+ })
+ if (!bounds || bounds.first + 1 > bounds.last) return
+ const separation =
+ ((packed.sourceRows[bounds.first + 1][0] - packed.sourceRows[bounds.first][0]) /
+ domainWidth) *
+ plotWidth
+ if (separation < minimumSeparation) minimumSeparation = separation
+ })
+
+ return Number.isFinite(minimumSeparation) ? minimumSeparation : null
+}
+
+export const getMultiBarGroupWidth = options => {
+ const minimumSeparation = getFirstReducedSeparation(options)
+ return minimumSeparation === null ? 0 : Math.floor((2 / 3) * minimumSeparation)
+}
+
+const makeMultiBarData = chart => makeLineData(chart, { trackGapEdges: false })
+
+export const makeMultiBarStyle = (chart, { packed, frame }) => ({
+ barWidth: getMultiBarGroupWidth({
+ packed,
+ visibleSeriesIndexes: getVisibleSeriesIndexes(chart),
+ afterMs: frame.afterMs,
+ beforeMs: frame.beforeMs,
+ plotWidth: frame.plot.width,
+ }),
+ fillAlpha: 1,
+ lineWidth: chart.isSparkline() ? 0 : 0.7,
+ smooth: false,
+ stepped: false,
+})
+
+export default options =>
+ makeLineVisualization({
+ ...options,
+ makeColors: makeMultiBarColors,
+ makeMarkers: () => [],
+ makePackedData: makeMultiBarData,
+ makeSeriesStyle: makeMultiBarStyle,
+ })
diff --git a/src/chartLibraries/gpu/visualizations/cartesian/multiBar/index.test.js b/src/chartLibraries/gpu/visualizations/cartesian/multiBar/index.test.js
new file mode 100644
index 000000000..09758a42e
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/cartesian/multiBar/index.test.js
@@ -0,0 +1,120 @@
+import { makeHeatmapPayload, makeTestChart } from "@jest/testUtilities"
+import {
+ getMultiBarGroupWidth,
+ getReducedWindowBounds,
+ makeMultiBarStyle,
+} from "."
+import { makeMultiBarColors } from "./colors"
+
+describe("GPU multi column visualization", () => {
+ const rows = [
+ [0, 1, 1],
+ [1000, 1, 1],
+ [2000, null, 1],
+ [3000, 1, 1],
+ [4000, 1, 1],
+ ]
+
+ it("matches Dygraphs reduced-window boundary expansion through nulls", () => {
+ expect(
+ getReducedWindowBounds({
+ rows,
+ point: null,
+ seriesIndex: 0,
+ afterMs: 2200,
+ beforeMs: 3200,
+ })
+ ).toEqual({ first: 1, last: 4 })
+ expect(
+ getReducedWindowBounds({
+ rows,
+ point: null,
+ seriesIndex: 1,
+ afterMs: 2200,
+ beforeMs: 3200,
+ })
+ ).toEqual({ first: 2, last: 4 })
+ })
+
+ it("uses only the first two reduced points for the historical group width", () => {
+ expect(
+ getMultiBarGroupWidth({
+ packed: {
+ sourceRows: [
+ [0, 1],
+ [1000, 1],
+ [3000, 1],
+ ],
+ point: null,
+ },
+ visibleSeriesIndexes: [0],
+ afterMs: 0,
+ beforeMs: 3000,
+ plotWidth: 90,
+ })
+ ).toBe(20)
+ expect(
+ getMultiBarGroupWidth({
+ packed: {
+ sourceRows: [
+ [0, 1],
+ [1000, 1],
+ ],
+ point: null,
+ },
+ visibleSeriesIndexes: [0],
+ afterMs: 0,
+ beforeMs: 2000000,
+ plotWidth: 800,
+ })
+ ).toBe(0)
+ })
+
+ it("reflows visible ranks and preserves legacy fill/stroke colors", async () => {
+ const { chart } = makeTestChart({
+ attributes: {
+ chartType: "multiBar",
+ colors: { first: "#ff0000", second: "#00ff00", third: "#0000ff" },
+ selectedLegendDimensions: ["first", "third"],
+ },
+ })
+ chart.doneFetch(makeHeatmapPayload(["first", "second", "third"], [[1, 2, 3]]))
+ await new Promise(resolve => setTimeout(resolve, 0))
+ const colors = Array.from(makeMultiBarColors(chart))
+
+ expect(colors.slice(8, 10)).toEqual([0, 2])
+ expect(colors.slice(20, 22)).toEqual([-1, 2])
+ expect(colors.slice(32, 34)).toEqual([1, 2])
+ expect(colors[15]).toBe(0)
+ })
+
+ it("preserves normal and sparkline bar styles", async () => {
+ const { chart } = makeTestChart({ attributes: { chartType: "multiBar" } })
+ chart.doneFetch(makeHeatmapPayload(["value"], [[1]]))
+ await new Promise(resolve => setTimeout(resolve, 0))
+ const state = {
+ packed: {
+ sourceRows: [
+ [0, 1],
+ [1000, 2],
+ ],
+ point: null,
+ },
+ frame: {
+ afterMs: 0,
+ beforeMs: 1000,
+ plot: { width: 90 },
+ },
+ }
+
+ expect(makeMultiBarStyle(chart, state)).toEqual({
+ barWidth: 60,
+ fillAlpha: 1,
+ lineWidth: 0.7,
+ smooth: false,
+ stepped: false,
+ })
+ chart.updateAttribute("sparkline", true)
+ expect(makeMultiBarStyle(chart, state).lineWidth).toBe(0)
+ })
+})
diff --git a/src/chartLibraries/gpu/visualizations/cartesian/overlays.js b/src/chartLibraries/gpu/visualizations/cartesian/overlays.js
new file mode 100644
index 000000000..c0a187569
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/cartesian/overlays.js
@@ -0,0 +1,196 @@
+import { makeVerticalDashRects } from "./interaction"
+
+const alarmLineColors = {
+ warning: "#F9A825",
+ critical: "#FF4136",
+ clear: "#00AB44",
+}
+
+const alarmBorderColors = {
+ warning: "#FFF8E1",
+ critical: "#FFEBEF",
+ clear: "#E5F5E8",
+}
+
+const alarmFillColors = {
+ warning: "rgba(255, 195, 0, 0.1)",
+ critical: "rgba(245, 155, 155, 0.1)",
+ clear: "rgba(104, 196, 125, 0.1)",
+}
+
+const transitionColors = {
+ WARNING: "rgba(255, 195, 0, 0.3)",
+ CRITICAL: "rgba(255, 65, 54, 0.3)",
+ CLEAR: "rgba(0, 171, 68, 0.3)",
+}
+
+const parseTimestamp = timestamp =>
+ typeof timestamp === "number" ? timestamp * 1000 : new Date(timestamp).getTime()
+
+const xPosition = (timestampMs, frame) =>
+ frame.plot.left +
+ ((timestampMs - frame.afterMs) / Math.max(frame.beforeMs - frame.afterMs, 1e-20)) *
+ frame.plot.width
+
+const getArea = (range, frame) => {
+ const rangeAfterMs = range[0] * 1000
+ const rangeBeforeMs = range[1] * 1000
+ if (rangeBeforeMs < frame.afterMs || rangeAfterMs > frame.beforeMs) return null
+
+ const from = xPosition(Math.max(frame.afterMs, rangeAfterMs), frame)
+ const to = xPosition(Math.min(frame.beforeMs, rangeBeforeMs), frame)
+ return { from, to, width: to - from }
+}
+
+const areasByChartUI = new WeakMap()
+const trigger = (chartUI, id, area) => {
+ if (!areasByChartUI.has(chartUI)) areasByChartUI.set(chartUI, new Map())
+ const areas = areasByChartUI.get(chartUI)
+ const key = area ? `${area.from}:${area.to}:${area.width}` : "none"
+ if (areas.get(id) === key) return
+ areas.set(id, key)
+ requestAnimationFrame(() => {
+ if (areas.get(id) === key && chartUI.getElement())
+ chartUI.trigger(`overlayedAreaChanged:${id}`, area)
+ })
+}
+
+const addVerticalLine = (rects, x, frame, color, dash = [4, 4], width = 1) => {
+ makeVerticalDashRects({ x, plot: frame.plot, color, dash }).forEach(rect =>
+ rects.push({ ...rect, width })
+ )
+}
+
+const addAlarm = ({ chartUI, overlay, id, frame, rects }) => {
+ const area = getArea([overlay.when, overlay.when], frame)
+ trigger(chartUI, id, area)
+ if (!area) return
+ addVerticalLine(rects, area.from - 1, frame, alarmLineColors[overlay.status], [4, 4], 2)
+}
+
+const addAlarmRange = ({ chartUI, overlay, id, frame, rects }) => {
+ const whenLast = overlay.whenLast ?? Math.floor(Date.now() / 1000)
+ const area = getArea([overlay.whenTriggered, whenLast], frame)
+ trigger(chartUI, id, area)
+ if (!area) return
+
+ rects.push({
+ x: area.from,
+ y: frame.plot.top,
+ width: area.width,
+ height: frame.plot.height,
+ color: alarmFillColors[overlay.status],
+ })
+ addVerticalLine(rects, area.from, frame, alarmBorderColors[overlay.status], [4, 4], 2)
+ addVerticalLine(rects, area.to - 2, frame, alarmLineColors[overlay.status], [4, 4], 2)
+}
+
+const addAnnotation = ({ chartUI, overlay, id, frame, rects, draft = false }) => {
+ if (!overlay?.timestamp) return
+ const timestampMs = overlay.timestamp * 1000
+ if (timestampMs < frame.afterMs || timestampMs > frame.beforeMs) {
+ trigger(chartUI, id)
+ return
+ }
+
+ const x = xPosition(timestampMs, frame)
+ const area = { from: x, to: x, width: 0 }
+ trigger(chartUI, id, area)
+ const synced = !!overlay.originallyFrom
+ const color = draft ? "#888888" : overlay.color || "#ff6b6b"
+ const alphaColor = synced && /^#[\da-f]{6}$/i.test(color) ? `${color}b3` : color
+ addVerticalLine(rects, x, frame, alphaColor, draft || synced ? [5, 5] : [100000, 0])
+ rects.push({
+ x: x - 2,
+ y: overlay.position === "bottom" ? frame.plot.top + frame.plot.height - 2 : frame.plot.top,
+ width: 4,
+ height: 2,
+ color: alphaColor,
+ })
+}
+
+const addTransitions = ({ chartUI, overlay, id, frame, rects }) => {
+ const transitions = [...(overlay.transitions || [])].sort(
+ (a, b) => parseTimestamp(a.timestamp) - parseTimestamp(b.timestamp)
+ )
+ transitions.forEach((transition, index) => {
+ const state = transition.to?.toUpperCase()
+ if (!transitionColors[state] || (overlay.showCleared === false && state === "CLEAR")) return
+ const startMs = parseTimestamp(transition.timestamp)
+ const endMs = transitions[index + 1]
+ ? parseTimestamp(transitions[index + 1].timestamp)
+ : frame.beforeMs
+ if (endMs < frame.afterMs || startMs > frame.beforeMs) return
+ const from = xPosition(Math.max(startMs, frame.afterMs), frame)
+ const to = xPosition(Math.min(endMs, frame.beforeMs), frame)
+ rects.push({
+ x: from,
+ y: frame.plot.top,
+ width: to - from,
+ height: frame.plot.height,
+ color: transitionColors[state],
+ })
+ })
+ trigger(chartUI, id)
+}
+
+const addHighlight = ({ chartUI, overlay, id, frame, rects }) => {
+ if (!overlay.range) return
+ const area = getArea(overlay.range, frame)
+ trigger(chartUI, id, area)
+ if (!area) return
+ rects.push({
+ x: area.from,
+ y: frame.plot.top,
+ width: area.width,
+ height: frame.plot.height,
+ color: "rgba(207, 213, 218, 0.12)",
+ })
+ addVerticalLine(rects, area.from, frame, "#CFD5DA", [2, 7])
+ addVerticalLine(rects, area.to, frame, "#CFD5DA", [2, 7])
+}
+
+const addPoint = ({ chart, overlay, frame, rects }) => {
+ const rowData = chart.getPayload().data[overlay.row]
+ if (!Array.isArray(rowData)) return
+ const x = xPosition(rowData[0], frame)
+ addVerticalLine(rects, x, frame, chart.getThemeAttribute("themeNetdata"), [2, 2])
+}
+
+const addProceeded = ({ chart, chartUI, id, frame }) => {
+ const beforeSecs = frame.beforeMs / 1000
+ const firstEntry = chart.getFirstEntry()
+ const { outOfLimits, error } = chart.getAttributes()
+ if (!outOfLimits && (!firstEntry || firstEntry > beforeSecs) && !error) return
+ const range = outOfLimits || error ? [beforeSecs, beforeSecs] : [firstEntry, firstEntry]
+ trigger(chartUI, id, getArea(range, frame))
+}
+
+const addByType = {
+ alarm: addAlarm,
+ alarmRange: addAlarmRange,
+ annotation: addAnnotation,
+ alertTransitions: addTransitions,
+ highlight: addHighlight,
+ point: addPoint,
+ proceeded: addProceeded,
+}
+
+export const makeOverlayRects = ({ chart, chartUI, frame }) => {
+ const rects = []
+ Object.entries(chart.getAttribute("overlays") || {}).forEach(([id, overlay]) => {
+ addByType[overlay.type]?.({ chart, chartUI, overlay, id, frame, rects })
+ })
+ const draft = chart.getAttribute("draftAnnotation")
+ if (draft)
+ addAnnotation({
+ chart,
+ chartUI,
+ overlay: draft,
+ id: "draftAnnotation",
+ frame,
+ rects,
+ draft: true,
+ })
+ return rects
+}
diff --git a/src/chartLibraries/gpu/visualizations/cartesian/overlays.test.js b/src/chartLibraries/gpu/visualizations/cartesian/overlays.test.js
new file mode 100644
index 000000000..0b5b44109
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/cartesian/overlays.test.js
@@ -0,0 +1,54 @@
+import { makeTestChart } from "@jest/testUtilities"
+import { makeOverlayRects } from "./overlays"
+
+const frame = {
+ afterMs: 1000,
+ beforeMs: 11000,
+ domain: [0, 1],
+ plot: { left: 10, top: 0, width: 100, height: 50 },
+}
+
+describe("WebGPU Cartesian overlays", () => {
+ it("builds highlight fill and dashed boundaries on the plot canvas", () => {
+ const { chart } = makeTestChart()
+ chart.updateAttribute("overlays", {
+ highlight: { type: "highlight", range: [3, 7] },
+ })
+
+ const rects = makeOverlayRects({ chart, chartUI: chart.getUI(), frame })
+ expect(rects[0]).toEqual({
+ x: 30,
+ y: 0,
+ width: 40,
+ height: 50,
+ color: "rgba(207, 213, 218, 0.12)",
+ })
+ expect(rects.some(rect => rect.x === 30 && rect.color === "#CFD5DA")).toBe(true)
+ expect(rects.some(rect => rect.x === 70 && rect.color === "#CFD5DA")).toBe(true)
+ })
+
+ it("respects cleared-transition visibility", () => {
+ const { chart } = makeTestChart()
+ chart.updateAttribute("overlays", {
+ transitions: {
+ type: "alertTransitions",
+ showCleared: false,
+ transitions: [
+ { timestamp: 2, to: "warning" },
+ { timestamp: 6, to: "clear" },
+ ],
+ },
+ })
+
+ const rects = makeOverlayRects({ chart, chartUI: chart.getUI(), frame })
+ expect(rects).toEqual([
+ {
+ x: 20,
+ y: 0,
+ width: 40,
+ height: 50,
+ color: "rgba(255, 195, 0, 0.3)",
+ },
+ ])
+ })
+})
diff --git a/src/chartLibraries/gpu/visualizations/cartesian/stacked/data.js b/src/chartLibraries/gpu/visualizations/cartesian/stacked/data.js
new file mode 100644
index 000000000..d134fe5a3
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/cartesian/stacked/data.js
@@ -0,0 +1,287 @@
+import {
+ RANGE_BLOCK_SIZE,
+ makePointValueReader,
+} from "@/chartLibraries/gpu/visualizations/cartesian/line/data"
+
+const lowerBound = (values, target) => {
+ let low = 0
+ let high = values.length
+ while (low < high) {
+ const middle = Math.floor((low + high) / 2)
+ if (values[middle] < target) low = middle + 1
+ else high = middle
+ }
+ return low
+}
+
+const upperBound = (values, target) => {
+ let low = 0
+ let high = values.length
+ while (low < high) {
+ const middle = Math.floor((low + high) / 2)
+ if (values[middle] <= target) low = middle + 1
+ else high = middle
+ }
+ return low - 1
+}
+
+const makeVisibility = (seriesCount, visibleSeriesIndexes) => {
+ const visibleSeries = new Uint8Array(seriesCount)
+ visibleSeriesIndexes.forEach(index => {
+ if (index >= 0 && index < seriesCount) visibleSeries[index] = 1
+ })
+ return visibleSeries
+}
+
+export const makeDivergingStackedBounds = (row, seriesCount, point, visibleSeries) => {
+ const bounds = Array(seriesCount).fill(null)
+ const readValue = makePointValueReader(point)
+ let positive = 0
+ let negative = 0
+
+ for (let seriesIndex = seriesCount - 1; seriesIndex >= 0; seriesIndex--) {
+ if (!visibleSeries[seriesIndex]) continue
+ const value = readValue(row?.[seriesIndex + 1])
+ if (!Number.isFinite(value)) continue
+ const base = value < 0 ? negative : positive
+ const end = base + value
+ if (value < 0) negative = end
+ else positive = end
+ bounds[seriesIndex] = { base, end }
+ }
+
+ return bounds
+}
+
+export const packDivergingStackedData = (
+ rows,
+ seriesCount,
+ point,
+ visibleSeriesIndexes,
+ { trackGapEdges = true } = {}
+) => {
+ const pointCount = rows.length
+ const totalValues = pointCount * seriesCount
+ const xOriginMs = pointCount ? rows[0][0] : 0
+ const x = new Float32Array(pointCount)
+ const baseRaw = new Float64Array(totalValues)
+ const visibleSeries = makeVisibility(seriesCount, visibleSeriesIndexes)
+ const rangeBlockCount = Math.ceil(pointCount / RANGE_BLOCK_SIZE)
+ const stackRangeMin = new Float64Array(rangeBlockCount)
+ const stackRangeMax = new Float64Array(rangeBlockCount)
+ stackRangeMin.fill(Infinity)
+ stackRangeMax.fill(-Infinity)
+ const gapEdgeIndexes = trackGapEdges
+ ? Array.from({ length: seriesCount }, () => [])
+ : []
+ const previousValid = trackGapEdges ? new Uint8Array(seriesCount) : null
+ const readValue = makePointValueReader(point)
+ let dataMin = Infinity
+ let dataMax = -Infinity
+ let storageMin = Infinity
+ let storageMax = -Infinity
+ let minXSeparationMs = Infinity
+
+ for (let pointIndex = 0; pointIndex < pointCount; pointIndex++) {
+ const row = rows[pointIndex]
+ x[pointIndex] = (row[0] - xOriginMs) / 1000
+ if (pointIndex > 0) {
+ const separation = row[0] - rows[pointIndex - 1][0]
+ if (separation < minXSeparationMs) minXSeparationMs = separation
+ }
+ let positive = 0
+ let negative = 0
+
+ const block = Math.floor(pointIndex / RANGE_BLOCK_SIZE)
+ for (let seriesIndex = seriesCount - 1; seriesIndex >= 0; seriesIndex--) {
+ const offset = pointIndex * seriesCount + seriesIndex
+ const value = readValue(row[seriesIndex + 1])
+ const valid = Boolean(visibleSeries[seriesIndex] && Number.isFinite(value))
+ if (trackGapEdges) {
+ if (pointIndex > 0) {
+ if (valid && !previousValid[seriesIndex])
+ gapEdgeIndexes[seriesIndex].push(pointIndex)
+ else if (!valid && previousValid[seriesIndex])
+ gapEdgeIndexes[seriesIndex].push(pointIndex - 1)
+ }
+ previousValid[seriesIndex] = valid ? 1 : 0
+ }
+ if (!valid) {
+ baseRaw[offset] = NaN
+ continue
+ }
+
+ const base = value < 0 ? negative : positive
+ const end = base + value
+ if (value < 0) negative = end
+ else positive = end
+ baseRaw[offset] = base
+ if (end < dataMin) dataMin = end
+ if (end > dataMax) dataMax = end
+ if (base < storageMin) storageMin = base
+ if (end < storageMin) storageMin = end
+ if (base > storageMax) storageMax = base
+ if (end > storageMax) storageMax = end
+ if (end < stackRangeMin[block]) stackRangeMin[block] = end
+ if (end > stackRangeMax[block]) stackRangeMax[block] = end
+ }
+ }
+
+ const yOrigin = Number.isFinite(storageMin) ? storageMin : 0
+ const yMax = Number.isFinite(storageMax) ? storageMax : 1
+ const yScale = yMax === yOrigin ? Math.abs(yOrigin || 1) : yMax - yOrigin
+ const base = new Float32Array(totalValues)
+ const y = new Float32Array(totalValues)
+ const inverseYScale = 1 / yScale
+
+ for (let pointIndex = 0; pointIndex < pointCount; pointIndex++) {
+ const row = rows[pointIndex]
+ for (let seriesIndex = 0; seriesIndex < seriesCount; seriesIndex++) {
+ const offset = pointIndex * seriesCount + seriesIndex
+ const rawBase = baseRaw[offset]
+ if (!Number.isFinite(rawBase)) {
+ base[offset] = NaN
+ y[offset] = NaN
+ continue
+ }
+ const value = readValue(row[seriesIndex + 1])
+ base[offset] = (rawBase - yOrigin) * inverseYScale
+ y[offset] = (rawBase + value - yOrigin) * inverseYScale
+ }
+ }
+
+ return {
+ sourceRows: rows,
+ point,
+ xOriginMs,
+ minXSeparationMs,
+ yOrigin,
+ yScale,
+ x,
+ y,
+ base,
+ pointCount,
+ seriesCount,
+ layout: "row-major",
+ visibleSeries,
+ rangeBlockSize: RANGE_BLOCK_SIZE,
+ rangeBlockCount,
+ stackRangeMin,
+ stackRangeMax,
+ dataMin,
+ dataMax,
+ gapEdgeIndexes,
+ byteLength:
+ x.byteLength +
+ y.byteLength +
+ base.byteLength +
+ visibleSeries.byteLength +
+ stackRangeMin.byteLength +
+ stackRangeMax.byteLength,
+ }
+}
+
+export const getVisibleStackedRange = ({ packed, afterMs, beforeMs }) => {
+ if (!packed?.pointCount) return null
+ const after = (Math.min(afterMs, beforeMs) - packed.xOriginMs) / 1000
+ const before = (Math.max(afterMs, beforeMs) - packed.xOriginMs) / 1000
+ const first = lowerBound(packed.x, after)
+ const last = upperBound(packed.x, before)
+ if (first > last || first >= packed.pointCount || last < 0) return null
+
+ const start = Math.max(0, first)
+ const end = Math.min(packed.pointCount - 1, last)
+ if (
+ start === 0 &&
+ end === packed.pointCount - 1 &&
+ Number.isFinite(packed.dataMin) &&
+ Number.isFinite(packed.dataMax)
+ )
+ return [packed.dataMin, packed.dataMax]
+
+ let min = Infinity
+ let max = -Infinity
+ const firstBlock = Math.floor(start / packed.rangeBlockSize)
+ const lastBlock = Math.floor(end / packed.rangeBlockSize)
+ for (let block = firstBlock; block <= lastBlock; block++) {
+ const blockStart = block * packed.rangeBlockSize
+ const blockEnd = Math.min(
+ packed.pointCount - 1,
+ blockStart + packed.rangeBlockSize - 1
+ )
+ if (blockStart >= start && blockEnd <= end) {
+ min = Math.min(min, packed.stackRangeMin[block])
+ max = Math.max(max, packed.stackRangeMax[block])
+ continue
+ }
+
+ const edgeStart = Math.max(start, blockStart)
+ const edgeEnd = Math.min(end, blockEnd)
+ for (let pointIndex = edgeStart; pointIndex <= edgeEnd; pointIndex++) {
+ makeDivergingStackedBounds(
+ packed.sourceRows[pointIndex],
+ packed.seriesCount,
+ packed.point,
+ packed.visibleSeries
+ ).forEach(bounds => {
+ if (!bounds) return
+ min = Math.min(min, bounds.end)
+ max = Math.max(max, bounds.end)
+ })
+ }
+ }
+
+ return Number.isFinite(min) && Number.isFinite(max) ? [min, max] : null
+}
+
+export default (chart, options) => {
+ let source = null
+ let dimensionKey = null
+ let pointSchema = null
+ let visibilityKey = null
+ let packed = null
+
+ const get = () => {
+ const { data, point } = chart.getPayload()
+ const dimensionIds = chart.getPayloadDimensionIds()
+ if (chart.getAttribute("outOfLimits") || !data?.length || !dimensionIds.length) return null
+
+ const selected = chart.getAttribute("selectedLegendDimensions") || []
+ const visibleSeriesIndexes = dimensionIds.reduce((indexes, id, index) => {
+ if (!selected.length || chart.isDimensionVisible(id)) indexes.push(index)
+ return indexes
+ }, [])
+ const nextDimensionKey = dimensionIds.join("\u0000")
+ const nextVisibilityKey = visibleSeriesIndexes.join("\u0000")
+ if (
+ source === data &&
+ dimensionKey === nextDimensionKey &&
+ pointSchema === point &&
+ visibilityKey === nextVisibilityKey
+ )
+ return packed
+
+ source = data
+ dimensionKey = nextDimensionKey
+ pointSchema = point
+ visibilityKey = nextVisibilityKey
+ packed = packDivergingStackedData(
+ data,
+ dimensionIds.length,
+ point,
+ visibleSeriesIndexes,
+ options
+ )
+ return packed
+ }
+
+ const clear = () => {
+ source = null
+ dimensionKey = null
+ pointSchema = null
+ visibilityKey = null
+ packed = null
+ }
+
+ return { get, clear }
+}
diff --git a/src/chartLibraries/gpu/visualizations/cartesian/stacked/data.test.js b/src/chartLibraries/gpu/visualizations/cartesian/stacked/data.test.js
new file mode 100644
index 000000000..4914ceb38
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/cartesian/stacked/data.test.js
@@ -0,0 +1,138 @@
+import { makeHeatmapPayload, makeTestChart } from "@jest/testUtilities"
+import makeStackedData, {
+ getVisibleStackedRange,
+ packDivergingStackedData,
+} from "./data"
+
+const readValue = (packed, values, seriesIndex, pointIndex) =>
+ packed.yOrigin +
+ values[pointIndex * packed.seriesCount + seriesIndex] * packed.yScale
+
+const expectClose = (actual, expected) =>
+ expected.forEach((value, index) => expect(actual[index]).toBeCloseTo(value, 6))
+
+describe("GPU diverging stacked data", () => {
+ it("stacks positive and negative values independently in reverse series order", () => {
+ const rows = [
+ [1000, 2, -1, 0.5],
+ [2000, -2, 1, -0.25],
+ ]
+ const packed = packDivergingStackedData(rows, 3, null, [0, 1, 2])
+
+ expectClose(
+ Array.from({ length: 6 }, (_, index) =>
+ readValue(packed, packed.base, Math.floor(index / 2), index % 2)
+ ),
+ [0.5, -0.25, 0, 0, 0, 0]
+ )
+ expectClose(
+ Array.from({ length: 6 }, (_, index) =>
+ readValue(packed, packed.y, Math.floor(index / 2), index % 2)
+ ),
+ [2.5, -2.25, -1, 1, 0.5, -0.25]
+ )
+ expect([packed.dataMin, packed.dataMax]).toEqual([-2.25, 2.5])
+ expect(rows).toEqual([
+ [1000, 2, -1, 0.5],
+ [2000, -2, 1, -0.25],
+ ])
+ })
+
+ it("rebases stacks when dimensions are hidden and preserves null gaps", () => {
+ const packed = packDivergingStackedData(
+ [
+ [1000, 2, null, 4],
+ [2000, 3, -1, null],
+ ],
+ 3,
+ null,
+ [0, 1]
+ )
+
+ expectClose(
+ [readValue(packed, packed.base, 0, 0), readValue(packed, packed.y, 0, 0)],
+ [0, 2]
+ )
+ expect(Number.isNaN(packed.base[1])).toBe(true)
+ expect(Number.isNaN(packed.y[1])).toBe(true)
+ expect(Number.isNaN(packed.base[2])).toBe(true)
+ expect(Number.isNaN(packed.y[2])).toBe(true)
+ expect(packed.gapEdgeIndexes[1]).toEqual([1])
+ })
+
+ it("skips gap-edge residency for bar adapters without changing nulls", () => {
+ const packed = packDivergingStackedData(
+ [
+ [1000, 1],
+ [2000, null],
+ ],
+ 1,
+ null,
+ [0],
+ { trackGapEdges: false }
+ )
+
+ expect(packed.gapEdgeIndexes).toEqual([])
+ expect(Number.isNaN(packed.y[1])).toBe(true)
+ })
+
+ it("keeps compact point-schema values exact", () => {
+ const point = { value: 1 }
+ const packed = packDivergingStackedData(
+ [
+ [1000, [99, 2], [99, 4]],
+ [2000, [99, -3], [99, -5]],
+ ],
+ 2,
+ point,
+ [0, 1]
+ )
+
+ expectClose(
+ [
+ readValue(packed, packed.base, 0, 0),
+ readValue(packed, packed.y, 0, 0),
+ readValue(packed, packed.base, 0, 1),
+ readValue(packed, packed.y, 0, 1),
+ ],
+ [4, 6, -5, -8]
+ )
+ })
+
+ it("rebuilds stack residency when visible dimensions change", async () => {
+ const { chart } = makeTestChart({
+ attributes: { chartType: "stacked", groupBy: [], selectedLegendDimensions: [] },
+ })
+ const payload = makeHeatmapPayload(["top", "middle", "bottom"], [[2, 3, 4]])
+ payload.view.chart_type = "stacked"
+ payload.view.dimensions.grouped_by = []
+ chart.doneFetch(payload)
+ await new Promise(resolve => setTimeout(resolve, 0))
+ const data = makeStackedData(chart)
+ const allVisible = data.get()
+
+ expect(readValue(allVisible, allVisible.base, 0, 0)).toBeCloseTo(7)
+ chart.updateAttribute("selectedLegendDimensions", ["top"])
+ const topOnly = data.get()
+
+ expect(topOnly).not.toBe(allVisible)
+ expect(readValue(topOnly, topOnly.base, 0, 0)).toBeCloseTo(0)
+ expect(readValue(topOnly, topOnly.y, 0, 0)).toBeCloseTo(2)
+ })
+
+ it("queries exact full and partial visible-window stack ranges", () => {
+ const rows = Array.from({ length: 70 }, (_, index) => [
+ 1000 + index * 1000,
+ index === 32 ? 100 : 2,
+ index === 33 ? -80 : -1,
+ ])
+ const packed = packDivergingStackedData(rows, 2, null, [0, 1])
+
+ expect(
+ getVisibleStackedRange({ packed, afterMs: 1000, beforeMs: 70000 })
+ ).toEqual([-80, 100])
+ expect(
+ getVisibleStackedRange({ packed, afterMs: 35000, beforeMs: 40000 })
+ ).toEqual([-1, 2])
+ })
+})
diff --git a/src/chartLibraries/gpu/visualizations/cartesian/stacked/index.js b/src/chartLibraries/gpu/visualizations/cartesian/stacked/index.js
new file mode 100644
index 000000000..ad4a3d275
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/cartesian/stacked/index.js
@@ -0,0 +1,23 @@
+import makeLineVisualization from "../line"
+import makeStackedData, { getVisibleStackedRange } from "./data"
+import { findClosestStackedDimension } from "./interaction"
+
+export const makeStackedStyle = chart => {
+ const sparkline = chart.isSparkline()
+ return {
+ fillAlpha: sparkline ? 1 : 0.8,
+ lineWidth: sparkline ? 0 : 0.1,
+ smooth: false,
+ stepped: chart.getAttribute("stepPlot"),
+ }
+}
+
+export default options =>
+ makeLineVisualization({
+ ...options,
+ findDimension: findClosestStackedDimension,
+ forceIncludeZero: true,
+ getPackedVisibleRange: getVisibleStackedRange,
+ makePackedData: makeStackedData,
+ makeSeriesStyle: makeStackedStyle,
+ })
diff --git a/src/chartLibraries/gpu/visualizations/cartesian/stacked/index.test.js b/src/chartLibraries/gpu/visualizations/cartesian/stacked/index.test.js
new file mode 100644
index 000000000..67f715630
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/cartesian/stacked/index.test.js
@@ -0,0 +1,39 @@
+import { makeTestChart } from "../../../../../../jest/testUtilities"
+import { makeStackedStyle } from "."
+
+describe("GPU stacked visualization", () => {
+ it("uses Dygraphs stacked fill and stroke semantics", () => {
+ const { chart } = makeTestChart({
+ attributes: { chartType: "stacked", sparkline: false, stepPlot: false },
+ })
+
+ expect(makeStackedStyle(chart)).toEqual({
+ fillAlpha: 0.8,
+ lineWidth: 0.1,
+ smooth: false,
+ stepped: false,
+ })
+
+ chart.updateAttribute("stepPlot", true)
+
+ expect(makeStackedStyle(chart)).toEqual({
+ fillAlpha: 0.8,
+ lineWidth: 0.1,
+ smooth: false,
+ stepped: true,
+ })
+ })
+
+ it("renders stacked sparklines as opaque fills without strokes", () => {
+ const { chart } = makeTestChart({
+ attributes: { chartType: "stacked", sparkline: true },
+ })
+
+ expect(makeStackedStyle(chart)).toEqual({
+ fillAlpha: 1,
+ lineWidth: 0,
+ smooth: false,
+ stepped: false,
+ })
+ })
+})
diff --git a/src/chartLibraries/gpu/visualizations/cartesian/stacked/interaction.js b/src/chartLibraries/gpu/visualizations/cartesian/stacked/interaction.js
new file mode 100644
index 000000000..88bca2777
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/cartesian/stacked/interaction.js
@@ -0,0 +1,34 @@
+import { valueToY } from "../line/interactions"
+import { makeDivergingStackedBounds } from "./data"
+
+export const findClosestStackedDimension = ({ chart, row, y, domain, plot }) => {
+ const dimensionIds = chart.getPayloadDimensionIds()
+ const selected = chart.getAttribute("selectedLegendDimensions") || []
+ const visibleSeries = new Uint8Array(dimensionIds.length)
+ dimensionIds.forEach((id, index) => {
+ if (!selected.length || chart.isDimensionVisible(id)) visibleSeries[index] = 1
+ })
+ const payload = chart.getPayload()
+ const boundsBySeries = makeDivergingStackedBounds(
+ payload.data[row],
+ dimensionIds.length,
+ payload.point,
+ visibleSeries
+ )
+ let dimensionId = null
+ let closestDistance = Infinity
+
+ boundsBySeries.forEach((bounds, index) => {
+ if (!bounds) return
+ const baseY = valueToY(bounds.base, domain, plot)
+ const endY = valueToY(bounds.end, domain, plot)
+ const top = Math.min(baseY, endY)
+ const bottom = Math.max(baseY, endY)
+ const distance = y < top ? top - y : y > bottom ? y - bottom : 0
+ if (distance >= closestDistance) return
+ closestDistance = distance
+ dimensionId = dimensionIds[index]
+ })
+
+ return dimensionId
+}
diff --git a/src/chartLibraries/gpu/visualizations/cartesian/stacked/interaction.test.js b/src/chartLibraries/gpu/visualizations/cartesian/stacked/interaction.test.js
new file mode 100644
index 000000000..35932a23c
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/cartesian/stacked/interaction.test.js
@@ -0,0 +1,33 @@
+import { makeHeatmapPayload, makeTestChart } from "@jest/testUtilities"
+import { findClosestStackedDimension } from "./interaction"
+
+const load = async chart => {
+ const payload = makeHeatmapPayload(
+ ["positive", "negative", "crossing"],
+ [[2, -1, 0.5]]
+ )
+ payload.view.chart_type = "stacked"
+ payload.view.dimensions.grouped_by = []
+ chart.doneFetch(payload)
+ await new Promise(resolve => setTimeout(resolve, 0))
+}
+
+describe("GPU diverging stacked interaction", () => {
+ it("selects the signed band under the pointer", async () => {
+ const { chart } = makeTestChart({
+ attributes: { chartType: "stacked", groupBy: [] },
+ })
+ await load(chart)
+ const frame = { domain: [-3, 3], plot: { left: 0, top: 0, width: 100, height: 120 } }
+
+ expect(findClosestStackedDimension({ chart, row: 0, y: 70, ...frame })).toBe(
+ "negative"
+ )
+ expect(findClosestStackedDimension({ chart, row: 0, y: 55, ...frame })).toBe(
+ "crossing"
+ )
+ expect(findClosestStackedDimension({ chart, row: 0, y: 40, ...frame })).toBe(
+ "positive"
+ )
+ })
+})
diff --git a/src/chartLibraries/gpu/visualizations/cartesian/stackedBar/colors.js b/src/chartLibraries/gpu/visualizations/cartesian/stackedBar/colors.js
new file mode 100644
index 000000000..a2382575d
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/cartesian/stackedBar/colors.js
@@ -0,0 +1,20 @@
+import { darkenColor } from "@/chartLibraries/dygraph/plotters/helpers"
+import { parseColor } from "../line/colors"
+
+export const makeStackedBarColors = chart => {
+ const colors = new Float32Array(chart.getPayloadDimensionIds().length * 8)
+
+ chart.getPayloadDimensionIds().forEach((id, index) => {
+ const source = chart.selectDimensionColor(id)
+ const fill = parseColor(source)
+ const stroke = parseColor(darkenColor(source))
+ if (!chart.isDimensionVisible(id)) {
+ fill[3] = 0
+ stroke[3] = 0
+ }
+ colors.set(fill, index * 8)
+ colors.set(stroke, index * 8 + 4)
+ })
+
+ return colors
+}
diff --git a/src/chartLibraries/gpu/visualizations/cartesian/stackedBar/index.js b/src/chartLibraries/gpu/visualizations/cartesian/stackedBar/index.js
new file mode 100644
index 000000000..bc6a4473c
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/cartesian/stackedBar/index.js
@@ -0,0 +1,41 @@
+import makeLineVisualization from "../line"
+import makeStackedData, { getVisibleStackedRange } from "../stacked/data"
+import { findClosestStackedDimension } from "../stacked/interaction"
+import { makeStackedBarColors } from "./colors"
+
+const makeStackedBarData = chart => makeStackedData(chart, { trackGapEdges: false })
+
+export const getStackedBarWidth = ({ packed, afterMs, beforeMs, plotWidth }) => {
+ const domainWidth = beforeMs - afterMs
+ const separation =
+ Number.isFinite(packed.minXSeparationMs) && domainWidth
+ ? (packed.minXSeparationMs / domainWidth) * plotWidth
+ : plotWidth / Math.max(packed.pointCount, 1)
+
+ return Math.max(1, Math.floor((2 / 3) * separation))
+}
+
+export const makeStackedBarStyle = (chart, { packed, frame }) => ({
+ barWidth: getStackedBarWidth({
+ packed,
+ afterMs: frame.afterMs,
+ beforeMs: frame.beforeMs,
+ plotWidth: frame.plot.width,
+ }),
+ fillAlpha: 1,
+ lineWidth: chart.isSparkline() ? 0 : 0.7,
+ smooth: false,
+ stepped: false,
+})
+
+export default options =>
+ makeLineVisualization({
+ ...options,
+ findDimension: findClosestStackedDimension,
+ forceIncludeZero: true,
+ getPackedVisibleRange: getVisibleStackedRange,
+ makeColors: makeStackedBarColors,
+ makeMarkers: () => [],
+ makePackedData: makeStackedBarData,
+ makeSeriesStyle: makeStackedBarStyle,
+ })
diff --git a/src/chartLibraries/gpu/visualizations/cartesian/stackedBar/index.test.js b/src/chartLibraries/gpu/visualizations/cartesian/stackedBar/index.test.js
new file mode 100644
index 000000000..733002303
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/cartesian/stackedBar/index.test.js
@@ -0,0 +1,65 @@
+import { makeHeatmapPayload, makeTestChart } from "@jest/testUtilities"
+import { getStackedBarWidth, makeStackedBarStyle } from "."
+import { makeStackedBarColors } from "./colors"
+
+describe("GPU stacked bar visualization", () => {
+ it("matches the legacy minimum-separation width", () => {
+ expect(
+ getStackedBarWidth({
+ packed: { minXSeparationMs: 1000, pointCount: 4 },
+ afterMs: 0,
+ beforeMs: 4000,
+ plotWidth: 120,
+ })
+ ).toBe(20)
+ })
+
+ it("uses the legacy single-point width fallback and one-pixel minimum", () => {
+ expect(
+ getStackedBarWidth({
+ packed: { minXSeparationMs: Infinity, pointCount: 1 },
+ afterMs: 0,
+ beforeMs: 4000,
+ plotWidth: 120,
+ })
+ ).toBe(80)
+ expect(
+ getStackedBarWidth({
+ packed: { minXSeparationMs: 1, pointCount: 4 },
+ afterMs: 0,
+ beforeMs: 4000,
+ plotWidth: 120,
+ })
+ ).toBe(1)
+ })
+
+ it("preserves legacy border parsing for configured colors", async () => {
+ const { chart } = makeTestChart({ attributes: { colors: { value: "red" } } })
+ chart.doneFetch(makeHeatmapPayload(["value"], [[1]]))
+ await new Promise(resolve => setTimeout(resolve, 0))
+
+ const colors = Array.from(makeStackedBarColors(chart))
+ expect(colors.slice(0, 4)).toEqual([1, 0, 0, 1])
+ colors.slice(4, 7).forEach(channel => expect(channel).toBeCloseTo(127 / 255))
+ expect(colors[7]).toBe(1)
+ })
+
+ it("preserves the normal and sparkline stroke widths", () => {
+ const { chart } = makeTestChart({ attributes: { chartType: "stackedBar" } })
+ const state = {
+ packed: { minXSeparationMs: 1000, pointCount: 4 },
+ frame: { afterMs: 0, beforeMs: 4000, plot: { width: 120 } },
+ }
+
+ expect(makeStackedBarStyle(chart, state)).toEqual({
+ barWidth: 20,
+ fillAlpha: 1,
+ lineWidth: 0.7,
+ smooth: false,
+ stepped: false,
+ })
+
+ chart.updateAttribute("sparkline", true)
+ expect(makeStackedBarStyle(chart, state).lineWidth).toBe(0)
+ })
+})
diff --git a/src/chartLibraries/gpu/visualizations/makeRegistry.js b/src/chartLibraries/gpu/visualizations/makeRegistry.js
new file mode 100644
index 000000000..b376f3cea
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/makeRegistry.js
@@ -0,0 +1,40 @@
+import makeAreaVisualization from "./cartesian/area"
+import makeHeatmapVisualization from "./cartesian/heatmap"
+import makeLineVisualization from "./cartesian/line"
+import makeMultiBarVisualization from "./cartesian/multiBar"
+import makeStackedVisualization from "./cartesian/stacked"
+import makeStackedBarVisualization from "./cartesian/stackedBar"
+import makeD3PieVisualization from "./radial/d3Pie"
+import makeEasyPieVisualization from "./radial/easyPie"
+import makeGaugeVisualization from "./radial/gauge"
+
+const models = {
+ area: makeAreaVisualization,
+ d3pie: makeD3PieVisualization,
+ easypiechart: makeEasyPieVisualization,
+ gauge: makeGaugeVisualization,
+ heatmap: makeHeatmapVisualization,
+ line: makeLineVisualization,
+ multiBar: makeMultiBarVisualization,
+ stacked: makeStackedVisualization,
+ stackedBar: makeStackedBarVisualization,
+}
+
+export default resources => {
+ const visualizations = Object.fromEntries(
+ Object.entries(resources).map(([id, makeResources]) => {
+ const makeVisualization = models[id]
+ if (!makeVisualization)
+ throw new Error(`Unknown GPU visualization model: ${id}`)
+ return [
+ id,
+ options => makeVisualization({ ...options, makeResources }),
+ ]
+ })
+ )
+
+ return {
+ get: visualization => visualizations[visualization],
+ has: visualization => visualization in visualizations,
+ }
+}
diff --git a/src/chartLibraries/gpu/visualizations/radial/d3Pie/index.js b/src/chartLibraries/gpu/visualizations/radial/d3Pie/index.js
new file mode 100644
index 000000000..61af00c98
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/radial/d3Pie/index.js
@@ -0,0 +1,274 @@
+import { parseColor } from "@/chartLibraries/gpu/color"
+import d3pie from "@/chartLibraries/d3pie/library"
+import getInitialOptions from "@/chartLibraries/d3pie/getInitialOptions"
+import {
+ groupD3PieContent,
+ makeD3PieContent,
+} from "@/chartLibraries/d3pie/data"
+import { unregister } from "@/helpers/makeListeners"
+
+const TAU = Math.PI * 2
+
+const shadeColor = (hex, luminosity) => {
+ const normalized = String(hex).replace(/[^0-9a-f]/gi, "")
+ const value = normalized.length < 6
+ ? normalized
+ .split("")
+ .map(character => character.repeat(2))
+ .join("")
+ : normalized
+ return `#${[0, 1, 2]
+ .map(index => {
+ const color = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16)
+ const adjusted = Math.round(Math.min(Math.max(0, color + color * luminosity), 255))
+ return adjusted.toString(16).padStart(2, "0")
+ })
+ .join("")}`
+}
+
+const getPathOffset = (path, startAngle, dpr) => {
+ const transform = path?.transform?.baseVal?.consolidate?.()
+ if (!transform) return [0, 0]
+ const { e, f } = transform.matrix
+ const cosine = Math.cos(startAngle)
+ const sine = Math.sin(startAngle)
+ return [(e * cosine - f * sine) * dpr, (e * sine + f * cosine) * dpr]
+}
+
+export const makeD3PieFrame = (pie, { width, height, dpr }, hoveredIndex = -1) => {
+ const content = pie.options.data.content
+ const total = content.reduce((sum, { value }) => sum + value, 0)
+ let startAngle = 0
+ const paths = pie.element.querySelectorAll("path[data-index]")
+ const segments = content.map(({ value }, index) => {
+ const endAngle = startAngle + (value / total) * TAU
+ const color =
+ index === hoveredIndex
+ ? shadeColor(pie.options.colors[index], pie.options.effects.highlightLuminosity)
+ : pie.options.colors[index]
+ const [offsetX, offsetY] = getPathOffset(paths[index], startAngle, dpr)
+ const segment = {
+ startAngle,
+ endAngle,
+ offsetX,
+ offsetY,
+ color: parseColor(color),
+ }
+ startAngle = endAngle
+ return segment
+ })
+
+ return {
+ width,
+ height,
+ dpr,
+ centerX: pie.pieCenter.x * dpr,
+ centerY: pie.pieCenter.y * dpr,
+ innerRadius: pie.innerRadius * dpr,
+ outerRadius: pie.outerRadius * dpr,
+ strokeWidth: dpr,
+ strokeColor: parseColor(pie.options.misc.colors.segmentStroke),
+ segments,
+ }
+}
+
+const makeOverlay = canvas => {
+ const container = canvas.parentNode
+ const previousPosition = container.style.position
+ const ownsPosition = getComputedStyle(container).position === "static"
+ if (ownsPosition) container.style.position = "relative"
+ const overlay = document.createElement("div")
+ overlay.dataset.rendererOverlay = "d3pie"
+ Object.assign(overlay.style, {
+ position: "absolute",
+ inset: "0",
+ width: "100%",
+ height: "100%",
+ pointerEvents: "none",
+ })
+ Object.assign(canvas.style, {
+ position: "absolute",
+ inset: "0",
+ })
+ container.appendChild(overlay)
+ return {
+ overlay,
+ restorePosition: () => {
+ if (ownsPosition) container.style.position = previousPosition
+ },
+ }
+}
+
+const getSegmentIndex = (overlay, target) => {
+ if (!(target instanceof Element)) return -1
+ const indexed = target.closest("[data-index]")
+ if (!indexed || !overlay.contains(indexed)) return -1
+ const index = Number.parseInt(indexed.getAttribute("data-index"), 10)
+ return Number.isNaN(index) ? -1 : index
+}
+
+export default ({ chart, chartUI, makeResources }) => {
+ let resource = null
+ let overlay = null
+ let restoreOverlayPosition = null
+ let pie = null
+ let listeners = null
+ let hoveredIndex = -1
+ let currentFrame = null
+ let drawStats = null
+ let prevMin
+ let prevMax
+ let interactionFrame = null
+
+ const drawSegments = () => {
+ if (!resource || !pie || !currentFrame) return false
+ const frame = makeD3PieFrame(pie, currentFrame, hoveredIndex)
+ resource.layer.update(frame)
+ resource.surface.draw([resource.layer], currentFrame)
+ drawStats = {
+ segmentCount: frame.segments.length,
+ centerX: frame.centerX,
+ centerY: frame.centerY,
+ width: frame.width,
+ height: frame.height,
+ dpr: frame.dpr,
+ innerRadius: frame.innerRadius,
+ outerRadius: frame.outerRadius,
+ firstSegment: frame.segments[0],
+ strokeColor: frame.strokeColor,
+ hoveredIndex,
+ expandedOffsetPixels: Math.max(
+ 0,
+ ...frame.segments.map(({ offsetX, offsetY }) => Math.hypot(offsetX, offsetY))
+ ),
+ }
+ return true
+ }
+
+ const stopInteractionAnimation = () => {
+ if (interactionFrame !== null) cancelAnimationFrame(interactionFrame)
+ interactionFrame = null
+ }
+
+ const animateTransforms = () => {
+ stopInteractionAnimation()
+ const end = performance.now() + 450
+ const tick = () => {
+ interactionFrame = null
+ drawSegments()
+ if (performance.now() < end) interactionFrame = requestAnimationFrame(tick)
+ }
+ interactionFrame = requestAnimationFrame(tick)
+ }
+
+ const onMouseOver = event => {
+ const index = getSegmentIndex(overlay, event.target)
+ if (index === -1 || index === hoveredIndex) return
+ hoveredIndex = index
+ drawSegments()
+ }
+
+ const onMouseOut = event => {
+ const index = getSegmentIndex(overlay, event.target)
+ if (index === -1 || index !== hoveredIndex) return
+ if (getSegmentIndex(overlay, event.relatedTarget) === index) return
+ hoveredIndex = -1
+ drawSegments()
+ }
+
+ const hideSegmentPaths = () => {
+ overlay.querySelectorAll("path[data-index]").forEach(path => {
+ path.style.opacity = "0"
+ path.style.pointerEvents = "all"
+ })
+ overlay.querySelectorAll("[data-index]:not(path)").forEach(label => {
+ label.style.pointerEvents = "auto"
+ })
+ }
+
+ const mount = ({ render, canvas }) => {
+ const overlayState = makeOverlay(canvas)
+ overlay = overlayState.overlay
+ restoreOverlayPosition = overlayState.restorePosition
+ overlay.addEventListener("mouseover", onMouseOver)
+ overlay.addEventListener("mouseout", onMouseOut)
+ overlay.addEventListener("click", animateTransforms)
+ const { loaded } = chart.getAttributes()
+ listeners = unregister(
+ chart.onAttributeChange("hoverX", render),
+ !loaded && chart.onceAttributeChange("loaded", render),
+ chart.onAttributeChange("theme", render),
+ chart.on("visibleDimensionsChanged", render)
+ )
+ }
+
+ const unmount = () => {
+ stopInteractionAnimation()
+ listeners?.()
+ listeners = null
+ overlay?.removeEventListener("mouseover", onMouseOver)
+ overlay?.removeEventListener("mouseout", onMouseOut)
+ overlay?.removeEventListener("click", animateTransforms)
+ pie?.destroy()
+ pie = null
+ overlay?.remove()
+ overlay = null
+ restoreOverlayPosition?.()
+ restoreOverlayPosition = null
+ resource?.destroy()
+ resource = null
+ hoveredIndex = -1
+ currentFrame = null
+ drawStats = null
+ prevMin = null
+ prevMax = null
+ }
+
+ const render = frame => {
+ if (!resource || !overlay || !chart.getAttribute("loaded")) return false
+ currentFrame = frame
+ hoveredIndex = -1
+ stopInteractionAnimation()
+
+ const content = groupD3PieContent(
+ makeD3PieContent(chart, chartUI),
+ chart.getThemeAttribute("themeD3pieSmallColor")
+ )
+ const options = getInitialOptions(
+ { chart, getElement: () => overlay },
+ {
+ content,
+ sortOrder: "none",
+ smallSegmentGrouping: { enabled: false },
+ }
+ )
+ pie?.destroy()
+ pie = new d3pie(overlay, options)
+ hideSegmentPaths()
+
+ const [min, max] = chart.getAttribute("getValueRange")(chart)
+ if (min !== prevMin || max !== prevMax) chart.trigger("yAxisChange", min, max)
+ prevMin = min
+ prevMax = max
+
+ drawSegments()
+ chartUI.render()
+ chartUI.trigger("rendered")
+ return true
+ }
+
+ return {
+ mount,
+ unmount,
+ render,
+ createResources: (runtime, canvas) => makeResources(runtime, canvas),
+ attachResources: nextResource => {
+ resource?.destroy()
+ resource = nextResource
+ },
+ getBufferBytes: () => resource?.layer.getBufferBytes() || 0,
+ getDrawStats: () => drawStats,
+ getQueueDone: () => resource?.surface.getQueueDone?.() || Promise.resolve(),
+ getMinMax: () => chart.getAttribute("getValueRange")(chart),
+ }
+}
diff --git a/src/chartLibraries/gpu/visualizations/radial/d3Pie/index.test.js b/src/chartLibraries/gpu/visualizations/radial/d3Pie/index.test.js
new file mode 100644
index 000000000..6c27b9c3b
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/radial/d3Pie/index.test.js
@@ -0,0 +1,54 @@
+import { makeD3PieFrame } from "."
+
+const makePie = () => {
+ const element = document.createElement("div")
+ element.innerHTML = ''
+ return {
+ element,
+ innerRadius: 90,
+ outerRadius: 180,
+ pieCenter: { x: 250, y: 200 },
+ options: {
+ colors: ["#3366cc", "#ff9900"],
+ data: { content: [{ value: 25 }, { value: 75 }] },
+ effects: { highlightLuminosity: -0.2 },
+ misc: { colors: { segmentStroke: "#dbe1e1" } },
+ },
+ }
+}
+
+describe("GPU D3 Pie visualization", () => {
+ it("preserves donut geometry and cumulative clockwise wedges", () => {
+ const frame = makeD3PieFrame(
+ makePie(),
+ { width: 500, height: 400, dpr: 2 },
+ -1
+ )
+
+ expect(frame.centerX).toBe(500)
+ expect(frame.centerY).toBe(400)
+ expect(frame.innerRadius).toBe(180)
+ expect(frame.outerRadius).toBe(360)
+ expect(frame.strokeWidth).toBe(2)
+ expect(frame.segments[0].startAngle).toBe(0)
+ expect(frame.segments[0].endAngle).toBeCloseTo(Math.PI / 2)
+ expect(frame.segments[1].startAngle).toBeCloseTo(Math.PI / 2)
+ expect(frame.segments[1].endAngle).toBeCloseTo(Math.PI * 2)
+ })
+
+ it("uses the exact legacy hover luminosity", () => {
+ const frame = makeD3PieFrame(
+ makePie(),
+ { width: 500, height: 400, dpr: 1 },
+ 0
+ )
+
+ expect(frame.segments[0].color).toEqual([
+ 41 / 255,
+ 82 / 255,
+ 163 / 255,
+ 1,
+ ])
+ expect(frame.segments[1].color).toEqual([1, 0.6, 0, 1])
+ })
+})
diff --git a/src/chartLibraries/gpu/visualizations/radial/easyPie/index.js b/src/chartLibraries/gpu/visualizations/radial/easyPie/index.js
new file mode 100644
index 000000000..7885ed27f
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/radial/easyPie/index.js
@@ -0,0 +1,85 @@
+import { parseColor } from "@/chartLibraries/gpu/color"
+import makeSimpleVisualization from "../makeSimpleVisualization"
+
+const clampPercentage = percentage =>
+ Math.min(Math.max(-1, percentage / 100 || 0), 1)
+
+export const getEasyPieValue = chart => {
+ const { data } = chart.getPayload()
+ if (data?.length === undefined) return undefined
+
+ const hoverX = chart.getAttribute("hoverX")
+ const row = hoverX ? chart.getClosestRow(hoverX[0]) : data.length - 1
+ const rowData = data[row]
+ if (!Array.isArray(rowData)) return null
+
+ return rowData.slice(1).reduce((sum, value = 0) => sum + value, 0)
+}
+
+export const makeEasyPieFrame = (
+ chart,
+ { width, height, dpr, colors },
+ value = getEasyPieValue(chart)
+) => {
+ if (value == null) return null
+
+ const [min, max] = chart.getAttribute("getValueRange")(chart)
+ const percentage = ((value - min) / (max - min)) * 100
+ const cssSize = Math.min(width, height)
+ const size = Math.max(20, cssSize)
+ const multiplier = cssSize / 22
+ const lineWidth = multiplier < 4 ? 2 : Math.floor(multiplier)
+ const scaleLength = multiplier < 4 ? 2 : Math.floor(multiplier)
+ const scaleEnabled = Boolean(colors.scale)
+ const trackEnabled = Boolean(colors.track)
+ const radius =
+ (size - lineWidth) / 2 - (scaleEnabled && scaleLength ? scaleLength + 2 : 0)
+
+ return {
+ width,
+ height,
+ dpr,
+ centerX: (width * dpr) / 2,
+ centerY: (height * dpr) / 2,
+ size: size * dpr,
+ radius: radius * dpr,
+ lineWidth: lineWidth * dpr,
+ scaleLength: scaleLength * dpr,
+ sweep: clampPercentage(percentage),
+ scaleEnabled,
+ trackEnabled,
+ barColor: parseColor(colors.bar),
+ trackColor: parseColor(colors.track),
+ scaleColor: parseColor(colors.scale),
+ value,
+ min,
+ max,
+ percentage,
+ }
+}
+
+export default ({ chart, makeResources }) =>
+ makeSimpleVisualization({
+ chart,
+ makeResources,
+ makeColors: target => ({
+ bar: target.selectDimensionColor(),
+ track: target.getThemeAttribute("themeEasyPieTrackColor"),
+ scale: target.getThemeAttribute("themeEasyPieScaleColor"),
+ }),
+ makeFrame: ({ chart: target, frame }) => {
+ const value = getEasyPieValue(target)
+ if (value === undefined) return false
+ if (value === null) return true
+ return makeEasyPieFrame(target, frame, value)
+ },
+ makeDrawStats: frame => ({
+ value: frame.value,
+ percentage: frame.percentage,
+ sweep: frame.sweep,
+ size: frame.size,
+ radius: frame.radius,
+ lineWidth: frame.lineWidth,
+ scaleLength: frame.scaleLength,
+ }),
+ })
diff --git a/src/chartLibraries/gpu/visualizations/radial/easyPie/index.test.js b/src/chartLibraries/gpu/visualizations/radial/easyPie/index.test.js
new file mode 100644
index 000000000..9dc01d39a
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/radial/easyPie/index.test.js
@@ -0,0 +1,94 @@
+import { makeTestChart } from "@jest/testUtilities"
+import { getEasyPieValue, makeEasyPieFrame } from "."
+
+const makeChart = ({ data, range = [-100, 100], hoverX = null }) => {
+ const { chart } = makeTestChart({
+ attributes: {
+ chartLibrary: "easypiechart",
+ getValueRange: () => range,
+ hoverX,
+ loaded: true,
+ },
+ })
+ chart.getPayload = () => ({ data })
+ chart.getClosestRow = timestamp => data.findIndex(row => row[0] === timestamp)
+ return chart
+}
+
+const colors = {
+ bar: "#ff0000",
+ track: "#00ff00",
+ scale: "#0000ff",
+}
+
+describe("GPU EasyPie visualization", () => {
+ it("preserves current-row summation and signed range normalization", () => {
+ const chart = makeChart({ data: [[1000, 10, -20]] })
+ const frame = makeEasyPieFrame(chart, {
+ width: 220,
+ height: 110,
+ dpr: 2,
+ colors,
+ })
+
+ expect(getEasyPieValue(chart)).toBe(-10)
+ expect(frame).toMatchObject({
+ value: -10,
+ percentage: 45,
+ sweep: 0.45,
+ centerX: 220,
+ centerY: 110,
+ size: 220,
+ radius: 91,
+ lineWidth: 10,
+ scaleLength: 10,
+ scaleEnabled: true,
+ trackEnabled: true,
+ })
+ })
+
+ it("uses synchronized hover rows and clamps signed drawing", () => {
+ const chart = makeChart({
+ data: [
+ [1000, -150],
+ [2000, 150],
+ ],
+ range: [0, 100],
+ hoverX: [1000, 1000],
+ })
+
+ expect(
+ makeEasyPieFrame(chart, { width: 100, height: 100, dpr: 1, colors }).sweep
+ ).toBe(-1)
+ chart.updateAttribute("hoverX", [2000, 2000])
+ expect(
+ makeEasyPieFrame(chart, { width: 100, height: 100, dpr: 1, colors }).sweep
+ ).toBe(1)
+ })
+
+ it("preserves the minimum canvas and thin-ring formulas", () => {
+ const chart = makeChart({ data: [[1000, 50]], range: [0, 100] })
+ const frame = makeEasyPieFrame(chart, {
+ width: 10,
+ height: 15,
+ dpr: 1,
+ colors: { ...colors, scale: null },
+ })
+
+ expect(frame).toMatchObject({
+ size: 20,
+ radius: 9,
+ lineWidth: 2,
+ scaleLength: 2,
+ scaleEnabled: false,
+ sweep: 0.5,
+ })
+ })
+
+ it("draws no progress when the range is degenerate", () => {
+ const chart = makeChart({ data: [[1000, 1]], range: [1, 1] })
+ expect(
+ makeEasyPieFrame(chart, { width: 100, height: 100, dpr: 1, colors }).sweep
+ ).toBe(0)
+ })
+})
diff --git a/src/chartLibraries/gpu/visualizations/radial/gauge/index.js b/src/chartLibraries/gpu/visualizations/radial/gauge/index.js
new file mode 100644
index 000000000..ab5a14910
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/radial/gauge/index.js
@@ -0,0 +1,116 @@
+import { parseColor } from "@/chartLibraries/gpu/color"
+import { UnsupportedVisualizationConfigurationError } from "@/chartLibraries/gpu/errors"
+import makeSimpleVisualization from "../makeSimpleVisualization"
+import lightenColor from "@/chartLibraries/gauge/makeGradientColors"
+import makeThresholdStops from "@/chartLibraries/gauge/makeThresholdStops"
+import { getEasyPieValue } from "../easyPie"
+
+const ANGLE = -0.2
+const START_ANGLE = (1 + ANGLE) * Math.PI
+const TOTAL_SWEEP = (1 - ANGLE * 2) * Math.PI
+
+const getThresholdColor = (stops, percentage) => {
+ const fraction = percentage / 100
+ return stops.find(([position]) => fraction <= position)?.[1] || stops.at(-1)[1]
+}
+
+export const isGaugeConfigurationSupported = chart => !chart?.getAttribute("staticZones")
+
+export const makeGaugeFrame = (chart, { width, height, dpr, colors }) => {
+ if (!isGaugeConfigurationSupported(chart))
+ throw new UnsupportedVisualizationConfigurationError(
+ "GPU Gauge does not support staticZones"
+ )
+
+ const value = getEasyPieValue(chart)
+ if (value == null) return null
+ const [min, max] = chart.getAttribute("getValueRange")(chart)
+ const rawPercentage = ((value - min) / (max - min)) * 100
+ const percentage = Number.isNaN(rawPercentage)
+ ? 0
+ : Math.max(Math.min(rawPercentage, 99.999), 0.001)
+ const gaugeHeight = Math.min(width, height) * 0.9
+ const canvasHeight = gaugeHeight * dpr
+ const canvasWidth = width * dpr
+ const availableHeight = canvasHeight * 0.8
+ const lineWidth = availableHeight * chart.getAttribute("gaugeLineWidth")
+ const extraPadding = Math.sin(START_ANGLE)
+ const radius = (availableHeight - lineWidth / 2) / (1 + extraPadding)
+ const canvasTop = ((height - gaugeHeight) * dpr) / 2
+ const centerX = canvasWidth / 2
+ const centerY =
+ canvasTop + canvasHeight * 0.1 + availableHeight - (radius + lineWidth / 2) * extraPadding
+ const pointerWidth = canvasHeight * 0.035
+ const pointerLength = radius * 1.2
+ const progressSweep = (percentage / 100) * TOTAL_SWEEP
+ const dimensionColor = colors.dimension
+ const thresholds = makeThresholdStops(
+ chart.getAttribute("gaugeThresholds"),
+ min,
+ max,
+ chart.getThemeIndex(),
+ dimensionColor
+ )
+ const thresholdColor = thresholds
+ ? getThresholdColor(thresholds, percentage)
+ : dimensionColor
+ const gradientEnabled = chart.getAttribute("gaugeGradient") && !thresholds
+
+ return {
+ width,
+ height,
+ dpr,
+ centerX,
+ centerY,
+ radius,
+ lineWidth,
+ startAngle: START_ANGLE,
+ totalSweep: TOTAL_SWEEP,
+ progressSweep,
+ pointerAngle: START_ANGLE + progressSweep,
+ pointerWidth,
+ pointerLength,
+ gradientEnabled,
+ progressStartColor: parseColor(
+ gradientEnabled ? lightenColor(dimensionColor) : thresholdColor
+ ),
+ progressEndColor: parseColor(thresholdColor),
+ trackColor: parseColor(colors.track),
+ pointerColor: parseColor(colors.pointer),
+ value,
+ min,
+ max,
+ percentage,
+ }
+}
+
+export default ({ chart, makeResources }) =>
+ makeSimpleVisualization({
+ chart,
+ makeResources,
+ makeColors: target => ({
+ dimension: target.selectDimensionColor(),
+ pointer: target.getThemeAttribute("themeGaugePointer"),
+ track: target.getThemeAttribute("themeGaugeStroke"),
+ }),
+ makeFrame: ({ chart: target, frame }) =>
+ makeGaugeFrame(target, frame) || false,
+ makeDrawStats: frame => ({
+ value: frame.value,
+ percentage: frame.percentage,
+ radius: frame.radius,
+ lineWidth: frame.lineWidth,
+ progressSweep: frame.progressSweep,
+ }),
+ watchedAttributes: [
+ "gaugeThresholds",
+ "gaugeGradient",
+ "gaugeLineWidth",
+ ],
+ makeExtraListeners: ({ chart: target }) => [
+ target.onAttributeChange("staticZones", () =>
+ target.reconcileRenderer()
+ ),
+ ],
+ getMinMax: target => target.getAttribute("getValueRange")(target),
+ })
diff --git a/src/chartLibraries/gpu/visualizations/radial/gauge/index.test.js b/src/chartLibraries/gpu/visualizations/radial/gauge/index.test.js
new file mode 100644
index 000000000..0f58637f4
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/radial/gauge/index.test.js
@@ -0,0 +1,78 @@
+import { makeTestChart } from "@jest/testUtilities"
+import { makeGaugeFrame } from "."
+
+const makeChart = (attributes = {}) => {
+ const { chart } = makeTestChart({
+ attributes: {
+ chartLibrary: "gauge",
+ getValueRange: () => [0, 100],
+ gaugeGradient: true,
+ gaugeLineWidth: 0.1,
+ loaded: true,
+ ...attributes,
+ },
+ })
+ chart.getPayload = () => ({ data: [[1000, 50]] })
+ return chart
+}
+
+const colors = {
+ dimension: "#3366cc",
+ pointer: "#8f9eaa",
+ track: "#dbe1e1",
+}
+
+describe("GPU Gauge visualization", () => {
+ it("preserves legacy arc, pointer, and sizing formulas", () => {
+ const frame = makeGaugeFrame(makeChart(), {
+ width: 500,
+ height: 500,
+ dpr: 1,
+ colors,
+ })
+
+ expect(frame.percentage).toBe(50)
+ expect(frame.progressSweep).toBeCloseTo(Math.PI * 0.7)
+ expect(frame.pointerAngle).toBeCloseTo(Math.PI * 1.5)
+ expect(frame.lineWidth).toBe(36)
+ expect(frame.radius).toBeCloseTo(215.394, 3)
+ expect(frame.pointerLength).toBeCloseTo(frame.radius * 1.2)
+ expect(frame.pointerWidth).toBeCloseTo(15.75)
+ expect(frame.gradientEnabled).toBe(true)
+ })
+
+ it("clamps display percentages exactly like the legacy gauge", () => {
+ const chart = makeChart()
+ chart.getPayload = () => ({ data: [[1000, -10]] })
+ expect(makeGaugeFrame(chart, { width: 100, height: 100, dpr: 1, colors }).percentage).toBe(
+ 0.001
+ )
+
+ chart.getPayload = () => ({ data: [[1000, 110]] })
+ expect(makeGaugeFrame(chart, { width: 100, height: 100, dpr: 1, colors }).percentage).toBe(
+ 99.999
+ )
+ })
+
+ it("selects threshold colors without applying the smooth gradient", () => {
+ const chart = makeChart({
+ gaugeThresholds: [
+ { from: 0, color: ["#00ff00", "#00ff00"] },
+ { from: 40, color: ["#ffff00", "#ffff00"] },
+ { from: 80, color: ["#ff0000", "#ff0000"] },
+ ],
+ })
+ const frame = makeGaugeFrame(chart, { width: 100, height: 100, dpr: 1, colors })
+
+ expect(frame.gradientEnabled).toBe(false)
+ expect(frame.progressEndColor).toEqual([1, 1, 0, 1])
+ expect(frame.progressStartColor).toEqual(frame.progressEndColor)
+ })
+
+ it("rejects static zones instead of approximating them", () => {
+ const chart = makeChart({ staticZones: [{ min: 0, max: 50, strokeStyle: "red" }] })
+ expect(() =>
+ makeGaugeFrame(chart, { width: 100, height: 100, dpr: 1, colors })
+ ).toThrow("staticZones")
+ })
+})
diff --git a/src/chartLibraries/gpu/visualizations/radial/makeSimpleVisualization.js b/src/chartLibraries/gpu/visualizations/radial/makeSimpleVisualization.js
new file mode 100644
index 000000000..686c9aba6
--- /dev/null
+++ b/src/chartLibraries/gpu/visualizations/radial/makeSimpleVisualization.js
@@ -0,0 +1,81 @@
+import { unregister } from "@/helpers/makeListeners"
+
+export default ({
+ chart,
+ makeResources,
+ makeColors,
+ makeFrame,
+ makeDrawStats,
+ watchedAttributes = [],
+ makeExtraListeners = () => [],
+ getMinMax,
+}) => {
+ let resource = null
+ let listeners = null
+ let colors = null
+ let previousRange = null
+ let drawStats = null
+
+ const updateColors = () => {
+ colors = makeColors(chart)
+ }
+
+ const mount = ({ render }) => {
+ updateColors()
+ const { loaded } = chart.getAttributes()
+ listeners = unregister(
+ chart.onAttributeChange("hoverX", render),
+ !loaded && chart.onceAttributeChange("loaded", render),
+ ...watchedAttributes.map(attribute =>
+ chart.onAttributeChange(attribute, render)
+ ),
+ ...makeExtraListeners({ chart, render }),
+ chart.onAttributeChange("theme", () => {
+ updateColors()
+ render()
+ })
+ )
+ }
+
+ const unmount = () => {
+ listeners?.()
+ listeners = null
+ resource?.destroy()
+ resource = null
+ colors = null
+ previousRange = null
+ drawStats = null
+ }
+
+ const render = frame => {
+ if (!resource || !chart.getAttribute("loaded")) return false
+ const nextFrame = makeFrame({ chart, frame: { ...frame, colors } })
+ if (nextFrame === false) return false
+ if (nextFrame === true) return true
+
+ const { min, max } = nextFrame
+ if (!previousRange || min !== previousRange[0] || max !== previousRange[1])
+ chart.trigger("yAxisChange", min, max)
+ previousRange = [min, max]
+
+ resource.layer.update(nextFrame)
+ resource.surface.draw([resource.layer], frame)
+ drawStats = makeDrawStats(nextFrame)
+ return true
+ }
+
+ return {
+ mount,
+ unmount,
+ render,
+ createResources: (runtime, canvas) => makeResources(runtime, canvas),
+ attachResources: nextResource => {
+ resource?.destroy()
+ resource = nextResource
+ },
+ getBufferBytes: () => resource?.layer.getBufferBytes() || 0,
+ getDrawStats: () => drawStats,
+ getQueueDone: () => resource?.surface.getQueueDone?.() || Promise.resolve(),
+ ...(getMinMax && { getMinMax: () => getMinMax(chart) }),
+ }
+}
diff --git a/src/chartLibraries/helpers/overlayArea.js b/src/chartLibraries/helpers/overlayArea.js
new file mode 100644
index 000000000..09abd3a81
--- /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 [rangeAfter, rangeBefore] = range
+ const rangeAfterMs = rangeAfter * 1000
+ const rangeBeforeMs = rangeBefore * 1000
+
+ if (rangeBeforeMs < afterMs || rangeAfterMs > beforeMs) return null
+
+ const from = chartUI.getXCoord(Math.max(afterMs, rangeAfterMs))
+ const to = chartUI.getXCoord(Math.min(beforeMs, rangeBeforeMs))
+
+ return { from, to, width: to - from }
+}
diff --git a/src/chartLibraries/webgl2/engine/makeInstancedLayer.js b/src/chartLibraries/webgl2/engine/makeInstancedLayer.js
new file mode 100644
index 000000000..c912f0ef2
--- /dev/null
+++ b/src/chartLibraries/webgl2/engine/makeInstancedLayer.js
@@ -0,0 +1,95 @@
+import { getSharedVisualizationProgram } from "./programs"
+
+const nextBufferSize = byteLength => {
+ let size = 4
+ while (size < byteLength) size *= 2
+ return size
+}
+
+const makeScissor = ({ scissor, width, height, dpr }) => {
+ if (!scissor) return null
+ const left = Math.max(0, Math.round(scissor.left * dpr))
+ const top = Math.max(0, Math.round(scissor.top * dpr))
+ return {
+ left: Math.min(left, width - 1),
+ top: Math.min(top, height - 1),
+ width: Math.max(1, Math.min(Math.round(scissor.width * dpr), width - left)),
+ height: Math.max(1, Math.min(Math.round(scissor.height * dpr), height - top)),
+ }
+}
+
+export default async ({ surface, pack }) => {
+ const { gl } = surface
+ const program = await getSharedVisualizationProgram(surface)
+ const vertexArray = gl.createVertexArray()
+ const instances = gl.createBuffer()
+ const canvasLocation = gl.getUniformLocation(program, "uCanvas")
+ const passLocation = gl.getUniformLocation(program, "uPassType")
+ let capacity = 0
+ let count = 0
+ let scissor = null
+
+ gl.bindVertexArray(vertexArray)
+ gl.bindBuffer(gl.ARRAY_BUFFER, instances)
+ gl.enableVertexAttribArray(0)
+ gl.vertexAttribPointer(0, 4, gl.FLOAT, false, 64, 0)
+ gl.vertexAttribDivisor(0, 1)
+ gl.enableVertexAttribArray(1)
+ gl.vertexAttribPointer(1, 4, gl.FLOAT, false, 64, 16)
+ gl.vertexAttribDivisor(1, 1)
+ gl.enableVertexAttribArray(2)
+ gl.vertexAttribPointer(2, 4, gl.FLOAT, false, 64, 32)
+ gl.vertexAttribDivisor(2, 1)
+ gl.enableVertexAttribArray(3)
+ gl.vertexAttribPointer(3, 4, gl.FLOAT, false, 64, 48)
+ gl.vertexAttribDivisor(3, 1)
+ gl.bindVertexArray(null)
+
+ const update = ({ items, width, height, dpr, scissor: nextScissor }) => {
+ count = items.length
+ scissor = makeScissor({
+ scissor: nextScissor,
+ width: Math.max(1, Math.round(width * dpr)),
+ height: Math.max(1, Math.round(height * dpr)),
+ dpr,
+ })
+ if (!count) return
+
+ const packed = pack(items, dpr)
+ gl.bindBuffer(gl.ARRAY_BUFFER, instances)
+ if (capacity < packed.byteLength) {
+ capacity = nextBufferSize(packed.byteLength)
+ gl.bufferData(gl.ARRAY_BUFFER, capacity, gl.DYNAMIC_DRAW)
+ }
+ gl.bufferSubData(gl.ARRAY_BUFFER, 0, packed)
+ }
+
+ const draw = size => {
+ if (!count) return false
+ gl.useProgram(program)
+ gl.bindVertexArray(vertexArray)
+ gl.uniform1i(passLocation, 1)
+ gl.uniform4f(canvasLocation, size.width, size.height, 0, 0)
+ gl.enable(gl.SCISSOR_TEST)
+ if (scissor)
+ gl.scissor(
+ scissor.left,
+ size.height - scissor.top - scissor.height,
+ scissor.width,
+ scissor.height
+ )
+ else gl.scissor(0, 0, size.width, size.height)
+ gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, count)
+ gl.bindVertexArray(null)
+ return true
+ }
+
+ const destroy = () => {
+ gl.deleteBuffer(instances)
+ gl.deleteVertexArray(vertexArray)
+ capacity = 0
+ count = 0
+ }
+
+ return { update, draw, destroy, getBufferBytes: () => capacity }
+}
diff --git a/src/chartLibraries/webgl2/engine/makeRenderer.js b/src/chartLibraries/webgl2/engine/makeRenderer.js
new file mode 100644
index 000000000..26fbfcd58
--- /dev/null
+++ b/src/chartLibraries/webgl2/engine/makeRenderer.js
@@ -0,0 +1,12 @@
+import makeRenderer from "@/chartLibraries/gpu/engine/makeRenderer"
+import { getWebGL2Runtime, isWebGL2Supported } from "./runtime"
+
+export default options =>
+ makeRenderer({
+ ...options,
+ rendererId: "webgl2",
+ fallbackRenderer: null,
+ getRuntime: getWebGL2Runtime,
+ isRuntimeSupported: isWebGL2Supported,
+ makeLossError: info => new Error(`${info.reason}: ${info.message}`),
+ })
diff --git a/src/chartLibraries/webgl2/engine/program.js b/src/chartLibraries/webgl2/engine/program.js
new file mode 100644
index 000000000..cc3587ba0
--- /dev/null
+++ b/src/chartLibraries/webgl2/engine/program.js
@@ -0,0 +1,35 @@
+const compileShader = (gl, type, source) => {
+ const shader = gl.createShader(type)
+ gl.shaderSource(shader, source)
+ gl.compileShader(shader)
+ return shader
+}
+
+const nextTask = () => new Promise(resolve => setTimeout(resolve))
+
+export default async (gl, vertexSource, fragmentSource) => {
+ const vertex = compileShader(gl, gl.VERTEX_SHADER, vertexSource)
+ const fragment = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSource)
+ const program = gl.createProgram()
+ gl.attachShader(program, vertex)
+ gl.attachShader(program, fragment)
+ gl.linkProgram(program)
+ gl.flush()
+
+ const completion = gl.getExtension("KHR_parallel_shader_compile")
+ while (completion && !gl.getProgramParameter(program, completion.COMPLETION_STATUS_KHR)) {
+ await nextTask()
+ }
+
+ const errors = [vertex, fragment]
+ .filter(shader => !gl.getShaderParameter(shader, gl.COMPILE_STATUS))
+ .map(shader => gl.getShaderInfoLog(shader))
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) errors.push(gl.getProgramInfoLog(program))
+ gl.deleteShader(vertex)
+ gl.deleteShader(fragment)
+ if (errors.length) {
+ gl.deleteProgram(program)
+ throw new Error(errors.join("\n"))
+ }
+ return program
+}
diff --git a/src/chartLibraries/webgl2/engine/programs.js b/src/chartLibraries/webgl2/engine/programs.js
new file mode 100644
index 000000000..b27e6cdf4
--- /dev/null
+++ b/src/chartLibraries/webgl2/engine/programs.js
@@ -0,0 +1,13 @@
+import {
+ fragmentShader,
+ vertexShader,
+} from "@/chartLibraries/webgl2/visualizations/cartesian/line/shader"
+
+export const sharedVisualizationProgramKey = "netdata-shared-visualization-v1"
+
+export const getSharedVisualizationProgram = surface =>
+ surface.getProgram(
+ sharedVisualizationProgramKey,
+ vertexShader,
+ fragmentShader
+ )
diff --git a/src/chartLibraries/webgl2/engine/runtime.js b/src/chartLibraries/webgl2/engine/runtime.js
new file mode 100644
index 000000000..9bea3944a
--- /dev/null
+++ b/src/chartLibraries/webgl2/engine/runtime.js
@@ -0,0 +1,201 @@
+import makeResourceCache from "@/chartLibraries/gpu/engine/makeResourceCache"
+import makeProgram from "./program"
+
+const runtimes = new WeakMap()
+const failedSDKs = new WeakSet()
+const idleDisposeMs = 30000
+let support
+let activeContexts = 0
+
+const getContextInfo = gl => {
+ const debug = gl.getExtension("WEBGL_debug_renderer_info")
+ return {
+ vendor: debug ? gl.getParameter(debug.UNMASKED_VENDOR_WEBGL) : gl.getParameter(gl.VENDOR),
+ renderer: debug
+ ? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL)
+ : gl.getParameter(gl.RENDERER),
+ version: gl.getParameter(gl.VERSION),
+ shadingLanguageVersion: gl.getParameter(gl.SHADING_LANGUAGE_VERSION),
+ maxTextureSize: gl.getParameter(gl.MAX_TEXTURE_SIZE),
+ }
+}
+
+const makeContext = canvas =>
+ canvas.getContext("webgl2", {
+ alpha: true,
+ antialias: false,
+ premultipliedAlpha: true,
+ preserveDrawingBuffer: true,
+ powerPreference: "default",
+ })
+
+export const inspectWebGL2 = () => {
+ if (typeof document === "undefined") return null
+ try {
+ const canvas = document.createElement("canvas")
+ const gl = makeContext(canvas)
+ if (!gl) return null
+ const info = getContextInfo(gl)
+ gl.getExtension("WEBGL_lose_context")?.loseContext()
+ return info
+ } catch {
+ return null
+ }
+}
+
+const probeSupport = () => Boolean(inspectWebGL2())
+
+export const isWebGL2Supported = sdk => {
+ if (sdk && failedSDKs.has(sdk)) return false
+ if (support === undefined) support = probeSupport()
+ return support
+}
+
+const makeRuntime = sdk => {
+ let canvas = null
+ let gl = null
+ let info = null
+ let references = 0
+ let disposeTimer = null
+ let disposed = false
+ let contextLostListener = null
+ let lastFailure = null
+ const programs = new Map()
+ const resourceCache = makeResourceCache()
+ const lostListeners = new Set()
+
+ const initialize = () => {
+ if (gl) return instance
+ if (!isWebGL2Supported(sdk)) throw new Error("WebGL2 is unavailable")
+
+ canvas = document.createElement("canvas")
+ gl = makeContext(canvas)
+ if (!gl) throw new Error("Unable to create a shared WebGL2 context")
+ activeContexts += 1
+ info = getContextInfo(gl)
+ contextLostListener = event => {
+ event.preventDefault()
+ if (disposed) return
+ failedSDKs.add(sdk)
+ lastFailure = { reason: "context-lost", message: "WebGL2 context lost" }
+ lostListeners.forEach(listener => listener(lastFailure))
+ }
+ canvas.addEventListener("webglcontextlost", contextLostListener)
+ return instance
+ }
+
+ const acquire = async () => {
+ references += 1
+ clearTimeout(disposeTimer)
+ disposeTimer = null
+ try {
+ return initialize()
+ } catch (error) {
+ references = Math.max(0, references - 1)
+ lastFailure = { reason: "initialization", message: error.message }
+ failedSDKs.add(sdk)
+ throw error
+ }
+ }
+
+ const release = () => {
+ references = Math.max(0, references - 1)
+ if (references || disposed) return
+ clearTimeout(disposeTimer)
+ disposeTimer = setTimeout(dispose, idleDisposeMs)
+ }
+
+ const getProgram = (key, vertexShader, fragmentShader) => {
+ if (!gl) throw new Error("WebGL2 runtime is not initialized")
+ if (!programs.has(key)) {
+ const record = { value: null, promise: null }
+ record.promise = makeProgram(gl, vertexShader, fragmentShader).then(program => {
+ record.value = program
+ return program
+ })
+ programs.set(key, record)
+ }
+ return programs.get(key).promise
+ }
+
+ const getResource = (key, create) => {
+ if (!gl) throw new Error("WebGL2 runtime is not initialized")
+ return resourceCache.get(key, create)
+ }
+
+ const getResourceBytes = resourceCache.getBytes
+
+ const onLost = listener => {
+ lostListeners.add(listener)
+ return () => lostListeners.delete(listener)
+ }
+
+ const dispose = () => {
+ if (disposed) return
+ disposed = true
+ clearTimeout(disposeTimer)
+ disposeTimer = null
+ lostListeners.clear()
+ programs.forEach(record => {
+ if (record.value) gl?.deleteProgram(record.value)
+ else record.promise.then(program => gl?.deleteProgram(program), () => {})
+ })
+ programs.clear()
+ resourceCache.destroy()
+ if (canvas && contextLostListener)
+ canvas.removeEventListener("webglcontextlost", contextLostListener)
+ contextLostListener = null
+ if (gl) {
+ gl.getExtension("WEBGL_lose_context")?.loseContext()
+ activeContexts = Math.max(0, activeContexts - 1)
+ }
+ gl = null
+ canvas = null
+ info = null
+ runtimes.delete(sdk)
+ }
+
+ const instance = {
+ acquire,
+ release,
+ dispose,
+ getProgram,
+ getResource,
+ getResourceBytes,
+ onLost,
+ get canvas() {
+ return canvas
+ },
+ get gl() {
+ return gl
+ },
+ get info() {
+ return info
+ },
+ get references() {
+ return references
+ },
+ get lastFailure() {
+ return lastFailure
+ },
+ }
+ return instance
+}
+
+export const getWebGL2Diagnostics = sdk => {
+ const runtime = runtimes.get(sdk)
+ return {
+ supported: isWebGL2Supported(sdk),
+ initialized: Boolean(runtime?.gl),
+ context: runtime?.info || null,
+ references: runtime?.references || 0,
+ sharedResourceBytes: runtime?.getResourceBytes?.() || 0,
+ lastFailure: runtime?.lastFailure || null,
+ }
+}
+export const getWebGL2Runtime = sdk => {
+ if (!runtimes.has(sdk)) runtimes.set(sdk, makeRuntime(sdk))
+ return runtimes.get(sdk)
+}
+export const disposeWebGL2Runtime = sdk => runtimes.get(sdk)?.dispose()
+export const getActiveWebGL2Contexts = () => activeContexts
diff --git a/src/chartLibraries/webgl2/engine/surface.js b/src/chartLibraries/webgl2/engine/surface.js
new file mode 100644
index 000000000..82fce9c63
--- /dev/null
+++ b/src/chartLibraries/webgl2/engine/surface.js
@@ -0,0 +1,64 @@
+export default (runtime, canvas) => {
+ const { gl, canvas: source } = runtime
+ const context = canvas.getContext("2d")
+ if (!gl || !source || !context) throw new Error("Unable to create a WebGL2 presentation surface")
+
+ let destroyed = false
+
+ const resize = ({ width, height, dpr }) => {
+ const pixelWidth = Math.max(1, Math.round(width * dpr))
+ const pixelHeight = Math.max(1, Math.round(height * dpr))
+ if (source.width < pixelWidth) source.width = pixelWidth
+ if (source.height < pixelHeight) source.height = pixelHeight
+ if (canvas.width !== pixelWidth) canvas.width = pixelWidth
+ if (canvas.height !== pixelHeight) canvas.height = pixelHeight
+ return { width: pixelWidth, height: pixelHeight, dpr }
+ }
+
+ const draw = (layers, frame) => {
+ if (destroyed || gl.isContextLost()) return false
+ const size = resize(frame)
+ gl.viewport(0, 0, size.width, size.height)
+ gl.disable(gl.SCISSOR_TEST)
+ gl.clearColor(0, 0, 0, 0)
+ gl.clear(gl.COLOR_BUFFER_BIT)
+ gl.enable(gl.BLEND)
+ gl.blendFuncSeparate(
+ gl.SRC_ALPHA,
+ gl.ONE_MINUS_SRC_ALPHA,
+ gl.ONE,
+ gl.ONE_MINUS_SRC_ALPHA
+ )
+
+ let rendered = false
+ for (const layer of layers) rendered = layer.draw(size) || rendered
+ // drawImage synchronizes its source; finish() would stall every shared-context chart.
+ gl.flush()
+ context.clearRect(0, 0, size.width, size.height)
+ context.drawImage(
+ source,
+ 0,
+ source.height - size.height,
+ size.width,
+ size.height,
+ 0,
+ 0,
+ size.width,
+ size.height
+ )
+ return rendered
+ }
+
+ const destroy = () => {
+ destroyed = true
+ }
+
+ return {
+ gl,
+ draw,
+ destroy,
+ getProgram: (...args) => runtime.getProgram(...args),
+ getResource: (...args) => runtime.getResource(...args),
+ getQueueDone: () => Promise.resolve(),
+ }
+}
diff --git a/src/chartLibraries/webgl2/engine/uniforms.js b/src/chartLibraries/webgl2/engine/uniforms.js
new file mode 100644
index 000000000..7611acf26
--- /dev/null
+++ b/src/chartLibraries/webgl2/engine/uniforms.js
@@ -0,0 +1,42 @@
+const types = Object.freeze({
+ int: Object.freeze({ method: "uniform1i", size: 1 }),
+ ivec2: Object.freeze({ method: "uniform2iv", size: 2 }),
+ vec3: Object.freeze({ method: "uniform3fv", size: 3 }),
+ vec4: Object.freeze({ method: "uniform4fv", size: 4 }),
+ uvec4: Object.freeze({ method: "uniform4uiv", size: 4 }),
+})
+
+export const validateUniformValue = (name, type, value) => {
+ const definition = types[type]
+ if (!definition) throw new Error(`Unknown uniform type ${type} for ${name}`)
+
+ if (definition.size === 1) {
+ if (!Number.isFinite(value))
+ throw new Error(`Uniform ${name} requires one finite value`)
+ return definition
+ }
+
+ if (!value || value.length !== definition.size)
+ throw new Error(
+ `Uniform ${name} requires exactly ${definition.size} values`
+ )
+ return definition
+}
+
+export default (gl, program, schema) => {
+ const records = Object.entries(schema).map(([name, type]) => ({
+ name,
+ type,
+ location: gl.getUniformLocation(program, name),
+ }))
+
+ return values => {
+ records.forEach(({ name, type, location }) => {
+ const value = values[name]
+ if (value === undefined) return
+ const { method, size } = validateUniformValue(name, type, value)
+ if (size === 1) gl[method](location, value)
+ else gl[method](location, value)
+ })
+ }
+}
diff --git a/src/chartLibraries/webgl2/engine/uniforms.test.js b/src/chartLibraries/webgl2/engine/uniforms.test.js
new file mode 100644
index 000000000..b88773408
--- /dev/null
+++ b/src/chartLibraries/webgl2/engine/uniforms.test.js
@@ -0,0 +1,33 @@
+import { validateUniformValue } from "./uniforms"
+
+describe("WebGL2 uniform contracts", () => {
+ it.each([
+ ["int", 1],
+ ["ivec2", [1, 2]],
+ ["vec3", [1, 2, 3]],
+ ["vec4", [1, 2, 3, 4]],
+ ["uvec4", new Uint32Array([1, 2, 3, 4])],
+ ])("accepts exact %s values", (type, value) => {
+ expect(validateUniformValue("uValue", type, value)).toBeDefined()
+ })
+
+ it.each([
+ ["ivec2", [1]],
+ ["vec3", [1, 2, 3, 4]],
+ ["vec4", [1, 2, 3]],
+ ["uvec4", [1, 2, 3, 4, 5]],
+ ])("rejects incorrect %s arity", (type, value) => {
+ expect(() => validateUniformValue("uValue", type, value)).toThrow(
+ /requires exactly/
+ )
+ })
+
+ it("rejects unknown types and non-finite scalar values", () => {
+ expect(() => validateUniformValue("uValue", "matrix", [])).toThrow(
+ /Unknown uniform type/
+ )
+ expect(() => validateUniformValue("uValue", "int", NaN)).toThrow(
+ /requires one finite value/
+ )
+ })
+})
diff --git a/src/chartLibraries/webgl2/index.js b/src/chartLibraries/webgl2/index.js
new file mode 100644
index 000000000..c35c2c7a9
--- /dev/null
+++ b/src/chartLibraries/webgl2/index.js
@@ -0,0 +1,29 @@
+import makeRenderer from "./engine/makeRenderer"
+import { getWebGL2Diagnostics, isWebGL2Supported } from "./engine/runtime"
+import { isGaugeConfigurationSupported } from "@/chartLibraries/gpu/visualizations/radial/gauge"
+import { getVisualization, hasVisualization } from "./visualizations"
+
+const makeUnsupportedVisualization = visualization => () => ({
+ mount: () => {},
+ unmount: () => {},
+ createResources: () =>
+ Promise.reject(new Error(`Unsupported WebGL2 visualization: ${visualization}`)),
+ attachResources: resource => resource.destroy?.(),
+ render: () => false,
+})
+
+const makeWebGL2 = (sdk, chart) => {
+ const visualizationId =
+ chart.getVisualizationType?.() || chart.getAttribute("chartType") || "line"
+ const makeVisualization =
+ getVisualization(visualizationId) || makeUnsupportedVisualization(visualizationId)
+ return makeRenderer({ sdk, chart, makeVisualization, visualizationId })
+}
+
+makeWebGL2.isSupported = (sdk, visualization = "line", chart) =>
+ hasVisualization(visualization) &&
+ (visualization !== "gauge" || isGaugeConfigurationSupported(chart)) &&
+ isWebGL2Supported(sdk)
+makeWebGL2.getDiagnostics = getWebGL2Diagnostics
+
+export default makeWebGL2
diff --git a/src/chartLibraries/webgl2/primitives/circle/index.js b/src/chartLibraries/webgl2/primitives/circle/index.js
new file mode 100644
index 000000000..90f99a8db
--- /dev/null
+++ b/src/chartLibraries/webgl2/primitives/circle/index.js
@@ -0,0 +1,25 @@
+import { parseColor } from "@/chartLibraries/gpu/color"
+import makeInstancedLayer from "@/chartLibraries/webgl2/engine/makeInstancedLayer"
+
+export const packCircles = (circles, dpr = 1) => {
+ const packed = new Float32Array(circles.length * 16)
+ circles.forEach(({ x, y, radius, color }, index) => {
+ const offset = index * 16
+ packed.set([x * dpr, y * dpr, radius * dpr, radius * dpr], offset)
+ packed.set(parseColor(color), offset + 8)
+ packed[offset + 12] = 1
+ })
+ return packed
+}
+
+export default async surface => {
+ const layer = await makeInstancedLayer({
+ surface,
+ pack: packCircles,
+ })
+ return {
+ ...layer,
+ update: ({ circles, plot, ...frame }) =>
+ layer.update({ items: circles, scissor: plot, ...frame }),
+ }
+}
diff --git a/src/chartLibraries/webgl2/primitives/circle/index.test.js b/src/chartLibraries/webgl2/primitives/circle/index.test.js
new file mode 100644
index 000000000..679da82c0
--- /dev/null
+++ b/src/chartLibraries/webgl2/primitives/circle/index.test.js
@@ -0,0 +1,14 @@
+import { packCircles } from "."
+
+describe("WebGL2 circle packing", () => {
+ it("packs DPR-scaled geometry and normalized color", () => {
+ const packed = packCircles([{ x: 2, y: 3, radius: 4, color: "rgba(10, 20, 30, 0.5)" }], 2)
+
+ expect(Array.from(packed.slice(0, 4))).toEqual([4, 6, 8, 8])
+ expect(packed[8]).toBeCloseTo(10 / 255)
+ expect(packed[9]).toBeCloseTo(20 / 255)
+ expect(packed[10]).toBeCloseTo(30 / 255)
+ expect(packed[11]).toBe(0.5)
+ expect(packed[12]).toBe(1)
+ })
+})
diff --git a/src/chartLibraries/webgl2/primitives/rect/index.js b/src/chartLibraries/webgl2/primitives/rect/index.js
new file mode 100644
index 000000000..49f07343b
--- /dev/null
+++ b/src/chartLibraries/webgl2/primitives/rect/index.js
@@ -0,0 +1,24 @@
+import { parseColor } from "@/chartLibraries/gpu/color"
+import makeInstancedLayer from "@/chartLibraries/webgl2/engine/makeInstancedLayer"
+
+export const packRects = (rects, dpr = 1) => {
+ const packed = new Float32Array(rects.length * 16)
+ rects.forEach(({ x, y, width, height, color }, index) => {
+ const offset = index * 16
+ packed.set([x * dpr, y * dpr, width * dpr, height * dpr], offset)
+ packed.set(parseColor(color), offset + 8)
+ packed[offset + 12] = 0
+ })
+ return packed
+}
+
+export default async surface => {
+ const layer = await makeInstancedLayer({
+ surface,
+ pack: packRects,
+ })
+ return {
+ ...layer,
+ update: ({ rects, ...frame }) => layer.update({ items: rects, ...frame }),
+ }
+}
diff --git a/src/chartLibraries/webgl2/primitives/rect/index.test.js b/src/chartLibraries/webgl2/primitives/rect/index.test.js
new file mode 100644
index 000000000..3a4bcc6c2
--- /dev/null
+++ b/src/chartLibraries/webgl2/primitives/rect/index.test.js
@@ -0,0 +1,14 @@
+import { packRects } from "."
+
+describe("WebGL2 rectangle packing", () => {
+ it("packs DPR-scaled geometry and normalized color", () => {
+ const packed = packRects([{ x: 1, y: 2, width: 3, height: 4, color: "#ff800080" }], 2)
+
+ expect(Array.from(packed.slice(0, 4))).toEqual([2, 4, 6, 8])
+ expect(packed[8]).toBe(1)
+ expect(packed[9]).toBeCloseTo(128 / 255)
+ expect(packed[10]).toBe(0)
+ expect(packed[11]).toBeCloseTo(128 / 255)
+ expect(packed[12]).toBe(0)
+ })
+})
diff --git a/src/chartLibraries/webgl2/text/atlas.js b/src/chartLibraries/webgl2/text/atlas.js
new file mode 100644
index 000000000..5991698b2
--- /dev/null
+++ b/src/chartLibraries/webgl2/text/atlas.js
@@ -0,0 +1,122 @@
+import makeBoundedCache from "@/chartLibraries/gpu/text/cache"
+import {
+ makeRasterCanvas,
+ makeTextCacheKey,
+ rasterizeText,
+} from "@/chartLibraries/gpu/text"
+
+const ATLAS_SIZE = 1024
+const ATLAS_PADDING = 2
+const CACHE_MAX = 1024
+
+export default gl => {
+ const size = Math.min(ATLAS_SIZE, gl.getParameter(gl.MAX_TEXTURE_SIZE))
+ const canvas = makeRasterCanvas()
+ const texture = gl.createTexture()
+ let generation = 0
+ let x = ATLAS_PADDING
+ let y = ATLAS_PADDING
+ let rowHeight = 0
+ let destroyed = false
+ const cache = makeBoundedCache(CACHE_MAX)
+
+ const reset = () => {
+ gl.bindTexture(gl.TEXTURE_2D, texture)
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
+ gl.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl.RGBA,
+ size,
+ size,
+ 0,
+ gl.RGBA,
+ gl.UNSIGNED_BYTE,
+ null
+ )
+ generation += 1
+ x = ATLAS_PADDING
+ y = ATLAS_PADDING
+ rowHeight = 0
+ cache.clear()
+ }
+
+ const allocate = (width, height) => {
+ if (width + ATLAS_PADDING * 2 > size || height + ATLAS_PADDING * 2 > size) return null
+ if (x + width + ATLAS_PADDING > size) {
+ x = ATLAS_PADDING
+ y += rowHeight + ATLAS_PADDING
+ rowHeight = 0
+ }
+ if (y + height + ATLAS_PADDING > size) return null
+
+ const allocation = { x, y, width, height }
+ x += width + ATLAS_PADDING
+ rowHeight = Math.max(rowHeight, height)
+ return allocation
+ }
+
+ const rasterize = ({ text, font, dpr }) => {
+ if (destroyed || !text) return null
+ const key = makeTextCacheKey({ text, font, dpr })
+ const cached = cache.get(key)
+ if (cached) return cached
+ if (cache.isFullFor(key)) reset()
+
+ const shaped = rasterizeText(canvas, { text, font, dpr })
+ if (!shaped) return null
+ const { width, height, pixelWidth, pixelHeight } = shaped
+ let allocation = allocate(pixelWidth, pixelHeight)
+ if (!allocation) {
+ reset()
+ allocation = allocate(pixelWidth, pixelHeight)
+ }
+ if (!allocation) return null
+
+ gl.bindTexture(gl.TEXTURE_2D, texture)
+ gl.texSubImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ allocation.x,
+ allocation.y,
+ gl.RGBA,
+ gl.UNSIGNED_BYTE,
+ canvas
+ )
+
+ const entry = {
+ generation,
+ width,
+ height,
+ pixelWidth,
+ pixelHeight,
+ u0: allocation.x / size,
+ v0: allocation.y / size,
+ u1: (allocation.x + pixelWidth) / size,
+ v1: (allocation.y + pixelHeight) / size,
+ }
+ cache.set(key, entry)
+ return entry
+ }
+
+ const destroy = () => {
+ if (destroyed) return
+ destroyed = true
+ cache.clear()
+ gl.deleteTexture(texture)
+ }
+
+ reset()
+ return {
+ texture,
+ rasterize,
+ destroy,
+ getGPUBytes: () => size * size * 4,
+ get generation() {
+ return generation
+ },
+ }
+}
diff --git a/src/chartLibraries/webgl2/text/index.js b/src/chartLibraries/webgl2/text/index.js
new file mode 100644
index 000000000..52d3613c1
--- /dev/null
+++ b/src/chartLibraries/webgl2/text/index.js
@@ -0,0 +1,121 @@
+import { parseColor } from "@/chartLibraries/gpu/color"
+import { placeRasterizedText } from "@/chartLibraries/gpu/text"
+import { getSharedVisualizationProgram } from "@/chartLibraries/webgl2/engine/programs"
+import makeAtlas from "./atlas"
+
+const nextBufferSize = byteLength => {
+ let size = 4
+ while (size < byteLength) size *= 2
+ return size
+}
+
+const resolveEntries = (atlas, labels, dpr) => {
+ for (let attempt = 0; attempt < 3; attempt += 1) {
+ const generation = atlas.generation
+ const entries = labels.map(({ text, font = "10px sans-serif" }) =>
+ atlas.rasterize({ text: `${text}`, font, dpr })
+ )
+ if (atlas.generation === generation) return entries
+ }
+ throw new Error("WebGL2 text atlas cannot fit the active label set")
+}
+
+export default async surface => {
+ const { gl } = surface
+ const [program, atlas] = await Promise.all([
+ getSharedVisualizationProgram(surface),
+ surface.getResource("netdata-text-atlas-v1", () => makeAtlas(gl)),
+ ])
+ const vertexArray = gl.createVertexArray()
+ const instances = gl.createBuffer()
+ const canvasLocation = gl.getUniformLocation(program, "uCanvas")
+ const passLocation = gl.getUniformLocation(program, "uPassType")
+ const atlasLocation = gl.getUniformLocation(program, "uAtlas")
+ let capacity = 0
+ let count = 0
+ let atlasGeneration = 0
+
+ gl.bindVertexArray(vertexArray)
+ gl.bindBuffer(gl.ARRAY_BUFFER, instances)
+ gl.enableVertexAttribArray(0)
+ gl.vertexAttribPointer(0, 4, gl.FLOAT, false, 64, 0)
+ gl.vertexAttribDivisor(0, 1)
+ gl.enableVertexAttribArray(1)
+ gl.vertexAttribPointer(1, 4, gl.FLOAT, false, 64, 16)
+ gl.vertexAttribDivisor(1, 1)
+ gl.enableVertexAttribArray(2)
+ gl.vertexAttribPointer(2, 4, gl.FLOAT, false, 64, 32)
+ gl.vertexAttribDivisor(2, 1)
+ gl.enableVertexAttribArray(3)
+ gl.vertexAttribPointer(3, 4, gl.FLOAT, false, 64, 48)
+ gl.vertexAttribDivisor(3, 1)
+ gl.bindVertexArray(null)
+
+ const update = ({ labels, dpr }) => {
+ count = labels.length
+ if (!count) return
+
+ const entries = resolveEntries(atlas, labels, dpr)
+ const packed = new Float32Array(count * 16)
+ labels.forEach((label, index) => {
+ const entry = entries[index]
+ if (!entry) return
+ const placement = placeRasterizedText({ label, entry, dpr })
+ const offset = index * 16
+ packed.set(
+ [
+ placement.x,
+ placement.y,
+ placement.width,
+ placement.height,
+ entry.u0,
+ entry.v0,
+ entry.u1,
+ entry.v1,
+ ],
+ offset
+ )
+ packed.set(parseColor(label.color), offset + 8)
+ packed[offset + 12] = 2
+ })
+
+ gl.bindBuffer(gl.ARRAY_BUFFER, instances)
+ if (capacity < packed.byteLength) {
+ capacity = nextBufferSize(packed.byteLength)
+ gl.bufferData(gl.ARRAY_BUFFER, capacity, gl.DYNAMIC_DRAW)
+ }
+ gl.bufferSubData(gl.ARRAY_BUFFER, 0, packed)
+ atlasGeneration = atlas.generation
+ }
+
+ const draw = size => {
+ if (!count) return false
+ gl.useProgram(program)
+ gl.bindVertexArray(vertexArray)
+ gl.uniform1i(passLocation, 1)
+ gl.uniform4f(canvasLocation, size.width, size.height, 0, 0)
+ gl.activeTexture(gl.TEXTURE0)
+ gl.bindTexture(gl.TEXTURE_2D, atlas.texture)
+ gl.uniform1i(atlasLocation, 0)
+ gl.enable(gl.SCISSOR_TEST)
+ gl.scissor(0, 0, size.width, size.height)
+ gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, count)
+ gl.bindVertexArray(null)
+ return true
+ }
+
+ const destroy = () => {
+ gl.deleteBuffer(instances)
+ gl.deleteVertexArray(vertexArray)
+ capacity = 0
+ count = 0
+ }
+
+ return {
+ update,
+ draw,
+ destroy,
+ needsUpdate: () => atlasGeneration !== atlas.generation,
+ getBufferBytes: () => capacity,
+ }
+}
diff --git a/src/chartLibraries/webgl2/visualizations/cartesian/area/resources.js b/src/chartLibraries/webgl2/visualizations/cartesian/area/resources.js
new file mode 100644
index 000000000..827c73081
--- /dev/null
+++ b/src/chartLibraries/webgl2/visualizations/cartesian/area/resources.js
@@ -0,0 +1,4 @@
+import makeLineResources from "../line/resources"
+
+export default (runtime, canvas) =>
+ makeLineResources(runtime, canvas, { fillMode: "area" })
diff --git a/src/chartLibraries/webgl2/visualizations/cartesian/heatmap/resources.js b/src/chartLibraries/webgl2/visualizations/cartesian/heatmap/resources.js
new file mode 100644
index 000000000..5f177b186
--- /dev/null
+++ b/src/chartLibraries/webgl2/visualizations/cartesian/heatmap/resources.js
@@ -0,0 +1,4 @@
+import makeLineResources from "../line/resources"
+
+export default (runtime, canvas) =>
+ makeLineResources(runtime, canvas, { fillMode: "heatmap", markers: false })
diff --git a/src/chartLibraries/webgl2/visualizations/cartesian/heatmap/shader.js b/src/chartLibraries/webgl2/visualizations/cartesian/heatmap/shader.js
new file mode 100644
index 000000000..b0c422a21
--- /dev/null
+++ b/src/chartLibraries/webgl2/visualizations/cartesian/heatmap/shader.js
@@ -0,0 +1,139 @@
+export const vertexShader = `#version 300 es
+precision highp float;
+precision highp int;
+
+const vec3 HEATMAP_COLORS[7] = vec3[7](
+ vec3(62.0, 73.0, 137.0) / 255.0,
+ vec3(49.0, 104.0, 142.0) / 255.0,
+ vec3(38.0, 130.0, 142.0) / 255.0,
+ vec3(31.0, 158.0, 137.0) / 255.0,
+ vec3(53.0, 183.0, 121.0) / 255.0,
+ vec3(110.0, 206.0, 88.0) / 255.0,
+ vec3(181.0, 222.0, 43.0) / 255.0
+);
+
+uniform sampler2D uXValues;
+uniform sampler2D uYValues;
+uniform sampler2D uSeriesColors;
+uniform ivec2 uXTextureSize;
+uniform ivec2 uYTextureSize;
+uniform ivec2 uColorTextureSize;
+uniform vec4 uDomain;
+uniform vec4 uPlot;
+uniform vec4 uCanvas;
+uniform vec4 uFill;
+uniform uvec4 uCounts;
+
+out vec2 vLocal;
+flat out vec2 vSize;
+flat out vec4 vColor;
+
+ivec2 linearCoordinate(int index, ivec2 size) {
+ return ivec2(index % size.x, index / size.x);
+}
+
+float loadValue(sampler2D source, ivec2 size, int index) {
+ return texelFetch(source, linearCoordinate(index, size), 0).r;
+}
+
+vec4 loadMetadata(int index) {
+ return texelFetch(
+ uSeriesColors,
+ linearCoordinate(index, uColorTextureSize),
+ 0
+ );
+}
+
+vec2 quadCoordinates(int vertexIndex) {
+ if (vertexIndex == 0) return vec2(0.0, 0.0);
+ if (vertexIndex == 1) return vec2(1.0, 0.0);
+ if (vertexIndex == 2) return vec2(0.0, 1.0);
+ return vec2(1.0, 1.0);
+}
+
+vec2 toScreen(vec2 point) {
+ float xRange = max(uDomain.y - uDomain.x, 1e-20);
+ float yRange = max(uDomain.w - uDomain.z, 1e-20);
+ return vec2(
+ uPlot.x + ((point.x - uDomain.x) / xRange) * uPlot.z,
+ uPlot.y + (1.0 - (point.y - uDomain.z) / yRange) * uPlot.w
+ );
+}
+
+vec4 heatmapColor(float value, float maximum) {
+ if (value == 0.0) return vec4(0.0);
+ if (isnan(maximum) || maximum <= 0.0) return vec4(HEATMAP_COLORS[0], 1.0);
+ float scaled = value / (maximum / 7.0);
+ float segment = clamp(floor(scaled), 0.0, 5.0);
+ vec3 rgb = mix(
+ HEATMAP_COLORS[int(segment)],
+ HEATMAP_COLORS[int(segment) + 1],
+ scaled - segment
+ );
+ return vec4(clamp(floor(rgb * 255.0 + 0.5), 0.0, 255.0) / 255.0, 1.0);
+}
+
+void main() {
+ uint pointCount = uCounts.x;
+ uint seriesCount = uCounts.y;
+ uint seriesIndex = uint(gl_InstanceID) / pointCount;
+ uint pointIndex = uint(gl_InstanceID) % pointCount;
+ float value = loadValue(
+ uYValues,
+ uYTextureSize,
+ int(pointIndex * seriesCount + seriesIndex)
+ );
+ vec4 metadata = loadMetadata(int(seriesIndex));
+ vec4 color = heatmapColor(value, uFill.w);
+
+ if (metadata.x < 0.0 || color.a <= 0.0) {
+ gl_Position = vec4(-2.0, -2.0, 0.0, 1.0);
+ vLocal = vec2(0.0);
+ vSize = vec2(0.0);
+ vColor = vec4(0.0);
+ return;
+ }
+
+ float x = loadValue(uXValues, uXTextureSize, int(pointIndex));
+ vec2 center = toScreen(vec2(x, metadata.x));
+ float nextRowY = toScreen(vec2(x, metadata.x + 1.0)).y;
+ vec2 fillSize = vec2(uFill.x, abs(center.y - nextRowY));
+ vec2 fillOrigin = center - fillSize * 0.5;
+ vec2 antialiasPadding = vec2(0.5);
+ vec2 outerOrigin = fillOrigin - antialiasPadding;
+ vec2 outerSize = fillSize + antialiasPadding * 2.0;
+ vec2 quad = quadCoordinates(gl_VertexID);
+ vec2 point = outerOrigin + quad * outerSize;
+
+ gl_Position = vec4(
+ point.x / uCanvas.x * 2.0 - 1.0,
+ 1.0 - point.y / uCanvas.y * 2.0,
+ 0.0,
+ 1.0
+ );
+ vLocal = quad * outerSize - antialiasPadding;
+ vSize = fillSize;
+ vColor = color;
+}
+`
+
+export const fragmentShader = `#version 300 es
+precision highp float;
+
+in vec2 vLocal;
+flat in vec2 vSize;
+flat in vec4 vColor;
+out vec4 outputColor;
+
+float axisCoverage(float center, float minimum, float maximum) {
+ return clamp(min(center + 0.5, maximum) - max(center - 0.5, minimum), 0.0, 1.0);
+}
+
+void main() {
+ float coverage =
+ axisCoverage(vLocal.x, 0.0, vSize.x) *
+ axisCoverage(vLocal.y, 0.0, vSize.y);
+ if (coverage <= 0.0) discard;
+ outputColor = vec4(vColor.rgb, vColor.a * coverage);
+}
+`
diff --git a/src/chartLibraries/webgl2/visualizations/cartesian/line/kernel.js b/src/chartLibraries/webgl2/visualizations/cartesian/line/kernel.js
new file mode 100644
index 000000000..a9e534c58
--- /dev/null
+++ b/src/chartLibraries/webgl2/visualizations/cartesian/line/kernel.js
@@ -0,0 +1,218 @@
+import makeRenderState from "@/chartLibraries/gpu/visualizations/cartesian/line/renderState"
+import { getSharedVisualizationProgram } from "@/chartLibraries/webgl2/engine/programs"
+import makeUniformWriter from "@/chartLibraries/webgl2/engine/uniforms"
+import { updateTexture } from "./textures"
+import makeUniformValues from "./uniforms"
+import {
+ fragmentShader as heatmapFragmentShader,
+ vertexShader as heatmapVertexShader,
+} from "../heatmap/shader"
+
+export default async (surface, { fillMode = null } = {}) => {
+ const { gl } = surface
+ const isMultiBar = fillMode === "multiBar"
+ const isHeatmap = fillMode === "heatmap"
+ const usesStackedData = fillMode === "stacked" || fillMode === "stackedBar"
+ const program = isHeatmap
+ ? await surface.getProgram(
+ "heatmap-v1",
+ heatmapVertexShader,
+ heatmapFragmentShader
+ )
+ : await getSharedVisualizationProgram(surface)
+ const vertexArray = gl.createVertexArray()
+ const textures = {
+ x: gl.createTexture(),
+ y: gl.createTexture(),
+ color: gl.createTexture(),
+ ...(usesStackedData && { base: gl.createTexture() }),
+ }
+ const textureStates = {
+ x: { width: 0, height: 0, byteLength: 0 },
+ y: { width: 0, height: 0, byteLength: 0 },
+ color: { width: 0, height: 0, byteLength: 0 },
+ ...(usesStackedData && {
+ base: { width: 0, height: 0, byteLength: 0 },
+ }),
+ }
+ const maxTextureSize = gl.getParameter(gl.MAX_TEXTURE_SIZE)
+ const writeUniforms = makeUniformWriter(gl, program, {
+ uPassType: "int",
+ uXValues: "int",
+ uYValues: "int",
+ uSeriesColors: "int",
+ uBaseValues: "int",
+ uXTextureSize: "ivec2",
+ uYTextureSize: "ivec2",
+ uColorTextureSize: "ivec2",
+ uBaseTextureSize: "ivec2",
+ uDomain: "vec4",
+ uPlot: "vec4",
+ uCanvas: "vec4",
+ uFill: isHeatmap ? "vec4" : "vec3",
+ uCounts: "uvec4",
+ })
+ let drawState = null
+ let bufferBytes = 0
+
+ const update = ({
+ packed,
+ colors,
+ dataChanged,
+ colorsChanged,
+ afterMs,
+ beforeMs,
+ min,
+ max,
+ width,
+ height,
+ dpr,
+ plot = { left: 0, top: 0, width, height },
+ fillAlpha = 0,
+ lineWidth,
+ barWidth = 0,
+ heatmapMax = 0,
+ stepped,
+ smooth,
+ }) => {
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1)
+ if (dataChanged) {
+ gl.activeTexture(gl.TEXTURE0)
+ updateTexture({
+ gl,
+ texture: textures.x,
+ state: textureStates.x,
+ values: packed.x,
+ components: 1,
+ internalFormat: gl.R32F,
+ format: gl.RED,
+ maxTextureSize,
+ })
+ gl.activeTexture(gl.TEXTURE1)
+ updateTexture({
+ gl,
+ texture: textures.y,
+ state: textureStates.y,
+ values: packed.y,
+ components: 1,
+ internalFormat: gl.R32F,
+ format: gl.RED,
+ maxTextureSize,
+ })
+ if (usesStackedData) {
+ gl.activeTexture(gl.TEXTURE3)
+ updateTexture({
+ gl,
+ texture: textures.base,
+ state: textureStates.base,
+ values: packed.base,
+ components: 1,
+ internalFormat: gl.R32F,
+ format: gl.RED,
+ maxTextureSize,
+ })
+ }
+ }
+ if (colorsChanged) {
+ gl.activeTexture(gl.TEXTURE2)
+ updateTexture({
+ gl,
+ texture: textures.color,
+ state: textureStates.color,
+ values: colors,
+ components: 4,
+ internalFormat: gl.RGBA32F,
+ format: gl.RGBA,
+ maxTextureSize,
+ })
+ }
+ bufferBytes = Object.values(textureStates).reduce(
+ (total, state) => total + state.byteLength,
+ 0
+ )
+
+ drawState = makeRenderState({
+ packed,
+ fillMode,
+ afterMs,
+ beforeMs,
+ minimum: min,
+ maximum: max,
+ width,
+ height,
+ dpr,
+ plot,
+ fillAlpha,
+ lineWidth,
+ barWidth,
+ heatmapMaximum: heatmapMax,
+ stepped,
+ smooth,
+ })
+ }
+
+ const draw = size => {
+ if (!drawState?.instanceCount) return false
+ gl.useProgram(program)
+ gl.bindVertexArray(vertexArray)
+ gl.activeTexture(gl.TEXTURE0)
+ gl.bindTexture(gl.TEXTURE_2D, textures.x)
+ gl.activeTexture(gl.TEXTURE1)
+ gl.bindTexture(gl.TEXTURE_2D, textures.y)
+ gl.activeTexture(gl.TEXTURE2)
+ gl.bindTexture(gl.TEXTURE_2D, textures.color)
+ if (usesStackedData) {
+ gl.activeTexture(gl.TEXTURE3)
+ gl.bindTexture(gl.TEXTURE_2D, textures.base)
+ } else if (isMultiBar) {
+ gl.activeTexture(gl.TEXTURE3)
+ gl.bindTexture(gl.TEXTURE_2D, textures.y)
+ }
+ writeUniforms(
+ makeUniformValues({
+ frame: drawState,
+ textureStates,
+ usesStackedData,
+ isMultiBar,
+ isHeatmap,
+ })
+ )
+ gl.enable(gl.SCISSOR_TEST)
+ gl.scissor(
+ drawState.plot.left,
+ size.height - drawState.plot.top - drawState.plot.height,
+ drawState.plot.width,
+ drawState.plot.height
+ )
+ if (drawState.fillInstanceCount) {
+ writeUniforms({ uPassType: drawState.fillPass })
+ gl.drawArraysInstanced(
+ isHeatmap ? gl.TRIANGLE_STRIP : gl.TRIANGLES,
+ 0,
+ isHeatmap ? 4 : 6,
+ drawState.fillInstanceCount
+ )
+ }
+ if (drawState.strokeInstanceCount) {
+ writeUniforms({ uPassType: 0 })
+ gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, drawState.strokeInstanceCount)
+ }
+ gl.bindVertexArray(null)
+ return true
+ }
+
+ const destroy = () => {
+ Object.values(textures).forEach(texture => gl.deleteTexture(texture))
+ gl.deleteVertexArray(vertexArray)
+ drawState = null
+ bufferBytes = 0
+ }
+
+ return {
+ update,
+ draw,
+ destroy,
+ getBufferBytes: () => bufferBytes,
+ getDrawStats: () => drawState?.drawStats || null,
+ }
+}
diff --git a/src/chartLibraries/webgl2/visualizations/cartesian/line/resources.js b/src/chartLibraries/webgl2/visualizations/cartesian/line/resources.js
new file mode 100644
index 000000000..e90249a41
--- /dev/null
+++ b/src/chartLibraries/webgl2/visualizations/cartesian/line/resources.js
@@ -0,0 +1,30 @@
+import createResourceSet from "@/chartLibraries/gpu/engine/createResourceSet"
+import makeSurface from "@/chartLibraries/webgl2/engine/surface"
+import makeCircleLayer from "@/chartLibraries/webgl2/primitives/circle"
+import makeRectLayer from "@/chartLibraries/webgl2/primitives/rect"
+import makeTextLayer from "@/chartLibraries/webgl2/text"
+import makeKernel from "./kernel"
+
+const makeEmptyLayer = () => ({
+ destroy: () => {},
+ draw: () => false,
+ getBufferBytes: () => 0,
+ update: () => {},
+})
+
+export default (
+ runtime,
+ canvas,
+ { fillMode = null, markers = true } = {}
+) => {
+ const surface = makeSurface(runtime, canvas)
+ return createResourceSet(surface, {
+ grid: () => makeRectLayer(surface),
+ interaction: () => makeRectLayer(surface),
+ overlay: () => makeRectLayer(surface),
+ line: () => makeKernel(surface, { fillMode }),
+ marker: () =>
+ markers ? makeCircleLayer(surface) : Promise.resolve(makeEmptyLayer()),
+ text: () => makeTextLayer(surface),
+ })
+}
diff --git a/src/chartLibraries/webgl2/visualizations/cartesian/line/shader.js b/src/chartLibraries/webgl2/visualizations/cartesian/line/shader.js
new file mode 100644
index 000000000..361dc7d77
--- /dev/null
+++ b/src/chartLibraries/webgl2/visualizations/cartesian/line/shader.js
@@ -0,0 +1,14 @@
+import { fragmentSource } from "./shader/fragment"
+import { vertexCommon } from "./shader/common"
+import { vertexFilled } from "./shader/filled"
+import { vertexLine } from "./shader/line"
+import { vertexPrimitives } from "./shader/primitives"
+
+export const vertexShader = [
+ vertexCommon,
+ vertexPrimitives,
+ vertexFilled,
+ vertexLine,
+].join("\n")
+
+export const fragmentShader = fragmentSource
diff --git a/src/chartLibraries/webgl2/visualizations/cartesian/line/shader/common.js b/src/chartLibraries/webgl2/visualizations/cartesian/line/shader/common.js
new file mode 100644
index 000000000..c3c036d53
--- /dev/null
+++ b/src/chartLibraries/webgl2/visualizations/cartesian/line/shader/common.js
@@ -0,0 +1,136 @@
+export const vertexCommon = `#version 300 es
+precision highp float;
+precision highp int;
+
+const float AA_PADDING = 1.0;
+const float SMOOTH_ALPHA = 0.3333333333333333;
+const uint MODE_STEP = 1u;
+const uint MODE_SMOOTH = 2u;
+
+layout(location = 0) in vec4 instanceGeometry;
+layout(location = 1) in vec4 instanceUv;
+layout(location = 2) in vec4 instanceColor;
+layout(location = 3) in vec4 instanceKind;
+uniform int uPassType;
+uniform sampler2D uXValues;
+uniform sampler2D uYValues;
+uniform sampler2D uSeriesColors;
+uniform sampler2D uBaseValues;
+uniform ivec2 uXTextureSize;
+uniform ivec2 uYTextureSize;
+uniform ivec2 uColorTextureSize;
+uniform ivec2 uBaseTextureSize;
+uniform vec4 uDomain;
+uniform vec4 uPlot;
+uniform vec4 uCanvas;
+uniform vec3 uFill;
+uniform uvec4 uCounts;
+
+out float vAcross;
+out vec2 vLocal;
+out vec2 vUv;
+flat out float vWidth;
+flat out float vKind;
+flat out vec4 vColor;
+flat out vec4 vStrokeColor;
+
+struct SmoothControls {
+ vec2 left;
+ vec2 right;
+};
+
+ivec2 linearCoordinate(int index, ivec2 size) {
+ return ivec2(index % size.x, index / size.x);
+}
+
+float loadValue(sampler2D source, ivec2 size, int index) {
+ return texelFetch(source, linearCoordinate(index, size), 0).r;
+}
+
+vec4 loadColor(int index) {
+ return texelFetch(uSeriesColors, linearCoordinate(index, uColorTextureSize), 0);
+}
+
+int valueIndex(uint seriesIndex, uint pointIndex) {
+ if (uFill.z > 0.5 && uFill.z < 1.5) {
+ return int(pointIndex * uCounts.y + seriesIndex);
+ }
+ return int(seriesIndex * uCounts.x + pointIndex);
+}
+
+vec2 quadCoordinates(int vertexIndex) {
+ if (vertexIndex == 0) return vec2(0.0, 0.0);
+ if (vertexIndex == 1) return vec2(1.0, 0.0);
+ if (vertexIndex == 2 || vertexIndex == 3) return vec2(0.0, 1.0);
+ if (vertexIndex == 4) return vec2(1.0, 0.0);
+ return vec2(1.0, 1.0);
+}
+
+vec2 toScreen(vec2 point) {
+ float xRange = max(uDomain.y - uDomain.x, 1e-20);
+ float yRange = max(uDomain.w - uDomain.z, 1e-20);
+ float x = uPlot.x + ((point.x - uDomain.x) / xRange) * uPlot.z;
+ float y = uPlot.y + (1.0 - (point.y - uDomain.z) / yRange) * uPlot.w;
+ return vec2(x, y);
+}
+
+vec2 loadScreenPoint(uint seriesIndex, uint pointIndex) {
+ float x = loadValue(uXValues, uXTextureSize, int(pointIndex));
+ float y = loadValue(uYValues, uYTextureSize, valueIndex(seriesIndex, pointIndex));
+ return toScreen(vec2(x, y));
+}
+
+bool validScreenPoint(vec2 point) {
+ return !isnan(point.y);
+}
+
+SmoothControls smoothControls(uint seriesIndex, uint pointIndex) {
+ vec2 point = loadScreenPoint(seriesIndex, pointIndex);
+ SmoothControls controls;
+ controls.left = point;
+ controls.right = point;
+ if (pointIndex == 0u || pointIndex + 1u >= uCounts.x) return controls;
+
+ vec2 previous = loadScreenPoint(seriesIndex, pointIndex - 1u);
+ vec2 next = loadScreenPoint(seriesIndex, pointIndex + 1u);
+ if (!validScreenPoint(previous) || !validScreenPoint(point) || !validScreenPoint(next)) {
+ return controls;
+ }
+
+ vec2 left = (1.0 - SMOOTH_ALPHA) * point + SMOOTH_ALPHA * previous;
+ vec2 right = (1.0 - SMOOTH_ALPHA) * point + SMOOTH_ALPHA * next;
+ if (left.x != right.x) {
+ float deltaY = point.y - right.y - ((point.x - right.x) * (left.y - right.y)) /
+ (left.x - right.x);
+ left.y += deltaY;
+ right.y += deltaY;
+ }
+
+ if (left.y > previous.y && left.y > point.y) {
+ left.y = max(previous.y, point.y);
+ right.y = 2.0 * point.y - left.y;
+ } else if (left.y < previous.y && left.y < point.y) {
+ left.y = min(previous.y, point.y);
+ right.y = 2.0 * point.y - left.y;
+ }
+
+ if (right.y > point.y && right.y > next.y) {
+ right.y = max(point.y, next.y);
+ left.y = 2.0 * point.y - right.y;
+ } else if (right.y < point.y && right.y < next.y) {
+ right.y = min(point.y, next.y);
+ left.y = 2.0 * point.y - right.y;
+ }
+
+ controls.left = left;
+ controls.right = right;
+ return controls;
+}
+
+vec2 cubicPoint(vec2 a, vec2 c1, vec2 c2, vec2 b, float t) {
+ vec2 q0 = mix(a, c1, t);
+ vec2 q1 = mix(c1, c2, t);
+ vec2 q2 = mix(c2, b, t);
+ return mix(mix(q0, q1, t), mix(q1, q2, t), t);
+}
+`
diff --git a/src/chartLibraries/webgl2/visualizations/cartesian/line/shader/filled.js b/src/chartLibraries/webgl2/visualizations/cartesian/line/shader/filled.js
new file mode 100644
index 000000000..f84e3b60a
--- /dev/null
+++ b/src/chartLibraries/webgl2/visualizations/cartesian/line/shader/filled.js
@@ -0,0 +1,147 @@
+export const vertexFilled = `void areaOutput() {
+ uint pairsPerSeries = uCounts.x - 1u;
+ uint reverseSeriesIndex = uint(gl_InstanceID) / pairsPerSeries;
+ uint seriesIndex = uCounts.y - reverseSeriesIndex - 1u;
+ uint pairIndex = uint(gl_InstanceID) % pairsPerSeries;
+
+ float x0 = loadValue(uXValues, uXTextureSize, int(pairIndex));
+ float x1 = loadValue(uXValues, uXTextureSize, int(pairIndex + 1u));
+ float y0 = loadValue(uYValues, uYTextureSize, valueIndex(seriesIndex, pairIndex));
+ float y1 = loadValue(uYValues, uYTextureSize, valueIndex(seriesIndex, pairIndex + 1u));
+ vec4 color = loadColor(int(seriesIndex));
+ if (isnan(y0) || isnan(y1) || color.a <= 0.0 || uFill.y <= 0.0) {
+ gapOutput(color);
+ return;
+ }
+
+ vec2 topA = toScreen(vec2(x0, y0));
+ vec2 topB = toScreen(vec2(x1, y1));
+ if (uint(uCanvas.w) == MODE_STEP) topB.y = topA.y;
+
+ vec2 baselineA = toScreen(vec2(x0, uFill.x));
+ vec2 baselineB = toScreen(vec2(x1, uFill.x));
+ float plotBottom = uPlot.y + uPlot.w;
+ baselineA.y = clamp(baselineA.y, uPlot.y, plotBottom);
+ baselineB.y = clamp(baselineB.y, uPlot.y, plotBottom);
+
+ vec2 quad = quadCoordinates(gl_VertexID);
+ vec2 top = mix(topA, topB, quad.x);
+ vec2 baseline = mix(baselineA, baselineB, quad.x);
+ vec2 point = mix(top, baseline, quad.y);
+ gl_Position = vec4(
+ point.x / uCanvas.x * 2.0 - 1.0,
+ 1.0 - point.y / uCanvas.y * 2.0,
+ 0.0,
+ 1.0
+ );
+ vAcross = 0.0;
+ vLocal = vec2(0.0);
+ vUv = vec2(0.0);
+ vWidth = 0.0;
+ vKind = 0.0;
+ vColor = vec4(color.rgb, color.a * uFill.y);
+ vStrokeColor = color;
+}
+
+void stackedAreaOutput() {
+ uint pairsPerSeries = uCounts.x - 1u;
+ uint seriesIndex = uint(gl_InstanceID) / pairsPerSeries;
+ uint pairIndex = uint(gl_InstanceID) % pairsPerSeries;
+ int offset = valueIndex(seriesIndex, pairIndex);
+ int nextOffset = valueIndex(seriesIndex, pairIndex + 1u);
+
+ float x0 = loadValue(uXValues, uXTextureSize, int(pairIndex));
+ float x1 = loadValue(uXValues, uXTextureSize, int(pairIndex + 1u));
+ float end0 = loadValue(uYValues, uYTextureSize, offset);
+ float end1 = loadValue(uYValues, uYTextureSize, nextOffset);
+ float base0 = loadValue(uBaseValues, uBaseTextureSize, offset);
+ float base1 = loadValue(uBaseValues, uBaseTextureSize, nextOffset);
+ vec4 color = loadColor(int(seriesIndex));
+ if (
+ isnan(end0) || isnan(end1) || isnan(base0) || isnan(base1) ||
+ color.a <= 0.0 || uFill.y <= 0.0
+ ) {
+ gapOutput(color);
+ return;
+ }
+
+ vec2 topA = toScreen(vec2(x0, end0));
+ vec2 topB = toScreen(vec2(x1, end1));
+ if (uint(uCanvas.w) == MODE_STEP) topB.y = topA.y;
+ vec2 baselineA = toScreen(vec2(x0, base0));
+ vec2 baselineB = toScreen(vec2(x1, base1));
+ vec2 quad = quadCoordinates(gl_VertexID);
+ vec2 top = mix(topA, topB, quad.x);
+ vec2 baseline = mix(baselineA, baselineB, quad.x);
+ vec2 point = mix(top, baseline, quad.y);
+ gl_Position = vec4(
+ point.x / uCanvas.x * 2.0 - 1.0,
+ 1.0 - point.y / uCanvas.y * 2.0,
+ 0.0,
+ 1.0
+ );
+ vAcross = 0.0;
+ vLocal = vec2(0.0);
+ vUv = vec2(0.0);
+ vWidth = 0.0;
+ vKind = 0.0;
+ vColor = vec4(color.rgb, color.a * uFill.y);
+ vStrokeColor = color;
+}
+
+void stackedBarOutput() {
+ uint pointCount = uCounts.x;
+ uint seriesIndex = uint(gl_InstanceID) / pointCount;
+ uint pointIndex = uint(gl_InstanceID) % pointCount;
+ bool isMultiBar = uFill.z > 1.5;
+ int offset = valueIndex(seriesIndex, pointIndex);
+ int colorOffset = int(seriesIndex) * (isMultiBar ? 3 : 2);
+ float x = loadValue(uXValues, uXTextureSize, int(pointIndex));
+ float end = loadValue(uYValues, uYTextureSize, offset);
+ float base = isMultiBar
+ ? uFill.y
+ : loadValue(uBaseValues, uBaseTextureSize, offset);
+ vec4 color = loadColor(colorOffset);
+ vec4 strokeColor = loadColor(colorOffset + 1);
+ vec2 visibility = isMultiBar ? loadColor(colorOffset + 2).xy : vec2(0.0, 1.0);
+ if (
+ isnan(end) || isnan(base) || color.a <= 0.0 ||
+ visibility.x < 0.0 || visibility.y <= 0.0
+ ) {
+ gapOutput(color);
+ return;
+ }
+
+ vec2 center = toScreen(vec2(x, end));
+ float baseY = toScreen(vec2(x, base)).y;
+ float barWidth = uFill.x;
+ float xLeft = center.x - barWidth * 0.5;
+ if (isMultiBar) {
+ float rankDenominator = visibility.y > 1.0 ? visibility.y - 1.0 : 1.0;
+ xLeft = center.x - uFill.x * 0.5 *
+ (1.0 - visibility.x / rankDenominator);
+ barWidth = uFill.x / visibility.y;
+ }
+ float strokeWidth = max(0.0, uCanvas.z);
+ vec2 fillOrigin = vec2(xLeft, min(center.y, baseY));
+ vec2 fillSize = vec2(barWidth, abs(center.y - baseY));
+ vec2 antialiasPadding = vec2(isMultiBar ? 0.5 : 0.0, 0.5);
+ vec2 outerOrigin = fillOrigin - vec2(strokeWidth * 0.5) - antialiasPadding;
+ vec2 outerSize = fillSize + vec2(strokeWidth) + antialiasPadding * 2.0;
+ vec2 quad = quadCoordinates(gl_VertexID);
+ vec2 point = outerOrigin + quad * outerSize;
+ gl_Position = vec4(
+ point.x / uCanvas.x * 2.0 - 1.0,
+ 1.0 - point.y / uCanvas.y * 2.0,
+ 0.0,
+ 1.0
+ );
+ vAcross = 0.0;
+ vLocal = quad * outerSize - vec2(strokeWidth * 0.5) - antialiasPadding;
+ vUv = fillSize;
+ vWidth = strokeWidth;
+ vKind = 4.0;
+ vColor = color;
+ vStrokeColor = strokeColor;
+}
+`
diff --git a/src/chartLibraries/webgl2/visualizations/cartesian/line/shader/fragment.js b/src/chartLibraries/webgl2/visualizations/cartesian/line/shader/fragment.js
new file mode 100644
index 000000000..1164847ba
--- /dev/null
+++ b/src/chartLibraries/webgl2/visualizations/cartesian/line/shader/fragment.js
@@ -0,0 +1,82 @@
+export const fragmentSource = `#version 300 es
+precision highp float;
+precision highp int;
+
+// Matches Canvas2D fillRect/strokeRect subpixel edge composition.
+const float CANVAS_STROKE_COVERAGE = 1.17;
+
+uniform int uPassType;
+uniform sampler2D uAtlas;
+in float vAcross;
+in vec2 vLocal;
+in vec2 vUv;
+flat in float vWidth;
+flat in float vKind;
+flat in vec4 vColor;
+flat in vec4 vStrokeColor;
+out vec4 outputColor;
+
+float axisCoverage(float center, float minimum, float maximum) {
+ return clamp(min(center + 0.5, maximum) - max(center - 0.5, minimum), 0.0, 1.0);
+}
+
+float rectCoverage(vec2 point, vec2 minimum, vec2 maximum) {
+ if (maximum.x <= minimum.x || maximum.y <= minimum.y) return 0.0;
+ return axisCoverage(point.x, minimum.x, maximum.x) *
+ axisCoverage(point.y, minimum.y, maximum.y);
+}
+
+void main() {
+ if (uPassType == 1) {
+ float alpha = vColor.a;
+ if (vKind > 0.5 && vKind < 1.5) {
+ float distanceFromCenter = length(vLocal);
+ float antialias = max(fwidth(distanceFromCenter), 1e-3);
+ alpha *= 1.0 - smoothstep(1.0 - antialias, 1.0, distanceFromCenter);
+ } else if (vKind > 1.5) {
+ alpha *= texture(uAtlas, vUv).a;
+ }
+ outputColor = vec4(vColor.rgb, alpha);
+ return;
+ }
+ if (uPassType == 2 || uPassType == 3) {
+ outputColor = vColor;
+ return;
+ }
+ if (uPassType == 4) {
+ float fillCoverage = rectCoverage(vLocal, vec2(0.0), vUv);
+ float halfStroke = vWidth * 0.5;
+ float outerCoverage = rectCoverage(
+ vLocal,
+ vec2(-halfStroke),
+ vUv + vec2(halfStroke)
+ );
+ float innerCoverage = rectCoverage(
+ vLocal,
+ vec2(halfStroke),
+ vUv - vec2(halfStroke)
+ );
+ float strokeCoverage = clamp(
+ (outerCoverage - innerCoverage) * CANVAS_STROKE_COVERAGE,
+ 0.0,
+ 1.0
+ );
+ float fillAlpha = vColor.a * fillCoverage * (1.0 - strokeCoverage);
+ float alpha = strokeCoverage + fillAlpha;
+ if (alpha <= 0.0) discard;
+ vec3 premultiplied =
+ vStrokeColor.rgb * strokeCoverage + vColor.rgb * fillAlpha;
+ outputColor = vec4(premultiplied / alpha, alpha);
+ return;
+ }
+
+ const float AA_PADDING = 1.0;
+ float center = vWidth * 0.5 + AA_PADDING;
+ float distanceFromCenter = abs(vAcross - center);
+ float antialias = max(fwidth(vAcross), 1e-3) * 0.75;
+ float inner = max(0.0, vWidth * 0.5 - antialias);
+ float outer = vWidth * 0.5 + antialias;
+ float coverage = 1.0 - smoothstep(inner, outer, distanceFromCenter);
+ outputColor = vec4(vColor.rgb, vColor.a * coverage);
+}
+`
diff --git a/src/chartLibraries/webgl2/visualizations/cartesian/line/shader/line.js b/src/chartLibraries/webgl2/visualizations/cartesian/line/shader/line.js
new file mode 100644
index 000000000..b4cbeb026
--- /dev/null
+++ b/src/chartLibraries/webgl2/visualizations/cartesian/line/shader/line.js
@@ -0,0 +1,80 @@
+export const vertexLine = `void main() {
+ if (uPassType == 1) {
+ primitiveOutput();
+ return;
+ }
+ if (uPassType == 2) {
+ areaOutput();
+ return;
+ }
+ if (uPassType == 3) {
+ stackedAreaOutput();
+ return;
+ }
+ if (uPassType == 4) {
+ stackedBarOutput();
+ return;
+ }
+
+ uint segmentsPerPair = uCounts.z;
+ uint segmentsPerSeries = uCounts.w;
+ uint instanceIndex = uint(gl_InstanceID);
+ uint seriesIndex = instanceIndex / segmentsPerSeries;
+ uint localSegment = instanceIndex % segmentsPerSeries;
+ uint pairIndex = localSegment / segmentsPerPair;
+ uint pairSegment = localSegment % segmentsPerPair;
+ uint mode = uint(uCanvas.w);
+
+ float x0 = loadValue(uXValues, uXTextureSize, int(pairIndex));
+ float x1 = loadValue(uXValues, uXTextureSize, int(pairIndex + 1u));
+ float y0 = loadValue(uYValues, uYTextureSize, valueIndex(seriesIndex, pairIndex));
+ float y1 = loadValue(uYValues, uYTextureSize, valueIndex(seriesIndex, pairIndex + 1u));
+ vec4 color = loadColor(int(seriesIndex));
+ vec2 sourceA = toScreen(vec2(x0, y0));
+ vec2 sourceB = toScreen(vec2(x1, y1));
+
+ if (isnan(y0) || isnan(y1) || color.a <= 0.0) {
+ gapOutput(color);
+ return;
+ }
+
+ vec2 screenA = sourceA;
+ vec2 screenB = sourceB;
+ if (mode == MODE_STEP) {
+ if (pairSegment == 0u) screenB = vec2(sourceB.x, sourceA.y);
+ else screenA = vec2(sourceB.x, sourceA.y);
+ } else if (mode == MODE_SMOOTH && segmentsPerPair > 1u) {
+ SmoothControls controlsA = smoothControls(seriesIndex, pairIndex);
+ SmoothControls controlsB = smoothControls(seriesIndex, pairIndex + 1u);
+ float t0 = float(pairSegment) / float(segmentsPerPair);
+ float t1 = float(pairSegment + 1u) / float(segmentsPerPair);
+ screenA = cubicPoint(sourceA, controlsA.right, controlsB.left, sourceB, t0);
+ screenB = cubicPoint(sourceA, controlsA.right, controlsB.left, sourceB, t1);
+ }
+
+ vec2 delta = screenB - screenA;
+ float lengthPixels = length(delta);
+ if (lengthPixels < 1e-6) {
+ gapOutput(color);
+ return;
+ }
+
+ vec2 quad = quadCoordinates(gl_VertexID);
+ vec2 perpendicular = vec2(delta.y, -delta.x) / lengthPixels;
+ float width = max(0.01, uCanvas.z);
+ float halfExtent = width * 0.5 + AA_PADDING;
+ float side = mix(1.0, -1.0, quad.y);
+ vec2 screenPosition = mix(screenA, screenB, quad.x) + perpendicular * halfExtent * side;
+ float clipX = screenPosition.x / uCanvas.x * 2.0 - 1.0;
+ float clipY = 1.0 - screenPosition.y / uCanvas.y * 2.0;
+
+ gl_Position = vec4(clipX, clipY, 0.0, 1.0);
+ vAcross = halfExtent * (1.0 + side);
+ vLocal = vec2(0.0);
+ vUv = vec2(0.0);
+ vWidth = width;
+ vKind = 0.0;
+ vColor = color;
+ vStrokeColor = color;
+}
+`
diff --git a/src/chartLibraries/webgl2/visualizations/cartesian/line/shader/primitives.js b/src/chartLibraries/webgl2/visualizations/cartesian/line/shader/primitives.js
new file mode 100644
index 000000000..27c90ffd9
--- /dev/null
+++ b/src/chartLibraries/webgl2/visualizations/cartesian/line/shader/primitives.js
@@ -0,0 +1,32 @@
+export const vertexPrimitives = `void gapOutput(vec4 color) {
+ gl_Position = vec4(-2.0, -2.0, 0.0, 1.0);
+ vAcross = 0.0;
+ vLocal = vec2(0.0);
+ vUv = vec2(0.0);
+ vWidth = 0.0;
+ vKind = 0.0;
+ vColor = color;
+ vStrokeColor = color;
+}
+
+void primitiveOutput() {
+ vec2 quad = quadCoordinates(gl_VertexID);
+ vec2 local = quad * 2.0 - 1.0;
+ vec2 point = instanceKind.x < 0.5 || instanceKind.x > 1.5
+ ? instanceGeometry.xy + quad * instanceGeometry.zw
+ : instanceGeometry.xy + local * instanceGeometry.z;
+ gl_Position = vec4(
+ point.x / uCanvas.x * 2.0 - 1.0,
+ 1.0 - point.y / uCanvas.y * 2.0,
+ 0.0,
+ 1.0
+ );
+ vAcross = 0.0;
+ vLocal = local;
+ vUv = mix(instanceUv.xy, instanceUv.zw, quad);
+ vWidth = 0.0;
+ vKind = instanceKind.x;
+ vColor = instanceColor;
+ vStrokeColor = instanceColor;
+}
+`
diff --git a/src/chartLibraries/webgl2/visualizations/cartesian/line/textures.js b/src/chartLibraries/webgl2/visualizations/cartesian/line/textures.js
new file mode 100644
index 000000000..40e72ce8a
--- /dev/null
+++ b/src/chartLibraries/webgl2/visualizations/cartesian/line/textures.js
@@ -0,0 +1,59 @@
+export const makeTextureLayout = (values, components, maxTextureSize) => {
+ const texelCount = Math.max(1, Math.ceil(values.length / components))
+ const width = Math.min(maxTextureSize, texelCount)
+ const height = Math.ceil(texelCount / width)
+ if (height > maxTextureSize)
+ throw new Error("WebGL2 texture capacity exceeded")
+
+ const valueCount = width * height * components
+ if (values.length === valueCount) return { width, height, values }
+ const padded = new values.constructor(valueCount)
+ padded.set(values)
+ return { width, height, values: padded }
+}
+
+export const updateTexture = ({
+ gl,
+ texture,
+ state,
+ values,
+ components,
+ internalFormat,
+ format,
+ maxTextureSize,
+}) => {
+ const layout = makeTextureLayout(values, components, maxTextureSize)
+ gl.bindTexture(gl.TEXTURE_2D, texture)
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
+ if (state.width !== layout.width || state.height !== layout.height) {
+ gl.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ internalFormat,
+ layout.width,
+ layout.height,
+ 0,
+ format,
+ gl.FLOAT,
+ layout.values
+ )
+ } else {
+ gl.texSubImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ 0,
+ 0,
+ layout.width,
+ layout.height,
+ format,
+ gl.FLOAT,
+ layout.values
+ )
+ }
+ state.width = layout.width
+ state.height = layout.height
+ state.byteLength = layout.values.byteLength
+}
diff --git a/src/chartLibraries/webgl2/visualizations/cartesian/line/textures.test.js b/src/chartLibraries/webgl2/visualizations/cartesian/line/textures.test.js
new file mode 100644
index 000000000..dca2fe2cb
--- /dev/null
+++ b/src/chartLibraries/webgl2/visualizations/cartesian/line/textures.test.js
@@ -0,0 +1,25 @@
+import { makeTextureLayout } from "./textures"
+
+describe("WebGL2 line texture layout", () => {
+ it("retains exact values when the texture is full", () => {
+ const values = new Float32Array([1, 2, 3, 4])
+ const layout = makeTextureLayout(values, 1, 2)
+
+ expect(layout).toEqual({ width: 2, height: 2, values })
+ })
+
+ it("pads only the unused tail of a rectangular texture", () => {
+ const values = new Float32Array([1, 2, 3, 4, 5])
+ const layout = makeTextureLayout(values, 1, 4)
+
+ expect(layout.width).toBe(4)
+ expect(layout.height).toBe(2)
+ expect([...layout.values]).toEqual([1, 2, 3, 4, 5, 0, 0, 0])
+ })
+
+ it("rejects values beyond the available texture capacity", () => {
+ expect(() => makeTextureLayout(new Float32Array(5), 1, 2)).toThrow(
+ /texture capacity exceeded/
+ )
+ })
+})
diff --git a/src/chartLibraries/webgl2/visualizations/cartesian/line/uniforms.js b/src/chartLibraries/webgl2/visualizations/cartesian/line/uniforms.js
new file mode 100644
index 000000000..978928077
--- /dev/null
+++ b/src/chartLibraries/webgl2/visualizations/cartesian/line/uniforms.js
@@ -0,0 +1,57 @@
+export default ({
+ frame,
+ textureStates,
+ usesStackedData,
+ isMultiBar,
+ isHeatmap,
+}) => ({
+ uXValues: 0,
+ uYValues: 1,
+ uSeriesColors: 2,
+ ...((usesStackedData || isMultiBar) && { uBaseValues: 3 }),
+ uXTextureSize: [textureStates.x.width, textureStates.x.height],
+ uYTextureSize: [textureStates.y.width, textureStates.y.height],
+ uColorTextureSize: [
+ textureStates.color.width,
+ textureStates.color.height,
+ ],
+ ...(usesStackedData && {
+ uBaseTextureSize: [
+ textureStates.base.width,
+ textureStates.base.height,
+ ],
+ }),
+ ...(isMultiBar && {
+ uBaseTextureSize: [textureStates.y.width, textureStates.y.height],
+ }),
+ uDomain: [
+ frame.domain.after,
+ frame.domain.before,
+ frame.domain.minimum,
+ frame.domain.maximum,
+ ],
+ uPlot: [
+ frame.plot.left,
+ frame.plot.top,
+ frame.plot.width,
+ frame.plot.height,
+ ],
+ uCanvas: [
+ frame.canvas.width,
+ frame.canvas.height,
+ frame.canvas.lineWidth,
+ frame.canvas.mode,
+ ],
+ uFill: [
+ frame.fill.baseline,
+ frame.fill.opacity,
+ frame.fill.mode,
+ ...(isHeatmap ? [frame.fill.heatmapMaximum] : []),
+ ],
+ uCounts: [
+ frame.counts.points,
+ frame.counts.series,
+ frame.counts.segmentsPerPair,
+ frame.counts.segmentsPerSeries,
+ ],
+})
diff --git a/src/chartLibraries/webgl2/visualizations/cartesian/line/uniforms.test.js b/src/chartLibraries/webgl2/visualizations/cartesian/line/uniforms.test.js
new file mode 100644
index 000000000..a3451e418
--- /dev/null
+++ b/src/chartLibraries/webgl2/visualizations/cartesian/line/uniforms.test.js
@@ -0,0 +1,49 @@
+import makeUniformValues from "./uniforms"
+
+const frame = {
+ domain: { after: 1, before: 2, minimum: 3, maximum: 4 },
+ plot: { left: 5, top: 6, width: 7, height: 8 },
+ canvas: { width: 9, height: 10, lineWidth: 11, mode: 12 },
+ fill: { baseline: 13, opacity: 14, mode: 15, heatmapMaximum: 16 },
+ counts: { points: 17, series: 18, segmentsPerPair: 19, segmentsPerSeries: 20 },
+}
+
+const textureStates = {
+ x: { width: 21, height: 22 },
+ y: { width: 23, height: 24 },
+ color: { width: 25, height: 26 },
+ base: { width: 27, height: 28 },
+}
+
+describe("WebGL2 line uniform packing", () => {
+ it("packs the shared shader with exact declared arity", () => {
+ const values = makeUniformValues({
+ frame,
+ textureStates,
+ usesStackedData: true,
+ isMultiBar: false,
+ isHeatmap: false,
+ })
+
+ expect(values.uDomain).toEqual([1, 2, 3, 4])
+ expect(values.uPlot).toEqual([5, 6, 7, 8])
+ expect(values.uCanvas).toEqual([9, 10, 11, 12])
+ expect(values.uFill).toEqual([13, 14, 15])
+ expect(values.uCounts).toEqual([17, 18, 19, 20])
+ expect(values.uBaseTextureSize).toEqual([27, 28])
+ })
+
+ it("adds the fourth fill value only for the Heatmap shader", () => {
+ const values = makeUniformValues({
+ frame,
+ textureStates,
+ usesStackedData: false,
+ isMultiBar: false,
+ isHeatmap: true,
+ })
+
+ expect(values.uFill).toEqual([13, 14, 15, 16])
+ expect(values).not.toHaveProperty("uBaseValues")
+ expect(values).not.toHaveProperty("uBaseTextureSize")
+ })
+})
diff --git a/src/chartLibraries/webgl2/visualizations/cartesian/multiBar/resources.js b/src/chartLibraries/webgl2/visualizations/cartesian/multiBar/resources.js
new file mode 100644
index 000000000..44f914760
--- /dev/null
+++ b/src/chartLibraries/webgl2/visualizations/cartesian/multiBar/resources.js
@@ -0,0 +1,4 @@
+import makeLineResources from "../line/resources"
+
+export default (runtime, canvas) =>
+ makeLineResources(runtime, canvas, { fillMode: "multiBar", markers: false })
diff --git a/src/chartLibraries/webgl2/visualizations/cartesian/stacked/resources.js b/src/chartLibraries/webgl2/visualizations/cartesian/stacked/resources.js
new file mode 100644
index 000000000..a6f6f2100
--- /dev/null
+++ b/src/chartLibraries/webgl2/visualizations/cartesian/stacked/resources.js
@@ -0,0 +1,4 @@
+import makeLineResources from "../line/resources"
+
+export default (runtime, canvas) =>
+ makeLineResources(runtime, canvas, { fillMode: "stacked" })
diff --git a/src/chartLibraries/webgl2/visualizations/cartesian/stackedBar/resources.js b/src/chartLibraries/webgl2/visualizations/cartesian/stackedBar/resources.js
new file mode 100644
index 000000000..71d7bf097
--- /dev/null
+++ b/src/chartLibraries/webgl2/visualizations/cartesian/stackedBar/resources.js
@@ -0,0 +1,4 @@
+import makeLineResources from "../line/resources"
+
+export default (runtime, canvas) =>
+ makeLineResources(runtime, canvas, { fillMode: "stackedBar", markers: false })
diff --git a/src/chartLibraries/webgl2/visualizations/index.js b/src/chartLibraries/webgl2/visualizations/index.js
new file mode 100644
index 000000000..244a64ecc
--- /dev/null
+++ b/src/chartLibraries/webgl2/visualizations/index.js
@@ -0,0 +1,25 @@
+import makeRegistry from "@/chartLibraries/gpu/visualizations/makeRegistry"
+import area from "./cartesian/area/resources"
+import heatmap from "./cartesian/heatmap/resources"
+import line from "./cartesian/line/resources"
+import multiBar from "./cartesian/multiBar/resources"
+import stacked from "./cartesian/stacked/resources"
+import stackedBar from "./cartesian/stackedBar/resources"
+import d3pie from "./radial/d3Pie/resources"
+import easypiechart from "./radial/easyPie/resources"
+import gauge from "./radial/gauge/resources"
+
+const registry = makeRegistry({
+ area,
+ d3pie,
+ easypiechart,
+ gauge,
+ heatmap,
+ line,
+ multiBar,
+ stacked,
+ stackedBar,
+})
+
+export const hasVisualization = registry.has
+export const getVisualization = registry.get
diff --git a/src/chartLibraries/webgl2/visualizations/radial/d3Pie/kernel.js b/src/chartLibraries/webgl2/visualizations/radial/d3Pie/kernel.js
new file mode 100644
index 000000000..5c45d56de
--- /dev/null
+++ b/src/chartLibraries/webgl2/visualizations/radial/d3Pie/kernel.js
@@ -0,0 +1,73 @@
+import { fragmentShader, vertexShader } from "./shader"
+
+const MAX_SEGMENTS = 6
+
+export default async surface => {
+ const { gl } = surface
+ const program = await surface.getProgram("d3pie-v1", vertexShader, fragmentShader)
+ const vertexArray = gl.createVertexArray()
+ const uniforms = Object.fromEntries(
+ [
+ "uCanvas",
+ "uGeometry",
+ "uStrokeColor",
+ "uSegmentGeometry[0]",
+ "uSegmentColors[0]",
+ ].map(name => [name, gl.getUniformLocation(program, name)])
+ )
+ let frame = null
+
+ const update = nextFrame => {
+ if (nextFrame.segments.length > MAX_SEGMENTS)
+ throw new Error(`GPU D3 Pie supports at most ${MAX_SEGMENTS} grouped segments`)
+ const segmentGeometry = new Float32Array(MAX_SEGMENTS * 4)
+ const segmentColors = new Float32Array(MAX_SEGMENTS * 4)
+ nextFrame.segments.forEach((segment, index) => {
+ segmentGeometry.set(
+ [segment.startAngle, segment.endAngle, segment.offsetX, segment.offsetY],
+ index * 4
+ )
+ segmentColors.set(segment.color, index * 4)
+ })
+ frame = {
+ canvas: [
+ nextFrame.width * nextFrame.dpr,
+ nextFrame.height * nextFrame.dpr,
+ nextFrame.centerX,
+ nextFrame.centerY,
+ ],
+ geometry: [
+ nextFrame.innerRadius,
+ nextFrame.outerRadius,
+ nextFrame.strokeWidth,
+ nextFrame.segments.length,
+ ],
+ strokeColor: nextFrame.strokeColor,
+ segmentGeometry,
+ segmentColors,
+ }
+ }
+
+ const draw = size => {
+ if (!frame) return false
+ gl.useProgram(program)
+ gl.bindVertexArray(vertexArray)
+ gl.uniform4fv(uniforms.uCanvas, frame.canvas)
+ gl.uniform4fv(uniforms.uGeometry, frame.geometry)
+ gl.uniform4fv(uniforms.uStrokeColor, frame.strokeColor)
+ gl.uniform4fv(uniforms["uSegmentGeometry[0]"], frame.segmentGeometry)
+ gl.uniform4fv(uniforms["uSegmentColors[0]"], frame.segmentColors)
+ gl.enable(gl.SCISSOR_TEST)
+ gl.scissor(0, 0, size.width, size.height)
+ gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
+ gl.bindVertexArray(null)
+ return true
+ }
+
+ const destroy = () => {
+ frame = null
+ gl.deleteVertexArray(vertexArray)
+ }
+
+ return { update, draw, destroy, getBufferBytes: () => 0 }
+}
diff --git a/src/chartLibraries/webgl2/visualizations/radial/d3Pie/resources.js b/src/chartLibraries/webgl2/visualizations/radial/d3Pie/resources.js
new file mode 100644
index 000000000..0389380ca
--- /dev/null
+++ b/src/chartLibraries/webgl2/visualizations/radial/d3Pie/resources.js
@@ -0,0 +1,4 @@
+import makeResources from "../makeResources"
+import makeKernel from "./kernel"
+
+export default makeResources(makeKernel)
diff --git a/src/chartLibraries/webgl2/visualizations/radial/d3Pie/shader.js b/src/chartLibraries/webgl2/visualizations/radial/d3Pie/shader.js
new file mode 100644
index 000000000..5a2c243bf
--- /dev/null
+++ b/src/chartLibraries/webgl2/visualizations/radial/d3Pie/shader.js
@@ -0,0 +1,84 @@
+export const vertexShader = `#version 300 es
+precision highp float;
+uniform vec4 uCanvas;
+out vec2 vPoint;
+vec2 quadCoordinates(int vertexIndex) {
+ if (vertexIndex == 0) return vec2(0.0, 0.0);
+ if (vertexIndex == 1) return vec2(1.0, 0.0);
+ if (vertexIndex == 2) return vec2(0.0, 1.0);
+ return vec2(1.0, 1.0);
+}
+void main() {
+ vec2 quad = quadCoordinates(gl_VertexID);
+ gl_Position = vec4(quad.x * 2.0 - 1.0, 1.0 - quad.y * 2.0, 0.0, 1.0);
+ vPoint = quad * uCanvas.xy - uCanvas.zw;
+}
+`
+
+export const fragmentShader = `#version 300 es
+precision highp float;
+const float TAU = 6.283185307179586;
+const int MAX_SEGMENTS = 6;
+uniform vec4 uGeometry;
+uniform vec4 uStrokeColor;
+uniform vec4 uSegmentGeometry[MAX_SEGMENTS];
+uniform vec4 uSegmentColors[MAX_SEGMENTS];
+in vec2 vPoint;
+out vec4 outputColor;
+
+float normalizedAngle(float angle) {
+ return angle - floor(angle / TAU) * TAU;
+}
+float intervalCoverage(float value, float minimum, float maximum) {
+ if (maximum <= minimum) return 0.0;
+ return clamp(min(value + 0.5, maximum) - max(value - 0.5, minimum), 0.0, 1.0);
+}
+float bandCoverage(float distance, float halfWidth) {
+ return clamp(halfWidth + 0.5 - distance, 0.0, 1.0);
+}
+float rayCoverage(vec2 point, float angle, float halfWidth) {
+ vec2 direction = vec2(sin(angle), -cos(angle));
+ float projection = dot(point, direction);
+ float distance = abs(direction.x * point.y - direction.y * point.x);
+ float radial = intervalCoverage(projection, uGeometry.x, uGeometry.y);
+ return bandCoverage(distance, halfWidth) * radial;
+}
+vec4 sourceOver(vec4 top, vec4 bottom) {
+ float alpha = top.a + bottom.a * (1.0 - top.a);
+ if (alpha <= 0.0) return vec4(0.0);
+ vec3 rgb = top.rgb * top.a + bottom.rgb * bottom.a * (1.0 - top.a);
+ return vec4(rgb / alpha, alpha);
+}
+void main() {
+ int segmentCount = int(uGeometry.w);
+ float halfStroke = uGeometry.z * 0.5;
+ vec4 result = vec4(0.0);
+ for (int index = 0; index < MAX_SEGMENTS; index++) {
+ if (index >= segmentCount) break;
+ vec4 segment = uSegmentGeometry[index];
+ vec2 point = vPoint - segment.zw;
+ float radius = length(point);
+ float angle = normalizedAngle(atan(point.x, -point.y));
+ float angular = intervalCoverage(angle * radius, segment.x * radius, segment.y * radius);
+ float radial = intervalCoverage(radius, uGeometry.x, uGeometry.y);
+ float fillCoverage = radial * angular;
+ float strokeCoverage = max(
+ bandCoverage(abs(radius - uGeometry.x), halfStroke),
+ bandCoverage(abs(radius - uGeometry.y), halfStroke)
+ ) * angular;
+ if (segmentCount > 1) {
+ strokeCoverage = max(
+ strokeCoverage,
+ max(
+ rayCoverage(point, segment.x, halfStroke),
+ rayCoverage(point, segment.y, halfStroke)
+ )
+ );
+ }
+ vec4 fill = vec4(uSegmentColors[index].rgb, uSegmentColors[index].a * fillCoverage);
+ vec4 stroke = vec4(uStrokeColor.rgb, uStrokeColor.a * strokeCoverage);
+ result = sourceOver(sourceOver(stroke, fill), result);
+ }
+ outputColor = result;
+}
+`
diff --git a/src/chartLibraries/webgl2/visualizations/radial/easyPie/kernel.js b/src/chartLibraries/webgl2/visualizations/radial/easyPie/kernel.js
new file mode 100644
index 000000000..ef4574c09
--- /dev/null
+++ b/src/chartLibraries/webgl2/visualizations/radial/easyPie/kernel.js
@@ -0,0 +1,86 @@
+import { fragmentShader, vertexShader } from "./shader"
+
+export default async surface => {
+ const { gl } = surface
+ const program = await surface.getProgram("easy-pie-v1", vertexShader, fragmentShader)
+ const vertexArray = gl.createVertexArray()
+ const uniforms = Object.fromEntries(
+ [
+ "uCanvas",
+ "uGeometry",
+ "uValues",
+ "uBarColor",
+ "uTrackColor",
+ "uScaleColor",
+ ].map(name => [name, gl.getUniformLocation(program, name)])
+ )
+ let frame = null
+
+ const update = nextFrame => {
+ const canvasWidth = Math.max(1, Math.round(nextFrame.width * nextFrame.dpr))
+ const canvasHeight = Math.max(1, Math.round(nextFrame.height * nextFrame.dpr))
+ const halfSize = nextFrame.size * 0.5 + 1
+ const left = Math.max(0, Math.floor(nextFrame.centerX - halfSize))
+ const top = Math.max(0, Math.floor(nextFrame.centerY - halfSize))
+ const right = Math.min(canvasWidth, Math.ceil(nextFrame.centerX + halfSize))
+ const bottom = Math.min(canvasHeight, Math.ceil(nextFrame.centerY + halfSize))
+ frame = {
+ canvas: [canvasWidth, canvasHeight, nextFrame.centerX, nextFrame.centerY],
+ geometry: [
+ nextFrame.size,
+ nextFrame.radius,
+ nextFrame.lineWidth,
+ nextFrame.scaleLength,
+ ],
+ values: [
+ nextFrame.sweep,
+ nextFrame.scaleEnabled ? 1 : 0,
+ nextFrame.trackEnabled ? 1 : 0,
+ nextFrame.dpr,
+ ],
+ barColor: nextFrame.barColor,
+ trackColor: nextFrame.trackColor,
+ scaleColor: nextFrame.scaleColor,
+ scissor: {
+ left: Math.min(left, canvasWidth - 1),
+ top: Math.min(top, canvasHeight - 1),
+ width: Math.max(1, right - left),
+ height: Math.max(1, bottom - top),
+ },
+ }
+ }
+
+ const draw = size => {
+ if (!frame) return false
+ gl.useProgram(program)
+ gl.bindVertexArray(vertexArray)
+ gl.uniform4fv(uniforms.uCanvas, frame.canvas)
+ gl.uniform4fv(uniforms.uGeometry, frame.geometry)
+ gl.uniform4fv(uniforms.uValues, frame.values)
+ gl.uniform4fv(uniforms.uBarColor, frame.barColor)
+ gl.uniform4fv(uniforms.uTrackColor, frame.trackColor)
+ gl.uniform4fv(uniforms.uScaleColor, frame.scaleColor)
+ gl.enable(gl.SCISSOR_TEST)
+ gl.scissor(
+ frame.scissor.left,
+ size.height - frame.scissor.top - frame.scissor.height,
+ frame.scissor.width,
+ frame.scissor.height
+ )
+ gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
+ gl.bindVertexArray(null)
+ return true
+ }
+
+ const destroy = () => {
+ frame = null
+ gl.deleteVertexArray(vertexArray)
+ }
+
+ return {
+ update,
+ draw,
+ destroy,
+ getBufferBytes: () => 0,
+ }
+}
diff --git a/src/chartLibraries/webgl2/visualizations/radial/easyPie/resources.js b/src/chartLibraries/webgl2/visualizations/radial/easyPie/resources.js
new file mode 100644
index 000000000..0389380ca
--- /dev/null
+++ b/src/chartLibraries/webgl2/visualizations/radial/easyPie/resources.js
@@ -0,0 +1,4 @@
+import makeResources from "../makeResources"
+import makeKernel from "./kernel"
+
+export default makeResources(makeKernel)
diff --git a/src/chartLibraries/webgl2/visualizations/radial/easyPie/shader.js b/src/chartLibraries/webgl2/visualizations/radial/easyPie/shader.js
new file mode 100644
index 000000000..3fa9dcc11
--- /dev/null
+++ b/src/chartLibraries/webgl2/visualizations/radial/easyPie/shader.js
@@ -0,0 +1,112 @@
+export const vertexShader = `#version 300 es
+precision highp float;
+
+uniform vec4 uCanvas;
+out vec2 vPoint;
+
+vec2 quadCoordinates(int vertexIndex) {
+ if (vertexIndex == 0) return vec2(0.0, 0.0);
+ if (vertexIndex == 1) return vec2(1.0, 0.0);
+ if (vertexIndex == 2) return vec2(0.0, 1.0);
+ return vec2(1.0, 1.0);
+}
+
+void main() {
+ vec2 quad = quadCoordinates(gl_VertexID);
+ gl_Position = vec4(quad.x * 2.0 - 1.0, 1.0 - quad.y * 2.0, 0.0, 1.0);
+ vPoint = quad * uCanvas.xy - uCanvas.zw;
+}
+`
+
+export const fragmentShader = `#version 300 es
+precision highp float;
+precision highp int;
+
+const float PI = 3.141592653589793;
+const float TAU = 6.283185307179586;
+
+uniform vec4 uGeometry;
+uniform vec4 uValues;
+uniform vec4 uBarColor;
+uniform vec4 uTrackColor;
+uniform vec4 uScaleColor;
+
+in vec2 vPoint;
+out vec4 outputColor;
+
+float intervalCoverage(float value, float minimum, float maximum) {
+ return clamp(min(value + 0.5, maximum) - max(value - 0.5, minimum), 0.0, 1.0);
+}
+
+float ringCoverage(float radius, float centerRadius, float lineWidth) {
+ float halfWidth = lineWidth * 0.5;
+ return intervalCoverage(radius, centerRadius - halfWidth, centerRadius + halfWidth);
+}
+
+float normalizedAngle(float angle) {
+ return angle - floor(angle / TAU) * TAU;
+}
+
+float arcCoverage(vec2 point, float radius, float lineWidth, float sweep) {
+ float absoluteSweep = abs(sweep);
+ if (absoluteSweep < 1e-7) return 0.0;
+ if (absoluteSweep >= TAU - 1e-5) return ringCoverage(length(point), radius, lineWidth);
+
+ float start = -PI * 0.5;
+ float angle = atan(point.y, point.x);
+ float forward = normalizedAngle(angle - start);
+ float backward = normalizedAngle(start - angle);
+ bool inside = sweep > 0.0 ? forward <= absoluteSweep : backward <= absoluteSweep;
+ float end = start + sweep;
+ vec2 startPoint = vec2(cos(start), sin(start)) * radius;
+ vec2 endPoint = vec2(cos(end), sin(end)) * radius;
+ float distance = inside
+ ? abs(length(point) - radius)
+ : min(length(point - startPoint), length(point - endPoint));
+ return clamp(lineWidth * 0.5 + 0.5 - distance, 0.0, 1.0);
+}
+
+float scaleCoverage(vec2 point) {
+ if (uValues.y < 0.5 || uGeometry.w <= 0.0) return 0.0;
+
+ float angle = atan(point.y, point.x);
+ float step = PI / 12.0;
+ float tick = round((angle - PI * 0.5) / step);
+ float tickAngle = PI * 0.5 + tick * step;
+ vec2 direction = vec2(cos(tickAngle), sin(tickAngle));
+ vec2 tangent = vec2(-direction.y, direction.x);
+ float radial = dot(point, direction);
+ float across = dot(point, tangent);
+ bool major = abs(int(tick)) % 6 == 0;
+ float tickLength = major ? uGeometry.w : uGeometry.w * 0.6;
+ float offset = uGeometry.w - tickLength;
+ float halfSize = uGeometry.x * 0.5;
+ return intervalCoverage(radial, halfSize - uGeometry.w, halfSize - offset) *
+ intervalCoverage(across, 0.0, uValues.w);
+}
+
+vec4 coveredColor(vec4 color, float coverage) {
+ return vec4(color.rgb, color.a * coverage);
+}
+
+vec4 sourceOver(vec4 top, vec4 bottom) {
+ float alpha = top.a + bottom.a * (1.0 - top.a);
+ if (alpha <= 0.0) return vec4(0.0);
+ vec3 premultiplied = top.rgb * top.a + bottom.rgb * bottom.a * (1.0 - top.a);
+ return vec4(premultiplied / alpha, alpha);
+}
+
+void main() {
+ float distance = length(vPoint);
+ vec4 scale = coveredColor(uScaleColor, scaleCoverage(vPoint));
+ float trackCoverage = uValues.z > 0.5
+ ? ringCoverage(distance, uGeometry.y, uGeometry.z)
+ : 0.0;
+ vec4 track = coveredColor(uTrackColor, trackCoverage);
+ vec4 bar = coveredColor(
+ uBarColor,
+ arcCoverage(vPoint, uGeometry.y, uGeometry.z, uValues.x * TAU)
+ );
+ outputColor = sourceOver(bar, sourceOver(track, scale));
+}
+`
diff --git a/src/chartLibraries/webgl2/visualizations/radial/gauge/kernel.js b/src/chartLibraries/webgl2/visualizations/radial/gauge/kernel.js
new file mode 100644
index 000000000..390e7897e
--- /dev/null
+++ b/src/chartLibraries/webgl2/visualizations/radial/gauge/kernel.js
@@ -0,0 +1,79 @@
+import { fragmentShader, vertexShader } from "./shader"
+
+export default async surface => {
+ const { gl } = surface
+ const program = await surface.getProgram("gauge-v1", vertexShader, fragmentShader)
+ const vertexArray = gl.createVertexArray()
+ const uniforms = Object.fromEntries(
+ [
+ "uCanvas",
+ "uGeometry",
+ "uAngles",
+ "uPointer",
+ "uProgressStartColor",
+ "uProgressEndColor",
+ "uTrackColor",
+ "uPointerColor",
+ ].map(name => [name, gl.getUniformLocation(program, name)])
+ )
+ let frame = null
+
+ const update = nextFrame => {
+ frame = {
+ canvas: [
+ nextFrame.width * nextFrame.dpr,
+ nextFrame.height * nextFrame.dpr,
+ nextFrame.centerX,
+ nextFrame.centerY,
+ ],
+ geometry: [
+ nextFrame.centerX,
+ nextFrame.centerY,
+ nextFrame.radius,
+ nextFrame.lineWidth,
+ ],
+ angles: [
+ nextFrame.startAngle,
+ nextFrame.totalSweep,
+ nextFrame.progressSweep,
+ nextFrame.pointerAngle,
+ ],
+ pointer: [
+ nextFrame.pointerLength,
+ nextFrame.pointerWidth,
+ nextFrame.gradientEnabled ? 1 : 0,
+ nextFrame.dpr,
+ ],
+ progressStartColor: nextFrame.progressStartColor,
+ progressEndColor: nextFrame.progressEndColor,
+ trackColor: nextFrame.trackColor,
+ pointerColor: nextFrame.pointerColor,
+ }
+ }
+
+ const draw = size => {
+ if (!frame) return false
+ gl.useProgram(program)
+ gl.bindVertexArray(vertexArray)
+ gl.uniform4fv(uniforms.uCanvas, frame.canvas)
+ gl.uniform4fv(uniforms.uGeometry, frame.geometry)
+ gl.uniform4fv(uniforms.uAngles, frame.angles)
+ gl.uniform4fv(uniforms.uPointer, frame.pointer)
+ gl.uniform4fv(uniforms.uProgressStartColor, frame.progressStartColor)
+ gl.uniform4fv(uniforms.uProgressEndColor, frame.progressEndColor)
+ gl.uniform4fv(uniforms.uTrackColor, frame.trackColor)
+ gl.uniform4fv(uniforms.uPointerColor, frame.pointerColor)
+ gl.enable(gl.SCISSOR_TEST)
+ gl.scissor(0, 0, size.width, size.height)
+ gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
+ gl.bindVertexArray(null)
+ return true
+ }
+
+ const destroy = () => {
+ frame = null
+ gl.deleteVertexArray(vertexArray)
+ }
+
+ return { update, draw, destroy, getBufferBytes: () => 0 }
+}
diff --git a/src/chartLibraries/webgl2/visualizations/radial/gauge/resources.js b/src/chartLibraries/webgl2/visualizations/radial/gauge/resources.js
new file mode 100644
index 000000000..0389380ca
--- /dev/null
+++ b/src/chartLibraries/webgl2/visualizations/radial/gauge/resources.js
@@ -0,0 +1,4 @@
+import makeResources from "../makeResources"
+import makeKernel from "./kernel"
+
+export default makeResources(makeKernel)
diff --git a/src/chartLibraries/webgl2/visualizations/radial/gauge/shader.js b/src/chartLibraries/webgl2/visualizations/radial/gauge/shader.js
new file mode 100644
index 000000000..eb8ebe1f6
--- /dev/null
+++ b/src/chartLibraries/webgl2/visualizations/radial/gauge/shader.js
@@ -0,0 +1,88 @@
+export const vertexShader = `#version 300 es
+precision highp float;
+uniform vec4 uCanvas;
+out vec2 vPoint;
+vec2 quadCoordinates(int vertexIndex) {
+ if (vertexIndex == 0) return vec2(0.0, 0.0);
+ if (vertexIndex == 1) return vec2(1.0, 0.0);
+ if (vertexIndex == 2) return vec2(0.0, 1.0);
+ return vec2(1.0, 1.0);
+}
+void main() {
+ vec2 quad = quadCoordinates(gl_VertexID);
+ gl_Position = vec4(quad.x * 2.0 - 1.0, 1.0 - quad.y * 2.0, 0.0, 1.0);
+ vPoint = quad * uCanvas.xy - uCanvas.zw;
+}
+`
+
+export const fragmentShader = `#version 300 es
+precision highp float;
+const float TAU = 6.283185307179586;
+uniform vec4 uGeometry;
+uniform vec4 uAngles;
+uniform vec4 uPointer;
+uniform vec4 uProgressStartColor;
+uniform vec4 uProgressEndColor;
+uniform vec4 uTrackColor;
+uniform vec4 uPointerColor;
+in vec2 vPoint;
+out vec4 outputColor;
+
+float normalizedAngle(float angle) {
+ return angle - floor(angle / TAU) * TAU;
+}
+float intervalCoverage(float value, float minimum, float maximum) {
+ if (maximum <= minimum) return 0.0;
+ return clamp(min(value + 0.5, maximum) - max(value - 0.5, minimum), 0.0, 1.0);
+}
+vec2 arcCoverage(vec2 point, float start, float sweep) {
+ float radius = length(point);
+ float radial = intervalCoverage(
+ radius,
+ uGeometry.z - uGeometry.w * 0.5,
+ uGeometry.z + uGeometry.w * 0.5
+ );
+ float relative = normalizedAngle(atan(point.y, point.x) - start);
+ float along = relative * uGeometry.z;
+ float angular = intervalCoverage(along, 0.0, sweep * uGeometry.z);
+ return vec2(radial * angular, clamp(relative / max(sweep, 1e-20), 0.0, 1.0));
+}
+float cross2(vec2 a, vec2 b) { return a.x * b.y - a.y * b.x; }
+float triangleCoverage(vec2 point, vec2 a, vec2 b, vec2 c) {
+ float orientation = cross2(b - a, c - a) >= 0.0 ? 1.0 : -1.0;
+ vec2 ab = b - a;
+ vec2 bc = c - b;
+ vec2 ca = a - c;
+ float d0 = orientation * cross2(ab, point - a) / max(length(ab), 1e-20);
+ float d1 = orientation * cross2(bc, point - b) / max(length(bc), 1e-20);
+ float d2 = orientation * cross2(ca, point - c) / max(length(ca), 1e-20);
+ return clamp(min(d0, min(d1, d2)) + 0.5, 0.0, 1.0);
+}
+vec4 sourceOver(vec4 top, vec4 bottom) {
+ float alpha = top.a + bottom.a * (1.0 - top.a);
+ if (alpha <= 0.0) return vec4(0.0);
+ vec3 rgb = top.rgb * top.a + bottom.rgb * bottom.a * (1.0 - top.a);
+ return vec4(rgb / alpha, alpha);
+}
+void main() {
+ vec2 progress = arcCoverage(vPoint, uAngles.x, uAngles.z);
+ vec2 remaining = arcCoverage(vPoint, uAngles.x + uAngles.z, uAngles.y - uAngles.z);
+ vec4 progressColor = mix(
+ uProgressStartColor,
+ uProgressEndColor,
+ uPointer.z > 0.5 ? progress.y : 1.0
+ );
+ vec4 track = vec4(uTrackColor.rgb, uTrackColor.a * remaining.x);
+ vec4 bar = vec4(progressColor.rgb, progressColor.a * progress.x);
+ vec2 direction = vec2(cos(uAngles.w), sin(uAngles.w));
+ vec2 perpendicular = vec2(-direction.y, direction.x);
+ vec2 a = perpendicular * uPointer.y;
+ vec2 b = direction * uPointer.x;
+ vec2 c = -perpendicular * uPointer.y;
+ float triangle = triangleCoverage(vPoint, a, b, c);
+ float center = clamp(uPointer.y + 0.5 - length(vPoint), 0.0, 1.0);
+ float pointerCoverage = max(triangle, center);
+ vec4 pointer = vec4(uPointerColor.rgb, uPointerColor.a * pointerCoverage);
+ outputColor = sourceOver(pointer, sourceOver(bar, track));
+}
+`
diff --git a/src/chartLibraries/webgl2/visualizations/radial/makeResources.js b/src/chartLibraries/webgl2/visualizations/radial/makeResources.js
new file mode 100644
index 000000000..70f04e909
--- /dev/null
+++ b/src/chartLibraries/webgl2/visualizations/radial/makeResources.js
@@ -0,0 +1,9 @@
+import createResourceSet from "@/chartLibraries/gpu/engine/createResourceSet"
+import makeSurface from "@/chartLibraries/webgl2/engine/surface"
+
+export default makeKernel => (runtime, canvas) => {
+ const surface = makeSurface(runtime, canvas)
+ return createResourceSet(surface, {
+ layer: () => makeKernel(surface),
+ })
+}
diff --git a/src/chartLibraries/webgpu/engine/color.js b/src/chartLibraries/webgpu/engine/color.js
new file mode 100644
index 000000000..cc89cc027
--- /dev/null
+++ b/src/chartLibraries/webgpu/engine/color.js
@@ -0,0 +1 @@
+export * from "@/chartLibraries/gpu/color"
diff --git a/src/chartLibraries/webgpu/engine/makeInstancedLayer.js b/src/chartLibraries/webgpu/engine/makeInstancedLayer.js
new file mode 100644
index 000000000..3d4dede3d
--- /dev/null
+++ b/src/chartLibraries/webgpu/engine/makeInstancedLayer.js
@@ -0,0 +1,148 @@
+const nextBufferSize = byteLength => {
+ let size = 4
+ while (size < byteLength) size *= 2
+ return size
+}
+
+const makePipeline = async (runtime, key, shader) => {
+ const { device, format } = runtime
+ const module = device.createShaderModule({ label: `netdata-${key}-shader`, code: shader })
+ const compilation = await module.getCompilationInfo()
+ const errors = compilation.messages.filter(message => message.type === "error")
+ if (errors.length) throw new Error(errors.map(({ message }) => message).join("\n"))
+
+ return device.createRenderPipelineAsync({
+ label: `netdata-${key}-pipeline`,
+ layout: "auto",
+ vertex: { module, entryPoint: "vertexMain" },
+ fragment: {
+ module,
+ entryPoint: "fragmentMain",
+ targets: [
+ {
+ format,
+ blend: {
+ color: {
+ operation: "add",
+ srcFactor: "src-alpha",
+ dstFactor: "one-minus-src-alpha",
+ },
+ alpha: {
+ operation: "add",
+ srcFactor: "one",
+ dstFactor: "one-minus-src-alpha",
+ },
+ },
+ },
+ ],
+ },
+ primitive: { topology: "triangle-list" },
+ })
+}
+
+const makeScissor = ({ scissor, width, height, dpr }) => {
+ if (!scissor) return null
+ const canvasWidth = Math.max(1, Math.round(width * dpr))
+ const canvasHeight = Math.max(1, Math.round(height * dpr))
+ const left = Math.max(0, Math.round(scissor.left * dpr))
+ const top = Math.max(0, Math.round(scissor.top * dpr))
+ return {
+ left: Math.min(left, canvasWidth - 1),
+ top: Math.min(top, canvasHeight - 1),
+ width: Math.max(1, Math.min(Math.round(scissor.width * dpr), canvasWidth - left)),
+ height: Math.max(1, Math.min(Math.round(scissor.height * dpr), canvasHeight - top)),
+ }
+}
+
+export default async ({ runtime, surface, key, label = key, shader, pack }) => {
+ const { device, format } = runtime
+ const pipeline = await runtime.getPipeline(`netdata-${key}-v1:${format}`, () =>
+ makePipeline(runtime, key, shader)
+ )
+ let uniform = null
+ let instances = null
+ let bindGroup = null
+ let count = 0
+ let bufferBytes = 0
+ let scissor = null
+
+ const replaceBuffer = (current, next) => {
+ bufferBytes += next.size - (current?.size || 0)
+ if (current) surface.destroyAfterSubmission(current)
+ return next
+ }
+
+ const ensureUniform = () => {
+ if (uniform) return uniform
+ uniform = replaceBuffer(
+ uniform,
+ device.createBuffer({
+ label: `netdata-${label}-uniform`,
+ size: 16,
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
+ })
+ )
+ bindGroup = null
+ return uniform
+ }
+
+ const ensureInstances = byteLength => {
+ if (instances && instances.size >= byteLength) return instances
+ instances = replaceBuffer(
+ instances,
+ device.createBuffer({
+ label: `netdata-${label}-instances`,
+ size: nextBufferSize(byteLength),
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
+ })
+ )
+ bindGroup = null
+ return instances
+ }
+
+ const update = ({ items, width, height, dpr, scissor: nextScissor }) => {
+ count = items.length
+ scissor = makeScissor({ scissor: nextScissor, width, height, dpr })
+ if (!count) return
+
+ const packed = pack(items, dpr)
+ const uniformBuffer = ensureUniform()
+ const instanceBuffer = ensureInstances(packed.byteLength)
+ device.queue.writeBuffer(uniformBuffer, 0, new Float32Array([width * dpr, height * dpr, 0, 0]))
+ device.queue.writeBuffer(instanceBuffer, 0, packed)
+
+ if (!bindGroup) {
+ bindGroup = device.createBindGroup({
+ layout: pipeline.getBindGroupLayout(0),
+ entries: [
+ { binding: 0, resource: { buffer: uniformBuffer } },
+ { binding: 1, resource: { buffer: instanceBuffer } },
+ ],
+ })
+ }
+ }
+
+ const encode = (pass, size) => {
+ if (!count || !bindGroup) return false
+ pass.setPipeline(pipeline)
+ pass.setBindGroup(0, bindGroup)
+ if (scissor)
+ pass.setScissorRect(scissor.left, scissor.top, scissor.width, scissor.height)
+ else pass.setScissorRect(0, 0, size.width, size.height)
+ pass.draw(6, count)
+ return true
+ }
+
+ const destroy = () => {
+ if (uniform) surface.destroyAfterSubmission(uniform)
+ if (instances) surface.destroyAfterSubmission(instances)
+ uniform = null
+ instances = null
+ bindGroup = null
+ count = 0
+ scissor = null
+ bufferBytes = 0
+ }
+
+ return { update, encode, destroy, getBufferBytes: () => bufferBytes }
+}
diff --git a/src/chartLibraries/webgpu/engine/makeRenderer.js b/src/chartLibraries/webgpu/engine/makeRenderer.js
new file mode 100644
index 000000000..e7a90e3be
--- /dev/null
+++ b/src/chartLibraries/webgpu/engine/makeRenderer.js
@@ -0,0 +1,13 @@
+import makeRenderer from "@/chartLibraries/gpu/engine/makeRenderer"
+import { getWebGPURuntime, isWebGPUSupported } from "./runtime"
+
+export default options =>
+ makeRenderer({
+ ...options,
+ rendererId: "webgpu",
+ fallbackRenderer: "webgl2",
+ getRuntime: getWebGPURuntime,
+ isRuntimeSupported: isWebGPUSupported,
+ makeLossError: info =>
+ new Error(`WebGPU device lost: ${info.reason}: ${info.message}`),
+ })
diff --git a/src/chartLibraries/webgpu/engine/retirement.js b/src/chartLibraries/webgpu/engine/retirement.js
new file mode 100644
index 000000000..008cce315
--- /dev/null
+++ b/src/chartLibraries/webgpu/engine/retirement.js
@@ -0,0 +1,5 @@
+export default (submission, resource) =>
+ submission.then(
+ () => resource.destroy(),
+ () => resource.destroy()
+ )
diff --git a/src/chartLibraries/webgpu/engine/retirement.test.js b/src/chartLibraries/webgpu/engine/retirement.test.js
new file mode 100644
index 000000000..5f42aff03
--- /dev/null
+++ b/src/chartLibraries/webgpu/engine/retirement.test.js
@@ -0,0 +1,22 @@
+import retireAfterSubmission from "./retirement"
+
+describe("WebGPU resource retirement", () => {
+ it("keeps a replaced resource alive until submitted work completes", async () => {
+ let complete
+ const submission = new Promise(resolve => {
+ complete = resolve
+ })
+ let destroyed = false
+ const retired = retireAfterSubmission(submission, {
+ destroy: () => {
+ destroyed = true
+ },
+ })
+
+ await Promise.resolve()
+ expect(destroyed).toBe(false)
+ complete()
+ await retired
+ expect(destroyed).toBe(true)
+ })
+})
diff --git a/src/chartLibraries/webgpu/engine/runtime.js b/src/chartLibraries/webgpu/engine/runtime.js
new file mode 100644
index 000000000..afe940e8b
--- /dev/null
+++ b/src/chartLibraries/webgpu/engine/runtime.js
@@ -0,0 +1,170 @@
+import makeResourceCache from "@/chartLibraries/gpu/engine/makeResourceCache"
+
+const runtimes = new WeakMap()
+const failedSDKs = new WeakSet()
+const idleDisposeMs = 30000
+
+export const isWebGPUSupported = sdk => {
+ if (sdk && failedSDKs.has(sdk)) return false
+ return typeof navigator !== "undefined" && Boolean(navigator.gpu)
+}
+
+const makeRuntime = sdk => {
+ let adapter = null
+ let device = null
+ let format = null
+ let initializing = null
+ let references = 0
+ let disposeTimer = null
+ let disposed = false
+ let uncapturedErrorListener = null
+ let lastFailure = null
+ const pipelinePromises = new Map()
+ const resourceCache = makeResourceCache()
+ const lostListeners = new Set()
+
+ const dispose = () => {
+ if (disposed) return
+ disposed = true
+ clearTimeout(disposeTimer)
+ disposeTimer = null
+ pipelinePromises.clear()
+ resourceCache.destroy()
+ lostListeners.clear()
+ if (device && uncapturedErrorListener)
+ device.removeEventListener("uncapturederror", uncapturedErrorListener)
+ uncapturedErrorListener = null
+ device?.destroy()
+ device = null
+ adapter = null
+ format = null
+ initializing = null
+ runtimes.delete(sdk)
+ }
+
+ const initialize = async () => {
+ if (device) return instance
+ if (initializing) return initializing
+
+ initializing = (async () => {
+ if (!isWebGPUSupported(sdk)) throw new Error("WebGPU is unavailable")
+
+ adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" })
+ if (!adapter) throw new Error("WebGPU adapter acquisition failed")
+
+ device = await adapter.requestDevice()
+ format = navigator.gpu.getPreferredCanvasFormat()
+ uncapturedErrorListener = event => {
+ event.preventDefault?.()
+ if (disposed) return
+ failedSDKs.add(sdk)
+ const info = {
+ reason: "uncaptured-error",
+ message: event.error?.message || "Uncaptured WebGPU error",
+ }
+ lastFailure = info
+ lostListeners.forEach(listener => listener(info))
+ }
+ device.addEventListener("uncapturederror", uncapturedErrorListener)
+ device.lost.then(info => {
+ if (disposed) return
+ lastFailure = info
+ failedSDKs.add(sdk)
+ lostListeners.forEach(listener => listener(info))
+ })
+ return instance
+ })().catch(error => {
+ lastFailure = { reason: "initialization", message: error.message }
+ failedSDKs.add(sdk)
+ initializing = null
+ throw error
+ })
+
+ return initializing
+ }
+
+ const acquire = async () => {
+ references += 1
+ clearTimeout(disposeTimer)
+ disposeTimer = null
+
+ try {
+ return await initialize()
+ } catch (error) {
+ references = Math.max(0, references - 1)
+ throw error
+ }
+ }
+
+ const release = () => {
+ references = Math.max(0, references - 1)
+ if (references || disposed) return
+
+ clearTimeout(disposeTimer)
+ disposeTimer = setTimeout(dispose, idleDisposeMs)
+ }
+
+ const getPipeline = (key, create) => {
+ if (!device) throw new Error("WebGPU runtime is not initialized")
+ if (!pipelinePromises.has(key)) pipelinePromises.set(key, Promise.resolve().then(create))
+ return pipelinePromises.get(key)
+ }
+
+ const getResource = (key, create) => {
+ if (!device) throw new Error("WebGPU runtime is not initialized")
+ return resourceCache.get(key, create)
+ }
+
+ const getResourceBytes = resourceCache.getBytes
+
+ const onLost = listener => {
+ lostListeners.add(listener)
+ return () => lostListeners.delete(listener)
+ }
+
+ const instance = {
+ acquire,
+ release,
+ dispose,
+ getPipeline,
+ getResource,
+ getResourceBytes,
+ onLost,
+ get adapter() {
+ return adapter
+ },
+ get device() {
+ return device
+ },
+ get format() {
+ return format
+ },
+ get references() {
+ return references
+ },
+ get lastFailure() {
+ return lastFailure
+ },
+ }
+
+ return instance
+}
+
+export const getWebGPUDiagnostics = sdk => {
+ const runtime = runtimes.get(sdk)
+ return {
+ supported: isWebGPUSupported(sdk),
+ initialized: Boolean(runtime?.device),
+ adapter: runtime?.adapter?.info || null,
+ references: runtime?.references || 0,
+ sharedResourceBytes: runtime?.getResourceBytes?.() || 0,
+ lastFailure: runtime?.lastFailure || null,
+ }
+}
+
+export const getWebGPURuntime = sdk => {
+ if (!runtimes.has(sdk)) runtimes.set(sdk, makeRuntime(sdk))
+ return runtimes.get(sdk)
+}
+
+export const disposeWebGPURuntime = sdk => runtimes.get(sdk)?.dispose()
diff --git a/src/chartLibraries/webgpu/engine/surface.js b/src/chartLibraries/webgpu/engine/surface.js
new file mode 100644
index 000000000..710af7af1
--- /dev/null
+++ b/src/chartLibraries/webgpu/engine/surface.js
@@ -0,0 +1,58 @@
+import retireAfterSubmission from "./retirement"
+
+export default (runtime, canvas) => {
+ const { device, format } = runtime
+ const context = canvas.getContext("webgpu")
+ if (!context) throw new Error("Unable to create a WebGPU canvas context")
+
+ context.configure({ device, format, alphaMode: "premultiplied" })
+ let submission = Promise.resolve()
+ let destroyed = false
+
+ const resize = ({ width, height, dpr }) => {
+ const pixelWidth = Math.max(1, Math.round(width * dpr))
+ const pixelHeight = Math.max(1, Math.round(height * dpr))
+ if (canvas.width !== pixelWidth) canvas.width = pixelWidth
+ if (canvas.height !== pixelHeight) canvas.height = pixelHeight
+ return { width: pixelWidth, height: pixelHeight, dpr }
+ }
+
+ const draw = (layers, frame) => {
+ if (destroyed) return false
+ const size = resize(frame)
+ const encoder = device.createCommandEncoder({ label: "netdata-visualization-frame" })
+ const pass = encoder.beginRenderPass({
+ label: "netdata-visualization-pass",
+ colorAttachments: [
+ {
+ view: context.getCurrentTexture().createView(),
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
+ loadOp: "clear",
+ storeOp: "store",
+ },
+ ],
+ })
+
+ let rendered = false
+ for (const layer of layers) rendered = layer.encode(pass, size) || rendered
+ pass.end()
+ device.queue.submit([encoder.finish()])
+ submission = device.queue.onSubmittedWorkDone()
+ return rendered
+ }
+
+ const destroyAfterSubmission = resource => retireAfterSubmission(submission, resource)
+
+ const destroy = () => {
+ if (destroyed) return
+ destroyed = true
+ context.unconfigure()
+ }
+
+ return {
+ draw,
+ destroy,
+ destroyAfterSubmission,
+ getQueueDone: () => submission,
+ }
+}
diff --git a/src/chartLibraries/webgpu/index.js b/src/chartLibraries/webgpu/index.js
new file mode 100644
index 000000000..90309009a
--- /dev/null
+++ b/src/chartLibraries/webgpu/index.js
@@ -0,0 +1,31 @@
+import makeRenderer from "./engine/makeRenderer"
+import { getWebGPUDiagnostics, isWebGPUSupported } from "./engine/runtime"
+import { isGaugeConfigurationSupported } from "@/chartLibraries/gpu/visualizations/radial/gauge"
+import { getVisualization, hasVisualization } from "./visualizations"
+
+const makeUnsupportedVisualization = visualization => () => ({
+ mount: () => {},
+ unmount: () => {},
+ createResources: () =>
+ Promise.reject(new Error(`Unsupported WebGPU visualization: ${visualization}`)),
+ attachResources: resource => resource.destroy?.(),
+ render: () => false,
+})
+
+const makeWebGPU = (sdk, chart) => {
+ const visualizationId =
+ chart.getVisualizationType?.() || chart.getAttribute("chartType") || "line"
+ const makeVisualization =
+ getVisualization(visualizationId) || makeUnsupportedVisualization(visualizationId)
+
+ return makeRenderer({ sdk, chart, makeVisualization, visualizationId })
+}
+
+makeWebGPU.isSupported = (sdk, visualization = "line", chart) =>
+ hasVisualization(visualization) &&
+ (visualization !== "gauge" || isGaugeConfigurationSupported(chart)) &&
+ isWebGPUSupported(sdk)
+makeWebGPU.fallbackRenderer = "webgl2"
+makeWebGPU.getDiagnostics = getWebGPUDiagnostics
+
+export default makeWebGPU
diff --git a/src/chartLibraries/webgpu/primitives/circle/index.js b/src/chartLibraries/webgpu/primitives/circle/index.js
new file mode 100644
index 000000000..c192360f9
--- /dev/null
+++ b/src/chartLibraries/webgpu/primitives/circle/index.js
@@ -0,0 +1,27 @@
+import { parseColor } from "@/chartLibraries/gpu/color"
+import makeInstancedLayer from "@/chartLibraries/webgpu/engine/makeInstancedLayer"
+import shader from "./shader"
+
+export const packCircles = (circles, dpr = 1) => {
+ const packed = new Float32Array(circles.length * 8)
+ circles.forEach(({ x, y, radius, color }, index) => {
+ packed.set([x * dpr, y * dpr, radius * dpr, 0], index * 8)
+ packed.set(parseColor(color), index * 8 + 4)
+ })
+ return packed
+}
+
+export default async (runtime, surface) => {
+ const layer = await makeInstancedLayer({
+ runtime,
+ surface,
+ key: "circle",
+ shader,
+ pack: packCircles,
+ })
+ return {
+ ...layer,
+ update: ({ circles, plot, ...frame }) =>
+ layer.update({ items: circles, scissor: plot, ...frame }),
+ }
+}
diff --git a/src/chartLibraries/webgpu/primitives/circle/index.test.js b/src/chartLibraries/webgpu/primitives/circle/index.test.js
new file mode 100644
index 000000000..3677f7f16
--- /dev/null
+++ b/src/chartLibraries/webgpu/primitives/circle/index.test.js
@@ -0,0 +1,13 @@
+import { packCircles } from "."
+
+describe("WebGPU circle primitive", () => {
+ it("packs physical centers, radii, and colors", () => {
+ const packed = packCircles([{ x: 2, y: 3, radius: 1.5, color: "#ff000080" }], 2)
+
+ expect(Array.from(packed.slice(0, 4))).toEqual([4, 6, 3, 0])
+ expect(packed[4]).toBe(1)
+ expect(packed[5]).toBe(0)
+ expect(packed[6]).toBe(0)
+ expect(packed[7]).toBeCloseTo(128 / 255)
+ })
+})
diff --git a/src/chartLibraries/webgpu/primitives/circle/shader.js b/src/chartLibraries/webgpu/primitives/circle/shader.js
new file mode 100644
index 000000000..c173afd33
--- /dev/null
+++ b/src/chartLibraries/webgpu/primitives/circle/shader.js
@@ -0,0 +1,57 @@
+export default `
+struct Uniforms {
+ canvas: vec2,
+ padding: vec2,
+};
+
+struct Circle {
+ geometry: vec4,
+ color: vec4,
+};
+
+@group(0) @binding(0) var uniforms: Uniforms;
+@group(0) @binding(1) var circles: array;
+
+fn quadCoordinates(vertexIndex: u32) -> vec2 {
+ switch vertexIndex {
+ case 0u: { return vec2(-1.0, -1.0); }
+ case 1u: { return vec2(1.0, -1.0); }
+ case 2u: { return vec2(-1.0, 1.0); }
+ case 3u: { return vec2(-1.0, 1.0); }
+ case 4u: { return vec2(1.0, -1.0); }
+ default: { return vec2(1.0, 1.0); }
+ }
+}
+
+struct VertexOutput {
+ @builtin(position) position: vec4,
+ @location(0) local: vec2,
+ @location(1) color: vec4,
+};
+
+@vertex
+fn vertexMain(
+ @builtin(vertex_index) vertexIndex: u32,
+ @builtin(instance_index) instanceIndex: u32,
+) -> VertexOutput {
+ let circle = circles[instanceIndex];
+ let local = quadCoordinates(vertexIndex);
+ let point = circle.geometry.xy + local * circle.geometry.z;
+ let clipX = point.x / uniforms.canvas.x * 2.0 - 1.0;
+ let clipY = 1.0 - point.y / uniforms.canvas.y * 2.0;
+
+ var output: VertexOutput;
+ output.position = vec4(clipX, clipY, 0.0, 1.0);
+ output.local = local;
+ output.color = circle.color;
+ return output;
+}
+
+@fragment
+fn fragmentMain(input: VertexOutput) -> @location(0) vec4 {
+ let distance = length(input.local);
+ let antialias = max(fwidth(distance), 1e-3);
+ let coverage = 1.0 - smoothstep(1.0 - antialias, 1.0, distance);
+ return vec4(input.color.rgb, input.color.a * coverage);
+}
+`
diff --git a/src/chartLibraries/webgpu/primitives/rect/index.js b/src/chartLibraries/webgpu/primitives/rect/index.js
new file mode 100644
index 000000000..001899682
--- /dev/null
+++ b/src/chartLibraries/webgpu/primitives/rect/index.js
@@ -0,0 +1,27 @@
+import { parseColor } from "@/chartLibraries/gpu/color"
+import makeInstancedLayer from "@/chartLibraries/webgpu/engine/makeInstancedLayer"
+import shader from "./shader"
+
+export const packRects = (rects, dpr = 1) => {
+ const packed = new Float32Array(rects.length * 8)
+ rects.forEach(({ x, y, width, height, color }, index) => {
+ packed.set([x * dpr, y * dpr, width * dpr, height * dpr], index * 8)
+ packed.set(parseColor(color), index * 8 + 4)
+ })
+ return packed
+}
+
+export default async (runtime, surface, label = "rect") => {
+ const layer = await makeInstancedLayer({
+ runtime,
+ surface,
+ key: "rect",
+ label,
+ shader,
+ pack: packRects,
+ })
+ return {
+ ...layer,
+ update: ({ rects, ...frame }) => layer.update({ items: rects, ...frame }),
+ }
+}
diff --git a/src/chartLibraries/webgpu/primitives/rect/index.test.js b/src/chartLibraries/webgpu/primitives/rect/index.test.js
new file mode 100644
index 000000000..64ed08323
--- /dev/null
+++ b/src/chartLibraries/webgpu/primitives/rect/index.test.js
@@ -0,0 +1,17 @@
+import { packRects } from "."
+
+describe("WebGPU rectangle primitive", () => {
+ it("packs physical geometry and color without mutating input", () => {
+ const rects = [{ x: 1, y: 2, width: 3, height: 4, color: "rgba(10, 20, 30, 0.5)" }]
+
+ const packed = packRects(rects, 2)
+ expect(Array.from(packed.slice(0, 4))).toEqual([2, 4, 6, 8])
+ expect(packed[4]).toBeCloseTo(10 / 255)
+ expect(packed[5]).toBeCloseTo(20 / 255)
+ expect(packed[6]).toBeCloseTo(30 / 255)
+ expect(packed[7]).toBe(0.5)
+ expect(rects).toEqual([
+ { x: 1, y: 2, width: 3, height: 4, color: "rgba(10, 20, 30, 0.5)" },
+ ])
+ })
+})
diff --git a/src/chartLibraries/webgpu/primitives/rect/shader.js b/src/chartLibraries/webgpu/primitives/rect/shader.js
new file mode 100644
index 000000000..ed566acc5
--- /dev/null
+++ b/src/chartLibraries/webgpu/primitives/rect/shader.js
@@ -0,0 +1,51 @@
+export default `
+struct Uniforms {
+ canvas: vec2,
+ padding: vec2,
+};
+
+struct Rect {
+ geometry: vec4,
+ color: vec4,
+};
+
+@group(0) @binding(0) var uniforms: Uniforms;
+@group(0) @binding(1) var rects: array;
+
+fn quadCoordinates(vertexIndex: u32) -> vec2 {
+ switch vertexIndex {
+ case 0u: { return vec2(0.0, 0.0); }
+ case 1u: { return vec2(1.0, 0.0); }
+ case 2u: { return vec2(0.0, 1.0); }
+ case 3u: { return vec2(0.0, 1.0); }
+ case 4u: { return vec2(1.0, 0.0); }
+ default: { return vec2(1.0, 1.0); }
+ }
+}
+
+struct VertexOutput {
+ @builtin(position) position: vec4,
+ @location(0) color: vec4,
+};
+
+@vertex
+fn vertexMain(
+ @builtin(vertex_index) vertexIndex: u32,
+ @builtin(instance_index) instanceIndex: u32,
+) -> VertexOutput {
+ let rect = rects[instanceIndex];
+ let point = rect.geometry.xy + quadCoordinates(vertexIndex) * rect.geometry.zw;
+ let clipX = point.x / uniforms.canvas.x * 2.0 - 1.0;
+ let clipY = 1.0 - point.y / uniforms.canvas.y * 2.0;
+
+ var output: VertexOutput;
+ output.position = vec4(clipX, clipY, 0.0, 1.0);
+ output.color = rect.color;
+ return output;
+}
+
+@fragment
+fn fragmentMain(input: VertexOutput) -> @location(0) vec4 {
+ return input.color;
+}
+`
diff --git a/src/chartLibraries/webgpu/text/atlas.js b/src/chartLibraries/webgpu/text/atlas.js
new file mode 100644
index 000000000..01cd4e0a9
--- /dev/null
+++ b/src/chartLibraries/webgpu/text/atlas.js
@@ -0,0 +1,128 @@
+import makeBoundedCache from "@/chartLibraries/gpu/text/cache"
+import {
+ makeRasterCanvas,
+ makeTextCacheKey,
+ rasterizeText,
+} from "@/chartLibraries/gpu/text"
+import retireAfterSubmission from "@/chartLibraries/webgpu/engine/retirement"
+
+const ATLAS_SIZE = 1024
+const ATLAS_PADDING = 2
+const CACHE_MAX = 1024
+
+export { makeTextCacheKey }
+
+export default runtime => {
+ const { device } = runtime
+ const size = Math.min(ATLAS_SIZE, device.limits.maxTextureDimension2D)
+ const canvas = makeRasterCanvas()
+ let texture = null
+ let generation = 0
+ let x = ATLAS_PADDING
+ let y = ATLAS_PADDING
+ let rowHeight = 0
+ let destroyed = false
+ const cache = makeBoundedCache(CACHE_MAX)
+
+ const createTexture = () =>
+ device.createTexture({
+ label: "netdata-text-atlas",
+ size: [size, size, 1],
+ format: "rgba8unorm",
+ usage:
+ GPUTextureUsage.TEXTURE_BINDING |
+ GPUTextureUsage.COPY_DST |
+ GPUTextureUsage.RENDER_ATTACHMENT,
+ })
+
+ const reset = () => {
+ const previous = texture
+ texture = createTexture()
+ generation += 1
+ x = ATLAS_PADDING
+ y = ATLAS_PADDING
+ rowHeight = 0
+ cache.clear()
+ if (previous) retireAfterSubmission(device.queue.onSubmittedWorkDone(), previous)
+ }
+
+ const allocate = (width, height) => {
+ if (width + ATLAS_PADDING * 2 > size || height + ATLAS_PADDING * 2 > size) return null
+ if (x + width + ATLAS_PADDING > size) {
+ x = ATLAS_PADDING
+ y += rowHeight + ATLAS_PADDING
+ rowHeight = 0
+ }
+ if (y + height + ATLAS_PADDING > size) return null
+
+ const allocation = { x, y, width, height }
+ x += width + ATLAS_PADDING
+ rowHeight = Math.max(rowHeight, height)
+ return allocation
+ }
+
+ const rasterize = ({ text, font, dpr }) => {
+ if (destroyed || !text) return null
+ const key = makeTextCacheKey({ text, font, dpr })
+ const cached = cache.get(key)
+ if (cached) return cached
+ if (cache.isFullFor(key)) reset()
+
+ const shaped = rasterizeText(canvas, { text, font, dpr })
+ if (!shaped) return null
+ const { width: widthCss, height: heightCss, pixelWidth: width, pixelHeight: height } =
+ shaped
+
+ let allocation = allocate(width, height)
+ if (!allocation) {
+ reset()
+ allocation = allocate(width, height)
+ }
+ if (!allocation) return null
+
+ device.queue.copyExternalImageToTexture(
+ { source: canvas },
+ { texture, origin: { x: allocation.x, y: allocation.y } },
+ { width, height }
+ )
+
+ const entry = {
+ generation,
+ width: widthCss,
+ height: heightCss,
+ pixelWidth: width,
+ pixelHeight: height,
+ u0: allocation.x / size,
+ v0: allocation.y / size,
+ u1: (allocation.x + width) / size,
+ v1: (allocation.y + height) / size,
+ }
+ cache.set(key, entry)
+ return entry
+ }
+
+ const destroy = () => {
+ if (destroyed) return
+ destroyed = true
+ cache.clear()
+ texture?.destroy()
+ texture = null
+ }
+
+ reset()
+
+ return {
+ rasterize,
+ destroy,
+ getGPUBytes: () => size * size * 4,
+ get texture() {
+ return texture
+ },
+ get generation() {
+ return generation
+ },
+ get size() {
+ return size
+ },
+ }
+}
diff --git a/src/chartLibraries/webgpu/text/atlas.test.js b/src/chartLibraries/webgpu/text/atlas.test.js
new file mode 100644
index 000000000..380ca157b
--- /dev/null
+++ b/src/chartLibraries/webgpu/text/atlas.test.js
@@ -0,0 +1,13 @@
+import { makeTextCacheKey } from "./atlas"
+
+describe("WebGPU shaped-text atlas keys", () => {
+ it("separates complete strings by font and device-pixel ratio", () => {
+ const base = { text: "23:59:55", font: "10px sans-serif", dpr: 1 }
+
+ expect(makeTextCacheKey(base)).not.toBe(makeTextCacheKey({ ...base, dpr: 2 }))
+ expect(makeTextCacheKey(base)).not.toBe(
+ makeTextCacheKey({ ...base, font: "12px sans-serif" })
+ )
+ expect(makeTextCacheKey(base)).not.toBe(makeTextCacheKey({ ...base, text: "23:59:56" }))
+ })
+})
diff --git a/src/chartLibraries/webgpu/text/index.js b/src/chartLibraries/webgpu/text/index.js
new file mode 100644
index 000000000..6f36cf653
--- /dev/null
+++ b/src/chartLibraries/webgpu/text/index.js
@@ -0,0 +1,185 @@
+import { parseColor } from "@/chartLibraries/gpu/color"
+import { placeRasterizedText, placeText } from "@/chartLibraries/gpu/text"
+import makeAtlas from "./atlas"
+import shader from "./shader"
+
+const nextBufferSize = byteLength => {
+ let size = 4
+ while (size < byteLength) size *= 2
+ return size
+}
+
+export { placeText }
+
+const makePipeline = async runtime => {
+ const { device, format } = runtime
+ const module = device.createShaderModule({ label: "netdata-text-shader", code: shader })
+ const compilation = await module.getCompilationInfo()
+ const errors = compilation.messages.filter(message => message.type === "error")
+ if (errors.length) throw new Error(errors.map(({ message }) => message).join("\n"))
+
+ return device.createRenderPipelineAsync({
+ label: "netdata-text-pipeline",
+ layout: "auto",
+ vertex: { module, entryPoint: "vertexMain" },
+ fragment: {
+ module,
+ entryPoint: "fragmentMain",
+ targets: [
+ {
+ format,
+ blend: {
+ color: {
+ operation: "add",
+ srcFactor: "src-alpha",
+ dstFactor: "one-minus-src-alpha",
+ },
+ alpha: {
+ operation: "add",
+ srcFactor: "one",
+ dstFactor: "one-minus-src-alpha",
+ },
+ },
+ },
+ ],
+ },
+ primitive: { topology: "triangle-list" },
+ })
+}
+
+const resolveEntries = (atlas, labels, dpr) => {
+ for (let attempt = 0; attempt < 3; attempt += 1) {
+ const generation = atlas.generation
+ const entries = labels.map(({ text, font = "10px sans-serif" }) =>
+ atlas.rasterize({ text: `${text}`, font, dpr })
+ )
+ if (atlas.generation === generation) return entries
+ }
+ throw new Error("WebGPU text atlas cannot fit the active label set")
+}
+
+export default async (runtime, surface) => {
+ const { device, format } = runtime
+ const [pipeline, atlas] = await Promise.all([
+ runtime.getPipeline(`netdata-text-v1:${format}`, () => makePipeline(runtime)),
+ runtime.getResource("netdata-text-atlas-v1", () => makeAtlas(runtime)),
+ ])
+ const sampler = device.createSampler({
+ label: "netdata-text-sampler",
+ magFilter: "linear",
+ minFilter: "linear",
+ })
+ let uniform = null
+ let instances = null
+ let bindGroup = null
+ let bindGroupGeneration = 0
+ let count = 0
+ let bufferBytes = 0
+
+ const replaceBuffer = (current, next) => {
+ bufferBytes += next.size - (current?.size || 0)
+ if (current) surface.destroyAfterSubmission(current)
+ return next
+ }
+
+ const ensureUniform = () => {
+ if (uniform) return uniform
+ uniform = replaceBuffer(
+ uniform,
+ device.createBuffer({
+ label: "netdata-text-uniform",
+ size: 16,
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
+ })
+ )
+ bindGroup = null
+ return uniform
+ }
+
+ const ensureInstances = byteLength => {
+ if (instances && instances.size >= byteLength) return instances
+ instances = replaceBuffer(
+ instances,
+ device.createBuffer({
+ label: "netdata-text-instances",
+ size: nextBufferSize(byteLength),
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
+ })
+ )
+ bindGroup = null
+ return instances
+ }
+
+ const update = ({ labels, width, height, dpr }) => {
+ count = labels.length
+ if (!count) return
+
+ const entries = resolveEntries(atlas, labels, dpr)
+ const packed = new Float32Array(count * 12)
+ labels.forEach((label, index) => {
+ const entry = entries[index]
+ if (!entry) return
+ const placement = placeRasterizedText({ label, entry, dpr })
+ const offset = index * 12
+ packed.set(
+ [
+ placement.x,
+ placement.y,
+ placement.width,
+ placement.height,
+ entry.u0,
+ entry.v0,
+ entry.u1,
+ entry.v1,
+ ],
+ offset
+ )
+ packed.set(parseColor(label.color), offset + 8)
+ })
+
+ const uniformBuffer = ensureUniform()
+ const instanceBuffer = ensureInstances(packed.byteLength)
+ device.queue.writeBuffer(uniformBuffer, 0, new Float32Array([width * dpr, height * dpr, 0, 0]))
+ device.queue.writeBuffer(instanceBuffer, 0, packed)
+
+ if (!bindGroup || bindGroupGeneration !== atlas.generation) {
+ bindGroup = device.createBindGroup({
+ layout: pipeline.getBindGroupLayout(0),
+ entries: [
+ { binding: 0, resource: { buffer: uniformBuffer } },
+ { binding: 1, resource: { buffer: instanceBuffer } },
+ { binding: 2, resource: atlas.texture.createView() },
+ { binding: 3, resource: sampler },
+ ],
+ })
+ bindGroupGeneration = atlas.generation
+ }
+ }
+
+ const encode = (pass, size) => {
+ if (!count || !bindGroup) return false
+ pass.setPipeline(pipeline)
+ pass.setBindGroup(0, bindGroup)
+ pass.setScissorRect(0, 0, size.width, size.height)
+ pass.draw(6, count)
+ return true
+ }
+
+ const destroy = () => {
+ if (uniform) surface.destroyAfterSubmission(uniform)
+ if (instances) surface.destroyAfterSubmission(instances)
+ uniform = null
+ instances = null
+ bindGroup = null
+ count = 0
+ bufferBytes = 0
+ }
+
+ return {
+ update,
+ encode,
+ destroy,
+ needsUpdate: () => bindGroupGeneration !== atlas.generation,
+ getBufferBytes: () => bufferBytes,
+ }
+}
diff --git a/src/chartLibraries/webgpu/text/shader.js b/src/chartLibraries/webgpu/text/shader.js
new file mode 100644
index 000000000..ecfefeeb9
--- /dev/null
+++ b/src/chartLibraries/webgpu/text/shader.js
@@ -0,0 +1,58 @@
+export default `
+struct Uniforms {
+ canvas: vec2,
+ padding: vec2,
+};
+
+struct TextInstance {
+ geometry: vec4,
+ uv: vec4,
+ color: vec4,
+};
+
+@group(0) @binding(0) var uniforms: Uniforms;
+@group(0) @binding(1) var instances: array;
+@group(0) @binding(2) var atlas: texture_2d;
+@group(0) @binding(3) var atlasSampler: sampler;
+
+fn quadCoordinates(vertexIndex: u32) -> vec2 {
+ switch vertexIndex {
+ case 0u: { return vec2(0.0, 0.0); }
+ case 1u: { return vec2(1.0, 0.0); }
+ case 2u: { return vec2(0.0, 1.0); }
+ case 3u: { return vec2(0.0, 1.0); }
+ case 4u: { return vec2(1.0, 0.0); }
+ default: { return vec2(1.0, 1.0); }
+ }
+}
+
+struct VertexOutput {
+ @builtin(position) position: vec4,
+ @location(0) uv: vec2,
+ @location(1) color: vec4,
+};
+
+@vertex
+fn vertexMain(
+ @builtin(vertex_index) vertexIndex: u32,
+ @builtin(instance_index) instanceIndex: u32,
+) -> VertexOutput {
+ let instance = instances[instanceIndex];
+ let quad = quadCoordinates(vertexIndex);
+ let point = instance.geometry.xy + quad * instance.geometry.zw;
+ let clipX = point.x / uniforms.canvas.x * 2.0 - 1.0;
+ let clipY = 1.0 - point.y / uniforms.canvas.y * 2.0;
+
+ var output: VertexOutput;
+ output.position = vec4(clipX, clipY, 0.0, 1.0);
+ output.uv = mix(instance.uv.xy, instance.uv.zw, quad);
+ output.color = instance.color;
+ return output;
+}
+
+@fragment
+fn fragmentMain(input: VertexOutput) -> @location(0) vec4 {
+ let alpha = textureSample(atlas, atlasSampler, input.uv).a;
+ return vec4(input.color.rgb, input.color.a * alpha);
+}
+`
diff --git a/src/chartLibraries/webgpu/visualizations/cartesian/area/resources.js b/src/chartLibraries/webgpu/visualizations/cartesian/area/resources.js
new file mode 100644
index 000000000..827c73081
--- /dev/null
+++ b/src/chartLibraries/webgpu/visualizations/cartesian/area/resources.js
@@ -0,0 +1,4 @@
+import makeLineResources from "../line/resources"
+
+export default (runtime, canvas) =>
+ makeLineResources(runtime, canvas, { fillMode: "area" })
diff --git a/src/chartLibraries/webgpu/visualizations/cartesian/area/shader.js b/src/chartLibraries/webgpu/visualizations/cartesian/area/shader.js
new file mode 100644
index 000000000..0fc726640
--- /dev/null
+++ b/src/chartLibraries/webgpu/visualizations/cartesian/area/shader.js
@@ -0,0 +1,100 @@
+export default `
+const MODE_STEP: u32 = 1u;
+
+struct Uniforms {
+ domain: vec4,
+ plot: vec4,
+ canvas: vec4,
+ fill: vec4,
+ counts: vec4,
+};
+
+@group(0) @binding(0) var uniforms: Uniforms;
+@group(0) @binding(1) var xValues: array;
+@group(0) @binding(2) var yValues: array;
+@group(0) @binding(3) var seriesColors: array>;
+
+struct VertexOutput {
+ @builtin(position) position: vec4,
+ @location(0) @interpolate(flat) color: vec4,
+};
+
+fn quadCoordinates(vertexIndex: u32) -> vec2 {
+ switch vertexIndex {
+ case 0u: { return vec2(0.0, 0.0); }
+ case 1u: { return vec2(1.0, 0.0); }
+ case 2u: { return vec2(0.0, 1.0); }
+ case 3u: { return vec2(0.0, 1.0); }
+ case 4u: { return vec2(1.0, 0.0); }
+ default: { return vec2(1.0, 1.0); }
+ }
+}
+
+fn toScreen(point: vec2) -> vec2 {
+ let xRange = max(uniforms.domain.y - uniforms.domain.x, 1e-20);
+ let yRange = max(uniforms.domain.w - uniforms.domain.z, 1e-20);
+ let x = uniforms.plot.x + ((point.x - uniforms.domain.x) / xRange) * uniforms.plot.z;
+ let y = uniforms.plot.y + (1.0 - (point.y - uniforms.domain.z) / yRange) * uniforms.plot.w;
+ return vec2(x, y);
+}
+
+fn hiddenOutput(color: vec4) -> VertexOutput {
+ var output: VertexOutput;
+ output.position = vec4(0.0, 0.0, 0.0, 0.0);
+ output.color = color;
+ return output;
+}
+
+@vertex
+fn vertexMain(
+ @builtin(vertex_index) vertexIndex: u32,
+ @builtin(instance_index) instanceIndex: u32,
+) -> VertexOutput {
+ let pairsPerSeries = uniforms.counts.x - 1u;
+ let reverseSeriesIndex = instanceIndex / pairsPerSeries;
+ let seriesIndex = uniforms.counts.y - reverseSeriesIndex - 1u;
+ let pairIndex = instanceIndex % pairsPerSeries;
+ let yOffset = seriesIndex * uniforms.counts.x;
+
+ let x0 = xValues[pairIndex];
+ let x1 = xValues[pairIndex + 1u];
+ let y0 = yValues[yOffset + pairIndex];
+ let y1 = yValues[yOffset + pairIndex + 1u];
+ let color = seriesColors[seriesIndex];
+ if (y0 != y0 || y1 != y1 || color.a <= 0.0 || uniforms.fill.y <= 0.0) {
+ return hiddenOutput(color);
+ }
+
+ let topA = toScreen(vec2(x0, y0));
+ var topB = toScreen(vec2(x1, y1));
+ if (u32(uniforms.canvas.w) == MODE_STEP) {
+ topB.y = topA.y;
+ }
+
+ var baselineA = toScreen(vec2(x0, uniforms.fill.x));
+ var baselineB = toScreen(vec2(x1, uniforms.fill.x));
+ let plotBottom = uniforms.plot.y + uniforms.plot.w;
+ baselineA.y = clamp(baselineA.y, uniforms.plot.y, plotBottom);
+ baselineB.y = clamp(baselineB.y, uniforms.plot.y, plotBottom);
+
+ let quad = quadCoordinates(vertexIndex);
+ let top = mix(topA, topB, quad.x);
+ let baseline = mix(baselineA, baselineB, quad.x);
+ let point = mix(top, baseline, quad.y);
+
+ var output: VertexOutput;
+ output.position = vec4(
+ point.x / uniforms.canvas.x * 2.0 - 1.0,
+ 1.0 - point.y / uniforms.canvas.y * 2.0,
+ 0.0,
+ 1.0
+ );
+ output.color = vec4(color.rgb, color.a * uniforms.fill.y);
+ return output;
+}
+
+@fragment
+fn fragmentMain(input: VertexOutput) -> @location(0) vec4 {
+ return input.color;
+}
+`
diff --git a/src/chartLibraries/webgpu/visualizations/cartesian/heatmap/resources.js b/src/chartLibraries/webgpu/visualizations/cartesian/heatmap/resources.js
new file mode 100644
index 000000000..5f177b186
--- /dev/null
+++ b/src/chartLibraries/webgpu/visualizations/cartesian/heatmap/resources.js
@@ -0,0 +1,4 @@
+import makeLineResources from "../line/resources"
+
+export default (runtime, canvas) =>
+ makeLineResources(runtime, canvas, { fillMode: "heatmap", markers: false })
diff --git a/src/chartLibraries/webgpu/visualizations/cartesian/heatmap/shader.js b/src/chartLibraries/webgpu/visualizations/cartesian/heatmap/shader.js
new file mode 100644
index 000000000..2f4a03408
--- /dev/null
+++ b/src/chartLibraries/webgpu/visualizations/cartesian/heatmap/shader.js
@@ -0,0 +1,127 @@
+export default `
+const HEATMAP_COLORS = array, 7>(
+ vec3(62.0, 73.0, 137.0) / 255.0,
+ vec3(49.0, 104.0, 142.0) / 255.0,
+ vec3(38.0, 130.0, 142.0) / 255.0,
+ vec3(31.0, 158.0, 137.0) / 255.0,
+ vec3(53.0, 183.0, 121.0) / 255.0,
+ vec3(110.0, 206.0, 88.0) / 255.0,
+ vec3(181.0, 222.0, 43.0) / 255.0
+);
+
+struct Uniforms {
+ domain: vec4,
+ plot: vec4,
+ canvas: vec4,
+ fill: vec4,
+ counts: vec4,
+};
+
+@group(0) @binding(0) var uniforms: Uniforms;
+@group(0) @binding(1) var xValues: array;
+@group(0) @binding(2) var yValues: array;
+@group(0) @binding(3) var seriesMetadata: array>;
+
+struct VertexOutput {
+ @builtin(position) position: vec4,
+ @location(0) @interpolate(flat) color: vec4,
+ @location(1) local: vec2,
+ @location(2) @interpolate(flat) size: vec2,
+};
+
+fn quadCoordinates(vertexIndex: u32) -> vec2 {
+ switch vertexIndex {
+ case 0u: { return vec2(0.0, 0.0); }
+ case 1u: { return vec2(1.0, 0.0); }
+ case 2u: { return vec2(0.0, 1.0); }
+ default: { return vec2(1.0, 1.0); }
+ }
+}
+
+fn toScreen(point: vec2) -> vec2 {
+ let xRange = max(uniforms.domain.y - uniforms.domain.x, 1e-20);
+ let yRange = max(uniforms.domain.w - uniforms.domain.z, 1e-20);
+ return vec2(
+ uniforms.plot.x + ((point.x - uniforms.domain.x) / xRange) * uniforms.plot.z,
+ uniforms.plot.y + (1.0 - (point.y - uniforms.domain.z) / yRange) * uniforms.plot.w
+ );
+}
+
+fn heatmapColor(value: f32, maximum: f32) -> vec4 {
+ if (value == 0.0) {
+ return vec4(0.0);
+ }
+ if (maximum != maximum || maximum <= 0.0) {
+ return vec4(HEATMAP_COLORS[0], 1.0);
+ }
+ let scaled = value / (maximum / 7.0);
+ let segment = clamp(floor(scaled), 0.0, 5.0);
+ let rgb = mix(
+ HEATMAP_COLORS[u32(segment)],
+ HEATMAP_COLORS[u32(segment) + 1u],
+ scaled - segment
+ );
+ let rounded =
+ clamp(floor(rgb * 255.0 + 0.5), vec3(0.0), vec3(255.0)) / 255.0;
+ return vec4(rounded, 1.0);
+}
+
+@vertex
+fn vertexMain(
+ @builtin(vertex_index) vertexIndex: u32,
+ @builtin(instance_index) instanceIndex: u32,
+) -> VertexOutput {
+ let pointCount = uniforms.counts.x;
+ let seriesCount = uniforms.counts.y;
+ let seriesIndex = instanceIndex / pointCount;
+ let pointIndex = instanceIndex % pointCount;
+ let value = yValues[pointIndex * seriesCount + seriesIndex];
+ let metadata = seriesMetadata[seriesIndex];
+ let color = heatmapColor(value, uniforms.fill.w);
+
+ var output: VertexOutput;
+ if (metadata.x < 0.0 || color.a <= 0.0) {
+ output.position = vec4(-2.0, -2.0, 0.0, 1.0);
+ output.color = vec4(0.0);
+ output.local = vec2(0.0);
+ output.size = vec2(0.0);
+ return output;
+ }
+
+ let center = toScreen(vec2(xValues[pointIndex], metadata.x));
+ let nextRowY = toScreen(vec2(xValues[pointIndex], metadata.x + 1.0)).y;
+ let fillSize = vec2(uniforms.fill.x, abs(center.y - nextRowY));
+ let fillOrigin = vec2(
+ center.x - fillSize.x * 0.5,
+ center.y - fillSize.y * 0.5
+ );
+ let antialiasPadding = vec2(0.5);
+ let outerOrigin = fillOrigin - antialiasPadding;
+ let outerSize = fillSize + antialiasPadding * 2.0;
+ let quad = quadCoordinates(vertexIndex);
+ let point = outerOrigin + quad * outerSize;
+
+ output.position = vec4(
+ point.x / uniforms.canvas.x * 2.0 - 1.0,
+ 1.0 - point.y / uniforms.canvas.y * 2.0,
+ 0.0,
+ 1.0
+ );
+ output.color = color;
+ output.local = quad * outerSize - antialiasPadding;
+ output.size = fillSize;
+ return output;
+}
+
+fn axisCoverage(center: f32, minimum: f32, maximum: f32) -> f32 {
+ return clamp(min(center + 0.5, maximum) - max(center - 0.5, minimum), 0.0, 1.0);
+}
+
+@fragment
+fn fragmentMain(input: VertexOutput) -> @location(0) vec4 {
+ let coverage =
+ axisCoverage(input.local.x, 0.0, input.size.x) *
+ axisCoverage(input.local.y, 0.0, input.size.y);
+ return vec4(input.color.rgb, input.color.a * coverage);
+}
+`
diff --git a/src/chartLibraries/webgpu/visualizations/cartesian/line/kernel.js b/src/chartLibraries/webgpu/visualizations/cartesian/line/kernel.js
new file mode 100644
index 000000000..0e5ddfc75
--- /dev/null
+++ b/src/chartLibraries/webgpu/visualizations/cartesian/line/kernel.js
@@ -0,0 +1,238 @@
+import makeRenderState from "@/chartLibraries/gpu/visualizations/cartesian/line/renderState"
+import areaShader from "../area/shader"
+import heatmapShader from "../heatmap/shader"
+import stackedShader from "../stacked/shader"
+import stackedBarShader from "../stackedBar/shader"
+import lineShader from "./shader"
+import {
+ makeScissor,
+ makeUniformData,
+ uniformByteLength,
+} from "./uniforms"
+
+const nextBufferSize = byteLength => {
+ let size = 4
+ while (size < byteLength) size *= 2
+ return size
+}
+
+const makePipeline = async (
+ runtime,
+ { label, shader, topology = "triangle-list" }
+) => {
+ const { device, format } = runtime
+ const module = device.createShaderModule({ label: `${label}-shader`, code: shader })
+ const compilation = await module.getCompilationInfo()
+ const errors = compilation.messages.filter(message => message.type === "error")
+ if (errors.length) throw new Error(errors.map(({ message }) => message).join("\n"))
+
+ return device.createRenderPipelineAsync({
+ label: `${label}-pipeline`,
+ layout: "auto",
+ vertex: { module, entryPoint: "vertexMain" },
+ fragment: {
+ module,
+ entryPoint: "fragmentMain",
+ targets: [
+ {
+ format,
+ blend: {
+ color: {
+ operation: "add",
+ srcFactor: "src-alpha",
+ dstFactor: "one-minus-src-alpha",
+ },
+ alpha: {
+ operation: "add",
+ srcFactor: "one",
+ dstFactor: "one-minus-src-alpha",
+ },
+ },
+ },
+ ],
+ },
+ primitive: { topology },
+ })
+}
+
+export default async (runtime, surface, { fillMode = null } = {}) => {
+ const { device, format } = runtime
+ const isMultiBar = fillMode === "multiBar"
+ const isHeatmap = fillMode === "heatmap"
+ const isBar = fillMode === "stackedBar" || isMultiBar || isHeatmap
+ const usesStackedData = fillMode === "stacked" || fillMode === "stackedBar"
+ const linePipeline = isBar
+ ? null
+ : await runtime.getPipeline(`netdata-line-v2:${format}`, () =>
+ makePipeline(runtime, { label: "netdata-line", shader: lineShader })
+ )
+ const fillShader =
+ fillMode === "stacked"
+ ? stackedShader
+ : isHeatmap
+ ? heatmapShader
+ : isBar
+ ? stackedBarShader
+ : areaShader
+ const fillPipeline = fillMode
+ ? await runtime.getPipeline(`netdata-${fillMode}-v1:${format}`, () =>
+ makePipeline(runtime, {
+ label: `netdata-${fillMode}`,
+ shader: fillShader,
+ topology: isHeatmap ? "triangle-strip" : "triangle-list",
+ })
+ )
+ : null
+ const buffers = {}
+ const bindGroups = { fill: null, line: null }
+ let drawLayout = { instanceCount: 0 }
+ let drawStats = null
+ let bufferBytes = 0
+ let scissor = { left: 0, top: 0, width: 1, height: 1 }
+
+ const ensureBuffer = (name, byteLength, usage) => {
+ const current = buffers[name]
+ if (current && current.size >= byteLength) return current
+
+ const next = device.createBuffer({
+ label: `netdata-line-${name}`,
+ size: nextBufferSize(byteLength),
+ usage,
+ })
+ buffers[name] = next
+ bindGroups.fill = null
+ bindGroups.line = null
+ bufferBytes += next.size - (current?.size || 0)
+ if (current) surface.destroyAfterSubmission(current)
+ return next
+ }
+
+ const update = ({
+ packed,
+ colors,
+ dataChanged,
+ colorsChanged,
+ afterMs,
+ beforeMs,
+ min,
+ max,
+ width,
+ height,
+ dpr,
+ plot = { left: 0, top: 0, width, height },
+ fillAlpha = 0,
+ lineWidth,
+ barWidth = 0,
+ heatmapMax = 0,
+ stepped,
+ smooth,
+ }) => {
+ const uniform = ensureBuffer(
+ "uniform",
+ uniformByteLength,
+ GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
+ )
+ const x = ensureBuffer("x", packed.x.byteLength, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST)
+ const y = ensureBuffer("y", packed.y.byteLength, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST)
+ const color = ensureBuffer(
+ "color",
+ colors.byteLength,
+ GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
+ )
+ const base =
+ usesStackedData
+ ? ensureBuffer(
+ "base",
+ packed.base.byteLength,
+ GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
+ )
+ : null
+
+ if (dataChanged) {
+ device.queue.writeBuffer(x, 0, packed.x)
+ device.queue.writeBuffer(y, 0, packed.y)
+ if (base) device.queue.writeBuffer(base, 0, packed.base)
+ }
+ if (colorsChanged) device.queue.writeBuffer(color, 0, colors)
+
+ const renderState = makeRenderState({
+ packed,
+ fillMode,
+ afterMs,
+ beforeMs,
+ minimum: min,
+ maximum: max,
+ width,
+ height,
+ dpr,
+ plot,
+ fillAlpha,
+ lineWidth,
+ barWidth,
+ heatmapMaximum: heatmapMax,
+ stepped,
+ smooth,
+ })
+ drawLayout = renderState.drawLayout
+ drawStats = renderState.drawStats
+ device.queue.writeBuffer(
+ uniform,
+ 0,
+ makeUniformData({ packed, drawLayout, ...renderState })
+ )
+ scissor = makeScissor(renderState)
+
+ const makeBindGroup = (pipeline, includeBase = false) => {
+ const entries = [
+ { binding: 0, resource: { buffer: uniform } },
+ { binding: 1, resource: { buffer: x } },
+ { binding: 2, resource: { buffer: y } },
+ { binding: 3, resource: { buffer: color } },
+ ]
+ if (includeBase)
+ entries.push({ binding: 4, resource: { buffer: base || y } })
+ return device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries })
+ }
+ if (linePipeline && !bindGroups.line) bindGroups.line = makeBindGroup(linePipeline)
+ if (fillPipeline && !bindGroups.fill)
+ bindGroups.fill = makeBindGroup(
+ fillPipeline,
+ fillMode === "stacked" || (isBar && !isHeatmap)
+ )
+ }
+
+ const encode = pass => {
+ if (!drawLayout.instanceCount) return false
+
+ pass.setScissorRect(scissor.left, scissor.top, scissor.width, scissor.height)
+ if (drawLayout.fillInstanceCount) {
+ pass.setPipeline(fillPipeline)
+ pass.setBindGroup(0, bindGroups.fill)
+ pass.draw(isHeatmap ? 4 : 6, drawLayout.fillInstanceCount)
+ }
+ if (drawLayout.strokeInstanceCount) {
+ pass.setPipeline(linePipeline)
+ pass.setBindGroup(0, bindGroups.line)
+ pass.draw(6, drawLayout.strokeInstanceCount)
+ }
+ return true
+ }
+
+ const destroy = () => {
+ Object.values(buffers).forEach(surface.destroyAfterSubmission)
+ Object.keys(buffers).forEach(name => delete buffers[name])
+ bindGroups.fill = null
+ bindGroups.line = null
+ drawLayout = { instanceCount: 0 }
+ drawStats = null
+ bufferBytes = 0
+ }
+
+ return {
+ update,
+ encode,
+ destroy,
+ getBufferBytes: () => bufferBytes,
+ getDrawStats: () => drawStats,
+ }
+}
diff --git a/src/chartLibraries/webgpu/visualizations/cartesian/line/resources.js b/src/chartLibraries/webgpu/visualizations/cartesian/line/resources.js
new file mode 100644
index 000000000..d0c6296d9
--- /dev/null
+++ b/src/chartLibraries/webgpu/visualizations/cartesian/line/resources.js
@@ -0,0 +1,32 @@
+import createResourceSet from "@/chartLibraries/gpu/engine/createResourceSet"
+import makeSurface from "@/chartLibraries/webgpu/engine/surface"
+import makeCircleLayer from "@/chartLibraries/webgpu/primitives/circle"
+import makeRectLayer from "@/chartLibraries/webgpu/primitives/rect"
+import makeTextLayer from "@/chartLibraries/webgpu/text"
+import makeKernel from "./kernel"
+
+const makeEmptyLayer = () => ({
+ destroy: () => {},
+ encode: () => false,
+ getBufferBytes: () => 0,
+ update: () => {},
+})
+
+export default (
+ runtime,
+ canvas,
+ { fillMode = null, markers = true } = {}
+) => {
+ const surface = makeSurface(runtime, canvas)
+ return createResourceSet(surface, {
+ grid: () => makeRectLayer(runtime, surface, "grid"),
+ interaction: () => makeRectLayer(runtime, surface, "interaction"),
+ overlay: () => makeRectLayer(runtime, surface, "overlay"),
+ line: () => makeKernel(runtime, surface, { fillMode }),
+ marker: () =>
+ markers
+ ? makeCircleLayer(runtime, surface)
+ : Promise.resolve(makeEmptyLayer()),
+ text: () => makeTextLayer(runtime, surface),
+ })
+}
diff --git a/src/chartLibraries/webgpu/visualizations/cartesian/line/shader.js b/src/chartLibraries/webgpu/visualizations/cartesian/line/shader.js
new file mode 100644
index 000000000..cd9347d73
--- /dev/null
+++ b/src/chartLibraries/webgpu/visualizations/cartesian/line/shader.js
@@ -0,0 +1,207 @@
+export default `
+const AA_PADDING: f32 = 1.0;
+const SMOOTH_ALPHA: f32 = 0.3333333333333333;
+const MODE_STEP: u32 = 1u;
+const MODE_SMOOTH: u32 = 2u;
+
+struct Uniforms {
+ domain: vec4,
+ plot: vec4,
+ canvas: vec4,
+ fill: vec4,
+ counts: vec4,
+};
+
+@group(0) @binding(0) var uniforms: Uniforms;
+@group(0) @binding(1) var xValues: array;
+@group(0) @binding(2) var yValues: array;
+@group(0) @binding(3) var seriesColors: array>;
+
+struct VertexOutput {
+ @builtin(position) position: vec4,
+ @location(0) across: f32,
+ @location(1) @interpolate(flat) width: f32,
+ @location(2) @interpolate(flat) color: vec4,
+};
+
+struct SmoothControls {
+ left: vec2,
+ right: vec2,
+};
+
+fn quadCoordinates(vertexIndex: u32) -> vec2 {
+ switch vertexIndex {
+ case 0u: { return vec2(0.0, 0.0); }
+ case 1u: { return vec2(1.0, 0.0); }
+ case 2u: { return vec2(0.0, 1.0); }
+ case 3u: { return vec2(0.0, 1.0); }
+ case 4u: { return vec2(1.0, 0.0); }
+ default: { return vec2(1.0, 1.0); }
+ }
+}
+
+fn toScreen(point: vec2) -> vec2 {
+ let xRange = max(uniforms.domain.y - uniforms.domain.x, 1e-20);
+ let yRange = max(uniforms.domain.w - uniforms.domain.z, 1e-20);
+ let x = uniforms.plot.x + ((point.x - uniforms.domain.x) / xRange) * uniforms.plot.z;
+ let y = uniforms.plot.y + (1.0 - (point.y - uniforms.domain.z) / yRange) * uniforms.plot.w;
+ return vec2(x, y);
+}
+
+fn valueIndex(seriesIndex: u32, pointIndex: u32) -> u32 {
+ if (uniforms.fill.z > 0.5) {
+ return pointIndex * uniforms.counts.y + seriesIndex;
+ }
+ return seriesIndex * uniforms.counts.x + pointIndex;
+}
+
+fn loadScreenPoint(seriesIndex: u32, pointIndex: u32) -> vec2 {
+ return toScreen(vec2(xValues[pointIndex], yValues[valueIndex(seriesIndex, pointIndex)]));
+}
+
+fn validScreenPoint(point: vec2) -> bool {
+ return point.y == point.y;
+}
+
+fn smoothControls(seriesIndex: u32, pointIndex: u32) -> SmoothControls {
+ let point = loadScreenPoint(seriesIndex, pointIndex);
+ var controls: SmoothControls;
+ controls.left = point;
+ controls.right = point;
+ if (pointIndex == 0u || pointIndex + 1u >= uniforms.counts.x) {
+ return controls;
+ }
+
+ let previous = loadScreenPoint(seriesIndex, pointIndex - 1u);
+ if (!validScreenPoint(previous) || !validScreenPoint(point)) {
+ return controls;
+ }
+ let next = loadScreenPoint(seriesIndex, pointIndex + 1u);
+ if (!validScreenPoint(next)) {
+ return controls;
+ }
+
+ var left = (1.0 - SMOOTH_ALPHA) * point + SMOOTH_ALPHA * previous;
+ var right = (1.0 - SMOOTH_ALPHA) * point + SMOOTH_ALPHA * next;
+ if (left.x != right.x) {
+ let deltaY = point.y - right.y - ((point.x - right.x) * (left.y - right.y)) / (left.x - right.x);
+ left.y += deltaY;
+ right.y += deltaY;
+ }
+
+ if (left.y > previous.y && left.y > point.y) {
+ left.y = max(previous.y, point.y);
+ right.y = 2.0 * point.y - left.y;
+ } else if (left.y < previous.y && left.y < point.y) {
+ left.y = min(previous.y, point.y);
+ right.y = 2.0 * point.y - left.y;
+ }
+
+ if (right.y > point.y && right.y > next.y) {
+ right.y = max(point.y, next.y);
+ left.y = 2.0 * point.y - right.y;
+ } else if (right.y < point.y && right.y < next.y) {
+ right.y = min(point.y, next.y);
+ left.y = 2.0 * point.y - right.y;
+ }
+
+ controls.left = left;
+ controls.right = right;
+ return controls;
+}
+
+fn cubicPoint(a: vec2, c1: vec2, c2: vec2, b: vec2, t: f32) -> vec2 {
+ let q0 = mix(a, c1, t);
+ let q1 = mix(c1, c2, t);
+ let q2 = mix(c2, b, t);
+ return mix(mix(q0, q1, t), mix(q1, q2, t), t);
+}
+
+fn gapOutput(color: vec4) -> VertexOutput {
+ var output: VertexOutput;
+ output.position = vec4(-2.0, -2.0, 0.0, 1.0);
+ output.across = 0.0;
+ output.width = 0.0;
+ output.color = color;
+ return output;
+}
+
+@vertex
+fn vertexMain(
+ @builtin(vertex_index) vertexIndex: u32,
+ @builtin(instance_index) instanceIndex: u32,
+) -> VertexOutput {
+ let segmentsPerPair = uniforms.counts.z;
+ let segmentsPerSeries = uniforms.counts.w;
+ let seriesIndex = instanceIndex / segmentsPerSeries;
+ let localSegment = instanceIndex % segmentsPerSeries;
+ let pairIndex = localSegment / segmentsPerPair;
+ let pairSegment = localSegment % segmentsPerPair;
+ let mode = u32(uniforms.canvas.w);
+
+ let x0 = xValues[pairIndex];
+ let x1 = xValues[pairIndex + 1u];
+ let y0 = yValues[valueIndex(seriesIndex, pairIndex)];
+ let y1 = yValues[valueIndex(seriesIndex, pairIndex + 1u)];
+ let color = seriesColors[seriesIndex];
+ let sourceA = toScreen(vec2(x0, y0));
+ let sourceB = toScreen(vec2(x1, y1));
+
+ if (y0 != y0 || y1 != y1 || color.a <= 0.0) {
+ return gapOutput(color);
+ }
+ if (mode == MODE_SMOOTH && (!validScreenPoint(sourceA) || !validScreenPoint(sourceB))) {
+ return gapOutput(color);
+ }
+
+ var screenA = sourceA;
+ var screenB = sourceB;
+ if (mode == MODE_STEP) {
+ if (pairSegment == 0u) {
+ screenB = vec2(sourceB.x, sourceA.y);
+ } else {
+ screenA = vec2(sourceB.x, sourceA.y);
+ }
+ } else if (mode == MODE_SMOOTH && segmentsPerPair > 1u) {
+ let controlsA = smoothControls(seriesIndex, pairIndex);
+ let controlsB = smoothControls(seriesIndex, pairIndex + 1u);
+ let t0 = f32(pairSegment) / f32(segmentsPerPair);
+ let t1 = f32(pairSegment + 1u) / f32(segmentsPerPair);
+ screenA = cubicPoint(sourceA, controlsA.right, controlsB.left, sourceB, t0);
+ screenB = cubicPoint(sourceA, controlsA.right, controlsB.left, sourceB, t1);
+ }
+
+ let delta = screenB - screenA;
+ let lengthPixels = length(delta);
+ if (lengthPixels < 1e-6) {
+ return gapOutput(color);
+ }
+
+ let quad = quadCoordinates(vertexIndex);
+ let perpendicular = vec2(delta.y, -delta.x) / lengthPixels;
+ let width = max(0.01, uniforms.canvas.z);
+ let halfExtent = width * 0.5 + AA_PADDING;
+ let side = mix(1.0, -1.0, quad.y);
+ let screenPosition = mix(screenA, screenB, quad.x) + perpendicular * halfExtent * side;
+ let clipX = screenPosition.x / uniforms.canvas.x * 2.0 - 1.0;
+ let clipY = 1.0 - screenPosition.y / uniforms.canvas.y * 2.0;
+
+ var output: VertexOutput;
+ output.position = vec4(clipX, clipY, 0.0, 1.0);
+ output.across = halfExtent * (1.0 + side);
+ output.width = width;
+ output.color = color;
+ return output;
+}
+
+@fragment
+fn fragmentMain(input: VertexOutput) -> @location(0) vec4 {
+ let center = input.width * 0.5 + AA_PADDING;
+ let distance = abs(input.across - center);
+ let antialias = max(fwidth(input.across), 1e-3) * 0.75;
+ let inner = max(0.0, input.width * 0.5 - antialias);
+ let outer = input.width * 0.5 + antialias;
+ let coverage = 1.0 - smoothstep(inner, outer, distance);
+ return vec4(input.color.rgb, input.color.a * coverage);
+}
+`
diff --git a/src/chartLibraries/webgpu/visualizations/cartesian/line/uniforms.js b/src/chartLibraries/webgpu/visualizations/cartesian/line/uniforms.js
new file mode 100644
index 000000000..25b30801c
--- /dev/null
+++ b/src/chartLibraries/webgpu/visualizations/cartesian/line/uniforms.js
@@ -0,0 +1,52 @@
+const uniformByteLength = 80
+
+export const makeUniformData = ({
+ packed,
+ drawLayout,
+ domain,
+ plot,
+ canvas,
+ fill,
+}) => {
+ const data = new ArrayBuffer(uniformByteLength)
+ const floats = new Float32Array(data)
+ const integers = new Uint32Array(data)
+
+ floats.set([
+ domain.after,
+ domain.before,
+ domain.minimum,
+ domain.maximum,
+ plot.left,
+ plot.top,
+ plot.width,
+ plot.height,
+ canvas.width,
+ canvas.height,
+ canvas.lineWidth,
+ canvas.mode,
+ fill.baseline,
+ fill.opacity,
+ fill.mode,
+ fill.heatmapMaximum,
+ ])
+ integers.set(
+ [
+ packed.pointCount,
+ packed.seriesCount,
+ drawLayout.segmentsPerPair,
+ drawLayout.segmentsPerSeries,
+ ],
+ 16
+ )
+ return data
+}
+
+export const makeScissor = ({ plot, canvas }) => ({
+ left: Math.min(plot.left, canvas.width - 1),
+ top: Math.min(plot.top, canvas.height - 1),
+ width: Math.max(1, Math.min(plot.width, canvas.width - plot.left)),
+ height: Math.max(1, Math.min(plot.height, canvas.height - plot.top)),
+})
+
+export { uniformByteLength }
diff --git a/src/chartLibraries/webgpu/visualizations/cartesian/line/uniforms.test.js b/src/chartLibraries/webgpu/visualizations/cartesian/line/uniforms.test.js
new file mode 100644
index 000000000..4944338d7
--- /dev/null
+++ b/src/chartLibraries/webgpu/visualizations/cartesian/line/uniforms.test.js
@@ -0,0 +1,38 @@
+import {
+ makeScissor,
+ makeUniformData,
+ uniformByteLength,
+} from "./uniforms"
+
+describe("WebGPU line uniform packing", () => {
+ it("packs named frame state into the exact shader layout", () => {
+ const data = makeUniformData({
+ packed: { pointCount: 17, seriesCount: 18 },
+ drawLayout: { segmentsPerPair: 19, segmentsPerSeries: 20 },
+ domain: { after: 1, before: 2, minimum: 3, maximum: 4 },
+ plot: { left: 5, top: 6, width: 7, height: 8 },
+ canvas: { width: 9, height: 10, lineWidth: 11, mode: 12 },
+ fill: {
+ baseline: 13,
+ opacity: 14,
+ mode: 15,
+ heatmapMaximum: 16,
+ },
+ })
+
+ expect(data.byteLength).toBe(uniformByteLength)
+ expect([...new Float32Array(data).slice(0, 16)]).toEqual([
+ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
+ ])
+ expect([...new Uint32Array(data).slice(16)]).toEqual([17, 18, 19, 20])
+ })
+
+ it("clamps the scissor to the physical canvas", () => {
+ expect(
+ makeScissor({
+ plot: { left: 90, top: 80, width: 30, height: 40 },
+ canvas: { width: 100, height: 100 },
+ })
+ ).toEqual({ left: 90, top: 80, width: 10, height: 20 })
+ })
+})
diff --git a/src/chartLibraries/webgpu/visualizations/cartesian/multiBar/resources.js b/src/chartLibraries/webgpu/visualizations/cartesian/multiBar/resources.js
new file mode 100644
index 000000000..44f914760
--- /dev/null
+++ b/src/chartLibraries/webgpu/visualizations/cartesian/multiBar/resources.js
@@ -0,0 +1,4 @@
+import makeLineResources from "../line/resources"
+
+export default (runtime, canvas) =>
+ makeLineResources(runtime, canvas, { fillMode: "multiBar", markers: false })
diff --git a/src/chartLibraries/webgpu/visualizations/cartesian/stacked/resources.js b/src/chartLibraries/webgpu/visualizations/cartesian/stacked/resources.js
new file mode 100644
index 000000000..a6f6f2100
--- /dev/null
+++ b/src/chartLibraries/webgpu/visualizations/cartesian/stacked/resources.js
@@ -0,0 +1,4 @@
+import makeLineResources from "../line/resources"
+
+export default (runtime, canvas) =>
+ makeLineResources(runtime, canvas, { fillMode: "stacked" })
diff --git a/src/chartLibraries/webgpu/visualizations/cartesian/stacked/shader.js b/src/chartLibraries/webgpu/visualizations/cartesian/stacked/shader.js
new file mode 100644
index 000000000..62f5b0004
--- /dev/null
+++ b/src/chartLibraries/webgpu/visualizations/cartesian/stacked/shader.js
@@ -0,0 +1,101 @@
+export default `
+const MODE_STEP: u32 = 1u;
+
+struct Uniforms {
+ domain: vec4,
+ plot: vec4,
+ canvas: vec4,
+ fill: vec4,
+ counts: vec4,
+};
+
+@group(0) @binding(0) var uniforms: Uniforms;
+@group(0) @binding(1) var xValues: array;
+@group(0) @binding(2) var yValues: array;
+@group(0) @binding(3) var seriesColors: array>;
+@group(0) @binding(4) var baseValues: array;
+
+struct VertexOutput {
+ @builtin(position) position: vec4,
+ @location(0) @interpolate(flat) color: vec4,
+};
+
+fn quadCoordinates(vertexIndex: u32) -> vec2 {
+ switch vertexIndex {
+ case 0u: { return vec2(0.0, 0.0); }
+ case 1u: { return vec2(1.0, 0.0); }
+ case 2u: { return vec2(0.0, 1.0); }
+ case 3u: { return vec2(0.0, 1.0); }
+ case 4u: { return vec2(1.0, 0.0); }
+ default: { return vec2(1.0, 1.0); }
+ }
+}
+
+fn toScreen(point: vec2) -> vec2 {
+ let xRange = max(uniforms.domain.y - uniforms.domain.x, 1e-20);
+ let yRange = max(uniforms.domain.w - uniforms.domain.z, 1e-20);
+ let x = uniforms.plot.x + ((point.x - uniforms.domain.x) / xRange) * uniforms.plot.z;
+ let y = uniforms.plot.y + (1.0 - (point.y - uniforms.domain.z) / yRange) * uniforms.plot.w;
+ return vec2(x, y);
+}
+
+fn hiddenOutput(color: vec4) -> VertexOutput {
+ var output: VertexOutput;
+ output.position = vec4(0.0, 0.0, 0.0, 0.0);
+ output.color = color;
+ return output;
+}
+
+@vertex
+fn vertexMain(
+ @builtin(vertex_index) vertexIndex: u32,
+ @builtin(instance_index) instanceIndex: u32,
+) -> VertexOutput {
+ let pairsPerSeries = uniforms.counts.x - 1u;
+ let seriesIndex = instanceIndex / pairsPerSeries;
+ let pairIndex = instanceIndex % pairsPerSeries;
+ let offset = pairIndex * uniforms.counts.y + seriesIndex;
+
+ let x0 = xValues[pairIndex];
+ let x1 = xValues[pairIndex + 1u];
+ let end0 = yValues[offset];
+ let nextOffset = (pairIndex + 1u) * uniforms.counts.y + seriesIndex;
+ let end1 = yValues[nextOffset];
+ let base0 = baseValues[offset];
+ let base1 = baseValues[nextOffset];
+ let color = seriesColors[seriesIndex];
+ if (
+ end0 != end0 || end1 != end1 || base0 != base0 || base1 != base1 ||
+ color.a <= 0.0 || uniforms.fill.y <= 0.0
+ ) {
+ return hiddenOutput(color);
+ }
+
+ let topA = toScreen(vec2(x0, end0));
+ var topB = toScreen(vec2(x1, end1));
+ if (u32(uniforms.canvas.w) == MODE_STEP) {
+ topB.y = topA.y;
+ }
+ let baselineA = toScreen(vec2(x0, base0));
+ let baselineB = toScreen(vec2(x1, base1));
+ let quad = quadCoordinates(vertexIndex);
+ let top = mix(topA, topB, quad.x);
+ let baseline = mix(baselineA, baselineB, quad.x);
+ let point = mix(top, baseline, quad.y);
+
+ var output: VertexOutput;
+ output.position = vec4(
+ point.x / uniforms.canvas.x * 2.0 - 1.0,
+ 1.0 - point.y / uniforms.canvas.y * 2.0,
+ 0.0,
+ 1.0
+ );
+ output.color = vec4(color.rgb, color.a * uniforms.fill.y);
+ return output;
+}
+
+@fragment
+fn fragmentMain(input: VertexOutput) -> @location(0) vec4 {
+ return input.color;
+}
+`
diff --git a/src/chartLibraries/webgpu/visualizations/cartesian/stackedBar/resources.js b/src/chartLibraries/webgpu/visualizations/cartesian/stackedBar/resources.js
new file mode 100644
index 000000000..71d7bf097
--- /dev/null
+++ b/src/chartLibraries/webgpu/visualizations/cartesian/stackedBar/resources.js
@@ -0,0 +1,4 @@
+import makeLineResources from "../line/resources"
+
+export default (runtime, canvas) =>
+ makeLineResources(runtime, canvas, { fillMode: "stackedBar", markers: false })
diff --git a/src/chartLibraries/webgpu/visualizations/cartesian/stackedBar/shader.js b/src/chartLibraries/webgpu/visualizations/cartesian/stackedBar/shader.js
new file mode 100644
index 000000000..dde9fc810
--- /dev/null
+++ b/src/chartLibraries/webgpu/visualizations/cartesian/stackedBar/shader.js
@@ -0,0 +1,165 @@
+export default `
+// Matches Canvas2D fillRect/strokeRect subpixel edge composition.
+const CANVAS_STROKE_COVERAGE: f32 = 1.17;
+
+struct Uniforms {
+ domain: vec4,
+ plot: vec4,
+ canvas: vec4,
+ fill: vec4,
+ counts: vec4,
+};
+
+@group(0) @binding(0) var uniforms: Uniforms;
+@group(0) @binding(1) var xValues: array;
+@group(0) @binding(2) var yValues: array;
+@group(0) @binding(3) var seriesColors: array>;
+@group(0) @binding(4) var baseValues: array;
+
+struct VertexOutput {
+ @builtin(position) position: vec4,
+ @location(0) @interpolate(flat) color: vec4,
+ @location(1) @interpolate(flat) strokeColor: vec4,
+ @location(2) local: vec2,
+ @location(3) @interpolate(flat) size: vec2,
+ @location(4) @interpolate(flat) strokeWidth: f32,
+};
+
+fn quadCoordinates(vertexIndex: u32) -> vec2 {
+ switch vertexIndex {
+ case 0u: { return vec2(0.0, 0.0); }
+ case 1u: { return vec2(1.0, 0.0); }
+ case 2u: { return vec2(0.0, 1.0); }
+ case 3u: { return vec2(0.0, 1.0); }
+ case 4u: { return vec2(1.0, 0.0); }
+ default: { return vec2(1.0, 1.0); }
+ }
+}
+
+fn toScreen(point: vec2) -> vec2 {
+ let xRange = max(uniforms.domain.y - uniforms.domain.x, 1e-20);
+ let yRange = max(uniforms.domain.w - uniforms.domain.z, 1e-20);
+ let x = uniforms.plot.x + ((point.x - uniforms.domain.x) / xRange) * uniforms.plot.z;
+ let y = uniforms.plot.y + (1.0 - (point.y - uniforms.domain.z) / yRange) * uniforms.plot.w;
+ return vec2(x, y);
+}
+
+@vertex
+fn vertexMain(
+ @builtin(vertex_index) vertexIndex: u32,
+ @builtin(instance_index) instanceIndex: u32,
+) -> VertexOutput {
+ let pointCount = uniforms.counts.x;
+ let seriesIndex = instanceIndex / pointCount;
+ let pointIndex = instanceIndex % pointCount;
+ let isMultiBar = uniforms.fill.z > 1.5;
+ var offset = pointIndex * uniforms.counts.y + seriesIndex;
+ var colorOffset = seriesIndex * 2u;
+ if (isMultiBar) {
+ offset = seriesIndex * pointCount + pointIndex;
+ colorOffset = seriesIndex * 3u;
+ }
+ let end = yValues[offset];
+ var base = baseValues[offset];
+ let color = seriesColors[colorOffset];
+ let strokeColor = seriesColors[colorOffset + 1u];
+ var visibleRank = 0.0;
+ var visibleCount = 1.0;
+ if (isMultiBar) {
+ base = uniforms.fill.y;
+ let metadata = seriesColors[colorOffset + 2u];
+ visibleRank = metadata.x;
+ visibleCount = metadata.y;
+ }
+
+ var output: VertexOutput;
+ if (
+ end != end || base != base || color.a <= 0.0 ||
+ visibleRank < 0.0 || visibleCount <= 0.0
+ ) {
+ output.position = vec4(0.0, 0.0, 0.0, 0.0);
+ output.color = color;
+ output.strokeColor = strokeColor;
+ output.local = vec2(0.0);
+ output.size = vec2