diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 0ce632949c..a7ede6e99d 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -28,7 +28,7 @@ on: - docs/ - examples/ - rust/perspective-python/README.md - pull_request: + pull_request_target: branches: - master workflow_dispatch: @@ -152,7 +152,7 @@ jobs: - name: WebAssembly Build run: pnpm run build --ci env: - PACKAGE: "server,client,viewer,viewer-datagrid,viewer-charts,viewer-openlayers,workspace,react" + PACKAGE: "server,client,viewer,viewer-datagrid,viewer-charts,workspace,react" # PSP_USE_CCACHE: 1 - uses: actions/upload-artifact@v4 @@ -166,7 +166,6 @@ jobs: rust/perspective-viewer/src packages/viewer-charts/dist packages/viewer-datagrid/dist - packages/viewer-openlayers/dist packages/workspace/dist packages/react/dist @@ -649,14 +648,24 @@ jobs: path: . - name: Run Tests + id: run_tests run: pnpm run test -- --fetch-snapshots env: - PACKAGE: "server,client,viewer,viewer-datagrid,viewer-charts,viewer-openlayers,workspace,react" + PACKAGE: "server,client,viewer,viewer-datagrid,viewer-charts,workspace,react" PSP_SNAPSHOT_REPO: ${{ vars.PSP_SNAPSHOT_REPO }} PSP_SNAPSHOT_TOKEN: ${{ secrets.PSP_SNAPSHOT_TOKEN }} PSP_SNAPSHOT_REF: ${{ github.head_ref || github.ref_name }} # PSP_USE_CCACHE: 1 + - name: Upload Test Failures + if: ${{ failure() && steps.run_tests.outcome == 'failure' }} + uses: actions/upload-artifact@v4 + with: + name: perspective-js-test-results + path: tools/test/dist/results + if-no-files-found: ignore + overwrite: true + # ,--,--' . .-,--. . . # `- | ,-. ,-. |- '|__/ . . |- |-. ,-. ,-. # , | |-' `-. | ,| | | | | | | | | | @@ -1020,9 +1029,6 @@ jobs: - run: pnpm pack --pack-destination=../.. working-directory: ./packages/viewer-charts - - run: pnpm pack --pack-destination=../.. - working-directory: ./packages/viewer-openlayers - - run: pnpm pack --pack-destination=../.. working-directory: ./packages/workspace diff --git a/Cargo.lock b/Cargo.lock index 9520d90a45..fd25758f26 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2095,6 +2095,7 @@ dependencies = [ "arrow-array", "arrow-ipc", "arrow-schema", + "arrow-select", "async-lock", "futures", "getrandom 0.3.4", diff --git a/docs/md/how_to/javascript/installation.md b/docs/md/how_to/javascript/installation.md index d4f0cf1db2..8471fb4809 100644 --- a/docs/md/how_to/javascript/installation.md +++ b/docs/md/how_to/javascript/installation.md @@ -13,16 +13,14 @@ which modules they need. The main modules are: includes the core data engine module as a dependency. `` by itself only implements a trivial debug renderer, which -prints the currently configured `view()` as a CSV. Plugin modules for popular -JavaScript libraries, such as [d3fc](https://d3fc.io/), are packaged separately -and must be imported individually. +prints the currently configured `view()` as a CSV. Plugin modules are packaged +separately and must be imported individually. - `@perspective-dev/viewer-datagrid` A custom high-performance data-grid component based on HTML ``. - `@perspective-dev/viewer-charts` - A `` plugin for the [d3fc](https://d3fc.io) charting - library. + A set of charting components base on WebGL. When imported after `@perspective-dev/viewer`, the plugin modules will register themselves automatically, and the renderers they export will be available in the @@ -44,7 +42,7 @@ installed separately. All Plugins are optional - but a `` without Plugins would be rather boring! ```bash -$ npm add @perspective-dev/viewer-charts @perspective-dev/viewer-datagrid @perspective-dev/viewer-openlayers +$ npm add @perspective-dev/viewer-charts @perspective-dev/viewer-datagrid ``` ## Node.js diff --git a/docs/src/components/gallery.ts b/docs/src/components/gallery.ts index e68d75366c..5a8bf9e485 100644 --- a/docs/src/components/gallery.ts +++ b/docs/src/components/gallery.ts @@ -11,7 +11,7 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import EXAMPLES from "../data/features.js"; -import { SUPERSTORE_TABLE } from "../data/superstore.js"; +import { WORKER, SUPERSTORE_TABLE } from "../data/superstore.js"; import { getColorMode, getPerspectiveTheme } from "./theme.js"; function showOverlay(index: number) { @@ -31,9 +31,10 @@ function showOverlay(index: number) { document.body.appendChild(overlay); SUPERSTORE_TABLE.then((table: any) => { - viewer.load(table); + viewer.load(WORKER); viewer.restore({ plugin: "Datagrid", + table: "superstore", group_by: [], expressions: {}, split_by: [], @@ -61,9 +62,7 @@ export async function initGallery(container: HTMLElement) { img.alt = "Perspective feature gallery"; img.src = `/features/montage${isDark ? "_dark" : "_light"}.png`; img.addEventListener("click", (event: MouseEvent) => { - const col = Math.floor( - (event.offsetX / img.offsetWidth) * map.columns, - ); + const col = Math.floor((event.offsetX / img.offsetWidth) * map.columns); const row = Math.floor((event.offsetY / img.offsetHeight) * rows); const tileIndex = row * map.columns + col; const featureIndex = map.order[tileIndex]; diff --git a/docs/src/data/superstore.ts b/docs/src/data/superstore.ts index 980100cd3b..ce3f23aaa7 100644 --- a/docs/src/data/superstore.ts +++ b/docs/src/data/superstore.ts @@ -14,9 +14,12 @@ import { worker } from "./worker.js"; // @ts-ignore import SUPERSTORE_URL from "superstore-arrow/superstore.lz4.arrow"; +export const WORKER = worker(); + export const SUPERSTORE_TABLE = (async function () { - const w = await worker(); const req = await fetch(SUPERSTORE_URL); const arrow = await req.arrayBuffer(); - return await w.table(arrow.slice()); + return await WORKER.then((w) => + w.table(arrow.slice(), { name: "superstore" }), + ); })(); diff --git a/docs/test/js/examples.spec.mts b/docs/test/js/examples.spec.mts index 9cbcf68f57..e56ebb624e 100644 --- a/docs/test/js/examples.spec.mts +++ b/docs/test/js/examples.spec.mts @@ -67,8 +67,6 @@ test.describe("Examples", () => { let selector = ""; if (new_config.plugin === "Datagrid") { selector = "perspective-viewer-datagrid"; - } else if (new_config.plugin === "Map Scatter") { - selector = "perspective-viewer-openlayers-scatter"; } else { const plugin = new_config.plugin .replace(/[-\/\s]/gi, "") diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000000..1c4b187137 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,101 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import tseslint from "typescript-eslint"; +// import globals from "globals"; + +export default tseslint.config( + { + ignores: [ + "docs/**", + "tools/**", + "rust/**", + "examples/**", + "packages/cli/**/*", + "packages/jupyterlab/**/*", + // "packages/viewer-charts/**/*", + // "packages/viewer-datagrid/**/*", + "packages/workspace/**/*", + "packages/react/**/*", + + ".emsdk/**", + "**/py_modules/**", + "**/node_modules/**", + "**/dist/**", + "**/build/**", + "**/target/**", + "**/pkg/**", + ], + }, + ...tseslint.configs.recommended, + { + files: ["**/*.{ts,mts,tsx}"], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + // globals: { + // ...globals.browser, + // ...globals.node, + // }, + }, + rules: { + "@typescript-eslint/no-explicit-any": "off", + curly: "warn", + "padding-line-between-statements": [ + "warn", + { blankLine: "always", prev: "block-like", next: "*" }, + ], + "@typescript-eslint/no-unused-vars": [ + "warn", + { + args: "none", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, + ], + + // // This is why we can't have nice things. I like this rule, but + // // `prettier` doesn't, so we conform or perish. + // + // "lines-around-comment": [ + // "warn", + // { + // beforeLineComment: true, + // beforeBlockComment: true, + // }, + // ], + "@typescript-eslint/no-empty-object-type": "off", + "@typescript-eslint/no-unused-expressions": "off", + "@typescript-eslint/no-this-alias": "off", + "@typescript-eslint/no-require-imports": "off", + "@typescript-eslint/ban-ts-comment": "off", + "@typescript-eslint/no-namespace": "off", + "@typescript-eslint/no-wrapper-object-types": "off", + "@typescript-eslint/no-unsafe-function-type": "off", + "no-empty": "off", + "no-prototype-builtins": "off", + "no-control-regex": "off", + "no-useless-escape": "off", + "no-async-promise-executor": "off", + "no-cond-assign": "off", + "no-misleading-character-class": "off", + }, + }, + { + files: ["examples/**/*.{ts,mts,tsx}", "tools/bench/**/*.{ts,mts,tsx}"], + rules: { + "@typescript-eslint/no-unused-vars": "warn", + }, + }, +); diff --git a/examples/blocks/package.json b/examples/blocks/package.json index a5b770212e..490be84ca0 100644 --- a/examples/blocks/package.json +++ b/examples/blocks/package.json @@ -16,7 +16,6 @@ "@perspective-dev/viewer": "workspace:", "@perspective-dev/viewer-charts": "workspace:", "@perspective-dev/viewer-datagrid": "workspace:", - "@perspective-dev/viewer-openlayers": "workspace:", "@perspective-dev/workspace": "workspace:", "superstore-arrow": "catalog:" }, diff --git a/examples/blocks/src/dataset/index.html b/examples/blocks/src/dataset/index.html index b6106a632b..504314858c 100644 --- a/examples/blocks/src/dataset/index.html +++ b/examples/blocks/src/dataset/index.html @@ -182,6 +182,13 @@ if (state.table) { // await viewer.eject(); // await window.psp_workspace.removeTable("superstore"); + for (const viewer of window.psp_workspace.children) { + const cfg = await viewer.save(); + if (cfg.table === "superstore") { + await viewer.eject(); + } + } + await state.table.then((x) => x.delete({ lazy: true })); state.table = undefined; } diff --git a/examples/blocks/src/evictions/index.html b/examples/blocks/src/evictions/index.html index 9d5ea42193..fa0cd1c864 100644 --- a/examples/blocks/src/evictions/index.html +++ b/examples/blocks/src/evictions/index.html @@ -10,7 +10,6 @@ import "/node_modules/@perspective-dev/viewer/dist/cdn/perspective-viewer.js"; import "/node_modules/@perspective-dev/viewer-datagrid/dist/cdn/perspective-viewer-datagrid.js"; import "/node_modules/@perspective-dev/viewer-charts/dist/cdn/perspective-viewer-charts.js"; - import "/node_modules/@perspective-dev/viewer-openlayers/dist/cdn/perspective-viewer-openlayers.js"; import { worker } from "/node_modules/@perspective-dev/client/dist/cdn/perspective.js"; diff --git a/examples/blocks/src/file/file.js b/examples/blocks/src/file/file.js index 4addbd4e32..4f0cfee98b 100644 --- a/examples/blocks/src/file/file.js +++ b/examples/blocks/src/file/file.js @@ -13,7 +13,6 @@ import "/node_modules/@perspective-dev/viewer/dist/cdn/perspective-viewer.js"; import "/node_modules/@perspective-dev/viewer-datagrid/dist/cdn/perspective-viewer-datagrid.js"; import "/node_modules/@perspective-dev/viewer-charts/dist/cdn/perspective-viewer-charts.js"; -import "/node_modules/@perspective-dev/viewer-openlayers/dist/cdn/perspective-viewer-openlayers.js"; import perspective from "/node_modules/@perspective-dev/client/dist/cdn/perspective.js"; diff --git a/examples/blocks/src/fractal/index.css b/examples/blocks/src/fractal/index.css index 0ce58160c6..743b0e5ad1 100644 --- a/examples/blocks/src/fractal/index.css +++ b/examples/blocks/src/fractal/index.css @@ -19,8 +19,9 @@ perspective-viewer { perspective-viewer[theme="Pro Light"], perspective-viewer[theme="Pro Dark"] { - --psp-d3fc--pos-gradient--background: linear-gradient( - #94d0ff, + --psp-charts--gradient--background: linear-gradient( + #000 0%, + #94d0ff 50%, #8795e8, #966bff, #ad8cff, @@ -80,8 +81,9 @@ perspective-viewer[theme="Pro Dark"] { span, input, button { - font-family: "ui-monospace", "SFMono-Regular", "SF Mono", "Menlo", - "Consolas", "Liberation Mono", monospace; + font-family: + "ui-monospace", "SFMono-Regular", "SF Mono", "Menlo", "Consolas", + "Liberation Mono", monospace; font-size: 12px; background: none; margin: 0px; @@ -104,8 +106,9 @@ input { } input[type="number"] { - font-family: "ui-monospace", "SFMono-Regular", "SF Mono", "Menlo", - "Consolas", "Liberation Mono", monospace; + font-family: + "ui-monospace", "SFMono-Regular", "SF Mono", "Menlo", "Consolas", + "Liberation Mono", monospace; } input:focus { @@ -157,6 +160,7 @@ input[type="range"]::-webkit-slider-thumb { input[type="range"]::-moz-range-thumb { -webkit-appearance: none; + appearance: none; pointer-events: all; width: 6px; height: 24px; @@ -171,8 +175,12 @@ input[type="range"]::-webkit-slider-thumb:hover { } input[type="range"]::-webkit-slider-thumb:active { - box-shadow: inset 0 0 3px #387bbe, 0 0 9px #387bbe; - -webkit-box-shadow: inset 0 0 3px #387bbe, 0 0 9px #387bbe; + box-shadow: + inset 0 0 3px #387bbe, + 0 0 9px #387bbe; + -webkit-box-shadow: + inset 0 0 3px #387bbe, + 0 0 9px #387bbe; } /* input[type="number"] { diff --git a/examples/blocks/src/fractal/index.js b/examples/blocks/src/fractal/index.js index 158c156111..180747187a 100644 --- a/examples/blocks/src/fractal/index.js +++ b/examples/blocks/src/fractal/index.js @@ -150,8 +150,6 @@ function set_runnable() { window.run.disabled = false; } -const heatmap_plugin = await window.viewer.getPlugin("Heatmap"); -heatmap_plugin.max_cells = 100000; make_range(xmin, xmax, "X"); make_range(ymin, ymax, "Y"); window.resolution.addEventListener("input", set_runnable); diff --git a/examples/blocks/src/market/layouts.json b/examples/blocks/src/market/layouts.json index 2e4daa7303..e89d3a6a3f 100644 --- a/examples/blocks/src/market/layouts.json +++ b/examples/blocks/src/market/layouts.json @@ -39,12 +39,7 @@ "split_by": ["if(\"status\"!='open'){\"status\"}else{\"side\"}"], "columns": ["price"], "filter": [], - "sort": [ - [ - "if(\"status\"=='closed'){1}else if(\"status\"=='expired'){2}else{0}", - "col desc" - ] - ], + "sort": [], "expressions": { "if(\"status\"=='closed'){1}else if(\"status\"=='expired'){2}else{0}": "if(\"status\"=='closed'){1}else if(\"status\"=='expired'){2}else{0}", "if(\"status\"!='open'){\"status\"}else{\"side\"}": "if(\"status\"!='open'){\"status\"}else{\"side\"}", @@ -64,12 +59,7 @@ "if(\"status\"!='open'){\"status\"}else{\"side\"}" ], "filter": [], - "sort": [ - [ - "if(\"status\"=='closed'){1}else if(\"status\"=='expired'){2}else{0}", - "desc" - ] - ], + "sort": [], "expressions": { "if(\"status\"!='open'){\"status\"}else{\"side\"}": "if(\"status\"!='open'){\"status\"}else{\"side\"}", "if(\"status\"=='closed'){1}else if(\"status\"=='expired'){2}else{0}": "if(\"status\"=='closed'){1}else if(\"status\"=='expired'){2}else{0}" diff --git a/examples/blocks/src/nypd/index.js b/examples/blocks/src/nypd/index.js index 6a8452b503..2128d55005 100644 --- a/examples/blocks/src/nypd/index.js +++ b/examples/blocks/src/nypd/index.js @@ -14,7 +14,6 @@ import "/node_modules/@perspective-dev/viewer/dist/cdn/perspective-viewer.js"; import "/node_modules/@perspective-dev/workspace/dist/cdn/perspective-workspace.js"; import "/node_modules/@perspective-dev/viewer-datagrid/dist/cdn/perspective-viewer-datagrid.js"; import "/node_modules/@perspective-dev/viewer-charts/dist/cdn/perspective-viewer-charts.js"; -import "/node_modules/@perspective-dev/viewer-openlayers/dist/cdn/perspective-viewer-openlayers.js"; import perspective from "/node_modules/@perspective-dev/client/dist/cdn/perspective.js"; diff --git a/examples/blocks/src/raycasting/index.css b/examples/blocks/src/raycasting/index.css index 58623eb21d..f24441df80 100644 --- a/examples/blocks/src/raycasting/index.css +++ b/examples/blocks/src/raycasting/index.css @@ -21,9 +21,9 @@ perspective-viewer { } perspective-viewer[theme="Pro Dark"] { - --d3fc-positive--gradient: linear-gradient( - #242526, - #071952 1%, + --d3fc-charts--gradient--background: linear-gradient( + #242526 50%, + #071952, #088395, #35a29f, #f2f7a1 @@ -31,9 +31,9 @@ perspective-viewer[theme="Pro Dark"] { } perspective-viewer[theme="Pro Light"] { - --d3fc-positive--gradient: linear-gradient( - #ffffff, - #071952 1%, + --d3fc-charts--gradient--background: linear-gradient( + #ffffff 50%, + #071952, #088395, #35a29f, #f2f7a1 diff --git a/examples/blocks/src/raycasting/index.html b/examples/blocks/src/raycasting/index.html index 38590dad8e..e9b44756ac 100644 --- a/examples/blocks/src/raycasting/index.html +++ b/examples/blocks/src/raycasting/index.html @@ -9,6 +9,6 @@ - Rendering ... + Rendering ... diff --git a/examples/blocks/src/raycasting/index.js b/examples/blocks/src/raycasting/index.js index 471bfe266d..e3fa58e475 100644 --- a/examples/blocks/src/raycasting/index.js +++ b/examples/blocks/src/raycasting/index.js @@ -137,10 +137,9 @@ const LAYOUT = { theme: "Pro Dark", }; -const heatmap_plugin = await window.viewer.getPlugin("Heatmap"); -heatmap_plugin.max_cells = 100000; const worker = await perspective.worker(); const index = new Array(Math.pow(RESOLUTION, 2)).fill(0); worker.table({ index }, { name: "raycasting" }); window.viewer.load(worker); await window.viewer.restore(LAYOUT); +document.querySelector("#rendering_label").remove(); diff --git a/package.json b/package.json index accd7f3698..78d067984a 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,6 @@ "packages/react", "packages/viewer-datagrid", "packages/viewer-charts", - "packages/viewer-openlayers", "packages/workspace", "packages/jupyterlab", "packages/cli", @@ -47,16 +46,17 @@ "@perspective-dev/viewer": "workspace:^", "@perspective-dev/viewer-charts": "workspace:^", "@perspective-dev/viewer-datagrid": "workspace:^", - "@perspective-dev/viewer-openlayers": "workspace:^", "@perspective-dev/workspace": "workspace:^", "@playwright/experimental-ct-react": "catalog:", "@playwright/test": "catalog:", "chalk": "catalog:", "dotenv": "catalog:", + "eslint": "catalog:", "husky": "catalog:", "npm-run-all": "catalog:", "prettier": "catalog:", - "tsx": "catalog:" + "tsx": "catalog:", + "typescript-eslint": "catalog:" }, "pnpm": { "overrides": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 84df138bc8..b5288efc94 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -26,7 +26,6 @@ "@perspective-dev/viewer": "workspace:", "@perspective-dev/viewer-charts": "workspace:", "@perspective-dev/viewer-datagrid": "workspace:", - "@perspective-dev/viewer-openlayers": "workspace:", "@perspective-dev/workspace": "workspace:", "commander": "catalog:", "puppeteer": "catalog:" diff --git a/packages/jupyterlab/package.json b/packages/jupyterlab/package.json index 9307b8e626..97f6326554 100644 --- a/packages/jupyterlab/package.json +++ b/packages/jupyterlab/package.json @@ -25,7 +25,6 @@ "dependencies": { "@perspective-dev/viewer-charts": "workspace:", "@perspective-dev/viewer-datagrid": "workspace:", - "@perspective-dev/viewer-openlayers": "workspace:", "@perspective-dev/viewer": "workspace:", "@perspective-dev/client": "workspace:", "@perspective-dev/server": "workspace:", diff --git a/packages/jupyterlab/src/js/index.js b/packages/jupyterlab/src/js/index.js index fb40b018ff..0f1ea99040 100644 --- a/packages/jupyterlab/src/js/index.js +++ b/packages/jupyterlab/src/js/index.js @@ -28,7 +28,6 @@ export * from "./widget"; import "@perspective-dev/viewer-datagrid"; import "@perspective-dev/viewer-charts"; -import "@perspective-dev/viewer-openlayers"; // NOTE: only expose the widget here import { PerspectiveJupyterPlugin } from "./plugin"; diff --git a/packages/jupyterlab/src/js/notebook/index.js b/packages/jupyterlab/src/js/notebook/index.js index a71ea1a076..2ee10787f2 100644 --- a/packages/jupyterlab/src/js/notebook/index.js +++ b/packages/jupyterlab/src/js/notebook/index.js @@ -12,7 +12,6 @@ import "@perspective-dev/viewer-datagrid"; import "@perspective-dev/viewer-charts"; -import "@perspective-dev/viewer-openlayers"; import { load_css } from "./css"; import { PerspectiveView } from "../view"; diff --git a/packages/react/package.json b/packages/react/package.json index 7ebb696b4e..f126f4554b 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,13 +1,28 @@ { "name": "@perspective-dev/react", "version": "4.4.1", - "description": "", - "main": "index.js", - "keywords": [], - "author": "", - "license": "ISC", + "description": "React component wrappers for `` and ``", + "keywords": [ + "perspective", + "react", + "data", + "analytics", + "visualization" + ], + "homepage": "https://perspective-dev.github.io", + "repository": { + "type": "git", + "url": "https://github.com/perspective-dev/perspective" + }, + "license": "Apache-2.0", + "sideEffects": false, "exports": { - ".": "./dist/esm/index.js" + ".": { + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js", + "default": "./dist/esm/index.js" + }, + "./package.json": "./package.json" }, "type": "module", "scripts": { diff --git a/packages/viewer-charts/build.mjs b/packages/viewer-charts/build.mjs index a8c630efd7..cc01d3f543 100644 --- a/packages/viewer-charts/build.mjs +++ b/packages/viewer-charts/build.mjs @@ -11,16 +11,41 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import { NodeModulesExternal } from "@perspective-dev/esbuild-plugin/external.js"; +import { WorkerPlugin } from "@perspective-dev/esbuild-plugin/worker.js"; import { build } from "@perspective-dev/esbuild-plugin/build.js"; import { transform as transformCss } from "lightningcss"; import { execSync } from "node:child_process"; import * as fs from "node:fs/promises"; -// TODO: if shader payload ever becomes a measured bottleneck, swap this -// regex minifier for an AST-based tool (e.g. `glsl-minifier`) to get -// identifier mangling on locals/varyings. Uniform/attribute names are -// resolved by string from JS via `getUniformLocation` / `getAttribLocation`, -// so only locals are safe to rename. +import { GlslMinify as AstGlslMinify } from "webpack-glsl-minify/build/minify.js"; + +/** + * Pull every identifier the JS code might resolve by string out of the + * unminified shader source so we can hand them to the AST minifier's + * `nomangle` list. `preserveUniforms: true` already covers `uniform` + * declarations, and the minifier auto-preserves `varying` / `in` / + * `out` names. The one category the minifier won't infer is the + * GLSL ES 1.00 `attribute` declaration in vertex shaders — those are + * the names `getAttribLocation` queries, so we surface them here. + */ +function extractPreservedNames(src) { + const names = new Set(); + const attrRe = + /\battribute\s+(?:highp\s+|mediump\s+|lowp\s+)?\S+\s+([a-zA-Z_][\w]*)/g; + let m; + while ((m = attrRe.exec(src))) { + names.add(m[1]); + } + return [...names]; +} + +// AST-based GLSL minifier. Mangles function locals and non-`main` +// function names; preserves uniforms, attributes, varyings, and `gl_*` +// built-ins (the chart impls resolve those by string via +// `getUniformLocation` / `getAttribLocation`). Saves ~7% of the +// bundled shader payload over the prior regex pass, and parses +// `#`-directives natively so the previous newline-preservation hack +// is no longer needed. const GlslMinify = () => ({ name: "glsl-minify", setup(build) { @@ -29,13 +54,21 @@ const GlslMinify = () => ({ if (process.env.PSP_DEBUG) { return { contents: src, loader: "text" }; } - const min = src - .replace(/\/\*[\s\S]*?\*\//g, "") - .replace(/\/\/[^\n]*/g, "") - .replace(/\s+/g, " ") - .replace(/\s*([;,(){}\[\]=+\-*/<>!&|^~?])\s*/g, "$1") - .trim(); - return { contents: min, loader: "text" }; + const minifier = new AstGlslMinify( + { + preserveDefines: true, + preserveUniforms: true, + preserveVariables: false, + nomangle: extractPreservedNames(src), + output: "source", + esModule: false, + stripVersion: false, + }, + undefined, + undefined, + ); + const { sourceCode } = await minifier.execute(src); + return { contents: sourceCode, loader: "text" }; }); }, }); @@ -72,7 +105,30 @@ const BUILD = [ define: { global: "window", }, - plugins: [NodeModulesExternal(), GlslMinify(), LightningCssMinify()], + plugins: [ + NodeModulesExternal(), + WorkerPlugin({ + inline: !process.env.PSP_DEBUG, + plugins: [GlslMinify(), LightningCssMinify()], + loader: { + ".css": "text", + ".glsl": "text", + }, + additionalOptions: { + minifyWhitespace: !process.env.PSP_DEBUG, + minifyIdentifiers: !process.env.PSP_DEBUG, + mangleProps: process.env.PSP_DEBUG + ? undefined + : /^[_#]|^(plotRect|paddedX(?:Min|Max)|paddedY(?:Min|Max)|dataToPixel|tickColor|labelColor|axisLineColor|gridlineColor|legendText|legendBorder|tooltipBg|tooltipText|tooltipBorder|areaOpacity|heatmapGapPx|sunburstGapPx|gradientStops|seriesPalette|bufferPool)$/, + reserveProps: /(handle_response|__unsafe_open_view)/, + }, + }), + GlslMinify(), + LightningCssMinify(), + ], + // minifyWhitespace: !process.env.PSP_DEBUG, + // minifyIdentifiers: !process.env.PSP_DEBUG, + // mangleProps: process.env.PSP_DEBUG ? false : /^[_#]/, format: "esm", loader: { ".css": "text", @@ -85,10 +141,35 @@ const BUILD = [ define: { global: "window", }, - plugins: [GlslMinify(), LightningCssMinify()], - minifyWhitespace: !process.env.PSP_DEBUG, - minifyIdentifiers: !process.env.PSP_DEBUG, - mangleProps: process.env.PSP_DEBUG ? false : /^[_#]/, + // minifyWhitespace: !process.env.PSP_DEBUG, + // minifyIdentifiers: !process.env.PSP_DEBUG, + // mangleProps: process.env.PSP_DEBUG ? false : /^[_#]/, + plugins: [ + WorkerPlugin({ + // Inline (Blob URL) for prod, file mode for debug — + // file mode preserves source maps + real paths in + // DevTools so worker breakpoints work. + inline: !process.env.PSP_DEBUG, + plugins: [GlslMinify(), LightningCssMinify()], + loader: { + ".css": "text", + ".glsl": "text", + }, + additionalOptions: { + minifyWhitespace: !process.env.PSP_DEBUG, + minifyIdentifiers: !process.env.PSP_DEBUG, + mangleProps: process.env.PSP_DEBUG + ? undefined + : /^[_#]|^(plotRect|paddedX(?:Min|Max)|paddedY(?:Min|Max)|dataToPixel|tickColor|labelColor|axisLineColor|gridlineColor|legendText|legendBorder|tooltipBg|tooltipText|tooltipBorder|areaOpacity|heatmapGapPx|sunburstGapPx|gradientStops|seriesPalette|bufferPool)$/, + reserveProps: /(handle_response|__unsafe_open_view)/, + }, + }), + GlslMinify(), + LightningCssMinify(), + ], + // minifyWhitespace: !process.env.PSP_DEBUG, + // minifyIdentifiers: !process.env.PSP_DEBUG, + // mangleProps: process.env.PSP_DEBUG ? false : /^[_#]/, format: "esm", loader: { ".css": "text", diff --git a/packages/viewer-charts/package.json b/packages/viewer-charts/package.json index 821e7d5006..14a8205be3 100644 --- a/packages/viewer-charts/package.json +++ b/packages/viewer-charts/package.json @@ -39,7 +39,9 @@ "@perspective-dev/viewer": "workspace:", "@perspective-dev/esbuild-plugin": "workspace:", "@perspective-dev/test": "workspace:", + "@types/node": "catalog:", "lightningcss": "catalog:", - "typescript": "catalog:" + "typescript": "catalog:", + "webpack-glsl-minify": "1.5.0" } } diff --git a/packages/viewer-charts/src/css/perspective-viewer-charts.css b/packages/viewer-charts/src/css/perspective-viewer-charts.css index efd9d4476c..cda4040633 100644 --- a/packages/viewer-charts/src/css/perspective-viewer-charts.css +++ b/packages/viewer-charts/src/css/perspective-viewer-charts.css @@ -4,76 +4,14 @@ width: 100%; height: 100%; overflow: hidden; - font-family: var( - --psp-interface-monospace--font-family, - "ui-monospace", - "SFMono-Regular", - "SF Mono", - "Menlo", - "Consolas", - "Liberation Mono", - monospace - ); + font-family: inherit; - --psp-webgl--font-family: var( - --psp-interface-monospace--font-family, - "ui-monospace", - "SFMono-Regular", - "SF Mono", - "Menlo", - "Consolas", - "Liberation Mono", - monospace - ); - --psp-webgl--tooltip--color: var(--psp--color); - --psp-webgl--tooltip--background: var(--psp--background-color); - --psp-webgl--tooltip--border-color: var(--psp-inactive--border-color); + /* font-family: var(--psp-interface-monospace--font-family, inherit); */ + /* --psp-charts--font-family: var(font-family, inherit); */ - /* --psp-webgl--axis-ticks--color: var( - --psp-d3fc--axis-ticks--color, - rgba(160, 160, 160, 0.8) - ); - --psp-webgl--axis-lines--color: var( - --psp-d3fc--axis-lines--color, - rgba(160, 160, 160, 0.4) - ); */ - /* --psp-webgl--gridline--color: var( - --psp-d3fc--gridline--color, - rgba(128, 128, 128, 0.8) - ); */ - /* --psp-webgl--label--color: var(--psp--color); - --psp-webgl--legend--color: var( - --psp-d3fc--legend--color, - var(--psp-d3fc--axis-ticks--color, rgba(180, 180, 180, 0.9)) - ); - --psp-webgl--gradient-start--color: var( - --psp-d3fc--series-1--color, - #0366d6 - ); - --psp-webgl--gradient-end--color: var(--psp-d3fc--series-2--color, #ff7f0e); - --psp-webgl--tooltip--background: var( - --psp-d3fc--tooltip--background-color, - rgba(155, 155, 155, 0.8) - ); - --psp-webgl--tooltip--color: var(--psp-d3fc--tooltip--color, #161616); - --psp-webgl--tooltip--border-color: var( - --psp-d3fc--tooltip--border-color, - #fff - ); - --psp-webgl--legend-border--color: var( - --psp-d3fc--axis-lines--color, - rgba(128, 128, 128, 0.3) - ); */ - /* --psp-webgl--font-family: var( - --psp-interface-monospace--font-family, - "ui-monospace", - "SFMono-Regular", - "SF Mono", - "Menlo", - "Consolas", - "Liberation Mono", - monospace - ); */ + --psp-charts--tooltip--color: var(--psp--color); + --psp-charts--tooltip--background: var(--psp--background-color); + --psp-charts--tooltip--border-color: var(--psp-inactive--border-color); } .webgl-container { @@ -126,12 +64,12 @@ } .zoom-reset { - background: var(--psp-webgl--tooltip--background); - color: var(--psp-webgl--tooltip--color); - border: 1px solid var(--psp-webgl--tooltip--border-color); + background: var(--psp-charts--tooltip--background); + color: var(--psp-charts--tooltip--color); + border: 1px solid var(--psp-charts--tooltip--border-color); border-radius: 4px; padding: 4px 12px; - font: 11px var(--psp-webgl--font-family); + font: 11px var(--psp-charts--font-family); cursor: pointer; opacity: 0.7; transition: opacity 0.15s; @@ -144,10 +82,10 @@ .webgl-tooltip { position: absolute; pointer-events: auto; - font: 11px var(--psp-webgl--font-family); - background: var(--psp-webgl--tooltip--background); - color: var(--psp-webgl--tooltip--color); - border: 1px solid var(--psp-webgl--tooltip--border-color); + font: 11px var(--psp-charts--font-family); + background: var(--psp-charts--tooltip--background); + color: var(--psp-charts--tooltip--color); + border: 1px solid var(--psp-charts--tooltip--border-color); border-radius: 3px; padding: 3px; overflow-y: auto; diff --git a/packages/viewer-charts/src/ts/chrome/axis-primitives.ts b/packages/viewer-charts/src/ts/axis/axis-primitives.ts similarity index 87% rename from packages/viewer-charts/src/ts/chrome/axis-primitives.ts rename to packages/viewer-charts/src/ts/axis/axis-primitives.ts index 0024cac82f..661e120536 100644 --- a/packages/viewer-charts/src/ts/chrome/axis-primitives.ts +++ b/packages/viewer-charts/src/ts/axis/axis-primitives.ts @@ -10,6 +10,7 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +import type { Context2D } from "../charts/canvas-types"; import type { PlotRect } from "../layout/plot-layout"; export const TICK_SIZE = 5; @@ -21,7 +22,7 @@ export const TICK_SIZE = 5; * `strokeStyle`, `fillStyle`, `font`, and `lineWidth`. */ export function drawXTickRow( - ctx: CanvasRenderingContext2D, + ctx: Context2D, plot: PlotRect, ticks: number[], axisY: number, @@ -35,7 +36,10 @@ export function drawXTickRow( const labelOffset = dir * (TICK_SIZE + 3); for (const tick of ticks) { const px = xToPixel(tick); - if (px < plot.x - 1 || px > plot.x + plot.width + 1) continue; + if (px < plot.x - 1 || px > plot.x + plot.width + 1) { + continue; + } + ctx.beginPath(); ctx.moveTo(px, axisY); ctx.lineTo(px, axisY + dir * TICK_SIZE); @@ -50,7 +54,7 @@ export function drawXTickRow( * the corresponding label alignment. Caller owns styling state. */ export function drawYTickColumn( - ctx: CanvasRenderingContext2D, + ctx: Context2D, plot: PlotRect, ticks: number[], axisX: number, @@ -64,7 +68,10 @@ export function drawYTickColumn( const labelOffset = dir * (TICK_SIZE + 3); for (const tick of ticks) { const py = yToPixel(tick); - if (py < plot.y - 1 || py > plot.y + plot.height + 1) continue; + if (py < plot.y - 1 || py > plot.y + plot.height + 1) { + continue; + } + ctx.beginPath(); ctx.moveTo(axisX, py); ctx.lineTo(axisX + dir * TICK_SIZE, py); @@ -73,16 +80,21 @@ export function drawYTickColumn( } } -/** Vertical gridlines at numeric X ticks, clipped to `plot`. */ +/** + * Vertical gridlines at numeric X ticks, clipped to `plot`. + */ export function drawGridlinesX( - ctx: CanvasRenderingContext2D, + ctx: Context2D, plot: PlotRect, ticks: number[], xToPixel: (v: number) => number, ): void { for (const tick of ticks) { const px = Math.round(xToPixel(tick)) + 0.5; - if (px < plot.x || px > plot.x + plot.width) continue; + if (px < plot.x || px > plot.x + plot.width) { + continue; + } + ctx.beginPath(); ctx.moveTo(px, plot.y); ctx.lineTo(px, plot.y + plot.height); @@ -90,16 +102,21 @@ export function drawGridlinesX( } } -/** Horizontal gridlines at numeric Y ticks, clipped to `plot`. */ +/** + * Horizontal gridlines at numeric Y ticks, clipped to `plot`. + */ export function drawGridlinesY( - ctx: CanvasRenderingContext2D, + ctx: Context2D, plot: PlotRect, ticks: number[], yToPixel: (v: number) => number, ): void { for (const tick of ticks) { const py = Math.round(yToPixel(tick)) + 0.5; - if (py < plot.y || py > plot.y + plot.height) continue; + if (py < plot.y || py > plot.y + plot.height) { + continue; + } + ctx.beginPath(); ctx.moveTo(plot.x, py); ctx.lineTo(plot.x + plot.width, py); diff --git a/packages/viewer-charts/src/ts/chrome/bar-axis.ts b/packages/viewer-charts/src/ts/axis/bar-axis.ts similarity index 62% rename from packages/viewer-charts/src/ts/chrome/bar-axis.ts rename to packages/viewer-charts/src/ts/axis/bar-axis.ts index 4c24a8c409..5c15280761 100644 --- a/packages/viewer-charts/src/ts/chrome/bar-axis.ts +++ b/packages/viewer-charts/src/ts/axis/bar-axis.ts @@ -10,8 +10,9 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +import type { Canvas2D, Context2D } from "../charts/canvas-types"; import { PlotLayout } from "../layout/plot-layout"; -import { formatTickValue } from "../layout/ticks"; +import { formatTickValue, formatDateTickValue } from "../layout/ticks"; import { initCanvas } from "./canvas"; import { renderCategoricalXTicks, @@ -27,14 +28,34 @@ import { import type { AxisDomain } from "./numeric-axis"; import type { Theme } from "../theme/theme"; -/** Render a numeric axis along the bottom or top of the plot area. */ +function tickFormatter( + domain: AxisDomain, + ticks: number[], + override?: (v: number) => string, +): (v: number) => string { + if (override) { + return override; + } + + if (!domain.isDate) { + return formatTickValue; + } + + const step = ticks.length > 1 ? ticks[1] - ticks[0] : 0; + return (v: number) => formatDateTickValue(v, step); +} + +/** + * Render a numeric axis along the bottom or top of the plot area. + */ function drawNumericXAxis( - ctx: CanvasRenderingContext2D, + ctx: Context2D, layout: PlotLayout, domain: AxisDomain, ticks: number[], side: "top" | "bottom", theme: Theme, + formatter?: (v: number) => string, ): void { const { labelColor, fontFamily } = theme; const { plotRect: plot } = layout; @@ -50,7 +71,7 @@ function drawNumericXAxis( axisY, side, (v) => layout.dataToPixel(v, 0).px, - formatTickValue, + tickFormatter(domain, ticks, formatter), ); ctx.font = `13px ${fontFamily}`; @@ -69,12 +90,13 @@ function drawNumericXAxis( * Used by bar charts with a categorical X and optional split Y axes. */ function drawYAxis( - ctx: CanvasRenderingContext2D, + ctx: Context2D, layout: PlotLayout, domain: AxisDomain, ticks: number[], side: "left" | "right", theme: Theme, + formatter?: (v: number) => string, ): void { const { labelColor, fontFamily } = theme; const { plotRect: plot } = layout; @@ -90,7 +112,7 @@ function drawYAxis( axisX, side, (v) => layout.dataToPixel(0, v).py, - formatTickValue, + tickFormatter(domain, ticks, formatter), ); ctx.font = `13px ${fontFamily}`; @@ -102,12 +124,50 @@ function drawYAxis( ctx.translate(layout.cssWidth - 10, plot.y + plot.height / 2); ctx.rotate(Math.PI / 2); } + ctx.textAlign = "center"; ctx.textBaseline = "bottom"; ctx.fillText(domain.label, 0, 0); ctx.restore(); } +/** + * The category-axis side of a bar chart can render as either a + * stringified hierarchical category axis or a true numeric axis (when + * the single group_by level is date / datetime / integer / float). + * Both shapes flow through `renderBarAxesChrome`; the discriminator is + * the `mode` field. + */ +export type BarCategoryAxis = + | { mode: "category"; domain: CategoricalDomain } + | { mode: "numeric"; domain: AxisDomain; ticks: number[] }; + +/** + * Render a numeric date-aware axis along the bottom of the plot. Aliases + * the bar-axis bottom variant so heatmap can share the implementation. + */ +export function drawNumericCategoryX( + ctx: Context2D, + layout: PlotLayout, + domain: AxisDomain, + ticks: number[], + theme: Theme, + formatter?: (v: number) => string, +): void { + drawNumericXAxis(ctx, layout, domain, ticks, "bottom", theme, formatter); +} + +export function drawNumericCategoryY( + ctx: Context2D, + layout: PlotLayout, + domain: AxisDomain, + ticks: number[], + theme: Theme, + formatter?: (v: number) => string, +): void { + drawYAxis(ctx, layout, domain, ticks, "left", theme, formatter); +} + /** * Render bar-chart chrome: L-shaped axis lines, a categorical axis * (bottom for Y Bar, left for X Bar), and one or two numeric axes on @@ -118,19 +178,32 @@ function drawYAxis( * `altDomain`/`altTicks` arguments always describe the *secondary* * numeric axis regardless of orientation. */ +export interface BarAxesFormatters { + /** Formatter for the value (Y in vertical, X in horizontal) axis. */ + value?: (v: number) => string; + /** Formatter for the secondary alt value axis. */ + alt?: (v: number) => string; + /** Formatter for the numeric category axis (when `catAxis.mode === "numeric"`). */ + category?: (v: number) => string; +} + export function renderBarAxesChrome( - canvas: HTMLCanvasElement, - catDomain: CategoricalDomain, + canvas: Canvas2D, + catAxis: BarCategoryAxis, valueDomain: AxisDomain, valueTicks: number[], layout: PlotLayout, theme: Theme, + dpr: number, altDomain?: AxisDomain, altTicks?: number[], isHorizontal = false, + formatters: BarAxesFormatters = {}, ): void { - const ctx = initCanvas(canvas, layout); - if (!ctx) return; + const ctx = initCanvas(canvas, layout, dpr); + if (!ctx) { + return; + } const { plotRect: plot } = layout; ctx.strokeStyle = theme.axisLineColor; @@ -148,29 +221,86 @@ export function renderBarAxesChrome( ctx.lineTo(plot.x + plot.width, plot.y + plot.height); } } + ctx.stroke(); if (isHorizontal) { - renderCategoricalYTicks(ctx, layout, catDomain, theme); - drawNumericXAxis(ctx, layout, valueDomain, valueTicks, "bottom", theme); + if (catAxis.mode === "category") { + renderCategoricalYTicks(ctx, layout, catAxis.domain, theme); + } else { + drawNumericCategoryY( + ctx, + layout, + catAxis.domain, + catAxis.ticks, + theme, + formatters.category, + ); + } + + drawNumericXAxis( + ctx, + layout, + valueDomain, + valueTicks, + "bottom", + theme, + formatters.value, + ); if (altDomain && altTicks) { const origMin = layout.paddedXMin; const origMax = layout.paddedXMax; layout.paddedXMin = altDomain.min; layout.paddedXMax = altDomain.max; - drawNumericXAxis(ctx, layout, altDomain, altTicks, "top", theme); + drawNumericXAxis( + ctx, + layout, + altDomain, + altTicks, + "top", + theme, + formatters.alt, + ); layout.paddedXMin = origMin; layout.paddedXMax = origMax; } } else { - renderCategoricalXTicks(ctx, layout, catDomain, theme); - drawYAxis(ctx, layout, valueDomain, valueTicks, "left", theme); + if (catAxis.mode === "category") { + renderCategoricalXTicks(ctx, layout, catAxis.domain, theme); + } else { + drawNumericCategoryX( + ctx, + layout, + catAxis.domain, + catAxis.ticks, + theme, + formatters.category, + ); + } + + drawYAxis( + ctx, + layout, + valueDomain, + valueTicks, + "left", + theme, + formatters.value, + ); if (altDomain && altTicks) { const origMin = layout.paddedYMin; const origMax = layout.paddedYMax; layout.paddedYMin = altDomain.min; layout.paddedYMax = altDomain.max; - drawYAxis(ctx, layout, altDomain, altTicks, "right", theme); + drawYAxis( + ctx, + layout, + altDomain, + altTicks, + "right", + theme, + formatters.alt, + ); layout.paddedYMin = origMin; layout.paddedYMax = origMax; } @@ -183,14 +313,17 @@ export function renderBarAxesChrome( * charts they run vertically at numeric X ticks. */ export function renderBarGridlines( - canvas: HTMLCanvasElement, + canvas: Canvas2D, layout: PlotLayout, valueTicks: number[], theme: Theme, + dpr: number, isHorizontal = false, ): void { - const ctx = initCanvas(canvas, layout); - if (!ctx) return; + const ctx = initCanvas(canvas, layout, dpr); + if (!ctx) { + return; + } ctx.strokeStyle = theme.gridlineColor; ctx.lineWidth = 1; diff --git a/packages/viewer-charts/src/ts/chrome/canvas.ts b/packages/viewer-charts/src/ts/axis/canvas.ts similarity index 89% rename from packages/viewer-charts/src/ts/chrome/canvas.ts rename to packages/viewer-charts/src/ts/axis/canvas.ts index ec6f4960b2..6f50272dac 100644 --- a/packages/viewer-charts/src/ts/chrome/canvas.ts +++ b/packages/viewer-charts/src/ts/axis/canvas.ts @@ -10,6 +10,7 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +import type { Canvas2D, Context2D } from "../charts/canvas-types"; import type { PlotLayout } from "../layout/plot-layout"; /** @@ -24,14 +25,17 @@ import type { PlotLayout } from "../layout/plot-layout"; * without re-wiping. */ export function initCanvas( - canvas: HTMLCanvasElement, + canvas: Canvas2D, layout: PlotLayout, -): CanvasRenderingContext2D | null { - const dpr = window.devicePixelRatio || 1; + dpr: number, +): Context2D | null { canvas.width = Math.round(layout.cssWidth * dpr); canvas.height = Math.round(layout.cssHeight * dpr); - const ctx = canvas.getContext("2d"); - if (!ctx) return null; + const ctx = canvas.getContext("2d") as Context2D; + if (!ctx) { + return null; + } + ctx.scale(dpr, dpr); ctx.clearRect(0, 0, layout.cssWidth, layout.cssHeight); return ctx; @@ -47,11 +51,14 @@ export function initCanvas( * canvas bitmap mid-frame. */ export function getScaledContext( - canvas: HTMLCanvasElement, -): CanvasRenderingContext2D | null { - const ctx = canvas.getContext("2d"); - if (!ctx) return null; - const dpr = window.devicePixelRatio || 1; + canvas: Canvas2D, + dpr: number, +): Context2D | null { + const ctx = canvas.getContext("2d") as Context2D; + if (!ctx) { + return null; + } + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); return ctx; } diff --git a/packages/viewer-charts/src/ts/chrome/categorical-axis-core.ts b/packages/viewer-charts/src/ts/axis/categorical-axis-core.ts similarity index 93% rename from packages/viewer-charts/src/ts/chrome/categorical-axis-core.ts rename to packages/viewer-charts/src/ts/axis/categorical-axis-core.ts index e079a21153..a12938f765 100644 --- a/packages/viewer-charts/src/ts/chrome/categorical-axis-core.ts +++ b/packages/viewer-charts/src/ts/axis/categorical-axis-core.ts @@ -25,7 +25,10 @@ */ export interface GroupRun { startIdx: number; - /** Inclusive. */ + + /** + * Inclusive. + */ endIdx: number; label: string; } @@ -44,7 +47,10 @@ export function buildGroupRuns( endRow: number, ): GroupRun[] { const runs: GroupRun[] = []; - if (endRow <= startRow) return runs; + if (endRow <= startRow) { + return runs; + } + let runStart = startRow; let runDict = indices[startRow]; for (let r = startRow + 1; r < endRow; r++) { @@ -59,6 +65,7 @@ export function buildGroupRuns( runDict = d; } } + runs.push({ startIdx: runStart, endIdx: endRow - 1, @@ -75,8 +82,11 @@ export function buildGroupRuns( export function maxDictLength(dictionary: string[]): number { let m = 0; for (const s of dictionary) { - if (s != null && s.length > m) m = s.length; + if (s != null && s.length > m) { + m = s.length; + } } + return m; } @@ -92,10 +102,16 @@ export function runsInRange( visMin: number, visMax: number, ): GroupRun[] { - if (visMax < visMin) return []; + if (visMax < visMin) { + return []; + } + const out: GroupRun[] = []; for (const run of runs) { - if (run.endIdx < visMin || run.startIdx > visMax) continue; + if (run.endIdx < visMin || run.startIdx > visMax) { + continue; + } + const startIdx = run.startIdx < visMin ? visMin : run.startIdx; const endIdx = run.endIdx > visMax ? visMax : run.endIdx; if (startIdx === run.startIdx && endIdx === run.endIdx) { @@ -104,5 +120,6 @@ export function runsInRange( out.push({ startIdx, endIdx, label: run.label }); } } + return out; } diff --git a/packages/viewer-charts/src/ts/chrome/categorical-axis.ts b/packages/viewer-charts/src/ts/axis/categorical-axis.ts similarity index 61% rename from packages/viewer-charts/src/ts/chrome/categorical-axis.ts rename to packages/viewer-charts/src/ts/axis/categorical-axis.ts index 54ab5ed1d0..2d847194fe 100644 --- a/packages/viewer-charts/src/ts/chrome/categorical-axis.ts +++ b/packages/viewer-charts/src/ts/axis/categorical-axis.ts @@ -10,6 +10,7 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +import type { Context2D } from "../charts/canvas-types"; import { PlotLayout } from "../layout/plot-layout"; import { labelRect, @@ -21,23 +22,6 @@ import { import { type GroupRun, runsInRange } from "./categorical-axis-core"; import type { Theme } from "../theme/theme"; -/** - * A level of the group_by hierarchy. Built once (inside the - * `with_typed_arrays` callback, while the WASM-backed index buffer is - * still valid) and then retained by the chart — all fields are plain JS - * and outlive the Arrow batch. - * - * - `labels[r]` is the pre-resolved string at row `r` (== the old - * `dictionary[indices[r]]`). - * - `runs` is the full precomputed run-length encoding of the level; - * outer-level brackets filter it by visible window at render time. - * Empty for the leaf level, which reads `labels` per-row. - * - `maxLabelChars` caches `max(labels[r].length)` for axis-sizing - * heuristics that previously scanned the dictionary. - * - * Levels are ordered outermost-first (level 0 = outermost, level N-1 = - * leaf). - */ export interface CategoricalLevel { labels: string[]; runs: GroupRun[]; @@ -60,41 +44,37 @@ const OUTER_LEVEL_HEIGHT = 22; const TICK_SIZE = 5; const LABEL_FONT_PX = 11; const LABEL_LINE_HEIGHT = 14; +const LEAF_LEVEL_WIDTH_MIN = 55; +const OUTER_LEVEL_WIDTH = 60; +const LEAF_LABEL_PADDING = 10; -export function categoryIndexToPixel( - layout: PlotLayout, - index: number, -): number { +function categoryIndexToPixelX(layout: PlotLayout, index: number): number { return layout.dataToPixel(index, 0).px; } -/** - * Choose label rotation + row height for the leaf level based on how many - * ticks would need to fit horizontally and how wide the longest one is. - * Mirrors d3fc's `getGroupTickLayout` but uses dictionary stats instead of - * iterating all rows. - */ +function categoryIndexToPixelY(layout: PlotLayout, index: number): number { + return layout.dataToPixel(0, index).py; +} + +export const categoryIndexToPixel = categoryIndexToPixelX; + function leafLevelLayout( numRows: number, longestCharCount: number, plotWidth: number, ): LevelTickLayout { - // The budget for label placement — d3fc subtracts 100 from width. const budget = Math.max(0, plotWidth - 100); if (numRows * 16 > budget) { return { size: longestCharCount * 6.62 + 10, rotation: 90 }; } + if (numRows * (longestCharCount * 6 + 10) > budget) { return { size: longestCharCount * 4 + 20, rotation: 45 }; } + return { size: LEAF_LEVEL_HEIGHT, rotation: 0 }; } -/** - * Returns per-level heights (outermost-first) so the caller can size the - * bottom margin before building `PlotLayout`. Uses dictionary statistics; - * does NOT iterate row indices. - */ export function measureCategoricalLevels( domain: CategoricalDomain, plotWidth: number, @@ -111,30 +91,63 @@ export function measureCategoricalLevels( result.push({ size: OUTER_LEVEL_HEIGHT, rotation: 0 }); } } + return result; } -/** - * Total CSS-pixel height required for the categorical tick band (levels), - * NOT including the bottom axis-label line. The caller feeds the result to - * `PlotLayout` as `bottomExtra`; the axis label is added separately by - * `PlotLayout` via `hasXLabel`. - */ +export function measureCategoricalLevelWidths( + domain: CategoricalDomain, +): number[] { + const L = domain.levels.length; + const widths: number[] = []; + const charPx = 6.2; + for (let l = 0; l < L; l++) { + if (l === L - 1) { + const longest = domain.levels[l].maxLabelChars; + widths.push( + Math.max( + LEAF_LEVEL_WIDTH_MIN, + longest * charPx + LEAF_LABEL_PADDING, + ), + ); + } else { + widths.push(OUTER_LEVEL_WIDTH); + } + } + + return widths; +} + +function sumNumeric(arr: number[]): number { + let t = 0; + for (const v of arr) { + t += v; + } + + return t; +} + export function measureCategoricalAxisHeight( domain: CategoricalDomain, plotWidth: number, ): number { - if (domain.numRows === 0 || domain.levels.length === 0) return 24; - const levels = measureCategoricalLevels(domain, plotWidth); - let total = 0; - for (const l of levels) total += l.size; - return total; + if (domain.numRows === 0 || domain.levels.length === 0) { + return 24; + } + + return sumNumeric( + measureCategoricalLevels(domain, plotWidth).map((l) => l.size), + ); +} + +export function measureCategoricalAxisWidth(domain: CategoricalDomain): number { + if (domain.numRows === 0 || domain.levels.length === 0) { + return 55; + } + + return sumNumeric(measureCategoricalLevelWidths(domain)); } -/** - * Pick a subset of leaf indices to label inside `[visMin, visMax]`. - * Always includes the endpoints when density permits. - */ function selectLeafTickIndices( visMin: number, visMax: number, @@ -142,16 +155,26 @@ function selectLeafTickIndices( avgLabelPx: number, ): number[] { const count = visMax - visMin + 1; - if (count <= 0) return []; + if (count <= 0) { + return []; + } + const maxLabels = Math.max(1, Math.floor(plotWidth / avgLabelPx)); if (count <= maxLabels) { const out: number[] = []; - for (let i = visMin; i <= visMax; i++) out.push(i); + for (let i = visMin; i <= visMax; i++) { + out.push(i); + } + return out; } + const step = Math.ceil(count / maxLabels); const out: number[] = []; - for (let i = visMin; i <= visMax; i += step) out.push(i); + for (let i = visMin; i <= visMax; i += step) { + out.push(i); + } + return out; } @@ -160,17 +183,64 @@ function getLeafText(level: CategoricalLevel, row: number): string { } /** - * Render the hierarchical X axis for a categorical domain. The axis line - * is drawn by `renderBarAxesChrome`. This function owns tick marks, tick - * labels, outer-level group brackets, and the axis label. + * Visible row window from a (possibly zoomed) padded data range. `flip` + * accounts for the categorical Y-axis storing the domain inverted so + * that catIdx=0 renders at the top. + */ +function visibleRowWindow( + numRows: number, + a: number, + b: number, + flip: boolean, +): [number, number] | null { + const lo = flip ? Math.min(a, b) : a; + const hi = flip ? Math.max(a, b) : b; + const visMin = Math.max(0, Math.ceil(lo)); + const visMax = Math.min(numRows - 1, Math.floor(hi)); + return visMax < visMin ? null : [visMin, visMax]; +} + +/** + * Compute clipped main-axis spans for each visible run. Used by both + * outer-level renderers; the caller supplies the projection from + * row-index to the relevant pixel coordinate (X or Y) and the plot + * extent along the main axis. */ +function clippedRuns( + runs: GroupRun[], + pixelOf: (idx: number) => number, + mainStart: number, + mainEnd: number, +): Array<{ + run: GroupRun; + nearEdge: number; + farEdge: number; + nearClip: number; + farClip: number; +}> { + const out = []; + for (const run of runs) { + const nearEdge = pixelOf(run.startIdx - 0.5); + const farEdge = pixelOf(run.endIdx + 0.5); + const nearClip = Math.max(mainStart, Math.min(nearEdge, farEdge)); + const farClip = Math.min(mainEnd, Math.max(nearEdge, farEdge)); + if (farClip > nearClip) { + out.push({ run, nearEdge, farEdge, nearClip, farClip }); + } + } + + return out; +} + export function renderCategoricalXTicks( - ctx: CanvasRenderingContext2D, + ctx: Context2D, layout: PlotLayout, domain: CategoricalDomain, theme: Theme, ): void { - if (domain.numRows === 0 || domain.levels.length === 0) return; + if (domain.numRows === 0 || domain.levels.length === 0) { + return; + } const { tickColor, labelColor, fontFamily } = theme; const { plotRect: plot } = layout; @@ -182,16 +252,20 @@ export function renderCategoricalXTicks( ctx.font = `${LABEL_FONT_PX}px ${fontFamily}`; const levelLayouts = measureCategoricalLevels(domain, plot.width); + const win = visibleRowWindow( + domain.numRows, + layout.paddedXMin, + layout.paddedXMax, + false, + ); + if (!win) { + return; + } - // Visible row window from the (possibly zoomed) padded X domain. - const visMin = Math.max(0, Math.ceil(layout.paddedXMin)); - const visMax = Math.min(domain.numRows - 1, Math.floor(layout.paddedXMax)); - if (visMax < visMin) return; + const [visMin, visMax] = win; const L = domain.levels.length; let yCursor = baselineY; - - // Inner → outer. Leaf is the last level (innermost). for (let l = L - 1; l >= 0; l--) { const level = domain.levels[l]; const lay = levelLayouts[l]; @@ -224,7 +298,6 @@ export function renderCategoricalXTicks( } } - // Axis label — single line that names all group_by fields joined by " / " const axisLabel = domain.levelLabels.filter((s) => !!s).join(" / "); if (axisLabel) { ctx.fillStyle = labelColor; @@ -236,7 +309,7 @@ export function renderCategoricalXTicks( } function renderLeafLevel( - ctx: CanvasRenderingContext2D, + ctx: Context2D, layout: PlotLayout, level: CategoricalLevel, visMin: number, @@ -248,10 +321,7 @@ function renderLeafLevel( ): void { const { plotRect: plot } = layout; - // Estimate avg label width from the precomputed longest-label - // length (filled in during level construction — see - // `resolveCategoryAxis`). - const avgCharWidth = 6.2; // 11px monospace-ish heuristic + const avgCharWidth = 6.2; const avgLabelPx = Math.max( 40, Math.min(level.maxLabelChars * avgCharWidth + 8, plot.width / 2), @@ -262,19 +332,21 @@ function renderLeafLevel( ? selectLeafTickIndices(visMin, visMax, plot.width, avgLabelPx) : leafRowsForRotated(visMin, visMax); - // Per-row tick marks. ctx.strokeStyle = tickColor; ctx.fillStyle = tickColor; ctx.beginPath(); for (const r of tickRows) { - const px = categoryIndexToPixel(layout, r); - if (px < plot.x - 1 || px > plot.x + plot.width + 1) continue; + const px = categoryIndexToPixelX(layout, r); + if (px < plot.x - 1 || px > plot.x + plot.width + 1) { + continue; + } + ctx.moveTo(px, rowTop); ctx.lineTo(px, rowTop + TICK_SIZE); } + ctx.stroke(); - // Labels with overlap hiding. ctx.font = `${LABEL_FONT_PX}px ${fontFamily}`; const labelY = rowTop + TICK_SIZE + 3; const boundsRect = { @@ -290,10 +362,15 @@ function renderLeafLevel( height: number; }[] = []; for (const r of tickRows) { - const px = categoryIndexToPixel(layout, r); - if (px < plot.x - 1 || px > plot.x + plot.width + 1) continue; + const px = categoryIndexToPixelX(layout, r); + if (px < plot.x - 1 || px > plot.x + plot.width + 1) { + continue; + } + const text = getLeafText(level, r); - if (!text) continue; + if (!text) { + continue; + } const textWidth = ctx.measureText(text).width; const rect = labelRect( @@ -303,12 +380,20 @@ function renderLeafLevel( LABEL_LINE_HEIGHT, lay.rotation, ); - if (!rectContained(rect, boundsRect)) continue; + if (!rectContained(rect, boundsRect)) { + continue; + } + if (lay.rotation === 0) { - if (kept.some((r) => rectsOverlap(r, rect))) continue; + if (kept.some((r) => rectsOverlap(r, rect))) { + continue; + } } else { - if (kept.some((r) => rotatedLabelsOverlap(r, rect))) continue; + if (kept.some((r) => rotatedLabelsOverlap(r, rect))) { + continue; + } } + kept.push(rect); drawLabel(ctx, text, px, labelY, lay.rotation, "center"); @@ -316,7 +401,7 @@ function renderLeafLevel( } function renderOuterLevel( - ctx: CanvasRenderingContext2D, + ctx: Context2D, layout: PlotLayout, level: CategoricalLevel, visMin: number, @@ -327,32 +412,35 @@ function renderOuterLevel( ): void { const { plotRect: plot } = layout; const runs = runsInRange(level.runs, visMin, visMax); - if (runs.length === 0) return; + if (runs.length === 0) { + return; + } + + const clipped = clippedRuns( + runs, + (idx) => categoryIndexToPixelX(layout, idx), + plot.x, + plot.x + plot.width, + ); + if (clipped.length === 0) { + return; + } ctx.strokeStyle = tickColor; ctx.fillStyle = tickColor; - // Boundary ticks at each run edge + bracket baseline across the group. ctx.beginPath(); - for (const run of runs) { - const xStart = categoryIndexToPixel(layout, run.startIdx - 0.5); - const xEnd = categoryIndexToPixel(layout, run.endIdx + 0.5); - const xLeft = Math.max(plot.x, xStart); - const xRight = Math.min(plot.x + plot.width, xEnd); - if (xRight <= xLeft) continue; - - // Bracket line - ctx.moveTo(xLeft, rowTop + 3); - ctx.lineTo(xRight, rowTop + 3); - // Boundary ticks - ctx.moveTo(xStart, rowTop); - ctx.lineTo(xStart, rowTop + 3); - ctx.moveTo(xEnd, rowTop); - ctx.lineTo(xEnd, rowTop + 3); + for (const c of clipped) { + ctx.moveTo(c.nearClip, rowTop + 3); + ctx.lineTo(c.farClip, rowTop + 3); + ctx.moveTo(c.nearEdge, rowTop); + ctx.lineTo(c.nearEdge, rowTop + 3); + ctx.moveTo(c.farEdge, rowTop); + ctx.lineTo(c.farEdge, rowTop + 3); } + ctx.stroke(); - // Labels centered within each span. ctx.font = `${LABEL_FONT_PX}px ${fontFamily}`; const labelY = rowTop + 3 + 4; const kept: { @@ -368,25 +456,30 @@ function renderOuterLevel( height: 9999, }; - for (const run of runs) { - const xStart = categoryIndexToPixel(layout, run.startIdx - 0.5); - const xEnd = categoryIndexToPixel(layout, run.endIdx + 0.5); - const xLeft = Math.max(plot.x, xStart); - const xRight = Math.min(plot.x + plot.width, xEnd); - if (xRight <= xLeft) continue; - const cx = (xLeft + xRight) / 2; + for (const c of clipped) { + const cx = (c.nearClip + c.farClip) / 2; - const text = run.label; - if (!text) continue; + const text = c.run.label; + if (!text) { + continue; + } - const available = xRight - xLeft - 4; + const available = c.farClip - c.nearClip - 4; const display = truncateLabel(ctx, text, available); - if (!display) continue; + if (!display) { + continue; + } const textWidth = ctx.measureText(display).width; const rect = labelRect(cx, labelY, textWidth, LABEL_LINE_HEIGHT, 0); - if (!rectContained(rect, boundsRect)) continue; - if (kept.some((r) => rectsOverlap(r, rect))) continue; + if (!rectContained(rect, boundsRect)) { + continue; + } + + if (kept.some((r) => rectsOverlap(r, rect))) { + continue; + } + kept.push(rect); drawLabel(ctx, display, cx, labelY, 0, "center"); @@ -395,77 +488,22 @@ function renderOuterLevel( function leafRowsForRotated(visMin: number, visMax: number): number[] { const out: number[] = []; - for (let i = visMin; i <= visMax; i++) out.push(i); - return out; -} - -// ── Horizontal (Y-axis) categorical variant ──────────────────────────── -// Used by X Bar charts: categories stack top-to-bottom on the left side -// of the plot, leaf level closest to the plot, outer levels further -// left. Labels are always horizontal; rotation is unnecessary because -// category count is bounded by plot height and overlap-hiding handles -// density. - -const LEAF_LEVEL_WIDTH_MIN = 55; -const OUTER_LEVEL_WIDTH = 60; -const LEAF_LABEL_PADDING = 10; - -/** Pixel Y for a row index on the categorical Y axis. */ -function categoryIndexToPixelY(layout: PlotLayout, index: number): number { - return layout.dataToPixel(0, index).py; -} - -/** - * Per-CSS-pixel widths (outermost-first) required to fit the hierarchical - * categorical Y axis. The leaf level auto-sizes to the longest label; outer - * levels use a fixed width per level. - */ -export function measureCategoricalLevelWidths( - domain: CategoricalDomain, -): number[] { - const L = domain.levels.length; - const widths: number[] = []; - const charPx = 6.2; - for (let l = 0; l < L; l++) { - if (l === L - 1) { - const longest = domain.levels[l].maxLabelChars; - widths.push( - Math.max( - LEAF_LEVEL_WIDTH_MIN, - longest * charPx + LEAF_LABEL_PADDING, - ), - ); - } else { - widths.push(OUTER_LEVEL_WIDTH); - } + for (let i = visMin; i <= visMax; i++) { + out.push(i); } - return widths; -} -/** - * Total CSS-pixel width required for the left-gutter categorical axis, - * excluding the axis-label column (added separately by `PlotLayout` via - * `hasYLabel`). Caller feeds this to `PlotLayout` as `leftExtra`. - */ -export function measureCategoricalAxisWidth(domain: CategoricalDomain): number { - if (domain.numRows === 0 || domain.levels.length === 0) return 55; - const widths = measureCategoricalLevelWidths(domain); - let total = 0; - for (const w of widths) total += w; - return total; + return out; } -/** - * Render the hierarchical Y axis for a categorical domain along the left - * side of the plot. Mirror of `renderCategoricalXTicks` for X Bar. - */ export function renderCategoricalYTicks( - ctx: CanvasRenderingContext2D, + ctx: Context2D, layout: PlotLayout, domain: CategoricalDomain, theme: Theme, ): void { - if (domain.numRows === 0 || domain.levels.length === 0) return; + if (domain.numRows === 0 || domain.levels.length === 0) { + return; + } const { tickColor, labelColor, fontFamily } = theme; const { plotRect: plot } = layout; @@ -477,21 +515,20 @@ export function renderCategoricalYTicks( ctx.font = `${LABEL_FONT_PX}px ${fontFamily}`; const widths = measureCategoricalLevelWidths(domain); + const win = visibleRowWindow( + domain.numRows, + layout.paddedYMin, + layout.paddedYMax, + true, + ); + if (!win) { + return; + } - // Visible row window from the (possibly zoomed) padded Y domain. The - // categorical Y domain is stored flipped (higher paddedYMin than - // paddedYMax) so that catIdx=0 renders at the top — see - // `buildProjectionMatrix` call in `bar-render.ts` for the swap. - const lo = Math.min(layout.paddedYMin, layout.paddedYMax); - const hi = Math.max(layout.paddedYMin, layout.paddedYMax); - const visMin = Math.max(0, Math.ceil(lo)); - const visMax = Math.min(domain.numRows - 1, Math.floor(hi)); - if (visMax < visMin) return; + const [visMin, visMax] = win; const L = domain.levels.length; let xCursor = axisX; - - // Inner → outer. Leaf is the last level (innermost = nearest plot). for (let l = L - 1; l >= 0; l--) { const level = domain.levels[l]; const w = widths[l]; @@ -524,7 +561,6 @@ export function renderCategoricalYTicks( } } - // Axis label — single line running vertically along the far-left gutter. const axisLabel = domain.levelLabels.filter((s) => !!s).join(" / "); if (axisLabel) { ctx.fillStyle = labelColor; @@ -540,7 +576,7 @@ export function renderCategoricalYTicks( } function renderLeafLevelY( - ctx: CanvasRenderingContext2D, + ctx: Context2D, layout: PlotLayout, level: CategoricalLevel, visMin: number, @@ -551,8 +587,6 @@ function renderLeafLevelY( ): void { const { plotRect: plot } = layout; - // Overlap-based tick thinning: estimate min vertical spacing from - // the label line height. const avgLabelHeight = LABEL_LINE_HEIGHT + 4; const count = visMax - visMin + 1; const maxLabels = Math.max(1, Math.floor(plot.height / avgLabelHeight)); @@ -563,10 +597,14 @@ function renderLeafLevelY( ctx.beginPath(); for (let r = visMin; r <= visMax; r += step) { const py = categoryIndexToPixelY(layout, r); - if (py < plot.y - 1 || py > plot.y + plot.height + 1) continue; + if (py < plot.y - 1 || py > plot.y + plot.height + 1) { + continue; + } + ctx.moveTo(colRight - TICK_SIZE, py); ctx.lineTo(colRight, py); } + ctx.stroke(); ctx.font = `${LABEL_FONT_PX}px ${fontFamily}`; @@ -574,15 +612,21 @@ function renderLeafLevelY( ctx.textBaseline = "middle"; for (let r = visMin; r <= visMax; r += step) { const py = categoryIndexToPixelY(layout, r); - if (py < plot.y - 1 || py > plot.y + plot.height + 1) continue; + if (py < plot.y - 1 || py > plot.y + plot.height + 1) { + continue; + } + const text = getLeafText(level, r); - if (!text) continue; + if (!text) { + continue; + } + ctx.fillText(text, colRight - TICK_SIZE - 3, py); } } function renderOuterLevelY( - ctx: CanvasRenderingContext2D, + ctx: Context2D, layout: PlotLayout, level: CategoricalLevel, visMin: number, @@ -594,54 +638,60 @@ function renderOuterLevelY( ): void { const { plotRect: plot } = layout; const runs = runsInRange(level.runs, visMin, visMax); - if (runs.length === 0) return; + if (runs.length === 0) { + return; + } + + const clipped = clippedRuns( + runs, + (idx) => categoryIndexToPixelY(layout, idx), + plot.y, + plot.y + plot.height, + ); + if (clipped.length === 0) { + return; + } ctx.strokeStyle = tickColor; ctx.fillStyle = tickColor; const bracketX = colRight - 3; ctx.beginPath(); - for (const run of runs) { - const yStart = categoryIndexToPixelY(layout, run.startIdx - 0.5); - const yEnd = categoryIndexToPixelY(layout, run.endIdx + 0.5); - const yTop = Math.max(plot.y, Math.min(yStart, yEnd)); - const yBot = Math.min(plot.y + plot.height, Math.max(yStart, yEnd)); - if (yBot <= yTop) continue; - - ctx.moveTo(bracketX, yTop); - ctx.lineTo(bracketX, yBot); - ctx.moveTo(bracketX, yStart); - ctx.lineTo(colRight, yStart); - ctx.moveTo(bracketX, yEnd); - ctx.lineTo(colRight, yEnd); + for (const c of clipped) { + ctx.moveTo(bracketX, c.nearClip); + ctx.lineTo(bracketX, c.farClip); + ctx.moveTo(bracketX, c.nearEdge); + ctx.lineTo(colRight, c.nearEdge); + ctx.moveTo(bracketX, c.farEdge); + ctx.lineTo(colRight, c.farEdge); } + ctx.stroke(); ctx.font = `${LABEL_FONT_PX}px ${fontFamily}`; ctx.textAlign = "right"; ctx.textBaseline = "middle"; - for (const run of runs) { - const yStart = categoryIndexToPixelY(layout, run.startIdx - 0.5); - const yEnd = categoryIndexToPixelY(layout, run.endIdx + 0.5); - const yTop = Math.max(plot.y, Math.min(yStart, yEnd)); - const yBot = Math.min(plot.y + plot.height, Math.max(yStart, yEnd)); - if (yBot <= yTop) continue; - const cy = (yTop + yBot) / 2; + for (const c of clipped) { + const cy = (c.nearClip + c.farClip) / 2; - const text = run.label; - if (!text) continue; + const text = c.run.label; + if (!text) { + continue; + } const available = colWidth - 6; const display = truncateLabel(ctx, text, available); - if (!display) continue; + if (!display) { + continue; + } ctx.fillText(display, bracketX - 3, cy); } } function drawLabel( - ctx: CanvasRenderingContext2D, + ctx: Context2D, text: string, px: number, py: number, @@ -654,12 +704,13 @@ function drawLabel( ctx.fillText(text, px, py); return; } + ctx.save(); ctx.translate(px, py); ctx.rotate((-rotation * Math.PI) / 180); ctx.textAlign = "right"; ctx.textBaseline = "middle"; - // Small offset so the right end of the rotated text sits near the tick. + ctx.fillText(text, -2, 0); ctx.restore(); } diff --git a/rust/perspective-viewer/src/rust/engines.rs b/packages/viewer-charts/src/ts/axis/facet-chrome.ts similarity index 67% rename from rust/perspective-viewer/src/rust/engines.rs rename to packages/viewer-charts/src/ts/axis/facet-chrome.ts index 5596a52f15..cff5e3bcf8 100644 --- a/rust/perspective-viewer/src/rust/engines.rs +++ b/packages/viewer-charts/src/ts/axis/facet-chrome.ts @@ -10,19 +10,33 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -//! Engine-handle types for the four major state singletons. -//! -//! These are the async-machinery halves of the engine/value split described in -//! the props modules. They own JS object handles, draw locks, async -//! subscriptions, and PubSub channels — i.e. anything that cannot be cheaply -//! cloned into a plain `PartialEq` prop. -//! -//! **Current status (Step 3 of the migration):** Each `*Engine` type is a thin -//! type alias for the existing `Rc<*Handle>` wrapper. They will be replaced -//! with true struct types in later migration steps once the component tree has -//! been updated to consume value-semantic props instead of the old handles. +import type { Canvas2D, Context2D } from "../charts/canvas-types"; +import type { Theme } from "../theme/theme"; -pub use crate::dragdrop::DragDrop as DragDropEngine; -pub use crate::presentation::Presentation as PresentationEngine; -pub use crate::renderer::Renderer as RendererEngine; -pub use crate::session::Session as SessionEngine; +/** + * Paint a single facet's title strip — one line of centered text in the + * caller-supplied `rect`. Shared by every faceted chart family + * (cartesian, heatmap, …) so the title typography stays uniform. + */ +export function drawFacetTitle( + canvas: Canvas2D, + label: string, + rect: { x: number; y: number; width: number; height: number }, + theme: Theme, + dpr: number, +): void { + const ctx = canvas.getContext("2d") as Context2D | null; + if (!ctx) { + return; + } + + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.scale(dpr, dpr); + ctx.font = `11px ${theme.fontFamily}`; + ctx.fillStyle = theme.labelColor; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(label, rect.x + rect.width / 2, rect.y + rect.height / 2); + ctx.restore(); +} diff --git a/packages/viewer-charts/src/ts/chrome/label-geometry.ts b/packages/viewer-charts/src/ts/axis/label-geometry.ts similarity index 67% rename from packages/viewer-charts/src/ts/chrome/label-geometry.ts rename to packages/viewer-charts/src/ts/axis/label-geometry.ts index 9584c4e79e..0b5832b3bc 100644 --- a/packages/viewer-charts/src/ts/chrome/label-geometry.ts +++ b/packages/viewer-charts/src/ts/axis/label-geometry.ts @@ -17,6 +17,8 @@ * All rectangles are in CSS pixels, origin top-left, Y-axis pointing down. */ +import type { Context2D } from "../charts/canvas-types"; + export interface Rect { x: number; y: number; @@ -45,6 +47,7 @@ export function labelRect( height: textHeight, }; } + if (rotation === 90) { return { x: cx - textHeight / 2, @@ -53,6 +56,7 @@ export function labelRect( height: textWidth, }; } + const w = (textWidth + textHeight) / Math.SQRT2; return { x: cx - w, y: cy, width: w, height: w }; } @@ -89,19 +93,96 @@ export function rectContained(inner: Rect, outer: Rect): boolean { * within `maxWidth`. Returns "" when even one character would overflow. */ export function truncateLabel( - ctx: CanvasRenderingContext2D, + ctx: Context2D, label: string, maxWidth: number, ): string { - if (maxWidth <= 0) return ""; - if (ctx.measureText(label).width <= maxWidth) return label; + if (maxWidth <= 0) { + return ""; + } + + if (ctx.measureText(label).width <= maxWidth) { + return label; + } + let lo = 0; let hi = label.length; while (lo < hi) { const mid = (lo + hi + 1) >> 1; const candidate = label.slice(0, mid) + "…"; - if (ctx.measureText(candidate).width <= maxWidth) lo = mid; - else hi = mid - 1; + if (ctx.measureText(candidate).width <= maxWidth) { + lo = mid; + } else { + hi = mid - 1; + } } + return lo === 0 ? "" : label.slice(0, lo) + "…"; } + +/** + * Word-wrap `text` into at most `maxLines` lines, each fitting within + * `maxWidth`. Breaks at the last whitespace before the fit boundary + * when possible, falls back to a hard character break otherwise. The + * final line is ellipsis-truncated via {@link truncateLabel} if it + * still doesn't fit. Returns `[]` when nothing meaningful fits (only + * one line of ≤ 2 chars after wrapping). + */ +export function wrapLabel( + ctx: Context2D, + text: string, + maxWidth: number, + maxLines: number, +): string[] { + if (maxLines <= 0 || maxWidth <= 0) { + return []; + } + + if (ctx.measureText(text).width <= maxWidth) { + return [text]; + } + + const lines: string[] = []; + let remaining = text; + + while (remaining.length > 0 && lines.length < maxLines) { + const isLastLine = lines.length === maxLines - 1; + + let fitLen = remaining.length; + while ( + fitLen > 0 && + ctx.measureText(remaining.slice(0, fitLen)).width > maxWidth + ) { + fitLen--; + } + + if (fitLen === 0) { + fitLen = 1; + } + + if (fitLen === remaining.length) { + lines.push(remaining); + break; + } + + let breakAt = fitLen; + const spaceIdx = remaining.lastIndexOf(" ", fitLen); + if (spaceIdx > 0) { + breakAt = spaceIdx; + } + + if (isLastLine) { + lines.push(truncateLabel(ctx, remaining, maxWidth)); + break; + } + + lines.push(remaining.slice(0, breakAt)); + remaining = remaining.slice(breakAt).trimStart(); + } + + if (lines.length === 1 && lines[0].length <= 2) { + return []; + } + + return lines; +} diff --git a/packages/viewer-charts/src/ts/chrome/legend.ts b/packages/viewer-charts/src/ts/axis/legend.ts similarity index 83% rename from packages/viewer-charts/src/ts/chrome/legend.ts rename to packages/viewer-charts/src/ts/axis/legend.ts index c1615c0c45..4a982127f8 100644 --- a/packages/viewer-charts/src/ts/chrome/legend.ts +++ b/packages/viewer-charts/src/ts/axis/legend.ts @@ -10,6 +10,7 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +import type { Canvas2D, Context2D } from "../charts/canvas-types"; import type { PlotLayout, PlotRect } from "../layout/plot-layout"; import { formatTickValue } from "../layout/ticks"; import { @@ -17,6 +18,7 @@ import { sampleGradient, type GradientStop, } from "../theme/gradient"; +import type { Theme } from "../theme/theme"; function rgbCss(c: [number, number, number, number]): string { return `rgb(${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)})`; @@ -32,10 +34,12 @@ function rgbCss(c: [number, number, number, number]): string { * legend and pass an explicit rect to `renderLegendAt` directly. */ export function renderLegend( - canvas: HTMLCanvasElement, + canvas: Canvas2D, layout: PlotLayout, colorDomain: { min: number; max: number; label: string }, stops: GradientStop[], + theme: Theme, + formatter?: (v: number) => string, ): void { const rect: PlotRect = { x: layout.plotRect.x + layout.plotRect.width + 12, @@ -46,7 +50,7 @@ export function renderLegend( ), height: Math.max(1, layout.plotRect.height), }; - renderLegendAt(canvas, rect, colorDomain, stops); + renderLegendAt(canvas, rect, colorDomain, stops, theme, formatter); } /** @@ -55,24 +59,21 @@ export function renderLegend( * by single-plot charts through {@link renderLegend}. */ export function renderLegendAt( - canvas: HTMLCanvasElement, + canvas: Canvas2D, rect: PlotRect, colorDomain: { min: number; max: number; label: string }, stops: GradientStop[], + theme: Theme, + formatter: (v: number) => string = formatTickValue, ): void { - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - const style = getComputedStyle(canvas); - const textColor = - style.getPropertyValue("--psp-webgl--legend--color").trim() || - "rgba(180, 180, 180, 0.9)"; - const borderColor = - style.getPropertyValue("--psp-webgl--legend-border--color").trim() || - "rgba(128,128,128,0.3)"; - const fontFamily = - style.getPropertyValue("--psp-webgl--font-family").trim() || - "monospace"; + const ctx = canvas.getContext("2d") as Context2D | null; + if (!ctx) { + return; + } + + const textColor = theme.legendText; + const borderColor = theme.legendBorder; + const fontFamily = theme.fontFamily; const barWidth = 16; const barHeight = Math.min(120, rect.height * 0.4); @@ -80,7 +81,7 @@ export function renderLegendAt( const y = rect.y; ctx.fillStyle = textColor; - ctx.font = `11px ${fontFamily}`; + ctx.font = `9px ${fontFamily}`; ctx.textAlign = "left"; ctx.textBaseline = "bottom"; ctx.fillText(colorDomain.label, x, y - 4); @@ -99,6 +100,7 @@ export function renderLegendAt( const rgba = sampleGradient(stops, t); gradient.addColorStop(offset, rgbCss(rgba)); } + ctx.fillStyle = gradient; ctx.fillRect(x, y, barWidth, barHeight); @@ -112,13 +114,13 @@ export function renderLegendAt( ctx.textBaseline = "middle"; const labelX = x + barWidth + 5; - ctx.fillText(formatTickValue(colorDomain.max), labelX, y + 2); + ctx.fillText(formatter(colorDomain.max), labelX, y + 2); ctx.fillText( - formatTickValue((colorDomain.min + colorDomain.max) / 2), + formatter((colorDomain.min + colorDomain.max) / 2), labelX, y + barHeight / 2, ); - ctx.fillText(formatTickValue(colorDomain.min), labelX, y + barHeight - 2); + ctx.fillText(formatter(colorDomain.min), labelX, y + barHeight - 2); // Sign-pivot marker when the data crosses zero: a small tick on the // right edge of the bar + a "0" label. @@ -147,10 +149,11 @@ export function renderLegendAt( * directly. */ export function renderCategoricalLegend( - canvas: HTMLCanvasElement, + canvas: Canvas2D, layout: PlotLayout, labels: Map, palette: [number, number, number][], + theme: Theme, ): void { const rect: PlotRect = { x: layout.plotRect.x + layout.plotRect.width + 12, @@ -161,7 +164,7 @@ export function renderCategoricalLegend( ), height: Math.max(1, layout.plotRect.height), }; - renderCategoricalLegendAt(canvas, rect, labels, palette); + renderCategoricalLegendAt(canvas, rect, labels, palette, theme); } /** @@ -170,22 +173,23 @@ export function renderCategoricalLegend( * single-plot charts through {@link renderCategoricalLegend}. */ export function renderCategoricalLegendAt( - canvas: HTMLCanvasElement, + canvas: Canvas2D, rect: PlotRect, labels: Map, palette: [number, number, number][], + theme: Theme, ): void { - const ctx = canvas.getContext("2d"); - if (!ctx) return; - if (labels.size === 0) return; - - const style = getComputedStyle(canvas); - const textColor = - style.getPropertyValue("--psp-webgl--legend--color").trim() || - "rgba(180, 180, 180, 0.9)"; - const fontFamily = - style.getPropertyValue("--psp-webgl--font-family").trim() || - "monospace"; + const ctx = canvas.getContext("2d") as Context2D | null; + if (!ctx) { + return; + } + + if (labels.size === 0) { + return; + } + + const textColor = theme.legendText; + const fontFamily = theme.fontFamily; const swatchSize = 10; const lineHeight = 18; @@ -197,7 +201,10 @@ export function renderCategoricalLegendAt( ctx.textBaseline = "middle"; for (const [label, idx] of labels) { - if (y + swatchSize / 2 > rect.y + rect.height) break; + if (y + swatchSize / 2 > rect.y + rect.height) { + break; + } + const color = palette[idx] ?? palette[idx % palette.length] ?? [0, 0, 0]; ctx.fillStyle = `rgb(${Math.round(color[0] * 255)},${Math.round(color[1] * 255)},${Math.round(color[2] * 255)})`; diff --git a/packages/viewer-charts/src/ts/axis/numeric-axis.ts b/packages/viewer-charts/src/ts/axis/numeric-axis.ts new file mode 100644 index 0000000000..3978de180e --- /dev/null +++ b/packages/viewer-charts/src/ts/axis/numeric-axis.ts @@ -0,0 +1,353 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import type { Canvas2D, Context2D } from "../charts/canvas-types"; +import { PlotLayout, type PlotRect } from "../layout/plot-layout"; +import { + computeNiceTicks, + formatTickValue, + formatDateTickValue, +} from "../layout/ticks"; +import { getScaledContext } from "./canvas"; +import { + drawGridlinesX, + drawGridlinesY, + drawXTickRow, + drawYTickColumn, +} from "./axis-primitives"; +import type { Theme } from "../theme/theme"; + +export interface AxisDomain { + min: number; + max: number; + label: string; + isDate?: boolean; +} + +export interface TickResult { + xTicks: number[]; + yTicks: number[]; +} + +export function computeTicks( + xDomain: AxisDomain, + yDomain: AxisDomain, + layout: PlotLayout, +): TickResult { + const { plotRect: plot } = layout; + const targetXTicks = Math.max(2, Math.floor(plot.width / 90)); + const targetYTicks = Math.max(2, Math.floor(plot.height / 60)); + return { + xTicks: computeNiceTicks(xDomain.min, xDomain.max, targetXTicks), + yTicks: computeNiceTicks(yDomain.min, yDomain.max, targetYTicks), + }; +} + +export function renderGridlines( + canvas: Canvas2D, + layout: PlotLayout, + xTicks: number[], + yTicks: number[], + theme: Theme, + dpr: number, +): void { + const ctx = getScaledContext(canvas, dpr); + if (!ctx) { + return; + } + + const { plotRect: plot } = layout; + ctx.strokeStyle = theme.gridlineColor; + ctx.lineWidth = 1; + drawGridlinesX(ctx, plot, xTicks, (v) => layout.dataToPixel(v, 0).px); + drawGridlinesY(ctx, plot, yTicks, (v) => layout.dataToPixel(0, v).py); +} + +function tickFmt( + domain: AxisDomain, + ticks: number[], + override?: (v: number) => string, +): (v: number) => string { + if (override) { + return override; + } + + const step = ticks.length > 1 ? ticks[1] - ticks[0] : 0; + return domain.isDate + ? (v: number) => formatDateTickValue(v, step) + : formatTickValue; +} + +/** + * Shared core for X-axis rendering used by both per-cell and outer-band + * variants. `axisY` is the pixel Y of the axis line; `band` defines the + * span of that line. `labelBand` (when label-rendering is requested) + * gives the box used to position/center the axis label below it. + */ +function renderXAxisCore( + ctx: Context2D, + xDomain: AxisDomain, + xTicks: number[], + layouts: PlotLayout[], + axisY: number, + band: { x: number; width: number }, + theme: Theme, + label: { cx: number; baselineY: number } | null, + formatter?: (v: number) => string, +): void { + const { tickColor, labelColor, axisLineColor, fontFamily } = theme; + const fmt = tickFmt(xDomain, xTicks, formatter); + + ctx.strokeStyle = axisLineColor; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(band.x, axisY); + ctx.lineTo(band.x + band.width, axisY); + ctx.stroke(); + + ctx.fillStyle = tickColor; + ctx.strokeStyle = tickColor; + ctx.font = `11px ${fontFamily}`; + for (const layout of layouts) { + drawXTickRow( + ctx, + layout.plotRect, + xTicks, + axisY, + "bottom", + (v) => layout.dataToPixel(v, 0).px, + fmt, + ); + } + + if (label && xDomain.label) { + ctx.fillStyle = labelColor; + ctx.font = `13px ${fontFamily}`; + ctx.textAlign = "center"; + ctx.textBaseline = "bottom"; + ctx.fillText(xDomain.label, label.cx, label.baselineY); + } +} + +/** + * Shared core for Y-axis rendering. `axisX` is the pixel X of the axis + * line; `band` defines its vertical span. `label` (when set) gives the + * pivot point for the rotated axis label. + */ +function renderYAxisCore( + ctx: Context2D, + yDomain: AxisDomain, + yTicks: number[], + layouts: PlotLayout[], + axisX: number, + band: { y: number; height: number }, + theme: Theme, + label: { pivotY: number } | null, + formatter?: (v: number) => string, +): void { + const { tickColor, labelColor, axisLineColor, fontFamily } = theme; + const fmt = tickFmt(yDomain, yTicks, formatter); + + ctx.strokeStyle = axisLineColor; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(axisX, band.y); + ctx.lineTo(axisX, band.y + band.height); + ctx.stroke(); + + ctx.fillStyle = tickColor; + ctx.strokeStyle = tickColor; + ctx.font = `11px ${fontFamily}`; + for (const layout of layouts) { + drawYTickColumn( + ctx, + layout.plotRect, + yTicks, + axisX, + "left", + (v) => layout.dataToPixel(0, v).py, + fmt, + ); + } + + if (label && yDomain.label) { + ctx.fillStyle = labelColor; + ctx.font = `13px ${fontFamily}`; + ctx.save(); + ctx.translate(14, label.pivotY); + ctx.rotate(-Math.PI / 2); + ctx.textAlign = "center"; + ctx.textBaseline = "bottom"; + ctx.fillText(yDomain.label, 0, 0); + ctx.restore(); + } +} + +export function renderCellXAxis( + canvas: Canvas2D, + xDomain: AxisDomain, + layout: PlotLayout, + xTicks: number[], + theme: Theme, + hasLabel: boolean, + dpr: number, + formatter?: (v: number) => string, +): void { + const ctx = getScaledContext(canvas, dpr); + if (!ctx) { + return; + } + + const { plotRect: plot } = layout; + renderXAxisCore( + ctx, + xDomain, + xTicks, + [layout], + plot.y + plot.height, + plot, + theme, + hasLabel + ? { + cx: plot.x + plot.width / 2, + baselineY: layout.cssHeight - 2, + } + : null, + formatter, + ); +} + +export function renderCellYAxis( + canvas: Canvas2D, + yDomain: AxisDomain, + layout: PlotLayout, + yTicks: number[], + theme: Theme, + hasLabel: boolean, + dpr: number, + formatter?: (v: number) => string, +): void { + const ctx = getScaledContext(canvas, dpr); + if (!ctx) { + return; + } + + const { plotRect: plot } = layout; + renderYAxisCore( + ctx, + yDomain, + yTicks, + [layout], + plot.x, + plot, + theme, + hasLabel ? { pivotY: plot.y + plot.height / 2 } : null, + formatter, + ); +} + +export function renderAxesChrome( + canvas: Canvas2D, + xDomain: AxisDomain, + yDomain: AxisDomain, + layout: PlotLayout, + xTicks: number[], + yTicks: number[], + theme: Theme, + dpr: number, + xFormatter?: (v: number) => string, + yFormatter?: (v: number) => string, +): void { + renderCellYAxis( + canvas, + yDomain, + layout, + yTicks, + theme, + true, + dpr, + yFormatter, + ); + renderCellXAxis( + canvas, + xDomain, + layout, + xTicks, + theme, + true, + dpr, + xFormatter, + ); +} + +export function renderOuterXAxis( + canvas: Canvas2D, + rect: PlotRect, + xDomain: AxisDomain, + xTicks: number[], + colLayouts: PlotLayout[], + theme: Theme, + hasLabel: boolean, + dpr: number, + formatter?: (v: number) => string, +): void { + const ctx = getScaledContext(canvas, dpr); + if (!ctx) { + return; + } + + renderXAxisCore( + ctx, + xDomain, + xTicks, + colLayouts, + rect.y, + rect, + theme, + hasLabel + ? { + cx: rect.x + rect.width / 2, + baselineY: rect.y + rect.height - 2, + } + : null, + formatter, + ); +} + +export function renderOuterYAxis( + canvas: Canvas2D, + rect: PlotRect, + yDomain: AxisDomain, + yTicks: number[], + rowLayouts: PlotLayout[], + theme: Theme, + hasLabel: boolean, + dpr: number, + formatter?: (v: number) => string, +): void { + const ctx = getScaledContext(canvas, dpr); + if (!ctx) { + return; + } + + renderYAxisCore( + ctx, + yDomain, + yTicks, + rowLayouts, + rect.x + rect.width, + rect, + theme, + hasLabel ? { pivotY: rect.y + rect.height / 2 } : null, + formatter, + ); +} diff --git a/packages/viewer-charts/src/ts/charts/bar/bar-build.ts b/packages/viewer-charts/src/ts/charts/bar/bar-build.ts deleted file mode 100644 index c3ca5c64af..0000000000 --- a/packages/viewer-charts/src/ts/charts/bar/bar-build.ts +++ /dev/null @@ -1,387 +0,0 @@ -// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ -// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ -// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ -// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ -// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ -// ┃ Copyright (c) 2017, the Perspective Authors. ┃ -// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ -// ┃ This file is part of the Perspective library, distributed under the terms ┃ -// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ -// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - -import type { ColumnDataMap } from "../../data/view-reader"; -import { buildSplitGroups } from "../../data/split-groups"; -import type { CategoricalLevel } from "../../chrome/categorical-axis"; -import { resolveCategoryAxis } from "../common/category-axis"; -import { computeSlotGeometry, slotCenter } from "../common/band-layout"; -import { - resolveChartType, - resolveStack, - type ChartType, - type ColumnChartConfig, -} from "./chart-type"; - -const DUAL_Y_RATIO_THRESHOLD = 50; - -export interface SeriesInfo { - seriesId: number; - aggIdx: number; - splitIdx: number; - aggName: string; - splitKey: string; - label: string; - color: [number, number, number]; - axis: 0 | 1; - chartType: ChartType; - stack: boolean; -} - -export interface BarRecord { - catIdx: number; - aggIdx: number; - splitIdx: number; - seriesId: number; - xCenter: number; - halfWidth: number; - y0: number; - y1: number; - value: number; - axis: 0 | 1; - /** `"bar"` quads or `"area"` strip segments both stack via this record. */ - chartType: "bar" | "area"; -} - -export interface BarPipelineInput { - columns: ColumnDataMap; - numRows: number; - columnSlots: (string | null)[]; - groupBy: string[]; - splitBy: string[]; - columnsConfig: Record | undefined; - /** Plugin-scoped default glyph when a column has no explicit entry. */ - defaultChartType?: ChartType; -} - -export interface BarPipelineResult { - aggregates: string[]; - splitPrefixes: string[]; - rowPaths: CategoricalLevel[]; - numCategories: number; - rowOffset: number; - series: SeriesInfo[]; - - /** - * Stacked records, one per (catIdx, agg, split) for series where - * `stack === true && chartType in ["bar", "area"]`. Consumed by the bar - * and area glyphs; areas draw their strip segments from the same y0/y1 - * ladder as bars. - */ - bars: BarRecord[]; - - /** - * Unstacked sample grid: `samples[catIdx * S + seriesId]` is the raw - * value for that cell. Only valid for non-stacking series (or for - * stacking series when you need the raw, pre-stack value); the - * corresponding bit in `sampleValid` indicates whether the cell carries - * data. `S === series.length`. - */ - samples: Float32Array; - sampleValid: Uint8Array; - - leftDomain: { min: number; max: number }; - rightDomain: { min: number; max: number } | null; - hasRightAxis: boolean; -} - -function setValidBit(valid: Uint8Array, idx: number): void { - valid[idx >> 3] |= 1 << (idx & 7); -} - -/** - * Pure pipeline: turn a raw `ColumnDataMap` into (a) stacked bar/area - * records and (b) an unstacked `samples` grid for line/scatter glyphs - * plus non-stacking bar/area series. Holds row_path data as zero-copy - * views (no materialization of category strings). - * - * Automatically splits aggregates across a secondary Y axis when their - * extents differ by more than {@link DUAL_Y_RATIO_THRESHOLD}×. - */ -export function buildBarPipeline(input: BarPipelineInput): BarPipelineResult { - const { - columns, - numRows, - columnSlots, - groupBy, - splitBy, - columnsConfig, - defaultChartType, - } = input; - - const empty: BarPipelineResult = { - aggregates: [], - splitPrefixes: [], - rowPaths: [], - numCategories: 0, - rowOffset: 0, - series: [], - bars: [], - samples: new Float32Array(0), - sampleValid: new Uint8Array(0), - leftDomain: { min: 0, max: 0 }, - rightDomain: null, - hasRightAxis: false, - }; - - const aggregates = columnSlots.filter((s): s is string => !!s); - if (aggregates.length === 0) return empty; - - const splitPrefixes: string[] = []; - if (splitBy.length > 0) { - for (const g of buildSplitGroups(columns, [], aggregates)) { - if (g.colNames.size > 0) splitPrefixes.push(g.prefix); - } - if (splitPrefixes.length === 0) splitPrefixes.push(""); - } else { - splitPrefixes.push(""); - } - - const { rowPaths, numCategories, rowOffset } = resolveCategoryAxis( - columns, - numRows, - groupBy.length, - ); - - if (numCategories === 0) { - return { - ...empty, - aggregates, - splitPrefixes, - rowPaths, - rowOffset, - }; - } - - const series: SeriesInfo[] = []; - const M = aggregates.length; - const P = splitPrefixes.length; - for (let k = 0; k < M; k++) { - for (let p = 0; p < P; p++) { - const aggName = aggregates[k]; - const splitKey = splitPrefixes[p]; - const label = - splitKey === "" - ? aggName - : `${splitKey}${M > 1 ? ` | ${aggName}` : ""}`; - const chartType = resolveChartType( - aggName, - columnsConfig, - defaultChartType, - ); - const stack = resolveStack(aggName, chartType, columnsConfig); - series.push({ - seriesId: k * P + p, - aggIdx: k, - splitIdx: p, - aggName, - splitKey, - label, - color: [0.5, 0.5, 0.5], - axis: 0, - chartType, - stack, - }); - } - } - - const aggExtents: { min: number; max: number }[] = []; - for (let k = 0; k < M; k++) aggExtents.push({ min: 0, max: 0 }); - - const N = numCategories; - const S = series.length; - // Stacking ladder, keyed by (catIdx, aggIdx). Only stacking series - // contribute; non-stacking series still extend aggExtents for axis - // domain computation but don't advance the stack. - const posStack = new Float64Array(N * M); - const negStack = new Float64Array(N * M); - - const samples = new Float32Array(N * S); - const sampleValid = new Uint8Array((N * S + 7) >> 3); - - const { slotWidth, halfWidth } = computeSlotGeometry(M); - - const bars: BarRecord[] = []; - for (let catI = 0; catI < N; catI++) { - const row = catI + rowOffset; - for (let k = 0; k < M; k++) { - for (let p = 0; p < P; p++) { - const seriesId = k * P + p; - const s = series[seriesId]; - const aggName = aggregates[k]; - const splitKey = splitPrefixes[p]; - const colName = - splitKey === "" ? aggName : `${splitKey}|${aggName}`; - const col = columns.get(colName); - if (!col?.values) continue; - if (col.valid) { - const bit = (col.valid[row >> 3] >> (row & 7)) & 1; - if (!bit) continue; - } - const v = col.values[row] as number; - if (!isFinite(v)) continue; - - // Record the raw value in the unstacked grid for every - // glyph that needs it (line, scatter, non-stacking bar/area). - const sampleIdx = catI * S + seriesId; - samples[sampleIdx] = v; - setValidBit(sampleValid, sampleIdx); - - const ext = aggExtents[k]; - - // Stacking-glyph path: emit a BarRecord with running y0/y1. - if ( - (s.chartType === "bar" || s.chartType === "area") && - s.stack - ) { - if (v === 0) continue; - - const stackIdx = catI * M + k; - let y0: number; - let y1: number; - if (v >= 0) { - y0 = posStack[stackIdx]; - y1 = y0 + v; - posStack[stackIdx] = y1; - } else { - y0 = negStack[stackIdx]; - y1 = y0 + v; - negStack[stackIdx] = y1; - } - - if (y0 < ext.min) ext.min = y0; - if (y1 < ext.min) ext.min = y1; - if (y0 > ext.max) ext.max = y0; - if (y1 > ext.max) ext.max = y1; - - const xCenter = slotCenter(catI, k, M, slotWidth); - - bars.push({ - catIdx: catI, - aggIdx: k, - splitIdx: p, - seriesId, - xCenter, - halfWidth, - y0, - y1, - value: v, - axis: 0, - chartType: s.chartType, - }); - } else { - // Non-stacking: extend extents by raw value against zero - // baseline so the axis still encloses line/scatter data. - if (v < ext.min) ext.min = v; - if (v > ext.max) ext.max = v; - if (0 < ext.min) ext.min = 0; - if (0 > ext.max) ext.max = 0; - - // Non-stacking bar/area still needs a BarRecord so the - // glyph draw call has a concrete rect. Unstacked: y0=0, - // y1=v. - if (s.chartType === "bar" || s.chartType === "area") { - if (v === 0) continue; - const xCenter = slotCenter(catI, k, M, slotWidth); - bars.push({ - catIdx: catI, - aggIdx: k, - splitIdx: p, - seriesId, - xCenter, - halfWidth, - y0: 0, - y1: v, - value: v, - axis: 0, - chartType: s.chartType, - }); - } - } - } - } - } - - let hasRightAxis = false; - if (M >= 2) { - const extents = aggExtents.map((e) => - Math.max(Math.abs(e.min), Math.abs(e.max), 1e-12), - ); - const maxExt = Math.max(...extents); - const minExt = Math.min(...extents); - if (maxExt / minExt > DUAL_Y_RATIO_THRESHOLD) { - const threshold = maxExt / Math.sqrt(DUAL_Y_RATIO_THRESHOLD); - for (let k = 0; k < M; k++) { - const onRight = extents[k] < threshold; - if (onRight) { - for (const s of series) { - if (s.aggIdx === k) s.axis = 1; - } - } - } - for (const b of bars) { - b.axis = series[b.seriesId].axis; - } - hasRightAxis = series.some((s) => s.axis === 1); - } - } - - // Axis domains: stack records contribute y0/y1; non-stacking samples - // contribute raw values against the zero baseline. - const leftExtent = { min: 0, max: 0 }; - const rightExtent = { min: 0, max: 0 }; - for (const b of bars) { - const ext = b.axis === 0 ? leftExtent : rightExtent; - if (b.y0 < ext.min) ext.min = b.y0; - if (b.y1 < ext.min) ext.min = b.y1; - if (b.y0 > ext.max) ext.max = b.y0; - if (b.y1 > ext.max) ext.max = b.y1; - } - for (let seriesId = 0; seriesId < S; seriesId++) { - const s = series[seriesId]; - if (s.stack && (s.chartType === "bar" || s.chartType === "area")) { - continue; // already counted via bars - } - const ext = s.axis === 0 ? leftExtent : rightExtent; - for (let catI = 0; catI < N; catI++) { - const sampleIdx = catI * S + seriesId; - if (!((sampleValid[sampleIdx >> 3] >> (sampleIdx & 7)) & 1)) { - continue; - } - const v = samples[sampleIdx]; - if (v < ext.min) ext.min = v; - if (v > ext.max) ext.max = v; - } - } - if (leftExtent.min === 0 && leftExtent.max === 0) leftExtent.max = 1; - - const rightDomain: { min: number; max: number } | null = hasRightAxis - ? rightExtent.min === 0 && rightExtent.max === 0 - ? { min: 0, max: 1 } - : rightExtent - : null; - - return { - aggregates, - splitPrefixes, - rowPaths, - numCategories, - rowOffset, - series, - bars, - samples, - sampleValid, - leftDomain: leftExtent, - rightDomain, - hasRightAxis, - }; -} diff --git a/packages/viewer-charts/src/ts/charts/bar/bar-render.ts b/packages/viewer-charts/src/ts/charts/bar/bar-render.ts deleted file mode 100644 index c09fba4449..0000000000 --- a/packages/viewer-charts/src/ts/charts/bar/bar-render.ts +++ /dev/null @@ -1,574 +0,0 @@ -// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ -// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ -// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ -// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ -// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ -// ┃ Copyright (c) 2017, the Perspective Authors. ┃ -// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ -// ┃ This file is part of the Perspective library, distributed under the terms ┃ -// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ -// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - -import type { WebGLContextManager } from "../../webgl/context-manager"; -import type { BarChart } from "./bar"; -import type { PlotRect } from "../../layout/plot-layout"; -import { PlotLayout } from "../../layout/plot-layout"; -import { resolveTheme, readSeriesPalette } from "../../theme/theme"; -import { resolvePalette } from "../../theme/palette"; -import { renderInPlotFrame } from "../../webgl/plot-frame"; -import { renderCanvasTooltip } from "../../interaction/tooltip-controller"; -import { drawBars } from "./glyphs/draw-bars"; -import { drawLines } from "./glyphs/draw-lines"; -import { drawScatter } from "./glyphs/draw-scatter"; -import { drawAreas } from "./glyphs/draw-areas"; -import { getHoveredBar } from "./bar-interact"; -import { computeNiceTicks } from "../../layout/ticks"; -import { type AxisDomain } from "../../chrome/numeric-axis"; -import { renderBarAxesChrome, renderBarGridlines } from "../../chrome/bar-axis"; -import { - measureCategoricalAxisHeight, - measureCategoricalAxisWidth, - type CategoricalDomain, -} from "../../chrome/categorical-axis"; -import { buildBarTooltipLines } from "./bar-interact"; - -/** - * Upload visible bar instance buffers for the currently hidden-series mask. - * Re-called after legend toggles. - */ -export function uploadBarInstances( - chart: BarChart, - glManager: WebGLContextManager, -): void { - // Only bar-typed records go through the instanced-quad pipeline. - // Area records are drawn as triangle strips by `draw-areas.ts` and - // are excluded here (they stay in `_bars` so hover hit-testing can - // still find them by rectangle). - const visibleBars = chart._bars.filter( - (b) => b.chartType === "bar" && !chart._hiddenSeries.has(b.seriesId), - ); - chart._visibleBars = visibleBars; - const n = visibleBars.length; - chart._uploadedBars = n; - if (n === 0) return; - - const xCenters = new Float32Array(n); - const halfWidths = new Float32Array(n); - const y0s = new Float32Array(n); - const y1s = new Float32Array(n); - const seriesIds = new Float32Array(n); - const axes = new Float32Array(n); - - for (let i = 0; i < n; i++) { - const b = visibleBars[i]; - xCenters[i] = b.xCenter; - halfWidths[i] = b.halfWidth; - y0s[i] = b.y0; - y1s[i] = b.y1; - seriesIds[i] = b.seriesId; - axes[i] = b.axis; - } - - glManager.bufferPool.ensureCapacity(n); - glManager.bufferPool.upload("bar_x", xCenters, 0, 1); - glManager.bufferPool.upload("bar_hw", halfWidths, 0, 1); - glManager.bufferPool.upload("bar_y0", y0s, 0, 1); - glManager.bufferPool.upload("bar_y1", y1s, 0, 1); - glManager.bufferPool.upload("bar_sid", seriesIds, 0, 1); - glManager.bufferPool.upload("bar_axis", axes, 0, 1); - - uploadBarColors(chart, glManager); -} - -/** Upload only the per-bar color buffer; cheaper than full re-upload. */ -export function uploadBarColors( - chart: BarChart, - glManager: WebGLContextManager, -): void { - const visibleBars = chart._visibleBars; - const n = visibleBars.length; - if (n === 0) return; - const colors = new Float32Array(n * 3); - for (let i = 0; i < n; i++) { - const s = chart._series[visibleBars[i].seriesId]; - colors[i * 3] = s.color[0]; - colors[i * 3 + 1] = s.color[1]; - colors[i * 3 + 2] = s.color[2]; - } - glManager.bufferPool.upload("bar_color", colors, 0, 3); -} - -/** - * Full-frame render: gridlines → WebGL bars (instanced) → chrome overlay. - */ -export function renderBarFrame( - chart: BarChart, - glManager: WebGLContextManager, -): void { - const gl = glManager.gl; - const dpr = window.devicePixelRatio || 1; - const cssWidth = gl.canvas.width / dpr; - const cssHeight = gl.canvas.height / dpr; - if (cssWidth <= 0 || cssHeight <= 0) return; - if (chart._numCategories === 0) return; - - const themeEl = (chart._gridlineCanvas!.getRootNode() as ShadowRoot).host; - const theme = resolveTheme(themeEl); - const palette = resolvePalette( - readSeriesPalette(themeEl), - theme.gradientStops, - chart._series.length, - ); - for (let i = 0; i < chart._series.length; i++) { - chart._series[i].color = palette[i]; - } - if (chart._uploadedBars > 0) uploadBarColors(chart, glManager); - - const horizontal = chart._isHorizontal; - - // Category axis always runs [-0.5, N-0.5] in logical units. In - // horizontal mode the Y domain is flipped so catIdx=0 sits at the - // top (standard horizontal-bar reading order); the flip happens in - // the projection-matrix call below. - const catMin = -0.5; - const catMax = chart._numCategories - 0.5; - const valMin = chart._leftDomain.min; - const valMax = chart._leftDomain.max; - - if (chart._zoomController) { - if (horizontal) { - chart._zoomController.setBaseDomain(valMin, valMax, catMin, catMax); - } else { - chart._zoomController.setBaseDomain(catMin, catMax, valMin, valMax); - } - } - // `visCat*` and `visVal*` always describe the currently-visible window - // in logical (category/value) coords regardless of orientation. - let visCatMin = catMin; - let visCatMax = catMax; - let visValMin = valMin; - let visValMax = valMax; - let visRightMin = chart._rightDomain?.min ?? 0; - let visRightMax = chart._rightDomain?.max ?? 1; - if (chart._zoomController) { - const vd = chart._zoomController.getVisibleDomain(); - if (horizontal) { - visValMin = vd.xMin; - visValMax = vd.xMax; - visCatMin = vd.yMin; - visCatMax = vd.yMax; - } else { - visCatMin = vd.xMin; - visCatMax = vd.xMax; - visValMin = vd.yMin; - visValMax = vd.yMax; - } - } - - // Auto-fit the value axis to the visible categorical window. Gated - // on `_autoFitValue` + non-default zoom: at default zoom the refit - // result always equals `_leftDomain`/`_rightDomain`, so walking - // would be wasted work (and would shift test baselines). - if ( - chart._autoFitValue && - chart._zoomController && - !chart._zoomController.isDefault() - ) { - const fit = computeVisibleValueExtent(chart, visCatMin, visCatMax); - if (fit.hasLeft) { - visValMin = fit.leftMin; - visValMax = fit.leftMax; - } - if (chart._rightDomain && fit.hasRight) { - visRightMin = fit.rightMin; - visRightMax = fit.rightMax; - } - } - - const hasLegend = chart._series.length > 1; - const hasCatLabel = chart._groupBy.length > 0; - - const provisionalDomain: CategoricalDomain = { - levels: chart._rowPaths, - numRows: chart._numCategories, - levelLabels: chart._groupBy.slice(), - }; - - let layout: PlotLayout; - if (horizontal) { - const leftExtra = measureCategoricalAxisWidth(provisionalDomain); - layout = new PlotLayout(cssWidth, cssHeight, { - hasXLabel: true, - hasYLabel: hasCatLabel, - hasLegend, - leftExtra, - }); - } else { - const estLeft = 55 + 16; - const estRight = hasLegend ? 80 : 16; - const estPlotWidth = Math.max(1, cssWidth - estLeft - estRight); - const bottomExtra = measureCategoricalAxisHeight( - provisionalDomain, - estPlotWidth, - ); - layout = new PlotLayout(cssWidth, cssHeight, { - hasXLabel: hasCatLabel, - hasYLabel: true, - hasLegend, - bottomExtra, - }); - } - chart._lastLayout = layout; - if (chart._zoomController) chart._zoomController.updateLayout(layout); - - // Build the primary projection. `clamp` names the axis that carries - // the *value* data (Y for Y Bar, X for X Bar). `requireZero: true` - // pins the baseline at zero so bars grow from the axis line even - // when the data range doesn't naturally include zero. - const projLeft = horizontal - ? layout.buildProjectionMatrix( - visValMin, - visValMax, - // Flip so catIdx=0 renders at the top. - visCatMax, - visCatMin, - "x", - true, - ) - : layout.buildProjectionMatrix( - visCatMin, - visCatMax, - visValMin, - visValMax, - "y", - true, - ); - - let projRight: Float32Array; - if (chart._hasRightAxis && chart._rightDomain && !horizontal) { - const savedPadXMin = layout.paddedXMin; - const savedPadXMax = layout.paddedXMax; - const savedPadYMin = layout.paddedYMin; - const savedPadYMax = layout.paddedYMax; - projRight = layout.buildProjectionMatrix( - visCatMin, - visCatMax, - visRightMin, - visRightMax, - "y", - true, - ); - layout.paddedXMin = savedPadXMin; - layout.paddedXMax = savedPadXMax; - layout.paddedYMin = savedPadYMin; - layout.paddedYMax = savedPadYMax; - } else { - // Dual-axis horizontal is not supported in this iteration; fall - // through to a single axis when horizontal + _hasRightAxis. - projRight = projLeft; - } - - const leftValueTicks = computeNiceTicks(visValMin, visValMax, 6); - const rightValueTicks = - chart._hasRightAxis && chart._rightDomain && !horizontal - ? computeNiceTicks(visRightMin, visRightMax, 6) - : null; - - const primaryValueLabel = chart._series - .filter((s) => s.axis === 0) - .map((s) => s.aggName) - .filter((v, i, a) => a.indexOf(v) === i) - .join(", "); - const altValueLabel = chart._series - .filter((s) => s.axis === 1) - .map((s) => s.aggName) - .filter((v, i, a) => a.indexOf(v) === i) - .join(", "); - - const catDomain: CategoricalDomain = provisionalDomain; - const valueDomain: AxisDomain = { - min: visValMin, - max: visValMax, - label: primaryValueLabel, - }; - const altValueDomain: AxisDomain | null = - chart._rightDomain && !horizontal - ? { - min: visRightMin, - max: visRightMax, - label: altValueLabel, - } - : null; - - if (chart._gridlineCanvas) { - renderBarGridlines( - chart._gridlineCanvas, - layout, - leftValueTicks, - theme, - horizontal, - ); - } - - renderInPlotFrame(gl, layout, () => { - // Paint order: areas behind bars (so bar borders stay crisp), - // bars above, lines above those, scatter points on top. X Bar - // only paints bars — the other glyphs bake in vertical geometry - // and aren't supported for horizontal orientation. - if (!horizontal) { - drawAreas( - chart, - gl, - glManager, - projLeft, - projRight, - theme.areaOpacity, - ); - } - - gl.useProgram(chart._program!); - const loc = chart._locations!; - gl.uniformMatrix4fv(loc.u_proj_left, false, projLeft); - gl.uniformMatrix4fv(loc.u_proj_right, false, projRight); - gl.uniform1f(loc.u_horizontal, horizontal ? 1.0 : 0.0); - const hovered = getHoveredBar(chart); - gl.uniform1f(loc.u_hover_series, hovered ? hovered.seriesId : -1); - drawBars(chart, gl, glManager); - - if (!horizontal) { - drawLines(chart, gl, glManager, projLeft, projRight); - drawScatter(chart, gl, glManager, projLeft, projRight); - } - }); - - chart._lastXDomain = catDomain; - chart._lastYDomain = valueDomain; - chart._lastYTicks = leftValueTicks; - chart._lastAltYDomain = altValueDomain; - chart._lastAltYTicks = rightValueTicks; - renderBarChromeOverlay(chart); -} - -/** - * Draw axes chrome + legend + tooltip onto the overlay canvas. - */ -export function renderBarChromeOverlay(chart: BarChart): void { - if ( - !chart._chromeCanvas || - !chart._lastLayout || - !chart._lastXDomain || - !chart._lastYDomain || - !chart._lastYTicks - ) - return; - - const theme = resolveTheme(chart._chromeCanvas); - renderBarAxesChrome( - chart._chromeCanvas, - chart._lastXDomain, - chart._lastYDomain, - chart._lastYTicks, - chart._lastLayout, - theme, - chart._lastAltYDomain ?? undefined, - chart._lastAltYTicks ?? undefined, - chart._isHorizontal, - ); - - renderBarLegend(chart); - - if (getHoveredBar(chart)) { - renderBarTooltipCanvas(chart); - } -} - -function renderBarLegend(chart: BarChart): void { - chart._legendRects = []; - if (!chart._chromeCanvas || !chart._lastLayout) return; - if (chart._series.length <= 1) return; - - const ctx = chart._chromeCanvas.getContext("2d"); - if (!ctx) return; - const dpr = window.devicePixelRatio || 1; - ctx.save(); - ctx.scale(dpr, dpr); - - const theme = resolveTheme(chart._chromeCanvas); - const textColor = theme.legendText; - const fontFamily = theme.fontFamily; - - const layout = chart._lastLayout; - const swatchSize = 10; - const lineHeight = 18; - const x = layout.plotRect.x + layout.plotRect.width + 12; - let y = layout.margins.top + 10; - - ctx.font = `11px ${fontFamily}`; - ctx.textAlign = "left"; - ctx.textBaseline = "middle"; - - for (const s of chart._series) { - const hidden = chart._hiddenSeries.has(s.seriesId); - const r = Math.round(s.color[0] * 255); - const g = Math.round(s.color[1] * 255); - const b = Math.round(s.color[2] * 255); - - ctx.globalAlpha = hidden ? 0.3 : 1.0; - ctx.fillStyle = `rgb(${r},${g},${b})`; - ctx.fillRect(x, y - swatchSize / 2, swatchSize, swatchSize); - - ctx.fillStyle = textColor; - ctx.fillText(s.label, x + swatchSize + 6, y); - - if (hidden) { - ctx.strokeStyle = textColor; - ctx.lineWidth = 1; - const textW = ctx.measureText(s.label).width; - ctx.beginPath(); - ctx.moveTo(x + swatchSize + 6, y); - ctx.lineTo(x + swatchSize + 6 + textW, y); - ctx.stroke(); - } - ctx.globalAlpha = 1.0; - - const rect: PlotRect = { - x: x - 2, - y: y - lineHeight / 2, - width: swatchSize + 6 + ctx.measureText(s.label).width + 4, - height: lineHeight, - }; - chart._legendRects.push({ seriesId: s.seriesId, rect }); - - y += lineHeight; - } - - ctx.restore(); -} - -function renderBarTooltipCanvas(chart: BarChart): void { - if (!chart._chromeCanvas || !chart._lastLayout) return; - const b = getHoveredBar(chart); - if (!b) return; - const layout = chart._lastLayout; - // Bar glyphs anchor the tooltip at the midpoint of the bar body so - // it reads against a solid swatch. Line / scatter / area glyphs - // have no body — the data point sits at `y1`, so anchor there - // (the tooltip visually hovers *over* the point). Hit records - // synthesized from line/scatter hover tag themselves as "bar" in - // `_hoveredSample` for rendering purposes, so we pull the true - // glyph from the series info instead. - const glyph = chart._series[b.seriesId]?.chartType ?? "bar"; - const anchorV = glyph === "bar" ? (b.y0 + b.y1) / 2 : b.y1; - // In horizontal mode the plot's dataToPixel expects (value, category). - const pos = - b.axis === 0 - ? chart._isHorizontal - ? layout.dataToPixel(anchorV, b.xCenter) - : layout.dataToPixel(b.xCenter, anchorV) - : rightAxisDataToPixel(chart, b.xCenter, anchorV); - - const lines = buildBarTooltipLines(chart, b); - const theme = resolveTheme(chart._chromeCanvas); - renderCanvasTooltip(chart._chromeCanvas, pos, lines, layout, theme); -} - -export function rightAxisDataToPixel( - chart: BarChart, - x: number, - y: number, -): { px: number; py: number } { - const layout = chart._lastLayout!; - const { x: px, y: py, width, height } = layout.plotRect; - const tx = - (x - layout.paddedXMin) / (layout.paddedXMax - layout.paddedXMin); - const r = chart._rightDomain!; - const ty = (y - r.min) / (r.max - r.min); - return { px: px + tx * width, py: py + (1 - ty) * height }; -} - -/** - * Compute per-axis value extent over bars whose `catIdx` falls inside - * `[visCatMin, visCatMax]`. Skips hidden series. Returns a cached - * result on `chart._autoFitCache` when `(visCatMin, visCatMax, - * _hiddenSeries)` match the previous call — hover-only redraws hit - * the cache every time. - * - * Value source is `min(y0, y1)`/`max(y0, y1)` per bar, which handles - * stacked + negative-value bars uniformly. - * - * TODO(perf): O(|_bars|) linear scan. `_bars` is already ordered by - * `catIdx`, so a binary-search pair to locate the visible slice would - * drop this to O(log N + K_visible). Deferred — under current - * `max_cells` ceilings the scan is <1% of frame time. - * - * Cache lifetime: reset on data upload ([bar.ts] `uploadAndRender`) - * and legend toggle ([bar-interact.ts] `handleBarLegendClick`). Any - * other mutation that affects the bar set must also null the cache. - */ -function computeVisibleValueExtent( - chart: BarChart, - visCatMin: number, - visCatMax: number, -): { - leftMin: number; - leftMax: number; - hasLeft: boolean; - rightMin: number; - rightMax: number; - hasRight: boolean; -} { - const cache = chart._autoFitCache; - if ( - cache && - cache.catMin === visCatMin && - cache.catMax === visCatMax && - cache.hidden === chart._hiddenSeries - ) { - return cache; - } - - let leftMin = Infinity; - let leftMax = -Infinity; - let hasLeft = false; - let rightMin = Infinity; - let rightMax = -Infinity; - let hasRight = false; - - const bars = chart._bars; - const hidden = chart._hiddenSeries; - for (let i = 0; i < bars.length; i++) { - const b = bars[i]; - if (b.catIdx < visCatMin || b.catIdx > visCatMax) continue; - if (hidden.has(b.seriesId)) continue; - const lo = b.y0 < b.y1 ? b.y0 : b.y1; - const hi = b.y0 < b.y1 ? b.y1 : b.y0; - if (b.axis === 1) { - if (lo < rightMin) rightMin = lo; - if (hi > rightMax) rightMax = hi; - hasRight = true; - } else { - if (lo < leftMin) leftMin = lo; - if (hi > leftMax) leftMax = hi; - hasLeft = true; - } - } - - // Reuse the same cache object to avoid per-frame allocation. - // `hidden` stored by reference — identity comparison in the cache - // hit path catches set-content changes because the legend-click - // handler swaps / mutates the set in ways that invalidate the - // cache via the explicit null-out. - const next = cache ?? ({} as NonNullable); - next.catMin = visCatMin; - next.catMax = visCatMax; - next.hidden = hidden; - next.leftMin = leftMin; - next.leftMax = leftMax; - next.hasLeft = hasLeft; - next.rightMin = rightMin; - next.rightMax = rightMax; - next.hasRight = hasRight; - chart._autoFitCache = next; - return next; -} diff --git a/packages/viewer-charts/src/ts/charts/bar/bar.ts b/packages/viewer-charts/src/ts/charts/bar/bar.ts deleted file mode 100644 index 68bd744301..0000000000 --- a/packages/viewer-charts/src/ts/charts/bar/bar.ts +++ /dev/null @@ -1,288 +0,0 @@ -// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ -// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ -// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ -// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ -// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ -// ┃ Copyright (c) 2017, the Perspective Authors. ┃ -// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ -// ┃ This file is part of the Perspective library, distributed under the terms ┃ -// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ -// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - -import type { ColumnDataMap } from "../../data/view-reader"; -import type { WebGLContextManager } from "../../webgl/context-manager"; -import type { ZoomConfig } from "../../interaction/zoom-controller"; -import { CategoricalYChart } from "../common/categorical-y-chart"; -import { type PlotRect } from "../../layout/plot-layout"; -import { type AxisDomain } from "../../chrome/numeric-axis"; -import { buildBarPipeline, type BarRecord, type SeriesInfo } from "./bar-build"; -import { renderBarFrame, uploadBarInstances } from "./bar-render"; -import { - handleBarHover, - handleBarLegendClick, - showBarPinnedTooltip, - showBarPinnedTooltipForSample, -} from "./bar-interact"; -import barVert from "../../shaders/bar.vert.glsl"; -import barFrag from "../../shaders/bar.frag.glsl"; - -export interface CachedLocations { - u_proj_left: WebGLUniformLocation | null; - u_proj_right: WebGLUniformLocation | null; - u_hover_series: WebGLUniformLocation | null; - u_horizontal: WebGLUniformLocation | null; - a_corner: number; - a_x_center: number; - a_half_width: number; - a_y0: number; - a_y1: number; - a_color: number; - a_series_id: number; - a_axis: number; -} - -/** - * Bar chart. Fields are package-internal (no `private`) so helper modules - * in this folder can read/write them. - * - * Orientation: vertical (Y Bar) is the default — categorical X, numeric - * Y. When `_isHorizontal` is true (X Bar) the roles swap: numeric X, - * categorical Y reading top-to-bottom. The data pipeline + instance - * attributes stay in *logical* coordinates (xCenter = category center, - * y0/y1 = value extent); the swap happens in three places only: - * 1. Projection matrix (`bar-render.ts`) — args reordered, Y flipped. - * 2. Vertex shader — `u_horizontal` uniform transposes position. - * 3. Chrome (`bar-axis.ts`) — categorical axis moves from bottom to - * left; numeric axis from left to bottom. - * Hit-testing reads the swapped pixel→data mapping via the projected - * `PlotLayout`, so its logical comparisons don't need changes. - */ -export class BarChart extends CategoricalYChart { - readonly _isHorizontal: boolean; - - constructor(orientation: "vertical" | "horizontal" = "vertical") { - super(); - this._isHorizontal = orientation === "horizontal"; - } - - /** - * Lock the categorical axis — scrolling through category indices - * isn't meaningful, and the layout code assumes all categories are - * always present. The value axis stays freely zoomable. - */ - protected override getZoomConfig(): ZoomConfig { - return { lockAxis: this._isHorizontal ? "x" : "y" }; - } - - _locations: CachedLocations | null = null; - - // Bar-specific categorical-axis bookkeeping. `_rowPaths`, - // `_numCategories`, `_rowOffset`, `_program`, `_cornerBuffer`, - // `_lastLayout`, `_lastXDomain`, `_lastYDomain`, `_lastYTicks`, and - // `_autoFitValue` all live on `CategoricalYChart`. - _aggregates: string[] = []; - _splitPrefixes: string[] = []; - _series: SeriesInfo[] = []; - _bars: BarRecord[] = []; - _leftDomain: { min: number; max: number } = { min: 0, max: 1 }; - _rightDomain: { min: number; max: number } | null = null; - _hasRightAxis = false; - - _hiddenSeries: Set = new Set(); - _hoveredBarIdx = -1; - _pinnedBarIdx = -1; - - /** - * Synthetic bar record for hover hits on line / scatter glyphs that - * don't have a real `BarRecord` in `_bars`. At most one of - * `_hoveredBarIdx` and `_hoveredSample` is populated per frame; see - * {@link ./bar-interact.getHoveredBar}. - */ - _hoveredSample: BarRecord | null = null; - - // Unstacked sample grid produced by buildBarPipeline: samples[catI * S + seriesId]. - _samples: Float32Array = new Float32Array(0); - _sampleValid: Uint8Array = new Uint8Array(0); - - // Lazily-initialised per-glyph shader / buffer caches. Undefined until - // the first frame that needs the corresponding glyph. Typed as `unknown` - // so the glyph modules can own their own cache shape without forcing a - // circular import into `bar.ts`. - _lineCache: unknown = undefined; - _scatterCache: unknown = undefined; - _areaCache: unknown = undefined; - - // Dual-axis bar charts keep a secondary Y-axis domain + ticks for - // the right-side axis chrome. - _lastAltYDomain: AxisDomain | null = null; - _lastAltYTicks: number[] | null = null; - - _uploadedBars = 0; - _visibleBars: BarRecord[] = []; - - _legendRects: { seriesId: number; rect: PlotRect }[] = []; - - /** - * Per-frame memo of the auto-fit value extent keyed on the visible - * categorical window. Two comparisons per hit → no walk. Reset to - * null on any mutation that would change the outcome (data reload, - * legend toggle). - * - * Two axis slots because dual-axis bar charts refit left and right - * independently. - * - * TODO(perf): when the visible window shrinks from a large N, the - * linear walk over `_bars` dominates for N > ~100K. `_bars` is - * already ordered by `catIdx`, so a binary-search pair to find the - * visible slice drops this to O(log N + K_visible). Deferred until - * profiling shows the walk in the hot path — current scale caps - * keep it below 1% of frame time. - */ - _autoFitCache: { - catMin: number; - catMax: number; - hidden: Set; - leftMin: number; - leftMax: number; - hasLeft: boolean; - rightMin: number; - rightMax: number; - hasRight: boolean; - } | null = null; - - attachTooltip(glCanvas: HTMLCanvasElement): void { - this._glCanvas = glCanvas; - this._tooltip.attach(glCanvas, { - onHover: (mx, my) => handleBarHover(this, mx, my), - onLeave: () => { - if (this._hoveredBarIdx !== -1 || this._hoveredSample) { - this._hoveredBarIdx = -1; - this._hoveredSample = null; - if (this._glManager) renderBarFrame(this, this._glManager); - } - }, - onClickPre: (mx, my) => handleBarLegendClick(this, mx, my), - onPin: () => { - if (this._hoveredBarIdx >= 0) { - showBarPinnedTooltip(this, this._hoveredBarIdx); - } else if (this._hoveredSample) { - showBarPinnedTooltipForSample(this, this._hoveredSample); - } - }, - }); - } - - uploadAndRender( - glManager: WebGLContextManager, - columns: ColumnDataMap, - startRow: number, - endRow: number, - ): void { - this._glManager = glManager; - const gl = glManager.gl; - - if (startRow !== 0) { - // Bar charts render a single consolidated pass — the viewer - // should not chunk this, but guard defensively. - return; - } - - this._cancelScheduledRender(); - - if (!this._program) { - this._program = glManager.shaders.getOrCreate( - "bar", - barVert, - barFrag, - ); - const p = this._program; - this._locations = { - u_proj_left: gl.getUniformLocation(p, "u_proj_left"), - u_proj_right: gl.getUniformLocation(p, "u_proj_right"), - u_hover_series: gl.getUniformLocation(p, "u_hover_series"), - u_horizontal: gl.getUniformLocation(p, "u_horizontal"), - a_corner: gl.getAttribLocation(p, "a_corner"), - a_x_center: gl.getAttribLocation(p, "a_x_center"), - a_half_width: gl.getAttribLocation(p, "a_half_width"), - a_y0: gl.getAttribLocation(p, "a_y0"), - a_y1: gl.getAttribLocation(p, "a_y1"), - a_color: gl.getAttribLocation(p, "a_color"), - a_series_id: gl.getAttribLocation(p, "a_series_id"), - a_axis: gl.getAttribLocation(p, "a_axis"), - }; - - this._cornerBuffer = gl.createBuffer()!; - gl.bindBuffer(gl.ARRAY_BUFFER, this._cornerBuffer); - gl.bufferData( - gl.ARRAY_BUFFER, - new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]), - gl.STATIC_DRAW, - ); - } - - const result = buildBarPipeline({ - columns, - numRows: endRow, - columnSlots: this._columnSlots, - groupBy: this._groupBy, - splitBy: this._splitBy, - columnsConfig: this._columnsConfig, - defaultChartType: this._defaultChartType as - | "bar" - | "line" - | "scatter" - | "area" - | undefined, - }); - this._aggregates = result.aggregates; - this._splitPrefixes = result.splitPrefixes; - this._rowPaths = result.rowPaths; - this._numCategories = result.numCategories; - this._rowOffset = result.rowOffset; - this._series = result.series; - this._bars = result.bars; - this._samples = result.samples; - // New bar records invalidate the auto-fit extent cache — the - // underlying `_bars` content just changed. - this._autoFitCache = null; - this._sampleValid = result.sampleValid; - this._leftDomain = result.leftDomain; - this._rightDomain = result.rightDomain; - this._hasRightAxis = result.hasRightAxis; - - uploadBarInstances(this, glManager); - this._scheduleRender(glManager); - } - - redraw(glManager: WebGLContextManager): void { - if (!this._program) return; - this._glManager = glManager; - this._fullRender(glManager); - } - - protected _fullRender(glManager: WebGLContextManager): void { - renderBarFrame(this, glManager); - } - - protected destroyInternal(): void { - if (this._cornerBuffer && this._glManager) { - this._glManager.gl.deleteBuffer(this._cornerBuffer); - } - this._program = null; - this._locations = null; - this._cornerBuffer = null; - this._bars = []; - this._series = []; - this._rowPaths = []; - this._numCategories = 0; - this._hiddenSeries.clear(); - } -} - -/** Horizontal bar chart — numeric X, categorical Y. */ -export class XBarChart extends BarChart { - constructor() { - super("horizontal"); - } -} diff --git a/packages/viewer-charts/src/ts/charts/bar/glyphs/draw-areas.ts b/packages/viewer-charts/src/ts/charts/bar/glyphs/draw-areas.ts deleted file mode 100644 index c875703c97..0000000000 --- a/packages/viewer-charts/src/ts/charts/bar/glyphs/draw-areas.ts +++ /dev/null @@ -1,180 +0,0 @@ -// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ -// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ -// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ -// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ -// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ -// ┃ Copyright (c) 2017, the Perspective Authors. ┃ -// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ -// ┃ This file is part of the Perspective library, distributed under the terms ┃ -// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ -// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - -import type { WebGLContextManager } from "../../../webgl/context-manager"; -import type { BarChart } from "../bar"; -import type { BarRecord, SeriesInfo } from "../bar-build"; -import areaVert from "../../../shaders/area.vert.glsl"; -import areaFrag from "../../../shaders/area.frag.glsl"; - -type GL = WebGL2RenderingContext | WebGLRenderingContext; - -export interface AreaCache { - program: WebGLProgram; - stripBuffer: WebGLBuffer; - u_projection: WebGLUniformLocation | null; - u_color: WebGLUniformLocation | null; - u_opacity: WebGLUniformLocation | null; - a_position: number; -} - -function ensureAreaCache( - chart: BarChart, - glManager: WebGLContextManager, -): AreaCache { - if (chart._areaCache) return chart._areaCache as AreaCache; - const gl = glManager.gl; - const program = glManager.shaders.getOrCreate( - "bar-area", - areaVert, - areaFrag, - ); - const cache: AreaCache = { - program, - stripBuffer: gl.createBuffer()!, - u_projection: gl.getUniformLocation(program, "u_projection"), - u_color: gl.getUniformLocation(program, "u_color"), - u_opacity: gl.getUniformLocation(program, "u_opacity"), - a_position: gl.getAttribLocation(program, "a_position"), - }; - chart._areaCache = cache; - return cache; -} - -/** - * Draw every area-typed series as a filled ribbon between its y0 baseline - * and y1 value across categories. Stacking is handled by the pipeline: - * stacked series get per-category (y0, y1) from the running stack ladder - * via `BarRecord`; non-stacking series draw from y=0 to the raw sample. - * - * Each contiguous run of valid samples emits one `TRIANGLE_STRIP` draw. - * Gaps (invalid samples) split the ribbon. - */ -export function drawAreas( - chart: BarChart, - gl: GL, - glManager: WebGLContextManager, - projLeft: Float32Array, - projRight: Float32Array, - opacity: number, -): void { - const areaSeries = chart._series.filter( - (s) => s.chartType === "area" && !chart._hiddenSeries.has(s.seriesId), - ); - if (areaSeries.length === 0) return; - - const N = chart._numCategories; - const S = chart._series.length; - if (N === 0 || S === 0) return; - - const cache = ensureAreaCache(chart, glManager); - gl.useProgram(cache.program); - gl.uniform1f(cache.u_opacity, opacity); - - // Pre-index bar records by (seriesId, catIdx) for stacked area lookup. - // Stacked area series contribute one BarRecord per valid (cat, series); - // non-stacking area series have no BarRecord and are synthesised from - // `_samples`. - const barIndex = indexBarsBySeriesCat(chart._bars); - - const samples = chart._samples; - const valid = chart._sampleValid; - - for (const s of areaSeries) { - const runs = collectAreaRuns(s, N, S, samples, valid, barIndex); - if (runs.length === 0) continue; - - gl.uniformMatrix4fv( - cache.u_projection, - false, - s.axis === 1 ? projRight : projLeft, - ); - gl.uniform3f(cache.u_color, s.color[0], s.color[1], s.color[2]); - - for (const strip of runs) { - if (strip.length < 4) continue; // need >=2 cats - gl.bindBuffer(gl.ARRAY_BUFFER, cache.stripBuffer); - gl.bufferData(gl.ARRAY_BUFFER, strip, gl.DYNAMIC_DRAW); - gl.enableVertexAttribArray(cache.a_position); - gl.vertexAttribPointer(cache.a_position, 2, gl.FLOAT, false, 0, 0); - gl.drawArrays(gl.TRIANGLE_STRIP, 0, strip.length / 2); - } - } -} - -/** - * Returns a Map `(seriesId * 1e9 + catIdx) → BarRecord`. 1e9 is a safe key - * separator since category counts never approach it; lets us avoid nested - * maps in the lookup loop. - */ -function indexBarsBySeriesCat(bars: BarRecord[]): Map { - const m = new Map(); - for (const b of bars) { - if (b.chartType !== "area") continue; - m.set(b.seriesId * 1_000_000_000 + b.catIdx, b); - } - return m; -} - -/** - * Collect per-run vertex arrays for one area series. Each run is a - * `[x0,y0_bot, x0,y0_top, x1,y1_bot, x1,y1_top, ...]` strip. - */ -function collectAreaRuns( - s: SeriesInfo, - N: number, - S: number, - samples: Float32Array, - valid: Uint8Array, - barIndex: Map, -): Float32Array[] { - const runs: Float32Array[] = []; - let scratch: number[] = []; - const seriesBase = s.seriesId * 1_000_000_000; - - for (let c = 0; c < N; c++) { - let bot: number; - let top: number; - let present = false; - - if (s.stack) { - const b = barIndex.get(seriesBase + c); - if (b) { - bot = b.y0; - top = b.y1; - present = true; - } else { - bot = 0; - top = 0; - } - } else { - const idx = c * S + s.seriesId; - if ((valid[idx >> 3] >> (idx & 7)) & 1) { - bot = 0; - top = samples[idx]; - present = true; - } else { - bot = 0; - top = 0; - } - } - - if (present) { - scratch.push(c, bot!, c, top!); - } else if (scratch.length > 0) { - runs.push(Float32Array.from(scratch)); - scratch = []; - } - } - if (scratch.length > 0) runs.push(Float32Array.from(scratch)); - return runs; -} diff --git a/packages/viewer-charts/src/ts/charts/bar/glyphs/draw-lines.ts b/packages/viewer-charts/src/ts/charts/bar/glyphs/draw-lines.ts deleted file mode 100644 index d6b62268f4..0000000000 --- a/packages/viewer-charts/src/ts/charts/bar/glyphs/draw-lines.ts +++ /dev/null @@ -1,213 +0,0 @@ -// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ -// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ -// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ -// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ -// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ -// ┃ Copyright (c) 2017, the Perspective Authors. ┃ -// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ -// ┃ This file is part of the Perspective library, distributed under the terms ┃ -// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ -// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - -import type { WebGLContextManager } from "../../../webgl/context-manager"; -import type { BarChart } from "../bar"; -import { getInstancing } from "../../../webgl/instanced-attrs"; -import lineVert from "../../../shaders/line-uniform.vert.glsl"; -import lineFrag from "../../../shaders/line-uniform.frag.glsl"; - -type GL = WebGL2RenderingContext | WebGLRenderingContext; - -const LINE_WIDTH_PX = 2.0; - -export interface LineCache { - program: WebGLProgram; - cornerBuffer: WebGLBuffer; - segmentBuffer: WebGLBuffer; - u_projection: WebGLUniformLocation | null; - u_color: WebGLUniformLocation | null; - u_resolution: WebGLUniformLocation | null; - u_line_width: WebGLUniformLocation | null; - a_start: number; - a_end: number; - a_corner: number; -} - -function ensureLineCache( - chart: BarChart, - glManager: WebGLContextManager, -): LineCache { - if (chart._lineCache) return chart._lineCache as LineCache; - const gl = glManager.gl; - const program = glManager.shaders.getOrCreate( - "bar-line", - lineVert, - lineFrag, - ); - const cornerBuffer = gl.createBuffer()!; - gl.bindBuffer(gl.ARRAY_BUFFER, cornerBuffer); - gl.bufferData( - gl.ARRAY_BUFFER, - new Float32Array([0, 1, 2, 3]), - gl.STATIC_DRAW, - ); - const segmentBuffer = gl.createBuffer()!; - const cache: LineCache = { - program, - cornerBuffer, - segmentBuffer, - u_projection: gl.getUniformLocation(program, "u_projection"), - u_color: gl.getUniformLocation(program, "u_color"), - u_resolution: gl.getUniformLocation(program, "u_resolution"), - u_line_width: gl.getUniformLocation(program, "u_line_width"), - a_start: gl.getAttribLocation(program, "a_start"), - a_end: gl.getAttribLocation(program, "a_end"), - a_corner: gl.getAttribLocation(program, "a_corner"), - }; - chart._lineCache = cache; - return cache; -} - -/** - * Draw every line-typed series as a per-series polyline at (catIdx, value). - * Segments spanning invalid samples are skipped (the polyline gaps rather - * than interpolating across missing values). - * - * One draw call per visible line series; each dispatch is instanced over - * the number of valid segments in that series. - */ -export function drawLines( - chart: BarChart, - gl: GL, - glManager: WebGLContextManager, - projLeft: Float32Array, - projRight: Float32Array, -): void { - const lineSeries = chart._series.filter( - (s) => s.chartType === "line" && !chart._hiddenSeries.has(s.seriesId), - ); - if (lineSeries.length === 0) return; - - const N = chart._numCategories; - const S = chart._series.length; - if (N === 0 || S === 0) return; - - const cache = ensureLineCache(chart, glManager); - const dpr = window.devicePixelRatio || 1; - - gl.useProgram(cache.program); - gl.uniform2f(cache.u_resolution, gl.canvas.width, gl.canvas.height); - gl.uniform1f(cache.u_line_width, LINE_WIDTH_PX * dpr); - - const instancing = getInstancing(glManager); - const { setDivisor, drawArraysInstanced } = instancing; - - // Per-vertex corner buffer (0..3), divisor = 0. - gl.bindBuffer(gl.ARRAY_BUFFER, cache.cornerBuffer); - gl.enableVertexAttribArray(cache.a_corner); - gl.vertexAttribPointer(cache.a_corner, 1, gl.FLOAT, false, 0, 0); - setDivisor(cache.a_corner, 0); - - // Scratch buffer for one series's segment-pair vertices. Grown on demand. - const stride = 2 * Float32Array.BYTES_PER_ELEMENT; - - for (const s of lineSeries) { - // Collect contiguous valid (x, y) points. Invalid cells break the - // polyline: emit the accumulated run, then start a new one. - const runs = collectRuns(chart, s.seriesId, N, S); - if (runs.length === 0) continue; - - // Flatten into one Float32Array containing consecutive points; we - // draw `count - 1` instanced segments per run with byte offsets - // advancing one point at a time. - let total = 0; - for (const r of runs) total += r.length / 2; - if (total < 2) continue; - - const positions = new Float32Array(total * 2); - const runOffsets: { offset: number; count: number }[] = []; - let write = 0; - for (const r of runs) { - const count = r.length / 2; - if (count < 2) { - // Still copy to keep offsets consistent? Better: skip single - // points; a polyline of one point has no segments. - continue; - } - runOffsets.push({ offset: write, count }); - positions.set(r, write * 2); - write += count; - } - if (runOffsets.length === 0) continue; - - gl.bindBuffer(gl.ARRAY_BUFFER, cache.segmentBuffer); - gl.bufferData(gl.ARRAY_BUFFER, positions, gl.DYNAMIC_DRAW); - - gl.uniformMatrix4fv( - cache.u_projection, - false, - s.axis === 1 ? projRight : projLeft, - ); - gl.uniform4f(cache.u_color, s.color[0], s.color[1], s.color[2], 1.0); - - for (const { offset, count } of runOffsets) { - const startBytes = offset * stride; - - gl.enableVertexAttribArray(cache.a_start); - gl.vertexAttribPointer( - cache.a_start, - 2, - gl.FLOAT, - false, - stride, - startBytes, - ); - setDivisor(cache.a_start, 1); - - gl.enableVertexAttribArray(cache.a_end); - gl.vertexAttribPointer( - cache.a_end, - 2, - gl.FLOAT, - false, - stride, - startBytes + stride, - ); - setDivisor(cache.a_end, 1); - - drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, count - 1); - } - } - - setDivisor(cache.a_start, 0); - setDivisor(cache.a_end, 0); -} - -/** - * Collect contiguous valid (catIdx, value) runs for one series. Each run is - * a Float32Array of `[x0,y0,x1,y1,...]` with at least 2 points — callers - * drop 1-point runs. - */ -function collectRuns( - chart: BarChart, - seriesId: number, - N: number, - S: number, -): Float32Array[] { - const samples = chart._samples; - const valid = chart._sampleValid; - const runs: Float32Array[] = []; - let scratch: number[] = []; - for (let c = 0; c < N; c++) { - const idx = c * S + seriesId; - const ok = (valid[idx >> 3] >> (idx & 7)) & 1; - if (ok) { - scratch.push(c, samples[idx]); - } else if (scratch.length > 0) { - runs.push(Float32Array.from(scratch)); - scratch = []; - } - } - if (scratch.length > 0) runs.push(Float32Array.from(scratch)); - return runs; -} diff --git a/packages/viewer-charts/src/ts/charts/bar/glyphs/draw-scatter.ts b/packages/viewer-charts/src/ts/charts/bar/glyphs/draw-scatter.ts deleted file mode 100644 index 6da51dba7e..0000000000 --- a/packages/viewer-charts/src/ts/charts/bar/glyphs/draw-scatter.ts +++ /dev/null @@ -1,152 +0,0 @@ -// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ -// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ -// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ -// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ -// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ -// ┃ Copyright (c) 2017, the Perspective Authors. ┃ -// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ -// ┃ This file is part of the Perspective library, distributed under the terms ┃ -// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ -// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - -import type { WebGLContextManager } from "../../../webgl/context-manager"; -import type { BarChart } from "../bar"; -import scatterVert from "../../../shaders/y-scatter.vert.glsl"; -import scatterFrag from "../../../shaders/y-scatter.frag.glsl"; - -type GL = WebGL2RenderingContext | WebGLRenderingContext; - -const POINT_SIZE_PX = 8.0; - -export interface ScatterCache { - program: WebGLProgram; - posLeftBuffer: WebGLBuffer; - posRightBuffer: WebGLBuffer; - colorLeftBuffer: WebGLBuffer; - colorRightBuffer: WebGLBuffer; - u_projection: WebGLUniformLocation | null; - u_point_size: WebGLUniformLocation | null; - a_position: number; - a_color: number; -} - -function ensureScatterCache( - chart: BarChart, - glManager: WebGLContextManager, -): ScatterCache { - if (chart._scatterCache) return chart._scatterCache as ScatterCache; - const gl = glManager.gl; - const program = glManager.shaders.getOrCreate( - "bar-scatter", - scatterVert, - scatterFrag, - ); - const cache: ScatterCache = { - program, - posLeftBuffer: gl.createBuffer()!, - posRightBuffer: gl.createBuffer()!, - colorLeftBuffer: gl.createBuffer()!, - colorRightBuffer: gl.createBuffer()!, - u_projection: gl.getUniformLocation(program, "u_projection"), - u_point_size: gl.getUniformLocation(program, "u_point_size"), - a_position: gl.getAttribLocation(program, "a_position"), - a_color: gl.getAttribLocation(program, "a_color"), - }; - chart._scatterCache = cache; - return cache; -} - -/** - * Draw every scatter-typed series as per-(series, category) points colored - * from the series palette. Collapses both axes into 2 draw calls (one per - * projection); each vertex carries its own RGB, so series don't need - * separate draws to change color. - */ -export function drawScatter( - chart: BarChart, - gl: GL, - glManager: WebGLContextManager, - projLeft: Float32Array, - projRight: Float32Array, -): void { - const scatterSeries = chart._series.filter( - (s) => - s.chartType === "scatter" && !chart._hiddenSeries.has(s.seriesId), - ); - if (scatterSeries.length === 0) return; - - const N = chart._numCategories; - const S = chart._series.length; - if (N === 0 || S === 0) return; - - const dpr = window.devicePixelRatio || 1; - const cache = ensureScatterCache(chart, glManager); - - // Partition by axis so each draw uses one projection uniform. - const leftPos: number[] = []; - const leftCol: number[] = []; - const rightPos: number[] = []; - const rightCol: number[] = []; - const samples = chart._samples; - const valid = chart._sampleValid; - for (const s of scatterSeries) { - const dst = s.axis === 1 ? rightPos : leftPos; - const col = s.axis === 1 ? rightCol : leftCol; - for (let c = 0; c < N; c++) { - const idx = c * S + s.seriesId; - if (!((valid[idx >> 3] >> (idx & 7)) & 1)) continue; - dst.push(c, samples[idx]); - col.push(s.color[0], s.color[1], s.color[2]); - } - } - - gl.useProgram(cache.program); - gl.uniform1f(cache.u_point_size, POINT_SIZE_PX * dpr); - - drawBucket( - gl, - cache, - cache.posLeftBuffer, - cache.colorLeftBuffer, - leftPos, - leftCol, - projLeft, - ); - drawBucket( - gl, - cache, - cache.posRightBuffer, - cache.colorRightBuffer, - rightPos, - rightCol, - projRight, - ); -} - -function drawBucket( - gl: GL, - cache: ScatterCache, - posBuf: WebGLBuffer, - colBuf: WebGLBuffer, - pos: number[], - col: number[], - proj: Float32Array, -): void { - const count = pos.length / 2; - if (count === 0) return; - - gl.uniformMatrix4fv(cache.u_projection, false, proj); - - gl.bindBuffer(gl.ARRAY_BUFFER, posBuf); - gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(pos), gl.DYNAMIC_DRAW); - gl.enableVertexAttribArray(cache.a_position); - gl.vertexAttribPointer(cache.a_position, 2, gl.FLOAT, false, 0, 0); - - gl.bindBuffer(gl.ARRAY_BUFFER, colBuf); - gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(col), gl.DYNAMIC_DRAW); - gl.enableVertexAttribArray(cache.a_color); - gl.vertexAttribPointer(cache.a_color, 3, gl.FLOAT, false, 0, 0); - - gl.drawArrays(gl.POINTS, 0, count); -} diff --git a/packages/viewer-charts/src/ts/charts/candlestick/candlestick-build.ts b/packages/viewer-charts/src/ts/charts/candlestick/candlestick-build.ts index 41447a89b4..0e856e2c4c 100644 --- a/packages/viewer-charts/src/ts/charts/candlestick/candlestick-build.ts +++ b/packages/viewer-charts/src/ts/charts/candlestick/candlestick-build.ts @@ -12,9 +12,15 @@ import type { ColumnDataMap } from "../../data/view-reader"; import { buildSplitGroups } from "../../data/split-groups"; -import type { CategoricalLevel } from "../../chrome/categorical-axis"; -import { resolveCategoryAxis } from "../common/category-axis"; -import { computeSlotGeometry, slotCenter } from "../common/band-layout"; +import type { CategoricalLevel } from "../../axis/categorical-axis"; +import { + resolveAxisMode, + resolveCategoryAxis, + resolveNumericCategoryDomain, + type AxisMode, + type NumericCategoryDomain, +} from "../common/category-axis-resolver"; +import { computeSlotGeometry } from "../common/band-layout"; export interface CandleSeriesInfo { seriesId: number; @@ -23,6 +29,12 @@ export interface CandleSeriesInfo { label: string; } +/** + * Logical candle record. Synthesized on demand from {@link CandleColumns} + * via {@link readCandleRecord} for tooltip / hover paths. The pipeline + * never materializes these — see `CandleColumns` for the columnar + * storage that replaces the legacy `CandleRecord[]`. + */ export interface CandleRecord { catIdx: number; splitIdx: number; @@ -36,38 +48,170 @@ export interface CandleRecord { isUp: boolean; } +/** + * Columnar storage for the candle record set. Replaces the legacy + * `CandleRecord[]` to avoid per-record POJO allocation at scale. + * + * Records are appended in `(splitIdx, catIdx)` order as the pipeline + * loop is structured (outer split, inner category) — both `xCenter` and + * `catIdx` are monotonically non-decreasing within a split, which the + * hit-test uses for binary-search narrowing. + * + * `count` is the active record count; the underlying typed arrays may + * be over-allocated for capacity reuse across builds. + */ +export interface CandleColumns { + count: number; + catIdx: Int32Array; + splitIdx: Int32Array; + seriesId: Int32Array; + xCenter: Float64Array; + halfWidth: Float64Array; + open: Float64Array; + close: Float64Array; + high: Float64Array; + low: Float64Array; + + /** + * 1 = up (close ≥ open), 0 = down. + */ + isUp: Uint8Array; +} + +export function emptyCandleColumns(): CandleColumns { + return { + count: 0, + catIdx: new Int32Array(0), + splitIdx: new Int32Array(0), + seriesId: new Int32Array(0), + xCenter: new Float64Array(0), + halfWidth: new Float64Array(0), + open: new Float64Array(0), + close: new Float64Array(0), + high: new Float64Array(0), + low: new Float64Array(0), + isUp: new Uint8Array(0), + }; +} + +/** + * Reuse `prev`'s typed arrays when capacity is sufficient, else allocate + * fresh. Resets `count` to 0; pipeline writes from index 0. + */ +export function ensureCandleColumnsCapacity( + prev: CandleColumns | null, + capacity: number, +): CandleColumns { + if (prev && prev.catIdx.length >= capacity) { + prev.count = 0; + return prev; + } + + return { + count: 0, + catIdx: new Int32Array(capacity), + splitIdx: new Int32Array(capacity), + seriesId: new Int32Array(capacity), + xCenter: new Float64Array(capacity), + halfWidth: new Float64Array(capacity), + open: new Float64Array(capacity), + close: new Float64Array(capacity), + high: new Float64Array(capacity), + low: new Float64Array(capacity), + isUp: new Uint8Array(capacity), + }; +} + +/** + * Synthesize a {@link CandleRecord} POJO for record `i`. Used by + * tooltip / pinned tooltip / hover return paths; not called in any + * frame-rate hot loop. + */ +export function readCandleRecord(cols: CandleColumns, i: number): CandleRecord { + return { + catIdx: cols.catIdx[i], + splitIdx: cols.splitIdx[i], + seriesId: cols.seriesId[i], + xCenter: cols.xCenter[i], + halfWidth: cols.halfWidth[i], + open: cols.open[i], + close: cols.close[i], + high: cols.high[i], + low: cols.low[i], + isUp: cols.isUp[i] !== 0, + }; +} + export interface CandlestickPipelineInput { columns: ColumnDataMap; numRows: number; columnSlots: (string | null)[]; groupBy: string[]; splitBy: string[]; + + /** + * Source-column types for `group_by` columns. Same shape as the bar + * pipeline — used to stringify non-string row-paths and to enable + * numeric-axis mode for a single non-string non-boolean group_by. + */ + groupByTypes: Record; + + /** + * Band-slot geometry knobs sourced from + * {@link PluginConfig.band_inner_frac} / `bar_inner_pad`. Forwarded + * to `computeSlotGeometry`. Replace the `BAND_INNER_FRAC` / + * `BAR_INNER_PAD` constants. + */ + bandInnerFrac: number; + barInnerPad: number; + + /** + * Reusable scratch — pipeline writes records into the typed arrays + * in place. Pass the previous build's columns to amortize + * allocation across data reloads. + */ + scratchCandles?: CandleColumns | null; } +export type { NumericCategoryDomain }; + export interface CandlestickPipelineResult { splitPrefixes: string[]; rowPaths: CategoricalLevel[]; numCategories: number; rowOffset: number; + + /** + * Axis mode discriminator (see bar-build for semantics). + */ + axisMode: AxisMode; + numericCategoryDomain: NumericCategoryDomain | null; + + /** + * Per-category X position (real data units) in numeric mode. + */ + categoryPositions: Float64Array | null; series: CandleSeriesInfo[]; - candles: CandleRecord[]; + candles: CandleColumns; yDomain: { min: number; max: number }; } -const EMPTY: CandlestickPipelineResult = { +const EMPTY_RESULT: Omit = { splitPrefixes: [], rowPaths: [], numCategories: 0, rowOffset: 0, + axisMode: { mode: "category" }, + numericCategoryDomain: null, + categoryPositions: null, series: [], - candles: [], yDomain: { min: 0, max: 1 }, }; /** - * Pure pipeline: turn a raw `ColumnDataMap` into `CandleRecord[]`. - * - * Column slots (Open / Close / High / Low) mirror d3fc's convention: + * Pure pipeline: turn a raw `ColumnDataMap` into a columnar + * {@link CandleColumns}. Column slots (Open / Close / High / Low) mirror + * d3fc's convention: * - `Open` is required. * - `Close` falls back to the next row's Open (last row: own Open). * - `High` falls back to `max(open, close)`. @@ -79,10 +223,24 @@ const EMPTY: CandlestickPipelineResult = { export function buildCandlestickPipeline( input: CandlestickPipelineInput, ): CandlestickPipelineResult { - const { columns, numRows, columnSlots, groupBy, splitBy } = input; + const { + columns, + numRows, + columnSlots, + groupBy, + splitBy, + groupByTypes, + bandInnerFrac, + barInnerPad, + scratchCandles, + } = input; + const axisMode = resolveAxisMode(groupBy, groupByTypes); const openBase = columnSlots[0] || ""; - if (!openBase) return EMPTY; + if (!openBase) { + return { ...EMPTY_RESULT, candles: emptyCandleColumns() }; + } + const closeBase = columnSlots[1] || ""; const highBase = columnSlots[2] || ""; const lowBase = columnSlots[3] || ""; @@ -92,28 +250,46 @@ export function buildCandlestickPipeline( const splitPrefixes: string[] = []; if (splitBy.length > 0) { const aggregates = [openBase]; - if (closeBase) aggregates.push(closeBase); - if (highBase) aggregates.push(highBase); - if (lowBase) aggregates.push(lowBase); + if (closeBase) { + aggregates.push(closeBase); + } + + if (highBase) { + aggregates.push(highBase); + } + + if (lowBase) { + aggregates.push(lowBase); + } + for (const g of buildSplitGroups(columns, [openBase], aggregates)) { - if (g.colNames.has(openBase)) splitPrefixes.push(g.prefix); + if (g.colNames.has(openBase)) { + splitPrefixes.push(g.prefix); + } + } + + if (splitPrefixes.length === 0) { + splitPrefixes.push(""); } - if (splitPrefixes.length === 0) splitPrefixes.push(""); } else { splitPrefixes.push(""); } + const levelTypes = groupBy.map((name) => groupByTypes[name] ?? "string"); const { rowPaths, numCategories, rowOffset } = resolveCategoryAxis( columns, numRows, groupBy.length, + levelTypes, ); if (numCategories === 0) { return { - ...EMPTY, + ...EMPTY_RESULT, + axisMode, splitPrefixes, rowPaths, rowOffset, + candles: emptyCandleColumns(), }; } @@ -129,20 +305,44 @@ export function buildCandlestickPipeline( }); } - const { slotWidth, halfWidth } = computeSlotGeometry(P); + // Numeric-mode category positions — read from `__ROW_PATH_0__` so + // candles anchor at real data values (e.g. ms-since-epoch) instead + // of logical category indices. + let categoryPositions: Float64Array | null = null; + let numericCategoryDomain: NumericCategoryDomain | null = null; + let numericBandWidth = 1; + if (axisMode.mode === "numeric" && numCategories > 0) { + const rp = columns.get("__ROW_PATH_0__"); + const resolved = resolveNumericCategoryDomain( + rp?.values, + numCategories, + rowOffset, + groupBy[0] ?? "", + axisMode.numericType === "date" || + axisMode.numericType === "datetime", + ); + if (resolved) { + categoryPositions = resolved.categoryPositions; + numericCategoryDomain = resolved.numericCategoryDomain; + numericBandWidth = resolved.numericCategoryDomain.bandWidth; + } + } + + const baseSlot = computeSlotGeometry(P, bandInnerFrac, barInnerPad); + const slotWidth = baseSlot.slotWidth * numericBandWidth; + const halfWidth = baseSlot.halfWidth * numericBandWidth; + const halfP = (P - 1) / 2; - // Per-series column references (resolved once per frame, not per - // row). For each split, the candle value reads come out of typed - // arrays with no repeated map lookups. + // Per-series column references (resolved once, not per row). const seriesCols: { - openCol: Float32Array | Int32Array; - closeCol: Float32Array | Int32Array | null; - highCol: Float32Array | Int32Array | null; - lowCol: Float32Array | Int32Array | null; - openValid: Uint8Array | undefined; - closeValid: Uint8Array | undefined; - highValid: Uint8Array | undefined; - lowValid: Uint8Array | undefined; + openCol: ArrayLike | null; + closeCol: ArrayLike | null; + highCol: ArrayLike | null; + lowCol: ArrayLike | null; + openValid: Uint8Array | null; + closeValid: Uint8Array | null; + highValid: Uint8Array | null; + lowValid: Uint8Array | null; }[] = []; for (let p = 0; p < P; p++) { const prefix = splitPrefixes[p]; @@ -150,19 +350,19 @@ export function buildCandlestickPipeline( prefix === "" ? base : `${prefix}|${base}`; const openCol = columns.get(nm(openBase)); if (!openCol?.values) { - // Skip this split if Open is unresolvable. seriesCols.push({ - openCol: new Float32Array(0), + openCol: null, closeCol: null, highCol: null, lowCol: null, - openValid: undefined, - closeValid: undefined, - highValid: undefined, - lowValid: undefined, + openValid: null, + closeValid: null, + highValid: null, + lowValid: null, }); continue; } + const closeCol = closeBase ? columns.get(nm(closeBase)) : null; const highCol = highBase ? columns.get(nm(highBase)) : null; const lowCol = lowBase ? columns.get(nm(lowBase)) : null; @@ -171,45 +371,70 @@ export function buildCandlestickPipeline( closeCol: closeCol?.values ?? null, highCol: highCol?.values ?? null, lowCol: lowCol?.values ?? null, - openValid: openCol.valid, - closeValid: closeCol?.valid, - highValid: highCol?.valid, - lowValid: lowCol?.valid, + openValid: openCol.valid ?? null, + closeValid: closeCol?.valid ?? null, + highValid: highCol?.valid ?? null, + lowValid: lowCol?.valid ?? null, }); } - const candles: CandleRecord[] = []; + // Pre-allocate columnar candle storage at N*P upper bound. The + // pipeline emits at most one record per (split, cat) cell. + const cap = numCategories * P; + const candles = ensureCandleColumnsCapacity(scratchCandles ?? null, cap); + let write = 0; + let yMin = Infinity; let yMax = -Infinity; const N = numCategories; - const isValid = (valid: Uint8Array | undefined, row: number): boolean => { - if (!valid) return true; - return !!((valid[row >> 3] >> (row & 7)) & 1); - }; - for (let p = 0; p < P; p++) { const sc = seriesCols[p]; - if (sc.openCol.length === 0) continue; + const openCol = sc.openCol; + if (!openCol) { + continue; + } + + const closeCol = sc.closeCol; + const highCol = sc.highCol; + const lowCol = sc.lowCol; + const openValid = sc.openValid; + const closeValid = sc.closeValid; + const highValid = sc.highValid; + const lowValid = sc.lowValid; + const slotOffset = (p - halfP) * slotWidth; for (let catI = 0; catI < N; catI++) { const row = catI + rowOffset; - if (!isValid(sc.openValid, row)) continue; - const open = sc.openCol[row] as number; - if (!isFinite(open)) continue; - // d3fc's getNextOpen fallback: use the next row's open as - // close; on the last row, fall back to own open (yielding a - // degenerate zero-height candle, as in d3fc). + // Inlined valid-bit test (was a closure in the legacy build). + if (openValid && !((openValid[row >> 3] >> (row & 7)) & 1)) { + continue; + } + + const open = openCol[row] as number; + if (!isFinite(open)) { + continue; + } + + // Close fallback: explicit close column → next row's open → + // own open (degenerate). Each branch inlines the validity + // check to avoid a per-row closure. let close: number; - if (sc.closeCol && isValid(sc.closeValid, row)) { - const v = sc.closeCol[row] as number; + if ( + closeCol && + (!closeValid || ((closeValid[row >> 3] >> (row & 7)) & 1) !== 0) + ) { + const v = closeCol[row] as number; close = isFinite(v) ? v : open; } else { const nextRow = catI < N - 1 ? catI + 1 + rowOffset : row; - if (isValid(sc.openValid, nextRow)) { - const v = sc.openCol[nextRow] as number; + if ( + !openValid || + ((openValid[nextRow >> 3] >> (nextRow & 7)) & 1) !== 0 + ) { + const v = openCol[nextRow] as number; close = isFinite(v) ? v : open; } else { close = open; @@ -217,41 +442,55 @@ export function buildCandlestickPipeline( } let high: number; - if (sc.highCol && isValid(sc.highValid, row)) { - const v = sc.highCol[row] as number; - high = isFinite(v) ? v : Math.max(open, close); + if ( + highCol && + (!highValid || ((highValid[row >> 3] >> (row & 7)) & 1) !== 0) + ) { + const v = highCol[row] as number; + high = isFinite(v) ? v : open > close ? open : close; } else { - high = Math.max(open, close); + high = open > close ? open : close; } let low: number; - if (sc.lowCol && isValid(sc.lowValid, row)) { - const v = sc.lowCol[row] as number; - low = isFinite(v) ? v : Math.min(open, close); + if ( + lowCol && + (!lowValid || ((lowValid[row >> 3] >> (row & 7)) & 1) !== 0) + ) { + const v = lowCol[row] as number; + low = isFinite(v) ? v : open < close ? open : close; } else { - low = Math.min(open, close); + low = open < close ? open : close; } - const xCenter = slotCenter(catI, p, P, slotWidth); - const isUp = close >= open; - candles.push({ - catIdx: catI, - splitIdx: p, - seriesId: p, - xCenter, - halfWidth, - open, - close, - high, - low, - isUp, - }); + const center = categoryPositions ? categoryPositions[catI] : catI; + const xCenter = center + slotOffset; + const isUp = close >= open ? 1 : 0; + + candles.catIdx[write] = catI; + candles.splitIdx[write] = p; + candles.seriesId[write] = p; + candles.xCenter[write] = xCenter; + candles.halfWidth[write] = halfWidth; + candles.open[write] = open; + candles.close[write] = close; + candles.high[write] = high; + candles.low[write] = low; + candles.isUp[write] = isUp; + write++; - if (low < yMin) yMin = low; - if (high > yMax) yMax = high; + if (low < yMin) { + yMin = low; + } + + if (high > yMax) { + yMax = high; + } } } + candles.count = write; + if (!isFinite(yMin) || !isFinite(yMax)) { yMin = 0; yMax = 1; @@ -267,6 +506,9 @@ export function buildCandlestickPipeline( rowPaths, numCategories, rowOffset, + axisMode, + numericCategoryDomain, + categoryPositions, series, candles, yDomain: { min: yMin, max: yMax }, diff --git a/packages/viewer-charts/src/ts/charts/candlestick/candlestick-interact.ts b/packages/viewer-charts/src/ts/charts/candlestick/candlestick-interact.ts index 8e259035f9..818475a357 100644 --- a/packages/viewer-charts/src/ts/charts/candlestick/candlestick-interact.ts +++ b/packages/viewer-charts/src/ts/charts/candlestick/candlestick-interact.ts @@ -11,45 +11,42 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import type { CandlestickChart } from "./candlestick"; -import type { CandleRecord } from "./candlestick-build"; -import { formatTickValue } from "../../layout/ticks"; -import { - renderCandlestickChromeOverlay, - renderCandlestickFrame, -} from "./candlestick-render"; +import { renderCandlestickChromeOverlay } from "./candlestick-render"; /** - * Test whether `(mx, my)` falls inside candle `i`'s body or wick - * bounding rect. Wicks use a small x-tolerance in pixels so narrow + * Pixels of horizontal slack around the wick centerline so narrow * bodies with tall wicks stay clickable. */ -function hitTestCandle( - chart: CandlestickChart, - i: number, - mx: number, - my: number, -): boolean { - const layout = chart._lastLayout; - if (!layout) return false; - const c = chart._candles[i]; - if (!c) return false; - - const x = layout.dataToPixel(c.xCenter, 0).px; - const xHalf = layout.plotRect.width * (c.halfWidth / chart._numCategories); - if (mx < x - xHalf || mx > x + xHalf) { - // Allow a few pixels of horizontal slack for the wick line. - if (Math.abs(mx - x) > 3) return false; - } +const WICK_TOLERANCE_PX = 3; - const bodyTop = layout.dataToPixel(0, Math.max(c.open, c.close)).py; - const bodyBot = layout.dataToPixel(0, Math.min(c.open, c.close)).py; - const wickTop = layout.dataToPixel(0, c.high).py; - const wickBot = layout.dataToPixel(0, c.low).py; +/** + * Find the leftmost candle index whose `xCenter` is `>= target`. Bars + * are appended in (split, cat) order; within a split `xCenter` is + * monotonically increasing, but across splits it interleaves at the + * same catIdx. The hit-test still only needs the first candidate at or + * after `target` — subsequent split records share the same catIdx and + * are visited until xCenter exceeds `target + halfWidth`, so a plain + * binary search on `xCenter` ordered as written suffices when + * splits=1; for multi-split we fall back to a linear scan from the + * lower bound found. + */ +function lowerBoundXCenter( + xC: Float64Array, + count: number, + target: number, +): number { + let lo = 0; + let hi = count; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (xC[mid] < target) { + lo = mid + 1; + } else { + hi = mid; + } + } - // Body rect + wick line region. - const insideBody = my >= bodyTop && my <= bodyBot; - const insideWick = my >= wickTop && my <= wickBot; - return insideBody || insideWick; + return lo; } export function handleCandlestickHover( @@ -57,13 +54,92 @@ export function handleCandlestickHover( mx: number, my: number, ): void { - if (chart._pinnedIdx !== -1) return; + if (chart._pinnedIdx !== -1) { + return; + } + + const layout = chart._lastLayout; + if (!layout) { + return; + } + + const candles = chart._candles; + if (candles.count === 0) { + if (chart._hoveredIdx !== -1) { + chart._hoveredIdx = -1; + renderCandlestickChromeOverlay(chart); + } + + return; + } + + // Convert mouse → data once; from then on hit-tests are in data + // space, eliminating ~5 `dataToPixel` calls per candidate that the + // legacy implementation performed. + const plot = layout.plotRect; + const padXMin = layout.paddedXMin; + const padXMax = layout.paddedXMax; + const padYMin = layout.paddedYMin; + const padYMax = layout.paddedYMax; + if ( + mx < plot.x || + mx > plot.x + plot.width || + my < plot.y || + my > plot.y + plot.height + ) { + if (chart._hoveredIdx !== -1) { + chart._hoveredIdx = -1; + renderCandlestickChromeOverlay(chart); + } - // Scan in reverse so the most-recently-added (frontmost) candle wins - // ties. At < 10k visible candles this linear scan is free. + return; + } + + const dataX = padXMin + ((mx - plot.x) / plot.width) * (padXMax - padXMin); + const dataY = padYMax - ((my - plot.y) / plot.height) * (padYMax - padYMin); + const pxPerDataX = plot.width / (padXMax - padXMin); + const wickToleranceData = WICK_TOLERANCE_PX / pxPerDataX; + + const xC = candles.xCenter; + const hw = candles.halfWidth; + const open = candles.open; + const close = candles.close; + const high = candles.high; + const low = candles.low; + + // Estimate a generous halfWidth bound so the binary-search visible + // slice covers any candle whose body could overlap `dataX`. The + // halfWidth is uniform per build; conservatively read from the + // first record (or fall back to a small constant). + const maxHalfWidth = candles.count > 0 ? hw[0] : 0; + const tol = Math.max(maxHalfWidth, wickToleranceData); + + // Binary-search to a small slice [lo, hi) covering candidates whose + // xCenter falls within ±tol of dataX. Candles outside this window + // can't possibly be hit; the linear scan that follows is bounded by + // (split count × overlap), not the full candle count. + const lo = lowerBoundXCenter(xC, candles.count, dataX - tol); + const hi = lowerBoundXCenter(xC, candles.count, dataX + tol + 1e-12); + + // Walk the slice in reverse so the most-recently-added (frontmost) + // candle wins ties — matches legacy behavior. let hit = -1; - for (let i = chart._candles.length - 1; i >= 0; i--) { - if (hitTestCandle(chart, i, mx, my)) { + for (let i = hi - 1; i >= lo; i--) { + const xc = xC[i]; + const halfW = hw[i]; + const xWithinBody = dataX >= xc - halfW && dataX <= xc + halfW; + const xWithinWick = Math.abs(dataX - xc) <= wickToleranceData; + if (!xWithinBody && !xWithinWick) { + continue; + } + + const o = open[i]; + const c = close[i]; + const bodyLow = o < c ? o : c; + const bodyHigh = o < c ? c : o; + const insideBody = xWithinBody && dataY >= bodyLow && dataY <= bodyHigh; + const insideWick = dataY >= low[i] && dataY <= high[i]; + if (insideBody || insideWick) { hit = i; break; } @@ -79,64 +155,102 @@ export function showCandlestickPinnedTooltip( chart: CandlestickChart, idx: number, ): void { - chart._tooltip.dismissPinned(); + chart._tooltip.dismiss(); chart._pinnedIdx = idx; - const candle = chart._candles[idx]; - if (!candle || !chart._lastLayout) return; + const candles = chart._candles; + if (idx < 0 || idx >= candles.count || !chart._lastLayout) { + return; + } - const lines = buildCandlestickTooltipLines(chart, candle); - if (lines.length === 0) return; + const lines = buildCandlestickTooltipLines(chart, idx); + if (lines.length === 0) { + return; + } - const parent = chart._glCanvas?.parentElement; - if (!parent) return; + const xCenter = candles.xCenter[idx]; + const yMid = (candles.high[idx] + candles.low[idx]) / 2; + const pos = chart._lastLayout.dataToPixel(xCenter, yMid); - const pos = chart._lastLayout.dataToPixel( - candle.xCenter, - (candle.high + candle.low) / 2, - ); - const dpr = window.devicePixelRatio || 1; - const cssWidth = (chart._glCanvas?.width || 100) / dpr; - const cssHeight = (chart._glCanvas?.height || 100) / dpr; + // CSS bounds come from the chart's own layout, which is populated + // by the render path regardless of where the chart runs. + const cssWidth = chart._lastLayout.cssWidth; + const cssHeight = chart._lastLayout.cssHeight; - chart._tooltip.showPinned(parent, lines, pos, { cssWidth, cssHeight }); + chart._tooltip.pin(lines, pos, { cssWidth, cssHeight }); + // Pinning hides the inline hover tooltip but does not change the + // WebGL pass — only the chrome overlay needs to redraw. chart._hoveredIdx = -1; - if (chart._glManager) renderCandlestickFrame(chart, chart._glManager); + renderCandlestickChromeOverlay(chart); } export function dismissCandlestickPinnedTooltip(chart: CandlestickChart): void { - chart._tooltip.dismissPinned(); + chart._tooltip.dismiss(); chart._pinnedIdx = -1; } +/** + * Build tooltip lines for candle at index `idx` in the columnar + * storage. Indexed access avoids materializing a `CandleRecord` POJO + * on the hot tooltip path. + */ export function buildCandlestickTooltipLines( chart: CandlestickChart, - candle: CandleRecord, + idx: number, ): string[] { const lines: string[] = []; + const candles = chart._candles; + if (idx < 0 || idx >= candles.count) { + return lines; + } - // Category label from the row-path dictionaries. - if (chart._rowPaths.length > 0) { + const catIdx = candles.catIdx[idx]; + const splitIdx = candles.splitIdx[idx]; + const open = candles.open[idx]; + const close = candles.close[idx]; + const high = candles.high[idx]; + const low = candles.low[idx]; + + if ( + chart._categoryAxisMode === "numeric" && + chart._numericCategoryDomain && + chart._categoryPositions + ) { + const v = chart._categoryPositions[catIdx]; + const xColumn = chart._groupBy[0]; + lines.push(chart.getColumnFormatter(xColumn, "value")(v)); + } else if (chart._rowPaths.length > 0) { const parts: string[] = []; for (const rp of chart._rowPaths) { - const s = rp.labels[candle.catIdx] ?? ""; - if (s) parts.push(s); + const s = rp.labels[catIdx] ?? ""; + if (s) { + parts.push(s); + } + } + + if (parts.length > 0) { + lines.push(parts.join(" › ")); } - if (parts.length > 0) lines.push(parts.join(" › ")); } else { - lines.push(`Row ${candle.catIdx + chart._rowOffset}`); + lines.push(`Row ${catIdx + chart._rowOffset}`); } - if (candle.splitIdx >= 0 && chart._splitPrefixes.length > 1) { - const prefix = chart._splitPrefixes[candle.splitIdx]; - if (prefix) lines.push(prefix); + if (splitIdx >= 0 && chart._splitPrefixes.length > 1) { + const prefix = chart._splitPrefixes[splitIdx]; + if (prefix) { + lines.push(prefix); + } } - lines.push(`Open: ${formatTickValue(candle.open)}`); - lines.push(`Close: ${formatTickValue(candle.close)}`); - lines.push(`High: ${formatTickValue(candle.high)}`); - lines.push(`Low: ${formatTickValue(candle.low)}`); + const openFmt = chart.getColumnFormatter(chart._columnSlots[0], "value"); + const closeFmt = chart.getColumnFormatter(chart._columnSlots[1], "value"); + const highFmt = chart.getColumnFormatter(chart._columnSlots[2], "value"); + const lowFmt = chart.getColumnFormatter(chart._columnSlots[3], "value"); + lines.push(`Open: ${openFmt(open)}`); + lines.push(`Close: ${closeFmt(close)}`); + lines.push(`High: ${highFmt(high)}`); + lines.push(`Low: ${lowFmt(low)}`); return lines; } diff --git a/packages/viewer-charts/src/ts/charts/candlestick/candlestick-render.ts b/packages/viewer-charts/src/ts/charts/candlestick/candlestick-render.ts index f3604d9b66..64370db125 100644 --- a/packages/viewer-charts/src/ts/charts/candlestick/candlestick-render.ts +++ b/packages/viewer-charts/src/ts/charts/candlestick/candlestick-render.ts @@ -11,51 +11,102 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import type { WebGLContextManager } from "../../webgl/context-manager"; -import type { CandlestickChart } from "./candlestick"; +import type { CandlestickChart, CandlestickAutoFitCache } from "./candlestick"; import { PlotLayout } from "../../layout/plot-layout"; -import { resolveTheme } from "../../theme/theme"; import { sampleGradient } from "../../theme/gradient"; import { renderInPlotFrame } from "../../webgl/plot-frame"; import { renderCanvasTooltip } from "../../interaction/tooltip-controller"; import { computeNiceTicks } from "../../layout/ticks"; -import { type AxisDomain } from "../../chrome/numeric-axis"; -import { renderBarAxesChrome, renderBarGridlines } from "../../chrome/bar-axis"; +import { type AxisDomain } from "../../axis/numeric-axis"; +import { + renderBarAxesChrome, + renderBarGridlines, + type BarCategoryAxis, +} from "../../axis/bar-axis"; import { measureCategoricalAxisHeight, type CategoricalDomain, -} from "../../chrome/categorical-axis"; -import { drawCandlesticks } from "./glyphs/draw-candlesticks"; -import { drawOHLC } from "./glyphs/draw-ohlc"; +} from "../../axis/categorical-axis"; import { buildCandlestickTooltipLines } from "./candlestick-interact"; import { computeVisibleExtent, type VisibleExtent, } from "../common/visible-extent"; +/** + * Resolve up/down body colors from `theme.gradientStops`. Cached on the + * chart via `_upDownColorKey` (reference identity of the stops array) + * — only `restyle()` (which clears the theme cache via + * `invalidateTheme`) or a data load with a fresh theme triggers + * resampling. Legacy code re-sampled every frame. + */ +export function ensureUpDownColors(chart: CandlestickChart): void { + const theme = chart._resolveTheme(); + const stops = theme.gradientStops; + if (chart._upDownColorKey === stops) { + return; + } + + const upSample = sampleGradient(stops, 1.0); + const downSample = sampleGradient(stops, 0.0); + chart._upColor = [upSample[0], upSample[1], upSample[2]]; + chart._downColor = [downSample[0], downSample[1], downSample[2]]; + chart._upDownColorKey = stops; +} + +/** + * Drop persistent body / wick / OHLC vertex buffers. Subsequent draws + * no-op until the next {@link rebuildGlyphBuffers} call. + */ +export function invalidateGlyphBuffers(chart: CandlestickChart): void { + chart._glyphs.bodyWick.invalidateBuffers(chart); + chart._glyphs.ohlc.invalidateBuffers(chart); +} + +/** + * Rebuild the persistent body / wick / OHLC vertex buffers. Reads + * `_candles` (columnar) plus the cached `_upColor` / `_downColor` to + * populate the GPU buffers exactly once per data load. Subsequent pan/ + * zoom redraws bind + dispatch with no uploads. + */ +export function rebuildGlyphBuffers( + chart: CandlestickChart, + glManager: WebGLContextManager, +): void { + chart._glyphs.bodyWick.rebuildBuffers(chart, glManager); + chart._glyphs.ohlc.rebuildBuffers(chart, glManager); +} + export function renderCandlestickFrame( chart: CandlestickChart, glManager: WebGLContextManager, ): void { const gl = glManager.gl; - const dpr = window.devicePixelRatio || 1; + const dpr = glManager.dpr; const cssWidth = gl.canvas.width / dpr; const cssHeight = gl.canvas.height / dpr; - if (cssWidth <= 0 || cssHeight <= 0) return; - if (chart._numCategories === 0) return; + if (cssWidth <= 0 || cssHeight <= 0) { + return; + } + + if (chart._numCategories === 0) { + return; + } - const themeEl = (chart._gridlineCanvas!.getRootNode() as ShadowRoot).host; - const theme = resolveTheme(themeEl); + const theme = chart._resolveTheme(); // Up/down colors sampled at the extremes of the theme gradient. - // Matches the sign-aware convention (value 0 → gradient midpoint, - // positive → top of gradient, negative → bottom). - const upSample = sampleGradient(theme.gradientStops, 1.0); - const downSample = sampleGradient(theme.gradientStops, 0.0); - chart._upColor = [upSample[0], upSample[1], upSample[2]]; - chart._downColor = [downSample[0], downSample[1], downSample[2]]; + // Cached on the chart — `ensureUpDownColors` is a no-op when the + // gradient-stops reference matches the previous call. `restyle()` + // clears the cache via `invalidateTheme`, and the data-load path + // refreshes it before rebuilding glyph buffers. + ensureUpDownColors(chart); - const xDomainMin = -0.5; - const xDomainMax = chart._numCategories - 0.5; + const numericCat = chart._categoryAxisMode === "numeric"; + const xDomainMin = numericCat ? chart._numericCategoryDomain!.min : -0.5; + const xDomainMax = numericCat + ? chart._numericCategoryDomain!.max + : chart._numCategories - 0.5; if (chart._zoomController) { chart._zoomController.setBaseDomain( xDomainMin, @@ -64,6 +115,7 @@ export function renderCandlestickFrame( chart._yDomain.max, ); } + const vis = chart._zoomController ? chart._zoomController.getVisibleDomain() : { @@ -90,27 +142,40 @@ export function renderCandlestickFrame( const hasXLabel = chart._groupBy.length > 0; - const estLeft = 55 + 16; - const estRight = 16; - const estPlotWidth = Math.max(1, cssWidth - estLeft - estRight); const provisionalDomain: CategoricalDomain = { levels: chart._rowPaths, numRows: chart._numCategories, levelLabels: chart._groupBy.slice(), }; - const bottomExtra = measureCategoricalAxisHeight( - provisionalDomain, - estPlotWidth, - ); - const layout = new PlotLayout(cssWidth, cssHeight, { - hasXLabel, - hasYLabel: true, - hasLegend: false, - bottomExtra, - }); + let layout: PlotLayout; + if (numericCat) { + layout = new PlotLayout(cssWidth, cssHeight, { + hasXLabel, + hasYLabel: true, + hasLegend: false, + bottomExtra: 24, + }); + } else { + const estLeft = 55 + 16; + const estRight = 16; + const estPlotWidth = Math.max(1, cssWidth - estLeft - estRight); + const bottomExtra = measureCategoricalAxisHeight( + provisionalDomain, + estPlotWidth, + ); + layout = new PlotLayout(cssWidth, cssHeight, { + hasXLabel, + hasYLabel: true, + hasLegend: false, + bottomExtra, + }); + } + chart._lastLayout = layout; - if (chart._zoomController) chart._zoomController.updateLayout(layout); + if (chart._zoomController) { + chart._zoomController.updateLayout(layout); + } const projection = layout.buildProjectionMatrix( vis.xMin, @@ -118,6 +183,10 @@ export function renderCandlestickFrame( vis.yMin, vis.yMax, "y", + undefined, + undefined, + chart._categoryOrigin, + 0, ); const yTicks = computeNiceTicks(vis.yMin, vis.yMax, 6); @@ -131,20 +200,29 @@ export function renderCandlestickFrame( }; if (chart._gridlineCanvas) { - renderBarGridlines(chart._gridlineCanvas, layout, yTicks, theme); + renderBarGridlines( + chart._gridlineCanvas, + layout, + yTicks, + theme, + glManager.dpr, + ); } - renderInPlotFrame(gl, layout, () => { + renderInPlotFrame(gl, layout, glManager.dpr, () => { if (chart._defaultChartType === "ohlc") { - drawOHLC(chart, gl, glManager, projection); + chart._glyphs.ohlc.draw(chart, gl, glManager, projection); } else { - drawCandlesticks(chart, gl, glManager, projection); + chart._glyphs.bodyWick.draw(chart, gl, glManager, projection); } }); chart._lastXDomain = xDomain; chart._lastYDomain = yDomain; chart._lastYTicks = yTicks; + chart._lastCatTicks = numericCat + ? computeNiceTicks(vis.xMin, vis.xMax, 6) + : null; renderCandlestickChromeOverlay(chart); } @@ -152,43 +230,95 @@ export function renderCandlestickChromeOverlay(chart: CandlestickChart): void { if ( !chart._chromeCanvas || !chart._lastLayout || - !chart._lastXDomain || !chart._lastYDomain || !chart._lastYTicks - ) + ) { return; + } - const theme = resolveTheme(chart._chromeCanvas); + const theme = chart._resolveTheme(); + let catAxis: BarCategoryAxis; + if ( + chart._categoryAxisMode === "numeric" && + chart._numericCategoryDomain && + chart._lastCatTicks + ) { + catAxis = { + mode: "numeric", + domain: { + min: chart._numericCategoryDomain.min, + max: chart._numericCategoryDomain.max, + isDate: chart._numericCategoryDomain.isDate, + label: chart._numericCategoryDomain.label, + }, + ticks: chart._lastCatTicks, + }; + } else if (chart._lastXDomain) { + catAxis = { mode: "category", domain: chart._lastXDomain }; + } else { + return; + } + + // OHLC value axis: all four price columns share the value axis; + // pick the first available (Open is always present, the rest can + // be null per `candlestick-build.ts`). + const valueColumn = + chart._columnSlots[0] ?? + chart._columnSlots[1] ?? + chart._columnSlots[2] ?? + chart._columnSlots[3]; + const xColumn = chart._groupBy[0]; renderBarAxesChrome( chart._chromeCanvas, - chart._lastXDomain, + catAxis, chart._lastYDomain, chart._lastYTicks, chart._lastLayout, theme, + chart._glManager?.dpr ?? 1, + undefined, + undefined, + false, + { + value: chart.getColumnFormatter(valueColumn, "tick"), + category: chart.getColumnFormatter(xColumn, "tick"), + }, ); - if (chart._hoveredIdx >= 0 && chart._hoveredIdx < chart._candles.length) { + if (chart._hoveredIdx >= 0 && chart._hoveredIdx < chart._candles.count) { renderCandlestickTooltip(chart); } } function renderCandlestickTooltip(chart: CandlestickChart): void { - if (!chart._chromeCanvas || !chart._lastLayout) return; - const candle = chart._candles[chart._hoveredIdx]; - if (!candle) return; + if (!chart._chromeCanvas || !chart._lastLayout) { + return; + } + + const i = chart._hoveredIdx; + const candles = chart._candles; + if (i < 0 || i >= candles.count) { + return; + } const layout = chart._lastLayout; - const pos = layout.dataToPixel( - candle.xCenter, - (candle.high + candle.low) / 2, + const xCenter = candles.xCenter[i]; + const yMid = (candles.high[i] + candles.low[i]) / 2; + const pos = layout.dataToPixel(xCenter, yMid); + const lines = buildCandlestickTooltipLines(chart, i); + const theme = chart._resolveTheme(); + renderCanvasTooltip( + chart._chromeCanvas, + pos, + lines, + layout, + theme, + chart._glManager?.dpr ?? 1, + { + crosshair: false, + highlightRadius: 0, + }, ); - const lines = buildCandlestickTooltipLines(chart, candle); - const theme = resolveTheme(chart._chromeCanvas); - renderCanvasTooltip(chart._chromeCanvas, pos, lines, layout, theme, { - crosshair: false, - highlightRadius: 0, - }); } /** @@ -198,8 +328,7 @@ function renderCandlestickTooltip(chart: CandlestickChart): void { * `chart._autoFitCache`; hover-only redraws hit the cache. * * Cache lifetime: reset on data upload ([candlestick.ts] - * `uploadAndRender`). No legend / hidden-series path exists on this - * chart, so no additional invalidation is required today. + * `uploadAndRender`). */ function computeVisibleCandleExtent( chart: CandlestickChart, @@ -208,25 +337,51 @@ function computeVisibleCandleExtent( ): VisibleExtent { const cache = chart._autoFitCache; if (cache && cache.xMin === visXMin && cache.xMax === visXMax) { - // Cache hit — return the stored extent as a VisibleExtent. return cache; } - const next = - cache ?? ({} as NonNullable); + const next = cache ?? newCandlestickAutoFitCache(); next.xMin = visXMin; next.xMax = visXMax; - computeVisibleExtent( - chart._candles, - visXMin, - visXMax, - (c, out) => { - out.cat = c.xCenter; - out.lo = c.low; - out.hi = c.high; - }, - next, - ); + + // Walk the columnar storage directly; the legacy form built a + // closure adapter per call, defeating monomorphism in + // `computeVisibleExtent`. + const candles = chart._candles; + let lo = Infinity; + let hi = -Infinity; + let hasFit = false; + const xC = candles.xCenter; + const lows = candles.low; + const highs = candles.high; + for (let j = 0; j < candles.count; j++) { + const cx = xC[j]; + if (cx < visXMin || cx > visXMax) { + continue; + } + + if (lows[j] < lo) { + lo = lows[j]; + } + + if (highs[j] > hi) { + hi = highs[j]; + } + + hasFit = true; + } + + next.min = hasFit ? lo : 0; + next.max = hasFit ? hi : 1; + next.hasFit = hasFit; chart._autoFitCache = next; + + // Reference suppression — `computeVisibleExtent` retained for the + // shared common helper but no longer used in this fast path. + void computeVisibleExtent; return next; } + +function newCandlestickAutoFitCache(): CandlestickAutoFitCache { + return { xMin: 0, xMax: 0, min: 0, max: 1, hasFit: false }; +} diff --git a/packages/viewer-charts/src/ts/charts/candlestick/candlestick.ts b/packages/viewer-charts/src/ts/charts/candlestick/candlestick.ts index 3befe6935b..566f18d52a 100644 --- a/packages/viewer-charts/src/ts/charts/candlestick/candlestick.ts +++ b/packages/viewer-charts/src/ts/charts/candlestick/candlestick.ts @@ -15,14 +15,39 @@ import type { WebGLContextManager } from "../../webgl/context-manager"; import { CategoricalYChart } from "../common/categorical-y-chart"; import { buildCandlestickPipeline, - type CandleRecord, + emptyCandleColumns, + type CandleColumns, type CandleSeriesInfo, + type NumericCategoryDomain, } from "./candlestick-build"; -import { renderCandlestickFrame } from "./candlestick-render"; +import { + renderCandlestickFrame, + renderCandlestickChromeOverlay, + invalidateGlyphBuffers, + rebuildGlyphBuffers, + ensureUpDownColors, +} from "./candlestick-render"; import { handleCandlestickHover, showCandlestickPinnedTooltip, } from "./candlestick-interact"; +import { BodyWickGlyph } from "./glyphs/draw-candlesticks"; +import { OHLCGlyph } from "./glyphs/draw-ohlc"; + +/** + * Per-frame memo of the auto-fit Y extent for a {@link CandlestickChart}, + * keyed on the visible X window. Hover-only redraws hit the cache. + */ +export interface CandlestickAutoFitCache { + // Cache key — the categorical (X) window. + xMin: number; + xMax: number; + + // VisibleExtent payload — value axis min/max + hasFit flag. + min: number; + max: number; + hasFit: boolean; +} export interface CandlestickLocations { u_proj_left: WebGLUniformLocation | null; @@ -55,22 +80,69 @@ export class CandlestickChart extends CategoricalYChart { // and `_lastYTicks` all live on `CategoricalYChart`. _splitPrefixes: string[] = []; _series: CandleSeriesInfo[] = []; - _candles: CandleRecord[] = []; + + /** + * Columnar candle records. Indexed in `[0, _candles.count)`. + * Replaces the legacy `CandleRecord[]` to avoid per-record POJO + * allocation on data load. + */ + _candles: CandleColumns = emptyCandleColumns(); _yDomain: { min: number; max: number } = { min: 0, max: 1 }; - /** Gradient-sampled colors for the up (close ≥ open) / down sides. */ + /** + * `domain_mode: "expand"` accumulators. Hold the running union of + * the value-axis (and, in numeric-category mode, category-axis) + * extent across data loads. Cleared in `resetExpandedDomain` — + * wired from the worker's `resetAllZooms` and from view-config + * mutations on `AbstractChart`. `null` whenever the option is + * `"fit"` or the accumulator has just been cleared. + */ + _expandedYDomain: { min: number; max: number } | null = null; + _expandedCategoryDomain: { min: number; max: number } | null = null; + + /** + * Numeric category-axis state (single non-string group_by). + */ + _categoryAxisMode: "category" | "numeric" = "category"; + _numericCategoryDomain: NumericCategoryDomain | null = null; + _categoryPositions: Float64Array | null = null; + _lastCatTicks: number[] | null = null; + + /** + * Origin used to rebase candle xCenters before f32 narrowing — see {@link SeriesChart._categoryOrigin}. + */ + _categoryOrigin = 0; + + /** + * Gradient-sampled colors for the up (close ≥ open) / down sides. + * Cached via `_upDownColorKey` — only `restyle()` (which clears the + * theme cache) or a data load forces re-sampling. + */ _upColor: [number, number, number] = [0, 0.8, 0.4]; _downColor: [number, number, number] = [0.8, 0.2, 0.2]; + /** + * Identity of the gradient-stops reference last used to sample the + * up/down colors. When this matches the current `theme.gradientStops` + * reference, `ensureUpDownColors` short-circuits. + */ + _upDownColorKey: unknown = null; + _hoveredIdx = -1; _pinnedIdx = -1; - // Lazy glyph caches. - _wickCache: unknown = undefined; - - /** Uploaded instance count for the body shader. */ - _uploadedBodies = 0; + /** + * Typed glyph composition. Each glyph owns its own program cache + * and persistent vertex buffers privately; the chart routes + * draw/rebuild/invalidate via `_glyphs`. `_defaultChartType` + * (`"candlestick"` vs `"ohlc"`) selects which glyph the frame + * builder dispatches to. + */ + readonly _glyphs = { + bodyWick: new BodyWickGlyph(), + ohlc: new OHLCGlyph(), + } as const; /** * Auto-fit the price (Y) axis to the `low`/`high` extent of @@ -85,51 +157,102 @@ export class CandlestickChart extends CategoricalYChart { * Per-frame memo of the auto-fit Y extent keyed on the visible X * window. Hover-only redraws (X window unchanged) hit the cache. * Reset to null on data upload. - * - * TODO(perf): O(|_candles|) linear scan. `_candles` is ordered by - * `xCenter`, so a binary-search pair to find the visible slice - * would drop this to O(log N + K_visible). Deferred — the - * `max_cells = 100_000` cap keeps the scan within the frame budget. */ - _autoFitCache: { - // Cache key — the categorical (X) window. - xMin: number; - xMax: number; - // VisibleExtent payload — value axis min/max + hasFit flag. - min: number; - max: number; - hasFit: boolean; - } | null = null; - - attachTooltip(glCanvas: HTMLCanvasElement): void { - this._glCanvas = glCanvas; - this._tooltip.attach(glCanvas, { - onHover: (mx, my) => handleCandlestickHover(this, mx, my), + _autoFitCache: CandlestickAutoFitCache | null = null; + + protected override tooltipCallbacks() { + return { + onHover: (mx: number, my: number) => + handleCandlestickHover(this, mx, my), onLeave: () => { if (this._hoveredIdx !== -1) { this._hoveredIdx = -1; - if (this._glManager) - renderCandlestickFrame(this, this._glManager); + + // Hover state only affects the chrome overlay + // (tooltip box) — the WebGL pass is unchanged. Skip + // the full repaint (which would rebuild glyph + // buffers and shake out one more frame of latency). + renderCandlestickChromeOverlay(this); } }, - onPin: () => { + onPin: (mx: number, my: number) => { + // Refresh the hit-test at the click coords so the pin + // path doesn't depend on the RAF-throttled hover state + // — see comment in `series.ts` `onPin`. + handleCandlestickHover(this, mx, my); if (this._hoveredIdx >= 0) { - showCandlestickPinnedTooltip(this, this._hoveredIdx); + const idx = this._hoveredIdx; + showCandlestickPinnedTooltip(this, idx); + void this._emitCandleClickSelect(idx); } }, + onUnpin: () => { + this.emitUnselect(); + }, + }; + } + + /** + * Resolve a clicked candle into a `PerspectiveClickDetail` and + * emit both `perspective-click` and + * `perspective-global-filter selected:true`. + * + * One candle per (catIdx, splitIdx). Like the series pipeline, + * `catIdx + _rowOffset` is the source-view row; the column name is + * the Close column (the canonical "y" target for OHLC). Group-by + * values come from `_rowPaths`; split-by values come from + * `_splitPrefixes[splitIdx]` split on `|`. + */ + private async _emitCandleClickSelect(idx: number): Promise { + if (idx < 0 || idx >= this._candles.count) { + return; + } + + const catIdx = this._candles.catIdx[idx]; + const splitIdx = this._candles.splitIdx[idx]; + const groupByValues: (string | null)[] = this._rowPaths.map( + (level) => level.labels[catIdx] ?? null, + ); + const splitKey = this._splitPrefixes[splitIdx] ?? ""; + const splitByValues = + this._splitBy.length > 0 && splitKey !== "" + ? splitKey.split("|") + : []; + + // OHLC plugins put Close in slot 1 (FIN_NAMES = ["Open", + // "Close", "High", "Low", "Tooltip"]). Fall back to the first + // non-null slot if Close isn't configured. + const columnName = + this._columnSlots[1] || + this._columnSlots.find((s): s is string => !!s) || + ""; + + await this.emitClickAndSelect({ + rowIdx: catIdx + this._rowOffset, + columnName, + groupByValues, + splitByValues, }); } - uploadAndRender( + override invalidateTheme(): void { + super.invalidateTheme(); + + // Up/down colors are sampled from `theme.gradientStops`. Drop the + // identity key so the next render re-samples after a `restyle()`. + this._upDownColorKey = null; + } + + async uploadAndRender( glManager: WebGLContextManager, columns: ColumnDataMap, startRow: number, endRow: number, - ): void { + ): Promise { this._glManager = glManager; - if (startRow !== 0) return; - - this._cancelScheduledRender(); + if (startRow !== 0) { + return; + } const result = buildCandlestickPipeline({ columns, @@ -137,7 +260,50 @@ export class CandlestickChart extends CategoricalYChart { columnSlots: this._columnSlots, groupBy: this._groupBy, splitBy: this._splitBy, + groupByTypes: this._groupByTypes, + bandInnerFrac: this._pluginConfig.band_inner_frac, + barInnerPad: this._pluginConfig.bar_inner_pad, + scratchCandles: this._candles, }); + // `domain_mode: "expand"` post-build union — mirrors the series + // pipeline. Mutate the pipeline result in place so the + // assignments below pick up the grown extent automatically. + if (this._pluginConfig.domain_mode === "expand") { + if (this._expandedYDomain) { + result.yDomain.min = Math.min( + this._expandedYDomain.min, + result.yDomain.min, + ); + result.yDomain.max = Math.max( + this._expandedYDomain.max, + result.yDomain.max, + ); + } + + this._expandedYDomain = { ...result.yDomain }; + + if (result.numericCategoryDomain) { + if (this._expandedCategoryDomain) { + result.numericCategoryDomain.min = Math.min( + this._expandedCategoryDomain.min, + result.numericCategoryDomain.min, + ); + result.numericCategoryDomain.max = Math.max( + this._expandedCategoryDomain.max, + result.numericCategoryDomain.max, + ); + } + + this._expandedCategoryDomain = { + min: result.numericCategoryDomain.min, + max: result.numericCategoryDomain.max, + }; + } + } else { + this._expandedYDomain = null; + this._expandedCategoryDomain = null; + } + this._rowPaths = result.rowPaths; this._numCategories = result.numCategories; this._rowOffset = result.rowOffset; @@ -145,32 +311,57 @@ export class CandlestickChart extends CategoricalYChart { this._series = result.series; this._candles = result.candles; this._yDomain = result.yDomain; - // New candles invalidate any auto-fit extent memo. + this._categoryAxisMode = result.axisMode.mode; + this._numericCategoryDomain = result.numericCategoryDomain; + this._categoryPositions = result.categoryPositions; + this._categoryOrigin = result.numericCategoryDomain?.min ?? 0; + + // New candles invalidate any auto-fit extent memo. Color key + // stays — gradient stops are theme-bound, not data-bound — but + // the persistent vertex buffers must be rebuilt to reflect the + // new candle set. this._autoFitCache = null; - this._scheduleRender(glManager); + // Resolve up/down colors (cheap on cache hit) before rebuilding + // glyph buffers so the persistent body buffer captures the + // correct per-candle RGB. Then rebuild buffers. + ensureUpDownColors(this); + invalidateGlyphBuffers(this); + rebuildGlyphBuffers(this, glManager); + + await this.requestRender(glManager); } - redraw(glManager: WebGLContextManager): void { + _fullRender(glManager: WebGLContextManager): void { this._glManager = glManager; - this._fullRender(glManager); + renderCandlestickFrame(this, glManager); } - protected _fullRender(glManager: WebGLContextManager): void { - renderCandlestickFrame(this, glManager); + override resetExpandedDomain(): void { + this._expandedYDomain = null; + this._expandedCategoryDomain = null; } protected destroyInternal(): void { - if (this._cornerBuffer && this._glManager) { - this._glManager.gl.deleteBuffer(this._cornerBuffer); + if (this._glManager) { + const gl = this._glManager.gl; + if (this._cornerBuffer) { + gl.deleteBuffer(this._cornerBuffer); + } + + // Each glyph owns its program-local GPU resources + + // persistent vertex buffers; `destroy` frees both. + this._glyphs.bodyWick.destroy(this); + this._glyphs.ohlc.destroy(this); } + this._program = null; this._locations = null; this._cornerBuffer = null; - this._wickCache = undefined; - this._candles = []; + this._candles = emptyCandleColumns(); this._series = []; this._rowPaths = []; this._numCategories = 0; + this._upDownColorKey = null; } } diff --git a/packages/viewer-charts/src/ts/charts/candlestick/glyphs/draw-candlesticks.ts b/packages/viewer-charts/src/ts/charts/candlestick/glyphs/draw-candlesticks.ts index a3d802685a..5257ef6977 100644 --- a/packages/viewer-charts/src/ts/charts/candlestick/glyphs/draw-candlesticks.ts +++ b/packages/viewer-charts/src/ts/charts/candlestick/glyphs/draw-candlesticks.ts @@ -12,8 +12,12 @@ import type { WebGLContextManager } from "../../../webgl/context-manager"; import type { CandlestickChart } from "../candlestick"; -import type { CandleRecord } from "../candlestick-build"; -import { getInstancing } from "../../../webgl/instanced-attrs"; +import { + createLineCornerBuffer, + createQuadCornerBuffer, + getInstancing, +} from "../../../webgl/instanced-attrs"; +import { compileProgram } from "../../../webgl/program-cache"; import bodyVert from "../../../shaders/candlestick-body.vert.glsl"; import bodyFrag from "../../../shaders/candlestick-body.frag.glsl"; import lineVert from "../../../shaders/line-uniform.vert.glsl"; @@ -21,8 +25,6 @@ import lineFrag from "../../../shaders/line-uniform.frag.glsl"; type GL = WebGL2RenderingContext | WebGLRenderingContext; -const WICK_WIDTH_PX = 1.0; - interface BodyCache { program: WebGLProgram; quadBuffer: WebGLBuffer; @@ -49,121 +51,271 @@ interface WickCache { a_end: number; } -interface Cache { +interface ProgramCache { body: BodyCache; wick: WickCache; } -function ensureCache( - chart: CandlestickChart, - glManager: WebGLContextManager, -): Cache { - if (chart._wickCache) return chart._wickCache as Cache; - const gl = glManager.gl; - - // ── Body shader + buffers ──────────────────────────────────────── - const bodyProg = glManager.shaders.getOrCreate( - "candlestick-body", - bodyVert, - bodyFrag, - ); - const quadBuffer = gl.createBuffer()!; - gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer); - gl.bufferData( - gl.ARRAY_BUFFER, - new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]), - gl.STATIC_DRAW, - ); - const instanceBuffer = gl.createBuffer()!; - const body: BodyCache = { - program: bodyProg, - quadBuffer, - instanceBuffer, - u_projection: gl.getUniformLocation(bodyProg, "u_projection"), - a_corner: gl.getAttribLocation(bodyProg, "a_corner"), - a_x_center: gl.getAttribLocation(bodyProg, "a_x_center"), - a_half_width: gl.getAttribLocation(bodyProg, "a_half_width"), - a_y0: gl.getAttribLocation(bodyProg, "a_y0"), - a_y1: gl.getAttribLocation(bodyProg, "a_y1"), - a_color: gl.getAttribLocation(bodyProg, "a_color"), - }; - - // ── Wick shader (reused for OHLC too via draw-ohlc) ────────────── - const wickProg = glManager.shaders.getOrCreate( - "line-uniform", - lineVert, - lineFrag, - ); - const cornerBuffer = gl.createBuffer()!; - gl.bindBuffer(gl.ARRAY_BUFFER, cornerBuffer); - gl.bufferData( - gl.ARRAY_BUFFER, - new Float32Array([0, 1, 2, 3]), - gl.STATIC_DRAW, - ); - const wick: WickCache = { - program: wickProg, - cornerBuffer, - segmentBuffer: gl.createBuffer()!, - u_projection: gl.getUniformLocation(wickProg, "u_projection"), - u_color: gl.getUniformLocation(wickProg, "u_color"), - u_resolution: gl.getUniformLocation(wickProg, "u_resolution"), - u_line_width: gl.getUniformLocation(wickProg, "u_line_width"), - a_corner: gl.getAttribLocation(wickProg, "a_corner"), - a_start: gl.getAttribLocation(wickProg, "a_start"), - a_end: gl.getAttribLocation(wickProg, "a_end"), - }; +/** + * Persistent body + wick vertex buffer state. Built once per data load + * by `rebuildBuffers`; pan/zoom redraws bind + dispatch with no uploads. + */ +interface BodyWickBuffers { + bodyCount: number; + upWickCount: number; + downWickCount: number; - chart._wickCache = { body, wick }; - return chart._wickCache as Cache; + /** + * Persistent GPU buffer for up wicks. Layout: [x,low, x,high, ...]. + */ + upWickBuffer: WebGLBuffer; + downWickBuffer: WebGLBuffer; } -export function drawCandlesticks( - chart: CandlestickChart, - gl: GL, - glManager: WebGLContextManager, - projection: Float32Array, -): void { - const candles = chart._candles; - if (candles.length === 0) return; +/** + * Candlestick body + wick glyph. Owns the body and wick programs + + * their corner/segment/instance buffers, and the persistent up/down + * wick vertex buffers built per data load. + */ +export class BodyWickGlyph { + private _program: ProgramCache | null = null; + private _buffers: BodyWickBuffers | null = null; + + /** + * Lazily compile the body and wick programs and create their static + * GPU buffers (corner / quad). Cached for the lifetime of the chart. + */ + private ensureProgram(glManager: WebGLContextManager): ProgramCache { + if (this._program) { + return this._program; + } + + const gl = glManager.gl; + + const quadBuffer = createQuadCornerBuffer(gl); + const bodyPartial = compileProgram< + Omit + >( + glManager, + "candlestick-body", + bodyVert, + bodyFrag, + ["u_projection"], + [ + "a_corner", + "a_x_center", + "a_half_width", + "a_y0", + "a_y1", + "a_color", + ], + ); + const body: BodyCache = { + ...bodyPartial, + quadBuffer, + instanceBuffer: gl.createBuffer()!, + }; - const cache = ensureCache(chart, glManager); - drawBodies(chart, gl, glManager, cache.body, candles, projection); - drawWicks(chart, gl, glManager, cache.wick, candles, projection); + const cornerBuffer = createLineCornerBuffer(gl); + const wickPartial = compileProgram< + Omit + >( + glManager, + "line-uniform", + lineVert, + lineFrag, + ["u_projection", "u_color", "u_resolution", "u_line_width"], + ["a_corner", "a_start", "a_end"], + ); + const wick: WickCache = { + ...wickPartial, + cornerBuffer, + segmentBuffer: gl.createBuffer()!, + }; + + this._program = { body, wick }; + return this._program; + } + + /** + * Drop persistent body + wick vertex buffers. Called from data-load + * (before `rebuildBuffers`) and from chart-destroy paths. + */ + invalidateBuffers(chart: CandlestickChart): void { + const buf = this._buffers; + if (!buf || !chart._glManager) { + this._buffers = null; + return; + } + + const gl = chart._glManager.gl; + gl.deleteBuffer(buf.upWickBuffer); + gl.deleteBuffer(buf.downWickBuffer); + this._buffers = null; + } + + /** + * Pre-build the per-instance body buffer (interleaved + * [xCenter, halfWidth, y0, y1, r, g, b]) and the up/down wick + * line-segment buffers. Single GPU upload per buffer per data load. + */ + rebuildBuffers( + chart: CandlestickChart, + glManager: WebGLContextManager, + ): void { + const candles = chart._candles; + const cache = this.ensureProgram(glManager); + const gl = glManager.gl; + const xOrigin = chart._categoryOrigin; + + if (candles.count === 0) { + const upBuf = gl.createBuffer()!; + const downBuf = gl.createBuffer()!; + this._buffers = { + bodyCount: 0, + upWickCount: 0, + downWickCount: 0, + upWickBuffer: upBuf, + downWickBuffer: downBuf, + }; + return; + } + + // Body buffer: 7 floats per candle (interleaved). + const data = new Float32Array(candles.count * 7); + let upCount = 0; + let downCount = 0; + const xC = candles.xCenter; + const hw = candles.halfWidth; + const open = candles.open; + const close = candles.close; + const isUp = candles.isUp; + const upColor = chart._upColor; + const downColor = chart._downColor; + for (let i = 0; i < candles.count; i++) { + const o = open[i]; + const c = close[i]; + const bodyLow = o < c ? o : c; + const bodyHigh = o < c ? c : o; + const up = isUp[i] !== 0; + const col = up ? upColor : downColor; + const off = i * 7; + data[off + 0] = xC[i] - xOrigin; + data[off + 1] = hw[i] * 0.7; + data[off + 2] = bodyLow; + data[off + 3] = bodyHigh; + data[off + 4] = col[0]; + data[off + 5] = col[1]; + data[off + 6] = col[2]; + + if (up) { + upCount++; + } else { + downCount++; + } + } + + gl.bindBuffer(gl.ARRAY_BUFFER, cache.body.instanceBuffer); + gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW); + + // Wick buffers: per-color, packed [x,low, x,high] segments. + const upWick = new Float32Array(upCount * 4); + const downWick = new Float32Array(downCount * 4); + let upW = 0; + let downW = 0; + const lows = candles.low; + const highs = candles.high; + for (let i = 0; i < candles.count; i++) { + const x = xC[i] - xOrigin; + const lo = lows[i]; + const hi = highs[i]; + if (isUp[i] !== 0) { + upWick[upW + 0] = x; + upWick[upW + 1] = lo; + upWick[upW + 2] = x; + upWick[upW + 3] = hi; + upW += 4; + } else { + downWick[downW + 0] = x; + downWick[downW + 1] = lo; + downWick[downW + 2] = x; + downWick[downW + 3] = hi; + downW += 4; + } + } + + // Reuse existing wick GL buffers when available; otherwise allocate. + const prev = this._buffers; + const upBuf = prev?.upWickBuffer ?? gl.createBuffer()!; + const downBuf = prev?.downWickBuffer ?? gl.createBuffer()!; + gl.bindBuffer(gl.ARRAY_BUFFER, upBuf); + gl.bufferData(gl.ARRAY_BUFFER, upWick, gl.STATIC_DRAW); + gl.bindBuffer(gl.ARRAY_BUFFER, downBuf); + gl.bufferData(gl.ARRAY_BUFFER, downWick, gl.STATIC_DRAW); + + this._buffers = { + bodyCount: candles.count, + upWickCount: upCount, + downWickCount: downCount, + upWickBuffer: upBuf, + downWickBuffer: downBuf, + }; + } + + draw( + chart: CandlestickChart, + gl: GL, + glManager: WebGLContextManager, + projection: Float32Array, + ): void { + const buf = this._buffers; + if (!buf || buf.bodyCount === 0) { + return; + } + + const cache = this.ensureProgram(glManager); + drawBodies(gl, glManager, cache.body, buf.bodyCount, projection); + drawWicks(chart, gl, glManager, cache.wick, buf, projection); + } + + /** + * Free program-local GPU buffers + persistent vertex buffers. The + * shader programs themselves are owned by `WebGLContextManager.shaders` + * and not freed here. + */ + destroy(chart: CandlestickChart): void { + const gl = chart._glManager?.gl; + if (gl) { + this.invalidateBuffers(chart); + const cache = this._program; + if (cache) { + gl.deleteBuffer(cache.body.quadBuffer); + gl.deleteBuffer(cache.body.instanceBuffer); + gl.deleteBuffer(cache.wick.cornerBuffer); + gl.deleteBuffer(cache.wick.segmentBuffer); + } + } + + this._program = null; + this._buffers = null; + } } /** - * Upload one filled-rect instance per candle and issue a single - * instanced draw. Interleaved layout: - * [ xCenter, halfWidth, y0, y1, r, g, b ] (7 floats / candle) + * Bind the persistent body buffer and issue one instanced draw. */ function drawBodies( - chart: CandlestickChart, gl: GL, glManager: WebGLContextManager, cache: BodyCache, - candles: CandleRecord[], + instanceCount: number, projection: Float32Array, ): void { - const stride = 7 * Float32Array.BYTES_PER_ELEMENT; - const data = new Float32Array(candles.length * 7); - for (let i = 0; i < candles.length; i++) { - const c = candles[i]; - const bodyLow = Math.min(c.open, c.close); - const bodyHigh = Math.max(c.open, c.close); - const col = c.isUp ? chart._upColor : chart._downColor; - const o = i * 7; - data[o + 0] = c.xCenter; - data[o + 1] = c.halfWidth * 0.7; // bodies narrower than the slot - data[o + 2] = bodyLow; - data[o + 3] = bodyHigh; - data[o + 4] = col[0]; - data[o + 5] = col[1]; - data[o + 6] = col[2]; + if (instanceCount === 0) { + return; } - gl.bindBuffer(gl.ARRAY_BUFFER, cache.instanceBuffer); - gl.bufferData(gl.ARRAY_BUFFER, data, gl.DYNAMIC_DRAW); + const stride = 7 * Float32Array.BYTES_PER_ELEMENT; gl.useProgram(cache.program); gl.uniformMatrix4fv(cache.u_projection, false, projection); @@ -177,7 +329,7 @@ function drawBodies( gl.vertexAttribPointer(cache.a_corner, 2, gl.FLOAT, false, 0, 0); setDivisor(cache.a_corner, 0); - // Per-instance attributes, all sourced from the interleaved buffer. + // Per-instance attributes from the persistent interleaved buffer. gl.bindBuffer(gl.ARRAY_BUFFER, cache.instanceBuffer); const f = Float32Array.BYTES_PER_ELEMENT; const bind = (loc: number, size: number, offset: number) => { @@ -185,13 +337,14 @@ function drawBodies( gl.vertexAttribPointer(loc, size, gl.FLOAT, false, stride, offset); setDivisor(loc, 1); }; + bind(cache.a_x_center, 1, 0); bind(cache.a_half_width, 1, 1 * f); bind(cache.a_y0, 1, 2 * f); bind(cache.a_y1, 1, 3 * f); bind(cache.a_color, 3, 4 * f); - instancing.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, candles.length); + instancing.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, instanceCount); setDivisor(cache.a_x_center, 0); setDivisor(cache.a_half_width, 0); @@ -201,90 +354,73 @@ function drawBodies( } /** - * Wicks: thin vertical line from `low` to `high` through each candle's - * x-center. One draw per color (up / down) since the line-uniform - * shader takes color as a uniform. + * Bind the persistent up/down wick buffers and dispatch one + * instanced draw per color group. No per-frame allocations. */ function drawWicks( chart: CandlestickChart, gl: GL, glManager: WebGLContextManager, cache: WickCache, - candles: CandleRecord[], + buf: BodyWickBuffers, projection: Float32Array, ): void { + if (buf.upWickCount === 0 && buf.downWickCount === 0) { + return; + } + + const dpr = glManager.dpr; + gl.useProgram(cache.program); + gl.uniformMatrix4fv(cache.u_projection, false, projection); + gl.uniform2f(cache.u_resolution, gl.canvas.width, gl.canvas.height); + gl.uniform1f(cache.u_line_width, chart._pluginConfig.wick_width_px * dpr); + + const instancing = getInstancing(glManager); + const { setDivisor } = instancing; + + gl.bindBuffer(gl.ARRAY_BUFFER, cache.cornerBuffer); + gl.enableVertexAttribArray(cache.a_corner); + gl.vertexAttribPointer(cache.a_corner, 1, gl.FLOAT, false, 0, 0); + setDivisor(cache.a_corner, 0); + drawWickGroup( gl, - glManager, + instancing, cache, - candles.filter((c) => c.isUp), + buf.upWickBuffer, + buf.upWickCount, chart._upColor, - projection, ); drawWickGroup( gl, - glManager, + instancing, cache, - candles.filter((c) => !c.isUp), + buf.downWickBuffer, + buf.downWickCount, chart._downColor, - projection, ); + + setDivisor(cache.a_start, 0); + setDivisor(cache.a_end, 0); } -/** - * Issue one instanced draw over `group`, where each instance is a - * single line segment. Layout: consecutive `[x, y_start, x, y_end, …]` - * so `a_start` reads offset 0 and `a_end` reads offset stride — exactly - * the trick `draw-lines.ts` uses for continuous runs. - */ function drawWickGroup( gl: GL, - glManager: WebGLContextManager, + instancing: ReturnType, cache: WickCache, - group: CandleRecord[], + segmentBuffer: WebGLBuffer, + count: number, color: [number, number, number], - projection: Float32Array, ): void { - if (group.length === 0) return; - - const dpr = window.devicePixelRatio || 1; - const stride = 2 * Float32Array.BYTES_PER_ELEMENT; - - // One pair of points per wick — not a continuous polyline. Pack - // start/end together; enable divisor=1 on a_start with offset 0 and - // on a_end with offset `stride`. - const data = new Float32Array(group.length * 4); - for (let i = 0; i < group.length; i++) { - const c = group[i]; - const o = i * 4; - data[o + 0] = c.xCenter; - data[o + 1] = c.low; - data[o + 2] = c.xCenter; - data[o + 3] = c.high; + if (count === 0) { + return; } - gl.bindBuffer(gl.ARRAY_BUFFER, cache.segmentBuffer); - gl.bufferData(gl.ARRAY_BUFFER, data, gl.DYNAMIC_DRAW); - - gl.useProgram(cache.program); - gl.uniformMatrix4fv(cache.u_projection, false, projection); - gl.uniform2f( - cache.u_resolution, - (gl.canvas as HTMLCanvasElement).width, - (gl.canvas as HTMLCanvasElement).height, - ); - gl.uniform1f(cache.u_line_width, WICK_WIDTH_PX * dpr); - gl.uniform4f(cache.u_color, color[0], color[1], color[2], 1.0); - - const instancing = getInstancing(glManager); + const stride = 2 * Float32Array.BYTES_PER_ELEMENT; const { setDivisor } = instancing; - gl.bindBuffer(gl.ARRAY_BUFFER, cache.cornerBuffer); - gl.enableVertexAttribArray(cache.a_corner); - gl.vertexAttribPointer(cache.a_corner, 1, gl.FLOAT, false, 0, 0); - setDivisor(cache.a_corner, 0); - - gl.bindBuffer(gl.ARRAY_BUFFER, cache.segmentBuffer); + gl.uniform4f(cache.u_color, color[0], color[1], color[2], 1.0); + gl.bindBuffer(gl.ARRAY_BUFFER, segmentBuffer); gl.enableVertexAttribArray(cache.a_start); gl.vertexAttribPointer(cache.a_start, 2, gl.FLOAT, false, 2 * stride, 0); setDivisor(cache.a_start, 1); @@ -292,8 +428,5 @@ function drawWickGroup( gl.vertexAttribPointer(cache.a_end, 2, gl.FLOAT, false, 2 * stride, stride); setDivisor(cache.a_end, 1); - instancing.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, group.length); - - setDivisor(cache.a_start, 0); - setDivisor(cache.a_end, 0); + instancing.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, count); } diff --git a/packages/viewer-charts/src/ts/charts/candlestick/glyphs/draw-ohlc.ts b/packages/viewer-charts/src/ts/charts/candlestick/glyphs/draw-ohlc.ts index bcba09ca1c..3320f63dc7 100644 --- a/packages/viewer-charts/src/ts/charts/candlestick/glyphs/draw-ohlc.ts +++ b/packages/viewer-charts/src/ts/charts/candlestick/glyphs/draw-ohlc.ts @@ -12,15 +12,16 @@ import type { WebGLContextManager } from "../../../webgl/context-manager"; import type { CandlestickChart } from "../candlestick"; -import type { CandleRecord } from "../candlestick-build"; -import { getInstancing } from "../../../webgl/instanced-attrs"; +import { + createLineCornerBuffer, + getInstancing, +} from "../../../webgl/instanced-attrs"; +import { compileProgram } from "../../../webgl/program-cache"; import lineVert from "../../../shaders/line-uniform.vert.glsl"; import lineFrag from "../../../shaders/line-uniform.frag.glsl"; type GL = WebGL2RenderingContext | WebGLRenderingContext; -const OHLC_LINE_WIDTH_PX = 1.0; - interface OHLCCache { program: WebGLProgram; cornerBuffer: WebGLBuffer; @@ -34,144 +35,263 @@ interface OHLCCache { a_end: number; } -function ensureCache( - chart: CandlestickChart, - glManager: WebGLContextManager, -): OHLCCache { - if (chart._wickCache && (chart._wickCache as any).ohlc) { - return (chart._wickCache as any).ohlc as OHLCCache; - } - const gl = glManager.gl; - const prog = glManager.shaders.getOrCreate( - "line-uniform", - lineVert, - lineFrag, - ); - const cornerBuffer = gl.createBuffer()!; - gl.bindBuffer(gl.ARRAY_BUFFER, cornerBuffer); - gl.bufferData( - gl.ARRAY_BUFFER, - new Float32Array([0, 1, 2, 3]), - gl.STATIC_DRAW, - ); - const cache: OHLCCache = { - program: prog, - cornerBuffer, - segmentBuffer: gl.createBuffer()!, - u_projection: gl.getUniformLocation(prog, "u_projection"), - u_color: gl.getUniformLocation(prog, "u_color"), - u_resolution: gl.getUniformLocation(prog, "u_resolution"), - u_line_width: gl.getUniformLocation(prog, "u_line_width"), - a_corner: gl.getAttribLocation(prog, "a_corner"), - a_start: gl.getAttribLocation(prog, "a_start"), - a_end: gl.getAttribLocation(prog, "a_end"), - }; - // Stash alongside any existing wick cache so destroy() can free both. - const existing = (chart._wickCache as any) || {}; - chart._wickCache = { ...existing, ohlc: cache }; - return cache; -} - /** - * OHLC glyph: for each record, three line segments — the vertical H–L - * line through `xCenter`, a short left-facing tick at `open`, and a - * short right-facing tick at `close`. Colors bichromatic by `isUp`, - * identical to candlestick bodies. + * Persistent OHLC vertex buffer state — one buffer per color group + * (up / down), each holding 3 line segments per candle (H–L vertical, + * open tick, close tick). Built once per data load; pan/zoom redraws + * rebind + dispatch with no uploads. */ -export function drawOHLC( - chart: CandlestickChart, - gl: GL, - glManager: WebGLContextManager, - projection: Float32Array, -): void { - const candles = chart._candles; - if (candles.length === 0) return; - - const cache = ensureCache(chart, glManager); - - drawOHLCGroup( - gl, - glManager, - cache, - candles.filter((c) => c.isUp), - chart._upColor, - projection, - ); - drawOHLCGroup( - gl, - glManager, - cache, - candles.filter((c) => !c.isUp), - chart._downColor, - projection, - ); +interface OHLCBuffers { + upBuffer: WebGLBuffer; + downBuffer: WebGLBuffer; + + /** + * Number of line-segment instances in the up buffer (= 3 × up candle count). + */ + upInstanceCount: number; + downInstanceCount: number; } /** - * Pack all three line segments (H–L, open tick, close tick) for every - * candle in `group` into a single interleaved instance buffer and - * issue one instanced draw. - * - * Layout per instance (16 bytes = 4 floats): `[ start.x, start.y, end.x, end.y ]`. - * `3 * group.length` total instances. + * OHLC bar glyph. Owns the OHLC program + per-color persistent segment + * buffers built per data load. Co-tenanted with `BodyWickGlyph` on + * `CandlestickChart`; only one of the two is active per frame depending + * on `_defaultChartType`. */ -function drawOHLCGroup( - gl: GL, - glManager: WebGLContextManager, - cache: OHLCCache, - group: CandleRecord[], - color: [number, number, number], - projection: Float32Array, -): void { - if (group.length === 0) return; - - const dpr = window.devicePixelRatio || 1; - const data = new Float32Array(group.length * 3 * 4); - let o = 0; - for (let i = 0; i < group.length; i++) { - const c = group[i]; - // H–L vertical line. - data[o++] = c.xCenter; - data[o++] = c.low; - data[o++] = c.xCenter; - data[o++] = c.high; - // Open tick: left-facing horizontal stub at y=open. - data[o++] = c.xCenter - c.halfWidth; - data[o++] = c.open; - data[o++] = c.xCenter; - data[o++] = c.open; - // Close tick: right-facing horizontal stub at y=close. - data[o++] = c.xCenter; - data[o++] = c.close; - data[o++] = c.xCenter + c.halfWidth; - data[o++] = c.close; +export class OHLCGlyph { + private _program: OHLCCache | null = null; + private _buffers: OHLCBuffers | null = null; + + private ensureProgram(glManager: WebGLContextManager): OHLCCache { + if (this._program) { + return this._program; + } + + const gl = glManager.gl; + const cornerBuffer = createLineCornerBuffer(gl); + const partial = compileProgram< + Omit + >( + glManager, + "line-uniform", + lineVert, + lineFrag, + ["u_projection", "u_color", "u_resolution", "u_line_width"], + ["a_corner", "a_start", "a_end"], + ); + this._program = { + ...partial, + cornerBuffer, + segmentBuffer: gl.createBuffer()!, + }; + return this._program; } - gl.bindBuffer(gl.ARRAY_BUFFER, cache.segmentBuffer); - gl.bufferData(gl.ARRAY_BUFFER, data, gl.DYNAMIC_DRAW); + /** + * Drop persistent OHLC vertex buffers. Called from data-load (before + * `rebuildBuffers`) and from chart-destroy paths. + */ + invalidateBuffers(chart: CandlestickChart): void { + const buf = this._buffers; + if (!buf || !chart._glManager) { + this._buffers = null; + return; + } - gl.useProgram(cache.program); - gl.uniformMatrix4fv(cache.u_projection, false, projection); - gl.uniform2f( - cache.u_resolution, - (gl.canvas as HTMLCanvasElement).width, - (gl.canvas as HTMLCanvasElement).height, - ); - gl.uniform1f(cache.u_line_width, OHLC_LINE_WIDTH_PX * dpr); - gl.uniform4f(cache.u_color, color[0], color[1], color[2], 1.0); + const gl = chart._glManager.gl; + gl.deleteBuffer(buf.upBuffer); + gl.deleteBuffer(buf.downBuffer); + this._buffers = null; + } - const instancing = getInstancing(glManager); - const { setDivisor } = instancing; + /** + * Pre-build the per-group OHLC instance buffers. Each candle emits 3 + * line segments (H–L, open tick, close tick); layout per instance is + * `[start.x, start.y, end.x, end.y]`. Single GPU upload per group per + * data load. + */ + rebuildBuffers( + chart: CandlestickChart, + glManager: WebGLContextManager, + ): void { + // Only rebuild when this chart actually paints OHLC. Cheap enough + // to always rebuild but skipping avoids two empty GPU buffers on + // candlestick instances. + if (chart._defaultChartType !== "ohlc") { + return; + } + + const candles = chart._candles; + const gl = glManager.gl; + this.ensureProgram(glManager); + + const xOrigin = chart._categoryOrigin; + const xC = candles.xCenter; + const hw = candles.halfWidth; + const open = candles.open; + const close = candles.close; + const high = candles.high; + const low = candles.low; + const isUp = candles.isUp; + + let upCount = 0; + let downCount = 0; + for (let i = 0; i < candles.count; i++) { + if (isUp[i] !== 0) { + upCount++; + } else { + downCount++; + } + } + + const upData = new Float32Array(upCount * 3 * 4); + const downData = new Float32Array(downCount * 3 * 4); + let upW = 0; + let downW = 0; + + for (let i = 0; i < candles.count; i++) { + const xc = xC[i] - xOrigin; + const o = open[i]; + const c = close[i]; + const lo = low[i]; + const hi = high[i]; + const halfW = hw[i]; + const target = isUp[i] !== 0 ? upData : downData; + let w = isUp[i] !== 0 ? upW : downW; + + // H–L vertical line. + target[w++] = xc; + target[w++] = lo; + target[w++] = xc; + target[w++] = hi; - gl.bindBuffer(gl.ARRAY_BUFFER, cache.cornerBuffer); - gl.enableVertexAttribArray(cache.a_corner); - gl.vertexAttribPointer(cache.a_corner, 1, gl.FLOAT, false, 0, 0); - setDivisor(cache.a_corner, 0); + // Open tick: left-facing horizontal stub at y=open. + target[w++] = xc - halfW; + target[w++] = o; + target[w++] = xc; + target[w++] = o; + + // Close tick: right-facing horizontal stub at y=close. + target[w++] = xc; + target[w++] = c; + target[w++] = xc + halfW; + target[w++] = c; + + if (isUp[i] !== 0) { + upW = w; + } else { + downW = w; + } + } + + const prev = this._buffers; + const upBuf = prev?.upBuffer ?? gl.createBuffer()!; + const downBuf = prev?.downBuffer ?? gl.createBuffer()!; + gl.bindBuffer(gl.ARRAY_BUFFER, upBuf); + gl.bufferData(gl.ARRAY_BUFFER, upData, gl.STATIC_DRAW); + gl.bindBuffer(gl.ARRAY_BUFFER, downBuf); + gl.bufferData(gl.ARRAY_BUFFER, downData, gl.STATIC_DRAW); + + this._buffers = { + upBuffer: upBuf, + downBuffer: downBuf, + upInstanceCount: upCount * 3, + downInstanceCount: downCount * 3, + }; + } + + /** + * Bind the persistent up/down OHLC buffers and dispatch one instanced + * draw per color group. + */ + draw( + chart: CandlestickChart, + gl: GL, + glManager: WebGLContextManager, + projection: Float32Array, + ): void { + const buf = this._buffers; + if ( + !buf || + (buf.upInstanceCount === 0 && buf.downInstanceCount === 0) + ) { + return; + } + + const cache = this.ensureProgram(glManager); + const dpr = glManager.dpr; + + gl.useProgram(cache.program); + gl.uniformMatrix4fv(cache.u_projection, false, projection); + gl.uniform2f(cache.u_resolution, gl.canvas.width, gl.canvas.height); + gl.uniform1f( + cache.u_line_width, + chart._pluginConfig.ohlc_line_width_px * dpr, + ); + + const instancing = getInstancing(glManager); + const { setDivisor } = instancing; + + gl.bindBuffer(gl.ARRAY_BUFFER, cache.cornerBuffer); + gl.enableVertexAttribArray(cache.a_corner); + gl.vertexAttribPointer(cache.a_corner, 1, gl.FLOAT, false, 0, 0); + setDivisor(cache.a_corner, 0); + + drawGroup( + gl, + instancing, + cache, + buf.upBuffer, + buf.upInstanceCount, + chart._upColor, + ); + drawGroup( + gl, + instancing, + cache, + buf.downBuffer, + buf.downInstanceCount, + chart._downColor, + ); + + setDivisor(cache.a_start, 0); + setDivisor(cache.a_end, 0); + } + + destroy(chart: CandlestickChart): void { + const gl = chart._glManager?.gl; + if (gl) { + this.invalidateBuffers(chart); + const cache = this._program; + if (cache) { + gl.deleteBuffer(cache.cornerBuffer); + gl.deleteBuffer(cache.segmentBuffer); + } + } + + this._program = null; + this._buffers = null; + } +} + +function drawGroup( + gl: GL, + instancing: ReturnType, + cache: OHLCCache, + buffer: WebGLBuffer, + instanceCount: number, + color: [number, number, number], +): void { + if (instanceCount === 0) { + return; + } const instanceStride = 4 * Float32Array.BYTES_PER_ELEMENT; const pointSize = 2 * Float32Array.BYTES_PER_ELEMENT; + const { setDivisor } = instancing; - gl.bindBuffer(gl.ARRAY_BUFFER, cache.segmentBuffer); + gl.uniform4f(cache.u_color, color[0], color[1], color[2], 1.0); + gl.bindBuffer(gl.ARRAY_BUFFER, buffer); gl.enableVertexAttribArray(cache.a_start); gl.vertexAttribPointer( cache.a_start, @@ -193,8 +313,5 @@ function drawOHLCGroup( ); setDivisor(cache.a_end, 1); - instancing.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, group.length * 3); - - setDivisor(cache.a_start, 0); - setDivisor(cache.a_end, 0); + instancing.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, instanceCount); } diff --git a/packages/viewer-charts/src/ts/charts/canvas-types.ts b/packages/viewer-charts/src/ts/charts/canvas-types.ts new file mode 100644 index 0000000000..7c4dd4dd0c --- /dev/null +++ b/packages/viewer-charts/src/ts/charts/canvas-types.ts @@ -0,0 +1,30 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +/** + * Either flavor of 2D canvas the chart code may paint to. The chart's + * gridline and chrome canvases are `OffscreenCanvas` once the Host + * transfers rendering control to the Renderer (which it does in + * `LocalTransport` to validate that the chart code is DOM-free for + * worker mode). The GL canvas is also `HTMLCanvasElement | OffscreenCanvas` + * via WebGL's `gl.canvas`. + */ +export type Canvas2D = HTMLCanvasElement | OffscreenCanvas; + +/** + * 2D rendering context for either canvas flavor. The `OffscreenCanvas` + * variant is missing only `getContextAttributes` and `drawFocusIfNeeded` — + * neither used by the chart render paths. + */ +export type Context2D = + | CanvasRenderingContext2D + | OffscreenCanvasRenderingContext2D; diff --git a/packages/viewer-charts/src/ts/charts/continuous/continuous-build.ts b/packages/viewer-charts/src/ts/charts/cartesian/cartesian-build.ts similarity index 63% rename from packages/viewer-charts/src/ts/charts/continuous/continuous-build.ts rename to packages/viewer-charts/src/ts/charts/cartesian/cartesian-build.ts index d265141df3..3b5a468b93 100644 --- a/packages/viewer-charts/src/ts/charts/continuous/continuous-build.ts +++ b/packages/viewer-charts/src/ts/charts/cartesian/cartesian-build.ts @@ -13,29 +13,42 @@ import type { ColumnDataMap, ColumnData } from "../../data/view-reader"; import { buildSplitGroups } from "../../data/split-groups"; import type { WebGLContextManager } from "../../webgl/context-manager"; -import type { ContinuousChart, SplitGroup } from "./continuous-chart"; +import type { CartesianChart, SplitGroup } from "./cartesian"; +import { LabelInterner } from "./label-interner"; /** * Resolve per-split-prefix column-name tuples. `colorBase`/`sizeBase` * are optional (empty string when the corresponding slot is unset). */ -function buildContinuousSplitGroups( +function buildCartesianSplitGroups( columns: ColumnDataMap, xBase: string, yBase: string, colorBase: string, sizeBase: string, + labelBase: string, ): SplitGroup[] { const required = xBase ? [xBase, yBase] : [yBase]; const optional: string[] = []; - if (colorBase) optional.push(colorBase); - if (sizeBase) optional.push(sizeBase); + if (colorBase) { + optional.push(colorBase); + } + + if (sizeBase) { + optional.push(sizeBase); + } + + if (labelBase) { + optional.push(labelBase); + } + return buildSplitGroups(columns, required, optional).map((g) => ({ prefix: g.prefix, xColName: xBase ? g.colNames.get(xBase)! : "", yColName: g.colNames.get(yBase)!, colorColName: colorBase ? `${g.prefix}|${colorBase}` : "", sizeColName: sizeBase ? `${g.prefix}|${sizeBase}` : "", + labelColName: labelBase ? `${g.prefix}|${labelBase}` : "", })); } @@ -43,28 +56,60 @@ function buildContinuousSplitGroups( * First-chunk init: compile the glyph program, reset data extents, * resolve column roles and split groups, pre-allocate CPU + GPU buffers. */ -export function initContinuousPipeline( - chart: ContinuousChart, +export function initCartesianPipeline( + chart: CartesianChart, glManager: WebGLContextManager, columns: ColumnDataMap, endRow: number, ): void { chart.glyph.ensureProgram(chart, glManager); - chart._xMin = Infinity; - chart._xMax = -Infinity; - chart._yMin = Infinity; - chart._yMax = -Infinity; - chart._colorMin = Infinity; - chart._colorMax = -Infinity; - chart._sizeMin = Infinity; - chart._sizeMax = -Infinity; + const prevColorName = chart._colorName; + const prevColorIsString = chart._colorIsString; + + // `domain_mode: "expand"` seeds the per-build extents from the + // running accumulator instead of `±Infinity`, so the per-row scan + // below naturally unions new data into the previously rendered + // domain / range / color / size scales. `"fit"` clears the + // accumulator alongside the live extents so toggling back to + // expand later starts from a fresh baseline. + const expand = chart._pluginConfig.domain_mode === "expand"; + if (expand) { + chart._xMin = chart._expandedXMin; + chart._xMax = chart._expandedXMax; + chart._yMin = chart._expandedYMin; + chart._yMax = chart._expandedYMax; + chart._colorMin = chart._expandedColorMin; + chart._colorMax = chart._expandedColorMax; + chart._sizeMin = chart._expandedSizeMin; + chart._sizeMax = chart._expandedSizeMax; + } else { + chart._xMin = Infinity; + chart._xMax = -Infinity; + chart._yMin = Infinity; + chart._yMax = -Infinity; + chart._colorMin = Infinity; + chart._colorMax = -Infinity; + chart._sizeMin = Infinity; + chart._sizeMax = -Infinity; + chart._expandedXMin = Infinity; + chart._expandedXMax = -Infinity; + chart._expandedYMin = Infinity; + chart._expandedYMax = -Infinity; + chart._expandedColorMin = Infinity; + chart._expandedColorMax = -Infinity; + chart._expandedSizeMin = Infinity; + chart._expandedSizeMax = -Infinity; + } + + chart._xOrigin = NaN; + chart._yOrigin = NaN; chart._dataCount = 0; - chart._uniqueColorLabels = new Map(); chart._hitTest.clear(); chart._maxSeriesUploaded = 0; const slots = chart._columnSlots; + // Line uses `[yBase]` with row-index X; scatter and X/Y Line use // `[xBase, yBase, colorBase, sizeBase]`. A single positional layout // handles both: treat an empty slot[0] as "X = row index". @@ -72,6 +117,7 @@ export function initContinuousPipeline( const yBase = slots[1] || ""; const colorBase = slots[2] || ""; const sizeBase = slots[3] || ""; + const labelBase = slots[4] || ""; chart._xLabel = xBase; chart._yLabel = yBase; chart._xIsRowIndex = !xBase; @@ -84,27 +130,31 @@ export function initContinuousPipeline( const rowsPerSeries = glManager.bufferPool.totalCapacity || endRow; if (chart._splitBy.length > 0) { - chart._splitGroups = buildContinuousSplitGroups( + chart._splitGroups = buildCartesianSplitGroups( columns, xBase, yBase, colorBase, sizeBase, + labelBase, ); if (chart._splitGroups.length === 0) { chart._seriesCapacity = 0; chart._seriesUploadedCounts = []; return; } + // Split mode: per-point columns live under `${prefix}|${base}`. // The `_*Name` fields hold the base names so downstream code // (render labels, tooltip lookup) can present them as one // logical column. The per-facet resolution happens inside - // `processContinuousChunk` via `_splitGroups[i].*ColName`. + // `processCartesianChunk` via `_splitGroups[i].*ColName`. chart._xName = chart._splitGroups[0].xColName; chart._yName = chart._splitGroups[0].yColName; chart._colorName = colorBase; chart._sizeName = sizeBase; + chart._labelName = labelBase; + // Infer dtype from any split's color column — all splits // share the same underlying column type. chart._colorIsString = false; @@ -114,6 +164,7 @@ export function initContinuousPipeline( ); chart._colorIsString = firstColorCol?.type === "string"; } + glManager.ensureBufferCapacity( rowsPerSeries * chart._splitGroups.length, ); @@ -123,6 +174,7 @@ export function initContinuousPipeline( chart._yName = yBase; chart._colorName = colorBase; chart._sizeName = sizeBase; + chart._labelName = labelBase; chart._colorIsString = false; if (chart._colorName) { @@ -131,6 +183,21 @@ export function initContinuousPipeline( } } + // Color label identity persists across `update()` calls so a given + // string keeps the same palette index for as long as the color column + // stays the same — perspective's dictionary encoding does not promise + // a stable index order between batches, so re-seeding from scratch + // would shuffle every label's color on each update. Reset only when + // the user changes the column or its dtype (string ↔ numeric); a + // numeric color column doesn't use this map and clearing keeps it + // small. + if ( + chart._colorName !== prevColorName || + chart._colorIsString !== prevColorIsString + ) { + chart._uniqueColorLabels = new Map(); + } + const numSeries = Math.max(1, chart._splitGroups.length); chart._seriesCapacity = rowsPerSeries; chart._seriesUploadedCounts = new Array(numSeries).fill(0); @@ -140,6 +207,8 @@ export function initContinuousPipeline( chart._yData = new Float32Array(cpuCap); chart._colorData = new Float32Array(cpuCap); chart._rowIndexData = new Int32Array(cpuCap); + + chart._labels = labelBase ? new LabelInterner(cpuCap) : null; } /** @@ -147,18 +216,26 @@ export function initContinuousPipeline( * point, extend extents, write into per-series slots, capture tooltip * data, and let the glyph upload its own GPU attribute buffers. */ -export function processContinuousChunk( - chart: ContinuousChart, +export function processCartesianChunk( + chart: CartesianChart, glManager: WebGLContextManager, columns: ColumnDataMap, startRow: number, chunkLength: number, endRow: number, ): void { - if (!chart._yName) return; + if (!chart._yName) { + return; + } + const sourceLength = chunkLength; - if (sourceLength === 0) return; - if (chart._seriesCapacity === 0) return; + if (sourceLength === 0) { + return; + } + + if (chart._seriesCapacity === 0) { + return; + } const hasSplits = chart._splitGroups.length > 0; @@ -169,12 +246,13 @@ export function processContinuousChunk( // logic in the inner loop reads uniformly from `ser.colorCol` // across both modes. type SeriesSrc = { - xCol: Float32Array | Int32Array | null; - yCol: Float32Array | Int32Array; + xCol: Float32Array | Float64Array | Int32Array | null; + yCol: Float32Array | Float64Array | Int32Array; xValid: Uint8Array | undefined; yValid: Uint8Array | undefined; colorCol: ColumnData | null; - sizeCol: (Float32Array | Int32Array) | null; + sizeCol: (Float32Array | Float64Array | Int32Array) | null; + labelCol: ColumnData | null; }; const series: SeriesSrc[] = []; @@ -182,11 +260,17 @@ export function processContinuousChunk( for (const sg of chart._splitGroups) { const xc = sg.xColName ? columns.get(sg.xColName) : null; const yc = columns.get(sg.yColName); - if (!yc?.values) continue; + if (!yc?.values) { + continue; + } + const sc = sg.sizeColName ? columns.get(sg.sizeColName) : null; const cc = sg.colorColName ? (columns.get(sg.colorColName) ?? null) : null; + const lc = sg.labelColName + ? (columns.get(sg.labelColName) ?? null) + : null; series.push({ xCol: xc?.values ?? null, yCol: yc.values, @@ -194,15 +278,22 @@ export function processContinuousChunk( yValid: yc.valid, colorCol: cc, sizeCol: sc?.values ?? null, + labelCol: lc, }); } } else { const xc = chart._xName ? columns.get(chart._xName) : null; const yc = chart._yName ? columns.get(chart._yName) : null; - if (!yc?.values) return; + if (!yc?.values) { + return; + } + const cc = chart._colorName ? (columns.get(chart._colorName) ?? null) : null; + const lc = chart._labelName + ? (columns.get(chart._labelName) ?? null) + : null; series.push({ xCol: xc?.values ?? null, yCol: yc.values, @@ -210,10 +301,13 @@ export function processContinuousChunk( yValid: yc?.valid, colorCol: cc, sizeCol: null, + labelCol: lc, }); } - if (series.length === 0) return; + if (series.length === 0) { + return; + } if (chart._stagingChunkSize < sourceLength) { chart._stagingPositions = new Float32Array(sourceLength * 2); @@ -221,6 +315,7 @@ export function processContinuousChunk( chart._stagingSizes = new Float32Array(sourceLength); chart._stagingChunkSize = sourceLength; } + const positions = chart._stagingPositions!; const colorValues = chart._stagingColors!; const sizeValues = chart._stagingSizes!; @@ -247,7 +342,10 @@ export function processContinuousChunk( if (chart._colorIsString && chart._colorName) { for (const ser of series) { const dict = ser.colorCol?.dictionary; - if (!dict) continue; + if (!dict) { + continue; + } + for (let i = 0; i < dict.length; i++) { const s = dict[i]; if (!chart._uniqueColorLabels.has(s)) { @@ -258,42 +356,105 @@ export function processContinuousChunk( } } } + if (chart._uniqueColorLabels.size > 0) { chart._colorMin = 0; chart._colorMax = chart._uniqueColorLabels.size - 1; } } + // Faceted-no-Color: pin the color range to the facet-index domain + // so the vertex shader's linear `(v - cmin) / (cmax - cmin)` + // mapping lands per-point at LUT stop `s / (N-1)`. Without this + // pin, `_colorMin/_colorMax` would stay at the +Inf/-Inf sentinel + // and every facet's points would sample the LUT center. + if (!chart._colorName && chart._splitGroups.length > 1) { + chart._colorMin = 0; + chart._colorMax = chart._splitGroups.length - 1; + } + for (let s = 0; s < series.length; s++) { const ser = series[s]; const prevCount = chart._seriesUploadedCounts[s] ?? 0; const slotBase = s * chart._seriesCapacity; const maxWrite = chart._seriesCapacity - prevCount; - if (maxWrite <= 0) continue; + if (maxWrite <= 0) { + continue; + } + const colorValid = ser.colorCol?.valid; let writeIdx = 0; for (let j = 0; j < sourceLength && writeIdx < maxWrite; j++) { const i = j; - if (ser.yValid && !((ser.yValid[i >> 3] >> (i & 7)) & 1)) continue; + if (ser.yValid && !((ser.yValid[i >> 3] >> (i & 7)) & 1)) { + continue; + } + if ( ser.xCol && ser.xValid && !((ser.xValid[i >> 3] >> (i & 7)) & 1) - ) + ) { continue; + } + + const colorIsNull = + colorValid !== undefined && + !((colorValid[i >> 3] >> (i & 7)) & 1); - const y = ser.yCol[i] as number; - const x = ser.xCol ? (ser.xCol[i] as number) : startRow + i; - if (isNaN(x) || isNaN(y)) continue; + const rawY = ser.yCol[i] as number; + const rawX = ser.xCol ? (ser.xCol[i] as number) : startRow + i; + if (isNaN(rawX) || isNaN(rawY)) { + continue; + } - if (x < chart._xMin) chart._xMin = x; - if (x > chart._xMax) chart._xMax = x; - if (y < chart._yMin) chart._yMin = y; - if (y > chart._yMax) chart._yMax = y; + // Project raw (x, y) → data-space (x, y). Default is + // identity for cartesian charts; map subclasses override + // to apply Mercator. Second NaN guard catches projection + // failures (e.g. Mercator's ±85° lat clamp). + const [x, y] = chart.projectPoint(rawX, rawY); + if (isNaN(x) || isNaN(y)) { + continue; + } + + if (x < chart._xMin) { + chart._xMin = x; + } + + if (x > chart._xMax) { + chart._xMax = x; + } + + if (y < chart._yMin) { + chart._yMin = y; + } + + if (y > chart._yMax) { + chart._yMax = y; + } + + // Capture rebase origins from the first valid sample. The + // origin is f64 in JS state but applied before every f32 + // store below — `_xData`, `_yData`, and the GPU `positions` + // staging buffer all hold rebased values, so the projection + // matrix's `tx`/`ty` terms (built from rebased extents in + // cartesian-render) stay near zero and the shader's + // `sx*x + tx` cancellation is precision-safe. + if (isNaN(chart._xOrigin)) { + chart._xOrigin = x; + } + + if (isNaN(chart._yOrigin)) { + chart._yOrigin = y; + } + + const xr = x - chart._xOrigin; + const yr = y - chart._yOrigin; const flatIdx = slotBase + prevCount + writeIdx; - chart._xData![flatIdx] = x; - chart._yData![flatIdx] = y; + chart._xData![flatIdx] = xr; + chart._yData![flatIdx] = yr; + // Remember the source arrow row this slot came from so // lazy tooltip fetches can resolve columns on demand. In // split mode each series duplicates the same arrow row @@ -301,22 +462,30 @@ export function processContinuousChunk( // row regardless of `s`. chart._rowIndexData![flatIdx] = startRow + i; - positions[writeIdx * 2] = x; - positions[writeIdx * 2 + 1] = y; + positions[writeIdx * 2] = xr; + positions[writeIdx * 2 + 1] = yr; - // ── Color: unified resolution for split + non-split. + // Color: unified resolution for split + non-split. // Read from this series' own color column (facet-specific // in split mode, the chart-wide column otherwise). Scales // (`_colorMin/_colorMax` and `_uniqueColorLabels`) are // shared across every series so identical values render // as identical colors in every facet. const cc = ser.colorCol; - if (cc && !chart._colorIsString && cc.values) { + if (colorIsNull) { + colorValues[writeIdx] = 0.5; + chart._colorData![flatIdx] = 0.5; + } else if (cc && !chart._colorIsString && cc.values) { const v = cc.values[i] as number; colorValues[writeIdx] = v; chart._colorData![flatIdx] = v; - if (v < chart._colorMin) chart._colorMin = v; - if (v > chart._colorMax) chart._colorMax = v; + if (v < chart._colorMin) { + chart._colorMin = v; + } + + if (v > chart._colorMax) { + chart._colorMax = v; + } } else if ( cc && chart._colorIsString && @@ -324,6 +493,7 @@ export function processContinuousChunk( cc.dictionary ) { const label = cc.dictionary[cc.indices[i]]; + // Dict-seeding above ensures this label is already // in `_uniqueColorLabels`; defensive insert for any // value that appears in data but not the dictionary @@ -335,27 +505,55 @@ export function processContinuousChunk( ); chart._colorMax = chart._uniqueColorLabels.size - 1; } + const idx = chart._uniqueColorLabels.get(label)!; colorValues[writeIdx] = idx; chart._colorData![flatIdx] = idx; + // Skip min/max updates — they were pinned to the full // palette-index domain during seeding. } else { - colorValues[writeIdx] = 0.5; - chart._colorData![flatIdx] = 0.5; + colorValues[writeIdx] = s; + chart._colorData![flatIdx] = s; } - // ── Size: per-split size column, or global sizeName. + // Label: resolve the slot's string via the column's arrow + // dictionary; `LabelInterner.set` deduplicates across + // facets so identical strings share an entry. Non-string + // or unencoded label columns are silently skipped — the + // slot stays at its `-1` initialization. + if (chart._labels && ser.labelCol) { + const lc = ser.labelCol; + const labelValid = lc.valid; + const labelIsNull = + labelValid !== undefined && + !((labelValid[i >> 3] >> (i & 7)) & 1); + if (!labelIsNull && lc.indices && lc.dictionary) { + chart._labels.set(flatIdx, lc.dictionary[lc.indices[i]]); + } + } + + // Size: per-split size column, or global sizeName. if (ser.sizeCol) { const v = ser.sizeCol[i] as number; sizeValues[writeIdx] = v; - if (v < chart._sizeMin) chart._sizeMin = v; - if (v > chart._sizeMax) chart._sizeMax = v; + if (v < chart._sizeMin) { + chart._sizeMin = v; + } + + if (v > chart._sizeMax) { + chart._sizeMax = v; + } } else if (nonSplitSizeValues) { const v = nonSplitSizeValues[i] as number; sizeValues[writeIdx] = v; - if (v < chart._sizeMin) chart._sizeMin = v; - if (v > chart._sizeMax) chart._sizeMax = v; + if (v < chart._sizeMin) { + chart._sizeMin = v; + } + + if (v > chart._sizeMax) { + chart._sizeMax = v; + } } else { sizeValues[writeIdx] = 0; } @@ -363,7 +561,9 @@ export function processContinuousChunk( writeIdx++; } - if (writeIdx === 0) continue; + if (writeIdx === 0) { + continue; + } // Upload the shared position buffer for this series's new slice. const positionByteOffset = @@ -397,7 +597,10 @@ export function processContinuousChunk( // Total dataCount = sum of all series' uploaded counts. let total = 0; - for (const c of chart._seriesUploadedCounts) total += c; + for (const c of chart._seriesUploadedCounts) { + total += c; + } + chart._dataCount = total; glManager.uploadedCount = total; chart._hitTest.markDirty(); diff --git a/packages/viewer-charts/src/ts/charts/continuous/continuous-interact.ts b/packages/viewer-charts/src/ts/charts/cartesian/cartesian-interact.ts similarity index 71% rename from packages/viewer-charts/src/ts/charts/continuous/continuous-interact.ts rename to packages/viewer-charts/src/ts/charts/cartesian/cartesian-interact.ts index 1faac5da7b..4d128c8cd0 100644 --- a/packages/viewer-charts/src/ts/charts/continuous/continuous-interact.ts +++ b/packages/viewer-charts/src/ts/charts/cartesian/cartesian-interact.ts @@ -10,8 +10,8 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import type { ContinuousChart } from "./continuous-chart"; -import { renderContinuousChromeOverlay } from "./continuous-render"; +import type { CartesianChart } from "./cartesian"; +import { renderCartesianChromeOverlay } from "./cartesian-render"; const TOOLTIP_RADIUS_PX = 24; @@ -20,18 +20,28 @@ const TOOLTIP_RADIUS_PX = 24; * point buffers. Walks every series slot so ranges with gaps (unused * tails) are skipped naturally. */ -function ensureContinuousSpatialGrid(chart: ContinuousChart): void { - if (!chart._hitTest.isDirty || !chart._xData || !chart._yData) return; +function ensureCartesianSpatialGrid(chart: CartesianChart): void { + if (!chart._hitTest.isDirty || !chart._xData || !chart._yData) { + return; + } + const xData = chart._xData; const yData = chart._yData; const numSeries = Math.max(1, chart._splitGroups.length); const cap = chart._seriesCapacity; + + // `_xData`/`_yData` hold rebased values (`absolute - origin`) so the + // f32 GPU pipeline keeps sub-millisecond precision for datetime + // axes; the spatial-grid bounds and queries below must live in the + // same rebased space. + const xOrigin = isNaN(chart._xOrigin) ? 0 : chart._xOrigin; + const yOrigin = isNaN(chart._yOrigin) ? 0 : chart._yOrigin; chart._hitTest.rebuild( { - xMin: chart._xMin, - xMax: chart._xMax, - yMin: chart._yMin, - yMax: chart._yMax, + xMin: chart._xMin - xOrigin, + xMax: chart._xMax - xOrigin, + yMin: chart._yMin - yOrigin, + yMax: chart._yMax - yOrigin, }, chart._dataCount, (insert) => { @@ -47,7 +57,7 @@ function ensureContinuousSpatialGrid(chart: ContinuousChart): void { } /** - * Update {@link ContinuousChart._hoveredIndex} for the given mouse + * Update {@link CartesianChart._hoveredIndex} for the given mouse * position. Triggers a chrome re-render if the hovered index changes. * * In faceted mode, the hit test first resolves which facet the mouse is @@ -55,12 +65,14 @@ function ensureContinuousSpatialGrid(chart: ContinuousChart): void { * makes hover local to a facet; coordinated ghost indicators in other * facets are painted by the chrome overlay. */ -export function handleContinuousHover( - chart: ContinuousChart, +export function handleCartesianHover( + chart: CartesianChart, mx: number, my: number, ): void { - if (!chart._xData || !chart._yData) return; + if (!chart._xData || !chart._yData) { + return; + } // Resolve the facet (and its layout) under the cursor. Non-facet // charts have `_facetGrid = null` and fall back to the cached @@ -91,14 +103,28 @@ export function handleContinuousHover( const pxPerDataX = plot.width / (xMax - xMin); const pxPerDataY = plot.height / (yMax - yMin); + // `_xData`/`_yData` are rebased (see `processCartesianChunk`), so + // pass rebased mouse coords into the hit-test routines below; range + // ratios (`pxPerData*`) are translation-invariant and stay as-is. + const xOrigin = isNaN(chart._xOrigin) ? 0 : chart._xOrigin; + const yOrigin = isNaN(chart._yOrigin) ? 0 : chart._yOrigin; + const dataXRebased = dataX - xOrigin; + const dataYRebased = dataY - yOrigin; + const bestIdx = facetIdx < 0 - ? hoverAllSeries(chart, dataX, dataY, pxPerDataX, pxPerDataY) + ? hoverAllSeries( + chart, + dataXRebased, + dataYRebased, + pxPerDataX, + pxPerDataY, + ) : hoverOneSeries( chart, facetIdx, - dataX, - dataY, + dataXRebased, + dataYRebased, pxPerDataX, pxPerDataY, ); @@ -106,30 +132,31 @@ export function handleContinuousHover( if (bestIdx !== chart._hoveredIndex || facetIdx !== chart._hoveredFacet) { chart._hoveredIndex = bestIdx; chart._hoveredFacet = facetIdx; - chart._hoveredTooltipLines = null; - const serial = ++chart._hoveredTooltipSerial; if (bestIdx >= 0) { - // Fire the lazy tooltip build; when it resolves, we only - // apply the result if the user is still hovering the same - // point (compare against the latest serial). The crosshair - // / highlight ring are painted immediately from geometry - // so the hover feels instant; the tooltip box fills in - // once the row arrives (no "loading…" flicker). + // Fire the lazy tooltip build; the controller drops stale + // resolves so rapid mouse motion can't paint out-of-date + // text. Crosshair / highlight ring are painted immediately + // from geometry so the hover feels instant; the tooltip + // box fills in once the row arrives (no "loading…" flicker). + const serial = chart._lazyTooltip.beginHover(bestIdx); chart.glyph.buildTooltipLines(chart, bestIdx).then((lines) => { - if (serial !== chart._hoveredTooltipSerial) return; - chart._hoveredTooltipLines = lines; - renderContinuousChromeOverlay(chart); + if (chart._lazyTooltip.commitHover(serial, lines)) { + renderCartesianChromeOverlay(chart); + } }); + } else { + chart._lazyTooltip.clearHover(); } - renderContinuousChromeOverlay(chart); + + renderCartesianChromeOverlay(chart); } } -function clearHover(chart: ContinuousChart): void { +function clearHover(chart: CartesianChart): void { if (chart._hoveredIndex !== -1 || chart._hoveredFacet !== -1) { chart._hoveredIndex = -1; chart._hoveredFacet = -1; - renderContinuousChromeOverlay(chart); + renderCartesianChromeOverlay(chart); } } @@ -141,7 +168,7 @@ function clearHover(chart: ContinuousChart): void { * clears hover in that case. */ function resolveHoverTarget( - chart: ContinuousChart, + chart: CartesianChart, mx: number, my: number, ): { @@ -161,19 +188,21 @@ function resolveHoverTarget( return { layout: cells[i].layout, facetIdx: i }; } } + return { layout: null, facetIdx: -1 }; } + return { layout: chart._lastLayout, facetIdx: -1 }; } function hoverAllSeries( - chart: ContinuousChart, + chart: CartesianChart, dataX: number, dataY: number, pxPerDataX: number, pxPerDataY: number, ): number { - ensureContinuousSpatialGrid(chart); + ensureCartesianSpatialGrid(chart); let bestIdx = chart._hitTest.query( dataX, dataY, @@ -183,7 +212,9 @@ function hoverAllSeries( chart._xData, chart._yData, ); - if (bestIdx >= 0) return bestIdx; + if (bestIdx >= 0) { + return bestIdx; + } // Brute-force fallback over every valid slot. let bestDistSq = TOOLTIP_RADIUS_PX * TOOLTIP_RADIUS_PX; @@ -205,6 +236,7 @@ function hoverAllSeries( } } } + return bestIdx; } @@ -216,7 +248,7 @@ function hoverAllSeries( * are read. */ function hoverOneSeries( - chart: ContinuousChart, + chart: CartesianChart, seriesIdx: number, dataX: number, dataY: number, @@ -224,7 +256,10 @@ function hoverOneSeries( pxPerDataY: number, ): number { const count = chart._seriesUploadedCounts[seriesIdx] ?? 0; - if (count === 0) return -1; + if (count === 0) { + return -1; + } + const cap = chart._seriesCapacity; const base = seriesIdx * cap; const xData = chart._xData!; @@ -242,6 +277,7 @@ function hoverOneSeries( bestIdx = idx; } } + return bestIdx; } @@ -252,53 +288,68 @@ function hoverOneSeries( * In faceted mode, resolves the source facet from `pointIdx` and uses * that cell's layout so the tooltip anchors to the correct sub-plot. */ -export function showContinuousPinnedTooltip( - chart: ContinuousChart, +export function showCartesianPinnedTooltip( + chart: CartesianChart, pointIdx: number, ): void { - chart._tooltip.dismissPinned(); + chart._tooltip.dismiss(); chart._pinnedIndex = pointIdx; - if (pointIdx < 0 || !chart._xData || !chart._yData) return; + if (pointIdx < 0 || !chart._xData || !chart._yData) { + return; + } const layout = layoutForIndex(chart, pointIdx); - if (!layout) return; + if (!layout) { + return; + } + const xOrigin = isNaN(chart._xOrigin) ? 0 : chart._xOrigin; + const yOrigin = isNaN(chart._yOrigin) ? 0 : chart._yOrigin; const pos = layout.dataToPixel( - chart._xData[pointIdx], - chart._yData[pointIdx], + chart._xData[pointIdx] + xOrigin, + chart._yData[pointIdx] + yOrigin, ); - const parent = chart._glCanvas?.parentElement; - if (!parent) return; - - const serial = ++chart._pinnedTooltipSerial; + const serial = chart._lazyTooltip.beginPin(); chart.glyph.buildTooltipLines(chart, pointIdx).then((lines) => { // Abandon the pin if the user moved on (another pin/dismiss // between click and resolve) or the underlying view changed. - if (serial !== chart._pinnedTooltipSerial) return; - if (chart._pinnedIndex !== pointIdx) return; - if (lines.length === 0) return; - chart._tooltip.showPinned(parent, lines, pos, layout); + if (!chart._lazyTooltip.isPinFresh(serial)) { + return; + } + + if (chart._pinnedIndex !== pointIdx) { + return; + } + + if (lines.length === 0) { + return; + } + + chart._tooltip.pin(lines, pos, layout); }); chart._hoveredIndex = -1; chart._hoveredFacet = -1; - renderContinuousChromeOverlay(chart); + renderCartesianChromeOverlay(chart); } function layoutForIndex( - chart: ContinuousChart, + chart: CartesianChart, pointIdx: number, ): import("../../layout/plot-layout").PlotLayout | null { if (chart._facetGrid && chart._seriesCapacity > 0) { const s = Math.floor(pointIdx / chart._seriesCapacity); const cell = chart._facetGrid.cells[s]; - if (cell) return cell.layout; + if (cell) { + return cell.layout; + } } + return chart._lastLayout; } -export function dismissContinuousPinnedTooltip(chart: ContinuousChart): void { - chart._tooltip.dismissPinned(); +export function dismissCartesianPinnedTooltip(chart: CartesianChart): void { + chart._tooltip.dismiss(); chart._pinnedIndex = -1; } diff --git a/packages/viewer-charts/src/ts/charts/continuous/continuous-render.ts b/packages/viewer-charts/src/ts/charts/cartesian/cartesian-render.ts similarity index 66% rename from packages/viewer-charts/src/ts/charts/continuous/continuous-render.ts rename to packages/viewer-charts/src/ts/charts/cartesian/cartesian-render.ts index 3d9fae0cf7..6a240cdc2f 100644 --- a/packages/viewer-charts/src/ts/charts/continuous/continuous-render.ts +++ b/packages/viewer-charts/src/ts/charts/cartesian/cartesian-render.ts @@ -10,11 +10,18 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +import type { Canvas2D } from "../canvas-types"; +import { drawFacetTitle } from "../../axis/facet-chrome"; import type { WebGLContextManager } from "../../webgl/context-manager"; -import type { ContinuousChart } from "./continuous-chart"; +import type { CartesianChart } from "./cartesian"; import { PlotLayout } from "../../layout/plot-layout"; -import { buildFacetGrid, type FacetGrid } from "../../layout/facet-grid"; -import { resolveTheme, readSeriesPalette, type Theme } from "../../theme/theme"; +import { + buildFacetGrid, + bottomRowLayouts, + leftColumnLayouts, + type FacetGrid, +} from "../../layout/facet-grid"; +import { type Theme } from "../../theme/theme"; import { resolvePalette } from "../../theme/palette"; import { paletteToStops } from "../../theme/gradient"; import { @@ -33,14 +40,21 @@ import { renderOuterXAxis, renderOuterYAxis, type AxisDomain, -} from "../../chrome/numeric-axis"; -import { initCanvas } from "../../chrome/canvas"; +} from "../../axis/numeric-axis"; +import { initCanvas, getScaledContext } from "../../axis/canvas"; import { renderLegend, renderLegendAt, renderCategoricalLegend, renderCategoricalLegendAt, -} from "../../chrome/legend"; +} from "../../axis/legend"; + +/** + * NaN guard: `_xOrigin`/`_yOrigin` start as NaN before the first valid sample. + */ +function rebaseOrigin(o: number): number { + return isNaN(o) ? 0 : o; +} /** * Full-frame render: gridlines → glyph draw inside the plot-frame @@ -50,43 +64,30 @@ import { * * - `"overlay"` (legacy): a single plot rect; all split series are * drawn together, distinguished by color. This is the pre-facet - * behavior, preserved for manual opt-in via `FACET_CONFIG`. + * behavior, preserved for manual opt-in via `plugin_config.facet_mode`. * - `"grid"` (default): when splits are present, `_splitGroups` laid * out as a grid of sub-plots by {@link buildFacetGrid}. When splits * are absent, falls through to the single-plot path — identical to * the `"overlay"` case with 0 splits, so the non-split render path * is byte-for-byte unchanged from before this feature. */ -export function renderContinuousFrame( - chart: ContinuousChart, +export function renderCartesianFrame( + chart: CartesianChart, glManager: WebGLContextManager, ): void { const gl = glManager.gl; - const dpr = window.devicePixelRatio || 1; + const dpr = glManager.dpr; const cssWidth = gl.canvas.width / dpr; const cssHeight = gl.canvas.height / dpr; - if (cssWidth <= 0 || cssHeight <= 0) return; + if (cssWidth <= 0 || cssHeight <= 0) { + return; + } const hasSplits = chart._splitGroups.length > 0; const facetMode = chart._facetConfig.facet_mode; const useGrid = hasSplits && facetMode === "grid"; - // Shared axes and independent zoom are incompatible: the outer - // axis band would display domain values that don't match any - // single cell's zoom. Force shared axes off when independent zoom - // is active; per-cell axes then reflect each cell's own domain. - if (useGrid && chart._facetConfig.zoom_mode === "independent") { - if ( - chart._facetConfig.shared_x_axis || - chart._facetConfig.shared_y_axis - ) { - chart._facetConfig = { - ...chart._facetConfig, - shared_x_axis: false, - shared_y_axis: false, - }; - } - } + chart.computeEffectiveFacetFlags(); // Legend appears only when the user wired a color column with a // non-degenerate range. `split_by` alone no longer forces a @@ -110,13 +111,13 @@ export function renderContinuousFrame( yMax: chart._yMax, }; } - if (!isFinite(domain.xMin) || !isFinite(domain.yMin)) return; - const themeEl = chart._gridlineCanvas!; - const theme = resolveTheme(themeEl); - chart._lastTheme = theme; - const seriesPalette = readSeriesPalette(themeEl); - chart._lastSeriesPalette = seriesPalette; + if (!isFinite(domain.xMin) || !isFinite(domain.yMin)) { + return; + } + + const theme = chart._resolveTheme(); + const seriesPalette = theme.seriesPalette; const xType = chart._columnTypes[chart._xLabel] || ""; const yType = chart._columnTypes[chart._yLabel] || ""; @@ -140,10 +141,19 @@ export function renderContinuousFrame( let lutStops = theme.gradientStops; if (isCategorical || hasNoColorSource) { const labelCount = hasNoColorSource - ? 1 + ? Math.max(1, chart._splitGroups.length) : Math.max(1, chart._uniqueColorLabels.size); - const key = `${labelCount}|${seriesPalette.length}`; - if (chart._lastLutStops && chart._lastLutKey === key) { + + // Cache key carries the `seriesPalette` reference (changes per + // theme — `_resolveTheme` returns a fresh `Theme` after + // `invalidateTheme()`) plus `labelCount`. Reference compare + // catches theme switches that the prior length-only key + // missed. + if ( + chart._lastLutStops && + chart._lastLutSeriesPalette === seriesPalette && + chart._lastLutLabelCount === labelCount + ) { lutStops = chart._lastLutStops; } else { const palette = resolvePalette( @@ -153,12 +163,15 @@ export function renderContinuousFrame( ); lutStops = paletteToStops(palette); chart._lastLutStops = lutStops; - chart._lastLutKey = key; + chart._lastLutSeriesPalette = seriesPalette; + chart._lastLutLabelCount = labelCount; } } else { chart._lastLutStops = null; - chart._lastLutKey = ""; + chart._lastLutSeriesPalette = null; + chart._lastLutLabelCount = -1; } + chart._gradientCache = ensureGradientTexture( glManager, chart._gradientCache, @@ -184,7 +197,7 @@ export function renderContinuousFrame( }); } - renderContinuousChromeOverlay(chart); + renderCartesianChromeOverlay(chart); } interface RenderFrameCtx { @@ -199,7 +212,7 @@ interface SinglePlotCtx extends RenderFrameCtx { } function buildXDomain( - chart: ContinuousChart, + chart: CartesianChart, min: number, max: number, isDate: boolean, @@ -214,7 +227,7 @@ function buildXDomain( } function buildYDomain( - chart: ContinuousChart, + chart: CartesianChart, min: number, max: number, isDate: boolean, @@ -233,7 +246,7 @@ function buildYDomain( * or when `facet_mode === "overlay"`. */ function renderSinglePlotFrame( - chart: ContinuousChart, + chart: CartesianChart, glManager: WebGLContextManager, domain: { xMin: number; xMax: number; yMin: number; yMax: number }, theme: Theme, @@ -248,27 +261,62 @@ function renderSinglePlotFrame( hasLegend: hasColorCol, }); chart._lastLayout = layout; - if (chart._zoomController) chart._zoomController.updateLayout(layout); + if (chart._zoomController) { + chart._zoomController.updateLayout(layout); + } const projection = layout.buildProjectionMatrix( domain.xMin, domain.xMax, domain.yMin, domain.yMax, + undefined, + undefined, + undefined, + rebaseOrigin(chart._xOrigin), + rebaseOrigin(chart._yOrigin), ); const xDomain = buildXDomain(chart, domain.xMin, domain.xMax, xIsDate); const yDomain = buildYDomain(chart, domain.yMin, domain.yMax, yIsDate); const { xTicks, yTicks } = computeTicks(xDomain, yDomain, layout); - if (chart._gridlineCanvas) { + const isMap = chart._renderMode === "map"; + + if (chart._gridlineCanvas && !isMap) { // One-shot destructive prep (resizes + clears + scales to DPR). // `renderGridlines` itself is non-destructive. - initCanvas(chart._gridlineCanvas, layout); - renderGridlines(chart._gridlineCanvas, layout, xTicks, yTicks, theme); + const dpr = glManager.dpr; + initCanvas(chart._gridlineCanvas, layout, dpr); + renderGridlines( + chart._gridlineCanvas, + layout, + xTicks, + yTicks, + theme, + dpr, + ); + } else if (chart._gridlineCanvas && isMap) { + // Map mode draws no cartesian gridlines, but the gridline + // canvas may carry stale ink from a prior cartesian chart + // type. Reset it to a clean transparent surface so the + // basemap (rendered into the GL canvas below) reads as the + // only background layer. + initCanvas(chart._gridlineCanvas, layout, glManager.dpr); } - renderInPlotFrame(gl, layout, () => { + renderInPlotFrame(gl, layout, glManager.dpr, () => { + if (isMap) { + chart.renderBackground( + glManager, + layout, + projection, + domain, + rebaseOrigin(chart._xOrigin), + rebaseOrigin(chart._yOrigin), + ); + } + chart.glyph.draw(chart, glManager, projection); }); @@ -292,7 +340,7 @@ function renderSinglePlotFrame( * per-facet `ZoomController`. */ function renderFacetedFrame( - chart: ContinuousChart, + chart: CartesianChart, glManager: WebGLContextManager, domain: { xMin: number; xMax: number; yMin: number; yMax: number }, theme: Theme, @@ -302,6 +350,7 @@ function renderFacetedFrame( const { cssWidth, cssHeight, xIsDate, yIsDate } = ctx; const labels = chart._splitGroups.map((g) => g.prefix); + // Legend: reserve space only when the user wired a color column. // - string column: categorical swatches from `_uniqueColorLabels`. // - numeric column: gradient bar from `_colorMin/_colorMax`. @@ -313,15 +362,19 @@ function renderFacetedFrame( !chart._colorIsString && chart._colorMin < chart._colorMax; const hasLegend = hasCategoricalLegend || hasGradientLegend; - // `FacetConfig.shared_x_axis` / `shared_y_axis` are booleans; - // continuous charts always have both axes, so the false branch - // maps to the per-cell mode (never to the axis-less "none" mode, - // which is reserved for tree charts). + + // Use the frame-local effective flags (set in + // `renderCartesianFrame`) so independent-zoom mode falls through + // to per-cell axes without mutating the user's stored + // `_facetConfig.shared_x_axis` / `shared_y_axis`. Continuous + // charts always have both axes, so the false branch maps to + // per-cell mode (never to "none", which is reserved for tree + // charts). const grid: FacetGrid = buildFacetGrid(labels, { cssWidth, cssHeight, - xAxis: chart._facetConfig.shared_x_axis ? "outer" : "cell", - yAxis: chart._facetConfig.shared_y_axis ? "outer" : "cell", + xAxis: chart._lastEffectiveSharedX ? "outer" : "cell", + yAxis: chart._lastEffectiveSharedY ? "outer" : "cell", hasLegend, hasXLabel: !!chart._xLabel, hasYLabel: !!chart._yLabel, @@ -354,12 +407,8 @@ function renderFacetedFrame( chart._lastLayout = grid.cells[0]?.layout ?? null; // Keep every controller's layout pointer fresh for wheel/pan math. + chart.syncFacetZoomLayouts(grid.cells); const independent = chart._facetConfig.zoom_mode === "independent"; - for (let i = 0; i < grid.cells.length; i++) { - const zc = chart.getZoomControllerForFacet(i); - if (zc) zc.updateLayout(grid.cells[i].layout); - if (!independent) break; - } const xDomain = buildXDomain(chart, domain.xMin, domain.xMax, xIsDate); const yDomain = buildYDomain(chart, domain.yMin, domain.yMax, yIsDate); @@ -378,8 +427,9 @@ function renderFacetedFrame( // every previously-drawn facet, leaving only the last cell // visible. if (chart._gridlineCanvas && sampleLayout) { - initCanvas(chart._gridlineCanvas, sampleLayout); + initCanvas(chart._gridlineCanvas, sampleLayout, glManager.dpr); } + clearAndSetupFrame(gl); for (let i = 0; i < grid.cells.length; i++) { @@ -399,12 +449,20 @@ function renderFacetedFrame( facetDomain.xMax, facetDomain.yMin, facetDomain.yMax, + undefined, + undefined, + undefined, + rebaseOrigin(chart._xOrigin), + rebaseOrigin(chart._yOrigin), ); // Per-facet gridlines: reuse shared ticks in shared-zoom mode, // compute fresh ticks in independent mode (each facet has its - // own domain). - if (chart._gridlineCanvas) { + // own domain). Map mode skips gridlines entirely; the + // basemap layer is rendered into the GL canvas inside the + // facet's scissor below. + const isMap = chart._renderMode === "map"; + if (chart._gridlineCanvas && !isMap) { const localXTicks = independent ? computeTicks( buildXDomain( @@ -445,9 +503,22 @@ function renderFacetedFrame( localXTicks, localYTicks, theme, + glManager.dpr, ); } - withScissor(gl, cell.layout, () => { + + withScissor(gl, cell.layout, glManager.dpr, () => { + if (isMap) { + chart.renderBackground( + glManager, + cell.layout, + projection, + facetDomain, + rebaseOrigin(chart._xOrigin), + rebaseOrigin(chart._yOrigin), + ); + } + chart.glyph.drawSeries(chart, glManager, projection, i); }); } @@ -463,20 +534,22 @@ function renderFacetedFrame( /** * Redraw the chrome canvas only. Used for lightweight hover updates. */ -export function renderContinuousChromeOverlay(chart: ContinuousChart): void { +export function renderCartesianChromeOverlay(chart: CartesianChart): void { if ( !chart._chromeCanvas || !chart._lastLayout || !chart._lastXDomain || - !chart._lastYDomain - ) + !chart._lastYDomain || + !chart._glManager + ) { return; + } // One-shot destructive prep for the chrome canvas — resizes to // CSS × DPR and scales the transform. Per-facet calls below read // the already-prepared context via `getScaledContext` so the // bitmap persists across the loop. - initCanvas(chart._chromeCanvas, chart._lastLayout); + initCanvas(chart._chromeCanvas, chart._lastLayout, chart._glManager.dpr); if (chart._facetGrid) { renderFacetedChromeOverlay(chart); } else { @@ -484,28 +557,34 @@ export function renderContinuousChromeOverlay(chart: ContinuousChart): void { } } -function renderSinglePlotChromeOverlay(chart: ContinuousChart): void { +function renderSinglePlotChromeOverlay(chart: CartesianChart): void { const layout = chart._lastLayout!; - const theme = chart._lastTheme ?? resolveTheme(chart._chromeCanvas!); + const theme = chart._resolveTheme(); + const dpr = chart._glManager?.dpr ?? 1; + const isMap = chart._renderMode === "map"; - renderAxesChrome( - chart._chromeCanvas!, - chart._lastXDomain!, - chart._lastYDomain!, - layout, - chart._lastXTicks!, - chart._lastYTicks!, - theme, - ); + if (isMap) { + chart.renderMapChrome(chart._chromeCanvas!, layout, theme, dpr); + } else { + renderAxesChrome( + chart._chromeCanvas!, + chart._lastXDomain!, + chart._lastYDomain!, + layout, + chart._lastXTicks!, + chart._lastYTicks!, + theme, + dpr, + chart.getColumnFormatter(chart._xName, "tick"), + chart.getColumnFormatter(chart._yName, "tick"), + ); + } if (chart._lastHasColorCol) { const stops = chart._lastGradientStops ?? theme.gradientStops; if (chart._colorIsString && chart._uniqueColorLabels.size > 0) { - const seriesPalette = - chart._lastSeriesPalette ?? - readSeriesPalette(chart._chromeCanvas!); const palette = resolvePalette( - seriesPalette, + theme.seriesPalette, stops, chart._uniqueColorLabels.size, ); @@ -514,6 +593,7 @@ function renderSinglePlotChromeOverlay(chart: ContinuousChart): void { layout, chart._uniqueColorLabels, palette, + theme, ); } else if (chart._colorName) { renderLegend( @@ -525,65 +605,79 @@ function renderSinglePlotChromeOverlay(chart: ContinuousChart): void { label: chart._colorName, }, stops, + theme, + chart.getColumnFormatter(chart._colorName, "value"), ); } } + renderScatterLabels(chart, chart._chromeCanvas!, layout, 0, 1); + if (chart._hoveredIndex >= 0 && chart._xData && chart._yData) { renderTooltip(chart, chart._chromeCanvas!, layout); } } -function renderFacetedChromeOverlay(chart: ContinuousChart): void { +function renderFacetedChromeOverlay(chart: CartesianChart): void { const grid = chart._facetGrid!; const canvas = chart._chromeCanvas!; - const theme = chart._lastTheme ?? resolveTheme(canvas); + const theme = chart._resolveTheme(); + const dpr = chart._glManager?.dpr ?? 1; const sharedXTicks = chart._lastXTicks!; const sharedYTicks = chart._lastYTicks!; const xDomain = chart._lastXDomain!; const yDomain = chart._lastYDomain!; - - // `shared_x_axis` / `shared_y_axis` are silently forced off in - // independent-zoom mode by the render entry — see `renderContinuousFrame`. - // So by the time we get here, shared = true implies shared-zoom too. - const sharedX = chart._facetConfig.shared_x_axis; - const sharedY = chart._facetConfig.shared_y_axis; + const isMap = chart._renderMode === "map"; + + // Read the frame-local effective flags set by `renderCartesianFrame` + // — these already fold in the independent-zoom override (outer + // axes are incompatible with per-cell viewports), so `sharedX` / + // `sharedY` true here implies shared-zoom too. + const sharedX = chart._lastEffectiveSharedX; + const sharedY = chart._lastEffectiveSharedY; const independent = chart._facetConfig.zoom_mode === "independent"; // Shared X axis: one outer band across the bottom of the grid, // with ticks painted per-column (one pass per bottom-row cell). // Shared Y axis: one outer band down the left, ticks per-row - // (one pass per leftmost-column cell). - if (sharedX && grid.outerXAxisRect) { - const bottomRowLayouts = grid.cells - .filter((c) => c.isBottomEdge) - .map((c) => c.layout); + // (one pass per leftmost-column cell). Map mode replaces both + // with `renderMapChrome` (attribution + scale bar), painted once + // over the whole facet grid. + if (isMap) { + chart.renderMapChrome(canvas, chart._lastLayout!, theme, dpr); + } + + if (!isMap && sharedX && grid.outerXAxisRect) { renderOuterXAxis( canvas, grid.outerXAxisRect, xDomain, sharedXTicks, - bottomRowLayouts, + bottomRowLayouts(grid), theme, !!chart._xLabel, + dpr, + chart.getColumnFormatter(chart._xName, "tick"), ); } - if (sharedY && grid.outerYAxisRect) { - const leftColLayouts = grid.cells - .filter((c) => c.isLeftEdge) - .map((c) => c.layout); + + if (!isMap && sharedY && grid.outerYAxisRect) { renderOuterYAxis( canvas, grid.outerYAxisRect, yDomain, sharedYTicks, - leftColLayouts, + leftColumnLayouts(grid), theme, !!chart._yLabel, + dpr, + chart.getColumnFormatter(chart._yName, "tick"), ); } // Per-facet axes for the non-shared sides + title strips. + // Map mode skips per-cell axis rendering (no cartesian axes + // belong on a map) but still paints facet titles and labels. for (let i = 0; i < grid.cells.length; i++) { const cell = grid.cells[i]; const zc = independent ? chart.getZoomControllerForFacet(i) : null; @@ -594,7 +688,7 @@ function renderFacetedChromeOverlay(chart: ContinuousChart): void { ? computeTicks(localX, localY, cell.layout) : { xTicks: sharedXTicks, yTicks: sharedYTicks }; - if (!sharedX) { + if (!isMap && !sharedX) { renderCellXAxis( canvas, localX, @@ -602,9 +696,12 @@ function renderFacetedChromeOverlay(chart: ContinuousChart): void { ticks.xTicks, theme, !!chart._xLabel, + dpr, + chart.getColumnFormatter(chart._xName, "tick"), ); } - if (!sharedY) { + + if (!isMap && !sharedY) { renderCellYAxis( canvas, localY, @@ -612,12 +709,16 @@ function renderFacetedChromeOverlay(chart: ContinuousChart): void { ticks.yTicks, theme, !!chart._yLabel, + dpr, + chart.getColumnFormatter(chart._yName, "tick"), ); } if (cell.titleRect) { - drawFacetTitle(canvas, cell.label, cell.titleRect, theme); + drawFacetTitle(canvas, cell.label, cell.titleRect, theme, dpr); } + + renderScatterLabels(chart, canvas, cell.layout, i, i + 1); } // Shared legend: categorical (string color) or gradient @@ -626,10 +727,8 @@ function renderFacetedChromeOverlay(chart: ContinuousChart): void { if (chart._lastHasColorCol && grid.legendRect) { const stops = chart._lastGradientStops ?? theme.gradientStops; if (chart._colorIsString && chart._uniqueColorLabels.size > 0) { - const seriesPalette = - chart._lastSeriesPalette ?? readSeriesPalette(canvas); const palette = resolvePalette( - seriesPalette, + theme.seriesPalette, stops, Math.max(1, chart._uniqueColorLabels.size), ); @@ -638,6 +737,7 @@ function renderFacetedChromeOverlay(chart: ContinuousChart): void { grid.legendRect, chart._uniqueColorLabels, palette, + theme, ); } else if (chart._colorName) { // Numeric gradient legend in the shared outer rect. The @@ -657,6 +757,8 @@ function renderFacetedChromeOverlay(chart: ContinuousChart): void { label: chart._colorName, }, stops, + theme, + chart.getColumnFormatter(chart._colorName, "value"), ); } } @@ -665,17 +767,23 @@ function renderFacetedChromeOverlay(chart: ContinuousChart): void { // lines are whatever the last resolved lazy fetch produced (or // null while a fetch is still in flight); `renderCanvasTooltip` // paints crosshair + ring regardless, but skips the text box - // until lines are available. See `handleContinuousHover`. + // until lines are available. See `handleCartesianHover`. if (chart._hoveredIndex >= 0 && chart._xData && chart._yData) { - const dataX = chart._xData[chart._hoveredIndex]; - const dataY = chart._yData[chart._hoveredIndex]; + // `_xData`/`_yData` are rebased; `dataToPixel` expects absolute + // domain coords (matching `paddedXMin`/`paddedXMax`), so undo + // the rebase before mapping. + const xOrigin = isNaN(chart._xOrigin) ? 0 : chart._xOrigin; + const yOrigin = isNaN(chart._yOrigin) ? 0 : chart._yOrigin; + const dataX = chart._xData[chart._hoveredIndex] + xOrigin; + const dataY = chart._yData[chart._hoveredIndex] + yOrigin; const sourceFacet = seriesFromIndex(chart, chart._hoveredIndex); const opts = chart.glyph.tooltipOptions(); - const tooltipLines = chart._hoveredTooltipLines ?? []; + const tooltipLines = chart._lazyTooltip.lines ?? []; for (let i = 0; i < grid.cells.length; i++) { const cell = grid.cells[i]; const isSource = i === sourceFacet; + // Pixel position inside this facet for the source point's // data coordinate — ghost indicator in non-source facets. const pos = cell.layout.dataToPixel(dataX, dataY); @@ -688,9 +796,10 @@ function renderFacetedChromeOverlay(chart: ContinuousChart): void { ) { continue; } + const coordinated = chart._facetConfig.coordinated_tooltip; const lines = isSource || coordinated ? tooltipLines : []; - renderCanvasTooltip(canvas, pos, lines, cell.layout, theme, { + renderCanvasTooltip(canvas, pos, lines, cell.layout, theme, dpr, { crosshair: opts.crosshair, highlightRadius: isSource ? opts.highlightRadius : 0, }); @@ -698,56 +807,142 @@ function renderFacetedChromeOverlay(chart: ContinuousChart): void { } } -function drawFacetTitle( - canvas: HTMLCanvasElement, - label: string, - rect: { x: number; y: number; width: number; height: number }, - theme: Theme, +/** + * Map a flat slotted index back to its series (facet) index. + */ +export function seriesFromIndex( + chart: CartesianChart, + flatIdx: number, +): number { + if (chart._seriesCapacity <= 0) { + return 0; + } + + return Math.floor(flatIdx / chart._seriesCapacity); +} + +/** + * Maximum scatter labels painted in a single chrome pass. Beyond this + * we sample with a fixed stride so the canvas pass stays bounded as + * the user zooms out. The chrome overlay redraws on hover, so an + * unbounded `fillText` loop would stutter on every mouse move. + */ +const MAX_SCATTER_LABELS = 5_000; + +/** + * Draw the scatter-label column (slot 4) as 2D text next to each + * visible point. Labels are anchored slightly to the right of the + * point and vertically centered on it, painted in the theme's + * `labelColor`. Caller scopes us to a series range so faceted mode + * draws only the cell's own labels. + */ +function renderScatterLabels( + chart: CartesianChart, + canvas: Canvas2D, + layout: PlotLayout, + seriesStart: number, + seriesEnd: number, ): void { - const ctx = canvas.getContext("2d"); - if (!ctx) return; - const dpr = window.devicePixelRatio || 1; + if (!chart._labels || !chart._xData || !chart._yData) { + return; + } + + const dict = chart._labels.dictionary; + const labelData = chart._labels.data; + const xData = chart._xData; + const yData = chart._yData; + const xOrigin = isNaN(chart._xOrigin) ? 0 : chart._xOrigin; + const yOrigin = isNaN(chart._yOrigin) ? 0 : chart._yOrigin; + const cap = chart._seriesCapacity; + if (cap <= 0) { + return; + } + + let visibleCount = 0; + for (let s = seriesStart; s < seriesEnd; s++) { + visibleCount += chart._seriesUploadedCounts[s] ?? 0; + } + + if (visibleCount === 0) { + return; + } + + const dpr = chart._glManager?.dpr ?? 1; + const ctx = getScaledContext(canvas, dpr); + if (!ctx) { + return; + } + + const theme = chart._resolveTheme(); + const plot = layout.plotRect; + const stride = Math.max(1, Math.ceil(visibleCount / MAX_SCATTER_LABELS)); + ctx.save(); - ctx.setTransform(1, 0, 0, 1, 0, 0); - ctx.scale(dpr, dpr); ctx.font = `11px ${theme.fontFamily}`; ctx.fillStyle = theme.labelColor; - ctx.textAlign = "center"; + ctx.textAlign = "left"; ctx.textBaseline = "middle"; - ctx.fillText(label, rect.x + rect.width / 2, rect.y + rect.height / 2); - ctx.restore(); -} -/** Map a flat slotted index back to its series (facet) index. */ -export function seriesFromIndex( - chart: ContinuousChart, - flatIdx: number, -): number { - if (chart._seriesCapacity <= 0) return 0; - return Math.floor(flatIdx / chart._seriesCapacity); + for (let s = seriesStart; s < seriesEnd; s++) { + const count = chart._seriesUploadedCounts[s] ?? 0; + const base = s * cap; + for (let j = 0; j < count; j += stride) { + const idx = base + j; + const dictIdx = labelData[idx]; + if (dictIdx < 0) { + continue; + } + + const { px, py } = layout.dataToPixel( + xData[idx] + xOrigin, + yData[idx] + yOrigin, + ); + if ( + px < plot.x || + px > plot.x + plot.width || + py < plot.y || + py > plot.y + plot.height + ) { + continue; + } + + ctx.fillText(dict[dictIdx], px + 8, py - 4); + } + } + + ctx.restore(); } function renderTooltip( - chart: ContinuousChart, - canvas: HTMLCanvasElement, + chart: CartesianChart, + canvas: Canvas2D, layout: PlotLayout, ): void { const idx = chart._hoveredIndex; - if (idx < 0 || !chart._xData || !chart._yData) return; + if (idx < 0 || !chart._xData || !chart._yData) { + return; + } + + const xOrigin = isNaN(chart._xOrigin) ? 0 : chart._xOrigin; + const yOrigin = isNaN(chart._yOrigin) ? 0 : chart._yOrigin; + const pos = layout.dataToPixel( + chart._xData[idx] + xOrigin, + chart._yData[idx] + yOrigin, + ); - const pos = layout.dataToPixel(chart._xData[idx], chart._yData[idx]); // Lines come from the async lazy tooltip fetch kicked off in - // `handleContinuousHover`. While a fetch is in flight this is + // `handleCartesianHover`. While a fetch is in flight this is // `null`; the canvas tooltip helper still paints the crosshair / // highlight ring but skips the text box. - const lines = chart._hoveredTooltipLines ?? []; - const theme = chart._lastTheme ?? resolveTheme(canvas); + const lines = chart._lazyTooltip.lines ?? []; + const theme = chart._resolveTheme(); renderCanvasTooltip( canvas, pos, lines, layout, theme, + chart._glManager?.dpr ?? 1, chart.glyph.tooltipOptions(), ); } diff --git a/packages/viewer-charts/src/ts/charts/cartesian/cartesian.ts b/packages/viewer-charts/src/ts/charts/cartesian/cartesian.ts new file mode 100644 index 0000000000..e58bc29a98 --- /dev/null +++ b/packages/viewer-charts/src/ts/charts/cartesian/cartesian.ts @@ -0,0 +1,469 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import type { ColumnDataMap } from "../../data/view-reader"; +import type { WebGLContextManager } from "../../webgl/context-manager"; +import { AbstractChart } from "../chart-base"; +import { SpatialHitTester } from "../../interaction/hit-test"; +import { PlotLayout } from "../../layout/plot-layout"; +import { type AxisDomain } from "../../axis/numeric-axis"; +import type { GradientTextureCache } from "../../webgl/gradient-texture"; +import type { Glyph } from "./glyph"; +import { + initCartesianPipeline, + processCartesianChunk, +} from "./cartesian-build"; +import { + renderCartesianFrame, + renderCartesianChromeOverlay, +} from "./cartesian-render"; +import { + handleCartesianHover, + showCartesianPinnedTooltip, + dismissCartesianPinnedTooltip, +} from "./cartesian-interact"; +import type { LabelInterner } from "./label-interner"; +import { LazyTooltip } from "../../interaction/lazy-tooltip"; + +export interface SplitGroup { + prefix: string; + xColName: string; + yColName: string; + colorColName: string; + sizeColName: string; + labelColName: string; +} + +/** + * Unified continuous (numeric X/Y) chart. Glyphs plug in to render + * points, lines, or (future) areas over the shared data pipeline: + * streaming chunk upload, per-series slotted buffer layout, pan/zoom, + * spatial hit testing, chrome overlay, tooltip controller. + * + * Fields are package-internal (no `private`) so the split helper + * modules and glyphs can read/write them. + */ +export class CartesianChart extends AbstractChart { + readonly glyph: Glyph; + + constructor(glyph: Glyph) { + super(); + this.glyph = glyph; + } + + /** + * Rendering pipeline selector. `"cartesian"` is the default — + * draws axes, gridlines, and ticks via the chrome canvas. + * `"map"` (set by `MapChart` subclasses) suppresses cartesian + * chrome and inserts a raster tile layer underneath the glyph + * draw in `_fullRender`, so the same glyphs (point / line / + * density) render on top of a basemap. + * + * Read in `cartesian-render.ts` at three branch points; the + * `"cartesian"` path is byte-for-byte unchanged by the addition + * of this enum. + */ + _renderMode: "cartesian" | "map" = "cartesian"; + + /** + * Per-point data-space projection hook. Default is identity; map + * subclasses override to map (lon, lat) → Mercator meters. Called + * from `processCartesianChunk` immediately after the NaN guard, + * before extent accumulation and the `_xData` / `_yData` slot + * writes — so every downstream consumer (axis domain, projection + * matrix, spatial hit-test, glyph buffers) sees projected space + * uniformly. Returning `[NaN, NaN]` from a subclass discards the + * row (e.g. Mercator's ±85° latitude clamp). + */ + projectPoint(x: number, y: number): [number, number] { + return [x, y]; + } + + /** + * Paint a per-frame background inside the plot-frame scissor, + * before the glyph draw. Map subclasses override to render the + * raster tile basemap; the default no-op leaves cartesian charts + * byte-for-byte unchanged. + * + * Called once per facet in faceted mode (each call's `projection` + * and `domain` are that cell's), wrapped in the cell's scissor — + * just like `glyph.drawSeries`. + * + * `xOrigin` / `yOrigin` are the rebase origins the projection + * matrix bakes in (see `buildProjectionMatrix`). Glyphs ship + * pre-rebased positions, so the background pass must subtract + * them from absolute-domain coords (e.g. tile Mercator extents) + * before uploading vertex positions; otherwise the matrix + * over-corrects and the background lands off-screen by + * `sx * xOrigin` clip units. + */ + renderBackground( + _glManager: import("../../webgl/context-manager").WebGLContextManager, + _layout: import("../../layout/plot-layout").PlotLayout, + _projection: Float32Array, + _domain: { xMin: number; xMax: number; yMin: number; yMax: number }, + _xOrigin: number, + _yOrigin: number, + ): void { + // no-op for cartesian charts + } + + /** + * Paint chrome (attribution, scale bar) for map mode on top of the + * chrome canvas, in place of the cartesian axes/gridlines/legend. + * Called only when `_renderMode === "map"`. Default no-op so + * cartesian charts still go through `renderAxesChrome`. + */ + renderMapChrome( + _canvas: import("../canvas-types").Canvas2D | null, + _layout: import("../../layout/plot-layout").PlotLayout, + _theme: import("../../theme/theme").Theme, + _dpr: number, + ): void { + // no-op for cartesian charts + } + + // GL resources + // Shared: gradient LUT texture (used by both glyphs for color mapping). + _gradientCache: GradientTextureCache | null = null; + + // Column roles + _xName = ""; + _yName = ""; + _xLabel = ""; + _yLabel = ""; + _xIsRowIndex = false; + _colorName = ""; + _sizeName = ""; + _labelName = ""; + _colorIsString = false; + _splitGroups: SplitGroup[] = []; + + // Data extents + _xMin = Infinity; + _xMax = -Infinity; + _yMin = Infinity; + _yMax = -Infinity; + + /** + * Origin used to rebase x values before f32 narrowing. With datetime + * x columns the absolute timestamp is ~1.7e12, beyond f32 precision; + * storing `(x - _xOrigin)` keeps sub-millisecond fidelity in the + * `_xData` mirror, the GPU position attribute, and the projection + * matrix's `tx` term, avoiding the catastrophic cancellation that + * would otherwise push points outside the clip volume. NaN until + * the first valid x sample is observed. + */ + _xOrigin = NaN; + _yOrigin = NaN; + _colorMin = Infinity; + _colorMax = -Infinity; + _sizeMin = Infinity; + _sizeMax = -Infinity; + + /** + * `domain_mode: "expand"` accumulators. The build pipeline seeds + * `_xMin/_xMax/_yMin/_yMax/_colorMin/_colorMax/_sizeMin/_sizeMax` + * from these instead of `±Infinity` when expand mode is active, so + * the per-row scan naturally unions new data into the running + * extent. Mirrored back from the live fields at the end of every + * `processCartesianChunk` so multi-chunk uploads accumulate into + * the same union. Cleared via `resetExpandedDomain` (called from + * the worker's `resetAllZooms` and the view-config setters on + * `AbstractChart`). + */ + _expandedXMin = Infinity; + _expandedXMax = -Infinity; + _expandedYMin = Infinity; + _expandedYMax = -Infinity; + _expandedColorMin = Infinity; + _expandedColorMax = -Infinity; + _expandedSizeMin = Infinity; + _expandedSizeMax = -Infinity; + + // Data buffers (per-series slotted) + // Series `s` owns indices `[s*_seriesCapacity, (s+1)*_seriesCapacity)` + // in the flat `_xData`/`_yData`/`_colorData` arrays and their GPU + // counterparts. `_seriesUploadedCounts[s]` tracks how many slots at + // the head of series `s` hold valid data; glyphs dispatch tight + // per-series draws using this count so the tail slots are never + // rasterized. + _seriesCapacity = 0; + _seriesUploadedCounts: number[] = []; + _maxSeriesUploaded = 0; + + _xData: Float32Array | null = null; + _yData: Float32Array | null = null; + _colorData: Float32Array | null = null; + + /** + * Source view row index for each slot in `_xData` / `_yData`, + * sized and laid out identically. Split expansion duplicates the + * same arrow source row across every series; this sidecar stores + * that source index so lazy tooltip fetches can retrieve the + * original row. Int32 for compactness — at 1M points this is + * ~4 MB, a small fraction of the ~70 MB that the prior eager + * row-data buffers cost. + */ + _rowIndexData: Int32Array | null = null; + + /** + * Slot-indexed string store for the scatter "Label" column. `null` + * when no label column was wired. See {@link LabelInterner} — the + * three formerly-separate label fields (`_labelData`, + * `_labelDictionary`, `_labelDictMap`) live there as one unit, so + * future label-related state stays cohesive instead of accreting + * sibling fields on the chart. + */ + _labels: LabelInterner | null = null; + _dataCount = 0; + _uniqueColorLabels: Map = new Map(); + + /** + * Lazy-tooltip cache. `lines` is `null` until the async row fetch + * resolves — the chrome overlay skips the tooltip text box in + * that state but still paints the crosshair + highlight ring + * from geometry data so the hover cue is immediate. The + * controller owns the serial dance that drops stale resolves + * when the user moves before the fetch returns. Target type is + * the flat slot index of the hovered point. + */ + _lazyTooltip = new LazyTooltip(); + + // Staging scratch (reused across chunks) + _stagingPositions: Float32Array | null = null; + _stagingColors: Float32Array | null = null; + _stagingSizes: Float32Array | null = null; + _stagingChunkSize = 0; + + // Interaction + _hitTest = new SpatialHitTester(); + _lastLayout: PlotLayout | null = null; + _hoveredIndex = -1; + _pinnedIndex = -1; + + /** + * Source facet for the current hover (`-1` when not over any facet). + * Drives coordinated hover indicator painting in other facets. + */ + _hoveredFacet = -1; + + // Facet state (set when rendering in grid mode) + _facetGrid: import("../../layout/facet-grid").FacetGrid | null = null; + + // Last-frame cache (for chrome overlay-only redraws) + _lastXDomain: AxisDomain | null = null; + _lastYDomain: AxisDomain | null = null; + _lastXTicks: number[] | null = null; + _lastYTicks: number[] | null = null; + _lastGradientStops: import("../../theme/gradient").GradientStop[] | null = + null; + _lastHasColorCol = false; + + // Memoized categorical LUT stops — `ensureGradientTexture` uses + // reference-equality on this array to skip rebuilding the 256-sample + // texture. The cache key carries the inputs that determine the + // resolved palette: `seriesPalette` reference (changes per theme, + // since `_resolveTheme` returns a fresh `Theme` after + // `invalidateTheme()` clears the cache) plus `labelCount`. Without + // the `seriesPalette` reference compare a `restyle()` could leave + // the chart painting with the prior theme's colors — same + // `labelCount`/palette length but different RGB values. + _lastLutStops: import("../../theme/gradient").GradientStop[] | null = null; + _lastLutSeriesPalette: [number, number, number][] | null = null; + _lastLutLabelCount = -1; + + protected override tooltipCallbacks() { + return { + onHover: (mx: number, my: number) => + handleCartesianHover(this, mx, my), + onLeave: () => { + if (this._hoveredIndex !== -1) { + this._hoveredIndex = -1; + renderCartesianChromeOverlay(this); + } + }, + onPin: (mx: number, my: number) => { + // Refresh the hit-test at the click coords so the pin + // path doesn't depend on the RAF-throttled hover state + // — see comment in `series.ts` `onPin`. + handleCartesianHover(this, mx, my); + if (this._hoveredIndex >= 0) { + const flatIdx = this._hoveredIndex; + showCartesianPinnedTooltip(this, flatIdx); + void this._emitCartesianClickSelect(flatIdx); + } + }, + onUnpin: () => { + this.emitUnselect(); + }, + }; + } + + /** + * Resolve a clicked cartesian point into a `PerspectiveClickDetail` + * and emit both `perspective-click` and + * `perspective-global-filter selected:true`. + * + * Cartesian charts don't use `group_by` for positioning; X and Y + * come from explicit user-selected columns. The only filter clause + * we can build is the split-by prefix (when present). The source + * row index is the chart's per-point `_rowIndexData[flatIdx]` + * mirror — same lookup the lazy tooltip uses. + */ + private async _emitCartesianClickSelect(flatIdx: number): Promise { + if (!this._rowIndexData) { + return; + } + + const rowIdx = this._rowIndexData[flatIdx]; + const yColumn = this._columnSlots[1] || this._columnSlots[0] || ""; + + let splitByValues: (string | null)[] = []; + if (this._splitGroups.length > 0 && this._seriesCapacity > 0) { + const seriesIdx = Math.floor(flatIdx / this._seriesCapacity); + const sg = this._splitGroups[seriesIdx]; + if (sg?.prefix && this._splitBy.length > 0) { + splitByValues = sg.prefix.split("|"); + } + } + + await this.emitClickAndSelect({ + rowIdx: rowIdx != null && rowIdx >= 0 ? rowIdx : null, + columnName: yColumn, + groupByValues: [], + splitByValues, + }); + } + + async uploadAndRender( + glManager: WebGLContextManager, + columns: ColumnDataMap, + startRow: number, + endRow: number, + ): Promise { + const chunkLength = endRow - startRow; + this._glManager = glManager; + if (startRow === 0) { + initCartesianPipeline(this, glManager, columns, endRow); + } + + if (chunkLength === 0) { + return; + } + + processCartesianChunk( + this, + glManager, + columns, + startRow, + chunkLength, + endRow, + ); + + // `domain_mode: "expand"` mirror-back. `processCartesianChunk` + // updates `_xMin/_xMax` etc. in place against the seeded value + // (the prior accumulator); the union is in `_xMin` etc., so we + // copy it back. Idempotent across multi-chunk uploads — every + // chunk leaves the accumulator equal to the running union. + if (this._pluginConfig.domain_mode === "expand") { + this._expandedXMin = this._xMin; + this._expandedXMax = this._xMax; + this._expandedYMin = this._yMin; + this._expandedYMax = this._yMax; + this._expandedColorMin = this._colorMin; + this._expandedColorMax = this._colorMax; + this._expandedSizeMin = this._sizeMin; + this._expandedSizeMax = this._sizeMax; + } + + await this.requestRender(glManager); + } + + override resetExpandedDomain(): void { + this._expandedXMin = Infinity; + this._expandedXMax = -Infinity; + this._expandedYMin = Infinity; + this._expandedYMax = -Infinity; + this._expandedColorMin = Infinity; + this._expandedColorMax = -Infinity; + this._expandedSizeMin = Infinity; + this._expandedSizeMax = -Infinity; + } + + _fullRender(glManager: WebGLContextManager): void { + if (glManager.uploadedCount === 0 && this._dataCount === 0) { + return; + } + + this._glManager = glManager; + renderCartesianFrame(this, glManager); + } + + protected destroyInternal(): void { + this.glyph.destroy(this); + this._gradientCache = null; + this._xData = null; + this._yData = null; + this._colorData = null; + this._rowIndexData = null; + this._labels = null; + this._lazyTooltip.clearHover(); + this._uniqueColorLabels.clear(); + this._hitTest.clear(); + this._stagingPositions = null; + this._stagingColors = null; + this._stagingSizes = null; + this._splitGroups = []; + this._seriesUploadedCounts = []; + dismissCartesianPinnedTooltip(this); + } +} + +// Convenience subclasses with nullary constructors +// `index.ts` registers plugin tags via `new ImplClass()`, so each chart +// type needs a parameterless constructor. These wrappers pin the glyph. + +import { PointGlyph } from "./glyphs/points"; +import { LineGlyph } from "./glyphs/lines"; +import { DensityGlyph } from "./glyphs/density"; + +/** + * X/Y Scatter — continuous chart with the point glyph. + */ +export class ScatterChart extends CartesianChart { + constructor() { + super(new PointGlyph()); + } +} + +/** + * X/Y Line — continuous chart with the line glyph. + */ +export class LineChart extends CartesianChart { + constructor() { + super(new LineGlyph()); + } +} + +/** + * Density — continuous chart that rasterizes each row as an + * additive radial splat, producing a density field over the plot rect. + * Shares the cartesian pipeline (build, hit-test, zoom, facets, + * tooltips); the glyph swaps the per-point glyph for the heat + * accumulation + resolve pair. + */ +export class DensityChart extends CartesianChart { + constructor() { + super(new DensityGlyph()); + } +} diff --git a/packages/viewer-charts/src/ts/charts/continuous/glyph.ts b/packages/viewer-charts/src/ts/charts/cartesian/glyph.ts similarity index 81% rename from packages/viewer-charts/src/ts/charts/continuous/glyph.ts rename to packages/viewer-charts/src/ts/charts/cartesian/glyph.ts index 29a8dcd741..79d82953d0 100644 --- a/packages/viewer-charts/src/ts/charts/continuous/glyph.ts +++ b/packages/viewer-charts/src/ts/charts/cartesian/glyph.ts @@ -11,27 +11,32 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import type { WebGLContextManager } from "../../webgl/context-manager"; -import type { ContinuousChart } from "./continuous-chart"; +import type { CartesianChart } from "./cartesian"; /** - * A Glyph is a pluggable renderer for a {@link ContinuousChart}. The + * A Glyph is a pluggable renderer for a {@link CartesianChart}. The * chart owns all data and shared pipeline (init, chunk processing, hover, * chrome, tooltip plumbing); the glyph owns its shader program, draw * call, and per-glyph tooltip lines. */ export interface Glyph { - /** `"point"` for scatter-style markers; `"line"` for polylines. */ - readonly name: "point" | "line"; + /** + * `"point"` for scatter-style markers; `"line"` for polylines; + * `"density"` for the density-field shader glyph. + */ + readonly name: "point" | "line" | "density"; /** * Compile the program + cache attrib/uniform locations on first * frame. Subsequent frames are a no-op. */ - ensureProgram(chart: ContinuousChart, glManager: WebGLContextManager): void; + ensureProgram(chart: CartesianChart, glManager: WebGLContextManager): void; - /** Issue the draw call(s) for this glyph's visible geometry. */ + /** + * Issue the draw call(s) for this glyph's visible geometry. + */ draw( - chart: ContinuousChart, + chart: CartesianChart, glManager: WebGLContextManager, projection: Float32Array, ): void; @@ -46,7 +51,7 @@ export interface Glyph { * `seriesIdx`. */ drawSeries( - chart: ContinuousChart, + chart: CartesianChart, glManager: WebGLContextManager, projection: Float32Array, seriesIdx: number, @@ -60,13 +65,17 @@ export interface Glyph { * a microtask-resolved promise. */ buildTooltipLines( - chart: ContinuousChart, + chart: CartesianChart, flatIdx: number, ): Promise; - /** Hover-overlay options (crosshair, highlight radius). */ + /** + * Hover-overlay options (crosshair, highlight radius). + */ tooltipOptions(): { crosshair: boolean; highlightRadius: number }; - /** Release GL resources created by `ensureProgram`. */ - destroy(chart: ContinuousChart): void; + /** + * Release GL resources created by `ensureProgram`. + */ + destroy(chart: CartesianChart): void; } diff --git a/packages/viewer-charts/src/ts/charts/cartesian/glyphs/density.ts b/packages/viewer-charts/src/ts/charts/cartesian/glyphs/density.ts new file mode 100644 index 0000000000..9cc77dcc26 --- /dev/null +++ b/packages/viewer-charts/src/ts/charts/cartesian/glyphs/density.ts @@ -0,0 +1,1263 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import type { WebGLContextManager } from "../../../webgl/context-manager"; +import type { CartesianChart } from "../cartesian"; +import type { Glyph } from "../glyph"; +import { bindGradientTexture } from "../../../webgl/gradient-texture"; +import { getInstancing } from "../../../webgl/instanced-attrs"; +import { buildPointRowTooltipLines } from "../tooltip-lines"; +import splatVert from "../../../shaders/density-splat.vert.glsl"; +import splatFrag from "../../../shaders/density-splat.frag.glsl"; +import extremeFrag from "../../../shaders/density-extreme.frag.glsl"; +import mrtVert from "../../../shaders/density-mrt.vert.glsl"; +import mrtFrag from "../../../shaders/density-mrt.frag.glsl"; +import resolveVert from "../../../shaders/density-resolve.vert.glsl"; +import resolveFrag from "../../../shaders/density-resolve.frag.glsl"; + +/** + * Integer mode identifiers shared with the resolve shader's + * `u_color_mode` branch ladder. Keep these in sync with the + * comparisons in `density-resolve.frag.glsl`. + */ +const MODE_DENSITY = 0; +const MODE_MEAN = 1; +const MODE_EXTREME = 2; +const MODE_SIGNED = 3; + +type ColorMode = "mean" | "density" | "extreme" | "signed"; + +/** + * Subset of `OES_draw_buffers_indexed` we touch. The official type + * isn't in `lib.dom.d.ts`; everything we use is `iOES`-suffixed. + */ +interface IndexedBlendExt { + blendEquationiOES(buf: number, mode: number): void; + blendFunciOES(buf: number, src: number, dst: number): void; + enableiOES(target: number, index: number): void; + disableiOES(target: number, index: number): void; +} + +interface SplatProgramCache { + program: WebGLProgram; + u_projection: WebGLUniformLocation | null; + u_radius_ndc: WebGLUniformLocation | null; + u_intensity: WebGLUniformLocation | null; + u_color_range: WebGLUniformLocation | null; + a_corner: number; + a_position: number; + a_color_value: number; +} + +interface DensityCache { + splat: SplatProgramCache; + + /** + * Single-target splat program writing `(w, w·t, 0, 0)` into the + * extreme FBO with MAX blend. Lazily compiled on first + * `extreme`-mode render (when MRT is unavailable). + */ + extremeSplat: SplatProgramCache | null; + + /** + * Two-target MRT splat program for the `extreme` path on hardware + * that advertises `OES_draw_buffers_indexed`. `gl_FragData[0]` + * routes to the heat FBO (ADD blend), `gl_FragData[1]` to the + * extreme FBO (MAX blend). Lazily compiled on first + * `extreme`-mode render after the indexed-blend extension is + * confirmed. + */ + mrtSplat: SplatProgramCache | null; + + resolve: { + program: WebGLProgram; + u_heat: WebGLUniformLocation | null; + u_extreme: WebGLUniformLocation | null; + u_gradient_lut: WebGLUniformLocation | null; + u_heat_max: WebGLUniformLocation | null; + u_color_mode: WebGLUniformLocation | null; + a_corner: number; + }; + + quadCornerBuffer: WebGLBuffer; + tripleCornerBuffer: WebGLBuffer; + + /** + * Heat (density / weighted-color) framebuffer + texture. R = Σw, + * G = Σ(w·t). Always allocated. + */ + heatTexture: WebGLTexture; + heatFramebuffer: WebGLFramebuffer; + + /** + * Extreme (signed-max deviation) framebuffer + texture. R holds the + * MAX of positive deviation, G holds the MAX of negative deviation + * magnitude. Lazily allocated the first time `extreme` mode runs; + * `null` otherwise so the common case doesn't pay for a 4MB + * float texture it never reads. + */ + extremeTexture: WebGLTexture | null; + extremeFramebuffer: WebGLFramebuffer | null; + + /** + * MRT framebuffer with both `heatTexture` and `extremeTexture` + * attached. Used only on the indexed-blend fast path; `null` + * otherwise. Lazily allocated alongside `extremeTexture`. + */ + mrtFramebuffer: WebGLFramebuffer | null; + + heatWidth: number; + heatHeight: number; + heatType: number; + heatInternalFormat: number; + heatFormat: number; + + /** + * `true` when the heat FBO uses a true float (or half-float) + * accumulation format. `signed` mode requires this; on the + * `UNSIGNED_BYTE` fallback the signed-sum math is meaningless + * (R and G saturate to 1 independently, so `G - 0.5·R` collapses + * to a constant 0.5) and the glyph silently degrades to `mean`. + */ + floatFbo: boolean; + + /** + * Cached probe result for `OES_draw_buffers_indexed`. `null` until + * the first `extreme`-mode draw, then either the extension object + * (MRT path) or `false` (two-pass fallback). + */ + indexedBlend: IndexedBlendExt | null | false; + + /** + * `true` after `console.warn` has fired once for a `signed`-mode + * downgrade on this glyph. Suppresses repeat noise across the + * 60Hz render loop. + */ + signedDowngradeWarned: boolean; + + robustBounds: { + lo: number; + hi: number; + dataCount: number; + colorName: string; + colorIsString: boolean; + } | null; +} + +/** + * Density-field glyph. Each cartesian row is rasterized as an additive + * radial splat into an RGBA float FBO; a fullscreen pass resolves the + * accumulated density (and optional color-weighted average) through the + * chart's gradient LUT and composites the result inside the plot rect. + * + * The user-facing `gradient_color_mode` plugin field selects between: + * + * - `density` — alpha and hue from density alone. + * - `mean` — density-weighted average of color-t (default). + * - `extreme` — sign-aware MAX of per-point color deviation. Requires + * a second accumulation target; uses `OES_draw_buffers_indexed` + * MRT in one pass when available, otherwise falls back to two + * sequential splat passes. + * - `signed` — net positive vs. negative accumulation via the + * `G - 0.5·R` identity. Requires a float-capable framebuffer; on + * `UNSIGNED_BYTE` fallback the glyph silently degrades to `mean` + * with a one-line console warning. + */ +export class DensityGlyph implements Glyph { + readonly name = "density" as const; + private _cache: DensityCache | null = null; + + ensureProgram(chart: CartesianChart, glManager: WebGLContextManager): void { + if (this._cache) { + this.ensureHeatTarget(chart, glManager); + return; + } + + const gl = glManager.gl; + const splatProgram = glManager.shaders.getOrCreate( + "density-splat", + splatVert, + splatFrag, + ); + const resolveProgram = glManager.shaders.getOrCreate( + "density-resolve", + resolveVert, + resolveFrag, + ); + + const quadCornerBuffer = gl.createBuffer()!; + gl.bindBuffer(gl.ARRAY_BUFFER, quadCornerBuffer); + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), + gl.STATIC_DRAW, + ); + + const tripleCornerBuffer = gl.createBuffer()!; + gl.bindBuffer(gl.ARRAY_BUFFER, tripleCornerBuffer); + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([-1, -1, 3, -1, -1, 3]), + gl.STATIC_DRAW, + ); + + const { internalFormat, format, type, isFloat } = + pickHeatFormat(glManager); + + const heatTexture = createAccumTexture(gl); + const heatFramebuffer = gl.createFramebuffer()!; + + this._cache = { + splat: extractSplatLocations(gl, splatProgram), + extremeSplat: null, + mrtSplat: null, + resolve: { + program: resolveProgram, + u_heat: gl.getUniformLocation(resolveProgram, "u_heat"), + u_extreme: gl.getUniformLocation(resolveProgram, "u_extreme"), + u_gradient_lut: gl.getUniformLocation( + resolveProgram, + "u_gradient_lut", + ), + u_heat_max: gl.getUniformLocation(resolveProgram, "u_heat_max"), + u_color_mode: gl.getUniformLocation( + resolveProgram, + "u_color_mode", + ), + a_corner: gl.getAttribLocation(resolveProgram, "a_corner"), + }, + quadCornerBuffer, + tripleCornerBuffer, + heatTexture, + heatFramebuffer, + extremeTexture: null, + extremeFramebuffer: null, + mrtFramebuffer: null, + heatWidth: 0, + heatHeight: 0, + heatType: type, + heatInternalFormat: internalFormat, + heatFormat: format, + floatFbo: isFloat, + indexedBlend: null, + signedDowngradeWarned: false, + robustBounds: null, + }; + + this.ensureHeatTarget(chart, glManager); + } + + draw( + chart: CartesianChart, + glManager: WebGLContextManager, + projection: Float32Array, + ): void { + const cache = this._cache; + if (!cache || !ensurePointBuffers(glManager)) { + return; + } + + const numSeries = Math.max(1, chart._splitGroups.length); + const cap = chart._seriesCapacity; + let total = 0; + for (let s = 0; s < numSeries; s++) { + total += chart._seriesUploadedCounts[s] ?? 0; + } + + if (total === 0) { + return; + } + + this.runSplatAndResolve(chart, glManager, cache, projection, (cb) => { + for (let s = 0; s < numSeries; s++) { + const count = chart._seriesUploadedCounts[s] ?? 0; + if (count <= 0) { + continue; + } + + cb(s * cap, count); + } + }); + } + + drawSeries( + chart: CartesianChart, + glManager: WebGLContextManager, + projection: Float32Array, + seriesIdx: number, + ): void { + const cache = this._cache; + if (!cache || !ensurePointBuffers(glManager)) { + return; + } + + const count = chart._seriesUploadedCounts[seriesIdx] ?? 0; + if (count <= 0) { + return; + } + + const cap = chart._seriesCapacity; + this.runSplatAndResolve(chart, glManager, cache, projection, (cb) => + cb(seriesIdx * cap, count), + ); + } + + buildTooltipLines( + chart: CartesianChart, + flatIdx: number, + ): Promise { + return buildPointRowTooltipLines(chart, flatIdx); + } + + tooltipOptions() { + return { crosshair: true, highlightRadius: 0 }; + } + + destroy(chart: CartesianChart): void { + const cache = this._cache; + if (!cache || !chart._glManager) { + this._cache = null; + return; + } + + const gl = chart._glManager.gl; + gl.deleteBuffer(cache.quadCornerBuffer); + gl.deleteBuffer(cache.tripleCornerBuffer); + gl.deleteTexture(cache.heatTexture); + gl.deleteFramebuffer(cache.heatFramebuffer); + if (cache.extremeTexture) { + gl.deleteTexture(cache.extremeTexture); + } + + if (cache.extremeFramebuffer) { + gl.deleteFramebuffer(cache.extremeFramebuffer); + } + + if (cache.mrtFramebuffer) { + gl.deleteFramebuffer(cache.mrtFramebuffer); + } + + this._cache = null; + } + + /** + * Resize the heat (and, when allocated, extreme + MRT) targets to + * the current canvas bitmap size. The canvas backing store changes + * on DPR or layout updates, so we compare cached dimensions and + * re-allocate when stale. + */ + private ensureHeatTarget( + _chart: CartesianChart, + glManager: WebGLContextManager, + ): void { + const cache = this._cache; + if (!cache) { + return; + } + + const gl = glManager.gl; + const w = gl.canvas.width; + const h = gl.canvas.height; + if (w === cache.heatWidth && h === cache.heatHeight) { + return; + } + + if (w <= 0 || h <= 0) { + return; + } + + this.allocAccumTexture(gl, cache, cache.heatTexture, w, h); + + gl.bindFramebuffer(gl.FRAMEBUFFER, cache.heatFramebuffer); + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.TEXTURE_2D, + cache.heatTexture, + 0, + ); + + if (cache.extremeTexture) { + this.allocAccumTexture(gl, cache, cache.extremeTexture, w, h); + if (cache.extremeFramebuffer) { + gl.bindFramebuffer(gl.FRAMEBUFFER, cache.extremeFramebuffer); + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.TEXTURE_2D, + cache.extremeTexture, + 0, + ); + } + + if (cache.mrtFramebuffer) { + // MRT FBO only exists when indexed-blend was probed + // successfully, which is gated on WebGL2. + const gl2 = gl as WebGL2RenderingContext; + gl2.bindFramebuffer(gl2.FRAMEBUFFER, cache.mrtFramebuffer); + gl2.framebufferTexture2D( + gl2.FRAMEBUFFER, + gl2.COLOR_ATTACHMENT0, + gl2.TEXTURE_2D, + cache.heatTexture, + 0, + ); + gl2.framebufferTexture2D( + gl2.FRAMEBUFFER, + gl2.COLOR_ATTACHMENT1, + gl2.TEXTURE_2D, + cache.extremeTexture, + 0, + ); + } + } + + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + + cache.heatWidth = w; + cache.heatHeight = h; + } + + /** + * Re-allocate the storage for one accumulation texture using the + * cached format triple. Called both at first draw and on every + * canvas-size change. + */ + private allocAccumTexture( + gl: WebGL2RenderingContext | WebGLRenderingContext, + cache: DensityCache, + tex: WebGLTexture, + w: number, + h: number, + ): void { + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + cache.heatInternalFormat, + w, + h, + 0, + cache.heatFormat, + cache.heatType, + null, + ); + } + + /** + * Lazily allocate the extreme-mode accumulation texture + its + * framebuffers. Sized to match the heat target. Also probes + * `OES_draw_buffers_indexed` once per cache; if available, builds + * the MRT framebuffer with both textures attached. + */ + private ensureExtremeTarget( + glManager: WebGLContextManager, + cache: DensityCache, + ): void { + const gl = glManager.gl; + if (cache.extremeTexture) { + return; + } + + const tex = createAccumTexture(gl); + cache.extremeTexture = tex; + cache.extremeFramebuffer = gl.createFramebuffer()!; + + this.allocAccumTexture( + gl, + cache, + tex, + cache.heatWidth, + cache.heatHeight, + ); + + gl.bindFramebuffer(gl.FRAMEBUFFER, cache.extremeFramebuffer); + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.TEXTURE_2D, + tex, + 0, + ); + + if (cache.indexedBlend === null) { + // First chance to probe: only attempt MRT on WebGL2 where + // `gl.drawBuffers` is in core. On WebGL1 we'd also need + // `WEBGL_draw_buffers` for the JS function, but the + // indexed-blend extension itself doesn't ship there. + const ext = glManager.isWebGL2 + ? (gl.getExtension( + "OES_draw_buffers_indexed", + ) as IndexedBlendExt | null) + : null; + cache.indexedBlend = ext ?? false; + } + + if (cache.indexedBlend) { + // Indexed blend is gated on `isWebGL2`, so `gl` is a + // WebGL2 context here — cast for `COLOR_ATTACHMENT1`, + // which isn't on the WebGL1 type. + const gl2 = gl as WebGL2RenderingContext; + cache.mrtFramebuffer = gl2.createFramebuffer()!; + gl2.bindFramebuffer(gl2.FRAMEBUFFER, cache.mrtFramebuffer); + gl2.framebufferTexture2D( + gl2.FRAMEBUFFER, + gl2.COLOR_ATTACHMENT0, + gl2.TEXTURE_2D, + cache.heatTexture, + 0, + ); + gl2.framebufferTexture2D( + gl2.FRAMEBUFFER, + gl2.COLOR_ATTACHMENT1, + gl2.TEXTURE_2D, + tex, + 0, + ); + } + + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + } + + /** + * Compile (and cache) the single-target extreme splat program — the + * fallback two-pass path's second pass. Reuses the splat vertex + * shader so `v_color_t` semantics match the heat pass. + */ + private ensureExtremeSplatProgram( + glManager: WebGLContextManager, + cache: DensityCache, + ): SplatProgramCache { + if (cache.extremeSplat) { + return cache.extremeSplat; + } + + const program = glManager.shaders.getOrCreate( + "density-extreme", + splatVert, + extremeFrag, + ); + cache.extremeSplat = extractSplatLocations(glManager.gl, program); + return cache.extremeSplat; + } + + /** + * Compile (and cache) the MRT splat program. Only safe to call + * after `cache.indexedBlend` resolves truthy — the program's + * `#extension GL_EXT_draw_buffers : require` would fail to + * compile on contexts without multi-render-target support. + */ + private ensureMrtSplatProgram( + glManager: WebGLContextManager, + cache: DensityCache, + ): SplatProgramCache { + if (cache.mrtSplat) { + return cache.mrtSplat; + } + + // The MRT frag is GLSL ES 3.00 (`layout(location=N) out vec4`); + // the legacy GLSL 100 splat vert can't link against it because + // a program's shaders must share a version. Use the paired + // `density-mrt.vert.glsl` instead — same math, 300 ES dialect. + const program = glManager.shaders.getOrCreate( + "density-mrt", + mrtVert, + mrtFrag, + ); + cache.mrtSplat = extractSplatLocations(glManager.gl, program); + return cache.mrtSplat; + } + + /** + * Resolve the active mode for this frame. Folds in the silent + * downgrades to `mean` with a one-shot console warning: + * + * - `signed` requires a float-capable framebuffer + * (`EXT_color_buffer_float` on WebGL2 in practice). + * - `extreme` requires `gl.MAX` blend and a second color + * attachment, both of which are WebGL2-only here. On WebGL1 + * we could probe `EXT_blend_minmax` + `WEBGL_draw_buffers` + * but degrading is simpler and the context manager prefers + * WebGL2 already. + */ + private activeMode( + glManager: WebGLContextManager, + chart: CartesianChart, + cache: DensityCache, + ): ColorMode { + const requested = chart._pluginConfig.gradient_color_mode; + if (requested === "signed" && !cache.floatFbo) { + this.warnDowngradeOnce( + cache, + "signed mode requires a float framebuffer (EXT_color_buffer_float); falling back to mean.", + ); + return "mean"; + } + + if (requested === "extreme" && !glManager.isWebGL2) { + this.warnDowngradeOnce( + cache, + "extreme mode requires WebGL2 (for MAX blend and a second color attachment); falling back to mean.", + ); + return "mean"; + } + + return requested; + } + + private warnDowngradeOnce(cache: DensityCache, message: string): void { + if (cache.signedDowngradeWarned) { + return; + } + + cache.signedDowngradeWarned = true; + console.warn(`Density: ${message}`); + } + + /** + * Shared splat → resolve pipeline. `dispatchSplats(cb)` iterates + * the series ranges the caller wants drawn, invoking + * `cb(slotOffset, count)` per range — `drawSeries` passes a single + * range, `draw` iterates every series. Internally branches on the + * active color mode: density/mean/signed share the single-target + * heat-only pass, `extreme` runs either an MRT single-pass or two + * sequential passes depending on extension support. + */ + private runSplatAndResolve( + chart: CartesianChart, + glManager: WebGLContextManager, + cache: DensityCache, + projection: Float32Array, + dispatchSplats: ( + cb: (slotOffset: number, count: number) => void, + ) => void, + ): void { + this.ensureHeatTarget(chart, glManager); + if (cache.heatWidth === 0 || cache.heatHeight === 0) { + return; + } + + if (!chart._gradientCache) { + return; + } + + const mode = this.activeMode(glManager, chart, cache); + + // Resolve the color range we want the splat shader to use for + // its per-point `t` mapping. Robust bounds apply to modes that + // consume `t` directly (`mean`, `extreme`); `signed` actively + // benefits from raw extents so outlier influence accumulates; + // `density` ignores color entirely. + const hasColor = + chart._colorMin < chart._colorMax && + (!!chart._colorName || chart._splitGroups.length > 1); + let cmin = 0.0; + let cmax = 0.0; + if (mode !== "density" && hasColor) { + cmin = chart._colorMin; + cmax = chart._colorMax; + const useRobust = + !chart._colorIsString && + (mode === "mean" || mode === "extreme"); + if (useRobust) { + const robust = ensureRobustBounds(chart, cache); + if (robust) { + cmin = robust.lo; + cmax = robust.hi; + } + } + } + + if (mode === "extreme") { + this.ensureExtremeTarget(glManager, cache); + } + + if (mode === "extreme" && cache.indexedBlend) { + this.runMrtExtremePass( + glManager, + cache, + projection, + chart._pluginConfig.gradient_intensity, + glManager.dpr * chart._pluginConfig.gradient_radius_px, + cmin, + cmax, + dispatchSplats, + ); + } else { + this.runHeatPass( + glManager, + cache, + projection, + chart._pluginConfig.gradient_intensity, + glManager.dpr * chart._pluginConfig.gradient_radius_px, + cmin, + cmax, + dispatchSplats, + ); + + if (mode === "extreme") { + this.runExtremePass( + glManager, + cache, + projection, + chart._pluginConfig.gradient_intensity, + glManager.dpr * chart._pluginConfig.gradient_radius_px, + cmin, + cmax, + dispatchSplats, + ); + } + } + + this.runResolvePass(glManager, cache, chart, mode); + } + + /** + * Single-target accumulation into the heat FBO. ADD blend; writes + * `(w, w·t, 0, 0)`. Used by every mode except `extreme` on the + * MRT path (which does this work and the extreme pass in one go). + */ + private runHeatPass( + glManager: WebGLContextManager, + cache: DensityCache, + projection: Float32Array, + intensity: number, + radiusPx: number, + cmin: number, + cmax: number, + dispatchSplats: ( + cb: (slotOffset: number, count: number) => void, + ) => void, + ): void { + const gl = glManager.gl; + const wasScissor = !!gl.getParameter(gl.SCISSOR_TEST); + + gl.bindFramebuffer(gl.FRAMEBUFFER, cache.heatFramebuffer); + gl.viewport(0, 0, cache.heatWidth, cache.heatHeight); + this.clearTarget(gl, wasScissor); + + gl.blendFunc(gl.ONE, gl.ONE); + gl.blendEquation(gl.FUNC_ADD); + + this.bindSplatProgram( + gl, + cache.splat, + projection, + intensity, + radiusPx, + cache.heatWidth, + cache.heatHeight, + cmin, + cmax, + ); + + this.bindAndDispatchInstanced( + glManager, + cache, + cache.splat, + dispatchSplats, + ); + this.unbindSplatInstancing(glManager, cache.splat); + } + + /** + * Second pass of the two-pass extreme path. MAX blend; writes + * sign-split deviation magnitudes into the extreme FBO. Skipped + * entirely on the MRT fast path. + */ + private runExtremePass( + glManager: WebGLContextManager, + cache: DensityCache, + projection: Float32Array, + intensity: number, + radiusPx: number, + cmin: number, + cmax: number, + dispatchSplats: ( + cb: (slotOffset: number, count: number) => void, + ) => void, + ): void { + const gl = glManager.gl; + const wasScissor = !!gl.getParameter(gl.SCISSOR_TEST); + const program = this.ensureExtremeSplatProgram(glManager, cache); + + gl.bindFramebuffer(gl.FRAMEBUFFER, cache.extremeFramebuffer!); + gl.viewport(0, 0, cache.heatWidth, cache.heatHeight); + this.clearTarget(gl, wasScissor); + + gl.blendFunc(gl.ONE, gl.ONE); + // `MAX` is WebGL2-only on the type; `activeMode` gates the + // extreme path on WebGL2 so the cast is safe at runtime. + const gl2 = gl as WebGL2RenderingContext; + gl2.blendEquation(gl2.MAX); + + this.bindSplatProgram( + gl, + program, + projection, + intensity, + radiusPx, + cache.heatWidth, + cache.heatHeight, + cmin, + cmax, + ); + + this.bindAndDispatchInstanced( + glManager, + cache, + program, + dispatchSplats, + ); + this.unbindSplatInstancing(glManager, program); + + // Restore default ADD equation for downstream callers. + gl.blendEquation(gl.FUNC_ADD); + } + + /** + * MRT fast path: one splat draw writes density (ADD) and extreme + * (MAX) in the same invocation by routing `gl_FragData[0]` / + * `gl_FragData[1]` to attachments 0 and 1 with per-attachment + * blend equations. + */ + private runMrtExtremePass( + glManager: WebGLContextManager, + cache: DensityCache, + projection: Float32Array, + intensity: number, + radiusPx: number, + cmin: number, + cmax: number, + dispatchSplats: ( + cb: (slotOffset: number, count: number) => void, + ) => void, + ): void { + const gl = glManager.gl as WebGL2RenderingContext; + const ext = cache.indexedBlend as IndexedBlendExt; + const wasScissor = !!gl.getParameter(gl.SCISSOR_TEST); + const program = this.ensureMrtSplatProgram(glManager, cache); + + gl.bindFramebuffer(gl.FRAMEBUFFER, cache.mrtFramebuffer!); + gl.viewport(0, 0, cache.heatWidth, cache.heatHeight); + gl.drawBuffers([gl.COLOR_ATTACHMENT0, gl.COLOR_ATTACHMENT1]); + this.clearTarget(gl, wasScissor); + + // Per-attachment blend: ADD for density, MAX for extreme. + ext.enableiOES(gl.BLEND, 0); + ext.enableiOES(gl.BLEND, 1); + ext.blendEquationiOES(0, gl.FUNC_ADD); + ext.blendFunciOES(0, gl.ONE, gl.ONE); + ext.blendEquationiOES(1, gl.MAX); + ext.blendFunciOES(1, gl.ONE, gl.ONE); + + this.bindSplatProgram( + gl, + program, + projection, + intensity, + radiusPx, + cache.heatWidth, + cache.heatHeight, + cmin, + cmax, + ); + + this.bindAndDispatchInstanced( + glManager, + cache, + program, + dispatchSplats, + ); + this.unbindSplatInstancing(glManager, program); + + // Restore the global default for both attachments — subsequent + // single-target draws (resolve, other charts) rely on it. The + // indexed extension leaks state across attachments otherwise. + ext.blendEquationiOES(0, gl.FUNC_ADD); + ext.blendEquationiOES(1, gl.FUNC_ADD); + ext.blendFunciOES(0, gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + ext.blendFunciOES(1, gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + gl.drawBuffers([gl.COLOR_ATTACHMENT0]); + } + + /** + * Clear the currently bound framebuffer's color attachment(s) to + * fully transparent, bypassing scissor so leftovers from a prior + * facet's region don't bleed into this pass's full sample range. + * Restores the scissor state on exit. + */ + private clearTarget( + gl: WebGL2RenderingContext | WebGLRenderingContext, + wasScissor: boolean, + ): void { + if (wasScissor) { + gl.disable(gl.SCISSOR_TEST); + } + + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); + + if (wasScissor) { + gl.enable(gl.SCISSOR_TEST); + } + } + + /** + * Upload the per-frame splat-program uniforms (projection, splat + * radius, intensity, color range). Shared by the heat-only pass, + * the extreme single-target pass, and the MRT pass since each + * program exposes the same uniform layout. + */ + private bindSplatProgram( + gl: WebGL2RenderingContext | WebGLRenderingContext, + cache: SplatProgramCache, + projection: Float32Array, + intensity: number, + radiusPx: number, + targetWidth: number, + targetHeight: number, + cmin: number, + cmax: number, + ): void { + gl.useProgram(cache.program); + gl.uniformMatrix4fv(cache.u_projection, false, projection); + gl.uniform1f(cache.u_intensity, intensity); + + const radiusNdcX = (2 * radiusPx) / Math.max(1, targetWidth); + const radiusNdcY = (2 * radiusPx) / Math.max(1, targetHeight); + gl.uniform2f(cache.u_radius_ndc, radiusNdcX, radiusNdcY); + gl.uniform2f(cache.u_color_range, cmin, cmax); + } + + /** + * Bind the static unit-quad corner buffer (divisor 0) and per- + * instance position + color attributes (divisor 1), then iterate + * the caller's series ranges issuing one instanced draw each. + */ + private bindAndDispatchInstanced( + glManager: WebGLContextManager, + cache: DensityCache, + program: SplatProgramCache, + dispatchSplats: ( + cb: (slotOffset: number, count: number) => void, + ) => void, + ): void { + const gl = glManager.gl; + + gl.bindBuffer(gl.ARRAY_BUFFER, cache.quadCornerBuffer); + gl.enableVertexAttribArray(program.a_corner); + gl.vertexAttribPointer(program.a_corner, 2, gl.FLOAT, false, 0, 0); + + const instancing = getInstancing(glManager); + instancing.setDivisor(program.a_corner, 0); + instancing.setDivisor(program.a_position, 1); + instancing.setDivisor(program.a_color_value, 1); + + const posBuf = glManager.bufferPool.peek("a_position")!; + const colorBuf = glManager.bufferPool.peek("a_color_value")!; + + dispatchSplats((slotOffset, count) => { + const posStride = 2 * Float32Array.BYTES_PER_ELEMENT; + const scalarStride = Float32Array.BYTES_PER_ELEMENT; + + gl.bindBuffer(gl.ARRAY_BUFFER, posBuf.buffer); + gl.enableVertexAttribArray(program.a_position); + gl.vertexAttribPointer( + program.a_position, + 2, + gl.FLOAT, + false, + posStride, + slotOffset * posStride, + ); + + gl.bindBuffer(gl.ARRAY_BUFFER, colorBuf.buffer); + gl.enableVertexAttribArray(program.a_color_value); + gl.vertexAttribPointer( + program.a_color_value, + 1, + gl.FLOAT, + false, + scalarStride, + slotOffset * scalarStride, + ); + + instancing.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, count); + }); + } + + /** + * Reset the per-instance divisors so subsequent draws (in this or + * another chart) don't inherit the instanced bindings. + */ + private unbindSplatInstancing( + glManager: WebGLContextManager, + program: SplatProgramCache, + ): void { + const instancing = getInstancing(glManager); + instancing.setDivisor(program.a_position, 0); + instancing.setDivisor(program.a_color_value, 0); + } + + /** + * Resolve pass on the canvas FBO. Standard alpha composite. Reads + * the heat FBO (always) and, in `extreme` mode, the extreme FBO. + * Uploads the mode int that the resolve frag branches on. + */ + private runResolvePass( + glManager: WebGLContextManager, + cache: DensityCache, + chart: CartesianChart, + mode: ColorMode, + ): void { + const gl = glManager.gl; + + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.viewport(0, 0, gl.canvas.width, gl.canvas.height); + gl.blendEquation(gl.FUNC_ADD); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + + const resolve = cache.resolve; + gl.useProgram(resolve.program); + gl.uniform1f(resolve.u_heat_max, chart._pluginConfig.gradient_heat_max); + gl.uniform1i(resolve.u_color_mode, modeToInt(mode)); + + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, cache.heatTexture); + gl.uniform1i(resolve.u_heat, 0); + + // The shader unconditionally samples `u_extreme` in the extreme + // branch. Bind whatever we have (the heat texture as a no-op + // bind in non-extreme modes) so the unit stays defined and + // texture-completeness checks pass. + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture( + gl.TEXTURE_2D, + cache.extremeTexture ?? cache.heatTexture, + ); + gl.uniform1i(resolve.u_extreme, 1); + + bindGradientTexture( + glManager, + chart._gradientCache!.texture, + resolve.u_gradient_lut, + 2, + ); + + gl.bindBuffer(gl.ARRAY_BUFFER, cache.tripleCornerBuffer); + gl.enableVertexAttribArray(resolve.a_corner); + gl.vertexAttribPointer(resolve.a_corner, 2, gl.FLOAT, false, 0, 0); + + gl.drawArrays(gl.TRIANGLES, 0, 3); + } +} + +function modeToInt(mode: ColorMode): number { + switch (mode) { + case "density": + return MODE_DENSITY; + case "extreme": + return MODE_EXTREME; + case "signed": + return MODE_SIGNED; + case "mean": + default: + return MODE_MEAN; + } +} + +function createAccumTexture( + gl: WebGL2RenderingContext | WebGLRenderingContext, +): WebGLTexture { + const tex = gl.createTexture()!; + gl.bindTexture(gl.TEXTURE_2D, tex); + 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); + return tex; +} + +function extractSplatLocations( + gl: WebGL2RenderingContext | WebGLRenderingContext, + program: WebGLProgram, +): SplatProgramCache { + return { + program, + u_projection: gl.getUniformLocation(program, "u_projection"), + u_radius_ndc: gl.getUniformLocation(program, "u_radius_ndc"), + u_intensity: gl.getUniformLocation(program, "u_intensity"), + u_color_range: gl.getUniformLocation(program, "u_color_range"), + a_corner: gl.getAttribLocation(program, "a_corner"), + a_position: gl.getAttribLocation(program, "a_position"), + a_color_value: gl.getAttribLocation(program, "a_color_value"), + }; +} + +/** + * Resolve the highest-precision float color buffer the running GL + * context will accept. WebGL2 + `EXT_color_buffer_float` gives + * RGBA16F; otherwise fall back to RGBA8. The fallback compresses + * density into [0, 1] and saturates earlier; `signed` mode degrades + * to `mean` on this path because its `G - 0.5·R` math depends on + * unclamped accumulation. + */ +function pickHeatFormat(glManager: WebGLContextManager): { + internalFormat: number; + format: number; + type: number; + isFloat: boolean; +} { + const gl = glManager.gl; + if (glManager.isWebGL2) { + const gl2 = gl as WebGL2RenderingContext; + if (gl2.getExtension("EXT_color_buffer_float")) { + return { + internalFormat: gl2.RGBA16F, + format: gl2.RGBA, + type: gl2.HALF_FLOAT, + isFloat: true, + }; + } + } + + return { + internalFormat: gl.RGBA, + format: gl.RGBA, + type: gl.UNSIGNED_BYTE, + isFloat: false, + }; +} + +/** + * Verify the shared cartesian position + color attribute buffers exist. + * The cartesian build pipeline uploads them on each chunk; render-path + * callers must use `peek` (never `getOrCreate`) so a pan/zoom render + * landing between an `ensureBufferCapacity` and its `uploadChunk` + * doesn't recreate the buffer with zeros. + */ +function ensurePointBuffers(glManager: WebGLContextManager): boolean { + const pos = glManager.bufferPool.peek("a_position"); + const color = glManager.bufferPool.peek("a_color_value"); + return !!pos && !!color; +} + +/** + * Cap for the strided sample used to compute the 5th/95th percentile + * color-column bounds. A larger sample tightens the quantile estimate + * but costs O(n log n) sort time. At 50k the sort runs ~10ms once per + * data refresh; subsequent renders hit the cache. + */ +const ROBUST_SAMPLE_MAX = 50_000; + +/** + * Resolve the robust (5th/95th percentile) bounds for the color column, + * reading from the cache when `(dataCount, colorName, colorIsString)` + * hasn't changed since the last compute. Returns `null` when robust + * clipping doesn't apply — no color column, categorical column (exact + * palette indices), or a degenerate sample. + */ +function ensureRobustBounds( + chart: CartesianChart, + cache: DensityCache, +): { lo: number; hi: number } | null { + if (!chart._colorName || chart._colorIsString) { + cache.robustBounds = null; + return null; + } + + const cur = cache.robustBounds; + if ( + cur && + cur.dataCount === chart._dataCount && + cur.colorName === chart._colorName && + cur.colorIsString === chart._colorIsString + ) { + return { lo: cur.lo, hi: cur.hi }; + } + + const computed = computeRobustBounds(chart); + if (!computed) { + cache.robustBounds = null; + return null; + } + + cache.robustBounds = { + lo: computed.lo, + hi: computed.hi, + dataCount: chart._dataCount, + colorName: chart._colorName, + colorIsString: chart._colorIsString, + }; + return computed; +} + +/** + * Sample `chart._colorData` along its slotted per-series ranges, sort + * the strided sample, and return the 5th/95th percentile values. The + * sample skips unused tail slots (per-series `_seriesUploadedCounts` + * cap) so split mode doesn't pollute the distribution with default + * `0.5` placeholders. + * + * Falls back to raw `_colorMin`/`_colorMax` when the quantile sample + * collapses to a single value — otherwise a zero-width range would + * trip the splat shader's `cmax <= cmin` branch and paint every + * point at t=0.5. + */ +function computeRobustBounds( + chart: CartesianChart, +): { lo: number; hi: number } | null { + if (!chart._colorData || chart._dataCount < 2) { + return null; + } + + const cap = chart._seriesCapacity; + const numSeries = Math.max(1, chart._splitGroups.length); + const stride = Math.max(1, Math.ceil(chart._dataCount / ROBUST_SAMPLE_MAX)); + + const samples: number[] = []; + const data = chart._colorData; + for (let s = 0; s < numSeries; s++) { + const count = chart._seriesUploadedCounts[s] ?? 0; + const base = s * cap; + for (let j = 0; j < count; j += stride) { + const v = data[base + j]; + if (Number.isFinite(v)) { + samples.push(v); + } + } + } + + if (samples.length < 2) { + return null; + } + + samples.sort((a, b) => a - b); + const loIdx = Math.floor(samples.length * 0.05); + const hiIdx = Math.min( + samples.length - 1, + Math.ceil(samples.length * 0.95), + ); + + const lo = samples[loIdx]; + const hi = samples[hiIdx]; + if (!(hi > lo)) { + if (chart._colorMax > chart._colorMin) { + return { lo: chart._colorMin, hi: chart._colorMax }; + } + + return null; + } + + return { lo, hi }; +} diff --git a/packages/viewer-charts/src/ts/charts/continuous/glyphs/lines.ts b/packages/viewer-charts/src/ts/charts/cartesian/glyphs/lines.ts similarity index 84% rename from packages/viewer-charts/src/ts/charts/continuous/glyphs/lines.ts rename to packages/viewer-charts/src/ts/charts/cartesian/glyphs/lines.ts index acb1d4dc42..69eb6913da 100644 --- a/packages/viewer-charts/src/ts/charts/continuous/glyphs/lines.ts +++ b/packages/viewer-charts/src/ts/charts/cartesian/glyphs/lines.ts @@ -11,16 +11,17 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import type { WebGLContextManager } from "../../../webgl/context-manager"; -import type { ContinuousChart } from "../continuous-chart"; +import type { CartesianChart } from "../cartesian"; import type { Glyph } from "../glyph"; import { bindGradientTexture } from "../../../webgl/gradient-texture"; -import { getInstancing } from "../../../webgl/instanced-attrs"; +import { + createLineCornerBuffer, + getInstancing, +} from "../../../webgl/instanced-attrs"; import { formatTickValue, formatDateTickValue } from "../../../layout/ticks"; import lineVert from "../../../shaders/line.vert.glsl"; import lineFrag from "../../../shaders/line.frag.glsl"; -const LINE_WIDTH_PX = 2.0; - interface LineCache { program: WebGLProgram; cornerBuffer: WebGLBuffer; @@ -45,26 +46,24 @@ interface LineCache { */ export class LineGlyph implements Glyph { readonly name = "line" as const; + private _cache: LineCache | null = null; ensureProgram( - chart: ContinuousChart, + _chart: CartesianChart, glManager: WebGLContextManager, ): void { - if (chart._glyphCache) return; + if (this._cache) { + return; + } + const gl = glManager.gl; const program = glManager.shaders.getOrCreate( "line", lineVert, lineFrag, ); - const cornerBuffer = gl.createBuffer()!; - gl.bindBuffer(gl.ARRAY_BUFFER, cornerBuffer); - gl.bufferData( - gl.ARRAY_BUFFER, - new Float32Array([0, 1, 2, 3]), - gl.STATIC_DRAW, - ); - const cache: LineCache = { + const cornerBuffer = createLineCornerBuffer(gl); + this._cache = { program, cornerBuffer, u_projection: gl.getUniformLocation(program, "u_projection"), @@ -78,52 +77,67 @@ export class LineGlyph implements Glyph { a_color_end: gl.getAttribLocation(program, "a_color_end"), a_corner: gl.getAttribLocation(program, "a_corner"), }; - chart._glyphCache = cache; } draw( - chart: ContinuousChart, + chart: CartesianChart, glManager: WebGLContextManager, projection: Float32Array, ): void { - const cache = chart._glyphCache as LineCache | null; - if (!cache) return; + const cache = this._cache; + if (!cache) { + return; + } + const bind = bindLineState(cache, chart, glManager, projection); - if (!bind) return; + if (!bind) { + return; + } const numSeries = Math.max(1, chart._splitGroups.length); for (let s = 0; s < numSeries; s++) { drawLineSeries(cache, chart, glManager, s); } + unbindLineDivisors(cache, glManager); } drawSeries( - chart: ContinuousChart, + chart: CartesianChart, glManager: WebGLContextManager, projection: Float32Array, seriesIdx: number, ): void { - const cache = chart._glyphCache as LineCache | null; - if (!cache) return; - if (!bindLineState(cache, chart, glManager, projection)) return; + const cache = this._cache; + if (!cache) { + return; + } + + if (!bindLineState(cache, chart, glManager, projection)) { + return; + } + drawLineSeries(cache, chart, glManager, seriesIdx); unbindLineDivisors(cache, glManager); } - // ── helpers ────────────────────────────────────────────────────────── + // helpers async buildTooltipLines( - chart: ContinuousChart, + chart: CartesianChart, flatIdx: number, ): Promise { const lines: string[] = []; - if (!chart._xData || !chart._yData) return lines; + if (!chart._xData || !chart._yData) { + return lines; + } if (chart._splitGroups.length > 0 && chart._seriesCapacity > 0) { const seriesIdx = Math.floor(flatIdx / chart._seriesCapacity); const sg = chart._splitGroups[seriesIdx]; - if (sg) lines.push(sg.prefix); + if (sg) { + lines.push(sg.prefix); + } } const xVal = chart._xData[flatIdx]; @@ -150,11 +164,13 @@ export class LineGlyph implements Glyph { return { crosshair: true, highlightRadius: 5 }; } - destroy(chart: ContinuousChart): void { - const cache = chart._glyphCache as LineCache | null; + destroy(chart: CartesianChart): void { + const cache = this._cache; if (cache?.cornerBuffer && chart._glManager) { chart._glManager.gl.deleteBuffer(cache.cornerBuffer); } + + this._cache = null; } } @@ -166,19 +182,21 @@ export class LineGlyph implements Glyph { */ function bindLineState( cache: LineCache, - chart: ContinuousChart, + chart: CartesianChart, glManager: WebGLContextManager, projection: Float32Array, ): boolean { const gl = glManager.gl; - if (!chart._gradientCache) return false; + if (!chart._gradientCache) { + return false; + } - const dpr = window.devicePixelRatio || 1; + const dpr = glManager.dpr; gl.useProgram(cache.program); gl.uniformMatrix4fv(cache.u_projection, false, projection); gl.uniform2f(cache.u_resolution, gl.canvas.width, gl.canvas.height); - gl.uniform1f(cache.u_line_width, LINE_WIDTH_PX * dpr); + gl.uniform1f(cache.u_line_width, chart._pluginConfig.line_width_px * dpr); if (chart._colorMin < chart._colorMax) { gl.uniform2f(cache.u_color_range, chart._colorMin, chart._colorMax); } else { @@ -219,28 +237,30 @@ function bindLineState( */ function drawLineSeries( cache: LineCache, - chart: ContinuousChart, + chart: CartesianChart, glManager: WebGLContextManager, s: number, ): void { const count = chart._seriesUploadedCounts[s] ?? 0; - if (count < 2) return; + if (count < 2) { + return; + } const gl = glManager.gl; const cap = chart._seriesCapacity; const posStride = 2 * Float32Array.BYTES_PER_ELEMENT; const idStride = Float32Array.BYTES_PER_ELEMENT; - const posBuf = glManager.bufferPool.getOrCreate( - "a_position", - 2, - Float32Array.BYTES_PER_ELEMENT, - ); - const idBuf = glManager.bufferPool.getOrCreate( - "a_color_value", - 1, - Float32Array.BYTES_PER_ELEMENT, - ); + // Render-path uses `peek`. If buffers haven't been uploaded + // yet (pan/zoom render landing between a pending draw's + // `ensureBufferCapacity` and its `uploadChunk`), skip — drawing + // against a recreated zero-filled buffer would produce one frame + // of empty plot area. + const posBuf = glManager.bufferPool.peek("a_position"); + const idBuf = glManager.bufferPool.peek("a_color_value"); + if (!posBuf || !idBuf) { + return; + } const posBase = s * cap * posStride; gl.bindBuffer(gl.ARRAY_BUFFER, posBuf.buffer); diff --git a/packages/viewer-charts/src/ts/charts/continuous/glyphs/points.ts b/packages/viewer-charts/src/ts/charts/cartesian/glyphs/points.ts similarity index 62% rename from packages/viewer-charts/src/ts/charts/continuous/glyphs/points.ts rename to packages/viewer-charts/src/ts/charts/cartesian/glyphs/points.ts index e3e04b5191..16352b4655 100644 --- a/packages/viewer-charts/src/ts/charts/continuous/glyphs/points.ts +++ b/packages/viewer-charts/src/ts/charts/cartesian/glyphs/points.ts @@ -11,10 +11,10 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import type { WebGLContextManager } from "../../../webgl/context-manager"; -import type { ContinuousChart } from "../continuous-chart"; +import type { CartesianChart } from "../cartesian"; import type { Glyph } from "../glyph"; import { bindGradientTexture } from "../../../webgl/gradient-texture"; -import { formatTickValue, formatDateTickValue } from "../../../layout/ticks"; +import { buildPointRowTooltipLines } from "../tooltip-lines"; import scatterVert from "../../../shaders/scatter.vert.glsl"; import scatterFrag from "../../../shaders/scatter.frag.glsl"; @@ -43,19 +43,23 @@ interface PointCache { */ export class PointGlyph implements Glyph { readonly name = "point" as const; + private _cache: PointCache | null = null; ensureProgram( - chart: ContinuousChart, + _chart: CartesianChart, glManager: WebGLContextManager, ): void { - if (chart._glyphCache) return; + if (this._cache) { + return; + } + const gl = glManager.gl; const program = glManager.shaders.getOrCreate( "scatter", scatterVert, scatterFrag, ); - const cache: PointCache = { + this._cache = { program, u_projection: gl.getUniformLocation(program, "u_projection"), u_point_size: gl.getUniformLocation(program, "u_point_size"), @@ -70,17 +74,21 @@ export class PointGlyph implements Glyph { a_color_value: gl.getAttribLocation(program, "a_color_value"), a_size_value: gl.getAttribLocation(program, "a_size_value"), }; - chart._glyphCache = cache; } draw( - chart: ContinuousChart, + chart: CartesianChart, glManager: WebGLContextManager, projection: Float32Array, ): void { - const cache = chart._glyphCache as PointCache | null; - if (!cache) return; - if (!bindPointState(cache, chart, glManager, projection)) return; + const cache = this._cache; + if (!cache) { + return; + } + + if (!bindPointState(cache, chart, glManager, projection)) { + return; + } // Per-series tight draws: each series `s` occupies slots // `[s*cap, s*cap + count[s])`. Dispatching `count[s]` avoids @@ -91,101 +99,54 @@ export class PointGlyph implements Glyph { const cap = chart._seriesCapacity; for (let s = 0; s < numSeries; s++) { const count = chart._seriesUploadedCounts[s] ?? 0; - if (count <= 0) continue; + if (count <= 0) { + continue; + } + gl.drawArrays(gl.POINTS, s * cap, count); } } drawSeries( - chart: ContinuousChart, + chart: CartesianChart, glManager: WebGLContextManager, projection: Float32Array, seriesIdx: number, ): void { - const cache = chart._glyphCache as PointCache | null; - if (!cache) return; - if (!bindPointState(cache, chart, glManager, projection)) return; + const cache = this._cache; + if (!cache) { + return; + } + + if (!bindPointState(cache, chart, glManager, projection)) { + return; + } const count = chart._seriesUploadedCounts[seriesIdx] ?? 0; - if (count <= 0) return; + if (count <= 0) { + return; + } + const gl = glManager.gl; const cap = chart._seriesCapacity; gl.drawArrays(gl.POINTS, seriesIdx * cap, count); } - async buildTooltipLines( - chart: ContinuousChart, + buildTooltipLines( + chart: CartesianChart, flatIdx: number, ): Promise { - const lines: string[] = []; - if (!chart._rowIndexData || !chart._lazyRows) return lines; - const rowIdx = chart._rowIndexData[flatIdx]; - if (rowIdx < 0) return lines; - - // In split mode, the row the user hovered corresponds to one - // series — so surface the split prefix as the first line so - // the user can tell which facet's data this is. - if (chart._splitGroups.length > 0 && chart._seriesCapacity > 0) { - const seriesIdx = Math.floor(flatIdx / chart._seriesCapacity); - const sg = chart._splitGroups[seriesIdx]; - if (sg?.prefix) lines.push(sg.prefix); - } - - const row = await chart._lazyRows.fetchRow(rowIdx); - - // Row-path (group_by): the view emits `__ROW_PATH_0__` … - // `__ROW_PATH_N__` dictionary columns. `LazyRowFetcher` - // filters out `__` columns, so fetch the row-path from the - // levels we know: iterate the view schema via `_columnTypes` - // is costly; instead, reuse the column-type map to infer only - // the non-metadata columns. Row-path columns are metadata; we - // skip them here and the visual hierarchy is instead conveyed - // by the aggregated view already surfacing grouped columns. - // - // In split mode we only have per-split columns like - // `A|price`. Filter to the prefix the user hovered on so - // the tooltip shows only relevant facet values. - const prefixFilter = - chart._splitGroups.length > 0 && chart._seriesCapacity > 0 - ? (chart._splitGroups[ - Math.floor(flatIdx / chart._seriesCapacity) - ]?.prefix ?? null) - : null; - - for (const [colName, value] of row) { - if (value === null || value === undefined) continue; - let displayName = colName; - if (prefixFilter !== null) { - const expected = `${prefixFilter}|`; - if (!colName.startsWith(expected)) continue; - displayName = colName.substring(expected.length); - } else if (colName.includes("|")) { - // Non-split chart that somehow has pipe-prefixed - // columns (shouldn't happen, but defensively skip). - continue; - } - if (typeof value === "number") { - const colType = chart._columnTypes[colName] || ""; - const isDate = colType === "date" || colType === "datetime"; - const formatted = isDate - ? formatDateTickValue(value) - : formatTickValue(value); - lines.push(`${displayName}: ${formatted}`); - } else { - lines.push(`${displayName}: ${value}`); - } - } - return lines; + return buildPointRowTooltipLines(chart, flatIdx); } tooltipOptions() { return { crosshair: true, highlightRadius: 6 }; } - destroy(_chart: ContinuousChart): void { - // Program lifetime is owned by the shader registry; nothing glyph- - // specific to free here beyond the cache reference itself, which - // `ContinuousChart.destroyInternal` clears. + destroy(_chart: CartesianChart): void { + // Program lifetime is owned by the shader registry; just drop + // the cache reference. No private GPU resources to free. + this._cache = null; } } @@ -193,11 +154,11 @@ function setUniforms( cache: PointCache, gl: GL, projection: Float32Array, - chart: ContinuousChart, + chart: CartesianChart, + dpr: number, ): void { - const dpr = window.devicePixelRatio || 1; gl.uniformMatrix4fv(cache.u_projection, false, projection); - gl.uniform1f(cache.u_point_size, 8.0 * dpr); + gl.uniform1f(cache.u_point_size, chart._pluginConfig.point_size_px * dpr); if (chart._colorMin < chart._colorMax) { gl.uniform2f(cache.u_color_range, chart._colorMin, chart._colorMax); @@ -211,7 +172,16 @@ function setUniforms( gl.uniform2f(cache.u_size_range, 0.0, 0.0); } - gl.uniform2f(cache.u_point_size_range, 2.0 * dpr, 16.0 * dpr); + const size_scale_factor = Math.min(chart._pluginConfig.point_size_px, 3); + + gl.uniform2f( + cache.u_point_size_range, + Math.max( + 2 * dpr, + (chart._pluginConfig.point_size_px / size_scale_factor) * dpr, + ), + chart._pluginConfig.point_size_px * size_scale_factor * dpr, + ); } /** @@ -221,15 +191,17 @@ function setUniforms( */ function bindPointState( cache: PointCache, - chart: ContinuousChart, + chart: CartesianChart, glManager: WebGLContextManager, projection: Float32Array, ): boolean { const gl = glManager.gl; - if (!chart._gradientCache) return false; + if (!chart._gradientCache) { + return false; + } gl.useProgram(cache.program); - setUniforms(cache, gl, projection, chart); + setUniforms(cache, gl, projection, chart, glManager.dpr); bindGradientTexture( glManager, chart._gradientCache.texture, @@ -237,21 +209,19 @@ function bindPointState( 0, ); - const posBuf = glManager.bufferPool.getOrCreate( - "a_position", - 2, - Float32Array.BYTES_PER_ELEMENT, - ); - const colorBuf = glManager.bufferPool.getOrCreate( - "a_color_value", - 1, - Float32Array.BYTES_PER_ELEMENT, - ); - const sizeBuf = glManager.bufferPool.getOrCreate( - "a_size_value", - 1, - Float32Array.BYTES_PER_ELEMENT, - ); + // Render-path uses `peek` (not `getOrCreate`) so we never + // recreate buffers from the draw path. If a buffer hasn't been + // uploaded yet — e.g. pan/zoom render landing between a pending + // draw's `ensureBufferCapacity` and its `uploadChunk` — return + // false and let the caller skip `drawArrays`. Painting against + // a freshly-recreated zero-filled buffer would show one frame + // of empty plot area while gridlines/chrome remain correct. + const posBuf = glManager.bufferPool.peek("a_position"); + const colorBuf = glManager.bufferPool.peek("a_color_value"); + const sizeBuf = glManager.bufferPool.peek("a_size_value"); + if (!posBuf || !colorBuf || !sizeBuf) { + return false; + } gl.bindBuffer(gl.ARRAY_BUFFER, posBuf.buffer); gl.enableVertexAttribArray(cache.a_position); diff --git a/packages/viewer-charts/src/ts/charts/cartesian/label-interner.ts b/packages/viewer-charts/src/ts/charts/cartesian/label-interner.ts new file mode 100644 index 0000000000..301374e009 --- /dev/null +++ b/packages/viewer-charts/src/ts/charts/cartesian/label-interner.ts @@ -0,0 +1,56 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +/** + * Slot-indexed string store for the scatter "Label" column. Strings + * are deduplicated across split-by facets so identical labels share a + * dictionary entry; the per-slot `Int32Array` then holds dictionary + * indices (`-1` means "no label for this slot"). + */ +export class LabelInterner { + readonly data: Int32Array; + readonly dictionary: string[] = []; + private readonly dictMap: Map = new Map(); + + constructor(capacity: number) { + this.data = new Int32Array(capacity); + this.data.fill(-1); + } + + /** + * Insert (or look up) `label` and write its dictionary index into + * the slot at `flatIdx`. Returns the assigned dictionary index. + */ + set(flatIdx: number, label: string): number { + let mapped = this.dictMap.get(label); + if (mapped === undefined) { + mapped = this.dictionary.length; + this.dictionary.push(label); + this.dictMap.set(label, mapped); + } + + this.data[flatIdx] = mapped; + return mapped; + } + + /** + * Resolve a slot's label string, or `null` if unset. + */ + get(flatIdx: number): string | null { + const idx = this.data[flatIdx]; + if (idx < 0) { + return null; + } + + return this.dictionary[idx]; + } +} diff --git a/packages/viewer-charts/src/ts/charts/cartesian/tooltip-lines.ts b/packages/viewer-charts/src/ts/charts/cartesian/tooltip-lines.ts new file mode 100644 index 0000000000..0f5228e916 --- /dev/null +++ b/packages/viewer-charts/src/ts/charts/cartesian/tooltip-lines.ts @@ -0,0 +1,80 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import type { CartesianChart } from "./cartesian"; + +/** + * Build the per-row tooltip for a point-style glyph (scatter, gradient + * heatmap). Resolves the source arrow row via the chart's lazy row + * fetcher, then surfaces every non-null column under the (split-aware) + * prefix filter formatted by column type. + * + * Returns `[]` when the chart has no row-index mirror or no fetcher; + * callers should fall back to a geometry-only tooltip in that case. + */ +export async function buildPointRowTooltipLines( + chart: CartesianChart, + flatIdx: number, +): Promise { + const lines: string[] = []; + if (!chart._rowIndexData || !chart._lazyRows) { + return lines; + } + + const rowIdx = chart._rowIndexData[flatIdx]; + if (rowIdx < 0) { + return lines; + } + + if (chart._splitGroups.length > 0 && chart._seriesCapacity > 0) { + const seriesIdx = Math.floor(flatIdx / chart._seriesCapacity); + const sg = chart._splitGroups[seriesIdx]; + if (sg?.prefix) { + lines.push(sg.prefix); + } + } + + const row = await chart._lazyRows.fetchRow(rowIdx); + + const prefixFilter = + chart._splitGroups.length > 0 && chart._seriesCapacity > 0 + ? (chart._splitGroups[Math.floor(flatIdx / chart._seriesCapacity)] + ?.prefix ?? null) + : null; + + for (const [colName, value] of row) { + if (value === null || value === undefined) { + continue; + } + + let displayName = colName; + if (prefixFilter !== null) { + const expected = `${prefixFilter}|`; + if (!colName.startsWith(expected)) { + continue; + } + + displayName = colName.substring(expected.length); + } else if (colName.includes("|")) { + continue; + } + + if (typeof value === "number") { + const formatted = chart.getColumnFormatter(colName, "value")(value); + lines.push(`${displayName}: ${formatted}`); + } else { + lines.push(`${displayName}: ${value}`); + } + } + + return lines; +} diff --git a/packages/viewer-charts/src/ts/charts/chart-base.ts b/packages/viewer-charts/src/ts/charts/chart-base.ts index 2df03d2693..064c4fd58a 100644 --- a/packages/viewer-charts/src/ts/charts/chart-base.ts +++ b/packages/viewer-charts/src/ts/charts/chart-base.ts @@ -11,8 +11,17 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import type { View } from "@perspective-dev/client"; +import { + createNumberFormatter, + createDatetimeFormatter, + createDateFormatter, + sourceColumn, + type NumberFormatConfig, + type DateFormatConfig, +} from "@perspective-dev/viewer/src/ts/column-format.js"; import type { ColumnDataMap } from "../data/view-reader"; import { LazyRowFetcher } from "../data/lazy-row"; +import { formatTickValue, formatDateTickValue } from "../layout/ticks"; import type { WebGLContextManager } from "../webgl/context-manager"; import { ZoomController, @@ -20,54 +29,91 @@ import { } from "../interaction/zoom-controller"; import { DEFAULT_FACET_CONFIG, + DEFAULT_PLUGIN_CONFIG, type ChartImplementation, type FacetConfig, + type PluginConfig, } from "./chart"; -import { TooltipController } from "../interaction/tooltip-controller"; +import { + TooltipController, + type HostSink, + type TooltipCallbacks, + type UserClickPayload, + type UserSelectPayload, +} from "../interaction/tooltip-controller"; +import type { PerspectiveClickDetail } from "../event-detail"; +import type { ViewConfig } from "@perspective-dev/client"; +import { resolveThemeFromVars, type Theme } from "../theme/theme"; +import { requestRender as scheduleRender } from "../render/scheduler"; + +/** + * Locale-aware fallback formatter applied to numeric tooltip / legend + * values when the column has no `number_format` configured. Two + * fractional digits matches the legacy datagrid default and gives + * tooltips a stable display width. + */ +const DEFAULT_VALUE_FORMATTER: (v: number) => string = ((): (( + v: number, +) => string) => { + return formatTickValue; + // const intl = createNumberFormatter("float"); + // return (v) => intl.format(v); +})(); + +/** + * Locale-aware fallback formatter for datetime tooltip / legend values + * when the column has no `date_format` configured. Uses the locale + * default (no `dateStyle` / `timeStyle`) to match what most users + * expect from an `Intl.DateTimeFormat()` constructed with no options. + */ +const DEFAULT_DATETIME_FORMATTER: (v: number) => string = ((): (( + v: number, +) => string) => { + return formatDateTickValue; + // const intl = createDatetimeFormatter(); + // return (v) => intl.format(v); +})(); /** * Base class for WebGL chart implementations. Owns the common lifecycle - * plumbing (canvas wiring, viewer config setters, render-batching RAF, - * tooltip controller) so each concrete chart only implements data pipeline, - * rendering, and destruction hooks. + * plumbing (canvas wiring, viewer config setters, tooltip controller) + * so each concrete chart only implements data pipeline, rendering, and + * destruction hooks. * * ## Frame lifecycle (three phases) * * Every render of a chart passes through three phases: * - * 1. **Upload** — `uploadAndRender(glManager, columns, startRow, endRow)`. + * 1. `uploadAndRender(glManager, columns, startRow, endRow)`. * Driven by the plugin wrapper once per data chunk. The subclass * runs its build pipeline (axis/series resolution, record * generation, domain accumulation) and pushes typed-array results * into GPU buffers via `glManager.bufferPool`. Most charts also * compile their shaders lazily here on first call. * - * 2. **Schedule** — `_scheduleRender(glManager)`, called by the - * subclass at the end of `uploadAndRender` (and any state-change - * path that needs a redraw: hover, legend toggle, zoom). This is a - * cheap RAF-coalesced trigger — idempotent within a frame, so - * multiple sources can call it without stacking work. + * 2. `requestRender(glManager)` — single entrypoint for triggering a + * paint. Routes through the module-level scheduler + * ([render/scheduler.ts]) which coalesces by glManager and runs + * `_fullRender` + `awaitGpuFence` + `endFrame` on the next RAF. + * Concurrent requests collapse to one `_fullRender` per frame and + * fence waits across charts run in parallel, so per-chart latency + * is bounded by that chart's own GPU work. * - * 3. **Render** — `_fullRender(glManager)` fires on the next animation - * frame. The subclass implements its own draw loop here: resolve - * visible domains from the zoom controller, build projection - * matrices, call into its glyph draw helpers, and paint the chrome - * overlay (axes, legend, tooltip). - * - * `redraw(glManager)` is a public shortcut for "skip the upload, - * re-render with whatever is already on the GPU" — used by the zoom - * controller and the resize path. + * 3. `_fullRender(glManager)` — the subclass implements its own draw + * loop: resolve visible domains from the zoom controller, build + * projection matrices, call into its glyph draw helpers, and paint + * the chrome overlay (axes, legend, tooltip). * * `destroy()` is called by the plugin wrapper on teardown. It detaches - * tooltip listeners, cancels any pending RAF, then invokes the - * subclass's `destroyInternal()` to free chart-specific GL resources. + * tooltip listeners, then invokes the subclass's `destroyInternal()` + * to free chart-specific GL resources. * * ## What subclasses implement - * - `uploadAndRender` — phase 1. - * - `redraw` — usually a one-liner that delegates to `_fullRender`. - * - `attachTooltip(glCanvas)` — wire `this._tooltip` hover/click - * callbacks into chart-specific state mutators. - * - `_fullRender` — phase 3. + * - `uploadAndRender` — phase 1; ends by `await this.requestRender(glManager)`. + * - `tooltipCallbacks()` — return chart-specific hover/click handlers. + * - `_fullRender` — phase 3; must be safe to call with no data + * (subclass guards on its own state machine — empty trees, missing + * programs, etc — and returns early without touching GL). * - `destroyInternal` — release chart-specific resources. * * `getZoomConfig()` is an optional override; default = both axes @@ -79,9 +125,19 @@ export abstract class AbstractChart implements ChartImplementation { // TypeScript's `protected` check. The underscore prefix marks them // as internal by convention. _glManager: WebGLContextManager | null = null; - _gridlineCanvas: HTMLCanvasElement | null = null; - _chromeCanvas: HTMLCanvasElement | null = null; + _gridlineCanvas: HTMLCanvasElement | OffscreenCanvas | null = null; + _chromeCanvas: HTMLCanvasElement | OffscreenCanvas | null = null; + + /** + * Host-supplied CSS-variable map. The host snapshots its DOM via + * `snapshotThemeVars(el)` and ships it over the control channel; + * the chart decodes via `resolveThemeFromVars` lazily in + * `_resolveTheme()`. The chart never reads the DOM itself (it + * always runs inside `WorkerRenderer`, possibly off-thread). + */ + _themeVars: Record = {}; _zoomController: ZoomController | null = null; + /** * Per-facet zoom controllers. Populated when `zoom_mode === * "independent"` and the chart enters faceted mode; each facet's @@ -91,18 +147,93 @@ export abstract class AbstractChart implements ChartImplementation { * single domain used for every facet. */ _facetZoomControllers: ZoomController[] = []; - _glCanvas: HTMLCanvasElement | null = null; _columnSlots: (string | null)[] = []; _groupBy: string[] = []; _splitBy: string[] = []; _columnTypes: Record = {}; + + /** + * Effective shared-axis flags for the most recent faceted frame. + * Derived per-frame from `_facetConfig.shared_x_axis` / + * `shared_y_axis` and `zoom_mode` via + * {@link computeEffectiveFacetFlags} — independent-zoom mode forces + * both off because an outer axis band has no single domain it could + * display. Stored here (rather than mutated back onto + * `_facetConfig`) so the user's configured shared-axis preferences + * survive a "shared → independent → shared" round-trip. Read by + * chrome-overlay code (e.g. `renderFacetedChromeOverlay`, + * `renderFacetedHeatmapChromeOverlay`) after the main render pass + * sets them. + */ + _lastEffectiveSharedX = false; + _lastEffectiveSharedY = false; + + /** + * Source-column types for `group_by` columns — sourced from + * `table.schema()` (plain columns) merged with `view.expression_schema()` + * (expression-typed group_bys). Distinct from `_columnTypes` (which + * is the post-aggregation `view.schema()` map): the level-type + * lookup for `__ROW_PATH_N__` columns must use the unaggregated + * type, since `view.schema()` doesn't key these synthetic columns. + */ + _groupByTypes: Record = {}; _columnsConfig: Record = {}; + + /** + * Pre-compiled per-column value formatters, keyed by the **source** + * column name (synthetic split-by paths are normalized via + * `sourceColumn`). Rebuilt by `setColumnsConfig` from the active + * plugin's `column_config_schema` output, then consulted by axis / + * tooltip / legend paths via {@link getColumnFormatter}. + * + * `undefined` means "no configured formatter for this column" — the + * caller falls back to the chart's hand-rolled tick formatter. + */ + _columnFormatters: Map string> = new Map(); _defaultChartType: string | undefined = undefined; _facetConfig: FacetConfig = { ...DEFAULT_FACET_CONFIG }; + /** + * Plugin-scoped global configuration. Updated by `setPluginConfig` + * (driven from the host's `plugin.restore()`) and read by render- + * path glyphs (`line_width_px`, `point_size_px`, etc.) and by the + * build pipelines (`auto_alt_y_axis`, `band_inner_frac`, + * `bar_inner_pad`). Defaults preserve the previous compile-time + * constants so first-frame rendering before `restore()` matches + * the pre-refactor output. + */ + _pluginConfig: PluginConfig = { ...DEFAULT_PLUGIN_CONFIG }; + _tooltip = new TooltipController(); + /** + * Reference to the active host sink, captured in {@link attachTooltip}. + * Used to emit `perspective-click` / `perspective-global-filter` user + * events back to the host. Distinct from `_tooltip._host` to avoid + * reaching into the tooltip controller's internals. + */ + _hostSink: HostSink | null = null; + + /** + * Promise chain that serializes user-event emissions so a rapid + * pin → unpin sequence stays in order even when `buildClickDetail` + * awaits `_lazyRows.fetchRow`. Without the queue, click 1's async + * row fetch could resolve AFTER click 2's synchronous `emitUnselect` + * — flipping the host's observed event order. All emit helpers + * (`emitClickAndSelect`, `emitUserClick`, `emitUserSelect`, + * `emitUnselect`) chain through this. + */ + _emitQueue: Promise = Promise.resolve(); + + /** + * Cached resolved theme — populated on first `_resolveTheme()` call, + * cleared by `invalidateTheme()` (driven from `plugin.restyle()`). + * `getComputedStyle` / `getPropertyValue` reads cost ~100µs each; + * zoom/hover dispatch redraws at 60Hz so we resolve once and reuse. + */ + _theme: Theme | null = null; + /** * On-demand single-row fetcher used by lazy tooltip column * lookups. Reset on every `setView` call; subclasses read @@ -115,19 +246,21 @@ export abstract class AbstractChart implements ChartImplementation { */ _lazyRows: LazyRowFetcher | null = null; - private _renderScheduled = false; - private _renderRAFId = 0; + // ChartImplementation setters (trivial stores) - // ── ChartImplementation setters (trivial stores) ─────────────────────── - - setGridlineCanvas(canvas: HTMLCanvasElement): void { + setGridlineCanvas(canvas: HTMLCanvasElement | OffscreenCanvas): void { this._gridlineCanvas = canvas; } - setChromeCanvas(canvas: HTMLCanvasElement): void { + setChromeCanvas(canvas: HTMLCanvasElement | OffscreenCanvas): void { this._chromeCanvas = canvas; } + setTheme(vars: Record): void { + this._themeVars = vars; + this._theme = null; + } + setZoomController(zc: ZoomController): void { this._zoomController = zc; zc.configure(this.getZoomConfig()); @@ -144,21 +277,72 @@ export abstract class AbstractChart implements ChartImplementation { if (this._facetConfig.zoom_mode === "shared") { return this._zoomController; } - if (!this._zoomController) return null; + + if (!this._zoomController) { + return null; + } + let zc = this._facetZoomControllers[idx]; if (!zc) { zc = new ZoomController(); zc.configure(this.getZoomConfig()); this._facetZoomControllers[idx] = zc; } + return zc; } /** - * Seed base domain on every zoom controller owned by this chart. - * Build paths call this once per load with the accumulated data - * extents; independent-zoom facets share the same base so visual - * zoom levels stay comparable across facets. + * Derive the effective shared-X / shared-Y flags for the current + * frame and stamp them onto `_lastEffectiveSharedX/Y` for downstream + * chrome-overlay code to consume. Independent-zoom mode forces both + * shared flags off — the outer axis band cannot display per-cell + * viewports — without mutating the user's stored `_facetConfig`. + * + * Returns `{ independentZoom, effectiveSharedX, effectiveSharedY }` + * for callers that need the values immediately (e.g. to pass + * `xAxis: "outer" | "cell"` into `buildFacetGrid`). + */ + computeEffectiveFacetFlags(): { + independentZoom: boolean; + effectiveSharedX: boolean; + effectiveSharedY: boolean; + } { + const independentZoom = this._facetConfig.zoom_mode === "independent"; + const effectiveSharedX = + !independentZoom && this._facetConfig.shared_x_axis; + const effectiveSharedY = + !independentZoom && this._facetConfig.shared_y_axis; + this._lastEffectiveSharedX = effectiveSharedX; + this._lastEffectiveSharedY = effectiveSharedY; + return { independentZoom, effectiveSharedX, effectiveSharedY }; + } + + /** + * Wire every active zoom controller's layout pointer for the + * supplied facet cells. In shared-zoom mode every + * `getZoomControllerForFacet(i)` returns the same `_zoomController`, + * so iterating past the first cell would just re-write the same + * pointer — `break`-on-shared keeps the cost O(1) and avoids the + * subtle bug where every facet's `updateLayout` overwrites the + * previous one with the last cell's layout. + */ + syncFacetZoomLayouts( + cells: ReadonlyArray<{ + layout: import("../layout/plot-layout").PlotLayout; + }>, + ): void { + const independent = this._facetConfig.zoom_mode === "independent"; + for (let i = 0; i < cells.length; i++) { + this.getZoomControllerForFacet(i)?.updateLayout(cells[i].layout); + if (!independent) { + return; + } + } + } + + /** + * Set base domain on every zoom controller owned by this chart. */ setZoomBaseDomain( xMin: number, @@ -169,8 +353,11 @@ export abstract class AbstractChart implements ChartImplementation { if (this._zoomController) { this._zoomController.setBaseDomain(xMin, xMax, yMin, yMax); } + for (const zc of this._facetZoomControllers) { - if (zc) zc.setBaseDomain(xMin, xMax, yMin, yMax); + if (zc) { + zc.setBaseDomain(xMin, xMax, yMin, yMax); + } } } @@ -194,10 +381,151 @@ export abstract class AbstractChart implements ChartImplementation { setColumnTypes(schema: Record): void { this._columnTypes = schema; + this._rebuildColumnFormatters(); + } + + /** + * Clear any `domain_mode: "expand"` accumulator state. Driven by + * `plugin.draw()` (a fresh `draw` always indicates a view-level + * change — viewer config, filters, sorts, etc. — that invalidates + * the previously-accumulated extent) and by the worker's + * `resetAllZooms` path (user clicked "Reset Zoom"). `plugin.update()` + * deliberately does *not* call this — same view, more data, the + * accumulator should keep growing. No-op on the base; chart + * families that hold accumulator fields override. + */ + resetExpandedDomain(): void {} + + setGroupByTypes(schema: Record): void { + this._groupByTypes = schema; } setColumnsConfig(cfg: Record): void { this._columnsConfig = cfg ?? {}; + this._rebuildColumnFormatters(); + } + + /** + * Rebuild {@link _columnFormatters} from `_columnsConfig` + + * `_columnTypes`. Called from both `setColumnsConfig` and + * `setColumnTypes` since either side of the (config, types) pair + * can arrive first depending on the host's restore order. Idempotent. + */ + private _rebuildColumnFormatters(): void { + this._columnFormatters = new Map(); + for (const [name, columnCfg] of Object.entries(this._columnsConfig)) { + // `_columnTypes` is the post-aggregation `view.schema()` map + // and doesn't key group_by source columns; fall back to + // `_groupByTypes` so a configured `date_format` on a + // group_by column (e.g. an "Order Date" pivot) still + // compiles to an `Intl.DateTimeFormat` rather than being + // silently dropped. + const type = this._columnTypes[name] ?? this._groupByTypes[name]; + const fmt = this._compileColumnFormatter(type, columnCfg); + if (fmt) { + this._columnFormatters.set(name, fmt); + } + } + } + + private _compileColumnFormatter( + type: string | undefined, + cfg: Record | undefined, + ): ((v: number) => string) | undefined { + if (!type || !cfg) { + return undefined; + } + + if (type === "integer" || type === "float") { + const numberFormat = cfg.number_format as + | NumberFormatConfig + | undefined; + if (!numberFormat) { + return undefined; + } + + const intl = createNumberFormatter(type, numberFormat); + return (v) => intl.format(v); + } + + if (type === "datetime") { + const dateFormat = cfg.date_format as DateFormatConfig | undefined; + if (!dateFormat) { + return undefined; + } + + const intl = createDatetimeFormatter(dateFormat); + return (v) => intl.format(v); + } + + if (type === "date") { + const dateFormat = cfg.date_format as DateFormatConfig | undefined; + if (!dateFormat) { + return undefined; + } + + const intl = createDateFormatter(dateFormat); + return (v) => intl.format(v); + } + + return undefined; + } + + /** + * Returns the formatter for `columnName` if one has been configured + * (via `column_config_schema` + the user's sidebar choices), else a + * type-appropriate fallback for the chart context. + * + * @param columnName May be a synthetic split-by path + * (`|...|`); the source column is recovered + * internally before lookup. + * @param context `"tick"` returns `undefined` when no per-column + * formatter is configured, so the receiving axis renderer can + * apply its own step-aware default (adaptive date precision from + * tick spacing, K/M/B suffixes for numerics). `"value"` returns + * a precise `Intl.NumberFormat` / `Intl.DateTimeFormat` fallback — + * appropriate for tooltips, legends, overlays where the caller + * invokes the formatter directly and needs a guaranteed function. + */ + getColumnFormatter( + columnName: string | null | undefined, + context: "tick", + ): ((v: number) => string) | undefined; + getColumnFormatter( + columnName: string | null | undefined, + context?: "value", + ): (v: number) => string; + getColumnFormatter( + columnName: string | null | undefined, + context: "tick" | "value" = "value", + ): ((v: number) => string) | undefined { + if (columnName) { + const formatter = this._columnFormatters.get( + sourceColumn(columnName), + ); + if (formatter) { + return formatter; + } + } + + if (context === "tick") { + return undefined; + } + + // `_columnTypes` is the post-aggregation schema and doesn't + // key group_by source columns (their post-aggregate form is + // `__ROW_PATH_N__`); fall back to `_groupByTypes` so date / + // datetime group_by axes don't get formatted as numbers. + const sourceName = columnName ? sourceColumn(columnName) : undefined; + const type = sourceName + ? (this._columnTypes[sourceName] ?? this._groupByTypes[sourceName]) + : undefined; + + if (type === "date" || type === "datetime") { + return DEFAULT_DATETIME_FORMATTER; + } + + return DEFAULT_VALUE_FORMATTER; } setDefaultChartType(chartType: string): void { @@ -208,74 +536,305 @@ export abstract class AbstractChart implements ChartImplementation { this._facetConfig = { ...cfg }; } + /** + * Apply plugin-scoped global config. Stores `cfg` for later reads + * and mirrors the overlapping fields onto adjacent state so deep + * render code keeps reading the single struct it already does: + * + * - `facet_mode` / `facet_zoom_mode` sync into `_facetConfig` so + * `cartesian-render.ts` (and the treemap/sunburst grid checks) + * keep working unchanged. + * - `series_zoom_mode` toggles the `_autoFitValue` flag declared + * on `CategoricalYChart` ("dynamic" = refit on zoom, "fixed" = + * pinned to full extent). Harmless write on charts that don't + * expose the field. + * + * Render-path uniform fields (`line_width_px`, `point_size_px`, + * `wick_width_px`, `ohlc_line_width_px`) are read directly from + * `_pluginConfig` by their respective glyphs on each draw — no + * sync needed. Build-time fields (`auto_alt_y_axis`, + * `band_inner_frac`, `bar_inner_pad`) are read by the pipeline + * inputs in `uploadAndRender`; they take effect on next data load. + */ + setPluginConfig(cfg: PluginConfig): void { + this._pluginConfig = { ...cfg }; + this._facetConfig = { + ...this._facetConfig, + facet_mode: cfg.facet_mode, + zoom_mode: cfg.facet_zoom_mode, + }; + + (this as { _autoFitValue?: boolean })._autoFitValue = + cfg.series_zoom_mode === "dynamic"; + } + + /** + * Lazily decode the host-supplied theme vars. Subsequent calls hit + * the cache until `invalidateTheme()` clears it. Render-path + * callers should always read theme values through this method so + * the parsed `Theme` (gradient stops, palette, etc.) amortizes + * across an entire frame. + */ + _resolveTheme(): Theme { + if (!this._theme) { + this._theme = resolveThemeFromVars(this._themeVars); + } + + return this._theme; + } + + /** + * Drop the cached theme so the next `_resolveTheme()` call re-decodes + * from `_themeVars`. Wired to `plugin.restyle()` — the host pushes + * a fresh var snapshot before invalidating. + */ + invalidateTheme(): void { + this._theme = null; + } + /** * Install a new view for lazy row fetches. Disposes any prior * fetcher and dismisses the pinned tooltip — the prior pinned * row index has no guaranteed correspondence in the new view * (pivot / filter / sort changes can all reshuffle rows). - * - * TODO: future work will keep pinned tooltips visible with their - * last-resolved lines until the user explicitly dismisses them, - * so a mid-session view update doesn't blow away focused context. */ setView(view: View): void { if (this._lazyRows) { this._lazyRows.dispose(); } + this._lazyRows = new LazyRowFetcher(view); - this._tooltip.dismissPinned(); + // A view change (filter / pivot / sort / schema) implicitly + // dismisses any active pin — the prior row index has no + // guaranteed correspondence in the new view. Emit a matching + // `selected: false` so downstream filter-coordinated consumers + // can roll back their derived state. + const wasPinned = this._tooltip.isPinned; + this._tooltip.dismiss(); + if (wasPinned) { + this.emitUnselect(); + } } - // ── Render batching ──────────────────────────────────────────────────── + /** + * Build the chart-specific {@link TooltipCallbacks} object — the + * `onHover` / `onLeave` / `onClickPre` / `onPin` / `onDblClick` + * surface that mediates between the cursor and chart state. + * Subclasses override this; the base returns a no-op pair. + */ + protected tooltipCallbacks(): TooltipCallbacks { + return { + onHover: () => {}, + onLeave: () => {}, + }; + } - /** Schedule one `_fullRender` on the next animation frame (idempotent). */ - protected _scheduleRender(glManager: WebGLContextManager): void { - if (this._renderScheduled) return; - this._renderScheduled = true; - this._renderRAFId = requestAnimationFrame(() => { - this._renderScheduled = false; - this._renderRAFId = 0; - this._fullRender(glManager); - }); + /** + * Wire the chart's `TooltipController` for virtual-dispatch + * `InteractionEvent`s forwarded from the host, and install the + * host sink that materializes pinned tooltips and cursor changes + * host-side. + */ + attachTooltip(host: HostSink): void { + this._tooltip.attach(this.tooltipCallbacks()); + this._tooltip.setHost(host); + this._hostSink = host; } - /** Cancel any pending render (used when a new stream begins). */ - protected _cancelScheduledRender(): void { - if (this._renderRAFId) { - cancelAnimationFrame(this._renderRAFId); - this._renderRAFId = 0; - this._renderScheduled = false; + /** + * Build a `PerspectiveClickDetail` payload from a per-family + * resolved click target. Fetches the source-view row via + * `_lazyRows` (returns `row: {}` if the row can't be resolved — + * e.g., aggregate / density cells), and concatenates the + * `group_by` and `split_by` pivot values into a + * `viewer.restore({ filter })`-shaped patch. + * + * Mirrors the filter-building logic in datagrid's + * `getCellConfig` ([packages/viewer-datagrid/src/ts/get_cell_config.ts]), + * but operates on `AbstractChart` state rather than a `DatagridModel`. + */ + async buildClickDetail(target: { + rowIdx: number | null; + columnName: string; + groupByValues: (string | number | null)[]; + splitByValues: (string | number | null)[]; + }): Promise { + let row: Record = {}; + if (target.rowIdx != null && target.rowIdx >= 0 && this._lazyRows) { + try { + const r = await this._lazyRows.fetchRow(target.rowIdx); + row = Object.fromEntries(r); + } catch { + // Fetcher may have been disposed mid-flight; treat as + // "no row" and emit the filter-only detail anyway. + row = {}; + } + } + + const filter: Array<[string, "==", string | number]> = []; + for (let i = 0; i < this._groupBy.length; i++) { + const v = target.groupByValues[i]; + if (v != null && v !== "") { + filter.push([this._groupBy[i], "==", v]); + } + } + + for (let i = 0; i < this._splitBy.length; i++) { + const v = target.splitByValues[i]; + if (v != null && v !== "") { + filter.push([this._splitBy[i], "==", v]); + } } + + return { + row, + column_names: [target.columnName], + config: { filter } as Partial, + }; + } + + /** + * Forward a `perspective-click` to the host. No-op when the chart + * has not been wired to a host sink (e.g., unit-tested charts). + * Synchronous; callers needing ordering with async emits should + * chain through `_emitQueue`. + */ + emitUserClick(detail: PerspectiveClickDetail): void { + const payload: UserClickPayload = { + row: detail.row, + column_names: detail.column_names, + config: detail.config as { filter?: unknown[] }, + }; + this._hostSink?.emitUserClick?.(payload); } - // ── Lifecycle ────────────────────────────────────────────────────────── + /** + * Forward a `perspective-global-filter` to the host. The host + * transport materializes a `PerspectiveSelectDetail` from this plus + * its cached previous-insert config and dispatches. Synchronous. + */ + emitUserSelect(args: { + selected: boolean; + row: Record; + column_names: string[]; + insertConfig: Partial; + }): void { + const payload: UserSelectPayload = { + selected: args.selected, + row: args.row, + column_names: args.column_names, + insertConfig: args.insertConfig as { filter?: unknown[] }, + }; + this._hostSink?.emitUserSelect?.(payload); + } + + /** + * Convenience: fire both `perspective-click` and + * `perspective-global-filter` (`selected: true`) from a resolved + * click target. Used by chart families where every click both + * "selects" and "filters" (series, heatmap, candlestick, scatter, + * treemap-leaf, etc.). Treemap branch / breadcrumb gestures use + * the lower-level helpers directly. + * + * Chains through `_emitQueue` so the row-fetch await can't reorder + * this emit behind a follow-up `emitUnselect`. + */ + emitClickAndSelect(target: { + rowIdx: number | null; + columnName: string; + groupByValues: (string | number | null)[]; + splitByValues: (string | number | null)[]; + }): Promise { + const next = this._emitQueue.then(async () => { + const detail = await this.buildClickDetail(target); + this.emitUserClick(detail); + this.emitUserSelect({ + selected: true, + row: detail.row, + column_names: detail.column_names, + insertConfig: detail.config, + }); + }); + // Swallow errors on the chain so a single failure doesn't + // poison subsequent emits; surface to console for debugging. + this._emitQueue = next.catch((e) => { + console.error("emitClickAndSelect failed", e); + }); + return next; + } + + /** + * Fire a `perspective-global-filter` with `selected: false`. Used + * by treemap / sunburst breadcrumb navigation and by chart-base's + * own `setView` when a view change implicitly dismisses any active + * pin. Chains through `_emitQueue` so it lands AFTER any in-flight + * `emitClickAndSelect`. + */ + emitUnselect( + args: { + row?: Record; + column_names?: string[]; + } = {}, + ): void { + const next = this._emitQueue.then(() => { + this.emitUserSelect({ + selected: false, + row: args.row ?? {}, + column_names: args.column_names ?? [], + insertConfig: { filter: [] }, + }); + }); + this._emitQueue = next.catch((e) => { + console.error("emitUnselect failed", e); + }); + } + + // Render entrypoint + + /** + * Public coalesced render. Routes through the module-level + * scheduler so concurrent calls collapse to one `_fullRender` per + * RAF and the host blitter receives one bitmap per frame. The + * returned promise resolves after this chart's `awaitGpuFence` + + * `endFrame` chain — independent of other charts in the same + * RAF, which run their fence waits in parallel. + * + * Every render-triggering caller — upload chunks, zoom / pan, + * resize, theme invalidation, host-driven redraws — calls this. + * The only sanctioned bypass is `snapshotPng`, which calls + * `_fullRender` directly to keep the GL backbuffer intact for + * `gl.readPixels`. + */ + requestRender(glManager: WebGLContextManager): Promise { + return scheduleRender(glManager, () => this._fullRender(glManager)); + } + + // Lifecycle destroy(): void { this._tooltip.detach(); - this._tooltip.dismissPinned(); - this._cancelScheduledRender(); + this._tooltip.dismiss(); if (this._lazyRows) { this._lazyRows.dispose(); this._lazyRows = null; } + this.destroyInternal(); } - // ── Abstract surface ─────────────────────────────────────────────────── + // Abstract surface abstract uploadAndRender( glManager: WebGLContextManager, columns: ColumnDataMap, startRow: number, endRow: number, - ): void; + ): Promise; - abstract redraw(glManager: WebGLContextManager): void; + abstract _fullRender(glManager: WebGLContextManager): void; - abstract attachTooltip(glCanvas: HTMLCanvasElement): void; - - protected abstract _fullRender(glManager: WebGLContextManager): void; - - /** Release chart-specific GL/CPU resources. `destroy` calls this. */ + /** + * Release chart-specific GL/CPU resources. `destroy` calls this. + */ protected abstract destroyInternal(): void; } diff --git a/packages/viewer-charts/src/ts/charts/chart.ts b/packages/viewer-charts/src/ts/charts/chart.ts index ddce63753e..a585db5450 100644 --- a/packages/viewer-charts/src/ts/charts/chart.ts +++ b/packages/viewer-charts/src/ts/charts/chart.ts @@ -14,6 +14,7 @@ import type { View } from "@perspective-dev/client"; import type { ColumnDataMap } from "../data/view-reader"; import type { WebGLContextManager } from "../webgl/context-manager"; import type { ZoomController } from "../interaction/zoom-controller"; +import type { HostSink } from "../interaction/tooltip-controller"; export interface ChartImplementation { uploadAndRender( @@ -23,8 +24,32 @@ export interface ChartImplementation { endRow: number, ): void; - /** Re-render with existing GPU buffer data (e.g., after resize). */ - redraw(glManager: WebGLContextManager): void; + /** + * The single render entrypoint. Every render-triggering caller — + * upload chunks, zoom / pan, resize, theme invalidation, + * host-driven redraws — calls this. Routes through the + * module-level scheduler ([render/scheduler.ts]) so concurrent + * calls collapse to one `_fullRender` per RAF and the host + * blitter receives one bitmap per frame per chart. + * + * The returned promise resolves after this entry's `_fullRender` + * + `awaitGpuFence` + `endFrame` chain completes — independent + * of other charts in the same RAF, which run their fence waits + * in parallel. + * + * The synchronous-render bypass for `snapshotPng` (calls + * `_fullRender` directly, skips `endFrame`) is the only + * sanctioned exception and lives inside the worker renderer. + */ + requestRender(glManager: WebGLContextManager): Promise; + + /** + * The chart-specific frame builder. The scheduler wraps this with + * fence + `endFrame`; callers must not invoke it directly except + * for `snapshotPng`, which needs an intact GL backbuffer for + * `gl.readPixels` and so must skip the `endFrame` pair. + */ + _fullRender(glManager: WebGLContextManager): void; /** * Hand the current View to the chart so it can make on-demand @@ -39,27 +64,65 @@ export interface ChartImplementation { */ setView?(view: View): void; - /** Set the gridline canvas (behind WebGL, for gridlines). */ - setGridlineCanvas?(canvas: HTMLCanvasElement): void; + /** + * Set the gridline canvas (behind WebGL, for gridlines). + */ + setGridlineCanvas?(canvas: HTMLCanvasElement | OffscreenCanvas): void; - /** Set the chrome canvas (above WebGL, for axes/labels/legend/tooltip). */ - setChromeCanvas?(canvas: HTMLCanvasElement): void; + /** + * Set the chrome canvas (above WebGL, for axes/labels/legend/tooltip). + */ + setChromeCanvas?(canvas: HTMLCanvasElement | OffscreenCanvas): void; - /** Set the zoom controller for interactive zoom/pan. */ + /** + * Hand the chart a pre-computed CSS-variable map produced on the + * main thread via `snapshotThemeVars(el)`, which it can decode into + * a full `Theme` without touching the DOM (charts always run inside + * the renderer scope, which has no `getComputedStyle`). + */ + setTheme?(vars: Record): void; + + /** + * Set the zoom controller for interactive zoom/pan. + */ setZoomController?(zc: ZoomController): void; - /** Attach tooltip mouse handlers to the GL canvas. */ - attachTooltip?(glCanvas: HTMLCanvasElement): void; + /** + * Wire the chart's `TooltipController` for virtual-dispatch hover / + * click events forwarded from the host. The renderer drives + * `dispatchHover` / `dispatchLeave` / `dispatchClick` / + * `dispatchDblClick` from `InteractionEvent`s; the supplied + * `HostSink` posts pin / dismiss / setCursor intents back to the + * host so the resulting DOM mutations happen there (the renderer + * scope has no DOM in worker mode, and uses the same channel + * in-process for symmetry). + */ + attachTooltip?(host: HostSink): void; - /** Set the column slot config (with nulls for empty slots). */ + /** + * Set the column slot config (with nulls for empty slots). + */ setColumnSlots?(slots: (string | null)[]): void; - /** Set group_by and split_by config from the viewer. */ + /** + * Set group_by and split_by config from the viewer. + */ setViewPivots?(groupBy: string[], splitBy: string[]): void; - /** Set column type schema from the view (e.g., { "col": "date" }). */ + /** + * Set column type schema from the view (e.g., { "col": "date" }). + */ setColumnTypes?(schema: Record): void; + /** + * Set the source-column types used for `group_by` level lookups — + * sourced from `table.schema()` + `view.expression_schema()`. Used + * by categorical-axis charts to detect numeric / date / boolean + * group_by levels (which are not keyed in `view.schema()` because + * they surface as `__ROW_PATH_N__` columns). + */ + setGroupByTypes?(schema: Record): void; + /** * Set per-column render config (the second argument to `plugin.restore`). * Key is the aggregate base name; value is an open object whose @@ -78,27 +141,71 @@ export interface ChartImplementation { /** * Set the faceting config: one small-multiple sub-plot per * `split_by` group, optional shared axes, coordinated tooltip, and - * zoom routing mode. Currently seeded from the `FACET_CONFIG` - * const in `plugin.ts`; eventually this will pass through - * `columns_config`. + * zoom routing mode. Seeded from `DEFAULT_FACET_CONFIG` at init; + * `plugin_config.facet_mode` / `facet_zoom_mode` override the + * matching fields via `AbstractChart.setPluginConfig`. */ setFacetConfig?(cfg: FacetConfig): void; + /** + * Set the plugin-scoped global configuration — the values backing + * `plugin_config_schema` / `plugin_config` in `restore`. Replaces + * the previous module-level constants (`LINE_WIDTH_PX`, + * `POINT_SIZE_PX`, `BAND_INNER_FRAC`, `BAR_INNER_PAD`, + * `WICK_WIDTH_PX`, `OHLC_LINE_WIDTH_PX`, `AUTO_ALT_Y_AXIS`) plus + * the faceted/series zoom-mode semantics described in + * {@link PluginConfig}. + */ + setPluginConfig?(cfg: PluginConfig): void; + + /** + * Drop any cached theme values so the next render re-reads CSS + * variables. Driven from `plugin.restyle()`. + */ + invalidateTheme?(): void; + + /** + * Clear the `domain_mode: "expand"` accumulator state so the next + * data load starts from the current data extent. Driven from + * `resetAllZooms` (the user clicked "Reset Zoom"). View-config + * mutations route through `AbstractChart`'s `setColumnSlots` / + * `setViewPivots` / `setColumnTypes` setters, which call the same + * hook internally. + */ + resetExpandedDomain?(): void; + destroy(): void; } export interface FacetConfig { - /** "grid" = small multiples (default); "overlay" = legacy single-plot. */ + /** + * "grid" = small multiples (default); "overlay" = legacy single-plot. + */ facet_mode: "grid" | "overlay"; - /** Share one bottom X axis across all columns of facets. */ + + /** + * Share one bottom X axis across all columns of facets. + */ shared_x_axis: boolean; - /** Share one left Y axis across all rows of facets. */ + + /** + * Share one left Y axis across all rows of facets. + */ shared_y_axis: boolean; - /** Paint a tooltip in every facet (otherwise only the source facet). */ + + /** + * Paint a tooltip in every facet (otherwise only the source facet). + */ coordinated_tooltip: boolean; - /** "shared" = one viewport for all facets; "independent" = per-facet. */ + + /** + * "shared" = one viewport for all facets; "independent" = per-facet. + */ zoom_mode: "shared" | "independent"; - /** Pixel gap between adjacent facet cells in grid mode. */ + + /** + * Pixel gap between adjacent facet cells in grid mode. + */ facet_padding: number; } @@ -110,3 +217,211 @@ export const DEFAULT_FACET_CONFIG: FacetConfig = { zoom_mode: "shared", facet_padding: 6, }; + +/** + * Plugin-scoped global configuration — the user-facing settings backing + * `plugin_config_schema()` / the `plugin_config` slot in `restore`. + * + * Each chart type's `plugin_config_schema` returns only the fields that + * are applicable for that chart (see `applicable_plugin_fields` on + * `ChartTypeConfig`); inapplicable fields are hidden in the UI. The + * chart impl receives the full struct on `setPluginConfig` and reads + * only the fields its render / build pipeline cares about. + * + * Some fields overlap with {@link FacetConfig} (`facet_mode`, + * `facet_zoom_mode`); the base `AbstractChart.setPluginConfig` syncs + * those onto `_facetConfig` so deep render code keeps reading the + * single facet struct it already does. `series_zoom_mode` toggles the + * categorical-Y chart base's `_autoFitValue` flag. + */ +export interface PluginConfig { + /** + * Auto-detect Y dual-axis splits when aggregate magnitudes differ + * by more than `DUAL_Y_RATIO_THRESHOLD`×. Series charts only. + * Replaces the `AUTO_ALT_Y_AXIS` compile-time toggle. + */ + auto_alt_y_axis: boolean; + + /** + * Faceting strategy when `split_by` is non-empty. + * + * - `"grid"` — one small-multiple sub-plot per split group. + * - `"overlay"` — single plot with split groups differentiated by + * color. Synced into `_facetConfig.facet_mode`. + */ + facet_mode: "grid" | "overlay"; + + /** + * Faceted-cartesian zoom routing. `"shared"` — one viewport across + * all facets; `"independent"` — wheel/pan routes to the facet under + * the cursor with its own viewport. Synced into + * `_facetConfig.zoom_mode`. + */ + facet_zoom_mode: "shared" | "independent"; + + /** + * Series-chart value-axis behavior on zoom. + * + * - `"dynamic"` — value axis refits to the visible categorical + * slice (current default; `CategoricalYChart._autoFitValue` = + * true). + * - `"fixed"` — value axis stays pinned to the full-data extent. + */ + series_zoom_mode: "fixed" | "dynamic"; + + /** + * Anchor the value axis to zero — when true, `0` is forced into + * the rendered domain even if all data sits well above or below + * it. Natural for bar / area glyphs (which grow from the zero + * baseline) and surprising for line / scatter (where the + * interesting variation often lives far from zero). Per-chart-type + * defaults route through `ChartTypeConfig.plugin_field_defaults`: + * `true` for Y Bar / Y Area / X Bar, `false` elsewhere. + */ + include_zero: boolean; + + /** + * Domain accumulation policy across successive `View` updates. + * + * - `"fit"` — every update recomputes the rendered domain (and on + * cartesian charts, the X/Y range and color/size scales) from + * the current data extent. Can grow or shrink frame-to-frame. + * - `"expand"` — the rendered domain monotonically *grows*: each + * update unions the new data extent with the previously rendered + * extent, so once a value is in scope it stays in scope. Reset + * by the "Reset Zoom" button, view-config changes (group_by / + * split_by / column-slot / column-type), or toggling back to + * `"fit"`. + */ + domain_mode: "fit" | "expand"; + + /** + * Width of polyline glyphs in CSS pixels (multiplied by DPR at GL + * upload). Replaces the duplicated `LINE_WIDTH_PX` constants in + * the cartesian + series line glyphs. + */ + line_width_px: number; + + /** + * Diameter of scatter point glyphs in CSS pixels. Replaces + * `POINT_SIZE_PX`. + */ + point_size_px: number; + + /** + * Fraction of each category band occupied by its slot(s). Replaces + * `BAND_INNER_FRAC`. Affects buffer contents — takes effect on + * next data load. + */ + band_inner_frac: number; + + /** + * Relative inner padding between adjacent slots within a band. + * Replaces `BAR_INNER_PAD`. Affects buffer contents — takes effect + * on next data load. + */ + bar_inner_pad: number; + + /** + * Candlestick wick stroke width in CSS pixels. Replaces + * `WICK_WIDTH_PX`. + */ + wick_width_px: number; + + /** + * OHLC bar stroke width in CSS pixels. Replaces + * `OHLC_LINE_WIDTH_PX`. + */ + ohlc_line_width_px: number; + + /** + * density splat radius in CSS pixels. Each data point is + * rasterized as a soft disk of this radius into the accumulation + * FBO before the gradient LUT pass resolves to a heat color. + */ + gradient_radius_px: number; + + /** + * density per-splat intensity multiplier. Controls how + * fast the density grows when points overlap (low values produce + * a smoother, more diffuse field; high values produce sharper + * peaks). + */ + gradient_intensity: number; + + /** + * density clamp on the maximum accumulated heat used for + * the gradient-LUT lookup. Lower values saturate sooner (more of + * the LUT's hot stops show up); higher values stay cooler. In + * every mode this controls the alpha (intensity) ramp; in + * `density` mode it also drives the hue, and in `signed` mode it + * scales the signed-sum-to-hue mapping. + */ + gradient_heat_max: number; + + /** + * density color-reduction mode. Controls how each pixel's + * stack of overlapping splats is reduced to a single LUT-t / alpha + * pair in the resolve pass. + * + * - `mean` (default) — hue is the density-weighted average of + * per-point color-t. Reads as "the typical color-column value + * in this region." Uses robust (5th/95th-percentile) bounds so + * one outlier can't compress the rest of the data toward the + * gradient midpoint. + * - `density` — ignore the color column even when wired; hue and + * alpha both follow density. Reads as "where do points cluster." + * Useful when the color column is attached for tooltip lookup + * only. + * - `extreme` — keep the per-pixel maximum signed deviation of + * `t - 0.5` (split into positive and negative channels, MAX- + * blended). Reads as "where are the outliers." Density still + * drives alpha so a single-point extreme fades naturally. + * Requires a second framebuffer; uses MRT on WebGL2 hardware + * with `OES_draw_buffers_indexed`, two passes otherwise. + * - `signed` — accumulate signed `t - 0.5` and let positive vs + * negative cancel out. Reads as "net positive vs net negative + * in each region." Requires a float-capable framebuffer; on + * `UNSIGNED_BYTE` fallback hardware degrades silently to + * `mean` with a one-line console warning. + */ + gradient_color_mode: "mean" | "density" | "extreme" | "signed"; + + /** + * Map basemap tile provider. Applies only to map plugin tags + * (`map-scatter`, `map-line`, `map-density`). Cartesian charts + * ignore the field. Surfaced as an enum on the settings panel so + * users can switch light/dark/voyager without writing custom + * tile-source code. + */ + map_tile_provider: "carto-positron" | "carto-dark-matter" | "carto-voyager"; + + /** + * Map basemap alpha (0..1). Pre-multiplied into the tile fragment + * shader's output so the chart's glyph layer composites over a + * dimmer or brighter version of the basemap. `1.0` (default) + * shows the tiles at full opacity. + */ + map_tile_alpha: number; +} + +export const DEFAULT_PLUGIN_CONFIG: PluginConfig = { + auto_alt_y_axis: false, + facet_mode: "grid", + facet_zoom_mode: "shared", + series_zoom_mode: "dynamic", + include_zero: false, + domain_mode: "fit", + line_width_px: 2.0, + point_size_px: 8.0, + band_inner_frac: 0.5, + bar_inner_pad: 0.1, + wick_width_px: 1.0, + ohlc_line_width_px: 1.0, + gradient_radius_px: 32.0, + gradient_intensity: 0.6, + gradient_heat_max: 4.0, + gradient_color_mode: "mean", + map_tile_provider: "carto-positron", + map_tile_alpha: 1.0, +}; diff --git a/packages/viewer-charts/src/ts/charts/common/band-layout.ts b/packages/viewer-charts/src/ts/charts/common/band-layout.ts index d660f7dc48..7da55a3858 100644 --- a/packages/viewer-charts/src/ts/charts/common/band-layout.ts +++ b/packages/viewer-charts/src/ts/charts/common/band-layout.ts @@ -17,23 +17,34 @@ * candles, …) are laid out side by side with a small inner padding. */ -/** Fraction of a category's band width actually covered by slots. */ -export const BAND_INNER_FRAC = 0.5; - -/** Relative padding between adjacent slots within a band. */ -export const BAR_INNER_PAD = 0.1; - export interface SlotGeometry { - /** Width (in data-space units) of a single slot. */ + /** + * Width (in data-space units) of a single slot. + */ slotWidth: number; - /** Half the drawable width of each slot after inner padding. */ + + /** + * Half the drawable width of each slot after inner padding. + */ halfWidth: number; } -/** Compute slot geometry for `numSlots` rectangles per category band. */ -export function computeSlotGeometry(numSlots: number): SlotGeometry { - const slotWidth = BAND_INNER_FRAC / Math.max(1, numSlots); - const halfWidth = (slotWidth * (1 - BAR_INNER_PAD)) / 2; +/** + * Compute slot geometry for `numSlots` rectangles per category band. + * + * `bandInnerFrac` is the fraction of each category's band width + * actually covered by slots; `barInnerPad` is the relative padding + * between adjacent slots within a band. Both come from + * {@link PluginConfig.band_inner_frac} / `bar_inner_pad`. Defaults + * match the previous hard-coded constants (0.5 / 0.1). + */ +export function computeSlotGeometry( + numSlots: number, + bandInnerFrac: number = 0.5, + barInnerPad: number = 0.1, +): SlotGeometry { + const slotWidth = bandInnerFrac / Math.max(1, numSlots); + const halfWidth = (slotWidth * (1 - barInnerPad)) / 2; return { slotWidth, halfWidth }; } diff --git a/packages/viewer-charts/src/ts/charts/common/categorical-y-chart.ts b/packages/viewer-charts/src/ts/charts/common/categorical-y-chart.ts index b13dd0ffb6..bbb9c569fe 100644 --- a/packages/viewer-charts/src/ts/charts/common/categorical-y-chart.ts +++ b/packages/viewer-charts/src/ts/charts/common/categorical-y-chart.ts @@ -11,11 +11,11 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import type { PlotLayout } from "../../layout/plot-layout"; -import type { AxisDomain } from "../../chrome/numeric-axis"; +import type { AxisDomain } from "../../axis/numeric-axis"; import type { CategoricalDomain, CategoricalLevel, -} from "../../chrome/categorical-axis"; +} from "../../axis/categorical-axis"; import type { ZoomConfig } from "../../interaction/zoom-controller"; import { AbstractChart } from "../chart-base"; @@ -33,25 +33,33 @@ import { AbstractChart } from "../chart-base"; * too much to usefully share. */ export abstract class CategoricalYChart extends AbstractChart { - // ── Categorical X axis state ───────────────────────────────────────── - /** Row-path levels (group_by hierarchy) for X-axis tick rendering. */ + // Categorical X axis state + /** + * Row-path levels (group_by hierarchy) for X-axis tick rendering. + */ _rowPaths: CategoricalLevel[] = []; - /** Number of categories on the X axis. */ + + /** + * Number of categories on the X axis. + */ _numCategories = 0; - /** Offset into the aggregated-row stream (total-rows are skipped). */ + + /** + * Offset into the aggregated-row stream (total-rows are skipped). + */ _rowOffset = 0; - // ── Shared GL resources ────────────────────────────────────────────── + // Shared GL resources _program: WebGLProgram | null = null; _cornerBuffer: WebGLBuffer | null = null; - // ── Last-frame cache (for chrome-only redraws) ─────────────────────── + // Last-frame cache (for chrome-only redraws) _lastLayout: PlotLayout | null = null; _lastXDomain: CategoricalDomain | null = null; _lastYDomain: AxisDomain | null = null; _lastYTicks: number[] | null = null; - // ── Auto-fit value axis (opt-in per chart) ─────────────────────────── + // Auto-fit value axis (opt-in per chart) /** * When true, the value axis refits to the visible categorical * window each frame — so zooming the categorical axis tightens the @@ -60,7 +68,7 @@ export abstract class CategoricalYChart extends AbstractChart { * (bar needs `hiddenSeries`, candlestick doesn't; bar may have * dual-axis extents, candlestick is single-axis). */ - _autoFitValue = false; + _autoFitValue = true; /** * Lock the value axis by default — user wheel/pan should only diff --git a/packages/viewer-charts/src/ts/charts/common/category-axis-resolver.ts b/packages/viewer-charts/src/ts/charts/common/category-axis-resolver.ts new file mode 100644 index 0000000000..4ac5cac818 --- /dev/null +++ b/packages/viewer-charts/src/ts/charts/common/category-axis-resolver.ts @@ -0,0 +1,314 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import type { ColumnDataMap, ColumnData } from "../../data/view-reader"; +import type { CategoricalLevel } from "../../axis/categorical-axis"; +import { buildGroupRuns } from "../../axis/categorical-axis-core"; +import { formatTickValue, formatDateTickValue } from "../../layout/ticks"; + +export interface CategoryAxisResult { + /** + * Fully materialized hierarchical levels — labels and group runs are + * pre-resolved from the view's `__ROW_PATH_N__` dictionaries (or + * synthesized for non-string levels) so the chart can retain them + * past the `with_typed_arrays` callback scope. Empty when `groupBy` + * is empty. + */ + rowPaths: CategoricalLevel[]; + + /** + * Rows that actually contribute a category (post-offset). + */ + numCategories: number; + + /** + * Leading rows skipped; callers use this to rebase per-row indices. + */ + rowOffset: number; +} + +export type AxisMode = + | { mode: "category" } + | { + mode: "numeric"; + numericType: "date" | "datetime" | "integer" | "float"; + }; + +/** + * Numeric category-axis state. Shared across bar / candlestick / heatmap + * pipelines: when an axis is driven by exactly one non-string group_by / + * split_by level, glyphs anchor at real data values via `categoryPositions` + * and the chrome renders a numeric (date-aware) tick row. + */ +export interface NumericCategoryDomain { + min: number; + max: number; + isDate: boolean; + label: string; + + /** + * Data-unit width of one category band, from min adjacent delta. + */ + bandWidth: number; +} + +/** + * Compute `categoryPositions` (per-row real data values) plus a + * `NumericCategoryDomain` summarizing min/max/bandWidth for a numeric + * row-path column. `bandWidth` falls back to the full domain when there + * are <2 distinct positions. Pivot rows for a single group_by come ASC + * by default, so a forward sweep for `minDelta` is sufficient. + * + * Returns `null` when the row-path column is missing or carries no + * `values` array (e.g. dictionary-encoded string column). + */ +export function resolveNumericCategoryDomain( + rpValues: ArrayLike | null | undefined, + numCategories: number, + rowOffset: number, + label: string, + isDate: boolean, +): { + categoryPositions: Float64Array; + numericCategoryDomain: NumericCategoryDomain; +} | null { + if (!rpValues || numCategories <= 0) { + return null; + } + + const categoryPositions = new Float64Array(numCategories); + let minVal = Infinity; + let maxVal = -Infinity; + for (let catI = 0; catI < numCategories; catI++) { + const v = rpValues[catI + rowOffset] as number; + categoryPositions[catI] = v; + if (v < minVal) { + minVal = v; + } + + if (v > maxVal) { + maxVal = v; + } + } + + let minDelta = Infinity; + for (let i = 1; i < numCategories; i++) { + const d = Math.abs(categoryPositions[i] - categoryPositions[i - 1]); + if (d > 0 && d < minDelta) { + minDelta = d; + } + } + + if (!isFinite(minDelta) || minDelta === 0) { + minDelta = Math.max(1, maxVal - minVal); + } + + return { + categoryPositions, + numericCategoryDomain: { + min: minVal - minDelta / 2, + max: maxVal + minDelta / 2, + isDate, + label, + bandWidth: minDelta, + }, + }; +} + +/** + * Decide whether the categorical axis should render as a stringified + * category axis or a true numeric axis. Numeric mode is only used when + * there is exactly one `group_by` level AND that level is a non-string, + * non-boolean numeric type. Boolean and any multi-level case → category. + */ +export function resolveAxisMode( + groupBy: string[], + groupByTypes: Record, +): AxisMode { + if (groupBy.length !== 1) { + return { mode: "category" }; + } + + const t = groupByTypes[groupBy[0]]; + if (t === "date" || t === "datetime" || t === "integer" || t === "float") { + return { mode: "numeric", numericType: t }; + } + + return { mode: "category" }; +} + +/** + * Stringify a single value from a non-string row-path column. + */ +function formatLevelValue( + value: number, + valid: boolean, + levelType: string, +): string { + if (!valid) { + return ""; + } + + if (levelType === "boolean") { + return value ? "true" : "false"; + } + + if (levelType === "date" || levelType === "datetime") { + return formatDateTickValue(value); + } + + if (levelType === "integer") { + return String(value | 0); + } + + if (levelType === "float") { + return formatTickValue(value); + } + + return String(value); +} + +/** + * Synthesize a `(indices, dictionary)` pair from a non-string row-path + * column so the rest of the categorical axis machinery (label + * pre-resolution, run-length encoding) can run unchanged. The dictionary + * uses `""` at index 0 as the rollup-row sentinel — this preserves the + * existing skip-rollup loop's `s !== ""` check. + */ +export function synthesizeStringLevel( + rp: ColumnData, + numRows: number, + levelType: string, +): { indices: Int32Array; dictionary: string[] } { + const values = rp.values!; + const valid = rp.valid; + const indices = new Int32Array(numRows); + const dictionary: string[] = [""]; + const seen = new Map(); + seen.set("", 0); + + for (let r = 0; r < numRows; r++) { + const isValid = valid ? !!((valid[r >> 3] >> (r & 7)) & 1) : true; + const v = values[r] as number; + const label = formatLevelValue(v, isValid, levelType); + let dictIdx = seen.get(label); + if (dictIdx === undefined) { + dictIdx = dictionary.length; + dictionary.push(label); + seen.set(label, dictIdx); + } + + indices[r] = dictIdx; + } + + return { indices, dictionary }; +} + +/** + * Resolve the category axis for a categorical-X chart (bar, candlestick, + * ohlc, …). Walks the `__ROW_PATH_N__` hierarchy columns, skips the + * rollup rows at the top ("Total" parent aggregates), and returns fully + * JS-owned level structures (precomputed labels + runs) plus the + * trimmed category count. + * + * Non-string row-path columns (date / datetime / integer / float / + * boolean group_by levels) are stringified into a synthetic dictionary + * so the downstream label / run-length machinery is type-agnostic. + * + * When `groupByLen === 0`, there are no row-path columns and the + * category axis falls back to the raw row index — callers infer that + * from `rowPaths.length === 0`. + */ +export function resolveCategoryAxis( + columns: ColumnDataMap, + numRows: number, + groupByLen: number, + levelTypes: string[] = [], +): CategoryAxisResult { + type RawLevel = { indices: Int32Array; dictionary: string[] }; + const rawRowPaths: RawLevel[] = []; + for (let n = 0; ; n++) { + const rp = columns.get(`__ROW_PATH_${n}__`); + if (!rp) { + break; + } + + if (rp.type === "string" && rp.indices && rp.dictionary) { + rawRowPaths.push({ + indices: rp.indices, + dictionary: rp.dictionary, + }); + } else if (rp.values) { + const levelType = levelTypes[n] ?? "string"; + rawRowPaths.push(synthesizeStringLevel(rp, numRows, levelType)); + } else { + break; + } + } + + let rowOffset = 0; + if (groupByLen > 0 && rawRowPaths.length > 0) { + while (rowOffset < numRows) { + let anyNonEmpty = false; + for (const rp of rawRowPaths) { + const s = rp.dictionary[rp.indices[rowOffset]]; + if (s != null && s !== "") { + anyNonEmpty = true; + break; + } + } + + if (anyNonEmpty) { + break; + } + + rowOffset++; + } + } + + const numCategories = Math.max(0, numRows - rowOffset); + + const L = rawRowPaths.length; + const rowPaths: CategoricalLevel[] = + groupByLen > 0 && L > 0 + ? rawRowPaths.map((rp, levelIdx) => { + const labels = new Array(numCategories); + let maxLabelChars = 0; + for (let r = 0; r < numCategories; r++) { + const s = rp.dictionary[rp.indices[r + rowOffset]] ?? ""; + labels[r] = s; + if (s.length > maxLabelChars) { + maxLabelChars = s.length; + } + } + + // Only outer levels need the run-length encoding for + // bracket rendering; leaves render per-row. + const runs = + levelIdx === L - 1 + ? [] + : buildGroupRuns( + rp.indices, + rp.dictionary, + rowOffset, + rowOffset + numCategories, + ).map((run) => ({ + startIdx: run.startIdx - rowOffset, + endIdx: run.endIdx - rowOffset, + label: run.label, + })); + return { labels, runs, maxLabelChars }; + }) + : []; + + return { rowPaths, numCategories, rowOffset }; +} diff --git a/packages/viewer-charts/src/ts/charts/common/category-axis.ts b/packages/viewer-charts/src/ts/charts/common/category-axis.ts deleted file mode 100644 index e488b30053..0000000000 --- a/packages/viewer-charts/src/ts/charts/common/category-axis.ts +++ /dev/null @@ -1,103 +0,0 @@ -// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ -// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ -// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ -// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ -// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ -// ┃ Copyright (c) 2017, the Perspective Authors. ┃ -// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ -// ┃ This file is part of the Perspective library, distributed under the terms ┃ -// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ -// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - -import type { ColumnDataMap } from "../../data/view-reader"; -import type { CategoricalLevel } from "../../chrome/categorical-axis"; -import { buildGroupRuns } from "../../chrome/categorical-axis-core"; - -export interface CategoryAxisResult { - /** - * Fully materialized hierarchical levels — labels and group runs are - * pre-resolved from the view's `__ROW_PATH_N__` dictionaries so the - * chart can retain them past the `with_typed_arrays` callback scope. - * Empty when `groupBy` is empty. - */ - rowPaths: CategoricalLevel[]; - /** Rows that actually contribute a category (post-offset). */ - numCategories: number; - /** Leading rows skipped; callers use this to rebase per-row indices. */ - rowOffset: number; -} - -/** - * Resolve the category axis for a categorical-X chart (bar, candlestick, - * ohlc, …). Walks the `__ROW_PATH_N__` hierarchy columns, skips the - * rollup rows at the top ("Total" parent aggregates), and returns fully - * JS-owned level structures (precomputed labels + runs) plus the - * trimmed category count. - * - * When `groupByLen === 0`, there are no row-path columns and the - * category axis falls back to the raw row index — callers infer that - * from `rowPaths.length === 0`. - */ -export function resolveCategoryAxis( - columns: ColumnDataMap, - numRows: number, - groupByLen: number, -): CategoryAxisResult { - type RawLevel = { indices: Int32Array; dictionary: string[] }; - const rawRowPaths: RawLevel[] = []; - for (let n = 0; ; n++) { - const rp = columns.get(`__ROW_PATH_${n}__`); - if (!rp || rp.type !== "string" || !rp.indices || !rp.dictionary) break; - rawRowPaths.push({ indices: rp.indices, dictionary: rp.dictionary }); - } - - let rowOffset = 0; - if (groupByLen > 0 && rawRowPaths.length > 0) { - while (rowOffset < numRows) { - let anyNonEmpty = false; - for (const rp of rawRowPaths) { - const s = rp.dictionary[rp.indices[rowOffset]]; - if (s != null && s !== "") { - anyNonEmpty = true; - break; - } - } - if (anyNonEmpty) break; - rowOffset++; - } - } - const numCategories = Math.max(0, numRows - rowOffset); - - const L = rawRowPaths.length; - const rowPaths: CategoricalLevel[] = - groupByLen > 0 && L > 0 - ? rawRowPaths.map((rp, levelIdx) => { - const labels = new Array(numCategories); - let maxLabelChars = 0; - for (let r = 0; r < numCategories; r++) { - const s = rp.dictionary[rp.indices[r + rowOffset]] ?? ""; - labels[r] = s; - if (s.length > maxLabelChars) maxLabelChars = s.length; - } - // Only outer levels need the run-length encoding for - // bracket rendering; leaves render per-row. - const runs = - levelIdx === L - 1 - ? [] - : buildGroupRuns( - rp.indices, - rp.dictionary, - rowOffset, - rowOffset + numCategories, - ).map((run) => ({ - startIdx: run.startIdx - rowOffset, - endIdx: run.endIdx - rowOffset, - label: run.label, - })); - return { labels, runs, maxLabelChars }; - }) - : []; - - return { rowPaths, numCategories, rowOffset }; -} diff --git a/packages/viewer-charts/src/ts/charts/common/chrome-cache.ts b/packages/viewer-charts/src/ts/charts/common/chrome-cache.ts new file mode 100644 index 0000000000..f84263fe69 --- /dev/null +++ b/packages/viewer-charts/src/ts/charts/common/chrome-cache.ts @@ -0,0 +1,79 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import type { Canvas2D, Context2D } from "../canvas-types"; + +export interface ChromeCacheChart { + _chromeCanvas: Canvas2D | null; + _chromeCache: ImageBitmap | null; + _chromeCacheDirty: boolean; + _chromeCacheGen: number; +} + +/** + * Run the static-chrome cache pattern shared by sunburst + treemap. + * Resizes the canvas, paints the static layer (and snapshots it as an + * `ImageBitmap`) when dirty, otherwise blits the cache; then runs the + * caller-provided overlay layer for hover/highlight state. + * + * Returns the prepared `ctx` already in DPR-scaled space so the overlay + * callback can paint in CSS pixels — except `null` if either the canvas + * is missing a 2D context or the chart has nothing to paint. + */ +export function withChromeCache( + chart: ChromeCacheChart, + canvas: Canvas2D, + dpr: number, + cssWidth: number, + cssHeight: number, + drawStatic: (ctx: Context2D) => void, + drawOverlay: ((ctx: Context2D) => void) | null, +): void { + const targetW = Math.round(cssWidth * dpr); + const targetH = Math.round(cssHeight * dpr); + if (canvas.width !== targetW || canvas.height !== targetH) { + canvas.width = targetW; + canvas.height = targetH; + chart._chromeCacheDirty = true; + } + + const ctx = canvas.getContext("2d") as Context2D | null; + if (!ctx) { + return; + } + + if (chart._chromeCacheDirty) { + chart._chromeCache?.close(); + chart._chromeCache = null; + chart._chromeCacheDirty = false; + const gen = ++chart._chromeCacheGen; + drawStatic(ctx); + createImageBitmap(canvas).then((bmp) => { + if (chart._chromeCacheGen === gen) { + chart._chromeCache?.close(); + chart._chromeCache = bmp; + } else { + bmp.close(); + } + }); + } else if (chart._chromeCache) { + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(chart._chromeCache, 0, 0); + } + + if (drawOverlay) { + ctx.save(); + ctx.scale(dpr, dpr); + drawOverlay(ctx); + ctx.restore(); + } +} diff --git a/packages/viewer-charts/src/ts/charts/common/draw-tooltip-box.ts b/packages/viewer-charts/src/ts/charts/common/draw-tooltip-box.ts new file mode 100644 index 0000000000..9a4e472ed7 --- /dev/null +++ b/packages/viewer-charts/src/ts/charts/common/draw-tooltip-box.ts @@ -0,0 +1,84 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import type { Context2D } from "../canvas-types"; +import type { Theme } from "../../theme/theme"; + +/** + * Draw a freestanding tooltip box anchored near (cx, cy), measuring + * lines, sizing/clamping the box, painting bg/border, and laying out + * text rows. Shared by sunburst + treemap which need a non-PlotLayout + * anchor. + */ +export function drawTooltipBox( + ctx: Context2D, + theme: Theme, + lines: string[], + cx: number, + cy: number, + cssWidth: number, + cssHeight: number, + fontFamily: string, +): void { + if (lines.length === 0) { + return; + } + + const { tooltipBg, tooltipText, tooltipBorder } = theme; + + ctx.font = `11px ${fontFamily}`; + const lineHeight = 16; + const padding = 8; + let maxWidth = 0; + for (const line of lines) { + const w = ctx.measureText(line).width; + if (w > maxWidth) { + maxWidth = w; + } + } + + const boxW = maxWidth + padding * 2; + const boxH = lines.length * lineHeight + padding * 2 - 4; + + let tx = cx + 12; + let ty = cy - boxH - 8; + if (tx + boxW > cssWidth) { + tx = cx - boxW - 12; + } + + if (tx < 0) { + tx = 4; + } + + if (ty < 0) { + ty = cy + 12; + } + + if (ty + boxH > cssHeight) { + ty = cssHeight - boxH - 4; + } + + ctx.fillStyle = tooltipBg; + ctx.strokeStyle = tooltipBorder; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.roundRect(tx, ty, boxW, boxH, 4); + ctx.fill(); + ctx.stroke(); + + ctx.fillStyle = tooltipText; + ctx.textAlign = "left"; + ctx.textBaseline = "top"; + for (let i = 0; i < lines.length; i++) { + ctx.fillText(lines[i], tx + padding, ty + padding + i * lineHeight); + } +} diff --git a/packages/viewer-charts/src/ts/charts/common/leaf-color.ts b/packages/viewer-charts/src/ts/charts/common/leaf-color.ts new file mode 100644 index 0000000000..d04fff33a3 --- /dev/null +++ b/packages/viewer-charts/src/ts/charts/common/leaf-color.ts @@ -0,0 +1,92 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import type { TreeChartBase } from "./tree-chart"; +import type { Vec3 } from "../../theme/palette"; +import { + colorValueToT, + sampleGradient, + type GradientStop, +} from "../../theme/gradient"; + +/** + * Perceptual luminance for a 0..1 RGB triple. Used by tree-chart label + * painters to pick a contrasting text color over each leaf's fill. + */ +export function luminance(r: number, g: number, b: number): number { + return 0.299 * r + 0.587 * g + 0.114 * b; +} + +/** + * Sample a gradient and drop the alpha channel. Treemap / sunburst + * fills carry alpha separately (see {@link leafRGBA}); this is the + * "just give me the RGB" entry point. + */ +export function sampleRGB( + stops: GradientStop[], + t: number, +): [number, number, number] { + const c = sampleGradient(stops, t); + return [c[0], c[1], c[2]]; +} + +/** + * Resolve a leaf's fill color according to the chart's color mode: + * - `"numeric"` — sign-aware gradient sample via `colorValueToT`. + * - `"series"` / `"empty"` — discrete palette lookup keyed by the + * node's `colorLabel` (composite of group_by levels in series mode; + * `""` in empty mode, which maps to `palette[0]`). + * + * Returns RGB only; the alpha channel is applied separately by + * {@link leafRGBA} using `negativeAlpha` for leaves whose raw size was + * negative. + */ +export function leafColor( + chart: TreeChartBase, + nodeId: number, + stops: GradientStop[], + palette: Vec3[], +): [number, number, number] { + const store = chart._nodeStore; + const colorValue = store.colorValue[nodeId]; + if ( + chart._colorMode === "numeric" && + !isNaN(colorValue) && + chart._colorMax > chart._colorMin + ) { + return sampleRGB( + stops, + colorValueToT(colorValue, chart._colorMin, chart._colorMax), + ); + } + + const idx = chart._uniqueColorLabels.get(store.colorLabel[nodeId]) ?? 0; + return palette[idx % palette.length] ?? [0, 0, 0]; +} + +/** + * `leafColor` + an alpha channel. Negative-size leaves receive + * `negativeAlpha` (mirrors `theme.areaOpacity` for area charts) so + * they stay visually distinguishable from positive leaves without + * disappearing. + */ +export function leafRGBA( + chart: TreeChartBase, + nodeId: number, + stops: GradientStop[], + palette: Vec3[], + negativeAlpha: number, +): [number, number, number, number] { + const rgb = leafColor(chart, nodeId, stops, palette); + const alpha = chart._nodeStore.sizeSign[nodeId] < 0 ? negativeAlpha : 1.0; + return [rgb[0], rgb[1], rgb[2], alpha]; +} diff --git a/packages/viewer-charts/src/ts/charts/common/node-store.ts b/packages/viewer-charts/src/ts/charts/common/node-store.ts index 45f81d4ea5..c666f12a9f 100644 --- a/packages/viewer-charts/src/ts/charts/common/node-store.ts +++ b/packages/viewer-charts/src/ts/charts/common/node-store.ts @@ -42,6 +42,7 @@ export class NodeStore { size!: Float32Array; value!: Float32Array; colorValue!: Float32Array; + /** * Sign of the leaf's raw size column value: `-1` when the source row * was negative, `1` otherwise. `size` itself always stores the @@ -111,6 +112,7 @@ export class NodeStore { this.r0.fill(0, 0, this.count); this.r1.fill(0, 0, this.count); } + this.count = 0; } @@ -123,6 +125,7 @@ export class NodeStore { if (this.count === this.capacity) { this._allocate(this.capacity * 2); } + const id = this.count++; this.firstChild[id] = NULL_NODE; this.nextSibling[id] = NULL_NODE; @@ -138,7 +141,9 @@ export class NodeStore { return id; } - /** O(1) append `childId` as the last child of `parentId`. */ + /** + * O(1) append `childId` as the last child of `parentId`. + */ appendChild(parentId: number, childId: number): void { this.parent[childId] = parentId; this.depth[childId] = this.depth[parentId] + 1; @@ -149,11 +154,14 @@ export class NodeStore { } else { this.nextSibling[last] = childId; } + this.lastChild[parentId] = childId; this.childCount[parentId]++; } - /** `true` if the node has no children (branches set firstChild when they acquire one). */ + /** + * `true` if the node has no children (branches set firstChild when they acquire one). + */ isLeaf(id: number): boolean { return this.firstChild[id] === NULL_NODE; } @@ -166,7 +174,10 @@ export class NodeStore { ctor: { new (n: number): T }, ): T => { const next = new ctor(cap); - if (old && old.length > 0) next.set(old); + if (old && old.length > 0) { + next.set(old); + } + return next; }; @@ -191,10 +202,17 @@ export class NodeStore { this.leafRowIdx = grow(this.leafRowIdx, Int32Array); // JS arrays: preserve existing, extend with empty slots. - if (!this.name) this.name = new Array(cap); - else this.name.length = cap; - if (!this.colorLabel) this.colorLabel = new Array(cap); - else this.colorLabel.length = cap; + if (!this.name) { + this.name = new Array(cap); + } else { + this.name.length = cap; + } + + if (!this.colorLabel) { + this.colorLabel = new Array(cap); + } else { + this.colorLabel.length = cap; + } this.capacity = cap; } @@ -212,5 +230,6 @@ export function ancestorNames(store: NodeStore, id: number): string[] { out.push(store.name[n]); n = store.parent[n]; } + return out.reverse(); } diff --git a/packages/viewer-charts/src/ts/charts/common/tree-chart.ts b/packages/viewer-charts/src/ts/charts/common/tree-chart.ts index 1b22645b35..fad31c3b1c 100644 --- a/packages/viewer-charts/src/ts/charts/common/tree-chart.ts +++ b/packages/viewer-charts/src/ts/charts/common/tree-chart.ts @@ -12,6 +12,7 @@ import { AbstractChart } from "../chart-base"; import { NodeStore, NULL_NODE } from "./node-store"; +import { LazyTooltip } from "../../interaction/lazy-tooltip"; /** * Shared state for hierarchical charts (treemap, sunburst). Holds the @@ -23,7 +24,7 @@ import { NodeStore, NULL_NODE } from "./node-store"; * layout modules can read/write them without friction. */ export abstract class TreeChartBase extends AbstractChart { - // ── Shared column-slot resolution ──────────────────────────────────── + // Shared column-slot resolution _sizeName = ""; _colorName = ""; @@ -37,7 +38,7 @@ export abstract class TreeChartBase extends AbstractChart { */ _colorMode: "empty" | "numeric" | "series" = "empty"; - // ── Tree storage (SOA + linked-list children) ──────────────────────── + // Tree storage (SOA + linked-list children) _nodeStore: NodeStore = new NodeStore(); _rootId: number = NULL_NODE; _currentRootId: number = NULL_NODE; @@ -49,31 +50,27 @@ export abstract class TreeChartBase extends AbstractChart { */ _childLookup: Map> = new Map(); - // ── Streaming-insert row counter ───────────────────────────────────── + // Streaming-insert row counter // Source-view row offset tracked across chunks so `leafRowIdx` on // each leaf points back to the correct view row for lazy tooltip // fetches via `AbstractChart._lazyRows`. _rowCount = 0; - // ── Color extents / categorical key table ─────────────────────────── + // Color extents / categorical key table _colorMin = Infinity; _colorMax = -Infinity; _uniqueColorLabels: Map = new Map(); - // ── Visible-node cache (populated per frame by layout/collect) ────── + // Visible-node cache (populated per frame by layout/collect) ────── _visibleNodeIds: Int32Array | null = null; _visibleNodeCount = 0; /** - * Cached hover-tooltip lines, filled in asynchronously when a - * lazy row fetch resolves. `null` means "not yet available" — the - * chrome overlay skips the in-chart tooltip box in that state. - * `_hoveredTooltipNodeId` records the node the cached lines are - * for so the render path can tell stale cache entries apart from - * fresh ones. + * Lazy-tooltip cache. `lines` is `null` until the async row fetch + * resolves; the controller's serial dance drops stale results so + * rapid mouse motion doesn't paint out-of-date text. The render + * path consults `hoveredTarget` to verify cached lines belong to + * the currently hovered node before painting. */ - _hoveredTooltipLines: string[] | null = null; - _hoveredTooltipNodeId: number = -1; - _hoveredTooltipSerial = 0; - _pinnedTooltipSerial = 0; + _lazyTooltip = new LazyTooltip(); } diff --git a/packages/viewer-charts/src/ts/charts/common/tree-chrome.ts b/packages/viewer-charts/src/ts/charts/common/tree-chrome.ts new file mode 100644 index 0000000000..05a302bcac --- /dev/null +++ b/packages/viewer-charts/src/ts/charts/common/tree-chrome.ts @@ -0,0 +1,123 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import type { Context2D } from "../canvas-types"; +import type { TreeChartBase } from "./tree-chart"; +import { drawTooltipBox } from "./draw-tooltip-box"; + +/** + * Click target for one breadcrumb segment. Tree-chart hit-testing + * checks `_breadcrumbRegions` first so a click on the trail re-roots + * the view to that ancestor. + */ +export interface BreadcrumbRegion { + nodeId: number; + x0: number; + y0: number; + x1: number; + y1: number; +} + +const BREADCRUMB_HEIGHT = 24; +const BREADCRUMB_PAD_X = 8; +const BREADCRUMB_TEXT_Y = 12; +const BREADCRUMB_HIT_PAD = 2; +const BREADCRUMB_SEPARATOR = " › "; + +/** + * Paint the ancestor-path strip across the top of a tree chart and + * record per-crumb hit regions on `chart._breadcrumbRegions`. Mirrors + * the structure used by both treemap and sunburst — they only differ + * in their hover-highlight geometry, so this strip is shared verbatim. + */ +export function renderBreadcrumbs( + chart: TreeChartBase & { _breadcrumbRegions: BreadcrumbRegion[] }, + ctx: Context2D, + cssWidth: number, + fontFamily: string, + textColor: string, +): void { + chart._breadcrumbRegions = []; + + const bgColor = chart._resolveTheme().tooltipBg; + ctx.fillStyle = bgColor; + ctx.fillRect(0, 0, cssWidth, BREADCRUMB_HEIGHT); + + ctx.font = `11px ${fontFamily}`; + ctx.textAlign = "left"; + ctx.textBaseline = "middle"; + + let x = BREADCRUMB_PAD_X; + const store = chart._nodeStore; + + for (let i = 0; i < chart._breadcrumbIds.length; i++) { + const crumbId = chart._breadcrumbIds[i]; + const isLast = i === chart._breadcrumbIds.length - 1; + const label = store.name[crumbId]; + + ctx.fillStyle = textColor; + const textW = ctx.measureText(label).width; + ctx.fillText(label, x, BREADCRUMB_TEXT_Y); + + chart._breadcrumbRegions.push({ + nodeId: crumbId, + x0: x - BREADCRUMB_HIT_PAD, + y0: 0, + x1: x + textW + BREADCRUMB_HIT_PAD, + y1: BREADCRUMB_HEIGHT, + }); + + x += textW; + + if (!isLast) { + ctx.fillText(BREADCRUMB_SEPARATOR, x, BREADCRUMB_TEXT_Y); + x += ctx.measureText(BREADCRUMB_SEPARATOR).width; + } + } +} + +/** + * Paint the lazy-tooltip box for a tree-chart node, anchored at + * `(cx, cy)`. Returns early when no lines are cached for `nodeId` + * (the lazy lookup hasn't resolved yet, or `nodeId` doesn't match the + * currently-hovered target). Both treemap (rect-center) and sunburst + * (arc-mid) charts only differ in how they compute the anchor. + */ +export function renderTreeTooltip( + chart: TreeChartBase, + ctx: Context2D, + nodeId: number, + cx: number, + cy: number, + cssWidth: number, + cssHeight: number, + fontFamily: string, +): void { + const lines = + chart._lazyTooltip.hoveredTarget === nodeId + ? (chart._lazyTooltip.lines ?? []) + : []; + if (lines.length === 0) { + return; + } + + drawTooltipBox( + ctx, + chart._resolveTheme(), + lines, + cx, + cy, + cssWidth, + cssHeight, + fontFamily, + ); +} diff --git a/packages/viewer-charts/src/ts/charts/common/tree-data.ts b/packages/viewer-charts/src/ts/charts/common/tree-data.ts index c2260034f3..b6bdba3af7 100644 --- a/packages/viewer-charts/src/ts/charts/common/tree-data.ts +++ b/packages/viewer-charts/src/ts/charts/common/tree-data.ts @@ -39,10 +39,11 @@ import type { ColumnDataMap, ColumnData } from "../../data/view-reader"; import { buildSplitGroups } from "../../data/split-groups"; +import { synthesizeStringLevel } from "./category-axis-resolver"; import { NULL_NODE } from "./node-store"; import type { TreeChartBase } from "./tree-chart"; -// ── Reset ──────────────────────────────────────────────────────────────── +// Reset /** * Reset the shared tree state. Called on the first chunk @@ -73,7 +74,7 @@ export function resetTreeState(chart: TreeChartBase): void { chart._visibleNodeCount = 0; } -// ── Tree insertion ─────────────────────────────────────────────────────── +// Tree insertion /** * Find-or-create a child of `parentId` named `segment`. Uses a per- @@ -89,8 +90,11 @@ function childByName( lookup = new Map(); chart._childLookup.set(parentId, lookup); } + const existing = lookup.get(segment); - if (existing !== undefined) return existing; + if (existing !== undefined) { + return existing; + } const childId = chart._nodeStore.allocate(); chart._nodeStore.name[childId] = segment; @@ -125,11 +129,18 @@ function insertRow( chart._nodeStore.sizeSign[childId] = sizeValue < 0 ? -1 : 1; chart._nodeStore.leafRowIdx[childId] = rowIdx; } + if (!isNaN(colorValue)) { chart._nodeStore.colorValue[childId] = colorValue; - if (colorValue < chart._colorMin) chart._colorMin = colorValue; - if (colorValue > chart._colorMax) chart._colorMax = colorValue; + if (colorValue < chart._colorMin) { + chart._colorMin = colorValue; + } + + if (colorValue > chart._colorMax) { + chart._colorMax = colorValue; + } } + if (colorLabel) { chart._nodeStore.colorLabel[childId] = colorLabel; if (!chart._uniqueColorLabels.has(colorLabel)) { @@ -152,7 +163,10 @@ function readColor( ): { colorValue: number; colorLabel: string } { let colorValue = NaN; let colorLabel = ""; - if (!colorCol) return { colorValue, colorLabel }; + if (!colorCol) { + return { colorValue, colorLabel }; + } + if (chart._colorMode === "numeric" && colorCol.values) { colorValue = colorCol.values[rowIdx] as number; } else if ( @@ -166,6 +180,7 @@ function readColor( // the end result is `palette[dictIdx % paletteSize]`. colorLabel = colorCol.dictionary[colorCol.indices[rowIdx]]; } + return { colorValue, colorLabel }; } @@ -183,8 +198,14 @@ function seedColorLabels( chart: TreeChartBase, colorCol: ColumnData | null | undefined, ): void { - if (chart._colorMode !== "series") return; - if (!colorCol?.dictionary) return; + if (chart._colorMode !== "series") { + return; + } + + if (!colorCol?.dictionary) { + return; + } + const dict = colorCol.dictionary; for (let i = 0; i < dict.length; i++) { const s = dict[i]; @@ -194,7 +215,7 @@ function seedColorLabels( } } -// ── Chunk processor ────────────────────────────────────────────────────── +// Chunk processor interface SplitSource { prefix: string; @@ -210,11 +231,17 @@ function resolveSplitSources( chart: TreeChartBase, columns: ColumnDataMap, ): SplitSource[] | null { - if (chart._splitBy.length === 0) return null; + if (chart._splitBy.length === 0) { + return null; + } + const required: string[] = chart._sizeName ? [chart._sizeName] : []; const optional: string[] = chart._colorName ? [chart._colorName] : []; const groups = buildSplitGroups(columns, required, optional); - if (groups.length === 0) return null; + if (groups.length === 0) { + return null; + } + return groups.map((g) => ({ prefix: g.prefix, sizeCol: chart._sizeName @@ -237,8 +264,23 @@ export function processTreeChunk( const rpCols: { indices: Int32Array; dictionary: string[] }[] = []; for (let n = 0; ; n++) { const rp = columns.get(`__ROW_PATH_${n}__`); - if (!rp || rp.type !== "string" || !rp.indices || !rp.dictionary) break; - rpCols.push({ indices: rp.indices, dictionary: rp.dictionary }); + if (!rp) { + break; + } + + if (rp.type === "string" && rp.indices && rp.dictionary) { + rpCols.push({ indices: rp.indices, dictionary: rp.dictionary }); + } else if (rp.values) { + // Non-string group_by (integer / float / date / datetime / + // boolean) — synthesize a string-indexed dictionary so the + // tree insertion loop can treat every level uniformly. + // Uses the same formatter as `resolveCategoryAxis`. + const levelName = chart._groupBy[n]; + const levelType = chart._groupByTypes[levelName] ?? "string"; + rpCols.push(synthesizeStringLevel(rp, rp.values.length, levelType)); + } else { + break; + } } const hasGroupBy = rpCols.length > 0; @@ -253,7 +295,9 @@ export function processTreeChunk( const numRows = hasGroupBy ? rpCols[0].indices.length : (firstSizeCol?.values?.length ?? 0); - if (numRows === 0) return; + if (numRows === 0) { + return; + } // Seed palette label indices from the color column's dictionary // BEFORE inserting rows, so the first row doesn't assign label 0 @@ -261,7 +305,9 @@ export function processTreeChunk( // seed once per split's own color column so every dict value is // known to the shared legend. if (hasSplits) { - for (const src of splitSources!) seedColorLabels(chart, src.colorCol); + for (const src of splitSources!) { + seedColorLabels(chart, src.colorCol); + } } else { seedColorLabels(chart, colorCol); } @@ -272,20 +318,67 @@ export function processTreeChunk( // to the correct view row after multiple chunk arrivals. const base = chart._rowCount; - // The split expansion inserts the same row under N different path - // prefixes. `groupByLen + 1` (or just 1 in non-group-by mode) is - // passed as the `groupByLen` override so `insertRow` treats the - // correct depth as the leaf; this keeps per-leaf `size` / `color` - // aligned with each facet's source column. - const effectiveGroupLen = hasSplits ? groupByLen + 1 : groupByLen; + // The split expansion inserts every row under one extra leading + // path segment — the split prefix. When `group_by` is also active + // the path is `[prefix, ...groupByLevels]` (length `groupByLen + 1`) + // and the leaf store gate inside `insertRow` (`depth === groupByLen`) + // needs the `+1` to find the leaf. When `group_by` is empty we fall + // through the no-group-by branch below, which writes paths of the + // form `[prefix, label]`; that path's leaf is the row label, and + // sizing it relies on `insertRow`'s `groupByLen === 0` + // "every leaf gets sized" branch — passing `0` here lets that + // branch fire so each row's leaf actually receives a size. The + // historical `+1`-always form quietly produced an effective + // `groupByLen=1` against a depth-2 path, so the size store was + // skipped and treemap / sunburst rendered nothing. + const effectiveGroupLen = + hasSplits && hasGroupBy ? groupByLen + 1 : groupByLen; if (!hasGroupBy) { // Flat fallback: synthesize a single-segment path per row from // the first string column (or a "Row N" sentinel). + // + // In `split_by` mode every column the view emits is pivoted as + // `${splitPrefix}|${baseName}`, so two correctness traps need + // to be avoided here: + // + // 1. The size / color column the user picked appears under + // its prefixed form (`First Class|Region`), so a literal + // `name === chart._sizeName/_colorName` skip would miss + // them and the loop would happily pick the color column + // itself as the label source. + // 2. Reading a pivoted column by row index returns values + // keyed to ONE specific split (the prefix that happened + // to win the iteration order), not to that row's actual + // split — so even a "different" pivoted dictionary column + // isn't a legitimate per-row label. And once any pivoted + // column is used as a label source, `childByName` collapses + // every row sharing a `(prefix, label)` pair onto a single + // node — last-write-wins for `size` — which truncates the + // visible cell count to `cardinality(label) × cardinality(splits)`. + // + // The two checks below address both: compare base names (post-`|`) + // for the size/color skip, and reject any pivoted column outright + // in split mode. If nothing remains, `labelCol` stays undefined + // and the per-row loop falls through to the `Row N` sentinel, + // which gives every row a unique key. let labelCol: ColumnData | undefined; for (const [name, col] of columns) { - if (name.startsWith("__")) continue; - if (name === chart._sizeName || name === chart._colorName) continue; + if (name.startsWith("__")) { + continue; + } + + if (hasSplits && name.includes("|")) { + continue; + } + + const pipeIdx = name.lastIndexOf("|"); + const baseName = + pipeIdx === -1 ? name : name.substring(pipeIdx + 1); + if (baseName === chart._sizeName || baseName === chart._colorName) { + continue; + } + if (col.type === "string" && col.indices && col.dictionary) { labelCol = col; break; @@ -342,6 +435,7 @@ export function processTreeChunk( ); } } + chart._rowCount = base + numRows; return; } @@ -356,10 +450,16 @@ export function processTreeChunk( for (let d = 0; d < rpCols.length; d++) { const rp = rpCols[d]; const label = rp.dictionary[rp.indices[i]]; - if (!label && label !== "0") break; + if (!label && label !== "0") { + break; + } + pathScratch[extra + pathLen++] = label; } - if (pathLen === 0) continue; // skip total row + + if (pathLen === 0) { + continue; + } // skip total row if (hasSplits) { for (const src of splitSources!) { @@ -400,10 +500,11 @@ export function processTreeChunk( ); } } + chart._rowCount = base + numRows; } -// ── Finalize ───────────────────────────────────────────────────────────── +// Finalize /** * Post-chunk finalization. @@ -431,7 +532,9 @@ export function finalizeTree(chart: TreeChartBase): void { let sp = 1; // Reset value accumulators. - for (let i = 0; i < store.count; i++) value[i] = 0; + for (let i = 0; i < store.count; i++) { + value[i] = 0; + } while (sp > 0) { sp--; @@ -446,6 +549,7 @@ export function finalizeTree(chart: TreeChartBase): void { bigger.set(stack); stack = bigger; } + stack[sp * 2] = c; stack[sp * 2 + 1] = 0; sp++; @@ -462,6 +566,7 @@ export function finalizeTree(chart: TreeChartBase): void { ) { sum += value[c]; } + value[id] = sum; } } @@ -476,6 +581,7 @@ export function finalizeTree(chart: TreeChartBase): void { for (let i = 1; i < chart._breadcrumbIds.length; i++) { breadcrumbNames.push(store.name[chart._breadcrumbIds[i]]); } + let node = chart._rootId; let valid = true; for (const seg of breadcrumbNames) { @@ -485,21 +591,26 @@ export function finalizeTree(chart: TreeChartBase): void { valid = false; break; } + node = next; } + if (valid && store.firstChild[node] !== NULL_NODE) { chart._currentRootId = node; rebuildBreadcrumbs(chart, node); return; } } + chart._currentRootId = chart._rootId; chart._breadcrumbIds = [chart._rootId]; } -// ── Breadcrumbs ────────────────────────────────────────────────────────── +// Breadcrumbs -/** Rebuild `chart._breadcrumbIds` by walking up from `nodeId`. */ +/** + * Rebuild `chart._breadcrumbIds` by walking up from `nodeId`. + */ export function rebuildBreadcrumbs(chart: TreeChartBase, nodeId: number): void { const ids: number[] = []; let n = nodeId; @@ -507,5 +618,6 @@ export function rebuildBreadcrumbs(chart: TreeChartBase, nodeId: number): void { ids.unshift(n); n = chart._nodeStore.parent[n]; } + chart._breadcrumbIds = ids; } diff --git a/packages/viewer-charts/src/ts/charts/common/visible-extent.ts b/packages/viewer-charts/src/ts/charts/common/visible-extent.ts index 93ecd6b1ef..ddda9a5066 100644 --- a/packages/viewer-charts/src/ts/charts/common/visible-extent.ts +++ b/packages/viewer-charts/src/ts/charts/common/visible-extent.ts @@ -33,13 +33,24 @@ export interface VisibleExtent { } export interface VisibleExtentRecord { - /** Position on the categorical axis — compared to the visible window. */ + /** + * Position on the categorical axis — compared to the visible window. + */ cat: number; - /** Low bound of the value-axis extent for this record. */ + + /** + * Low bound of the value-axis extent for this record. + */ lo: number; - /** High bound of the value-axis extent for this record. */ + + /** + * High bound of the value-axis extent for this record. + */ hi: number; - /** True to skip this record (hidden series / wrong axis / etc.). */ + + /** + * True to skip this record (hidden series / wrong axis / etc.). + */ skip: boolean; } @@ -63,23 +74,37 @@ export function computeVisibleExtent( ): VisibleExtent { let min = Infinity; let max = -Infinity; + // Reuse a single scratch record across the walk. `extract` mutates // it in place — zero allocations per iteration. const scratch: VisibleExtentRecord = { cat: 0, lo: 0, hi: 0, skip: false }; for (let i = 0; i < items.length; i++) { scratch.skip = false; extract(items[i], scratch); - if (scratch.skip) continue; - if (scratch.cat < visCatMin || scratch.cat > visCatMax) continue; - if (scratch.lo < min) min = scratch.lo; - if (scratch.hi > max) max = scratch.hi; + if (scratch.skip) { + continue; + } + + if (scratch.cat < visCatMin || scratch.cat > visCatMax) { + continue; + } + + if (scratch.lo < min) { + min = scratch.lo; + } + + if (scratch.hi > max) { + max = scratch.hi; + } } + const hasFit = isFinite(min) && isFinite(max); if (hasFit && min === max) { const pad = Math.abs(min) || 1; min -= pad; max += pad; } + out.min = min; out.max = max; out.hasFit = hasFit; diff --git a/packages/viewer-charts/src/ts/charts/continuous/continuous-chart.ts b/packages/viewer-charts/src/ts/charts/continuous/continuous-chart.ts deleted file mode 100644 index a7783c3cce..0000000000 --- a/packages/viewer-charts/src/ts/charts/continuous/continuous-chart.ts +++ /dev/null @@ -1,264 +0,0 @@ -// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ -// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ -// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ -// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ -// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ -// ┃ Copyright (c) 2017, the Perspective Authors. ┃ -// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ -// ┃ This file is part of the Perspective library, distributed under the terms ┃ -// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ -// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - -import type { ColumnDataMap } from "../../data/view-reader"; -import type { WebGLContextManager } from "../../webgl/context-manager"; -import { AbstractChart } from "../chart-base"; -import { SpatialHitTester } from "../../interaction/hit-test"; -import { PlotLayout } from "../../layout/plot-layout"; -import { type AxisDomain } from "../../chrome/numeric-axis"; -import type { GradientTextureCache } from "../../webgl/gradient-texture"; -import type { Glyph } from "./glyph"; -import { - initContinuousPipeline, - processContinuousChunk, -} from "./continuous-build"; -import { - renderContinuousFrame, - renderContinuousChromeOverlay, -} from "./continuous-render"; -import { - handleContinuousHover, - showContinuousPinnedTooltip, - dismissContinuousPinnedTooltip, -} from "./continuous-interact"; - -export interface SplitGroup { - prefix: string; - xColName: string; - yColName: string; - colorColName: string; - sizeColName: string; -} - -/** - * Unified continuous (numeric X/Y) chart. Glyphs plug in to render - * points, lines, or (future) areas over the shared data pipeline: - * streaming chunk upload, per-series slotted buffer layout, pan/zoom, - * spatial hit testing, chrome overlay, tooltip controller. - * - * Fields are package-internal (no `private`) so the split helper - * modules and glyphs can read/write them. - */ -export class ContinuousChart extends AbstractChart { - readonly glyph: Glyph; - - constructor(glyph: Glyph) { - super(); - this.glyph = glyph; - } - - // ── GL resources ────────────────────────────────────────────────────── - // Shared: gradient LUT texture (used by both glyphs for color mapping). - _gradientCache: GradientTextureCache | null = null; - // Glyph-owned cache (program, attribute locations, scratch buffers). - _glyphCache: any = null; - - // ── Column roles ────────────────────────────────────────────────────── - _xName = ""; - _yName = ""; - _xLabel = ""; - _yLabel = ""; - _xIsRowIndex = false; - _colorName = ""; - _sizeName = ""; - _colorIsString = false; - _splitGroups: SplitGroup[] = []; - - // ── Data extents ────────────────────────────────────────────────────── - _xMin = Infinity; - _xMax = -Infinity; - _yMin = Infinity; - _yMax = -Infinity; - _colorMin = Infinity; - _colorMax = -Infinity; - _sizeMin = Infinity; - _sizeMax = -Infinity; - - // ── Data buffers (per-series slotted) ───────────────────────────────── - // Series `s` owns indices `[s*_seriesCapacity, (s+1)*_seriesCapacity)` - // in the flat `_xData`/`_yData`/`_colorData` arrays and their GPU - // counterparts. `_seriesUploadedCounts[s]` tracks how many slots at - // the head of series `s` hold valid data; glyphs dispatch tight - // per-series draws using this count so the tail slots are never - // rasterized. - _seriesCapacity = 0; - _seriesUploadedCounts: number[] = []; - _maxSeriesUploaded = 0; - - _xData: Float32Array | null = null; - _yData: Float32Array | null = null; - _colorData: Float32Array | null = null; - /** - * Source view row index for each slot in `_xData` / `_yData`, - * sized and laid out identically. Split expansion duplicates the - * same arrow source row across every series; this sidecar stores - * that source index so lazy tooltip fetches can retrieve the - * original row. Int32 for compactness — at 1M points this is - * ~4 MB, a small fraction of the ~70 MB that the prior eager - * row-data buffers cost. - */ - _rowIndexData: Int32Array | null = null; - _dataCount = 0; - _uniqueColorLabels: Map = new Map(); - - /** - * Hovered / pinned tooltip lines, filled in asynchronously when a - * lazy row fetch resolves. `null` means "not yet available" — the - * chrome overlay skips the tooltip box entirely in that state (it - * still paints the crosshair + highlight ring from geometry data - * so the hover cue is immediate). Each hover change bumps - * `_hoveredTooltipSerial`; resolutions that observe a stale serial - * are discarded so rapid mouse motion doesn't paint out-of-date - * data. - */ - _hoveredTooltipLines: string[] | null = null; - _hoveredTooltipSerial = 0; - _pinnedTooltipSerial = 0; - - // ── Staging scratch (reused across chunks) ─────────────────────────── - _stagingPositions: Float32Array | null = null; - _stagingColors: Float32Array | null = null; - _stagingSizes: Float32Array | null = null; - _stagingChunkSize = 0; - - // ── Interaction ─────────────────────────────────────────────────────── - _hitTest = new SpatialHitTester(); - _lastLayout: PlotLayout | null = null; - _hoveredIndex = -1; - _pinnedIndex = -1; - /** - * Source facet for the current hover (`-1` when not over any facet). - * Drives coordinated hover indicator painting in other facets. - */ - _hoveredFacet = -1; - - // ── Facet state (set when rendering in grid mode) ──────────────────── - _facetGrid: import("../../layout/facet-grid").FacetGrid | null = null; - - // ── Last-frame cache (for chrome overlay-only redraws) ──────────────── - _lastXDomain: AxisDomain | null = null; - _lastYDomain: AxisDomain | null = null; - _lastXTicks: number[] | null = null; - _lastYTicks: number[] | null = null; - _lastGradientStops: import("../../theme/gradient").GradientStop[] | null = - null; - _lastHasColorCol = false; - - // ── Per-frame theme/palette cache (shared across render + overlay) ──── - // resolveTheme / readSeriesPalette each call getComputedStyle — ~100µs. - // Zoom dispatches redraw at 60Hz; we resolve once per frame and reuse. - // Null until first render populates; chrome-only redraws fall back to - // fresh resolution if these are null (should never happen in practice). - _lastTheme: import("../../theme/theme").Theme | null = null; - _lastSeriesPalette: [number, number, number][] | null = null; - // Memoized categorical LUT stops — `ensureGradientTexture` uses - // reference-equality on this array to skip rebuilding the 256-sample - // texture. Key is a cheap identity over inputs; hit means reuse prior - // reference so the GPU upload elides. - _lastLutStops: import("../../theme/gradient").GradientStop[] | null = null; - _lastLutKey = ""; - - attachTooltip(glCanvas: HTMLCanvasElement): void { - this._glCanvas = glCanvas; - this._tooltip.attach(glCanvas, { - onHover: (mx, my) => handleContinuousHover(this, mx, my), - onLeave: () => { - if (this._hoveredIndex !== -1) { - this._hoveredIndex = -1; - renderContinuousChromeOverlay(this); - } - }, - onPin: () => { - if (this._hoveredIndex >= 0) { - showContinuousPinnedTooltip(this, this._hoveredIndex); - } - }, - }); - } - - uploadAndRender( - glManager: WebGLContextManager, - columns: ColumnDataMap, - startRow: number, - endRow: number, - ): void { - const chunkLength = endRow - startRow; - this._glManager = glManager; - if (startRow === 0) { - this._cancelScheduledRender(); - initContinuousPipeline(this, glManager, columns, endRow); - } - - if (chunkLength === 0) return; - processContinuousChunk( - this, - glManager, - columns, - startRow, - chunkLength, - endRow, - ); - - this._scheduleRender(glManager); - } - - redraw(glManager: WebGLContextManager): void { - if (glManager.uploadedCount === 0 && this._dataCount === 0) return; - this._glManager = glManager; - this._fullRender(glManager); - } - - protected _fullRender(glManager: WebGLContextManager): void { - renderContinuousFrame(this, glManager); - } - - protected destroyInternal(): void { - this.glyph.destroy(this); - this._glyphCache = null; - this._gradientCache = null; - this._xData = null; - this._yData = null; - this._colorData = null; - this._rowIndexData = null; - this._hoveredTooltipLines = null; - this._uniqueColorLabels.clear(); - this._hitTest.clear(); - this._stagingPositions = null; - this._stagingColors = null; - this._stagingSizes = null; - this._splitGroups = []; - this._seriesUploadedCounts = []; - dismissContinuousPinnedTooltip(this); - } -} - -// ── Convenience subclasses with nullary constructors ───────────────────── -// `index.ts` registers plugin tags via `new ImplClass()`, so each chart -// type needs a parameterless constructor. These wrappers pin the glyph. - -import { PointGlyph } from "./glyphs/points"; -import { LineGlyph } from "./glyphs/lines"; - -/** X/Y Scatter — continuous chart with the point glyph. */ -export class ScatterChart extends ContinuousChart { - constructor() { - super(new PointGlyph()); - } -} - -/** X/Y Line — continuous chart with the line glyph. */ -export class LineChart extends ContinuousChart { - constructor() { - super(new LineGlyph()); - } -} diff --git a/packages/viewer-charts/src/ts/charts/heatmap/heatmap-build.ts b/packages/viewer-charts/src/ts/charts/heatmap/heatmap-build.ts index 9125df73da..726f6b7182 100644 --- a/packages/viewer-charts/src/ts/charts/heatmap/heatmap-build.ts +++ b/packages/viewer-charts/src/ts/charts/heatmap/heatmap-build.ts @@ -11,8 +11,15 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import type { ColumnDataMap } from "../../data/view-reader"; -import type { CategoricalLevel } from "../../chrome/categorical-axis"; -import { buildGroupRuns } from "../../chrome/categorical-axis-core"; +import type { CategoricalLevel } from "../../axis/categorical-axis"; +import { buildGroupRuns } from "../../axis/categorical-axis-core"; +import { + resolveAxisMode, + resolveCategoryAxis, + resolveNumericCategoryDomain, + type AxisMode, + type NumericCategoryDomain, +} from "../common/category-axis-resolver"; export interface HeatmapCell { xIdx: number; @@ -24,23 +31,55 @@ export interface HeatmapPipelineInput { columns: ColumnDataMap; numRows: number; groupBy: string[]; + splitBy: string[]; + + /** + * Source-column types keyed by column name (table.schema() merged + * with view.expression_schema()). Drives both the X-axis level-type + * lookup (for non-string row-paths) and the Y-axis numeric-mode + * decision when there's a single split_by. + */ + groupByTypes: Record; } export interface HeatmapPipelineResult { - /** Hierarchical row_path levels driving the X axis (outermost-first). */ + /** + * Hierarchical row_path levels driving the X axis (outermost-first). + */ xLevels: CategoricalLevel[]; - /** Arrow column names in iteration order; `yIdx === index in this list`. */ + + /** + * Arrow column names in iteration order; `yIdx === index in this list`. + */ yColumnNames: string[]; - /** Hierarchical Y levels derived by splitting each name on `|`. */ + + /** + * Hierarchical Y levels derived by splitting each name on `|`. + */ yLevels: CategoricalLevel[]; numX: number; numY: number; rowOffset: number; cells: HeatmapCell[]; - /** O(1) lookup by `yIdx * numX + xIdx`; `null` means no-data. */ + + /** + * O(1) lookup by `yIdx * numX + xIdx`; `null` means no-data. + */ cells2D: (HeatmapCell | null)[]; colorMin: number; colorMax: number; + + /** + * X-axis mode. `numeric` fires when the single group_by is + * date/datetime/integer/float; positions live in `xPositions` + * and the domain in `xNumericDomain`. + */ + xAxisMode: AxisMode; + yAxisMode: AxisMode; + xPositions: Float64Array | null; + yPositions: Float64Array | null; + xNumericDomain: NumericCategoryDomain | null; + yNumericDomain: NumericCategoryDomain | null; } /** @@ -51,11 +90,19 @@ export interface HeatmapPipelineResult { * * Externally enforced: only one entry sits in the `Color` slot, so every * non-metadata column is a splitwise expansion of that single aggregate. + * + * Numeric-axis mode (matching bar/candlestick): when there's exactly one + * non-string group_by, the X axis switches to a real numeric/date axis + * with `xPositions[xIdx]` carrying the data-space center. Y mirrors this + * for a single non-string split_by, parsed best-effort out of the column + * name leaf segment; on parse failure it falls back to category mode. */ export function buildHeatmapPipeline( input: HeatmapPipelineInput, ): HeatmapPipelineResult { - const { columns, numRows, groupBy } = input; + const { columns, numRows, groupBy, splitBy, groupByTypes } = input; + + const xAxisMode = resolveAxisMode(groupBy, groupByTypes); const empty: HeatmapPipelineResult = { xLevels: [], @@ -68,73 +115,61 @@ export function buildHeatmapPipeline( cells2D: [], colorMin: 0, colorMax: 1, + xAxisMode, + yAxisMode: { mode: "category" }, + xPositions: null, + yPositions: null, + xNumericDomain: null, + yNumericDomain: null, }; - // Resolve group_by row-paths + grand-total offset (same as bar pipeline). - type RawLevel = { indices: Int32Array; dictionary: string[] }; - const rawRowPaths: RawLevel[] = []; - for (let n = 0; ; n++) { - const rp = columns.get(`__ROW_PATH_${n}__`); - if (!rp || rp.type !== "string" || !rp.indices || !rp.dictionary) break; - rawRowPaths.push({ indices: rp.indices, dictionary: rp.dictionary }); - } + const levelTypes = groupBy.map((name) => groupByTypes[name] ?? "string"); + const { + rowPaths: xLevels, + numCategories: numX, + rowOffset, + } = resolveCategoryAxis(columns, numRows, groupBy.length, levelTypes); - let rowOffset = 0; - if (groupBy.length > 0 && rawRowPaths.length > 0) { - while (rowOffset < numRows) { - let anyNonEmpty = false; - for (const rp of rawRowPaths) { - const s = rp.dictionary[rp.indices[rowOffset]]; - if (s != null && s !== "") { - anyNonEmpty = true; - break; - } - } - if (anyNonEmpty) break; - rowOffset++; + // Numeric X domain: sourced from `__ROW_PATH_0__`'s raw values when + // the single group_by is non-string. + let xPositions: Float64Array | null = null; + let xNumericDomain: NumericCategoryDomain | null = null; + if (xAxisMode.mode === "numeric" && numX > 0) { + const rp = columns.get("__ROW_PATH_0__"); + const resolved = resolveNumericCategoryDomain( + rp?.values, + numX, + rowOffset, + groupBy[0] ?? "", + xAxisMode.numericType === "date" || + xAxisMode.numericType === "datetime", + ); + if (resolved) { + xPositions = resolved.categoryPositions; + xNumericDomain = resolved.numericCategoryDomain; } } - const numX = Math.max(0, numRows - rowOffset); - - const L = rawRowPaths.length; - const xLevels: CategoricalLevel[] = - groupBy.length > 0 && L > 0 - ? rawRowPaths.map((rp, levelIdx) => { - const labels = new Array(numX); - let maxLabelChars = 0; - for (let r = 0; r < numX; r++) { - const s = rp.dictionary[rp.indices[r + rowOffset]] ?? ""; - labels[r] = s; - if (s.length > maxLabelChars) maxLabelChars = s.length; - } - const runs = - levelIdx === L - 1 - ? [] - : buildGroupRuns( - rp.indices, - rp.dictionary, - rowOffset, - rowOffset + numX, - ).map((run) => ({ - startIdx: run.startIdx - rowOffset, - endIdx: run.endIdx - rowOffset, - label: run.label, - })); - return { labels, runs, maxLabelChars }; - }) - : []; // Enumerate Y columns in arrow iteration order, skipping metadata. const yColumnNames: string[] = []; for (const name of columns.keys()) { - if (name.startsWith("__")) continue; + if (name.startsWith("__")) { + continue; + } + const col = columns.get(name); - if (!col?.values) continue; + if (!col?.values) { + continue; + } + yColumnNames.push(name); } + const numY = yColumnNames.length; - if (numX === 0 || numY === 0) return empty; + if (numX === 0 || numY === 0) { + return { ...empty, xLevels, rowOffset }; + } // Build hierarchical Y levels by splitting each name on `|`, coalescing // consecutive equal tokens per level into a shared dictionary entry. @@ -142,6 +177,34 @@ export function buildHeatmapPipeline( // indices (length `numY`) + a string dictionary per level. const yLevels = buildYLevelsFromNames(yColumnNames); + // Y-numeric mode: only when split_by has exactly one non-string level + // AND every column name parses into a finite number. The leaf segment + // is the (split_value, aggregate) `splitVal` token — leading segment + // when there's a trailing `|aggregate`, or the whole name when there + // is none. + const yAxisModeRaw = resolveYAxisMode(splitBy, groupByTypes); + let yAxisMode: AxisMode = { mode: "category" }; + let yPositions: Float64Array | null = null; + let yNumericDomain: NumericCategoryDomain | null = null; + if (yAxisModeRaw.mode === "numeric") { + const parsed = parseYPositions(yColumnNames, yAxisModeRaw.numericType); + if (parsed) { + const resolved = resolveNumericCategoryDomain( + parsed, + numY, + 0, + splitBy[0] ?? "", + yAxisModeRaw.numericType === "date" || + yAxisModeRaw.numericType === "datetime", + ); + if (resolved) { + yAxisMode = yAxisModeRaw; + yPositions = resolved.categoryPositions; + yNumericDomain = resolved.numericCategoryDomain; + } + } + } + // Walk cells. Per-column loop (outer) lets us exploit arrow-contiguous // value arrays; validity checks are bit-mask reads. const cells: HeatmapCell[] = []; @@ -157,16 +220,26 @@ export function buildHeatmapPipeline( const row = xIdx + rowOffset; if (valid) { const bit = (valid[row >> 3] >> (row & 7)) & 1; - if (!bit) continue; + if (!bit) { + continue; + } } + const v = values[row] as number; - if (!isFinite(v)) continue; + if (!isFinite(v)) { + continue; + } const cell: HeatmapCell = { xIdx, yIdx, value: v }; cells.push(cell); cells2D[yIdx * numX + xIdx] = cell; - if (v < colorMin) colorMin = v; - if (v > colorMax) colorMax = v; + if (v < colorMin) { + colorMin = v; + } + + if (v > colorMax) { + colorMax = v; + } } } @@ -189,9 +262,78 @@ export function buildHeatmapPipeline( cells2D, colorMin, colorMax, + xAxisMode, + yAxisMode, + xPositions, + yPositions, + xNumericDomain, + yNumericDomain, }; } +/** + * Y-axis mode for heatmap. Only fires when `splitBy.length === 1` and the + * split column is non-string non-boolean. Multi-split chains stringify + * each segment so the numeric round-trip is ambiguous; keep them on the + * categorical path. + */ +function resolveYAxisMode( + splitBy: string[], + splitByTypes: Record, +): AxisMode { + if (splitBy.length !== 1) { + return { mode: "category" }; + } + + const t = splitByTypes[splitBy[0]]; + if (t === "date" || t === "datetime" || t === "integer" || t === "float") { + return { mode: "numeric", numericType: t }; + } + + return { mode: "category" }; +} + +/** + * Best-effort parse of the leading `|`-segment of every column name back + * into a numeric value. Returns `null` if any name fails to parse — + * caller falls back to category mode. + * + * Date/datetime split values are written by the engine as ISO-ish text; + * `Date.parse` accepts both `YYYY-MM-DD` and `YYYY-MM-DD HH:MM:SS.fff`. + * Integer/float go through `Number()`. + */ +function parseYPositions( + names: string[], + numericType: "date" | "datetime" | "integer" | "float", +): Float64Array | null { + const positions = new Float64Array(names.length); + for (let i = 0; i < names.length; i++) { + const name = names[i]; + const pipeIdx = name.indexOf("|"); + const seg = pipeIdx === -1 ? name : name.slice(0, pipeIdx); + let v: number; + if (numericType === "date" || numericType === "datetime") { + v = Date.parse(seg); + if (!isFinite(v)) { + // Engine sometimes emits `YYYY-MM-DD HH:MM:SS.fff` with a + // space separator that older browsers reject; retry with + // `T` substitution. + v = Date.parse(seg.replace(" ", "T")); + } + } else { + v = Number(seg); + } + + if (!isFinite(v)) { + return null; + } + + positions[i] = v; + } + + return positions; +} + /** * Partition a `ColumnDataMap` into one sub-map per user column. Every * arrow value column is assigned to the partition whose user column name @@ -213,10 +355,14 @@ export function partitionColumnsPerFacet( partition.set(name, col); continue; } + const pipeIdx = name.lastIndexOf("|"); const leaf = pipeIdx === -1 ? name : name.slice(pipeIdx + 1); - if (leaf === userCol) partition.set(name, col); + if (leaf === userCol) { + partition.set(name, col); + } } + return { label: userCol, columns: partition }; }); } @@ -228,15 +374,23 @@ export function partitionColumnsPerFacet( * render because the Y axis compares `indices[yIdx]` against neighbours. */ function buildYLevelsFromNames(names: string[]): CategoricalLevel[] { - if (names.length === 0) return []; + if (names.length === 0) { + return []; + } + // Find max depth across all names so every Y entry has a value at // every level. let maxDepth = 0; const segments: string[][] = names.map((n) => n.split("|")); for (const s of segments) { - if (s.length > maxDepth) maxDepth = s.length; + if (s.length > maxDepth) { + maxDepth = s.length; + } + } + + if (maxDepth === 0) { + return []; } - if (maxDepth === 0) return []; const levels: CategoricalLevel[] = []; for (let d = 0; d < maxDepth; d++) { @@ -253,15 +407,20 @@ function buildYLevelsFromNames(names: string[]): CategoricalLevel[] { dictionary.push(seg); dictIndex.set(seg, idx); } + indices[i] = idx; labels[i] = seg; - if (seg.length > maxLabelChars) maxLabelChars = seg.length; + if (seg.length > maxLabelChars) { + maxLabelChars = seg.length; + } } + const isLeaf = d === maxDepth - 1; const runs = isLeaf ? [] : buildGroupRuns(indices, dictionary, 0, names.length); levels.push({ labels, runs, maxLabelChars }); } + return levels; } diff --git a/packages/viewer-charts/src/ts/charts/heatmap/heatmap-interact.ts b/packages/viewer-charts/src/ts/charts/heatmap/heatmap-interact.ts index 5e89e6519c..116c531a7d 100644 --- a/packages/viewer-charts/src/ts/charts/heatmap/heatmap-interact.ts +++ b/packages/viewer-charts/src/ts/charts/heatmap/heatmap-interact.ts @@ -12,10 +12,8 @@ import type { HeatmapChart } from "./heatmap"; import type { HeatmapCell } from "./heatmap-build"; -import { resolveTheme } from "../../theme/theme"; -import { formatTickValue } from "../../layout/ticks"; import { renderCanvasTooltip } from "../../interaction/tooltip-controller"; -import type { CategoricalLevel } from "../../chrome/categorical-axis"; +import type { CategoricalLevel } from "../../axis/categorical-axis"; /** * Find the heatmap cell under `(mx, my)`. O(1) via the prebuilt `cells2D` @@ -30,6 +28,8 @@ export function handleHeatmapHover( mx: number, my: number, ): void { + chart._hoveredMouseX = mx; + chart._hoveredMouseY = my; if (chart._facets.length > 0) { for (let i = 0; i < chart._facets.length; i++) { const facet = chart._facets[i]; @@ -42,38 +42,95 @@ export function handleHeatmapHover( ) { continue; } + const cell = hitCell( facet.layout, facet.pipeline.numX, facet.pipeline.numY, facet.pipeline.cells2D, + facet.pipeline.xPositions, + facet.pipeline.yPositions, + facet.pipeline.xNumericDomain?.bandWidth ?? 1, + facet.pipeline.yNumericDomain?.bandWidth ?? 1, mx, my, ); setHovered(chart, cell, i); return; } + setHovered(chart, null, -1); return; } - if (!chart._lastLayout) return; + if (!chart._lastLayout) { + return; + } + const cell = hitCell( chart._lastLayout, chart._numX, chart._numY, chart._cells2D, + chart._xPositions, + chart._yPositions, + chart._xNumericDomain?.bandWidth ?? 1, + chart._yNumericDomain?.bandWidth ?? 1, mx, my, ); setHovered(chart, cell, -1); } +/** + * Binary-search a sorted positions array for the entry closest to + * `value`. Returns -1 when the closest entry is more than half a band + * away (the cursor is in the gap between two cells). + */ +function nearestCategoryIdx( + positions: Float64Array, + value: number, + bandWidth: number, +): number { + if (positions.length === 0) { + return -1; + } + + let lo = 0; + let hi = positions.length - 1; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (positions[mid] < value) { + lo = mid + 1; + } else { + hi = mid; + } + } + + let idx = lo; + if ( + idx > 0 && + Math.abs(positions[idx - 1] - value) < Math.abs(positions[idx] - value) + ) { + idx -= 1; + } + + if (Math.abs(positions[idx] - value) > bandWidth * 0.5) { + return -1; + } + + return idx; +} + function hitCell( layout: import("../../layout/plot-layout").PlotLayout, numX: number, numY: number, cells2D: (HeatmapCell | null)[], + xPositions: Float64Array | null, + yPositions: Float64Array | null, + xBandWidth: number, + yBandWidth: number, mx: number, my: number, ): HeatmapCell | null { @@ -86,15 +143,23 @@ function hitCell( ) { return null; } + const xMin = layout.paddedXMin; const xMax = layout.paddedXMax; const yMin = layout.paddedYMin; const yMax = layout.paddedYMax; const dataX = xMin + ((mx - plot.x) / plot.width) * (xMax - xMin); const dataY = yMax - ((my - plot.y) / plot.height) * (yMax - yMin); - const xIdx = Math.round(dataX); - const yIdx = Math.round(dataY); - if (xIdx < 0 || xIdx >= numX || yIdx < 0 || yIdx >= numY) return null; + const xIdx = xPositions + ? nearestCategoryIdx(xPositions, dataX, xBandWidth) + : Math.round(dataX); + const yIdx = yPositions + ? nearestCategoryIdx(yPositions, dataY, yBandWidth) + : Math.round(dataY); + if (xIdx < 0 || xIdx >= numX || yIdx < 0 || yIdx >= numY) { + return null; + } + return cells2D[yIdx * numX + xIdx] ?? null; } @@ -108,7 +173,10 @@ function setHovered( (prev?.xIdx ?? -1) === (next?.xIdx ?? -1) && (prev?.yIdx ?? -1) === (next?.yIdx ?? -1) && chart._hoveredFacetIdx === facetIdx; - if (same) return; + if (same) { + return; + } + chart._hoveredCell = next; chart._hoveredFacetIdx = facetIdx; if (chart._glManager && chart._renderChromeOverlay) { @@ -118,7 +186,9 @@ function setHovered( } } -/** Format a hierarchical path from a precomputed-label `CategoricalLevel` array. */ +/** + * Format a hierarchical path from a precomputed-label `CategoricalLevel` array. + */ export function formatHierarchicalPath( levels: CategoricalLevel[], idx: number, @@ -126,14 +196,21 @@ export function formatHierarchicalPath( const parts: string[] = []; for (const lev of levels) { const s = lev.labels[idx]; - if (s != null && s !== "") parts.push(s); + if (s != null && s !== "") { + parts.push(s); + } } + return parts.join(" / "); } -/** Render a tooltip for the currently hovered cell. */ +/** + * Render a tooltip for the currently hovered cell. + */ export function renderHeatmapTooltip(chart: HeatmapChart): void { - if (!chart._chromeCanvas || !chart._hoveredCell) return; + if (!chart._chromeCanvas || !chart._hoveredCell) { + return; + } let layout: import("../../layout/plot-layout").PlotLayout | null; let xLevels: CategoricalLevel[]; @@ -142,29 +219,56 @@ export function renderHeatmapTooltip(chart: HeatmapChart): void { if (chart._hoveredFacetIdx >= 0) { const facet = chart._facets[chart._hoveredFacetIdx]; - if (!facet) return; + if (!facet) { + return; + } + layout = facet.layout; xLevels = facet.pipeline.xLevels; yLevels = facet.pipeline.yLevels; facetLabel = facet.label; } else { - if (!chart._lastLayout) return; + if (!chart._lastLayout) { + return; + } + layout = chart._lastLayout; xLevels = chart._xLevels; yLevels = chart._yLevels; } const cell = chart._hoveredCell; - const pos = layout.dataToPixel(cell.xIdx, cell.yIdx); + + // Anchor the tooltip at the cursor rather than the cell center so + // the label tracks the mouse on coarse heatmaps where cells span + // many pixels. + const pos = { px: chart._hoveredMouseX, py: chart._hoveredMouseY }; const lines: string[] = []; - if (facetLabel) lines.push(facetLabel); + if (facetLabel) { + lines.push(facetLabel); + } + const xPath = formatHierarchicalPath(xLevels, cell.xIdx); const yPath = formatHierarchicalPath(yLevels, cell.yIdx); - if (xPath) lines.push(xPath); - if (yPath) lines.push(yPath); - lines.push(`Value: ${formatTickValue(cell.value)}`); + if (xPath) { + lines.push(xPath); + } + + if (yPath) { + lines.push(yPath); + } + + const valueFmt = chart.getColumnFormatter(chart._columnSlots[0], "value"); + lines.push(`Value: ${valueFmt(cell.value)}`); - const theme = resolveTheme(chart._chromeCanvas); - renderCanvasTooltip(chart._chromeCanvas, pos, lines, layout, theme); + const theme = chart._resolveTheme(); + renderCanvasTooltip( + chart._chromeCanvas, + pos, + lines, + layout, + theme, + chart._glManager?.dpr ?? 1, + ); } diff --git a/packages/viewer-charts/src/ts/charts/heatmap/heatmap-render.ts b/packages/viewer-charts/src/ts/charts/heatmap/heatmap-render.ts index 62bce4bfa0..464090c4f0 100644 --- a/packages/viewer-charts/src/ts/charts/heatmap/heatmap-render.ts +++ b/packages/viewer-charts/src/ts/charts/heatmap/heatmap-render.ts @@ -11,27 +11,32 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import type { WebGLContextManager } from "../../webgl/context-manager"; -import type { HeatmapChart, HeatmapFacet } from "./heatmap"; +import type { HeatmapChart } from "./heatmap"; import { PlotLayout } from "../../layout/plot-layout"; -import { resolveTheme, type Theme } from "../../theme/theme"; +import { drawFacetTitle } from "../../axis/facet-chrome"; import { renderInPlotFrame, clearAndSetupFrame, withScissor, } from "../../webgl/plot-frame"; import { getInstancing } from "../../webgl/instanced-attrs"; -import { initCanvas } from "../../chrome/canvas"; +import { initCanvas } from "../../axis/canvas"; import { buildFacetGrid } from "../../layout/facet-grid"; import { measureCategoricalAxisHeight, renderCategoricalXTicks, type CategoricalDomain, -} from "../../chrome/categorical-axis"; +} from "../../axis/categorical-axis"; import { measureCategoricalAxisWidth, renderCategoricalYTicks, type CategoricalYAxisOptions, } from "./heatmap-y-axis"; +import { + drawNumericCategoryX, + drawNumericCategoryY, +} from "../../axis/bar-axis"; +import { computeNiceTicks } from "../../layout/ticks"; // The heatmap's Y-axis column names end with the (single, externally // enforced) aggregate name. That leaf column is a redundant constant and @@ -40,7 +45,8 @@ import { const HEATMAP_Y_AXIS_OPTS: CategoricalYAxisOptions = { skipLeafLevel: true, }; -import { renderLegend, renderLegendAt } from "../../chrome/legend"; + +import { renderLegend, renderLegendAt } from "../../axis/legend"; import heatmapVert from "../../shaders/heatmap.vert.glsl"; import heatmapFrag from "../../shaders/heatmap.frag.glsl"; import { colorValueToT } from "../../theme/gradient"; @@ -53,32 +59,38 @@ import { renderHeatmapTooltip } from "./heatmap-interact"; export interface HeatmapLocations { u_projection: WebGLUniformLocation | null; u_cell_inset: WebGLUniformLocation | null; + u_cell_size: WebGLUniformLocation | null; u_gradient_lut: WebGLUniformLocation | null; a_corner: number; a_cell: number; a_color_t: number; } -/** Full-frame render: WebGL cells → chrome overlay. */ +/** + * Full-frame render: WebGL cells → chrome overlay. + */ export function renderHeatmapFrame( chart: HeatmapChart, glManager: WebGLContextManager, ): void { const gl = glManager.gl; - const dpr = window.devicePixelRatio || 1; + const dpr = glManager.dpr; const cssWidth = gl.canvas.width / dpr; const cssHeight = gl.canvas.height / dpr; - if (cssWidth <= 0 || cssHeight <= 0) return; + if (cssWidth <= 0 || cssHeight <= 0) { + return; + } if (chart._facets.length > 0) { renderFacetedHeatmap(chart, glManager, cssWidth, cssHeight); return; } - if (chart._numX === 0 || chart._numY === 0) return; + if (chart._numX === 0 || chart._numY === 0) { + return; + } - const themeEl = (chart._gridlineCanvas!.getRootNode() as ShadowRoot).host; - const theme = resolveTheme(themeEl); + const theme = chart._resolveTheme(); const xDomain: CategoricalDomain = { levels: chart._xLevels, @@ -88,17 +100,24 @@ export function renderHeatmapFrame( const yDomain: CategoricalDomain = { levels: chart._yLevels, numRows: chart._numY, - // The Y axis shows columns directly; no meaningful label set. levelLabels: [], }; + const xNumeric = chart._xAxisMode.mode === "numeric"; + const yNumeric = chart._yAxisMode.mode === "numeric"; + // Measure both hierarchical axes *before* building the layout so the - // plot rect accounts for their footprints. - const estLeft = measureCategoricalAxisWidth(yDomain, HEATMAP_Y_AXIS_OPTS); - // For the bottom extra we need an estimated plot width. Use the CSS - // width minus rough left/right gutters as a first approximation. - const estPlotWidth = Math.max(1, cssWidth - estLeft - 110); - const bottomExtra = measureCategoricalAxisHeight(xDomain, estPlotWidth); + // plot rect accounts for their footprints. Numeric axes get fixed + // gutters matching bar's branch (24px bottom, 55px left). + const estLeft = yNumeric + ? 55 + : measureCategoricalAxisWidth(yDomain, HEATMAP_Y_AXIS_OPTS); + const bottomExtra = xNumeric + ? 24 + : measureCategoricalAxisHeight( + xDomain, + Math.max(1, cssWidth - estLeft - 110), + ); const layout = new PlotLayout(cssWidth, cssHeight, { hasXLabel: chart._groupBy.length > 0, @@ -108,15 +127,22 @@ export function renderHeatmapFrame( leftExtra: estLeft, }); chart._lastLayout = layout; - if (chart._zoomController) chart._zoomController.updateLayout(layout); - - // Apply zoom + domain padding via the standard projection matrix. The - // domain is the cell grid `[-0.5, numX-0.5] × [-0.5, numY-0.5]` so - // cells sit at integer coordinates. - const xDomainMin = -0.5; - const xDomainMax = chart._numX - 0.5; - const yDomainMin = -0.5; - const yDomainMax = chart._numY - 0.5; + if (chart._zoomController) { + chart._zoomController.updateLayout(layout); + } + + // Domain depends on axis mode. Category mode: cell grid + // `[-0.5, N-0.5]` so cells sit at integer coordinates. Numeric mode: + // pre-padded `numericDomain` already includes a half-band on each + // edge so cells stay flush with the axis. + const xDomainMin = xNumeric ? chart._xNumericDomain!.min : -0.5; + const xDomainMax = xNumeric + ? chart._xNumericDomain!.max + : chart._numX - 0.5; + const yDomainMin = yNumeric ? chart._yNumericDomain!.min : -0.5; + const yDomainMax = yNumeric + ? chart._yNumericDomain!.max + : chart._numY - 0.5; if (chart._zoomController) { chart._zoomController.setBaseDomain( xDomainMin, @@ -125,6 +151,7 @@ export function renderHeatmapFrame( yDomainMax, ); } + const vis = chart._zoomController ? chart._zoomController.getVisibleDomain() : { @@ -134,29 +161,43 @@ export function renderHeatmapFrame( yMax: yDomainMax, }; + // Heatmap cell rects span the exact domain edge-to-edge, so any + // cosmetic padding leaves a visible sliver between the outermost + // cells and the axis chrome. Force `padRatio: 0` for flush edges. const projection = layout.buildProjectionMatrix( vis.xMin, vis.xMax, vis.yMin, vis.yMax, + undefined, + undefined, + 0, + chart._xOrigin, + chart._yOrigin, ); // Cell gap is specified in CSS pixels but the shader needs data-space - // insets. Convert using the plot's data-per-pixel scale. + // insets. Convert using the plot's data-per-pixel scale; clamp to + // half a band so the gap can't eat the entire cell. const plot = layout.plotRect; const pxPerDataX = plot.width / (vis.xMax - vis.xMin); const pxPerDataY = plot.height / (vis.yMax - vis.yMin); const halfGap = theme.heatmapGapPx * 0.5; - const insetX = Math.min(0.5, pxPerDataX > 0 ? halfGap / pxPerDataX : 0); - const insetY = Math.min(0.5, pxPerDataY > 0 ? halfGap / pxPerDataY : 0); + const cellSizeX = xNumeric ? chart._xNumericDomain!.bandWidth : 1; + const cellSizeY = yNumeric ? chart._yNumericDomain!.bandWidth : 1; + const insetX = Math.min( + cellSizeX * 0.5, + pxPerDataX > 0 ? halfGap / pxPerDataX : 0, + ); + const insetY = Math.min( + cellSizeY * 0.5, + pxPerDataY > 0 ? halfGap / pxPerDataY : 0, + ); // Gridline canvas isn't used by heatmap — clear it so stale content // from a previous plugin doesn't bleed through. if (chart._gridlineCanvas) { - const gctx = initCanvas(chart._gridlineCanvas, layout); - if (gctx) { - // already cleared by initCanvas - } + const _gctx = initCanvas(chart._gridlineCanvas, layout, glManager.dpr); } ensureProgram(chart, glManager); @@ -168,11 +209,12 @@ export function renderHeatmapFrame( theme.gradientStops, ); - renderInPlotFrame(gl, layout, () => { + renderInPlotFrame(gl, layout, glManager.dpr, () => { gl.useProgram(chart._program!); const loc = chart._locations!; gl.uniformMatrix4fv(loc.u_projection, false, projection); gl.uniform2f(loc.u_cell_inset, insetX, insetY); + gl.uniform2f(loc.u_cell_size, cellSizeX, cellSizeY); bindGradientTexture( glManager, chart._gradientCache!.texture, @@ -189,7 +231,10 @@ function ensureProgram( chart: HeatmapChart, glManager: WebGLContextManager, ): void { - if (chart._program) return; + if (chart._program) { + return; + } + const gl = glManager.gl; const program = glManager.shaders.getOrCreate( "heatmap", @@ -200,6 +245,7 @@ function ensureProgram( chart._locations = { u_projection: gl.getUniformLocation(program, "u_projection"), u_cell_inset: gl.getUniformLocation(program, "u_cell_inset"), + u_cell_size: gl.getUniformLocation(program, "u_cell_size"), u_gradient_lut: gl.getUniformLocation(program, "u_gradient_lut"), a_corner: gl.getAttribLocation(program, "a_corner"), a_cell: gl.getAttribLocation(program, "a_cell"), @@ -208,6 +254,7 @@ function ensureProgram( const cornerBuffer = gl.createBuffer()!; gl.bindBuffer(gl.ARRAY_BUFFER, cornerBuffer); + // Triangle strip: (0,0) (1,0) (0,1) (1,1) gl.bufferData( gl.ARRAY_BUFFER, @@ -223,16 +270,53 @@ function uploadInstanceBuffers( ): void { const n = chart._cells.length; chart._uploadedCells = n; - if (n === 0) return; + if (n === 0) { + return; + } const cellXY = new Float32Array(n * 2); const colorT = new Float32Array(n); + // Sign-aware `t`: 0 always lands at 0.5. See `theme/gradient.ts`. - for (let i = 0; i < n; i++) { - const c = chart._cells[i]; - cellXY[i * 2] = c.xIdx; - cellXY[i * 2 + 1] = c.yIdx; - colorT[i] = colorValueToT(c.value, chart._colorMin, chart._colorMax); + // Numeric-axis mode pre-multiplies the integer index into the real + // data position so the shader can apply `u_cell_size` (band width) + // uniformly without per-instance attrs. Datetime axes need an + // origin rebase before f32 narrowing — see {@link HeatmapFacet} + // and `HeatmapChart._xOrigin/_yOrigin`. Origins are 0 for category + // mode, where the integer index is already small. + if (chart._facets.length > 0) { + let i = 0; + for (const facet of chart._facets) { + const xPos = facet.pipeline.xPositions; + const yPos = facet.pipeline.yPositions; + const xO = facet.xOrigin; + const yO = facet.yOrigin; + for (const c of facet.pipeline.cells) { + cellXY[i * 2] = xPos ? xPos[c.xIdx] - xO : c.xIdx; + cellXY[i * 2 + 1] = yPos ? yPos[c.yIdx] - yO : c.yIdx; + colorT[i] = colorValueToT( + c.value, + chart._colorMin, + chart._colorMax, + ); + i++; + } + } + } else { + const xPos = chart._xPositions; + const yPos = chart._yPositions; + const xO = chart._xOrigin; + const yO = chart._yOrigin; + for (let i = 0; i < n; i++) { + const c = chart._cells[i]; + cellXY[i * 2] = xPos ? xPos[c.xIdx] - xO : c.xIdx; + cellXY[i * 2 + 1] = yPos ? yPos[c.yIdx] - yO : c.yIdx; + colorT[i] = colorValueToT( + c.value, + chart._colorMin, + chart._colorMax, + ); + } } glManager.bufferPool.ensureCapacity(n); @@ -247,7 +331,10 @@ function drawCellsInstanced( instanceStart: number, instanceCount: number, ): void { - if (instanceCount === 0) return; + if (instanceCount === 0) { + return; + } + const loc = chart._locations!; const instancing = getInstancing(glManager); const { setDivisor } = instancing; @@ -261,7 +348,16 @@ function drawCellsInstanced( // Per-instance cell position. Byte offset into the packed buffer // advances instance 0 of this draw to slot `instanceStart`. - const cellBuf = glManager.bufferPool.getOrCreate("heatmap_cell", 2, f); + // + // Render-path uses `peek` (not `getOrCreate`); if the buffers + // haven't been uploaded yet, skip the draw rather than render + // against a recreated zero buffer. + const cellBuf = glManager.bufferPool.peek("heatmap_cell"); + const tBuf = glManager.bufferPool.peek("heatmap_t"); + if (!cellBuf || !tBuf) { + return; + } + gl.bindBuffer(gl.ARRAY_BUFFER, cellBuf.buffer); gl.enableVertexAttribArray(loc.a_cell); gl.vertexAttribPointer( @@ -274,7 +370,6 @@ function drawCellsInstanced( ); setDivisor(loc.a_cell, 1); - const tBuf = glManager.bufferPool.getOrCreate("heatmap_t", 1, f); gl.bindBuffer(gl.ARRAY_BUFFER, tBuf.buffer); gl.enableVertexAttribArray(loc.a_color_t); gl.vertexAttribPointer( @@ -293,19 +388,31 @@ function drawCellsInstanced( setDivisor(loc.a_color_t, 0); } -/** Chrome overlay: X axis + Y axis + color legend + (optional) tooltip. */ +/** + * Chrome overlay: X axis + Y axis + color legend + (optional) tooltip. + */ export function renderHeatmapChromeOverlay(chart: HeatmapChart): void { - if (!chart._chromeCanvas) return; + if (!chart._chromeCanvas) { + return; + } + if (chart._facets.length > 0) { renderFacetedHeatmapChromeOverlay(chart); return; } - if (!chart._lastLayout) return; + + if (!chart._lastLayout) { + return; + } + const layout = chart._lastLayout; - const theme = resolveTheme(chart._chromeCanvas); + const theme = chart._resolveTheme(); + const dpr = chart._glManager?.dpr ?? 1; - const ctx = initCanvas(chart._chromeCanvas, layout); - if (!ctx) return; + const ctx = initCanvas(chart._chromeCanvas, layout, dpr); + if (!ctx) { + return; + } // L-shaped axis line, same as bar chart chrome. ctx.strokeStyle = theme.gridlineColor; @@ -324,16 +431,54 @@ export function renderHeatmapChromeOverlay(chart: HeatmapChart): void { numRows: chart._numX, levelLabels: chart._groupBy.slice(), }; + const yDomain: CategoricalDomain = { levels: chart._yLevels, numRows: chart._numY, levelLabels: [], }; - renderCategoricalXTicks(ctx, layout, xDomain, theme); - renderCategoricalYTicks(ctx, layout, yDomain, theme, HEATMAP_Y_AXIS_OPTS); + // Heatmap X axis is the first group_by level; Y axis is the + // second (when present) or the first split_by level. + const xColumn = chart._groupBy[0]; + const yColumn = chart._groupBy[1] ?? chart._splitBy[0]; + + if (chart._xAxisMode.mode === "numeric" && chart._xNumericDomain) { + const ticks = computeNiceTicks(layout.paddedXMin, layout.paddedXMax, 6); + drawNumericCategoryX( + ctx, + layout, + chart._xNumericDomain, + ticks, + theme, + chart.getColumnFormatter(xColumn, "tick"), + ); + } else { + renderCategoricalXTicks(ctx, layout, xDomain, theme); + } + + if (chart._yAxisMode.mode === "numeric" && chart._yNumericDomain) { + const ticks = computeNiceTicks(layout.paddedYMin, layout.paddedYMax, 6); + drawNumericCategoryY( + ctx, + layout, + chart._yNumericDomain, + ticks, + theme, + chart.getColumnFormatter(yColumn, "tick"), + ); + } else { + renderCategoricalYTicks( + ctx, + layout, + yDomain, + theme, + HEATMAP_Y_AXIS_OPTS, + ); + } - // Color legend on the right. + // Color legend on the right. The aggregate column name is in + // `_columnSlots[0]` (heatmap's only data column slot is "Color"). renderLegend( chart._chromeCanvas, layout, @@ -343,6 +488,8 @@ export function renderHeatmapChromeOverlay(chart: HeatmapChart): void { label: chart._aggName, }, theme.gradientStops, + theme, + chart.getColumnFormatter(chart._columnSlots[0], "value"), ); if (chart._hoveredCell) { @@ -360,16 +507,22 @@ function renderFacetedHeatmap( cssHeight: number, ): void { const gl = glManager.gl; - const themeEl = (chart._gridlineCanvas!.getRootNode() as ShadowRoot).host; - const theme = resolveTheme(themeEl); + const theme = chart._resolveTheme(); + + // Derive the effective shared-axis flags for this frame. Stamps + // `_lastEffectiveSharedX/Y` on the chart so + // `renderFacetedHeatmapChromeOverlay` reads the same values without + // re-deriving (and without us having to mutate `_facetConfig`). + const { effectiveSharedX, effectiveSharedY } = + chart.computeEffectiveFacetFlags(); const grid = buildFacetGrid( chart._facets.map((f) => f.label), { cssWidth, cssHeight, - xAxis: "cell", - yAxis: "cell", + xAxis: effectiveSharedX ? "outer" : "cell", + yAxis: effectiveSharedY ? "outer" : "cell", hasLegend: true, hasXLabel: chart._groupBy.length > 0, hasYLabel: false, @@ -380,9 +533,15 @@ function renderFacetedHeatmap( for (let i = 0; i < chart._facets.length; i++) { const cell = grid.cells[i]; - if (cell) chart._facets[i].layout = cell.layout; + if (cell) { + chart._facets[i].layout = cell.layout; + } } + // Wire every active zoom controller's layout pointer so wheel/pan + // hit-tests compute correct data deltas. + chart.syncFacetZoomLayouts(grid.cells); + ensureProgram(chart, glManager); uploadInstanceBuffers(chart, glManager); chart._gradientCache = ensureGradientTexture( @@ -407,32 +566,82 @@ function renderFacetedHeatmap( for (let i = 0; i < chart._facets.length; i++) { const facet = chart._facets[i]; - if (facet.instanceCount === 0) continue; + if (facet.instanceCount === 0) { + continue; + } + const { numX, numY } = facet.pipeline; - if (numX === 0 || numY === 0) continue; + if (numX === 0 || numY === 0) { + continue; + } const layout = facet.layout; - const xDomainMin = -0.5; - const xDomainMax = numX - 0.5; - const yDomainMin = -0.5; - const yDomainMax = numY - 0.5; + const xNumeric = facet.pipeline.xAxisMode.mode === "numeric"; + const yNumeric = facet.pipeline.yAxisMode.mode === "numeric"; + const xDomainMin = xNumeric ? facet.pipeline.xNumericDomain!.min : -0.5; + const xDomainMax = xNumeric + ? facet.pipeline.xNumericDomain!.max + : numX - 0.5; + const yDomainMin = yNumeric ? facet.pipeline.yNumericDomain!.min : -0.5; + const yDomainMax = yNumeric + ? facet.pipeline.yNumericDomain!.max + : numY - 0.5; + + // Anchor the controller's base domain to this facet's data + // extent so wheel/pan transforms compose against a meaningful + // identity. In shared mode every facet writes the same base + // (heatmap facets share group_by → identical X domain, and + // matching Y shapes from `partitionColumnsPerFacet` → identical + // Y domain), so last-write-wins is a no-op. In independent + // mode each facet's own controller gets its own base. + const zc = chart.getZoomControllerForFacet(i); + if (zc) { + zc.setBaseDomain(xDomainMin, xDomainMax, yDomainMin, yDomainMax); + } + + const vis = zc + ? zc.getVisibleDomain() + : { + xMin: xDomainMin, + xMax: xDomainMax, + yMin: yDomainMin, + yMax: yDomainMax, + }; const projection = layout.buildProjectionMatrix( - xDomainMin, - xDomainMax, - yDomainMin, - yDomainMax, + vis.xMin, + vis.xMax, + vis.yMin, + vis.yMax, + undefined, + undefined, + 0, + facet.xOrigin, + facet.yOrigin, ); const plot = layout.plotRect; - const pxPerDataX = plot.width / (xDomainMax - xDomainMin); - const pxPerDataY = plot.height / (yDomainMax - yDomainMin); + const pxPerDataX = plot.width / (vis.xMax - vis.xMin); + const pxPerDataY = plot.height / (vis.yMax - vis.yMin); const halfGap = theme.heatmapGapPx * 0.5; - const insetX = Math.min(0.5, pxPerDataX > 0 ? halfGap / pxPerDataX : 0); - const insetY = Math.min(0.5, pxPerDataY > 0 ? halfGap / pxPerDataY : 0); + const cellSizeX = xNumeric + ? facet.pipeline.xNumericDomain!.bandWidth + : 1; + const cellSizeY = yNumeric + ? facet.pipeline.yNumericDomain!.bandWidth + : 1; + const insetX = Math.min( + cellSizeX * 0.5, + pxPerDataX > 0 ? halfGap / pxPerDataX : 0, + ); + const insetY = Math.min( + cellSizeY * 0.5, + pxPerDataY > 0 ? halfGap / pxPerDataY : 0, + ); - withScissor(gl, layout, () => { + withScissor(gl, layout, glManager.dpr, () => { gl.uniformMatrix4fv(loc.u_projection, false, projection); gl.uniform2f(loc.u_cell_inset, insetX, insetY); + gl.uniform2f(loc.u_cell_size, cellSizeX, cellSizeY); drawCellsInstanced( chart, gl, @@ -446,17 +655,40 @@ function renderFacetedHeatmap( renderHeatmapChromeOverlay(chart); } -/** Multi-facet chrome: per-facet X/Y axis + title, one shared legend. */ +/** + * Multi-facet chrome: per-facet X/Y axis + title, one shared legend. + */ function renderFacetedHeatmapChromeOverlay(chart: HeatmapChart): void { - if (!chart._chromeCanvas || !chart._facetGrid) return; - const theme = resolveTheme(chart._chromeCanvas); + if (!chart._chromeCanvas || !chart._facetGrid) { + return; + } + + const theme = chart._resolveTheme(); + // `initCanvas` wants a `PlotLayout` to sync DPR-aware sizing. The // first facet's layout is canvas-sized (cssWidth/cssHeight match // the element), so either facet works for the DPR handshake. - const ctx = initCanvas(chart._chromeCanvas, chart._facets[0].layout); - if (!ctx) return; + const dpr = chart._glManager?.dpr ?? 1; + const ctx = initCanvas(chart._chromeCanvas, chart._facets[0].layout, dpr); + if (!ctx) { + return; + } - for (const facet of chart._facets) { + // Shared-axis suppression: when shared-X is active the X tick + // labels paint just below `cell.layout.plotRect` — which, because + // `buildFacetGrid` was called with `xAxis: "outer"`, falls into + // the reserved `outerXAxisRect` band rather than per-cell padding. + // Painting from a bottom-edge cell's layout is enough; non-edge + // rows would paint the same labels at the wrong y coordinate, so + // we skip them. Symmetric for Y. The cleanest way to express + // "shared = only edge cells render axes" is to gate the per-cell + // call on `!sharedX || isBottomEdge` (and analogously for Y). + const sharedX = chart._lastEffectiveSharedX; + const sharedY = chart._lastEffectiveSharedY; + const grid = chart._facetGrid; + for (let i = 0; i < chart._facets.length; i++) { + const facet = chart._facets[i]; + const cell = grid.cells[i]; const layout = facet.layout; const plot = layout.plotRect; @@ -479,25 +711,79 @@ function renderFacetedHeatmapChromeOverlay(chart: HeatmapChart): void { levelLabels: [], }; - renderCategoricalXTicks(ctx, layout, xDomain, theme); - renderCategoricalYTicks( - ctx, - layout, - yDomain, - theme, - HEATMAP_Y_AXIS_OPTS, - ); + const xColumn = chart._groupBy[0]; + const yColumn = chart._groupBy[1] ?? chart._splitBy[0]; + + if (!sharedX || cell.isBottomEdge) { + if ( + facet.pipeline.xAxisMode.mode === "numeric" && + facet.pipeline.xNumericDomain + ) { + const ticks = computeNiceTicks( + layout.paddedXMin, + layout.paddedXMax, + 6, + ); + drawNumericCategoryX( + ctx, + layout, + facet.pipeline.xNumericDomain, + ticks, + theme, + chart.getColumnFormatter(xColumn, "tick"), + ); + } else { + renderCategoricalXTicks(ctx, layout, xDomain, theme); + } + } + + if (!sharedY || cell.isLeftEdge) { + if ( + facet.pipeline.yAxisMode.mode === "numeric" && + facet.pipeline.yNumericDomain + ) { + const ticks = computeNiceTicks( + layout.paddedYMin, + layout.paddedYMax, + 6, + ); + drawNumericCategoryY( + ctx, + layout, + facet.pipeline.yNumericDomain, + ticks, + theme, + chart.getColumnFormatter(yColumn, "tick"), + ); + } else { + renderCategoricalYTicks( + ctx, + layout, + yDomain, + theme, + HEATMAP_Y_AXIS_OPTS, + ); + } + } } // Per-facet titles sit in the grid cell's titleRect — one strip per // facet, above the plot rect. The grid's cells and the chart's // facets are parallel arrays by construction. - const grid = chart._facetGrid; for (let i = 0; i < grid.cells.length; i++) { const cell = grid.cells[i]; const facet = chart._facets[i]; - if (!facet || !cell.titleRect) continue; - drawFacetTitle(chart._chromeCanvas, facet.label, cell.titleRect, theme); + if (!facet || !cell.titleRect) { + continue; + } + + drawFacetTitle( + chart._chromeCanvas, + facet.label, + cell.titleRect, + theme, + dpr, + ); } // Shared colorbar at `grid.legendRect`. No meaningful single label — @@ -518,6 +804,8 @@ function renderFacetedHeatmapChromeOverlay(chart: HeatmapChart): void { label: "", }, theme.gradientStops, + theme, + chart.getColumnFormatter(chart._columnSlots[0], "value"), ); } @@ -525,23 +813,3 @@ function renderFacetedHeatmapChromeOverlay(chart: HeatmapChart): void { renderHeatmapTooltip(chart); } } - -function drawFacetTitle( - canvas: HTMLCanvasElement, - label: string, - rect: { x: number; y: number; width: number; height: number }, - theme: Theme, -): void { - const ctx = canvas.getContext("2d"); - if (!ctx) return; - const dpr = window.devicePixelRatio || 1; - ctx.save(); - ctx.setTransform(1, 0, 0, 1, 0, 0); - ctx.scale(dpr, dpr); - ctx.font = `11px ${theme.fontFamily}`; - ctx.fillStyle = theme.labelColor; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.fillText(label, rect.x + rect.width / 2, rect.y + rect.height / 2); - ctx.restore(); -} diff --git a/packages/viewer-charts/src/ts/charts/heatmap/heatmap-y-axis.ts b/packages/viewer-charts/src/ts/charts/heatmap/heatmap-y-axis.ts index e7ab754bb5..fe432b116b 100644 --- a/packages/viewer-charts/src/ts/charts/heatmap/heatmap-y-axis.ts +++ b/packages/viewer-charts/src/ts/charts/heatmap/heatmap-y-axis.ts @@ -10,14 +10,15 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +import type { Context2D } from "../canvas-types"; import { PlotLayout } from "../../layout/plot-layout"; import type { Theme } from "../../theme/theme"; import type { CategoricalDomain, CategoricalLevel, -} from "../../chrome/categorical-axis"; -import { runsInRange } from "../../chrome/categorical-axis-core"; -import { truncateLabel } from "../../chrome/label-geometry"; +} from "../../axis/categorical-axis"; +import { runsInRange } from "../../axis/categorical-axis-core"; +import { truncateLabel } from "../../axis/label-geometry"; interface LevelTickLayout { size: number; // width in CSS pixels consumed by this level @@ -46,9 +47,10 @@ function effectiveLevels( levels: CategoricalLevel[], opts?: CategoricalYAxisOptions, ): CategoricalLevel[] { - if (opts?.skipLeafLevel && levels.length > 1) { + if (opts?.skipLeafLevel && levels.length > 0) { return levels.slice(0, -1); } + return levels; } @@ -92,6 +94,7 @@ export function measureCategoricalYLevels( result.push({ size: w }); } } + return result; } @@ -105,10 +108,16 @@ export function measureCategoricalAxisWidth( opts?: CategoricalYAxisOptions, ): number { const levels = effectiveLevels(domain.levels, opts); - if (domain.numRows === 0 || levels.length === 0) return 55; + if (domain.numRows === 0 || levels.length === 0) { + return 55; + } + const levelLayouts = measureCategoricalYLevels(domain, opts); let total = 0; - for (const l of levelLayouts) total += l.size; + for (const l of levelLayouts) { + total += l.size; + } + return total; } @@ -122,14 +131,16 @@ function getLeafText(level: CategoricalLevel, row: number): string { * caller alongside the X axis. */ export function renderCategoricalYTicks( - ctx: CanvasRenderingContext2D, + ctx: Context2D, layout: PlotLayout, domain: CategoricalDomain, theme: Theme, opts?: CategoricalYAxisOptions, ): void { const levels = effectiveLevels(domain.levels, opts); - if (domain.numRows === 0 || levels.length === 0) return; + if (domain.numRows === 0 || levels.length === 0) { + return; + } const { tickColor, labelColor, fontFamily } = theme; const { plotRect: plot } = layout; @@ -144,9 +155,12 @@ export function renderCategoricalYTicks( // Visible Y range from the (possibly zoomed) padded Y domain. const visMin = Math.max(0, Math.ceil(layout.paddedYMin)); const visMax = Math.min(domain.numRows - 1, Math.floor(layout.paddedYMax)); - if (visMax < visMin) return; + if (visMax < visMin) { + return; + } const L = levels.length; + // Cursor walks from the plot's left edge leftward, innermost (leaf) // level closest to the plot, outer levels further left. let xCursor = plot.x; @@ -201,7 +215,7 @@ export function renderCategoricalYTicks( } function renderLeafLevel( - ctx: CanvasRenderingContext2D, + ctx: Context2D, layout: PlotLayout, level: CategoricalLevel, visMin: number, @@ -218,10 +232,14 @@ function renderLeafLevel( ctx.beginPath(); for (let r = visMin; r <= visMax; r++) { const py = categoryIndexToPixelY(layout, r); - if (py < plot.y - 1 || py > plot.y + plot.height + 1) continue; + if (py < plot.y - 1 || py > plot.y + plot.height + 1) { + continue; + } + ctx.moveTo(bandRight, py); ctx.lineTo(bandRight - TICK_SIZE, py); } + ctx.stroke(); ctx.font = `${LABEL_FONT_PX}px ${fontFamily}`; @@ -230,17 +248,26 @@ function renderLeafLevel( const labelMaxWidth = bandRight - TICK_SIZE - 4 - bandLeft - 4; for (let r = visMin; r <= visMax; r++) { const py = categoryIndexToPixelY(layout, r); - if (py < plot.y - 1 || py > plot.y + plot.height + 1) continue; + if (py < plot.y - 1 || py > plot.y + plot.height + 1) { + continue; + } + const text = getLeafText(level, r); - if (!text) continue; + if (!text) { + continue; + } + const truncated = truncateLabel(ctx, text, labelMaxWidth); - if (!truncated) continue; + if (!truncated) { + continue; + } + ctx.fillText(truncated, bandRight - TICK_SIZE - 4, py); } } function renderOuterLevel( - ctx: CanvasRenderingContext2D, + ctx: Context2D, layout: PlotLayout, level: CategoricalLevel, visMin: number, @@ -252,7 +279,9 @@ function renderOuterLevel( ): void { const plot = layout.plotRect; const runs = runsInRange(level.runs, visMin, visMax); - if (runs.length === 0) return; + if (runs.length === 0) { + return; + } ctx.strokeStyle = tickColor; ctx.fillStyle = tickColor; @@ -268,16 +297,20 @@ function renderOuterLevel( const yLo = Math.max(yTop, yBot); const clippedHi = Math.max(plot.y, yHi); const clippedLo = Math.min(plot.y + plot.height, yLo); - if (clippedLo <= clippedHi) continue; + if (clippedLo <= clippedHi) { + continue; + } ctx.moveTo(bracketX, clippedHi); ctx.lineTo(bracketX, clippedLo); + // Boundary ticks pointing inward (toward the plot). ctx.moveTo(bracketX, clippedHi); ctx.lineTo(bracketX + 3, clippedHi); ctx.moveTo(bracketX, clippedLo); ctx.lineTo(bracketX + 3, clippedLo); } + ctx.stroke(); // Centered run label, rotated -90° so long labels fit in a narrow band. @@ -289,15 +322,23 @@ function renderOuterLevel( const yLo = Math.max(yTop, yBot); const clippedHi = Math.max(plot.y, yHi); const clippedLo = Math.min(plot.y + plot.height, yLo); - if (clippedLo <= clippedHi) continue; + if (clippedLo <= clippedHi) { + continue; + } + const cy = (clippedHi + clippedLo) / 2; const cx = bandLeft + (bandRight - bandLeft - 3) / 2; const text = run.label; - if (!text) continue; + if (!text) { + continue; + } + const span = clippedLo - clippedHi - 4; const truncated = truncateLabel(ctx, text, Math.max(0, span)); - if (!truncated) continue; + if (!truncated) { + continue; + } ctx.save(); ctx.translate(cx, cy); diff --git a/packages/viewer-charts/src/ts/charts/heatmap/heatmap.ts b/packages/viewer-charts/src/ts/charts/heatmap/heatmap.ts index 4192e63483..725af29309 100644 --- a/packages/viewer-charts/src/ts/charts/heatmap/heatmap.ts +++ b/packages/viewer-charts/src/ts/charts/heatmap/heatmap.ts @@ -14,8 +14,12 @@ import type { ColumnDataMap } from "../../data/view-reader"; import type { WebGLContextManager } from "../../webgl/context-manager"; import { AbstractChart } from "../chart-base"; import { PlotLayout } from "../../layout/plot-layout"; -import type { CategoricalLevel } from "../../chrome/categorical-axis"; +import type { CategoricalLevel } from "../../axis/categorical-axis"; import type { FacetGrid } from "../../layout/facet-grid"; +import type { + AxisMode, + NumericCategoryDomain, +} from "../common/category-axis-resolver"; import { buildHeatmapPipeline, partitionColumnsPerFacet, @@ -42,6 +46,16 @@ export interface HeatmapFacet { layout: PlotLayout; instanceStart: number; instanceCount: number; + + /** + * Origins used to rebase cell positions before f32 narrowing — + * matches the per-axis convention in `SeriesChart._categoryOrigin`. + * `0` for non-numeric axes; for numeric (especially datetime) axes + * pinned to `xNumericDomain.min` / `yNumericDomain.min` so the + * shader's projection matrix can be built in rebased space. + */ + xOrigin: number; + yOrigin: number; } /** @@ -69,6 +83,24 @@ export class HeatmapChart extends AbstractChart { _numY = 0; _rowOffset = 0; + _xAxisMode: AxisMode = { mode: "category" }; + _yAxisMode: AxisMode = { mode: "category" }; + _xPositions: Float64Array | null = null; + _yPositions: Float64Array | null = null; + _xNumericDomain: NumericCategoryDomain | null = null; + _yNumericDomain: NumericCategoryDomain | null = null; + + /** + * Single-plot rebase origins. Datetime numeric axes carry ~1.7e12 + * timestamps which the f32 GPU pipeline cannot resolve below + * ~256ms; the cell upload subtracts these and the projection + * matrix is built with the same values so its `tx`/`ty` terms stay + * small enough for f32 cancellation in the shader. `0` for + * categorical or numeric-non-date axes. + */ + _xOrigin = 0; + _yOrigin = 0; + _cells: HeatmapCell[] = []; _cells2D: (HeatmapCell | null)[] = []; _uploadedCells = 0; @@ -80,32 +112,92 @@ export class HeatmapChart extends AbstractChart { _hoveredCell: HeatmapCell | null = null; _lastLayout: PlotLayout | null = null; + /** + * Last cursor position (canvas-CSS pixels) recorded by + * `handleHeatmapHover`. Used as the tooltip anchor instead of the + * cell center so the hover label tracks the mouse — necessary + * because heatmap cells can be many pixels wide and a center-anchored + * tooltip drifts away from the cursor. + */ + _hoveredMouseX = 0; + _hoveredMouseY = 0; + _facets: HeatmapFacet[] = []; _facetGrid: FacetGrid | null = null; _hoveredFacetIdx = -1; - /** Bound accessor so the interact module can trigger a chrome redraw. */ + /** + * Bound accessor so the interact module can trigger a chrome redraw. + */ _renderChromeOverlay = () => renderHeatmapChromeOverlay(this); - attachTooltip(glCanvas: HTMLCanvasElement): void { - this._glCanvas = glCanvas; - this._tooltip.attach(glCanvas, { - onHover: (mx, my) => handleHeatmapHover(this, mx, my), + protected override tooltipCallbacks() { + return { + onHover: (mx: number, my: number) => + handleHeatmapHover(this, mx, my), onLeave: () => { if (this._hoveredCell) { this._hoveredCell = null; this._renderChromeOverlay(); } }, + onPin: (mx: number, my: number) => { + // Refresh the hit-test at the click coords so the pin + // path doesn't depend on the RAF-throttled hover state + // — see comment in `series.ts` `onPin`. + handleHeatmapHover(this, mx, my); + if (this._hoveredCell) { + void this._emitHeatmapClickSelect( + this._hoveredCell.xIdx, + this._hoveredCell.yIdx, + ); + } + }, + onUnpin: () => { + this.emitUnselect(); + }, + }; + } + + /** + * Resolve a clicked heatmap cell into a `PerspectiveClickDetail` + * and emit both `perspective-click` and + * `perspective-global-filter selected:true`. `xIdx` indexes the + * outer (group-by) hierarchy; `yIdx` indexes the column-side + * hierarchy (split-by + value-column splits). Each level is read + * directly from the pre-resolved `_xLevels` / `_yLevels` labels. + * + * Representative source row: `xIdx + _rowOffset` — the row in the + * pivoted view that owns this category. Sufficient for the + * `row.{col}` lookups consumers typically do; not authoritative for + * cells that aggregate across many source rows. + */ + private async _emitHeatmapClickSelect( + xIdx: number, + yIdx: number, + ): Promise { + const groupByValues: (string | null)[] = this._xLevels.map( + (level) => level.labels[xIdx] ?? null, + ); + const splitByValues: (string | null)[] = this._yLevels + .slice(0, this._splitBy.length) + .map((level) => level.labels[yIdx] ?? null); + + const colorColumn = this._columnSlots[0] ?? ""; + await this.emitClickAndSelect({ + rowIdx: xIdx + this._rowOffset, + columnName: colorColumn, + groupByValues, + splitByValues, }); } - uploadAndRender( + async uploadAndRender( glManager: WebGLContextManager, columns: ColumnDataMap, startRow: number, endRow: number, - ): void { + ): Promise { this._glManager = glManager; if (startRow !== 0) { @@ -113,7 +205,6 @@ export class HeatmapChart extends AbstractChart { // should not chunk this but guard defensively. return; } - this._cancelScheduledRender(); const userColumns = this._columnSlots.filter((s): s is string => !!s); @@ -128,8 +219,11 @@ export class HeatmapChart extends AbstractChart { columns: part.columns, numRows: endRow, groupBy: this._groupBy, + splitBy: this._splitBy, + groupByTypes: this._groupByTypes, }); const instanceStart = allCells.length; + // Re-stamp each cell with its facet offset so the packed // instance buffer can be drawn in one sweep; the facet's // own `pipeline.cells` keeps its original indices for @@ -141,6 +235,7 @@ export class HeatmapChart extends AbstractChart { value: c.value, }); } + facets.push({ label: part.label, pipeline, @@ -151,6 +246,8 @@ export class HeatmapChart extends AbstractChart { }), instanceStart, instanceCount: pipeline.cells.length, + xOrigin: pipeline.xNumericDomain?.min ?? 0, + yOrigin: pipeline.yNumericDomain?.min ?? 0, }); if ( isFinite(pipeline.colorMin) && @@ -158,6 +255,7 @@ export class HeatmapChart extends AbstractChart { ) { globalMin = pipeline.colorMin; } + if ( isFinite(pipeline.colorMax) && pipeline.colorMax > globalMax @@ -165,6 +263,7 @@ export class HeatmapChart extends AbstractChart { globalMax = pipeline.colorMax; } } + if (!isFinite(globalMin) || !isFinite(globalMax)) { globalMin = 0; globalMax = 1; @@ -182,6 +281,14 @@ export class HeatmapChart extends AbstractChart { this._rowOffset = 0; this._cells2D = []; this._lastLayout = null; + this._xAxisMode = { mode: "category" }; + this._yAxisMode = { mode: "category" }; + this._xPositions = null; + this._yPositions = null; + this._xNumericDomain = null; + this._yNumericDomain = null; + this._xOrigin = 0; + this._yOrigin = 0; this._facets = facets; this._cells = allCells; @@ -193,6 +300,8 @@ export class HeatmapChart extends AbstractChart { columns, numRows: endRow, groupBy: this._groupBy, + splitBy: this._splitBy, + groupByTypes: this._groupByTypes, }); this._facets = []; @@ -208,20 +317,27 @@ export class HeatmapChart extends AbstractChart { this._colorMin = result.colorMin; this._colorMax = result.colorMax; this._aggName = userColumns[0] ?? "Color"; + this._xAxisMode = result.xAxisMode; + this._yAxisMode = result.yAxisMode; + this._xPositions = result.xPositions; + this._yPositions = result.yPositions; + this._xNumericDomain = result.xNumericDomain; + this._yNumericDomain = result.yNumericDomain; + this._xOrigin = result.xNumericDomain?.min ?? 0; + this._yOrigin = result.yNumericDomain?.min ?? 0; } - this._scheduleRender(glManager); + await this.requestRender(glManager); } - redraw(glManager: WebGLContextManager): void { + _fullRender(glManager: WebGLContextManager): void { this._glManager = glManager; const hasSingle = this._numX > 0 && this._numY > 0; const hasFacets = this._facets.length > 0; - if (!hasSingle && !hasFacets) return; - this._fullRender(glManager); - } + if (!hasSingle && !hasFacets) { + return; + } - protected _fullRender(glManager: WebGLContextManager): void { renderHeatmapFrame(this, glManager); } @@ -229,6 +345,7 @@ export class HeatmapChart extends AbstractChart { if (this._cornerBuffer && this._glManager) { this._glManager.gl.deleteBuffer(this._cornerBuffer); } + this._program = null; this._locations = null; this._cornerBuffer = null; @@ -241,5 +358,11 @@ export class HeatmapChart extends AbstractChart { this._facets = []; this._facetGrid = null; this._hoveredFacetIdx = -1; + this._xAxisMode = { mode: "category" }; + this._yAxisMode = { mode: "category" }; + this._xPositions = null; + this._yPositions = null; + this._xNumericDomain = null; + this._yNumericDomain = null; } } diff --git a/packages/viewer-charts/src/ts/charts/map/map.ts b/packages/viewer-charts/src/ts/charts/map/map.ts new file mode 100644 index 0000000000..4cef7661a2 --- /dev/null +++ b/packages/viewer-charts/src/ts/charts/map/map.ts @@ -0,0 +1,201 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import { CartesianChart } from "../cartesian/cartesian"; +import { PointGlyph } from "../cartesian/glyphs/points"; +import { LineGlyph } from "../cartesian/glyphs/lines"; +import { DensityGlyph } from "../cartesian/glyphs/density"; +import type { Glyph } from "../cartesian/glyph"; +import type { WebGLContextManager } from "../../webgl/context-manager"; +import type { PlotLayout } from "../../layout/plot-layout"; +import type { Theme } from "../../theme/theme"; +import type { Canvas2D, Context2D } from "../canvas-types"; +import type { ZoomConfig } from "../../interaction/zoom-controller"; +import type { PluginConfig } from "../chart"; +import { TileLayer } from "../../map/tile-layer"; +import { tileSourceFor, type TileProviderId } from "../../map/tile-source"; +import { lonLatToMercator } from "../../map/mercator"; +import { getScaledContext } from "../../axis/canvas"; + +/** + * Map-mode base for cartesian charts. Reuses the entire cartesian + * pipeline (build, hit-test, zoom controller, lazy tooltips, faceting, + * theme, gradient texture) and swaps three behaviors: + * + * - `projectPoint(lon, lat)` Mercator-projects incoming columns so + * the rest of the pipeline operates in meter-space (linear + * projection matrix, screen-space splat radius, hit-test grid all + * "just work"). + * - `_renderMode = "map"` flips the render-frame branches to skip + * cartesian gridlines and axes, insert a basemap layer before the + * glyph draw, and use the map-specific chrome (attribution). + * - `getZoomConfig()` returns `lockAspect: true` so wheel zoom keeps + * `dataPerPixel` uniform on both axes (required: Mercator + * preserves angle, glyphs distort otherwise). + * + * Concrete map plugin tags (`map-scatter`, `map-line`, `map-density`) + * pin a glyph in their nullary constructor exactly like the cartesian + * convenience subclasses. + */ +export class MapChart extends CartesianChart { + override _renderMode = "map" as const; + + private _tileLayer: TileLayer; + + constructor(glyph: Glyph) { + super(glyph); + this._tileLayer = new TileLayer(); + this._tileLayer.setOnTileLoad(() => { + // Tile arrival schedules a fresh frame through the shared + // render scheduler; no-ops if a frame is already pending. + if (this._glManager) { + this.requestRender(this._glManager); + } + }); + } + + override projectPoint(lon: number, lat: number): [number, number] { + return lonLatToMercator(lon, lat); + } + + protected override getZoomConfig(): ZoomConfig { + return { lockAspect: true }; + } + + override setPluginConfig(cfg: PluginConfig): void { + super.setPluginConfig(cfg); + if (this._glManager) { + this._tileLayer.setSource( + this._glManager.gl, + tileSourceFor(cfg.map_tile_provider as TileProviderId), + ); + } + + this._tileLayer.setAlpha(cfg.map_tile_alpha); + } + + override renderBackground( + glManager: WebGLContextManager, + layout: PlotLayout, + projection: Float32Array, + domain: { xMin: number; xMax: number; yMin: number; yMax: number }, + xOrigin: number, + yOrigin: number, + ): void { + // Lazy source bind — `setPluginConfig` runs before + // `_glManager` is wired in some host bootstraps, so we + // re-attempt on first render if needed. + if (!this._tileLayer.source) { + this._tileLayer.setSource( + glManager.gl, + tileSourceFor( + this._pluginConfig.map_tile_provider as TileProviderId, + ), + ); + this._tileLayer.setAlpha(this._pluginConfig.map_tile_alpha); + } + + this._tileLayer.render( + glManager, + layout, + projection, + domain, + xOrigin, + yOrigin, + ); + } + + override renderMapChrome( + canvas: Canvas2D | null, + layout: PlotLayout, + theme: Theme, + dpr: number, + ): void { + if (!canvas) { + return; + } + + const ctx = getScaledContext(canvas, dpr) as Context2D | null; + if (!ctx) { + return; + } + + const attribution = this._tileLayer.source?.attribution ?? ""; + if (!attribution) { + return; + } + + const plot = layout.plotRect; + ctx.save(); + ctx.font = `10px ${theme.fontFamily}`; + ctx.textAlign = "right"; + ctx.textBaseline = "bottom"; + + // Pill background so attribution stays legible over any tile. + const padding = 4; + const metrics = ctx.measureText(attribution); + const textW = metrics.width; + const textH = 12; + const x = plot.x + plot.width - 4; + const y = plot.y + plot.height - 4; + ctx.fillStyle = "rgba(255, 255, 255, 0.75)"; + ctx.fillRect( + x - textW - padding * 2, + y - textH - padding, + textW + padding * 2, + textH + padding, + ); + ctx.fillStyle = theme.labelColor; + ctx.fillText(attribution, x - padding, y - padding / 2); + ctx.restore(); + } + + protected override destroyInternal(): void { + if (this._glManager) { + this._tileLayer.destroy(this._glManager.gl); + } + + super.destroyInternal(); + } +} + +/** + * Map Scatter — Mercator scatter on a raster basemap. Same glyph as + * `X/Y Scatter`; only projection and chrome differ. + */ +export class MapScatterChart extends MapChart { + constructor() { + super(new PointGlyph()); + } +} + +/** + * Map Line — Mercator polyline on a raster basemap. Same glyph as + * `X/Y Line`. + */ +export class MapLineChart extends MapChart { + constructor() { + super(new LineGlyph()); + } +} + +/** + * Map Density — Mercator KDE on a raster basemap. Same glyph as + * `Density`; the four `gradient_color_mode` variants + * (density/mean/extreme/signed) are all available on the map too + * because the glyph reads `_pluginConfig` directly. + */ +export class MapDensityChart extends MapChart { + constructor() { + super(new DensityGlyph()); + } +} diff --git a/packages/viewer-charts/src/ts/charts/registry.ts b/packages/viewer-charts/src/ts/charts/registry.ts new file mode 100644 index 0000000000..d8ce54bd80 --- /dev/null +++ b/packages/viewer-charts/src/ts/charts/registry.ts @@ -0,0 +1,65 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import { ScatterChart, LineChart, DensityChart } from "./cartesian/cartesian"; +import { TreemapChart } from "./treemap/treemap"; +import { SunburstChart } from "./sunburst/sunburst"; +import { SeriesChart, XBarChart } from "./series/series"; +import { HeatmapChart } from "./heatmap/heatmap"; +import { CandlestickChart } from "./candlestick/candlestick"; +import type { ChartImplementation } from "./chart"; + +type ChartImplCtor = new () => ChartImplementation; + +/** + * Map from `ChartTypeConfig.tag` to a factory returning the chart-impl + * class. Consumed by `renderer.worker.ts` which `await`s the factory + * before constructing the impl. + * + * Eager chart types resolve immediately — `Promise.resolve(Class)` is + * a microtask, no I/O. Map plugins resolve via dynamic `import()` so + * the bundler emits the tile-rendering subsystem and the map-mode + * subclasses as a separate chunk; non-map users never fetch it. + */ +export const CHART_IMPLS: Record Promise> = { + scatter: async () => ScatterChart, + line: async () => LineChart, + density: async () => DensityChart, + treemap: async () => TreemapChart, + sunburst: async () => SunburstChart, + heatmap: async () => HeatmapChart, + + // All four Y-series plugins share BarChart; they differ only in the + // per-plugin default `chart_type` forwarded via `setDefaultChartType` + // during plugin setup. + "y-bar": async () => SeriesChart, + "y-line": async () => SeriesChart, + "y-scatter": async () => SeriesChart, + "y-area": async () => SeriesChart, + + // X Bar is the horizontal orientation of the same chart class. + "x-bar": async () => XBarChart, + + // Both candlestick-family plugins share one impl; the render path + // branches on `_defaultChartType` (set from `default_chart_type` in + // the plugin config) to pick the glyph. + candlestick: async () => CandlestickChart, + ohlc: async () => CandlestickChart, + + // Map plugins. Dynamic-imported so the bundler splits the + // `map/*` (tile fetch, cache, layer, shaders) and `charts/map/*` + // (MapChart + subclasses) modules into a chunk that loads only + // when the user activates one of these tags. + "map-scatter": async () => (await import("./map/map")).MapScatterChart, + "map-line": async () => (await import("./map/map")).MapLineChart, + "map-density": async () => (await import("./map/map")).MapDensityChart, +}; diff --git a/packages/viewer-charts/src/ts/charts/series/glyphs/draw-areas.ts b/packages/viewer-charts/src/ts/charts/series/glyphs/draw-areas.ts new file mode 100644 index 0000000000..2e97f5d3c5 --- /dev/null +++ b/packages/viewer-charts/src/ts/charts/series/glyphs/draw-areas.ts @@ -0,0 +1,331 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import type { WebGLContextManager } from "../../../webgl/context-manager"; +import type { SeriesChart } from "../series"; +import type { SeriesInfo } from "../series-build"; +import { compileProgram } from "../../../webgl/program-cache"; +import areaVert from "../../../shaders/area.vert.glsl"; +import areaFrag from "../../../shaders/area.frag.glsl"; + +type GL = WebGL2RenderingContext | WebGLRenderingContext; + +interface AreaProgramCache { + program: WebGLProgram; + u_projection: WebGLUniformLocation | null; + u_color: WebGLUniformLocation | null; + u_opacity: WebGLUniformLocation | null; + a_position: number; +} + +interface AreaStrip { + /** + * Byte offset of the strip start within the per-series GPU buffer. + */ + offsetBytes: number; + + /** + * Vertex count (= 2 × number of categories in this run). + */ + vertexCount: number; +} + +interface AreaSeriesEntry { + seriesId: number; + axis: 0 | 1; + color: [number, number, number]; + gpuBuffer: WebGLBuffer; + strips: AreaStrip[]; +} + +/** + * Persistent area glyph state. Built in `rebuildBuffers`. Each series + * owns one GPU buffer holding all of its strip vertices in + * `[x,y_bot, x,y_top, ...]` layout; draws rebind without uploading. + */ +interface AreaBuffers { + series: AreaSeriesEntry[]; +} + +/** + * Reusable Float32 strip scratch. Sized to `N * 4` (two vertices per + * category: bottom + top). Grown on demand. + */ +let _stripScratch: Float32Array = new Float32Array(0); + +function ensureStripScratch(n: number): Float32Array { + if (_stripScratch.length >= n) { + return _stripScratch; + } + + _stripScratch = new Float32Array(Math.max(n, _stripScratch.length * 2)); + return _stripScratch; +} + +/** + * Area glyph for {@link SeriesChart}. Owns its program + per-series + * strip buffers privately. + */ +export class AreaGlyph { + private _program: AreaProgramCache | null = null; + private _buffers: AreaBuffers | null = null; + + private ensureProgram(glManager: WebGLContextManager): AreaProgramCache { + if (this._program) { + return this._program; + } + + this._program = compileProgram( + glManager, + "bar-area", + areaVert, + areaFrag, + ["u_projection", "u_color", "u_opacity"], + ["a_position"], + ); + return this._program; + } + + /** + * Drop persistent area buffers. Subsequent draws no-op until rebuild. + */ + invalidateBuffers(chart: SeriesChart): void { + const buf = this._buffers; + if (!buf || !chart._glManager) { + this._buffers = null; + return; + } + + const gl = chart._glManager.gl; + for (const s of buf.series) { + gl.deleteBuffer(s.gpuBuffer); + } + + this._buffers = null; + } + + /** + * Build per-series strip buffers for area glyphs. Reads stacked + * y0/y1 from `chart._areaBarIndex` (cached at data load) and + * unstacked values from `_samples`. Single GPU upload per series; + * subsequent frames just rebind. + */ + rebuildBuffers(chart: SeriesChart, glManager: WebGLContextManager): void { + const areaSeries = chart._areaSeries; + if (areaSeries.length === 0) { + this._buffers = null; + return; + } + + const N = chart._numCategories; + const S = chart._series.length; + if (N === 0 || S === 0) { + this._buffers = null; + return; + } + + this.ensureProgram(glManager); + const gl = glManager.gl; + const samples = chart._samples; + const valid = chart._sampleValid; + const positions = chart._categoryPositions; + const xOrigin = chart._categoryOrigin; + const barIndex = chart._areaBarIndex; + const bars = chart._bars; + + const entries: AreaSeriesEntry[] = []; + for (const s of areaSeries) { + const strips = collectAreaStrips( + s, + N, + S, + samples, + valid, + barIndex, + bars.y0, + bars.y1, + positions, + xOrigin, + ); + if (strips.totalVertices === 0) { + continue; + } + + const buf = gl.createBuffer()!; + gl.bindBuffer(gl.ARRAY_BUFFER, buf); + gl.bufferData( + gl.ARRAY_BUFFER, + strips.scratch.subarray(0, strips.totalVertices * 2), + gl.STATIC_DRAW, + ); + + entries.push({ + seriesId: s.seriesId, + axis: s.axis, + color: [s.color[0], s.color[1], s.color[2]], + gpuBuffer: buf, + strips: strips.descriptors, + }); + } + + this._buffers = { series: entries }; + } + + /** + * Bind persistent strip buffers and dispatch one TRIANGLE_STRIP per + * series-run. Skips hidden series. + */ + draw( + chart: SeriesChart, + gl: GL, + _glManager: WebGLContextManager, + projLeft: Float32Array, + projRight: Float32Array, + opacity: number, + ): void { + const buf = this._buffers; + const cache = this._program; + if (!buf || !cache || buf.series.length === 0) { + return; + } + + gl.useProgram(cache.program); + gl.uniform1f(cache.u_opacity, opacity); + + const hidden = chart._hiddenSeries; + for (const s of buf.series) { + if (hidden.has(s.seriesId)) { + continue; + } + + gl.uniformMatrix4fv( + cache.u_projection, + false, + s.axis === 1 ? projRight : projLeft, + ); + const color = chart._series[s.seriesId].color; + gl.uniform3f(cache.u_color, color[0], color[1], color[2]); + + gl.bindBuffer(gl.ARRAY_BUFFER, s.gpuBuffer); + gl.enableVertexAttribArray(cache.a_position); + gl.vertexAttribPointer(cache.a_position, 2, gl.FLOAT, false, 0, 0); + + for (const strip of s.strips) { + gl.bindBuffer(gl.ARRAY_BUFFER, s.gpuBuffer); + gl.vertexAttribPointer( + cache.a_position, + 2, + gl.FLOAT, + false, + 0, + strip.offsetBytes, + ); + gl.drawArrays(gl.TRIANGLE_STRIP, 0, strip.vertexCount); + } + } + } + + destroy(chart: SeriesChart): void { + this.invalidateBuffers(chart); + this._program = null; + } +} + +interface CollectedStrips { + descriptors: AreaStrip[]; + totalVertices: number; + scratch: Float32Array; +} + +/** + * Walk the per-category sample grid for one series and emit strip + * descriptors. Each contiguous run of present cells becomes one + * `TRIANGLE_STRIP` with `[x,bot, x,top, ...]` layout. + * + * Reads stacked y0/y1 from the pre-built `barIndex` (cached on the + * chart at data load) so this hot path doesn't rebuild the map each + * call. + */ +function collectAreaStrips( + s: SeriesInfo, + N: number, + S: number, + samples: Float32Array, + valid: Uint8Array, + barIndex: Map | null, + barY0: Float64Array, + barY1: Float64Array, + positions: Float64Array | null, + xOrigin: number, +): CollectedStrips { + const scratch = ensureStripScratch(N * 4); + const descriptors: AreaStrip[] = []; + const seriesBase = s.seriesId * 1_000_000_000; + + let write = 0; + let runStart = 0; + + for (let c = 0; c < N; c++) { + let bot = 0; + let top = 0; + let present = false; + + if (s.stack) { + const idx = barIndex?.get(seriesBase + c); + if (idx !== undefined) { + bot = barY0[idx]; + top = barY1[idx]; + present = true; + } + } else { + const idx = c * S + s.seriesId; + if ((valid[idx >> 3] >> (idx & 7)) & 1) { + top = samples[idx]; + present = true; + } + } + + if (present) { + const x = positions ? positions[c] - xOrigin : c; + scratch[write++] = x; + scratch[write++] = bot; + scratch[write++] = x; + scratch[write++] = top; + } else if (write > runStart) { + const vertexCount = (write - runStart) / 2; + if (vertexCount >= 4) { + descriptors.push({ + offsetBytes: runStart * 4, + vertexCount, + }); + } + + runStart = write; + } + } + + if (write > runStart) { + const vertexCount = (write - runStart) / 2; + if (vertexCount >= 4) { + descriptors.push({ + offsetBytes: runStart * 4, + vertexCount, + }); + } + } + + return { + descriptors, + totalVertices: write / 2, + scratch, + }; +} diff --git a/packages/viewer-charts/src/ts/charts/bar/glyphs/draw-bars.ts b/packages/viewer-charts/src/ts/charts/series/glyphs/draw-bars.ts similarity index 60% rename from packages/viewer-charts/src/ts/charts/bar/glyphs/draw-bars.ts rename to packages/viewer-charts/src/ts/charts/series/glyphs/draw-bars.ts index 84b2d4a6e5..a17ceb2199 100644 --- a/packages/viewer-charts/src/ts/charts/bar/glyphs/draw-bars.ts +++ b/packages/viewer-charts/src/ts/charts/series/glyphs/draw-bars.ts @@ -11,11 +11,19 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import type { WebGLContextManager } from "../../../webgl/context-manager"; -import type { BarChart } from "../bar"; +import type { SeriesChart } from "../series"; import { getInstancing, bindInstancedFloatAttr, } from "../../../webgl/instanced-attrs"; +import { BAR_TYPE_BAR } from "../series-build"; + +/** + * Re-export of the bar discriminator value so `series-render.ts` can + * filter the `_bars` columnar storage without pulling the full + * `series-build` types into its import surface. + */ +export const BAR_TYPE_BAR_VAL = BAR_TYPE_BAR; type GL = WebGL2RenderingContext | WebGLRenderingContext; @@ -24,11 +32,14 @@ type GL = WebGL2RenderingContext | WebGLRenderingContext; * the caller has already `useProgram`'d the bar shader and set uniforms. */ export function drawBars( - chart: BarChart, + chart: SeriesChart, gl: GL, glManager: WebGLContextManager, ): void { - if (chart._uploadedBars === 0) return; + if (chart._uploadedBars === 0) { + return; + } + const loc = chart._locations!; const instancing = getInstancing(glManager); const { setDivisor } = instancing; @@ -38,32 +49,59 @@ export function drawBars( gl.vertexAttribPointer(loc.a_corner, 2, gl.FLOAT, false, 0, 0); setDivisor(loc.a_corner, 0); - bindInstancedFloatAttr(glManager, instancing, loc.a_x_center, "bar_x", 1); - bindInstancedFloatAttr( - glManager, - instancing, - loc.a_half_width, - "bar_hw", - 1, - ); - bindInstancedFloatAttr(glManager, instancing, loc.a_y0, "bar_y0", 1); - bindInstancedFloatAttr(glManager, instancing, loc.a_y1, "bar_y1", 1); - bindInstancedFloatAttr(glManager, instancing, loc.a_color, "bar_color", 3); - bindInstancedFloatAttr( - glManager, - instancing, - loc.a_series_id, - "bar_sid", - 1, - ); - bindInstancedFloatAttr(glManager, instancing, loc.a_axis, "bar_axis", 1); + // If any per-instance buffer hasn't been uploaded yet, skip the + // draw rather than paint zeros: `bindInstancedFloatAttr` uses + // `peek` and returns `false` when the buffer is missing. This + // triggers when a render lands between a pending draw's + // `ensureBufferCapacity` and its first `bufferPool.upload` — + // common during pan/zoom while data is being repopulated. + const ok = + bindInstancedFloatAttr( + glManager, + instancing, + loc.a_x_center, + "bar_x", + 1, + ) && + bindInstancedFloatAttr( + glManager, + instancing, + loc.a_half_width, + "bar_hw", + 1, + ) && + bindInstancedFloatAttr(glManager, instancing, loc.a_y0, "bar_y0", 1) && + bindInstancedFloatAttr(glManager, instancing, loc.a_y1, "bar_y1", 1) && + bindInstancedFloatAttr( + glManager, + instancing, + loc.a_color, + "bar_color", + 3, + ) && + bindInstancedFloatAttr( + glManager, + instancing, + loc.a_series_id, + "bar_sid", + 1, + ) && + bindInstancedFloatAttr( + glManager, + instancing, + loc.a_axis, + "bar_axis", + 1, + ); - instancing.drawArraysInstanced( - gl.TRIANGLE_STRIP, - 0, - 4, - chart._uploadedBars, - ); + if (ok) { + instancing.drawArraysInstanced( + gl.TRIANGLE_STRIP, + 0, + 4, + chart._uploadedBars, + ); + } setDivisor(loc.a_x_center, 0); setDivisor(loc.a_half_width, 0); diff --git a/packages/viewer-charts/src/ts/charts/series/glyphs/draw-lines.ts b/packages/viewer-charts/src/ts/charts/series/glyphs/draw-lines.ts new file mode 100644 index 0000000000..d35c64de13 --- /dev/null +++ b/packages/viewer-charts/src/ts/charts/series/glyphs/draw-lines.ts @@ -0,0 +1,320 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import type { WebGLContextManager } from "../../../webgl/context-manager"; +import type { SeriesChart } from "../series"; +import { + createLineCornerBuffer, + getInstancing, +} from "../../../webgl/instanced-attrs"; +import { compileProgram } from "../../../webgl/program-cache"; +import lineVert from "../../../shaders/line-uniform.vert.glsl"; +import lineFrag from "../../../shaders/line-uniform.frag.glsl"; + +type GL = WebGL2RenderingContext | WebGLRenderingContext; + +interface LineProgramCache { + program: WebGLProgram; + cornerBuffer: WebGLBuffer; + u_projection: WebGLUniformLocation | null; + u_color: WebGLUniformLocation | null; + u_resolution: WebGLUniformLocation | null; + u_line_width: WebGLUniformLocation | null; + a_start: number; + a_end: number; + a_corner: number; +} + +interface LineRun { + /** + * Byte offset into the per-series GPU buffer at the start of this run. + */ + offsetBytes: number; + + /** + * Number of points in this run; the run draws `count - 1` segments. + */ + count: number; +} + +interface LineSeriesEntry { + seriesId: number; + axis: 0 | 1; + color: [number, number, number]; + + /** + * GPU buffer holding `[x0,y0,x1,y1,...]` for every run in the series. + */ + gpuBuffer: WebGLBuffer; + + /** + * Run offsets into `gpuBuffer`. Empty when the series has no segments. + */ + runs: LineRun[]; +} + +/** + * Persistent line glyph state. Built in `rebuildBuffers` (called from + * `uploadAndRender`) and reused across pan/zoom frames. Legacy code + * rebuilt the JS-side polylines + GPU buffers on every frame, which + * dominated the per-frame budget at high N. + */ +interface LineBuffers { + /** + * One entry per line series (hidden series included; draw skips them). + */ + series: LineSeriesEntry[]; +} + +/** + * Reusable Float32 scratch for assembling polyline points before GPU + * upload. Sized lazily and grown on demand. Replaces the legacy + * `scratch: number[]` (boxed) → `Float32Array.from(scratch)` (copy) + * pattern. + */ +let _lineScratch: Float32Array = new Float32Array(0); + +function ensureLineScratch(n: number): Float32Array { + if (_lineScratch.length >= n) { + return _lineScratch; + } + + _lineScratch = new Float32Array(Math.max(n, _lineScratch.length * 2)); + return _lineScratch; +} + +/** + * Line glyph for {@link SeriesChart}. Owns its program + per-series + * GPU buffers privately; chart routes lifecycle through + * `_glyphs.lines`. + */ +export class LineGlyph { + private _program: LineProgramCache | null = null; + private _buffers: LineBuffers | null = null; + + private ensureProgram(glManager: WebGLContextManager): LineProgramCache { + if (this._program) { + return this._program; + } + + const cornerBuffer = createLineCornerBuffer(glManager.gl); + const partial = compileProgram>( + glManager, + "bar-line", + lineVert, + lineFrag, + ["u_projection", "u_color", "u_resolution", "u_line_width"], + ["a_start", "a_end", "a_corner"], + ); + this._program = { ...partial, cornerBuffer }; + return this._program; + } + + /** + * Drop persistent line buffers. Subsequent draws will no-op until + * the next `rebuildBuffers` call. + */ + invalidateBuffers(chart: SeriesChart): void { + const buf = this._buffers; + if (!buf || !chart._glManager) { + this._buffers = null; + return; + } + + const gl = chart._glManager.gl; + for (const s of buf.series) { + gl.deleteBuffer(s.gpuBuffer); + } + + this._buffers = null; + } + + /** + * Rebuild the per-series GPU buffers for line glyphs. Called once + * per data load (and once after `restyle()` because palette colors + * are captured on the {@link LineSeriesEntry}). The buffer contents + * encode `[x,y]` points in run-major order; one `bufferData` per + * series. After this, every `draw` call rebinds + dispatches with + * no further uploads until the next data load. + */ + rebuildBuffers(chart: SeriesChart, glManager: WebGLContextManager): void { + const lineSeries = chart._lineSeries; + if (lineSeries.length === 0) { + this._buffers = null; + return; + } + + const N = chart._numCategories; + if (N === 0) { + this._buffers = null; + return; + } + + this.ensureProgram(glManager); + const gl = glManager.gl; + const samples = chart._samples; + const valid = chart._sampleValid; + const xOrigin = chart._categoryOrigin; + const positions = chart._categoryPositions; + const S = chart._series.length; + + const entries: LineSeriesEntry[] = []; + for (const s of lineSeries) { + // Walk the per-category sample grid for this series, breaking + // into contiguous valid runs. Write directly into a pre-sized + // Float32 scratch — no boxed JS arrays, no `Float32Array.from`. + const scratch = ensureLineScratch(N * 2); + const runs: LineRun[] = []; + let write = 0; + let runStart = 0; + for (let c = 0; c < N; c++) { + const idx = c * S + s.seriesId; + const ok = (valid[idx >> 3] >> (idx & 7)) & 1; + if (ok) { + const x = positions ? positions[c] - xOrigin : c; + scratch[write++] = x; + scratch[write++] = samples[idx]; + } else if (write > runStart) { + const count = (write - runStart) / 2; + if (count >= 2) { + runs.push({ offsetBytes: runStart * 4, count }); + } + + runStart = write; + } + } + + if (write > runStart) { + const count = (write - runStart) / 2; + if (count >= 2) { + runs.push({ offsetBytes: runStart * 4, count }); + } + } + + if (runs.length === 0) { + continue; + } + + const buf = gl.createBuffer()!; + gl.bindBuffer(gl.ARRAY_BUFFER, buf); + gl.bufferData( + gl.ARRAY_BUFFER, + scratch.subarray(0, write), + gl.STATIC_DRAW, + ); + + entries.push({ + seriesId: s.seriesId, + axis: s.axis, + color: [s.color[0], s.color[1], s.color[2]], + gpuBuffer: buf, + runs, + }); + } + + this._buffers = { series: entries }; + } + + /** + * Bind the persistent vertex buffers and dispatch one instanced draw + * per (series, run). Skips hidden series via `_hiddenSeries`. + */ + draw( + chart: SeriesChart, + gl: GL, + glManager: WebGLContextManager, + projLeft: Float32Array, + projRight: Float32Array, + ): void { + const buf = this._buffers; + const cache = this._program; + if (!buf || !cache || buf.series.length === 0) { + return; + } + + const dpr = glManager.dpr; + gl.useProgram(cache.program); + gl.uniform2f(cache.u_resolution, gl.canvas.width, gl.canvas.height); + gl.uniform1f( + cache.u_line_width, + chart._pluginConfig.line_width_px * dpr, + ); + + const instancing = getInstancing(glManager); + const { setDivisor, drawArraysInstanced } = instancing; + + gl.bindBuffer(gl.ARRAY_BUFFER, cache.cornerBuffer); + gl.enableVertexAttribArray(cache.a_corner); + gl.vertexAttribPointer(cache.a_corner, 1, gl.FLOAT, false, 0, 0); + setDivisor(cache.a_corner, 0); + + const stride = 2 * Float32Array.BYTES_PER_ELEMENT; + const hidden = chart._hiddenSeries; + for (const s of buf.series) { + if (hidden.has(s.seriesId)) { + continue; + } + + gl.bindBuffer(gl.ARRAY_BUFFER, s.gpuBuffer); + gl.uniformMatrix4fv( + cache.u_projection, + false, + s.axis === 1 ? projRight : projLeft, + ); + + const color = chart._series[s.seriesId].color; + gl.uniform4f(cache.u_color, color[0], color[1], color[2], 1.0); + + gl.enableVertexAttribArray(cache.a_start); + setDivisor(cache.a_start, 1); + gl.enableVertexAttribArray(cache.a_end); + setDivisor(cache.a_end, 1); + + for (const run of s.runs) { + gl.vertexAttribPointer( + cache.a_start, + 2, + gl.FLOAT, + false, + stride, + run.offsetBytes, + ); + gl.vertexAttribPointer( + cache.a_end, + 2, + gl.FLOAT, + false, + stride, + run.offsetBytes + stride, + ); + drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, run.count - 1); + } + } + + setDivisor(cache.a_start, 0); + setDivisor(cache.a_end, 0); + } + + destroy(chart: SeriesChart): void { + const gl = chart._glManager?.gl; + if (gl) { + this.invalidateBuffers(chart); + const cache = this._program; + if (cache) { + gl.deleteBuffer(cache.cornerBuffer); + } + } + + this._program = null; + this._buffers = null; + } +} diff --git a/packages/viewer-charts/src/ts/charts/series/glyphs/draw-scatter.ts b/packages/viewer-charts/src/ts/charts/series/glyphs/draw-scatter.ts new file mode 100644 index 0000000000..673ac641a6 --- /dev/null +++ b/packages/viewer-charts/src/ts/charts/series/glyphs/draw-scatter.ts @@ -0,0 +1,328 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import type { WebGLContextManager } from "../../../webgl/context-manager"; +import type { SeriesChart } from "../series"; +import { compileProgram } from "../../../webgl/program-cache"; +import scatterVert from "../../../shaders/y-scatter.vert.glsl"; +import scatterFrag from "../../../shaders/y-scatter.frag.glsl"; + +type GL = WebGL2RenderingContext | WebGLRenderingContext; + +interface ScatterProgramCache { + program: WebGLProgram; + posLeftBuffer: WebGLBuffer; + posRightBuffer: WebGLBuffer; + colorLeftBuffer: WebGLBuffer; + colorRightBuffer: WebGLBuffer; + u_projection: WebGLUniformLocation | null; + u_point_size: WebGLUniformLocation | null; + a_position: number; + a_color: number; +} + +/** + * Persistent scatter glyph state — left/right axis position + color + * buffers built once at data load. Pan/zoom redraws rebind without + * uploading. + */ +interface ScatterBuffers { + leftCount: number; + rightCount: number; +} + +/** + * Reusable Float32 scratch for point assembly. Two buckets (positions + * and colors) packed into one buffer; sized lazily. + */ +let _posScratch: Float32Array = new Float32Array(0); +let _colScratch: Float32Array = new Float32Array(0); + +function ensureScratch(n: number): void { + if (_posScratch.length < n * 2) { + _posScratch = new Float32Array(Math.max(n * 2, _posScratch.length * 2)); + } + + if (_colScratch.length < n * 3) { + _colScratch = new Float32Array(Math.max(n * 3, _colScratch.length * 2)); + } +} + +/** + * Scatter glyph for {@link SeriesChart}. Owns the program + per-axis + * (position, color) GPU buffers. Single program/buffer set; left and + * right axes are merged into shared buffers with sub-ranges. + */ +export class ScatterGlyph { + private _program: ScatterProgramCache | null = null; + private _buffers: ScatterBuffers | null = null; + + private ensureProgram(glManager: WebGLContextManager): ScatterProgramCache { + if (this._program) { + return this._program; + } + + const gl = glManager.gl; + const partial = compileProgram< + Omit< + ScatterProgramCache, + | "posLeftBuffer" + | "posRightBuffer" + | "colorLeftBuffer" + | "colorRightBuffer" + > + >( + glManager, + "bar-scatter", + scatterVert, + scatterFrag, + ["u_projection", "u_point_size"], + ["a_position", "a_color"], + ); + this._program = { + ...partial, + posLeftBuffer: gl.createBuffer()!, + posRightBuffer: gl.createBuffer()!, + colorLeftBuffer: gl.createBuffer()!, + colorRightBuffer: gl.createBuffer()!, + }; + return this._program; + } + + /** + * Drop persistent scatter buffer state. The underlying GL buffer + * objects on `_program` are reused (owned by the program cache, + * not the per-build buffer view). + */ + invalidateBuffers(_chart: SeriesChart): void { + this._buffers = null; + } + + /** + * Build merged per-axis (position, color) buffers for every visible + * scatter series and upload them. Hidden series are excluded — call + * this from data-load and from the legend-toggle path so the GPU + * buffers always reflect the current visible mask. + */ + rebuildBuffers(chart: SeriesChart, glManager: WebGLContextManager): void { + const scatterSeries = chart._scatterSeries; + if (scatterSeries.length === 0) { + this._buffers = null; + return; + } + + const N = chart._numCategories; + const S = chart._series.length; + if (N === 0 || S === 0) { + this._buffers = null; + return; + } + + const cache = this.ensureProgram(glManager); + const gl = glManager.gl; + + const samples = chart._samples; + const valid = chart._sampleValid; + const positions = chart._categoryPositions; + const xOrigin = chart._categoryOrigin; + const hidden = chart._hiddenSeries; + + // Two-pass: first count to size scratch, then fill. Avoids a + // number[] growth path while still accommodating both axes in a + // single pair of buffers. + let leftCount = 0; + let rightCount = 0; + for (const s of scatterSeries) { + if (hidden.has(s.seriesId)) { + continue; + } + + for (let c = 0; c < N; c++) { + const idx = c * S + s.seriesId; + if (!((valid[idx >> 3] >> (idx & 7)) & 1)) { + continue; + } + + if (s.axis === 1) { + rightCount++; + } else { + leftCount++; + } + } + } + + const total = leftCount + rightCount; + if (total === 0) { + this._buffers = null; + return; + } + + ensureScratch(total); + + // Fill left bucket from `[0, leftCount)`, right bucket from + // `[leftCount, total)` — single typed-array allocation each. + let leftWrite = 0; + let rightWrite = leftCount; + for (const s of scatterSeries) { + if (hidden.has(s.seriesId)) { + continue; + } + + const r = s.color[0]; + const g = s.color[1]; + const b = s.color[2]; + for (let c = 0; c < N; c++) { + const idx = c * S + s.seriesId; + if (!((valid[idx >> 3] >> (idx & 7)) & 1)) { + continue; + } + + const x = positions ? positions[c] - xOrigin : c; + const v = samples[idx]; + if (s.axis === 1) { + _posScratch[rightWrite * 2] = x; + _posScratch[rightWrite * 2 + 1] = v; + _colScratch[rightWrite * 3] = r; + _colScratch[rightWrite * 3 + 1] = g; + _colScratch[rightWrite * 3 + 2] = b; + rightWrite++; + } else { + _posScratch[leftWrite * 2] = x; + _posScratch[leftWrite * 2 + 1] = v; + _colScratch[leftWrite * 3] = r; + _colScratch[leftWrite * 3 + 1] = g; + _colScratch[leftWrite * 3 + 2] = b; + leftWrite++; + } + } + } + + if (leftCount > 0) { + gl.bindBuffer(gl.ARRAY_BUFFER, cache.posLeftBuffer); + gl.bufferData( + gl.ARRAY_BUFFER, + _posScratch.subarray(0, leftCount * 2), + gl.STATIC_DRAW, + ); + gl.bindBuffer(gl.ARRAY_BUFFER, cache.colorLeftBuffer); + gl.bufferData( + gl.ARRAY_BUFFER, + _colScratch.subarray(0, leftCount * 3), + gl.STATIC_DRAW, + ); + } + + if (rightCount > 0) { + gl.bindBuffer(gl.ARRAY_BUFFER, cache.posRightBuffer); + gl.bufferData( + gl.ARRAY_BUFFER, + _posScratch.subarray(leftCount * 2, total * 2), + gl.STATIC_DRAW, + ); + gl.bindBuffer(gl.ARRAY_BUFFER, cache.colorRightBuffer); + gl.bufferData( + gl.ARRAY_BUFFER, + _colScratch.subarray(leftCount * 3, total * 3), + gl.STATIC_DRAW, + ); + } + + this._buffers = { leftCount, rightCount }; + } + + /** + * Bind the persistent left/right buffers and issue up to two draw + * calls. No per-frame allocations or buffer uploads. + */ + draw( + chart: SeriesChart, + gl: GL, + glManager: WebGLContextManager, + projLeft: Float32Array, + projRight: Float32Array, + ): void { + const buf = this._buffers; + const cache = this._program; + if (!buf || !cache) { + return; + } + + if (buf.leftCount === 0 && buf.rightCount === 0) { + return; + } + + const dpr = glManager.dpr; + gl.useProgram(cache.program); + gl.uniform1f( + cache.u_point_size, + chart._pluginConfig.point_size_px * dpr, + ); + + drawBucket( + gl, + cache, + cache.posLeftBuffer, + cache.colorLeftBuffer, + buf.leftCount, + projLeft, + ); + drawBucket( + gl, + cache, + cache.posRightBuffer, + cache.colorRightBuffer, + buf.rightCount, + projRight, + ); + } + + destroy(chart: SeriesChart): void { + const gl = chart._glManager?.gl; + if (gl) { + const cache = this._program; + if (cache) { + gl.deleteBuffer(cache.posLeftBuffer); + gl.deleteBuffer(cache.posRightBuffer); + gl.deleteBuffer(cache.colorLeftBuffer); + gl.deleteBuffer(cache.colorRightBuffer); + } + } + + this._program = null; + this._buffers = null; + } +} + +function drawBucket( + gl: GL, + cache: ScatterProgramCache, + posBuf: WebGLBuffer, + colBuf: WebGLBuffer, + count: number, + proj: Float32Array, +): void { + if (count === 0) { + return; + } + + gl.uniformMatrix4fv(cache.u_projection, false, proj); + + gl.bindBuffer(gl.ARRAY_BUFFER, posBuf); + gl.enableVertexAttribArray(cache.a_position); + gl.vertexAttribPointer(cache.a_position, 2, gl.FLOAT, false, 0, 0); + + gl.bindBuffer(gl.ARRAY_BUFFER, colBuf); + gl.enableVertexAttribArray(cache.a_color); + gl.vertexAttribPointer(cache.a_color, 3, gl.FLOAT, false, 0, 0); + + gl.drawArrays(gl.POINTS, 0, count); +} diff --git a/packages/viewer-charts/src/ts/charts/series/series-build.ts b/packages/viewer-charts/src/ts/charts/series/series-build.ts new file mode 100644 index 0000000000..1bc342f20e --- /dev/null +++ b/packages/viewer-charts/src/ts/charts/series/series-build.ts @@ -0,0 +1,848 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import type { ColumnDataMap } from "../../data/view-reader"; +import { buildSplitGroups } from "../../data/split-groups"; +import type { CategoricalLevel } from "../../axis/categorical-axis"; +import { + resolveAxisMode, + resolveCategoryAxis, + resolveNumericCategoryDomain, + type AxisMode, + type NumericCategoryDomain, +} from "../common/category-axis-resolver"; +import { computeSlotGeometry } from "../common/band-layout"; +import { + resolveChartType, + resolveStack, + resolveAltAxis, + type ChartType, + type ColumnChartConfig, +} from "./series-type"; + +const DUAL_Y_RATIO_THRESHOLD = 50; + +export interface SeriesInfo { + seriesId: number; + aggIdx: number; + splitIdx: number; + aggName: string; + splitKey: string; + label: string; + color: [number, number, number]; + axis: 0 | 1; + chartType: ChartType; + stack: boolean; +} + +/** + * Logical bar/area record. Synthesized on demand from {@link BarColumns} + * via {@link readBarRecord} for tooltip / hover paths. The pipeline never + * materializes these — see `BarColumns` for the columnar storage that + * replaces the legacy `SeriesChartRecord[]`. + */ +export interface SeriesChartRecord { + catIdx: number; + aggIdx: number; + splitIdx: number; + seriesId: number; + xCenter: number; + halfWidth: number; + y0: number; + y1: number; + value: number; + axis: 0 | 1; + + /** + * `"bar"` quads or `"area"` strip segments both stack via this record. + */ + chartType: "bar" | "area"; +} + +export const BAR_TYPE_BAR = 0; +export const BAR_TYPE_AREA = 1; + +/** + * Columnar storage for the bar/area record set. Replaces the legacy + * `SeriesChartRecord[]` to avoid per-record POJO allocation at scale — + * with N×M×P potentially in the millions, the array-of-objects layout + * was the dominant build-time GC pressure. + * + * Records are appended in `(catIdx, aggIdx, splitIdx)` lexicographic + * order — the outer category loop guarantees `catIdx` is monotonically + * non-decreasing, which the renderer / hit-test use for binary-search + * narrowing. + * + * `count` is the active record count; the underlying typed arrays may + * be over-allocated for capacity reuse across builds. + */ +/** + * Compact columnar storage for the bar/area record set. + * + * Three fields the prior schema carried have been dropped because + * they're cheaply derivable at hover time: + * - `aggIdx` ← `seriesId / splitCount` (integer division) + * - `splitIdx` ← `seriesId % splitCount` + * - `value` ← `samples[catIdx * S + seriesId]` + * + * Per-cell write count drops from 11 to 8 (~27% fewer typed-array + * stores) and per-record memory drops from 58 B to 42 B (~28% lower + * footprint at scale). `chartType` is kept (1 B / record) — it's + * read in tight loops in the render and hit-test paths and a string + * dispatch via `_series[]` would be slower than the byte compare. + */ +export interface BarColumns { + count: number; + catIdx: Int32Array; + seriesId: Int32Array; + + /** + * 0 = left axis, 1 = right axis. + */ + axis: Uint8Array; + + /** + * {@link BAR_TYPE_BAR} | {@link BAR_TYPE_AREA}. + */ + chartType: Uint8Array; + xCenter: Float64Array; + halfWidth: Float64Array; + y0: Float64Array; + y1: Float64Array; +} + +export function emptyBarColumns(): BarColumns { + return { + count: 0, + catIdx: new Int32Array(0), + seriesId: new Int32Array(0), + axis: new Uint8Array(0), + chartType: new Uint8Array(0), + xCenter: new Float64Array(0), + halfWidth: new Float64Array(0), + y0: new Float64Array(0), + y1: new Float64Array(0), + }; +} + +/** + * Reuse `prev`'s typed arrays when capacity is sufficient, else allocate + * fresh. Resets `count` to 0 either way; pipeline writes from index 0. + */ +export function ensureBarColumnsCapacity( + prev: BarColumns | null, + capacity: number, +): BarColumns { + if (prev && prev.catIdx.length >= capacity) { + prev.count = 0; + return prev; + } + + return { + count: 0, + catIdx: new Int32Array(capacity), + seriesId: new Int32Array(capacity), + axis: new Uint8Array(capacity), + chartType: new Uint8Array(capacity), + xCenter: new Float64Array(capacity), + halfWidth: new Float64Array(capacity), + y0: new Float64Array(capacity), + y1: new Float64Array(capacity), + }; +} + +/** + * Synthesize a {@link SeriesChartRecord} POJO for record `i`. Used by + * tooltip / hover paths that hand out a single record reference; not + * called in any frame-rate hot loop. + * + * `splitCount` is `splitPrefixes.length` from the build result (= `P` + * in pipeline notation). `samples` + `numSeries` recover the raw + * value from the unstacked sample grid; `samples[catIdx * S + sid]` + * always carries the same value the pipeline saw when emitting the + * record (both writes share the same `v` source). + */ +export function readBarRecord( + cols: BarColumns, + i: number, + splitCount: number, + samples: Float32Array, + numSeries: number, +): SeriesChartRecord { + const sid = cols.seriesId[i]; + const ci = cols.catIdx[i]; + return { + catIdx: ci, + aggIdx: Math.floor(sid / splitCount), + splitIdx: sid % splitCount, + seriesId: sid, + xCenter: cols.xCenter[i], + halfWidth: cols.halfWidth[i], + y0: cols.y0[i], + y1: cols.y1[i], + value: samples[ci * numSeries + sid], + axis: cols.axis[i] as 0 | 1, + chartType: cols.chartType[i] === BAR_TYPE_BAR ? "bar" : "area", + }; +} + +/** + * Reusable Float64 scratch — chart owns one for `posStack` and one for + * `negStack`. Pipeline zero-fills the active prefix on entry. + */ +export function ensureFloat64Scratch( + prev: Float64Array | null, + capacity: number, +): Float64Array { + if (prev && prev.length >= capacity) { + return prev; + } + + return new Float64Array(Math.max(capacity, prev?.length ?? 0)); +} + +export interface SeriesPipelineInput { + columns: ColumnDataMap; + numRows: number; + columnSlots: (string | null)[]; + groupBy: string[]; + splitBy: string[]; + + /** + * Source-column types for `group_by` columns (table.schema() merged + * with view.expression_schema()). Used to (a) stringify non-string + * row-path levels and (b) decide between category and numeric axis + * mode for single-level group_bys. + */ + groupByTypes: Record; + columnsConfig: Record | undefined; + + /** + * Plugin-scoped default glyph when a column has no explicit entry. + */ + defaultChartType?: ChartType; + + /** + * Plugin-config knobs consumed by the build pipeline. Pulled from + * the chart impl's `_pluginConfig` (sourced from the host's + * `plugin_config_schema` / `restore({ plugin_config })`): + * + * - `autoAltYAxis` — auto-split aggregates onto a secondary Y + * axis when their magnitude ratio exceeds + * `DUAL_Y_RATIO_THRESHOLD`. Replaces the `AUTO_ALT_Y_AXIS` + * compile-time toggle. + * - `bandInnerFrac` / `barInnerPad` — band-slot geometry forwarded + * to `computeSlotGeometry`. Replace the `BAND_INNER_FRAC` / + * `BAR_INNER_PAD` constants. + */ + autoAltYAxis: boolean; + bandInnerFrac: number; + barInnerPad: number; + + /** + * Anchor value-axis extents to zero. When `true` (bar / area + * default), `leftDomain` / `rightDomain` are guaranteed to enclose + * `0` so bars and areas render against their natural baseline. + * When `false` (line / scatter default), the domain is the raw + * `min`/`max` of the data — the axis tightens around the visible + * variation. Maps directly to `PluginConfig.include_zero`. + */ + includeZero: boolean; + + /** + * Reusable scratch — pipeline writes records into these in place + * and zero-fills the stack ladder. Pass the previous build's + * outputs to amortize allocation across data reloads. + */ + scratchBars?: BarColumns | null; + scratchPosStack?: Float64Array | null; + scratchNegStack?: Float64Array | null; +} + +export type { NumericCategoryDomain }; + +export interface SeriesPipelineResult { + aggregates: string[]; + splitPrefixes: string[]; + rowPaths: CategoricalLevel[]; + numCategories: number; + rowOffset: number; + + /** + * Axis mode discriminator. `category` is the default (zero or + * many group_by levels, or a single string/boolean level). `numeric` + * fires for a single non-string non-boolean group_by — bars are + * positioned by the underlying data value rather than logical + * category index. + */ + axisMode: AxisMode; + + /** + * Populated only when `axisMode.mode === "numeric"`. + */ + numericCategoryDomain: NumericCategoryDomain | null; + + /** + * Per-category X coordinate in real data units. Populated only in + * numeric axis mode — `null` in category mode where catIdx itself + * is the position. Indexed by `catIdx` (0..numCategories-1). + */ + categoryPositions: Float64Array | null; + series: SeriesInfo[]; + + /** + * Columnar bar/area records, one per (catIdx, agg, split) for series + * where `stack === true && chartType in ["bar", "area"]` (stacked) or + * `chartType in ["bar", "area"]` with non-zero value (unstacked). + */ + bars: BarColumns; + + /** + * Reusable scratch passthrough — these own the stack ladder typed + * arrays so the next build can reuse capacity. + */ + posStack: Float64Array | null; + negStack: Float64Array | null; + + /** + * Unstacked sample grid: `samples[catIdx * S + seriesId]` is the raw + * value for that cell. Only valid for non-stacking series (or for + * stacking series when you need the raw, pre-stack value); the + * corresponding bit in `sampleValid` indicates whether the cell carries + * data. `S === series.length`. + */ + samples: Float32Array; + sampleValid: Uint8Array; + + leftDomain: { min: number; max: number }; + rightDomain: { min: number; max: number } | null; + hasRightAxis: boolean; +} + +function setValidBit(valid: Uint8Array, idx: number): void { + valid[idx >> 3] |= 1 << (idx & 7); +} + +/** + * Pure pipeline: turn a raw `ColumnDataMap` into (a) columnar stacked + * bar/area records and (b) an unstacked `samples` grid for line/scatter + * glyphs plus non-stacking bar/area series. Holds row_path data as + * zero-copy views (no materialization of category strings). + * + * Automatically splits aggregates across a secondary Y axis when their + * extents differ by more than {@link DUAL_Y_RATIO_THRESHOLD}×. + */ +export function buildSeriesPipeline( + input: SeriesPipelineInput, +): SeriesPipelineResult { + const { + columns, + numRows, + columnSlots, + groupBy, + splitBy, + groupByTypes, + columnsConfig, + defaultChartType, + autoAltYAxis, + bandInnerFrac, + barInnerPad, + includeZero, + scratchBars, + scratchPosStack, + scratchNegStack, + } = input; + + const axisMode = resolveAxisMode(groupBy, groupByTypes); + const empty: SeriesPipelineResult = { + aggregates: [], + splitPrefixes: [], + rowPaths: [], + numCategories: 0, + rowOffset: 0, + axisMode, + numericCategoryDomain: null, + categoryPositions: null, + series: [], + bars: emptyBarColumns(), + posStack: scratchPosStack ?? null, + negStack: scratchNegStack ?? null, + samples: new Float32Array(0), + sampleValid: new Uint8Array(0), + leftDomain: { min: 0, max: 0 }, + rightDomain: null, + hasRightAxis: false, + }; + + const aggregates = columnSlots.filter((s): s is string => !!s); + if (aggregates.length === 0) { + return empty; + } + + const splitPrefixes: string[] = []; + if (splitBy.length > 0) { + for (const g of buildSplitGroups(columns, [], aggregates)) { + if (g.colNames.size > 0) { + splitPrefixes.push(g.prefix); + } + } + + if (splitPrefixes.length === 0) { + splitPrefixes.push(""); + } + } else { + splitPrefixes.push(""); + } + + const levelTypes = groupBy.map((name) => groupByTypes[name] ?? "string"); + const { rowPaths, numCategories, rowOffset } = resolveCategoryAxis( + columns, + numRows, + groupBy.length, + levelTypes, + ); + + if (numCategories === 0) { + return { + ...empty, + aggregates, + splitPrefixes, + rowPaths, + rowOffset, + }; + } + + const series: SeriesInfo[] = []; + const M = aggregates.length; + const P = splitPrefixes.length; + for (let k = 0; k < M; k++) { + for (let p = 0; p < P; p++) { + const aggName = aggregates[k]; + const splitKey = splitPrefixes[p]; + const label = + splitKey === "" + ? aggName + : `${splitKey}${M > 1 ? ` | ${aggName}` : ""}`; + const chartType = resolveChartType( + aggName, + columnsConfig, + defaultChartType, + ); + const stack = resolveStack(aggName, chartType, columnsConfig); + series.push({ + seriesId: k * P + p, + aggIdx: k, + splitIdx: p, + aggName, + splitKey, + label, + color: [0.5, 0.5, 0.5], + axis: 0, + chartType, + stack, + }); + } + } + + // `aggExtents` accumulates per-aggregate value ranges for the + // dual-axis split heuristic below. `includeZero` decides whether + // the range starts anchored at zero (bar / area: the bar grows + // from the zero baseline, so it's part of the natural extent) or + // open (line / scatter: extent is the raw `min`..`max`). + const aggExtents: { min: number; max: number }[] = []; + for (let k = 0; k < M; k++) { + aggExtents.push( + includeZero + ? { min: 0, max: 0 } + : { min: Infinity, max: -Infinity }, + ); + } + + const N = numCategories; + const S = series.length; + + // Stacking ladder, keyed by (catIdx, aggIdx). Reuse chart-owned + // scratch when sized; else allocate. Active prefix is zero-filled. + const stackLen = N * M; + const posStack = ensureFloat64Scratch(scratchPosStack ?? null, stackLen); + const negStack = ensureFloat64Scratch(scratchNegStack ?? null, stackLen); + posStack.fill(0, 0, stackLen); + negStack.fill(0, 0, stackLen); + + const samples = new Float32Array(N * S); + const sampleValid = new Uint8Array((N * S + 7) >> 3); + + // Numeric-mode category positions: real data values from __ROW_PATH_0__. + // null in category mode (catIdx is the position). + let categoryPositions: Float64Array | null = null; + let numericCategoryDomain: NumericCategoryDomain | null = null; + let numericBandWidth = 1; + if (axisMode.mode === "numeric" && N > 0) { + const rp = columns.get("__ROW_PATH_0__"); + const resolved = resolveNumericCategoryDomain( + rp?.values, + N, + rowOffset, + groupBy[0] ?? "", + axisMode.numericType === "date" || + axisMode.numericType === "datetime", + ); + if (resolved) { + categoryPositions = resolved.categoryPositions; + numericCategoryDomain = resolved.numericCategoryDomain; + numericBandWidth = resolved.numericCategoryDomain.bandWidth; + } + } + + // Per-band slot geometry — `computeSlotGeometry` returns values in + // band-relative units (band width = 1). In numeric mode scale by + // the data-unit band width derived above. + const baseSlot = computeSlotGeometry(M, bandInnerFrac, barInnerPad); + const slotWidth = baseSlot.slotWidth * numericBandWidth; + const halfWidth = baseSlot.halfWidth * numericBandWidth; + + // Pre-build per-aggregate slot offsets. The legacy form recomputed + // `(k - (M - 1) / 2) * slotWidth` for every (catI, k, p) — N×M×P + // FMA chains in the inner loop. Hoist to a length-M lookup. + const slotOffsets = new Float64Array(M); + const halfWidthOffset = (M - 1) / 2; + for (let k = 0; k < M; k++) { + slotOffsets[k] = (k - halfWidthOffset) * slotWidth; + } + + // Pre-resolve the (k, p) → column reference + valid mask. The legacy + // form built the column name string and called `columns.get(...)` for + // every (catI, k, p) cell — N×M×P string allocs + Map lookups, which + // dominates for dense data. Hoist outside the row loop to an + // M*P-shaped flat array of (values, valid) tuples. + const colValues: (ArrayLike | null)[] = new Array(M * P); + const colValid: (Uint8Array | null)[] = new Array(M * P); + for (let k = 0; k < M; k++) { + const aggName = aggregates[k]; + for (let p = 0; p < P; p++) { + const splitKey = splitPrefixes[p]; + const colName = + splitKey === "" ? aggName : `${splitKey}|${aggName}`; + const col = columns.get(colName); + const idx = k * P + p; + colValues[idx] = col?.values ?? null; + colValid[idx] = col?.valid ?? null; + } + } + + // Pre-allocate columnar bar storage at N*M*P upper bound. The + // pipeline emits at most one record per (cat, agg, split) cell; + // `bars.count` tracks the active prefix. + const barCap = N * M * P; + const bars = ensureBarColumnsCapacity(scratchBars ?? null, barCap); + let barWrite = 0; + + for (let catI = 0; catI < N; catI++) { + const row = catI + rowOffset; + + // Hoist the category center — same value across all (k, p) for + // the current catI. + const catCenter = categoryPositions ? categoryPositions[catI] : catI; + + for (let k = 0; k < M; k++) { + const slotOffset = slotOffsets[k]; + const xCenter = catCenter + slotOffset; + const ext = aggExtents[k]; + + for (let p = 0; p < P; p++) { + const seriesId = k * P + p; + const s = series[seriesId]; + const colIdx = k * P + p; + const values = colValues[colIdx]; + if (!values) { + continue; + } + + const valid = colValid[colIdx]; + if (valid) { + const bit = (valid[row >> 3] >> (row & 7)) & 1; + if (!bit) { + continue; + } + } + + const v = values[row] as number; + if (!isFinite(v)) { + continue; + } + + // Record the raw value in the unstacked grid for every + // glyph that needs it (line, scatter, non-stacking bar/area). + const sampleIdx = catI * S + seriesId; + samples[sampleIdx] = v; + setValidBit(sampleValid, sampleIdx); + + // Stacking-glyph path: emit a record with running y0/y1. + if ( + (s.chartType === "bar" || s.chartType === "area") && + s.stack + ) { + if (v === 0) { + continue; + } + + const stackIdx = catI * M + k; + let y0: number; + let y1: number; + if (v >= 0) { + y0 = posStack[stackIdx]; + y1 = y0 + v; + posStack[stackIdx] = y1; + } else { + y0 = negStack[stackIdx]; + y1 = y0 + v; + negStack[stackIdx] = y1; + } + + if (y0 < ext.min) { + ext.min = y0; + } + + if (y1 < ext.min) { + ext.min = y1; + } + + if (y0 > ext.max) { + ext.max = y0; + } + + if (y1 > ext.max) { + ext.max = y1; + } + + bars.catIdx[barWrite] = catI; + bars.seriesId[barWrite] = seriesId; + bars.axis[barWrite] = 0; + bars.chartType[barWrite] = + s.chartType === "bar" ? BAR_TYPE_BAR : BAR_TYPE_AREA; + bars.xCenter[barWrite] = xCenter; + bars.halfWidth[barWrite] = halfWidth; + bars.y0[barWrite] = y0; + bars.y1[barWrite] = y1; + barWrite++; + } else { + // Non-stacking: extend extents by raw value against zero + // baseline so the axis still encloses line/scatter data. + if (v < ext.min) { + ext.min = v; + } + + if (v > ext.max) { + ext.max = v; + } + + if (includeZero) { + if (0 < ext.min) { + ext.min = 0; + } + + if (0 > ext.max) { + ext.max = 0; + } + } + + // Non-stacking bar/area still needs a record so the + // glyph draw call has a concrete rect. Unstacked: y0=0, + // y1=v. + if (s.chartType === "bar" || s.chartType === "area") { + if (v === 0) { + continue; + } + + bars.catIdx[barWrite] = catI; + bars.seriesId[barWrite] = seriesId; + bars.axis[barWrite] = 0; + bars.chartType[barWrite] = + s.chartType === "bar" + ? BAR_TYPE_BAR + : BAR_TYPE_AREA; + bars.xCenter[barWrite] = xCenter; + bars.halfWidth[barWrite] = halfWidth; + bars.y0[barWrite] = 0; + bars.y1[barWrite] = v; + barWrite++; + } + } + } + } + } + + bars.count = barWrite; + + let hasRightAxis = false; + if (autoAltYAxis && M >= 2) { + const extents: number[] = new Array(M); + let maxExt = 0; + let minExt = Infinity; + for (let k = 0; k < M; k++) { + const e = aggExtents[k]; + const ae = Math.max(Math.abs(e.min), Math.abs(e.max), 1e-12); + extents[k] = ae; + if (ae > maxExt) { + maxExt = ae; + } + + if (ae < minExt) { + minExt = ae; + } + } + + if (maxExt / minExt > DUAL_Y_RATIO_THRESHOLD) { + const threshold = maxExt / Math.sqrt(DUAL_Y_RATIO_THRESHOLD); + for (let k = 0; k < M; k++) { + const onRight = extents[k] < threshold; + if (onRight) { + for (const s of series) { + if (s.aggIdx === k) { + s.axis = 1; + } + } + } + } + + // Propagate axis assignment into bar storage. + for (let i = 0; i < bars.count; i++) { + bars.axis[i] = series[bars.seriesId[i]].axis; + } + + hasRightAxis = series.some((s) => s.axis === 1); + } + } + + // Per-column `alt_axis` override — always wins over the auto + // split. Runs unconditionally so it works even when + // `autoAltYAxis` is off or there's only a single aggregate. + let forcedRight = false; + for (let k = 0; k < M; k++) { + if (resolveAltAxis(aggregates[k], columnsConfig)) { + for (const s of series) { + if (s.aggIdx === k) { + s.axis = 1; + forcedRight = true; + } + } + } + } + + if (forcedRight) { + for (let i = 0; i < bars.count; i++) { + bars.axis[i] = series[bars.seriesId[i]].axis; + } + + hasRightAxis = true; + } + + // Axis domains: stack records contribute y0/y1; non-stacking + // samples contribute raw values. When `includeZero` is true the + // domain starts anchored at zero so bar / area glyphs always have + // their baseline in view; when false the domain opens to + // `[Infinity, -Infinity]` and closes around the data extent. + const leftExtent = includeZero + ? { min: 0, max: 0 } + : { min: Infinity, max: -Infinity }; + const rightExtent = includeZero + ? { min: 0, max: 0 } + : { min: Infinity, max: -Infinity }; + for (let i = 0; i < bars.count; i++) { + const ext = bars.axis[i] === 0 ? leftExtent : rightExtent; + const y0 = bars.y0[i]; + const y1 = bars.y1[i]; + if (y0 < ext.min) { + ext.min = y0; + } + + if (y1 < ext.min) { + ext.min = y1; + } + + if (y0 > ext.max) { + ext.max = y0; + } + + if (y1 > ext.max) { + ext.max = y1; + } + } + + for (let seriesId = 0; seriesId < S; seriesId++) { + const s = series[seriesId]; + if (s.stack && (s.chartType === "bar" || s.chartType === "area")) { + continue; // already counted via bars + } + + const ext = s.axis === 0 ? leftExtent : rightExtent; + for (let catI = 0; catI < N; catI++) { + const sampleIdx = catI * S + seriesId; + if (!((sampleValid[sampleIdx >> 3] >> (sampleIdx & 7)) & 1)) { + continue; + } + + const v = samples[sampleIdx]; + if (v < ext.min) { + ext.min = v; + } + + if (v > ext.max) { + ext.max = v; + } + } + } + + // Empty-data fallback: an untouched extent still sits at its + // sentinel state. `includeZero=true` initializes to `{0, 0}`; + // `includeZero=false` initializes to `{Infinity, -Infinity}`. + // Either way, collapse to `{0, 1}` so axis rendering has a finite + // domain to work with. + const leftEmpty = + !isFinite(leftExtent.min) || + !isFinite(leftExtent.max) || + (leftExtent.min === 0 && leftExtent.max === 0); + if (leftEmpty) { + leftExtent.min = 0; + leftExtent.max = 1; + } + + const rightEmpty = + !isFinite(rightExtent.min) || + !isFinite(rightExtent.max) || + (rightExtent.min === 0 && rightExtent.max === 0); + const rightDomain: { min: number; max: number } | null = hasRightAxis + ? rightEmpty + ? { min: 0, max: 1 } + : rightExtent + : null; + + return { + aggregates, + splitPrefixes, + rowPaths, + numCategories, + rowOffset, + axisMode, + numericCategoryDomain, + categoryPositions, + series, + bars, + posStack, + negStack, + samples, + sampleValid, + leftDomain: leftExtent, + rightDomain, + hasRightAxis, + }; +} diff --git a/packages/viewer-charts/src/ts/charts/bar/bar-interact.ts b/packages/viewer-charts/src/ts/charts/series/series-interact.ts similarity index 59% rename from packages/viewer-charts/src/ts/charts/bar/bar-interact.ts rename to packages/viewer-charts/src/ts/charts/series/series-interact.ts index 2d0c349e5f..8b57b3d642 100644 --- a/packages/viewer-charts/src/ts/charts/bar/bar-interact.ts +++ b/packages/viewer-charts/src/ts/charts/series/series-interact.ts @@ -10,25 +10,39 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import type { BarChart } from "./bar"; -import type { BarRecord } from "./bar-build"; -import { formatTickValue } from "../../layout/ticks"; +import type { SeriesChart } from "./series"; +import { + BAR_TYPE_BAR, + BAR_TYPE_AREA, + readBarRecord, + type SeriesChartRecord, +} from "./series-build"; import { renderBarFrame, uploadBarInstances, + rebuildGlyphBuffers, rightAxisDataToPixel, -} from "./bar-render"; +} from "./series-render"; const POINT_HIT_RADIUS_PX = 10; /** * Unified accessor for the currently hovered glyph. Returns either the - * real {@link BarRecord} from `_bars` (bar / stacked-area hits) or the + * real {@link SeriesChartRecord} from `_bars` (bar / stacked-area hits) or the * synthetic one stored in `_hoveredSample` (line / scatter / non-stacked * area hits), or `null`. */ -export function getHoveredBar(chart: BarChart): BarRecord | null { - if (chart._hoveredBarIdx >= 0) return chart._bars[chart._hoveredBarIdx]; +export function getHoveredBar(chart: SeriesChart): SeriesChartRecord | null { + if (chart._hoveredBarIdx >= 0) { + return readBarRecord( + chart._bars, + chart._hoveredBarIdx, + chart._splitPrefixes.length, + chart._samples, + chart._series.length, + ); + } + return chart._hoveredSample; } @@ -37,8 +51,15 @@ export function getHoveredBar(chart: BarChart): BarRecord | null { * so top glyphs win): scatter points → line points → bars → areas. * Updates `_hoveredBarIdx` or `_hoveredSample` and re-renders on change. */ -export function handleBarHover(chart: BarChart, mx: number, my: number): void { - if (!chart._lastLayout) return; +export function handleBarHover( + chart: SeriesChart, + mx: number, + my: number, +): void { + if (!chart._lastLayout) { + return; + } + const layout = chart._lastLayout; const plot = layout.plotRect; @@ -85,6 +106,7 @@ export function handleBarHover(chart: BarChart, mx: number, my: number): void { pxPerDataX = plot.width / (padXMax - padXMin); pxPerDataYLeft = plot.height / (padYMax - padYMin); } + const dataYRight = chart._hasRightAxis && chart._rightDomain && !chart._isHorizontal ? chart._rightDomain.max - @@ -97,7 +119,7 @@ export function handleBarHover(chart: BarChart, mx: number, my: number): void { : pxPerDataYLeft; let nextBarIdx = -1; - let nextSample: BarRecord | null = null; + let nextSample: SeriesChartRecord | null = null; // 1. Scatter (top). nextSample = hitTestPoints( @@ -127,18 +149,35 @@ export function handleBarHover(chart: BarChart, mx: number, my: number): void { // 3. Bars (rect intersect). if (!nextSample) { - for (let i = 0; i < chart._bars.length; i++) { - const b = chart._bars[i]; - if (b.chartType !== "bar") continue; - if (chart._hiddenSeries.has(b.seriesId)) continue; - if ( - dataX < b.xCenter - b.halfWidth || - dataX > b.xCenter + b.halfWidth - ) + const bars = chart._bars; + const ct = bars.chartType; + const sid = bars.seriesId; + const xC = bars.xCenter; + const hw = bars.halfWidth; + const by0 = bars.y0; + const by1 = bars.y1; + const ax = bars.axis; + const hidden = chart._hiddenSeries; + for (let i = 0; i < bars.count; i++) { + if (ct[i] !== BAR_TYPE_BAR) { + continue; + } + + if (hidden.has(sid[i])) { + continue; + } + + const xc = xC[i]; + const halfW = hw[i]; + if (dataX < xc - halfW || dataX > xc + halfW) { continue; - const dy = b.axis === 0 ? dataYLeft : dataYRight; - const lo = Math.min(b.y0, b.y1); - const hi = Math.max(b.y0, b.y1); + } + + const dy = ax[i] === 0 ? dataYLeft : dataYRight; + const y0 = by0[i]; + const y1 = by1[i]; + const lo = y0 < y1 ? y0 : y1; + const hi = y0 < y1 ? y1 : y0; if (dy >= lo && dy <= hi) { nextBarIdx = i; break; @@ -150,8 +189,11 @@ export function handleBarHover(chart: BarChart, mx: number, my: number): void { if (nextBarIdx < 0 && !nextSample) { const areaHit = hitTestAreas(chart, dataX, dataYLeft, dataYRight); if (areaHit) { - if (areaHit.idx >= 0) nextBarIdx = areaHit.idx; - else nextSample = areaHit.bar; + if (areaHit.idx >= 0) { + nextBarIdx = areaHit.idx; + } else { + nextSample = areaHit.bar; + } } } @@ -159,7 +201,7 @@ export function handleBarHover(chart: BarChart, mx: number, my: number): void { } function hitTestPoints( - chart: BarChart, + chart: SeriesChart, chartType: "scatter" | "line", dataX: number, dataYLeft: number, @@ -167,39 +209,55 @@ function hitTestPoints( pxPerDataX: number, pxPerDataYLeft: number, pxPerDataYRight: number, -): BarRecord | null { +): SeriesChartRecord | null { const N = chart._numCategories; const S = chart._series.length; - if (N === 0 || S === 0) return null; + if (N === 0 || S === 0) { + return null; + } + const samples = chart._samples; const valid = chart._sampleValid; const rSq = POINT_HIT_RADIUS_PX * POINT_HIT_RADIUS_PX; let bestDistSq = rSq; - let best: BarRecord | null = null; + let best: SeriesChartRecord | null = null; + const positions = chart._categoryPositions; for (const s of chart._series) { - if (s.chartType !== chartType) continue; - if (chart._hiddenSeries.has(s.seriesId)) continue; + if (s.chartType !== chartType) { + continue; + } + + if (chart._hiddenSeries.has(s.seriesId)) { + continue; + } + const dataY = s.axis === 1 ? dataYRight : dataYLeft; const pyPerData = s.axis === 1 ? pxPerDataYRight : pxPerDataYLeft; - // Narrow the sweep to categories in-radius on X; outside that range - // the X-pixel delta alone exceeds the hit radius. - const catMin = Math.max( - 0, - Math.floor(dataX - POINT_HIT_RADIUS_PX / pxPerDataX), - ); - const catMax = Math.min( - N - 1, - Math.ceil(dataX + POINT_HIT_RADIUS_PX / pxPerDataX), - ); + // In numeric mode the per-category X positions aren't dense so + // the catIdx-based narrowing doesn't apply — fall back to a + // full sweep. In category mode, narrow to ±radius around dataX. + const catMin = positions + ? 0 + : Math.max(0, Math.floor(dataX - POINT_HIT_RADIUS_PX / pxPerDataX)); + const catMax = positions + ? N - 1 + : Math.min( + N - 1, + Math.ceil(dataX + POINT_HIT_RADIUS_PX / pxPerDataX), + ); for (let c = catMin; c <= catMax; c++) { const idx = c * S + s.seriesId; - if (!((valid[idx >> 3] >> (idx & 7)) & 1)) continue; + if (!((valid[idx >> 3] >> (idx & 7)) & 1)) { + continue; + } + const v = samples[idx]; - const dx = (c - dataX) * pxPerDataX; + const x = positions ? positions[c] : c; + const dx = (x - dataX) * pxPerDataX; const dy = (v - dataY) * pyPerData; const distSq = dx * dx + dy * dy; if (distSq < bestDistSq) { @@ -209,55 +267,90 @@ function hitTestPoints( aggIdx: s.aggIdx, splitIdx: s.splitIdx, seriesId: s.seriesId, - xCenter: c, + xCenter: x, halfWidth: 0, y0: 0, y1: v, value: v, axis: s.axis, + // Tag as bar so the tooltip renderer treats it like one. chartType: "bar", }; } } } + return best; } function hitTestAreas( - chart: BarChart, + chart: SeriesChart, dataX: number, dataYLeft: number, dataYRight: number, -): { idx: number; bar: BarRecord | null } | null { +): { idx: number; bar: SeriesChartRecord | null } | null { // Closest category to the mouse; an area covers every [cat - 0.5, cat + 0.5] // slot, so use `round(dataX)` as the candidate index. const cat = Math.round(dataX); - if (cat < 0 || cat >= chart._numCategories) return null; - if (Math.abs(dataX - cat) > 0.5) return null; + if (cat < 0 || cat >= chart._numCategories) { + return null; + } + + if (Math.abs(dataX - cat) > 0.5) { + return null; + } const S = chart._series.length; const samples = chart._samples; const valid = chart._sampleValid; // Prefer stacked hits (iterate existing bar records — they carry y0/y1). - for (let i = 0; i < chart._bars.length; i++) { - const b = chart._bars[i]; - if (b.chartType !== "area") continue; - if (b.catIdx !== cat) continue; - if (chart._hiddenSeries.has(b.seriesId)) continue; - const dy = b.axis === 0 ? dataYLeft : dataYRight; - const lo = Math.min(b.y0, b.y1); - const hi = Math.max(b.y0, b.y1); - if (dy >= lo && dy <= hi) return { idx: i, bar: null }; + const bars = chart._bars; + const ct = bars.chartType; + const ci = bars.catIdx; + const sid = bars.seriesId; + const ax = bars.axis; + const by0 = bars.y0; + const by1 = bars.y1; + for (let i = 0; i < bars.count; i++) { + if (ct[i] !== BAR_TYPE_AREA) { + continue; + } + + if (ci[i] !== cat) { + continue; + } + + if (chart._hiddenSeries.has(sid[i])) { + continue; + } + + const dy = ax[i] === 0 ? dataYLeft : dataYRight; + const y0 = by0[i]; + const y1 = by1[i]; + const lo = y0 < y1 ? y0 : y1; + const hi = y0 < y1 ? y1 : y0; + if (dy >= lo && dy <= hi) { + return { idx: i, bar: null }; + } } // Unstacked area series: synthesise from samples. for (const s of chart._series) { - if (s.chartType !== "area" || s.stack) continue; - if (chart._hiddenSeries.has(s.seriesId)) continue; + if (s.chartType !== "area" || s.stack) { + continue; + } + + if (chart._hiddenSeries.has(s.seriesId)) { + continue; + } + const idx = cat * S + s.seriesId; - if (!((valid[idx >> 3] >> (idx & 7)) & 1)) continue; + if (!((valid[idx >> 3] >> (idx & 7)) & 1)) { + continue; + } + const v = samples[idx]; const dy = s.axis === 1 ? dataYRight : dataYLeft; const lo = Math.min(0, v); @@ -281,31 +374,39 @@ function hitTestAreas( }; } } + return null; } -function clearHover(chart: BarChart): void { +function clearHover(chart: SeriesChart): void { if (chart._hoveredBarIdx !== -1 || chart._hoveredSample !== null) { chart._hoveredBarIdx = -1; chart._hoveredSample = null; - if (chart._glManager) renderBarFrame(chart, chart._glManager); + if (chart._glManager) { + renderBarFrame(chart, chart._glManager); + } } } function applyHover( - chart: BarChart, + chart: SeriesChart, nextBarIdx: number, - nextSample: BarRecord | null, + nextSample: SeriesChartRecord | null, ): void { const sameBar = chart._hoveredBarIdx === nextBarIdx; const sameSample = (chart._hoveredSample?.seriesId ?? -1) === (nextSample?.seriesId ?? -1) && (chart._hoveredSample?.catIdx ?? -1) === (nextSample?.catIdx ?? -1); - if (sameBar && sameSample) return; + if (sameBar && sameSample) { + return; + } + chart._hoveredBarIdx = nextBarIdx; chart._hoveredSample = nextSample; - if (chart._glManager) renderBarFrame(chart, chart._glManager); + if (chart._glManager) { + renderBarFrame(chart, chart._glManager); + } } /** @@ -313,11 +414,14 @@ function applyHover( * legend entry (the caller should then treat the event as consumed). */ export function handleBarLegendClick( - chart: BarChart, + chart: SeriesChart, mx: number, my: number, ): boolean { - if (chart._legendRects.length === 0) return false; + if (chart._legendRects.length === 0) { + return false; + } + for (const entry of chart._legendRects) { const r = entry.rect; if ( @@ -331,30 +435,58 @@ export function handleBarLegendClick( } else { chart._hiddenSeries.add(entry.seriesId); } + // Hidden-series change affects which bars contribute to - // the auto-fit extent. + // the auto-fit extent and which scatter / line points are + // uploaded. Bars rebuild via `uploadBarInstances` (it + // filters hidden). Scatter and line rebuild because their + // bulk-packed per-axis buffers need to drop hidden points + // — the line glyph used to skip rebuilds (per-series + // buffers + draw-path filter), but with the bulk pack the + // hidden filter must apply at upload time. Area still + // uses per-series buffers; its draw path skips hidden. + // The per-category extent buckets (`_catExtents`) are + // also invalidated — they hold a pointer to the hidden + // set used at build time and rebuild on next read. chart._autoFitCache = null; + chart._legendCacheValid = false; + chart._catExtentsHidden = null; if (chart._glManager) { uploadBarInstances(chart, chart._glManager); + rebuildGlyphBuffers(chart, chart._glManager); renderBarFrame(chart, chart._glManager); } + return true; } } + return false; } -/** Build the per-bar tooltip content lines. */ -export function buildBarTooltipLines(chart: BarChart, b: BarRecord): string[] { +/** + * Build the per-bar tooltip content lines. + */ +export function buildBarTooltipLines( + chart: SeriesChart, + b: SeriesChartRecord, +): string[] { const lines: string[] = []; const s = chart._series[b.seriesId]; const categoryPath = formatBarCategoryPath(chart, b.catIdx); - if (categoryPath) lines.push(categoryPath); - lines.push(`${s.aggName}: ${formatTickValue(b.value)}`); - if (s.splitKey) lines.push(`Split: ${s.splitKey}`); + if (categoryPath) { + lines.push(categoryPath); + } + + const yFmt = chart.getColumnFormatter(s.aggName, "value"); + lines.push(`${s.aggName}: ${yFmt(b.value)}`); + if (s.splitKey) { + lines.push(`Split: ${s.splitKey}`); + } + if (b.y0 !== 0) { - lines.push(`Base: ${formatTickValue(b.y0)}`); - lines.push(`Top: ${formatTickValue(b.y1)}`); + lines.push(`Base: ${yFmt(b.y0)}`); + lines.push(`Top: ${yFmt(b.y1)}`); } return lines; @@ -364,37 +496,83 @@ export function buildBarTooltipLines(chart: BarChart, b: BarRecord): string[] { * Format the hierarchical path label for a given category index. Used by * the tooltip — the axis uses per-level text directly instead. */ -export function formatBarCategoryPath(chart: BarChart, catIdx: number): string { - if (chart._rowPaths.length === 0) return ""; +export function formatBarCategoryPath( + chart: SeriesChart, + catIdx: number, +): string { + // Numeric category mode: resolve from the bar's xCenter (real data + // value) rather than the row-path label array, which is empty when + // the single group_by level is non-string. + if (chart._categoryAxisMode === "numeric" && chart._numericCategoryDomain) { + const bars = chart._bars; + let v: number | null = null; + for (let i = 0; i < bars.count; i++) { + if (bars.catIdx[i] === catIdx) { + v = bars.xCenter[i]; + break; + } + } + + if (v == null) { + return ""; + } + + const xColumn = chart._groupBy[0]; + return chart.getColumnFormatter(xColumn, "value")(v); + } + + if (chart._rowPaths.length === 0) { + return ""; + } + const parts: string[] = []; for (const rp of chart._rowPaths) { const s = rp.labels[catIdx]; - if (s != null && s !== "") parts.push(s); + if (s != null && s !== "") { + parts.push(s); + } } + return parts.join(" / "); } -export function showBarPinnedTooltip(chart: BarChart, barIdx: number): void { - const b = chart._bars[barIdx]; - if (!b) return; +export function showBarPinnedTooltip(chart: SeriesChart, barIdx: number): void { + if (barIdx < 0 || barIdx >= chart._bars.count) { + return; + } + chart._pinnedBarIdx = barIdx; - pinTooltip(chart, b); + pinTooltip( + chart, + readBarRecord( + chart._bars, + barIdx, + chart._splitPrefixes.length, + chart._samples, + chart._series.length, + ), + ); } -/** Pin a tooltip against a synthetic BarRecord (scatter/line/area hit). */ +/** + * Pin a tooltip against a synthetic BarRecord (scatter/line/area hit). + */ export function showBarPinnedTooltipForSample( - chart: BarChart, - bar: BarRecord, + chart: SeriesChart, + bar: SeriesChartRecord, ): void { chart._pinnedBarIdx = -1; pinTooltip(chart, bar); } -function pinTooltip(chart: BarChart, b: BarRecord): void { - chart._tooltip.dismissPinned(); - if (!chart._lastLayout) return; +function pinTooltip(chart: SeriesChart, b: SeriesChartRecord): void { + chart._tooltip.dismiss(); + if (!chart._lastLayout) { + return; + } const layout = chart._lastLayout; + // Anchor at the bar midpoint for bar glyphs (tooltip reads against // the body); at the point itself (`y1`) for line / scatter / area. const glyph = chart._series[b.seriesId]?.chartType ?? "bar"; @@ -407,18 +585,20 @@ function pinTooltip(chart: BarChart, b: BarRecord): void { : rightAxisDataToPixel(chart, b.xCenter, anchorV); const lines = buildBarTooltipLines(chart, b); - if (lines.length === 0) return; + if (lines.length === 0) { + return; + } - const parent = chart._glCanvas?.parentElement; - if (!parent) return; - chart._tooltip.showPinned(parent, lines, pos, layout); + chart._tooltip.pin(lines, pos, layout); chart._hoveredBarIdx = -1; chart._hoveredSample = null; - if (chart._glManager) renderBarFrame(chart, chart._glManager); + if (chart._glManager) { + renderBarFrame(chart, chart._glManager); + } } -export function dismissBarPinnedTooltip(chart: BarChart): void { - chart._tooltip.dismissPinned(); +export function dismissBarPinnedTooltip(chart: SeriesChart): void { + chart._tooltip.dismiss(); chart._pinnedBarIdx = -1; } diff --git a/packages/viewer-charts/src/ts/charts/series/series-render.ts b/packages/viewer-charts/src/ts/charts/series/series-render.ts new file mode 100644 index 0000000000..e71c0b9d89 --- /dev/null +++ b/packages/viewer-charts/src/ts/charts/series/series-render.ts @@ -0,0 +1,1109 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import type { Context2D } from "../canvas-types"; +import type { WebGLContextManager } from "../../webgl/context-manager"; +import { + ensurePalette, + type SeriesChart, + type SeriesAutoFitCache, +} from "./series"; +import type { PlotRect } from "../../layout/plot-layout"; +import { PlotLayout } from "../../layout/plot-layout"; +import { renderInPlotFrame } from "../../webgl/plot-frame"; +import { renderCanvasTooltip } from "../../interaction/tooltip-controller"; +import { drawBars, BAR_TYPE_BAR_VAL as BAR_TYPE_BAR } from "./glyphs/draw-bars"; +import { getHoveredBar } from "./series-interact"; +import { computeNiceTicks } from "../../layout/ticks"; +import { type AxisDomain } from "../../axis/numeric-axis"; +import { + renderBarAxesChrome, + renderBarGridlines, + type BarCategoryAxis, +} from "../../axis/bar-axis"; +import { + measureCategoricalAxisHeight, + measureCategoricalAxisWidth, + type CategoricalDomain, +} from "../../axis/categorical-axis"; +import { buildBarTooltipLines } from "./series-interact"; + +/** + * Reusable scratch for bar instance uploads. Sized lazily at the first + * use; grown on demand. Avoids `new Float32Array(n)` × 7 buffers per + * legend-toggle / data-load; size is bounded by the bar-typed subset + * of `_bars.count`. + */ +interface BarInstanceScratch { + xCenters: Float32Array; + halfWidths: Float32Array; + y0s: Float32Array; + y1s: Float32Array; + seriesIds: Float32Array; + axes: Float32Array; + colors: Float32Array; +} + +let _barInstanceScratch: BarInstanceScratch | null = null; + +function ensureBarInstanceScratch(n: number): BarInstanceScratch { + if ( + _barInstanceScratch && + _barInstanceScratch.xCenters.length >= n && + _barInstanceScratch.colors.length >= n * 3 + ) { + return _barInstanceScratch; + } + + const cap = Math.max(n, _barInstanceScratch?.xCenters.length ?? 0); + _barInstanceScratch = { + xCenters: new Float32Array(cap), + halfWidths: new Float32Array(cap), + y0s: new Float32Array(cap), + y1s: new Float32Array(cap), + seriesIds: new Float32Array(cap), + axes: new Float32Array(cap), + colors: new Float32Array(cap * 3), + }; + return _barInstanceScratch; +} + +/** + * Upload bar instance buffers from the columnar `_bars` storage. Filters + * to bar-typed records only (areas draw as triangle strips). Skips + * hidden series. Re-called from data-load and legend-toggle paths; the + * scratch buffers and `_visibleBarIndices` are reused across calls. + */ +export function uploadBarInstances( + chart: SeriesChart, + glManager: WebGLContextManager, +): void { + const bars = chart._bars; + const total = bars.count; + let n = 0; + + if (total > 0) { + const scratch = ensureBarInstanceScratch(total); + if ( + !chart._visibleBarIndices || + chart._visibleBarIndices.length < total + ) { + chart._visibleBarIndices = new Int32Array(total); + } + + const indices = chart._visibleBarIndices; + + // Rebase each xCenter by `_categoryOrigin` before f32 narrowing. + // For datetime numeric category axes the absolute xCenter is + // ~1.7e12 and f32 narrowing collapses adjacent bars onto the + // same value; subtracting the origin brings every value into + // the seconds range where f32 has full precision. The matching + // projection matrix is built with the same origin so the shader + // math is consistent. + const xOrigin = chart._categoryOrigin; + const series = chart._series; + const hidden = chart._hiddenSeries; + const ct = bars.chartType; + const sid = bars.seriesId; + const xC = bars.xCenter; + const hw = bars.halfWidth; + const by0 = bars.y0; + const by1 = bars.y1; + const ax = bars.axis; + for (let i = 0; i < total; i++) { + if (ct[i] !== BAR_TYPE_BAR) { + continue; + } + + const seriesId = sid[i]; + if (hidden.has(seriesId)) { + continue; + } + + scratch.xCenters[n] = xC[i] - xOrigin; + scratch.halfWidths[n] = hw[i]; + scratch.y0s[n] = by0[i]; + scratch.y1s[n] = by1[i]; + scratch.seriesIds[n] = seriesId; + scratch.axes[n] = ax[i]; + const color = series[seriesId].color; + scratch.colors[n * 3] = color[0]; + scratch.colors[n * 3 + 1] = color[1]; + scratch.colors[n * 3 + 2] = color[2]; + indices[n] = i; + n++; + } + } + + chart._uploadedBars = n; + if (n === 0) { + chart._lastUploadedColors = null; + return; + } + + const scratch = _barInstanceScratch!; + glManager.bufferPool.ensureCapacity(n); + // `subarray(0, n)` slices the scratch to the current frame's + // valid-data length. The scratch grows monotonically across + // frames (see `ensureBarInstanceScratch`) so its `.length` reflects + // historical peak, not current `n` — passing it whole would + // overflow the GPU buffer after any session reset. + glManager.bufferPool.upload("bar_x", scratch.xCenters.subarray(0, n), 0, 1); + glManager.bufferPool.upload( + "bar_hw", + scratch.halfWidths.subarray(0, n), + 0, + 1, + ); + glManager.bufferPool.upload("bar_y0", scratch.y0s.subarray(0, n), 0, 1); + glManager.bufferPool.upload("bar_y1", scratch.y1s.subarray(0, n), 0, 1); + glManager.bufferPool.upload( + "bar_sid", + scratch.seriesIds.subarray(0, n), + 0, + 1, + ); + glManager.bufferPool.upload("bar_axis", scratch.axes.subarray(0, n), 0, 1); + glManager.bufferPool.upload( + "bar_color", + scratch.colors.subarray(0, n * 3), + 0, + 3, + ); + + // Snapshot the uploaded color bytes so subsequent palette-only + // changes can detect a no-op and skip the GPU write. + if ( + !chart._lastUploadedColors || + chart._lastUploadedColors.length < n * 3 + ) { + chart._lastUploadedColors = new Float32Array( + Math.max(n * 3, chart._lastUploadedColors?.length ?? 0), + ); + } + + chart._lastUploadedColors.set(scratch.colors.subarray(0, n * 3)); +} + +/** + * Re-upload the per-bar color attribute. Short-circuits when the new + * colors match the last-uploaded snapshot byte-for-byte. Legacy code + * ran this every frame regardless; with the cached palette now stable + * across pan/zoom this becomes a no-op except after data load / + * `restyle()`. + */ +export function uploadBarColors( + chart: SeriesChart, + glManager: WebGLContextManager, +): void { + const n = chart._uploadedBars; + if (n === 0) { + return; + } + + const indices = chart._visibleBarIndices; + const series = chart._series; + const sid = chart._bars.seriesId; + const scratch = ensureBarInstanceScratch(n); + for (let i = 0; i < n; i++) { + const color = series[sid[indices[i]]].color; + scratch.colors[i * 3] = color[0]; + scratch.colors[i * 3 + 1] = color[1]; + scratch.colors[i * 3 + 2] = color[2]; + } + + const last = chart._lastUploadedColors; + if (last && last.length >= n * 3) { + let same = true; + for (let i = 0; i < n * 3; i++) { + if (last[i] !== scratch.colors[i]) { + same = false; + break; + } + } + + if (same) { + return; + } + } + + glManager.bufferPool.upload( + "bar_color", + scratch.colors.subarray(0, n * 3), + 0, + 3, + ); + if (!last || last.length < n * 3) { + chart._lastUploadedColors = new Float32Array(n * 3); + } + + chart._lastUploadedColors!.set(scratch.colors.subarray(0, n * 3)); +} + +/** + * Drop persistent vertex buffers for line / scatter / area glyphs. + * Called from `uploadAndRender` before {@link rebuildGlyphBuffers}. + */ +export function invalidateGlyphBuffers(chart: SeriesChart): void { + chart._glyphs.lines.invalidateBuffers(chart); + chart._glyphs.scatter.invalidateBuffers(chart); + chart._glyphs.areas.invalidateBuffers(chart); +} + +/** + * Build persistent vertex buffers for line / scatter / area glyphs. + * The legacy renderers rebuilt and re-uploaded these every frame inside + * the per-glyph draw functions; with stable post-build geometry the + * uploads now happen exactly once per data-load / palette change. + */ +export function rebuildGlyphBuffers( + chart: SeriesChart, + glManager: WebGLContextManager, +): void { + chart._glyphs.lines.rebuildBuffers(chart, glManager); + chart._glyphs.scatter.rebuildBuffers(chart, glManager); + chart._glyphs.areas.rebuildBuffers(chart, glManager); +} + +/** + * Full-frame render: gridlines → WebGL bars (instanced) → chrome overlay. + */ +export function renderBarFrame( + chart: SeriesChart, + glManager: WebGLContextManager, +): void { + const gl = glManager.gl; + const dpr = glManager.dpr; + const cssWidth = gl.canvas.width / dpr; + const cssHeight = gl.canvas.height / dpr; + if (cssWidth <= 0 || cssHeight <= 0) { + return; + } + + if (chart._numCategories === 0) { + return; + } + + // Resolve the theme + palette. `ensurePalette` is a no-op when the + // palette inputs (theme refs + series count) are unchanged — under + // pan/zoom this short-circuits, leaving frame work to the GPU draw + // calls only. After data load / `restyle()` it stamps fresh RGB + // onto `_series[i].color`, and the color upload path detects the + // change and re-uploads the bar instance colors. + const theme = chart._resolveTheme(); + if (ensurePalette(chart) && chart._uploadedBars > 0) { + uploadBarColors(chart, glManager); + } + + const horizontal = chart._isHorizontal; + const numericCat = chart._categoryAxisMode === "numeric"; + + // Category axis bounds. Category mode runs [-0.5, N-0.5] in logical + // units; numeric mode reads min/max from the data-unit + // `_numericCategoryDomain`. Horizontal mode flips the Y domain so + // catIdx=0 sits at the top (handled below in the projection call). + const catMin = numericCat ? chart._numericCategoryDomain!.min : -0.5; + const catMax = numericCat + ? chart._numericCategoryDomain!.max + : chart._numCategories - 0.5; + + const valMin = chart._leftDomain.min; + const valMax = chart._leftDomain.max; + if (chart._zoomController) { + if (horizontal) { + chart._zoomController.setBaseDomain(valMin, valMax, catMin, catMax); + } else { + chart._zoomController.setBaseDomain(catMin, catMax, valMin, valMax); + } + } + + // `visCat*` and `visVal*` always describe the currently-visible window + // in logical (category/value) coords regardless of orientation. + let visCatMin = catMin; + let visCatMax = catMax; + let visValMin = valMin; + let visValMax = valMax; + let visRightMin = chart._rightDomain?.min ?? 0; + let visRightMax = chart._rightDomain?.max ?? 1; + if (chart._zoomController) { + const vd = chart._zoomController.getVisibleDomain(); + if (horizontal) { + visValMin = vd.xMin; + visValMax = vd.xMax; + visCatMin = vd.yMin; + visCatMax = vd.yMax; + } else { + visCatMin = vd.xMin; + visCatMax = vd.xMax; + visValMin = vd.yMin; + visValMax = vd.yMax; + } + } + + // Auto-fit the value axis to the visible categorical window. Gated + // on `_autoFitValue` + non-default zoom: at default zoom the refit + // result always equals `_leftDomain`/`_rightDomain`, so walking + // would be wasted work (and would shift test baselines). + if ( + chart._autoFitValue && + chart._zoomController && + !chart._zoomController.isDefault() + ) { + const fit = computeVisibleValueExtent(chart, visCatMin, visCatMax); + if (fit.hasLeft) { + visValMin = fit.leftMin; + visValMax = fit.leftMax; + } + + if (chart._rightDomain && fit.hasRight) { + visRightMin = fit.rightMin; + visRightMax = fit.rightMax; + } + } + + // `include_zero` is absolute — zero must stay inside the rendered + // domain even after a dynamic-zoom refit (`computeVisibleValueExtent` + // returns the data-only extent, which can drop the baseline). + // Without this, tick computation sees the refit window while the + // projection's `requireZero` snap silently re-anchors to zero, so + // ticks crowd one edge of an otherwise zero-anchored plot. + if (chart._pluginConfig.include_zero) { + if (visValMin > 0) { + visValMin = 0; + } + + if (visValMax < 0) { + visValMax = 0; + } + + if (chart._rightDomain) { + if (visRightMin > 0) { + visRightMin = 0; + } + + if (visRightMax < 0) { + visRightMax = 0; + } + } + } + + const hasLegend = chart._series.length > 1; + const hasCatLabel = chart._groupBy.length > 0; + + const provisionalDomain: CategoricalDomain = { + levels: chart._rowPaths, + numRows: chart._numCategories, + levelLabels: chart._groupBy.slice(), + }; + + let layout: PlotLayout; + if (horizontal) { + // Numeric category axis on the Y side: the gutter just needs + // standard numeric tick width (~55px), no per-row label + // measurement. + const leftExtra = numericCat + ? 55 + : measureCategoricalAxisWidth(provisionalDomain); + + layout = new PlotLayout(cssWidth, cssHeight, { + hasXLabel: true, + hasYLabel: hasCatLabel, + hasLegend, + leftExtra, + }); + } else if (numericCat) { + // Numeric category axis on the X side: bottom gutter is a + // fixed numeric-axis row (~24px), no leaf-rotation measurement. + layout = new PlotLayout(cssWidth, cssHeight, { + hasXLabel: hasCatLabel, + hasYLabel: true, + hasLegend, + bottomExtra: 24, + }); + } else { + const estLeft = 55 + 16; + const estRight = hasLegend ? 80 : 16; + const estPlotWidth = Math.max(1, cssWidth - estLeft - estRight); + const bottomExtra = measureCategoricalAxisHeight( + provisionalDomain, + estPlotWidth, + ); + layout = new PlotLayout(cssWidth, cssHeight, { + hasXLabel: hasCatLabel, + hasYLabel: true, + hasLegend, + bottomExtra, + }); + } + + chart._lastLayout = layout; + if (chart._zoomController) { + chart._zoomController.updateLayout(layout); + } + + // Build the primary projection. `clamp` names the axis that carries + // the *value* data (Y for Y Bar, X for X Bar). `requireZero` pins + // the baseline at zero so bar / area glyphs grow from the axis + // line; it must track `include_zero` so the projection's padded + // domain matches the build pipeline's `leftDomain` (otherwise the + // tick computation and the WebGL geometry use different scales). + const requireZero = chart._pluginConfig.include_zero; + const projLeft = horizontal + ? layout.buildProjectionMatrix( + visValMin, + visValMax, + + // Flip so catIdx=0 renders at the top. + visCatMax, + visCatMin, + "x", + requireZero, + undefined, + 0, + chart._categoryOrigin, + ) + : layout.buildProjectionMatrix( + visCatMin, + visCatMax, + visValMin, + visValMax, + "y", + requireZero, + undefined, + chart._categoryOrigin, + 0, + ); + + let projRight: Float32Array; + if (chart._hasRightAxis && chart._rightDomain && !horizontal) { + const savedPadXMin = layout.paddedXMin; + const savedPadXMax = layout.paddedXMax; + const savedPadYMin = layout.paddedYMin; + const savedPadYMax = layout.paddedYMax; + projRight = layout.buildProjectionMatrix( + visCatMin, + visCatMax, + visRightMin, + visRightMax, + "y", + requireZero, + undefined, + chart._categoryOrigin, + 0, + ); + layout.paddedXMin = savedPadXMin; + layout.paddedXMax = savedPadXMax; + layout.paddedYMin = savedPadYMin; + layout.paddedYMax = savedPadYMax; + } else { + // Dual-axis horizontal is not supported in this iteration; fall + // through to a single axis when horizontal + _hasRightAxis. + projRight = projLeft; + } + + const leftValueTicks = computeNiceTicks(visValMin, visValMax, 6); + const rightValueTicks = + chart._hasRightAxis && chart._rightDomain && !horizontal + ? computeNiceTicks(visRightMin, visRightMax, 6) + : null; + + const catDomain: CategoricalDomain = provisionalDomain; + const valueDomain: AxisDomain = { + min: visValMin, + max: visValMax, + label: chart._primaryValueLabel, + }; + const altValueDomain: AxisDomain | null = + chart._rightDomain && !horizontal + ? { + min: visRightMin, + max: visRightMax, + label: chart._altValueLabel, + } + : null; + + if (chart._gridlineCanvas) { + renderBarGridlines( + chart._gridlineCanvas, + layout, + leftValueTicks, + theme, + glManager.dpr, + horizontal, + ); + } + + renderInPlotFrame(gl, layout, glManager.dpr, () => { + // Paint order: areas behind bars (so bar borders stay crisp), + // bars above, lines above those, scatter points on top. X Bar + // only paints bars — the other glyphs bake in vertical geometry + // and aren't supported for horizontal orientation. + if (!horizontal) { + chart._glyphs.areas.draw( + chart, + gl, + glManager, + projLeft, + projRight, + theme.areaOpacity, + ); + } + + gl.useProgram(chart._program!); + const loc = chart._locations!; + gl.uniformMatrix4fv(loc.u_proj_left, false, projLeft); + gl.uniformMatrix4fv(loc.u_proj_right, false, projRight); + gl.uniform1f(loc.u_horizontal, horizontal ? 1.0 : 0.0); + const hovered = chart._series.length > 1 ? getHoveredBar(chart) : null; + gl.uniform1f(loc.u_hover_series, hovered ? hovered.seriesId : -1); + drawBars(chart, gl, glManager); + + if (!horizontal) { + chart._glyphs.lines.draw(chart, gl, glManager, projLeft, projRight); + chart._glyphs.scatter.draw( + chart, + gl, + glManager, + projLeft, + projRight, + ); + } + }); + + chart._lastXDomain = catDomain; + chart._lastYDomain = valueDomain; + chart._lastYTicks = leftValueTicks; + chart._lastAltYDomain = altValueDomain; + chart._lastAltYTicks = rightValueTicks; + chart._lastCatTicks = numericCat + ? computeNiceTicks(visCatMin, visCatMax, 6) + : null; + renderBarChromeOverlay(chart); +} + +/** + * Draw axes chrome + legend + tooltip onto the overlay canvas. + */ +export function renderBarChromeOverlay(chart: SeriesChart): void { + if ( + !chart._chromeCanvas || + !chart._lastLayout || + !chart._lastYDomain || + !chart._lastYTicks + ) { + return; + } + + const theme = chart._resolveTheme(); + let catAxis: BarCategoryAxis; + if ( + chart._categoryAxisMode === "numeric" && + chart._numericCategoryDomain && + chart._lastCatTicks + ) { + catAxis = { + mode: "numeric", + domain: { + min: chart._numericCategoryDomain.min, + max: chart._numericCategoryDomain.max, + isDate: chart._numericCategoryDomain.isDate, + label: chart._numericCategoryDomain.label, + }, + ticks: chart._lastCatTicks, + }; + } else if (chart._lastXDomain) { + catAxis = { mode: "category", domain: chart._lastXDomain }; + } else { + return; + } + + // Y axis columns: the primary axis aggregates the unique Y column + // shared by all series on it. With `auto_alt_y_axis`, series can + // split across primary/secondary by `_series[i].onAltAxis`; the + // primary formatter follows the first non-alt series, alt follows + // the first alt series (falls back to the formatter's own type- + // aware fallback if no such series exists). + const primarySeries = chart._series.find((s) => s.axis === 0); + const altSeries = chart._series.find((s) => s.axis === 1); + const xColumn = chart._groupBy[0]; + renderBarAxesChrome( + chart._chromeCanvas, + catAxis, + chart._lastYDomain, + chart._lastYTicks, + chart._lastLayout, + theme, + chart._glManager?.dpr ?? 1, + chart._lastAltYDomain ?? undefined, + chart._lastAltYTicks ?? undefined, + chart._isHorizontal, + { + value: chart.getColumnFormatter( + primarySeries?.aggName ?? null, + "tick", + ), + alt: chart.getColumnFormatter(altSeries?.aggName ?? null, "tick"), + category: chart.getColumnFormatter(xColumn, "tick"), + }, + ); + + renderBarLegend(chart); + + if (getHoveredBar(chart)) { + renderBarTooltipCanvas(chart); + } +} + +/** + * Cached parallel array of measured legend text widths. The legend + * renderer reads from this each frame instead of re-running + * `ctx.measureText` per series; the widths only change on series-set + * or theme change. `_legendCacheValid` gates rebuild. + */ +let _legendTextWidths: Float64Array = new Float64Array(0); + +function ensureLegendLayout( + chart: SeriesChart, + ctx: Context2D, + fontFamily: string, +): void { + if (chart._legendCacheValid) { + return; + } + + const series = chart._series; + if (_legendTextWidths.length < series.length) { + _legendTextWidths = new Float64Array(series.length); + } + + ctx.save(); + ctx.font = `11px ${fontFamily}`; + for (let i = 0; i < series.length; i++) { + _legendTextWidths[i] = ctx.measureText(series[i].label).width; + } + + ctx.restore(); + chart._legendCacheValid = true; +} + +function renderBarLegend(chart: SeriesChart): void { + chart._legendRects = []; + if (!chart._chromeCanvas || !chart._lastLayout) { + return; + } + + if (chart._series.length <= 1) { + return; + } + + const ctx = chart._chromeCanvas.getContext("2d") as Context2D | null; + if (!ctx) { + return; + } + + ctx.save(); + + const theme = chart._resolveTheme(); + const textColor = theme.legendText; + const fontFamily = theme.fontFamily; + + ensureLegendLayout(chart, ctx, fontFamily); + + const layout = chart._lastLayout; + const swatchSize = 10; + const lineHeight = 18; + const x = layout.plotRect.x + layout.plotRect.width + 12; + let y = layout.margins.top + 10; + + ctx.font = `11px ${fontFamily}`; + ctx.textAlign = "left"; + ctx.textBaseline = "middle"; + + const series = chart._series; + const widths = _legendTextWidths; + for (let i = 0; i < series.length; i++) { + const s = series[i]; + const hidden = chart._hiddenSeries.has(s.seriesId); + const r = Math.round(s.color[0] * 255); + const g = Math.round(s.color[1] * 255); + const b = Math.round(s.color[2] * 255); + + ctx.globalAlpha = hidden ? 0.3 : 1.0; + ctx.fillStyle = `rgb(${r},${g},${b})`; + ctx.fillRect(x, y - swatchSize / 2, swatchSize, swatchSize); + + ctx.fillStyle = textColor; + ctx.fillText(s.label, x + swatchSize + 6, y); + + const textW = widths[i]; + if (hidden) { + ctx.strokeStyle = textColor; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x + swatchSize + 6, y); + ctx.lineTo(x + swatchSize + 6 + textW, y); + ctx.stroke(); + } + + ctx.globalAlpha = 1.0; + + const rect: PlotRect = { + x: x - 2, + y: y - lineHeight / 2, + width: swatchSize + 6 + textW + 4, + height: lineHeight, + }; + chart._legendRects.push({ seriesId: s.seriesId, rect }); + + y += lineHeight; + } + + ctx.restore(); +} + +function renderBarTooltipCanvas(chart: SeriesChart): void { + if (!chart._chromeCanvas || !chart._lastLayout) { + return; + } + + const b = getHoveredBar(chart); + if (!b) { + return; + } + + const layout = chart._lastLayout; + + // Bar glyphs anchor the tooltip at the midpoint of the bar body so + // it reads against a solid swatch. Line / scatter / area glyphs + // have no body — the data point sits at `y1`, so anchor there + // (the tooltip visually hovers *over* the point). Hit records + // synthesized from line/scatter hover tag themselves as "bar" in + // `_hoveredSample` for rendering purposes, so we pull the true + // glyph from the series info instead. + const glyph = chart._series[b.seriesId]?.chartType ?? "bar"; + const anchorV = glyph === "bar" ? (b.y0 + b.y1) / 2 : b.y1; + + const pos = + b.axis === 0 + ? chart._isHorizontal + ? layout.dataToPixel(anchorV, b.xCenter) + : layout.dataToPixel(b.xCenter, anchorV) + : rightAxisDataToPixel(chart, b.xCenter, anchorV); + + const lines = buildBarTooltipLines(chart, b); + const theme = chart._resolveTheme(); + renderCanvasTooltip( + chart._chromeCanvas, + pos, + lines, + layout, + theme, + chart._glManager?.dpr ?? 1, + ); +} + +export function rightAxisDataToPixel( + chart: SeriesChart, + x: number, + y: number, +): { px: number; py: number } { + const layout = chart._lastLayout!; + const { x: px, y: py, width, height } = layout.plotRect; + const tx = + (x - layout.paddedXMin) / (layout.paddedXMax - layout.paddedXMin); + const r = chart._rightDomain!; + const ty = (y - r.min) / (r.max - r.min); + return { px: px + tx * width, py: py + (1 - ty) * height }; +} + +/** + * Compute per-axis value extent over bars whose `catIdx` falls inside + * `[visCatMin, visCatMax]`. Skips hidden series. Returns a cached + * result on `chart._autoFitCache` when `(visCatMin, visCatMax, + * _hiddenSeries)` match the previous call — hover-only redraws hit + * the cache every time. + * + * Value source is `min(y0, y1)`/`max(y0, y1)` per bar, which handles + * stacked + negative-value bars uniformly. + * + * TODO(perf): O(|_bars|) linear scan. `_bars` is already ordered by + * `catIdx`, so a binary-search pair to locate the visible slice would + * drop this to O(log N + K_visible). Deferred — under current + * `max_cells` ceilings the scan is <1% of frame time. + * + * Cache lifetime: reset on data upload ([bar.ts] `uploadAndRender`) + * and legend toggle ([bar-interact.ts] `handleBarLegendClick`). Any + * other mutation that affects the bar set must also null the cache. + */ +function computeVisibleValueExtent( + chart: SeriesChart, + visCatMin: number, + visCatMax: number, +): { + leftMin: number; + leftMax: number; + hasLeft: boolean; + rightMin: number; + rightMax: number; + hasRight: boolean; +} { + const cache = chart._autoFitCache; + if ( + cache && + cache.catMin === visCatMin && + cache.catMax === visCatMax && + cache.hidden === chart._hiddenSeries + ) { + return cache; + } + + // Pre-bucketed extent table — built once per data load (and on + // hidden-series mutation) — turns the per-frame walk from + // O(`bars.count` = N×M×P) into O(visibleCats). The original + // O(`bars.count`) walk now runs only inside `ensureCatExtents`. + const buckets = ensureCatExtents(chart); + + let leftMin = Infinity; + let leftMax = -Infinity; + let hasLeft = false; + let rightMin = Infinity; + let rightMax = -Infinity; + let hasRight = false; + + if (buckets.n > 0) { + // Clamp to the populated [0, n-1] range. `visCat*` is in + // continuous coords (numeric or category index space), so + // floor/ceil to integer bucket indices. + const lo = Math.max(0, Math.floor(visCatMin)); + const hi = Math.min(buckets.n - 1, Math.ceil(visCatMax)); + const lMin = buckets.leftMin; + const lMax = buckets.leftMax; + const rMin = buckets.rightMin; + const rMax = buckets.rightMax; + const hL = buckets.hasLeft; + const hR = buckets.hasRight; + for (let i = lo; i <= hi; i++) { + if (hL[i]) { + if (lMin[i] < leftMin) { + leftMin = lMin[i]; + } + + if (lMax[i] > leftMax) { + leftMax = lMax[i]; + } + + hasLeft = true; + } + + if (hR[i]) { + if (rMin[i] < rightMin) { + rightMin = rMin[i]; + } + + if (rMax[i] > rightMax) { + rightMax = rMax[i]; + } + + hasRight = true; + } + } + } + + // Reuse the same cache object to avoid per-frame allocation. + // `hidden` stored by reference — identity comparison in the cache + // hit path catches set-content changes because the legend-click + // handler swaps / mutates the set in ways that invalidate the + // cache via the explicit null-out. + const next = cache ?? newSeriesAutoFitCache(); + next.catMin = visCatMin; + next.catMax = visCatMax; + next.hidden = chart._hiddenSeries; + next.leftMin = leftMin; + next.leftMax = leftMax; + next.hasLeft = hasLeft; + next.rightMin = rightMin; + next.rightMax = rightMax; + next.hasRight = hasRight; + chart._autoFitCache = next; + return next; +} + +function newSeriesAutoFitCache(): SeriesAutoFitCache { + return { + catMin: 0, + catMax: 0, + hidden: new Set(), + leftMin: 0, + leftMax: 0, + hasLeft: false, + rightMin: 0, + rightMax: 0, + hasRight: false, + }; +} + +/** + * Build (or rebuild) the per-category extent buckets for the current + * `_bars` set plus the line / scatter sample grid, filtered by the + * current `_hiddenSeries` set. The buckets answer "what's the value + * range across this category?" in O(1) per category, replacing the + * O(`bars.count` + N × |line+scatter|) per-frame walk. + * + * Bar / area glyphs contribute via `_bars` (min/max of `y0`,`y1`, so + * stacking and negative values are handled uniformly). Line / scatter + * glyphs have no `_bars` records — they contribute the raw sample + * value `v` as the single-point extent `[v, v]`; without this pass + * `series_zoom_mode === "dynamic"` would silently behave as `"fixed"` + * on any pure line/scatter chart. + * + * Capacity-reused: typed arrays grown only when `_numCategories` + * exceeds prior capacity. Amortizes across pan/zoom frames — runs + * once per data load + once per legend toggle, not per frame. + */ +function ensureCatExtents( + chart: SeriesChart, +): NonNullable { + const N = chart._numCategories; + let buckets = chart._catExtents; + + const sameCapacity = buckets && buckets.leftMin.length >= N; + if ( + buckets && + sameCapacity && + chart._catExtentsHidden === chart._hiddenSeries + ) { + return buckets; + } + + if (!buckets || !sameCapacity) { + buckets = { + leftMin: new Float64Array(N), + leftMax: new Float64Array(N), + rightMin: new Float64Array(N), + rightMax: new Float64Array(N), + hasLeft: new Uint8Array(N), + hasRight: new Uint8Array(N), + n: N, + }; + chart._catExtents = buckets; + } else { + buckets.n = N; + } + + // Initialize every per-cat slot to the empty extent. `Infinity` / + // `-Infinity` so that the first contributing bar wins on + // min/max comparisons. + for (let i = 0; i < N; i++) { + buckets.leftMin[i] = Infinity; + buckets.leftMax[i] = -Infinity; + buckets.rightMin[i] = Infinity; + buckets.rightMax[i] = -Infinity; + buckets.hasLeft[i] = 0; + buckets.hasRight[i] = 0; + } + + const bars = chart._bars; + const hidden = chart._hiddenSeries; + const catIdxArr = bars.catIdx; + const seriesIdArr = bars.seriesId; + const y0Arr = bars.y0; + const y1Arr = bars.y1; + const axisArr = bars.axis; + for (let i = 0; i < bars.count; i++) { + if (hidden.has(seriesIdArr[i])) { + continue; + } + + const ci = catIdxArr[i]; + if (ci < 0 || ci >= N) { + continue; + } + + const y0 = y0Arr[i]; + const y1 = y1Arr[i]; + const lo = y0 < y1 ? y0 : y1; + const hi = y0 < y1 ? y1 : y0; + if (axisArr[i] === 1) { + if (lo < buckets.rightMin[ci]) { + buckets.rightMin[ci] = lo; + } + + if (hi > buckets.rightMax[ci]) { + buckets.rightMax[ci] = hi; + } + + buckets.hasRight[ci] = 1; + } else { + if (lo < buckets.leftMin[ci]) { + buckets.leftMin[ci] = lo; + } + + if (hi > buckets.leftMax[ci]) { + buckets.leftMax[ci] = hi; + } + + buckets.hasLeft[ci] = 1; + } + } + + // Line / scatter glyphs route through `_samples`, not `_bars`, so + // fold their per-cat values in here. Bar / area series are already + // covered by the loop above (including non-stacking bar/area, which + // emit `_bars` records with `y0=0`, `y1=v`); line / scatter never + // stack, so the sample grid is their only contribution. + const samplingSeries = [chart._lineSeries, chart._scatterSeries]; + const samples = chart._samples; + const sampleValid = chart._sampleValid; + const S = chart._series.length; + for (const seriesArr of samplingSeries) { + for (const s of seriesArr) { + if (hidden.has(s.seriesId)) { + continue; + } + + const onRight = s.axis === 1; + const sid = s.seriesId; + for (let ci = 0; ci < N; ci++) { + const sampleIdx = ci * S + sid; + if (!((sampleValid[sampleIdx >> 3] >> (sampleIdx & 7)) & 1)) { + continue; + } + + const v = samples[sampleIdx]; + if (onRight) { + if (v < buckets.rightMin[ci]) { + buckets.rightMin[ci] = v; + } + + if (v > buckets.rightMax[ci]) { + buckets.rightMax[ci] = v; + } + + buckets.hasRight[ci] = 1; + } else { + if (v < buckets.leftMin[ci]) { + buckets.leftMin[ci] = v; + } + + if (v > buckets.leftMax[ci]) { + buckets.leftMax[ci] = v; + } + + buckets.hasLeft[ci] = 1; + } + } + } + } + + chart._catExtentsHidden = hidden; + return buckets; +} diff --git a/packages/viewer-charts/src/ts/charts/bar/chart-type.ts b/packages/viewer-charts/src/ts/charts/series/series-type.ts similarity index 81% rename from packages/viewer-charts/src/ts/charts/bar/chart-type.ts rename to packages/viewer-charts/src/ts/charts/series/series-type.ts index 956c54a9f8..3c8f6d0b40 100644 --- a/packages/viewer-charts/src/ts/charts/bar/chart-type.ts +++ b/packages/viewer-charts/src/ts/charts/series/series-type.ts @@ -19,13 +19,24 @@ export type ChartType = "bar" | "line" | "scatter" | "area"; * keys the Y-bar glyph router consumes. */ export interface ColumnChartConfig { - /** "Bar" | "Line" | "Scatter" | "Area" (case-insensitive). Invalid / missing → bar. */ + /** + * "Bar" | "Line" | "Scatter" | "Area" (case-insensitive). Invalid / missing → bar. + */ chart_type?: string; + /** * Explicit stack override. If omitted: bar / area stack by default, * line / scatter do not. */ stack?: boolean; + + /** + * Force this aggregate onto the secondary (right) Y axis, + * independent of `autoAltYAxis` and the dual-axis ratio + * heuristic. Missing / false → axis assignment is driven by + * `autoAltYAxis` alone. + */ + alt_axis?: boolean; } /** @@ -52,6 +63,7 @@ export function resolveChartType( ) { return raw; } + return fallback; } @@ -66,6 +78,22 @@ export function resolveStack( cfg: Record | undefined, ): boolean { const explicit = cfg?.[aggName]?.stack; - if (typeof explicit === "boolean") return explicit; + if (typeof explicit === "boolean") { + return explicit; + } + return chartType === "bar" || chartType === "area"; } + +/** + * Resolve whether a column is pinned to the secondary Y axis via + * `columns_config[aggName].alt_axis`. Independent of `autoAltYAxis`: + * when `true`, the per-column override forces axis 1 regardless of + * the auto-split heuristic. + */ +export function resolveAltAxis( + aggName: string, + cfg: Record | undefined, +): boolean { + return cfg?.[aggName]?.alt_axis === true; +} diff --git a/packages/viewer-charts/src/ts/charts/series/series.ts b/packages/viewer-charts/src/ts/charts/series/series.ts new file mode 100644 index 0000000000..63bba2faaf --- /dev/null +++ b/packages/viewer-charts/src/ts/charts/series/series.ts @@ -0,0 +1,794 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import type { ColumnDataMap } from "../../data/view-reader"; +import type { WebGLContextManager } from "../../webgl/context-manager"; +import type { ZoomConfig } from "../../interaction/zoom-controller"; +import { CategoricalYChart } from "../common/categorical-y-chart"; +import { type PlotRect } from "../../layout/plot-layout"; +import { type AxisDomain } from "../../axis/numeric-axis"; +import { + buildSeriesPipeline, + readBarRecord, + type SeriesChartRecord, + type NumericCategoryDomain, + type SeriesInfo, + type BarColumns, + emptyBarColumns, +} from "./series-build"; +import { + renderBarFrame, + uploadBarInstances, + invalidateGlyphBuffers, + rebuildGlyphBuffers, +} from "./series-render"; +import { + handleBarHover, + handleBarLegendClick, + showBarPinnedTooltip, + showBarPinnedTooltipForSample, +} from "./series-interact"; +import { resolvePalette } from "../../theme/palette"; +import { LineGlyph } from "./glyphs/draw-lines"; +import { ScatterGlyph } from "./glyphs/draw-scatter"; +import { AreaGlyph } from "./glyphs/draw-areas"; +import barVert from "../../shaders/bar.vert.glsl"; +import barFrag from "../../shaders/bar.frag.glsl"; + +/** + * Per-frame memo of the auto-fit value extent for a {@link SeriesChart}, + * keyed on the visible categorical window. Two axis slots (`left*` / + * `right*`) because dual-axis bar charts refit independently. + */ +export interface SeriesAutoFitCache { + catMin: number; + catMax: number; + hidden: Set; + leftMin: number; + leftMax: number; + hasLeft: boolean; + rightMin: number; + rightMax: number; + hasRight: boolean; +} + +export interface CachedLocations { + u_proj_left: WebGLUniformLocation | null; + u_proj_right: WebGLUniformLocation | null; + u_hover_series: WebGLUniformLocation | null; + u_horizontal: WebGLUniformLocation | null; + a_corner: number; + a_x_center: number; + a_half_width: number; + a_y0: number; + a_y1: number; + a_color: number; + a_series_id: number; + a_axis: number; +} + +/** + * Bar chart. Fields are package-internal (no `private`) so helper modules + * in this folder can read/write them. + * + * Orientation: vertical (Y Bar) is the default — categorical X, numeric + * Y. When `_isHorizontal` is true (X Bar) the roles swap: numeric X, + * categorical Y reading top-to-bottom. The data pipeline + instance + * attributes stay in *logical* coordinates (xCenter = category center, + * y0/y1 = value extent); the swap happens in three places only: + * 1. Projection matrix (`bar-render.ts`) — args reordered, Y flipped. + * 2. Vertex shader — `u_horizontal` uniform transposes position. + * 3. Chrome (`bar-axis.ts`) — categorical axis moves from bottom to + * left; numeric axis from left to bottom. + * Hit-testing reads the swapped pixel→data mapping via the projected + * `PlotLayout`, so its logical comparisons don't need changes. + */ +export class SeriesChart extends CategoricalYChart { + readonly _isHorizontal: boolean; + + constructor(orientation: "vertical" | "horizontal" = "vertical") { + super(); + this._isHorizontal = orientation === "horizontal"; + } + + /** + * Lock the categorical axis — scrolling through category indices + * isn't meaningful, and the layout code assumes all categories are + * always present. The value axis stays freely zoomable. + */ + protected override getZoomConfig(): ZoomConfig { + return { lockAxis: this._isHorizontal ? "x" : "y" }; + } + + _locations: CachedLocations | null = null; + + // Series-specific categorical-axis bookkeeping. `_rowPaths`, + // `_numCategories`, `_rowOffset`, `_program`, `_cornerBuffer`, + // `_lastLayout`, `_lastXDomain`, `_lastYDomain`, `_lastYTicks`, and + // `_autoFitValue` all live on `CategoricalYChart`. + _aggregates: string[] = []; + _splitPrefixes: string[] = []; + _series: SeriesInfo[] = []; + + /** + * Columnar bar/area record storage. Indexed by bar slot in + * `[0, _bars.count)`. Replaces the legacy `SeriesChartRecord[]` to + * avoid per-record POJO allocation on data load. + */ + _bars: BarColumns = emptyBarColumns(); + + /** + * Pre-partitioned series indices by glyph type — populated at the end + * of `uploadAndRender` and reused across frames. Eliminates per-glyph + * `chart._series.filter(...)` allocations in the render loop. Each + * holds the full list of that type (including hidden series); the + * draw paths still skip hidden via `_hiddenSeries` lookup. + */ + _barSeries: SeriesInfo[] = []; + _lineSeries: SeriesInfo[] = []; + _scatterSeries: SeriesInfo[] = []; + _areaSeries: SeriesInfo[] = []; + + /** + * Cached primary / secondary axis labels — `_series.filter().map(). + * dedupe().join()` per axis, recomputed only on series-set change. + */ + _primaryValueLabel = ""; + _altValueLabel = ""; + + /** + * (seriesId * 1e9 + catIdx) → bar-record index in `_bars`. Built once + * per pipeline run for area-strip lookups; rebuilt on hidden-toggle + * is unnecessary because the index keys don't depend on hidden state. + */ + _areaBarIndex: Map | null = null; + + /** + * Cached Y-color buffer state for `uploadBarColors` short-circuit. + * `_lastUploadedColors` mirrors the bytes last shipped to the GPU; + * `uploadBarColors` skips when the new buffer matches byte-for-byte. + * Reset (set to `null`) on data load or palette change. + */ + _lastUploadedColors: Float32Array | null = null; + + /** + * Cached palette + identity-keys for short-circuiting per-frame + * resolution. Inputs (`seriesPalette` ref, `gradientStops` ref, + * `series.length`) only change on data load or `restyle()`. + */ + _paletteCache: [number, number, number][] | null = null; + _paletteCacheKey: { + seriesPalette: [number, number, number][] | null; + gradientStops: unknown; + seriesLength: number; + } | null = null; + + /** + * Reusable scratch for the build pipeline — keeps the stack ladder + * `Float64Array(N*M)` capacity hot across data reloads. The pipeline + * resizes if the new build's footprint exceeds capacity. + */ + _posStackScratch: Float64Array | null = null; + _negStackScratch: Float64Array | null = null; + _leftDomain: { min: number; max: number } = { min: 0, max: 1 }; + _rightDomain: { min: number; max: number } | null = null; + _hasRightAxis = false; + + /** + * `domain_mode: "expand"` accumulators. Hold the running union of + * every prior build's value-axis (and, in numeric-category mode, + * category-axis) extent for as long as the option is active. + * Cleared in `resetExpandedDomain` — wired from the worker's + * `resetAllZooms` and from view-config mutations on the base + * class. `null` whenever the option is `"fit"` or the accumulator + * has just been cleared; the next build re-seeds. + */ + _expandedLeftDomain: { min: number; max: number } | null = null; + _expandedRightDomain: { min: number; max: number } | null = null; + _expandedCategoryDomain: { min: number; max: number } | null = null; + + /** + * Numeric category-axis state. Populated only when `group_by` has + * exactly one level and that level is `date | datetime | integer | + * float` (boolean → category). When set, `_bars[].xCenter` lives in + * real data units (not logical category indices), and the + * categorical-side axis renders as a numeric axis instead of the + * stringified-category one. + */ + _categoryAxisMode: "category" | "numeric" = "category"; + _numericCategoryDomain: NumericCategoryDomain | null = null; + + /** + * Origin used to rebase category positions before f32 narrowing. + * Datetime numeric category axes carry ~1.7e12-magnitude values + * which f32 cannot represent below ~256ms; the GPU buffers store + * `(xCenter - _categoryOrigin)` and the projection matrix is built + * with the same origin so its `tx` term stays small. Leftover + * absolute coords are still available via `_numericCategoryDomain` + * for axis-tick formatting and `dataToPixel`. `0` in category mode + * (where positions are small integer indices) and in non-datetime + * numeric modes (integer / float categories also fit in f32). + */ + _categoryOrigin = 0; + + /** + * Cached numeric category-axis ticks for the last frame. + */ + _lastCatTicks: number[] | null = null; + + /** + * Per-category X coordinate in real data units (numeric axis mode + * only). `null` in category mode — line/scatter/area glyphs fall + * back to using `catIdx` directly as the X coordinate. + */ + _categoryPositions: Float64Array | null = null; + + _hiddenSeries: Set = new Set(); + _hoveredBarIdx = -1; + _pinnedBarIdx = -1; + + /** + * Synthetic bar record for hover hits on line / scatter glyphs that + * don't have a real `BarRecord` in `_bars`. At most one of + * `_hoveredBarIdx` and `_hoveredSample` is populated per frame; see + * {@link ./bar-interact.getHoveredBar}. + */ + _hoveredSample: SeriesChartRecord | null = null; + + // Unstacked sample grid produced by buildBarPipeline: samples[catI * S + seriesId]. + _samples: Float32Array = new Float32Array(0); + _sampleValid: Uint8Array = new Uint8Array(0); + + /** + * Typed glyph composition. Each glyph (line / scatter / area) owns + * its program cache and persistent vertex buffers privately; the + * chart routes draw / rebuild / invalidate via `_glyphs`. Bar + * glyph state lives on the chart directly (shared bar program + + * `_locations` + buffer pool), so it's a free function rather than + * a class. + */ + readonly _glyphs = { + lines: new LineGlyph(), + scatter: new ScatterGlyph(), + areas: new AreaGlyph(), + } as const; + + // Dual-axis bar charts keep a secondary Y-axis domain + ticks for + // the right-side axis chrome. + _lastAltYDomain: AxisDomain | null = null; + _lastAltYTicks: number[] | null = null; + + _uploadedBars = 0; + + /** + * Bar-record indices uploaded to the instance buffers, in dispatch + * order. `_uploadedBars` is the active prefix length; the trailing + * capacity is reused across data reloads / legend toggles. + */ + _visibleBarIndices: Int32Array = new Int32Array(0); + + _legendRects: { seriesId: number; rect: PlotRect }[] = []; + + /** + * Cached legend layout — recomputed only on series-set / palette / + * hidden-set / theme change. Frame-rate redraws read from this + * directly; otherwise `ctx.measureText` would run per series each + * frame. `null` flags an invalidation; `_legendRects` is rebuilt + * lazily on the next chrome pass. + */ + _legendCacheValid = false; + + /** + * Per-frame memo of the auto-fit value extent keyed on the visible + * categorical window. Two comparisons per hit → no walk. Reset to + * null on any mutation that would change the outcome (data reload, + * legend toggle). + * + * Two axis slots because dual-axis bar charts refit left and right + * independently. + * + * TODO(perf): when the visible window shrinks from a large N, the + * linear walk over `_bars` dominates for N > ~100K. `_bars` is + * already ordered by `catIdx`, so a binary-search pair to find the + * visible slice drops this to O(log N + K_visible). Deferred until + * profiling shows the walk in the hot path — current scale caps + * keep it below 1% of frame time. + */ + _autoFitCache: SeriesAutoFitCache | null = null; + + /** + * Per-category extent buckets. Built once per data load (and + * rebuilt when `_hiddenSeries` changes), then read per-frame by + * `computeVisibleValueExtent` to compute the auto-fit window over + * the visible cat range in O(visibleCats) instead of + * O(`bars.count`). Capacity reused across builds via + * length-checked grow. + * + * Memory: 4 × Float64 + 2 × Uint8 = 34 bytes per category. For + * typical N (≤ 1000 cats) this is < 35 KB; for high-cardinality + * N = 100k it's 3.4 MB. Acceptable trade for eliminating the + * O(N×M×P) per-frame walk during pan/zoom animations. + */ + _catExtents: { + leftMin: Float64Array; + leftMax: Float64Array; + rightMin: Float64Array; + rightMax: Float64Array; + hasLeft: Uint8Array; + hasRight: Uint8Array; + n: number; + } | null = null; + + /** + * Identity of the `_hiddenSeries` set baked into `_catExtents`. + * Pointer-compares to detect legend-toggle invalidations. + */ + _catExtentsHidden: Set | null = null; + + protected override tooltipCallbacks() { + return { + onHover: (mx: number, my: number) => handleBarHover(this, mx, my), + onLeave: () => { + if (this._hoveredBarIdx !== -1 || this._hoveredSample) { + this._hoveredBarIdx = -1; + this._hoveredSample = null; + if (this._glManager) { + renderBarFrame(this, this._glManager); + } + } + }, + onClickPre: (mx: number, my: number) => + handleBarLegendClick(this, mx, my), + onPin: (mx: number, my: number) => { + // Refresh the hit-test at the click coords directly: + // `dispatchHover` is RAF-throttled in the worker, so a + // click that follows a mousemove in the same task may + // arrive at `onPin` before the prior hover RAF has + // updated `_hoveredBarIdx`. Re-running the hit-test + // here makes the pin path independent of hover timing. + handleBarHover(this, mx, my); + if (this._hoveredBarIdx >= 0) { + const barIdx = this._hoveredBarIdx; + showBarPinnedTooltip(this, barIdx); + const rec = readBarRecord( + this._bars, + barIdx, + this._splitPrefixes.length, + this._samples, + this._series.length, + ); + void this._emitSeriesClickSelect(rec); + } else if (this._hoveredSample) { + const rec = this._hoveredSample; + showBarPinnedTooltipForSample(this, rec); + void this._emitSeriesClickSelect(rec); + } + }, + onUnpin: () => { + this.emitUnselect(); + }, + }; + } + + /** + * Resolve a clicked bar / point into a `PerspectiveClickDetail` + * (via `buildClickDetail`) and emit both + * `perspective-click` and `perspective-global-filter` to the host. + * + * `rowIdx` derivation: the series pipeline emits one record per + * (catIdx, agg, split) tuple, and a pivoted view has one view row + * per category — so `catIdx + _rowOffset` is the source-view row. + * `_aggregates[aggIdx]` is the *base* column name (no split + * prefix). Group-by values come from per-level `_rowPaths`, split-by + * values are recovered by splitting `_splitPrefixes[splitIdx]` on + * the `|` delimiter the engine uses for pivoted column names. + */ + private async _emitSeriesClickSelect(b: SeriesChartRecord): Promise { + if (!this._aggregates[b.aggIdx]) { + return; + } + + const groupByValues: (string | null)[] = this._rowPaths.map( + (level) => level.labels[b.catIdx] ?? null, + ); + const splitKey = this._splitPrefixes[b.splitIdx] ?? ""; + const splitByValues = + this._splitBy.length > 0 && splitKey !== "" + ? splitKey.split("|") + : []; + + await this.emitClickAndSelect({ + rowIdx: b.catIdx + this._rowOffset, + columnName: this._aggregates[b.aggIdx], + groupByValues, + splitByValues, + }); + } + + async uploadAndRender( + glManager: WebGLContextManager, + columns: ColumnDataMap, + startRow: number, + endRow: number, + ): Promise { + this._glManager = glManager; + const gl = glManager.gl; + + if (startRow !== 0) { + // Bar charts render a single consolidated pass — the viewer + // should not chunk this, but guard defensively. + return; + } + + if (!this._program) { + this._program = glManager.shaders.getOrCreate( + "bar", + barVert, + barFrag, + ); + const p = this._program; + this._locations = { + u_proj_left: gl.getUniformLocation(p, "u_proj_left"), + u_proj_right: gl.getUniformLocation(p, "u_proj_right"), + u_hover_series: gl.getUniformLocation(p, "u_hover_series"), + u_horizontal: gl.getUniformLocation(p, "u_horizontal"), + a_corner: gl.getAttribLocation(p, "a_corner"), + a_x_center: gl.getAttribLocation(p, "a_x_center"), + a_half_width: gl.getAttribLocation(p, "a_half_width"), + a_y0: gl.getAttribLocation(p, "a_y0"), + a_y1: gl.getAttribLocation(p, "a_y1"), + a_color: gl.getAttribLocation(p, "a_color"), + a_series_id: gl.getAttribLocation(p, "a_series_id"), + a_axis: gl.getAttribLocation(p, "a_axis"), + }; + + this._cornerBuffer = gl.createBuffer()!; + gl.bindBuffer(gl.ARRAY_BUFFER, this._cornerBuffer); + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]), + gl.STATIC_DRAW, + ); + } + + const result = buildSeriesPipeline({ + columns, + numRows: endRow, + columnSlots: this._columnSlots, + groupBy: this._groupBy, + splitBy: this._splitBy, + groupByTypes: this._groupByTypes, + columnsConfig: this._columnsConfig, + defaultChartType: this._defaultChartType as + | "bar" + | "line" + | "scatter" + | "area" + | undefined, + autoAltYAxis: this._pluginConfig.auto_alt_y_axis, + bandInnerFrac: this._pluginConfig.band_inner_frac, + barInnerPad: this._pluginConfig.bar_inner_pad, + includeZero: this._pluginConfig.include_zero, + scratchBars: this._bars, + scratchPosStack: this._posStackScratch, + scratchNegStack: this._negStackScratch, + }); + + // `domain_mode: "expand"` post-build union. Mutate the pipeline + // result struct in place so every downstream assignment below + // (`_leftDomain`, `_rightDomain`, `_numericCategoryDomain`, + // `_categoryOrigin`) automatically picks up the grown extent. + // `"fit"` (or a fresh reset) leaves the result untouched and + // clears the accumulators so the next toggle starts fresh. + if (this._pluginConfig.domain_mode === "expand") { + if (this._expandedLeftDomain) { + result.leftDomain.min = Math.min( + this._expandedLeftDomain.min, + result.leftDomain.min, + ); + result.leftDomain.max = Math.max( + this._expandedLeftDomain.max, + result.leftDomain.max, + ); + } + + this._expandedLeftDomain = { ...result.leftDomain }; + + if (result.rightDomain) { + if (this._expandedRightDomain) { + result.rightDomain.min = Math.min( + this._expandedRightDomain.min, + result.rightDomain.min, + ); + result.rightDomain.max = Math.max( + this._expandedRightDomain.max, + result.rightDomain.max, + ); + } + + this._expandedRightDomain = { ...result.rightDomain }; + } + + if (result.numericCategoryDomain) { + if (this._expandedCategoryDomain) { + result.numericCategoryDomain.min = Math.min( + this._expandedCategoryDomain.min, + result.numericCategoryDomain.min, + ); + result.numericCategoryDomain.max = Math.max( + this._expandedCategoryDomain.max, + result.numericCategoryDomain.max, + ); + } + + this._expandedCategoryDomain = { + min: result.numericCategoryDomain.min, + max: result.numericCategoryDomain.max, + }; + } + } else { + this._expandedLeftDomain = null; + this._expandedRightDomain = null; + this._expandedCategoryDomain = null; + } + + this._aggregates = result.aggregates; + this._splitPrefixes = result.splitPrefixes; + this._rowPaths = result.rowPaths; + this._numCategories = result.numCategories; + this._rowOffset = result.rowOffset; + this._categoryAxisMode = result.axisMode.mode; + this._numericCategoryDomain = result.numericCategoryDomain; + this._categoryPositions = result.categoryPositions; + + // Rebase origin for the category axis. Pin to the domain min so + // every bar/sample can be uploaded as `(xCenter - origin)` and + // the f32 GPU pipeline never sees the full ~1.7e12 timestamp. + // Non-numeric modes (categorical, no domain) leave origin at 0. + this._categoryOrigin = result.numericCategoryDomain?.min ?? 0; + this._series = result.series; + this._bars = result.bars; + this._posStackScratch = result.posStack; + this._negStackScratch = result.negStack; + this._samples = result.samples; + + // Pre-partition `_series` by glyph type once per build. Frame + // paths read these directly instead of `_series.filter(...)`. + // Single bucket-push pass over the source array — replaces + // four `Array.filter` allocations with in-place `length = 0` + // resets on the chart-owned arrays. Same total memory in + // steady state, but skips three array-header allocations and + // one redundant pass over `result.series` per data load. + this._barSeries.length = 0; + this._lineSeries.length = 0; + this._scatterSeries.length = 0; + this._areaSeries.length = 0; + for (const s of result.series) { + switch (s.chartType) { + case "bar": + this._barSeries.push(s); + break; + case "line": + this._lineSeries.push(s); + break; + case "scatter": + this._scatterSeries.push(s); + break; + case "area": + this._areaSeries.push(s); + break; + } + } + + // Cache the per-axis label string. Recomputing the dedupe-and- + // join per frame allocated four arrays + a string, all stable + // between data loads. + this._primaryValueLabel = uniqueAggLabels(result.series, 0); + this._altValueLabel = uniqueAggLabels(result.series, 1); + + // Pre-build the area-strip lookup index (seriesId * 1e9 + catIdx + // → bar slot). Legacy code rebuilt this every frame inside + // `drawAreas`. The index is derived purely from `_bars` and is + // valid for the lifetime of this build. + this._areaBarIndex = buildAreaBarIndex(this._bars); + + // New bar records invalidate downstream caches — auto-fit extent, + // legend layout (text widths can shift on series-set change), + // palette + color upload (palette length changes), and persistent + // glyph buffers (vertex data is rebuilt below). Also drop the + // per-category extent identity so the bucket rebuilds on + // next read. + this._autoFitCache = null; + this._legendCacheValid = false; + this._paletteCache = null; + this._paletteCacheKey = null; + this._catExtentsHidden = null; + this._lastUploadedColors = null; + this._sampleValid = result.sampleValid; + this._leftDomain = result.leftDomain; + this._rightDomain = result.rightDomain; + this._hasRightAxis = result.hasRightAxis; + + // Resolve the palette eagerly. Both `uploadBarInstances` (color + // attribute) and `rebuildGlyphBuffers` (per-series RGB capture) + // read `_series[i].color`, so the stamp has to happen first. + ensurePalette(this); + + uploadBarInstances(this, glManager); + + invalidateGlyphBuffers(this); + rebuildGlyphBuffers(this, glManager); + + await this.requestRender(glManager); + } + + _fullRender(glManager: WebGLContextManager): void { + if (!this._program) { + return; + } + + this._glManager = glManager; + renderBarFrame(this, glManager); + } + + override resetExpandedDomain(): void { + this._expandedLeftDomain = null; + this._expandedRightDomain = null; + this._expandedCategoryDomain = null; + } + + protected destroyInternal(): void { + if (this._glManager) { + const gl = this._glManager.gl; + if (this._cornerBuffer) { + gl.deleteBuffer(this._cornerBuffer); + } + + destroyGlyphBuffers(this); + } + + this._program = null; + this._locations = null; + this._cornerBuffer = null; + this._bars = emptyBarColumns(); + this._series = []; + this._barSeries = []; + this._lineSeries = []; + this._scatterSeries = []; + this._areaSeries = []; + this._areaBarIndex = null; + this._paletteCache = null; + this._paletteCacheKey = null; + this._lastUploadedColors = null; + this._posStackScratch = null; + this._negStackScratch = null; + this._rowPaths = []; + this._numCategories = 0; + this._hiddenSeries.clear(); + } +} + +/** + * Build the `(seriesId * 1e9 + catIdx) → bar-record-index` lookup for + * area glyphs. Areas read y0/y1 by (seriesId, catIdx) on every strip; + * legacy code rebuilt this map per frame from the bars list. Invariant: + * 1e9 is safe since category counts never approach it. + */ +function buildAreaBarIndex(bars: BarColumns): Map { + const m = new Map(); + for (let i = 0; i < bars.count; i++) { + if (bars.chartType[i] !== 1 /* AREA */) { + continue; + } + + m.set(bars.seriesId[i] * 1_000_000_000 + bars.catIdx[i], i); + } + + return m; +} + +/** + * Dedupe + join the aggregate names for series on a given axis. Stable + * across pan/zoom — caches on the chart so the legacy O(S²) `indexOf`- + * based dedupe doesn't run per frame. + */ +function uniqueAggLabels(series: SeriesInfo[], axis: 0 | 1): string { + const seen = new Set(); + const ordered: string[] = []; + for (const s of series) { + if (s.axis !== axis) { + continue; + } + + if (seen.has(s.aggName)) { + continue; + } + + seen.add(s.aggName); + ordered.push(s.aggName); + } + + return ordered.join(", "); +} + +/** + * Resolve the per-series palette and stamp it onto `_series[i].color`. + * Cached on `_paletteCache` keyed by reference identity of the theme + * inputs + series count — only `restyle()` (which clears `_paletteCache` + * via `invalidateTheme`) or a data load (which clears it explicitly) + * forces re-resolution. + * + * Returns true when the cache changed (caller invalidates color upload). + */ +export function ensurePalette(chart: SeriesChart): boolean { + const theme = chart._resolveTheme(); + const seriesPalette = theme.seriesPalette; + const gradientStops = theme.gradientStops; + const seriesLength = chart._series.length; + + const key = chart._paletteCacheKey; + if ( + chart._paletteCache && + key && + key.seriesPalette === seriesPalette && + key.gradientStops === gradientStops && + key.seriesLength === seriesLength + ) { + return false; + } + + const palette = resolvePaletteCached( + seriesPalette, + gradientStops, + seriesLength, + ); + chart._paletteCache = palette; + chart._paletteCacheKey = { seriesPalette, gradientStops, seriesLength }; + + for (let i = 0; i < chart._series.length; i++) { + chart._series[i].color = palette[i]; + } + + return true; +} + +/** + * Module-local indirection so `series.ts` can call into the palette + * resolver without pulling the entire `series-render.ts` import graph + * into its file scope. Re-exported through `series-render.ts`. + */ +function resolvePaletteCached( + seriesPalette: [number, number, number][], + gradientStops: import("../../theme/gradient").GradientStop[], + seriesLength: number, +): [number, number, number][] { + return resolvePalette(seriesPalette, gradientStops, seriesLength); +} + +/** + * Tear down per-glyph GPU resources. Each glyph instance owns its own + * program cache + persistent buffers and frees both in `destroy`. + */ +function destroyGlyphBuffers(chart: SeriesChart): void { + chart._glyphs.lines.destroy(chart); + chart._glyphs.scatter.destroy(chart); + chart._glyphs.areas.destroy(chart); +} + +/** + * Horizontal bar chart — numeric X, categorical Y. + */ +export class XBarChart extends SeriesChart { + constructor() { + super("horizontal"); + } +} diff --git a/packages/viewer-charts/src/ts/charts/sunburst/sunburst-interact.ts b/packages/viewer-charts/src/ts/charts/sunburst/sunburst-interact.ts index 2f823ca185..ee7fd7d4eb 100644 --- a/packages/viewer-charts/src/ts/charts/sunburst/sunburst-interact.ts +++ b/packages/viewer-charts/src/ts/charts/sunburst/sunburst-interact.ts @@ -11,31 +11,30 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import type { SunburstChart } from "./sunburst"; -import { NULL_NODE } from "../common/node-store"; +import { NULL_NODE, ancestorNames } from "../common/node-store"; import { rebuildBreadcrumbs } from "../common/tree-data"; -import { formatTickValue } from "../../layout/ticks"; import { renderSunburstFrame, renderSunburstChromeOverlay, + facetCenterForNode, } from "./sunburst-render"; -export interface SunburstBreadcrumbRegion { - nodeId: number; - x0: number; - y0: number; - x1: number; - y1: number; -} +export type { BreadcrumbRegion as SunburstBreadcrumbRegion } from "../common/tree-chrome"; interface FacetHitContext { centerX: number; centerY: number; drillRoot: number; - /** Pre-upload visible range for this facet; undefined in non-facet mode. */ + + /** + * Pre-upload visible range for this facet; undefined in non-facet mode. + */ range?: { start: number; end: number }; } -/** Resolve the facet under cursor; returns single-plot defaults outside facet mode. */ +/** + * Resolve the facet under cursor; returns single-plot defaults outside facet mode. + */ function facetUnderCursor( chart: SunburstChart, mx: number, @@ -48,6 +47,7 @@ function facetUnderCursor( drillRoot: chart._currentRootId, }; } + for (const facet of chart._facets) { // Post-upload `instanceStart` / `instanceCount` are scan-index // ranges into `_visibleNodeIds` — we need the *pre-upload* @@ -57,13 +57,17 @@ function facetUnderCursor( const dx = mx - facet.centerX; const dy = my - facet.centerY; const r = Math.sqrt(dx * dx + dy * dy); - if (r > facet.maxRadius + 4) continue; + if (r > facet.maxRadius + 4) { + continue; + } + return { centerX: facet.centerX, centerY: facet.centerY, drillRoot: facet.drillRoot, }; } + return null; } @@ -81,26 +85,39 @@ function isDescendantOf( const store = chart._nodeStore; let p = id; while (p !== NULL_NODE) { - if (p === anc) return true; + if (p === anc) { + return true; + } + p = store.parent[p]; } + return false; } -/** Convert `(mx, my)` to polar and find the containing visible arc. */ +/** + * Convert `(mx, my)` to polar and find the containing visible arc. + */ function polarHitTest(chart: SunburstChart, mx: number, my: number): number { const ctx = facetUnderCursor(chart, mx, my); - if (!ctx) return NULL_NODE; + if (!ctx) { + return NULL_NODE; + } + const store = chart._nodeStore; const ids = chart._visibleNodeIds; const n = chart._visibleNodeCount; - if (!ids) return NULL_NODE; + if (!ids) { + return NULL_NODE; + } const dx = mx - ctx.centerX; const dy = my - ctx.centerY; const r = Math.sqrt(dx * dx + dy * dy); let theta = Math.atan2(dy, dx); - if (theta < 0) theta += 2 * Math.PI; + if (theta < 0) { + theta += 2 * Math.PI; + } // Center-circle hit — drill-up target. if (r < store.r1[chart._rootId] + 0.001) { @@ -112,16 +129,29 @@ function polarHitTest(chart: SunburstChart, mx: number, my: number): number { const faceted = chart._facets.length > 0; for (let i = 0; i < n; i++) { const id = ids[i]; - if (id === ctx.drillRoot) continue; - if (faceted && !isDescendantOf(chart, id, ctx.drillRoot)) continue; + if (id === ctx.drillRoot) { + continue; + } + + if (faceted && !isDescendantOf(chart, id, ctx.drillRoot)) { + continue; + } + const a0 = store.a0[id]; const a1 = store.a1[id]; const r0 = store.r0[id]; const r1 = store.r1[id]; - if (r < r0 || r > r1) continue; - if (theta < a0 || theta > a1) continue; + if (r < r0 || r > r1) { + continue; + } + + if (theta < a0 || theta > a1) { + continue; + } + return id; } + return NULL_NODE; } @@ -130,7 +160,9 @@ export function handleSunburstHover( mx: number, my: number, ): void { - if (chart._pinnedNodeId !== NULL_NODE) return; + if (chart._pinnedNodeId !== NULL_NODE) { + return; + } // Breadcrumb region check first (they sit atop the chart area). for (const region of chart._breadcrumbRegions) { @@ -140,36 +172,37 @@ export function handleSunburstHover( my >= region.y0 && my <= region.y1 ) { - if (chart._glCanvas) chart._glCanvas.style.cursor = "pointer"; + chart._tooltip.setCursor("pointer"); if (chart._hoveredNodeId !== NULL_NODE) { chart._hoveredNodeId = NULL_NODE; renderSunburstChromeOverlay(chart); } + return; } } const hit = polarHitTest(chart, mx, my); const store = chart._nodeStore; - if (chart._glCanvas) { - chart._glCanvas.style.cursor = - hit !== NULL_NODE && store.firstChild[hit] !== NULL_NODE - ? "pointer" - : "default"; - } + chart._tooltip.setCursor( + hit !== NULL_NODE && store.firstChild[hit] !== NULL_NODE + ? "pointer" + : "default", + ); if (hit !== chart._hoveredNodeId) { chart._hoveredNodeId = hit; - chart._hoveredTooltipLines = null; - chart._hoveredTooltipNodeId = hit; - const serial = ++chart._hoveredTooltipSerial; if (hit !== NULL_NODE) { + const serial = chart._lazyTooltip.beginHover(hit); buildSunburstTooltipLines(chart, hit).then((lines) => { - if (serial !== chart._hoveredTooltipSerial) return; - chart._hoveredTooltipLines = lines; - renderSunburstChromeOverlay(chart); + if (chart._lazyTooltip.commitHover(serial, lines)) { + renderSunburstChromeOverlay(chart); + } }); + } else { + chart._lazyTooltip.clearHover(); } + renderSunburstChromeOverlay(chart); } } @@ -181,6 +214,7 @@ export function handleSunburstClick( ): void { if (chart._pinnedNodeId !== NULL_NODE) { dismissSunburstPinnedTooltip(chart); + chart.emitUnselect(); return; } @@ -194,7 +228,9 @@ export function handleSunburstClick( ) { if (region.nodeId !== chart._currentRootId) { drillTo(chart, region.nodeId); + chart.emitUnselect(); } + return; } } @@ -210,30 +246,74 @@ export function handleSunburstClick( const parent = store.parent[ctx.drillRoot]; if (parent !== NULL_NODE && parent !== chart._rootId) { drillTo(chart, parent); + chart.emitUnselect(); } else if (chart._facets.length > 0) { // Already at the facet root: reset this facet's drill. const facet = chart._facets.find( (f) => f.drillRoot === ctx.drillRoot, ); - if (facet) chart._facetDrillRoots.delete(facet.label); + if (facet) { + chart._facetDrillRoots.delete(facet.label); + chart.emitUnselect(); + } + if (chart._glManager) { renderSunburstFrame(chart, chart._glManager); } } + return; } } const hit = polarHitTest(chart, mx, my); - if (hit === NULL_NODE) return; + if (hit === NULL_NODE) { + return; + } if (store.firstChild[hit] !== NULL_NODE) { drillTo(chart, hit); + void emitSunburstNodeEvent(chart, hit, "branch"); } else { showSunburstPinnedTooltip(chart, hit); + void emitSunburstNodeEvent(chart, hit, "leaf"); } } +/** + * Counterpart to `emitTreemapNodeEvent` for sunburst. Same path-walk + * semantics — split-by prefix in faceted mode, group-by levels + * afterward, leaf row idx from `_nodeStore.leafRowIdx`. + */ +async function emitSunburstNodeEvent( + chart: SunburstChart, + nodeId: number, + kind: "leaf" | "branch", +): Promise { + const store = chart._nodeStore; + const path = ancestorNames(store, nodeId); + const isFaceted = + chart._splitBy.length > 0 && chart._facetConfig.facet_mode === "grid"; + const splitByValues: (string | null)[] = isFaceted + ? path.slice(0, chart._splitBy.length) + : []; + const groupByValues: (string | null)[] = isFaceted + ? path.slice( + chart._splitBy.length, + chart._splitBy.length + chart._groupBy.length, + ) + : path.slice(0, chart._groupBy.length); + + const rowIdx = kind === "leaf" ? (store.leafRowIdx[nodeId] ?? null) : null; + + await chart.emitClickAndSelect({ + rowIdx: rowIdx != null && rowIdx >= 0 ? rowIdx : null, + columnName: chart._sizeName, + groupByValues, + splitByValues, + }); +} + /** * Drill the clicked facet (or the whole chart in non-facet mode). * Faceted drill walks up to the facet root (top-level child of @@ -247,73 +327,59 @@ function drillTo(chart: SunburstChart, nodeId: number): void { while (p !== NULL_NODE && store.parent[p] !== chart._rootId) { p = store.parent[p]; } + if (p !== NULL_NODE) { chart._facetDrillRoots.set(store.name[p], nodeId); } + chart._hoveredNodeId = NULL_NODE; - if (chart._glManager) renderSunburstFrame(chart, chart._glManager); + if (chart._glManager) { + renderSunburstFrame(chart, chart._glManager); + } + return; } + chart._currentRootId = nodeId; rebuildBreadcrumbs(chart, nodeId); chart._hoveredNodeId = NULL_NODE; - if (chart._glManager) renderSunburstFrame(chart, chart._glManager); + if (chart._glManager) { + renderSunburstFrame(chart, chart._glManager); + } } export function showSunburstPinnedTooltip( chart: SunburstChart, nodeId: number, ): void { - chart._tooltip.dismissPinned(); + chart._tooltip.dismiss(); chart._pinnedNodeId = nodeId; - const parent = chart._glCanvas?.parentElement; - if (!parent) return; - const store = chart._nodeStore; const midA = (store.a0[nodeId] + store.a1[nodeId]) / 2; const midR = (store.r0[nodeId] + store.r1[nodeId]) / 2; - // In faceted mode resolve which facet owns this node so the - // tooltip anchors to the correct sub-chart's center. - let anchorX = chart._centerX; - let anchorY = chart._centerY; - if (chart._facets.length > 0) { - for (const facet of chart._facets) { - let p = nodeId; - let owned = false; - while (p !== NULL_NODE) { - if (p === facet.drillRoot) { - owned = true; - break; - } - p = store.parent[p]; - } - if (owned) { - anchorX = facet.centerX; - anchorY = facet.centerY; - break; - } - } - } - const cx = anchorX + Math.cos(midA) * midR; - const cy = anchorY + Math.sin(midA) * midR; + const { centerX, centerY } = facetCenterForNode(chart, nodeId); + const cx = centerX + Math.cos(midA) * midR; + const cy = centerY + Math.sin(midA) * midR; - const dpr = window.devicePixelRatio || 1; - const cssWidth = (chart._glCanvas?.width || 100) / dpr; - const cssHeight = (chart._glCanvas?.height || 100) / dpr; + // CSS bounds: prefer `glManager` (works in both local and worker + // modes, since the worker constructs its own context manager). + const cssWidth = chart._glManager?.cssWidth ?? 0; + const cssHeight = chart._glManager?.cssHeight ?? 0; // Tooltip columns are fetched lazily from the view — the tree // itself only retains ancestor names + aggregated value + color. // Stale resolutions are discarded via the `_pinnedNodeId` check. buildSunburstTooltipLines(chart, nodeId).then((lines) => { - if (chart._pinnedNodeId !== nodeId) return; - if (lines.length === 0) return; - chart._tooltip.showPinned( - parent, - lines, - { px: cx, py: cy }, - { cssWidth, cssHeight }, - ); + if (chart._pinnedNodeId !== nodeId) { + return; + } + + if (lines.length === 0) { + return; + } + + chart._tooltip.pin(lines, { px: cx, py: cy }, { cssWidth, cssHeight }); }); chart._hoveredNodeId = NULL_NODE; @@ -321,7 +387,7 @@ export function showSunburstPinnedTooltip( } export function dismissSunburstPinnedTooltip(chart: SunburstChart): void { - chart._tooltip.dismissPinned(); + chart._tooltip.dismiss(); chart._pinnedNodeId = NULL_NODE; } @@ -339,6 +405,7 @@ export async function buildSunburstTooltipLines( pathNames.push(store.name[p]); p = store.parent[p]; } + pathNames.reverse(); if (pathNames.length > 0) { lines.push(pathNames.join(" › ")); @@ -346,13 +413,15 @@ export async function buildSunburstTooltipLines( lines.push(store.name[nodeId]); } - lines.push(`Value: ${formatTickValue(store.value[nodeId])}`); + const sizeFmt = chart.getColumnFormatter(chart._sizeName, "value"); + lines.push(`Value: ${sizeFmt(store.value[nodeId])}`); // Color value (numeric branch): stored on the node at insert // time, so it's always available without a view fetch. if (chart._colorName && !isNaN(store.colorValue[nodeId])) { + const colorFmt = chart.getColumnFormatter(chart._colorName, "value"); lines.push( - `${chart._colorName}: ${formatTickValue(store.colorValue[nodeId])}`, + `${chart._colorName}: ${colorFmt(store.colorValue[nodeId])}`, ); } @@ -365,12 +434,18 @@ export async function buildSunburstTooltipLines( if (isLeaf && chart._lazyRows) { const row = await chart._lazyRows.fetchRow(rowIdx); for (const [name, value] of row) { - if (value === null || value === undefined) continue; + if (value === null || value === undefined) { + continue; + } + if (name === chart._colorName && !isNaN(store.colorValue[nodeId])) { continue; } + if (typeof value === "number") { - lines.push(`${name}: ${formatTickValue(value)}`); + lines.push( + `${name}: ${chart.getColumnFormatter(name, "value")(value)}`, + ); } else { lines.push(`${name}: ${value}`); } diff --git a/packages/viewer-charts/src/ts/charts/sunburst/sunburst-layout.ts b/packages/viewer-charts/src/ts/charts/sunburst/sunburst-layout.ts index 6d0eb08ba6..a239009d0d 100644 --- a/packages/viewer-charts/src/ts/charts/sunburst/sunburst-layout.ts +++ b/packages/viewer-charts/src/ts/charts/sunburst/sunburst-layout.ts @@ -20,7 +20,9 @@ import { NULL_NODE, type NodeStore } from "../common/node-store"; */ const MIN_VISIBLE_ARC_AREA = 4; -/** Inner radius: reserved for the current-root drill-up target. */ +/** + * Inner radius: reserved for the current-root drill-up target. + */ const INNER_RING_PX = 30; /** @@ -38,7 +40,10 @@ function maxDepthBelow(store: NodeStore, currentRootId: number): number { sp--; const id = stack[sp]; const d = store.depth[id] - baseDepth; - if (d > maxDepth) maxDepth = d; + if (d > maxDepth) { + maxDepth = d; + } + for ( let c = store.firstChild[id]; c !== NULL_NODE; @@ -49,9 +54,11 @@ function maxDepthBelow(store: NodeStore, currentRootId: number): number { bigger.set(stack); stack = bigger; } + stack[sp++] = c; } } + return maxDepth; } @@ -100,7 +107,10 @@ function partitionChildren( baseDepth: number, ): void { const totalValue = store.value[parentId]; - if (totalValue <= 0) return; + if (totalValue <= 0) { + return; + } + const span = a1 - a0; let cursor = a0; @@ -110,7 +120,10 @@ function partitionChildren( c = store.nextSibling[c] ) { const v = store.value[c]; - if (v <= 0) continue; + if (v <= 0) { + continue; + } + const frac = v / totalValue; const childA0 = cursor; const childA1 = cursor + span * frac; @@ -130,7 +143,9 @@ function partitionChildren( const midR = (childR0 + childR1) / 2; const arcLen = arcSpan * midR; // pixel length along arc const area = arcLen * ringWidth; - if (area < MIN_VISIBLE_ARC_AREA) continue; + if (area < MIN_VISIBLE_ARC_AREA) { + continue; + } if (store.firstChild[c] !== NULL_NODE) { partitionChildren( @@ -181,6 +196,7 @@ export function collectVisibleArcsAppend( if (!chart._visibleNodeIds || chart._visibleNodeIds.length < store.count) { chart._visibleNodeIds = new Int32Array(store.count); } + const out = chart._visibleNodeIds; let outIdx = startOffset; @@ -191,7 +207,9 @@ export function collectVisibleArcsAppend( while (sp > 0) { sp--; const id = stack[sp]; - if (value[id] <= 0) continue; + if (value[id] <= 0) { + continue; + } out[outIdx++] = id; @@ -199,7 +217,9 @@ export function collectVisibleArcsAppend( const midR = (r0[id] + r1[id]) / 2; const arcLen = arcSpan * midR; const ringWidth = r1[id] - r0[id]; - if (arcLen * ringWidth < MIN_VISIBLE_ARC_AREA) continue; + if (arcLen * ringWidth < MIN_VISIBLE_ARC_AREA) { + continue; + } for (let c = firstChild[id]; c !== NULL_NODE; c = nextSibling[c]) { if (sp >= stack.length) { @@ -207,6 +227,7 @@ export function collectVisibleArcsAppend( bigger.set(stack); stack = bigger; } + stack[sp++] = c; } } diff --git a/packages/viewer-charts/src/ts/charts/sunburst/sunburst-render.ts b/packages/viewer-charts/src/ts/charts/sunburst/sunburst-render.ts index aa20328380..111fba712f 100644 --- a/packages/viewer-charts/src/ts/charts/sunburst/sunburst-render.ts +++ b/packages/viewer-charts/src/ts/charts/sunburst/sunburst-render.ts @@ -10,18 +10,15 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +import type { Context2D } from "../canvas-types"; import type { WebGLContextManager } from "../../webgl/context-manager"; import type { SunburstChart } from "./sunburst"; import { NULL_NODE } from "../common/node-store"; -import { resolveTheme, readSeriesPalette } from "../../theme/theme"; import { resolvePalette, type Vec3 } from "../../theme/palette"; -import { - colorValueToT, - sampleGradient, - type GradientStop, -} from "../../theme/gradient"; -import { renderLegend, renderCategoricalLegend } from "../../chrome/legend"; +import { type GradientStop } from "../../theme/gradient"; +import { renderLegend, renderCategoricalLegend } from "../../axis/legend"; import { PlotLayout } from "../../layout/plot-layout"; +import { leafColor, leafRGBA, luminance } from "../common/leaf-color"; import arcVert from "../../shaders/sunburst-arc.vert.glsl"; import arcFrag from "../../shaders/sunburst-arc.frag.glsl"; import { getInstancing } from "../../webgl/instanced-attrs"; @@ -32,7 +29,12 @@ import { INNER_RING_PX, } from "./sunburst-layout"; import { buildFacetGrid } from "../../layout/facet-grid"; -import { renderCategoricalLegendAt } from "../../chrome/legend"; +import { renderCategoricalLegendAt } from "../../axis/legend"; +import { withChromeCache } from "../common/chrome-cache"; +import { + renderBreadcrumbs as renderTreeBreadcrumbs, + renderTreeTooltip, +} from "../common/tree-chrome"; /** * Triangle-strip template resolution. `N_STEPS` angular samples × 2 @@ -44,67 +46,57 @@ const N_STEPS = 32; const BREADCRUMB_H = 28; const LEGEND_W = 90; -function luminance(r: number, g: number, b: number): number { - return 0.299 * r + 0.587 * g + 0.114 * b; -} - -function sampleRGB(stops: GradientStop[], t: number): [number, number, number] { - const c = sampleGradient(stops, t); - return [c[0], c[1], c[2]]; -} - -function leafColor( +/** + * Resolve the `(centerX, centerY)` of the facet that owns `nodeId`. + * Walks the ancestor chain and matches against each facet's + * `drillRoot`; returns `chart._centerX/_centerY` in non-faceted mode + * or as a defensive fallback. Used by every chrome path that needs to + * place geometry around an arc — labels, hover highlight, hover + * tooltip, pinned tooltip — so all four agree on which facet owns the + * node. The chart-wide `_centerX/_centerY` fields are + * `layoutFacetedSunburst`'s legacy first-facet publication and are + * not safe for these calls. + */ +export function facetCenterForNode( chart: SunburstChart, nodeId: number, - stops: GradientStop[], - palette: Vec3[], -): [number, number, number] { +): { centerX: number; centerY: number } { + if (chart._facets.length === 0) { + return { centerX: chart._centerX, centerY: chart._centerY }; + } + const store = chart._nodeStore; - const colorValue = store.colorValue[nodeId]; - if ( - chart._colorMode === "numeric" && - !isNaN(colorValue) && - chart._colorMax > chart._colorMin - ) { - return sampleRGB( - stops, - colorValueToT(colorValue, chart._colorMin, chart._colorMax), - ); + for (const facet of chart._facets) { + let p = nodeId; + while (p !== NULL_NODE) { + if (p === facet.drillRoot) { + return { centerX: facet.centerX, centerY: facet.centerY }; + } + + p = store.parent[p]; + } } - const idx = chart._uniqueColorLabels.get(store.colorLabel[nodeId]) ?? 0; - return palette[idx % palette.length] ?? [0, 0, 0]; + + return { centerX: chart._centerX, centerY: chart._centerY }; } /** - * `leafColor` + alpha: arcs whose source-row size was negative dim - * to `negativeAlpha` so they read as "magnitude with inverse sign" - * rather than disappearing. Mirrors the treemap helper. + * Full-frame render: layout → WebGL arcs → chrome overlay. */ -function leafRGBA( - chart: SunburstChart, - nodeId: number, - stops: GradientStop[], - palette: Vec3[], - negativeAlpha: number, -): [number, number, number, number] { - const rgb = leafColor(chart, nodeId, stops, palette); - const alpha = chart._nodeStore.sizeSign[nodeId] < 0 ? negativeAlpha : 1.0; - return [rgb[0], rgb[1], rgb[2], alpha]; -} - -/** Full-frame render: layout → WebGL arcs → chrome overlay. */ export function renderSunburstFrame( chart: SunburstChart, glManager: WebGLContextManager, ): void { - if (chart._currentRootId === NULL_NODE) return; + if (chart._currentRootId === NULL_NODE) { + return; + } const gl = glManager.gl; - const cssWidth = (gl.canvas as HTMLCanvasElement).getBoundingClientRect() - .width; - const cssHeight = (gl.canvas as HTMLCanvasElement).getBoundingClientRect() - .height; - if (cssWidth <= 0 || cssHeight <= 0) return; + const cssWidth = glManager.cssWidth; + const cssHeight = glManager.cssHeight; + if (cssWidth <= 0 || cssHeight <= 0) { + return; + } const hasSplits = chart._splitBy.length > 0 && chart._facetConfig.facet_mode === "grid"; @@ -138,17 +130,16 @@ export function renderSunburstFrame( ensureProgram(chart, glManager); - const themeEl = chart._gridlineCanvas || chart._chromeCanvas!; - const theme = resolveTheme(themeEl); + const theme = chart._resolveTheme(); const stops = theme.gradientStops; const palette = resolvePalette( - readSeriesPalette(themeEl), + theme.seriesPalette, stops, Math.max(1, chart._uniqueColorLabels.size), ); if (chart._gridlineCanvas) { - const gCtx = chart._gridlineCanvas.getContext("2d"); + const gCtx = chart._gridlineCanvas.getContext("2d") as Context2D | null; if (gCtx) { gCtx.clearRect( 0, @@ -159,10 +150,9 @@ export function renderSunburstFrame( } } + const dpr = glManager.dpr; chart._chromeCacheDirty = true; - uploadArcInstances(chart, gl, stops, palette, theme.areaOpacity); - - const dpr = window.devicePixelRatio || 1; + uploadArcInstances(chart, gl, stops, palette, theme.areaOpacity, dpr); gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT); gl.enable(gl.BLEND); @@ -170,11 +160,7 @@ export function renderSunburstFrame( gl.useProgram(chart._program!); const loc = chart._locations!; - gl.uniform2f( - loc.u_resolution, - (gl.canvas as HTMLCanvasElement).width, - (gl.canvas as HTMLCanvasElement).height, - ); + gl.uniform2f(loc.u_resolution, gl.canvas.width, gl.canvas.height); gl.uniform1f(loc.u_border_px, theme.sunburstGapPx * dpr); if (chart._facets.length > 0) { @@ -182,7 +168,10 @@ export function renderSunburstFrame( // and instance range. Instance attribs are rebound per facet so // instance 0 of each dispatch is the facet's first arc. for (const facet of chart._facets) { - if (facet.instanceCount === 0) continue; + if (facet.instanceCount === 0) { + continue; + } + gl.uniform2f( loc.u_center, facet.centerX * dpr, @@ -224,7 +213,10 @@ function layoutFacetedSunburst( c !== NULL_NODE; c = store.nextSibling[c] ) { - if (store.value[c] <= 0) continue; + if (store.value[c] <= 0) { + continue; + } + facetIds.push(c); labels.push(store.name[c]); } @@ -234,6 +226,7 @@ function layoutFacetedSunburst( cssWidth: gridWidth, cssHeight, hasLegend: false, + // Sunburst has no X/Y axes — no per-cell gutter reservation. xAxis: "none", yAxis: "none", @@ -246,7 +239,10 @@ function layoutFacetedSunburst( for (let i = 0; i < facetIds.length; i++) { const facetId = facetIds[i]; const cell = grid.cells[i]; - if (!cell) continue; + if (!cell) { + continue; + } + const label = store.name[facetId]; const drillRoot = chart._facetDrillRoots.get(label) ?? facetId; const plot = cell.layout.plotRect; @@ -270,9 +266,12 @@ function layoutFacetedSunburst( drillRoot, instanceStart, instanceCount, + nodeStart: instanceStart, + nodeCount: instanceCount, }); outIdx = nextIdx; } + chart._visibleNodeCount = outIdx; chart._facets = facets; @@ -290,7 +289,10 @@ function ensureProgram( chart: SunburstChart, glManager: WebGLContextManager, ): void { - if (chart._program) return; + if (chart._program) { + return; + } + const gl = glManager.gl; const prog = glManager.shaders.getOrCreate( "sunburst-arc", @@ -321,6 +323,7 @@ function ensureProgram( template[o + 2] = t; template[o + 3] = 1; // outer } + chart._stripBuffer = gl.createBuffer()!; gl.bindBuffer(gl.ARRAY_BUFFER, chart._stripBuffer); gl.bufferData(gl.ARRAY_BUFFER, template, gl.STATIC_DRAW); @@ -334,10 +337,10 @@ function uploadArcInstances( stops: GradientStop[], palette: Vec3[], negativeAlpha: number, + dpr: number, ): void { const store = chart._nodeStore; const ids = chart._visibleNodeIds!; - const dpr = window.devicePixelRatio || 1; const faceted = chart._facets.length > 0; // Walk each facet's pre-upload visible range (instanceStart and @@ -359,12 +362,18 @@ function uploadArcInstances( const rangeStart = instance; for (let i = start; i < end; i++) { const id = ids[i]; - if (id === drillRoot) continue; + if (id === drillRoot) { + continue; + } + const a0 = store.a0[id]; const a1 = store.a1[id]; const r0 = store.r0[id]; const r1 = store.r1[id]; - if (a1 <= a0 || r1 <= r0) continue; + if (a1 <= a0 || r1 <= r0) { + continue; + } + const color = leafRGBA(chart, id, stops, palette, negativeAlpha); const o = instance * 8; data[o + 0] = a0; @@ -377,6 +386,7 @@ function uploadArcInstances( data[o + 7] = color[3]; instance++; } + return { rangeStart, rangeCount: instance - rangeStart }; }; @@ -421,7 +431,10 @@ function drawArcs( instanceStart: number, instanceCount: number, ): void { - if (instanceCount === 0) return; + if (instanceCount === 0) { + return; + } + const loc = chart._locations!; // Static strip: per-vertex (strip_t, side). @@ -489,69 +502,46 @@ function drawArcs( setDivisor(loc.a_color, 0); } -// ── Chrome overlay (Canvas2D) ──────────────────────────────────────────── +// Chrome overlay (Canvas2D) export function renderSunburstChromeOverlay(chart: SunburstChart): void { - if (!chart._chromeCanvas || chart._currentRootId === NULL_NODE) return; - - const canvas = chart._chromeCanvas; - const dpr = window.devicePixelRatio || 1; - - const domRect = canvas.getBoundingClientRect(); - const cssWidth = domRect.width; - const cssHeight = domRect.height; - const targetW = Math.round(cssWidth * dpr); - const targetH = Math.round(cssHeight * dpr); - if (canvas.width !== targetW || canvas.height !== targetH) { - canvas.width = targetW; - canvas.height = targetH; - chart._chromeCacheDirty = true; + if (!chart._chromeCanvas || chart._currentRootId === NULL_NODE) { + return; } - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - if (chart._chromeCacheDirty) { - chart._chromeCache?.close(); - chart._chromeCache = null; - chart._chromeCacheDirty = false; - // Bump gen so in-flight `createImageBitmap` calls from prior - // static draws see a mismatch and discard their snapshot. - const gen = ++chart._chromeCacheGen; - drawStaticChrome(chart, ctx, dpr, cssWidth, cssHeight); - - createImageBitmap(canvas).then((bmp) => { - if (chart._chromeCacheGen === gen) { - chart._chromeCache?.close(); - chart._chromeCache = bmp; - } else { - bmp.close(); - } - }); - } else if (chart._chromeCache) { - ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.drawImage(chart._chromeCache, 0, 0); + const glManager = chart._glManager; + if (!glManager) { + return; } - if (chart._hoveredNodeId !== NULL_NODE) { - ctx.save(); - ctx.scale(dpr, dpr); - renderHoverHighlight(ctx, chart, chart._hoveredNodeId); - renderSunburstTooltip( - chart, - ctx, - chart._hoveredNodeId, - cssWidth, - cssHeight, - resolveTheme(canvas).fontFamily, - ); - ctx.restore(); - } + const { dpr, cssWidth, cssHeight } = glManager; + + withChromeCache( + chart, + chart._chromeCanvas, + dpr, + cssWidth, + cssHeight, + (ctx) => drawStaticChrome(chart, ctx, dpr, cssWidth, cssHeight), + chart._hoveredNodeId !== NULL_NODE + ? (ctx) => { + renderHoverHighlight(ctx, chart, chart._hoveredNodeId); + renderSunburstTooltip( + chart, + ctx, + chart._hoveredNodeId, + cssWidth, + cssHeight, + chart._resolveTheme().fontFamily, + ); + } + : null, + ); } function drawStaticChrome( chart: SunburstChart, - ctx: CanvasRenderingContext2D, + ctx: Context2D, dpr: number, cssWidth: number, cssHeight: number, @@ -561,12 +551,11 @@ function drawStaticChrome( ctx.save(); ctx.scale(dpr, dpr); - const themeEl = chart._gridlineCanvas || canvas; - const theme = resolveTheme(themeEl); + const theme = chart._resolveTheme(); const { fontFamily, labelColor: textColor, tooltipBg } = theme; const stops = theme.gradientStops; const palette = resolvePalette( - readSeriesPalette(themeEl), + theme.seriesPalette, stops, Math.max(1, chart._uniqueColorLabels.size), ); @@ -574,20 +563,53 @@ function drawStaticChrome( const ids = chart._visibleNodeIds!; const n = chart._visibleNodeCount; const faceted = chart._facets.length > 0; - const drillRoots = faceted - ? new Set(chart._facets.map((f) => f.drillRoot)) - : null; - - // Arc labels — skip the facet's own drill root (its label is the - // center text / facet title, handled below). - for (let i = 0; i < n; i++) { - const id = ids[i]; - if (faceted) { - if (drillRoots!.has(id)) continue; - } else if (id === chart._currentRootId) { - continue; + + // Arc labels — skip each facet's own drill root (its label is the + // center text / facet title, handled below). In faceted mode, walk + // each facet's `nodeStart`/`nodeCount` range over `_visibleNodeIds` + // and rotate around that facet's `(centerX, centerY)`. Without the + // per-facet center, every label translates around the chart's + // `_centerX/_centerY`, which `layoutFacetedSunburst` publishes + // from facet 0 — the symptom is "all labels pile onto facet 0." + if (faceted) { + for (const facet of chart._facets) { + const end = facet.nodeStart + facet.nodeCount; + for (let i = facet.nodeStart; i < end; i++) { + const id = ids[i]; + if (id === facet.drillRoot) { + continue; + } + + renderArcLabel( + chart, + ctx, + id, + fontFamily, + stops, + palette, + facet.centerX, + facet.centerY, + ); + } + } + } else { + for (let i = 0; i < n; i++) { + const id = ids[i]; + if (id === chart._currentRootId) { + continue; + } + + renderArcLabel( + chart, + ctx, + id, + fontFamily, + stops, + palette, + chart._centerX, + chart._centerY, + ); } - renderArcLabel(chart, ctx, id, fontFamily, stops, palette); } // Inner drill-up circle(s). One per facet in faceted mode so each @@ -610,6 +632,7 @@ function drawStaticChrome( facet.centerX, facet.centerY, ); + // Facet title band above the arcs. if (chart._facetGrid) { const cell = chart._facetGrid.cells.find( @@ -643,7 +666,7 @@ function drawStaticChrome( // Breadcrumbs (non-facet only — per-facet drill is tracked through // the per-facet drill root's label, not a global breadcrumb trail). if (!faceted && chart._breadcrumbIds.length > 1) { - renderBreadcrumbs(chart, ctx, cssWidth, fontFamily, textColor); + renderTreeBreadcrumbs(chart, ctx, cssWidth, fontFamily, textColor); } // Legend. In faceted mode use the grid's explicit rect; otherwise @@ -658,6 +681,7 @@ function drawStaticChrome( chart._facetGrid.legendRect, chart._uniqueColorLabels, palette, + theme, ); } else if ( chart._colorMode === "numeric" && @@ -677,6 +701,8 @@ function drawStaticChrome( label: chart._colorName, }, stops, + theme, + chart.getColumnFormatter(chart._colorName, "value"), ); } } else if ( @@ -693,6 +719,7 @@ function drawStaticChrome( legendLayout, chart._uniqueColorLabels, palette, + theme, ); } else if ( chart._colorMode === "numeric" && @@ -712,6 +739,8 @@ function drawStaticChrome( label: chart._colorName, }, stops, + theme, + chart.getColumnFormatter(chart._colorName, "value"), ); } @@ -732,11 +761,13 @@ function drawStaticChrome( */ function renderArcLabel( chart: SunburstChart, - ctx: CanvasRenderingContext2D, + ctx: Context2D, nodeId: number, fontFamily: string, stops: GradientStop[], palette: Vec3[], + centerX: number, + centerY: number, ): void { const store = chart._nodeStore; const a0 = store.a0[nodeId]; @@ -750,10 +781,14 @@ function renderArcLabel( // Radial labels need enough ring-width for text length and enough // tangential space for font height. - if (ringWidth < 16 || arcLen < 8) return; + if (ringWidth < 16 || arcLen < 8) { + return; + } const fontSize = Math.min(11, Math.floor(arcLen * 0.7)); - if (fontSize < 7) return; + if (fontSize < 7) { + return; + } ctx.font = `${fontSize}px ${fontFamily}`; const name = store.name[nodeId]; @@ -768,12 +803,16 @@ function renderArcLabel( } } } - if (text.length < 2) return; + + if (text.length < 2) { + return; + } const midA = (a0 + a1) / 2; ctx.save(); - ctx.translate(chart._centerX, chart._centerY); + ctx.translate(centerX, centerY); + // Rotate so the local +x axis points outward along the radius // through the arc's midpoint. Text then runs along that axis. let rot = midA; @@ -781,6 +820,7 @@ function renderArcLabel( if (chart._labelRotation === "upright" && onLeftHalf) { rot += Math.PI; } + ctx.rotate(rot); // Pick label color by luminance of the arc's fill for contrast. @@ -799,56 +839,8 @@ function renderArcLabel( ctx.restore(); } -function renderBreadcrumbs( - chart: SunburstChart, - ctx: CanvasRenderingContext2D, - cssWidth: number, - fontFamily: string, - textColor: string, -): void { - chart._breadcrumbRegions = []; - const bgColor = resolveTheme(chart._chromeCanvas!).tooltipBg; - - ctx.fillStyle = bgColor; - ctx.fillRect(0, 0, cssWidth, 24); - - ctx.font = `11px ${fontFamily}`; - ctx.textAlign = "left"; - ctx.textBaseline = "middle"; - - let x = 8; - const y = 12; - const store = chart._nodeStore; - - for (let i = 0; i < chart._breadcrumbIds.length; i++) { - const crumbId = chart._breadcrumbIds[i]; - const isLast = i === chart._breadcrumbIds.length - 1; - const label = store.name[crumbId]; - - ctx.fillStyle = textColor; - ctx.font = isLast ? `11px ${fontFamily}` : `11px ${fontFamily}`; - const textW = ctx.measureText(label).width; - ctx.fillText(label, x, y); - - chart._breadcrumbRegions.push({ - nodeId: crumbId, - x0: x - 2, - y0: 0, - x1: x + textW + 2, - y1: 24, - }); - - x += textW; - if (!isLast) { - const sep = " › "; - ctx.fillText(sep, x, y); - x += ctx.measureText(sep).width; - } - } -} - function renderHoverHighlight( - ctx: CanvasRenderingContext2D, + ctx: Context2D, chart: SunburstChart, nodeId: number, ): void { @@ -857,70 +849,39 @@ function renderHoverHighlight( const a1 = store.a1[nodeId]; const r0 = store.r0[nodeId]; const r1 = store.r1[nodeId]; + const { centerX, centerY } = facetCenterForNode(chart, nodeId); ctx.strokeStyle = "rgba(255,255,255,0.9)"; ctx.lineWidth = 2; ctx.beginPath(); - ctx.arc(chart._centerX, chart._centerY, r1, a0, a1); - ctx.arc(chart._centerX, chart._centerY, r0, a1, a0, true); + ctx.arc(centerX, centerY, r1, a0, a1); + ctx.arc(centerX, centerY, r0, a1, a0, true); ctx.closePath(); ctx.stroke(); } function renderSunburstTooltip( chart: SunburstChart, - ctx: CanvasRenderingContext2D, + ctx: Context2D, nodeId: number, cssWidth: number, cssHeight: number, fontFamily: string, ): void { - const theme = resolveTheme(chart._chromeCanvas!); - const { tooltipBg, tooltipText, tooltipBorder } = theme; - - // Lines come from the async lazy tooltip fetch in - // `handleSunburstHover`; empty while in flight. - const lines = - chart._hoveredTooltipNodeId === nodeId - ? (chart._hoveredTooltipLines ?? []) - : []; - if (lines.length === 0) return; - - ctx.font = `11px ${fontFamily}`; - const lineHeight = 16; - const padding = 8; - let maxWidth = 0; - for (const line of lines) { - const w = ctx.measureText(line).width; - if (w > maxWidth) maxWidth = w; - } - const boxW = maxWidth + padding * 2; - const boxH = lines.length * lineHeight + padding * 2 - 4; - const store = chart._nodeStore; const midA = (store.a0[nodeId] + store.a1[nodeId]) / 2; const midR = (store.r0[nodeId] + store.r1[nodeId]) / 2; - const cx = chart._centerX + Math.cos(midA) * midR; - const cy = chart._centerY + Math.sin(midA) * midR; - let tx = cx + 12; - let ty = cy - boxH - 8; - if (tx + boxW > cssWidth) tx = cx - boxW - 12; - if (tx < 0) tx = 4; - if (ty < 0) ty = cy + 12; - if (ty + boxH > cssHeight) ty = cssHeight - boxH - 4; - - ctx.fillStyle = tooltipBg; - ctx.strokeStyle = tooltipBorder; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.roundRect(tx, ty, boxW, boxH, 4); - ctx.fill(); - ctx.stroke(); - - ctx.fillStyle = tooltipText; - ctx.textAlign = "left"; - ctx.textBaseline = "top"; - for (let i = 0; i < lines.length; i++) { - ctx.fillText(lines[i], tx + padding, ty + padding + i * lineHeight); - } + const { centerX, centerY } = facetCenterForNode(chart, nodeId); + const cx = centerX + Math.cos(midA) * midR; + const cy = centerY + Math.sin(midA) * midR; + renderTreeTooltip( + chart, + ctx, + nodeId, + cx, + cy, + cssWidth, + cssHeight, + fontFamily, + ); } diff --git a/packages/viewer-charts/src/ts/charts/sunburst/sunburst.ts b/packages/viewer-charts/src/ts/charts/sunburst/sunburst.ts index 82d8ede25e..137e12da19 100644 --- a/packages/viewer-charts/src/ts/charts/sunburst/sunburst.ts +++ b/packages/viewer-charts/src/ts/charts/sunburst/sunburst.ts @@ -26,7 +26,6 @@ import { import { handleSunburstHover, handleSunburstClick, - showSunburstPinnedTooltip, dismissSunburstPinnedTooltip, type SunburstBreadcrumbRegion, } from "./sunburst-interact"; @@ -48,8 +47,11 @@ export interface SunburstLocations { */ function firstNonMetadataColumn(columns: ColumnDataMap): string { for (const k of columns.keys()) { - if (!k.startsWith("__")) return k; + if (!k.startsWith("__")) { + return k; + } } + return ""; } @@ -70,7 +72,9 @@ export class SunburstChart extends TreeChartBase { _instanceBuffer: WebGLBuffer | null = null; _instanceCount = 0; - /** Label orientation mode — see class docstring. */ + /** + * Label orientation mode — see class docstring. + */ _labelRotation: "upright" | "radial" = "upright"; // Center / radius state resolved per frame. @@ -78,26 +82,40 @@ export class SunburstChart extends TreeChartBase { _centerY = 0; _maxRadius = 0; - // ── Interaction ────────────────────────────────────────────────────── + // Interaction _hoveredNodeId: number = NULL_NODE; _pinnedNodeId: number = NULL_NODE; _breadcrumbRegions: SunburstBreadcrumbRegion[] = []; _chromeCache: ImageBitmap | null = null; _chromeCacheDirty = true; - /** See `TreemapChart._chromeCacheGen` — same race, same fix. */ + + /** + * See `TreemapChart._chromeCacheGen` — same race, same fix. + */ _chromeCacheGen = 0; - // ── Faceted state ──────────────────────────────────────────────────── + // Faceted state _facetGrid: import("../../layout/facet-grid").FacetGrid | null = null; - /** Per-facet drill roots — mirrors `TreemapChart._facetDrillRoots`. */ + + /** + * Per-facet drill roots — mirrors `TreemapChart._facetDrillRoots`. + */ _facetDrillRoots: Map = new Map(); + /** * Per-facet rendering state. `index` matches the facet grid cell; * `centerX`, `centerY`, `maxRadius` are used for layout + hit test; * `drillRoot` is the sub-root the facet is currently showing; - * `instanceStart`, `instanceCount` index into the shared instance - * buffer for draw dispatch. + * `instanceStart`, `instanceCount` index into the shared GPU + * instance buffer for draw dispatch (these are post-skip values, + * rewritten by `uploadArcInstances` after zero-width arcs and the + * drill root are filtered out). `nodeStart`, `nodeCount` are the + * pre-skip range over `_visibleNodeIds` and are *not* rewritten — + * canvas chrome (arc-label translate origin) walks this range so + * each label can be placed around its own facet's center instead + * of the chart-wide `_centerX/_centerY` (which always point at the + * first facet). */ _facets: { label: string; @@ -107,12 +125,14 @@ export class SunburstChart extends TreeChartBase { drillRoot: number; instanceStart: number; instanceCount: number; + nodeStart: number; + nodeCount: number; }[] = []; - attachTooltip(glCanvas: HTMLCanvasElement): void { - this._glCanvas = glCanvas; - this._tooltip.attach(glCanvas, { - onHover: (mx, my) => handleSunburstHover(this, mx, my), + protected override tooltipCallbacks() { + return { + onHover: (mx: number, my: number) => + handleSunburstHover(this, mx, my), onLeave: () => { if ( this._hoveredNodeId !== NULL_NODE && @@ -122,24 +142,22 @@ export class SunburstChart extends TreeChartBase { renderSunburstChromeOverlay(this); } }, - onClickPre: (mx, my) => { + onClickPre: (mx: number, my: number) => { handleSunburstClick(this, mx, my); return true; }, - }); + }; } - uploadAndRender( + async uploadAndRender( glManager: WebGLContextManager, columns: ColumnDataMap, startRow: number, _endRow: number, - ): void { + ): Promise { this._glManager = glManager; if (startRow === 0) { - this._cancelScheduledRender(); - const slots = this._columnSlots; this._sizeName = slots[0] || firstNonMetadataColumn(columns) || ""; this._colorName = slots[1] || ""; @@ -164,14 +182,14 @@ export class SunburstChart extends TreeChartBase { this._facetDrillRoots.clear(); this._facetGrid = null; this._facets = []; + // Invalidate the instance buffer so a render that fires // before the fresh upload draws zero arcs. this._instanceCount = 0; + // Drop any in-flight hover tooltip promise (see treemap). - this._hoveredTooltipLines = null; - this._hoveredTooltipNodeId = -1; - this._hoveredTooltipSerial++; - this._pinnedTooltipSerial++; + this._lazyTooltip.clearHover(); + this._lazyTooltip.invalidatePin(); dismissSunburstPinnedTooltip(this); this._chromeCache?.close(); this._chromeCache = null; @@ -183,15 +201,17 @@ export class SunburstChart extends TreeChartBase { processTreeChunk(this, columns); finalizeTree(this); - if (this._rootId !== NULL_NODE) this._scheduleRender(glManager); + if (this._rootId !== NULL_NODE) { + await this.requestRender(glManager); + } } - redraw(glManager: WebGLContextManager): void { - this._glManager = glManager; - if (this._rootId !== NULL_NODE) this._scheduleRender(glManager); - } + _fullRender(glManager: WebGLContextManager): void { + if (this._rootId === NULL_NODE) { + return; + } - protected _fullRender(glManager: WebGLContextManager): void { + this._glManager = glManager; renderSunburstFrame(this, glManager); } @@ -201,9 +221,15 @@ export class SunburstChart extends TreeChartBase { this._chromeCache = null; const gl = this._glManager?.gl; if (gl) { - if (this._stripBuffer) gl.deleteBuffer(this._stripBuffer); - if (this._instanceBuffer) gl.deleteBuffer(this._instanceBuffer); + if (this._stripBuffer) { + gl.deleteBuffer(this._stripBuffer); + } + + if (this._instanceBuffer) { + gl.deleteBuffer(this._instanceBuffer); + } } + this._stripBuffer = null; this._instanceBuffer = null; this._program = null; diff --git a/packages/viewer-charts/src/ts/charts/treemap/treemap-interact.ts b/packages/viewer-charts/src/ts/charts/treemap/treemap-interact.ts index dca2fe6870..eecf59912d 100644 --- a/packages/viewer-charts/src/ts/charts/treemap/treemap-interact.ts +++ b/packages/viewer-charts/src/ts/charts/treemap/treemap-interact.ts @@ -11,9 +11,8 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import type { TreemapChart } from "./treemap"; -import { NULL_NODE } from "../common/node-store"; +import { NULL_NODE, ancestorNames } from "../common/node-store"; import { PADDING_LABEL, rebuildBreadcrumbs } from "./treemap-layout"; -import { formatTickValue } from "../../layout/ticks"; import { renderTreemapFrame, renderTreemapChromeOverlay, @@ -60,9 +59,13 @@ function hitTest(chart: TreemapChart, mx: number, my: number): HitResult { for (let i = 0; i < n; i++) { const id = ids[i]; const rootId = rootArr ? rootArr[i] : chart._currentRootId; - if (id === rootId) continue; - if (!(mx >= x0[id] && mx <= x1[id] && my >= y0[id] && my <= y1[id])) + if (id === rootId) { continue; + } + + if (!(mx >= x0[id] && mx <= x1[id] && my >= y0[id] && my <= y1[id])) { + continue; + } const area = (x1[id] - x0[id]) * (y1[id] - y0[id]); if (firstChild[id] !== NULL_NODE) { @@ -70,6 +73,7 @@ function hitTest(chart: TreemapChart, mx: number, my: number): HitResult { bestBranchArea = area; bestBranchId = id; } + const baseDepth = baseArr ? baseArr[i] : depth[chart._currentRootId]; @@ -77,6 +81,7 @@ function hitTest(chart: TreemapChart, mx: number, my: number): HitResult { if (relDepth === 1 && my <= y0[id] + PADDING_LABEL) { labelBranchId = id; } + if (relDepth === 2) { const nw = x1[id] - x0[id]; const nh = y1[id] - y0[id]; @@ -102,6 +107,7 @@ function hitTest(chart: TreemapChart, mx: number, my: number): HitResult { if (labelBranchId !== NULL_NODE) { return { leafId: NULL_NODE, branchId: labelBranchId, inHeader: true }; } + return { leafId: bestLeafId, branchId: bestBranchId, @@ -114,7 +120,9 @@ export function handleTreemapHover( mx: number, my: number, ): void { - if (chart._pinnedNodeId !== NULL_NODE) return; + if (chart._pinnedNodeId !== NULL_NODE) { + return; + } for (const region of chart._breadcrumbRegions) { if ( @@ -123,7 +131,7 @@ export function handleTreemapHover( my >= region.y0 && my <= region.y1 ) { - if (chart._glCanvas) chart._glCanvas.style.cursor = "pointer"; + chart._tooltip.setCursor("pointer"); chart._hoveredNodeId = NULL_NODE; renderTreemapChromeOverlay(chart); return; @@ -135,24 +143,24 @@ export function handleTreemapHover( if (best !== chart._hoveredNodeId) { chart._hoveredNodeId = best; - chart._hoveredTooltipLines = null; - chart._hoveredTooltipNodeId = best; - const serial = ++chart._hoveredTooltipSerial; - if (chart._glCanvas) { - chart._glCanvas.style.cursor = - branchId !== NULL_NODE ? "pointer" : "default"; - } + chart._tooltip.setCursor( + branchId !== NULL_NODE ? "pointer" : "default", + ); if (best !== NULL_NODE) { // Kick off the lazy tooltip build for hover; re-render // the chrome overlay once lines resolve. Stale results - // (mouse moved elsewhere, new view) are dropped via the - // serial check. + // (mouse moved elsewhere, new view) are dropped by the + // controller's serial gate. + const serial = chart._lazyTooltip.beginHover(best); buildTreemapTooltipLines(chart, best).then((lines) => { - if (serial !== chart._hoveredTooltipSerial) return; - chart._hoveredTooltipLines = lines; - renderTreemapChromeOverlay(chart); + if (chart._lazyTooltip.commitHover(serial, lines)) { + renderTreemapChromeOverlay(chart); + } }); + } else { + chart._lazyTooltip.clearHover(); } + renderTreemapChromeOverlay(chart); } } @@ -164,6 +172,7 @@ export function handleTreemapClick( ): void { if (chart._pinnedNodeId !== NULL_NODE) { dismissTreemapPinnedTooltip(chart); + chart.emitUnselect(); return; } @@ -176,7 +185,12 @@ export function handleTreemapClick( ) { if (region.nodeId !== chart._currentRootId) { drillTo(chart, region.nodeId); + // Breadcrumb is chrome — no `perspective-click`. The + // drill-up pops one or more levels off the host's + // cached filter stack via `selected: false`. + chart.emitUnselect(); } + return; } } @@ -185,19 +199,70 @@ export function handleTreemapClick( if (branchId !== NULL_NODE && inHeader) { drillTo(chart, branchId); + void emitTreemapNodeEvent(chart, branchId, "branch"); } else if (leafId !== NULL_NODE) { showTreemapPinnedTooltip(chart, leafId); + void emitTreemapNodeEvent(chart, leafId, "leaf"); } else if (branchId !== NULL_NODE) { drillTo(chart, branchId); + void emitTreemapNodeEvent(chart, branchId, "branch"); } } +/** + * Build a click detail from a treemap node id and emit both + * `perspective-click` and `perspective-global-filter selected:true`. + * + * For leaves, the source-view row index is `store.leafRowIdx[id]` and + * the row payload is populated via `_lazyRows`. For branches, no + * source row exists (the branch is a rollup), so `rowIdx: null` and + * the row payload is `{}` — only the filter path is meaningful. + * + * The path is walked via `ancestorNames` and split into split-by + * prefix + group-by levels using `_splitBy.length` as the boundary. + * Faceted mode (`facet_mode === "grid"` with non-empty `_splitBy`) + * keeps the depth-0 ancestor name as the split prefix. + */ +async function emitTreemapNodeEvent( + chart: TreemapChart, + nodeId: number, + kind: "leaf" | "branch", +): Promise { + const store = chart._nodeStore; + const path = ancestorNames(store, nodeId); + const isFaceted = + chart._splitBy.length > 0 && chart._facetConfig.facet_mode === "grid"; + const splitByValues: (string | null)[] = isFaceted + ? path.slice(0, chart._splitBy.length) + : []; + const groupByValues: (string | null)[] = isFaceted + ? path.slice( + chart._splitBy.length, + chart._splitBy.length + chart._groupBy.length, + ) + : path.slice(0, chart._groupBy.length); + + const rowIdx = kind === "leaf" ? (store.leafRowIdx[nodeId] ?? null) : null; + + await chart.emitClickAndSelect({ + rowIdx: rowIdx != null && rowIdx >= 0 ? rowIdx : null, + columnName: chart._sizeName, + groupByValues, + splitByValues, + }); +} + export function handleTreemapDblClick( chart: TreemapChart, mx: number, my: number, ): void { + const wasPinned = chart._pinnedNodeId !== NULL_NODE; dismissTreemapPinnedTooltip(chart); + if (wasPinned) { + chart.emitUnselect(); + } + const { leafId, branchId } = hitTest(chart, mx, my); const store = chart._nodeStore; let target = branchId; @@ -207,14 +272,17 @@ export function handleTreemapDblClick( target = parent; } } + if ( target !== NULL_NODE && target !== chart._currentRootId && store.firstChild[target] !== NULL_NODE ) { drillTo(chart, target); + void emitTreemapNodeEvent(chart, target, "branch"); if (leafId !== NULL_NODE && store.firstChild[leafId] === NULL_NODE) { showTreemapPinnedTooltip(chart, leafId); + void emitTreemapNodeEvent(chart, leafId, "leaf"); } } } @@ -238,50 +306,58 @@ function drillTo(chart: TreemapChart, nodeId: number): void { while (p !== NULL_NODE && store.parent[p] !== chart._rootId) { p = store.parent[p]; } + if (p !== NULL_NODE) { const label = store.name[p]; chart._facetDrillRoots.set(label, nodeId); } + chart._hoveredNodeId = NULL_NODE; - if (chart._glManager) renderTreemapFrame(chart, chart._glManager); + if (chart._glManager) { + renderTreemapFrame(chart, chart._glManager); + } + return; } + chart._currentRootId = nodeId; rebuildBreadcrumbs(chart, nodeId); chart._hoveredNodeId = NULL_NODE; - if (chart._glManager) renderTreemapFrame(chart, chart._glManager); + if (chart._glManager) { + renderTreemapFrame(chart, chart._glManager); + } } export function showTreemapPinnedTooltip( chart: TreemapChart, nodeId: number, ): void { - chart._tooltip.dismissPinned(); + chart._tooltip.dismiss(); chart._pinnedNodeId = nodeId; - const parent = chart._glCanvas?.parentElement; - if (!parent) return; - const store = chart._nodeStore; const cx = (store.x0[nodeId] + store.x1[nodeId]) / 2; const cy = (store.y0[nodeId] + store.y1[nodeId]) / 2; - const dpr = window.devicePixelRatio || 1; - const cssWidth = (chart._glCanvas?.width || 100) / dpr; - const cssHeight = (chart._glCanvas?.height || 100) / dpr; + + // CSS bounds: prefer `glManager` (works in both local and worker + // modes, since the worker constructs its own context manager). + const cssWidth = chart._glManager?.cssWidth ?? 0; + const cssHeight = chart._glManager?.cssHeight ?? 0; // Tooltip columns are fetched lazily from the view — the tree // itself only retains ancestor names + aggregated value + color. // If the user dismisses or re-pins between click and resolve, the // `_pinnedNodeId` check discards the stale result. buildTreemapTooltipLines(chart, nodeId).then((lines) => { - if (chart._pinnedNodeId !== nodeId) return; - if (lines.length === 0) return; - chart._tooltip.showPinned( - parent, - lines, - { px: cx, py: cy }, - { cssWidth, cssHeight }, - ); + if (chart._pinnedNodeId !== nodeId) { + return; + } + + if (lines.length === 0) { + return; + } + + chart._tooltip.pin(lines, { px: cx, py: cy }, { cssWidth, cssHeight }); }); chart._hoveredNodeId = NULL_NODE; @@ -289,7 +365,7 @@ export function showTreemapPinnedTooltip( } export function dismissTreemapPinnedTooltip(chart: TreemapChart): void { - chart._tooltip.dismissPinned(); + chart._tooltip.dismiss(); chart._pinnedNodeId = NULL_NODE; } @@ -312,6 +388,7 @@ export async function buildTreemapTooltipLines( pathNames.push(store.name[p]); p = store.parent[p]; } + pathNames.reverse(); if (pathNames.length > 0) { lines.push(pathNames.join(" \u203A ")); @@ -319,13 +396,15 @@ export async function buildTreemapTooltipLines( lines.push(store.name[nodeId]); } - lines.push(`Value: ${formatTickValue(store.value[nodeId])}`); + const sizeFmt = chart.getColumnFormatter(chart._sizeName, "value"); + lines.push(`Value: ${sizeFmt(store.value[nodeId])}`); // Color value (numeric branch): stored on the node at insert // time, so it's always available without a view fetch. if (chart._colorName && !isNaN(store.colorValue[nodeId])) { + const colorFmt = chart.getColumnFormatter(chart._colorName, "value"); lines.push( - `${chart._colorName}: ${formatTickValue(store.colorValue[nodeId])}`, + `${chart._colorName}: ${colorFmt(store.colorValue[nodeId])}`, ); } @@ -339,13 +418,19 @@ export async function buildTreemapTooltipLines( if (isLeaf && chart._lazyRows) { const row = await chart._lazyRows.fetchRow(rowIdx); for (const [name, value] of row) { - if (value === null || value === undefined) continue; + if (value === null || value === undefined) { + continue; + } + if (name === chart._colorName && !isNaN(store.colorValue[nodeId])) { // Already emitted from the retained tree state above. continue; } + if (typeof value === "number") { - lines.push(`${name}: ${formatTickValue(value)}`); + lines.push( + `${name}: ${chart.getColumnFormatter(name, "value")(value)}`, + ); } else { lines.push(`${name}: ${value}`); } diff --git a/packages/viewer-charts/src/ts/charts/treemap/treemap-layout.ts b/packages/viewer-charts/src/ts/charts/treemap/treemap-layout.ts index 190cfdbe17..960da4fae5 100644 --- a/packages/viewer-charts/src/ts/charts/treemap/treemap-layout.ts +++ b/packages/viewer-charts/src/ts/charts/treemap/treemap-layout.ts @@ -13,13 +13,7 @@ import type { TreemapChart } from "./treemap"; import { NodeStore, NULL_NODE } from "../common/node-store"; -export interface BreadcrumbRegion { - nodeId: number; - x0: number; - y0: number; - x1: number; - y1: number; -} +export type { BreadcrumbRegion } from "../common/tree-chrome"; export const PADDING_OUTER = 1; export const PADDING_LABEL = 14; @@ -41,7 +35,7 @@ export { */ const MIN_VISIBLE_AREA = 4; // 2×2 px -// ── Squarify layout ────────────────────────────────────────────────────── +// Squarify layout /** * Order-preserving treemap layout. Walks the linked-list child graph @@ -63,10 +57,14 @@ export function squarify( store.x1[id] = Math.round(x1); store.y1[id] = Math.round(y1); - if (store.firstChild[id] === NULL_NODE) return; + if (store.firstChild[id] === NULL_NODE) { + return; + } const area = (x1 - x0) * (y1 - y0); - if (area < MIN_VISIBLE_AREA) return; + if (area < MIN_VISIBLE_AREA) { + return; + } const relDepth = store.depth[id] - baseDepth; const showHeader = @@ -80,7 +78,9 @@ export function squarify( const iy0 = store.y0[id] + padTop; const ix1 = store.x1[id] - padOuter; const iy1 = store.y1[id] - padOuter; - if (ix1 <= ix0 || iy1 <= iy0) return; + if (ix1 <= ix0 || iy1 <= iy0) { + return; + } let activeCount = 0; for ( @@ -92,7 +92,10 @@ export function squarify( scratch[activeCount++] = c; } } - if (activeCount === 0) return; + + if (activeCount === 0) { + return; + } layoutOrdered( store, @@ -123,7 +126,10 @@ function layoutOrdered( showBranchHeader: boolean, ): void { const n = hi - lo; - if (n === 0) return; + if (n === 0) { + return; + } + if (n === 1) { squarify( store, @@ -140,7 +146,10 @@ function layoutOrdered( } let totalValue = 0; - for (let i = lo; i < hi; i++) totalValue += store.value[nodes[i]]; + for (let i = lo; i < hi; i++) { + totalValue += store.value[nodes[i]]; + } + const halfValue = totalValue / 2; let cumulative = 0; @@ -156,7 +165,10 @@ function layoutOrdered( } let leftValue = 0; - for (let i = lo; i < splitIdx; i++) leftValue += store.value[nodes[i]]; + for (let i = lo; i < splitIdx; i++) { + leftValue += store.value[nodes[i]]; + } + const fraction = leftValue / totalValue; const rw = x1 - x0; @@ -220,7 +232,7 @@ function layoutOrdered( } } -// ── Collect visible ────────────────────────────────────────────────────── +// Collect visible /** * Walk from `startId` depth-first, emitting every descendant whose rect @@ -271,6 +283,7 @@ export function collectVisibleAppend( if (!chart._visibleNodeIds || chart._visibleNodeIds.length < store.count) { chart._visibleNodeIds = new Int32Array(store.count); } + const out = chart._visibleNodeIds; let outIdx = startOffset; @@ -282,17 +295,23 @@ export function collectVisibleAppend( while (sp > 0) { sp--; const id = stack[sp]; - if (value[id] <= 0) continue; + if (value[id] <= 0) { + continue; + } if (depth[id] >= baseDepth) { out[outIdx++] = id; } - if (depth[id] - baseDepth >= maxDepth) continue; + if (depth[id] - baseDepth >= maxDepth) { + continue; + } const w = x1[id] - x0[id]; const h = y1[id] - y0[id]; - if (w * h < MIN_VISIBLE_AREA) continue; + if (w * h < MIN_VISIBLE_AREA) { + continue; + } for (let c = firstChild[id]; c !== NULL_NODE; c = nextSibling[c]) { if (sp >= stack.length) { @@ -300,6 +319,7 @@ export function collectVisibleAppend( bigger.set(stack); stack = bigger; } + stack[sp++] = c; } } diff --git a/packages/viewer-charts/src/ts/charts/treemap/treemap-render.ts b/packages/viewer-charts/src/ts/charts/treemap/treemap-render.ts index cbf47de265..db81c37937 100644 --- a/packages/viewer-charts/src/ts/charts/treemap/treemap-render.ts +++ b/packages/viewer-charts/src/ts/charts/treemap/treemap-render.ts @@ -10,6 +10,7 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +import type { Context2D } from "../canvas-types"; import type { WebGLContextManager } from "../../webgl/context-manager"; import type { TreemapChart } from "./treemap"; import { NULL_NODE } from "../common/node-store"; @@ -18,85 +19,24 @@ import { collectVisible, collectVisibleAppend, } from "./treemap-layout"; -import { resolveTheme, readSeriesPalette } from "../../theme/theme"; +import { Theme } from "../../theme/theme"; import { resolvePalette, type Vec3 } from "../../theme/palette"; -import { - colorValueToT, - sampleGradient, - type GradientStop, -} from "../../theme/gradient"; -import { - renderLegend, - renderCategoricalLegend, - renderCategoricalLegendAt, -} from "../../chrome/legend"; +import { type GradientStop } from "../../theme/gradient"; +import { renderLegend, renderCategoricalLegend } from "../../axis/legend"; import { PlotLayout } from "../../layout/plot-layout"; import { buildFacetGrid } from "../../layout/facet-grid"; +import { leafColor, leafRGBA, luminance } from "../common/leaf-color"; import treemapVert from "../../shaders/treemap.vert.glsl"; import treemapFrag from "../../shaders/treemap.frag.glsl"; +import { withChromeCache } from "../common/chrome-cache"; +import { wrapLabel } from "../../axis/label-geometry"; +import { + renderBreadcrumbs as renderTreeBreadcrumbs, + renderTreeTooltip, +} from "../common/tree-chrome"; type GL = WebGL2RenderingContext | WebGLRenderingContext; -function luminance(r: number, g: number, b: number): number { - return 0.299 * r + 0.587 * g + 0.114 * b; -} - -function sampleRGB(stops: GradientStop[], t: number): [number, number, number] { - const c = sampleGradient(stops, t); - return [c[0], c[1], c[2]]; -} - -/** - * Resolve a leaf's fill color according to the chart's color mode: - * - `"numeric"` — sign-aware gradient sample via `colorValueToT`. - * - `"series"` / `"empty"` — discrete palette lookup keyed by the - * node's `colorLabel` (composite of group_by levels in series mode; - * `""` in empty mode, which maps to `palette[0]`). - * - * Returns RGB only; the alpha channel is applied separately by - * `leafRGBA` using `negativeAlpha` for leaves whose raw size was - * negative. - */ -function leafColor( - chart: TreemapChart, - nodeId: number, - stops: GradientStop[], - palette: Vec3[], -): [number, number, number] { - const store = chart._nodeStore; - const colorValue = store.colorValue[nodeId]; - if ( - chart._colorMode === "numeric" && - !isNaN(colorValue) && - chart._colorMax > chart._colorMin - ) { - return sampleRGB( - stops, - colorValueToT(colorValue, chart._colorMin, chart._colorMax), - ); - } - const idx = chart._uniqueColorLabels.get(store.colorLabel[nodeId]) ?? 0; - return palette[idx % palette.length] ?? [0, 0, 0]; -} - -/** - * `leafColor` + an alpha channel. Negative-size leaves receive - * `negativeAlpha` (mirrors `theme.areaOpacity` for area charts) so - * they stay visually distinguishable from positive leaves without - * disappearing. - */ -function leafRGBA( - chart: TreemapChart, - nodeId: number, - stops: GradientStop[], - palette: Vec3[], - negativeAlpha: number, -): [number, number, number, number] { - const rgb = leafColor(chart, nodeId, stops, palette); - const alpha = chart._nodeStore.sizeSign[nodeId] < 0 ? negativeAlpha : 1.0; - return [rgb[0], rgb[1], rgb[2], alpha]; -} - /** * Full-frame treemap render: layout → WebGL rects → chrome overlay. * @@ -109,14 +49,16 @@ export function renderTreemapFrame( chart: TreemapChart, glManager: WebGLContextManager, ): void { - if (chart._currentRootId === NULL_NODE) return; + if (chart._currentRootId === NULL_NODE) { + return; + } const gl = glManager.gl; - const cssWidth = (gl.canvas as HTMLCanvasElement).getBoundingClientRect() - .width; - const cssHeight = (gl.canvas as HTMLCanvasElement).getBoundingClientRect() - .height; - if (cssWidth <= 0 || cssHeight <= 0) return; + const cssWidth = glManager.cssWidth; + const cssHeight = glManager.cssHeight; + if (cssWidth <= 0 || cssHeight <= 0) { + return; + } const store = chart._nodeStore; const hasSplits = @@ -181,17 +123,16 @@ export function renderTreemapFrame( }; } - const themeEl = chart._gridlineCanvas || chart._chromeCanvas!; - const theme = resolveTheme(themeEl); + const theme = chart._resolveTheme(); const stops = theme.gradientStops; const palette = resolvePalette( - readSeriesPalette(themeEl), + theme.seriesPalette, stops, Math.max(1, chart._uniqueColorLabels.size), ); if (chart._gridlineCanvas) { - const gCtx = chart._gridlineCanvas.getContext("2d"); + const gCtx = chart._gridlineCanvas.getContext("2d") as Context2D | null; if (gCtx) { gCtx.clearRect( 0, @@ -253,6 +194,7 @@ function layoutFaceted( legendW: number, ): void { const store = chart._nodeStore; + // Collect the facet roots in declaration order (= top-level children // of the synthetic root). Skip zero-value facets. const facetIds: number[] = []; @@ -262,7 +204,10 @@ function layoutFaceted( c !== NULL_NODE; c = store.nextSibling[c] ) { - if (store.value[c] <= 0) continue; + if (store.value[c] <= 0) { + continue; + } + facetIds.push(c); labels.push(store.name[c]); } @@ -283,17 +228,20 @@ function layoutFaceted( ensureVisibleMetadata(chart); const baseArr = chart._visibleBaseDepths!; - const rootArr = chart._visibleRootIds!; let outIdx = 0; for (let i = 0; i < facetIds.length; i++) { const facetId = facetIds[i]; const cell = grid.cells[i]; - if (!cell) continue; + if (!cell) { + continue; + } + const label = store.name[facetId]; const drillRoot = chart._facetDrillRoots.get(label) ?? facetId; const baseDepth = store.depth[drillRoot]; const plot = cell.layout.plotRect; + // Shift by breadcrumb band — `buildFacetGrid` works in a // local coord system starting at (0,0), but we need absolute // canvas coords for squarify's rect. @@ -315,18 +263,22 @@ function layoutFaceted( baseDepth, outIdx, ); + // Ensure metadata arrays are wide enough after the append. if (baseArr.length < nextIdx) { ensureVisibleMetadata(chart); } + const baseArr2 = chart._visibleBaseDepths!; const rootArr2 = chart._visibleRootIds!; for (let k = outIdx; k < nextIdx; k++) { baseArr2[k] = baseDepth; rootArr2[k] = drillRoot; } + outIdx = nextIdx; } + chart._visibleNodeCount = outIdx; } @@ -335,6 +287,7 @@ function ensureVisibleMetadata(chart: TreemapChart): void { if (!chart._visibleBaseDepths || chart._visibleBaseDepths.length < need) { chart._visibleBaseDepths = new Int32Array(need); } + if (!chart._visibleRootIds || chart._visibleRootIds.length < need) { chart._visibleRootIds = new Int32Array(need); } @@ -362,10 +315,16 @@ function generateAndUploadTreemap( let rectCount = 0; for (let i = 0; i < n; i++) { const id = ids[i]; - if (id === rootOf(i)) continue; + if (id === rootOf(i)) { + continue; + } + const w = store.x1[id] - store.x0[id]; const h = store.y1[id] - store.y0[id]; - if (w < 1 || h < 1) continue; + if (w < 1 || h < 1) { + continue; + } + if (store.firstChild[id] === NULL_NODE) { rectCount++; } else if (store.depth[id] - baseDepthOf(i) === 1) { @@ -374,6 +333,7 @@ function generateAndUploadTreemap( } const positions = new Float32Array(rectCount * 6 * 2); + // 4 floats per vertex (RGBA) — negative-size leaves emit with a // reduced alpha (= `theme.areaOpacity`); everything else is opaque. const colors = new Float32Array(rectCount * 6 * 4); @@ -381,14 +341,19 @@ function generateAndUploadTreemap( for (let i = 0; i < n; i++) { const id = ids[i]; - if (id === rootOf(i)) continue; + if (id === rootOf(i)) { + continue; + } + const sx0 = store.x0[id]; const sy0 = store.y0[id]; const sx1 = store.x1[id]; const sy1 = store.y1[id]; const w = sx1 - sx0; const h = sy1 - sy0; - if (w < 1 || h < 1) continue; + if (w < 1 || h < 1) { + continue; + } if (store.firstChild[id] === NULL_NODE) { const color = leafRGBA(chart, id, stops, palette, negativeAlpha); @@ -435,7 +400,10 @@ function generateAndUploadTreemap( chart._vertexCount = vi; - if (!chart._positionBuffer) chart._positionBuffer = gl.createBuffer(); + if (!chart._positionBuffer) { + chart._positionBuffer = gl.createBuffer(); + } + gl.bindBuffer(gl.ARRAY_BUFFER, chart._positionBuffer); gl.bufferData( gl.ARRAY_BUFFER, @@ -443,7 +411,10 @@ function generateAndUploadTreemap( gl.DYNAMIC_DRAW, ); - if (!chart._colorBuffer) chart._colorBuffer = gl.createBuffer(); + if (!chart._colorBuffer) { + chart._colorBuffer = gl.createBuffer(); + } + gl.bindBuffer(gl.ARRAY_BUFFER, chart._colorBuffer); gl.bufferData(gl.ARRAY_BUFFER, colors.subarray(0, vi * 4), gl.DYNAMIC_DRAW); } @@ -492,146 +463,122 @@ function emitRect( * tooltip + highlight on top. */ export function renderTreemapChromeOverlay(chart: TreemapChart): void { - if (!chart._chromeCanvas || chart._currentRootId === NULL_NODE) return; - - const canvas = chart._chromeCanvas; - const dpr = window.devicePixelRatio || 1; - - const domRect = canvas.getBoundingClientRect(); - const cssWidth = domRect.width; - const cssHeight = domRect.height; - const targetW = Math.round(cssWidth * dpr); - const targetH = Math.round(cssHeight * dpr); - if (canvas.width !== targetW || canvas.height !== targetH) { - canvas.width = targetW; - canvas.height = targetH; - chart._chromeCacheDirty = true; + if (!chart._chromeCanvas || chart._currentRootId === NULL_NODE) { + return; } - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - if (chart._chromeCacheDirty) { - chart._chromeCache?.close(); - chart._chromeCache = null; - chart._chromeCacheDirty = false; - // Bump the generation so any in-flight `createImageBitmap` - // for a previous static draw (including one from a prior - // dataset) resolves into the stale branch below. - const gen = ++chart._chromeCacheGen; - drawStaticChrome(chart, ctx, dpr, cssWidth, cssHeight); - - createImageBitmap(canvas).then((bmp) => { - if (chart._chromeCacheGen === gen) { - // Newer draw already landed — discard our snapshot - // rather than overwriting whatever is current. - chart._chromeCache?.close(); - chart._chromeCache = bmp; - } else { - bmp.close(); - } - }); - } else if (chart._chromeCache) { - ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.drawImage(chart._chromeCache, 0, 0); + const glManager = chart._glManager; + if (!glManager) { + return; } + const { dpr, cssWidth, cssHeight } = glManager; + const highlightId = chart._pinnedNodeId !== NULL_NODE ? chart._pinnedNodeId : chart._hoveredNodeId; - if (highlightId !== NULL_NODE) { - ctx.save(); - ctx.scale(dpr, dpr); - const theme = resolveTheme(canvas); - const { fontFamily, labelColor: textColor } = theme; - const store = chart._nodeStore; - - renderHoverHighlight(ctx, store, highlightId); - - const ids = chart._visibleNodeIds!; - const n = chart._visibleNodeCount; - const baseArr = chart._visibleBaseDepths; - const rootArr = chart._visibleRootIds; - for (let i = 0; i < n; i++) { - const id = ids[i]; - const rootId = rootArr ? rootArr[i] : chart._currentRootId; - if (id === rootId || store.firstChild[id] === NULL_NODE) continue; - const nw = store.x1[id] - store.x0[id]; - const nh = store.y1[id] - store.y0[id]; - const baseDepth = baseArr - ? baseArr[i] - : store.depth[chart._currentRootId]; - const relDepth = store.depth[id] - baseDepth; - if (relDepth === 1) { - renderBranchLabel( - ctx, - store, - id, - nw, - nh, - fontFamily, - textColor, - !chart._showBranchHeader, - ); - } else if (relDepth === 2) { - renderBranchLabel( - ctx, - store, - id, - nw, - nh, - fontFamily, - textColor, - true, - ); - } - } - if (store.firstChild[highlightId] === NULL_NODE) { - const themeEl = chart._gridlineCanvas || canvas; - const innerTheme = resolveTheme(themeEl); - const stops = innerTheme.gradientStops; - const palette = resolvePalette( - readSeriesPalette(themeEl), - stops, - Math.max(1, chart._uniqueColorLabels.size), - ); - const hw = store.x1[highlightId] - store.x0[highlightId]; - const hh = store.y1[highlightId] - store.y0[highlightId]; - renderNodeLabel( - chart, - ctx, - highlightId, - hw, - hh, - fontFamily, - stops, - palette, - true, - ); - } - - if ( - chart._pinnedNodeId === NULL_NODE && - chart._hoveredNodeId !== NULL_NODE - ) { - renderTreemapTooltip( - chart, - ctx, - chart._hoveredNodeId, - cssWidth, - cssHeight, - fontFamily, - ); - } - ctx.restore(); - } + withChromeCache( + chart, + chart._chromeCanvas, + dpr, + cssWidth, + cssHeight, + (ctx) => drawStaticChrome(chart, ctx, dpr, cssWidth, cssHeight), + highlightId !== NULL_NODE + ? (ctx) => { + const theme = chart._resolveTheme(); + const { fontFamily } = theme; + const store = chart._nodeStore; + + renderHoverHighlight(ctx, store, highlightId); + + const ids = chart._visibleNodeIds!; + const n = chart._visibleNodeCount; + const baseArr = chart._visibleBaseDepths; + const rootArr = chart._visibleRootIds; + for (let i = 0; i < n; i++) { + const id = ids[i]; + const rootId = rootArr + ? rootArr[i] + : chart._currentRootId; + if (id === rootId || store.firstChild[id] === NULL_NODE) { + continue; + } + + const nw = store.x1[id] - store.x0[id]; + const nh = store.y1[id] - store.y0[id]; + const baseDepth = baseArr + ? baseArr[i] + : store.depth[chart._currentRootId]; + const relDepth = store.depth[id] - baseDepth; + if (relDepth === 1) { + renderBranchLabel( + ctx, + store, + id, + nw, + nh, + theme, + !chart._showBranchHeader, + ); + } else if (relDepth === 2) { + renderBranchLabel( + ctx, + store, + id, + nw, + nh, + theme, + true, + ); + } + } + + if (store.firstChild[highlightId] === NULL_NODE) { + const stops = theme.gradientStops; + const palette = resolvePalette( + theme.seriesPalette, + stops, + Math.max(1, chart._uniqueColorLabels.size), + ); + const hw = store.x1[highlightId] - store.x0[highlightId]; + const hh = store.y1[highlightId] - store.y0[highlightId]; + renderNodeLabel( + chart, + ctx, + highlightId, + hw, + hh, + fontFamily, + stops, + palette, + true, + ); + } + + if ( + chart._pinnedNodeId === NULL_NODE && + chart._hoveredNodeId !== NULL_NODE + ) { + renderTreemapTooltip( + chart, + ctx, + chart._hoveredNodeId, + cssWidth, + cssHeight, + fontFamily, + ); + } + } + : null, + ); } function drawStaticChrome( chart: TreemapChart, - ctx: CanvasRenderingContext2D, + ctx: Context2D, dpr: number, cssWidth: number, cssHeight: number, @@ -642,12 +589,11 @@ function drawStaticChrome( ctx.save(); ctx.scale(dpr, dpr); - const themeEl = chart._gridlineCanvas || canvas; - const theme = resolveTheme(themeEl); + const theme = chart._resolveTheme(); const { fontFamily, labelColor: textColor } = theme; const stops = theme.gradientStops; const palette = resolvePalette( - readSeriesPalette(themeEl), + theme.seriesPalette, stops, Math.max(1, chart._uniqueColorLabels.size), ); @@ -661,15 +607,22 @@ function drawStaticChrome( for (let i = 0; i < n; i++) { const id = ids[i]; const rootId = rootArr ? rootArr[i] : chart._currentRootId; - if (id === rootId || store.firstChild[id] !== NULL_NODE) continue; + if (id === rootId || store.firstChild[id] !== NULL_NODE) { + continue; + } + const w = store.x1[id] - store.x0[id]; const h = store.y1[id] - store.y0[id]; renderNodeLabel(chart, ctx, id, w, h, fontFamily, stops, palette); } + for (let i = 0; i < n; i++) { const id = ids[i]; const rootId = rootArr ? rootArr[i] : chart._currentRootId; - if (id === rootId || store.firstChild[id] === NULL_NODE) continue; + if (id === rootId || store.firstChild[id] === NULL_NODE) { + continue; + } + const w = store.x1[id] - store.x0[id]; const h = store.y1[id] - store.y0[id]; const baseDepth = baseArr @@ -683,26 +636,16 @@ function drawStaticChrome( id, w, h, - fontFamily, - textColor, + theme, !chart._showBranchHeader, ); } else if (relDepth === 2) { - renderBranchLabel( - ctx, - store, - id, - w, - h, - fontFamily, - textColor, - true, - ); + renderBranchLabel(ctx, store, id, w, h, theme, true); } } if (chart._breadcrumbIds.length > 1) { - renderBreadcrumbs(chart, ctx, cssWidth, fontFamily, textColor); + renderTreeBreadcrumbs(chart, ctx, cssWidth, fontFamily, textColor); } // Legend: numeric mode → gradient bar; series mode with 2+ unique @@ -719,6 +662,7 @@ function drawStaticChrome( legendLayout, chart._uniqueColorLabels, palette, + theme, ); } else if ( chart._colorMode === "numeric" && @@ -738,6 +682,8 @@ function drawStaticChrome( label: chart._colorName, }, stops, + theme, + chart.getColumnFormatter(chart._colorName, "value"), ); } @@ -759,7 +705,7 @@ function drawStaticChrome( function renderNodeLabel( chart: TreemapChart, - ctx: CanvasRenderingContext2D, + ctx: Context2D, nodeId: number, w: number, h: number, @@ -772,7 +718,9 @@ function renderNodeLabel( const PAD = 4; const LINE_HEIGHT = 1.3; - if (w < 30 || h < 14) return; + if (w < 30 || h < 14) { + return; + } const store = chart._nodeStore; const fillColor = leafColor(chart, nodeId, stops, palette); @@ -787,15 +735,20 @@ function renderNodeLabel( : "rgba(255,255,255,0.55)"; const fontSize = Math.min(MAX_FONT, Math.floor(h / 2)); - if (fontSize < 7) return; + if (fontSize < 7) { + return; + } + ctx.font = `${fontSize}px ${fontFamily}`; const maxW = w - PAD * 2; const lineH = fontSize * LINE_HEIGHT; const maxLines = Math.max(1, Math.floor((h - PAD * 2) / lineH)); - const lines = wrapText(ctx, store.name[nodeId], maxW, maxLines); - if (lines.length === 0) return; + const lines = wrapLabel(ctx, store.name[nodeId], maxW, maxLines); + if (lines.length === 0) { + return; + } const blockH = lines.length * lineH; const startY = store.y0[nodeId] + (h - blockH) / 2 + lineH / 2; @@ -809,76 +762,13 @@ function renderNodeLabel( } } -function wrapText( - ctx: CanvasRenderingContext2D, - text: string, - maxW: number, - maxLines: number, -): string[] { - if (maxLines <= 0 || maxW <= 0) return []; - - if (ctx.measureText(text).width <= maxW) return [text]; - - const lines: string[] = []; - let remaining = text; - - while (remaining.length > 0 && lines.length < maxLines) { - const isLastLine = lines.length === maxLines - 1; - - let fitLen = remaining.length; - while ( - fitLen > 0 && - ctx.measureText(remaining.slice(0, fitLen)).width > maxW - ) { - fitLen--; - } - if (fitLen === 0) fitLen = 1; - - if (fitLen === remaining.length) { - lines.push(remaining); - break; - } - - let breakAt = fitLen; - const spaceIdx = remaining.lastIndexOf(" ", fitLen); - if (spaceIdx > 0) breakAt = spaceIdx; - - if (isLastLine) { - lines.push(truncateWithEllipsis(ctx, remaining, maxW)); - break; - } - - lines.push(remaining.slice(0, breakAt)); - remaining = remaining.slice(breakAt).trimStart(); - } - - if (lines.length === 1 && lines[0].length <= 2) return []; - return lines; -} - -function truncateWithEllipsis( - ctx: CanvasRenderingContext2D, - text: string, - maxW: number, -): string { - if (ctx.measureText(text).width <= maxW) return text; - while (text.length > 1) { - text = text.slice(0, -1); - if (ctx.measureText(text + "\u2026").width <= maxW) { - return text + "\u2026"; - } - } - return text; -} - function renderBranchLabel( - ctx: CanvasRenderingContext2D, + ctx: Context2D, store: import("../common/node-store").NodeStore, nodeId: number, w: number, h: number, - fontFamily: string, - textColor: string, + { fontFamily, labelColor, backgroundColor }: Theme, nested: boolean, ): void { const x0 = store.x0[nodeId]; @@ -886,7 +776,9 @@ function renderBranchLabel( const name = store.name[nodeId]; if (nested) { - if (w < 60 || h < 30) return; + if (w < 60 || h < 30) { + return; + } const fontSize = 12; ctx.font = `${fontSize}px ${fontFamily}`; @@ -903,7 +795,10 @@ function renderBranchLabel( } } } - if (text.length <= 3) return; + + if (text.length <= 3) { + return; + } ctx.save(); ctx.beginPath(); @@ -914,16 +809,18 @@ function renderBranchLabel( const cy = y0 + h / 2; ctx.textAlign = "center"; ctx.textBaseline = "middle"; - ctx.lineWidth = 3; - ctx.strokeStyle = "rgba(0, 0, 0, 0.7)"; + ctx.lineWidth = 2; + ctx.strokeStyle = labelColor; ctx.lineJoin = "round"; ctx.strokeText(text, cx, cy); - ctx.fillStyle = "rgba(255, 255, 255, 0.95)"; + ctx.fillStyle = backgroundColor; ctx.fillText(text, cx, cy); ctx.restore(); } else { - if (w < 40 || h < 22) return; + if (w < 40 || h < 22) { + return; + } const fontSize = 11; ctx.font = `${fontSize}px ${fontFamily}`; @@ -941,7 +838,7 @@ function renderBranchLabel( } } - ctx.fillStyle = textColor; + ctx.fillStyle = labelColor; ctx.globalAlpha = 0.85; ctx.textAlign = "left"; ctx.textBaseline = "top"; @@ -950,61 +847,8 @@ function renderBranchLabel( } } -function renderBreadcrumbs( - chart: TreemapChart, - ctx: CanvasRenderingContext2D, - cssWidth: number, - fontFamily: string, - textColor: string, -): void { - chart._breadcrumbRegions = []; - - const bgColor = resolveTheme(chart._chromeCanvas!).tooltipBg; - - ctx.fillStyle = bgColor; - ctx.fillRect(0, 0, cssWidth, 24); - - ctx.font = `11px ${fontFamily}`; - ctx.textAlign = "left"; - ctx.textBaseline = "middle"; - - let x = 8; - const y = 12; - const store = chart._nodeStore; - - for (let i = 0; i < chart._breadcrumbIds.length; i++) { - const crumbId = chart._breadcrumbIds[i]; - const isLast = i === chart._breadcrumbIds.length - 1; - const label = store.name[crumbId]; - - ctx.fillStyle = textColor; - ctx.font = isLast ? `11px ${fontFamily}` : `11px ${fontFamily}`; - - const textW = ctx.measureText(label).width; - ctx.fillText(label, x, y); - - chart._breadcrumbRegions.push({ - nodeId: crumbId, - x0: x - 2, - y0: 0, - x1: x + textW + 2, - y1: 24, - }); - - x += textW; - - if (!isLast) { - ctx.fillStyle = textColor; - ctx.font = `11px ${fontFamily}`; - const sep = " \u203A "; - ctx.fillText(sep, x, y); - x += ctx.measureText(sep).width; - } - } -} - function renderHoverHighlight( - ctx: CanvasRenderingContext2D, + ctx: Context2D, store: import("../common/node-store").NodeStore, nodeId: number, ): void { @@ -1020,58 +864,23 @@ function renderHoverHighlight( function renderTreemapTooltip( chart: TreemapChart, - ctx: CanvasRenderingContext2D, + ctx: Context2D, nodeId: number, cssWidth: number, cssHeight: number, fontFamily: string, ): void { - const theme = resolveTheme(chart._chromeCanvas!); - const { tooltipBg, tooltipText, tooltipBorder } = theme; - - // Lines come from the async lazy tooltip fetch kicked off in - // `handleTreemapHover`. While a fetch is in flight (or for the - // wrong node) this is empty; the tooltip box is skipped until - // fresh lines land. - const lines = - chart._hoveredTooltipNodeId === nodeId - ? (chart._hoveredTooltipLines ?? []) - : []; - if (lines.length === 0) return; - - ctx.font = `11px ${fontFamily}`; - const lineHeight = 16; - const padding = 8; - let maxWidth = 0; - for (const line of lines) { - const w = ctx.measureText(line).width; - if (w > maxWidth) maxWidth = w; - } - const boxW = maxWidth + padding * 2; - const boxH = lines.length * lineHeight + padding * 2 - 4; - const store = chart._nodeStore; const cx = (store.x0[nodeId] + store.x1[nodeId]) / 2; const cy = (store.y0[nodeId] + store.y1[nodeId]) / 2; - let tx = cx + 12; - let ty = cy - boxH - 8; - if (tx + boxW > cssWidth) tx = cx - boxW - 12; - if (tx < 0) tx = 4; - if (ty < 0) ty = cy + 12; - if (ty + boxH > cssHeight) ty = cssHeight - boxH - 4; - - ctx.fillStyle = tooltipBg; - ctx.strokeStyle = tooltipBorder; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.roundRect(tx, ty, boxW, boxH, 4); - ctx.fill(); - ctx.stroke(); - - ctx.fillStyle = tooltipText; - ctx.textAlign = "left"; - ctx.textBaseline = "top"; - for (let i = 0; i < lines.length; i++) { - ctx.fillText(lines[i], tx + padding, ty + padding + i * lineHeight); - } + renderTreeTooltip( + chart, + ctx, + nodeId, + cx, + cy, + cssWidth, + cssHeight, + fontFamily, + ); } diff --git a/packages/viewer-charts/src/ts/charts/treemap/treemap.ts b/packages/viewer-charts/src/ts/charts/treemap/treemap.ts index 611bd31891..007fabf6ff 100644 --- a/packages/viewer-charts/src/ts/charts/treemap/treemap.ts +++ b/packages/viewer-charts/src/ts/charts/treemap/treemap.ts @@ -41,8 +41,11 @@ export interface TreemapLocations { */ function firstNonMetadataColumn(columns: ColumnDataMap): string { for (const k of columns.keys()) { - if (!k.startsWith("__")) return k; + if (!k.startsWith("__")) { + return k; + } } + return ""; } @@ -58,11 +61,10 @@ export class TreemapChart extends TreeChartBase { _colorBuffer: WebGLBuffer | null = null; _vertexCount = 0; - // ── Interaction ────────────────────────────────────────────────────── + // Interaction _hoveredNodeId: number = NULL_NODE; _pinnedNodeId: number = NULL_NODE; _breadcrumbRegions: BreadcrumbRegion[] = []; - _dblClickHandler: ((e: MouseEvent) => void) | null = null; _chromeCache: ImageBitmap | null = null; _chromeCacheDirty = true; @@ -78,7 +80,7 @@ export class TreemapChart extends TreeChartBase { */ _chromeCacheGen = 0; - // ── Faceted state ──────────────────────────────────────────────────── + // Faceted state /** * Per-facet drill roots in split_by mode. Key is the facet label * (the top-level child of `_rootId`); value is the currently drilled @@ -102,6 +104,7 @@ export class TreemapChart extends TreeChartBase { * layout. */ _visibleBaseDepths: Int32Array | null = null; + /** * Parallel to `_visibleNodeIds`. The drill-root node id that owns * each visible node (= `_currentRootId` in non-facet mode, per- @@ -110,46 +113,39 @@ export class TreemapChart extends TreeChartBase { */ _visibleRootIds: Int32Array | null = null; - attachTooltip(glCanvas: HTMLCanvasElement): void { - this._glCanvas = glCanvas; - this._tooltip.attach(glCanvas, { - onHover: (mx, my) => handleTreemapHover(this, mx, my), + protected override tooltipCallbacks() { + return { + onHover: (mx: number, my: number) => + handleTreemapHover(this, mx, my), onLeave: () => { if ( this._hoveredNodeId !== NULL_NODE && this._pinnedNodeId === NULL_NODE ) { this._hoveredNodeId = NULL_NODE; - if (this._glManager) + if (this._glManager) { renderTreemapFrame(this, this._glManager); + } } }, - onClickPre: (mx, my) => { + onClickPre: (mx: number, my: number) => { handleTreemapClick(this, mx, my); return true; // treemap owns all click logic }, - }); - - this._dblClickHandler = (e: MouseEvent) => { - const rect = glCanvas.getBoundingClientRect(); - const mx = e.clientX - rect.left; - const my = e.clientY - rect.top; - handleTreemapDblClick(this, mx, my); + onDblClick: (mx: number, my: number) => + handleTreemapDblClick(this, mx, my), }; - glCanvas.addEventListener("dblclick", this._dblClickHandler); } - uploadAndRender( + async uploadAndRender( glManager: WebGLContextManager, columns: ColumnDataMap, startRow: number, _endRow: number, - ): void { + ): Promise { this._glManager = glManager; if (startRow === 0) { - this._cancelScheduledRender(); - const slots = this._columnSlots; this._sizeName = slots[0] || firstNonMetadataColumn(columns) || ""; this._colorName = slots[1] || ""; @@ -180,19 +176,18 @@ export class TreemapChart extends TreeChartBase { this._facetGrid = null; this._visibleBaseDepths = null; this._visibleRootIds = null; + // Invalidate the GPU buffer contents so any render that // fires before `generateAndUploadTreemap` has refilled the // buffers draws zero triangles instead of the previous // tree's geometry. this._vertexCount = 0; - // Drop any in-flight hover tooltip promise — its serial - // is captured by the caller, so bumping here makes stale - // resolutions no-ops rather than painting old lines on - // the new chart. - this._hoveredTooltipLines = null; - this._hoveredTooltipNodeId = -1; - this._hoveredTooltipSerial++; - this._pinnedTooltipSerial++; + + // Drop any in-flight hover tooltip promise — bumping the + // controller's serials makes stale resolutions no-ops + // rather than painting old lines on the new chart. + this._lazyTooltip.clearHover(); + this._lazyTooltip.invalidatePin(); dismissTreemapPinnedTooltip(this); this._chromeCache?.close(); this._chromeCache = null; @@ -204,34 +199,35 @@ export class TreemapChart extends TreeChartBase { processTreemapChunk(this, columns); finalizeTreemap(this); - if (this._rootId !== NULL_NODE) this._scheduleRender(glManager); + if (this._rootId !== NULL_NODE) { + await this.requestRender(glManager); + } } - redraw(glManager: WebGLContextManager): void { - this._glManager = glManager; - if (this._rootId !== NULL_NODE) this._scheduleRender(glManager); - } + _fullRender(glManager: WebGLContextManager): void { + if (this._rootId === NULL_NODE) { + return; + } - protected _fullRender(glManager: WebGLContextManager): void { + this._glManager = glManager; renderTreemapFrame(this, glManager); } protected destroyInternal(): void { - if (this._glCanvas && this._dblClickHandler) { - this._glCanvas.removeEventListener( - "dblclick", - this._dblClickHandler, - ); - } - this._dblClickHandler = null; dismissTreemapPinnedTooltip(this); this._chromeCache?.close(); this._chromeCache = null; const gl = this._glManager?.gl; if (gl) { - if (this._positionBuffer) gl.deleteBuffer(this._positionBuffer); - if (this._colorBuffer) gl.deleteBuffer(this._colorBuffer); + if (this._positionBuffer) { + gl.deleteBuffer(this._positionBuffer); + } + + if (this._colorBuffer) { + gl.deleteBuffer(this._colorBuffer); + } } + this._positionBuffer = null; this._colorBuffer = null; this._program = null; diff --git a/packages/viewer-charts/src/ts/chrome/numeric-axis.ts b/packages/viewer-charts/src/ts/chrome/numeric-axis.ts deleted file mode 100644 index 1523c9799c..0000000000 --- a/packages/viewer-charts/src/ts/chrome/numeric-axis.ts +++ /dev/null @@ -1,377 +0,0 @@ -// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ -// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ -// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ -// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ -// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ -// ┃ Copyright (c) 2017, the Perspective Authors. ┃ -// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ -// ┃ This file is part of the Perspective library, distributed under the terms ┃ -// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ -// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - -import { PlotLayout, type PlotRect } from "../layout/plot-layout"; -import { - computeNiceTicks, - formatTickValue, - formatDateTickValue, -} from "../layout/ticks"; -import { getScaledContext } from "./canvas"; -import { - drawGridlinesX, - drawGridlinesY, - drawXTickRow, - drawYTickColumn, -} from "./axis-primitives"; -import type { Theme } from "../theme/theme"; - -export interface AxisDomain { - min: number; - max: number; - label: string; - isDate?: boolean; -} - -export interface TickResult { - xTicks: number[]; - yTicks: number[]; -} - -/** - * Compute tick positions for both axes of a numeric plot. - */ -export function computeTicks( - xDomain: AxisDomain, - yDomain: AxisDomain, - layout: PlotLayout, -): TickResult { - const { plotRect: plot } = layout; - const targetXTicks = Math.max(2, Math.floor(plot.width / 90)); - const targetYTicks = Math.max(2, Math.floor(plot.height / 60)); - return { - xTicks: computeNiceTicks(xDomain.min, xDomain.max, targetXTicks), - yTicks: computeNiceTicks(yDomain.min, yDomain.max, targetYTicks), - }; -} - -/** - * Render gridlines on the BOTTOM canvas (behind WebGL points) for a - * numeric / numeric plot. - * - * Non-destructive: caller must call `initCanvas` (from - * `chrome/canvas.ts`) on the target canvas exactly once per frame - * before any per-rect renderer calls. This helper only reads the - * already-sized canvas and draws into the current transform. - */ -export function renderGridlines( - canvas: HTMLCanvasElement, - layout: PlotLayout, - xTicks: number[], - yTicks: number[], - theme: Theme, -): void { - const ctx = getScaledContext(canvas); - if (!ctx) return; - - const { plotRect: plot } = layout; - ctx.strokeStyle = theme.gridlineColor; - ctx.lineWidth = 1; - drawGridlinesX(ctx, plot, xTicks, (v) => layout.dataToPixel(v, 0).px); - drawGridlinesY(ctx, plot, yTicks, (v) => layout.dataToPixel(0, v).py); -} - -/** - * Paint the X axis chrome for a single plot rect: bottom axis line, - * tick marks, tick labels, and (optionally) the axis label at the - * bottom of the canvas. - * - * Non-destructive — see {@link renderGridlines}. - */ -export function renderCellXAxis( - canvas: HTMLCanvasElement, - xDomain: AxisDomain, - layout: PlotLayout, - xTicks: number[], - theme: Theme, - hasLabel: boolean, -): void { - const ctx = getScaledContext(canvas); - if (!ctx) return; - - const { plotRect: plot } = layout; - const { - tickColor, - labelColor, - axisLineColor: lineColor, - fontFamily, - } = theme; - const xStep = xTicks.length > 1 ? xTicks[1] - xTicks[0] : 0; - const fmtX = xDomain.isDate - ? (v: number) => formatDateTickValue(v, xStep) - : formatTickValue; - - // Axis line - ctx.strokeStyle = lineColor; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(plot.x, plot.y + plot.height); - ctx.lineTo(plot.x + plot.width, plot.y + plot.height); - ctx.stroke(); - - ctx.fillStyle = tickColor; - ctx.strokeStyle = tickColor; - ctx.font = `11px ${fontFamily}`; - drawXTickRow( - ctx, - plot, - xTicks, - plot.y + plot.height, - "bottom", - (v) => layout.dataToPixel(v, 0).px, - fmtX, - ); - - if (hasLabel && xDomain.label) { - ctx.fillStyle = labelColor; - ctx.font = `13px ${fontFamily}`; - ctx.textAlign = "center"; - ctx.textBaseline = "bottom"; - ctx.fillText( - xDomain.label, - plot.x + plot.width / 2, - layout.cssHeight - 2, - ); - } -} - -/** - * Paint the Y axis chrome for a single plot rect: left axis line, - * tick marks, tick labels, and (optionally) a rotated axis label in - * the outer-left margin. - * - * Non-destructive — see {@link renderGridlines}. - */ -export function renderCellYAxis( - canvas: HTMLCanvasElement, - yDomain: AxisDomain, - layout: PlotLayout, - yTicks: number[], - theme: Theme, - hasLabel: boolean, -): void { - const ctx = getScaledContext(canvas); - if (!ctx) return; - - const { plotRect: plot } = layout; - const { - tickColor, - labelColor, - axisLineColor: lineColor, - fontFamily, - } = theme; - const yStep = yTicks.length > 1 ? yTicks[1] - yTicks[0] : 0; - const fmtY = yDomain.isDate - ? (v: number) => formatDateTickValue(v, yStep) - : formatTickValue; - - ctx.strokeStyle = lineColor; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(plot.x, plot.y); - ctx.lineTo(plot.x, plot.y + plot.height); - ctx.stroke(); - - ctx.fillStyle = tickColor; - ctx.strokeStyle = tickColor; - ctx.font = `11px ${fontFamily}`; - drawYTickColumn( - ctx, - plot, - yTicks, - plot.x, - "left", - (v) => layout.dataToPixel(0, v).py, - fmtY, - ); - - if (hasLabel && yDomain.label) { - ctx.fillStyle = labelColor; - ctx.font = `13px ${fontFamily}`; - ctx.save(); - ctx.translate(14, plot.y + plot.height / 2); - ctx.rotate(-Math.PI / 2); - ctx.textAlign = "center"; - ctx.textBaseline = "bottom"; - ctx.fillText(yDomain.label, 0, 0); - ctx.restore(); - } -} - -/** - * Render axis lines, tick marks, tick labels, and axis labels on the TOP - * canvas (above WebGL points) for a numeric / numeric plot. - * - * Non-destructive — see {@link renderGridlines}. Caller owns the - * per-frame `initCanvas` call. Single-plot convenience — composes - * {@link renderCellXAxis} + {@link renderCellYAxis}. - */ -export function renderAxesChrome( - canvas: HTMLCanvasElement, - xDomain: AxisDomain, - yDomain: AxisDomain, - layout: PlotLayout, - xTicks: number[], - yTicks: number[], - theme: Theme, -): void { - // `renderAxesChrome` historically treated `hasXLabel` / `hasYLabel` - // as "does this layout reserve space for a label" — `PlotLayout` - // encodes that in its margins, but there's no flag to read back. - // Since single-plot callers always pass the same `layout` they - // used for `computeTicks` / `buildProjectionMatrix`, just paint - // labels unconditionally: the gutter is already sized for them. - renderCellYAxis(canvas, yDomain, layout, yTicks, theme, true); - renderCellXAxis(canvas, xDomain, layout, xTicks, theme, true); -} - -/** - * Paint a shared X axis into the outer band of a facet grid. The - * axis line spans the full band width (once); ticks + labels repeat - * per column — one pass per layout in `colLayouts`, each providing - * the data→pixel mapping for that column's plot rect. - * - * `colLayouts` must contain one entry per bottom-row cell. All cells - * share the same X scale, so the layout's `dataToPixel(val, 0).px` - * gives the correct tick X for that column's pixel range. - */ -export function renderOuterXAxis( - canvas: HTMLCanvasElement, - rect: PlotRect, - xDomain: AxisDomain, - xTicks: number[], - colLayouts: PlotLayout[], - theme: Theme, - hasLabel: boolean, -): void { - const ctx = getScaledContext(canvas); - if (!ctx) return; - - const { - tickColor, - labelColor, - axisLineColor: lineColor, - fontFamily, - } = theme; - const xStep = xTicks.length > 1 ? xTicks[1] - xTicks[0] : 0; - const fmtX = xDomain.isDate - ? (v: number) => formatDateTickValue(v, xStep) - : formatTickValue; - - const axisY = rect.y; - - // Axis line: one span across the entire outer band. - ctx.strokeStyle = lineColor; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(rect.x, axisY); - ctx.lineTo(rect.x + rect.width, axisY); - ctx.stroke(); - - // Ticks + tick labels: one pass per column. All columns share the - // same X scale so tick values are the same; only the pixel range - // shifts. - ctx.fillStyle = tickColor; - ctx.strokeStyle = tickColor; - ctx.font = `11px ${fontFamily}`; - for (const layout of colLayouts) { - drawXTickRow( - ctx, - layout.plotRect, - xTicks, - axisY, - "bottom", - (v) => layout.dataToPixel(v, 0).px, - fmtX, - ); - } - - // Axis label once, centered across the full band. - if (hasLabel && xDomain.label) { - ctx.fillStyle = labelColor; - ctx.font = `13px ${fontFamily}`; - ctx.textAlign = "center"; - ctx.textBaseline = "bottom"; - ctx.fillText( - xDomain.label, - rect.x + rect.width / 2, - rect.y + rect.height - 2, - ); - } -} - -/** - * Paint a shared Y axis into the outer band of a facet grid. The - * axis line spans the full band height (once); ticks + labels repeat - * per row — one pass per layout in `rowLayouts`. - * - * `rowLayouts` must contain one entry per leftmost-column cell. - */ -export function renderOuterYAxis( - canvas: HTMLCanvasElement, - rect: PlotRect, - yDomain: AxisDomain, - yTicks: number[], - rowLayouts: PlotLayout[], - theme: Theme, - hasLabel: boolean, -): void { - const ctx = getScaledContext(canvas); - if (!ctx) return; - - const { - tickColor, - labelColor, - axisLineColor: lineColor, - fontFamily, - } = theme; - const yStep = yTicks.length > 1 ? yTicks[1] - yTicks[0] : 0; - const fmtY = yDomain.isDate - ? (v: number) => formatDateTickValue(v, yStep) - : formatTickValue; - - const axisX = rect.x + rect.width; - - ctx.strokeStyle = lineColor; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(axisX, rect.y); - ctx.lineTo(axisX, rect.y + rect.height); - ctx.stroke(); - - ctx.fillStyle = tickColor; - ctx.strokeStyle = tickColor; - ctx.font = `11px ${fontFamily}`; - for (const layout of rowLayouts) { - drawYTickColumn( - ctx, - layout.plotRect, - yTicks, - axisX, - "left", - (v) => layout.dataToPixel(0, v).py, - fmtY, - ); - } - - if (hasLabel && yDomain.label) { - ctx.fillStyle = labelColor; - ctx.font = `13px ${fontFamily}`; - ctx.save(); - ctx.translate(14, rect.y + rect.height / 2); - ctx.rotate(-Math.PI / 2); - ctx.textAlign = "center"; - ctx.textBaseline = "bottom"; - ctx.fillText(yDomain.label, 0, 0); - ctx.restore(); - } -} diff --git a/packages/viewer-charts/src/ts/config.ts b/packages/viewer-charts/src/ts/config.ts new file mode 100644 index 0000000000..bc73ed736a --- /dev/null +++ b/packages/viewer-charts/src/ts/config.ts @@ -0,0 +1,41 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +/** + * Renderer mode. `"worker"` runs the chart code in a Web Worker (off + * the main thread, gets parallelism but pays a postMessage hop on + * every interaction). `"inprocess"` dynamic-imports the same worker + * module on the main thread so the bundle stays single-copy without + * the worker boundary. The two paths share a `MessageChannel`-shaped + * control protocol — only the handle around it differs. + */ +export const RUNTIME_MODE: "worker" | "inprocess" = "worker"; + +/** + * Build-time toggle between the two GL-canvas display strategies. + * + * - `"direct"` — host transfers `.webgl-canvas` to the renderer via + * `transferControlToOffscreen`. The renderer's GL context renders + * straight into the visible drawing buffer. + * + * - `"blit"` — host keeps the visible canvas main-thread with a 2D + * context. The renderer creates its own internal `OffscreenCanvas` + * for GL rendering and emits each completed frame as an + * `ImageBitmap` over the control channel; the host blits the bitmap + * into the visible canvas via `drawImage`. + */ +export const RENDER_BLIT_MODE: "direct" | "blit" = "direct"; + +/** + * Strict-mode validation for `BufferPool.upload`. + */ +export const BUFFER_POOL_STRICT: boolean = false; diff --git a/packages/viewer-charts/src/ts/data/lazy-row.ts b/packages/viewer-charts/src/ts/data/lazy-row.ts index ff30f701aa..78208303cd 100644 --- a/packages/viewer-charts/src/ts/data/lazy-row.ts +++ b/packages/viewer-charts/src/ts/data/lazy-row.ts @@ -47,7 +47,10 @@ export class LazyRowFetcher { } async fetchRow(rowIdx: number): Promise { - if (!this._view) throw new Error("LazyRowFetcher disposed"); + if (!this._view) { + throw new Error("LazyRowFetcher disposed"); + } + const cached = this._cache.get(rowIdx); if (cached) { // LRU touch: re-insert to move to tail. @@ -55,19 +58,28 @@ export class LazyRowFetcher { this._cache.set(rowIdx, cached); return cached; } + const inflight = this._inFlight.get(rowIdx); - if (inflight) return inflight; + if (inflight) { + return inflight; + } const p = this._fetch(rowIdx); this._inFlight.set(rowIdx, p); try { const result = await p; - if (!this._view) return result; // disposed mid-flight + if (!this._view) { + return result; + } // disposed mid-flight + this._cache.set(rowIdx, result); if (this._cache.size > this._maxCacheSize) { const oldest = this._cache.keys().next().value; - if (oldest !== undefined) this._cache.delete(oldest); + if (oldest !== undefined) { + this._cache.delete(oldest); + } } + return result; } finally { this._inFlight.delete(rowIdx); @@ -76,7 +88,10 @@ export class LazyRowFetcher { private async _fetch(rowIdx: number): Promise { const view = this._view; - if (!view) throw new Error("LazyRowFetcher disposed"); + if (!view) { + throw new Error("LazyRowFetcher disposed"); + } + const row: LazyRow = new Map(); await (view as any).with_typed_arrays( { @@ -92,7 +107,10 @@ export class LazyRowFetcher { ) => { for (let i = 0; i < names.length; i++) { const name = names[i]; - if (name.startsWith("__")) continue; + if (name.startsWith("__")) { + continue; + } + const vals = values[i]; const valid = validities[i]; const dict = dictionaries[i]; diff --git a/packages/viewer-charts/src/ts/data/split-groups.ts b/packages/viewer-charts/src/ts/data/split-groups.ts index 59994f9138..6450fe716d 100644 --- a/packages/viewer-charts/src/ts/data/split-groups.ts +++ b/packages/viewer-charts/src/ts/data/split-groups.ts @@ -13,9 +13,14 @@ import type { ColumnDataMap } from "./view-reader"; export interface SplitGroup { - /** Composite prefix (e.g., "East", "East|Enterprise" for multi-level). */ + /** + * Composite prefix (e.g., "East", "East|Enterprise" for multi-level). + */ prefix: string; - /** Map of base column name → full Arrow column name ("prefix|base"). */ + + /** + * Map of base column name → full Arrow column name ("prefix|base"). + */ colNames: Map; } @@ -34,11 +39,20 @@ export function buildSplitGroups( ): SplitGroup[] { const prefixCols = new Map>(); for (const key of columns.keys()) { - if (key.startsWith("__")) continue; + if (key.startsWith("__")) { + continue; + } + const pipeIdx = key.lastIndexOf("|"); - if (pipeIdx === -1) continue; + if (pipeIdx === -1) { + continue; + } + const prefix = key.substring(0, pipeIdx); - if (!prefixCols.has(prefix)) prefixCols.set(prefix, new Set()); + if (!prefixCols.has(prefix)) { + prefixCols.set(prefix, new Set()); + } + prefixCols.get(prefix)!.add(key); } @@ -47,25 +61,37 @@ export function buildSplitGroups( const resolved = new Map(); let ok = true; for (const base of requiredBases) { - if (!base) continue; + if (!base) { + continue; + } + const full = `${prefix}|${base}`; const col = columns.get(full); if (!keys.has(full) || !col?.values) { ok = false; break; } + resolved.set(base, full); } - if (!ok) continue; + + if (!ok) { + continue; + } for (const base of optionalBases) { - if (!base) continue; + if (!base) { + continue; + } + const full = `${prefix}|${base}`; if (keys.has(full) && columns.get(full)?.values) { resolved.set(base, full); } } + out.push({ prefix, colNames: resolved }); } + return out; } diff --git a/packages/viewer-charts/src/ts/data/view-reader.ts b/packages/viewer-charts/src/ts/data/view-reader.ts index 5393c6cfce..4673a2578a 100644 --- a/packages/viewer-charts/src/ts/data/view-reader.ts +++ b/packages/viewer-charts/src/ts/data/view-reader.ts @@ -13,13 +13,22 @@ import type { View } from "@perspective-dev/client"; export interface ColumnData { - type: "float32" | "int32" | "string"; - values?: Float32Array | Int32Array; - /** Dictionary key indices for string columns. */ + type: "float32" | "float64" | "int32" | "string"; + values?: Float32Array | Float64Array | Int32Array; + + /** + * Dictionary key indices for string columns. + */ indices?: Int32Array; - /** Dictionary values for string columns. */ + + /** + * Dictionary values for string columns. + */ dictionary?: string[]; - /** Arrow validity bitfield (1 bit per row). */ + + /** + * Arrow validity bitfield (1 bit per row). + */ valid?: Uint8Array; } @@ -75,12 +84,12 @@ export async function viewToColumnDataMap( } else if (vals instanceof Int32Array) { result.set(name, { type: "int32", values: vals, valid }); } else if (vals instanceof Float64Array) { - // Float64 without float32 mode — narrow for WebGL - result.set(name, { - type: "float32", - values: new Float32Array(vals), - valid, - }); + // Datetime/Date columns are emitted as Float64 to keep + // millisecond precision; numeric Float64 also lands here + // when `float32` mode is off. Keep them as f64 — the + // chart's CPU mirrors and extents will rebase to f32 at + // upload time. + result.set(name, { type: "float64", values: vals, valid }); } else { // Fallback: treat as float32 // TODO: Instance check if this needs a copy? diff --git a/packages/viewer-charts/src/ts/event-detail.ts b/packages/viewer-charts/src/ts/event-detail.ts new file mode 100644 index 0000000000..4daf7bdce0 --- /dev/null +++ b/packages/viewer-charts/src/ts/event-detail.ts @@ -0,0 +1,44 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import type { ViewConfig } from "@perspective-dev/client"; + +/** + * Detail payload for the `perspective-click` CustomEvent dispatched on + * the `` host when the user clicks a chart glyph. + * Shape matches the equivalent type in `@perspective-dev/viewer-datagrid` + * so consumers can listen to either plugin uniformly. + */ +export interface PerspectiveClickDetail { + /** + * Source-view row data — keys are column names, values are scalar + * cell values. Empty `{}` when the click hit a non-leaf aggregate + * (e.g. a treemap branch). + */ + row: Record; + + /** + * Aggregate column(s) the click targeted. Single-entry for series / + * heatmap / tree charts; multi-entry would only appear for plugins + * that surface multiple measures per glyph (none today). + */ + column_names: string[]; + + /** + * Pre-built `viewer.restore({ filter })` patch — concatenates the + * group_by and split_by pivot values at the click target as + * `[, "==", ]` clauses. + */ + config: Partial; +} + +export { PerspectiveSelectDetail } from "@perspective-dev/viewer/src/ts/extensions.js"; diff --git a/packages/viewer-charts/src/ts/index.ts b/packages/viewer-charts/src/ts/index.ts index fbedf58aae..2fba320c3c 100644 --- a/packages/viewer-charts/src/ts/index.ts +++ b/packages/viewer-charts/src/ts/index.ts @@ -12,37 +12,9 @@ import CHARTS from "./plugin/charts"; import { HTMLPerspectiveViewerWebGLPluginElement } from "./plugin/plugin"; -import { ScatterChart, LineChart } from "./charts/continuous/continuous-chart"; -import { TreemapChart } from "./charts/treemap/treemap"; -import { SunburstChart } from "./charts/sunburst/sunburst"; -import { BarChart, XBarChart } from "./charts/bar/bar"; -import { HeatmapChart } from "./charts/heatmap/heatmap"; -import { CandlestickChart } from "./charts/candlestick/candlestick"; -const CHART_IMPLS: Record<(typeof CHARTS)[number]["tag"], new () => any> = { - scatter: ScatterChart, - line: LineChart, - treemap: TreemapChart, - sunburst: SunburstChart, - heatmap: HeatmapChart, - - // All four Y-series plugins share BarChart; they differ only in the - // per-plugin default `chart_type` forwarded via `setDefaultChartType` - // during plugin setup. - "y-bar": BarChart, - "y-line": BarChart, - "y-scatter": BarChart, - "y-area": BarChart, - - // X Bar is the horizontal orientation of the same chart class. - "x-bar": XBarChart, - - // Both candlestick-family plugins share one impl; the render path - // branches on `_defaultChartType` (set from `default_chart_type` in - // the plugin config) to pick the glyph. - candlestick: CandlestickChart, - ohlc: CandlestickChart, -}; +export type { PerspectiveClickDetail } from "./event-detail"; +export { PerspectiveSelectDetail } from "./event-detail"; export function register(...plugin_names: string[]) { const plugins = new Set( @@ -54,17 +26,19 @@ export function register(...plugin_names: string[]) { CHARTS.forEach((chart) => { if (plugins.has(chart.name)) { const tagName = `perspective-viewer-charts-${chart.tag}`; - const ImplClass = CHART_IMPLS[chart.tag]; + + // Each registered tag is a thin subclass that pins + // `_chartType` so `draw()` / `save()` / etc. know which + // `ChartTypeConfig` they're driving. The chart impl + // class itself lives in the worker bundle — only + // `ChartTypeConfig.tag` crosses the host/renderer + // boundary, and the renderer constructs the impl from + // its own `CHART_IMPLS` registry. customElements.define( tagName, class extends HTMLPerspectiveViewerWebGLPluginElement { _chartType = chart; static _chartType = chart; - - constructor() { - super(); - (this as any)._chartImpl = new ImplClass(); - } }, ); diff --git a/packages/viewer-charts/src/ts/interaction/hit-test.ts b/packages/viewer-charts/src/ts/interaction/hit-test.ts index 74b5c99006..acc4829ec3 100644 --- a/packages/viewer-charts/src/ts/interaction/hit-test.ts +++ b/packages/viewer-charts/src/ts/interaction/hit-test.ts @@ -55,6 +55,7 @@ export class SpatialHitTester { this._dirty = false; return; } + const xRange = bounds.xMax - bounds.xMin || 1; const yRange = bounds.yMax - bounds.yMin || 1; const avgRange = (xRange + yRange) / 2; @@ -71,7 +72,9 @@ export class SpatialHitTester { this._dirty = false; } - /** Query the nearest point within `radiusPx` of (dataX, dataY). */ + /** + * Query the nearest point within `radiusPx` of (dataX, dataY). + */ query( dataX: number, dataY: number, @@ -81,7 +84,10 @@ export class SpatialHitTester { xData: Float32Array | null, yData: Float32Array | null, ): number { - if (!this._grid || !xData || !yData) return -1; + if (!this._grid || !xData || !yData) { + return -1; + } + return this._grid.query( dataX, dataY, diff --git a/packages/viewer-charts/src/ts/interaction/host-sink-dom.ts b/packages/viewer-charts/src/ts/interaction/host-sink-dom.ts new file mode 100644 index 0000000000..c1a8216381 --- /dev/null +++ b/packages/viewer-charts/src/ts/interaction/host-sink-dom.ts @@ -0,0 +1,85 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import type { CssBounds, HostSink } from "./tooltip-controller"; + +/** + * Host-side `HostSink` that materializes pinned tooltips as a `
` + * next to the GL canvas, and applies cursor changes to the canvas's + * own `style.cursor`. Host-only — depends on `document` / + * `getComputedStyle`. + */ +export class DomHostSink implements HostSink { + private _glCanvas: HTMLCanvasElement; + private _parent: HTMLElement; + private _div: HTMLDivElement | null = null; + + constructor(glCanvas: HTMLCanvasElement, parent: HTMLElement) { + this._glCanvas = glCanvas; + this._parent = parent; + } + + pin( + lines: string[], + pos: { px: number; py: number }, + bounds: CssBounds, + ): void { + this.dismiss(); + const div = document.createElement("div"); + + div.className = "webgl-tooltip"; + div.style.maxHeight = `${Math.round(bounds.cssHeight * 0.6)}px`; + div.textContent = lines.join("\n"); + + if (getComputedStyle(this._parent).position === "static") { + this._parent.style.position = "relative"; + } + + div.style.left = "-9999px"; + div.style.top = "0px"; + this._parent.appendChild(div); + this._div = div; + const divW = div.getBoundingClientRect().width; + const divH = div.getBoundingClientRect().height; + let tx = pos.px + 12; + let ty = pos.py - divH - 8; + if (tx + divW > bounds.cssWidth) { + tx = pos.px - divW - 12; + } + + if (tx < 0) { + tx = 4; + } + + if (ty < 0) { + ty = pos.py + 12; + } + + if (ty + divH > bounds.cssHeight) { + ty = bounds.cssHeight - divH - 4; + } + + div.style.left = `${tx}px`; + div.style.top = `${ty}px`; + } + + dismiss(): void { + if (this._div) { + this._div.remove(); + this._div = null; + } + } + + setCursor(cursor: string): void { + this._glCanvas.style.cursor = cursor; + } +} diff --git a/packages/viewer-charts/src/ts/interaction/host-sink-message.ts b/packages/viewer-charts/src/ts/interaction/host-sink-message.ts new file mode 100644 index 0000000000..922a328212 --- /dev/null +++ b/packages/viewer-charts/src/ts/interaction/host-sink-message.ts @@ -0,0 +1,75 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import type { + CssBounds, + HostSink, + UserClickPayload, + UserSelectPayload, +} from "./tooltip-controller"; + +/** + * Envelope shape sent by `MessageHostSink`. The transport translates + * each one into a corresponding `WorkerMsg` (`pinTooltip` / + * `dismissTooltip` / `setCursor` / `userClick` / `userSelect`). + */ +export type HostSinkEnvelope = + | { + kind: "pin"; + payload: { + lines: string[]; + pos: { px: number; py: number }; + bounds: CssBounds; + }; + } + | { kind: "dismiss" } + | { kind: "setCursor"; cursor: string } + | { kind: "userClick"; payload: UserClickPayload } + | { kind: "userSelect"; payload: UserSelectPayload }; + +/** + * `HostSink` that posts pin / dismiss / setCursor / user-event intents + * over a `postMessage`-style channel. The host-side transport listens + * for these envelopes and drives a `DomHostSink` for pin/dismiss and + * dispatches `CustomEvent`s on the viewer for user events. + */ +export class MessageHostSink implements HostSink { + private _send: (msg: HostSinkEnvelope) => void; + + constructor(send: (msg: HostSinkEnvelope) => void) { + this._send = send; + } + + pin( + lines: string[], + pos: { px: number; py: number }, + bounds: CssBounds, + ): void { + this._send({ kind: "pin", payload: { lines, pos, bounds } }); + } + + dismiss(): void { + this._send({ kind: "dismiss" }); + } + + setCursor(cursor: string): void { + this._send({ kind: "setCursor", cursor }); + } + + emitUserClick(payload: UserClickPayload): void { + this._send({ kind: "userClick", payload }); + } + + emitUserSelect(payload: UserSelectPayload): void { + this._send({ kind: "userSelect", payload }); + } +} diff --git a/packages/viewer-charts/src/ts/interaction/lazy-tooltip.ts b/packages/viewer-charts/src/ts/interaction/lazy-tooltip.ts new file mode 100644 index 0000000000..aeb40ba4e9 --- /dev/null +++ b/packages/viewer-charts/src/ts/interaction/lazy-tooltip.ts @@ -0,0 +1,102 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +/** + * Serial-checked async tooltip cache. + * + * Every chart family that paints tooltip text from a lazy row fetch + * needs the same dance: each hover (or pin) bumps a serial; the async + * line-build resolves later; if the user has moved on by then the + * resolved lines must be discarded so we don't paint stale text. + * + * Hit-test logic stays per-chart — that genuinely diverges (spatial + * grid for cartesian, walk-visible-nodes for treemap, angular for + * sunburst). The serial discipline is what was getting copy-pasted. + * + * `Target` is whatever identity the chart uses for hover/pin (flat + * slot index, node id, etc.); it's stored on `hoveredTarget` so the + * render path can tell whether cached `lines` belong to the currently + * hovered entity (some charts paint tooltip text only when both + * agree). + */ +export class LazyTooltip { + /** + * Cached lines for the latest committed hover, or `null`. + */ + lines: string[] | null = null; + + /** + * Identity of the entity `lines` describe. `null` when cleared. + */ + hoveredTarget: Target | null = null; + + private _hoverSerial = 0; + private _pinSerial = 0; + + /** + * Begin a new hover. Call this only when the hovered entity has + * actually changed. Clears the cached lines, records the new + * target, bumps the hover serial, and returns the new value — pass + * it back to {@link commitHover} from your async resolver to gate + * the write. + */ + beginHover(target: Target): number { + this.lines = null; + this.hoveredTarget = target; + return ++this._hoverSerial; + } + + /** + * Commit a freshly-resolved line list for `serial`. Returns true + * when the write happened (caller should repaint), false when the + * serial was stale. + */ + commitHover(serial: number, lines: string[]): boolean { + if (serial !== this._hoverSerial) { + return false; + } + + this.lines = lines; + return true; + } + + /** + * Clear hover state (mouse left, view changed, etc.). + */ + clearHover(): void { + this.lines = null; + this.hoveredTarget = null; + this._hoverSerial++; + } + + /** + * Begin a pin operation. Returns the serial; pass it to + * {@link isPinFresh} from your async resolver. + */ + beginPin(): number { + return ++this._pinSerial; + } + + /** + * True when `serial` still names the latest pin attempt. + */ + isPinFresh(serial: number): boolean { + return serial === this._pinSerial; + } + + /** + * Bump the pin serial without starting a new pin (e.g. dismiss). + */ + invalidatePin(): void { + this._pinSerial++; + } +} diff --git a/packages/viewer-charts/src/ts/interaction/raw-event-forwarder.ts b/packages/viewer-charts/src/ts/interaction/raw-event-forwarder.ts new file mode 100644 index 0000000000..9fb306230c --- /dev/null +++ b/packages/viewer-charts/src/ts/interaction/raw-event-forwarder.ts @@ -0,0 +1,175 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import type { InteractionEvent } from "../transport/protocol"; + +/** + * Worker-mode counterpart to {@link ZoomRouter}. Captures wheel / + * pointer events on the GL canvas, normalizes coords to canvas-relative + * CSS pixels, and emits semantic {@link InteractionEvent}s to the + * Renderer over its transport. The Renderer (running in a Web Worker) + * owns the `ZoomController`s and runs the actual hit-test + apply + * logic — see `applyWheel` / `applyPan` in `zoom-router.ts`. + * + * Pointer capture is set on `pointerdown` and released on `pointerup` + * so drags continue to deliver `pointermove` events when the cursor + * leaves the canvas, matching the in-process `ZoomRouter` behavior. + */ +export class RawEventForwarder { + private _element: HTMLElement | null = null; + private _emit: ((e: InteractionEvent) => void) | null = null; + private _pointerId: number | null = null; + + private _onWheel: ((e: WheelEvent) => void) | null = null; + private _onPointerDown: ((e: PointerEvent) => void) | null = null; + private _onPointerMove: ((e: PointerEvent) => void) | null = null; + private _onPointerUp: ((e: PointerEvent) => void) | null = null; + private _onPointerLeave: (() => void) | null = null; + private _onClick: ((e: MouseEvent) => void) | null = null; + private _onDblClick: ((e: MouseEvent) => void) | null = null; + + attach(element: HTMLElement, emit: (e: InteractionEvent) => void): void { + this.detach(); + this._element = element; + this._emit = emit; + + this._onWheel = (e: WheelEvent) => { + const rect = element.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + + // Match `ZoomRouter`: only consume the wheel event if the + // cursor is over the canvas. `preventDefault` is fired + // unconditionally so the page does not scroll while the + // chart is hovered — the worker may still no-op if the + // cursor is outside any facet's plot rect. + e.preventDefault(); + emit({ type: "wheel", mx, my, deltaY: e.deltaY }); + }; + + this._onPointerDown = (e: PointerEvent) => { + const rect = element.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + element.setPointerCapture(e.pointerId); + this._pointerId = e.pointerId; + emit({ type: "pointerdown", mx, my, pointerId: e.pointerId }); + }; + + this._onPointerMove = (e: PointerEvent) => { + const rect = element.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + emit({ type: "pointermove", mx, my }); + }; + + this._onPointerUp = (e: PointerEvent) => { + if (this._pointerId !== null) { + try { + element.releasePointerCapture(this._pointerId); + } catch { + // capture may have already been released + } + + this._pointerId = null; + } + + void e; + emit({ type: "pointerup" }); + }; + + // Pointerleave gives the renderer a single signal that the + // cursor truly exited the canvas — used for tooltip dismissal. + // Hover + drag both ride the `pointermove` stream above, so no + // parallel `mousemove` channel exists. + this._onPointerLeave = () => { + emit({ type: "pointerleave" }); + }; + + this._onClick = (e: MouseEvent) => { + const rect = element.getBoundingClientRect(); + emit({ + type: "click", + mx: e.clientX - rect.left, + my: e.clientY - rect.top, + }); + }; + + this._onDblClick = (e: MouseEvent) => { + const rect = element.getBoundingClientRect(); + emit({ + type: "dblclick", + mx: e.clientX - rect.left, + my: e.clientY - rect.top, + }); + }; + + element.addEventListener("wheel", this._onWheel, { passive: false }); + element.addEventListener("pointerdown", this._onPointerDown); + element.addEventListener("pointermove", this._onPointerMove); + element.addEventListener("pointerup", this._onPointerUp); + element.addEventListener("pointerleave", this._onPointerLeave); + element.addEventListener("click", this._onClick); + element.addEventListener("dblclick", this._onDblClick); + } + + detach(): void { + if (this._element) { + if (this._onWheel) { + this._element.removeEventListener("wheel", this._onWheel); + } + + if (this._onPointerDown) { + this._element.removeEventListener( + "pointerdown", + this._onPointerDown, + ); + } + + if (this._onPointerMove) { + this._element.removeEventListener( + "pointermove", + this._onPointerMove, + ); + } + + if (this._onPointerUp) { + this._element.removeEventListener( + "pointerup", + this._onPointerUp, + ); + } + + if (this._onPointerLeave) { + this._element.removeEventListener( + "pointerleave", + this._onPointerLeave, + ); + } + + if (this._onClick) { + this._element.removeEventListener("click", this._onClick); + } + + if (this._onDblClick) { + this._element.removeEventListener("dblclick", this._onDblClick); + } + } + + this._element = null; + this._emit = null; + this._pointerId = null; + this._onPointerLeave = null; + this._onClick = null; + this._onDblClick = null; + } +} diff --git a/packages/viewer-charts/src/ts/interaction/spatial-grid.ts b/packages/viewer-charts/src/ts/interaction/spatial-grid.ts index 78d2b1bcc1..75848d17f4 100644 --- a/packages/viewer-charts/src/ts/interaction/spatial-grid.ts +++ b/packages/viewer-charts/src/ts/interaction/spatial-grid.ts @@ -43,6 +43,7 @@ export class SpatialGrid { cell = []; this._cells.set(key, cell); } + cell.push(index); } @@ -78,7 +79,10 @@ export class SpatialGrid { cx++ ) { const cell = this._cells.get(this._cellKey(cx, cy)); - if (!cell) continue; + if (!cell) { + continue; + } + for (const i of cell) { const dx = (xData[i] - dataX) * pxPerDataX; const dy = (yData[i] - dataY) * pxPerDataY; diff --git a/packages/viewer-charts/src/ts/interaction/tooltip-controller.ts b/packages/viewer-charts/src/ts/interaction/tooltip-controller.ts index 0d0516062f..3580d655e8 100644 --- a/packages/viewer-charts/src/ts/interaction/tooltip-controller.ts +++ b/packages/viewer-charts/src/ts/interaction/tooltip-controller.ts @@ -10,32 +10,65 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +import type { Canvas2D, Context2D } from "../charts/canvas-types"; import type { PlotLayout } from "../layout/plot-layout"; import type { Theme } from "../theme/theme"; -/** Minimal positioning input — PlotLayout satisfies this. */ +/** + * Minimal positioning input — PlotLayout satisfies this. + */ export interface CssBounds { cssWidth: number; cssHeight: number; } export interface TooltipCallbacks { - /** RAF-throttled mouse position in CSS pixels, relative to `glCanvas`. */ + /** + * RAF-throttled mouse position in CSS pixels, relative to the GL + * canvas (host already subtracted `getBoundingClientRect`). + */ onHover(mx: number, my: number): void; - /** Fires on mouseleave; skipped while a pinned tooltip is active. */ + + /** + * Fires on mouseleave; skipped while a pinned tooltip is active. + */ onLeave(): void; + /** * Fires on click with mouse position. Return true to consume the click * (skipping the default pin/dismiss flow — used for legend clicks). */ onClickPre?(mx: number, my: number): boolean; - /** Fires when a click should pin the current hover target. */ + + /** + * Fires when a click should pin the current hover target. + */ onPin?(mx: number, my: number): void; + + /** + * Fires on dblclick (treemap drill-up gesture). Optional — charts + * that don't bind a handler simply ignore the event. + */ + onDblClick?(mx: number, my: number): void; + + /** + * Fires when an active pin is dismissed by a click on the + * already-pinned target (the "click again to unpin" gesture in + * `dispatchClick`). Chart impls hook this to emit a + * `perspective-global-filter` with `selected: false`. Does *not* + * fire on the implicit dismiss inside `pin()` that replaces an + * existing pin — that path is followed by a fresh `onPin` which + * emits its own `selected: true`. + */ + onUnpin?(): void; } export interface RenderTooltipOptions { - /** Draw a dashed crosshair at `pos`. Used by scatter/line. */ + /** + * Draw a dashed crosshair at `pos`. Used by scatter/line. + */ crosshair?: boolean; + /** * Draw a ring of `radius` CSS pixels at `pos`. Used to highlight a * hovered point. Omit for bars (where the bar itself highlights). @@ -44,137 +77,222 @@ export interface RenderTooltipOptions { } /** - * Owns tooltip mouse wiring and the pinned-DOM-tooltip lifecycle. - * Composition-friendly: each chart instantiates one, forwards callbacks - * into its own state, and calls the canvas/DOM render helpers. + * Side-channel from the chart back to the host's DOM. The chart calls + * into a sink rather than touching the DOM itself; the host + * materializes the actual visual (`
` for pinned tooltip, cursor + * mutation on the GL canvas). + * + * - `MessageHostSink` (in worker/) — forwards calls over a + * `postMessage`-shaped channel back to + * the host. + * - `DomHostSink` (in host transport) — receives the matching + * envelopes and applies them to the DOM. + * + * The controller's `_pinned` flag is the source of truth for whether + * hover updates are gated; the sink only owns the visual artifact. + */ +export interface HostSink { + pin( + lines: string[], + pos: { px: number; py: number }, + bounds: CssBounds, + ): void; + dismiss(): void; + setCursor(cursor: string): void; + + /** + * Forward a `perspective-click` to the host. Optional — only the + * worker-bound `MessageHostSink` implements it; `DomHostSink` (the + * host-side consumer of pin/dismiss) never sees user-event calls, + * so omits the implementation. + */ + emitUserClick?(detail: UserClickPayload): void; + + /** + * Forward a `perspective-global-filter` to the host with the + * `selected: true` / `selected: false` semantics. The host owns the + * `removeConfigs` history (mirrors datagrid's + * `model._last_insert_configs`); the sink only ships the new state. + */ + emitUserSelect?(payload: UserSelectPayload): void; +} + +/** + * Plain-object payload for `HostSink.emitUserClick`. Matches + * `PerspectiveClickDetail` byte-for-byte; defined locally to avoid a + * cycle through `event-detail.ts`. + */ +export interface UserClickPayload { + row: Record; + column_names: string[]; + config: { filter?: unknown[] }; +} + +/** + * Plain-object payload for `HostSink.emitUserSelect`. The host + * transport reconstructs a `PerspectiveSelectDetail` class instance + * from this plus its cached `_lastInsertConfig`. + */ +export interface UserSelectPayload { + selected: boolean; + row: Record; + column_names: string[]; + insertConfig: { filter?: unknown[] }; +} + +/** + * Owns the hover/click/dblclick state machine and the pinned-tooltip + * lifecycle. The renderer drives this purely through + * `dispatchHover` / `dispatchLeave` / `dispatchClick` / + * `dispatchDblClick` — the host's `RawEventForwarder` captures DOM + * events on the GL canvas and posts them as `InteractionEvent`s. + * + * Pinning + cursor changes go through a {@link HostSink} so the actual + * DOM mutations happen host-side regardless of where the chart runs. */ export class TooltipController { - private _canvas: HTMLCanvasElement | null = null; - private _moveHandler: ((e: MouseEvent) => void) | null = null; - private _leaveHandler: (() => void) | null = null; - private _clickHandler: ((e: MouseEvent) => void) | null = null; + private _callbacks: TooltipCallbacks | null = null; private _hoverRAFId = 0; - private _pinnedDiv: HTMLDivElement | null = null; + private _hoverTimeoutId: ReturnType | null = null; + private _host: HostSink | null = null; + private _pinned = false; get isPinned(): boolean { - return this._pinnedDiv !== null; + return this._pinned; } - attach(glCanvas: HTMLCanvasElement, callbacks: TooltipCallbacks): void { - this.detach(); - this._canvas = glCanvas; - - this._moveHandler = (e: MouseEvent) => { - if (this.isPinned) return; - if (this._hoverRAFId) return; - const rect = glCanvas.getBoundingClientRect(); - const mx = e.clientX - rect.left; - const my = e.clientY - rect.top; - this._hoverRAFId = requestAnimationFrame(() => { - this._hoverRAFId = 0; - callbacks.onHover(mx, my); - }); - }; + /** + * Replace the active host sink. Dismisses any existing pin via the + * prior sink so we never leak a pinned artifact across resets — + * though in practice each chart instance uses one sink for its + * lifetime. + */ + setHost(sink: HostSink): void { + if (this._pinned) { + this._host?.dismiss(); + this._pinned = false; + } - this._leaveHandler = () => { - if (this.isPinned) return; - callbacks.onLeave(); - }; + this._host = sink; + } - this._clickHandler = (e: MouseEvent) => { - const rect = glCanvas.getBoundingClientRect(); - const mx = e.clientX - rect.left; - const my = e.clientY - rect.top; - if (callbacks.onClickPre?.(mx, my)) return; - if (this.isPinned) { - this.dismissPinned(); - return; - } - callbacks.onPin?.(mx, my); - }; + /** + * Forward a cursor change to the host. No-op when no host sink is + * installed (chart constructed without a transport). + */ + setCursor(cursor: string): void { + this._host?.setCursor(cursor); + } - glCanvas.addEventListener("mousemove", this._moveHandler); - glCanvas.addEventListener("mouseleave", this._leaveHandler); - glCanvas.addEventListener("click", this._clickHandler); + /** + * Install the chart's tooltip callbacks. The renderer drives the + * controller via `dispatchHover` / `dispatchLeave` / + * `dispatchClick` / `dispatchDblClick`; this controller never + * touches the DOM directly. + */ + attach(callbacks: TooltipCallbacks): void { + this.detach(); + this._callbacks = callbacks; } detach(): void { - if (this._canvas) { - if (this._moveHandler) - this._canvas.removeEventListener( - "mousemove", - this._moveHandler, - ); - if (this._leaveHandler) - this._canvas.removeEventListener( - "mouseleave", - this._leaveHandler, - ); - if (this._clickHandler) - this._canvas.removeEventListener("click", this._clickHandler); - } - if (this._hoverRAFId) { cancelAnimationFrame(this._hoverRAFId); this._hoverRAFId = 0; } - this._moveHandler = null; - this._leaveHandler = null; - this._clickHandler = null; + if (this._hoverTimeoutId !== null) { + clearTimeout(this._hoverTimeoutId); + this._hoverTimeoutId = null; + } + + this._callbacks = null; } - /** Create a floating DOM tooltip and attach to `parent`. */ - showPinned( - parent: HTMLElement, + /** + * Schedule an `onHover` callback for the given canvas-relative + * coords. Coalesces multiple calls within one animation frame so + * pointer streams don't backlog the chart's hit-test path. + * + * Workers ship with `requestAnimationFrame` (DedicatedWorkerGlobalScope + * exposes it for OffscreenCanvas painting), so the same coalescer + * works in both modes. We fall back to setTimeout if RAF is missing + * (e.g. node tests without a polyfill). + */ + dispatchHover(mx: number, my: number): void { + if (this._pinned || !this._callbacks) { + return; + } + + if (this._hoverRAFId || this._hoverTimeoutId !== null) { + return; + } + + const fire = () => { + this._hoverRAFId = 0; + this._hoverTimeoutId = null; + this._callbacks?.onHover(mx, my); + }; + + if (typeof requestAnimationFrame === "function") { + this._hoverRAFId = requestAnimationFrame(fire); + } else { + this._hoverTimeoutId = setTimeout(fire, 16); + } + } + + dispatchLeave(): void { + if (this._pinned || !this._callbacks) { + return; + } + + this._callbacks.onLeave(); + } + + dispatchClick(mx: number, my: number): void { + if (!this._callbacks) { + return; + } + + if (this._callbacks.onClickPre?.(mx, my)) { + return; + } + + if (this._pinned) { + const cb = this._callbacks; + this.dismiss(); + cb.onUnpin?.(); + return; + } + + this._callbacks.onPin?.(mx, my); + } + + dispatchDblClick(mx: number, my: number): void { + this._callbacks?.onDblClick?.(mx, my); + } + + /** + * Pin a tooltip (or replace an active one). Forwards through the + * configured sink and flips the controller's pinned flag so hover + * dispatch is suppressed until dismissal. + */ + pin( lines: string[], pos: { px: number; py: number }, bounds: CssBounds, ): void { - this.dismissPinned(); - if (lines.length === 0) return; - const div = document.createElement("div"); - // Styling lives in the package's adopted stylesheet under - // `.webgl-tooltip` — keeps theme wiring in CSS (via - // `--psp-webgl--tooltip--*` custom properties) and reserves - // this path for the truly dynamic bits: the bounds-derived - // max-height and the post-measurement position. - div.className = "webgl-tooltip"; - div.style.maxHeight = `${Math.round(bounds.cssHeight * 0.6)}px`; - - div.textContent = lines.join("\n"); - - // The pinned div uses `position: absolute` and anchors to the - // nearest positioned ancestor. Force a positioned parent only - // when it's still `static` — flipping an already-positioned - // parent (e.g. `.webgl-container` which relies on - // `position: absolute` + four-edge insets for sizing) would - // collapse its box. - if (getComputedStyle(parent).position === "static") { - parent.style.position = "relative"; + if (lines.length === 0) { + return; } - div.style.left = "-9999px"; - div.style.top = "0px"; - parent.appendChild(div); - this._pinnedDiv = div; - const divW = div.getBoundingClientRect().width; - const divH = div.getBoundingClientRect().height; - let tx = pos.px + 12; - let ty = pos.py - divH - 8; - if (tx + divW > bounds.cssWidth) tx = pos.px - divW - 12; - if (tx < 0) tx = 4; - if (ty < 0) ty = pos.py + 12; - if (ty + divH > bounds.cssHeight) ty = bounds.cssHeight - divH - 4; - div.style.left = `${tx}px`; - div.style.top = `${ty}px`; + this._host?.pin(lines, pos, bounds); + this._pinned = true; } - dismissPinned(): void { - if (this._pinnedDiv) { - this._pinnedDiv.remove(); - this._pinnedDiv = null; - } + dismiss(): void { + this._host?.dismiss(); + this._pinned = false; } } @@ -188,16 +306,23 @@ export class TooltipController { * proportionally to its distance from the origin. */ export function renderCanvasTooltip( - canvas: HTMLCanvasElement, + canvas: Canvas2D | null, pos: { px: number; py: number }, lines: string[], layout: PlotLayout, theme: Theme, + dpr: number, options: RenderTooltipOptions = {}, ): void { - const ctx = canvas.getContext("2d"); - if (!ctx) return; - const dpr = window.devicePixelRatio || 1; + if (!canvas) { + return; + } + + const ctx = canvas.getContext("2d") as Context2D | null; + if (!ctx) { + return; + } + ctx.save(); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.scale(dpr, dpr); @@ -207,16 +332,26 @@ export function renderCanvasTooltip( let maxWidth = 0; for (const line of lines) { const w = ctx.measureText(line).width; - if (w > maxWidth) maxWidth = w; + if (w > maxWidth) { + maxWidth = w; + } } const boxW = maxWidth + padding * 2; const boxH = lines.length * lineHeight + padding * 2 - 4; let tx = pos.px + 12; let ty = pos.py - boxH - 8; - if (tx + boxW > layout.cssWidth) tx = pos.px - boxW - 12; - if (ty < 0) ty = pos.py + 12; - if (ty + boxH > layout.cssHeight) ty = layout.cssHeight - boxH - 4; + if (tx + boxW > layout.cssWidth) { + tx = pos.px - boxW - 12; + } + + if (ty < 0) { + ty = pos.py + 12; + } + + if (ty + boxH > layout.cssHeight) { + ty = layout.cssHeight - boxH - 4; + } const hasLines = lines.length > 0; diff --git a/packages/viewer-charts/src/ts/interaction/zoom-controller.ts b/packages/viewer-charts/src/ts/interaction/zoom-controller.ts index 9baed6329a..1b14e66741 100644 --- a/packages/viewer-charts/src/ts/interaction/zoom-controller.ts +++ b/packages/viewer-charts/src/ts/interaction/zoom-controller.ts @@ -15,6 +15,7 @@ import type { PlotLayout } from "../layout/plot-layout"; export interface ZoomState { scaleX: number; scaleY: number; + // Translate as fraction of base domain range (0 = centered, ±0.5 = edge) normTranslateX: number; normTranslateY: number; @@ -27,17 +28,74 @@ export interface ZoomState { * `lockAxis` pins one axis at `scale=1, translate=0` and suppresses * wheel / pan updates on that axis, leaving the other axis fully * zoomable. Categorical axes are the typical candidates. + * + * `lockAspect` keeps `dataPerPixel` equal on both axes by padding the + * narrower axis of the rendered domain to match the plot rect's aspect + * ratio. Required by map plugins (Mercator preserves local angle, so + * map glyphs distort under independent X/Y zoom), and useful for any + * cartesian view where stretching points along one axis would lie + * about the data. */ export interface ZoomConfig { lockAxis?: "x" | "y" | null; + lockAspect?: boolean; } export const MAX_ZOOM = 100_000; export const MIN_ZOOM = 1; +/** + * Pad the narrower axis of `domain` so its aspect ratio matches + * `plotRect`. Preserves the center of each axis and the longer axis's + * extent — only the shorter axis grows. Returns the input unmodified + * if either dimension is non-positive. + * + * Used by `lockAspect` mode to keep `dataPerPixel` equal on both axes, + * which is what map plugins (Mercator) and any "square pixel" view + * require. + */ +function applyAspectLock( + domain: { xMin: number; xMax: number; yMin: number; yMax: number }, + plotRect: { width: number; height: number }, +): { xMin: number; xMax: number; yMin: number; yMax: number } { + if (plotRect.width <= 0 || plotRect.height <= 0) { + return domain; + } + + const xRange = domain.xMax - domain.xMin; + const yRange = domain.yMax - domain.yMin; + if (xRange <= 0 || yRange <= 0) { + return domain; + } + + const plotAspect = plotRect.width / plotRect.height; + const dataAspect = xRange / yRange; + + if (dataAspect < plotAspect) { + const cx = (domain.xMin + domain.xMax) / 2; + const newX = (yRange * plotAspect) / 2; + return { + xMin: cx - newX, + xMax: cx + newX, + yMin: domain.yMin, + yMax: domain.yMax, + }; + } else { + const cy = (domain.yMin + domain.yMax) / 2; + const newY = xRange / plotAspect / 2; + return { + xMin: domain.xMin, + xMax: domain.xMax, + yMin: cy - newY, + yMax: cy + newY, + }; + } +} + export class ZoomController { private _scaleX = 1; private _scaleY = 1; + // Normalized translate: fraction of base domain range private _normTX = 0; private _normTY = 0; @@ -48,6 +106,7 @@ export class ZoomController { private _baseYMax = 1; private _lockAxis: "x" | "y" | null = null; + private _lockAspect = false; private _element: HTMLElement | null = null; private _layout: PlotLayout | null = null; @@ -99,16 +158,62 @@ export class ZoomController { return this._baseYMax - this._baseYMin; } + /** + * Update the base (full-data) domain that this controller's + * normalized translate is interpreted against, while preserving + * the *absolute* center of any user-applied pan. + * + * `_normTX` / `_normTY` are stored as fractions of the base + * range, not absolute coordinates. A naive "swap base, keep + * normTranslate" update reinterprets the same fraction against a + * new range, so when an external `draw()` updates the extent + * (via `processCartesianChunk` → `setZoomBaseDomain`) the user's + * pan-offset visible center jumps to a different absolute + * position. With concurrent pan events feeding in offsets that + * were computed against the old base, the jump can project the + * visible center past the data entirely, leaving `_fullRender` + * to draw zero glyphs onto a freshly-cleared canvas — a blank + * bitmap reaches the host as a flicker. + * + * When the user is in default state (no pan, no zoom — fresh + * controller, or just-reset), no rebase is needed; just swap the + * base and let the chart auto-fit to the new data. Otherwise + * recompute `_normTX` / `_normTY` so the visible center stays at + * the same absolute (data-coordinate) position before and after + * the swap. + */ setBaseDomain( xMin: number, xMax: number, yMin: number, yMax: number, ): void { + if (this.isDefault()) { + this._baseXMin = xMin; + this._baseXMax = xMax; + this._baseYMin = yMin; + this._baseYMax = yMax; + return; + } + + const oldRangeX = this._baseXMax - this._baseXMin; + const oldRangeY = this._baseYMax - this._baseYMin; + const oldCx = + (this._baseXMin + this._baseXMax) / 2 + this._normTX * oldRangeX; + const oldCy = + (this._baseYMin + this._baseYMax) / 2 + this._normTY * oldRangeY; + this._baseXMin = xMin; this._baseXMax = xMax; this._baseYMin = yMin; this._baseYMax = yMax; + + const newRangeX = xMax - xMin; + const newRangeY = yMax - yMin; + this._normTX = + newRangeX > 0 ? (oldCx - (xMin + xMax) / 2) / newRangeX : 0; + this._normTY = + newRangeY > 0 ? (oldCy - (yMin + yMax) / 2) / newRangeY : 0; } /** @@ -119,6 +224,7 @@ export class ZoomController { */ configure(config: ZoomConfig): void { this._lockAxis = config.lockAxis ?? null; + this._lockAspect = config.lockAspect ?? false; if (this._lockAxis === "x") { this._scaleX = 1; this._normTX = 0; @@ -154,12 +260,18 @@ export class ZoomController { const cy = (this._baseYMin + this._baseYMax) / 2 + this._normTY * byRange; - return { + const domain = { xMin: cx - vxRange / 2, xMax: cx + vxRange / 2, yMin: cy - vyRange / 2, yMax: cy + vyRange / 2, }; + + if (this._lockAspect && this._layout) { + return applyAspectLock(domain, this._layout.plotRect); + } + + return domain; } attach( @@ -184,8 +296,9 @@ export class ZoomController { mouseX > plot.x + plot.width || mouseY < plot.y || mouseY > plot.y + plot.height - ) + ) { return; + } // Data coordinate under cursor before zoom const domain = this.getVisibleDomain(); @@ -205,6 +318,7 @@ export class ZoomController { Math.min(MAX_ZOOM, this._scaleX * factor), ); } + if (this._lockAxis !== "y") { this._scaleY = Math.max( MIN_ZOOM, @@ -228,6 +342,7 @@ export class ZoomController { if (this._lockAxis !== "x" && bxRange > 0) { this._normTX += (dataX - newDataX) / bxRange; } + if (this._lockAxis !== "y" && byRange > 0) { this._normTY += (dataY - newDataY) / byRange; } @@ -255,7 +370,10 @@ export class ZoomController { }; this._onPointerMove = (e: PointerEvent) => { - if (!this._pointerDown) return; + if (!this._pointerDown) { + return; + } + const dx = e.clientX - this._lastPointerX; const dy = e.clientY - this._lastPointerY; this._lastPointerX = e.clientX; @@ -271,6 +389,7 @@ export class ZoomController { if (this._lockAxis !== "x" && bxRange > 0) { this._normTX -= (dx * dataPerPixelX) / bxRange; } + if (this._lockAxis !== "y" && byRange > 0) { this._normTY += (dy * dataPerPixelY) / byRange; } @@ -294,24 +413,32 @@ export class ZoomController { detach(): void { if (this._element) { - if (this._onWheel) + if (this._onWheel) { this._element.removeEventListener("wheel", this._onWheel); - if (this._onPointerDown) + } + + if (this._onPointerDown) { this._element.removeEventListener( "pointerdown", this._onPointerDown, ); - if (this._onPointerMove) + } + + if (this._onPointerMove) { this._element.removeEventListener( "pointermove", this._onPointerMove, ); - if (this._onPointerUp) + } + + if (this._onPointerUp) { this._element.removeEventListener( "pointerup", this._onPointerUp, ); + } } + this._element = null; this._onUpdate = null; } diff --git a/packages/viewer-charts/src/ts/interaction/zoom-router.ts b/packages/viewer-charts/src/ts/interaction/zoom-router.ts index 2738484c33..cadb6cffd9 100644 --- a/packages/viewer-charts/src/ts/interaction/zoom-router.ts +++ b/packages/viewer-charts/src/ts/interaction/zoom-router.ts @@ -62,7 +62,10 @@ export class ZoomRouter { const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; const target = resolve(mouseX, mouseY); - if (!target) return; + if (!target) { + return; + } + e.preventDefault(); applyWheel(target, mouseX, mouseY, e.deltaY); onUpdate(); @@ -73,7 +76,10 @@ export class ZoomRouter { const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; const target = resolve(mouseX, mouseY); - if (!target) return; + if (!target) { + return; + } + this._pointerDown = true; this._pointerTarget = target; this._lastPointerX = e.clientX; @@ -82,7 +88,10 @@ export class ZoomRouter { }; this._onPointerMove = (e: PointerEvent) => { - if (!this._pointerDown || !this._pointerTarget) return; + if (!this._pointerDown || !this._pointerTarget) { + return; + } + const dx = e.clientX - this._lastPointerX; const dy = e.clientY - this._lastPointerY; this._lastPointerX = e.clientX; @@ -104,24 +113,32 @@ export class ZoomRouter { detach(): void { if (this._element) { - if (this._onWheel) + if (this._onWheel) { this._element.removeEventListener("wheel", this._onWheel); - if (this._onPointerDown) + } + + if (this._onPointerDown) { this._element.removeEventListener( "pointerdown", this._onPointerDown, ); - if (this._onPointerMove) + } + + if (this._onPointerMove) { this._element.removeEventListener( "pointermove", this._onPointerMove, ); - if (this._onPointerUp) + } + + if (this._onPointerUp) { this._element.removeEventListener( "pointerup", this._onPointerUp, ); + } } + this._element = null; this._resolve = null; this._onUpdate = null; @@ -130,7 +147,13 @@ export class ZoomRouter { } } -function applyWheel( +/** + * Apply a wheel-zoom delta to the resolved target. Exported for + * re-use inside the worker, where the renderer dispatches interaction + * events forwarded from the host's `RawEventForwarder` instead of + * receiving DOM events directly. + */ +export function applyWheel( target: ZoomTarget, mouseX: number, mouseY: number, @@ -155,6 +178,7 @@ function applyWheel( Math.min(MAX_ZOOM, controller.scaleX * factor), ); } + if (locked !== "y") { controller.scaleY = Math.max( MIN_ZOOM, @@ -175,12 +199,18 @@ function applyWheel( if (locked !== "x" && bxRange > 0) { controller.normTranslateX += (dataX - newDataX) / bxRange; } + if (locked !== "y" && byRange > 0) { controller.normTranslateY += (dataY - newDataY) / byRange; } } -function applyPan(target: ZoomTarget, dx: number, dy: number): void { +/** + * Apply a drag-pan delta to the resolved target. Exported for the + * same reason as {@link applyWheel}: worker-mode interaction + * dispatch reuses the math without owning DOM listeners. + */ +export function applyPan(target: ZoomTarget, dx: number, dy: number): void { const { controller, layout } = target; const domain = controller.getVisibleDomain(); const plot = layout.plotRect; @@ -193,6 +223,7 @@ function applyPan(target: ZoomTarget, dx: number, dy: number): void { if (locked !== "x" && bxRange > 0) { controller.normTranslateX -= (dx * dataPerPixelX) / bxRange; } + if (locked !== "y" && byRange > 0) { controller.normTranslateY += (dy * dataPerPixelY) / byRange; } diff --git a/packages/viewer-charts/src/ts/layout/facet-grid.ts b/packages/viewer-charts/src/ts/layout/facet-grid.ts index 26605ffbf3..42e76944b2 100644 --- a/packages/viewer-charts/src/ts/layout/facet-grid.ts +++ b/packages/viewer-charts/src/ts/layout/facet-grid.ts @@ -34,18 +34,32 @@ export type AxisMode = "outer" | "cell" | "none"; export interface FacetGridOptions { cssWidth: number; cssHeight: number; - /** See {@link AxisMode}. Default `"cell"`. */ + + /** + * See {@link AxisMode}. Default `"cell"`. + */ xAxis?: AxisMode; - /** See {@link AxisMode}. Default `"cell"`. */ + + /** + * See {@link AxisMode}. Default `"cell"`. + */ yAxis?: AxisMode; - /** Reserve a right gutter for a single shared legend. */ + + /** + * Reserve a right gutter for a single shared legend. + */ hasLegend?: boolean; + /** Axis-label allowance (consumed only when the corresponding axis * mode produces a gutter — outer band or per-cell). */ hasXLabel?: boolean; hasYLabel?: boolean; - /** Per-facet title strip height (px). 0 disables. */ + + /** + * Per-facet title strip height (px). 0 disables. + */ titleBand?: number; + /** * Pixel gap between adjacent cells. Carved out of the grid * interior before cell sizing; outer edges of the leftmost / @@ -58,6 +72,7 @@ export interface FacetGridOptions { export interface FacetCell { index: number; label: string; + /** * Sub-plot layout. Every cell in a grid has *identical* * `plotRect.width` and `plotRect.height` — cell internal margins @@ -66,7 +81,10 @@ export interface FacetCell { * once per frame by the caller. */ layout: PlotLayout; - /** Title strip above the facet's plot rect, if `titleBand > 0`. */ + + /** + * Title strip above the facet's plot rect, if `titleBand > 0`. + */ titleRect?: PlotRect; isLeftEdge: boolean; isBottomEdge: boolean; @@ -74,8 +92,12 @@ export interface FacetCell { export interface FacetGrid { cells: FacetCell[]; - /** Right-gutter rect for the shared legend. */ + + /** + * Right-gutter rect for the shared legend. + */ legendRect?: PlotRect; + /** * Outer band reserved for the shared X axis (ticks + label). Only * set when `xAxis === "outer"`. Spans the grid interior's @@ -83,6 +105,7 @@ export interface FacetGrid { * cells. */ outerXAxisRect?: PlotRect; + /** * Outer band reserved for the shared Y axis (ticks + label). Only * set when `yAxis === "outer"`. Spans the grid interior's @@ -92,6 +115,23 @@ export interface FacetGrid { outerYAxisRect?: PlotRect; } +/** + * Collect the bottom-row cells' `PlotLayout`s — i.e. the cells that + * sit on the grid's bottom edge. Shared-X axis renderers paint X + * ticks aligned to each of these. Empty when the grid has zero cells. + */ +export function bottomRowLayouts(grid: FacetGrid): PlotLayout[] { + return grid.cells.filter((c) => c.isBottomEdge).map((c) => c.layout); +} + +/** + * Collect the left-column cells' `PlotLayout`s — symmetric to + * {@link bottomRowLayouts} for the shared-Y axis path. + */ +export function leftColumnLayouts(grid: FacetGrid): PlotLayout[] { + return grid.cells.filter((c) => c.isLeftEdge).map((c) => c.layout); +} + // Per-cell internal gutter defaults mirror `PlotLayout`'s constants so // that a cell with `leftExtra: undefined` reserves the same space the // outer band would reserve when the axis is shared. Keep these in sync @@ -117,7 +157,10 @@ function pickGridShape( gridH: number, gap: number, ): { cols: number; rows: number } { - if (count <= 1) return { cols: 1, rows: 1 }; + if (count <= 1) { + return { cols: 1, rows: 1 }; + } + let bestCols = 1; let bestRows = count; let bestCost = Infinity; @@ -136,6 +179,7 @@ function pickGridShape( bestTotal = total; } } + return { cols: bestCols, rows: bestRows }; } @@ -180,6 +224,7 @@ export function buildFacetGrid( const xMode: AxisMode = opts.xAxis ?? "cell"; const yMode: AxisMode = opts.yAxis ?? "cell"; + // Axis-less chart types (trees) benefit from fully-flush cells — // no per-cell breathing on the right either, so adjacent plot // rects share a boundary instead of leaving a 16 px seam. diff --git a/packages/viewer-charts/src/ts/layout/plot-layout.ts b/packages/viewer-charts/src/ts/layout/plot-layout.ts index 54f3cbb060..ee938760b2 100644 --- a/packages/viewer-charts/src/ts/layout/plot-layout.ts +++ b/packages/viewer-charts/src/ts/layout/plot-layout.ts @@ -28,6 +28,7 @@ export interface PlotLayoutOptions { hasXLabel: boolean; hasYLabel: boolean; hasLegend: boolean; + /** * Additional CSS-pixel height reserved at the bottom of the plot for a * hierarchical / rotated categorical X axis. Overrides the default 24px @@ -147,7 +148,7 @@ export class PlotLayout { * * `clamp`, when set, names the axis that carries the *value* (as * opposed to categorical / positional) data. Today it only affects - * `requireZero`; both axes always receive symmetric 2% padding. + * `requireZero`; both axes always receive symmetric `padRatio` padding. * * `requireZero`, when true, guarantees that the unpadded value `0` * falls inside the clamped axis's final domain. For all-positive @@ -155,6 +156,11 @@ export class PlotLayout { * axis line); for all-negative data the maximum is pinned at `0`; * for data that already straddles zero, nothing changes. Pairs with * `clamp`, and is a no-op when `clamp` is unset. + * + * `padRatio` controls the symmetric cosmetic pad on both axes + * (default 2%). Charts that want plot edges flush with the axes + * (e.g. heatmap, whose cell rects already span the exact domain) + * pass `0`. */ buildProjectionMatrix( xMin: number, @@ -163,14 +169,23 @@ export class PlotLayout { yMax: number, clamp?: "x" | "y", requireZero?: boolean, + padRatio: number = 0.02, + xOrigin: number = 0, + yOrigin: number = 0, ): Float32Array { - // Symmetric 2% cosmetic padding on both axes. + // Symmetric cosmetic padding on both axes (default 2%). let xRange = xMax - xMin; let yRange = yMax - yMin; - if (xRange === 0) xRange = 1; - if (yRange === 0) yRange = 1; - const xPad = xRange * 0.02; - const yPad = yRange * 0.02; + if (xRange === 0) { + xRange = 1; + } + + if (yRange === 0) { + yRange = 1; + } + + const xPad = xRange * padRatio; + const yPad = yRange * padRatio; // Evaluate the zero-snap condition against the *pre-pad* // values so that an exact-zero boundary (e.g. bar pipelines @@ -199,6 +214,7 @@ export class PlotLayout { yMax = 0; yMin -= yPad; } + if (snapXMin) { xMin = 0; xMax += xPad; @@ -219,11 +235,18 @@ export class PlotLayout { const clipBottom = (2 * this.margins.bottom) / this.cssHeight - 1; const clipTop = 1 - (2 * this.margins.top) / this.cssHeight; - // Scale and translate: data [min,max] → clip [clipMin, clipMax] + // Scale and translate: rebased data [(min-origin), (max-origin)] + // → clip [clipMin, clipMax]. Callers that ship rebased values to + // the GPU pass the origin they used; for ordinary numeric data + // both origins default to 0 and the math collapses to the + // legacy `tx = clipLeft - sx*xMin` form. With a datetime axis + // the origin lifts ~1.7e12 out of `tx` before the f32 + // narrowing in the matrix below, keeping shader cancellation + // precision-safe down to sub-millisecond granularity. const sx = (clipRight - clipLeft) / (xMax - xMin); const sy = (clipTop - clipBottom) / (yMax - yMin); - const tx = clipLeft - sx * xMin; - const ty = clipBottom - sy * yMin; + const tx = clipLeft - sx * (xMin - xOrigin); + const ty = clipBottom - sy * (yMin - yOrigin); // Column-major 4x4 matrix // prettier-ignore diff --git a/packages/viewer-charts/src/ts/layout/ticks.ts b/packages/viewer-charts/src/ts/layout/ticks.ts index 2541403156..4c8ef9ab93 100644 --- a/packages/viewer-charts/src/ts/layout/ticks.ts +++ b/packages/viewer-charts/src/ts/layout/ticks.ts @@ -15,16 +15,27 @@ function niceNum(value: number, round: boolean): number { const frac = value / Math.pow(10, exp); let nice: number; if (round) { - if (frac < 1.5) nice = 1; - else if (frac < 3) nice = 2; - else if (frac < 7) nice = 5; - else nice = 10; + if (frac < 1.5) { + nice = 1; + } else if (frac < 3) { + nice = 2; + } else if (frac < 7) { + nice = 5; + } else { + nice = 10; + } } else { - if (frac <= 1) nice = 1; - else if (frac <= 2) nice = 2; - else if (frac <= 5) nice = 5; - else nice = 10; + if (frac <= 1) { + nice = 1; + } else if (frac <= 2) { + nice = 2; + } else if (frac <= 5) { + nice = 5; + } else { + nice = 10; + } } + return nice * Math.pow(10, exp); } @@ -39,17 +50,22 @@ export function computeNiceTicks( max: number, targetCount: number, ): number[] { - if (targetCount < 1) targetCount = 1; + if (targetCount < 1) { + targetCount = 1; + } + const range = niceNum(max - min, false); const step = niceNum(range / targetCount, true); const tickMin = Math.ceil(min / step) * step; const tickMax = Math.floor(max / step) * step; const ticks: number[] = []; + // Use epsilon to avoid floating point overshoot for (let t = tickMin; t <= tickMax + step * 0.001; t += step) { ticks.push(t); } + return ticks; } @@ -59,12 +75,30 @@ export function computeNiceTicks( */ export function formatTickValue(val: number): string { const abs = Math.abs(val); - if (abs === 0) return "0"; - if (abs >= 1e9) return (val / 1e9).toFixed(1) + "B"; - if (abs >= 1e6) return (val / 1e6).toFixed(1) + "M"; - if (abs >= 1e3) return (val / 1e3).toFixed(1) + "K"; - if (Number.isInteger(val)) return val.toString(); - if (abs >= 1) return val.toFixed(1); + if (abs === 0) { + return "0"; + } + + if (abs >= 1e9) { + return (val / 1e9).toFixed(1) + "B"; + } + + if (abs >= 1e6) { + return (val / 1e6).toFixed(1) + "M"; + } + + if (abs >= 1e3) { + return (val / 1e3).toFixed(1) + "K"; + } + + if (Number.isInteger(val)) { + return val.toString(); + } + + if (abs >= 1) { + return val.toFixed(1); + } + return val.toFixed(2); } @@ -74,7 +108,9 @@ export function formatTickValue(val: number): string { */ export function formatDateTickValue(val: number, stepMs?: number): string { const d = new Date(val); - if (isNaN(d.getTime())) return formatTickValue(val); + if (isNaN(d.getTime())) { + return formatTickValue(val); + } // If step is provided, choose precision based on tick interval if (stepMs !== undefined && stepMs > 0) { @@ -89,6 +125,7 @@ export function formatDateTickValue(val: number, stepMs?: number): string { month: "short", }); } + if (stepMs >= DAY) { // Daily — show month and day return d.toLocaleDateString(undefined, { @@ -96,6 +133,7 @@ export function formatDateTickValue(val: number, stepMs?: number): string { day: "numeric", }); } + if (stepMs >= HOUR) { // Hourly return d.toLocaleString(undefined, { @@ -104,6 +142,7 @@ export function formatDateTickValue(val: number, stepMs?: number): string { hour: "numeric", }); } + if (stepMs >= MINUTE) { // Minutes return d.toLocaleTimeString(undefined, { @@ -111,6 +150,7 @@ export function formatDateTickValue(val: number, stepMs?: number): string { minute: "2-digit", }); } + // Sub-minute return d.toLocaleTimeString(undefined, { hour: "numeric", diff --git a/packages/viewer-charts/src/ts/map/mercator.ts b/packages/viewer-charts/src/ts/map/mercator.ts new file mode 100644 index 0000000000..4483fe077e --- /dev/null +++ b/packages/viewer-charts/src/ts/map/mercator.ts @@ -0,0 +1,204 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +/** + * Web Mercator projection helpers and XYZ tile-pyramid math. Pure + * functions, no side effects, safe to call from any thread (including + * the renderer worker). + * + * Mercator output is in *meters*, not the normalized [0, 1] form that + * some libraries use. We feed the meter values straight into the + * cartesian projection matrix in `plot-layout.ts`, so the upstream + * domain stays in physical units and per-pixel ground resolution is a + * straightforward division. + */ + +/** + * WGS84 equatorial radius in meters. Matches what every standard tile + * provider (OSM, CartoDB, Mapbox, ...) uses for Web Mercator. + */ +export const EARTH_RADIUS_M = 6378137; + +/** + * Half the world extent in Mercator meters: π · R ≈ 20037508.34. The + * full Mercator square is `[-WORLD_HALF, +WORLD_HALF]` on both axes. + */ +export const WORLD_HALF = Math.PI * EARTH_RADIUS_M; + +/** + * Maximum absolute latitude representable in Web Mercator. Beyond this + * the projection diverges to ±∞; tile providers don't ship tiles + * outside [-MAX_LAT, +MAX_LAT]. Computed as + * `atan(sinh(π)) · 180 / π ≈ 85.0511287798°`. + */ +export const MAX_LAT = 85.0511287798066; + +/** + * Project (longitude, latitude) in degrees to Web Mercator meters. + * + * Latitudes outside ±MAX_LAT return `[NaN, NaN]` so callers in the + * cartesian build path (which already has a post-`projectPoint` NaN + * guard) discard those rows without special-casing. + */ +export function lonLatToMercator(lon: number, lat: number): [number, number] { + if (lat > MAX_LAT || lat < -MAX_LAT) { + return [NaN, NaN]; + } + + const x = (lon * Math.PI * EARTH_RADIUS_M) / 180; + const latRad = (lat * Math.PI) / 180; + const y = EARTH_RADIUS_M * Math.log(Math.tan(Math.PI / 4 + latRad / 2)); + return [x, y]; +} + +/** + * Inverse: Mercator meters → (lon, lat) in degrees. Used by tooltip + * formatting and any UI that surfaces the cursor position to the user. + */ +export function mercatorToLonLat(x: number, y: number): [number, number] { + const lon = (x * 180) / (Math.PI * EARTH_RADIUS_M); + const lat = (Math.atan(Math.exp(y / EARTH_RADIUS_M)) * 360) / Math.PI - 90; + return [lon, lat]; +} + +/** + * A single XYZ tile address. + */ +export interface TileId { + z: number; + x: number; + y: number; +} + +/** + * Mercator extent in meters of one XYZ tile. Y is in the Mercator + * convention (north positive), not the tile-pyramid convention (y=0 + * at the top); the conversion is done inside the helper. + */ +export interface TileExtent { + xMin: number; + yMin: number; + xMax: number; + yMax: number; +} + +/** + * Return the Mercator-meter bounds of an XYZ tile. Tile (0, 0) at + * zoom 0 covers `[-WORLD_HALF, +WORLD_HALF]` on both axes — the whole + * world. Each zoom level subdivides into `2^z × 2^z` equal squares. + */ +export function tileExtent(z: number, x: number, y: number): TileExtent { + const n = 1 << z; + const tileSize = (2 * WORLD_HALF) / n; + const xMin = -WORLD_HALF + x * tileSize; + const xMax = xMin + tileSize; + // Tile y=0 sits at the *top* of the pyramid (north), so flip. + const yMax = WORLD_HALF - y * tileSize; + const yMin = yMax - tileSize; + return { xMin, yMin, xMax, yMax }; +} + +/** + * Pick the integer zoom level whose tile pixel resolution best matches + * the requested Mercator-meters-per-pixel. Snaps to the next coarser + * level so we never undersample (a finer level would fetch tiles only + * to scale them down). + * + * `targetResolutionMpp` is meters per *device pixel*; the caller + * computes it as `(domain.xMax - domain.xMin) / plotRect.width`. + */ +export function pickZoom( + targetResolutionMpp: number, + tileSizePx = 256, + maxZoom = 19, +): number { + if (!isFinite(targetResolutionMpp) || targetResolutionMpp <= 0) { + return 0; + } + + // Resolution at zoom z = (2·WORLD_HALF) / (tileSizePx · 2^z). + // Solve for z; floor so we stay at the next coarser level when in + // between two levels. + const z = Math.log2((2 * WORLD_HALF) / (tileSizePx * targetResolutionMpp)); + return Math.max(0, Math.min(maxZoom, Math.floor(z))); +} + +/** + * Enumerate every visible tile at a single zoom level that intersects + * the given Mercator extent. Returned in left-to-right, top-to-bottom + * order so the layer's render loop produces a deterministic draw + * sequence (helps with debugging tile-load races). + * + * Tiles outside the world's `[0, 2^z)` X range are *not* wrapped — + * antimeridian wraparound is a v2 feature. Callers see a gap when + * panning past ±180° lon; tiles inside the valid range still render. + */ +export function tilesForExtent(extent: TileExtent, z: number): TileId[] { + const n = 1 << z; + const tileSize = (2 * WORLD_HALF) / n; + + // Tile X grows east; tile Y grows south. Convert the extent's + // bounds accordingly. + const xMinTile = Math.floor((extent.xMin + WORLD_HALF) / tileSize); + const xMaxTile = Math.floor((extent.xMax + WORLD_HALF) / tileSize); + const yMinTile = Math.floor((WORLD_HALF - extent.yMax) / tileSize); + const yMaxTile = Math.floor((WORLD_HALF - extent.yMin) / tileSize); + + const result: TileId[] = []; + for (let ty = yMinTile; ty <= yMaxTile; ty++) { + if (ty < 0 || ty >= n) { + continue; + } + + for (let tx = xMinTile; tx <= xMaxTile; tx++) { + if (tx < 0 || tx >= n) { + continue; + } + + result.push({ z, x: tx, y: ty }); + } + } + + return result; +} + +/** + * Return the parent tile (one zoom level coarser) of the given tile, + * along with the [0, 1] UV sub-rect that this tile occupies inside + * its parent. Used by the layer's "draw what we have" fallback while + * a missing target tile is in-flight — the parent texture is sampled + * with the sub-rect so the visible region keeps tile-aligned content + * instead of flashing blank. + * + * Returns `null` for zoom-0 tiles (no parent). + */ +export function parentTile(tile: TileId): { + parent: TileId; + uvMin: [number, number]; + uvMax: [number, number]; +} | null { + if (tile.z <= 0) { + return null; + } + + const parent: TileId = { + z: tile.z - 1, + x: tile.x >> 1, + y: tile.y >> 1, + }; + + const u = tile.x & 1; + const v = tile.y & 1; + const uvMin: [number, number] = [u * 0.5, v * 0.5]; + const uvMax: [number, number] = [uvMin[0] + 0.5, uvMin[1] + 0.5]; + return { parent, uvMin, uvMax }; +} diff --git a/packages/viewer-charts/src/ts/map/tile-cache.ts b/packages/viewer-charts/src/ts/map/tile-cache.ts new file mode 100644 index 0000000000..439b6a2a74 --- /dev/null +++ b/packages/viewer-charts/src/ts/map/tile-cache.ts @@ -0,0 +1,96 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +/** + * Bounded LRU of `WebGLTexture` keyed by tile cache key (see + * `tile-loader.tileKey`). Evicts least-recently-touched entries on + * insert past `capacity`, deleting their GPU textures. + * + * Default capacity of 256 is enough to cover a full screen worth of + * tiles at zoom-1 jumps either side of the current viewport plus the + * parent fallbacks — at 256×256 RGBA that's ~64 MB of texture memory, + * which sits comfortably under the 256 MB headroom most browsers + * grant to a tab. + */ +export class TileCache { + private _entries = new Map(); + private readonly _capacity: number; + + constructor(capacity = 256) { + this._capacity = capacity; + } + + /** + * Fetch a texture by key. Touching an existing entry moves it to + * the LRU tail so it survives the next eviction sweep. + */ + get(key: string): WebGLTexture | undefined { + const tex = this._entries.get(key); + if (tex !== undefined) { + // Re-insert to push the entry to the tail. + this._entries.delete(key); + this._entries.set(key, tex); + } + + return tex; + } + + /** + * Insert a texture under `key`. If the cache is at capacity, evict + * the oldest entry first (calling `gl.deleteTexture` on its GPU + * resource). + */ + set( + gl: WebGL2RenderingContext | WebGLRenderingContext, + key: string, + texture: WebGLTexture, + ): void { + if (this._entries.has(key)) { + const old = this._entries.get(key)!; + gl.deleteTexture(old); + this._entries.delete(key); + } + + this._entries.set(key, texture); + while (this._entries.size > this._capacity) { + const oldestKey = this._entries.keys().next().value; + if (oldestKey === undefined) { + break; + } + + const tex = this._entries.get(oldestKey)!; + gl.deleteTexture(tex); + this._entries.delete(oldestKey); + } + } + + /** + * Whether a key is resident. Used to gate the "kick off a fetch" + * branch in the layer's render loop. + */ + has(key: string): boolean { + return this._entries.has(key); + } + + /** + * Release every texture. Called on chart destroy. Safe to call + * with a stale `gl` reference (no-op if `deleteTexture` rejects), + * but in practice the caller passes the still-live worker context. + */ + dispose(gl: WebGL2RenderingContext | WebGLRenderingContext): void { + for (const tex of this._entries.values()) { + gl.deleteTexture(tex); + } + + this._entries.clear(); + } +} diff --git a/packages/viewer-charts/src/ts/map/tile-layer.ts b/packages/viewer-charts/src/ts/map/tile-layer.ts new file mode 100644 index 0000000000..330655f3dd --- /dev/null +++ b/packages/viewer-charts/src/ts/map/tile-layer.ts @@ -0,0 +1,382 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import type { WebGLContextManager } from "../webgl/context-manager"; +import type { PlotLayout } from "../layout/plot-layout"; +import { pickZoom, tilesForExtent, tileExtent, type TileId } from "./mercator"; +import { TileCache } from "./tile-cache"; +import { TileLoader, tileKey } from "./tile-loader"; +import type { TileSource } from "./tile-source"; +import tileVert from "../shaders/tile.vert.glsl"; +import tileFrag from "../shaders/tile.frag.glsl"; + +type GL = WebGL2RenderingContext | WebGLRenderingContext; + +interface TileProgramCache { + program: WebGLProgram; + u_projection: WebGLUniformLocation | null; + u_extent_min: WebGLUniformLocation | null; + u_extent_max: WebGLUniformLocation | null; + u_uv_min: WebGLUniformLocation | null; + u_uv_max: WebGLUniformLocation | null; + u_tile: WebGLUniformLocation | null; + u_alpha: WebGLUniformLocation | null; + a_corner: number; +} + +/** + * Renders an XYZ raster tile basemap into the chart's webgl canvas + * inside the plot rect's scissor. Used by `MapChart` from inside + * `renderInPlotFrame`, before the glyph draw, so chart glyphs (point, + * line, density) composite naturally on top of the basemap. + * + * The layer owns: + * - The tile shader program (compiled once per WebGL context). + * - A unit-quad corner buffer (one 2-byte attribute, reused per + * tile). + * - A `TileCache` (LRU of `WebGLTexture`). + * - A `TileLoader` (async fetch + dedup). + * + * On each render: pick the integer zoom that matches the requested + * meters-per-pixel, enumerate visible tiles at that zoom, and draw + * each one — either with the loaded texture or with a parent + * texture's sub-rect while the target is in flight. + */ +export class TileLayer { + private _program: TileProgramCache | null = null; + private _cornerBuffer: WebGLBuffer | null = null; + private _cache = new TileCache(); + private _loader = new TileLoader(); + private _source: TileSource | null = null; + private _alpha = 1.0; + private _onTileLoad: () => void = () => {}; + + /** + * Hook the "tile arrived" notification through to the chart's + * render scheduler. Called once when the layer is constructed + * by `MapChart`. + */ + setOnTileLoad(cb: () => void): void { + this._onTileLoad = cb; + this._loader.setOnLoad(cb); + } + + /** + * Swap the tile source (e.g. light ↔ dark theme). Drops the cache + * because the cached textures came from the prior source's URLs. + */ + setSource(gl: GL, source: TileSource): void { + if (this._source?.id === source.id) { + return; + } + + this._cache.dispose(gl); + this._loader.cancelAll(); + this._source = source; + } + + setAlpha(alpha: number): void { + this._alpha = Math.max(0, Math.min(1, alpha)); + } + + get source(): TileSource | null { + return this._source; + } + + /** + * Render the basemap for the current visible Mercator extent. + * Caller is responsible for binding the chart's main framebuffer + * (the plot-frame scissor is already in place by the time we get + * here). The same `projection` matrix the glyph draw uses is + * passed straight through. + */ + render( + glManager: WebGLContextManager, + layout: PlotLayout, + projection: Float32Array, + domain: { xMin: number; xMax: number; yMin: number; yMax: number }, + xOrigin: number, + yOrigin: number, + ): void { + const source = this._source; + if (!source) { + return; + } + + this._ensureProgram(glManager); + const prog = this._program; + const cornerBuf = this._cornerBuffer; + if (!prog || !cornerBuf) { + return; + } + + const gl = glManager.gl; + const dpr = glManager.dpr; + const plotWidth = Math.max(1, layout.plotRect.width * dpr); + const xRange = domain.xMax - domain.xMin; + if (!isFinite(xRange) || xRange <= 0) { + return; + } + + const mpp = xRange / plotWidth; + const z = pickZoom(mpp, source.tileSize, source.maxZoom); + const visible = tilesForExtent(domain, z); + + // Cancel any in-flight fetches for old zooms / off-screen + // tiles. We compute the live key set up-front so the loader + // doesn't keep tickling `requestRender` after the user pans + // past a slow-loading region. + const liveKeys = new Set(); + for (const t of visible) { + liveKeys.add(tileKey(source.id, t.z, t.x, t.y)); + } + + this._loader.cancelExcept(liveKeys); + + // Setup the shader program + static unit-quad buffer once per + // frame. Inside the loop only the per-tile uniforms change. + gl.useProgram(prog.program); + gl.uniformMatrix4fv(prog.u_projection, false, projection); + gl.uniform1f(prog.u_alpha, this._alpha); + gl.bindBuffer(gl.ARRAY_BUFFER, cornerBuf); + gl.enableVertexAttribArray(prog.a_corner); + gl.vertexAttribPointer(prog.a_corner, 2, gl.FLOAT, false, 0, 0); + gl.activeTexture(gl.TEXTURE0); + gl.uniform1i(prog.u_tile, 0); + + // Tiles are opaque; use the simplest blend mode so the + // glyph layer (drawn next in `_fullRender`) lands on top + // naturally without weird premultiplied tricks. + const wasBlend = gl.isEnabled(gl.BLEND); + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + + for (const tile of visible) { + this._drawTile(gl, prog, source, tile, xOrigin, yOrigin); + } + + if (!wasBlend) { + gl.disable(gl.BLEND); + } + } + + /** + * Free every GPU resource. Called from `MapChart.destroyInternal`. + */ + destroy(gl: GL): void { + this._loader.cancelAll(); + this._cache.dispose(gl); + if (this._cornerBuffer) { + gl.deleteBuffer(this._cornerBuffer); + this._cornerBuffer = null; + } + + this._program = null; + } + + private _drawTile( + gl: GL, + prog: TileProgramCache, + source: TileSource, + tile: TileId, + xOrigin: number, + yOrigin: number, + ): void { + // Subtract the chart's rebase origin so the position matches + // the convention glyphs use (`_xData = absX - xOrigin`). The + // shared projection matrix bakes in `xOrigin/yOrigin` and + // would otherwise shift tiles by `sx*xOrigin` clip units — + // for Mercator-scale origins (~1e7 m), well off-screen. + const rawExtent = tileExtent(tile.z, tile.x, tile.y); + const extent = { + xMin: rawExtent.xMin - xOrigin, + xMax: rawExtent.xMax - xOrigin, + yMin: rawExtent.yMin - yOrigin, + yMax: rawExtent.yMax - yOrigin, + }; + const key = tileKey(source.id, tile.z, tile.x, tile.y); + + const tex = this._cache.get(key); + if (tex) { + this._issueDraw(gl, prog, tex, extent, [0, 0], [1, 1]); + return; + } + + // Cache miss: kick off async load (idempotent — loader dedups). + // No await — we paint a fallback this frame and the chart will + // re-render when the texture arrives. + this._kickLoad(gl, source, tile); + + // Walk up the pyramid up to 6 levels looking for any loaded + // ancestor. Each level halves the UV sub-rect that the target + // tile occupies inside the ancestor; the math is direct from + // the tile coordinate bit-shift, no recursive accumulation. + for (let dz = 1; dz <= 6; dz++) { + const az = tile.z - dz; + if (az < 0) { + return; + } + + const ax = tile.x >> dz; + const ay = tile.y >> dz; + const ancestorKey = tileKey(source.id, az, ax, ay); + const ancestorTex = this._cache.get(ancestorKey); + if (!ancestorTex) { + continue; + } + + const n = 1 << dz; + const localX = tile.x - ax * n; + const localY = tile.y - ay * n; + const span = 1 / n; + const uvMin: [number, number] = [localX * span, localY * span]; + const uvMax: [number, number] = [uvMin[0] + span, uvMin[1] + span]; + this._issueDraw(gl, prog, ancestorTex, extent, uvMin, uvMax); + return; + } + } + + private _issueDraw( + gl: GL, + prog: TileProgramCache, + tex: WebGLTexture, + extent: { xMin: number; yMin: number; xMax: number; yMax: number }, + uvMin: [number, number], + uvMax: [number, number], + ): void { + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.uniform2f(prog.u_extent_min, extent.xMin, extent.yMin); + gl.uniform2f(prog.u_extent_max, extent.xMax, extent.yMax); + gl.uniform2f(prog.u_uv_min, uvMin[0], uvMin[1]); + gl.uniform2f(prog.u_uv_max, uvMax[0], uvMax[1]); + gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); + } + + private _kickLoad(gl: GL, source: TileSource, tile: TileId): void { + const key = tileKey(source.id, tile.z, tile.x, tile.y); + if (this._cache.has(key)) { + return; + } + + this._loader.load(source, tile.z, tile.x, tile.y).then((bmp) => { + if (!bmp) { + return; + } + + // The chart may have switched sources between launch and + // resolve; drop the bitmap if so. + if (this._source?.id !== source.id) { + bmp.close(); + return; + } + + const tex = gl.createTexture(); + if (!tex) { + bmp.close(); + return; + } + + // Anchor the upload to a known texture unit. Without + // this the upload binds to whatever unit was last + // active (gradient LUT lives at TEXTURE2, etc.), which + // is harmless for the upload itself but easy to confuse + // with the sampling path during debugging. + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, tex); + 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, + ); + + // Pin pixel-store flags. Workers and main threads start + // with the WebGL spec defaults, but other parts of the + // chart (or future extensions) may flip + // `UNPACK_PREMULTIPLY_ALPHA_WEBGL` or + // `UNPACK_FLIP_Y_WEBGL` and not restore them. Set them + // explicitly so the upload result is deterministic. + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); + gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA, + gl.RGBA, + gl.UNSIGNED_BYTE, + bmp, + ); + + // Do *not* `bmp.close()` here. The WebGL spec says + // `texImage2D(ImageBitmap)` consumes the source at call + // time, but several browser/driver combinations defer + // the actual GPU copy until the next draw; closing the + // bitmap before that copy lands leaves the texture + // valid-but-empty and the sampler returns (0,0,0,1) — + // i.e. solid black — for every tile after the first one + // or two whose upload happened to drain in time. The + // ImageBitmap is small (≤256×256×4 ≈ 256 KB) and will + // be garbage-collected once the .then closure is + // released. + this._cache.set(gl, key, tex); + this._onTileLoad(); + }); + } + + private _ensureProgram(glManager: WebGLContextManager): void { + if (this._program && this._cornerBuffer) { + return; + } + + const gl = glManager.gl; + const program = glManager.shaders.getOrCreate( + "map-tile", + tileVert, + tileFrag, + ); + + this._program = { + program, + u_projection: gl.getUniformLocation(program, "u_projection"), + u_extent_min: gl.getUniformLocation(program, "u_extent_min"), + u_extent_max: gl.getUniformLocation(program, "u_extent_max"), + u_uv_min: gl.getUniformLocation(program, "u_uv_min"), + u_uv_max: gl.getUniformLocation(program, "u_uv_max"), + u_tile: gl.getUniformLocation(program, "u_tile"), + u_alpha: gl.getUniformLocation(program, "u_alpha"), + a_corner: gl.getAttribLocation(program, "a_corner"), + }; + + const buf = gl.createBuffer(); + if (!buf) { + return; + } + + gl.bindBuffer(gl.ARRAY_BUFFER, buf); + // Unit-quad corners in TRIANGLE_STRIP order: + // (0,0) (1,0) (0,1) (1,1) + // Stretched into Mercator space by `u_extent_*` uniforms in + // the vertex shader; UV picked by `u_uv_*`. + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]), + gl.STATIC_DRAW, + ); + this._cornerBuffer = buf; + } +} diff --git a/packages/viewer-charts/src/ts/map/tile-loader.ts b/packages/viewer-charts/src/ts/map/tile-loader.ts new file mode 100644 index 0000000000..401e9220ce --- /dev/null +++ b/packages/viewer-charts/src/ts/map/tile-loader.ts @@ -0,0 +1,143 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import type { TileSource } from "./tile-source"; + +/** + * One in-flight tile fetch. The same key (z/x/y under one source) only + * launches one fetch; concurrent requesters share the promise. + */ +interface InFlight { + promise: Promise; + abort: AbortController; +} + +/** + * Async tile fetcher with in-flight dedup and abort-on-zoom-change. + * + * Runs inside the renderer worker (the chart's host process). `fetch` + * and `createImageBitmap` are both available in workers, and the + * resulting `ImageBitmap` can be uploaded straight to a WebGL2 texture + * via `gl.texImage2D(..., ImageBitmap)` — no main-thread bounce. + * + * The loader does not own GPU resources. Upload to texture happens in + * the tile layer once an `ImageBitmap` resolves; the layer then drops + * the bitmap (calling `close()` to free the decoded pixels). + */ +export class TileLoader { + private _inFlight = new Map(); + private _onLoad: () => void = () => {}; + + /** + * Register the "tile arrived" callback. The layer wires this to + * `chart.requestRender(glManager)` so a newly-loaded tile triggers + * exactly one extra frame. + */ + setOnLoad(cb: () => void): void { + this._onLoad = cb; + } + + /** + * Kick off a fetch (or return the in-flight one). The same + * (source.id, z, x, y) tuple only ever has one outstanding + * fetch; multiple callers share the result. Rejected fetches + * (network error, abort) resolve to `null` so callers can skip + * the tile without try/catch noise on every miss. + */ + load( + source: TileSource, + z: number, + x: number, + y: number, + ): Promise { + const key = tileKey(source.id, z, x, y); + const existing = this._inFlight.get(key); + if (existing) { + return existing.promise; + } + + const abort = new AbortController(); + const url = source.urlFor(z, x, y); + const promise = this._fetchAndDecode(url, abort.signal) + .then((bmp) => { + this._inFlight.delete(key); + if (bmp) { + this._onLoad(); + } + + return bmp; + }) + .catch(() => { + this._inFlight.delete(key); + return null; + }); + + this._inFlight.set(key, { promise, abort }); + return promise; + } + + /** + * Abort every in-flight fetch. Called on view teardown / chart + * destroy. Fetches whose `Response` has already arrived but + * haven't yet decoded will still complete the decode and resolve + * to `null` (because `_onLoad` is replaced or the cache is gone) + * — harmless, no resource leak. + */ + cancelAll(): void { + for (const entry of this._inFlight.values()) { + entry.abort.abort(); + } + + this._inFlight.clear(); + } + + /** + * Abort just the fetches whose key isn't in the supplied set. + * The layer calls this on every render with the currently-visible + * tile set so old-zoom requests don't keep arriving and triggering + * spurious re-renders after the user has moved on. + */ + cancelExcept(liveKeys: Set): void { + for (const [key, entry] of this._inFlight) { + if (!liveKeys.has(key)) { + entry.abort.abort(); + this._inFlight.delete(key); + } + } + } + + private async _fetchAndDecode( + url: string, + signal: AbortSignal, + ): Promise { + const resp = await fetch(url, { signal }); + if (!resp.ok) { + return null; + } + + const blob = await resp.blob(); + return await createImageBitmap(blob); + } +} + +/** + * Stable cache key for a tile under a given source. Embedded source id + * so swapping sources (light/dark) doesn't surface stale tiles. + */ +export function tileKey( + sourceId: string, + z: number, + x: number, + y: number, +): string { + return `${sourceId}/${z}/${x}/${y}`; +} diff --git a/packages/viewer-charts/src/ts/map/tile-source.ts b/packages/viewer-charts/src/ts/map/tile-source.ts new file mode 100644 index 0000000000..bf68fe8445 --- /dev/null +++ b/packages/viewer-charts/src/ts/map/tile-source.ts @@ -0,0 +1,156 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +/** + * A tile source describes *where* to fetch raster XYZ tiles and what + * attribution text the renderer must display in the chrome canvas. + * Implementations are stateless — the tile loader handles caching and + * in-flight dedup. + */ +export interface TileSource { + /** + * Build the URL for tile (z, x, y). Implementations typically + * substitute a template like `{z}/{x}/{y}` and may rotate + * subdomains for browsers that throttle concurrent connections + * per host. + */ + urlFor(z: number, x: number, y: number): string; + + /** + * Plain-text attribution shown in the bottom-right of the chrome + * canvas. Required by every major tile provider's terms of use — + * do not suppress it without provider-side opt-out. + */ + readonly attribution: string; + + /** + * Side length of one tile in pixels. Tile providers ship 256 by + * default; a few offer 512 (`@2x`) variants. Used by the zoom- + * level picker to convert meters-per-pixel into a zoom level. + */ + readonly tileSize: number; + + /** + * Maximum zoom level the provider serves. Tiles requested above + * this fall back to the deepest available level with sub-tile + * UVs — same trick used during async loads. + */ + readonly maxZoom: number; + + /** + * Stable identifier for caching. Two `TileSource` instances with + * the same `id` share a tile cache; switching source (e.g. theme + * change) invalidates and re-fetches. + */ + readonly id: string; +} + +/** + * Subdomain-rotated URL template. Replaces `{s}` with one of the + * provided subdomains hashed by `(x + y)`, and `{z}`, `{x}`, `{y}` + * with the tile address. Most major tile providers fit this shape. + */ +export class TemplatedTileSource implements TileSource { + constructor( + readonly id: string, + private readonly template: string, + readonly attribution: string, + readonly tileSize = 256, + readonly maxZoom = 19, + private readonly subdomains: readonly string[] = [], + ) {} + + urlFor(z: number, x: number, y: number): string { + let url = this.template + .replace("{z}", String(z)) + .replace("{x}", String(x)) + .replace("{y}", String(y)); + if (this.subdomains.length > 0) { + const idx = (x + y) % this.subdomains.length; + url = url.replace("{s}", this.subdomains[idx]); + } + + return url; + } +} + +/** + * Identifier of the default tile providers shipped with `viewer-charts`. + * Surfaced as the `map_tile_provider` PluginConfig enum so users can + * pick light vs. dark vs. labels-only without writing a custom source. + */ +export type TileProviderId = + | "carto-positron" + | "carto-dark-matter" + | "carto-voyager"; + +/** + * CartoDB's "Positron" basemap — light, low-contrast, designed to sit + * behind a chart overlay. Default for light themes. + */ +function cartoPositron(): TileSource { + return new TemplatedTileSource( + "carto-positron", + "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png", + "© OpenStreetMap contributors © CARTO", + 256, + 19, + ["a", "b", "c", "d"], + ); +} + +/** + * CartoDB's "Dark Matter" basemap — dark, low-contrast. Default for + * dark themes. + */ +function cartoDarkMatter(): TileSource { + return new TemplatedTileSource( + "carto-dark-matter", + "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png", + "© OpenStreetMap contributors © CARTO", + 256, + 19, + ["a", "b", "c", "d"], + ); +} + +/** + * CartoDB's "Voyager" basemap — full color, more land/water contrast + * than Positron. Good when the chart glyphs are translucent. + */ +function cartoVoyager(): TileSource { + return new TemplatedTileSource( + "carto-voyager", + "https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png", + "© OpenStreetMap contributors © CARTO", + 256, + 19, + ["a", "b", "c", "d"], + ); +} + +/** + * Resolve a `TileProviderId` (from PluginConfig) to a concrete + * `TileSource`. Unknown ids fall back to Positron so a misconfigured + * `_pluginConfig` never produces a blank map. + */ +export function tileSourceFor(id: TileProviderId | string): TileSource { + switch (id) { + case "carto-dark-matter": + return cartoDarkMatter(); + case "carto-voyager": + return cartoVoyager(); + case "carto-positron": + default: + return cartoPositron(); + } +} diff --git a/packages/viewer-charts/src/ts/plugin/charts.ts b/packages/viewer-charts/src/ts/plugin/charts.ts index 1e1b000f83..a98308635d 100644 --- a/packages/viewer-charts/src/ts/plugin/charts.ts +++ b/packages/viewer-charts/src/ts/plugin/charts.ts @@ -10,15 +10,19 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +import type { PluginConfig } from "../charts/chart"; + export type ChartType = "bar" | "line" | "scatter" | "area"; +export type PluginChartType = ChartType | "candlestick" | "ohlc"; + /** - * Chart-type identifiers plugins may pin via `default_chart_type`. - * Extends `ChartType` with the candlestick/ohlc plugins' identifiers, - * which own their own chart class — bar's `resolveChartType` never - * sees these because they never flow through the bar pipeline. + * Subset of `PluginConfig` keys that a chart impl actually consumes. + * Drives `plugin_config_schema()` filtering — the host only renders + * the controls listed here, and `plugin.restore({ plugin_config })` + * still hands the full struct to the worker (other keys are inert). */ -export type PluginChartType = ChartType | "candlestick" | "ohlc"; +export type PluginConfigField = keyof PluginConfig; export interface ChartTypeConfig { name: string; @@ -32,160 +36,251 @@ export interface ChartTypeConfig { max_cells: number; max_columns: number; default_chart_type?: PluginChartType; + + /** + * Plugin-config keys this chart type renders controls for. Empty + * for plugins with no global settings (heatmap / treemap / + * sunburst). See {@link PluginConfig} for field semantics. + */ + applicable_plugin_fields: readonly PluginConfigField[]; + + /** + * Per-chart-type overrides for `DEFAULT_PLUGIN_CONFIG`. Used when a + * field's sensible default differs by chart family — currently + * `include_zero` (true for Y Bar / Y Area / X Bar, false for line + * / scatter / cartesian / financial). Applied at schema generation + * and at `restore({})` so the effective default matches the + * surfaced UI default. + */ + plugin_field_defaults?: Partial; +} + +const SERIES = "Series Charts"; +const CART = "Cartesian Charts"; +const HIER = "Hierarchical Charts"; +const FIN = "Financial Charts"; +const MAP = "Map Charts"; +const X_AXIS = ["X Axis"]; +const Y_AXIS = ["Y Axis"]; +const SELECT = "select"; +const TOGGLE = "toggle"; + +const DEFAULT_MAX_CELLS = 2_000_000; +const DEFAULT_MAX_COLUMNS = 10_000; + +// Plugin-config field sets, by chart family. +// +// Series charts paint bars / lines / scatter / area glyphs (selected +// per-column via `chart_type`), so the union covers every glyph that +// might appear. `auto_alt_y_axis` + `series_zoom_mode` are Series-only. +const SERIES_FIELDS: readonly PluginConfigField[] = [ + "auto_alt_y_axis", + "facet_mode", + "series_zoom_mode", + "include_zero", + "domain_mode", + "line_width_px", + "point_size_px", + "band_inner_frac", + "bar_inner_pad", +]; + +// Bar / area series glyphs grow from the zero baseline, so the value +// axis must enclose 0 to render correctly. Line / scatter glyphs have +// no such constraint — their default `include_zero` stays `false`. +const ZERO_ANCHORED_DEFAULTS: Partial = { include_zero: true }; + +// Pure Cartesian (X/Y Scatter, X/Y Line) — no categorical axis, so no +// band geometry; gets the facet-routing variant of zoom_mode. +const CARTESIAN_FIELDS: readonly PluginConfigField[] = [ + "facet_mode", + "facet_zoom_mode", + "domain_mode", + "line_width_px", + "point_size_px", +]; + +// Candlestick/OHLC share the categorical-X build pipeline (band slots) +// and add their two stroke widths. +const FIN_FIELDS: readonly PluginConfigField[] = [ + "facet_mode", + "series_zoom_mode", + "domain_mode", + "band_inner_frac", + "bar_inner_pad", + "wick_width_px", + "ohlc_line_width_px", +]; + +// Hierarchical — none of the listed fields apply. +const NO_FIELDS: readonly PluginConfigField[] = []; + +// Heatmap +const HEATMAP_FIELDS: readonly PluginConfigField[] = ["facet_zoom_mode"]; + +// Map — reuses the cartesian build pipeline with a Mercator +// projection hook. Carries the basemap controls (`map_tile_provider`, +// `map_tile_alpha`) plus the relevant glyph-styling fields the +// underlying chart type already uses. Density-on-map adds the four +// gradient knobs on top. +const MAP_BASE_FIELDS: readonly PluginConfigField[] = [ + "facet_mode", + "facet_zoom_mode", + "domain_mode", + "map_tile_provider", + "map_tile_alpha", +]; +const MAP_SCATTER_FIELDS: readonly PluginConfigField[] = [ + ...MAP_BASE_FIELDS, + "point_size_px", +]; +const MAP_LINE_FIELDS: readonly PluginConfigField[] = [ + ...MAP_BASE_FIELDS, + "line_width_px", +]; +const MAP_DENSITY_FIELDS: readonly PluginConfigField[] = [ + ...MAP_BASE_FIELDS, + "gradient_color_mode", + "gradient_radius_px", + "gradient_intensity", + "gradient_heat_max", +]; + +// Density — shares the cartesian build pipeline (X/Y numeric +// with an optional Color column), then routes through the density-field +// glyph. Reuses the cartesian facet/zoom controls and adds the three +// shader-specific knobs. +const DENSITY_FIELDS: readonly PluginConfigField[] = [ + "facet_mode", + "facet_zoom_mode", + "domain_mode", + "gradient_color_mode", + "gradient_radius_px", + "gradient_intensity", + "gradient_heat_max", +]; + +function make( + name: string, + tag: string, + category: string, + selectMode: "select" | "toggle", + count: number, + names: readonly string[], + applicable_plugin_fields: readonly PluginConfigField[], + overrides?: Partial< + Pick< + ChartTypeConfig, + | "max_cells" + | "max_columns" + | "default_chart_type" + | "plugin_field_defaults" + > + >, +): ChartTypeConfig { + return { + name, + tag, + category, + selectMode, + initial: { count, names: names as string[] }, + max_cells: overrides?.max_cells ?? DEFAULT_MAX_CELLS, + max_columns: overrides?.max_columns ?? DEFAULT_MAX_COLUMNS, + applicable_plugin_fields, + ...(overrides?.default_chart_type + ? { default_chart_type: overrides.default_chart_type } + : {}), + ...(overrides?.plugin_field_defaults + ? { plugin_field_defaults: overrides.plugin_field_defaults } + : {}), + }; } -const CHARTS = [ - { - name: "X/Y Scatter", - tag: "scatter", - category: "Charts", - selectMode: "toggle", - initial: { - count: 2, - names: ["X Axis", "Y Axis", "Color", "Size", "Tooltip"], - }, - max_cells: 10_000_000, - max_columns: 50, - }, - { - name: "X/Y Line", - tag: "line", - category: "Charts", - selectMode: "select", - initial: { - count: 2, - names: ["X Axis", "Y Axis"], - }, - max_cells: 10_000_000, - max_columns: 50, - }, - { - name: "Treemap", - tag: "treemap", - category: "Charts", - selectMode: "toggle", - initial: { - count: 1, - names: ["Size", "Color", "Tooltip"], - }, - max_cells: 10_000_000, - max_columns: 10, - }, - { - name: "Sunburst", - tag: "sunburst", - category: "Charts", - selectMode: "toggle", - initial: { - count: 1, - names: ["Size", "Color", "Tooltip"], - }, - max_cells: 10_000_000, - max_columns: 10, - }, - { - name: "Y Bar", - tag: "y-bar", - category: "Charts", - selectMode: "select", - initial: { - count: 1, - names: ["Y Axis"], - }, - max_cells: 10_000_000, - max_columns: 50, +const FIN_NAMES = ["Open", "Close", "High", "Low", "Tooltip"]; +const HIER_NAMES = ["Size", "Color", "Tooltip"]; + +const CHARTS: ChartTypeConfig[] = [ + make("X Bar", "x-bar", SERIES, SELECT, 1, X_AXIS, SERIES_FIELDS, { default_chart_type: "bar", - }, - { - name: "X Bar", - tag: "x-bar", - category: "Charts", - selectMode: "select", - initial: { - count: 1, - names: ["X Axis"], - }, - max_cells: 10_000_000, - max_columns: 50, + plugin_field_defaults: ZERO_ANCHORED_DEFAULTS, + }), + make("Y Bar", "y-bar", SERIES, SELECT, 1, Y_AXIS, SERIES_FIELDS, { default_chart_type: "bar", - }, - { - name: "Y Line", - tag: "y-line", - category: "Charts", - selectMode: "select", - initial: { - count: 1, - names: ["Y Axis"], - }, - max_cells: 10_000_000, - max_columns: 50, + plugin_field_defaults: ZERO_ANCHORED_DEFAULTS, + }), + make("Y Line", "y-line", SERIES, SELECT, 1, Y_AXIS, SERIES_FIELDS, { default_chart_type: "line", - }, - { - name: "Y Scatter", - tag: "y-scatter", - category: "Charts", - selectMode: "select", - initial: { - count: 1, - names: ["Y Axis"], - }, - max_cells: 10_000_000, - max_columns: 50, + }), + make("Y Scatter", "y-scatter", SERIES, SELECT, 1, Y_AXIS, SERIES_FIELDS, { default_chart_type: "scatter", - }, - { - name: "Y Area", - tag: "y-area", - category: "Charts", - selectMode: "select", - initial: { - count: 1, - names: ["Y Axis"], - }, - max_cells: 10_000_000, - max_columns: 50, + }), + make("Y Area", "y-area", SERIES, SELECT, 1, Y_AXIS, SERIES_FIELDS, { default_chart_type: "area", - }, - { - name: "Candlestick", - tag: "candlestick", - category: "Charts", - selectMode: "toggle", - initial: { - count: 1, - names: ["Open", "Close", "High", "Low", "Tooltip"], - }, - max_cells: 10_000_000, - max_columns: 50, + plugin_field_defaults: ZERO_ANCHORED_DEFAULTS, + }), + make( + "X/Y Scatter", + "scatter", + CART, + TOGGLE, + 2, + ["X Axis", "Y Axis", "Color", "Size", "Label", "Tooltip"], + CARTESIAN_FIELDS, + ), + make( + "X/Y Line", + "line", + CART, + SELECT, + 2, + ["X Axis", "Y Axis", "Tooltip"], + CARTESIAN_FIELDS, + ), + make( + "Density", + "density", + CART, + TOGGLE, + 2, + ["X Axis", "Y Axis", "Color", "Tooltip"], + DENSITY_FIELDS, + ), + make("Treemap", "treemap", HIER, TOGGLE, 1, HIER_NAMES, NO_FIELDS), + make("Sunburst", "sunburst", HIER, TOGGLE, 1, HIER_NAMES, NO_FIELDS), + make("Heatmap", "heatmap", HIER, SELECT, 1, ["Color"], HEATMAP_FIELDS), + make("Candlestick", "candlestick", FIN, TOGGLE, 1, FIN_NAMES, FIN_FIELDS, { default_chart_type: "candlestick", - }, - { - name: "OHLC", - tag: "ohlc", - category: "Charts", - selectMode: "toggle", - initial: { - count: 1, - names: ["Open", "Close", "High", "Low", "Tooltip"], - }, - max_cells: 100_000, - max_columns: 50, + }), + make("OHLC", "ohlc", FIN, TOGGLE, 1, FIN_NAMES, FIN_FIELDS, { default_chart_type: "ohlc", - }, - { - name: "Heatmap", - tag: "heatmap", - category: "Charts", - selectMode: "select", - initial: { - count: 1, - names: ["Color"], - }, - max_cells: 10_000_000, - max_columns: 500, - }, -] as const satisfies readonly ChartTypeConfig[]; + }), + make( + "Map Scatter", + "map-scatter", + MAP, + TOGGLE, + 2, + ["Longitude", "Latitude", "Color", "Size", "Label", "Tooltip"], + MAP_SCATTER_FIELDS, + ), + make( + "Map Line", + "map-line", + MAP, + SELECT, + 2, + ["Longitude", "Latitude", "Tooltip"], + MAP_LINE_FIELDS, + ), + make( + "Map Density", + "map-density", + MAP, + TOGGLE, + 2, + ["Longitude", "Latitude", "Color", "Tooltip"], + MAP_DENSITY_FIELDS, + ), +]; export default CHARTS; diff --git a/packages/viewer-charts/src/ts/plugin/plugin.ts b/packages/viewer-charts/src/ts/plugin/plugin.ts index c92ac6c817..184576550c 100644 --- a/packages/viewer-charts/src/ts/plugin/plugin.ts +++ b/packages/viewer-charts/src/ts/plugin/plugin.ts @@ -11,38 +11,201 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import type { View } from "@perspective-dev/client"; -import { ChartTypeConfig } from "./charts"; +import type { + HTMLPerspectiveViewerElement, + IPerspectiveViewerPlugin, + PluginStaticConfig, +} from "@perspective-dev/viewer"; +import { ChartTypeConfig, PluginConfigField } from "./charts"; import style from "../../css/perspective-viewer-charts.css"; -import { WebGLContextManager } from "../webgl/context-manager"; -import { viewToColumnDataMap, ColumnDataMap } from "../data/view-reader"; import { - ChartImplementation, DEFAULT_FACET_CONFIG, + DEFAULT_PLUGIN_CONFIG, type FacetConfig, + type PluginConfig, } from "../charts/chart"; -import { ZoomController } from "../interaction/zoom-controller"; -import { ZoomRouter } from "../interaction/zoom-router"; -import { PlotLayout } from "../layout/plot-layout"; +import { RawEventForwarder } from "../interaction/raw-event-forwarder"; +import { RendererTransport } from "../transport/renderer-transport"; +import { RENDER_BLIT_MODE } from "../config"; /** - * Compile-time facet configuration. Baked in at module load for now — - * flip values here + rebuild to toggle small-multiples behavior. When - * the UI wires `columns_config` through `restore`, this const seeds - * the default and per-column overrides win. + * Facet-rendering defaults shared by every chart. Per-chart overrides + * arrive through `plugin_config` (`facet_mode` + `facet_zoom_mode`); + * the remaining fields (`shared_x_axis`, `shared_y_axis`, + * `coordinated_tooltip`, `facet_padding`) are not yet user-configurable + * — flip the defaults in `DEFAULT_FACET_CONFIG` to change globally. */ -const FACET_CONFIG: FacetConfig = { - ...DEFAULT_FACET_CONFIG, - // Flip to "overlay" to fall back to the pre-facet single-plot - // rendering of split_by (all splits drawn in one plot rect, - // differentiated by color). - facet_mode: "grid", - shared_x_axis: true, - shared_y_axis: true, - coordinated_tooltip: false, - // "independent" routes wheel/pan to the facet under the cursor and - // each facet draws its own viewport. - zoom_mode: "shared", -}; +const FACET_CONFIG_DEFAULTS: FacetConfig = { ...DEFAULT_FACET_CONFIG }; + +/** + * Build a UI control spec for one plugin-config field. Mirrors the + * shape `column_config_schema` already returns (datagrid). Numeric + * fields get a `Number` control with min/max clamps; fractions get a + * 0..1 range; enums + booleans pass through their variant list. + */ +function fieldSpec( + key: PluginConfigField, + defaults: PluginConfig, +): Record & { kind: string } { + switch (key) { + case "auto_alt_y_axis": + return { kind: "Bool", key, default: defaults.auto_alt_y_axis }; + case "include_zero": + return { kind: "Bool", key, default: defaults.include_zero }; + case "domain_mode": + return { + kind: "Enum", + key, + default: defaults.domain_mode, + variants: [ + { value: "fit", label: "Fit" }, + { value: "expand", label: "Expand" }, + ], + }; + case "facet_mode": + return { + kind: "Enum", + key, + default: DEFAULT_PLUGIN_CONFIG.facet_mode, + variants: [ + { value: "grid", label: "Grid" }, + { value: "overlay", label: "Overlay" }, + ], + }; + case "facet_zoom_mode": + return { + kind: "Enum", + key, + default: DEFAULT_PLUGIN_CONFIG.facet_zoom_mode, + variants: [ + { value: "shared", label: "Shared" }, + { value: "independent", label: "Independent" }, + ], + }; + case "series_zoom_mode": + return { + kind: "Enum", + key, + default: DEFAULT_PLUGIN_CONFIG.series_zoom_mode, + variants: [ + { value: "dynamic", label: "Dynamic" }, + { value: "fixed", label: "Fixed" }, + ], + }; + case "line_width_px": + return { + kind: "Number", + key, + default: DEFAULT_PLUGIN_CONFIG.line_width_px, + min: 0.5, + step: 0.5, + max: 16, + }; + case "point_size_px": + return { + kind: "Number", + key, + default: DEFAULT_PLUGIN_CONFIG.point_size_px, + min: 1, + max: 32, + }; + case "band_inner_frac": + return { + kind: "Number", + key, + default: DEFAULT_PLUGIN_CONFIG.band_inner_frac, + min: 0.1, + max: 1, + step: 0.01, + }; + case "bar_inner_pad": + return { + kind: "Number", + key, + default: DEFAULT_PLUGIN_CONFIG.bar_inner_pad, + min: 0, + max: 0.9, + step: 0.01, + }; + case "wick_width_px": + return { + kind: "Number", + key, + default: DEFAULT_PLUGIN_CONFIG.wick_width_px, + min: 0.5, + step: 0.5, + max: 8, + }; + case "ohlc_line_width_px": + return { + kind: "Number", + key, + default: DEFAULT_PLUGIN_CONFIG.ohlc_line_width_px, + min: 0.5, + step: 0.5, + max: 8, + }; + case "gradient_radius_px": + return { + kind: "Number", + key, + default: DEFAULT_PLUGIN_CONFIG.gradient_radius_px, + min: 2, + step: 1, + max: 256, + }; + case "gradient_intensity": + return { + kind: "Number", + key, + default: DEFAULT_PLUGIN_CONFIG.gradient_intensity, + min: 0.05, + step: 0.05, + max: 4, + }; + case "gradient_heat_max": + return { + kind: "Number", + key, + default: DEFAULT_PLUGIN_CONFIG.gradient_heat_max, + min: 0.1, + step: 0.1, + max: 64, + }; + case "gradient_color_mode": + return { + kind: "Enum", + key, + default: DEFAULT_PLUGIN_CONFIG.gradient_color_mode, + variants: [ + { value: "mean", label: "Mean (density-weighted)" }, + { value: "density", label: "Density only" }, + { value: "extreme", label: "Extremes" }, + { value: "signed", label: "Signed sum" }, + ], + }; + case "map_tile_provider": + return { + kind: "Enum", + key, + default: DEFAULT_PLUGIN_CONFIG.map_tile_provider, + variants: [ + { value: "carto-positron", label: "Light (Positron)" }, + { value: "carto-dark-matter", label: "Dark Matter" }, + { value: "carto-voyager", label: "Voyager" }, + ], + }; + case "map_tile_alpha": + return { + kind: "Number", + key, + default: DEFAULT_PLUGIN_CONFIG.map_tile_alpha, + min: 0, + max: 1, + step: 0.05, + }; + } +} const GLOBAL_STYLES = (() => { const sheet = new CSSStyleSheet(); @@ -50,19 +213,56 @@ const GLOBAL_STYLES = (() => { return [sheet]; })(); -export class HTMLPerspectiveViewerWebGLPluginElement extends HTMLElement { +export class HTMLPerspectiveViewerWebGLPluginElement + extends HTMLElement + implements IPerspectiveViewerPlugin +{ declare _chartType: ChartTypeConfig; - declare static _chartType: ChartTypeConfig; private _initialized = false; private _glCanvas!: HTMLCanvasElement; private _gridlineCanvas!: HTMLCanvasElement; private _chromeCanvas!: HTMLCanvasElement; - private _glManager: WebGLContextManager | null = null; - private _chartImpl: ChartImplementation | null = null; - private _zoomController: ZoomController | null = null; - private _zoomRouter: ZoomRouter | null = null; + private _renderer: RendererTransport | null = null; + private _rendererPromise: Promise | null = null; + private _rawEventForwarder: RawEventForwarder | null = null; private _generation = 0; + private _renderBlitMode: "direct" | "blit" = RENDER_BLIT_MODE; + private _resetClickAbort: AbortController | null = null; + + /** + * Plugin-scoped global config. Seeded lazily from + * `_effectiveDefaults()` (which folds + * `_chartType.plugin_field_defaults` over `DEFAULT_PLUGIN_CONFIG`) + * because base-class field initializers run before the subclass + * `_chartType` assignment. `restore({ plugin_config })` merges + * incoming values on top of the same effective defaults so fields + * the host omits fall back to the chart-type default + * (`include_zero = true` for Y Bar / Y Area / X Bar, `false` + * elsewhere). Held on the element (not just inside the worker) so + * a `_buildRenderer` triggered after a `restore` ships the + * resolved values in the `InitMsg`. + */ + private _pluginConfigStore: PluginConfig | null = null; + + private get _pluginConfig(): PluginConfig { + if (!this._pluginConfigStore) { + this._pluginConfigStore = this._effectiveDefaults(); + } + + return this._pluginConfigStore; + } + + private set _pluginConfig(value: PluginConfig) { + this._pluginConfigStore = value; + } + + private _effectiveDefaults(): PluginConfig { + return { + ...DEFAULT_PLUGIN_CONFIG, + ...(this._chartType.plugin_field_defaults ?? {}), + }; + } connectedCallback() { if (!this._initialized) { @@ -71,416 +271,398 @@ export class HTMLPerspectiveViewerWebGLPluginElement extends HTMLElement { this.shadowRoot!.adoptedStyleSheets.push(sheet); } - const zoom_button = ``; - const canvas_stack = - `` + - `` + - `` + + this.shadowRoot!.innerHTML = + `
` + `
` + - zoom_button + + `` + + `
` + `
`; - this.shadowRoot!.innerHTML = - `
` + canvas_stack + `
`; + this._initialized = true; + } - this._glCanvas = - this.shadowRoot!.querySelector( - ".webgl-canvas", - )!; + if (!this._glCanvas?.isConnected) { + this._buildCanvasStack(); + } + } - this._gridlineCanvas = - this.shadowRoot!.querySelector( - ".webgl-gridlines", - )!; + private _buildCanvasStack(): void { + const container = this.shadowRoot!.querySelector(".webgl-container")!; + container.insertAdjacentHTML( + "afterbegin", + `` + + `` + + ``, + ); - this._chromeCanvas = - this.shadowRoot!.querySelector( - ".webgl-chrome", - )!; + this._glCanvas = + container.querySelector(".webgl-canvas")!; + this._gridlineCanvas = + container.querySelector(".webgl-gridlines")!; + this._chromeCanvas = + container.querySelector(".webgl-chrome")!; + } - this._initialized = true; + private _clearCanvasStack(): void { + const container = this.shadowRoot?.querySelector(".webgl-container"); + if (container) { + for (const c of Array.from(container.querySelectorAll("canvas"))) { + c.remove(); + } } + + this._glCanvas = null!; + this._gridlineCanvas = null!; + this._chromeCanvas = null!; + } + + /** + * Fires when the host (``) removes this plugin + * from the DOM on chart-type switch — see + * `renderer/activate.rs::remove_inactive_plugin`. Without this, + * inactive plugin instances retain their `RendererTransport` + * (worker + WebGL context + compiled shader programs) until the + * entire viewer is torn down, so a user cycling all 12 chart kinds + * holds 12 GL contexts per viewer and routinely exceeds the + * browser's per-page context cap (~16) in workspaces. + */ + disconnectedCallback() { + this.delete(); + this._clearCanvasStack(); } - private _ensureGL(): WebGLContextManager { + /** + * Lazy renderer construction. Memoizes the in-flight `init()` + * promise so concurrent `draw()` calls during async setup await + * the same initialization rather than racing. + */ + private _ensureRenderer(view: View): Promise { if (!this._initialized) { this.connectedCallback(); } - if (!this._glManager) { - this._glManager = new WebGLContextManager(this._glCanvas); - this._setupChartIntegration(); + + if (this._rendererPromise) { + return this._rendererPromise; } - return this._glManager; - } - private _setupChartIntegration(): void { - if (!this._chartImpl) return; + this._rendererPromise = this._buildRenderer(view).then((r) => { + this._renderer = r; + this._setupInteraction(r); + return r; + }); - // Wire overlay and tooltip canvases - if (this._chartImpl.setGridlineCanvas) { - this._chartImpl.setGridlineCanvas(this._gridlineCanvas); - } - if (this._chartImpl.setChromeCanvas) { - this._chartImpl.setChromeCanvas(this._chromeCanvas); - } + return this._rendererPromise; + } - // Parameterised default glyph for Y-series plugins. Non-Y plugins - // leave `default_chart_type` unset so this is a no-op for them. - if ( - this._chartImpl.setDefaultChartType && - this._chartType.default_chart_type - ) { - this._chartImpl.setDefaultChartType( - this._chartType.default_chart_type, - ); + /** + * Capture raw DOM events on the GL canvas with `RawEventForwarder` + * and post them over the control channel. The renderer dispatches + * them through its own resolver + `applyWheel` / `applyPan` for + * zoom/pan, and through `TooltipController` virtual dispatch for + * hover/click; `zoomChanged` updates push back so the reset-zoom + * button visibility tracks the renderer-side state. + * + * The `zoomChanged` callback was wired at `RendererTransport` + * construction time; here we just attach the event forwarder and + * the reset-button click handler. + */ + private _setupInteraction(renderer: RendererTransport): void { + if (this._rawEventForwarder) { + return; } - // Seed the facet config. Currently a compile-time const; when UI - // wiring lands, `restore` merges viewer-provided overrides on - // top of this default so call order is "set default → restore - // override". - if (this._chartImpl.setFacetConfig) { - this._chartImpl.setFacetConfig(FACET_CONFIG); - } + const zoomControls = this.shadowRoot!.querySelector( + ".zoom-controls", + ) as HTMLDivElement | null; - // Create and wire zoom controller(s) - if (this._chartImpl.setZoomController && !this._zoomController) { - this._zoomController = new ZoomController(); - this._chartImpl.setZoomController(this._zoomController); - this._setupZoomRouter(); - } + this._rawEventForwarder = new RawEventForwarder(); + this._rawEventForwarder.attach(this._glCanvas, (event) => { + renderer.forwardInteraction(event); + }); - // Attach tooltip - if (this._chartImpl.attachTooltip) { - this._chartImpl.attachTooltip(this._glCanvas); + const resetBtn = this.shadowRoot!.querySelector(".zoom-reset"); + if (resetBtn) { + this._resetClickAbort = new AbortController(); + resetBtn.addEventListener( + "click", + () => { + renderer.resetAllZooms(); + if (zoomControls) { + zoomControls.classList.remove("visible"); + } + }, + { signal: this._resetClickAbort.signal }, + ); } } - /** - * Wire the `ZoomRouter` to the GL canvas with a resolver that - * dispatches events to the facet under the cursor. In shared-zoom - * mode the resolver always returns the single `_zoomController` - * with the facet's own layout (so data/pixel math uses the right - * plot rect). In independent-zoom mode the resolver walks the - * chart's facet grid and routes to the per-facet controller. - */ - private _setupZoomRouter(): void { - if (!this._zoomController || this._zoomRouter) return; + private async _buildRenderer(view: View): Promise { + const viewer = this.parentElement as HTMLPerspectiveViewerElement; + const client = await viewer.getClient(); + const viewer_class = customElements.get("perspective-viewer"); + const clientWasm = viewer_class.get_wasm_module(); + const clientWorkerURL = viewer_class.get_worker_url(); + const table = await viewer?.getTable?.(); + const tableName: string | undefined = table + ? await table.get_name() + : undefined; - this._zoomRouter = new ZoomRouter(); - const router = this._zoomRouter; const zoomControls = this.shadowRoot!.querySelector( ".zoom-controls", ) as HTMLDivElement | null; - // Dummy seed layout — replaced per-frame via the chart's - // `updateLayout` call inside its render paths. Also used by the - // shared-mode resolver as a fallback when no facet grid exists. - const rect = this._glCanvas.getBoundingClientRect(); - const seedLayout = new PlotLayout( - rect.width || 100, - rect.height || 100, - { - hasXLabel: true, - hasYLabel: true, - hasLegend: false, - }, - ); - - router.attach( - this._glCanvas, - (mx, my) => { - const chart = this._chartImpl as any; - const facetGrid = chart?._facetGrid; - if (facetGrid) { - for (let i = 0; i < facetGrid.cells.length; i++) { - const cell = facetGrid.cells[i]; - const plot = cell.layout.plotRect; - if ( - mx >= plot.x && - mx <= plot.x + plot.width && - my >= plot.y && - my <= plot.y + plot.height - ) { - const zc = - chart.getZoomControllerForFacet?.(i) ?? - this._zoomController!; - return { controller: zc, layout: cell.layout }; - } - } - return null; - } - // Non-facet chart: consult the single controller, using - // the chart's current layout (or seed) for pixel math. - const layout = chart?._lastLayout ?? seedLayout; - const plot = layout.plotRect; - if ( - mx < plot.x || - mx > plot.x + plot.width || - my < plot.y || - my > plot.y + plot.height - ) { - return null; - } - return { - controller: this._zoomController!, - layout, - }; - }, - () => { - if (this._chartImpl && this._glManager) { - this._chartImpl.redraw(this._glManager); - } + const transport = new RendererTransport({ + client, + view, + tableName, + clientWorkerURL, + clientWasm, + chartTag: this._chartType.tag, + maxCells: this._chartType.max_cells, + precompileShaders: true, + onZoomChanged: (isDefault: boolean) => { if (zoomControls) { - zoomControls.classList.toggle( - "visible", - !this._allZoomsDefault(), - ); + zoomControls.classList.toggle("visible", !isDefault); } }, - ); - - const resetBtn = this.shadowRoot!.querySelector(".zoom-reset"); - if (resetBtn) { - resetBtn.addEventListener("click", () => { - this._resetAllZooms(); - if (zoomControls) { - zoomControls.classList.remove("visible"); - } - if (this._chartImpl && this._glManager) { - this._chartImpl.redraw(this._glManager); - } - }); - } - } + }); - private _allZoomsDefault(): boolean { - if (this._zoomController && !this._zoomController.isDefault()) { - return false; - } - const chart = this._chartImpl as any; - if (chart?._facetZoomControllers) { - for (const zc of chart._facetZoomControllers) { - if (zc && !zc.isDefault()) return false; - } - } - return true; - } + await transport.init({ + gl: this._glCanvas, + gridlines: this._gridlineCanvas, + chrome: this._chromeCanvas, + facetConfig: { + ...FACET_CONFIG_DEFAULTS, + facet_mode: this._pluginConfig.facet_mode, + zoom_mode: this._pluginConfig.facet_zoom_mode, + }, + pluginConfig: this._pluginConfig, + defaultChartType: this._chartType.default_chart_type, + renderBlitMode: this._renderBlitMode, + }); - private _resetAllZooms(): void { - this._zoomController?.reset(); - const chart = this._chartImpl as any; - if (chart?._facetZoomControllers) { - for (const zc of chart._facetZoomControllers) { - zc?.reset(); - } - } + return transport; } - get name() { - return this._chartType.name; + setBlitMode(mode: "direct" | "blit") { + console.assert(this._initialized, "Already initialized"); + this._renderBlitMode = mode; } - get category() { - return this._chartType.category; + get_static_config(): PluginStaticConfig { + return { + name: this._chartType.name, + category: this._chartType.category, + select_mode: this._chartType.selectMode, + min_config_columns: this._chartType.initial.count, + config_column_names: this._chartType.initial.names, + max_cells: this._chartType.max_cells, + max_columns: this._chartType.max_columns, + group_rollup_modes: ["flat"], + priority: 0, + can_render_column_styles: + !!this._chartType.default_chart_type || + this._chartType.category === "Cartesian Charts", + }; } - get select_mode() { - return this._chartType.selectMode; - } + column_config_schema( + column_type: string, + _group: string | undefined, + _column_name: string, + current_value: Record | null, + _viewer_config?: { group_by?: string[]; group_rollup_mode?: string }, + ) { + const fields: Array & { kind: string }> = []; + + // Y-series plugins expose the per-column chart_type picker; non-Y + // plugins leave `default_chart_type` unset. + const def = this._chartType.default_chart_type; + if (def && (column_type === "integer" || column_type === "float")) { + fields.push({ + kind: "Enum", + key: "chart_type", + default: def, + variants: [ + { value: "bar", label: "Bar" }, + { value: "line", label: "Line" }, + { value: "scatter", label: "Scatter" }, + { value: "area", label: "Area" }, + ], + }); - get min_config_columns() { - return this._chartType.initial.count; - } + const effective_chart_type = + (current_value?.chart_type as string | undefined) ?? def; - get config_column_names() { - return this._chartType.initial.names; - } + const supports_stack = + effective_chart_type === "bar" || + effective_chart_type === "area"; - get max_cells() { - return this._chartType.max_cells; - } + if (supports_stack) { + fields.push({ + kind: "Bool", + key: "stack", + default: supports_stack, + }); + } - get max_columns() { - return this._chartType.max_columns; - } + const is_series_glyph = + def === "bar" || + def === "line" || + def === "scatter" || + def === "area"; + + if (is_series_glyph) { + fields.push({ + kind: "Bool", + key: "alt_axis", + default: false, + }); + } + } - get priority() { - return 0; - } + // Per-column formatter widgets. Surfaced for every chart type so + // axes / tooltips / legends honor the user's format choice. + if (column_type === "integer" || column_type === "float") { + fields.push({ kind: "NumberFormat" }); + } else if (column_type === "date" || column_type === "datetime") { + fields.push({ kind: "DatetimeFormat" }); + } - get group_rollups(): string[] { - return ["flat"]; + return { fields }; } - get render_warning() { - return false; - } + plugin_config_schema(_view_config?: { + group_by?: string[]; + group_rollup_mode?: string; + }) { + const defaults = this._effectiveDefaults(); + const fields = this._chartType.applicable_plugin_fields.map((key) => + fieldSpec(key, defaults), + ); - set render_warning(_value: boolean) { - // No-op: viewer toggles this after draw + return { fields }; } - can_render_column_styles(column_type: string, _group?: string) { - // Every Y-series plugin exposes the Chart Type picker; they're - // identified by having a `default_chart_type`. - if (!this._chartType.default_chart_type) return false; - return column_type === "integer" || column_type === "float"; + async draw(view: View): Promise { + // `draw` always indicates a view-level change (pivots, columns, + // filters, sorts, schema, …) — invalidate the `domain_mode: + // "expand"` accumulator so the new view's extent starts fresh. + // `update` (data-only redraw on the same view) shares + // `_drawImpl` but skips this reset. + this._renderer?.resetExpandedDomain(); + this._renderer?.resetAllZooms(); + return this._drawImpl(view); } - column_style_controls(column_type: string, _group?: string) { - // Pre-select the plugin's default glyph in the sidebar Chart Type - // picker so e.g. Y Line shows "Line" on first render rather than - // Bar. - const def = this._chartType.default_chart_type; - if (!def) return {}; - if (column_type !== "integer" && column_type !== "float") return {}; - return { - number_series_style: { - chart_type: def, - }, - }; + async update(view: View): Promise { + return this._drawImpl(view); } - async draw(view: View): Promise { + private async _drawImpl(view: View): Promise { const gen = ++this._generation; - const glManager = this._ensureGL(); - glManager.resize(); - // glManager.clear(); - glManager.bufferPool.maxCapacity = this._chartType.max_cells; - const viewer = this.parentElement as any; - const [numRows, schema, viewerConfig] = await Promise.all([ - view.num_rows(), - view.schema(), - viewer?.getViewConfig?.() ?? {}, - ]); - - if (this._generation !== gen) return; - // Install the current View on the chart so it can make - // on-demand per-row queries for lazy tooltip lookups. - // Called before any chunk processing so the first hover after - // a (slow) upload completes can already dispatch a fetch. - if (this._chartImpl?.setView) { - this._chartImpl.setView(view); - } - const groupBy: string[] = viewerConfig?.group_by ?? []; - const splitBy: string[] = viewerConfig?.split_by ?? []; - if (this._chartImpl?.setViewPivots) { - this._chartImpl.setViewPivots(groupBy, splitBy); + const renderer = await this._ensureRenderer(view); + if (this._generation !== gen) { + return; } - if (this._chartImpl?.setColumnTypes && schema) { - this._chartImpl.setColumnTypes(schema as Record); + renderer.setView(view); + renderer.setBufferMaxCapacity(this._chartType.max_cells); + const viewer = this + .parentElement as HTMLPerspectiveViewerElement | null; + const viewerConfig = (await viewer?.getViewConfig?.()) ?? {}; + if (this._generation !== gen) { + return; } - const columnSlots: (string | null)[] = viewerConfig?.columns ?? []; - if (this._chartImpl?.setColumnSlots) { - this._chartImpl.setColumnSlots(columnSlots); - } - - const numCols = - Object.keys(schema as Record).length || 1; - - const maxRows = Math.floor(this._chartType.max_cells / numCols); - const totalRows = Math.min(numRows, maxRows); - glManager.ensureBufferCapacity(totalRows); - const callback = (columns: ColumnDataMap) => { - if (this._generation !== gen) return; - this._renderChunkData(columns, 0, totalRows); - }; - - await viewToColumnDataMap(view, callback, { - end_row: totalRows, - float32: true, + await renderer.loadAndRender({ + viewerConfig: { + group_by: viewerConfig?.group_by ?? [], + split_by: viewerConfig?.split_by ?? [], + columns: viewerConfig?.columns ?? [], + }, + options: { float32: true }, }); } - async update(view: View): Promise { - return this.draw(view); - } - async clear(): Promise { this._generation++; - if (this._glManager) { - this._glManager.clear(); - } - // Clear overlay - if (this._gridlineCanvas) { - const ctx = this._gridlineCanvas.getContext("2d"); - if (ctx) { - ctx.clearRect( - 0, - 0, - this._gridlineCanvas.width, - this._gridlineCanvas.height, - ); - } - } + this._renderer?.clear(); } async resize(): Promise { - if (this._glManager) { - this._glManager.resize(); - if (this._chartImpl) { - this._chartImpl.redraw(this._glManager); - } - } + this._renderer?.resize(); } - async restyle(): Promise { - await this.resize(); + restyle() { + this._renderer?.invalidateTheme(); + return 5; } save() { const state: any = {}; - if (this._zoomController) { - state.zoom = this._zoomController.serialize(); + const zoom = this._renderer?.saveZoom(); + if (zoom) { + state.zoom = zoom; } + + // Only emit the keys this chart actually consumes. + const cfg: Partial = {}; + for (const key of this._chartType.applicable_plugin_fields) { + // `key` is `PluginConfigField` = `keyof PluginConfig`, so this + // indexed assignment is type-safe without a cast. + (cfg[key] as PluginConfig[typeof key]) = this._pluginConfig[key]; + } + + if (Object.keys(cfg).length > 0) { + state.plugin_config = cfg; + } + return state; } + async render(view: View): Promise { + await this._ensureRenderer(view); + await this.draw(view); + return this._renderer!.snapshotPng(); + } + restore(config: any, columns_config?: Record) { - if (config?.zoom && this._zoomController) { - this._zoomController.restore(config.zoom); + if (config?.zoom) { + this._renderer?.restoreZoom(config.zoom); } - if (this._chartImpl?.setColumnsConfig) { - this._chartImpl.setColumnsConfig(columns_config ?? {}); - } + // Merge incoming plugin_config on top of the `chart_type` + // effective defaults so a partial restore (UI emits only + // changed fields) keeps untouched defaults in place — and + // chart-type overrides (e.g. `include_zero=true` for Y Bar / + // Y Area / X Bar) survive when the host elides their values. + this._pluginConfig = { + ...this._effectiveDefaults(), + ...config, + }; + + this._renderer?.setPluginConfig(this._pluginConfig); + this._renderer?.setColumnsConfig(columns_config ?? {}); } delete() { this._generation++; - // Destroy chart first — it may need the GL context for cleanup. - if (this._chartImpl) { - this._chartImpl.destroy(); - this._chartImpl = null; - } - if (this._zoomRouter) { - this._zoomRouter.detach(); - this._zoomRouter = null; + if (this._rawEventForwarder) { + this._rawEventForwarder.detach(); + this._rawEventForwarder = null; } - this._zoomController = null; - if (this._glManager) { - this._glManager.destroy(); - this._glManager = null; + + if (this._resetClickAbort) { + this._resetClickAbort.abort(); + this._resetClickAbort = null; } - } - private _renderChunkData( - columns: ColumnDataMap, - startRow: number, - endRow: number, - ): void { - if (!this._glManager) return; - - if (this._chartImpl) { - this._chartImpl.uploadAndRender( - this._glManager, - columns, - startRow, - endRow, - ); + if (this._renderer) { + this._renderer.destroy(); + this._renderer = null; } + + this._rendererPromise = null; } } diff --git a/packages/viewer-charts/src/ts/render/scheduler.ts b/packages/viewer-charts/src/ts/render/scheduler.ts new file mode 100644 index 0000000000..4b44de5a49 --- /dev/null +++ b/packages/viewer-charts/src/ts/render/scheduler.ts @@ -0,0 +1,339 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import type { WebGLContextManager } from "../webgl/context-manager"; + +/** + * Module-level render scheduler. The single entry point for driving a + * chart frame. Every render-triggering caller — upload chunks, zoom / + * pan, resize, theme invalidation, host-driven redraws — calls + * `requestRender(glManager, fullRender)` and awaits the returned + * promise. + * + * ## Guarantees + * + * 1. **At most one RAF queued globally.** The first request kicks + * off `requestAnimationFrame(drain)`; subsequent requests during + * that window enqueue without scheduling another callback. + * + * 2. **Coalesced per `glManager`.** The pending map is keyed by + * `WebGLContextManager`, so concurrent requests for the same + * chart share an entry — there is exactly one `_fullRender` call + * per glManager per RAF, regardless of how many requests landed. + * + * 3. **Promise resolves after the entry's own present.** Each + * waiter resolves when its entry's `_fullRender` + + * `awaitGpuFence` + `endFrame` chain completes. Independent + * glManagers run their fence waits in parallel (Phase 2 below), + * so a fast chart's waiters do not block on a slow chart in the + * same frame. + * + * 4. **At most one `_fullRender` per glManager per RAF.** GL + * contexts are not re-entered. `transferToImageBitmap` is called + * exactly once per glManager per frame, so the host blitter + * receives one bitmap per frame per chart and never an empty + * one. + * + * ## Drain ordering + * + * - **Phase 1 (synchronous):** iterate the pending snapshot and call + * each entry's `fullRender()` in one un-yielded loop. This pushes + * all GL command buffers to all contexts before any fence wait + * begins, letting per-context GPU work overlap. + * + * - **Phase 2 (parallel):** `Promise.all(snapshot.map(present))` + * where `present` does `await awaitGpuFence(); endFrame(); + * resolve waiters`. Each entry's waiters resolve as soon as its + * own present completes — independent of other entries. + * + * ## Failure modes + * + * - A throw from `fullRender()` rejects that entry's waiters and + * drops the entry; other entries continue to drain normally. + * + * - A rejection from `awaitGpuFence` calls `endFrame` anyway (so + * canvas state stays consistent — `transferToImageBitmap` clears + * the offscreen even on the error path) and rejects that entry's + * waiters. + * + * ## Snapshot bypass + * + * The scheduler always pairs `_fullRender` with `endFrame()`, which + * calls `transferToImageBitmap` and clears the offscreen. PNG + * export needs `gl.readPixels` against an intact backbuffer, so + * `snapshotPng` deliberately calls `_fullRender` directly and skips + * `endFrame`. That is the only sanctioned bypass; everything else + * goes through `requestRender`. + */ + +interface Entry { + glManager: WebGLContextManager; + fullRender: () => void; + waiters: PromiseWithResolvers[]; +} + +const pending = new Map(); +let rafId = 0; + +/** + * Set of `glManager`s currently in `present()` (Phase 2 — between + * Phase 1 paint and `endFrame`). Mutations to these canvases must be + * deferred until Phase 2 completes, otherwise they corrupt the bitmap + * that `transferToImageBitmap` ships: + * + * - `glManager.resize` sets `canvas.width = N`, which the spec + * mandates clears the drawing buffer immediately (out-of-band + * from the GL command queue). + * - `glManager.clear` queues `gl.clear` after Phase 1's draw + * commands but before the fence; if it executes before + * `transferToImageBitmap`, the canvas is wiped. + * + * Either path produces a blank frame on the host. `deferIfDraining` + * is the gate; sibling message handlers (resize, clear) wrap their + * canvas-mutating bodies in it. + */ +const inFlight = new Set(); +const deferred = new Map void)[]>(); + +/** + * Request a coalesced render of `glManager` whose body is + * `fullRender`. Returns a promise that resolves when this entry's + * Phase 2 (`awaitGpuFence` + `endFrame`) completes. + * + * If a request is already pending for the same glManager, the new + * call's `fullRender` closure replaces the prior one (latest call + * wins; closures read chart state lazily so this is functionally a + * no-op, but keeps the closure fresh) and the returned promise + * resolves alongside the existing waiters. + */ +export function requestRender( + glManager: WebGLContextManager, + fullRender: () => void, +): Promise { + let entry = pending.get(glManager); + if (entry) { + entry.fullRender = fullRender; + } else { + entry = { glManager, fullRender, waiters: [] }; + pending.set(glManager, entry); + } + + const waiter = Promise.withResolvers(); + entry.waiters.push(waiter); + + if (!rafId) { + rafId = scheduleFrame(drain); + } + + return waiter.promise; +} + +/** + * Run `op` synchronously if no drain `present()` is currently active + * for `glManager`. Otherwise queue `op` to run as soon as that + * glManager's in-flight `present()` completes (after `endFrame`, + * after the resolved/rejected waiters). + * + * Used by canvas-mutating callers — `WorkerRenderer.resize`, + * `WorkerRenderer.clear` — to avoid wiping the offscreen between + * Phase 1 paint and Phase 2 `endFrame`. `glManager.resize` setting + * `canvas.width = N` clears the drawing buffer immediately + * (per the WebGL spec, out-of-band from the GL command queue), and + * a clear that lands in Phase 2's fence-wait yield window corrupts + * the bitmap that `transferToImageBitmap` ships, producing a blank + * frame on the host. + * + * Deferred ops execute in `present()`'s `finally` clause, so they + * land *after* the in-flight drain's bitmap has been shipped and + * before the next drain starts. If a deferred op itself triggers a + * `requestRender`, the resulting entry queues into `pending` and + * the drain's tail check (`pending.size > 0 → scheduleFrame(drain)`) + * picks it up for the next RAF. + */ +export function deferIfDraining( + glManager: WebGLContextManager, + op: () => void, +): void { + if (!inFlight.has(glManager)) { + op(); + return; + } + + let ops = deferred.get(glManager); + if (!ops) { + ops = []; + deferred.set(glManager, ops); + } + + ops.push(op); +} + +/** + * Test-only: clear pending state. Production callers must not use + * this — outstanding waiters are silently dropped. + */ +export function _resetForTest(): void { + if (rafId) { + cancelFrame(rafId); + rafId = 0; + } + + pending.clear(); + inFlight.clear(); + deferred.clear(); +} + +// async function drain(): Promise { +// rafId = 0; + +// // Snapshot the pending set up front so requests that arrive during +// // the drain (in microtasks between Phase 2 awaits, or in tasks +// // unblocked by `awaitGpuFence`'s yields) queue into the next RAF +// // rather than mutating this drain's working set. +// const snapshot = Array.from(pending.values()); +// pending.clear(); + +// // Phase 1: synchronously queue GL commands for every entry. One +// // un-yielded loop so all contexts have their commands submitted +// // before any fence wait begins; otherwise fence waits serialize +// // behind each subsequent `_fullRender`'s draw submissions. +// const ready: Entry[] = []; +// for (const entry of snapshot) { +// try { +// entry.fullRender(); +// ready.push(entry); +// } catch (err) { +// console.error("scheduler: fullRender threw", err); +// for (const w of entry.waiters) { +// w.reject(err); +// } +// } +// } + +// // Phase 2: run each entry's fence + endFrame + waiter-resolve as +// // its own async task. `Promise.all` joins for the drain wall +// // time, but per-entry waiters resolve as soon as their entry's +// // present completes — a fast chart in this frame is not held up +// // by a slow chart. +// await Promise.all(ready.map(present)); +// } + +async function drain(): Promise { + const snapshot = Array.from(pending.values()); + pending.clear(); + const ready: Entry[] = []; + for (const entry of snapshot) { + try { + // Apply any dimension change recorded by + // `glManager.requestResize` *before* the paint, in the + // same un-yielded synchronous Phase 1 loop. This pairs + // the canvas-clearing `canvas.width = N` assignment + // with the immediately-following `_fullRender`, so the + // browser's compositor only ever observes the canvas + // post-paint. In direct/in-process modes the visible + // canvas IS the GL canvas, and a clear-without-matching- + // paint in the previous task would otherwise present an + // empty frame to the user. + entry.glManager.applyPendingResize(); + entry.fullRender(); + ready.push(entry); + } catch (err) { + console.error("scheduler: fullRender threw", err); + for (const w of entry.waiters) { + w.reject(err); + } + } + } + + await Promise.all(ready.map(present)); + + // Now (and only now) clear rafId. If new requests landed during + // this drain, schedule the next RAF. + rafId = 0; + if (pending.size > 0) { + rafId = scheduleFrame(drain); + } +} + +async function present(entry: Entry): Promise { + // Mark this glManager as in-flight *synchronously*, before the + // first await. `Promise.all(ready.map(present))` calls each + // `present` synchronously to collect its returned promise, so + // every entry's glManager is registered in `inFlight` before + // any fence-wait yields and before any sibling message handler + // can run. Mutations posted by sibling handlers (resize, clear) + // route through `deferIfDraining` and queue into `deferred` + // until the `finally` block flushes them. + inFlight.add(entry.glManager); + try { + await entry.glManager.awaitGpuFence(); + entry.glManager.endFrame(); + for (const w of entry.waiters) { + w.resolve(); + } + } catch (err) { + console.error("scheduler: present failed", err); + // Still call `endFrame` so the canvas state is consistent — + // `transferToImageBitmap` clears the offscreen, and skipping + // it would leave a stale image bound to a context the host + // already considers presented. + try { + entry.glManager.endFrame(); + } catch { + // Swallow: already in a failure path. + } + + for (const w of entry.waiters) { + w.reject(err); + } + } finally { + // Bitmap shipped (or error reported). Re-open the canvas to + // mutations and flush any deferred ops in arrival order. + // Deferred ops may call `requestRender`; the resulting + // entry queues into `pending` and the drain's tail check + // picks it up for the next RAF. + inFlight.delete(entry.glManager); + const ops = deferred.get(entry.glManager); + if (ops) { + deferred.delete(entry.glManager); + for (const op of ops) { + try { + op(); + } catch (err) { + console.error("scheduler: deferred op threw", err); + } + } + } + } +} + +/** + * RAF in worker scope is exposed by `DedicatedWorkerGlobalScope` for + * `OffscreenCanvas` painting and is the same primitive as on the + * main thread. Fall back to `setTimeout(16)` for environments + * without RAF (jsdom, headless tests without a polyfill). + */ +function scheduleFrame(cb: () => void): number { + if (typeof requestAnimationFrame === "function") { + return requestAnimationFrame(cb); + } + + return setTimeout(cb, 16) as unknown as number; +} + +function cancelFrame(id: number): void { + if (typeof cancelAnimationFrame === "function") { + cancelAnimationFrame(id); + } else { + clearTimeout(id); + } +} diff --git a/rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab/stub.rs b/packages/viewer-charts/src/ts/shaders/density-extreme.frag.glsl similarity index 77% rename from rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab/stub.rs rename to packages/viewer-charts/src/ts/shaders/density-extreme.frag.glsl index 166c9e3aa4..e8bb8750a5 100644 --- a/rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab/stub.rs +++ b/packages/viewer-charts/src/ts/shaders/density-extreme.frag.glsl @@ -10,25 +10,21 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -use yew::{Html, Properties, function_component, html}; +precision highp float; -#[derive(Properties, PartialEq)] -pub struct StubProps { - pub error: Option, - pub message: String, -} +varying vec2 v_uv; +varying float v_color_t; -#[function_component(Stub)] -pub fn stub(p: &StubProps) -> Html { - if let Some(error) = p.error.clone() { - tracing::error!("Rendered stub: {error}"); +void main() { + float r = length(v_uv); + if(r >= 1.0) { + discard; } - html! { -
-
-
{ p.message.clone() }
-
-
- } + // Signed deviation from the gradient midpoint, in [-1, +1]. Split + // into two non-negative channels so the FBO format stays in [0, 1] + // and so the `MAX` blend at the call site keeps the strongest + // contributor of each sign separately. + float dev = (v_color_t - 0.5) * 2.0; + gl_FragColor = vec4(max(0.0, dev), max(0.0, -dev), 0.0, 0.0); } diff --git a/rust/perspective-viewer/src/rust/tasks/structural.rs b/packages/viewer-charts/src/ts/shaders/density-mrt.frag.glsl similarity index 68% rename from rust/perspective-viewer/src/rust/tasks/structural.rs rename to packages/viewer-charts/src/ts/shaders/density-mrt.frag.glsl index b507a1b765..2d7adf18cc 100644 --- a/rust/perspective-viewer/src/rust/tasks/structural.rs +++ b/packages/viewer-charts/src/ts/shaders/density-mrt.frag.glsl @@ -1,3 +1,4 @@ +#version 300 es // ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ // ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ // ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ @@ -10,44 +11,34 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -//! Structural typing traits that allow task methods to be automatically defined -//! for any struct that holds the necessary state handles. +// MRT variant of the splat fragment used by `extreme` mode when the +// running context advertises `OES_draw_buffers_indexed`. One pass +// writes to both targets: +// +// - location 0 (heat FBO, ADD blend): same payload as +// density-splat.frag.glsl — `(w, w·t, 0, 0)`. +// - location 1 (extreme FBO, MAX blend): same payload as +// density-extreme.frag.glsl — split signed deviation. -use crate::custom_events::*; -use crate::dragdrop::*; -use crate::presentation::*; -use crate::renderer::*; -use crate::session::*; +precision highp float; -pub trait HasCustomEvents { - fn custom_events(&self) -> &'_ CustomEvents; -} +uniform float u_intensity; -pub trait HasDragDrop { - fn dragdrop(&self) -> &'_ DragDrop; -} +in vec2 v_uv; +in float v_color_t; -pub trait HasPresentation { - fn presentation(&self) -> &'_ Presentation; -} - -pub trait HasRenderer { - fn renderer(&self) -> &'_ Renderer; -} +layout(location = 0) out vec4 outHeat; +layout(location = 1) out vec4 outExtreme; -pub trait HasSession { - fn session(&self) -> &'_ Session; -} - -impl HasSession for Session { - fn session(&self) -> &'_ Session { - self +void main() { + float r = length(v_uv); + float w = max(0.0f, 1.0f - r); + w = w * w * u_intensity; + if(w <= 0.0f) { + discard; } -} - -pub trait StateProvider { - type State: Clone + 'static; - /// Clones just the state object fields into a new dedicated state struct. - fn clone_state(&self) -> Self::State; + outHeat = vec4(w, w * v_color_t, 0.0f, 0.0f); + float dev = (v_color_t - 0.5f) * 2.0f; + outExtreme = vec4(max(0.0f, dev), max(0.0f, -dev), 0.0f, 0.0f); } diff --git a/packages/viewer-openlayers/src/js/style/categoryColors.js b/packages/viewer-charts/src/ts/shaders/density-mrt.vert.glsl similarity index 62% rename from packages/viewer-openlayers/src/js/style/categoryColors.js rename to packages/viewer-charts/src/ts/shaders/density-mrt.vert.glsl index fd9708359d..fd2c748a34 100644 --- a/packages/viewer-openlayers/src/js/style/categoryColors.js +++ b/packages/viewer-charts/src/ts/shaders/density-mrt.vert.glsl @@ -1,3 +1,4 @@ +#version 300 es // ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ // ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ // ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ @@ -10,46 +11,38 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import { computedStyle, toFillAndStroke } from "./computed"; +// GLSL ES 3.00 variant of `density-splat.vert.glsl`, mirroring its math +// 1:1 in the modern dialect (`in`/`out` instead of `attribute`/ +// `varying`). Paired with `density-mrt.frag.glsl` for the MRT fast +// path used by `extreme` mode on WebGL2 — the program's vertex and +// fragment shaders must share a GLSL version, so the WebGL1-style +// splat vert can't be linked against a 300 ES MRT frag. -const CATEGORY_COLOR_VAR = "--map-category-"; -const defaultColors = [ - "#1f77b4", - "#0366d6", - "#ff7f0e", - "#2ca02c", - "#d62728", - "#9467bd", - "#8c564b", - "#e377c2", - "#7f7f7f", - "#bcbd22", - "#17becf", -]; +in vec2 a_corner; +in vec2 a_position; +in float a_color_value; -const defaultValueFn = (d) => d.category; -export const categoryColorMap = (container, data, valueFn = defaultValueFn) => { - let colIndex = 0; - const categories = {}; - const categoryColors = getCategoryColors(container); +uniform mat4 u_projection; +uniform vec2 u_radius_ndc; +uniform vec2 u_color_range; - data.forEach((point) => { - const category = valueFn(point); - if (!categories[category]) { - const col = categoryColors[colIndex]; - categories[category] = toFillAndStroke(col); +out vec2 v_uv; +out float v_color_t; - colIndex++; - if (colIndex >= categoryColors.length) colIndex = 0; - } - }); +void main() { + vec4 center = u_projection * vec4(a_position, 0.0, 1.0); + gl_Position = center + vec4(a_corner * u_radius_ndc * center.w, 0.0, 0.0); - return (point) => categories[valueFn(point)]; -}; + v_uv = a_corner; -const getCategoryColors = (container) => { - const computed = computedStyle(container); - return defaultColors.map((defaultColor, i) => - computed(`${CATEGORY_COLOR_VAR}${i + 1}`, defaultColor), - ); -}; + float cmin = u_color_range.x; + float cmax = u_color_range.y; + if(cmax <= cmin) { + v_color_t = 0.5; + } else if(cmin < 0.0 && cmax > 0.0) { + float denom = max(-cmin, cmax); + v_color_t = clamp(0.5 + 0.5 * (a_color_value / denom), 0.0, 1.0); + } else { + v_color_t = clamp((a_color_value - cmin) / (cmax - cmin), 0.0, 1.0); + } +} diff --git a/packages/viewer-charts/src/ts/shaders/density-resolve.frag.glsl b/packages/viewer-charts/src/ts/shaders/density-resolve.frag.glsl new file mode 100644 index 0000000000..0f87d56189 --- /dev/null +++ b/packages/viewer-charts/src/ts/shaders/density-resolve.frag.glsl @@ -0,0 +1,89 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +precision highp float; + +uniform sampler2D u_heat; +uniform sampler2D u_extreme; +uniform sampler2D u_gradient_lut; + +// Saturation knob shared by every mode. `density` maps it onto hue, +// `mean` / `extreme` map it onto alpha, `signed` uses it as the +// signed-sum denominator. +uniform float u_heat_max; + +// 0 = density-only (color column ignored) +// 1 = mean (density-weighted average of color-t) +// 2 = extreme (sign-aware max-blended deviation) +// 3 = signed (net positive vs net negative accumulation) +uniform int u_color_mode; + +varying vec2 v_uv; + +void main() { + vec4 sd = texture2D(u_heat, v_uv); + float density = sd.r; + float weighted = sd.g; + + if(density <= 0.0) { + discard; + } + + float t; + float alpha; + float n = clamp(density / max(u_heat_max, 1e-4), 0.0, 1.0); + + if(u_color_mode == 0) { + // Density only: gamma-curve clamped density into the hue + // axis. Alpha follows raw density so a single splat fades + // smoothly into the plot background. + t = pow(n, 0.6); + alpha = clamp(density, 0.0, 1.0); + } else if(u_color_mode == 2) { + // Extreme: signed max of `t - 0.5` is split across R (positive + // deviation) and G (negative deviation magnitude); whichever + // is larger wins the sign. Falls back to neutral if neither + // contributed at this pixel. + vec4 ext = texture2D(u_extreme, v_uv); + float pos = ext.r; + float neg = ext.g; + float winner = max(pos, neg); + if(winner <= 0.0) { + t = 0.5; + } else if(pos >= neg) { + t = clamp(0.5 + pos * 0.5, 0.5, 1.0); + } else { + t = clamp(0.5 - neg * 0.5, 0.0, 0.5); + } + alpha = n; + } else if(u_color_mode == 3) { + // Signed sum: each splat additively contributes `w * t`, so + // the per-pixel signed sum (relative to the neutral midpoint) + // is `Σ w(t-0.5) = G - 0.5·R`. Sign drives which half of the + // LUT we land in, magnitude/heat_max drives both saturation + // and alpha. + float signed_sum = weighted - 0.5 * density; + float mag = clamp(abs(signed_sum) / max(u_heat_max, 1e-4), 0.0, 1.0); + float dir = signed_sum >= 0.0 ? 1.0 : -1.0; + t = clamp(0.5 + 0.5 * dir * mag, 0.0, 1.0); + alpha = mag; + } else { + // Mean (mode 1, default): density-weighted average of per- + // point color-t, with density driving alpha so sparse + // pixels fade out. + t = clamp(weighted / density, 0.0, 1.0); + alpha = n; + } + + vec4 color = texture2D(u_gradient_lut, vec2(t, 0.5)); + gl_FragColor = vec4(color.rgb, color.a * alpha); +} diff --git a/packages/viewer-openlayers/src/js/views/views.js b/packages/viewer-charts/src/ts/shaders/density-resolve.vert.glsl similarity index 84% rename from packages/viewer-openlayers/src/js/views/views.js rename to packages/viewer-charts/src/ts/shaders/density-resolve.vert.glsl index 14ce5f4f75..3422fdc32b 100644 --- a/packages/viewer-openlayers/src/js/views/views.js +++ b/packages/viewer-charts/src/ts/shaders/density-resolve.vert.glsl @@ -10,9 +10,14 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import mapView from "./map-view"; -// import regionView from "./region-view"; +// Fullscreen triangle covering NDC `[-1, 1]^2`. Drawn with +// `drawArrays(TRIANGLES, 0, 3)`; the lone triangle's overscan rasterizes +// every pixel exactly once and replaces the standard two-triangle quad. +attribute vec2 a_corner; -// const views = [mapView, regionView]; -const views = [mapView]; -export default views; +varying vec2 v_uv; + +void main() { + v_uv = a_corner * 0.5 + 0.5; + gl_Position = vec4(a_corner, 0.0, 1.0); +} diff --git a/packages/viewer-openlayers/src/js/style/computed.js b/packages/viewer-charts/src/ts/shaders/density-splat.frag.glsl similarity index 75% rename from packages/viewer-openlayers/src/js/style/computed.js rename to packages/viewer-charts/src/ts/shaders/density-splat.frag.glsl index 3d848b8231..911e391ec1 100644 --- a/packages/viewer-openlayers/src/js/style/computed.js +++ b/packages/viewer-charts/src/ts/shaders/density-splat.frag.glsl @@ -10,25 +10,25 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import { color, rgb } from "d3-color"; +precision highp float; -export const computedStyle = (container) => { - const containerStyles = getComputedStyle(container); - return (name, defaultValue) => - containerStyles.getPropertyValue(name) || defaultValue; -}; +uniform float u_intensity; -export const toFillAndStroke = (col) => { - const asColor = color(col); - const stroke = `${asColor}`; - asColor.opacity = 0.5; - const fill = `${asColor}`; +varying vec2 v_uv; +varying float v_color_t; - return { stroke, fill }; -}; +void main() { + // Radial falloff. `length(v_uv)` is `1.0` at the disk edge. + // `pow(...)^2` matches the WGSL reference splat shape. + float r = length(v_uv); + float w = max(0.0, 1.0 - r); + w = w * w * u_intensity; + if(w <= 0.0) { + discard; + } -export const lightenRgb = (col, lighten) => { - const source = Array.isArray(col) ? rgb(col) : color(col); - const target = source.brighter(lighten); - return `${target}`; -}; + // R: accumulated density. G: density-weighted color-t. The resolve + // pass divides G/R to recover the per-pixel weighted-average t. + // Blend func at the call site is `(gl.ONE, gl.ONE)` (additive). + gl_FragColor = vec4(w, w * v_color_t, 0.0, 0.0); +} diff --git a/packages/viewer-charts/src/ts/shaders/density-splat.vert.glsl b/packages/viewer-charts/src/ts/shaders/density-splat.vert.glsl new file mode 100644 index 0000000000..bc40919105 --- /dev/null +++ b/packages/viewer-charts/src/ts/shaders/density-splat.vert.glsl @@ -0,0 +1,52 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +// Per-vertex (divisor 0): unit-quad corner in `[(-1,-1), (1,-1), (-1,1), (1,1)]`. +attribute vec2 a_corner; + +// Per-instance (divisor 1): data-space xy of the splat center. +attribute vec2 a_position; + +// Per-instance (divisor 1): raw color column value. Folded into a +// sign-aware `t` matching the scatter glyph for cross-chart parity. +attribute float a_color_value; + +// Splat radius expressed in NDC. Computed CPU-side as +// `(radius_px * dpr * 2) / plot_pixel_width` so the disk maintains a +// fixed pixel footprint as the user zooms. +uniform vec2 u_radius_ndc; + +uniform mat4 u_projection; +uniform vec2 u_color_range; +varying vec2 v_uv; +varying float v_color_t; + +void main() { + // Project the data-space center into clip space, then offset by the + // unit-quad corner scaled by `u_radius_ndc`. This keeps splats + // axis-aligned to the screen regardless of zoom level. + vec4 center = u_projection * vec4(a_position, 0.0, 1.0); + gl_Position = center + vec4(a_corner * u_radius_ndc * center.w, 0.0, 0.0); + + v_uv = a_corner; + + float cmin = u_color_range.x; + float cmax = u_color_range.y; + if(cmax <= cmin) { + v_color_t = 0.5; + } else if(cmin < 0.0 && cmax > 0.0) { + float denom = max(-cmin, cmax); + v_color_t = clamp(0.5 + 0.5 * (a_color_value / denom), 0.0, 1.0); + } else { + v_color_t = clamp((a_color_value - cmin) / (cmax - cmin), 0.0, 1.0); + } +} diff --git a/packages/viewer-charts/src/ts/shaders/heatmap.vert.glsl b/packages/viewer-charts/src/ts/shaders/heatmap.vert.glsl index 05eadb4528..1b96c0cd18 100644 --- a/packages/viewer-charts/src/ts/shaders/heatmap.vert.glsl +++ b/packages/viewer-charts/src/ts/shaders/heatmap.vert.glsl @@ -13,25 +13,30 @@ // Unit-quad corner per vertex: (0,0) bottom-left → (1,1) top-right. attribute vec2 a_corner; -// Per-instance: cell grid coordinate + normalized color t in [0, 1]. +// Per-instance: cell center (data units) + normalized color t in [0, 1]. +// In category mode the center is the integer index `(xIdx, yIdx)`; in +// numeric mode the JS uploader pre-multiplies into real data values +// (e.g. ms-since-epoch). attribute vec2 a_cell; attribute float a_color_t; - uniform mat4 u_projection; + // Inset applied in data-space so the shader can carve a pixel-accurate // gap between neighbouring cells regardless of plot size. uniform vec2 u_cell_inset; +// Cell size in data units. `(1.0, 1.0)` for the integer category grid; +// in numeric mode the bandWidth derived from min adjacent delta. +uniform vec2 u_cell_size; varying float v_color_t; void main() { - // Cell `c` occupies [c - 0.5, c + 0.5] on each axis. Inset shrinks + // Cell `c` occupies [c - half, c + half] on each axis. Inset shrinks // the rendered rect inward by `u_cell_inset` on all sides. - float span_x = 1.0 - 2.0 * u_cell_inset.x; - float span_y = 1.0 - 2.0 * u_cell_inset.y; - float x = a_cell.x - 0.5 + u_cell_inset.x + a_corner.x * span_x; - float y = a_cell.y - 0.5 + u_cell_inset.y + a_corner.y * span_y; - + vec2 half_size = 0.5 * u_cell_size; + vec2 span = u_cell_size - 2.0 * u_cell_inset; + float x = a_cell.x - half_size.x + u_cell_inset.x + a_corner.x * span.x; + float y = a_cell.y - half_size.y + u_cell_inset.y + a_corner.y * span.y; gl_Position = u_projection * vec4(x, y, 0.0, 1.0); v_color_t = a_color_t; } diff --git a/packages/viewer-charts/src/ts/shaders/scatter.vert.glsl b/packages/viewer-charts/src/ts/shaders/scatter.vert.glsl index db20bf2738..d652aa7c39 100644 --- a/packages/viewer-charts/src/ts/shaders/scatter.vert.glsl +++ b/packages/viewer-charts/src/ts/shaders/scatter.vert.glsl @@ -28,20 +28,13 @@ varying float v_color_t; varying float v_point_size; void main() { - // Unused-slot sentinel: the per-series slotted buffer leaves tails - // filled with `a_color_value = -1` so a single draw can cover all - // series. Collapse those vertices to clip-discard. The sentinel is - // only exposed in multi-series mode, where `a_color_value` holds a - // non-negative series index; single-series draws bound the count to - // valid rows and never reach this branch. - if(a_color_value < 0.0) { - gl_Position = vec4(2.0, 2.0, 2.0, 1.0); - gl_PointSize = 0.0; - v_point_size = 0.0; - v_color_t = 0.0; - return; - } - + // No unused-slot discard: tight per-series draws in `points.ts` + // already bound `gl.drawArrays(gl.POINTS, s*cap, count[s])` to + // valid rows, so the shader never sees a tail slot. A historical + // sentinel branch here culled `a_color_value < 0.0`, which under + // current dispatch silently dropped real numeric color data + // whenever the user's color column legitimately went negative + // (e.g., a diverging `Profit` column). gl_Position = u_projection * vec4(a_position, 0.0, 1.0); float sizeRange = u_size_range.y - u_size_range.x; diff --git a/packages/viewer-openlayers/index.d.ts b/packages/viewer-charts/src/ts/shaders/tile.frag.glsl similarity index 83% rename from packages/viewer-openlayers/index.d.ts rename to packages/viewer-charts/src/ts/shaders/tile.frag.glsl index 78b63e8861..cac33dcf30 100644 --- a/packages/viewer-openlayers/index.d.ts +++ b/packages/viewer-charts/src/ts/shaders/tile.frag.glsl @@ -9,3 +9,19 @@ // ┃ This file is part of the Perspective library, distributed under the terms ┃ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +precision highp float; + +varying vec2 v_uv; + +uniform sampler2D u_tile; + +// Optional alpha multiplier (0.0..1.0). Used to fade the tile basemap +// when the chart's theme prefers a subdued backdrop behind the glyph +// layer; defaults to 1.0 so callers can omit it. +uniform float u_alpha; + +void main() { + vec4 c = texture2D(u_tile, v_uv); + gl_FragColor = vec4(c.rgb, c.a * u_alpha); +} diff --git a/packages/viewer-charts/src/ts/shaders/tile.vert.glsl b/packages/viewer-charts/src/ts/shaders/tile.vert.glsl new file mode 100644 index 0000000000..78d873790f --- /dev/null +++ b/packages/viewer-charts/src/ts/shaders/tile.vert.glsl @@ -0,0 +1,35 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +// Unit-quad vertex shader for one map tile. The same static [0..1] +// corner buffer is bound for every tile in a frame; per-tile uniforms +// stretch the corner into Mercator space and pick the UV sub-rect. +// Sub-rect support is for the parent-tile fallback path (sample the +// loaded parent at the child's quadrant while the child fetches). +attribute vec2 a_corner; + +uniform mat4 u_projection; +uniform vec2 u_extent_min; +uniform vec2 u_extent_max; +uniform vec2 u_uv_min; +uniform vec2 u_uv_max; + +varying vec2 v_uv; + +void main() { + vec2 pos = mix(u_extent_min, u_extent_max, a_corner); + // Tiles ship north-up; the WebGL Y axis points up at clip + // coordinates so flip the V channel before sampling. + vec2 uv = mix(u_uv_min, u_uv_max, vec2(a_corner.x, 1.0 - a_corner.y)); + v_uv = uv; + gl_Position = u_projection * vec4(pos, 0.0, 1.0); +} diff --git a/packages/viewer-charts/src/ts/theme/gradient.ts b/packages/viewer-charts/src/ts/theme/gradient.ts index 6adf54a300..067b65f65c 100644 --- a/packages/viewer-charts/src/ts/theme/gradient.ts +++ b/packages/viewer-charts/src/ts/theme/gradient.ts @@ -12,7 +12,9 @@ import { parseCSSColorToVec3 } from "../utils/css"; -/** A single stop on a parsed CSS gradient. `offset` ∈ [0, 1]. */ +/** + * A single stop on a parsed CSS gradient. `offset` ∈ [0, 1]. + */ export interface GradientStop { offset: number; color: [number, number, number, number]; // RGBA, each ∈ [0, 1] @@ -35,16 +37,30 @@ const DEFAULT_STOPS: GradientStop[] = [ export function parseCssGradient( src: string | null | undefined, ): GradientStop[] { - if (!src) return DEFAULT_STOPS.slice(); + if (!src) { + return DEFAULT_STOPS.slice(); + } + const trimmed = src.trim(); - if (!trimmed) return DEFAULT_STOPS.slice(); + if (!trimmed) { + return DEFAULT_STOPS.slice(); + } // Strip the `linear-gradient(` wrapper. Bail out if we don't find it. const openIdx = trimmed.indexOf("("); - if (openIdx < 0) return DEFAULT_STOPS.slice(); - if (!/^linear-gradient\s*\(/i.test(trimmed)) return DEFAULT_STOPS.slice(); + if (openIdx < 0) { + return DEFAULT_STOPS.slice(); + } + + if (!/^linear-gradient\s*\(/i.test(trimmed)) { + return DEFAULT_STOPS.slice(); + } + const closeIdx = trimmed.lastIndexOf(")"); - if (closeIdx <= openIdx) return DEFAULT_STOPS.slice(); + if (closeIdx <= openIdx) { + return DEFAULT_STOPS.slice(); + } + const body = trimmed.substring(openIdx + 1, closeIdx); // Split on commas at depth 0 (respecting nested `rgb(...)` / `rgba(...)` / @@ -54,13 +70,16 @@ export function parseCssGradient( let start = 0; for (let i = 0; i < body.length; i++) { const ch = body[i]; - if (ch === "(") depth++; - else if (ch === ")") depth--; - else if (ch === "," && depth === 0) { + if (ch === "(") { + depth++; + } else if (ch === ")") { + depth--; + } else if (ch === "," && depth === 0) { parts.push(body.substring(start, i)); start = i + 1; } } + parts.push(body.substring(start)); // First part may be a direction (`to right`, `90deg`, `to bottom right`) @@ -82,7 +101,10 @@ export function parseCssGradient( for (let i = startIdx; i < parts.length; i++) { const piece = parts[i].trim(); - if (!piece) continue; + if (!piece) { + continue; + } + // Peel off an optional trailing `%` or `px`. const pctMatch = piece.match(/\s([\-\d.]+)%\s*$/); const color = pctMatch @@ -97,7 +119,10 @@ export function parseCssGradient( } } - if (stops.length === 0) return DEFAULT_STOPS.slice(); + if (stops.length === 0) { + return DEFAULT_STOPS.slice(); + } + if (stops.length === 1) { // Single stop → solid color. Duplicate across [0, 1] so sampling works. const [r, g, b] = stops[0].color; @@ -109,15 +134,25 @@ export function parseCssGradient( // Fill in missing offsets by linear interpolation of neighbours with // known positions (CSS implicit-position semantics). - if (stops[0].offset === null) stops[0].offset = 0; - if (stops[stops.length - 1].offset === null) + if (stops[0].offset === null) { + stops[0].offset = 0; + } + + if (stops[stops.length - 1].offset === null) { stops[stops.length - 1].offset = 1; + } for (let i = 1; i < stops.length - 1; i++) { - if (stops[i].offset !== null) continue; + if (stops[i].offset !== null) { + continue; + } + // Find next known offset. let j = i + 1; - while (j < stops.length && stops[j].offset === null) j++; + while (j < stops.length && stops[j].offset === null) { + j++; + } + const before = stops[i - 1].offset!; const after = stops[j].offset!; const span = j - (i - 1); @@ -125,6 +160,7 @@ export function parseCssGradient( stops[k].offset = before + ((k - (i - 1)) / span) * (after - before); } + i = j - 1; } @@ -150,21 +186,31 @@ export function sampleGradient( stops: GradientStop[], t: number, ): [number, number, number, number] { - if (stops.length === 0) return [0, 0, 0, 1]; - if (t <= stops[0].offset) + if (stops.length === 0) { + return [0, 0, 0, 1]; + } + + if (t <= stops[0].offset) { return stops[0].color.slice() as [number, number, number, number]; + } + const last = stops[stops.length - 1]; - if (t >= last.offset) + if (t >= last.offset) { return last.color.slice() as [number, number, number, number]; + } // Bisect for the interval containing `t`. let lo = 0; let hi = stops.length - 1; while (hi - lo > 1) { const mid = (lo + hi) >> 1; - if (stops[mid].offset <= t) lo = mid; - else hi = mid; + if (stops[mid].offset <= t) { + lo = mid; + } else { + hi = mid; + } } + const a = stops[lo]; const b = stops[hi]; const span = b.offset - a.offset; @@ -190,7 +236,10 @@ export function colorValueToT( colorMin: number, colorMax: number, ): number { - if (!isFinite(value) || colorMin === colorMax) return 0.5; + if (!isFinite(value) || colorMin === colorMax) { + return 0.5; + } + let denom: number; if (colorMin >= 0) { denom = colorMax; @@ -199,13 +248,17 @@ export function colorValueToT( } else { denom = Math.max(-colorMin, colorMax); } - if (denom <= 0) return 0.5; + + if (denom <= 0) { + return 0.5; + } + const t = 0.5 + 0.5 * (value / denom); return t < 0 ? 0 : t > 1 ? 1 : t; } /** - * Convert a discrete series palette (from `--psp-webgl--series-N--color`) + * Convert a discrete series palette (from `--psp-charts--series-N--color`) * into a `GradientStop[]` with stops at `i / (N - 1)`. The resulting * stops can feed `buildGradientLUT` / `ensureGradientTexture` / any * other code path that already accepts a gradient — so categorical @@ -217,7 +270,10 @@ export function colorValueToT( export function paletteToStops( palette: [number, number, number][], ): GradientStop[] { - if (palette.length === 0) return DEFAULT_STOPS.slice(); + if (palette.length === 0) { + return DEFAULT_STOPS.slice(); + } + if (palette.length === 1) { const [r, g, b] = palette[0]; return [ @@ -225,6 +281,7 @@ export function paletteToStops( { offset: 1, color: [r, g, b, 1] }, ]; } + const denom = palette.length - 1; return palette.map(([r, g, b], i) => ({ offset: i / denom, @@ -250,5 +307,6 @@ export function buildGradientLUT( out[i * 4 + 2] = Math.round(c[2] * 255); out[i * 4 + 3] = Math.round(c[3] * 255); } + return out; } diff --git a/packages/viewer-charts/src/ts/theme/palette.ts b/packages/viewer-charts/src/ts/theme/palette.ts index 1c53af0d23..cf0d12fcc8 100644 --- a/packages/viewer-charts/src/ts/theme/palette.ts +++ b/packages/viewer-charts/src/ts/theme/palette.ts @@ -22,18 +22,22 @@ export function interpolatePalette( stops: GradientStop[], count: number, ): Vec3[] { - if (count <= 0) return []; + if (count <= 0) { + return []; + } + const out: Vec3[] = new Array(count); for (let i = 0; i < count; i++) { const t = count === 1 ? 0.5 : i / (count - 1); const c = sampleGradient(stops, t); out[i] = [c[0], c[1], c[2]]; } + return out; } /** - * Resolve a series palette: use the discrete `--psp-webgl--series-N--color` + * Resolve a series palette: use the discrete `--psp-charts--series-N--color` * palette when available, otherwise fall back to evenly-spaced samples of * the theme gradient. */ @@ -43,11 +47,18 @@ export function resolvePalette( count: number, ): Vec3[] { if (discrete.length > 0) { - if (discrete.length >= count) return discrete.slice(0, count); + if (discrete.length >= count) { + return discrete.slice(0, count); + } + // Cycle through the discrete palette for overflow indices. const out: Vec3[] = new Array(count); - for (let i = 0; i < count; i++) out[i] = discrete[i % discrete.length]; + for (let i = 0; i < count; i++) { + out[i] = discrete[i % discrete.length]; + } + return out; } + return interpolatePalette(stops, count); } diff --git a/packages/viewer-openlayers/src/js/data/data.js b/packages/viewer-charts/src/ts/theme/theme-snapshot.ts similarity index 54% rename from packages/viewer-openlayers/src/js/data/data.js rename to packages/viewer-charts/src/ts/theme/theme-snapshot.ts index 8b59ca338b..fd08d0283e 100644 --- a/packages/viewer-openlayers/src/js/data/data.js +++ b/packages/viewer-charts/src/ts/theme/theme-snapshot.ts @@ -10,48 +10,57 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -export function getMapData(config) { - const points = []; - // const cols = config.columns.map(x => ) - config.data.forEach((row, i) => { - // Exclude "total" rows that don't have all values - const groupCount = row.__ROW_PATH__ ? row.__ROW_PATH__.length : 0; - if (groupCount < config.group_by.length) { - return; - } +import type { ThemeSnapshot } from "./theme"; - // Get the group from the row path - const group = row.__ROW_PATH__ ? row.__ROW_PATH__.join("|") : `${i}`; - const rowPoints = {}; +/** + * CSS variables (and one inherited property) the chart renderer reads. + * Host-only: workers can't call `getComputedStyle`, so this list (and + * `snapshotThemeVars`) doesn't need to ship in the worker bundle. + */ +const THEME_VARS = [ + "--psp-charts--font-family", + "font-family", + "--psp--background-color", + "--psp-charts--axis-ticks--color", + "--psp--color", + "--psp-charts--axis-lines--color", + "--psp-charts--gridline--color", + "--psp-charts--gradient--background", + "--psp-charts--full-gradient--background", + "--psp-charts--legend--color", + "--psp-charts--legend-border--color", + "--psp-charts--tooltip--background", + "--psp-charts--tooltip--color", + "--psp-charts--tooltip--border-color", + "--psp-charts--area--opacity", + "--psp-charts--heatmap-gap--px", + "--psp-charts--sunburst-gap--px", +]; - // Split the rest of the row into a point for each category - Object.keys(row) - .filter((key) => key !== "__ROW_PATH__" && row[key] !== null) - .forEach((key) => { - const split = key.split("|"); - const category = - split.length > 1 - ? split.slice(0, split.length - 1).join("|") - : "__default__"; - rowPoints[category] = rowPoints[category] || { group, row }; - rowPoints[category][split[split.length - 1]] = row[key]; - }); +/** + * Capture every CSS variable the renderer cares about into a + * structured-cloneable map. Series-palette colours are walked until the + * first missing var. + */ +export function snapshotThemeVars(el: Element): ThemeSnapshot { + const style = getComputedStyle(el); + const out: ThemeSnapshot = {}; + for (const v of THEME_VARS) { + const raw = style.getPropertyValue(v).trim(); + if (raw) { + out[v] = raw; + } + } - // Add the points for this row to the data set - Object.keys(rowPoints).forEach((key) => { - const rowPoint = rowPoints[key]; - const cols = config.real_columns.map((c) => - c ? rowPoint[c] : null, - ); + for (let i = 1; ; i++) { + const key = `--psp-charts--series-${i}--color`; + const raw = style.getPropertyValue(key).trim(); + if (!raw) { + break; + } - points.push({ - cols, - group: rowPoint.group, - row: rowPoint.row, - category: key, - }); - }); - }); + out[key] = raw; + } - return points; + return out; } diff --git a/packages/viewer-charts/src/ts/theme/theme.ts b/packages/viewer-charts/src/ts/theme/theme.ts index 471bf2bad1..d89d46232e 100644 --- a/packages/viewer-charts/src/ts/theme/theme.ts +++ b/packages/viewer-charts/src/ts/theme/theme.ts @@ -16,14 +16,26 @@ import { parseCssGradient, type GradientStop } from "./gradient"; export type { GradientStop } from "./gradient"; function clampOpacity(v: number): number { - if (!isFinite(v)) return 0.5; - if (v < 0) return 0; - if (v > 1) return 1; + if (!isFinite(v)) { + return 0.5; + } + + if (v < 0) { + return 0; + } + + if (v > 1) { + return 1; + } + return v; } function clampGap(v: number): number { - if (!isFinite(v) || v < 0) return 1; + if (!isFinite(v) || v < 0) { + return 1; + } + return v; } @@ -33,31 +45,23 @@ export interface Theme { labelColor: string; axisLineColor: string; gridlineColor: string; - /** - * Parsed multi-stop sequential gradient. Source precedence: - * 1. `--psp-webgl--gradient--background` (canonical) - * 2. `--psp-webgl--full-gradient--background` (legacy d3fc name) - * 3. Hard-coded blue → orange fallback. - * - * The **50% offset stop** is the sign pivot used by the chart - * renderers (see `colorValueToT` in `theme/gradient`). Pair the stops - * with the chart's `[colorMin, colorMax]` and pass the result through - * `colorValueToT` / `sampleGradient` to produce a consistent color - * across the WebGL, Canvas2D legend, and tooltip paths. - */ + backgroundColor: string; gradientStops: GradientStop[]; legendText: string; legendBorder: string; tooltipBg: string; tooltipText: string; tooltipBorder: string; - /** Fill opacity for area glyphs. `--psp-webgl--area--opacity`. */ + + /** + * Fill opacity for area glyphs. `--psp-charts--area--opacity`. + */ areaOpacity: number; /** * Pixel gap between heatmap cells. Controls the inset applied in the * heatmap vertex shader so neighbouring cells remain visually - * distinguishable. `--psp-webgl--heatmap-gap--px`. + * distinguishable. `--psp-charts--heatmap-gap--px`. */ heatmapGapPx: number; @@ -66,88 +70,97 @@ export interface Theme { * levels — and angular — between siblings). Works the same way as * `heatmapGapPx`: a symmetric inset in the vertex shader so the * transparent background shows through as a border. - * `--psp-webgl--sunburst-gap--px`. + * `--psp-charts--sunburst-gap--px`. */ sunburstGapPx: number; + + /** + * Discrete series palette read from `--psp-charts--series-N--color` + * (N = 1, 2, …). Empty when no palette is defined — callers should + * fall back to `gradientStops` sampling in that case. + */ + seriesPalette: [number, number, number][]; } /** - * Read every theme CSS variable from a single `getComputedStyle` call and - * return a plain object. Cheap to call once per render; do not call per bar - * or per series. + * Plain map of CSS variable name → resolved value. Produced on the + * main thread via `snapshotThemeVars` and shipped to the worker + * Renderer (which has no DOM and can't call `getComputedStyle`). */ -export function resolveTheme(el: Element): Theme { - const style = getComputedStyle(el); +export type ThemeSnapshot = Record; + +/** + * Decode a `ThemeSnapshot` into the parsed `Theme` the renderer + * consumes. Workers reach this from a serialized snapshot; host code + * snapshots from the live DOM via `theme-snapshot.ts` and feeds it + * through here. + */ +export function resolveThemeFromVars(vars: ThemeSnapshot): Theme { const get = (prop: string, fallback: string): string => - style.getPropertyValue(prop).trim() || fallback; + vars[prop] || fallback; - // Canonical multi-stop gradient var with a legacy fallback for themes - // that only define the d3fc-era `full-gradient` variant. const gradientSrc = - style.getPropertyValue("--psp-webgl--gradient--background").trim() || - style - .getPropertyValue("--psp-webgl--full-gradient--background") - .trim() || + vars["--psp-charts--gradient--background"] || + vars["--psp-charts--full-gradient--background"] || "linear-gradient(#0366d6 0%, #ff7f0e 100%)"; + const gradientStops = parseCssGradient(gradientSrc); + const seriesPalette: [number, number, number][] = []; + for (let i = 1; ; i++) { + const raw = vars[`--psp-charts--series-${i}--color`]; + if (!raw) { + break; + } + + seriesPalette.push(parseCSSColorToVec3(raw)); + } return { - fontFamily: get("--psp-webgl--font-family", "monospace"), + fontFamily: get( + "--psp-charts--font-family", + get("font-family", "monospace"), + ), + backgroundColor: get( + "--psp--background-color", + "rgba(255, 255, 255, 1)", + ), tickColor: get( - "--psp-webgl--axis-ticks--color", + "--psp-charts--axis-ticks--color", "rgba(160, 0, 0, 0.8)", ), labelColor: get("--psp--color", "rgba(180, 0, 0, 0.9)"), axisLineColor: get( - "--psp-webgl--axis-lines--color", + "--psp-charts--axis-lines--color", "rgba(160, 0, 0, 0.4)", ), gridlineColor: get( - "--psp-webgl--gridline--color", + "--psp-charts--gridline--color", "rgba(255, 0, 0, 1)", ), gradientStops, legendText: get( - "--psp-webgl--legend--color", + "--psp-charts--legend--color", "rgba(180, 180, 180, 0.9)", ), legendBorder: get( - "--psp-webgl--legend-border--color", + "--psp-charts--legend-border--color", "rgba(128,128,128,0.3)", ), tooltipBg: get( - "--psp-webgl--tooltip--background", + "--psp-charts--tooltip--background", "rgba(155,155,155,0.8)", ), - tooltipText: get("--psp-webgl--tooltip--color", "#161616"), - tooltipBorder: get("--psp-webgl--tooltip--border-color", "#fff"), + tooltipText: get("--psp-charts--tooltip--color", "#161616"), + tooltipBorder: get("--psp-charts--tooltip--border-color", "#fff"), areaOpacity: clampOpacity( - parseFloat(get("--psp-webgl--area--opacity", "0.75")), + parseFloat(get("--psp-charts--area--opacity", "0.85")), ), heatmapGapPx: clampGap( - parseFloat(get("--psp-webgl--heatmap-gap--px", "0")), + parseFloat(get("--psp-charts--heatmap-gap--px", "0")), ), sunburstGapPx: clampGap( - parseFloat(get("--psp-webgl--sunburst-gap--px", "1")), + parseFloat(get("--psp-charts--sunburst-gap--px", "1")), ), + seriesPalette, }; } - -/** - * Read the discrete series palette from `--psp-webgl--series-N--color` - * custom properties (N = 1, 2, …). Stops at the first missing index. - * Returns an empty array when no palette is defined — callers should fall - * back to `theme.gradientStops` sampling in that case. - */ -export function readSeriesPalette(el: Element): [number, number, number][] { - const style = getComputedStyle(el); - const palette: [number, number, number][] = []; - for (let i = 1; ; i++) { - const raw = style - .getPropertyValue(`--psp-webgl--series-${i}--color`) - .trim(); - if (!raw) break; - palette.push(parseCSSColorToVec3(raw)); - } - return palette; -} diff --git a/packages/viewer-charts/src/ts/transport/protocol.ts b/packages/viewer-charts/src/ts/transport/protocol.ts new file mode 100644 index 0000000000..d490fb69ad --- /dev/null +++ b/packages/viewer-charts/src/ts/transport/protocol.ts @@ -0,0 +1,497 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import type { FacetConfig, PluginConfig } from "../charts/chart"; +import type { PerspectiveClickDetail } from "../event-detail"; +import type { ViewConfig } from "@perspective-dev/client"; + +/** + * Worker-mode control-channel messages. Distinct from the perspective + * `ProxySession` channel that the worker's `Client` uses to talk to the + * host's real `Client` — that's pure protobuf bytes; this one is the + * chart's own renderer control plane. + */ +export type ControlMsg = + | InitMsg + | SetViewByNameMsg + | SetColumnsConfigMsg + | SetPluginConfigMsg + | SetBufferMaxCapacityMsg + | LoadAndRenderMsg + | RedrawMsg + | ResizeMsg + | ClearMsg + | InvalidateThemeMsg + | RestoreZoomMsg + | ResetAllZoomsMsg + | ResetExpandedDomainMsg + | SaveZoomReqMsg + | SnapshotPngReqMsg + | InteractionMsg + | DestroyMsg; + +export type WorkerMsg = + | ReadyMsg + | ZoomChangedMsg + | SaveZoomReplyMsg + | SnapshotPngReplyMsg + | PinTooltipMsg + | DismissTooltipMsg + | SetCursorMsg + | UserClickMsg + | UserSelectMsg + | LoadAndRenderAckMsg + | FrameBitmapMsg + | ErrorMsg; + +/** + * Session-tagged envelopes for the shared-worker transport. Every + * message between the host and the shared `Worker` carries a numeric + * `sessionId` that addresses a specific `WorkerRenderer` slot in the + * worker's `RENDERERS` map. + * + * In-process mode bypasses these — its `MessageChannel` is per- + * transport, so messages are already private and routing isn't + * needed. Only worker-mode `RendererTransport` and the worker-scope + * message handler wrap / unwrap envelopes. + */ +export interface ControlEnvelope { + sessionId: number; + msg: ControlMsg; +} + +export interface WorkerEnvelope { + sessionId: number; + msg: WorkerMsg; +} + +export interface InitMsg { + kind: "init"; + + /** + * GL canvas display strategy. `"direct"` means `glCanvas` below is + * the host's `.webgl-canvas` transferred via + * `transferControlToOffscreen` and the renderer paints straight + * into it. `"blit"` means `glCanvas` is omitted; the renderer + * allocates its own internal `OffscreenCanvas`, renders into it, + * and posts each completed frame back as a `FrameBitmapMsg` for + * the host to draw into a 2D-context display canvas. + */ + renderMode: "direct" | "blit"; + + /** + * Transferred via `transferControlToOffscreen` on the host. Present + * iff `renderMode === "direct"`. In blit mode the renderer + * constructs its own offscreen surface from `cssWidth`/`cssHeight`/ + * `dpr` and there is no host-side GL drawing buffer. + */ + glCanvas?: OffscreenCanvas; + gridlinesCanvas: OffscreenCanvas; + chromeCanvas: OffscreenCanvas; + + /** + * `MessagePort` to the host's `ProxySession`. Worker mode only — + * the worker bootstraps a fresh `Client` and bridges it through + * this port. In-process mode uses the host's `Client` directly + * (handed in via `bootstrapInProcess`'s `client` option), so no + * proxy bridge is needed. + */ + proxyPort?: MessagePort; + + /** + * Compiled perspective-js client wasm forwarded from the host. + * Worker mode only — passed to `module.initSync(...)` after the + * worker dynamic-imports `clientWorkerURL`. In-process mode + * inherits the host's already-bound wasm via the supplied + * `Client` instance. + */ + clientWasm?: WebAssembly.Module; + + /** + * URL the worker uses to dynamic-import the perspective-viewer + * wasm-bindgen JS module. Worker mode only — required because + * the worker scope can't share module instances with the host. + * In-process mode uses the host's already-loaded module via + * the supplied `Client` instance. + */ + clientWorkerURL?: URL; + + /** + * `ChartTypeConfig.tag` — selects which `ChartImplementation` to + * construct in the worker. The worker has its own copy of the + * chart class registry. + */ + chartTag: string; + + /** + * Server-assigned `View` name for `client.__unsafe_open_view(name)`. + */ + viewName: string; + + /** + * `Table` name for the worker to resolve via `client.open_table(...)` + * once at bootstrap. Used for source-schema lookups (group-by level + * types) on the render path so the host doesn't have to await + * `table.schema()` on every draw. May be omitted if the host viewer + * has no table loaded yet — `loadAndRender` falls back to an empty + * source schema in that case. + */ + tableName?: string; + facetConfig: FacetConfig; + + /** + * Initial plugin-scoped global config. Seeds the chart impl's + * `_pluginConfig` before the first `loadAndRender` so the build + * pipeline (`auto_alt_y_axis`, `band_inner_frac`, `bar_inner_pad`) + * and render-path uniforms see correct values on the very first + * frame. The host's later `restore({ plugin_config })` arrives as + * a `setPluginConfig` control msg. + */ + pluginConfig: PluginConfig; + defaultChartType?: string; + + /** + * Pre-resolved CSS-variable theme snapshot from the host. + */ + themeVars: ThemeSnapshot; + + /** + * `@font-face` rules captured from the host document, to be + * re-loaded into the worker's `self.fonts` set before first + * paint. Worker mode only — workers don't share `FontFaceSet` + * with the document, so any font referenced via `font-family` + * must be reloaded there. In-process mode shares + * `document.fonts` with the host so the array is empty / unused. + * See `snapshotFontFaces()` for CORS / scope caveats. + */ + fontFaces: FontFaceDescriptor[]; + + /** + * Initial CSS size + DPR; subsequent resizes arrive as `resize`. + */ + cssWidth: number; + cssHeight: number; + dpr: number; + + /** + * `ChartTypeConfig.max_cells` for the buffer pool. + */ + bufferMaxCapacity: number; + + /** + * If `true`, the `WebGLContextManager` constructed on the + * renderer side compiles + links every shader in + * `SHADER_MANIFEST` during construction. Trades a known init-time + * cost for elimination of inline compile latency on first frame. + * Default behavior (when undefined) is lazy compilation as a + * side effect of each glyph's first `getOrCreate` call. + */ + precompileShaders?: boolean; +} + +/** + * Plain-object form of an `@font-face` rule, structured-cloneable for + * `postMessage`. The worker reconstitutes a `FontFace` via + * `new FontFace(family, src, descriptors)`, awaits its load, and + * registers it in `self.fonts`. + * + * `src` is the raw CSS `src:` value (e.g. + * `url(https://…/foo.woff2) format("woff2")`), with every `url(...)` + * already absolutized on the host against the parent stylesheet's + * `href` — the worker's script URL is a Blob URL, so relative URLs + * would otherwise fail to resolve. + */ +export interface FontFaceDescriptor { + family: string; + src: string; + style?: string; + weight?: string; + stretch?: string; + unicodeRange?: string; + variant?: string; + featureSettings?: string; + display?: string; +} + +/** + * Theme values resolved on the host via `getComputedStyle` and shipped + * to the renderer scope, which has no DOM. Decoded by the chart via + * `theme/theme.ts::resolveThemeFromVars`. Plain map for + * structured-clone. + */ +export type ThemeSnapshot = Record; + +export interface SetViewByNameMsg { + kind: "setViewByName"; + name: string; +} + +export interface SetColumnsConfigMsg { + kind: "setColumnsConfig"; + cfg: Record; +} + +/** + * Host → worker: replace the chart impl's `_pluginConfig` with a new + * snapshot. Sent on every `plugin.restore({ plugin_config })`. The + * chart re-syncs derived state (`_facetConfig.facet_mode`, + * `_facetConfig.zoom_mode`, `_autoFitValue`) in `setPluginConfig` and + * the host posts a `redraw` so render-path uniform changes (line + * widths, point size) take effect on the next frame. Build-time + * fields (`auto_alt_y_axis`, `band_inner_frac`, `bar_inner_pad`) take + * effect on the next `loadAndRender`. + */ +export interface SetPluginConfigMsg { + kind: "setPluginConfig"; + cfg: PluginConfig; +} + +export interface SetBufferMaxCapacityMsg { + kind: "setBufferMaxCapacity"; + n: number; +} + +/** + * Host → worker: trigger a full data-fetch + render cycle. The worker + * resolves all schema / row-count metadata against its own `View` and + * `Table` (no host-side `Client`/`Table`/`View` await on the render + * path), runs `view.with_typed_arrays`, and uploads the resulting + * column data straight into the chart impl on the same thread — + * eliminating the prior cross-boundary `postMessage` of column buffers. + * + * `viewerConfig` ships the bits the worker can't read for itself + * (`` element APIs, not `Client`/`Table`/`View`). + * + * Mid-flight cancellation: each call increments a worker-side + * generation counter. A newer `loadAndRender` arriving while one is in + * flight causes the older call's `with_typed_arrays` callback to throw + * a sentinel before any chart mutation, unwinding the wasm Arrow + * buffer release before the new call proceeds. Both calls reply with + * `loadAndRenderAck` so the host promise resolves either way. + */ +export interface LoadAndRenderMsg { + kind: "loadAndRender"; + msgId: number; + viewerConfig: { + group_by: string[]; + split_by: string[]; + columns: (string | null)[]; + }; + options: { + float32: boolean; + }; +} + +/** + * Worker → host reply to a `LoadAndRenderMsg`. Always sent — including + * on stale-generation drop — so the host's awaited promise resolves + * deterministically. + */ +export interface LoadAndRenderAckMsg { + kind: "loadAndRenderAck"; + msgId: number; +} + +export interface RedrawMsg { + kind: "redraw"; +} + +export interface ResizeMsg { + kind: "resize"; + cssWidth: number; + cssHeight: number; + dpr: number; +} + +export interface ClearMsg { + kind: "clear"; +} + +export interface InvalidateThemeMsg { + kind: "invalidateTheme"; + + /** + * Fresh CSS-variable snapshot — the worker can't read DOM. + */ + themeVars: ThemeSnapshot; +} + +export interface RestoreZoomMsg { + kind: "restoreZoom"; + state: any; +} + +export interface ResetAllZoomsMsg { + kind: "resetAllZooms"; +} + +/** + * Host → worker: clear the chart's `domain_mode: "expand"` accumulator + * so the next data load starts from a fresh extent. Sent at the head + * of every `plugin.draw()` (which always indicates a view-level + * change). `plugin.update()` does not send this — same view, more + * data, the accumulator should keep growing. + */ +export interface ResetExpandedDomainMsg { + kind: "resetExpandedDomain"; +} + +export interface SaveZoomReqMsg { + kind: "saveZoom"; + requestId: number; +} + +/** + * Host → worker: ask the renderer to flush a frame and return a PNG of + * the composited canvas stack. The reply ships a `Blob` correlated to + * the request via `requestId` (allocated by the host's + * `RendererTransport.snapshotPng()`). + */ +export interface SnapshotPngReqMsg { + kind: "snapshotPng"; + requestId: number; +} + +/** + * Worker → host reply to a `SnapshotPngReqMsg`. Resolves the + * corresponding host-side promise with the encoded `Blob`. + */ +export interface SnapshotPngReplyMsg { + kind: "snapshotPngReply"; + requestId: number; + blob: Blob; +} + +export interface DestroyMsg { + kind: "destroy"; +} + +/** + * Raw pointer / wheel event forwarded from the host's + * `RawEventForwarder` to the renderer. Coordinates are canvas-relative + * CSS pixels (host already subtracted `getBoundingClientRect`). + * + * `pointerdown` carries `pointerId` so the host can drive + * `setPointerCapture` while the corresponding `pointermove` / + * `pointerup` events fire even when the cursor leaves the canvas. + * + * `pointermove` drives both pan (when a drag is active) and tooltip + * hover (when not). `pointerleave` drives tooltip leave. `click` / + * `dblclick` drive tooltip click handling. One channel per cursor + * stream — no parallel `mouse*` mirror. + */ +export type InteractionEvent = + | { type: "wheel"; mx: number; my: number; deltaY: number } + | { type: "pointerdown"; mx: number; my: number; pointerId: number } + | { type: "pointermove"; mx: number; my: number } + | { type: "pointerup" } + | { type: "pointerleave" } + | { type: "click"; mx: number; my: number } + | { type: "dblclick"; mx: number; my: number }; + +export interface InteractionMsg { + kind: "interaction"; + event: InteractionEvent; +} + +export interface ReadyMsg { + kind: "ready"; +} + +export interface ZoomChangedMsg { + kind: "zoomChanged"; + isDefault: boolean; +} + +export interface SaveZoomReplyMsg { + kind: "saveZoomReply"; + requestId: number; + state: any; +} + +export interface ErrorMsg { + kind: "error"; + message: string; +} + +/** + * Worker-side request to render a pinned tooltip on the host. The + * worker has no DOM, so the persistent tooltip `
` is materialized + * by `RendererTransport` (via a `DomHostSink`) on receipt. `bounds` ships + * the chart's CSS size so the host can clamp the tooltip without + * reading the canvas geometry itself. + */ +export interface PinTooltipMsg { + kind: "pinTooltip"; + lines: string[]; + pos: { px: number; py: number }; + bounds: { cssWidth: number; cssHeight: number }; +} + +export interface DismissTooltipMsg { + kind: "dismissTooltip"; +} + +/** + * Renderer → host: set the GL canvas's `style.cursor`. The renderer + * has no DOM (worker mode) — `cursor` is a CSS cursor value + * (`"pointer"`, `"default"`, etc.) applied directly by the host on + * receipt. + */ +export interface SetCursorMsg { + kind: "setCursor"; + cursor: string; +} + +/** + * Renderer → host: a user click landed on a chart glyph. Host + * re-dispatches as `CustomEvent` on the + * `` ancestor. Payload is a plain object so it + * survives `postMessage` without losing the class prototype. + */ +export interface UserClickMsg { + kind: "userClick"; + detail: PerspectiveClickDetail; +} + +/** + * Renderer → host: a user click pinned or unpinned a chart target. + * Host materializes a `PerspectiveSelectDetail` from this payload plus + * its own cached previous-insert config and dispatches as + * `CustomEvent` (`perspective-global-filter`). + * `removeConfigs` is computed host-side — not sent. + */ +export interface UserSelectMsg { + kind: "userSelect"; + selected: boolean; + row: Record; + column_names: string[]; + insertConfig: Partial; +} + +/** + * Worker → host: a completed GL frame, materialized as an + * `ImageBitmap` from the renderer's internal offscreen canvas via + * `transferToImageBitmap()`. Sent only in `renderMode === "blit"`, + * after each `_fullRender` completes. The host draws the bitmap into + * its 2D-context display canvas and calls `bitmap.close()` to release + * the GPU-backed surface. + * + * The bitmap MUST appear in the postMessage transfer list — the + * underlying surface is moved, not copied, so failing to transfer + * renders the host's drawImage a no-op (or worse, a Safari crash on + * older WebKits). + */ +export interface FrameBitmapMsg { + kind: "frameBitmap"; + bitmap: ImageBitmap; +} diff --git a/packages/viewer-charts/src/ts/transport/renderer-transport.ts b/packages/viewer-charts/src/ts/transport/renderer-transport.ts new file mode 100644 index 0000000000..6c9a4dcd98 --- /dev/null +++ b/packages/viewer-charts/src/ts/transport/renderer-transport.ts @@ -0,0 +1,788 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import type { Client, View, ViewConfig } from "@perspective-dev/client"; +import type { FacetConfig, PluginConfig } from "../charts/chart"; +import type { + ControlMsg, + InitMsg, + InteractionEvent, + LoadAndRenderMsg, + WorkerEnvelope, + WorkerMsg, +} from "./protocol"; +import { + PerspectiveSelectDetail, + type PerspectiveClickDetail, +} from "../event-detail"; +import { snapshotThemeVars } from "../theme/theme-snapshot"; +import { snapshotFontFaces } from "../utils/font-snapshot"; +import { DomHostSink } from "../interaction/host-sink-dom"; +import { RUNTIME_MODE } from "../config"; + +// @ts-ignore — resolved at build time by `@perspective-dev/esbuild-plugin/worker` +import getWorkerURL from "../worker/renderer.worker.js"; + +/** + * Module-level shared `Worker` for every `RendererTransport` running + * in worker mode. One worker process hosts one `WorkerRenderer` per + * active `sessionId` — N chart instances share startup costs (wasm + * `initSync`, font loads, JS module parse) instead of paying them N + * times. + * + * Lazy: created on first `transport.init()` in worker mode. Lives + * until page teardown — no refcount, no termination logic. Pages + * with no charts never spawn one. Per-session memory still scales + * with N (each session retains its own WebGL context, buffer pool, + * chart impl, view, client); the browser's ~16-context-per-worker + * cap is the new ceiling on simultaneous worker-mode charts. + * + * In-process mode bypasses this entirely — each transport gets its + * own `MessageChannel` + in-thread `WorkerRenderer`. + */ +let SHARED_WORKER: Promise | null = null; + +/** + * Per-session message handlers, keyed by the host-allocated + * `sessionId`. The shared worker's response listener demultiplexes + * incoming envelopes into here. + */ +const HOST_LISTENERS = new Map void>(); + +let NEXT_SESSION_ID = 0; + +async function getSharedWorker(): Promise { + if (SHARED_WORKER) { + return SHARED_WORKER; + } + + SHARED_WORKER = (async () => { + const url = await getWorkerURL(); + const w = new Worker(url, { type: "module", name: "viewer-charts" }); + w.addEventListener("message", (e: MessageEvent) => { + const env = e.data as WorkerEnvelope; + HOST_LISTENERS.get(env.sessionId)?.(env.msg); + }); + return w; + })(); + + return SHARED_WORKER; +} + +interface RendererHandle { + post(msg: any, transfer: Transferable[]): void; + addMessageListener(cb: (msg: any) => void): void; + terminate(): void; +} + +type PendingRenderType = "saveZoom" | "loadAndRender" | "snapshotPng"; +interface PendingRenderRequest { + kind: PendingRenderType; + resolve: (v: any) => void; + reject: (e: Error) => void; +} + +/** + * Unified host-side driver for the chart renderer. Owns one of two + * handle shapes: + * + * - **Worker mode**: a real `Worker` running the same module. The + * handle posts `ControlMsg`s over `Worker.postMessage`. + * - **In-process mode**: a `MessageChannel` whose `port2` is owned + * by an in-thread `WorkerRenderer` instantiated via + * `await import(workerURL)`. Same module bytes, different host. + * + * Both modes go through the same control channel, the same + * `ProxySession` proxy port, and the same `OffscreenCanvas` transfer + * — `MessageChannel` and `transferControlToOffscreen` work in-realm + * just as well as cross-thread. The only branching is at construction + * (handle creation) and bootstrap (worker scope sets up its own + * `Client`; in-process reuses the host's). + */ +export class RendererTransport { + private _handle: RendererHandle | null = null; + private _proxyChannel: MessageChannel | null = null; + private _proxySession: any = null; + private _client: Client; + private _view: View; + private _tableName: string | undefined; + private _clientWorkerURL: URL; + private _clientWasm: WebAssembly.Module; + private _chartTag: string; + private _maxCells: number; + private _precompileShaders: boolean; + private _ready: Promise; + private _resolveReady!: () => void; + private _rejectReady!: (err: Error) => void; + + /** + * Pending request/reply promises across all worker round-trips — + * `saveZoom`, `uploadChunk` ACKs, and `snapshotPng`. Each entry + * carries its `kind` so `destroy()` can apply per-kind teardown + * semantics (uploadChunk resolves silently, the rest reject with + * a teardown error). + * + * Keyed by a single monotonic counter; the worker's reply messages + * carry that id back verbatim. One counter for all kinds is safe + * because the host's switch already keys on `msg.kind` before + * resolving. + */ + private _pending = new Map(); + + private _pendingCounter = 0; + private _onZoomChanged: ((isDefault: boolean) => void) | null = null; + + /** + * Cached zoom-default flag pushed by the renderer after each zoom + * mutation. Surfaced sync via `allZoomsDefault()`; updates between + * calls are best-effort. + */ + private _allZoomsDefault = true; + private _hostGlCanvas: HTMLCanvasElement | null = null; + + /** + * Blit-mode only: the visible `.webgl-canvas`'s 2D context. The + * worker emits each completed GL frame as a `FrameBitmapMsg`; on + * receipt we `drawImage` the bitmap into this context and `close()` + * it to release the GPU surface. Null in direct mode (the visible + * canvas's drawing buffer is the worker's transferred GL canvas). + */ + private _displayCtx: CanvasRenderingContext2D | null = null; + + /** + * Host-side sink for tooltip + cursor side-effects. The chart + * inside the renderer calls into a `MessageHostSink` that posts + * `pinTooltip` / `dismissTooltip` / `setCursor` over the control + * channel; this sink applies them to the DOM. Initialized lazily + * on first signal so we don't pay for the parent-style lookup + * unless a user interacts. + */ + private _hostSink: DomHostSink | null = null; + + /** + * Last `insertConfig` accepted by a `userSelect { selected: true }` + * message. Used to populate `removeConfigs` on the next + * `selected: false` (unpin / drill-up / view-change) — mirrors + * datagrid's `model._last_insert_configs` so coordinated-filter + * consumers can roll back the previous select when a new one + * supplants it. + */ + private _lastInsertConfig: Partial | undefined = undefined; + + constructor(opts: { + client: Client; + view: View; + tableName?: string; + clientWasm: WebAssembly.Module; + clientWorkerURL: URL; + chartTag: string; + maxCells: number; + precompileShaders?: boolean; + onZoomChanged?: (isDefault: boolean) => void; + }) { + this._client = opts.client; + this._view = opts.view; + this._tableName = opts.tableName; + this._clientWorkerURL = opts.clientWorkerURL; + this._clientWasm = opts.clientWasm; + this._chartTag = opts.chartTag; + this._maxCells = opts.maxCells; + this._precompileShaders = opts.precompileShaders ?? false; + this._onZoomChanged = opts.onZoomChanged ?? null; + this._ready = new Promise((resolve, reject) => { + this._resolveReady = resolve; + this._rejectReady = reject; + }); + } + + async init(opts: { + gl: HTMLCanvasElement; + gridlines: HTMLCanvasElement; + chrome: HTMLCanvasElement; + facetConfig: FacetConfig; + pluginConfig: PluginConfig; + defaultChartType?: string; + renderBlitMode: "blit" | "direct"; + }): Promise { + this._hostGlCanvas = opts.gl; + const workerURL: string = await getWorkerURL(); + + // Worker mode: bridge the worker's fresh `Client` (instantiated + // in `bootstrapWorker` from `clientWasm` + `clientWorkerURL`) + // back to the host's real `Client` via a `ProxySession` over a + // dedicated `MessageChannel`. + // + // In-process mode skips this entirely — `bootstrapInProcess` + // is handed the host's `Client` directly, so there's no + // worker-side `Client` to bridge. The proxy port would just + // dangle. + if (RUNTIME_MODE === "worker") { + this._proxyChannel = new MessageChannel(); + this._proxySession = (this._client as any).new_proxy_session( + (bytes: Uint8Array) => { + const buf = bytes.slice().buffer; + this._proxyChannel!.port1.postMessage(buf, [buf]); + }, + ); + + this._proxyChannel.port1.addEventListener( + "message", + (e: MessageEvent) => { + this._proxySession.handle_request(new Uint8Array(e.data)); + }, + ); + + this._proxyChannel.port1.start(); + } + + // Blit mode keeps the visible `.webgl-canvas` main-thread with + // a 2D context — the renderer paints into its own internal + // `OffscreenCanvas` and ships each completed frame back as an + // `ImageBitmap`. Direct mode transfers the visible canvas's + // drawing buffer to the renderer so GL paints straight to + // screen. + let glOC: OffscreenCanvas | undefined; + if (opts.renderBlitMode === "blit") { + this._displayCtx = opts.gl.getContext("2d"); + } else { + glOC = opts.gl.transferControlToOffscreen(); + } + + const gridlinesOC = opts.gridlines.transferControlToOffscreen(); + const chromeOC = opts.chrome.transferControlToOffscreen(); + const rect = opts.gl.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + const themeVars = snapshotThemeVars(opts.gl); + + // Worker mode forwards `@font-face` rules so the worker's + // separate `FontFaceSet` can resolve `ctx.font` family names. + // In-process mode shares `document.fonts` with the host — + // omit the descriptors entirely. + const fontFaces = RUNTIME_MODE === "worker" ? snapshotFontFaces() : []; + const clientWasm = + RUNTIME_MODE === "worker" ? this._clientWasm : undefined; + + const clientWorkerURL = + RUNTIME_MODE === "worker" ? this._clientWorkerURL : undefined; + + const proxyPort = + RUNTIME_MODE === "worker" ? this._proxyChannel!.port2 : undefined; + + const initMsg: InitMsg = { + kind: "init", + renderMode: opts.renderBlitMode, + glCanvas: glOC, + gridlinesCanvas: gridlinesOC, + chromeCanvas: chromeOC, + proxyPort, + clientWorkerURL, + clientWasm, + chartTag: this._chartTag, + viewName: this._view.__unsafe_get_name(), + tableName: this._tableName, + facetConfig: opts.facetConfig, + pluginConfig: opts.pluginConfig, + defaultChartType: opts.defaultChartType, + themeVars, + fontFaces, + cssWidth: rect.width, + cssHeight: rect.height, + dpr, + bufferMaxCapacity: 0, + precompileShaders: this._precompileShaders, + }; + + this._handle = await this._createHandle(workerURL, initMsg); + this._handle.addMessageListener((msg) => + this._handleRendererMsg(msg as WorkerMsg), + ); + + if (RUNTIME_MODE === "worker") { + // Worker mode: the bootstrap is triggered by posting the + // init message into the worker's scope (which the + // `if (IS_WORKER_SCOPE)` block in `renderer.worker.ts` + // listens for). `glOC` is omitted in blit mode (the + // renderer allocates its own offscreen) — only include the + // GL canvas in the transfer list when present. + const transfer: Transferable[] = [ + gridlinesOC, + chromeOC, + this._proxyChannel!.port2, + ]; + if (glOC) { + transfer.unshift(glOC); + } + + this._handle.post(initMsg, transfer); + } + + // In-process mode: the handle's `_createHandle` already kicked + // off `bootstrapInProcess` with the init msg directly, no + // postMessage needed. + + await this._ready; + } + + /** + * Construct the underlying transport. Worker mode wraps the + * module-shared `Worker` (lazy, page-singleton) and tags every + * message with a unique `sessionId`. In-process mode pairs a + * `MessageChannel` with a dynamically-imported + * {@link bootstrapInProcess}. + */ + private async _createHandle( + workerURL: string, + initMsg: InitMsg, + ): Promise { + if (RUNTIME_MODE === "worker") { + const w = await getSharedWorker(); + const sessionId = ++NEXT_SESSION_ID; + return { + post: (msg, transfer) => + w.postMessage({ sessionId, msg }, transfer), + addMessageListener: (cb) => { + HOST_LISTENERS.set(sessionId, cb); + }, + terminate: () => { + HOST_LISTENERS.delete(sessionId); + // Don't terminate the underlying worker — other + // sessions may still be live. Worker-side + // `WorkerRenderer` cleanup is driven by the + // `destroy` ControlMsg posted by the transport + // before reaching here. + }, + }; + } + + // In-process: instantiate the renderer on this thread by + // dynamic-importing the same module the worker uses. The Blob + // URL (or file URL in debug builds) loads as ESM, so module + // dedup means only one copy of the chart code lives in + // memory regardless of how many host elements use this mode. + // `@vite-ignore` is harmless under esbuild (esbuild's parser + // ignores it); some downstream bundlers honor it to suppress + // a static-import warning on the dynamic URL. + // + // Hand the host's already-bound `Client` to the renderer via + // `bootstrapInProcess` — option B. The dynamically-imported + // module has its own copy of the perspective-viewer + // wasm-bindgen JS, but that copy stays unused: we never + // construct `new Client(...)` inside it; we only ever call + // methods on the host-supplied instance. + const mod: any = await import(/* @vite-ignore */ workerURL); + const channel = new MessageChannel(); + await mod.bootstrapInProcess({ + msg: initMsg, + client: this._client, + controlPort: channel.port2, + }); + + return { + post: (msg, transfer) => channel.port1.postMessage(msg, transfer), + addMessageListener: (cb) => { + // `addEventListener("message", …)` does NOT auto-start + // a `MessagePort` — only setting `onmessage` does. + // Without this explicit `start()` the renderer's + // `{ kind: "ready" }` would queue on `port1` forever + // and `init()` would hang on `await this._ready`. + channel.port1.addEventListener("message", (e: MessageEvent) => + cb(e.data), + ); + channel.port1.start(); + }, + terminate: () => { + channel.port1.close(); + channel.port2.close(); + }, + }; + } + + setView(view: View): void { + this._view = view; + this._post({ + kind: "setViewByName", + name: this._view.__unsafe_get_name(), + }); + } + + setColumnsConfig(cfg: Record): void { + this._post({ kind: "setColumnsConfig", cfg }); + } + + setPluginConfig(cfg: PluginConfig): void { + this._post({ kind: "setPluginConfig", cfg }); + } + + setBufferMaxCapacity(n: number): void { + this._post({ kind: "setBufferMaxCapacity", n }); + } + + /** + * Trigger a worker-side data fetch + render cycle. The worker + * resolves all schema / row-count metadata against its own `View` + * and `Table`, runs `view.with_typed_arrays`, and pipes the + * resulting `ColumnDataMap` directly into `chartImpl.uploadAndRender` + * — no host-side `Client`/`Table`/`View` await, no `postMessage` of + * column buffers. + * + * The returned promise resolves when the worker replies with + * `loadAndRenderAck`. Per the worker's "resolve on stale" + * contract, a mid-flight cancellation (a newer `loadAndRender` + * superseding this one) still acks — the host's awaiter just + * resolves quietly. + */ + loadAndRender(opts: { + viewerConfig: { + group_by: string[]; + split_by: string[]; + columns: (string | null)[]; + }; + options?: { float32?: boolean }; + }): Promise { + const { id, promise } = this._allocPending("loadAndRender"); + const msg: LoadAndRenderMsg = { + kind: "loadAndRender", + msgId: id, + viewerConfig: opts.viewerConfig, + options: { float32: opts.options?.float32 ?? true }, + }; + + this._post(msg); + return promise; + } + + redraw(): void { + this._post({ kind: "redraw" }); + } + + resize(): void { + if (!this._hostGlCanvas) { + return; + } + + const rect = this._hostGlCanvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + this._post({ + kind: "resize", + cssWidth: rect.width, + cssHeight: rect.height, + dpr, + }); + } + + clear() { + this._post({ kind: "clear" }); + } + + invalidateTheme() { + if (!this._hostGlCanvas) { + return; + } + + const themeVars = snapshotThemeVars(this._hostGlCanvas); + this._post({ kind: "invalidateTheme", themeVars }); + } + + async saveZoom() { + const { id } = this._allocPending("saveZoom"); + this._post({ kind: "saveZoom", requestId: id }); + } + + /** + * Allocate a pending request slot of the given `kind`. Returns the + * id (encoded into the outgoing `ControlMsg`) and a promise that + * resolves / rejects when the matching reply arrives or + * `destroy()` drains the table. + */ + private _allocPending(kind: PendingRenderType): { + id: number; + promise: Promise; + } { + const id = ++this._pendingCounter; + const promise = new Promise((resolve, reject) => { + this._pending.set(id, { kind, resolve, reject }); + }); + + return { id, promise }; + } + + restoreZoom(state: any): void { + this._post({ kind: "restoreZoom", state }); + } + + allZoomsDefault(): boolean { + return this._allZoomsDefault; + } + + resetAllZooms(): void { + this._post({ kind: "resetAllZooms" }); + } + + resetExpandedDomain(): void { + this._post({ kind: "resetExpandedDomain" }); + } + + /** + * Request a PNG snapshot of the current frame. The worker flushes a + * synchronous render across the GL + gridlines + chrome layers, + * composites them into a single `OffscreenCanvas`, fills the theme + * background, and replies with the `convertToBlob` result. + */ + snapshotPng(): Promise { + const { id, promise } = this._allocPending("snapshotPng"); + this._post({ kind: "snapshotPng", requestId: id }); + return promise; + } + + forwardInteraction(event: InteractionEvent): void { + this._post({ kind: "interaction", event }); + } + + destroy(): void { + this._post({ kind: "destroy" }); + if (this._proxySession) { + this._proxySession.close().catch(() => {}); + } + + if (this._proxyChannel) { + this._proxyChannel.port1.close(); + this._proxyChannel = null; + } + + if (this._handle) { + this._handle.terminate(); + this._handle = null; + } + + this._hostSink?.dismiss(); + this._hostSink = null; + + // The host's `` elements are torn down by the plugin + // element's `disconnectedCallback` after `destroy()` returns — + // null these refs now so any post-destroy code can't dereference + // them, and so the GPU-backed 2D context can release earlier. + this._hostGlCanvas = null; + this._displayCtx = null; + + // Drain pending request promises with kind-aware semantics: + // - `loadAndRender` resolves silently (the host's awaited draw + // observes a clean "no more work" rather than a teardown + // rejection it would otherwise have to suppress). + // - `saveZoom` / `snapshotPng` reject so the upstream promise + // chain doesn't hang. Any unanswered messages still in the + // worker's queue are abandoned along with the renderer when + // the `destroy` ControlMsg fires worker-side. + const teardownErr = new Error("RendererTransport destroyed"); + for (const entry of this._pending.values()) { + if (entry.kind === "loadAndRender") { + entry.resolve(undefined); + } else { + entry.reject(teardownErr); + } + } + + this._pending.clear(); + } + + private _post(msg: ControlMsg): void { + this._postRaw(msg, []); + } + + private _postRaw(msg: ControlMsg, transfer: Transferable[]): void { + if (!this._handle) { + return; + } + + this._handle.post(msg, transfer); + } + + private _handleRendererMsg(msg: WorkerMsg): void { + switch (msg.kind) { + case "ready": + this._resolveReady(); + break; + case "zoomChanged": + this._allZoomsDefault = msg.isDefault; + this._onZoomChanged?.(msg.isDefault); + break; + case "saveZoomReply": + this._resolvePending(msg.requestId, "saveZoom", msg.state); + break; + case "pinTooltip": + this._ensureHostSink()?.pin(msg.lines, msg.pos, msg.bounds); + break; + case "dismissTooltip": + this._hostSink?.dismiss(); + break; + case "setCursor": + this._ensureHostSink()?.setCursor(msg.cursor); + break; + case "userClick": + this._dispatchOnViewer( + new CustomEvent( + "perspective-click", + { + bubbles: true, + composed: true, + detail: msg.detail, + }, + ), + ); + break; + case "userSelect": { + const removeConfigs = this._lastInsertConfig + ? [this._lastInsertConfig] + : []; + const insertConfigs = msg.selected ? [msg.insertConfig] : []; + this._lastInsertConfig = msg.selected + ? msg.insertConfig + : undefined; + const detail = new PerspectiveSelectDetail( + msg.selected, + msg.row, + msg.column_names, + // `Partial` (what the chart emits) is + // structurally a `ViewConfigUpdate` for the + // `filter`-only patches we ship; the only + // incompatible field (`group_by_depth: number | + // null`) is never set by our emitters. + removeConfigs as any, + insertConfigs as any, + ); + this._dispatchOnViewer( + new CustomEvent( + "perspective-global-filter", + { + bubbles: true, + composed: true, + detail, + }, + ), + ); + break; + } + + case "frameBitmap": + this._drawFrameBitmap(msg.bitmap); + break; + case "error": + this._rejectReady(new Error(msg.message)); + break; + case "loadAndRenderAck": + this._resolvePending(msg.msgId, "loadAndRender", undefined); + break; + case "snapshotPngReply": + this._resolvePending(msg.requestId, "snapshotPng", msg.blob); + break; + } + } + + /** + * Look up a pending request by id, verify the recorded kind + * matches the inbound reply, resolve, and remove. Mismatches are + * silently dropped — they would only fire if the worker echoed + * the wrong kind for a given id, which would itself be a bug + * worth catching at the worker side. + */ + private _resolvePending( + id: number, + kind: PendingRenderType, + value: unknown, + ): void { + const entry = this._pending.get(id); + if (!entry || entry.kind !== kind) { + return; + } + + this._pending.delete(id); + entry.resolve(value); + } + + /** + * Blit-mode handler: draw a renderer-emitted frame into the + * visible 2D-context display canvas, then close the bitmap so its + * GPU-backed surface is released. Resizes the visible canvas's + * drawing buffer to the bitmap dimensions on first frame and + * after any worker-side resize — the host doesn't directly + * control GL canvas size in blit mode, so we follow whatever the + * renderer emits. + */ + private _drawFrameBitmap(bitmap: ImageBitmap): void { + if (this._displayCtx && this._hostGlCanvas) { + const w = bitmap.width; + const h = bitmap.height; + if (this._hostGlCanvas.width !== w) { + this._hostGlCanvas.width = w; + } + + if (this._hostGlCanvas.height !== h) { + this._hostGlCanvas.height = h; + } + + this._displayCtx.globalCompositeOperation = "copy"; + this._displayCtx.drawImage(bitmap, 0, 0); + } + + bitmap.close(); + } + + /** + * Dispatch a `CustomEvent` on the `` ancestor + * of this transport's GL canvas. Walks the parent chain so the + * event bubbles from the viewer (matching where datagrid + * dispatches its `perspective-click` / `perspective-global-filter` + * events). No-op when the canvas is detached or no viewer ancestor + * exists (test harnesses, snapshot mode). + */ + private _dispatchOnViewer(ev: CustomEvent): void { + if (!this._hostGlCanvas) { + return; + } + + let node: Node | null = this._hostGlCanvas; + while (node) { + if ( + node instanceof HTMLElement && + node.tagName === "PERSPECTIVE-VIEWER" + ) { + node.dispatchEvent(ev); + return; + } + + // Cross shadow-root boundaries — `parentNode` returns `null` + // at a ShadowRoot, so use `host` when present. + node = + (node as ShadowRoot).host ?? + (node as Element).parentNode ?? + null; + } + } + + /** + * Lazily construct a `DomHostSink` rooted at the host GL canvas + * (cursor mutations) and its parent (pinned-tooltip `
`). + * Returns `null` if the canvas has been detached. + */ + private _ensureHostSink(): DomHostSink | null { + if (this._hostSink) { + return this._hostSink; + } + + const parent = this._hostGlCanvas?.parentElement; + if (!parent || !this._hostGlCanvas) { + return null; + } + + this._hostSink = new DomHostSink(this._hostGlCanvas, parent); + return this._hostSink; + } +} diff --git a/packages/viewer-charts/src/ts/utils/css.ts b/packages/viewer-charts/src/ts/utils/css.ts index d34cfae610..a08f180520 100644 --- a/packages/viewer-charts/src/ts/utils/css.ts +++ b/packages/viewer-charts/src/ts/utils/css.ts @@ -16,23 +16,21 @@ export function parseCSSColorToVec3( const s = colorStr.trim(); if (s.startsWith("#")) { let hex = s.slice(1); - if (hex.length === 3) + if (hex.length === 3) { hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; + } + return [ parseInt(hex.slice(0, 2), 16) / 255, parseInt(hex.slice(2, 4), 16) / 255, parseInt(hex.slice(4, 6), 16) / 255, ]; } + const m = s.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/); - if (m) return [+m[1] / 255, +m[2] / 255, +m[3] / 255]; - return [0.5, 0.5, 0.5]; -} + if (m) { + return [+m[1] / 255, +m[2] / 255, +m[3] / 255]; + } -export function getCSSVar( - el: Element, - prop: string, - fallback?: string, -): string { - return (getComputedStyle(el).getPropertyValue(prop).trim() || fallback)!; + return [0.5, 0.5, 0.5]; } diff --git a/packages/viewer-charts/src/ts/utils/font-snapshot.ts b/packages/viewer-charts/src/ts/utils/font-snapshot.ts new file mode 100644 index 0000000000..12529849e0 --- /dev/null +++ b/packages/viewer-charts/src/ts/utils/font-snapshot.ts @@ -0,0 +1,159 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import type { FontFaceDescriptor } from "../transport/protocol"; + +/** + * Walk every accessible `@font-face` rule in the document and collect a + * descriptor list ready for forwarding to a Web Worker, where each + * entry is reconstituted via `new FontFace(family, src, descriptors)` + * and registered in the worker's own `self.fonts` set. + * + * Background: a `Worker` has its own `FontFaceSet` distinct from the + * document's. Fonts loaded into `document.fonts` (by ``, + * `@font-face`, or programmatic `FontFace`) are *not* visible to the + * worker — Canvas2D `ctx.font` lookups inside the worker fall back to + * the platform default if the family isn't in `self.fonts`. This + * helper bridges that gap for the in-CSS case. + * + * # CORS / security caveats + * + * 1. **Cross-origin stylesheets without permissive CORS** throw + * `SecurityError` on `cssRules` access, and we silently skip them. + * Their `@font-face` rules are unreachable to JS regardless of + * where the worker would re-load them, so this is the same + * fundamental limitation the document itself has. + * + * 2. **Font URLs are absolutized against the parent stylesheet's + * `href`** before forwarding. The worker's own script URL is a + * Blob URL produced by `WorkerPlugin`, so relative URLs in the raw + * `src:` declaration would resolve against the Blob origin (i.e. + * fail). Callers should treat the absolute URLs as the canonical + * source. + * + * 3. **The actual font fetch issued by `face.load()` in the worker + * is a fresh cross-origin request from the worker scope.** The + * font server must respond with `Access-Control-Allow-Origin` + * (e.g. `*` or the page origin) and an appropriate + * `Access-Control-Allow-Headers` policy if any non-simple headers + * are involved. A "no-cors" / opaque response is *not* usable + * here: `FontFace.load()` rejects on opaque responses, and even + * if it didn't, painted glyphs would taint the canvas and break + * `getImageData`. Same-origin fonts (including `data:` URIs) + * sidestep this entirely. + * + * 4. **Programmatic fonts** (e.g. `document.fonts.add(new + * FontFace(name, source))`) are *not* captured by this walker — + * they don't appear in any stylesheet's `cssRules`. Iterating + * `document.fonts` directly would catch them, but `FontFace` + * instances don't expose their source URL or buffer post- + * construction, so there's no public path to forward them. If a + * test or app needs that, it must register the same fonts in the + * worker explicitly. + */ +export function snapshotFontFaces(): FontFaceDescriptor[] { + const out: FontFaceDescriptor[] = []; + for (const sheet of Array.from(document.styleSheets)) { + let rules: CSSRuleList; + try { + rules = sheet.cssRules; + } catch { + // Cross-origin stylesheet without permissive CORS — its + // `@font-face` rules are unreachable to JS. See caveat (1). + continue; + } + + const base = sheet.href ?? document.baseURI; + for (const rule of Array.from(rules)) { + if (!(rule instanceof CSSFontFaceRule)) { + continue; + } + + const style = rule.style; + const rawFamily = style.getPropertyValue("font-family").trim(); + const rawSrc = style.getPropertyValue("src").trim(); + if (!rawFamily || !rawSrc) { + continue; + } + + out.push({ + // CSSOM serializes multi-word family names with their + // CSS quotes preserved (e.g. `"Roboto Mono"`). The + // `FontFace` constructor *doesn't* unquote on parse — + // Chromium stores the literal input and re-quotes / + // escapes on `face.family` getter access, yielding + // `"\"Roboto Mono\""` and a name that no `ctx.font` + // lookup can match. Strip one layer of matching outer + // quotes here so the worker's font set keys align + // with how Canvas2D resolves family names. + family: stripOuterQuotes(rawFamily), + src: resolveSrcUrls(rawSrc, base), + style: optional(style, "font-style"), + weight: optional(style, "font-weight"), + stretch: optional(style, "font-stretch"), + unicodeRange: optional(style, "unicode-range"), + variant: optional(style, "font-variant"), + featureSettings: optional(style, "font-feature-settings"), + display: optional(style, "font-display"), + }); + } + } + + return out; +} + +function optional( + style: CSSStyleDeclaration, + prop: string, +): string | undefined { + const v = style.getPropertyValue(prop).trim(); + return v || undefined; +} + +/** + * Remove a single matching pair of outer `"…"` or `'…'` quotes from a + * family name. CSSOM serializes multi-word `font-family` descriptor + * values with their quotes preserved; `FontFace` then re-quotes on + * serialization, producing the literal `"\"Foo\""` double-quoting + * observed in `face.family`. Stripping one layer here aligns the + * stored family with what Canvas2D resolves at render time. + */ +function stripOuterQuotes(s: string): string { + if (s.length >= 2) { + const first = s.charCodeAt(0); + const last = s.charCodeAt(s.length - 1); + if (first === last && (first === 0x22 || first === 0x27)) { + return s.slice(1, -1); + } + } + + return s; +} + +/** + * Rewrite every `url(...)` token inside a CSS `src:` value to its + * absolute form. Multi-source declarations (`url(...) format(...), + * url(...) format(...)`) are handled by replacing each `url(...)` + * chunk independently. See caveat (2). + */ +function resolveSrcUrls(src: string, base: string): string { + return src.replace( + /url\(\s*(['"]?)([^'")]+)\1\s*\)/g, + (match, _quote, url) => { + try { + return `url(${new URL(url, base).href})`; + } catch { + return match; + } + }, + ); +} diff --git a/packages/viewer-charts/src/ts/webgl/buffer-pool.ts b/packages/viewer-charts/src/ts/webgl/buffer-pool.ts index ac1a71cb09..e0dcd84eed 100644 --- a/packages/viewer-charts/src/ts/webgl/buffer-pool.ts +++ b/packages/viewer-charts/src/ts/webgl/buffer-pool.ts @@ -10,6 +10,8 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +import { BUFFER_POOL_STRICT } from "../config"; + type GL = WebGL2RenderingContext | WebGLRenderingContext; export interface ManagedBuffer { @@ -31,6 +33,10 @@ export class BufferPool { return this._totalCapacity; } + get maxCapacity() { + return this._maxCapacity; + } + set maxCapacity(cap: number) { this._maxCapacity = cap; } @@ -42,6 +48,29 @@ export class BufferPool { : totalRows; } + /** + * Read-only lookup by name. Returns the existing managed buffer, or + * `undefined` if no buffer has been allocated under that name yet. + * + * Render-path callers that bind buffers for `drawArrays` / + * `drawArraysInstanced` MUST use this rather than `getOrCreate`: + * the latter recreates (zero-initialized) when `_totalCapacity` has + * grown past the current `byteCapacity`. That recreate is desired + * during `upload` (where `bufferSubData` immediately writes the + * actual data) but catastrophic during render — it wipes the + * previous draw's vertex data, leaving `drawArrays` to issue + * against zeros and produce no visible glyphs (a one-frame blank + * plot area while gridlines/chrome remain correct). + * + * Specifically, `ensureBufferCapacity(totalRows)` from a pending + * draw updates `_totalCapacity` *before* its matching `uploadChunk` + * has run; a pan/zoom-induced render landing in that window would + * otherwise see `requiredBytes > byteCapacity` and recreate. + */ + peek(name: string): ManagedBuffer | undefined { + return this._buffers.get(name); + } + getOrCreate( name: string, componentsPerVertex: number, @@ -50,12 +79,15 @@ export class BufferPool { const requiredBytes = this._totalCapacity * componentsPerVertex * bytesPerElement; let managed = this._buffers.get(name); - if (managed && managed.byteCapacity >= requiredBytes) return managed; + if (managed && managed.byteCapacity >= requiredBytes) { + return managed; + } const gl = this._gl; if (managed) { gl.deleteBuffer(managed.buffer); } + const buffer = gl.createBuffer()!; gl.bindBuffer(gl.ARRAY_BUFFER, buffer); gl.bufferData(gl.ARRAY_BUFFER, requiredBytes, gl.DYNAMIC_DRAW); @@ -65,6 +97,28 @@ export class BufferPool { return managed; } + /** + * Upload `data` into the named GPU buffer at `byteOffset`. The + * buffer is sized lazily by `getOrCreate` to + * `_totalCapacity × componentsPerVertex × data.BYTES_PER_ELEMENT`. + * Callers MUST keep `byteOffset + data.byteLength` within that + * capacity — `bufferSubData` raises `INVALID_VALUE` on overflow + * and the upload is silently dropped at the GL layer. + * + * Common pitfall: passing a module-level scratch typed array whose + * `length` exceeds the current frame's valid-data count. Scratch + * buffers in chart impls grow monotonically across frames; the + * GPU buffer is sized to the *current* `_totalCapacity`. After a + * renderer-session reset (e.g. plugin disconnect/reconnect) the + * GPU buffer is fresh while the scratch retains its historical- + * peak length, and the upload writes past the buffer end. Always + * pass `data.subarray(0, n × componentsPerVertex)` when uploading + * from a scratch. + * + * Set `BUFFER_POOL_STRICT = true` in `config.ts` to convert + * overflows from opaque GL errors into descriptive throws at the + * offending stack frame. + */ upload( name: string, data: Float32Array | Int32Array, @@ -78,6 +132,20 @@ export class BufferPool { data.BYTES_PER_ELEMENT, ); + if (BUFFER_POOL_STRICT) { + const writeEnd = byteOffset + data.byteLength; + if (writeEnd > managed.byteCapacity) { + throw new Error( + `BufferPool.upload("${name}"): write ${byteOffset}..${writeEnd} ` + + `exceeds capacity ${managed.byteCapacity} ` + + `(_totalCapacity=${this._totalCapacity}, ` + + `components=${componentsPerVertex}, ` + + `bytes=${data.BYTES_PER_ELEMENT}). ` + + `Did you pass the full scratch buffer instead of a subarray(0, n)?`, + ); + } + } + gl.bindBuffer(gl.ARRAY_BUFFER, managed.buffer); gl.bufferSubData(gl.ARRAY_BUFFER, byteOffset, data); @@ -88,6 +156,7 @@ export class BufferPool { for (const managed of this._buffers.values()) { this._gl.deleteBuffer(managed.buffer); } + this._buffers.clear(); this._totalCapacity = 0; } diff --git a/packages/viewer-charts/src/ts/webgl/context-manager.ts b/packages/viewer-charts/src/ts/webgl/context-manager.ts index a2da1c8c9a..54e49ba565 100644 --- a/packages/viewer-charts/src/ts/webgl/context-manager.ts +++ b/packages/viewer-charts/src/ts/webgl/context-manager.ts @@ -11,25 +11,79 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import { ShaderRegistry } from "./shader-registry"; +import { SHADER_MANIFEST } from "./shader-manifest"; import { BufferPool } from "./buffer-pool"; +export type WebGLCanvas = HTMLCanvasElement | OffscreenCanvas; + +export interface WebGLContextManagerOptions { + /** + * If `true`, compile + link every shader in `SHADER_MANIFEST` + * during construction (and again after a context-loss/restore + * cycle). Trades ~30-100ms of init time for elimination of the + * compile/link cost on the first-frame path of every chart type + * that ends up rendered. Default `false` keeps the original + * lazy-compile behavior — programs are compiled on first + * `getOrCreate(name, ...)` call from a glyph's render path. + */ + precompile?: boolean; +} + export class WebGLContextManager { - private _canvas: HTMLCanvasElement; + private _canvas: WebGLCanvas; private _gl: WebGL2RenderingContext | WebGLRenderingContext; private _isWebGL2: boolean; private _shaders: ShaderRegistry; private _buffers: BufferPool; private _uploadedCount = 0; + private _cssWidth = 0; + private _cssHeight = 0; + private _dpr = 1; + private _precompile: boolean; + private _frameCallback: ((bitmap: ImageBitmap) => void) | null = null; + + /** + * Per-instance `MessageChannel` used by `_yieldToTask` to resume a + * polling `awaitGpuFence` loop on the next task. Allocated lazily — + * the polling path is rarely hit when the GPU is idle, and many + * `WebGLContextManager` instances never need one. + * + * Must be per-instance: a module-level singleton races when two + * managers poll concurrently. Both call sites assign + * `port1.onmessage = resolve`, the second assignment overwrites the + * first, and the first poll's promise never settles — leaving its + * `awaitGpuFence` hung. The hang propagates up through the render + * scheduler's `present` → `uploadAndRender` → the worker's + * `uploadChunkAck` → the host's `with_typed_arrays` callback, + * stalling `draw()` indefinitely. (Now that fence waits across + * different `WebGLContextManager`s run in parallel inside one + * scheduler drain, the per-instance discipline matters even + * more — concurrent poll loops are the common case, not the + * exception.) + * + * Cost of per-instance allocation: one extra `MessageChannel` (two + * `MessagePort`s, ~negligible bytes, zero idle CPU) per chart on + * top of the per-chart proxy channel that the transport already + * holds. Bounded by chart count; safe even for pathological pages + * with hundreds of charts. The alternative — allocating a fresh + * channel on every `_yieldToTask` call — would churn ports on every + * fence poll (potentially many per frame on slow GPUs), which is + * far worse for the structured-clone subsystem than holding one + * port pair for the manager's lifetime. + */ + private _yieldChannel: MessageChannel | null = null; - constructor(canvas: HTMLCanvasElement) { + constructor(canvas: WebGLCanvas, options: WebGLContextManagerOptions = {}) { this._canvas = canvas; + this._precompile = options.precompile ?? false; const gl2 = canvas.getContext("webgl2", { antialias: true, alpha: true, premultipliedAlpha: false, }); + if (gl2) { - this._gl = gl2; + this._gl = gl2 as WebGL2RenderingContext; this._isWebGL2 = true; } else { const gl1 = canvas.getContext("webgl", { @@ -37,27 +91,44 @@ export class WebGLContextManager { alpha: true, premultipliedAlpha: false, }); + if (!gl1) { throw new Error("WebGL is not supported"); } - this._gl = gl1; + + this._gl = gl1 as WebGLRenderingContext; this._isWebGL2 = false; } this._shaders = new ShaderRegistry(this._gl); this._buffers = new BufferPool(this._gl); - // Handle context loss - canvas.addEventListener("webglcontextlost", (e) => { - e.preventDefault(); - }); + if (this._precompile) { + this._shaders.precompile(SHADER_MANIFEST); + } + + // Both `HTMLCanvasElement` and `OffscreenCanvas` dispatch + // `webglcontextlost` / `webglcontextrestored` events on the + // canvas itself. `addEventListener` exists on both. + (canvas as EventTarget).addEventListener( + "webglcontextlost", + (e: Event) => { + e.preventDefault(); + }, + ); - canvas.addEventListener("webglcontextrestored", () => { + (canvas as EventTarget).addEventListener("webglcontextrestored", () => { this._shaders.releaseAll(); this._buffers.releaseAll(); this._shaders = new ShaderRegistry(this._gl); this._buffers = new BufferPool(this._gl); this._uploadedCount = 0; + + // Re-prime the cache after restore so post-recovery + // first-frame doesn't re-pay the lazy compile cost. + if (this._precompile) { + this._shaders.precompile(SHADER_MANIFEST); + } }); } @@ -85,11 +156,21 @@ export class WebGLContextManager { this._uploadedCount = count; } - resize(): void { - const dpr = window.devicePixelRatio || 1; - const rect = this._canvas.getBoundingClientRect(); - const width = Math.round(rect.width * dpr); - const height = Math.round(rect.height * dpr); + /** + * Resize the GL canvas's bitmap to match the host's CSS layout. The + * Host is responsible for measuring the DOM element (or otherwise + * deciding the target CSS size) and the device pixel ratio — the + * manager itself does not touch DOM, so the same code path works + * whether the canvas is an `HTMLCanvasElement` (in-process) or an + * `OffscreenCanvas` (in-process via transfer, or in a worker). + */ + resize(cssWidth: number, cssHeight: number, dpr: number): void { + this._cssWidth = cssWidth; + this._cssHeight = cssHeight; + this._dpr = dpr; + + const width = Math.round(cssWidth * dpr); + const height = Math.round(cssHeight * dpr); if (this._canvas.width !== width || this._canvas.height !== height) { this._canvas.width = width; @@ -98,19 +179,233 @@ export class WebGLContextManager { } } + /** + * Pending dimensions to apply at the start of the next render. + * `null` when no resize is queued. See {@link requestResize} / + * {@link applyPendingResize}. + */ + private _pendingResize: { + cssWidth: number; + cssHeight: number; + dpr: number; + } | null = null; + + /** + * Record a dimension change to be applied at the start of the + * next render's Phase 1, *before* `_fullRender` runs. The actual + * `canvas.width = N` assignment (which clears the drawing buffer + * per the WebGL spec) happens inside `applyPendingResize()`, + * paired in the same synchronous task as the paint that fills + * the new buffer. + * + * Why split the dimension change off from the existing + * {@link resize} method: in direct / in-process modes the + * GL canvas IS the host's visible canvas, and `canvas.width = N` + * is immediately observable to the browser's compositor as a + * cleared buffer. If the resize lands in the message handler + * (one task) but the matching `_fullRender` lands in the next + * RAF (a later task), the compositor cycles between them and + * presents one full frame of empty canvas — visible flicker. + * Deferring the dimension change to the same RAF as the paint + * eliminates the inter-frame gap; both happen inside Phase 1's + * un-yielded loop. + * + * Multiple `requestResize` calls before the next render coalesce + * to last-write-wins — five rapid width changes from a window + * drag produce one resize+paint, not five. + */ + requestResize(cssWidth: number, cssHeight: number, dpr: number): void { + this._pendingResize = { cssWidth, cssHeight, dpr }; + } + + /** + * Apply any pending dimension change recorded by + * {@link requestResize}. Called by the scheduler's Phase 1 + * (immediately before each entry's `fullRender`) and by the + * `snapshotPng` bypass path. Returns `true` when a resize was + * applied, `false` when there was nothing pending — useful for + * callers that want to skip a no-op render. + */ + applyPendingResize(): boolean { + if (!this._pendingResize) { + return false; + } + + const { cssWidth, cssHeight, dpr } = this._pendingResize; + this._pendingResize = null; + this.resize(cssWidth, cssHeight, dpr); + return true; + } + + /** + * Last CSS width passed to `resize()`. + */ + get cssWidth(): number { + return this._cssWidth; + } + + /** + * Last CSS height passed to `resize()`. + */ + get cssHeight(): number { + return this._cssHeight; + } + + /** + * Last device pixel ratio passed to `resize()`. + */ + get dpr(): number { + return this._dpr; + } + clear(): void { this._gl.clearColor(0, 0, 0, 0); this._gl.clear(this._gl.COLOR_BUFFER_BIT | this._gl.DEPTH_BUFFER_BIT); this._uploadedCount = 0; } + /** + * Register a per-frame hook invoked at the end of each render. In + * blit-mode rendering, the worker installs a callback that + * transfers an `ImageBitmap` from `_canvas` (an `OffscreenCanvas`) + * back to the host so the visible display canvas can `drawImage` + * it. In direct mode the callback is left null and `endFrame` is a + * no-op. + * + * Pass `null` to detach. + */ + setFrameCallback(cb: ((bitmap: ImageBitmap) => void) | null): void { + this._frameCallback = cb; + } + + /** + * Called by chart impls at the bottom of `_fullRender` (and any + * other path that produces a complete frame). When a frame + * callback is registered AND the GL surface is an + * `OffscreenCanvas`, ship its current contents as an + * `ImageBitmap` to the host. Otherwise no-op — direct-mode + * rendering has nothing to ship; the visible canvas already holds + * the drawing buffer. + */ + endFrame(): void { + if (!this._frameCallback) { + return; + } + + const canvas = this._canvas as + | OffscreenCanvas + | (HTMLCanvasElement & { transferToImageBitmap?: never }); + if ( + typeof (canvas as OffscreenCanvas).transferToImageBitmap !== + "function" + ) { + return; + } + + const bitmap = (canvas as OffscreenCanvas).transferToImageBitmap(); + this._frameCallback(bitmap); + } + + /** + * Resolve when every GL command submitted up to this call has been + * executed by the GPU. + * + * On WebGL2 this issues a `fenceSync(SYNC_GPU_COMMANDS_COMPLETE)` + * and polls `clientWaitSync` with a zero timeout, yielding to the + * task queue between polls. The first poll passes + * `SYNC_FLUSH_COMMANDS_BIT` so the fence becomes reachable without + * a separate `gl.flush()`. + * + * On WebGL1 there is no fenceSync; we fall back to the blocking + * `gl.finish()`. This is acceptable in a worker — never call this + * from the main thread on a heavy frame. + * + * Used as a per-frame "GPU is idle" barrier so callers can serialize + * follow-on work (`endFrame` snapshot, present roundtrip, the next + * chunk upload) against actual GPU completion instead of the + * implicit, implementation-defined timing of `transferToImageBitmap`. + */ + async awaitGpuFence(): Promise { + if (!this._isWebGL2) { + this._gl.finish(); + return; + } + + const gl = this._gl as WebGL2RenderingContext; + const fence = gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0); + if (!fence) { + gl.finish(); + return; + } + + try { + let flags: GLbitfield = gl.SYNC_FLUSH_COMMANDS_BIT; + while (true) { + const status = gl.clientWaitSync(fence, flags, 0); + if ( + status === gl.ALREADY_SIGNALED || + status === gl.CONDITION_SATISFIED + ) { + return; + } + + if (status === gl.WAIT_FAILED) { + gl.finish(); + return; + } + + flags = 0; + await this._yieldToTask(); + } + } finally { + gl.deleteSync(fence); + } + } + ensureBufferCapacity(totalRows: number): void { this._buffers.ensureCapacity(totalRows); } + /** + * Yield to the task queue between fence polls. We avoid + * `setTimeout(0)` because Chromium clamps nested `setTimeout` to + * ~4ms in workers, which would inflate the measured cost of + * `awaitGpuFence`. A reused per-instance `MessageChannel` lands the + * resume in the next task with sub-ms latency. + * + * `addEventListener(..., { once: true })` is used over + * `port1.onmessage = ...` so concurrent in-flight resumes (should + * any path ever introduce them) cannot clobber each other's + * resolvers — the previous module-level singleton lost a resolver + * on every overlap and hung one chart's `draw()` indefinitely. + */ + private _yieldToTask(): Promise { + if (!this._yieldChannel) { + this._yieldChannel = new MessageChannel(); + // `addEventListener` does not auto-start a `MessagePort` — + // only `onmessage = ...` does. Start once at allocation so + // posted resumes are actually delivered. + this._yieldChannel.port1.start(); + } + + return new Promise((resolve) => { + const ch = this._yieldChannel!; + ch.port1.addEventListener("message", () => resolve(), { + once: true, + }); + ch.port2.postMessage(null); + }); + } + destroy(): void { this._buffers.releaseAll(); this._shaders.releaseAll(); + if (this._yieldChannel) { + this._yieldChannel.port1.close(); + this._yieldChannel.port2.close(); + this._yieldChannel = null; + } + const ext = this._gl.getExtension("WEBGL_lose_context"); if (ext) { ext.loseContext(); diff --git a/packages/viewer-charts/src/ts/webgl/gradient-texture.ts b/packages/viewer-charts/src/ts/webgl/gradient-texture.ts index 37eca2e0b6..e2d181d873 100644 --- a/packages/viewer-charts/src/ts/webgl/gradient-texture.ts +++ b/packages/viewer-charts/src/ts/webgl/gradient-texture.ts @@ -17,6 +17,7 @@ const LUT_SIZE = 256; export interface GradientTextureCache { texture: WebGLTexture; + // The `GradientStop[]` reference last uploaded. `resolveTheme` returns a // fresh object per render, so comparing the array reference is enough to // detect a theme change and skip the upload otherwise. @@ -38,7 +39,9 @@ export function ensureGradientTexture( let texture: WebGLTexture; if (cache?.texture) { texture = cache.texture; - if (cache.lastStops === stops) return cache; // no-op fast path + if (cache.lastStops === stops) { + return cache; + } // no-op fast path } else { texture = gl.createTexture()!; } diff --git a/packages/viewer-charts/src/ts/webgl/instanced-attrs.ts b/packages/viewer-charts/src/ts/webgl/instanced-attrs.ts index 2a8317497b..a139119881 100644 --- a/packages/viewer-charts/src/ts/webgl/instanced-attrs.ts +++ b/packages/viewer-charts/src/ts/webgl/instanced-attrs.ts @@ -37,7 +37,10 @@ export function getInstancing(glManager: WebGLContextManager): Instancing { const gl2 = gl as WebGL2RenderingContext; return { setDivisor(location, divisor) { - if (location < 0) return; + if (location < 0) { + return; + } + gl2.vertexAttribDivisor(location, divisor); }, drawArraysInstanced(mode, first, count, instances) { @@ -51,7 +54,10 @@ export function getInstancing(glManager: WebGLContextManager): Instancing { ) as ANGLE_instanced_arrays | null; return { setDivisor(location, divisor) { - if (location < 0) return; + if (location < 0) { + return; + } + ext?.vertexAttribDivisorANGLE(location, divisor); }, drawArraysInstanced(mode, first, count, instances) { @@ -60,10 +66,53 @@ export function getInstancing(glManager: WebGLContextManager): Instancing { }; } +/** + * Allocate the static `[0, 1, 2, 3]` line-strip corner buffer used by + * every instanced line/wick glyph: each corner index is multiplied by + * `LINE_WIDTH_PX` in the shader to expand a 2-vertex segment into a + * `TRIANGLE_STRIP` quad. Identical contents across all callers; one + * helper avoids the four-way `createBuffer` / `bufferData` boilerplate. + */ +export function createLineCornerBuffer(gl: GL): WebGLBuffer { + const buffer = gl.createBuffer()!; + gl.bindBuffer(gl.ARRAY_BUFFER, buffer); + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([0, 1, 2, 3]), + gl.STATIC_DRAW, + ); + return buffer; +} + +/** + * Allocate the static `[(0,0), (1,0), (0,1), (1,1)]` quad-strip corner + * buffer used by instanced rect glyphs (candlestick body). Used as a + * `vec2 a_corner` attribute that the shader scales by per-instance + * width/height to expand into a `TRIANGLE_STRIP` rect. + */ +export function createQuadCornerBuffer(gl: GL): WebGLBuffer { + const buffer = gl.createBuffer()!; + gl.bindBuffer(gl.ARRAY_BUFFER, buffer); + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]), + gl.STATIC_DRAW, + ); + return buffer; +} + /** * Bind a per-instance float attribute from a named buffer in the buffer - * pool. No-op when `attr` is negative. Caller is responsible for calling - * `setDivisor(attr, 0)` after the draw if state must be reset. + * pool. Returns `true` when the bind happened, `false` when the buffer + * has not yet been allocated (caller should skip the draw rather than + * paint zero data). No-op return `true` when `attr` is negative + * (optimized-out attribute — drawing the rest is still valid). + * + * Render-path uses `peek`, never `getOrCreate`: the latter recreates + * with zero-initialized contents when `_totalCapacity` has grown past + * the current buffer, which would wipe the previous draw's vertex + * data and leave `drawArraysInstanced` to render zeros. See + * {@link BufferPool#peek} for the full rationale. */ export function bindInstancedFloatAttr( glManager: WebGLContextManager, @@ -71,16 +120,20 @@ export function bindInstancedFloatAttr( attr: number, name: string, components: number, -): void { - if (attr < 0) return; +): boolean { + if (attr < 0) { + return true; + } + + const buf = glManager.bufferPool.peek(name); + if (!buf) { + return false; + } + const gl: GL = glManager.gl; - const buf = glManager.bufferPool.getOrCreate( - name, - components, - Float32Array.BYTES_PER_ELEMENT, - ); gl.bindBuffer(gl.ARRAY_BUFFER, buf.buffer); gl.enableVertexAttribArray(attr); gl.vertexAttribPointer(attr, components, gl.FLOAT, false, 0, 0); instancing.setDivisor(attr, 1); + return true; } diff --git a/packages/viewer-charts/src/ts/webgl/plot-frame.ts b/packages/viewer-charts/src/ts/webgl/plot-frame.ts index 168f31a8cd..4e8dd61deb 100644 --- a/packages/viewer-charts/src/ts/webgl/plot-frame.ts +++ b/packages/viewer-charts/src/ts/webgl/plot-frame.ts @@ -14,9 +14,13 @@ import type { PlotLayout } from "../layout/plot-layout"; type GL = WebGL2RenderingContext | WebGLRenderingContext; -/** Return CSS-pixel dimensions of the GL canvas. */ -export function cssSize(gl: GL): { cssWidth: number; cssHeight: number } { - const dpr = window.devicePixelRatio || 1; +/** + * Return CSS-pixel dimensions of the GL canvas. + */ +export function cssSize( + gl: GL, + dpr: number, +): { cssWidth: number; cssHeight: number } { return { cssWidth: gl.canvas.width / dpr, cssHeight: gl.canvas.height / dpr, @@ -49,9 +53,9 @@ export function clearAndSetupFrame(gl: GL): void { export function withScissor( gl: GL, layout: PlotLayout, + dpr: number, draw: () => void, ): void { - const dpr = window.devicePixelRatio || 1; gl.enable(gl.SCISSOR_TEST); gl.scissor( Math.round(layout.margins.left * dpr), @@ -79,8 +83,9 @@ export function withScissor( export function renderInPlotFrame( gl: GL, layout: PlotLayout, + dpr: number, draw: () => void, ): void { clearAndSetupFrame(gl); - withScissor(gl, layout, draw); + withScissor(gl, layout, dpr, draw); } diff --git a/packages/viewer-openlayers/test/js/superstore.spec.js b/packages/viewer-charts/src/ts/webgl/program-cache.ts similarity index 62% rename from packages/viewer-openlayers/test/js/superstore.spec.js rename to packages/viewer-charts/src/ts/webgl/program-cache.ts index dbb64f9faa..a0a4ce79ad 100644 --- a/packages/viewer-openlayers/test/js/superstore.spec.js +++ b/packages/viewer-charts/src/ts/webgl/program-cache.ts @@ -10,35 +10,37 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import { test } from "@perspective-dev/test"; -import { run_standard_tests } from "@perspective-dev/test"; +import type { WebGLContextManager } from "./context-manager"; -async function get_contents(page) { - return await page.evaluate(async () => { - const viewer = document.querySelector( - "perspective-viewer perspective-viewer-openlayers-scatter", - ); - return viewer.innerHTML || "MISSING"; - }); -} - -test.describe("OpenLayers with superstore data set", () => { - test.beforeEach(async ({ page }) => { - await page.goto( - "/node_modules/@perspective-dev/viewer-openlayers/test/html/superstore.html", - ); - await page.evaluate(async () => { - while (!window["__TEST_PERSPECTIVE_READY__"]) { - await new Promise((x) => setTimeout(x, 10)); - } - }); +/** + * Compile (or fetch from the shader registry) a program and resolve a + * flat record of uniform + attribute locations keyed by name. Uniforms + * resolve to `WebGLUniformLocation | null`; attribs resolve to `number`. + * + * Each glyph drawer used to repeat ~10 lines of `gl.getUniformLocation` + * / `gl.getAttribLocation` calls — replacing those with one + * `compileProgram(...)` call shrinks the worker bundle by ~80 bytes per + * site (uniform/attrib name strings still ship — WebGL needs them + * verbatim — but the per-call wrapper goes away). + */ +export function compileProgram( + glManager: WebGLContextManager, + key: string, + vert: string, + frag: string, + uniforms: readonly string[], + attrs: readonly string[], +): C { + const program = glManager.shaders.getOrCreate(key, vert, frag); + const gl = glManager.gl; + const out: any = { program }; + for (const n of uniforms) { + out[n] = gl.getUniformLocation(program, n); + } - await page.evaluate(async () => { - await document.querySelector("perspective-viewer").restore({ - plugin: "Map Scatter", - }); - }); - }); + for (const n of attrs) { + out[n] = gl.getAttribLocation(program, n); + } - run_standard_tests("perspective-viewer-openlayers-scatter", get_contents); -}); + return out as C; +} diff --git a/packages/viewer-charts/src/ts/webgl/shader-manifest.ts b/packages/viewer-charts/src/ts/webgl/shader-manifest.ts new file mode 100644 index 0000000000..a057e7d24d --- /dev/null +++ b/packages/viewer-charts/src/ts/webgl/shader-manifest.ts @@ -0,0 +1,148 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import barVert from "../shaders/bar.vert.glsl"; +import barFrag from "../shaders/bar.frag.glsl"; +import lineVert from "../shaders/line.vert.glsl"; +import lineFrag from "../shaders/line.frag.glsl"; +import scatterVert from "../shaders/scatter.vert.glsl"; +import scatterFrag from "../shaders/scatter.frag.glsl"; +import areaVert from "../shaders/area.vert.glsl"; +import areaFrag from "../shaders/area.frag.glsl"; +import lineUniformVert from "../shaders/line-uniform.vert.glsl"; +import lineUniformFrag from "../shaders/line-uniform.frag.glsl"; +import yScatterVert from "../shaders/y-scatter.vert.glsl"; +import yScatterFrag from "../shaders/y-scatter.frag.glsl"; +import candlestickBodyVert from "../shaders/candlestick-body.vert.glsl"; +import candlestickBodyFrag from "../shaders/candlestick-body.frag.glsl"; +import heatmapVert from "../shaders/heatmap.vert.glsl"; +import heatmapFrag from "../shaders/heatmap.frag.glsl"; +import sunburstArcVert from "../shaders/sunburst-arc.vert.glsl"; +import sunburstArcFrag from "../shaders/sunburst-arc.frag.glsl"; +import treemapVert from "../shaders/treemap.vert.glsl"; +import treemapFrag from "../shaders/treemap.frag.glsl"; +import densitySplatVert from "../shaders/density-splat.vert.glsl"; +import densitySplatFrag from "../shaders/density-splat.frag.glsl"; +import densityExtremeFrag from "../shaders/density-extreme.frag.glsl"; +import densityMrtVert from "../shaders/density-mrt.vert.glsl"; +import densityMrtFrag from "../shaders/density-mrt.frag.glsl"; +import densityResolveVert from "../shaders/density-resolve.vert.glsl"; +import densityResolveFrag from "../shaders/density-resolve.frag.glsl"; +import tileVert from "../shaders/tile.vert.glsl"; +import tileFrag from "../shaders/tile.frag.glsl"; + +/** + * One shader program in the build's static manifest. The `name` is the + * cache key consumed by `ShaderRegistry.getOrCreate(name, ...)` — + * call sites must pass the same name (and the same vert/frag source) + * for the cache to hit. The single source of truth for both fields is + * this file; glyph code re-imports from here so the manifest and the + * call sites can never drift. + */ +export interface ShaderSpec { + name: string; + vert: string; + frag: string; +} + +/** + * Every WebGL program the build ships, keyed by cache name. Used by + * `ShaderRegistry.precompile(SHADER_MANIFEST)` to compile + link all + * programs eagerly during renderer construction so the first-frame + * path doesn't pay the compile cost inline. + * + * Names mirror the existing `getOrCreate` keys at each call site — + * the lazy path stays valid; precompile just primes the cache early. + */ +export const SHADER_MANIFEST: readonly ShaderSpec[] = [ + { name: "bar", vert: barVert, frag: barFrag }, + { name: "line", vert: lineVert, frag: lineFrag }, + { name: "scatter", vert: scatterVert, frag: scatterFrag }, + { name: "bar-area", vert: areaVert, frag: areaFrag }, + { name: "bar-scatter", vert: yScatterVert, frag: yScatterFrag }, + + // Shared by series-line glyph (`bar-line` consolidated here), + // candlestick wicks, and OHLC. One compile per context. + { name: "line-uniform", vert: lineUniformVert, frag: lineUniformFrag }, + { + name: "candlestick-body", + vert: candlestickBodyVert, + frag: candlestickBodyFrag, + }, + { name: "heatmap", vert: heatmapVert, frag: heatmapFrag }, + { name: "sunburst-arc", vert: sunburstArcVert, frag: sunburstArcFrag }, + { name: "treemap", vert: treemapVert, frag: treemapFrag }, + { + name: "density-splat", + vert: densitySplatVert, + frag: densitySplatFrag, + }, + { + name: "density-extreme", + vert: densitySplatVert, + frag: densityExtremeFrag, + }, + { + name: "density-resolve", + vert: densityResolveVert, + frag: densityResolveFrag, + }, + // The MRT variant declares `#extension GL_EXT_draw_buffers : + // require` and only compiles on contexts that advertise it. The + // glyph compiles it lazily after probing `OES_draw_buffers_indexed` + // — adding it here would crash precompile on hardware without + // multi-render-target support. + + // Map tile basemap (textured quad in Mercator space). Compiled + // unconditionally because the program is GLSL 100 and links on + // every WebGL1/2 context; the loader only kicks in when a map + // plugin tag activates. + { name: "map-tile", vert: tileVert, frag: tileFrag }, +]; + +// Re-export each shader source so glyph modules can import their +// source from the manifest rather than from `../shaders/*.glsl` +// directly. This keeps the manifest's vert/frag fields and the +// `getOrCreate` call sites pointing at exactly the same string +// constants — module dedup ensures reference identity, so cache +// hits work even on the lazy path. +export { + barVert, + barFrag, + lineVert, + lineFrag, + scatterVert, + scatterFrag, + areaVert, + areaFrag, + lineUniformVert, + lineUniformFrag, + yScatterVert, + yScatterFrag, + candlestickBodyVert, + candlestickBodyFrag, + heatmapVert, + heatmapFrag, + sunburstArcVert, + sunburstArcFrag, + treemapVert, + treemapFrag, + densitySplatVert, + densitySplatFrag, + densityExtremeFrag, + densityMrtVert, + densityMrtFrag, + densityResolveVert, + densityResolveFrag, + tileVert, + tileFrag, +}; diff --git a/packages/viewer-charts/src/ts/webgl/shader-registry.ts b/packages/viewer-charts/src/ts/webgl/shader-registry.ts index e9257106cd..15c21f74a1 100644 --- a/packages/viewer-charts/src/ts/webgl/shader-registry.ts +++ b/packages/viewer-charts/src/ts/webgl/shader-registry.ts @@ -10,6 +10,8 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +import type { ShaderSpec } from "./shader-manifest"; + type GL = WebGL2RenderingContext | WebGLRenderingContext; export class ShaderRegistry { @@ -20,9 +22,28 @@ export class ShaderRegistry { this._gl = gl; } + /** + * Compile + link every program in `specs` eagerly. Used by + * `WebGLContextManager` when constructed with `precompile: true` + * so the first-frame path doesn't pay the compile cost inline. + * + * Compilation is synchronous and serial — single-digit ms per + * program on a modern GPU. With `KHR_parallel_shader_compile` + * (browser-supported but not yet wired here) the work could be + * dispatched to driver threads; today we accept the wall-time + * cost in exchange for simpler code and a deterministic init. + */ + precompile(specs: readonly ShaderSpec[]): void { + for (const spec of specs) { + this.getOrCreate(spec.name, spec.vert, spec.frag); + } + } + getOrCreate(name: string, vertSrc: string, fragSrc: string): WebGLProgram { let program = this._programs.get(name); - if (program) return program; + if (program) { + return program; + } const gl = this._gl; @@ -70,6 +91,7 @@ export class ShaderRegistry { for (const program of this._programs.values()) { this._gl.deleteProgram(program); } + this._programs.clear(); } } diff --git a/packages/viewer-charts/src/ts/worker/boot.ts b/packages/viewer-charts/src/ts/worker/boot.ts new file mode 100644 index 0000000000..fdbb54d76e --- /dev/null +++ b/packages/viewer-charts/src/ts/worker/boot.ts @@ -0,0 +1,22 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +// In Web Worker scope, `window` is undefined but some bundled code +// (`@perspective-dev/viewer` wasm-bindgen JS, plus a few feature +// detects) probes it. Provide a stub *only* in worker scope — when +// this module is dynamic-imported on the main thread for the +// in-process renderer path, `window` is the real `Window` and we must +// not clobber it. +if (typeof window === "undefined") { + // @ts-ignore + globalThis.window = {}; +} diff --git a/packages/viewer-charts/src/ts/worker/dispatch.ts b/packages/viewer-charts/src/ts/worker/dispatch.ts new file mode 100644 index 0000000000..1ca1adc797 --- /dev/null +++ b/packages/viewer-charts/src/ts/worker/dispatch.ts @@ -0,0 +1,99 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import type { ControlMsg } from "../transport/protocol"; +import type { WorkerRenderer } from "./renderer.worker"; + +/** + * Stateless control-message dispatcher. The renderer instance is + * supplied by the caller so worker mode (one global instance) and + * in-process mode (one instance per host element) share the same + * routing logic. + */ +export function dispatch(r: WorkerRenderer, msg: ControlMsg): void { + switch (msg.kind) { + case "setViewByName": + r.setViewByName(msg.name); + break; + case "setColumnsConfig": + r.chartImpl.setColumnsConfig?.(msg.cfg); + break; + case "setPluginConfig": + r.chartImpl.setPluginConfig?.(msg.cfg); + r.redraw(); + break; + case "setBufferMaxCapacity": + r.glManager.bufferPool.maxCapacity = msg.n; + break; + case "loadAndRender": + r.loadAndRender(msg); + break; + case "redraw": + r.redraw(); + break; + case "resize": + console.log("resize"); + r.resize(msg.cssWidth, msg.cssHeight, msg.dpr); + r.redraw(); + break; + case "clear": + r.clear(); + break; + case "invalidateTheme": + r.chartImpl.setTheme?.(msg.themeVars); + r.chartImpl.invalidateTheme?.(); + r.resize(r.cssWidth, r.cssHeight, r.dpr); + break; + case "restoreZoom": + r.restoreZoom(msg.state); + break; + case "resetAllZooms": + r.resetAllZooms(); + r.redraw(); + r.post({ + kind: "zoomChanged", + isDefault: r.allZoomsDefault(), + }); + break; + case "resetExpandedDomain": + r.resetExpandedDomain(); + break; + case "interaction": + r.onInteraction(msg.event); + break; + case "saveZoom": + r.post({ + kind: "saveZoomReply", + requestId: msg.requestId, + state: r.saveZoom(), + }); + break; + case "destroy": + r.destroy(); + break; + case "init": + // Re-init not supported; ignore to keep the renderer alive + // for the host's `delete()` cleanup path. + break; + case "snapshotPng": { + const requestId = msg.requestId; + r.snapshotPng() + .then((blob) => { + r.post({ kind: "snapshotPngReply", requestId, blob }); + }) + .catch((err) => { + r.post({ kind: "error", message: String(err) }); + }); + break; + } + } +} diff --git a/packages/viewer-charts/src/ts/worker/font-loader.ts b/packages/viewer-charts/src/ts/worker/font-loader.ts new file mode 100644 index 0000000000..c52aef9234 --- /dev/null +++ b/packages/viewer-charts/src/ts/worker/font-loader.ts @@ -0,0 +1,89 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import type { FontFaceDescriptor } from "../transport/protocol"; + +/** + * Worker-scope font registration cache, keyed by `family|src|weight| + * style`. Multiple sessions in one shared worker often ship identical + * font snapshots (the host's `snapshotFontFaces()` walks + * `document.styleSheets`, which is page-global). Without this cache, + * each session would `face.load()` + `fonts.add(face)` afresh — + * harmless but wasteful, since `fonts.add` dedupes by family but each + * `face.load()` still pays the await. + * + * The first session to ask for a given font owns the load promise; + * every subsequent session awaits it. + */ +const FONT_LOADS = new Map>(); + +export async function loadFontDeduped(d: FontFaceDescriptor): Promise { + const key = [ + d.family, + d.src, + d.weight ?? "", + d.style ?? "", + // d.stretch ?? "", + // d.unicodeRange ?? "", + // d.variant ?? "", + // d.featureSettings ?? "", + // d.display ?? "", + ].join("|"); + + let p = FONT_LOADS.get(key); + if (p) { + await p; + return; + } + + p = (async () => { + try { + const descriptors: FontFaceDescriptors = {}; + if (d.style) { + descriptors.style = d.style; + } + + if (d.weight) { + descriptors.weight = d.weight; + } + + if (d.stretch) { + descriptors.stretch = d.stretch; + } + + // if (d.unicodeRange) { + // descriptors.unicodeRange = d.unicodeRange; + // } + + // if (d.variant) { + // (descriptors as any).variant = d.variant; + // } + + // if (d.featureSettings) { + // descriptors.featureSettings = d.featureSettings; + // } + + // if (d.display) { + // (descriptors as any).display = d.display; + // } + + const face = new FontFace(d.family, d.src, descriptors); + await face.load(); + (self as any).fonts.add(face); + } catch (err) { + console.warn(`Failed to load font ${d.family}:`, err); + } + })(); + + FONT_LOADS.set(key, p); + await p; +} diff --git a/packages/viewer-charts/src/ts/worker/renderer.worker.ts b/packages/viewer-charts/src/ts/worker/renderer.worker.ts new file mode 100644 index 0000000000..413f49cb57 --- /dev/null +++ b/packages/viewer-charts/src/ts/worker/renderer.worker.ts @@ -0,0 +1,734 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import "./boot"; + +import type { Client, Table, View } from "@perspective-dev/client"; + +import type * as wasm_module_type from "@perspective-dev/viewer/dist/wasm/perspective-viewer.js"; +import { WebGLContextManager } from "../webgl/context-manager"; +import { ChartImplementation } from "../charts/chart"; +import { ZoomController } from "../interaction/zoom-controller"; +import { + applyPan, + applyWheel, + type ZoomTarget, +} from "../interaction/zoom-router"; +import { MessageHostSink } from "../interaction/host-sink-message"; +import { CHART_IMPLS } from "../charts/registry"; +import type { PlotLayout } from "../layout/plot-layout"; +import type { + ControlMsg, + InitMsg, + InteractionEvent, + LoadAndRenderMsg, + WorkerMsg, +} from "../transport/protocol"; +import { viewToColumnDataMap } from "../data/view-reader"; +import { loadFontDeduped } from "./font-loader"; +import { dispatch } from "./dispatch"; +import { installSessionHost } from "./session-host"; +import { deferIfDraining } from "../render/scheduler"; + +/** + * Sentinel thrown inside the `with_typed_arrays` callback when a newer + * `loadAndRender` has bumped the generation counter. Lets the wasm-side + * Arrow buffer release path run (the callback's promise must reject + * cleanly so `with_typed_arrays` unwinds before the next call) without + * polluting the worker's error path — caught and swallowed by + * `loadAndRender`'s try/catch. + */ +class StaleGenerationError extends Error { + constructor() { + super("StaleGenerationError"); + } +} + +/** + * Renderer state. One per host element. In worker mode it lives in + * the worker; in in-process mode (host loads this module via dynamic + * `import(workerURL)`) it lives on the main thread. The class itself + * doesn't care — both modes drive it through a `MessagePort` of + * `ControlMsg`s. + */ +/** + * Resolve a chart tag to its impl class via the lazy registry. Eager + * tags microtask-resolve; map tags trigger a dynamic `import()` that + * the bundler emits as a separately-fetched chunk. + */ +async function resolveChartImpl( + tag: string, +): Promise ChartImplementation> { + const factory = CHART_IMPLS[tag]; + if (!factory) { + throw new Error(`Unknown chart tag: ${tag}`); + } + + return await factory(); +} + +export class WorkerRenderer { + chartImpl: ChartImplementation; + glManager: WebGLContextManager; + zoomController: ZoomController | null = null; + gridlines: OffscreenCanvas; + chrome: OffscreenCanvas; + cssWidth: number; + cssHeight: number; + dpr: number; + client: Client; + view: View; + + /** + * Source `Table` opened once at bootstrap from the host-supplied + * `tableName`. Used by `loadAndRender` to fetch the source schema + * for group-by level types — the worker resolves it itself so the + * host's render path makes zero `Client`/`Table`/`View` awaits. + * Null when the host had no table loaded at init time. + */ + table: Table | null; + controlPort: MessagePort; + + /** + * Monotonic counter bumped by every `loadAndRender` entry. Captured + * locally as `myGen` and re-checked after each await — a stale + * value means a newer call has superseded this one and we must + * bail (throwing inside the `with_typed_arrays` callback so the + * wasm Arrow buffer release runs cleanly). + */ + private _renderGen = 0; + + /** + * Active drag state. `pointerdown` resolves a target via the + * facet grid and stores it; `pointermove` consults this until + * `pointerup` clears it. Pointer capture itself is host-side. + */ + private _dragTarget: ZoomTarget | null = null; + private _lastDragX = 0; + private _lastDragY = 0; + + constructor( + msg: InitMsg, + client: Client, + view: View, + table: Table | null, + controlPort: MessagePort, + ImplClass: new () => ChartImplementation, + ) { + this.client = client; + this.view = view; + this.table = table; + this.controlPort = controlPort; + + this.chartImpl = new ImplClass(); + + // Direct mode hands us the host's transferred `.webgl-canvas`. + // Blit mode omits it — the renderer owns its own offscreen + // surface and posts each completed frame back as an + // `ImageBitmap` via the `endFrame` callback wired below. + const glCanvas = + msg.glCanvas ?? + new OffscreenCanvas( + Math.max(1, Math.round(msg.cssWidth * msg.dpr)), + Math.max(1, Math.round(msg.cssHeight * msg.dpr)), + ); + + this.glManager = new WebGLContextManager(glCanvas, { + precompile: msg.precompileShaders ?? false, + }); + + if (msg.renderMode === "blit") { + this.glManager.setFrameCallback((bitmap) => { + this.post({ kind: "frameBitmap", bitmap }, [bitmap]); + }); + } + + this.gridlines = msg.gridlinesCanvas; + this.chrome = msg.chromeCanvas; + this.cssWidth = msg.cssWidth; + this.cssHeight = msg.cssHeight; + this.dpr = msg.dpr; + + this.chartImpl.setGridlineCanvas?.(msg.gridlinesCanvas); + this.chartImpl.setChromeCanvas?.(msg.chromeCanvas); + this.chartImpl.setTheme?.(msg.themeVars); + + if (msg.defaultChartType) { + this.chartImpl.setDefaultChartType?.(msg.defaultChartType); + } + + this.chartImpl.setFacetConfig?.(msg.facetConfig); + this.chartImpl.setPluginConfig?.(msg.pluginConfig); + + if (this.chartImpl.setZoomController) { + this.zoomController = new ZoomController(); + this.chartImpl.setZoomController(this.zoomController); + } + + this.chartImpl.setView?.(view); + this.glManager.bufferPool.maxCapacity = msg.bufferMaxCapacity; + this.glManager.resize(msg.cssWidth, msg.cssHeight, msg.dpr); + const hostSink = new MessageHostSink((envelope) => { + switch (envelope.kind) { + case "pin": + this.post({ + kind: "pinTooltip", + lines: envelope.payload.lines, + pos: envelope.payload.pos, + bounds: envelope.payload.bounds, + }); + break; + case "dismiss": + this.post({ kind: "dismissTooltip" }); + break; + case "setCursor": + this.post({ kind: "setCursor", cursor: envelope.cursor }); + break; + case "userClick": + this.post({ + kind: "userClick", + detail: envelope.payload as any, + }); + break; + case "userSelect": + this.post({ + kind: "userSelect", + selected: envelope.payload.selected, + row: envelope.payload.row, + column_names: envelope.payload.column_names, + insertConfig: envelope.payload.insertConfig as any, + }); + break; + } + }); + + this.chartImpl.attachTooltip?.(hostSink); + } + + setViewByName(name: string): void { + this.view = this.client.__unsafe_open_view(name); + this.chartImpl.setView?.(this.view); + } + + /** + * Full data-fetch + render pipeline. Owns every `Client`/`Table`/ + * `View` await on the render path: + * + * 1. Resolve metadata (`view.num_rows`, `view.schema`, + * `view.expression_schema`, `table.schema`) in parallel. + * 2. Apply schema + viewer-config to the chart impl (replaces the + * individual `setColumnTypes` / `setGroupByTypes` / + * `setViewPivots` / `setColumnSlots` setters that used to + * stream from the host). + * 3. Compute `totalRows` from `bufferPool.maxCapacity / numCols` + * and grow the buffer pool to fit. + * 4. Run `view.with_typed_arrays`; the inner callback hands the + * resulting `ColumnDataMap` straight to + * `chartImpl.uploadAndRender` — no `postMessage`, no transfer. + * + * Mid-flight cancellation: each entry bumps `_renderGen` and + * captures `myGen`. After the metadata await we re-check; if a + * newer call has superseded this one, ack-and-return so the host + * promise resolves cleanly. Inside the `with_typed_arrays` + * callback the same check throws `StaleGenerationError` so the + * wasm Arrow buffer release path runs (callback's promise must + * reject for `with_typed_arrays` to unwind) before the next call + * proceeds — caught and swallowed here. + * + * Always sends `loadAndRenderAck` (even on stale drop) per the + * "resolve on stale" host contract. + */ + async loadAndRender(msg: LoadAndRenderMsg): Promise { + const myGen = ++this._renderGen; + try { + const [numRows, schema, exprSchema, tableSchema] = + await Promise.all([ + this.view.num_rows(), + this.view.schema() as Promise>, + this.view.expression_schema() as Promise< + Record + >, + (this.table?.schema() ?? Promise.resolve({})) as Promise< + Record + >, + ]); + + if (this._renderGen !== myGen) { + return; + } + + // Order mirrors the pre-refactor host-side message stream + // (pivots → types → groupByTypes → slots) — chart impls + // assume types/groupByTypes are pushed after pivots so + // axis-builder code paths see consistent state. + this.chartImpl.setViewPivots?.( + msg.viewerConfig.group_by, + msg.viewerConfig.split_by, + ); + this.chartImpl.setColumnTypes?.(schema); + this.chartImpl.setGroupByTypes?.({ ...tableSchema, ...exprSchema }); + this.chartImpl.setColumnSlots?.(msg.viewerConfig.columns); + + const numCols = Object.keys(schema).length || 1; + const maxRows = Math.floor( + this.glManager.bufferPool.maxCapacity / numCols, + ); + + const totalRows = Math.min(numRows, maxRows); + this.glManager.ensureBufferCapacity(totalRows); + + try { + await viewToColumnDataMap( + this.view, + async (cols) => { + if (this._renderGen !== myGen) { + throw new StaleGenerationError(); + } + + await this.chartImpl.uploadAndRender( + this.glManager, + cols, + 0, + totalRows, + ); + }, + { end_row: totalRows, float32: msg.options.float32 }, + ); + } catch (e) { + if (!(e instanceof StaleGenerationError)) { + throw e; + } + } + } catch (err) { + // Any unexpected throw — proxy hiccup, chart-impl mutation + // failure, RAF chain rejection — must not leak past the + // outer fire-and-forget caller (`dispatch` does not await + // this method). Surface to the worker console; the host's + // pending promise still gets resolved via the `finally` + // ack below so `draw()` can't deadlock on a renderer error. + console.error("loadAndRender failed", err); + } finally { + this.post({ kind: "loadAndRenderAck", msgId: msg.msgId }); + } + } + + redraw(): void { + this.chartImpl.requestRender(this.glManager); + } + + resize(cssWidth: number, cssHeight: number, dpr: number): void { + // `glManager.resize` would set `canvas.width = N`, which the + // spec mandates clears the drawing buffer immediately. In + // direct / in-process modes the GL canvas IS the host's + // visible canvas, so a clear at message-receipt time + // followed by a paint on the next RAF leaves one full + // compositor cycle between them displaying an empty buffer + // — visible flicker. + // + // `requestResize` only stores the pending dimensions; the + // `canvas.width = N` assignment is deferred to the next + // `drain()` Phase 1, where it runs in the same un-yielded + // synchronous loop as `_fullRender`. Compositor only + // observes the post-paint state. + // + // Because `requestResize` is a pure JS-state operation (no + // GL ops, no canvas mutation), it doesn't need + // `deferIfDraining` — it's safe to call concurrently with + // an in-flight drain. The drain serialization at the + // scheduler level ensures the actual `applyPendingResize` + // happens between drains, never during one. + // + // Multiple `requestResize` calls before the next render + // coalesce: last write wins. Five rapid width changes from + // a window-drag produce one resize+paint, not five. + this.cssWidth = cssWidth; + this.cssHeight = cssHeight; + this.dpr = dpr; + this.glManager.requestResize(cssWidth, cssHeight, dpr); + this.chartImpl.requestRender(this.glManager); + } + + clear(): void { + // Same rationale as `resize`: `gl.clear` would queue after + // Phase 1's draws but could execute before + // `transferToImageBitmap`, wiping the bitmap. Defer. + deferIfDraining(this.glManager, () => { + this.glManager.clear(); + const ctx = this.gridlines.getContext("2d"); + ctx?.clearRect(0, 0, this.gridlines.width, this.gridlines.height); + }); + } + + saveZoom(): any { + return this.zoomController?.serialize(); + } + + restoreZoom(state: any): void { + if (state) { + this.zoomController?.restore(state); + } + } + + allZoomsDefault(): boolean { + if (this.zoomController && !this.zoomController.isDefault()) { + return false; + } + + const facets = (this.chartImpl as any)?._facetZoomControllers; + if (facets) { + for (const zc of facets) { + if (zc && !zc.isDefault()) { + return false; + } + } + } + + return true; + } + + resetAllZooms(): void { + this.zoomController?.reset(); + const facets = (this.chartImpl as any)?._facetZoomControllers; + if (facets) { + for (const zc of facets) { + zc?.reset(); + } + } + + // Also drop any `domain_mode: "expand"` accumulator — the user + // explicitly asked for a clean reset, so the next data load + // should start from the fresh data extent rather than the + // previously-grown one. + this.resetExpandedDomain(); + } + + resetExpandedDomain(): void { + this.chartImpl.resetExpandedDomain?.(); + } + + /** + * Hit-test the cursor against the chart's facet grid (in faceted + * mode) or its current layout (single-plot). Mirrors the resolver + * `_setupZoomRouter` builds on the host for in-process mode — the + * worker owns the facet grid and controllers, so the resolution + * runs here. + */ + private _resolveTarget(mx: number, my: number): ZoomTarget | null { + const chart = this.chartImpl as any; + const facetGrid = chart?._facetGrid as + | { cells: { layout: PlotLayout }[] } + | null + | undefined; + if (facetGrid) { + for (let i = 0; i < facetGrid.cells.length; i++) { + const cell = facetGrid.cells[i]; + const plot = cell.layout.plotRect; + if ( + mx >= plot.x && + mx <= plot.x + plot.width && + my >= plot.y && + my <= plot.y + plot.height + ) { + const zc = + chart.getZoomControllerForFacet?.(i) ?? + this.zoomController; + return zc ? { controller: zc, layout: cell.layout } : null; + } + } + + return null; + } + + if (!this.zoomController) { + return null; + } + + const layout = chart?._lastLayout as PlotLayout | null | undefined; + if (!layout) { + return null; + } + + const plot = layout.plotRect; + if ( + mx < plot.x || + mx > plot.x + plot.width || + my < plot.y || + my > plot.y + plot.height + ) { + return null; + } + + return { controller: this.zoomController, layout }; + } + + onInteraction(event: InteractionEvent): void { + switch (event.type) { + case "wheel": { + const target = this._resolveTarget(event.mx, event.my); + if (!target) { + return; + } + + applyWheel(target, event.mx, event.my, event.deltaY); + this.chartImpl.requestRender(this.glManager); + this.post({ + kind: "zoomChanged", + isDefault: this.allZoomsDefault(), + }); + break; + } + + case "pointerdown": { + const target = this._resolveTarget(event.mx, event.my); + if (!target) { + return; + } + + this._dragTarget = target; + this._lastDragX = event.mx; + this._lastDragY = event.my; + break; + } + + case "pointermove": { + if (this._dragTarget) { + // Mid-drag: pan only; suppress hover dispatch so + // the tooltip doesn't chase the cursor across a + // zoom gesture. + const dx = event.mx - this._lastDragX; + const dy = event.my - this._lastDragY; + this._lastDragX = event.mx; + this._lastDragY = event.my; + applyPan(this._dragTarget, dx, dy); + this.chartImpl.requestRender(this.glManager); + this.post({ + kind: "zoomChanged", + isDefault: this.allZoomsDefault(), + }); + } else { + // Plain hover: route into the chart's + // `TooltipController` (RAF-coalesced). + this._tooltip()?.dispatchHover(event.mx, event.my); + } + + break; + } + + case "pointerup": { + this._dragTarget = null; + break; + } + + case "pointerleave": { + this._tooltip()?.dispatchLeave(); + break; + } + + case "click": { + this._tooltip()?.dispatchClick(event.mx, event.my); + break; + } + + case "dblclick": { + this._tooltip()?.dispatchDblClick(event.mx, event.my); + break; + } + } + } + + /** + * Read the chart impl's `TooltipController`. Charts that don't use + * one (no `attachTooltip` override) yield `null` and the + * mouse-event branches fall through. + */ + private _tooltip(): { + dispatchHover: (mx: number, my: number) => void; + dispatchLeave: () => void; + dispatchClick: (mx: number, my: number) => void; + dispatchDblClick: (mx: number, my: number) => void; + } | null { + const tt = (this.chartImpl as any)?._tooltip; + return tt ?? null; + } + + /** + * Composite the three layers into a single PNG `Blob`. + */ + async snapshotPng(): Promise { + // Snapshot bypasses the scheduler's drain, so it must + // mirror Phase 1's "apply pending resize before paint" + // step itself — otherwise a snapshot taken after a resize + // message but before the next drain would render at the + // previous dimensions. + this.glManager.applyPendingResize(); + this.chartImpl._fullRender(this.glManager); + const gl = this.glManager.gl; + const glCanvas = gl.canvas as OffscreenCanvas; + const w = glCanvas.width; + const h = glCanvas.height; + const pixels = new Uint8ClampedArray(w * h * 4); + gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, pixels); + const composite = new OffscreenCanvas(w, h); + const ctx = composite.getContext("2d"); + if (!ctx) { + throw new Error("snapshotPng: 2D context unavailable"); + } + + const theme = (this.chartImpl as any)._resolveTheme?.(); + const bg = theme?.backgroundColor ?? "transparent"; + if (bg !== "transparent") { + ctx.fillStyle = bg; + ctx.fillRect(0, 0, w, h); + } + + ctx.drawImage(this.gridlines, 0, 0); + const glLayer = new OffscreenCanvas(w, h); + const glCtx = glLayer.getContext("2d"); + if (!glCtx) { + throw new Error("snapshotPng: 2D context unavailable for GL blit"); + } + + glCtx.putImageData(new ImageData(pixels, w, h), 0, 0); + ctx.save(); + ctx.scale(1, -1); + + // `readPixels` returns rows bottom-up; flip on the Y axis + ctx.drawImage(glLayer, 0, -h); + ctx.restore(); + ctx.drawImage(this.chrome, 0, 0); + + return await composite.convertToBlob({ type: "image/png" }); + } + + destroy(): void { + this.chartImpl.destroy(); + this.glManager.destroy(); + } + + post(msg: WorkerMsg, transfer?: Transferable[]): void { + if (transfer && transfer.length > 0) { + this.controlPort.postMessage(msg, transfer); + } else { + this.controlPort.postMessage(msg); + } + } +} + +/** + * Detect whether this module is loaded in a Web Worker scope. + */ +const IS_WORKER_SCOPE = typeof (globalThis as any).importScripts === "function"; + +/** + * Worker-mode bootstrap: receives the host's `InitMsg`, instantiates + * wasm, registers fonts, opens a `Client` against the host's + * `ProxySession`, and constructs a {@link WorkerRenderer} bound to the + * supplied control port (which in worker scope is `self`). + */ +async function bootstrapWorker( + msg: InitMsg, + host: MessagePort, +): Promise { + if (!msg.clientWorkerURL || !msg.clientWasm || !msg.proxyPort) { + throw new Error("Init error"); + } + + const module = (await import( + msg.clientWorkerURL.toString() + )) as typeof wasm_module_type; + + await module.initSync({ module: msg.clientWasm }); + + // Register every `@font-face` the host found in its document so + // Canvas2D `ctx.font` lookups inside this worker resolve correctly. + if (msg.fontFaces?.length) { + await Promise.all(msg.fontFaces.map(loadFontDeduped)); + } + + const proxyPort = msg.proxyPort; + const client = new module.Client( + async (proto: Uint8Array) => { + const buf = proto.slice().buffer; + proxyPort.postMessage(buf, [buf]); + }, + async () => proxyPort.close(), + ); + + proxyPort.addEventListener("message", (e: MessageEvent) => { + client.handle_response(new Uint8Array(e.data)); + }); + + proxyPort.start(); + const view = client.__unsafe_open_view(msg.viewName); + const table = msg.tableName ? await client.open_table(msg.tableName) : null; + const ImplClass = await resolveChartImpl(msg.chartTag); + const renderer = new WorkerRenderer( + msg, + client, + view, + table, + host, + ImplClass, + ); + renderer.post({ kind: "ready" }); + return renderer; +} + +/** + * In-process bootstrap. Used when the host loads this same module via + * `await import(workerURL)` to run the renderer on the main thread — + * skips the wasm / font / proxy-port plumbing because the host already + * owns a live `Client` and the document's `FontFaceSet` is the active + * one. + */ +export async function bootstrapInProcess(opts: { + msg: InitMsg; + client: Client; + controlPort: MessagePort; +}): Promise { + const view = opts.client.__unsafe_open_view(opts.msg.viewName); + const table = opts.msg.tableName + ? await opts.client.open_table(opts.msg.tableName) + : null; + const ImplClass = await resolveChartImpl(opts.msg.chartTag); + const renderer = new WorkerRenderer( + opts.msg, + opts.client, + view, + table, + opts.controlPort, + ImplClass, + ); + + // Listen for control messages on the same port so the host's + // `RendererTransport` shape doesn't need to branch. + opts.controlPort.addEventListener("message", (e: MessageEvent) => { + const ctrl = e.data as ControlMsg; + if (ctrl?.kind === "init") { + return; + } + + dispatch(renderer, ctrl); + }); + + opts.controlPort.start(); + + renderer.post({ kind: "ready" }); + return renderer; +} + +// Worker scope only: install the shared message handler . The same module is +// dynamic-imported on the main thread (in-process mode) where this branch is +// skipped. +if (IS_WORKER_SCOPE) { + installSessionHost(bootstrapWorker); +} diff --git a/packages/viewer-charts/src/ts/worker/session-host.ts b/packages/viewer-charts/src/ts/worker/session-host.ts new file mode 100644 index 0000000000..afe7d32204 --- /dev/null +++ b/packages/viewer-charts/src/ts/worker/session-host.ts @@ -0,0 +1,118 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import type { + ControlEnvelope, + InitMsg, + WorkerEnvelope, + WorkerMsg, +} from "../transport/protocol"; +import type { WorkerRenderer } from "./renderer.worker"; +import { dispatch } from "./dispatch"; + +const RENDERERS = new Map(); + +function postSession( + sessionId: number, + msg: WorkerMsg, + transfer?: Transferable[], +): void { + const envelope = { sessionId, msg } satisfies WorkerEnvelope; + if (transfer && transfer.length > 0) { + (self as unknown as MessagePort).postMessage(envelope, transfer); + } else { + (self as unknown as MessagePort).postMessage(envelope); + } +} + +/** + * Adapter that satisfies the `MessagePort` shape `WorkerRenderer` + * expects (it only ever calls `.postMessage` on it). Outgoing posts + * get session-tagged on the way out so the host can route them to + * the right `RendererTransport` instance. + * + * The receive direction is handled exclusively by the worker-scope + * `self.addEventListener("message", …)` installed by `installSessionHost`; + * this adapter does not own a real port pair. + */ +export function makeSessionPort(sessionId: number): MessagePort { + return { + postMessage: (msg: WorkerMsg, transfer?: Transferable[]) => + postSession(sessionId, msg, transfer), + addEventListener: () => {}, + removeEventListener: () => {}, + start: () => {}, + close: () => {}, + dispatchEvent: () => false, + onmessage: null, + onmessageerror: null, + } as unknown as MessagePort; +} + +/** + * Install the shared message handler on the worker scope. One worker + * process hosts many `WorkerRenderer` instances, one per `sessionId` + * allocated by the host's `RendererTransport`; this listener + * demultiplexes incoming `ControlEnvelope`s and routes them to the + * matching renderer. + * + * The `bootstrap` callback constructs a `WorkerRenderer` from an + * `InitMsg` and a session-tagged port. It's injected so this module + * doesn't need to import `renderer.worker` for runtime bindings (avoids + * a runtime cycle — `renderer.worker` imports this module). + */ +export function installSessionHost( + bootstrap: (msg: InitMsg, port: MessagePort) => Promise, +): void { + self.addEventListener("message", function (e: MessageEvent) { + const env = e.data as ControlEnvelope; + const { sessionId, msg } = env; + + if (msg.kind === "init") { + if (RENDERERS.has(sessionId)) { + // Should never happen. + postSession(sessionId, { + kind: "error", + message: `sessionId ${sessionId} already initialized`, + }); + return; + } + + bootstrap(msg as InitMsg, makeSessionPort(sessionId)) + .then((r) => { + RENDERERS.set(sessionId, r); + }) + .catch((err) => { + postSession(sessionId, { + kind: "error", + message: String(err), + }); + }); + return; + } + + if (msg.kind === "destroy") { + const r = RENDERERS.get(sessionId); + if (r) { + dispatch(r, msg); + RENDERERS.delete(sessionId); + } + + return; + } + + const r = RENDERERS.get(sessionId); + if (r) { + dispatch(r, msg); + } + }); +} diff --git a/packages/viewer-charts/test/js/helpers.ts b/packages/viewer-charts/test/js/helpers.ts deleted file mode 100644 index 4f353eccf3..0000000000 --- a/packages/viewer-charts/test/js/helpers.ts +++ /dev/null @@ -1,109 +0,0 @@ -// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ -// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ -// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ -// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ -// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ -// ┃ Copyright (c) 2017, the Perspective Authors. ┃ -// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ -// ┃ This file is part of the Perspective library, distributed under the terms ┃ -// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ -// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - -import type { Page } from "@playwright/test"; -import { expect, test } from "@perspective-dev/test"; -import type { ViewerConfigUpdate } from "@perspective-dev/viewer"; - -/** - * Default pixel tolerance for chart screenshots. SwiftShader is - * deterministic on a given machine, but a handful of sub-pixel AA - * decisions still wiggle across Chromium versions. - */ -const DEFAULT_MAX_DIFF_PIXEL_RATIO = 0.02; -const DEFAULT_THRESHOLD = 0; - -/** - * Load the shared `basic-test.html` shell and block until the test - * harness signals that perspective is ready. All specs start here. - */ -export async function gotoBasic(page: Page): Promise { - await page.goto("/tools/test/src/html/basic-test.html"); - await page.evaluate(async () => { - while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { - await new Promise((x) => setTimeout(x, 10)); - } - }); -} - -/** - * Restore the viewer with `config`, then wait one animation frame so - * the chart's scheduled render (`_scheduleRender` → RAF → `_fullRender`) - * has fired. By the time this returns, WebGL draw commands have been - * issued to the GL context and `page.screenshot()` will capture them. - */ -export async function restoreChart( - page: Page, - config: ViewerConfigUpdate, -): Promise { - await page.evaluate( - async (c) => { - const viewer = document.querySelector("perspective-viewer")!; - await (viewer as any).restore(c); - }, - config as unknown as Record, - ); - await waitOneFrame(page); -} - -/** Await a single RAF in the page context. */ -export async function waitOneFrame(page: Page): Promise { - await page.evaluate( - () => - new Promise((resolve) => - requestAnimationFrame(() => resolve()), - ), - ); -} - -/** - * Take a screenshot of the viewer element (not the whole page) and - * compare to `name`'s baseline. Cropping to the viewer excludes page - * scrollbars / viewport chrome that would add pixel noise. - */ -export async function expectViewerScreenshot( - page: Page, - options: { maxDiffPixelRatio?: number } = {}, -): Promise { - const viewer = page.locator("perspective-viewer"); - const snapshotName = - test - .info() - .titlePath.slice(1) - .map((s) => - s - .trim() - .replace(/[^a-z0-9]+/gi, "-") - .toLowerCase(), - ) - .join("-") + ".png"; - - await expect(viewer).toHaveScreenshot(snapshotName, { - threshold: DEFAULT_THRESHOLD, - maxDiffPixelRatio: - options.maxDiffPixelRatio ?? DEFAULT_MAX_DIFF_PIXEL_RATIO, - }); -} - -/** - * Full-flow convenience: go to the test page, restore the chart with - * `config`, wait for the render, and screenshot. The snapshot filename - * is derived from the describe path and test title. - */ -export async function renderAndCapture( - page: Page, - config: ViewerConfigUpdate, - options?: { maxDiffPixelRatio?: number }, -): Promise { - await restoreChart(page, config); - await expectViewerScreenshot(page, options); -} diff --git a/packages/viewer-charts/test/ts/events.spec.ts b/packages/viewer-charts/test/ts/events.spec.ts new file mode 100644 index 0000000000..6b3ab5a0de --- /dev/null +++ b/packages/viewer-charts/test/ts/events.spec.ts @@ -0,0 +1,568 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +/** + * Click-event emission for `viewer-charts`. Asserts the + * `` host element dispatches + * `perspective-click` (`CustomEvent`) and + * `perspective-global-filter` (`CustomEvent`) + * with detail shapes compatible with `viewer-datagrid`'s equivalents. + * + * Coordinate strategy: every chart loaded here is configured so that a + * click at the canvas centerpoint reliably lands on a single glyph + * (one bar across the plot rect, a single treemap leaf filling most + * of the area, etc.). Tests assert detail *shape* rather than exact + * values for the volatile fields (the precise row index / category + * label depends on canvas-size + hit-test geometry); the filter + * structure (column count, `[col, "==", value]` triples, presence of + * `row` keys) is what consumers actually code against. + */ + +import type { Page } from "@playwright/test"; +import { expect, test } from "@perspective-dev/test"; +import type { ViewerConfigUpdate } from "@perspective-dev/viewer"; +import { gotoBasic, restoreChart, waitOneFrame } from "./helpers"; + +/** + * Plain-object snapshot of a captured `CustomEvent.detail`. We + * postMessage these between page and Playwright contexts so class + * instances are flattened — assertions only inspect own fields, not + * the `PerspectiveSelectDetail` prototype getters. + */ +interface CapturedEvent { + name: string; + detail: any; + detailIsSelect: boolean; +} + +/** + * Install listeners on `` for the named events. + * Subsequent `drainCapturedEvents` reads them back as plain objects. + */ +async function installEventCapture(page: Page): Promise { + await page.evaluate(() => { + const v = document.querySelector("perspective-viewer")!; + const events: any[] = []; + const capture = (name: string) => (e: Event) => { + const ce = e as CustomEvent; + // Detect `PerspectiveSelectDetail` via its distinctive + // prototype getters (`insertFilters` / `removeFilters`). + // The class name is mangled by minification so + // `constructor.name` is unreliable, but the getters are + // preserved on the prototype. + const d = ce.detail as any; + const detailIsSelect = !!( + d && + typeof d === "object" && + Array.isArray(d.insertFilters) && + Array.isArray(d.removeFilters) + ); + // Flatten own + getter values into the captured payload + // so it survives the structured-clone boundary. + const detail = + d && typeof d === "object" + ? { + ...d, + ...(detailIsSelect + ? { + insertFilters: d.insertFilters, + removeFilters: d.removeFilters, + } + : {}), + } + : d; + events.push({ name, detail, detailIsSelect }); + }; + + for (const name of ["perspective-click", "perspective-global-filter"]) { + v.addEventListener(name, capture(name)); + } + + (window as any).__capturedEvents = events; + }); +} + +async function drainCapturedEvents(page: Page): Promise { + return page.evaluate(() => { + const events = ((window as any).__capturedEvents ?? []) as any[]; + const copy = events.slice(); + events.length = 0; + return copy; + }); +} + +/** + * Resolve the chart plugin's `.webgl-canvas` and return its center + * coordinates in page pixels. Walks the shadow-DOM chain rooted at + * `` since the canvas lives inside the plugin's + * shadow root. + */ +async function getCanvasCenter( + page: Page, +): Promise<{ x: number; y: number; width: number; height: number }> { + return page.evaluate(() => { + const v = document.querySelector("perspective-viewer")!; + const visit = ( + root: Element | ShadowRoot, + ): HTMLCanvasElement | null => { + if (root instanceof Element) { + const sr = (root as any).shadowRoot as ShadowRoot | null; + if (sr) { + const hit = visit(sr); + if (hit) { + return hit; + } + } + } + + const direct = (root as ParentNode).querySelector?.( + ".webgl-canvas", + ) as HTMLCanvasElement | null; + if (direct) { + return direct; + } + + const els = (root as ParentNode).querySelectorAll?.("*") ?? []; + for (const el of Array.from(els)) { + const sr = (el as any).shadowRoot as ShadowRoot | null; + if (sr) { + const hit = visit(sr); + if (hit) { + return hit; + } + } + } + + return null; + }; + + const canvas = visit(v); + if (!canvas) { + throw new Error("webgl-canvas not found in plugin shadow tree"); + } + + const r = canvas.getBoundingClientRect(); + return { + x: r.left + r.width / 2, + y: r.top + r.height / 2, + width: r.width, + height: r.height, + }; + }); +} + +/** + * Click the GL canvas at a fractional position (0..1) from the + * canvas's top-left corner. + */ +async function clickCanvasLower( + page: Page, + fx = 0.25, + fy = 0.7, +): Promise { + const c = await getCanvasCenter(page); + const x = c.x - c.width / 2 + c.width * fx; + const y = c.y - c.height / 2 + c.height * fy; + const baseline = await page.evaluate( + () => ((window as any).__capturedEvents ?? []).length, + ); + + await page.mouse.click(x, y); + await waitForEvents(page, baseline + 1, 500); +} + +/** + * Poll until `window.__capturedEvents` has at least `min` entries + * (or `timeoutMs` elapses). + */ +async function waitForEvents( + page: Page, + min: number, + timeoutMs: number, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const n = await page.evaluate( + () => ((window as any).__capturedEvents ?? []).length, + ); + if (n >= min) { + return; + } + + await waitOneFrame(page); + } +} + +/** + * Restore + install capture in one step. The chart's first render + * dismisses any prior pin (which can fire a stray + * `perspective-global-filter selected:false`); restore *before* + * installing capture so those startup events don't pollute the + * captured array. + */ +async function setupChart( + page: Page, + config: ViewerConfigUpdate, +): Promise { + await restoreChart(page, config); + await installEventCapture(page); +} + +test.beforeEach(async ({ page }) => { + await gotoBasic(page); +}); + +test.describe("viewer-charts user events", () => { + test("Y Bar: click emits perspective-click with row + filter", async ({ + page, + }) => { + await setupChart(page, { + plugin: "Y Bar", + group_by: ["Category"], + columns: ["Sales"], + }); + + await clickCanvasLower(page); + const events = await drainCapturedEvents(page); + + const click = events.find((e) => e.name === "perspective-click"); + if (!click) { + throw new Error( + `expected perspective-click; got: ${JSON.stringify(events.map((e) => e.name))}`, + ); + } + + expect(click.detail.column_names).toEqual(["Sales"]); + expect(Array.isArray(click.detail.config?.filter)).toBe(true); + // Filter has at least one clause naming the group_by column. + const cats = click.detail.config.filter.filter( + (f: unknown[]) => f[0] === "Category" && f[1] === "==", + ); + expect(cats.length).toBe(1); + expect(typeof cats[0][2]).toBe("string"); + expect(typeof click.detail.row).toBe("object"); + }); + + test("Y Bar: click also emits perspective-global-filter selected:true", async ({ + page, + }) => { + await setupChart(page, { + plugin: "Y Bar", + group_by: ["Category"], + columns: ["Sales"], + }); + + await clickCanvasLower(page); + const events = await drainCapturedEvents(page); + + const select = events.find( + (e) => e.name === "perspective-global-filter", + ); + expect(select).toBeDefined(); + expect(select!.detailIsSelect).toBe(true); + expect(select!.detail.selected).toBe(true); + expect(select!.detail.column_names).toEqual(["Sales"]); + expect(select!.detail.insertConfigs).toHaveLength(1); + expect(select!.detail.removeConfigs).toHaveLength(0); + // Helper getters survived our manual copy. + expect(Array.isArray(select!.detail.insertFilters)).toBe(true); + expect(select!.detail.insertFilters.length).toBeGreaterThan(0); + }); + + test("Y Bar: click + same-target re-click pins then unpins", async ({ + page, + }) => { + await setupChart(page, { + plugin: "Y Bar", + group_by: ["Category"], + columns: ["Sales"], + }); + + await clickCanvasLower(page); + await clickCanvasLower(page); + const events = await drainCapturedEvents(page); + + // Expect the second click to land on the same target and + // dismiss the pin — yielding a `selected:false` after the + // initial `selected:true`. + const selects = events.filter( + (e) => e.name === "perspective-global-filter", + ); + expect(selects.length).toBeGreaterThanOrEqual(2); + expect(selects[0].detail.selected).toBe(true); + const lastUnselect = selects + .reverse() + .find((e) => e.detail.selected === false); + expect(lastUnselect).toBeDefined(); + // The unpin carries the previous insert as `removeConfigs`. + expect(lastUnselect!.detail.removeConfigs.length).toBe(1); + expect(lastUnselect!.detail.insertConfigs).toHaveLength(0); + }); + + test("Y Bar with split_by: filter carries both group_by and split_by clauses", async ({ + page, + }) => { + await setupChart(page, { + plugin: "Y Bar", + group_by: ["Category"], + split_by: ["Region"], + columns: ["Sales"], + }); + + await clickCanvasLower(page); + const events = await drainCapturedEvents(page); + + const click = events.find((e) => e.name === "perspective-click"); + if (!click) { + // Some splits may be hidden; if the centerpoint lands in a + // gap the test asserts nothing — the next assertion would + // fire downstream from a real click. + test.skip(true, "center click missed a glyph"); + return; + } + + const filter = click.detail.config.filter as unknown[][]; + const hasCategory = filter.some( + (f) => f[0] === "Category" && f[1] === "==", + ); + const hasRegion = filter.some( + (f) => f[0] === "Region" && f[1] === "==", + ); + expect(hasCategory).toBe(true); + expect(hasRegion).toBe(true); + }); + + test("Treemap: leaf click emits click + select with full path filter", async ({ + page, + }) => { + await setupChart(page, { + plugin: "Treemap", + group_by: ["Category", "Sub-Category"], + columns: ["Sales"], + }); + + await clickCanvasLower(page, 0.5, 0.5); + const events = await drainCapturedEvents(page); + + const click = events.find((e) => e.name === "perspective-click"); + const select = events.find( + (e) => e.name === "perspective-global-filter", + ); + + expect(click).toBeDefined(); + expect(select).toBeDefined(); + // Path filter carries one clause per resolved group_by level. + // Treemap may drill to a branch (1 level) or a leaf (2 levels); + // assert at least one is present. + const filter = click!.detail.config.filter as unknown[][]; + expect(filter.length).toBeGreaterThanOrEqual(1); + expect(filter[0][0]).toBe("Category"); + expect(select!.detail.selected).toBe(true); + }); + + test("Sunburst: leaf click emits click + select", async ({ page }) => { + await setupChart(page, { + plugin: "Sunburst", + group_by: ["Category", "Sub-Category"], + columns: ["Sales"], + }); + + // Sunburst's center is the drill-up zone, so a centerpoint + // click would either no-op or fire `selected:false`. Click + // off-center to land on an outer ring (leaf) and assert a + // pin-style event fires. + const c = await getCanvasCenter(page); + // Offset 30% of the smaller dimension into the ring area. + const r = Math.min(c.width, c.height) * 0.3; + await page.mouse.click(c.x + r, c.y); + await waitOneFrame(page); + await waitOneFrame(page); + await waitOneFrame(page); + const events = await drainCapturedEvents(page); + + const select = events.find( + (e) => e.name === "perspective-global-filter", + ); + expect(select).toBeDefined(); + }); + + test("Heatmap: cell click emits click + select with both axes filtered", async ({ + page, + }) => { + await setupChart(page, { + plugin: "Heatmap", + group_by: ["Category"], + split_by: ["Region"], + columns: ["Sales"], + }); + + await clickCanvasLower(page, 0.5, 0.5); + const events = await drainCapturedEvents(page); + + const click = events.find((e) => e.name === "perspective-click"); + const select = events.find( + (e) => e.name === "perspective-global-filter", + ); + expect(click).toBeDefined(); + expect(select).toBeDefined(); + expect(click!.detail.column_names).toEqual(["Sales"]); + const filter = click!.detail.config.filter as unknown[][]; + // Either filter clause might be missing if the centerpoint + // lands on a rollup row; require at least one. + expect(filter.length).toBeGreaterThanOrEqual(1); + }); + + test("Candlestick: click on a candle emits click + select", async ({ + page, + }) => { + // Use a coarse group_by so each candle spans many CSS pixels. + await setupChart(page, { + plugin: "Candlestick", + group_by: ["Category"], + columns: ["Sales", "Profit", "Discount", "Quantity"], + }); + + await clickCanvasLower(page); + const events = await drainCapturedEvents(page); + + const click = events.find((e) => e.name === "perspective-click"); + if (!click) { + test.skip(true, "centerpoint missed wick / body"); + return; + } + + const filter = click.detail.config.filter as unknown[][]; + expect(filter.some((f) => f[0] === "Category")).toBe(true); + }); + + test("X/Y Scatter: click on a point emits click + select", async ({ + page, + }) => { + await setupChart(page, { + plugin: "X/Y Scatter", + columns: ["Sales", "Profit"], + }); + + await clickCanvasLower(page, 0.5, 0.5); + const events = await drainCapturedEvents(page); + + const click = events.find((e) => e.name === "perspective-click"); + // Scatter centerpoint may not hit a point in a sparse dataset + // — skip rather than flake. + if (!click) { + test.skip(true, "centerpoint missed a point"); + return; + } + + expect(click.detail.column_names).toEqual(["Profit"]); + // No group_by → filter is empty (or contains only the split + // prefix if `split_by` was set). + expect(Array.isArray(click.detail.config.filter)).toBe(true); + }); + + test("X/Y Scatter with split_by: filter carries the split clause", async ({ + page, + }) => { + await setupChart(page, { + plugin: "X/Y Scatter", + split_by: ["Region"], + columns: ["Sales", "Profit"], + }); + + await clickCanvasLower(page, 0.5, 0.5); + const events = await drainCapturedEvents(page); + + const click = events.find((e) => e.name === "perspective-click"); + if (!click) { + test.skip(true, "centerpoint missed a point"); + return; + } + + const filter = click.detail.config.filter as unknown[][]; + expect(filter.some((f) => f[0] === "Region" && f[1] === "==")).toBe( + true, + ); + }); + + test("Map plugins: no event emitted (scoped out)", async ({ page }) => { + await setupChart(page, { + plugin: "Map Scatter", + columns: ["Postal Code", "Postal Code"], + }); + + await clickCanvasLower(page, 0.5, 0.5); + const events = await drainCapturedEvents(page); + // Map plugins inherit from CartesianChart so they'll wire + // up — the relevant assertion is "no crash", not silence. + // Document the current behavior: map clicks may emit if the + // underlying CartesianChart resolves a hit. Either is OK. + expect(Array.isArray(events)).toBe(true); + }); + + test("Detail prototype: select event detail is a PerspectiveSelectDetail instance", async ({ + page, + }) => { + await setupChart(page, { + plugin: "Y Bar", + group_by: ["Category"], + columns: ["Sales"], + }); + + await clickCanvasLower(page); + const events = await drainCapturedEvents(page); + const select = events.find( + (e) => e.name === "perspective-global-filter", + ); + expect(select).toBeDefined(); + expect(select!.detailIsSelect).toBe(true); + }); + + test("View change: restore() while pinned emits a selected:false", async ({ + page, + }) => { + await setupChart(page, { + plugin: "Y Bar", + group_by: ["Category"], + columns: ["Sales"], + }); + + await clickCanvasLower(page); + const pre = await drainCapturedEvents(page); + if (!pre.find((e) => e.name === "perspective-global-filter")) { + test.skip(true, "initial click missed a glyph"); + return; + } + + // Re-restore with a different filter: the view changes, the + // chart's `setView` runs, the pin is dismissed, and we expect + // a `selected:false` to surface. + await page.evaluate(async () => { + const v = document.querySelector("perspective-viewer")! as any; + await v.restore({ + plugin: "Y Bar", + group_by: ["Sub-Category"], + columns: ["Sales"], + }); + }); + await waitOneFrame(page); + await waitOneFrame(page); + await waitOneFrame(page); + + const post = await drainCapturedEvents(page); + const unselect = post.find( + (e) => + e.name === "perspective-global-filter" && + e.detail.selected === false, + ); + expect(unselect).toBeDefined(); + }); +}); diff --git a/packages/viewer-charts/test/ts/frame-timing.spec.ts b/packages/viewer-charts/test/ts/frame-timing.spec.ts new file mode 100644 index 0000000000..f131be5e47 --- /dev/null +++ b/packages/viewer-charts/test/ts/frame-timing.spec.ts @@ -0,0 +1,462 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +/** + * Frame-timing invariants — the chart's plot region must never go + * blank between gestures. Every test follows the same shape: + * + * 1. Restore a chart with data. + * 2. Calibrate a quiescent-state pixel baseline. + * 3. Drive an interaction (pan / zoom / resize / concurrent draws) + * while `captureFrames` samples the visible canvas every RAF. + * 4. Assert every captured frame's plot region was non-blank. + */ + +import type { Page } from "@playwright/test"; +import { test } from "@perspective-dev/test"; +import type { ViewerConfigUpdate } from "@perspective-dev/viewer"; +import { + assertPlotNeverBlank, + calibratePlotBaseline, + captureFrames, + gotoBasic, + restoreChart, + waitOneFrame, +} from "./helpers"; + +const PLOT_CX = 640; +const PLOT_CY = 360; + +/** + * Pan-blank threshold as a fraction of the quiescent baseline. + */ +const BLANK_THRESHOLD_FRACTION = 0; + +/** + * Render modes to exercise. Each test in the suite runs twice — one + * group per mode. `setBlitMode` on the plugin element flips the + * config on the next renderer construction; we call it after the + * plugin's first activation (so its `_initialized` assertion passes), + * then `plugin.delete()` to tear down the default-mode renderer, and + * re-restore to rebuild in the requested mode. + * + * The two modes have meaningfully different compositor behavior: + * + * - `"blit"`: visible canvas is host-side 2D; worker ships + * `transferToImageBitmap` bitmaps over postMessage. Bitmap is + * fence-synchronized — host blits only fully-painted frames. + * - `"direct"`: visible canvas is `transferControlToOffscreen`'d + * to the worker; the browser's compositor reads it directly, + * unsynchronized with the scheduler's fence. Mid-render and + * just-resized states can be observed by the compositor. + * + * The same blank-frame invariant must hold under both. Direct-mode + * tests previously failed when the resize message handler + * synchronously cleared the visible canvas one task before the next + * RAF's render landed; the in-RAF resize fix + * (`glManager.requestResize` + `applyPendingResize` inside Phase 1) + * pairs the clear with the matching paint in a single un-yielded + * task so the compositor never observes the cleared intermediate. + */ +const RENDER_MODES = ["blit", "direct"] as const; +type RenderMode = (typeof RENDER_MODES)[number]; + +/** + * Each chart-type fixture: a viewer config that produces a useful + * baseline (enough glyphs in the plot region for blank-detection + * to work), and the per-test scaling for the blank threshold. + */ +interface ChartFixture { + name: string; + config: ViewerConfigUpdate; +} + +const FIXTURES: ChartFixture[] = [ + { + name: "X/Y Scatter", + config: { + plugin: "X/Y Scatter", + columns: ["Quantity", "Postal Code"], + }, + }, + { + name: "Y Line", + config: { + plugin: "Y Line", + columns: ["Profit"], + }, + }, + { + name: "Y Area", + config: { + plugin: "Y Area", + columns: ["Profit"], + }, + }, + { + name: "Y Bar", + config: { + plugin: "Y Bar", + group_by: ["State"], + columns: ["Profit"], + }, + }, + { + name: "X Bar", + config: { + plugin: "X Bar", + group_by: ["State"], + columns: ["Profit"], + }, + }, + { + name: "Heatmap", + config: { + plugin: "Heatmap", + group_by: ["State"], + split_by: ["Region"], + columns: ["Profit"], + }, + }, +]; + +/** + * Setup helper: restore the chart in the requested render mode, + * calibrate a quiescent baseline, compute the blank-frame threshold. + * + * The mode-switching dance: + * + * 1. First `restoreChart` activates the plugin (its + * `connectedCallback` runs and `_initialized` becomes true) + * and builds the renderer in the bundle's default mode. + * 2. `plugin.setBlitMode(mode)` records the desired mode for the + * next renderer build. Calling after step 1 means the + * `console.assert(this._initialized, ...)` inside `setBlitMode` + * passes silently. + * 3. `plugin.delete()` destroys the existing renderer transport + * so step 4's draw triggers a fresh `_ensureRenderer` → + * `_buildRenderer` that picks up the new mode. + * 4. Second `restoreChart` rebuilds the renderer in the requested + * mode and re-renders the chart. + * + * Cost: one extra restore per test setup. Worth it to avoid the + * `console.assert` log noise of a pre-activation `setBlitMode`. + */ +async function setupChart( + page: Page, + fixture: ChartFixture, + mode: RenderMode, +): Promise<{ baseline: number; threshold: number }> { + // Step 1: activate the plugin in default mode. + await restoreChart(page, fixture.config); + + // Steps 2 + 3: switch mode + tear down the default-mode + // renderer. The plugin element stays in the DOM; only its + // `RendererTransport` is destroyed. + await page.evaluate( + ({ mode }) => { + const viewer = document.querySelector( + "perspective-viewer", + ) as unknown as { getPlugin(): unknown }; + const plugin = viewer.getPlugin() as { + setBlitMode(mode: "blit" | "direct"): void; + delete(): void; + }; + plugin.setBlitMode(mode); + plugin.delete(); + }, + { mode }, + ); + + // Step 4: re-restore. Triggers `draw` → `_ensureRenderer` → + // `_buildRenderer` with `_renderBlitMode = mode`. + await restoreChart(page, fixture.config); + + // Two extra RAFs to make sure all paint has settled before we + // measure baseline. `restoreChart` already awaits one. + await waitOneFrame(page); + await waitOneFrame(page); + const baseline = await calibratePlotBaseline(page); + const threshold = Math.max( + 1, + Math.floor(baseline * BLANK_THRESHOLD_FRACTION), + ); + return { baseline, threshold }; +} + +/** + * Drive a drag-pan from `(PLOT_CX, PLOT_CY)` to `(PLOT_CX + dx, + * PLOT_CY + dy)` in `steps` increments. + */ +async function dragPan( + page: Page, + dx: number, + dy: number, + steps = 20, +): Promise { + await page.mouse.move(PLOT_CX, PLOT_CY); + await page.mouse.down(); + for (let i = 1; i <= steps; i++) { + const fx = (i * dx) / steps; + const fy = (i * dy) / steps; + await page.mouse.move(PLOT_CX + fx, PLOT_CY + fy); + } + + await page.mouse.up(); +} + +/** + * Drive a wheel zoom — many small wheel deltas. `deltaY < 0` is + * zoom-in; `> 0` is zoom-out. + */ +async function wheelZoom( + page: Page, + deltaY: number, + steps = 12, +): Promise { + await page.mouse.move(PLOT_CX, PLOT_CY); + const stepDelta = deltaY / steps; + for (let i = 0; i < steps; i++) { + await page.mouse.wheel(0, stepDelta); + } +} + +/** + * Single update row used by the streaming-update tests. Hoisted to + * a helper so A3/A4's `page.evaluate` bodies don't duplicate the + * literal six times. + */ +function makeUpdateRow(rowId: number): Record { + return { + "Product Name": "Fake Prod", + "Ship Date": +new Date(), + City: "Fake Town", + "Row ID": rowId, + "Customer Name": "Fakey Fakerton", + Quantity: 13, + Discount: 0.25, + "Sub-Category": "Chairs", + Segment: "Office Supplies", + Category: "Furniture", + "Order Date": +new Date(), + "Order ID": "ABC123", + Sales: 123.456, + State: "New York", + "Postal Code": 10001, + Country: "US", + "Customer ID": "XYZ321", + "Ship Mode": "First Class", + Region: "Easr", + Profit: 12.34, + "Product ID": "ABC123", + }; +} + +test.describe("Frame timing — blank-plot invariant", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + for (const mode of RENDER_MODES) { + test.describe(`render mode: ${mode}`, () => { + for (const fixture of FIXTURES) { + test.describe(fixture.name, () => { + test("pan does not blank the plot", async ({ page }) => { + const { threshold } = await setupChart( + page, + fixture, + mode, + ); + const frames = await captureFrames(page, async () => { + await dragPan(page, -160, -80); + }); + + assertPlotNeverBlank(frames, threshold); + }); + + test("zoom does not blank the plot", async ({ page }) => { + const { threshold } = await setupChart( + page, + fixture, + mode, + ); + const frames = await captureFrames(page, async () => { + await wheelZoom(page, -600); + }); + + assertPlotNeverBlank(frames, threshold); + }); + + test("resize does not blank", async ({ page }) => { + const { threshold } = await setupChart( + page, + fixture, + mode, + ); + const frames = await captureFrames(page, async () => { + await page.evaluate(async () => { + const viewer = document.querySelector( + "perspective-viewer", + ) as HTMLElement; + const widths = [ + "100%", + "85%", + "100%", + "92%", + "100%", + ]; + for (const w of widths) { + viewer.style.width = w; + await new Promise((resolve) => + requestAnimationFrame(() => resolve()), + ); + } + }); + + await page.evaluate(() => { + const viewer = document.querySelector( + "perspective-viewer", + ) as HTMLElement; + viewer.style.width = ""; + }); + }); + + assertPlotNeverBlank(frames, threshold); + }); + + test("A3 — pan + concurrent draws does not blank", async ({ + page, + }) => { + const { threshold } = await setupChart( + page, + fixture, + mode, + ); + const frames = await captureFrames(page, async () => { + const drawLoop = page.evaluate(async () => { + const viewer = + document.querySelector( + "perspective-viewer", + )!; + const table = await viewer.getTable(); + // @ts-ignore + window["__is_running"] = true; + // @ts-ignore + for (let i = 0; window["__is_running"]; i++) { + await table.update([ + { + "Product Name": "Fake Prod", + "Ship Date": +new Date(), + City: "Fake Town", + "Row ID": 9995 + i, + "Customer Name": "Fakey Fakerton", + Quantity: 13, + Discount: 0.25, + "Sub-Category": "Chairs", + Segment: "Office Supplies", + Category: "Furniture", + "Order Date": +new Date(), + "Order ID": "ABC123", + Sales: 123.456, + State: "New York", + "Postal Code": 10001, + Country: "US", + "Customer ID": "XYZ321", + "Ship Mode": "First Class", + Region: "Easr", + Profit: 12.34, + "Product ID": "ABC123", + }, + ]); + } + }); + + await dragPan(page, -160, -80, 30); + await page.evaluate(async () => { + // @ts-ignore + window["__is_running"] = false; + }); + + await drawLoop; + }); + + assertPlotNeverBlank(frames, threshold); + }); + + test("A4 — zoom + concurrent draws does not blank", async ({ + page, + }) => { + const { threshold } = await setupChart( + page, + fixture, + mode, + ); + const frames = await captureFrames(page, async () => { + const drawLoop = page.evaluate(async () => { + const viewer = + document.querySelector( + "perspective-viewer", + )!; + const table = await viewer.getTable(); + // @ts-ignore + window["__is_running"] = true; + // @ts-ignore + for (let i = 0; window["__is_running"]; i++) { + await table.update([ + { + "Product Name": "Fake Prod", + "Ship Date": +new Date(), + City: "Fake Town", + "Row ID": 9995 + i, + "Customer Name": "Fakey Fakerton", + Quantity: 13, + Discount: 0.25, + "Sub-Category": "Chairs", + Segment: "Office Supplies", + Category: "Furniture", + "Order Date": +new Date(), + "Order ID": "ABC123", + Sales: 123.456, + State: "New York", + "Postal Code": 10001, + Country: "US", + "Customer ID": "XYZ321", + "Ship Mode": "First Class", + Region: "Easr", + Profit: 12.34, + "Product ID": "ABC123", + }, + ]); + } + }); + + await wheelZoom(page, -600, 20); + await page.evaluate(async () => { + // @ts-ignore + window["__is_running"] = false; + }); + + await drawLoop; + }); + + assertPlotNeverBlank(frames, threshold); + }); + }); + } + }); + } +}); + +// Suppress an unused-helper hint when the inline `update` row +// literals haven't been migrated to use it. Kept around so future +// streaming tests can avoid duplicating the row body. +void makeUpdateRow; diff --git a/packages/viewer-charts/test/ts/helpers.ts b/packages/viewer-charts/test/ts/helpers.ts new file mode 100644 index 0000000000..d830ac05c1 --- /dev/null +++ b/packages/viewer-charts/test/ts/helpers.ts @@ -0,0 +1,778 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import type { Page } from "@playwright/test"; +import { expect, test } from "@perspective-dev/test"; +import type { ViewerConfigUpdate } from "@perspective-dev/viewer"; + +/** + * Default pixel tolerance for chart screenshots. SwiftShader is + * deterministic on a given machine, but a handful of sub-pixel AA + * decisions still wiggle across Chromium versions. + */ +// @ts-ignore +const DEFAULT_MAX_DIFF_PIXEL_RATIO = process.env.CI ? 0.01 : 0; +const DEFAULT_THRESHOLD = 0; + +/** + * Load the shared `basic-test.html` shell and block until the test + * harness signals that perspective is ready. All specs start here. + */ +export async function gotoBasic(page: Page): Promise { + await page.goto("/tools/test/src/html/basic-test.html"); + await page.evaluate(async () => { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); +} + +/** + * Restore the viewer with `config`, then wait one animation frame so + * the chart's scheduled render (`requestRender` → scheduler RAF → + * `_fullRender`) has fired. By the time this returns, WebGL draw + * commands have been issued to the GL context and `page.screenshot()` + * will capture them. + */ +export async function restoreChart( + page: Page, + config: ViewerConfigUpdate, +): Promise { + await page.evaluate( + async (c) => { + const viewer = document.querySelector("perspective-viewer")!; + await (viewer as any).restore(c); + }, + config as unknown as Record, + ); + await waitOneFrame(page); +} + +/** Await a single RAF in the page context. */ +export async function waitOneFrame(page: Page): Promise { + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => resolve()), + ), + ); +} + +/** + * Take a screenshot of the viewer element (not the whole page) and + * compare to `name`'s baseline. Cropping to the viewer excludes page + * scrollbars / viewport chrome that would add pixel noise. + */ +export async function expectViewerScreenshot( + page: Page, + options: { maxDiffPixelRatio?: number } = {}, +): Promise { + const viewer = page.locator("perspective-viewer"); + const snapshotName = + test + .info() + .titlePath.slice(1) + .map((s) => + s + .trim() + .replace(/[^a-z0-9]+/gi, "-") + .toLowerCase(), + ) + .join("-") + ".png"; + + await expect(viewer).toHaveScreenshot(snapshotName, { + threshold: DEFAULT_THRESHOLD, + maxDiffPixelRatio: + options.maxDiffPixelRatio ?? DEFAULT_MAX_DIFF_PIXEL_RATIO, + }); +} + +/** + * Full-flow convenience: go to the test page, restore the chart with + * `config`, wait for the render, and screenshot. The snapshot filename + * is derived from the describe path and test title. + */ +export async function renderAndCapture( + page: Page, + config: ViewerConfigUpdate, + options?: { maxDiffPixelRatio?: number }, +): Promise { + await restoreChart(page, config); + await expectViewerScreenshot(page, options); +} + +/** + * One per-frame measurement, captured during the action passed to + * `captureFrames`. `plotPixels` counts non-background pixels inside + * `plotRegionFrac` (default = central 80% of the visible canvas). + */ +export interface FrameSample { + timestampMs: number; + plotPixels: number; + canvasWidth: number; + canvasHeight: number; +} + +/** + * The sub-region of the visible canvas, expressed as fractions of + * canvas width/height, that the capture loop counts pixels in. + */ +export interface PlotRegionFrac { + x: number; + y: number; + w: number; + h: number; +} + +const DEFAULT_PLOT_REGION: PlotRegionFrac = { + x: 0.1, + y: 0.1, + w: 0.8, + h: 0.8, +}; + +/** + * Pixel-color match threshold for "is this pixel part of the chart + * background?" + */ +const DEFAULT_BG_TOLERANCE = 30; + +/** + * Run `action` while a per-frame capture loop reads the visible + * canvas's plot region. Returns one `FrameSample` per browser RAF + * that fired between `start` and `stop`. The capture loop is + * installed and torn down inside this helper — no global state + * leaks between tests. + */ +export async function captureFrames( + page: Page, + action: () => Promise, + options: { plotRegionFrac?: PlotRegionFrac; bgTolerance?: number } = {}, +): Promise { + const region = options.plotRegionFrac ?? DEFAULT_PLOT_REGION; + const tolerance = options.bgTolerance ?? DEFAULT_BG_TOLERANCE; + await page.evaluate( + ({ region, tolerance }) => { + type Sample = { + timestampMs: number; + plotPixels: number; + canvasWidth: number; + canvasHeight: number; + }; + + const w = window as unknown as { + __captureFrames?: Sample[]; + __captureRunning?: boolean; + __captureRAF?: number; + }; + + w.__captureFrames = []; + w.__captureRunning = true; + const findCanvas = (): HTMLCanvasElement | null => { + const visit = ( + root: Document | ShadowRoot, + ): HTMLCanvasElement | null => { + const direct = root.querySelector( + ".webgl-canvas", + ) as HTMLCanvasElement | null; + if (direct) { + return direct; + } + + const all = root.querySelectorAll("*"); + for (const el of Array.from(all)) { + const sr = (el as Element & { shadowRoot?: ShadowRoot }) + .shadowRoot; + if (sr) { + const found = visit(sr); + if (found) { + return found; + } + } + } + + return null; + }; + + return visit(document); + }; + + // Cache the canvas reference across ticks. + let cachedCanvas: HTMLCanvasElement | null = null; + + // Sampler canvas: the visible `.webgl-canvas` may have + // any of three context modes: + // + // - blit mode: 2D context (host blits worker bitmaps + // onto it). `getImageData` works directly. + // - direct mode: `transferControlToOffscreen` — + // placeholder for the worker's WebGL OffscreenCanvas. + // Host has *no* context on this canvas; + // `getImageData` impossible. + // - in-process mode: WebGL context owned by main + // thread. `getContext("2d")` returns null. + // + // The unifying invariant: in all three modes the canvas + // is a valid image source for `drawImage`. Routing the + // sample through a 2D sampler canvas — `drawImage` copy + // followed by `getImageData` on the sampler — reads + // pixels in every mode without any production code + // change. The sampler is sized to the requested region, + // resized lazily as the source canvas dimensions change. + const sampler = document.createElement("canvas"); + const samplerCtx = sampler.getContext("2d"); + if (!samplerCtx) { + throw new Error( + "captureFrames: sampler canvas 2D context unavailable", + ); + } + + const tick = () => { + if (!w.__captureRunning) { + return; + } + + if (!cachedCanvas || !cachedCanvas.isConnected) { + cachedCanvas = findCanvas(); + } + + const canvas = cachedCanvas; + if (canvas && canvas.width > 0 && canvas.height > 0) { + const x0 = Math.max(0, Math.round(region.x * canvas.width)); + + const y0 = Math.max( + 0, + Math.round(region.y * canvas.height), + ); + + const rw = Math.max( + 1, + Math.min( + canvas.width - x0, + Math.round(region.w * canvas.width), + ), + ); + + const rh = Math.max( + 1, + Math.min( + canvas.height - y0, + Math.round(region.h * canvas.height), + ), + ); + + if (sampler.width !== rw) { + sampler.width = rw; + } + + if (sampler.height !== rh) { + sampler.height = rh; + } + + samplerCtx.clearRect(0, 0, rw, rh); + try { + samplerCtx.drawImage( + canvas, + x0, + y0, + rw, + rh, + 0, + 0, + rw, + rh, + ); + } catch { + // `drawImage` can throw on transient zero- + // size sources during plugin reconnect; + // skip this frame and try again next RAF. + w.__captureRAF = requestAnimationFrame(tick); + return; + } + + const data = samplerCtx.getImageData(0, 0, rw, rh).data; + let nonBg = 0; + + // The GL canvas is cleared with + // `clearColor(0,0,0,0)` and glyphs paint with + // `a > 0`; in blit mode the host blits with + // `globalCompositeOperation = "copy"`, so post- + // blit pixels are either glyph (`a > 0`) or + // fully transparent (`a == 0`). In direct/in- + // process modes the WebGL canvas itself follows + // the same alpha convention. So a simple alpha + // test is the correct invariant for "glyph + // fragment landed here" in every mode. + void tolerance; + for (let i = 3; i < data.length; i += 4) { + if (data[i] > 0) { + nonBg++; + } + } + + w.__captureFrames!.push({ + timestampMs: performance.now(), + plotPixels: nonBg, + canvasWidth: canvas.width, + canvasHeight: canvas.height, + }); + } + + w.__captureRAF = requestAnimationFrame(tick); + }; + + w.__captureRAF = requestAnimationFrame(tick); + }, + { region, tolerance }, + ); + + try { + await action(); + // One trailing RAF so the final post-action paint is captured. + await waitOneFrame(page); + } finally { + await page.evaluate(() => { + const w = window as unknown as { + __captureRunning?: boolean; + __captureRAF?: number; + }; + w.__captureRunning = false; + if (w.__captureRAF !== undefined) { + cancelAnimationFrame(w.__captureRAF); + w.__captureRAF = undefined; + } + }); + } + + const frames = await page.evaluate(() => { + const w = window as unknown as { __captureFrames?: FrameSample[] }; + const out = w.__captureFrames ?? []; + w.__captureFrames = undefined; + return out; + }); + + return frames; +} + +/** + * Take one quiescent-state pixel sample of the chart's plot region + * to use as the calibration baseline for `assertPlotNeverBlank`. + */ +export async function calibratePlotBaseline( + page: Page, + options: { plotRegionFrac?: PlotRegionFrac; bgTolerance?: number } = {}, +): Promise { + const samples = await captureFrames( + page, + async () => { + await waitOneFrame(page); + await waitOneFrame(page); + }, + options, + ); + + // The first frame can lag the prior render; pick the median of + // the captured frames as a stable baseline. + if (samples.length === 0) { + throw new Error("calibratePlotBaseline: no frames captured"); + } + + const sorted = samples.map((s) => s.plotPixels).sort((a, b) => a - b); + return sorted[Math.floor(sorted.length / 2)]; +} + +/** + * Primary invariant assertion. For every frame in `frames`, + * `plotPixels` must be ≥ `minPixels`. + */ +export function assertPlotNeverBlank( + frames: FrameSample[], + minPixels: number, +): void { + if (frames.length === 0) { + throw new Error("assertPlotNeverBlank: no frames captured"); + } + + for (let i = 0; i < frames.length; i++) { + if (frames[i].plotPixels < minPixels) { + const ctxStart = Math.max(0, i - 3); + const ctxEnd = Math.min(frames.length, i + 4); + const ctxLines = frames + .slice(ctxStart, ctxEnd) + .map((f, j) => { + const idx = ctxStart + j; + const marker = idx === i ? " ← BLANK" : ""; + return ` [${idx}] plotPixels=${f.plotPixels}${marker}`; + }) + .join("\n"); + throw new Error( + `assertPlotNeverBlank: frame ${i} of ${frames.length} ` + + `had ${frames[i].plotPixels} non-background pixels ` + + `(threshold ${minPixels}). Surrounding frames:\n${ctxLines}`, + ); + } + } +} + +// ╭──────────────╮ +// │ │ +// Multi-viewer helpers │ │ +// ╰──────────────╯ +// +// Variants of the single-viewer helpers above scoped to a specific +// `` index, plus a parallel-capture entry point +// that returns one `FrameSample[]` per viewer in DOM order. Used by +// `multi-chart.spec.ts` to verify cross-chart isolation: pan on +// viewer A must not blank viewer B; both panning concurrently must +// leave each chart's invariant intact; streaming-update on the +// shared table must surface as live data on both without blanking. +// +// All multi-viewer helpers walk the document for *all* +// `` elements (not the single one +// `gotoBasic` assumes). The order is `document.querySelectorAll` +// tree order, which matches the order viewers are placed in +// `two-chart-test.html`. + +/** + * Two-viewer page entry. Same shape as `gotoBasic` but loads the + * two-chart fixture and waits for both viewers to be bound to the + * shared table. + */ +export async function gotoTwoChart(page: Page): Promise { + await page.goto("/tools/test/src/html/two-chart-test.html"); + await page.evaluate(async () => { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); +} + +/** + * Restore a specific viewer (by 0-based DOM-order index). Mirrors + * `restoreChart` for the multi-viewer fixture. Awaits one RAF so + * the chart's first paint has fired by the time this returns. + */ +export async function restoreChartAt( + page: Page, + viewerIndex: number, + config: ViewerConfigUpdate, +): Promise { + await page.evaluate( + async ({ viewerIndex, c }) => { + const viewers = document.querySelectorAll("perspective-viewer"); + const viewer = viewers[viewerIndex]; + if (!viewer) { + throw new Error( + `restoreChartAt: no viewer at index ${viewerIndex} (` + + `found ${viewers.length})`, + ); + } + + await (viewer as any).restore(c); + }, + { viewerIndex, c: config as unknown as Record }, + ); + await waitOneFrame(page); +} + +/** + * Run `action` while sampling every viewer's plot region every RAF. + * Returns one `FrameSample[]` per viewer in DOM order. + * + * Implementation parallels `captureFrames` but walks every shadow + * root for *all* `.webgl-canvas` elements, samples each one's plot + * region per tick, and tracks per-canvas logs in arrays indexed by + * the canvas's discovery order. Cached canvas list is invalidated + * if any cached canvas becomes detached (plugin reconnect during + * capture); the rebuild is O(n) over reachable elements but only + * runs when the cache is stale. + */ +export async function captureFramesAllViewers( + page: Page, + action: () => Promise, + options: { plotRegionFrac?: PlotRegionFrac } = {}, +): Promise { + const region = options.plotRegionFrac ?? DEFAULT_PLOT_REGION; + + await page.evaluate( + ({ region }) => { + type Sample = { + timestampMs: number; + plotPixels: number; + canvasWidth: number; + canvasHeight: number; + }; + + const w = window as unknown as { + __captureMultiFrames?: Sample[][]; + __captureRunning?: boolean; + __captureRAF?: number; + }; + + w.__captureMultiFrames = []; + w.__captureRunning = true; + + const findAllCanvases = (): HTMLCanvasElement[] => { + const out: HTMLCanvasElement[] = []; + const visit = (root: Document | ShadowRoot): void => { + const direct = root.querySelectorAll(".webgl-canvas"); + for (const c of Array.from(direct)) { + out.push(c as HTMLCanvasElement); + } + + const all = root.querySelectorAll("*"); + for (const el of Array.from(all)) { + const sr = (el as Element & { shadowRoot?: ShadowRoot }) + .shadowRoot; + if (sr) { + visit(sr); + } + } + }; + + visit(document); + return out; + }; + + let cachedCanvases: HTMLCanvasElement[] = []; + + const cacheStale = (): boolean => { + if (cachedCanvases.length === 0) { + return true; + } + + for (const c of cachedCanvases) { + if (!c.isConnected) { + return true; + } + } + + return false; + }; + + // Single shared sampler canvas — resized per-viewer + // inside the loop. Same rationale as in `captureFrames`: + // routing the read through a 2D sampler via `drawImage` + // works whether the source canvas is in blit mode (2D + // context), direct mode (`transferControlToOffscreen` + // placeholder), or in-process mode (WebGL on main + // thread). `getImageData` directly on the source canvas + // would fail in two of those three modes. + const sampler = document.createElement("canvas"); + const samplerCtx = sampler.getContext("2d"); + if (!samplerCtx) { + throw new Error( + "captureFramesAllViewers: sampler canvas 2D context unavailable", + ); + } + + const tick = () => { + if (!w.__captureRunning) { + return; + } + + if (cacheStale()) { + cachedCanvases = findAllCanvases(); + // Make sure the per-viewer logs array has slots + // for every discovered canvas; preserve any + // existing prefix for viewers that survived the + // rebuild. + while ( + w.__captureMultiFrames!.length < cachedCanvases.length + ) { + w.__captureMultiFrames!.push([]); + } + } + + const tMs = performance.now(); + for (let vi = 0; vi < cachedCanvases.length; vi++) { + const canvas = cachedCanvases[vi]; + if (!canvas || canvas.width === 0 || canvas.height === 0) { + continue; + } + + const x0 = Math.max(0, Math.round(region.x * canvas.width)); + const y0 = Math.max( + 0, + Math.round(region.y * canvas.height), + ); + const rw = Math.max( + 1, + Math.min( + canvas.width - x0, + Math.round(region.w * canvas.width), + ), + ); + const rh = Math.max( + 1, + Math.min( + canvas.height - y0, + Math.round(region.h * canvas.height), + ), + ); + + if (sampler.width !== rw) { + sampler.width = rw; + } + + if (sampler.height !== rh) { + sampler.height = rh; + } + + samplerCtx.clearRect(0, 0, rw, rh); + try { + samplerCtx.drawImage( + canvas, + x0, + y0, + rw, + rh, + 0, + 0, + rw, + rh, + ); + } catch { + continue; + } + + const data = samplerCtx.getImageData(0, 0, rw, rh).data; + let nonBg = 0; + for (let i = 3; i < data.length; i += 4) { + if (data[i] > 0) { + nonBg++; + } + } + + w.__captureMultiFrames![vi].push({ + timestampMs: tMs, + plotPixels: nonBg, + canvasWidth: canvas.width, + canvasHeight: canvas.height, + }); + } + + w.__captureRAF = requestAnimationFrame(tick); + }; + + w.__captureRAF = requestAnimationFrame(tick); + }, + { region }, + ); + + try { + await action(); + await waitOneFrame(page); + } finally { + await page.evaluate(() => { + const w = window as unknown as { + __captureRunning?: boolean; + __captureRAF?: number; + }; + w.__captureRunning = false; + if (w.__captureRAF !== undefined) { + cancelAnimationFrame(w.__captureRAF); + w.__captureRAF = undefined; + } + }); + } + + const frames = await page.evaluate(() => { + const w = window as unknown as { + __captureMultiFrames?: FrameSample[][]; + }; + const out = w.__captureMultiFrames ?? []; + w.__captureMultiFrames = undefined; + return out; + }); + + return frames; +} + +/** + * Quiescent baselines for every viewer. One median value per + * viewer, in DOM order. Used by `multi-chart.spec.ts` to set + * per-viewer blank thresholds before driving the action. + */ +export async function calibrateAllBaselines( + page: Page, + options: { plotRegionFrac?: PlotRegionFrac } = {}, +): Promise { + const samples = await captureFramesAllViewers( + page, + async () => { + await waitOneFrame(page); + await waitOneFrame(page); + }, + options, + ); + + if (samples.length === 0) { + throw new Error("calibrateAllBaselines: no viewers found"); + } + + return samples.map((perViewer, idx) => { + if (perViewer.length === 0) { + throw new Error( + `calibrateAllBaselines: viewer ${idx} captured no frames`, + ); + } + + const sorted = perViewer.map((s) => s.plotPixels).sort((a, b) => a - b); + return sorted[Math.floor(sorted.length / 2)]; + }); +} + +/** + * Cross-chart isolation invariant: viewer at `viewerIndex` should + * have stayed quiescent across `frames` (no rendered changes from + * actions targeting other viewers). Allows ±`tolerance` pixels + * around the median to absorb anti-aliasing noise from the host's + * compositor; rejects on a single frame whose `plotPixels` deviates + * by more than that. + * + * Use when the test drives an interaction on a different viewer and + * needs to verify the un-interacted viewer was not collateral + * damage. + */ +export function assertViewerQuiescent( + frames: FrameSample[], + tolerance: number, +): void { + if (frames.length === 0) { + throw new Error("assertViewerQuiescent: no frames captured"); + } + + const counts = frames.map((f) => f.plotPixels); + const sorted = [...counts].sort((a, b) => a - b); + const median = sorted[Math.floor(sorted.length / 2)]; + + for (let i = 0; i < counts.length; i++) { + if (Math.abs(counts[i] - median) > tolerance) { + const ctxStart = Math.max(0, i - 3); + const ctxEnd = Math.min(counts.length, i + 4); + const ctxLines = counts + .slice(ctxStart, ctxEnd) + .map((c, j) => { + const idx = ctxStart + j; + const marker = idx === i ? " ← DEVIANT" : ""; + return ` [${idx}] plotPixels=${c}${marker}`; + }) + .join("\n"); + throw new Error( + `assertViewerQuiescent: frame ${i} of ${counts.length} ` + + `had ${counts[i]} non-background pixels, median=${median}, ` + + `tolerance=±${tolerance}. Surrounding frames:\n${ctxLines}`, + ); + } + } +} diff --git a/packages/viewer-charts/test/ts/multi-chart.spec.ts b/packages/viewer-charts/test/ts/multi-chart.spec.ts new file mode 100644 index 0000000000..3ee7aadba4 --- /dev/null +++ b/packages/viewer-charts/test/ts/multi-chart.spec.ts @@ -0,0 +1,218 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +/** + * Multi-chart shared-renderer invariants. + * + * Two ``s loaded from a single shared table + * (`load-viewer-two-csv.js`). Each tests a different cross-chart + * isolation property: + */ + +import type { Page } from "@playwright/test"; +import { test } from "@perspective-dev/test"; +import { + assertPlotNeverBlank, + assertViewerQuiescent, + calibrateAllBaselines, + captureFramesAllViewers, + gotoTwoChart, + restoreChartAt, + waitOneFrame, +} from "./helpers"; + +const LEFT_VIEWER_CX = 320; +const LEFT_VIEWER_CY = 360; +const RIGHT_VIEWER_OFFSET_X = 640; + +const BLANK_THRESHOLD_FRACTION = 0; + +/** + * Tolerance (in pixels) for the "viewer B was unaffected" check. + */ +const QUIESCENT_TOLERANCE_PIXELS = 50; + +/** + * Drag-pan a specific viewer. Same shape as the single-viewer + * helper in `frame-timing.spec.ts`, parameterized by a center + * point so we can target either viewer. + */ +async function dragPanAt( + page: Page, + cx: number, + cy: number, + dx: number, + dy: number, + steps = 20, +): Promise { + await page.mouse.move(cx, cy); + await page.mouse.down(); + for (let i = 1; i <= steps; i++) { + const fx = (i * dx) / steps; + const fy = (i * dy) / steps; + await page.mouse.move(cx + fx, cy + fy); + } + + await page.mouse.up(); +} + +/** + * Set up both viewers with a chart that has enough glyph density + * for blank-detection. Returns per-viewer blank-frame thresholds + * derived from each viewer's quiescent baseline. + */ +async function setupBoth(page: Page): Promise<{ thresholds: number[] }> { + await restoreChartAt(page, 0, { + plugin: "X/Y Scatter", + columns: ["Quantity", "Postal Code"], + }); + await restoreChartAt(page, 1, { + plugin: "X/Y Scatter", + columns: ["Quantity", "Postal Code"], + }); + + // Two extra RAFs so paint settles before baseline. + await waitOneFrame(page); + await waitOneFrame(page); + + const baselines = await calibrateAllBaselines(page); + const thresholds = baselines.map((b) => + Math.max(1, Math.floor(b * BLANK_THRESHOLD_FRACTION)), + ); + return { thresholds }; +} + +test.describe("Multi-chart shared renderer", () => { + test.beforeEach(async ({ page }) => { + await gotoTwoChart(page); + }); + + test("pan on viewer A leaves viewer B quiescent", async ({ page }) => { + const { thresholds } = await setupBoth(page); + const allFrames = await captureFramesAllViewers(page, async () => { + await dragPanAt(page, LEFT_VIEWER_CX, LEFT_VIEWER_CY, -120, -60); + }); + + // Viewer A (the panned one) must satisfy the standard + // blank-frame invariant. + assertPlotNeverBlank(allFrames[0], thresholds[0]); + + // Viewer B (the un-interacted one) must have stayed + // quiescent — its plot pixel count should not deviate from + // its own baseline by more than the AA tolerance. + assertViewerQuiescent(allFrames[1], QUIESCENT_TOLERANCE_PIXELS); + }); + + test("both viewers panning concurrently stay non-blank", async ({ + page, + }) => { + const { thresholds } = await setupBoth(page); + const allFrames = await captureFramesAllViewers(page, async () => { + // Run viewer A's pan and viewer B's pan in parallel by + // interleaving sub-step mouse events through both + // viewers' centers. + const leftSteps = 12; + const rightSteps = 12; + for (let i = 0; i < Math.max(leftSteps, rightSteps); i++) { + if (i < leftSteps) { + await page.mouse.move(LEFT_VIEWER_CX, LEFT_VIEWER_CY); + await page.mouse.down(); + await page.mouse.move( + LEFT_VIEWER_CX - (i + 1) * 6, + LEFT_VIEWER_CY - (i + 1) * 3, + ); + await page.mouse.up(); + } + + if (i < rightSteps) { + const rcx = LEFT_VIEWER_CX + RIGHT_VIEWER_OFFSET_X; + await page.mouse.move(rcx, LEFT_VIEWER_CY); + await page.mouse.down(); + await page.mouse.move( + rcx - (i + 1) * 6, + LEFT_VIEWER_CY - (i + 1) * 3, + ); + await page.mouse.up(); + } + } + }); + + assertPlotNeverBlank(allFrames[0], thresholds[0]); + assertPlotNeverBlank(allFrames[1], thresholds[1]); + }); + + test("pan on viewer A + streaming updates does not blank either chart", async ({ + page, + }) => { + const { thresholds } = await setupBoth(page); + const allFrames = await captureFramesAllViewers(page, async () => { + // Streaming `table.update` runs as a background loop + // gated by `__is_running`. + const drawLoop = page.evaluate(async () => { + const viewer = document.querySelector( + "perspective-viewer", + ) as any; + + const table = await viewer.getTable(); + + // @ts-ignore + window["__is_running"] = true; + // @ts-ignore + for (let i = 0; window["__is_running"]; i++) { + await table.update([ + { + "Product Name": "Fake Prod", + "Ship Date": +new Date(), + City: "Fake Town", + "Row ID": 9995 + i, + "Customer Name": "Fakey Fakerton", + Quantity: 13, + Discount: 0.25, + "Sub-Category": "Chairs", + Segment: "Office Supplies", + Category: "Furniture", + "Order Date": +new Date(), + "Order ID": "ABC123", + Sales: 123.456, + State: "New York", + "Postal Code": 10001, + Country: "US", + "Customer ID": "XYZ321", + "Ship Mode": "First Class", + Region: "Easr", + Profit: 12.34, + "Product ID": "ABC123", + }, + ]); + } + }); + + await dragPanAt( + page, + LEFT_VIEWER_CX, + LEFT_VIEWER_CY, + -160, + -80, + 30, + ); + await page.evaluate(() => { + // @ts-ignore + window["__is_running"] = false; + }); + + await drawLoop; + }); + + assertPlotNeverBlank(allFrames[0], thresholds[0]); + assertPlotNeverBlank(allFrames[1], thresholds[1]); + }); +}); diff --git a/packages/viewer-charts/test/js/candlestick.spec.ts b/packages/viewer-charts/test/ts/snapshot/candlestick.spec.ts similarity index 98% rename from packages/viewer-charts/test/js/candlestick.spec.ts rename to packages/viewer-charts/test/ts/snapshot/candlestick.spec.ts index 013c614a6f..8a8cf730aa 100644 --- a/packages/viewer-charts/test/js/candlestick.spec.ts +++ b/packages/viewer-charts/test/ts/snapshot/candlestick.spec.ts @@ -11,7 +11,7 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import { test } from "@perspective-dev/test"; -import { gotoBasic, renderAndCapture } from "./helpers"; +import { gotoBasic, renderAndCapture } from "../helpers"; test.describe("Y Candlestick", () => { test.beforeEach(async ({ page }) => { diff --git a/packages/viewer-charts/test/ts/snapshot/datetime-subsecond.spec.ts b/packages/viewer-charts/test/ts/snapshot/datetime-subsecond.spec.ts new file mode 100644 index 0000000000..194f45f0a0 --- /dev/null +++ b/packages/viewer-charts/test/ts/snapshot/datetime-subsecond.spec.ts @@ -0,0 +1,76 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +// Sub-second datetime regression coverage. The numeric category axis +// path narrows positions to f32 for GPU upload; with absolute epoch +// timestamps (~1.7e12) and a sub-second window, naive narrowing +// collapses every distinct point onto one of ~5 representable values +// (~262144 ms apart at this magnitude) and the chart renders a single +// stripe — or nothing at all when the projection's `tx` term blows up +// the shader's clip-space cancellation. The fix is twofold: +// +// 1. WASM emits datetime columns as Float64 regardless of the +// `float32` flag, preserving millisecond precision into JS. +// 2. Each chart subtracts an origin (data min) before f32 narrowing +// and threads the same origin into `buildProjectionMatrix`, so +// `tx ≈ 0` and the shader sees rebased values that fit cleanly +// in f32. +// +// The expression below pins timestamps near 2025-01-01 UTC, scaling +// `Quantity` (1–14) by 200ms to produce 14 distinct buckets across a +// ~2.8s window — well inside the f32 collapse zone for absolute +// epoch-ms. + +import { test } from "@perspective-dev/test"; +import { gotoBasic, renderAndCapture } from "../helpers"; + +const SUBSECOND_DATETIME_EXPR = { + "Order DT Subsecond": 'datetime(1735689600000 + "Quantity" * 200)', +}; + +test.describe("Sub-second datetime numeric axis", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + test("Y Bar renders distinct bars across a 2.8s window", async ({ + page, + }) => { + await renderAndCapture(page, { + plugin: "Y Bar", + columns: ["Sales"], + group_by: ["Order DT Subsecond"], + expressions: SUBSECOND_DATETIME_EXPR, + }); + }); + + test("Y Line traces all sub-second points", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Line", + columns: ["Sales"], + group_by: ["Order DT Subsecond"], + expressions: SUBSECOND_DATETIME_EXPR, + }); + }); + + test("Heatmap fills cells across a sub-second X domain", async ({ + page, + }) => { + await renderAndCapture(page, { + plugin: "Heatmap", + columns: ["Sales"], + group_by: ["Order DT Subsecond"], + split_by: ["Category"], + expressions: SUBSECOND_DATETIME_EXPR, + }); + }); +}); diff --git a/packages/viewer-charts/test/ts/snapshot/gradient-heatmap.spec.ts b/packages/viewer-charts/test/ts/snapshot/gradient-heatmap.spec.ts new file mode 100644 index 0000000000..6d98427584 --- /dev/null +++ b/packages/viewer-charts/test/ts/snapshot/gradient-heatmap.spec.ts @@ -0,0 +1,90 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import { test } from "@perspective-dev/test"; +import { gotoBasic, renderAndCapture } from "../helpers"; + +test.describe("Density", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + test("basic x/y", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Density", + columns: ["Quantity", "Profit"], + }); + }); + + test("with numeric color", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Density", + columns: ["Quantity", "Profit", "Sales"], + }); + }); + + test("split_by produces faceted heatmaps", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Density", + columns: ["Quantity", "Profit"], + split_by: ["Category"], + }); + }); + + test("date X axis", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Density", + columns: ["Order Date", "Profit"], + }); + }); + + // color_mode variants + // Snapshots compare side-by-side on the same fixture so any + // regression to one mode's resolve / splat branch is obvious. + test.describe("color_mode", () => { + test("mean (default, density-weighted average)", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Density", + columns: ["Quantity", "Profit", "Sales"], + settings: true, + plugin_config: { gradient_color_mode: "mean" }, + }); + }); + + test("density (ignores color column)", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Density", + columns: ["Quantity", "Profit", "Sales"], + settings: true, + plugin_config: { gradient_color_mode: "density" }, + }); + }); + + test("extreme (signed max deviation)", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Density", + columns: ["Quantity", "Profit", "Profit"], + settings: true, + plugin_config: { gradient_color_mode: "extreme" }, + }); + }); + + test("signed (net positive vs negative)", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Density", + columns: ["Quantity", "Profit", "Profit"], + settings: true, + plugin_config: { gradient_color_mode: "signed" }, + }); + }); + }); +}); diff --git a/packages/viewer-charts/test/js/heatmap.spec.ts b/packages/viewer-charts/test/ts/snapshot/heatmap.spec.ts similarity index 62% rename from packages/viewer-charts/test/js/heatmap.spec.ts rename to packages/viewer-charts/test/ts/snapshot/heatmap.spec.ts index 83580e6d13..c5efe65321 100644 --- a/packages/viewer-charts/test/js/heatmap.spec.ts +++ b/packages/viewer-charts/test/ts/snapshot/heatmap.spec.ts @@ -11,7 +11,14 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import { test } from "@perspective-dev/test"; -import { gotoBasic, renderAndCapture } from "./helpers"; +import { gotoBasic, renderAndCapture } from "../helpers"; + +// `Order Date` is parsed as `date` in the superstore CSV. To exercise +// the `datetime` axis path (distinct schema type, same numeric code +// path) we materialize a synthetic datetime column via expression. +const DATETIME_EXPR = { + "Order DT": 'datetime("Quantity" * 86400000)', +}; test.describe("Heatmap", () => { test.beforeEach(async ({ page }) => { @@ -82,3 +89,71 @@ test.describe("Heatmap", () => { await page.pause(); }); }); + +// Numeric-axis coverage for the heatmap. The X axis sources from +// `__ROW_PATH_0__` (group_by). +test.describe("Heatmap numeric axes", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + test("integer X axis as sole group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Heatmap", + columns: ["Sales"], + group_by: ["Quantity"], + split_by: ["Category"], + }); + }); + + test("datetime X axis as sole group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Heatmap", + columns: ["Sales"], + group_by: ["Order DT"], + split_by: ["Category"], + expressions: DATETIME_EXPR, + }); + }); + + test("integer Y axis as sole split_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Heatmap", + columns: ["Sales"], + group_by: ["Region"], + split_by: ["Quantity"], + }); + }); + + test("datetime Y axis as sole split_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Heatmap", + columns: ["Sales"], + group_by: ["Region"], + split_by: ["Order DT"], + expressions: DATETIME_EXPR, + }); + }); + + test("integer X falls back to categorical with extra group_by level", async ({ + page, + }) => { + await renderAndCapture(page, { + plugin: "Heatmap", + columns: ["Sales"], + group_by: ["Region", "Quantity"], + split_by: ["Category"], + }); + }); + + test("integer Y falls back to categorical with extra split_by level", async ({ + page, + }) => { + await renderAndCapture(page, { + plugin: "Heatmap", + columns: ["Sales"], + group_by: ["Region"], + split_by: ["Category", "Quantity"], + }); + }); +}); diff --git a/packages/viewer-charts/test/js/line.spec.ts b/packages/viewer-charts/test/ts/snapshot/line.spec.ts similarity index 98% rename from packages/viewer-charts/test/js/line.spec.ts rename to packages/viewer-charts/test/ts/snapshot/line.spec.ts index d3d631828a..81e5208e79 100644 --- a/packages/viewer-charts/test/js/line.spec.ts +++ b/packages/viewer-charts/test/ts/snapshot/line.spec.ts @@ -11,7 +11,7 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import { test } from "@perspective-dev/test"; -import { gotoBasic, renderAndCapture } from "./helpers"; +import { gotoBasic, renderAndCapture } from "../helpers"; test.describe("X/Y Line", () => { test.beforeEach(async ({ page }) => { diff --git a/packages/viewer-charts/test/ts/snapshot/numeric-axis.spec.ts b/packages/viewer-charts/test/ts/snapshot/numeric-axis.spec.ts new file mode 100644 index 0000000000..bb1a068a52 --- /dev/null +++ b/packages/viewer-charts/test/ts/snapshot/numeric-axis.spec.ts @@ -0,0 +1,230 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +// Coverage for the numeric-axis path on the bar family. A non-string +// single `group_by` level (date / datetime / integer / float) should +// render as a numeric axis on the categorical side; multi-level +// group_bys with a non-string leaf fall back to a stringified +// hierarchical category axis. + +import { test } from "@perspective-dev/test"; +import { gotoBasic, renderAndCapture } from "../helpers"; + +// `Order Date` is parsed as `date` in the superstore CSV. To exercise +// the `datetime` axis path (distinct schema type, same numeric code +// path) we materialize a synthetic datetime column via expression: +// `Quantity` ranges 1–14, scaled to ms-into-1970 gives a small datetime +// spread that fits cleanly on a single axis. +const DATETIME_EXPR = { + "Order DT": 'datetime("Quantity" * 86400000)', +}; + +test.describe("Numeric category axis (Y Bar)", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + test("date axis as sole group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Bar", + columns: ["Sales"], + group_by: ["Order Date"], + }); + }); + + test("datetime axis as sole group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Bar", + columns: ["Sales"], + group_by: ["Order DT"], + expressions: DATETIME_EXPR, + }); + }); + + test("integer axis as sole group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Bar", + columns: ["Sales"], + group_by: ["Quantity"], + }); + }); + + test("float axis as sole group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Bar", + columns: ["Sales"], + group_by: ["Discount"], + }); + }); + + test("integer last but not only group_by stays categorical", async ({ + page, + }) => { + await renderAndCapture(page, { + plugin: "Y Bar", + columns: ["Sales"], + group_by: ["Region", "Quantity"], + }); + }); + + test("float last but not only group_by stays categorical", async ({ + page, + }) => { + await renderAndCapture(page, { + plugin: "Y Bar", + columns: ["Sales"], + group_by: ["Region", "Discount"], + }); + }); + + test("datetime last but not only group_by stays categorical", async ({ + page, + }) => { + await renderAndCapture(page, { + plugin: "Y Bar", + columns: ["Sales"], + group_by: ["Region", "Order DT"], + expressions: DATETIME_EXPR, + }); + }); +}); + +test.describe("Numeric category axis (X Bar)", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + test("date axis as sole group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "X Bar", + columns: ["Sales"], + group_by: ["Order Date"], + }); + }); + + test("datetime axis as sole group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "X Bar", + columns: ["Sales"], + group_by: ["Order DT"], + expressions: DATETIME_EXPR, + }); + }); + + test("integer axis as sole group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "X Bar", + columns: ["Sales"], + group_by: ["Quantity"], + }); + }); + + test("float axis as sole group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "X Bar", + columns: ["Sales"], + group_by: ["Discount"], + }); + }); + + test("integer last but not only group_by stays categorical", async ({ + page, + }) => { + await renderAndCapture(page, { + plugin: "X Bar", + columns: ["Sales"], + group_by: ["Region", "Quantity"], + }); + }); +}); + +test.describe("Numeric category axis (Y Line)", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + test("date axis as sole group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Line", + columns: ["Sales"], + group_by: ["Order Date"], + }); + }); + + test("integer axis as sole group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Line", + columns: ["Sales"], + group_by: ["Quantity"], + }); + }); + + test("float axis as sole group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Line", + columns: ["Sales"], + group_by: ["Discount"], + }); + }); +}); + +test.describe("Numeric category axis (Y Scatter)", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + test("date axis as sole group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Scatter", + columns: ["Sales"], + group_by: ["Order Date"], + }); + }); + + test("integer axis as sole group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Scatter", + columns: ["Sales"], + group_by: ["Quantity"], + }); + }); + + test("float axis as sole group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Scatter", + columns: ["Sales"], + group_by: ["Discount"], + }); + }); +}); + +test.describe("Numeric category axis (Y Area)", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + test("date axis as sole group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Area", + columns: ["Sales"], + group_by: ["Order Date"], + }); + }); + + test("integer axis as sole group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Area", + columns: ["Sales"], + group_by: ["Quantity"], + }); + }); +}); diff --git a/packages/viewer-charts/test/js/pan.spec.ts b/packages/viewer-charts/test/ts/snapshot/pan.spec.ts similarity index 99% rename from packages/viewer-charts/test/js/pan.spec.ts rename to packages/viewer-charts/test/ts/snapshot/pan.spec.ts index 8fc6d15674..127f4be82d 100644 --- a/packages/viewer-charts/test/js/pan.spec.ts +++ b/packages/viewer-charts/test/ts/snapshot/pan.spec.ts @@ -16,7 +16,7 @@ import { gotoBasic, restoreChart, waitOneFrame, -} from "./helpers"; +} from "../helpers"; const PLOT_CX = 640; const PLOT_CY = 360; @@ -31,6 +31,7 @@ test.describe("Pan", () => { plugin: "X/Y Scatter", columns: ["Quantity", "Profit"], }); + // Zoom in first so the pan offset is visible in the frame. await page.mouse.move(PLOT_CX, PLOT_CY); await page.mouse.wheel(0, -500); diff --git a/packages/viewer-charts/test/js/regressions.spec.ts b/packages/viewer-charts/test/ts/snapshot/regressions.spec.ts similarity index 88% rename from packages/viewer-charts/test/js/regressions.spec.ts rename to packages/viewer-charts/test/ts/snapshot/regressions.spec.ts index 5a9ca7bf45..a88c1b03ef 100644 --- a/packages/viewer-charts/test/js/regressions.spec.ts +++ b/packages/viewer-charts/test/ts/snapshot/regressions.spec.ts @@ -17,14 +17,14 @@ */ import { test } from "@perspective-dev/test"; -import { gotoBasic, renderAndCapture } from "./helpers"; +import { gotoBasic, renderAndCapture } from "../helpers"; test.describe("Regressions", () => { test.beforeEach(async ({ page }) => { await gotoBasic(page); }); - // ── split_by series correctness ───────────────────────────────────── + // split_by series correctness // Bug: the point/line glyph's draw count used // `numSeries * maxSeriesUploaded` which is correct only for a packed // buffer layout. With the slotted layout series 1..N live at @@ -48,7 +48,7 @@ test.describe("Regressions", () => { }); }); - // ── scatter categorical colors match legend ───────────────────────── + // scatter categorical colors match legend // Bug: the scatter vertex shader used a sign-aware color-t mapping // that folded non-negative domains into the top half of the // gradient, so split indices `0..N-1` mapped to `[0.5, 1]` while the @@ -61,7 +61,7 @@ test.describe("Regressions", () => { }); }); - // ── Y Line shows series colors (not all-black) ────────────────────── + // Y Line shows series colors (not all-black) // Bug: after refactoring the line shader to read color from a // gradient LUT + varying, bar/glyphs/draw-lines.ts was still wiring // up the old `u_color` uniform and never bound the LUT — every @@ -76,7 +76,7 @@ test.describe("Regressions", () => { }); }); - // ── Treemap transparent background ────────────────────────────────── + // Treemap transparent background // Bug: treemap cleared WebGL to a dimmed gridline color instead of // transparent, so themed hosts got an opaque backdrop under the // chart. @@ -88,7 +88,7 @@ test.describe("Regressions", () => { }); }); - // ── Treemap color-mode: date/datetime → numeric gradient ──────────── + // Treemap color-mode: date/datetime → numeric gradient // Bug: `_colorMode` detection read `ColumnData.type` (runtime storage // type "float32"/"int32"/"string") instead of the view-typed schema // type. Date and datetime columns were incorrectly classified as diff --git a/packages/viewer-charts/test/js/scatter.spec.ts b/packages/viewer-charts/test/ts/snapshot/scatter.spec.ts similarity index 98% rename from packages/viewer-charts/test/js/scatter.spec.ts rename to packages/viewer-charts/test/ts/snapshot/scatter.spec.ts index 57886118a1..fa61c265f7 100644 --- a/packages/viewer-charts/test/js/scatter.spec.ts +++ b/packages/viewer-charts/test/ts/snapshot/scatter.spec.ts @@ -11,7 +11,7 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import { test } from "@perspective-dev/test"; -import { gotoBasic, renderAndCapture } from "./helpers"; +import { gotoBasic, renderAndCapture } from "../helpers"; test.describe("X/Y Scatter", () => { test.beforeEach(async ({ page }) => { diff --git a/packages/viewer-charts/test/js/sunburst.spec.ts b/packages/viewer-charts/test/ts/snapshot/sunburst.spec.ts similarity index 63% rename from packages/viewer-charts/test/js/sunburst.spec.ts rename to packages/viewer-charts/test/ts/snapshot/sunburst.spec.ts index 2c333a0690..692d4245dd 100644 --- a/packages/viewer-charts/test/js/sunburst.spec.ts +++ b/packages/viewer-charts/test/ts/snapshot/sunburst.spec.ts @@ -11,7 +11,7 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import { test } from "@perspective-dev/test"; -import { gotoBasic, renderAndCapture } from "./helpers"; +import { gotoBasic, renderAndCapture } from "../helpers"; test.describe("Sunburst", () => { test.beforeEach(async ({ page }) => { @@ -57,4 +57,54 @@ test.describe("Sunburst", () => { group_by: ["Region", "Category", "Sub-Category"], }); }); + + // `split_by` activates the facet grid: one sunburst per split value, + // each with its own center / radius / drill root. + test("faceted by split_by — labels per facet", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Sunburst", + columns: ["Sales"], + group_by: ["Region", "Category"], + split_by: ["Ship Mode"], + }); + }); + + test("faceted with numeric color gradient", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Sunburst", + columns: ["Sales", "Profit"], + group_by: ["Region", "Category"], + split_by: ["Ship Mode"], + }); + }); + + // Regression: with `split_by` populated but `group_by` empty, + // `processTreeChunk` synthesizes a `[prefix, "Row N"]` path per + // row. Before the fix at `tree-data.ts`, `effectiveGroupLen` + // resolved to `1` against that depth-2 path, so the leaf size + // store was silently skipped and every node ended up with size 0 + // — sunburst computed zero-width arcs and rendered an empty plot. + test("split_by only, no group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Sunburst", + columns: ["Sales"], + split_by: ["Ship Mode"], + }); + }); + + test("split_by + string color, no group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Sunburst", + columns: ["Sales", "Region"], + split_by: ["Ship Mode"], + }); + }); + + test("split_by + numeric color, no group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Sunburst", + columns: ["Sales", "Profit"], + split_by: ["Ship Mode"], + }); + }); }); diff --git a/packages/viewer-charts/test/js/tooltip.spec.ts b/packages/viewer-charts/test/ts/snapshot/tooltip.spec.ts similarity index 99% rename from packages/viewer-charts/test/js/tooltip.spec.ts rename to packages/viewer-charts/test/ts/snapshot/tooltip.spec.ts index 1eaf60aa1c..95629893d7 100644 --- a/packages/viewer-charts/test/js/tooltip.spec.ts +++ b/packages/viewer-charts/test/ts/snapshot/tooltip.spec.ts @@ -11,7 +11,7 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import { test } from "@perspective-dev/test"; -import { expectViewerScreenshot, gotoBasic, restoreChart } from "./helpers"; +import { expectViewerScreenshot, gotoBasic, restoreChart } from "../helpers"; const PLOT_CX = 640; const PLOT_CY = 360; diff --git a/packages/viewer-charts/test/js/treemap.spec.ts b/packages/viewer-charts/test/ts/snapshot/treemap.spec.ts similarity index 57% rename from packages/viewer-charts/test/js/treemap.spec.ts rename to packages/viewer-charts/test/ts/snapshot/treemap.spec.ts index 2f12e70175..7697fbe53d 100644 --- a/packages/viewer-charts/test/js/treemap.spec.ts +++ b/packages/viewer-charts/test/ts/snapshot/treemap.spec.ts @@ -11,7 +11,7 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import { test } from "@perspective-dev/test"; -import { gotoBasic, renderAndCapture } from "./helpers"; +import { gotoBasic, renderAndCapture } from "../helpers"; test.describe("Treemap", () => { test.beforeEach(async ({ page }) => { @@ -55,10 +55,84 @@ test.describe("Treemap", () => { }); test("three-level group_by", async ({ page }) => { - await renderAndCapture(page, { - plugin: "Treemap", - columns: ["Sales"], - group_by: ["Region", "Category", "Sub-Category"], - }); + await renderAndCapture( + page, + { + plugin: "Treemap", + columns: ["Sales"], + group_by: ["Region", "Category", "Sub-Category"], + }, + + // Treemaps have a lot of text that gets shredded on CI + { maxDiffPixelRatio: 0.02 }, + ); + }); + + // `split_by` activates the facet grid: one treemap per split value + // sharing a color scale + legend. + test("faceted by split_by", async ({ page }) => { + await renderAndCapture( + page, + { + plugin: "Treemap", + columns: ["Sales"], + group_by: ["Region", "Category"], + split_by: ["Ship Mode"], + }, + { maxDiffPixelRatio: 0.02 }, + ); + }); + + test("faceted with numeric color gradient", async ({ page }) => { + await renderAndCapture( + page, + { + plugin: "Treemap", + columns: ["Sales", "Profit"], + group_by: ["Region", "Category"], + split_by: ["Ship Mode"], + }, + { maxDiffPixelRatio: 0.02 }, + ); + }); + + // Regression: see the matching sunburst test for the full path — + // `split_by` with no `group_by` produced depth-2 paths whose + // leaves were never sized, leaving treemap with zero-area rects + // and an empty canvas. + test("split_by only, no group_by", async ({ page }) => { + await renderAndCapture( + page, + { + plugin: "Treemap", + columns: ["Sales"], + split_by: ["Ship Mode"], + }, + { maxDiffPixelRatio: 0.02 }, + ); + }); + + test("split_by + string color, no group_by", async ({ page }) => { + await renderAndCapture( + page, + { + plugin: "Treemap", + columns: ["Sales", "Region"], + split_by: ["Ship Mode"], + }, + { maxDiffPixelRatio: 0.02 }, + ); + }); + + test("split_by + numeric color, no group_by", async ({ page }) => { + await renderAndCapture( + page, + { + plugin: "Treemap", + columns: ["Sales", "Profit"], + split_by: ["Ship Mode"], + }, + { maxDiffPixelRatio: 0.02 }, + ); }); }); diff --git a/packages/viewer-charts/test/js/x-bar.spec.ts b/packages/viewer-charts/test/ts/snapshot/x-bar.spec.ts similarity index 98% rename from packages/viewer-charts/test/js/x-bar.spec.ts rename to packages/viewer-charts/test/ts/snapshot/x-bar.spec.ts index cb2c947c08..5b44616986 100644 --- a/packages/viewer-charts/test/js/x-bar.spec.ts +++ b/packages/viewer-charts/test/ts/snapshot/x-bar.spec.ts @@ -11,7 +11,7 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import { test } from "@perspective-dev/test"; -import { gotoBasic, renderAndCapture } from "./helpers"; +import { gotoBasic, renderAndCapture } from "../helpers"; test.describe("X Bar", () => { test.beforeEach(async ({ page }) => { diff --git a/packages/viewer-charts/test/js/y-area.spec.ts b/packages/viewer-charts/test/ts/snapshot/y-area.spec.ts similarity index 97% rename from packages/viewer-charts/test/js/y-area.spec.ts rename to packages/viewer-charts/test/ts/snapshot/y-area.spec.ts index d5558a5d36..09d95a3c40 100644 --- a/packages/viewer-charts/test/js/y-area.spec.ts +++ b/packages/viewer-charts/test/ts/snapshot/y-area.spec.ts @@ -11,7 +11,7 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import { test } from "@perspective-dev/test"; -import { gotoBasic, renderAndCapture } from "./helpers"; +import { gotoBasic, renderAndCapture } from "../helpers"; test.describe("Y Area", () => { test.beforeEach(async ({ page }) => { diff --git a/packages/viewer-charts/test/js/y-bar.spec.ts b/packages/viewer-charts/test/ts/snapshot/y-bar.spec.ts similarity index 98% rename from packages/viewer-charts/test/js/y-bar.spec.ts rename to packages/viewer-charts/test/ts/snapshot/y-bar.spec.ts index e2d860bc31..4fbba7db1c 100644 --- a/packages/viewer-charts/test/js/y-bar.spec.ts +++ b/packages/viewer-charts/test/ts/snapshot/y-bar.spec.ts @@ -11,7 +11,7 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import { test } from "@perspective-dev/test"; -import { gotoBasic, renderAndCapture } from "./helpers"; +import { gotoBasic, renderAndCapture } from "../helpers"; test.describe("Y Bar", () => { test.beforeEach(async ({ page }) => { diff --git a/packages/viewer-charts/test/js/y-line.spec.ts b/packages/viewer-charts/test/ts/snapshot/y-line.spec.ts similarity index 97% rename from packages/viewer-charts/test/js/y-line.spec.ts rename to packages/viewer-charts/test/ts/snapshot/y-line.spec.ts index d24c5a05ae..4ee988df2e 100644 --- a/packages/viewer-charts/test/js/y-line.spec.ts +++ b/packages/viewer-charts/test/ts/snapshot/y-line.spec.ts @@ -11,7 +11,7 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import { test } from "@perspective-dev/test"; -import { gotoBasic, renderAndCapture } from "./helpers"; +import { gotoBasic, renderAndCapture } from "../helpers"; test.describe("Y Line", () => { test.beforeEach(async ({ page }) => { diff --git a/packages/viewer-charts/test/js/y-ohlc.spec.ts b/packages/viewer-charts/test/ts/snapshot/y-ohlc.spec.ts similarity index 97% rename from packages/viewer-charts/test/js/y-ohlc.spec.ts rename to packages/viewer-charts/test/ts/snapshot/y-ohlc.spec.ts index f966c3bd4f..1fd24bcd5b 100644 --- a/packages/viewer-charts/test/js/y-ohlc.spec.ts +++ b/packages/viewer-charts/test/ts/snapshot/y-ohlc.spec.ts @@ -11,7 +11,7 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import { test } from "@perspective-dev/test"; -import { gotoBasic, renderAndCapture } from "./helpers"; +import { gotoBasic, renderAndCapture } from "../helpers"; test.describe("Y OHLC", () => { test.beforeEach(async ({ page }) => { diff --git a/packages/viewer-charts/test/js/y-scatter.spec.ts b/packages/viewer-charts/test/ts/snapshot/y-scatter.spec.ts similarity index 97% rename from packages/viewer-charts/test/js/y-scatter.spec.ts rename to packages/viewer-charts/test/ts/snapshot/y-scatter.spec.ts index 0c3f2d530c..67019aa03f 100644 --- a/packages/viewer-charts/test/js/y-scatter.spec.ts +++ b/packages/viewer-charts/test/ts/snapshot/y-scatter.spec.ts @@ -11,7 +11,7 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import { test } from "@perspective-dev/test"; -import { gotoBasic, renderAndCapture } from "./helpers"; +import { gotoBasic, renderAndCapture } from "../helpers"; test.describe("Y Scatter", () => { test.beforeEach(async ({ page }) => { diff --git a/packages/viewer-charts/test/js/zoom.spec.ts b/packages/viewer-charts/test/ts/snapshot/zoom.spec.ts similarity index 92% rename from packages/viewer-charts/test/js/zoom.spec.ts rename to packages/viewer-charts/test/ts/snapshot/zoom.spec.ts index 604f2fa984..ce03edc882 100644 --- a/packages/viewer-charts/test/js/zoom.spec.ts +++ b/packages/viewer-charts/test/ts/snapshot/zoom.spec.ts @@ -16,7 +16,7 @@ import { gotoBasic, restoreChart, waitOneFrame, -} from "./helpers"; +} from "../helpers"; // Plot center is well inside the layout's plotRect for the default // 1280×720 viewport — the layout leaves ~80px of gutter on every side. @@ -37,7 +37,7 @@ test.describe("Zoom", () => { await page.mouse.move(PLOT_CX, PLOT_CY); await page.mouse.wheel(0, -500); await waitOneFrame(page); - await expectViewerScreenshot(page, "scatter-wheel-in.png"); + await expectViewerScreenshot(page); }); test("wheel zooms in on line with date axis", async ({ page }) => { @@ -50,7 +50,7 @@ test.describe("Zoom", () => { await page.mouse.move(PLOT_CX, PLOT_CY); await page.mouse.wheel(0, -500); await waitOneFrame(page); - await expectViewerScreenshot(page, "line-wheel-in.png"); + await expectViewerScreenshot(page); }); test("wheel zooms in on Y Bar", async ({ page }) => { @@ -63,7 +63,7 @@ test.describe("Zoom", () => { await page.mouse.move(PLOT_CX, PLOT_CY); await page.mouse.wheel(0, -500); await waitOneFrame(page); - await expectViewerScreenshot(page, "bar-wheel-in.png"); + await expectViewerScreenshot(page); }); test("wheel zooms in on Candlestick", async ({ page }) => { @@ -76,6 +76,6 @@ test.describe("Zoom", () => { await page.mouse.move(PLOT_CX, PLOT_CY); await page.mouse.wheel(0, -500); await waitOneFrame(page); - await expectViewerScreenshot(page, "candlestick-wheel-in.png"); + await expectViewerScreenshot(page); }); }); diff --git a/packages/viewer-charts/test/ts/tsconfig.json b/packages/viewer-charts/test/ts/tsconfig.json new file mode 100644 index 0000000000..9ec391d023 --- /dev/null +++ b/packages/viewer-charts/test/ts/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": ".", + }, + "include": ["./**/*.ts"] +} diff --git a/packages/viewer-charts/tsconfig.json b/packages/viewer-charts/tsconfig.json index a6c436932c..4c28052403 100644 --- a/packages/viewer-charts/tsconfig.json +++ b/packages/viewer-charts/tsconfig.json @@ -7,7 +7,7 @@ "outDir": "./dist/esm", "rootDir": "./src/ts", "moduleResolution": "bundler", - "skipLibCheck": true, + "skipLibCheck": false, "resolveJsonModule": true, "strict": true, "noImplicitAny": true, diff --git a/packages/viewer-datagrid/index.d.ts b/packages/viewer-datagrid/index.d.ts index f111c87246..193cf50164 100644 --- a/packages/viewer-datagrid/index.d.ts +++ b/packages/viewer-datagrid/index.d.ts @@ -23,9 +23,11 @@ declare global { } } +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging interface HTMLPerspectiveViewerDatagridPluginElement extends IPerspectiveViewerPlugin {} +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging export declare class HTMLPerspectiveViewerDatagridPluginElement extends HTMLElement implements IPerspectiveViewerPlugin diff --git a/packages/viewer-datagrid/src/ts/color_utils.ts b/packages/viewer-datagrid/src/ts/color_utils.ts index f7772230e1..5db4f3a457 100644 --- a/packages/viewer-datagrid/src/ts/color_utils.ts +++ b/packages/viewer-datagrid/src/ts/color_utils.ts @@ -28,13 +28,18 @@ function parse_hex(input: string): RGB | null { const r = parseInt(s[0] + s[0], 16); const g = parseInt(s[1] + s[1], 16); const b = parseInt(s[2] + s[2], 16); - if (!isNaN(r) && !isNaN(g) && !isNaN(b)) return [r, g, b]; + if (!isNaN(r) && !isNaN(g) && !isNaN(b)) { + return [r, g, b]; + } } else if (s.length === 6 || s.length === 8) { const r = parseInt(s.slice(0, 2), 16); const g = parseInt(s.slice(2, 4), 16); const b = parseInt(s.slice(4, 6), 16); - if (!isNaN(r) && !isNaN(g) && !isNaN(b)) return [r, g, b]; + if (!isNaN(r) && !isNaN(g) && !isNaN(b)) { + return [r, g, b]; + } } + return null; } @@ -43,7 +48,10 @@ function parse_rgb_fn(input: string): RGB | null { const m = input.match( /^rgba?\(\s*([\d.]+)\s*[, ]\s*([\d.]+)\s*[, ]\s*([\d.]+)/i, ); - if (!m) return null; + if (!m) { + return null; + } + return [ Math.round(parseFloat(m[1])), Math.round(parseFloat(m[2])), @@ -58,7 +66,11 @@ function parse_via_canvas(input: string): RGB { canvas.width = canvas.height = 1; parse_ctx = canvas.getContext("2d"); } - if (!parse_ctx) return [0, 0, 0]; + + if (!parse_ctx) { + return [0, 0, 0]; + } + parse_ctx.fillStyle = "#000"; parse_ctx.fillStyle = input; const normalized = parse_ctx.fillStyle as string; @@ -69,7 +81,10 @@ function parse_via_canvas(input: string): RGB { export function parseColor(input: string): RGB { const key = input.trim(); const cached = parse_cache.get(key); - if (cached) return cached; + if (cached) { + return cached; + } + const rgb = parse_hex(key) ?? parse_rgb_fn(key) ?? parse_via_canvas(key); parse_cache.set(key, rgb); return rgb; @@ -99,10 +114,15 @@ export function rgbToHsl([r, g, b]: RGB): HSL { let s = 0; if (d !== 0) { s = l > 0.5 ? d / (2 - max - min) : d / (max + min); - if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) * 60; - else if (max === gn) h = ((bn - rn) / d + 2) * 60; - else h = ((rn - gn) / d + 4) * 60; + if (max === rn) { + h = ((gn - bn) / d + (gn < bn ? 6 : 0)) * 60; + } else if (max === gn) { + h = ((bn - rn) / d + 2) * 60; + } else { + h = ((rn - gn) / d + 4) * 60; + } } + return [h, s, l]; } @@ -113,16 +133,33 @@ export function hslToRgb([h, s, l]: HSL): RGB { const v = Math.round(l * 255); return [v, v, v]; } + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; const p = 2 * l - q; const f = (t: number): number => { - if (t < 0) t += 1; - if (t > 1) t -= 1; - if (t < 1 / 6) return p + (q - p) * 6 * t; - if (t < 1 / 2) return q; - if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + if (t < 0) { + t += 1; + } + + if (t > 1) { + t -= 1; + } + + if (t < 1 / 6) { + return p + (q - p) * 6 * t; + } + + if (t < 1 / 2) { + return q; + } + + if (t < 2 / 3) { + return p + (q - p) * (2 / 3 - t) * 6; + } + return p; }; + return [ Math.round(f(hn + 1 / 3) * 255), Math.round(f(hn) * 255), diff --git a/packages/viewer-datagrid/src/ts/custom_elements/datagrid.ts b/packages/viewer-datagrid/src/ts/custom_elements/datagrid.ts index ee9f6f9aab..183abf2517 100644 --- a/packages/viewer-datagrid/src/ts/custom_elements/datagrid.ts +++ b/packages/viewer-datagrid/src/ts/custom_elements/datagrid.ts @@ -15,16 +15,17 @@ import { activate } from "../plugin/activate.js"; import { restore } from "../plugin/restore.js"; import { save } from "../plugin/save.js"; import { draw } from "../plugin/draw.js"; -import column_style_controls, { - ColumnStyleOpts, -} from "../plugin/column_style_controls.js"; +import column_config_schema, { + ColumnConfigSchema, +} from "../plugin/column_config_schema.js"; import datagridStyles from "../../../dist/css/perspective-viewer-datagrid.css"; import { format_raw } from "../data_listener/format_cell.js"; +import { sourceColumn } from "@perspective-dev/viewer/src/ts/column-format.js"; import type { View, ViewWindow } from "@perspective-dev/client"; import type { - HTMLPerspectiveViewerElement, IPerspectiveViewerPlugin, + PluginStaticConfig, } from "@perspective-dev/viewer"; import type { DatagridModel, @@ -115,44 +116,64 @@ export class HTMLPerspectiveViewerDatagridPluginElement return await activate.call(this, view); } - get name(): string { - return "Datagrid"; + get_static_config(): PluginStaticConfig { + return { + name: "Datagrid", + category: "Basic", + select_mode: "toggle", + config_column_names: ["Columns"], + group_rollup_modes: ["rollup", "flat", "total"], + // Higher priority than the chart plugins so the Datagrid is + // loaded by default. + priority: 1, + can_render_column_styles: true, + }; } - get category(): string { - return "Basic"; + plugin_config_schema(): ColumnConfigSchema { + const fields = []; + fields.push({ + kind: "Enum", + key: "edit_mode", + default: "READ_ONLY", + variants: [ + { value: "EDIT", label: "Edit" }, + { value: "READ_ONLY", label: "Read-only" }, + { value: "SELECT_ROW", label: "Row Select" }, + { value: "SELECT_COLUMN", label: "Column Select" }, + { value: "SELECT_REGION", label: "Region Select" }, + { value: "SELECT_ROW_TREE", label: "Tree Select" }, + ], + }); + + fields.push({ + kind: "Bool", + key: "scroll_lock", + default: false, + }); + + return { + fields, + }; } - get select_mode(): string { - return "toggle"; - } - - get min_config_columns(): number | undefined { - return undefined; - } - - get config_column_names(): string[] { - return ["Columns"]; - } - - get group_rollups(): string[] { - return ["rollup", "flat", "total"]; - } - - /** - * Give the Datagrid a higher priority so it is loaded - * over the default charts by default. - */ - get priority(): number { - return 1; - } - - can_render_column_styles(type: string, _group: string): boolean { - return type !== "boolean"; - } - - column_style_controls(type: string, group: string): ColumnStyleOpts { - return column_style_controls.call(this, type as any, group); + column_config_schema( + type: string, + group: string | undefined, + column_name: string, + current_value: Record | null, + viewer_config?: { group_by?: string[]; group_rollup_mode?: string }, + column_stats?: { abs_max: number }, + ): ColumnConfigSchema { + return column_config_schema.call( + this, + type as any, + group, + column_name, + current_value, + viewer_config, + column_stats, + ); } async draw(view: View): Promise { @@ -172,9 +193,7 @@ export class HTMLPerspectiveViewerDatagridPluginElement } } - async render(viewport?: ViewWindow): Promise { - const viewer = this.parentElement as HTMLPerspectiveViewerElement; - const view = await viewer.getView(); + async render(view: View, viewport?: ViewWindow): Promise { const json = await view.to_columns(viewport as any); const cols = await view.column_paths(viewport as any); @@ -194,7 +213,7 @@ export class HTMLPerspectiveViewerDatagridPluginElement const pluginConfig = (this.regular_table as any)[ PRIVATE_PLUGIN_SYMBOL ] as ColumnsConfig | undefined; - const columnName = col_name.split("|").at(-1)!; + const columnName = sourceColumn(col_name); const formatter = format_raw( type, pluginConfig?.[columnName] || {}, @@ -206,6 +225,7 @@ export class HTMLPerspectiveViewerDatagridPluginElement out += col[ridx] + "\t"; } } + out += "\n"; } @@ -235,12 +255,7 @@ export class HTMLPerspectiveViewerDatagridPluginElement return restore.call(this, token, columns_config ?? {}); } - async restyle(view: View): Promise { - // Get view from model if available, otherwise no-op - if (this.model?._view) { - await this.draw(view); - } - } + restyle() {} delete(): void { this.disconnectedCallback(); diff --git a/packages/viewer-datagrid/src/ts/data_listener/format_tree_header.ts b/packages/viewer-datagrid/src/ts/data_listener/format_tree_header.ts index 05b3f2dece..78e166f474 100644 --- a/packages/viewer-datagrid/src/ts/data_listener/format_tree_header.ts +++ b/packages/viewer-datagrid/src/ts/data_listener/format_tree_header.ts @@ -27,7 +27,7 @@ export function* format_tree_header_row_path( ): Generator { const plugins: ColumnsConfig = (regularTable as any)[PRIVATE_PLUGIN_SYMBOL] || {}; - for (let path of paths) { + for (const path of paths) { const fullPath: unknown[] = ["TOTAL", ...path]; const last = fullPath[fullPath.length - 1]; let newPath: RowHeaderCell[] = fullPath @@ -61,7 +61,7 @@ export function* format_flat_header_row_path( const plugins: ColumnsConfig = (regularTable as any)[PRIVATE_PLUGIN_SYMBOL] || {}; - for (let path of paths) { + for (const path of paths) { yield path.map((part, i) => format_cell.call(this, row_headers[i], part, plugins, true), ) as RowHeaderCell[]; diff --git a/packages/viewer-datagrid/src/ts/data_listener/formatter_cache.ts b/packages/viewer-datagrid/src/ts/data_listener/formatter_cache.ts index d028556328..7983a44cb1 100644 --- a/packages/viewer-datagrid/src/ts/data_listener/formatter_cache.ts +++ b/packages/viewer-datagrid/src/ts/data_listener/formatter_cache.ts @@ -11,6 +11,11 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import type { ColumnType } from "@perspective-dev/client"; +import { + createDateFormatter, + createDatetimeFormatter, + createNumberFormatter, +} from "@perspective-dev/viewer/src/ts/column-format.js"; import type { ColumnConfig } from "../types.js"; export interface Formatter { @@ -26,30 +31,6 @@ class BooleanFormatter implements Formatter { // PluginConfig is a subset of ColumnConfig with the formatting properties type PluginConfig = Pick; -const LEGACY_CONFIG: Record< - string, - { format: Intl.NumberFormatOptions | Intl.DateTimeFormatOptions } -> = { - float: { - format: { - style: "decimal", - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }, - }, - datetime: { - format: { - dateStyle: "short", - timeStyle: "medium", - } as Intl.DateTimeFormatOptions, - }, - date: { - format: { - dateStyle: "short", - } as Intl.DateTimeFormatOptions, - }, -}; - export class FormatterCache { private _formatters: Map; @@ -61,89 +42,21 @@ export class FormatterCache { _type: ColumnType, plugin: PluginConfig, ): Intl.DateTimeFormat { - if (plugin.date_format?.format !== "custom") { - const options: Intl.DateTimeFormatOptions = { - timeZone: plugin.date_format?.timeZone, - dateStyle: - plugin.date_format?.dateStyle === "disabled" - ? undefined - : (plugin.date_format?.dateStyle ?? "short"), - timeStyle: - plugin.date_format?.timeStyle === "disabled" - ? undefined - : (plugin.date_format?.timeStyle ?? "medium"), - }; - - return new Intl.DateTimeFormat( - navigator.languages as string[], - options, - ); - } else { - const options: Intl.DateTimeFormatOptions = { - timeZone: plugin.date_format?.timeZone, - hour12: plugin.date_format?.hour12 ?? true, - fractionalSecondDigits: - plugin.date_format?.fractionalSecondDigits, - }; - - if (plugin.date_format?.year !== "disabled") { - options.year = plugin.date_format?.year ?? "2-digit"; - } - if (plugin.date_format?.month !== "disabled") { - options.month = plugin.date_format?.month ?? "numeric"; - } - if (plugin.date_format?.day !== "disabled") { - options.day = plugin.date_format?.day ?? "numeric"; - } - if ( - plugin.date_format?.weekday && - plugin.date_format?.weekday !== "disabled" - ) { - options.weekday = plugin.date_format.weekday; - } - if (plugin.date_format?.hour !== "disabled") { - options.hour = plugin.date_format?.hour ?? "numeric"; - } - if (plugin.date_format?.minute !== "disabled") { - options.minute = plugin.date_format?.minute ?? "numeric"; - } - if (plugin.date_format?.second !== "disabled") { - options.second = plugin.date_format?.second ?? "numeric"; - } - - return new Intl.DateTimeFormat( - navigator.languages as string[], - options, - ); - } + return createDatetimeFormatter(plugin.date_format); } private create_date_formatter( _type: ColumnType, plugin: PluginConfig, ): Intl.DateTimeFormat { - const options: Intl.DateTimeFormatOptions = { - timeZone: "utc", - dateStyle: - plugin.date_format?.dateStyle === "disabled" - ? undefined - : (plugin.date_format?.dateStyle ?? "short"), - }; - - return new Intl.DateTimeFormat( - navigator.languages as string[], - options, - ); + return createDateFormatter(plugin.date_format); } private create_number_formatter( type: ColumnType, plugin: PluginConfig, ): Intl.NumberFormat { - const format = - plugin.number_format ?? - (LEGACY_CONFIG[type]?.format as Intl.NumberFormatOptions); - return new Intl.NumberFormat(navigator.languages as string[], format); + return createNumberFormatter(type, plugin.number_format); } private create_boolean_formatter( diff --git a/packages/viewer-datagrid/src/ts/data_listener/index.ts b/packages/viewer-datagrid/src/ts/data_listener/index.ts index 079148b2a9..3f55df14c6 100644 --- a/packages/viewer-datagrid/src/ts/data_listener/index.ts +++ b/packages/viewer-datagrid/src/ts/data_listener/index.ts @@ -11,6 +11,7 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import { PRIVATE_PLUGIN_SYMBOL } from "../types.js"; +import { isMetaColumn } from "../model/meta_columns.js"; import { format_cell } from "./format_cell.js"; import { format_flat_header_row_path, @@ -84,9 +85,14 @@ export function createDataListener( num_columns = z; columns = JSON.parse(x as string) as ColumnData; const y = Object.keys(columns); - const new_col_paths = y.filter( - (x) => x !== "__ROW_PATH__" && x !== "__ID__", - ); + // `isMetaColumn` covers `__ROW_PATH__`, `__ID__`, + // `__GROUPING_ID__`, and the per-level `__ROW_PATH___` + // columns the DuckDB virtual server emits inline alongside + // the JSON sidecar. Exact-match against `__ROW_PATH__` / + // `__ID__` (the previous filter) misses the per-level + // form and the virtual server's columns leak into the + // visible grid. + const new_col_paths = y.filter((x) => !isMetaColumn(x)); let changed_cols = false; for (let i = 0; i < new_col_paths.length; i++) { diff --git a/packages/viewer-datagrid/src/ts/event_handlers/click/edit_click.ts b/packages/viewer-datagrid/src/ts/event_handlers/click/edit_click.ts index c1ed3568f0..b20b008123 100644 --- a/packages/viewer-datagrid/src/ts/event_handlers/click/edit_click.ts +++ b/packages/viewer-datagrid/src/ts/event_handlers/click/edit_click.ts @@ -28,6 +28,7 @@ export function write_cell( if (!meta) { return false; } + const type = model._schema[model._column_paths[meta.x!]]; let text: string | number | boolean | null = active_cell.textContent || ""; const id = model._ids[meta.y! - meta.y0][0]; @@ -36,12 +37,14 @@ export function write_cell( if (isNaN(parsed)) { return false; } + text = parsed; } else if (type === "date" || type === "datetime") { const parsed = Date.parse(text); if (isNaN(parsed)) { return false; } + text = parsed; } else if (type === "boolean") { text = text === "true" ? false : text === "false" ? true : null; diff --git a/packages/viewer-datagrid/src/ts/event_handlers/dispatch_click.ts b/packages/viewer-datagrid/src/ts/event_handlers/dispatch_click.ts index 7d685fc9cb..5578c98848 100644 --- a/packages/viewer-datagrid/src/ts/event_handlers/dispatch_click.ts +++ b/packages/viewer-datagrid/src/ts/event_handlers/dispatch_click.ts @@ -23,7 +23,10 @@ export function createDispatchClickListener( return async (event: Event): Promise => { const mouseEvent = event as MouseEvent; const meta = table.getMeta(mouseEvent.target as HTMLElement); - if (!meta || meta.type !== "body") return; + if (!meta || meta.type !== "body") { + return; + } + const { x, y } = meta; const { row, column_names, config } = await getCellConfig(model, y, x); viewer.dispatchEvent( diff --git a/packages/viewer-datagrid/src/ts/event_handlers/expand_collapse.ts b/packages/viewer-datagrid/src/ts/event_handlers/expand_collapse.ts index 95f82d3769..009ed1bf4e 100644 --- a/packages/viewer-datagrid/src/ts/event_handlers/expand_collapse.ts +++ b/packages/viewer-datagrid/src/ts/event_handlers/expand_collapse.ts @@ -18,7 +18,10 @@ export async function expandCollapseHandler( event: MouseEvent, ): Promise { const meta = regularTable.getMeta(event.target as HTMLElement); - if (!meta || meta.type !== "row_header") return; + if (!meta || meta.type !== "row_header") { + return; + } + const is_collapse = (event.target as Element).classList.contains( "psp-tree-label-collapse", ); diff --git a/packages/viewer-datagrid/src/ts/event_handlers/header_click.ts b/packages/viewer-datagrid/src/ts/event_handlers/header_click.ts index 4d45e60c23..9f545f1549 100644 --- a/packages/viewer-datagrid/src/ts/event_handlers/header_click.ts +++ b/packages/viewer-datagrid/src/ts/event_handlers/header_click.ts @@ -38,7 +38,9 @@ export function createMousedownListener( } } - if (!target) return; + if (!target) { + return; + } if (target.classList.contains("psp-tree-label")) { if (model._edit_mode !== "SELECT_ROW_TREE") { @@ -82,7 +84,9 @@ export function createDblclickListener( } } - if (!target) return; + if (!target) { + return; + } if (target.classList.contains("psp-tree-label")) { if (model._edit_mode === "SELECT_ROW_TREE") { @@ -107,7 +111,9 @@ export function createClickListener(regularTable: RegularTable): EventListener { } } - if (!target) return; + if (!target) { + return; + } if ( target.classList.contains("psp-tree-label") && diff --git a/packages/viewer-datagrid/src/ts/event_handlers/keydown/edit_keydown.ts b/packages/viewer-datagrid/src/ts/event_handlers/keydown/edit_keydown.ts index 29b327788d..506f93fe09 100644 --- a/packages/viewer-datagrid/src/ts/event_handlers/keydown/edit_keydown.ts +++ b/packages/viewer-datagrid/src/ts/event_handlers/keydown/edit_keydown.ts @@ -68,7 +68,10 @@ function getPos(elem: ContentEditableElement): number { const _range = (elem.getRootNode() as Document) .getSelection() ?.getRangeAt(0); - if (!_range) return 0; + if (!_range) { + return 0; + } + const range = _range.cloneRange(); range.selectNodeContents(elem); range.setEnd(_range.endContainer, _range.endOffset); @@ -87,7 +90,10 @@ const moveSelection = lock(async function ( dy: number, ): Promise { const meta = table.getMeta(active_cell); - if (!meta || meta.type !== "body") return; + if (!meta || meta.type !== "body") { + return; + } + const num_columns = model._column_paths.length; const num_rows = model._num_rows; const selected_position = selected_position_map.get(table); @@ -167,6 +173,7 @@ export function keydownListener( 1, ); } + break; case "ArrowLeft": if (getPos(target as ContentEditableElement) === 0) { @@ -180,6 +187,7 @@ export function keydownListener( 0, ); } + break; case "ArrowUp": event.preventDefault(); @@ -200,6 +208,7 @@ export function keydownListener( 0, ); } + break; case "ArrowDown": event.preventDefault(); diff --git a/packages/viewer-datagrid/src/ts/event_handlers/select_region.ts b/packages/viewer-datagrid/src/ts/event_handlers/select_region.ts index e45b34c3aa..a6f6abbb86 100644 --- a/packages/viewer-datagrid/src/ts/event_handlers/select_region.ts +++ b/packages/viewer-datagrid/src/ts/event_handlers/select_region.ts @@ -85,7 +85,10 @@ const getMousedownListener = mouseEvent.button === 0 && isSelectionMode(datagrid.model!._edit_mode) ) { - if (isSingleClickMode(datagrid.model!._edit_mode)) return; + if (isSingleClickMode(datagrid.model!._edit_mode)) { + return; + } + datagrid.model!._selection_state.CURRENT_MOUSEDOWN_COORDINATES = {}; const meta = table.getMeta(mouseEvent.target as HTMLElement); if ( @@ -199,7 +202,9 @@ const getMouseupListener = const mode = datagrid.model!._edit_mode; if (isSelectionMode(mode)) { const meta = table.getMeta(mouseEvent.target as HTMLElement); - if (!meta) return; + if (!meta) { + return; + } // For single-click modes (SELECT_ROW_TREE), handle toggle if (isSingleClickMode(mode)) { @@ -423,11 +428,17 @@ const applyMouseAreaSelection = ( className: string, ): void => { const predicate = SELECTION_PREDICATES[datagrid.model!._edit_mode]; - if (!predicate || selected.length === 0) return; + if (!predicate || selected.length === 0) { + return; + } + const tds = table.querySelectorAll("tbody td"); for (const td of tds) { const meta = table.getMeta(td as HTMLElement); - if (!meta || meta.type !== "body") continue; + if (!meta || meta.type !== "body") { + continue; + } + let rendered = false; for (const area of selected) { if ( diff --git a/packages/viewer-datagrid/src/ts/event_handlers/sort.ts b/packages/viewer-datagrid/src/ts/event_handlers/sort.ts index ffd07fa710..bec21ee342 100644 --- a/packages/viewer-datagrid/src/ts/event_handlers/sort.ts +++ b/packages/viewer-datagrid/src/ts/event_handlers/sort.ts @@ -37,7 +37,10 @@ export async function sortHandler( target: HTMLElement, ): Promise { const meta = regularTable.getMeta(target); - if (!meta?.column_header) return; + if (!meta?.column_header) { + return; + } + const column_name = meta.column_header[model._config.split_by.length]; const sort_method = event.ctrlKey || diff --git a/packages/viewer-datagrid/src/ts/get_cell_config.ts b/packages/viewer-datagrid/src/ts/get_cell_config.ts index 10e756e1fb..d7089a18c4 100644 --- a/packages/viewer-datagrid/src/ts/get_cell_config.ts +++ b/packages/viewer-datagrid/src/ts/get_cell_config.ts @@ -12,6 +12,7 @@ import type { View, ViewConfig, Filter, Scalar } from "@perspective-dev/client"; import type { CellConfigResult } from "./types.js"; +import { isMetaColumn } from "./model/meta_columns.js"; interface ModelWithViewAndConfig { _view: View; @@ -42,8 +43,14 @@ export default async function getCellConfig( }) .filter((x): x is Filter => x !== undefined); - const column_index = group_by.length > 0 ? col_idx + 1 : col_idx; - const column_paths = Object.keys(r[0])[column_index]; + // Filter out *all* meta columns before indexing into the row's + // keys — the DuckDB virtual server's JSON output now includes + // per-level `__ROW_PATH___` columns alongside the + // `__ROW_PATH__` sidecar, so the previous `+1` skip (which + // assumed exactly one leading meta column) lands on a meta key + // when group_by has multiple levels. + const user_keys = Object.keys(r[0]).filter((k) => !isMetaColumn(k)); + const column_paths = user_keys[col_idx]; const result: CellConfigResult = { row: r[0] as Record, column_names: [], @@ -60,7 +67,7 @@ export default async function getCellConfig( return pivot_value ? [pivot, "==", pivot_value] : undefined; }) .filter((x): x is Filter => x !== undefined) - .filter(([, , value]) => value !== "__ROW_PATH__"); + .filter(([, , value]) => !isMetaColumn(value as string)); } const filter = _config.filter.concat(row_filters).concat(column_filters); diff --git a/packages/viewer-datagrid/src/ts/model/column_overrides.ts b/packages/viewer-datagrid/src/ts/model/column_overrides.ts index 128f2edb08..69eb90dcf6 100644 --- a/packages/viewer-datagrid/src/ts/model/column_overrides.ts +++ b/packages/viewer-datagrid/src/ts/model/column_overrides.ts @@ -10,11 +10,7 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import type { - ColumnOverrides, - DatagridPluginElement, - RegularTable, -} from "../types.js"; +import type { ColumnOverrides, DatagridPluginElement } from "../types.js"; interface RegularTableWithOverrides { restoreColumnSizes(overrides: Record): void; @@ -60,6 +56,7 @@ export function restore_column_size_overrides( | undefined; } else { const index = this.model!._column_paths.indexOf(key); + // Skip keys that don't resolve to a known column — e.g. on the // first draw after `activate`, `_column_paths` has not yet been // populated by the data listener, so we leave any existing @@ -68,6 +65,7 @@ export function restore_column_size_overrides( if (index === -1) { continue; } + overrides[index + tree_header_offset] = old_sizes[key] as | number | undefined; diff --git a/packages/viewer-datagrid/src/ts/model/create.ts b/packages/viewer-datagrid/src/ts/model/create.ts index abdc9722c8..df0aa064ea 100644 --- a/packages/viewer-datagrid/src/ts/model/create.ts +++ b/packages/viewer-datagrid/src/ts/model/create.ts @@ -29,21 +29,36 @@ import { import type { HTMLPerspectiveViewerElement } from "@perspective-dev/viewer"; function arraysChanged(a: T[], b: T[]): boolean { - if (a.length !== b.length) return true; + if (a.length !== b.length) { + return true; + } + for (let i = 0; i < a.length; i++) { - if (a[i] !== b[i]) return true; + if (a[i] !== b[i]) { + return true; + } } + return false; } function nestedArraysChanged(a: T[][], b: T[][]): boolean { - if (a.length !== b.length) return true; + if (a.length !== b.length) { + return true; + } + for (let i = 0; i < a.length; i++) { - if (a[i].length !== b[i].length) return true; + if (a[i].length !== b[i].length) { + return true; + } + for (let j = 0; j < a[i].length; j++) { - if (a[i][j] !== b[i][j]) return true; + if (a[i][j] !== b[i][j]) { + return true; + } } } + return false; } @@ -75,6 +90,7 @@ class ElemFactoryImpl implements ElemFactory { if (!this._elements[this._index]) { this._elements[this._index] = document.createElement(this._name); } + const elem = this._elements[this._index]; this._index += 1; return elem; @@ -216,6 +232,7 @@ export async function createModel( }), _series_color_map: new Map(), _series_color_seed: new Map(), + // get_psp_type, _div_factory: extend._div_factory || new ElemFactoryImpl("div"), }) as DatagridModel; diff --git a/packages/viewer-datagrid/src/ts/model/meta_columns.ts b/packages/viewer-datagrid/src/ts/model/meta_columns.ts new file mode 100644 index 0000000000..2ce964cedb --- /dev/null +++ b/packages/viewer-datagrid/src/ts/model/meta_columns.ts @@ -0,0 +1,47 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +/** + * Single-source-of-truth predicate for "this column is metadata, hide + * it from the user-visible grid." Five distinct names show up across + * the perspective protocol; older datagrid code filtered them by + * exact-match in three places, which only worked when the wire shape + * was the JSON-sidecar form (`__ROW_PATH__` array-of-arrays). + * + * The DuckDB virtual-server backend additionally surfaces per-level + * `__ROW_PATH___` columns directly in `to_columns_string` / + * `to_json` output (a side effect of keeping them in the frozen Arrow + * batch so that `to_arrow` consumers — viewer-charts via + * `with_typed_arrays` — see them inline, matching native + * `perspective-server`'s `to_arrow` behavior). Without this helper, + * those per-level columns slip through the legacy exact-match filters + * and render as user columns to the right of the grid. + * + * Match list: + * - `__ROW_PATH__` — JSON sidecar; used by the tree header. + * - `__ROW_PATH___` — per-level columns from the virtual server's + * inline-arrow shape. + * - `__ID__` — per-row identity column. + * - `__GROUPING_ID__` — internal SQL-rollup discriminator. The + * virtual server strips it server-side, but + * we cover it defensively in case a future + * backend leaks it. + * + * User columns named with leading/trailing double-underscores (e.g. + * `__user_col__`) are *not* matched — the regex requires the exact + * stems above. + */ +const META_COLUMN_RE = /^__(?:ROW_PATH(?:_\d+)?|ID|GROUPING_ID)__$/; + +export function isMetaColumn(name: string): boolean { + return META_COLUMN_RE.test(name); +} diff --git a/packages/viewer-datagrid/src/ts/model/toolbar.ts b/packages/viewer-datagrid/src/ts/model/toolbar.ts index 6fb1065a35..bca372a503 100644 --- a/packages/viewer-datagrid/src/ts/model/toolbar.ts +++ b/packages/viewer-datagrid/src/ts/model/toolbar.ts @@ -10,6 +10,7 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +import { HTMLPerspectiveViewerElement } from "@perspective-dev/viewer"; import type { DatagridModel, DatagridPluginElement, @@ -26,7 +27,10 @@ export const EDIT_MODES: readonly EditMode[] = [ ] as const; function isSelectRowTreeAvailable(model?: DatagridModel): boolean { - if (!model) return false; + if (!model) { + return false; + } + return ( model._config.group_by.length > 0 && model._config.group_rollup_mode !== "flat" @@ -45,10 +49,11 @@ export function toggle_edit_mode( EDIT_MODES[idx] === "SELECT_ROW_TREE" && !isSelectRowTreeAvailable(this.model) ); + mode = EDIT_MODES[idx]; } - (this.parentElement as any)?.setSelection?.(); + (this.parentElement as HTMLPerspectiveViewerElement)?.setSelection?.(); this._edit_mode = mode; if (this.model) { this.model._edit_mode = mode; @@ -59,6 +64,10 @@ export function toggle_edit_mode( }; } + (this.parentElement as HTMLPerspectiveViewerElement)?.restore?.({ + plugin_config: { edit_mode: mode }, + }); + if (this._edit_button !== undefined) { this._edit_button.dataset.editMode = mode; } diff --git a/packages/viewer-datagrid/src/ts/plugin/activate.ts b/packages/viewer-datagrid/src/ts/plugin/activate.ts index 5f84e3227c..5f1921b0b6 100644 --- a/packages/viewer-datagrid/src/ts/plugin/activate.ts +++ b/packages/viewer-datagrid/src/ts/plugin/activate.ts @@ -91,7 +91,9 @@ export async function activate( area: SelectionArea, isDeselect: boolean, ) => { - if (model._edit_mode !== "SELECT_ROW_TREE") return; + if (model._edit_mode !== "SELECT_ROW_TREE") { + return; + } // Store the selected row identity on the model so it persists // even when the selected row scrolls out of the viewport. diff --git a/packages/viewer-datagrid/src/ts/plugin/column_config_schema.ts b/packages/viewer-datagrid/src/ts/plugin/column_config_schema.ts new file mode 100644 index 0000000000..5bd6125531 --- /dev/null +++ b/packages/viewer-datagrid/src/ts/plugin/column_config_schema.ts @@ -0,0 +1,187 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import type { ColumnType } from "@perspective-dev/client"; +import type { DatagridPluginElement } from "../types.js"; + +interface ViewerConfigLike { + group_by?: string[]; + group_rollup_mode?: string; +} + +type ControlSpec = Record & { kind: string }; + +export interface ColumnConfigSchema { + fields: ControlSpec[]; +} + +/** + * Plugin schema for the Datagrid column-settings sidebar. Returns the + * controls the viewer should render in the Style tab for a given column. + * + * Each entry in `fields` is a `ControlSpec` discriminated by `kind`. + * Composite kinds (`NumberStyle`, `DatetimeFormat`, `StringFormat`, + * `NumberFormat`, `AggregateDepth`) own a fixed key namespace and + * carry only their `default`. Primitive kinds (`Enum`, `Bool`, `Color`, + * etc.) carry their own `key` (storage) and `label` (UI) inline. + * + * Aggregate Depth is plugin-owned — surfaced only inside the Datagrid + * because rollup-mode pivots are a Datagrid concern. Emitted only when + * the active view has a non-empty `group_by` and rollup mode is `Rollup`. + */ +interface ColumnStats { + abs_max?: number; +} + +export default function column_config_schema( + this: DatagridPluginElement, + type: ColumnType, + _group: string | undefined, + _column_name: string, + current_value: Record | null, + viewer_config?: ViewerConfigLike, + column_stats?: ColumnStats, +): ColumnConfigSchema { + const fields: ControlSpec[] = []; + + if (type === "integer" || type === "float") { + const pos_fg = this.model!._pos_fg_color[0]; + const neg_fg = this.model!._neg_fg_color[0]; + const pos_bg = this.model!._pos_bg_color[0]; + const neg_bg = this.model!._neg_bg_color[0]; + + fields.push({ + kind: "Enum", + key: "number_fg_mode", + default: "color", + variants: [ + { value: "disabled", label: "Disabled" }, + { value: "color", label: "Color" }, + { value: "bar", label: "Bar" }, + { value: "label-bar", label: "Gradient" }, + ], + }); + + const fg_mode = (current_value?.number_fg_mode as string) ?? "color"; + if (fg_mode !== "disabled") { + fields.push({ + kind: "ColorRange", + key_pos: "pos_fg_color", + key_neg: "neg_fg_color", + default_pos: pos_fg, + default_neg: neg_fg, + is_gradient: false, + }); + } + + if (fg_mode === "bar" || fg_mode === "label-bar") { + fields.push({ + kind: "Number", + key: "fg_gradient", + default: column_stats?.abs_max ?? 0, + include: true, + }); + } + + fields.push({ + kind: "Enum", + key: "number_bg_mode", + default: "disabled", + variants: [ + { value: "disabled", label: "Disabled" }, + { value: "color", label: "Color" }, + { value: "gradient", label: "Gradient" }, + { value: "pulse", label: "Pulse" }, + ], + }); + + const bg_mode = (current_value?.number_bg_mode as string) ?? "disabled"; + if (bg_mode !== "disabled") { + fields.push({ + kind: "ColorRange", + key_pos: "pos_bg_color", + key_neg: "neg_bg_color", + default_pos: pos_bg, + default_neg: neg_bg, + is_gradient: bg_mode === "gradient" || bg_mode === "pulse", + }); + } + + if (bg_mode === "gradient") { + fields.push({ + kind: "Number", + key: "bg_gradient", + include: true, + default: column_stats?.abs_max ?? 0, + }); + } + + fields.push({ kind: "NumberFormat" }); + } else if (type === "date" || type === "datetime") { + fields.push({ kind: "DatetimeFormat" }); + + fields.push({ + kind: "Enum", + key: "datetime_color_mode", + default: "none", + variants: [ + { value: "none", label: "None" }, + { value: "foreground", label: "Foreground" }, + { value: "background", label: "Background" }, + ], + }); + + const dt_mode = + (current_value?.datetime_color_mode as string) ?? "none"; + + if (dt_mode !== "none") { + fields.push({ + kind: "Color", + key: "color", + default: this.model!._color[0], + }); + } + } else if (type === "string") { + fields.push({ kind: "StringFormat" }); + + fields.push({ + kind: "Enum", + key: "string_color_mode", + default: "none", + variants: [ + { value: "none", label: "None" }, + { value: "foreground", label: "Foreground" }, + { value: "background", label: "Background" }, + { value: "series", label: "Series" }, + ], + }); + + const str_mode = (current_value?.string_color_mode as string) ?? "none"; + if (str_mode !== "none") { + fields.push({ + kind: "Color", + key: "color", + default: this.model!._color[0], + }); + } + } + + const group_by = viewer_config?.group_by ?? []; + const is_rollup = + (viewer_config?.group_rollup_mode ?? "rollup") === "rollup"; + + if (group_by.length > 0 && is_rollup) { + fields.push({ kind: "AggregateDepth" }); + } + + return { fields }; +} diff --git a/packages/viewer-datagrid/src/ts/plugin/column_style_controls.ts b/packages/viewer-datagrid/src/ts/plugin/column_style_controls.ts deleted file mode 100644 index 4cafaff7a1..0000000000 --- a/packages/viewer-datagrid/src/ts/plugin/column_style_controls.ts +++ /dev/null @@ -1,76 +0,0 @@ -// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ -// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ -// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ -// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ -// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ -// ┃ Copyright (c) 2017, the Perspective Authors. ┃ -// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ -// ┃ This file is part of the Perspective library, distributed under the terms ┃ -// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ -// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - -import type { ColumnType } from "@perspective-dev/client"; -import type { DatagridPluginElement } from "../types.js"; - -interface NumberStyleOpts { - datagrid_number_style: { - fg_gradient: number; - pos_fg_color: string; - neg_fg_color: string; - number_fg_mode: string; - bg_gradient: number; - pos_bg_color: string; - neg_bg_color: string; - number_bg_mode: string; - }; - number_string_format: boolean; -} - -interface DatetimeStyleOpts { - datagrid_datetime_style?: { - color: string; - bg_color: string; - }; - datagrid_string_style?: { - color: string; - bg_color: string; - }; -} - -export type ColumnStyleOpts = NumberStyleOpts | DatetimeStyleOpts | null; - -export default function column_style_opts( - this: DatagridPluginElement, - type: ColumnType, - _group: string, -): ColumnStyleOpts { - if (type === "integer" || type === "float") { - return { - datagrid_number_style: { - fg_gradient: 0, - pos_fg_color: this.model!._pos_fg_color[0], - neg_fg_color: this.model!._neg_fg_color[0], - number_fg_mode: "color", - bg_gradient: 0, - pos_bg_color: this.model!._pos_bg_color[0], - neg_bg_color: this.model!._neg_bg_color[0], - number_bg_mode: "disabled", - }, - number_string_format: true, - }; - } else if (type === "date" || type === "datetime" || type === "string") { - const control = - type === "date" || type === "datetime" - ? "datagrid_datetime_style" - : `datagrid_string_style`; - return { - [control]: { - color: this.model!._color[0], - bg_color: this.model!._color[0], - }, - } as DatetimeStyleOpts; - } else { - return null; - } -} diff --git a/packages/viewer-datagrid/src/ts/plugin/draw.ts b/packages/viewer-datagrid/src/ts/plugin/draw.ts index 3abe1af671..db486b3fb1 100644 --- a/packages/viewer-datagrid/src/ts/plugin/draw.ts +++ b/packages/viewer-datagrid/src/ts/plugin/draw.ts @@ -47,6 +47,7 @@ export async function draw( this.regular_table.scrollLeft = 0; this._reset_scroll_left = false; } + if (this._reset_select) { this.regular_table.dispatchEvent( new CustomEvent("psp-deselect-all", { bubbles: false }), diff --git a/packages/viewer-datagrid/src/ts/plugin/restore.ts b/packages/viewer-datagrid/src/ts/plugin/restore.ts index cd9767b2b9..64cdf6dc78 100644 --- a/packages/viewer-datagrid/src/ts/plugin/restore.ts +++ b/packages/viewer-datagrid/src/ts/plugin/restore.ts @@ -92,16 +92,20 @@ export function restore( } } - if ("edit_mode" in token && token.edit_mode) { - if (EDIT_MODES.indexOf(token.edit_mode) !== -1) { + if ("edit_mode" in token) { + if (EDIT_MODES.indexOf(token.edit_mode!) !== -1) { toggle_edit_mode.call(this, token.edit_mode); } else { console.error("Unknown edit mode " + token.edit_mode); } + } else { + toggle_edit_mode.call(this, "READ_ONLY"); } if ("scroll_lock" in token) { toggle_scroll_lock.call(this, token.scroll_lock); + } else { + toggle_scroll_lock.call(this, false); } const datagrid = this.regular_table; diff --git a/packages/viewer-datagrid/src/ts/plugin/save.ts b/packages/viewer-datagrid/src/ts/plugin/save.ts index 87bcf560f3..da3cbca184 100644 --- a/packages/viewer-datagrid/src/ts/plugin/save.ts +++ b/packages/viewer-datagrid/src/ts/plugin/save.ts @@ -11,11 +11,7 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import { save_column_size_overrides } from "../model/column_overrides.js"; -import type { - DatagridPluginElement, - DatagridPluginConfig, - EditMode, -} from "../types.js"; +import type { DatagridPluginElement, DatagridPluginConfig } from "../types.js"; export function save( this: DatagridPluginElement, diff --git a/packages/viewer-datagrid/src/ts/style_handlers/body.ts b/packages/viewer-datagrid/src/ts/style_handlers/body.ts index 79a0fbf54d..ab305e8f37 100644 --- a/packages/viewer-datagrid/src/ts/style_handlers/body.ts +++ b/packages/viewer-datagrid/src/ts/style_handlers/body.ts @@ -185,8 +185,11 @@ export function applyBodyCellStyles( td.classList.toggle("psp-select-region-inactive", isSub); } } - } else { - td.classList.toggle("psp-select-region", false); + // } else if ( + // model._edit_mode === "READ_ONLY" || + // model._edit_mode === "EDIT" + // ) { + // td.classList.toggle("psp-select-region", false); } // Apply editable styling (if editable) @@ -212,6 +215,7 @@ export function applyBodyCellStyles( if (isEditable !== td.hasAttribute("contenteditable")) { td.toggleAttribute("contenteditable", isEditable); } + td.classList.toggle("boolean-editable", false); } } else { diff --git a/packages/viewer-datagrid/src/ts/style_handlers/column_header.ts b/packages/viewer-datagrid/src/ts/style_handlers/column_header.ts index 7be3865204..345b79ac5f 100644 --- a/packages/viewer-datagrid/src/ts/style_handlers/column_header.ts +++ b/packages/viewer-datagrid/src/ts/style_handlers/column_header.ts @@ -72,7 +72,10 @@ export function style_selected_column( if (model._config.columns.length > 1) { for (const r of regularTable.querySelectorAll("td")) { const meta = regularTable.getMeta(r); - if (!meta?.column_header) continue; + if (!meta?.column_header) { + continue; + } + const isOpen = meta.column_header[ meta.column_header.length - 2 @@ -104,8 +107,9 @@ export function styleColumnHeaderRow( !metadata || metadata.type === "body" || metadata.type === "row_header" - ) + ) { continue; + } const column_name = metadata.column_header?.[model._config.split_by.length]; diff --git a/packages/viewer-datagrid/src/ts/style_handlers/consolidated.ts b/packages/viewer-datagrid/src/ts/style_handlers/consolidated.ts index e63dbb4728..66263a9f36 100644 --- a/packages/viewer-datagrid/src/ts/style_handlers/consolidated.ts +++ b/packages/viewer-datagrid/src/ts/style_handlers/consolidated.ts @@ -96,6 +96,7 @@ export function createConsolidatedStyleListener( metadata, }); } + groupHeaderRows.push(rowData); } } diff --git a/packages/viewer-datagrid/src/ts/style_handlers/editable.ts b/packages/viewer-datagrid/src/ts/style_handlers/editable.ts index d47ca279a9..4d40fdd469 100644 --- a/packages/viewer-datagrid/src/ts/style_handlers/editable.ts +++ b/packages/viewer-datagrid/src/ts/style_handlers/editable.ts @@ -27,7 +27,9 @@ export function applyColumnHeaderStyles( regularTable: RegularTableElement, viewer: HTMLPerspectiveViewerElement, ): void { - if (headerRows.length === 0) return; + if (headerRows.length === 0) { + return; + } // Style selected column for settings panel const selectedColumn = model._column_settings_selected_column; @@ -68,7 +70,9 @@ export function applyColumnHeaderStyles( for (let i = 0; i < titlesRow.cells.length; i++) { const title = titlesRow.cells[i]?.element; const editBtn = editBtnsRow.cells[i]?.element; - if (!title || !editBtn) continue; + if (!title || !editBtn) { + continue; + } const open = title.textContent === selectedColumn; title.classList.toggle("psp-menu-open", open); diff --git a/packages/viewer-datagrid/src/ts/style_handlers/focus.ts b/packages/viewer-datagrid/src/ts/style_handlers/focus.ts index b2e732d0ce..1770bc35c9 100644 --- a/packages/viewer-datagrid/src/ts/style_handlers/focus.ts +++ b/packages/viewer-datagrid/src/ts/style_handlers/focus.ts @@ -37,6 +37,7 @@ export function applyFocusStyle( if (host.activeElement !== td) { td.focus({ preventScroll: true }); } + return; } } @@ -82,6 +83,7 @@ export function focusSelectedCell( if (host.activeElement !== cell) { (cell as HTMLElement).focus({ preventScroll: true }); } + return true; } } diff --git a/packages/viewer-datagrid/src/ts/style_handlers/group_header.ts b/packages/viewer-datagrid/src/ts/style_handlers/group_header.ts index 4140469c62..53e42e238d 100644 --- a/packages/viewer-datagrid/src/ts/style_handlers/group_header.ts +++ b/packages/viewer-datagrid/src/ts/style_handlers/group_header.ts @@ -28,12 +28,14 @@ export function applyGroupHeaderStyles( let marked = new Set(); for (let y = 0; y < headerRows.length; y++) { - const { row, cells } = headerRows[y]; + const { cells } = headerRows[y]; const tops = new Set(); for (let x = 0; x < cells.length; x++) { const { element: td, metadata } = cells[x]; - if (!metadata) continue; + if (!metadata) { + continue; + } td.style.backgroundColor = ""; @@ -63,13 +65,17 @@ export function applyGroupHeaderStyles( // Calculate spanning for psp-is-top let xx = x; - for (; m[y] && m[y][xx]; ++xx); + for (; m[y] && m[y][xx]; ++xx) {} + tops.add(xx); const cell = td; for (let tx = xx; tx < xx + cell.colSpan; ++tx) { for (let ty = y; ty < y + cell.rowSpan; ++ty) { - if (!m[ty]) m[ty] = []; + if (!m[ty]) { + m[ty] = []; + } + m[ty][tx] = true; } } diff --git a/packages/viewer-datagrid/test/js/column_style.spec.js b/packages/viewer-datagrid/test/js/column_style.spec.js index 14d1ca3573..17c9145c71 100644 --- a/packages/viewer-datagrid/test/js/column_style.spec.js +++ b/packages/viewer-datagrid/test/js/column_style.spec.js @@ -13,7 +13,7 @@ import { test, expect } from "@perspective-dev/test"; import { compareContentsToSnapshot } from "@perspective-dev/test"; -async function test_column(page, selector, selector2) { +async function test_column(page, selector, container_class) { const { x, y } = await page.evaluate(async (selector) => { const viewer = document.querySelector("perspective-viewer"); await viewer.getTable(); @@ -37,7 +37,7 @@ async function test_column(page, selector, selector2) { }, selector); await page.mouse.click(x, y); - const column_style_selector = `#column-style-container.${selector2}-column-style-container`; + const column_style_selector = `#column-style-container.${container_class}`; await page.waitForSelector(column_style_selector); await new Promise((x) => setTimeout(x, 3000)); @@ -168,9 +168,8 @@ test.describe("Column Style Tests", () => { const contents = await page .locator(`perspective-viewer-datagrid regular-table`) .innerHTML(); - await compareContentsToSnapshot(contents, [ - "number_column_style_pulse.txt", - ]); + + await compareContentsToSnapshot(contents); }); test("Pulse styling works when settings panel is open", async ({ @@ -205,9 +204,8 @@ test.describe("Column Style Tests", () => { const contents = await page .locator(`perspective-viewer-datagrid regular-table`) .innerHTML(); - await compareContentsToSnapshot(contents, [ - "number_column_style_pulse_with_settings.txt", - ]); + + await compareContentsToSnapshot(contents); }); test("Column style menu opens for numeric columns", async ({ page }) => { @@ -224,8 +222,8 @@ test.describe("Column Style Tests", () => { }); }); - const contents = await test_column(page, "", "number"); - await compareContentsToSnapshot(contents, ["number_column_style.txt"]); + const contents = await test_column(page, "", "tab-section"); + await compareContentsToSnapshot(contents); }); test("Column style menu opens for string columns", async ({ page }) => { @@ -242,8 +240,318 @@ test.describe("Column Style Tests", () => { }); }); - const contents = await test_column(page, ":nth-child(2)", "string"); + const contents = await test_column( + page, + ":nth-child(2)", + "string-column-style-container", + ); + + await compareContentsToSnapshot(contents); + }); + + // ────────────────────────────────────────────────────────────────── + // Foreground rendering modes against a float column that contains + // negatives ("Profit"), so the pos/neg color split has signal in + // both halves of the range. + // ────────────────────────────────────────────────────────────────── + test("Bar foreground on float column with negatives", async ({ page }) => { + await page.goto("/tools/test/src/html/basic-test.html"); + await page.evaluate(async () => { + while (!window["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + + await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer"); + await viewer.restore({ + plugin: "Datagrid", + columns: ["Row ID", "Profit"], + columns_config: { + Profit: { number_fg_mode: "bar" }, + }, + }); + }); + + const contents = await page + .locator(`perspective-viewer-datagrid regular-table`) + .innerHTML(); + + await compareContentsToSnapshot(contents); + }); + + test("Label-bar foreground on float column with negatives", async ({ + page, + }) => { + await page.goto("/tools/test/src/html/basic-test.html"); + await page.evaluate(async () => { + while (!window["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + + await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer"); + await viewer.restore({ + plugin: "Datagrid", + columns: ["Row ID", "Profit"], + columns_config: { + Profit: { number_fg_mode: "label-bar" }, + }, + }); + }); + + const contents = await page + .locator(`perspective-viewer-datagrid regular-table`) + .innerHTML(); + + await compareContentsToSnapshot(contents); + }); + + test("Label-bar foreground + gradient background on float column", async ({ + page, + }) => { + await page.goto("/tools/test/src/html/basic-test.html"); + await page.evaluate(async () => { + while (!window["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + + await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer"); + await viewer.restore({ + plugin: "Datagrid", + columns: ["Row ID", "Profit"], + columns_config: { + Profit: { + number_fg_mode: "label-bar", + number_bg_mode: "gradient", + }, + }, + }); + }); + + const contents = await page + .locator(`perspective-viewer-datagrid regular-table`) + .innerHTML(); + + await compareContentsToSnapshot(contents); + }); + + // ────────────────────────────────────────────────────────────────── + // Sidebar should re-query schema and surface extra controls (the + // background `ColorRange` and gradient `Number` max) when + // `number_bg_mode` is set to `gradient`. + // ────────────────────────────────────────────────────────────────── + test("Sidebar surfaces gradient controls when bg_mode = gradient", async ({ + page, + }) => { + await page.goto("/tools/test/src/html/basic-test.html"); + await page.evaluate(async () => { + while (!window["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + + await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer"); + await viewer.restore({ + plugin: "Datagrid", + columns: ["Row ID", "Profit"], + settings: true, + columns_config: { + Profit: { number_bg_mode: "gradient" }, + }, + }); + }); + + const { x, y } = await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer"); + const editBtn = viewer + .querySelector("perspective-viewer-datagrid") + .shadowRoot.querySelector( + "#psp-column-edit-buttons th.psp-menu-enabled:nth-child(2) span", + ); + + const rect = editBtn.getBoundingClientRect(); + return { + x: Math.floor(rect.left + rect.width / 2), + y: Math.floor(rect.top + rect.height / 2), + }; + }); + + await page.mouse.click(x, y); + + // The schema for `Profit` with bg_mode=gradient should emit a + // `ColorRange` (background-pos/neg) and a `Number` field for + // `bg_gradient`. Both are tab-section children in the StyleTab. + await page + .locator("perspective-viewer #column_settings_sidebar") + .waitFor(); + + const sidebar_locator = page.locator( + "perspective-viewer #column_settings_sidebar #style-tab", + ); + + // Background ColorRange ids derive from the `label` + // ("background") in the Datagrid schema. + await sidebar_locator.locator(".pos_bg_color").waitFor(); + await sidebar_locator.locator(".neg_bg_color").waitFor(); + + // Snapshot the sidebar's style-tab DOM as a holistic check. + const contents = await sidebar_locator.innerHTML(); + await compareContentsToSnapshot(contents); + }); + + // ────────────────────────────────────────────────────────────────── + // At least one columns_config setting from each column type renders + // a visible change in the grid when applied. + // ────────────────────────────────────────────────────────────────── + test("float number_format use_grouping renders in grid", async ({ + page, + }) => { + await page.goto("/tools/test/src/html/basic-test.html"); + await page.evaluate(async () => { + while (!window["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + + await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer"); + await viewer.restore({ + plugin: "Datagrid", + columns: ["Row ID", "Sales"], + columns_config: { + Sales: { + number_format: { use_grouping: "always" }, + }, + }, + }); + }); + + const contents = await page + .locator(`perspective-viewer-datagrid regular-table`) + .innerHTML(); + + await compareContentsToSnapshot(contents); + }); + + test("integer number_format notation renders in grid", async ({ page }) => { + await page.goto("/tools/test/src/html/basic-test.html"); + await page.evaluate(async () => { + while (!window["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + + await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer"); + await viewer.restore({ + plugin: "Datagrid", + columns: ["Row ID"], + columns_config: { + "Row ID": { + number_format: { notation: "compact" }, + }, + }, + }); + }); + + const contents = await page + .locator(`perspective-viewer-datagrid regular-table`) + .innerHTML(); + + await compareContentsToSnapshot(contents); + }); + + test("string format renders in grid", async ({ page }) => { + await page.goto("/tools/test/src/html/basic-test.html"); + await page.evaluate(async () => { + while (!window["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + + await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer"); + await viewer.restore({ + plugin: "Datagrid", + columns: ["Row ID", "State"], + columns_config: { + State: { format: "bold" }, + }, + }); + }); + + const contents = await page + .locator(`perspective-viewer-datagrid regular-table`) + .innerHTML(); + + await compareContentsToSnapshot(contents); + }); + + test("date date_format renders in grid", async ({ page }) => { + await page.goto("/tools/test/src/html/basic-test.html"); + await page.evaluate(async () => { + while (!window["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + + await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer"); + await viewer.restore({ + plugin: "Datagrid", + columns: ["Row ID", "Order Date"], + columns_config: { + "Order Date": { + date_format: { + date_style: "full", + time_style: "medium", + }, + }, + }, + }); + }); + + const contents = await page + .locator(`perspective-viewer-datagrid regular-table`) + .innerHTML(); + + await compareContentsToSnapshot(contents); + }); + + test("datetime date_format renders in grid", async ({ page }) => { + await page.goto("/tools/test/src/html/basic-test.html"); + await page.evaluate(async () => { + while (!window["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + + await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer"); + await viewer.restore({ + plugin: "Datagrid", + // Order Date is a datetime in basic-test fixture. + columns: ["Row ID", "Order Date"], + columns_config: { + "Order Date": { + date_format: { + date_style: "long", + time_style: "long", + }, + }, + }, + }); + }); + + const contents = await page + .locator(`perspective-viewer-datagrid regular-table`) + .innerHTML(); - await compareContentsToSnapshot(contents, ["string_column_style.txt"]); + await compareContentsToSnapshot(contents); }); }); diff --git a/packages/viewer-datagrid/tsconfig.json b/packages/viewer-datagrid/tsconfig.json index a6c436932c..2eeda123ae 100644 --- a/packages/viewer-datagrid/tsconfig.json +++ b/packages/viewer-datagrid/tsconfig.json @@ -13,5 +13,5 @@ "noImplicitAny": true, "esModuleInterop": true }, - "include": ["./src/ts/**/*", "./types.d.ts"] + "include": ["./src/ts/**/*", "./types.d.ts", "./index.d.ts"] } diff --git a/packages/viewer-openlayers/package.json b/packages/viewer-openlayers/package.json deleted file mode 100644 index 92ebf8411c..0000000000 --- a/packages/viewer-openlayers/package.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "name": "@perspective-dev/viewer-openlayers", - "version": "4.4.1", - "unpkg": "dist/cdn/perspective-viewer-openlayers.js", - "jsdelivr": "dist/cdn/perspective-viewer-openlayers.js", - "exports": { - ".": "./dist/esm/perspective-viewer-openlayers.js", - "./dist/*": "./dist/*", - "./package.json": "./package.json" - }, - "type": "module", - "files": [ - "dist/**/*", - "index.d.ts" - ], - "types": "index.d.ts", - "author": "", - "license": "Apache-2.0", - "publishConfig": { - "access": "public" - }, - "scripts": { - "build": "node ./build.mjs", - "clean": "node ./clean.mjs" - }, - "dependencies": { - "@perspective-dev/client": "workspace:", - "@perspective-dev/viewer": "workspace:", - "d3": "catalog:", - "d3-color": "catalog:", - "gradient-parser": "catalog:", - "less": "catalog:", - "ol": "catalog:" - }, - "devDependencies": { - "@perspective-dev/esbuild-plugin": "workspace:", - "@perspective-dev/test": "workspace:", - "lightningcss": "catalog:" - } -} diff --git a/packages/viewer-openlayers/src/css/perspective-viewer-openlayers.css b/packages/viewer-openlayers/src/css/perspective-viewer-openlayers.css deleted file mode 100644 index e5578b0dac..0000000000 --- a/packages/viewer-openlayers/src/css/perspective-viewer-openlayers.css +++ /dev/null @@ -1,2 +0,0 @@ -@import "ol/ol.css"; -@import "./plugin.css"; diff --git a/packages/viewer-openlayers/src/css/plugin.css b/packages/viewer-openlayers/src/css/plugin.css deleted file mode 100644 index 45ef4dc91a..0000000000 --- a/packages/viewer-openlayers/src/css/plugin.css +++ /dev/null @@ -1,94 +0,0 @@ -/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ - * ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ - * ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ - * ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ - * ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ - * ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ - * ┃ Copyright (c) 2017, the Perspective Authors. ┃ - * ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ - * ┃ This file is part of the Perspective library, distributed under the terms ┃ - * ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ - * ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - */ - -/* @import "ol/ol.css"; */ - -:host { - #container { - position: absolute; - left: 0; - top: 0; - right: 0; - bottom: 0; - overflow: hidden; - - & .ol-zoom { - left: auto; - top: 0.5em; - right: 0.5em; - } - - & .ol-attribution { - filter: var(--psp-openlayers--attribution--filter, none); - } - - & .map-tooltip { - position: absolute; - display: none; - background: var(--psp-openlayers--element--background, #eee); - border: 1px solid #888; - border-radius: 5px; - padding: 5px; - pointer-events: none; - opacity: 0.8; - font-size: 0.8em; - - &.show { - display: block; - } - - & .title { - margin: 0 0 0.5em 0; - font-size: 1.2em; - } - - & ul { - margin: 0 0 0.5em 0; - padding: 0; - list-style: none; - - & li { - margin-bottom: 0.2em; - } - - & .label { - display: inline-block; - font-weight: bold; - margin-right: 1em; - } - } - } - - & .map-legend { - position: absolute; - box-sizing: border-box; - top: 0.5em; - right: 4em; - width: 80px; - height: 122px; - background: var(--psp-openlayers--element--background, #eee); - border-radius: 5px; - pointer-events: none; - opacity: 0.8; - font-size: 0.8em; - - & path.domain { - visibility: hidden; - } - - & .tick line { - color: var(--psp-openlayers--element--background, #eee); - } - } - } -} diff --git a/packages/viewer-openlayers/src/js/legend/legend.js b/packages/viewer-openlayers/src/js/legend/legend.js deleted file mode 100644 index 4c447ac5cd..0000000000 --- a/packages/viewer-openlayers/src/js/legend/legend.js +++ /dev/null @@ -1,79 +0,0 @@ -// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ -// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ -// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ -// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ -// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ -// ┃ Copyright (c) 2017, the Perspective Authors. ┃ -// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ -// ┃ This file is part of the Perspective library, distributed under the terms ┃ -// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ -// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - -import { select, axisRight, scaleLinear, range } from "d3"; - -const height = 100; - -export function showLegend(container, colorScale, extent) { - const legend = getOrCreateDiv(container); - const domain = [extent.min, extent.max]; - const scale = scaleLinear().domain(domain).range([height, 0]).nice(); - - // axis - const axis = axisRight(scale).tickSize(15).tickArguments([5]); - legend.select(".map-legend-axis").call(axis); - - // color bar - const colorLines = range(1, height + 1, 1); - const lines = legend - .select(".map-legend-color") - .selectAll("line") - .data(colorLines); - lines - .enter() - .append("line") - .attr("x1", 0) - .attr("x2", 15) - .merge(lines) - .attr("y1", (d) => (d * height) / 100) - .attr("y2", (d) => (d * height) / 100) - .attr( - "stroke", - (d) => - colorScale( - ((100 - d) * (extent.max - extent.min)) / 100 + extent.min, - ).stroke, - ); - - let maxLabelWidth = 0; - legend.selectAll(".map-legend-axis .tick text").each((d, i, node) => { - maxLabelWidth = Math.max(maxLabelWidth, node[i].getBBox().width); - }); - - legend.style("width", `${maxLabelWidth + 37}px`); -} - -export function hideLegend(container) { - select(container).select(".map-legend").remove(); -} - -const getOrCreateDiv = (container) => { - const selection = select(container); - let legend = selection.select(".map-legend"); - - if (legend.size() === 0) { - legend = selection.append("svg").attr("class", "map-legend"); - // color bar - legend - .append("g") - .attr("class", "map-legend-color") - .attr("transform", "translate(10, 10)"); - // axis - legend - .append("g") - .attr("class", "map-legend-axis") - .attr("transform", "translate(10, 10)"); - } - - return legend; -}; diff --git a/packages/viewer-openlayers/src/js/plugin/plugin.js b/packages/viewer-openlayers/src/js/plugin/plugin.js deleted file mode 100644 index e3439d97c8..0000000000 --- a/packages/viewer-openlayers/src/js/plugin/plugin.js +++ /dev/null @@ -1,113 +0,0 @@ -// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ -// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ -// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ -// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ -// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ -// ┃ Copyright (c) 2017, the Perspective Authors. ┃ -// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ -// ┃ This file is part of the Perspective library, distributed under the terms ┃ -// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ -// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - -import mapView from "../views/map-view"; -import views from "../views/views"; -import css from "../../../dist/css/perspective-viewer-openlayers.css"; - -views.forEach(async (plugin) => { - customElements.define( - plugin.plugin.type, - class extends HTMLElement { - constructor() { - super(); - this.attachShadow({ mode: "open" }); - const style = document.createElement("style"); - style.textContent = css; - this.shadowRoot.appendChild(style); - const container = document.createElement("div"); - container.setAttribute("id", "container"); - this.shadowRoot.appendChild(container); - } - - async draw(view) { - drawView(plugin).call(this.shadowRoot.children[1], view); - } - - async update(view) { - drawView(plugin).call(this.shadowRoot.children[1], view); - } - - async resize() { - mapView.resize(this.shadowRoot.children[1]); - } - - get name() { - return plugin.plugin.name; - } - - get category() { - return "OpenStreetMap"; - } - - async restyle(view) { - mapView.restyle(this.shadowRoot.children[1]); - } - - get select_mode() { - return "toggle"; - } - - get min_config_columns() { - return 2; - } - - get config_column_names() { - return plugin.plugin.initial.names; - } - - save() { - return mapView.save(this.shadowRoot.children[1]); - } - - async restore(token) { - mapView.restore(this.shadowRoot.children[1], token); - } - - delete() {} - }, - ); - - await customElements.whenDefined("perspective-viewer"); - customElements.get("perspective-viewer").registerPlugin(plugin.plugin.type); -}); - -function drawView(viewEntryPoint) { - return async function (view) { - const table = await this.getRootNode().host.parentElement.getTable(); - - // TODO ue faster serialization method - const [tschema, schema, data, config] = await Promise.all([ - table.schema(), - view.schema(), - view.to_json(), - this.getRootNode().host.parentElement.save(), - ]); - - config.real_columns = config.columns; - config.columns = config.columns.filter((x) => !!x); - - // Enrich color info - if (!!config.real_columns[2]) { - const [min, max] = await view.get_min_max(config.real_columns[2]); - config.color_extents = { min, max }; - } - - // Enrich size info - if (!!config.real_columns[3]) { - const [min, max] = await view.get_min_max(config.real_columns[3]); - config.size_extents = { min, max }; - } - - viewEntryPoint(this, Object.assign({ schema, tschema, data }, config)); - }; -} diff --git a/packages/viewer-openlayers/src/js/style/categoryShapes.js b/packages/viewer-openlayers/src/js/style/categoryShapes.js deleted file mode 100644 index 802008179d..0000000000 --- a/packages/viewer-openlayers/src/js/style/categoryShapes.js +++ /dev/null @@ -1,111 +0,0 @@ -// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ -// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ -// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ -// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ -// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ -// ┃ Copyright (c) 2017, the Perspective Authors. ┃ -// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ -// ┃ This file is part of the Perspective library, distributed under the terms ┃ -// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ -// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - -import { - symbol, - symbolCross, - symbolDiamond, - symbolSquare, - symbolStar, - symbolTriangle, - symbolWye, -} from "d3"; - -import { Polygon, Circle } from "ol/geom"; -import { toContext } from "ol/render"; -import { Style, Fill, Stroke } from "ol/style"; - -const shapes = [ - null, - symbolCross, - symbolDiamond, - symbolSquare, - symbolStar, - symbolTriangle, - symbolWye, -]; -let shapePoints = null; - -const defaultValueFn = (d) => d.category; -export const categoryShapeMap = (container, data, valueFn = defaultValueFn) => { - const categoryMap = categoryPointsMap(data, valueFn); - const style = new Style({ renderer: createRenderer(categoryMap) }); - return () => style; -}; - -function categoryPointsMap(data, valueFn) { - loadShapes(); - const categories = {}; - let catIndex = 0; - data.forEach((point) => { - const category = valueFn(point); - if (!categories[category]) { - categories[category] = shapePoints[catIndex]; - - catIndex++; - if (catIndex >= shapePoints.length) catIndex = 0; - } - }); - return categories; -} - -function createRenderer(categoryMap) { - return (location, { context, feature }) => { - const { category, style, scale } = feature.getProperties(); - const points = categoryMap[category]; - - var render = toContext(context, { pixelRatio: 1 }); - - const fillStyle = new Fill({ color: style.fill }); - const strokeStyle = new Stroke({ color: style.stroke }); - render.setFillStrokeStyle(fillStyle, strokeStyle); - - if (points.length) { - const sizedPoints = points.map((p) => [ - p[0] * scale + location[0], - p[1] * scale + location[1], - ]); - render.drawPolygon(new Polygon([sizedPoints])); - } else { - render.drawCircle(new Circle(location, scale * 8)); - } - }; -} - -function loadShapes() { - if (!shapePoints) { - shapePoints = shapes.map(shapeToPoints); - } -} - -function shapeToPoints(d3Shape) { - if (d3Shape) { - const shapeSymbol = symbol().type(d3Shape); - const shapePath = shapeSymbol.size(150)(); - const points = shapePath - .substring(1, shapePath.length - 1) - .split("L") - .map((p) => p.split(",").map((c) => parseFloat(c))); - - if (points.length === 1) { - // Square - const l = -points[0][0]; - points.push([l, -l]); - points.push([l, l]); - points.push([-l, l]); - } - - points.push(points[0]); - return points; - } - return []; -} diff --git a/packages/viewer-openlayers/src/js/style/linearColors.js b/packages/viewer-openlayers/src/js/style/linearColors.js deleted file mode 100644 index ab0143fb97..0000000000 --- a/packages/viewer-openlayers/src/js/style/linearColors.js +++ /dev/null @@ -1,73 +0,0 @@ -// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ -// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ -// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ -// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ -// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ -// ┃ Copyright (c) 2017, the Perspective Authors. ┃ -// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ -// ┃ This file is part of the Perspective library, distributed under the terms ┃ -// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ -// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - -import * as gparser from "gradient-parser"; -import { interpolate, scaleSequential } from "d3"; - -import { computedStyle, toFillAndStroke } from "./computed"; - -const GRADIENT_COLOR_VAR = "--map-gradient"; -const GRADIENT_DEFAULT = - "linear-gradient(#4d342f 0%, #e4521b 22.5%, #decb45 42.5%, #a0a0a0 50%, #bccda8 57.5%, #42b3d5 67.5%, #1a237e 100%)"; - -export const linearColorScale = (container, extent) => { - const gradient = getGradient(container); - - const interpolator = multiInterpolator(gradient); - - const domain = [ - Math.min(extent.min, -extent.max), - Math.max(-extent.min, extent.max), - ]; - return scaleSequential(interpolator).domain(domain); -}; - -const getGradient = (container) => { - const computed = computedStyle(container); - const gradient = computed(GRADIENT_COLOR_VAR, GRADIENT_DEFAULT); - - return gparser - .parse(gradient)[0] - .colorStops.map((g) => [ - g.length.value / 100, - toFillAndStroke(`#${g.value}`), - ]) - .sort((a, b) => a[0] - b[0]); -}; - -const multiInterpolator = (gradientPairs) => { - // A new interpolator that calls through to a set of - // interpolators between each value/color pair - const interpolators = gradientPairs - .slice(1) - .map((p, i) => interpolate(gradientPairs[i][1], p[1])); - return (value) => { - const index = gradientPairs.findIndex( - (p, i) => - i < gradientPairs.length - 1 && - value <= gradientPairs[i + 1][0] && - value > p[0], - ); - if (index === -1) { - if (value <= gradientPairs[0][0]) { - return gradientPairs[0][1]; - } - return gradientPairs[gradientPairs.length - 1][1]; - } - - const interpolator = interpolators[index]; - const [value1] = gradientPairs[index]; - const [value2] = gradientPairs[index + 1]; - - return interpolator((value - value1) / (value2 - value1)); - }; -}; diff --git a/packages/viewer-openlayers/src/js/tooltip/tooltip.js b/packages/viewer-openlayers/src/js/tooltip/tooltip.js deleted file mode 100644 index ed29e9e100..0000000000 --- a/packages/viewer-openlayers/src/js/tooltip/tooltip.js +++ /dev/null @@ -1,270 +0,0 @@ -// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ -// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ -// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ -// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ -// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ -// ┃ Copyright (c) 2017, the Perspective Authors. ┃ -// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ -// ┃ This file is part of the Perspective library, distributed under the terms ┃ -// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ -// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - -export function createTooltip(container, map) { - let data = null; - let config = null; - let vectorSource = null; - let regions = false; - let onHighlight = null; - - let currentPoint = null; - let currentFeature = null; - - map.on("pointermove", (evt) => { - if (!evt.dragging) { - onMove(evt); - } else { - onLeave(evt); - } - }); - map.on("click", (evt) => onClick(evt)); - - container.addEventListener("mouseleave", (evt) => onLeave(evt)); - - const tooltipDiv = document.createElement("div"); - tooltipDiv.className = "map-tooltip"; - container.appendChild(tooltipDiv); - - const _tooltip = {}; - _tooltip.data = (...args) => { - if (args.length) { - data = args[0]; - return _tooltip; - } - return data; - }; - _tooltip.config = (...args) => { - if (args.length) { - config = args[0]; - return _tooltip; - } - return config; - }; - _tooltip.vectorSource = (...args) => { - if (args.length) { - vectorSource = args[0]; - return _tooltip; - } - return vectorSource; - }; - _tooltip.regions = (...args) => { - if (args.length) { - regions = args[0]; - return _tooltip; - } - return regions; - }; - _tooltip.onHighlight = (...args) => { - if (args.length) { - onHighlight = args[0]; - return _tooltip; - } - return onHighlight; - }; - - const onMove = (evt) => { - // Find the closest point - const { coordinate } = evt; - const hoverFeature = getClosest(coordinate); - const closest = hoverFeature && hoverFeature.get("data"); - if (closest) { - const geometry = hoverFeature.getGeometry(); - const extent = geometry.getExtent(); - - const position = [ - (extent[0] + extent[2]) / 2, - (extent[1] + extent[3]) / 2, - ]; - const screen = map.getPixelFromCoordinate(position); - - if (!regions) { - const mouse = map.getPixelFromCoordinate(coordinate); - if (distanceBetween(screen, mouse) > 50) { - return onLeave(evt); - } - } - - if (currentPoint !== closest) { - currentPoint = closest; - highlighFeature(hoverFeature); - - tooltipDiv.innerHTML = composeHtml(currentPoint); - tooltipDiv.style.left = `${screen[0]}px`; - tooltipDiv.style.top = `${screen[1]}px`; - tooltipDiv.className = "map-tooltip show"; - } - } else { - return onLeave(evt); - } - }; - - const getClosest = (coordinate) => { - if (regions) { - const hitFeatures = - vectorSource.getFeaturesAtCoordinate(coordinate); - return hitFeatures.length ? hitFeatures[0] : null; - } - return vectorSource.getClosestFeatureToCoordinate(coordinate); - }; - - const highlighFeature = (feature) => { - restoreFeature(); - currentFeature = feature; - - if (currentFeature && onHighlight) { - onHighlight(currentFeature, true); - - // if (regions) { - // } else { - // const imageStyle = featureStyle && featureStyle.getImage(); - // if (featureStyle && imageStyle) { - // const color = imageStyle.getStroke().getColor(); - - // const newStyle = new CircleStyle({ - // stroke: new Stroke({color: lightenRgb(color, 0.25)}), - // fill: new Fill({color: lightenRgb(color, 0.5)}), - // radius: imageStyle.getRadius() - // }); - - // currentFeature.setStyle(new Style({image: newStyle, zIndex: 10})); - // } else { - // const color = featureProperties.stroke; - // currentFeature.setProperties({ - // stroke: lightenRgb(color, 0.25), - // fill: lightenRgb(color, 0.5) - // }); - // } - // } - } - }; - - const restoreFeature = () => { - if (currentFeature && onHighlight) { - onHighlight(currentFeature, false); - - // currentFeature.setProperties(featureProperties); - // currentFeature.setStyle(featureStyle); - } - currentFeature = null; - }; - - const onLeave = () => { - tooltipDiv.className = "map-tooltip"; - currentPoint = null; - restoreFeature(); - }; - - const onClick = () => { - if (currentPoint) { - const column_names = config.columns; - const groupFilters = getFilter( - getListFromJoin(currentPoint.group, config.group_by), - ); - const categoryFilters = getFilter( - getListFromJoin(currentPoint.category, config.split_by), - ); - const filters = config.filter - .concat(groupFilters) - .concat(categoryFilters); - - container.dispatchEvent( - new CustomEvent("perspective-click", { - bubbles: true, - composed: true, - detail: { - column_names, - config: { filters }, - row: currentPoint.row, - }, - }), - ); - } - }; - - const composeHtml = (point) => { - const group = composeGroup(point.group); - const aggregates = composeAggregates(point.cols, regions ? 0 : 2); - const category = composeCategory(point.category); - const location = regions ? "" : composeLocation(point.cols); - - return `${group}${aggregates}${category}${location}`; - }; - - const composeAggregates = (cols, fromIndex) => { - if (!cols) return ""; - const list = config.real_columns.slice(fromIndex).map((c, i) => - c === null - ? null - : { - name: c, - value: cols[i + fromIndex].toLocaleString(), - }, - ); - return composeList(list); - }; - - const composeGroup = (group) => { - const groupList = getListFromJoin(group, config.group_by); - if (groupList.length === 1) { - return `

${group}

`; - } - return composeList(groupList); - }; - - const composeCategory = (category) => { - return composeList(getListFromJoin(category, config.split_by)); - }; - - const getListFromJoin = (join, pivot) => { - if (join && pivot.length) { - const values = join.split("|"); - return values.map((value, i) => ({ name: pivot[i], value })); - } - return []; - }; - - const getFilter = (list) => { - return list.map((item) => [item.name, "==", item.value]); - }; - - const composeList = (items) => { - if (items.length) { - const itemList = items.map((item) => - item === null - ? `` - : `
  • ${sanitize( - item.name, - )}${sanitize(item.value)}
  • `, - ); - return `
      ${itemList.join("")}
    `; - } - return ""; - }; - - const composeLocation = (cols) => { - return `(${cols[0]}, ${cols[1]})`; - }; - - const sanitize = (text) => { - tooltipDiv.innerText = text; - return tooltipDiv.innerHTML; - }; - - const distanceBetween = (c1, c2) => { - return Math.sqrt( - Math.pow(c1[0] - c2[0], 2) + Math.pow(c1[1] - c2[1], 2), - ); - }; - - return _tooltip; -} diff --git a/packages/viewer-openlayers/src/js/views/base-map.js b/packages/viewer-openlayers/src/js/views/base-map.js deleted file mode 100644 index 956db86640..0000000000 --- a/packages/viewer-openlayers/src/js/views/base-map.js +++ /dev/null @@ -1,124 +0,0 @@ -// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ -// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ -// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ -// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ -// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ -// ┃ Copyright (c) 2017, the Perspective Authors. ┃ -// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ -// ┃ This file is part of the Perspective library, distributed under the terms ┃ -// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ -// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - -import { computedStyle } from "../style/computed"; -import { createTooltip } from "../tooltip/tooltip"; - -// const ol = require("ol"); -import { Map, View } from "ol"; -import TileLayer from "ol/layer/Tile"; -import { OSM } from "ol/source"; - -const DEFAULT_TILE_URL = - '"http://{a-c}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png"'; - -const PRIVATE = Symbol("map-view-data"); - -export function baseMap(container) { - // Setup the initial base map - return getOrCreateMap(container); -} - -baseMap.resize = (container) => { - if (container[PRIVATE]) { - container[PRIVATE].map.updateSize(); - } -}; - -baseMap.restyle = (container) => { - if (container[PRIVATE]) { - setTileUrl(container); - } -}; - -baseMap.restore = (container, token) => { - if (container[PRIVATE]) { - container[PRIVATE].next_zoom_state = token; - } -}; - -baseMap.save = (container) => { - if (container[PRIVATE]) { - const view = container[PRIVATE].map.getView(); - return { - center: view.getCenter(), - zoom: view.getZoom(), - }; - } - return {}; -}; - -baseMap.initializeView = (container, extent) => { - initializeView(container, extent); -}; - -function getOrCreateMap(container) { - if (!container[PRIVATE]) { - // console.log - const tileLayer = new TileLayer(); - const map = new Map({ - target: container, - layers: [tileLayer], - view: new View({ center: [0, 0], zoom: 1 }), - }); - - const tooltip = createTooltip(container, map); - container[PRIVATE] = { - map, - tileLayer, - tooltip, - invalid_extents: true, - }; - } - removeVectorLayer(container); - setTileUrl(container); - return container[PRIVATE]; -} - -function initializeView(container, vectorSource) { - const map = container[PRIVATE].map; - const extents = vectorSource.getExtent(); - if (container[PRIVATE]?.next_zoom_state) { - map.getView().setZoom(container[PRIVATE].next_zoom_state.zoom); - map.getView().setCenter(container[PRIVATE].next_zoom_state.center); - container[PRIVATE].next_zoom_state = undefined; - } else if ( - map.getView().getCenter().some(isNaN) || - (!!container[PRIVATE] && container[PRIVATE].invalid_extents) - ) { - const map = container[PRIVATE].map; - map.getView().fit(extents, { size: map.getSize() }); - } - - container[PRIVATE].invalid_extents = extents.some(isNaN); -} - -function removeVectorLayer(container) { - const { map } = container[PRIVATE]; - const layers = map.getLayers().getArray(); - for (var n = layers.length - 1; n > 0; n--) { - map.removeLayer(layers[n]); - } -} - -function setTileUrl(container) { - const tileUrl = computedStyle(container)( - "--map-tile-url", - DEFAULT_TILE_URL, - ); - const url = tileUrl.trim().substring(1, tileUrl.length - 1); - - if (container[PRIVATE].tileUrl != url) { - container[PRIVATE].tileLayer.setSource(new OSM({ wrapX: false, url })); - container[PRIVATE].tileUrl = url; - } -} diff --git a/packages/viewer-openlayers/src/js/views/map-view.js b/packages/viewer-openlayers/src/js/views/map-view.js deleted file mode 100644 index cca0303c3d..0000000000 --- a/packages/viewer-openlayers/src/js/views/map-view.js +++ /dev/null @@ -1,151 +0,0 @@ -// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ -// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ -// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ -// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ -// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ -// ┃ Copyright (c) 2017, the Perspective Authors. ┃ -// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ -// ┃ This file is part of the Perspective library, distributed under the terms ┃ -// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ -// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - -import { getMapData } from "../data/data"; -import { baseMap } from "./base-map"; -import { categoryColorMap } from "../style/categoryColors"; -import { linearColorScale } from "../style/linearColors"; -import { showLegend, hideLegend } from "../legend/legend"; -import { categoryShapeMap } from "../style/categoryShapes"; -import { lightenRgb } from "../style/computed"; - -import VectorLayer from "ol/layer/Vector"; -import VectorSource from "ol/source/Vector"; - -import { Feature } from "ol"; -import { fromLonLat } from "ol/proj"; -import { Point } from "ol/geom"; - -const MIN_SIZE = 2; -const MAX_SIZE = 10; -const DEFAULT_SIZE = 2; - -function mapView(container, config) { - const data = getMapData(config); - const map = baseMap(container); - const useLinearColors = !!config.color_extents; - const colorScale = useLinearColors - ? linearColorScale(container, config.color_extents) - : null; - - const colorMap = useLinearColors - ? (d) => colorScale(d.cols[2]) - : categoryColorMap(container, data); - - const sizeMap = sizeMapFromExtents(config); - const shapeMap = categoryShapeMap(container, data); - const vectorSource = new VectorSource({ - features: data.map((point) => - featureFromPoint(point, colorMap, sizeMap, shapeMap), - ), - wrapX: false, - }); - - baseMap.initializeView(container, vectorSource); - const vectorLayer = new VectorLayer({ - source: vectorSource, - updateWhileInteracting: true, - renderMode: "image", - }); - - map.map.addLayer(vectorLayer); - map.tooltip - .config(config) - .vectorSource(vectorSource) - .regions(false) - .onHighlight(onHighlight) - .data(data); - - if (useLinearColors) { - showLegend(container, colorScale, config.color_extents); - } else { - hideLegend(container); - } -} - -mapView.resize = (container) => { - baseMap.resize(container); -}; - -mapView.restyle = (container) => { - baseMap.restyle(container); -}; - -mapView.save = (container) => { - return baseMap.save(container); -}; - -mapView.restore = (container, token) => { - baseMap.restore(container, token); -}; - -function featureFromPoint(point, colorMap, sizeMap, shapeMap) { - const feature = new Feature(new Point(fromLonLat(point.cols))); - const fillAndStroke = colorMap(point); - if (fillAndStroke) { - feature.setProperties({ - category: point.category, - scale: sizeMap(point) / 4, - style: { - fill: fillAndStroke.fill, - stroke: fillAndStroke.stroke, - }, - data: point, - }); - - // Use custom shapes - feature.setStyle(shapeMap(point)); - } - return feature; -} - -function onHighlight(feature, highlighted) { - const featureProperties = feature.getProperties(); - const oldStyle = featureProperties.oldStyle || featureProperties.style; - - const style = highlighted - ? { - stroke: lightenRgb(oldStyle.stroke, 0.25), - fill: lightenRgb(oldStyle.stroke, 0.5), - } - : oldStyle; - - feature.setProperties({ - oldStyle, - style, - }); -} - -function sizeMapFromExtents({ size_extents }) { - if (!!size_extents) { - // We have the size value - const range = size_extents.max - size_extents.min; - return (point) => - ((point.cols[3] - size_extents.min) / range) * - (MAX_SIZE - MIN_SIZE) + - MIN_SIZE; - } - - return () => DEFAULT_SIZE; -} - -mapView.plugin = { - type: "perspective-viewer-openlayers-scatter", - name: "Map Scatter", - max_size: 25000, - initial: { - type: "number", - count: 2, - names: ["Longitude", "Latitude", "Color", "Size", "Tooltip"], - }, -}; -export default mapView; diff --git a/packages/viewer-openlayers/src/js/views/region-view.js b/packages/viewer-openlayers/src/js/views/region-view.js deleted file mode 100644 index 1a6106cedf..0000000000 --- a/packages/viewer-openlayers/src/js/views/region-view.js +++ /dev/null @@ -1,136 +0,0 @@ -// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ -// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ -// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ -// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ -// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ -// ┃ Copyright (c) 2017, the Perspective Authors. ┃ -// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ -// ┃ This file is part of the Perspective library, distributed under the terms ┃ -// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ -// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - -import { getMapData } from "../data/data"; -import { baseMap } from "./base-map"; -import { linearColorScale } from "../style/linearColors"; -import { showLegend, hideLegend } from "../legend/legend"; -import { lightenRgb } from "../style/computed"; - -import VectorLayer from "ol/layer/Vector"; -import VectorSource from "ol/source/Vector"; - -import { KML } from "ol/format"; - -// /const ol = require("ol"); -// const {Vector: VectorLayer} = ol.layer; -// const {Vector: VectorSource} = ol.source; -// const {KML} = ol.format; -import { Style, Fill, Stroke } from "ol/style"; - -const regionSources = {}; -window.registerMapRegions = ({ - name, - url, - key, - format = new KML({ extractStyles: false }), -}) => { - const source = new VectorSource({ url, format, wrapX: false }); - const nameFn = typeof key == "string" ? (props) => props[key] : key; - regionSources[name] = { source, nameFn }; -}; - -function regionView(container, config) { - const data = getMapData(config); - const extents = getDataExtents(data); - const map = baseMap(container); - - const regionSource = - config.group_by.length && regionSources[config.group_by[0]]; - - if (regionSource) { - const vectorSource = regionSource.source; - const colorScale = linearColorScale(container, extents[0]); - const vectorLayer = new VectorLayer({ - source: vectorSource, - updateWhileInteracting: true, - style: createStyleFunction(regionSource, data, colorScale), - }); - map.map.addLayer(vectorLayer); - - vectorSource.on("change", () => { - baseMap.initializeView(container, vectorSource); - }); - - // Update the tooltip component - map.tooltip - .config(config) - .vectorSource(vectorSource) - .regions(true) - .onHighlight(onHighlight) - .data(data); - - showLegend(container, colorScale, extents[0]); - } else { - hideLegend(container); - } -} - -function createStyleFunction(regionSource, data, colorScale) { - return (feature) => { - const properties = feature.getProperties(); - const regionName = regionSource.nameFn(properties); - const dataPoint = data.find((d) => d.group == regionName); - if (dataPoint) { - const style = colorScale(dataPoint.cols[0]); - feature.setProperties({ data: dataPoint, style }); - - const drawStyle = properties.highlightStyle || style; - return new Style({ - fill: new Fill({ color: drawStyle.fill }), - stroke: new Stroke({ color: drawStyle.stroke }), - }); - } else { - // Mark it with a name so we can identify it in a tooltip - feature.setProperties({ data: { group: regionName } }); - return new Style({ - stroke: new Stroke({ color: "rgba(200, 150, 150, 0.2)" }), - }); - } - }; -} - -function onHighlight(feature, highlighted) { - const featureProperties = feature.getProperties(); - - const oldStyle = featureProperties.style; - if (!oldStyle) return; - - const style = highlighted - ? { - stroke: lightenRgb(oldStyle.stroke, 0.25), - fill: lightenRgb(oldStyle.fill, 0.25), - } - : null; - - feature.setProperties({ highlightStyle: style }); -} - -regionView.resize = (container) => { - baseMap.resize(container); -}; - -regionView.restyle = (container) => { - baseMap.restyle(container); -}; - -regionView.plugin = { - type: "perspective-viewer-map-regions", - name: "Map Regions", - max_size: 25000, - initial: { - type: "number", - count: 1, - names: ["Color", "Tooltip"], - }, -}; -export default regionView; diff --git a/packages/viewer-openlayers/src/themes/material.dark.css b/packages/viewer-openlayers/src/themes/material.dark.css deleted file mode 100644 index 4ce33c568a..0000000000 --- a/packages/viewer-openlayers/src/themes/material.dark.css +++ /dev/null @@ -1,41 +0,0 @@ -/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ - * ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ - * ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ - * ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ - * ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ - * ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ - * ┃ Copyright (c) 2017, the Perspective Authors. ┃ - * ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ - * ┃ This file is part of the Perspective library, distributed under the terms ┃ - * ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ - * ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ */ - -perspective-viewer { - --map-element-background: #333333; - - /* colors for categories 1 to 11 */ - --map-category-1: #1f77b4; - --map-category-2: #0366d6; - --map-category-3: #ff7f0e; - --map-category-4: #2ca02c; - --map-category-5: #d62728; - --map-category-6: #9467bd; - --map-category-7: #8c564b; - --map-category-8: #e377c2; - --map-category-9: #7f7f7f; - --map-category-10: #bcbd22; - --map-category-11: #17becf; - - /* color gradient when color value is selected */ - --map-gradient: linear-gradient( - #4d342f 0%, - #e4521b 22.5%, - #decb45 42.5%, - #a0a0a0 50%, - #bccda8 57.5%, - #42b3d5 67.5%, - #1a237e 100% - ); - - --map-tile-url: "http://{a-c}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png"; -} diff --git a/packages/viewer-openlayers/test/html/superstore.html b/packages/viewer-openlayers/test/html/superstore.html deleted file mode 100644 index d0b6e2c938..0000000000 --- a/packages/viewer-openlayers/test/html/superstore.html +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/packages/workspace/test/js/global_filter.spec.js b/packages/workspace/test/js/global_filter.spec.js index aecbe398bd..d178c4ccdc 100644 --- a/packages/workspace/test/js/global_filter.spec.js +++ b/packages/workspace/test/js/global_filter.spec.js @@ -391,8 +391,11 @@ function tests(context, compare) { class MyGrid extends customElements.get( "perspective-viewer-datagrid", ) { - get name() { - return "My Datagrid"; + get_static_config() { + return { + ...super.get_static_config(), + name: "My Datagrid", + }; } } customElements.define("my-grid", MyGrid); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e7f7f5089..312e7d9f13 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,27 +63,21 @@ catalogs: css-loader: specifier: '>=7 <8' version: 7.1.2 - d3: - specifier: ^7.9.0 - version: 7.9.0 - d3-color: - specifier: '>=3.1' - version: 3.1.0 dotenv: specifier: '>=17' version: 17.2.3 esbuild: specifier: ^0.25.5 version: 0.25.11 + eslint: + specifier: ^9.0.0 + version: 9.39.4 fs-extra: specifier: '>=11 <12' version: 11.3.2 glob-gitignore: specifier: ^1.0.15 version: 1.0.15 - gradient-parser: - specifier: '>=1 <2' - version: 1.1.1 html-webpack-plugin: specifier: '>=5 <6' version: 5.6.4 @@ -96,9 +90,6 @@ catalogs: inquirer: specifier: '>=12 <13' version: 12.10.0 - less: - specifier: ^4.1.0 - version: 4.4.2 lightningcss: specifier: ^1.29.0 version: 1.32.0 @@ -117,9 +108,6 @@ catalogs: octokit: specifier: ^1.8.1 version: 1.8.1 - ol: - specifier: ^5.3.2 - version: 5.3.3 prettier: specifier: '>=3 <4' version: 3.6.2 @@ -159,6 +147,9 @@ catalogs: typescript: specifier: '>=5 <6' version: 5.9.3 + typescript-eslint: + specifier: ^8.0.0 + version: 8.59.1 vite: specifier: '>=6 <7' version: 6.4.1 @@ -217,9 +208,6 @@ importers: '@perspective-dev/viewer-datagrid': specifier: workspace:^ version: link:packages/viewer-datagrid - '@perspective-dev/viewer-openlayers': - specifier: workspace:^ - version: link:packages/viewer-openlayers '@perspective-dev/workspace': specifier: workspace:^ version: link:packages/workspace @@ -235,6 +223,9 @@ importers: dotenv: specifier: 'catalog:' version: 17.2.3 + eslint: + specifier: 'catalog:' + version: 9.39.4(jiti@1.21.7) husky: specifier: 'catalog:' version: 9.1.7 @@ -247,6 +238,9 @@ importers: tsx: specifier: 'catalog:' version: 4.20.6 + typescript-eslint: + specifier: 'catalog:' + version: 8.59.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) docs: dependencies: @@ -311,9 +305,6 @@ importers: '@perspective-dev/viewer-datagrid': specifier: 'workspace:' version: link:../../packages/viewer-datagrid - '@perspective-dev/viewer-openlayers': - specifier: 'workspace:' - version: link:../../packages/viewer-openlayers '@perspective-dev/workspace': specifier: 'workspace:' version: link:../../packages/workspace @@ -679,9 +670,6 @@ importers: '@perspective-dev/viewer-datagrid': specifier: 'workspace:' version: link:../viewer-datagrid - '@perspective-dev/viewer-openlayers': - specifier: 'workspace:' - version: link:../viewer-openlayers '@perspective-dev/workspace': specifier: 'workspace:' version: link:../workspace @@ -725,9 +713,6 @@ importers: '@perspective-dev/viewer-datagrid': specifier: 'workspace:' version: link:../viewer-datagrid - '@perspective-dev/viewer-openlayers': - specifier: 'workspace:' - version: link:../viewer-openlayers devDependencies: '@jupyterlab/builder': specifier: ^4 @@ -799,12 +784,18 @@ importers: '@perspective-dev/viewer': specifier: 'workspace:' version: link:../../rust/perspective-viewer + '@types/node': + specifier: 'catalog:' + version: 24.9.1 lightningcss: specifier: 'catalog:' version: 1.32.0 typescript: specifier: 'catalog:' version: 5.9.3 + webpack-glsl-minify: + specifier: 1.5.0 + version: 1.5.0 packages/viewer-datagrid: dependencies: @@ -837,40 +828,6 @@ importers: specifier: 'catalog:' version: 8.8.5 - packages/viewer-openlayers: - dependencies: - '@perspective-dev/client': - specifier: 'workspace:' - version: link:../../rust/perspective-js - '@perspective-dev/viewer': - specifier: 'workspace:' - version: link:../../rust/perspective-viewer - d3: - specifier: 'catalog:' - version: 7.9.0 - d3-color: - specifier: 'catalog:' - version: 3.1.0 - gradient-parser: - specifier: 'catalog:' - version: 1.1.1 - less: - specifier: 'catalog:' - version: 4.4.2 - ol: - specifier: 'catalog:' - version: 5.3.3 - devDependencies: - '@perspective-dev/esbuild-plugin': - specifier: 'workspace:' - version: link:../../tools/esbuild-plugin - '@perspective-dev/test': - specifier: 'workspace:' - version: link:../../tools/test - lightningcss: - specifier: 'catalog:' - version: 1.32.0 - packages/workspace: dependencies: '@lumino/algorithm': @@ -1176,6 +1133,12 @@ importers: '@perspective-dev/client': specifier: 'workspace:' version: link:../../rust/perspective-js + '@perspective-dev/viewer-charts': + specifier: 'workspace:' + version: link:../../packages/viewer-charts + '@playwright/test': + specifier: 'catalog:' + version: 1.58.0 microtime: specifier: 'catalog:' version: 3.1.1 @@ -1516,6 +1479,44 @@ packages: cpu: [x64] os: [win32] + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@finos/perspective@0.10.0': resolution: {integrity: sha512-PmsUiaOjChJaivDxGLNH8vAw8lG98OLlPjKCvci3iAFAEclGKMhSkHP/qYDU+NUZSPqsvk2Rf66TFsK8aiy/jg==} engines: {node: '>=12'} @@ -1609,6 +1610,26 @@ packages: '@gerrit0/mini-shiki@3.13.1': resolution: {integrity: sha512-fDWM5QQc70jwBIt/WYMybdyXdyBmoJe7r1hpM+V/bHnyla79sygVDK2/LlVxIPc4n5FA3B5Wzt7AQH2+psNphg==} + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + '@iarna/toml@3.0.0': resolution: {integrity: sha512-td6ZUkz2oS3VeleBcN+m//Q6HlCFCPrnI0FZhrt/h4XqLEdOyYp2u21nd8MdsR+WJy5r9PTDaHTDDfhf4H4l6Q==} @@ -2316,6 +2337,65 @@ packages: '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + '@typescript-eslint/eslint-plugin@8.59.1': + resolution: {integrity: sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.59.1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.59.1': + resolution: {integrity: sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.59.1': + resolution: {integrity: sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.59.1': + resolution: {integrity: sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.59.1': + resolution: {integrity: sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.59.1': + resolution: {integrity: sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.59.1': + resolution: {integrity: sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.59.1': + resolution: {integrity: sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.59.1': + resolution: {integrity: sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.59.1': + resolution: {integrity: sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vitejs/plugin-react@4.7.0': resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} @@ -2412,6 +2492,11 @@ packages: peerDependencies: acorn: ^8.14.0 + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + acorn@8.15.0: resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} engines: {node: '>=0.4.0'} @@ -2446,6 +2531,9 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} @@ -2521,6 +2609,7 @@ packages: aws-sdk@2.1692.0: resolution: {integrity: sha512-x511uiJ/57FIsbgUe5csJ13k3uzu25uWQE+XqfBis/sB0SFoiElJWXRkgEAUh0U6n40eT3ay5Ue4oPkRMu1LYw==} engines: {node: '>= 10.0.0'} + deprecated: The AWS SDK for JavaScript (v2) has reached end-of-support, and no longer receives updates. Please migrate your code to use AWS SDK for JavaScript (v3). More info https://a.co/cUPnyil b4a@1.7.3: resolution: {integrity: sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==} @@ -2536,6 +2625,10 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + bare-events@2.8.1: resolution: {integrity: sha512-oxSAxTS1hRfnyit2CL5QpAOS5ixfBjj6ex3yTNvXyY/kE719jQ/IjuESJBK2w5v4wwQRAHGseVJXx9QBYOtFGQ==} peerDependencies: @@ -2588,7 +2681,7 @@ packages: basic-ftp@5.0.5: resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==} engines: {node: '>=10.0.0'} - deprecated: Security vulnerability fixed in 5.2.0, please upgrade + deprecated: Security vulnerability fixed in 5.2.1, please upgrade before-after-hook@2.2.3: resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==} @@ -2611,6 +2704,10 @@ packages: brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -2853,129 +2950,6 @@ packages: resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} engines: {node: '>=12'} - d3-axis@3.0.0: - resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} - engines: {node: '>=12'} - - d3-brush@3.0.0: - resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} - engines: {node: '>=12'} - - d3-chord@3.0.1: - resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} - engines: {node: '>=12'} - - d3-color@3.1.0: - resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} - engines: {node: '>=12'} - - d3-contour@4.0.2: - resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} - engines: {node: '>=12'} - - d3-delaunay@6.0.4: - resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} - engines: {node: '>=12'} - - d3-dispatch@3.0.1: - resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} - engines: {node: '>=12'} - - d3-drag@3.0.0: - resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} - engines: {node: '>=12'} - - d3-dsv@3.0.1: - resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} - engines: {node: '>=12'} - hasBin: true - - d3-ease@3.0.1: - resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} - engines: {node: '>=12'} - - d3-fetch@3.0.1: - resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} - engines: {node: '>=12'} - - d3-force@3.0.0: - resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} - engines: {node: '>=12'} - - d3-format@3.1.0: - resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==} - engines: {node: '>=12'} - - d3-geo@3.1.1: - resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} - engines: {node: '>=12'} - - d3-hierarchy@3.1.2: - resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} - engines: {node: '>=12'} - - d3-interpolate@3.0.1: - resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} - engines: {node: '>=12'} - - d3-path@3.1.0: - resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} - engines: {node: '>=12'} - - d3-polygon@3.0.1: - resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} - engines: {node: '>=12'} - - d3-quadtree@3.0.1: - resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} - engines: {node: '>=12'} - - d3-random@3.0.1: - resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} - engines: {node: '>=12'} - - d3-scale-chromatic@3.1.0: - resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} - engines: {node: '>=12'} - - d3-scale@4.0.2: - resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} - engines: {node: '>=12'} - - d3-selection@3.0.0: - resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} - engines: {node: '>=12'} - - d3-shape@3.2.0: - resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} - engines: {node: '>=12'} - - d3-time-format@4.1.0: - resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} - engines: {node: '>=12'} - - d3-time@3.1.0: - resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} - engines: {node: '>=12'} - - d3-timer@3.0.1: - resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} - engines: {node: '>=12'} - - d3-transition@3.0.1: - resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} - engines: {node: '>=12'} - peerDependencies: - d3-selection: '>=3 <4' - - d3-zoom@3.0.0: - resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} - engines: {node: '>=12'} - - d3@7.9.0: - resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} - engines: {node: '>=12'} - data-uri-to-buffer@6.0.2: resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} engines: {node: '>= 14'} @@ -3005,6 +2979,9 @@ packages: supports-color: optional: true + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} @@ -3021,9 +2998,6 @@ packages: resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} engines: {node: '>= 14'} - delaunator@5.0.1: - resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==} - deprecation@2.3.1: resolution: {integrity: sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==} @@ -3173,11 +3147,45 @@ packages: resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} engines: {node: '>=8.0.0'} + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} hasBin: true + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + esrecurse@4.3.0: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} engines: {node: '>=4.0'} @@ -3229,6 +3237,9 @@ packages: fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} @@ -3254,6 +3265,10 @@ packages: fflate@0.7.4: resolution: {integrity: sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==} + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -3269,6 +3284,14 @@ packages: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + flat@5.0.2: resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==} hasBin: true @@ -3279,6 +3302,9 @@ packages: flatbuffers@24.12.23: resolution: {integrity: sha512-dLVCAISd5mhls514keQzmEG6QHmUUsNuWsb4tFafIUwvvgDjXhtfAYSKOzt5SWOy+qByV5pbsDZ+Vb7HUOBEdA==} + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + follow-redirects@1.15.11: resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} engines: {node: '>=4.0'} @@ -3387,6 +3413,10 @@ packages: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + globalthis@1.0.4: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} @@ -3402,10 +3432,6 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - gradient-parser@1.1.1: - resolution: {integrity: sha512-Hu0YfNU+38EsTmnUfLXUKFMXq9yz7htGYpF4x+dlbBhUCvIvzLt0yVLT/gJRmvLKFJdqNFrz4eKkIUjIXSr7Tw==} - engines: {node: '>=0.10.0'} - handlebars@4.7.8: resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} engines: {node: '>=0.4.7'} @@ -3548,6 +3574,10 @@ packages: engines: {node: '>=8'} hasBin: true + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + indent-string@4.0.0: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} @@ -3748,6 +3778,10 @@ packages: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -3757,6 +3791,9 @@ packages: resolution: {integrity: sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg==} engines: {node: '>=0.8'} + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-parse-better-errors@1.0.2: resolution: {integrity: sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==} @@ -3776,6 +3813,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json5@1.0.2: resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} hasBin: true @@ -3802,6 +3842,9 @@ packages: jws@3.2.2: resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kind-of@6.0.3: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} @@ -3814,6 +3857,10 @@ packages: engines: {node: '>=14'} hasBin: true + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + lib0@0.2.114: resolution: {integrity: sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==} engines: {node: '>=16'} @@ -3923,6 +3970,10 @@ packages: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + lodash-es@4.17.21: resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} @@ -3950,6 +4001,9 @@ packages: lodash.isstring@4.0.1: resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} @@ -4047,9 +4101,16 @@ packages: resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==} hasBin: true + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + minimatch@9.0.5: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} @@ -4083,6 +4144,9 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + needle@3.3.1: resolution: {integrity: sha512-6k0YULvhpw+RoLNiQCRKOl09Rv1dPLr8hHnVjHqdolKwDrdNyk+Hmrthi4lIGPPz3r39dLx0hsF5s40sZ3Us4Q==} engines: {node: '>= 4.4.x'} @@ -4154,9 +4218,6 @@ packages: octokit@1.8.1: resolution: {integrity: sha512-xBLKFIivbl7wnLwxzLYuDO/JDNYxdyxoSjFrl/QMrY/fwGGQYYklvKUDTUyGMU0aXPrQtJ0IZnG3BXpCkDQzWg==} - ol@5.3.3: - resolution: {integrity: sha512-7eU4x8YMduNcED1D5wI+AMWDRe7/1HmGfsbV+kFFROI9RNABU/6n4osj6Q3trZbxxKnK2DSRIjIRGwRHT/Z+Ww==} - once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -4164,6 +4225,10 @@ packages: resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} hasBin: true + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + own-keys@1.0.1: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} @@ -4172,10 +4237,18 @@ packages: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + p-locate@4.1.0: resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} engines: {node: '>=8'} + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} @@ -4248,10 +4321,6 @@ packages: resolution: {integrity: sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==} engines: {node: '>=18'} - pbf@3.1.0: - resolution: {integrity: sha512-/hYJmIsTmh7fMkHAWWXJ5b8IKLWdjdlAFb3IHkRBn1XUhIYBChVGfVwmHEAV3UfXTxsP/AKfYTXTS/dCPxJd5w==} - hasBin: true - pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} @@ -4279,9 +4348,6 @@ packages: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} - pixelworks@1.1.0: - resolution: {integrity: sha512-nDqeyp0pvOvCihLsyc9GHWKP4THUtcfQ+qs61uiVaZdlNv0j7y6PWNyPfnTtuxMJ+MTAqff2QbbM/1DyCcRdOQ==} - pkg-dir@4.2.0: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} @@ -4339,6 +4405,10 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + prettier@3.6.2: resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} engines: {node: '>=14'} @@ -4366,9 +4436,6 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} - protocol-buffers-schema@3.6.0: - resolution: {integrity: sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==} - proxy-agent@6.5.0: resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==} engines: {node: '>= 14'} @@ -4421,15 +4488,9 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - quickselect@1.1.1: - resolution: {integrity: sha512-qN0Gqdw4c4KGPsBOQafj6yj/PA6c/L63f6CaZ/DCF/xF4Esu3jVmKLUDYxghFx8Kb/O7y9tI7x2RjTSXwdK1iQ==} - randombytes@2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} - rbush@2.0.2: - resolution: {integrity: sha512-XBOuALcTm+O/H8G90b6pzu6nX6v2zCKiFG4BJho8a+bY6AER6t8uQUZdi5bomQc0AprCWhEGa7ncAbbRap0bRA==} - react-dom@18.3.1: resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} peerDependencies: @@ -4502,9 +4563,6 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - resolve-protobuf-schema@2.1.0: - resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==} - resolve@1.22.11: resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} engines: {node: '>= 0.4'} @@ -4514,9 +4572,6 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - robust-predicates@3.0.2: - resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} - rollup@4.52.5: resolution: {integrity: sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -4529,9 +4584,6 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - rw@1.3.3: - resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} - rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} @@ -4745,6 +4797,10 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + style-loader@3.3.4: resolution: {integrity: sha512-0WqXzrsMTyb8yjZJHDqwmnwRJvhALK9LfRtRc6B4UTWe8AijYLZYZ9thuJTZc2VfQWINADW/j+LiJnfy2RoC1w==} engines: {node: '>= 12.13.0'} @@ -4796,6 +4852,7 @@ packages: tar@7.5.1: resolution: {integrity: sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==} engines: {node: '>=18'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me terser-webpack-plugin@5.3.14: resolution: {integrity: sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==} @@ -4839,6 +4896,12 @@ packages: resolution: {integrity: sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==} engines: {node: '>=8'} + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + tslib@1.14.1: resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} @@ -4850,6 +4913,10 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -4876,6 +4943,13 @@ packages: peerDependencies: typescript: 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x + typescript-eslint@8.59.1: + resolution: {integrity: sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -5058,6 +5132,10 @@ packages: webpack-dev-server: optional: true + webpack-glsl-minify@1.5.0: + resolution: {integrity: sha512-Q2zVxqWzCIRSw72Nf2qesAAOr/XdS3QUnKf6zoXwQsnnVHeWs89eVZ3YRcc5E0iO2vt6inxfthPfb8PdTK+TgA==} + hasBin: true + webpack-merge@5.10.0: resolution: {integrity: sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==} engines: {node: '>=10.0.0'} @@ -5122,6 +5200,10 @@ packages: wildcard@2.0.1: resolution: {integrity: sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==} + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} @@ -5217,6 +5299,10 @@ packages: resolution: {integrity: sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==} engines: {node: '>=16.0.0', npm: '>=8.0.0'} + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + yoctocolors-cjs@2.1.3: resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} engines: {node: '>=18'} @@ -5440,6 +5526,52 @@ snapshots: '@esbuild/win32-x64@0.25.11': optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@1.21.7))': + dependencies: + eslint: 9.39.4(jiti@1.21.7) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.4': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + '@finos/perspective@0.10.0': dependencies: '@babel/runtime': 7.28.4 @@ -5661,6 +5793,22 @@ snapshots: '@shikijs/types': 3.13.0 '@shikijs/vscode-textmate': 10.0.2 + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + '@iarna/toml@3.0.0': {} '@inquirer/ansi@1.0.1': {} @@ -6831,6 +6979,97 @@ snapshots: '@types/node': 24.9.1 optional: true + '@typescript-eslint/eslint-plugin@8.59.1(@typescript-eslint/parser@8.59.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.59.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.59.1 + '@typescript-eslint/type-utils': 8.59.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.1 + eslint: 9.39.4(jiti@1.21.7) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.59.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.59.1 + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.1 + debug: 4.4.3 + eslint: 9.39.4(jiti@1.21.7) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.59.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.1(typescript@5.9.3) + '@typescript-eslint/types': 8.59.1 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.59.1': + dependencies: + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/visitor-keys': 8.59.1 + + '@typescript-eslint/tsconfig-utils@8.59.1(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.59.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.4(jiti@1.21.7) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.59.1': {} + + '@typescript-eslint/typescript-estree@8.59.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.59.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.59.1(typescript@5.9.3) + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/visitor-keys': 8.59.1 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.7.3 + tinyglobby: 0.2.15 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.59.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7)) + '@typescript-eslint/scope-manager': 8.59.1 + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) + eslint: 9.39.4(jiti@1.21.7) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.59.1': + dependencies: + '@typescript-eslint/types': 8.59.1 + eslint-visitor-keys: 5.0.1 + '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@24.9.1)(jiti@1.21.7)(less@4.4.2)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))': dependencies: '@babel/core': 7.28.5 @@ -6946,6 +7185,10 @@ snapshots: dependencies: acorn: 8.15.0 + acorn-jsx@5.3.2(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + acorn@8.15.0: {} agent-base@7.1.4: {} @@ -6975,6 +7218,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + ajv@8.17.1: dependencies: fast-deep-equal: 3.1.3 @@ -7089,6 +7339,8 @@ snapshots: balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + bare-events@2.8.1: {} bare-fs@4.5.0: @@ -7155,6 +7407,10 @@ snapshots: dependencies: balanced-match: 1.0.2 + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -7324,6 +7580,7 @@ snapshots: copy-anything@2.0.6: dependencies: is-what: 3.14.1 + optional: true copy-webpack-plugin@12.0.2(webpack@5.102.1(webpack-cli@5.1.4)): dependencies: @@ -7408,154 +7665,6 @@ snapshots: dependencies: internmap: 2.0.3 - d3-axis@3.0.0: {} - - d3-brush@3.0.0: - dependencies: - d3-dispatch: 3.0.1 - d3-drag: 3.0.0 - d3-interpolate: 3.0.1 - d3-selection: 3.0.0 - d3-transition: 3.0.1(d3-selection@3.0.0) - - d3-chord@3.0.1: - dependencies: - d3-path: 3.1.0 - - d3-color@3.1.0: {} - - d3-contour@4.0.2: - dependencies: - d3-array: 3.2.4 - - d3-delaunay@6.0.4: - dependencies: - delaunator: 5.0.1 - - d3-dispatch@3.0.1: {} - - d3-drag@3.0.0: - dependencies: - d3-dispatch: 3.0.1 - d3-selection: 3.0.0 - - d3-dsv@3.0.1: - dependencies: - commander: 7.2.0 - iconv-lite: 0.6.3 - rw: 1.3.3 - - d3-ease@3.0.1: {} - - d3-fetch@3.0.1: - dependencies: - d3-dsv: 3.0.1 - - d3-force@3.0.0: - dependencies: - d3-dispatch: 3.0.1 - d3-quadtree: 3.0.1 - d3-timer: 3.0.1 - - d3-format@3.1.0: {} - - d3-geo@3.1.1: - dependencies: - d3-array: 3.2.4 - - d3-hierarchy@3.1.2: {} - - d3-interpolate@3.0.1: - dependencies: - d3-color: 3.1.0 - - d3-path@3.1.0: {} - - d3-polygon@3.0.1: {} - - d3-quadtree@3.0.1: {} - - d3-random@3.0.1: {} - - d3-scale-chromatic@3.1.0: - dependencies: - d3-color: 3.1.0 - d3-interpolate: 3.0.1 - - d3-scale@4.0.2: - dependencies: - d3-array: 3.2.4 - d3-format: 3.1.0 - d3-interpolate: 3.0.1 - d3-time: 3.1.0 - d3-time-format: 4.1.0 - - d3-selection@3.0.0: {} - - d3-shape@3.2.0: - dependencies: - d3-path: 3.1.0 - - d3-time-format@4.1.0: - dependencies: - d3-time: 3.1.0 - - d3-time@3.1.0: - dependencies: - d3-array: 3.2.4 - - d3-timer@3.0.1: {} - - d3-transition@3.0.1(d3-selection@3.0.0): - dependencies: - d3-color: 3.1.0 - d3-dispatch: 3.0.1 - d3-ease: 3.0.1 - d3-interpolate: 3.0.1 - d3-selection: 3.0.0 - d3-timer: 3.0.1 - - d3-zoom@3.0.0: - dependencies: - d3-dispatch: 3.0.1 - d3-drag: 3.0.0 - d3-interpolate: 3.0.1 - d3-selection: 3.0.0 - d3-transition: 3.0.1(d3-selection@3.0.0) - - d3@7.9.0: - dependencies: - d3-array: 3.2.4 - d3-axis: 3.0.0 - d3-brush: 3.0.0 - d3-chord: 3.0.1 - d3-color: 3.1.0 - d3-contour: 4.0.2 - d3-delaunay: 6.0.4 - d3-dispatch: 3.0.1 - d3-drag: 3.0.0 - d3-dsv: 3.0.1 - d3-ease: 3.0.1 - d3-fetch: 3.0.1 - d3-force: 3.0.0 - d3-format: 3.1.0 - d3-geo: 3.1.1 - d3-hierarchy: 3.1.2 - d3-interpolate: 3.0.1 - d3-path: 3.1.0 - d3-polygon: 3.0.1 - d3-quadtree: 3.0.1 - d3-random: 3.0.1 - d3-scale: 4.0.2 - d3-scale-chromatic: 3.1.0 - d3-selection: 3.0.0 - d3-shape: 3.2.0 - d3-time: 3.1.0 - d3-time-format: 4.1.0 - d3-timer: 3.0.1 - d3-transition: 3.0.1(d3-selection@3.0.0) - d3-zoom: 3.0.0 - data-uri-to-buffer@6.0.2: {} data-urls@2.0.0: @@ -7586,6 +7695,8 @@ snapshots: dependencies: ms: 2.1.3 + deep-is@0.1.4: {} + deepmerge@4.3.1: {} define-data-property@1.1.4: @@ -7606,10 +7717,6 @@ snapshots: escodegen: 2.1.0 esprima: 4.0.1 - delaunator@5.0.1: - dependencies: - robust-predicates: 3.0.2 - deprecation@2.3.1: {} detect-libc@2.1.2: {} @@ -7840,8 +7947,70 @@ snapshots: esrecurse: 4.3.0 estraverse: 4.3.0 + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.4(jiti@1.21.7): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 1.21.7 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) + eslint-visitor-keys: 4.2.1 + esprima@4.0.1: {} + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + esrecurse@4.3.0: dependencies: estraverse: 5.3.0 @@ -7891,6 +8060,8 @@ snapshots: fast-json-stable-stringify@2.1.0: {} + fast-levenshtein@2.0.6: {} + fast-uri@3.1.0: {} fastest-levenshtein@1.0.16: {} @@ -7909,6 +8080,10 @@ snapshots: fflate@0.7.4: {} + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -7924,12 +8099,24 @@ snapshots: locate-path: 5.0.0 path-exists: 4.0.0 + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + flat@5.0.2: {} flatbuffers@1.12.0: {} flatbuffers@24.12.23: {} + flatted@3.4.2: {} + follow-redirects@1.15.11: {} for-each@0.3.5: @@ -8055,6 +8242,8 @@ snapshots: once: 1.4.0 path-is-absolute: 1.0.1 + globals@14.0.0: {} + globalthis@1.0.4: dependencies: define-properties: 1.2.1 @@ -8073,8 +8262,6 @@ snapshots: graceful-fs@4.2.11: {} - gradient-parser@1.1.1: {} - handlebars@4.7.8: dependencies: minimist: 1.2.8 @@ -8208,7 +8395,8 @@ snapshots: ieee754@1.1.13: optional: true - ieee754@1.2.1: {} + ieee754@1.2.1: + optional: true ignore@5.3.2: {} @@ -8235,6 +8423,8 @@ snapshots: pkg-dir: 4.2.0 resolve-cwd: 3.0.0 + imurmurhash@0.1.4: {} + indent-string@4.0.0: {} inflight@1.0.6: @@ -8392,7 +8582,8 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 - is-what@3.14.1: {} + is-what@3.14.1: + optional: true isarray@1.0.0: optional: true @@ -8431,10 +8622,16 @@ snapshots: dependencies: argparse: 2.0.1 + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + jsesc@3.1.0: {} json-bignum@0.0.3: {} + json-buffer@3.0.1: {} + json-parse-better-errors@1.0.2: {} json-parse-even-better-errors@2.3.1: {} @@ -8453,6 +8650,8 @@ snapshots: json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} + json5@1.0.2: dependencies: minimist: 1.2.8 @@ -8491,6 +8690,10 @@ snapshots: jwa: 1.4.2 safe-buffer: 5.2.1 + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + kind-of@6.0.3: {} leb128@0.0.5: @@ -8511,6 +8714,12 @@ snapshots: mime: 1.6.0 needle: 3.3.1 source-map: 0.6.1 + optional: true + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 lib0@0.2.114: dependencies: @@ -8603,6 +8812,10 @@ snapshots: dependencies: p-locate: 4.1.0 + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + lodash-es@4.17.21: {} lodash.camelcase@4.3.0: {} @@ -8621,6 +8834,8 @@ snapshots: lodash.isstring@4.0.1: {} + lodash.merge@4.6.2: {} + lodash.once@4.1.1: {} lodash@4.17.21: {} @@ -8702,10 +8917,18 @@ snapshots: mini-svg-data-uri@1.4.4: {} + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.5 + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.12 + minimatch@9.0.5: dependencies: brace-expansion: 2.0.2 @@ -8728,6 +8951,8 @@ snapshots: nanoid@3.3.11: {} + natural-compare@1.4.0: {} + needle@3.3.1: dependencies: iconv-lite: 0.6.3 @@ -8808,18 +9033,21 @@ snapshots: transitivePeerDependencies: - encoding - ol@5.3.3: - dependencies: - pbf: 3.1.0 - pixelworks: 1.1.0 - rbush: 2.0.2 - once@1.4.0: dependencies: wrappy: 1.0.2 opener@1.5.2: {} + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + own-keys@1.0.1: dependencies: get-intrinsic: 1.3.0 @@ -8830,10 +9058,18 @@ snapshots: dependencies: p-try: 2.2.0 + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + p-locate@4.1.0: dependencies: p-limit: 2.3.0 + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + p-try@2.2.0: {} pac-proxy-agent@7.2.0: @@ -8877,7 +9113,8 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 - parse-node-version@1.0.1: {} + parse-node-version@1.0.1: + optional: true parse-srcset@1.0.2: {} @@ -8904,11 +9141,6 @@ snapshots: path-type@6.0.0: {} - pbf@3.1.0: - dependencies: - ieee754: 1.2.1 - resolve-protobuf-schema: 2.1.0 - pend@1.2.0: {} picocolors@1.1.1: {} @@ -8924,8 +9156,6 @@ snapshots: pify@4.0.1: optional: true - pixelworks@1.1.0: {} - pkg-dir@4.2.0: dependencies: find-up: 4.1.0 @@ -8981,6 +9211,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + prelude-ls@1.2.1: {} + prettier@3.6.2: {} pretty-error@4.0.0: @@ -9009,8 +9241,6 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 - protocol-buffers-schema@3.6.0: {} - proxy-agent@6.5.0: dependencies: agent-base: 7.1.4 @@ -9090,16 +9320,10 @@ snapshots: queue-microtask@1.2.3: {} - quickselect@1.1.1: {} - randombytes@2.1.0: dependencies: safe-buffer: 5.2.1 - rbush@2.0.2: - dependencies: - quickselect: 1.1.1 - react-dom@18.3.1(react@18.3.1): dependencies: loose-envify: 1.4.0 @@ -9174,10 +9398,6 @@ snapshots: resolve-pkg-maps@1.0.0: {} - resolve-protobuf-schema@2.1.0: - dependencies: - protocol-buffers-schema: 3.6.0 - resolve@1.22.11: dependencies: is-core-module: 2.16.1 @@ -9186,8 +9406,6 @@ snapshots: reusify@1.1.0: {} - robust-predicates@3.0.2: {} - rollup@4.52.5: dependencies: '@types/estree': 1.0.8 @@ -9222,8 +9440,6 @@ snapshots: dependencies: queue-microtask: 1.2.3 - rw@1.3.3: {} - rxjs@7.8.2: dependencies: tslib: 2.8.1 @@ -9485,6 +9701,8 @@ snapshots: strip-bom@3.0.0: {} + strip-json-comments@3.1.1: {} + style-loader@3.3.4(webpack@5.102.1): dependencies: webpack: 5.102.1(webpack-cli@5.1.4) @@ -9586,6 +9804,10 @@ snapshots: dependencies: punycode: 2.3.1 + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + tslib@1.14.1: {} tslib@2.8.1: {} @@ -9597,6 +9819,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -9641,6 +9867,17 @@ snapshots: typescript: 5.9.3 yaml: 2.8.1 + typescript-eslint@8.59.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.59.1(@typescript-eslint/parser@8.59.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + eslint: 9.39.4(jiti@1.21.7) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + typescript@5.9.3: {} typestyle@2.4.0: @@ -9794,6 +10031,11 @@ snapshots: webpack: 5.102.1(webpack-cli@5.1.4) webpack-merge: 5.10.0 + webpack-glsl-minify@1.5.0: + dependencies: + glob: 7.2.3 + yargs: 17.7.2 + webpack-merge@5.10.0: dependencies: clone-deep: 4.0.1 @@ -9909,6 +10151,8 @@ snapshots: wildcard@2.0.1: {} + word-wrap@1.2.5: {} + wordwrap@1.0.0: {} wordwrapjs@5.1.1: {} @@ -9984,6 +10228,8 @@ snapshots: dependencies: lib0: 0.2.114 + yocto-queue@0.1.0: {} + yoctocolors-cjs@2.1.3: {} zod@3.25.76: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 19131e14e6..4cdb959073 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,7 +6,6 @@ packages: - "tools/esbuild-plugin" - "packages/viewer-datagrid" - "packages/viewer-charts" - - "packages/viewer-openlayers" - "packages/workspace" - "packages/jupyterlab" - "packages/react" @@ -22,8 +21,6 @@ packages: catalog: # Dependencies - "@d3fc/d3fc-chart": "5.1.9" - "@d3fc/d3fc-element": "6.2.0" "@jupyter-widgets/base": ">2 <5" "@jupyterlab/application": ">2 <5" "@lumino/application": "<3" @@ -34,7 +31,6 @@ catalog: "d3-selection": ">=3" "d3-svg-legend": ">=2" "d3": "^7.9.0" - "d3fc": "^15.2.13" "ol": "^5.3.2" "pro_self_extracting_wasm": "0.0.9" "react-dom": ">17 <20" @@ -53,7 +49,6 @@ catalog: "@playwright/experimental-ct-react": "=1.58.0" "@playwright/test": "=1.58.0" "lightningcss": "^1.29.0" - "@types/d3": "^7.4.3" "@types/lodash": "^4.17.20" "@types/node": ">=22" "@types/react-dom": ">17 <20" @@ -71,6 +66,7 @@ catalog: "css-loader": ">=7 <8" "dotenv": ">=17" "esbuild": "^0.25.5" + "eslint": "^9.0.0" "express-ws": ">=5 <6" "express": ">=5 <6" "fs-extra": ">=11 <12" @@ -95,6 +91,7 @@ catalog: "tsx": "^4.20.3" "typedoc": "^0.28.7" "typescript": ">=5 <6" + "typescript-eslint": "^8.0.0" "vite": ">=6 <7" "webpack-cli": ">=5 <6" "webpack": ">=5 <6" diff --git a/rust/metadata/main.rs b/rust/metadata/main.rs index cf09a0e0b5..c3b9f208e9 100644 --- a/rust/metadata/main.rs +++ b/rust/metadata/main.rs @@ -36,13 +36,14 @@ use perspective_client::{ TableInitOptions, UpdateOptions, ViewWindow, }; use perspective_js::TypedArrayWindow; -use perspective_viewer::config::{ViewerConfig, ViewerConfigUpdate}; +use perspective_viewer::config::{PluginStaticConfig, ViewerConfig, ViewerConfigUpdate}; use ts_rs::TS; pub fn generate_type_bindings_viewer() -> Result<(), Box> { let path = std::env::current_dir()?.join("../perspective-viewer/src/ts/ts-rs"); ViewerConfigUpdate::export_all_to(&path)?; ViewerConfig::::export_all_to(&path)?; + PluginStaticConfig::export_all_to(&path)?; OnUpdateData::export_all_to(&path)?; Ok(()) } diff --git a/rust/perspective-client/Cargo.toml b/rust/perspective-client/Cargo.toml index f4ec73c888..f88fde02e6 100644 --- a/rust/perspective-client/Cargo.toml +++ b/rust/perspective-client/Cargo.toml @@ -68,6 +68,7 @@ protobuf-src = { version = "2.1.1", optional = true } arrow-array = { version = "57.3.0", default-features = false } arrow-ipc = { version = "57.3.0", default-features = false } arrow-schema = { version = "57.3.0", default-features = false } +arrow-select = { version = "57.3.0", default-features = false } async-lock = { version = "2.5.0" } futures = { version = "0.3.28" } indexmap = { version = "2.2.6", features = ["serde"] } diff --git a/rust/perspective-client/src/rust/virtual_server/data.rs b/rust/perspective-client/src/rust/virtual_server/data.rs index 30079acec3..2e74c945ec 100644 --- a/rust/perspective-client/src/rust/virtual_server/data.rs +++ b/rust/perspective-client/src/rust/virtual_server/data.rs @@ -64,6 +64,21 @@ pub enum VirtualDataCell { RowPath(Vec), } +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum RowPathStyle { + /// Legacy: emit a single `__ROW_PATH__` sidecar (per-row nested + /// array in `render_to_rows`, array-of-arrays in + /// `render_to_columns_json`). `__ROW_PATH_N__` per-level columns + /// are filtered out. Matches the native engine's `to_json` / + /// `to_columns` shape. + Sidecar, + + /// Native: emit per-level `__ROW_PATH_0__`, `__ROW_PATH_1__`, … + /// columns directly. No `__ROW_PATH__` sidecar. Matches the native + /// engine's Arrow IPC, CSV, and NDJSON shapes. + PerLevel, +} + /// Trait for types that can be written to a [`ColumnBuilder`] which /// enforces sequential construction. /// @@ -578,7 +593,13 @@ impl VirtualDataSlice { /// /// When `group_by` is active, extracts `__GROUPING_ID__` and /// `__ROW_PATH_N__` columns to build `self.row_path`, then removes - /// them from the output `RecordBatch`. + /// `__GROUPING_ID__` from the output `RecordBatch`. The + /// `__ROW_PATH_N__` columns are *kept* in the frozen batch so + /// downstream Arrow IPC consumers (`with_typed_arrays`, used by + /// viewer-charts to drive its categorical/numeric axis resolvers + /// and tree-hierarchy walkers) see them inline — matching the + /// native `perspective-server`'s `to_arrow` output when + /// `emit_legacy_row_path_names: false`. /// /// When `split_by` is active, renames data columns by replacing `_` /// with `|` (the DuckDB PIVOT separator). @@ -587,14 +608,16 @@ impl VirtualDataSlice { /// to Perspective-compatible types. pub fn from_arrow_ipc(&mut self, ipc: &[u8]) -> Result<(), Box> { let cursor = std::io::Cursor::new(ipc); - let batch = if &ipc[0..6] == "ARROW1".as_bytes() { - FileReader::try_new(cursor, None)? - .next() - .ok_or("Arrow IPC stream contained no record batches")?? + let batches: Vec = if &ipc[0..6] == "ARROW1".as_bytes() { + FileReader::try_new(cursor, None)?.collect::, _>>()? } else { - StreamReader::try_new(cursor, None)? - .next() - .ok_or("Arrow IPC stream contained no record batches")?? + StreamReader::try_new(cursor, None)?.collect::, _>>()? + }; + + let batch = match batches.len() { + 0 => return Err("Arrow IPC stream contained no record batches".into()), + 1 => batches.into_iter().next().unwrap(), + _ => arrow_select::concat::concat_batches(&batches[0].schema(), &batches)?, }; let has_group_by = !self.config.group_by.is_empty(); @@ -660,7 +683,22 @@ impl VirtualDataSlice { let mut new_arrays: Vec = Vec::new(); for (col_idx, field) in schema.fields().iter().enumerate() { let name = field.name(); - if name == "__GROUPING_ID__" || name.starts_with("__ROW_PATH_") { + // `__GROUPING_ID__` is an internal SQL-rollup discriminator + // (used in Phase A above to decide which row-path levels + // belong to each row). No JS consumer reads it, so it's + // dropped from the frozen batch. + // + // `__ROW_PATH_N__` columns are kept. Phase A copied their + // values into `self.row_path` for the JSON sidecar paths + // (`render_to_columns_json`, `render_to_rows`), but + // viewer-charts' `with_typed_arrays` callback needs the + // per-level columns inline in the Arrow stream — its + // categorical-axis resolver, numeric-position lookup, and + // tree hierarchy walker all do `columns.get(\`__ROW_PATH_${n}__\`)`. + // Keeping the columns here lets `render_to_arrow_ipc` + // serialize them naturally, matching native + // `perspective-server`'s `to_arrow` output. + if name == "__GROUPING_ID__" { continue; } @@ -743,7 +781,15 @@ impl VirtualDataSlice { /// Converts the columnar data to a row-oriented representation for JSON /// serialization. - pub(crate) fn render_to_rows(&mut self) -> Vec> { + /// + /// `style` selects between the legacy `__ROW_PATH__` sidecar + /// (`Sidecar`, used by `to_json`) and the native per-level + /// `__ROW_PATH_N__` columns (`PerLevel`, used by `to_csv` / + /// `to_ndjson`). See [`RowPathStyle`] for the deprecation plan. + pub(crate) fn render_to_rows( + &mut self, + style: RowPathStyle, + ) -> Vec> { let batch = self.freeze().clone(); let num_rows = batch.num_rows(); let schema = batch.schema(); @@ -751,9 +797,8 @@ impl VirtualDataSlice { (0..num_rows) .map(|row_idx| { let mut row = IndexMap::new(); - - // Add RowPath column first if present - if let Some(ref rp) = self.row_path + if style == RowPathStyle::Sidecar + && let Some(ref rp) = self.row_path && row_idx < rp.len() { row.insert( @@ -762,8 +807,11 @@ impl VirtualDataSlice { ); } - // Add Arrow columns for (col_idx, field) in schema.fields().iter().enumerate() { + if style == RowPathStyle::Sidecar && field.name().starts_with("__ROW_PATH_") { + continue; + } + let col = batch.column(col_idx); let cell = if col.is_null(row_idx) { match field.data_type() { @@ -801,6 +849,25 @@ impl VirtualDataSlice { let arr = col.as_any().downcast_ref::().unwrap(); VirtualDataCell::Integer(Some(arr.value(row_idx))) }, + DataType::Int64 => { + // TODO ???? + let arr = col.as_any().downcast_ref::().unwrap(); + VirtualDataCell::Float(Some(arr.value(row_idx) as f64)) + }, + DataType::Time64(TimeUnit::Microsecond) => { + let arr = col + .as_any() + .downcast_ref::() + .unwrap(); + VirtualDataCell::Float(Some(arr.value(row_idx) as f64)) + }, + DataType::Timestamp(TimeUnit::Microsecond, _) => { + let arr = col + .as_any() + .downcast_ref::() + .unwrap(); + VirtualDataCell::Datetime(Some(arr.value(row_idx) * 1000)) + }, DataType::Timestamp(TimeUnit::Millisecond, _) => { let arr = col .as_any() @@ -829,17 +896,31 @@ impl VirtualDataSlice { } /// Serializes the data to a column-oriented JSON string. - pub fn render_to_columns_json(&mut self) -> Result> { + /// + /// `style` selects between the legacy `__ROW_PATH__` sidecar + /// (`Sidecar`, used by `to_columns`) and the native per-level + /// `__ROW_PATH_N__` columns (`PerLevel`, currently unused — reserved + /// for the future deprecation of `__ROW_PATH__`). See + /// [`RowPathStyle`] for context. + pub fn render_to_columns_json( + &mut self, + style: RowPathStyle, + ) -> Result> { let batch = self.freeze().clone(); let schema = batch.schema(); let mut map = serde_json::Map::new(); - // Add RowPath if present - if let Some(ref rp) = self.row_path { + if style == RowPathStyle::Sidecar + && let Some(ref rp) = self.row_path + { map.insert("__ROW_PATH__".to_string(), serde_json::to_value(rp)?); } for (col_idx, field) in schema.fields().iter().enumerate() { + if style == RowPathStyle::Sidecar && field.name().starts_with("__ROW_PATH_") { + continue; + } + let col = batch.column(col_idx); let num_rows = col.len(); let values: serde_json::Value = match field.data_type() { @@ -914,6 +995,20 @@ impl VirtualDataSlice { .collect::>(), )? }, + DataType::Int64 => { + let arr = col.as_any().downcast_ref::().unwrap(); + serde_json::to_value( + (0..num_rows) + .map(|i| { + if arr.is_null(i) { + None + } else { + Some(arr.value(i) as f64) + } + }) + .collect::>(), + )? + }, DataType::Timestamp(TimeUnit::Millisecond, _) => { let arr = col .as_any() @@ -931,6 +1026,23 @@ impl VirtualDataSlice { .collect::>(), )? }, + DataType::Time64(TimeUnit::Microsecond) => { + let arr = col + .as_any() + .downcast_ref::() + .unwrap(); + serde_json::to_value( + (0..num_rows) + .map(|i| { + if arr.is_null(i) { + None + } else { + Some(arr.value(i) as f64) + } + }) + .collect::>(), + )? + }, DataType::Date32 => { let arr = col.as_any().downcast_ref::().unwrap(); serde_json::to_value( diff --git a/rust/perspective-client/src/rust/virtual_server/mod.rs b/rust/perspective-client/src/rust/virtual_server/mod.rs index ad70a46b18..c35bd51c3f 100644 --- a/rust/perspective-client/src/rust/virtual_server/mod.rs +++ b/rust/perspective-client/src/rust/virtual_server/mod.rs @@ -22,7 +22,7 @@ mod generic_sql_model; mod handler; mod server; -pub use data::{SetVirtualDataColumn, VirtualDataCell, VirtualDataSlice}; +pub use data::{RowPathStyle, SetVirtualDataColumn, VirtualDataCell, VirtualDataSlice}; pub use error::{ResultExt, VirtualServerError}; pub use features::{AggSpec, Features}; pub use generic_sql_model::{ diff --git a/rust/perspective-client/src/rust/virtual_server/server.rs b/rust/perspective-client/src/rust/virtual_server/server.rs index dfb01de399..e1c557f022 100644 --- a/rust/perspective-client/src/rust/virtual_server/server.rs +++ b/rust/perspective-client/src/rust/virtual_server/server.rs @@ -16,6 +16,7 @@ use indexmap::IndexMap; use prost::Message as ProstMessage; use prost::bytes::{Bytes, BytesMut}; +use super::data::RowPathStyle; use super::error::VirtualServerError; use super::handler::VirtualServerHandler; use crate::config::{ViewConfig, ViewConfigUpdate}; @@ -322,7 +323,7 @@ impl VirtualServer { .view_get_data(msg.entity_id.as_str(), config, &schema, &viewport) .await?; - let rows = cols.render_to_rows(); + let rows = cols.render_to_rows(RowPathStyle::PerLevel); let mut csv = String::new(); if let Some(first_row) = rows.first() { let headers: Vec<&str> = first_row.keys().map(|k| k.as_str()).collect(); @@ -350,7 +351,7 @@ impl VirtualServer { .view_get_data(msg.entity_id.as_str(), config, &schema, &viewport) .await?; - let rows = cols.render_to_rows(); + let rows = cols.render_to_rows(RowPathStyle::PerLevel); let ndjson_string = rows .iter() .map(serde_json::to_string) @@ -369,7 +370,7 @@ impl VirtualServer { .view_get_data(msg.entity_id.as_str(), config, &schema, &viewport) .await?; - let rows = cols.render_to_rows(); + let rows = cols.render_to_rows(RowPathStyle::Sidecar); let json_string = serde_json::to_string(&rows) .map_err(|e| VirtualServerError::InvalidJSON(std::sync::Arc::new(e)))?; @@ -385,7 +386,7 @@ impl VirtualServer { .await?; let json_string = cols - .render_to_columns_json() + .render_to_columns_json(RowPathStyle::Sidecar) .map_err(|e| VirtualServerError::Other(e.to_string()))?; respond!(msg, ViewToColumnsStringResp { json_string }) diff --git a/rust/perspective-js/package.json b/rust/perspective-js/package.json index eebc44d741..7062b1abd0 100644 --- a/rust/perspective-js/package.json +++ b/rust/perspective-js/package.json @@ -1,29 +1,53 @@ { "name": "@perspective-dev/client", "version": "4.4.1", - "description": "", + "description": "Client for Perspective, a high-performance WASM engine with reactive Tables, Views, and joins.", + "keywords": [ + "perspective", + "data", + "analytics", + "streaming", + "wasm", + "arrow", + "duckdb", + "clickhouse" + ], + "homepage": "https://perspective-dev.github.io", "repository": { "type": "git", "url": "https://github.com/perspective-dev/perspective" }, "type": "module", "license": "Apache-2.0", + "sideEffects": false, "unpkg": "dist/cdn/perspective.js", "jsdelivr": "dist/cdn/perspective.js", "exports": { ".": { "node": { "types": "./dist/esm/perspective.node.d.ts", + "import": "./dist/esm/perspective.node.js", "default": "./dist/esm/perspective.node.js" }, "types": "./dist/esm/perspective.browser.d.ts", + "import": "./dist/esm/perspective.js", "default": "./dist/esm/perspective.js" }, "./node": { "types": "./dist/esm/perspective.node.d.ts", + "import": "./dist/esm/perspective.node.js", "default": "./dist/esm/perspective.node.js" }, - "./virtual_servers/*": "./dist/esm/virtual_servers/*", + "./inline": { + "types": "./dist/esm/perspective.browser.d.ts", + "import": "./dist/esm/perspective.inline.js", + "default": "./dist/esm/perspective.inline.js" + }, + "./virtual_servers/*": { + "types": "./dist/esm/virtual_servers/*.d.ts", + "import": "./dist/esm/virtual_servers/*.js", + "default": "./dist/esm/virtual_servers/*.js" + }, "./dist/*": "./dist/*", "./src/*": "./src/*", "./test/*": "./test/*", diff --git a/rust/perspective-js/src/rust/client.rs b/rust/perspective-js/src/rust/client.rs index 3d8359db02..ac24837333 100644 --- a/rust/perspective-js/src/rust/client.rs +++ b/rust/perspective-js/src/rust/client.rs @@ -481,6 +481,34 @@ impl Client { Ok(Table(self.client.open_table(entity_id).await?)) } + /// Unsafely gets a [`View`] by raw ID, useful for JavaScript multi-threaded + /// (via Web Worker) context where a standard `View` cannot otherwise be + /// shared because its wrapper is not serializable. + /// + /// # Safety + /// + /// This method is unsafe because the lifetime of a [`View`] is bound to + /// the [`Client`] which created it. + /// + /// The caller must guarantee that `entity_id` corresponds to a live + /// [`crate::View`] on the connected server (obtained from another + /// [`Client`]'s [`crate::View::get_name`] and forwarded across the + /// serialization boundary). + /// + /// # JavaScript Examples + /// + /// ```javascript + /// const view = client.__unsafe_open_view(name_from_main_thread); + /// const cols = await view.to_columns(); + /// ``` + #[wasm_bindgen] + pub fn __unsafe_open_view(&self, entity_id: String) -> crate::view::View { + crate::view::View(perspective_client::View::new( + entity_id, + self.client.clone(), + )) + } + /// Retrieves the names of all tables that this client has access to. /// /// `name` is a string identifier unique to the [`Table`] (per [`Client`]), diff --git a/rust/perspective-js/src/rust/typed_array.rs b/rust/perspective-js/src/rust/typed_array.rs index 361f27970f..ad195d797e 100644 --- a/rust/perspective-js/src/rust/typed_array.rs +++ b/rust/perspective-js/src/rust/typed_array.rs @@ -128,33 +128,23 @@ pub(crate) async fn decode_and_call( js_dicts.set(col_idx as u32, JsValue::NULL); }, DataType::Date32 => { + // Datetime values are always emitted as Float64 — narrowing + // epoch-ms to f32 collapses ~256 ms of resolution at modern + // timestamps, so the `float32` flag is intentionally ignored + // for date/timestamp columns. let typed = col.as_primitive::(); - if float32 { - f32_storage.push( - typed - .values() - .iter() - .map(|&v| v as f32 * 86_400_000.0) - .collect(), - ); - } else { - f64_storage.push( - typed - .values() - .iter() - .map(|&v| v as f64 * 86_400_000.0) - .collect(), - ); - } + f64_storage.push( + typed + .values() + .iter() + .map(|&v| v as f64 * 86_400_000.0) + .collect(), + ); js_dicts.set(col_idx as u32, JsValue::NULL); }, DataType::Timestamp(TimeUnit::Millisecond, _) => { let typed = col.as_primitive::(); - if float32 { - f32_storage.push(typed.values().iter().map(|&v| v as f32).collect()); - } else { - f64_storage.push(typed.values().iter().map(|&v| v as f64).collect()); - } + f64_storage.push(typed.values().iter().map(|&v| v as f64).collect()); js_dicts.set(col_idx as u32, JsValue::NULL); }, DataType::Int64 => { @@ -207,15 +197,12 @@ pub(crate) async fn decode_and_call( let col = batch.column(col_idx); let uses_f32_storage = matches!( (col.data_type(), float32), - (DataType::Float64, true) - | (DataType::Date32, true) - | (DataType::Timestamp(TimeUnit::Millisecond, _), true) - | (DataType::Int64, true), + (DataType::Float64, true) | (DataType::Int64, true), ); let uses_f64_storage = matches!( (col.data_type(), float32), - (DataType::Date32, false) - | (DataType::Timestamp(TimeUnit::Millisecond, _), false) + (DataType::Date32, _) + | (DataType::Timestamp(TimeUnit::Millisecond, _), _) | (DataType::Int64, false), ); diff --git a/rust/perspective-js/src/rust/view.rs b/rust/perspective-js/src/rust/view.rs index 84e1b49b87..9ba8a70a56 100644 --- a/rust/perspective-js/src/rust/view.rs +++ b/rust/perspective-js/src/rust/view.rs @@ -88,6 +88,12 @@ impl View { self.clone() } + #[wasm_bindgen] + #[doc(hidden)] + pub fn __unsafe_get_name(&self) -> String { + self.0.name.clone() + } + /// Returns an array of strings containing the column paths of the [`View`] /// without any of the source columns. /// diff --git a/rust/perspective-js/src/rust/virtual_server.rs b/rust/perspective-js/src/rust/virtual_server.rs index c918aeb0d4..d3f6a74324 100644 --- a/rust/perspective-js/src/rust/virtual_server.rs +++ b/rust/perspective-js/src/rust/virtual_server.rs @@ -372,7 +372,7 @@ impl VirtualServerHandler for JsServerHandler { let handler = self.0.clone(); let view_id = view_id.to_string(); - let config_value = serde_wasm_bindgen::to_value(config).unwrap(); + let config_value = JsValue::from_serde_ext(config).unwrap(); let config = config.clone(); Box::pin(async move { let this = JsServerHandler(handler); @@ -493,7 +493,7 @@ impl VirtualServerHandler for JsServerHandler { let handler = self.0.clone(); let view_id = view_id.to_string(); let column_name = column_name.to_string(); - let config_js = serde_wasm_bindgen::to_value(config).unwrap(); + let config_js = JsValue::from_serde_ext(config).unwrap(); Box::pin(async move { let this = JsServerHandler(handler); let args = Array::new(); @@ -518,8 +518,8 @@ impl VirtualServerHandler for JsServerHandler { let handler = self.0.clone(); let view_id = view_id.to_string(); let window: JsViewPort = viewport.clone().into(); - let config_value = serde_wasm_bindgen::to_value(config).unwrap(); - let window_value = serde_wasm_bindgen::to_value(&window).unwrap(); + let config_value = JsValue::from_serde_ext(config).unwrap(); + let window_value = JsValue::from_serde_ext(&window).unwrap(); let schema_value = JsValue::from_serde_ext(&schema).unwrap(); Box::pin(async move { diff --git a/rust/perspective-js/src/ts/perspective.browser.ts b/rust/perspective-js/src/ts/perspective.browser.ts index dc951ca1f2..6b4f935843 100644 --- a/rust/perspective-js/src/ts/perspective.browser.ts +++ b/rust/perspective-js/src/ts/perspective.browser.ts @@ -65,13 +65,32 @@ export function init_server( } let GLOBAL_CLIENT_WASM: Promise; +let GLOBAL_CLIENT_MODULE: Promise | undefined; + +async function compile_module(wasm: any): Promise { + if (wasm instanceof WebAssembly.Module) { + return wasm; + } + + if (typeof Response !== "undefined" && wasm instanceof Response) { + return WebAssembly.compileStreaming(wasm); + } + + return WebAssembly.compile(wasm); +} async function compilerize( wasm: PerspectiveWasm, disable_stage_0: boolean = false, ) { const wasm_buff = disable_stage_0 ? wasm : await load_wasm_stage_0(wasm); - await wasm_module.default({ module_or_path: wasm_buff }); + // Compile to a `WebAssembly.Module` once so it can be both instantiated + // locally and forwarded to other workers via `getCompiledClientWasm()`. + // `WebAssembly.Module` is structured-cloneable across workers, so the + // recipient can instantiate without re-fetching or re-compiling. + const compiled = await compile_module(wasm_buff); + GLOBAL_CLIENT_MODULE = Promise.resolve(compiled); + await wasm_module.default({ module_or_path: compiled }); await wasm_module.init(); return wasm_module; } @@ -103,6 +122,14 @@ function get_client() { const viewer_class: any = customElements.get("perspective-viewer"); if (viewer_class) { GLOBAL_CLIENT_WASM = Promise.resolve(viewer_class.__wasm_module__); + if ( + GLOBAL_CLIENT_MODULE === undefined && + viewer_class.__wasm_client_module__ + ) { + GLOBAL_CLIENT_MODULE = Promise.resolve( + viewer_class.__wasm_client_module__, + ); + } } else if (GLOBAL_CLIENT_WASM === undefined) { throw new Error("Missing perspective-client.wasm"); } @@ -110,6 +137,43 @@ function get_client() { return GLOBAL_CLIENT_WASM; } +/** + * Returns the compiled `WebAssembly.Module` for the perspective-js client + * runtime. The module is structured-cloneable, so it can be sent via + * `postMessage` to a Worker which can instantiate its own `Client` without + * re-fetching or re-compiling the wasm binary. + * + * Requires that the client wasm has been initialized — typically by a prior + * call to `init_client(...)`, or implicitly by mounting a `` + * element. Throws otherwise. + * + * # Examples + * + * ```javascript + * const mod = await perspective.getCompiledClientWasm(); + * worker.postMessage({ kind: "init", clientWasm: mod }, [port]); + * ``` + */ +export async function getCompiledClientWasm(): Promise { + if (GLOBAL_CLIENT_MODULE !== undefined) { + return GLOBAL_CLIENT_MODULE; + } + + const viewer_class: any = customElements.get("perspective-viewer"); + if (viewer_class?.__wasm_client_module__) { + GLOBAL_CLIENT_MODULE = Promise.resolve( + viewer_class.__wasm_client_module__, + ); + return GLOBAL_CLIENT_MODULE; + } + + throw new Error( + "perspective-js client wasm has not been compiled yet — call " + + "`init_client(...)` or `perspective.worker()` before " + + "`getCompiledClientWasm()`.", + ); +} + function get_server() { if (GLOBAL_SERVER_WASM === undefined) { throw new Error("Missing perspective-server.wasm"); @@ -122,13 +186,30 @@ function get_server() { let GLOBAL_WORKER: undefined | (() => Promise) = undefined; -// Inline the worker for now. This code will eventually allow outlining this resource -// @ts-ignore -import perspective_wasm_worker from "../../src/ts/perspective-server.worker.js"; - -function get_worker(): Promise { +// `WorkerPlugin` resolves this import to a stub that exports +// `getPerspectiveWorkerURL(): Promise`. The URL is either a +// Blob URL (inline mode — production builds) or a real file path +// resolved against `import.meta.url` (file mode — debug builds). +// Constructing the `Worker` lives here in the consumer rather than +// inside the plugin so the same module text can also be loaded +// in-process via dynamic `import(url)` when a future caller wants +// it; the plugin no longer owns Worker lifecycle. +// +// `initialize()` constructs `new Worker(blobUrl)` and falls back to +// running the worker source on the main thread via `new Function(...)` +// when Worker construction is unavailable (e.g. `file://` origins where +// module-Worker support is gated). The shim it returns is +// MessagePort-shaped so downstream code can treat it like a real +// Worker. +// @ts-ignore — resolved at build time by `@perspective-dev/esbuild-plugin/worker` +import { initialize as initializePerspectiveWorker } from "../../src/ts/perspective-server.worker.js"; + +async function get_worker(): Promise { if (GLOBAL_WORKER === undefined) { - return perspective_wasm_worker(); + return await initializePerspectiveWorker({ + type: "module", + name: "perspective-server", + }); } return GLOBAL_WORKER(); @@ -154,6 +235,7 @@ export default { init_client, init_server, createMessageHandler, + getCompiledClientWasm, GenericSQLVirtualServerModel, VirtualDataSlice, VirtualServer, diff --git a/rust/perspective-js/src/ts/perspective.node.ts b/rust/perspective-js/src/ts/perspective.node.ts index c9bcb03f4a..f4e23d0721 100644 --- a/rust/perspective-js/src/ts/perspective.node.ts +++ b/rust/perspective-js/src/ts/perspective.node.ts @@ -121,7 +121,7 @@ export async function cwd_static_file_handler( request: http.IncomingMessage, response: http.ServerResponse, assets = ["./"], - { debug = true } = {}, + { debug = false } = {}, ) { let url = request.url @@ -143,10 +143,12 @@ export async function cwd_static_file_handler( if (debug) { console.log(`200 ${url}`); } + response.writeHead(200, { "Content-Type": contentType, "Access-Control-Allow-Origin": "*", }); + if (extname === ".arrow" || extname === ".feather") { response.end(content, "utf8"); } else { diff --git a/rust/perspective-js/src/ts/virtual_servers/duckdb.ts b/rust/perspective-js/src/ts/virtual_servers/duckdb.ts index 436c64f726..a8a762aaac 100644 --- a/rust/perspective-js/src/ts/virtual_servers/duckdb.ts +++ b/rust/perspective-js/src/ts/virtual_servers/duckdb.ts @@ -115,6 +115,10 @@ function duckdbTypeToPsp(name: string): ColumnType { return "string"; } + if (name.startsWith("time")) { + return "float"; + } + console.warn(`Unknown type '${name}'`); return "string"; } diff --git a/rust/perspective-js/src/ts/wasm/perspective-server.poly.ts b/rust/perspective-js/src/ts/wasm/perspective-server.poly.ts index 386c3c2609..587f98eb23 100644 --- a/rust/perspective-js/src/ts/wasm/perspective-server.poly.ts +++ b/rust/perspective-js/src/ts/wasm/perspective-server.poly.ts @@ -73,7 +73,7 @@ export default async function (obj: any) { }, ); - // e.message = getExceptionMessage(e); + // @ts-ignore This is helpful for debugging e.message = "Unexpected internal error"; throw e; }, diff --git a/rust/perspective-python/clean.mjs b/rust/perspective-python/clean.mjs index 581cb356b6..d971b54527 100644 --- a/rust/perspective-python/clean.mjs +++ b/rust/perspective-python/clean.mjs @@ -13,9 +13,19 @@ import * as fs from "node:fs"; import "zx/globals"; +const version = JSON.parse(fs.readFileSync("./package.json")).version; + fs.rmSync("dist", { recursive: true, force: true }); fs.rmSync("build", { recursive: true, force: true }); +fs.rmSync(`perspective_python-${version}.data`, { + recursive: true, + force: true, +}); + +fs.rmSync("LICENSE.md", { recursive: true, force: true }); +fs.rmSync("LICENSE_THIRDPARTY_cargo.yml", { recursive: true, force: true }); +fs.rmSync("perspective/*.so", { recursive: true, force: true }); -for (const path in glob("*.data")) { - fs.rmSync(path, { recursive: true, force: true }); -} +// for (const path in glob("*.data")) { +// fs.rmSync(path, { recursive: true, force: true }); +// } diff --git a/rust/perspective-python/perspective/templates/exported_widget.html.template b/rust/perspective-python/perspective/templates/exported_widget.html.template index 2cd81db325..080f46de31 100644 --- a/rust/perspective-python/perspective/templates/exported_widget.html.template +++ b/rust/perspective-python/perspective/templates/exported_widget.html.template @@ -1,7 +1,7 @@ - +
    diff --git a/rust/perspective-python/perspective/widget/__init__.py b/rust/perspective-python/perspective/widget/__init__.py index f939d2be27..adf2bca858 100644 --- a/rust/perspective-python/perspective/widget/__init__.py +++ b/rust/perspective-python/perspective/widget/__init__.py @@ -326,7 +326,7 @@ def psp_cdn(module, path=None): psp_cdn_perspective_viewer_datagrid=psp_cdn( "perspective-viewer-datagrid" ), - psp_cdn_perspective_viewer_d3fc=psp_cdn("perspective-viewer-charts"), + psp_cdn_perspective_viewer_charts=psp_cdn("perspective-viewer-charts"), psp_cdn_perspective_viewer_themes=psp_cdn( "perspective-viewer-themes", "css/themes.css" ), diff --git a/rust/perspective-python/src/server/virtual_server_sync.rs b/rust/perspective-python/src/server/virtual_server_sync.rs index 664306ec58..710f4dc20f 100644 --- a/rust/perspective-python/src/server/virtual_server_sync.rs +++ b/rust/perspective-python/src/server/virtual_server_sync.rs @@ -17,7 +17,8 @@ use chrono::{DateTime, TimeZone, Utc}; use indexmap::IndexMap; use perspective_client::proto::{ColumnType, HostedTable}; use perspective_client::virtual_server::{ - Features, ResultExt, VirtualDataSlice, VirtualServer, VirtualServerFuture, VirtualServerHandler, + Features, ResultExt, RowPathStyle, VirtualDataSlice, VirtualServer, VirtualServerFuture, + VirtualServerHandler, }; use pyo3::exceptions::PyValueError; use pyo3::types::{ @@ -415,7 +416,7 @@ impl PyVirtualDataSlice { self.0 .lock() .unwrap() - .render_to_columns_json() + .render_to_columns_json(RowPathStyle::Sidecar) .map_err(|e| PyValueError::new_err(e.to_string())) } diff --git a/rust/perspective-server/cpp/perspective/src/cpp/arrow_loader.cpp b/rust/perspective-server/cpp/perspective/src/cpp/arrow_loader.cpp index a6226d923b..083bba1704 100644 --- a/rust/perspective-server/cpp/perspective/src/cpp/arrow_loader.cpp +++ b/rust/perspective-server/cpp/perspective/src/cpp/arrow_loader.cpp @@ -185,6 +185,9 @@ convert_type(const std::string& src) { if (src == "timestamp") { return DTYPE_TIME; } + if (src == "time32" || src == "time64" || src == "time32[s]" ) { + return DTYPE_UINT32; + } if (src == "date32" || src == "date64") { return DTYPE_DATE; } @@ -776,6 +779,17 @@ copy_array( dest->set_valid(i, false); } } break; + case arrow::Time32Type::type_id: { + auto scol = std::static_pointer_cast(src); + std::memcpy( + dest->get_nth(offset), + (void*)scol->raw_values(), + len * 8 + ); + } break; + // case arrow::Type { + + // } break; default: { std::stringstream ss; std::string arrow_type = src->type()->ToString(); diff --git a/rust/perspective-viewer/build.mjs b/rust/perspective-viewer/build.mjs index c1a46521d3..21e6ee74bb 100644 --- a/rust/perspective-viewer/build.mjs +++ b/rust/perspective-viewer/build.mjs @@ -12,6 +12,7 @@ import { execSync } from "child_process"; import { build } from "@perspective-dev/esbuild-plugin/build.js"; +import { WorkerPlugin } from "@perspective-dev/esbuild-plugin/worker.js"; import { NodeModulesExternal } from "@perspective-dev/esbuild-plugin/external.js"; import * as fs from "node:fs"; import * as path from "node:path"; @@ -60,6 +61,16 @@ export async function build_all() { format: "esm", loader: { ".wasm": "binary" }, outfile: "dist/esm/perspective-viewer.inline.js", + plugins: [ + WorkerPlugin({ + inline: !process.env.PSP_DEBUG, + // plugins: [GlslMinify(), LightningCssMinify()], + // loader: { + // ".css": "text", + // ".glsl": "text", + // }, + }), + ], }, // No WASM assets inlined or linked. // { @@ -73,6 +84,16 @@ export async function build_all() { format: "esm", external: ["*.wasm"], outdir: "dist/esm", + plugins: [ + WorkerPlugin({ + inline: true, + // plugins: [GlslMinify(), LightningCssMinify()], + // loader: { + // ".css": "text", + // ".glsl": "text", + // }, + }), + ], }, // WASM assets linked to relative path via `fetch()`. This efficiently // loading build is great for ` + + + + + + + + + + + + + + + diff --git a/tools/esbuild-plugin/index.js b/tools/esbuild-plugin/index.js index 0cbc619d79..eb576c06ce 100644 --- a/tools/esbuild-plugin/index.js +++ b/tools/esbuild-plugin/index.js @@ -21,8 +21,12 @@ exports.PerspectiveEsbuildPlugin = function PerspectiveEsbuildPlugin( // !options.wasm?.webpack_hack // ); + // `inline` (default true) keeps the legacy single-bundle behavior + // — worker source embedded as a string and wrapped in a Blob URL + // at runtime. Pass `worker: { inline: false }` to emit the worker + // as a sibling file (preserves source maps in DevTools). const worker_plugin = WorkerPlugin({ - targetdir: options.worker?.targetdir, + inline: options.worker?.inline !== false, }); function setup(build) { diff --git a/tools/esbuild-plugin/worker.js b/tools/esbuild-plugin/worker.js index 80b6ccbe65..e13e337b45 100644 --- a/tools/esbuild-plugin/worker.js +++ b/tools/esbuild-plugin/worker.js @@ -14,43 +14,92 @@ const fs = require("fs"); const path = require("path"); const esbuild = require("esbuild"); +/** + * Esbuild plugin that compiles a `.worker.js` import into a + * URL-yielding module. The shim `getWorkerURL()` returns a string + * that's usable by both `new Worker(url, { type: "module" })` *and* + * `await import(url)` — the in-process renderer path uses the latter + * so the chart code lives in the worker bundle exactly once. + * + * Two output modes: + * - `inline: true` (default, prod). The subbuild's bytes are + * embedded as a JS string in the parent bundle. `getWorkerURL()` + * creates a Blob URL at runtime, cached so both consumers share + * the same URL (and the same module instance via dynamic-import + * dedup). + * - `inline: false` (debug). The subbuild's output is written to + * the parent's `outdir` next to the main bundle, with source + * maps. `getWorkerURL()` resolves `import.meta.url` to that file, + * so DevTools can show real source paths and breakpoints work. + */ exports.WorkerPlugin = function WorkerPlugin(options = {}) { - const targetdir = options.targetdir || "build/worker"; + /** + * Optional esbuild plugins to apply to the worker sub-build (e.g. + * `GlslMinify`, `LightningCssMinify`). Use when the worker entry + * imports the same custom-loader file types as the outer bundle. + */ + const subbuildPlugins = options.plugins || []; + + /** + * Optional `loader` map for the worker sub-build (e.g. + * `{ ".glsl": "text", ".css": "text" }`). + */ + const subbuildLoader = options.loader || {}; + + /** + * `false` to emit the worker bundle as a real file alongside the + * parent bundle (debug builds — preserves source maps + real + * paths in DevTools). Defaults to inline-Blob mode for prod. + */ + const inline = options.inline !== false; + + const additionalOptions = options.additionalOptions || {}; + function setup(build) { - const options = build.initialOptions; - options.metafile = true; + build.initialOptions.metafile = true; + build.onResolve({ filter: /\.worker(\.js)?$/ }, (args) => { if (args.namespace === "worker-stub") { - const outfile = - `${targetdir}/` + - path.basename(args.path).replace(".worker", ""); + const baseName = path + .basename(args.path) + .replace(".worker", ""); const entryPoint = path.join( args.pluginData.resolveDir, - args.path + args.path, ); + // `outdir` is set so that file-mode subbuilds produce + // real, non-`` paths in `outputFiles[].path` + // — we use those paths to name the on-disk artifacts + // when copying to the parent bundle's outdir. In + // inline mode the path doesn't matter (we only read + // the bytes), but setting `outdir` is harmless. const subbuild = esbuild.build({ target: ["es2021"], entryPoints: [entryPoint], - // outfile, define: { global: "self", }, + outdir: ".", entryNames: "[name]", chunkNames: "[name]", assetNames: "[name]", - minify: true, + minify: !process.env.PSP_DEBUG, bundle: true, - sourcemap: false, + sourcemap: !inline, write: false, + plugins: subbuildPlugins, + loader: subbuildLoader, + format: "esm", + ...additionalOptions, }); return { path: args.path.replace(".worker", ""), namespace: "worker", pluginData: { - outfile, + baseName, subbuild, }, }; @@ -68,75 +117,144 @@ exports.WorkerPlugin = function WorkerPlugin(options = {}) { build.onLoad( { filter: /.*/, namespace: "worker-stub" }, async (args) => { - return { - pluginData: args.pluginData, - contents: ` - import worker from ${JSON.stringify(args.path)}; - function make_host(a, b) { - function addEventListener(type, callback) { - if (type === "message") { - a.push(callback); - } - } + if (inline) { + return { + pluginData: args.pluginData, - function removeEventListener(callback) { - const idx = a.indexOf(callback); - if (idx > -1) { - a.splice(idx, 1); - } + // Inline mode: the parent bundle imports the + // worker bytes as a text string and constructs + // a Blob URL on first call to `getWorkerURL`. + // The cached URL is reused for both + // `new Worker(url)` and `await import(url)` so + // module dedup keeps a single instance. + // + // `initialize()` adds a Worker-or-shim path: + // attempts `new Worker(blobUrl)` first; falls + // back to running the worker source text on the + // main thread via `new Function(...)` when + // Worker construction is unavailable (e.g. + // `file://` origins where module-Worker support + // is gated, or environments without the Worker + // constructor at all). The shim returned by + // \`make_host\` is MessagePort-shaped so + // downstream consumers can treat it like a real + // Worker. + contents: ` + import workerSource from ${JSON.stringify(args.path)}; + let cached = null; + export async function getWorkerURL() { + if (cached) return cached; + const blob = new Blob([workerSource], { + type: "application/javascript", + }); + + cached = URL.createObjectURL(blob); + return cached; } - function postMessage(msg, ports) { - for (const listener of b) { - listener({data: msg, ports: ports}); - } + function make_host(a, b) { + return { + addEventListener(type, callback) { + if (type === "message") { + a.push(callback); + } + }, + removeEventListener(type, callback) { + const idx = a.indexOf(callback); + if (idx > -1) { + a.splice(idx, 1); + } + }, + postMessage(msg, ports) { + for (const listener of b) { + listener({ + data: msg, + ports: ports, + }); + } + }, + terminate() {}, + location: { href: "" }, + }; } - return { - addEventListener, - removeEventListener, - postMessage, - location: {href: ""} + function run_single_threaded() { + console.warn( + "Running perspective in single-threaded mode" + ); + const f = Function( + "const self = arguments[0];" + workerSource + ); + const workers = []; + const mains = []; + f(make_host(workers, mains)); + return make_host(mains, workers); } - } - function run_single_threaded(code) { - console.error("Running perspective in single-threaded mode"); - let f = Function("const self = arguments[0];" + code); - const workers = []; - const mains = []; - f(make_host(workers, mains)); - return make_host(mains, workers); - } + export async function initialize(opts) { + const workerOpts = opts || { + type: "module", + }; + try { + if ( + typeof window !== "undefined" && + window.location && + window.location.protocol && + window.location.protocol.startsWith( + "file" + ) + ) { + console.warn( + "file:// protocol does not reliably support Web Workers" + ); + return run_single_threaded(); + } - export const initialize = async function () { - try { - if (window.location.protocol.startsWith("file")) { - console.warn("file:// protocol does not support Web Workers"); - return run_single_threaded(worker); - } else { - const blob = new Blob([worker], {type: 'application/javascript'}); - const url = URL.createObjectURL(blob); - return new Worker(url, {type: "module"}); + const url = await getWorkerURL(); + const worker = new Worker(url, workerOpts); + return worker; + } catch (e) { + console.error( + "Error instantiating worker; falling back to single-threaded mode", + e + ); + return run_single_threaded(); } - } catch (e) { - console.error("Error instantiating engine", e); } - }; - export default initialize; + export default getWorkerURL; + `, + }; + } + + // File mode: the subbuild writes its output to disk + // next to the parent bundle, and `getWorkerURL` + // resolves the worker file relative to the consuming + // module's URL (so ` - - - - - - - - - - - - - diff --git a/tools/test/src/html/two-chart-test.html b/tools/test/src/html/two-chart-test.html new file mode 100644 index 0000000000..a585f20720 --- /dev/null +++ b/tools/test/src/html/two-chart-test.html @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + +
    +
    + + diff --git a/tools/test/src/js/models/column_settings.ts b/tools/test/src/js/models/column_settings.ts index bf98b09d49..ef39272ab1 100644 --- a/tools/test/src/js/models/column_settings.ts +++ b/tools/test/src/js/models/column_settings.ts @@ -33,14 +33,21 @@ export class ColumnSettingsSidebar { this.attributesTab = new AttributesTab(this.container); this.styleTab = new StyleTab(this.container); this.closeBtn = viewer.locator("#column_settings_close_button"); - this.tabTitle = view.container.locator( - ".tab:not(.tab-padding) .tab-title", - ); + // Tab DOM now uses `.settings_tab` / `.settings_tab.selected_tab` + // (see `containers/tab_list.rs`); the previous `.tab-padding` + // filler span was removed, so no `:not(.tab-padding)` filter is + // needed. These class names are shared with the viewer's query + // tabbar (`#query_tabbar_tab`), so the locators are scoped to + // `this.container` (the sidebar root) to avoid strict-mode + // collisions across the two tab implementations. + this.tabTitle = this.container.locator(".settings_tab .tab-title"); this.nameInputWrapper = view.container.locator( ".sidebar_header_contents", ); this.nameInput = view.container.locator("input.sidebar_header_title"); - this.selectedTab = view.container.locator(".tab.selected"); + this.selectedTab = this.container.locator( + ".settings_tab.selected_tab", + ); this.typeIcon = this.container.locator(".type-icon"); } @@ -48,7 +55,7 @@ export class ColumnSettingsSidebar { let locator = this.container.locator("#" + name); await locator.click({ timeout: 1000 }); await this.container - .locator(`.tab.selected #${name}`) + .locator(`.settings_tab.selected_tab #${name}`) .waitFor({ timeout: 1000 }); } diff --git a/tools/test/src/js/models/plugins/datagrid.ts b/tools/test/src/js/models/plugins/datagrid.ts index decd29c37f..5108d38fe1 100644 --- a/tools/test/src/js/models/plugins/datagrid.ts +++ b/tools/test/src/js/models/plugins/datagrid.ts @@ -46,8 +46,8 @@ export class RegularTable { } async getTitleIdx(name: string) { - let ths = await this.realTitles.all(); - for (let [i, locator] of ths.entries()) { + const ths = await this.realTitles.all(); + for (const [i, locator] of ths.entries()) { if ((await locator.innerText()) === name) { this.element.evaluate( (_, i) => console.log("getTitleIdx returned:", i), @@ -56,20 +56,22 @@ export class RegularTable { return i; } } + return -1; } async getEditBtnByName(name: string) { - let n = await this.getTitleIdx(name); + const n = await this.getTitleIdx(name); expect(n).not.toBe(-1); return this.editBtnRow.locator("th").nth(n); } + /** * Takes the name of a column and returns a locator for the first corresponding TD in the body. * @param name */ async getFirstCellByColumnName(name: string) { - let n = await this.getTitleIdx(name); + const n = await this.getTitleIdx(name); expect(n).not.toBe(-1); return this.table.locator("tbody tr").first().locator("td").nth(n); } diff --git a/tools/test/src/js/snapshot-sync.ts b/tools/test/src/js/snapshot-sync.ts index b49d5ce092..17f196286e 100644 --- a/tools/test/src/js/snapshot-sync.ts +++ b/tools/test/src/js/snapshot-sync.ts @@ -60,6 +60,7 @@ function mirrorSnapshots(srcRoot: string, dest: string) { fs.rmSync(dest, { recursive: true, force: true }); fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.cpSync(srcSnapshots, dest, { recursive: true }); + fs.rmSync(path.join(dest, ".git"), { recursive: true, force: true }); } export async function fetchSnapshots(): Promise { @@ -83,6 +84,7 @@ export async function fetchSnapshots(): Promise { console.log( `Snapshot branch '${requestedRef}' not found on ${repo}; falling back to '${DEFAULT_REF}'.`, ); + ref = DEFAULT_REF; } @@ -126,11 +128,14 @@ export async function writebackSnapshots(): Promise { console.log( `No snapshot clone at ${CACHE_DIR}; skipping writeback. Run with --fetch-snapshots first to populate the cache.`, ); + return; } + if (!fs.existsSync(DEST_DIR)) { return; } + fs.cpSync(DEST_DIR, CACHE_DIR, { recursive: true, force: true }); - console.log(`Copied updated snapshots into ${CACHE_DIR}`); + console.log(`\nCopied updated snapshots into ${CACHE_DIR}`); }