From cdb2f03edf22e6a6a263cd368118ba8ae6efa05a Mon Sep 17 00:00:00 2001 From: Andrew Stein Date: Tue, 5 May 2026 15:31:06 -0400 Subject: [PATCH 1/4] `Worker` support for `viewer-charts` Signed-off-by: Andrew Stein --- .github/workflows/build.yaml | 10 + docs/md/how_to/javascript/installation.md | 8 +- eslint.config.mjs | 102 ++ examples/blocks/src/fractal/index.css | 24 +- examples/blocks/src/fractal/index.js | 2 - examples/blocks/src/market/layouts.json | 14 +- examples/blocks/src/raycasting/index.css | 12 +- examples/blocks/src/raycasting/index.html | 2 +- examples/blocks/src/raycasting/index.js | 3 +- package.json | 4 +- packages/react/package.json | 27 +- packages/viewer-charts/build.mjs | 59 +- packages/viewer-charts/package.json | 1 + .../src/css/perspective-viewer-charts.css | 90 +- .../ts/{chrome => axis}/axis-primitives.ts | 37 +- .../src/ts/{chrome => axis}/bar-axis.ts | 110 +- .../src/ts/{chrome => axis}/canvas.ts | 27 +- .../{chrome => axis}/categorical-axis-core.ts | 27 +- .../ts/{chrome => axis}/categorical-axis.ts | 485 +++++---- .../src/ts/{chrome => axis}/label-geometry.ts | 91 +- .../src/ts/{chrome => axis}/legend.ts | 69 +- .../viewer-charts/src/ts/axis/numeric-axis.ts | 315 ++++++ .../src/ts/charts/bar/bar-build.ts | 387 ------- .../src/ts/charts/bar/bar-render.ts | 574 ---------- .../viewer-charts/src/ts/charts/bar/bar.ts | 288 ----- .../src/ts/charts/bar/glyphs/draw-areas.ts | 180 ---- .../src/ts/charts/bar/glyphs/draw-lines.ts | 213 ---- .../src/ts/charts/bar/glyphs/draw-scatter.ts | 152 --- .../charts/candlestick/candlestick-build.ts | 391 +++++-- .../candlestick/candlestick-interact.ts | 251 +++-- .../charts/candlestick/candlestick-render.ts | 278 +++-- .../src/ts/charts/candlestick/candlestick.ts | 198 +++- .../candlestick/glyphs/draw-candlesticks.ts | 394 ++++--- .../ts/charts/candlestick/glyphs/draw-ohlc.ts | 278 +++-- .../src/ts/charts/canvas-types.ts | 59 +- .../cartesian-build.ts} | 245 ++++- .../cartesian-interact.ts} | 161 ++- .../cartesian-render.ts} | 318 ++++-- .../cartesian.ts} | 169 +-- .../charts/{continuous => cartesian}/glyph.ts | 30 +- .../{continuous => cartesian}/glyphs/lines.ts | 97 +- .../glyphs/points.ts | 115 +- .../src/ts/charts/cartesian/label-interner.ts | 56 + .../viewer-charts/src/ts/charts/chart-base.ts | 232 ++-- packages/viewer-charts/src/ts/charts/chart.ts | 122 ++- .../src/ts/charts/common/band-layout.ts | 21 +- .../ts/charts/common/categorical-y-chart.ts | 28 +- .../charts/common/category-axis-resolver.ts | 314 ++++++ .../src/ts/charts/common/category-axis.ts | 103 -- .../src/ts/charts/common/chrome-cache.ts | 79 ++ .../src/ts/charts/common/draw-tooltip-box.ts | 84 ++ .../src/ts/charts/common/leaf-color.ts | 92 ++ .../src/ts/charts/common/node-store.ts | 33 +- .../src/ts/charts/common/tree-chart.ts | 27 +- .../src/ts/charts/common/tree-chrome.ts | 123 +++ .../src/ts/charts/common/tree-data.ts | 155 ++- .../src/ts/charts/common/visible-extent.ts | 41 +- .../src/ts/charts/heatmap/heatmap-build.ts | 297 ++++-- .../src/ts/charts/heatmap/heatmap-interact.ts | 142 ++- .../src/ts/charts/heatmap/heatmap-render.ts | 384 +++++-- .../src/ts/charts/heatmap/heatmap-y-axis.ts | 81 +- .../src/ts/charts/heatmap/heatmap.ts | 107 +- .../viewer-charts/src/ts/charts/registry.ts | 51 + .../src/ts/charts/series/glyphs/draw-areas.ts | 339 ++++++ .../{bar => series}/glyphs/draw-bars.ts | 94 +- .../src/ts/charts/series/glyphs/draw-lines.ts | 328 ++++++ .../ts/charts/series/glyphs/draw-scatter.ts | 318 ++++++ .../src/ts/charts/series/series-build.ts | 762 +++++++++++++ .../series-interact.ts} | 364 +++++-- .../src/ts/charts/series/series-render.ts | 998 ++++++++++++++++++ .../chart-type.ts => series/series-type.ts} | 11 +- .../src/ts/charts/series/series.ts | 703 ++++++++++++ .../ts/charts/sunburst/sunburst-interact.ts | 188 ++-- .../src/ts/charts/sunburst/sunburst-layout.ts | 35 +- .../src/ts/charts/sunburst/sunburst-render.ts | 453 ++++---- .../src/ts/charts/sunburst/sunburst.ts | 88 +- .../src/ts/charts/treemap/treemap-interact.ts | 94 +- .../src/ts/charts/treemap/treemap-layout.ts | 58 +- .../src/ts/charts/treemap/treemap-render.ts | 632 ++++------- .../src/ts/charts/treemap/treemap.ts | 86 +- .../src/ts/chrome/numeric-axis.ts | 377 ------- packages/viewer-charts/src/ts/config.ts | 46 + .../viewer-charts/src/ts/data/lazy-row.ts | 30 +- .../viewer-charts/src/ts/data/split-groups.ts | 42 +- .../viewer-charts/src/ts/data/view-reader.ts | 31 +- packages/viewer-charts/src/ts/index.ts | 45 +- .../src/ts/interaction/hit-test.ts | 10 +- .../src/ts/interaction/host-sink-dom.ts | 85 ++ .../src/ts/interaction/host-sink-message.ts | 61 ++ .../src/ts/interaction/lazy-tooltip.ts | 102 ++ .../src/ts/interaction/raw-event-forwarder.ts | 175 +++ .../src/ts/interaction/spatial-grid.ts | 6 +- .../src/ts/interaction/tooltip-controller.ts | 315 ++++-- .../src/ts/interaction/zoom-controller.ts | 75 +- .../src/ts/interaction/zoom-router.ts | 49 +- .../viewer-charts/src/ts/layout/facet-grid.ts | 42 +- .../src/ts/layout/plot-layout.ts | 41 +- packages/viewer-charts/src/ts/layout/ticks.ts | 72 +- .../viewer-charts/src/ts/plugin/charts.ts | 228 ++-- .../viewer-charts/src/ts/plugin/plugin.ts | 627 +++++------ .../viewer-charts/src/ts/render/scheduler.ts | 339 ++++++ .../src/ts/shaders/heatmap.vert.glsl | 21 +- .../src/ts/shaders/scatter.vert.glsl | 21 +- .../viewer-charts/src/ts/theme/gradient.ts | 106 +- .../viewer-charts/src/ts/theme/palette.ts | 19 +- .../src/ts/theme/theme-snapshot.ts | 66 ++ packages/viewer-charts/src/ts/theme/theme.ts | 137 +-- .../src/ts/transport/protocol.ts | 429 ++++++++ .../src/ts/transport/renderer-transport.ts | 687 ++++++++++++ packages/viewer-charts/src/ts/utils/css.ts | 18 +- .../src/ts/utils/font-snapshot.ts | 159 +++ .../viewer-charts/src/ts/webgl/buffer-pool.ts | 71 +- .../src/ts/webgl/context-manager.ts | 323 +++++- .../src/ts/webgl/gradient-texture.ts | 5 +- .../src/ts/webgl/instanced-attrs.ts | 75 +- .../viewer-charts/src/ts/webgl/plot-frame.ts | 15 +- .../src/ts/webgl/program-cache.ts | 46 + .../src/ts/webgl/shader-manifest.ts | 104 ++ .../src/ts/webgl/shader-registry.ts | 24 +- packages/viewer-charts/src/ts/worker/boot.ts | 22 + .../viewer-charts/src/ts/worker/dispatch.ts | 92 ++ .../src/ts/worker/font-loader.ts | 89 ++ .../src/ts/worker/renderer.worker.ts | 690 ++++++++++++ .../src/ts/worker/session-host.ts | 118 +++ packages/viewer-charts/test/js/helpers.ts | 109 -- .../test/ts/frame-timing.spec.ts | 462 ++++++++ packages/viewer-charts/test/ts/helpers.ts | 778 ++++++++++++++ .../viewer-charts/test/ts/multi-chart.spec.ts | 218 ++++ .../{js => ts/snapshot}/candlestick.spec.ts | 2 +- .../ts/snapshot/datetime-subsecond.spec.ts | 76 ++ .../test/{js => ts/snapshot}/heatmap.spec.ts | 77 +- .../test/{js => ts/snapshot}/line.spec.ts | 2 +- .../test/ts/snapshot/numeric-axis.spec.ts | 230 ++++ .../test/{js => ts/snapshot}/pan.spec.ts | 3 +- .../{js => ts/snapshot}/regressions.spec.ts | 12 +- .../test/{js => ts/snapshot}/scatter.spec.ts | 2 +- .../test/{js => ts/snapshot}/sunburst.spec.ts | 52 +- .../test/{js => ts/snapshot}/tooltip.spec.ts | 2 +- .../test/{js => ts/snapshot}/treemap.spec.ts | 86 +- .../test/{js => ts/snapshot}/x-bar.spec.ts | 2 +- .../test/{js => ts/snapshot}/y-area.spec.ts | 2 +- .../test/{js => ts/snapshot}/y-bar.spec.ts | 2 +- .../test/{js => ts/snapshot}/y-line.spec.ts | 2 +- .../test/{js => ts/snapshot}/y-ohlc.spec.ts | 2 +- .../{js => ts/snapshot}/y-scatter.spec.ts | 2 +- .../test/{js => ts/snapshot}/zoom.spec.ts | 10 +- packages/viewer-charts/test/ts/tsconfig.json | 8 + packages/viewer-charts/tsconfig.json | 2 +- packages/viewer-datagrid/index.d.ts | 2 + .../viewer-datagrid/src/ts/color_utils.ts | 63 +- .../src/ts/custom_elements/datagrid.ts | 42 +- .../ts/data_listener/format_tree_header.ts | 4 +- .../src/ts/data_listener/formatter_cache.ts | 6 + .../src/ts/data_listener/index.ts | 12 +- .../src/ts/event_handlers/click/edit_click.ts | 3 + .../src/ts/event_handlers/dispatch_click.ts | 5 +- .../src/ts/event_handlers/expand_collapse.ts | 5 +- .../src/ts/event_handlers/header_click.ts | 12 +- .../ts/event_handlers/keydown/edit_keydown.ts | 13 +- .../src/ts/event_handlers/select_region.ts | 19 +- .../src/ts/event_handlers/sort.ts | 5 +- .../viewer-datagrid/src/ts/get_cell_config.ts | 13 +- .../src/ts/model/column_overrides.ts | 8 +- .../viewer-datagrid/src/ts/model/create.ts | 27 +- .../src/ts/model/meta_columns.ts | 47 + .../viewer-datagrid/src/ts/model/toolbar.ts | 6 +- .../viewer-datagrid/src/ts/plugin/activate.ts | 4 +- .../src/ts/plugin/column_config_schema.ts | 203 ++++ .../viewer-datagrid/src/ts/plugin/draw.ts | 1 + .../viewer-datagrid/src/ts/plugin/save.ts | 6 +- .../src/ts/style_handlers/body.ts | 1 + .../src/ts/style_handlers/column_header.ts | 8 +- .../src/ts/style_handlers/consolidated.ts | 1 + .../src/ts/style_handlers/editable.ts | 8 +- .../src/ts/style_handlers/focus.ts | 2 + .../src/ts/style_handlers/group_header.ts | 14 +- .../test/js/column_style.spec.js | 332 +++++- packages/viewer-datagrid/tsconfig.json | 2 +- .../viewer-openlayers/src/js/plugin/plugin.js | 2 +- pnpm-lock.yaml | 629 +++++++++++ pnpm-workspace.yaml | 6 +- rust/perspective-js/package.json | 28 +- rust/perspective-js/src/rust/client.rs | 28 + rust/perspective-js/src/rust/typed_array.rs | 43 +- rust/perspective-js/src/rust/view.rs | 6 + .../src/ts/perspective.browser.ts | 96 +- .../perspective-js/src/ts/perspective.node.ts | 4 +- .../src/ts/wasm/perspective-server.poly.ts | 2 +- rust/perspective-python/clean.mjs | 16 +- .../templates/exported_widget.html.template | 2 +- .../perspective/widget/__init__.py | 2 +- rust/perspective-viewer/build.mjs | 31 + rust/perspective-viewer/package.json | 24 +- .../src/rust/custom_elements/debug_plugin.rs | 4 +- .../src/rust/custom_elements/viewer.rs | 3 +- rust/perspective-viewer/src/rust/js/plugin.rs | 13 +- rust/perspective-viewer/src/rust/lib.rs | 28 +- rust/perspective-viewer/src/rust/renderer.rs | 20 +- .../src/rust/renderer/activate.rs | 1 + .../src/rust/tasks/copy_export.rs | 36 +- .../src/themes/botanical.css | 48 +- .../src/themes/defaults.css | 42 +- .../perspective-viewer/src/themes/dracula.css | 68 +- .../src/themes/gruvbox-dark.css | 74 +- .../perspective-viewer/src/themes/gruvbox.css | 28 +- .../perspective-viewer/src/themes/monokai.css | 86 +- .../src/themes/phosphor.css | 38 +- .../src/themes/pro-dark.css | 48 +- .../src/themes/solarized-dark.css | 38 +- .../src/themes/solarized.css | 24 +- .../src/themes/vaporwave.css | 76 +- rust/perspective-viewer/src/ts/bootstrap.ts | 17 +- rust/perspective-viewer/src/ts/extensions.ts | 4 + .../src/ts/perspective-viewer.worker.ts | 14 + rust/perspective-viewer/src/ts/plugin.ts | 4 +- tools/bench/charts_cases.mjs | 111 ++ tools/bench/charts_page_harness.js | 59 ++ tools/bench/charts_suite.mjs | 135 +++ tools/bench/package.json | 5 +- tools/bench/src/html/charts-bench.html | 113 ++ tools/esbuild-plugin/index.js | 6 +- tools/esbuild-plugin/worker.js | 287 +++-- tools/scripts/bench.mjs | 6 +- tools/scripts/lint.mjs | 27 +- tools/test/load-viewer-two-csv.js | 42 + tools/test/playwright.config.ts | 35 +- tools/test/src/html/themed-test.html | 57 - tools/test/src/html/two-chart-test.html | 56 + tools/test/src/js/models/plugins/datagrid.ts | 10 +- tools/test/src/js/snapshot-sync.ts | 7 +- 230 files changed, 19676 insertions(+), 6485 deletions(-) create mode 100644 eslint.config.mjs rename packages/viewer-charts/src/ts/{chrome => axis}/axis-primitives.ts (87%) rename packages/viewer-charts/src/ts/{chrome => axis}/bar-axis.ts (73%) rename packages/viewer-charts/src/ts/{chrome => axis}/canvas.ts (89%) rename packages/viewer-charts/src/ts/{chrome => axis}/categorical-axis-core.ts (93%) rename packages/viewer-charts/src/ts/{chrome => axis}/categorical-axis.ts (61%) rename packages/viewer-charts/src/ts/{chrome => axis}/label-geometry.ts (67%) rename packages/viewer-charts/src/ts/{chrome => axis}/legend.ts (86%) create mode 100644 packages/viewer-charts/src/ts/axis/numeric-axis.ts delete mode 100644 packages/viewer-charts/src/ts/charts/bar/bar-build.ts delete mode 100644 packages/viewer-charts/src/ts/charts/bar/bar-render.ts delete mode 100644 packages/viewer-charts/src/ts/charts/bar/bar.ts delete mode 100644 packages/viewer-charts/src/ts/charts/bar/glyphs/draw-areas.ts delete mode 100644 packages/viewer-charts/src/ts/charts/bar/glyphs/draw-lines.ts delete mode 100644 packages/viewer-charts/src/ts/charts/bar/glyphs/draw-scatter.ts rename rust/perspective-viewer/src/rust/tasks/structural.rs => packages/viewer-charts/src/ts/charts/canvas-types.ts (67%) rename packages/viewer-charts/src/ts/charts/{continuous/continuous-build.ts => cartesian/cartesian-build.ts} (71%) rename packages/viewer-charts/src/ts/charts/{continuous/continuous-interact.ts => cartesian/cartesian-interact.ts} (71%) rename packages/viewer-charts/src/ts/charts/{continuous/continuous-render.ts => cartesian/cartesian-render.ts} (76%) rename packages/viewer-charts/src/ts/charts/{continuous/continuous-chart.ts => cartesian/cartesian.ts} (64%) rename packages/viewer-charts/src/ts/charts/{continuous => cartesian}/glyph.ts (84%) rename packages/viewer-charts/src/ts/charts/{continuous => cartesian}/glyphs/lines.ts (86%) rename packages/viewer-charts/src/ts/charts/{continuous => cartesian}/glyphs/points.ts (83%) create mode 100644 packages/viewer-charts/src/ts/charts/cartesian/label-interner.ts create mode 100644 packages/viewer-charts/src/ts/charts/common/category-axis-resolver.ts delete mode 100644 packages/viewer-charts/src/ts/charts/common/category-axis.ts create mode 100644 packages/viewer-charts/src/ts/charts/common/chrome-cache.ts create mode 100644 packages/viewer-charts/src/ts/charts/common/draw-tooltip-box.ts create mode 100644 packages/viewer-charts/src/ts/charts/common/leaf-color.ts create mode 100644 packages/viewer-charts/src/ts/charts/common/tree-chrome.ts create mode 100644 packages/viewer-charts/src/ts/charts/registry.ts create mode 100644 packages/viewer-charts/src/ts/charts/series/glyphs/draw-areas.ts rename packages/viewer-charts/src/ts/charts/{bar => series}/glyphs/draw-bars.ts (60%) create mode 100644 packages/viewer-charts/src/ts/charts/series/glyphs/draw-lines.ts create mode 100644 packages/viewer-charts/src/ts/charts/series/glyphs/draw-scatter.ts create mode 100644 packages/viewer-charts/src/ts/charts/series/series-build.ts rename packages/viewer-charts/src/ts/charts/{bar/bar-interact.ts => series/series-interact.ts} (59%) create mode 100644 packages/viewer-charts/src/ts/charts/series/series-render.ts rename packages/viewer-charts/src/ts/charts/{bar/chart-type.ts => series/series-type.ts} (95%) create mode 100644 packages/viewer-charts/src/ts/charts/series/series.ts delete mode 100644 packages/viewer-charts/src/ts/chrome/numeric-axis.ts create mode 100644 packages/viewer-charts/src/ts/config.ts create mode 100644 packages/viewer-charts/src/ts/interaction/host-sink-dom.ts create mode 100644 packages/viewer-charts/src/ts/interaction/host-sink-message.ts create mode 100644 packages/viewer-charts/src/ts/interaction/lazy-tooltip.ts create mode 100644 packages/viewer-charts/src/ts/interaction/raw-event-forwarder.ts create mode 100644 packages/viewer-charts/src/ts/render/scheduler.ts create mode 100644 packages/viewer-charts/src/ts/theme/theme-snapshot.ts create mode 100644 packages/viewer-charts/src/ts/transport/protocol.ts create mode 100644 packages/viewer-charts/src/ts/transport/renderer-transport.ts create mode 100644 packages/viewer-charts/src/ts/utils/font-snapshot.ts create mode 100644 packages/viewer-charts/src/ts/webgl/program-cache.ts create mode 100644 packages/viewer-charts/src/ts/webgl/shader-manifest.ts create mode 100644 packages/viewer-charts/src/ts/worker/boot.ts create mode 100644 packages/viewer-charts/src/ts/worker/dispatch.ts create mode 100644 packages/viewer-charts/src/ts/worker/font-loader.ts create mode 100644 packages/viewer-charts/src/ts/worker/renderer.worker.ts create mode 100644 packages/viewer-charts/src/ts/worker/session-host.ts delete mode 100644 packages/viewer-charts/test/js/helpers.ts create mode 100644 packages/viewer-charts/test/ts/frame-timing.spec.ts create mode 100644 packages/viewer-charts/test/ts/helpers.ts create mode 100644 packages/viewer-charts/test/ts/multi-chart.spec.ts rename packages/viewer-charts/test/{js => ts/snapshot}/candlestick.spec.ts (98%) create mode 100644 packages/viewer-charts/test/ts/snapshot/datetime-subsecond.spec.ts rename packages/viewer-charts/test/{js => ts/snapshot}/heatmap.spec.ts (62%) rename packages/viewer-charts/test/{js => ts/snapshot}/line.spec.ts (98%) create mode 100644 packages/viewer-charts/test/ts/snapshot/numeric-axis.spec.ts rename packages/viewer-charts/test/{js => ts/snapshot}/pan.spec.ts (99%) rename packages/viewer-charts/test/{js => ts/snapshot}/regressions.spec.ts (88%) rename packages/viewer-charts/test/{js => ts/snapshot}/scatter.spec.ts (98%) rename packages/viewer-charts/test/{js => ts/snapshot}/sunburst.spec.ts (63%) rename packages/viewer-charts/test/{js => ts/snapshot}/tooltip.spec.ts (99%) rename packages/viewer-charts/test/{js => ts/snapshot}/treemap.spec.ts (57%) rename packages/viewer-charts/test/{js => ts/snapshot}/x-bar.spec.ts (98%) rename packages/viewer-charts/test/{js => ts/snapshot}/y-area.spec.ts (97%) rename packages/viewer-charts/test/{js => ts/snapshot}/y-bar.spec.ts (98%) rename packages/viewer-charts/test/{js => ts/snapshot}/y-line.spec.ts (97%) rename packages/viewer-charts/test/{js => ts/snapshot}/y-ohlc.spec.ts (97%) rename packages/viewer-charts/test/{js => ts/snapshot}/y-scatter.spec.ts (97%) rename packages/viewer-charts/test/{js => ts/snapshot}/zoom.spec.ts (92%) create mode 100644 packages/viewer-charts/test/ts/tsconfig.json create mode 100644 packages/viewer-datagrid/src/ts/model/meta_columns.ts create mode 100644 packages/viewer-datagrid/src/ts/plugin/column_config_schema.ts create mode 100644 rust/perspective-viewer/src/ts/perspective-viewer.worker.ts create mode 100644 tools/bench/charts_cases.mjs create mode 100644 tools/bench/charts_page_harness.js create mode 100644 tools/bench/charts_suite.mjs create mode 100644 tools/bench/src/html/charts-bench.html create mode 100644 tools/test/load-viewer-two-csv.js delete mode 100644 tools/test/src/html/themed-test.html create mode 100644 tools/test/src/html/two-chart-test.html diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 0ce632949c..88804d9bcd 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -649,6 +649,7 @@ 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" @@ -657,6 +658,15 @@ jobs: 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 + # ,--,--' . .-,--. . . # `- | ,-. ,-. |- '|__/ . . |- |-. ,-. ,-. # , | |-' `-. | ,| | | | | | | | | | diff --git a/docs/md/how_to/javascript/installation.md b/docs/md/how_to/javascript/installation.md index d4f0cf1db2..20fc073935 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 diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000000..2e7603a4c5 --- /dev/null +++ b/eslint.config.mjs @@ -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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +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/viewer-openlayers/**/*", + "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/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/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..17f6aad161 100644 --- a/package.json +++ b/package.json @@ -53,10 +53,12 @@ "@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/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..36b4aab928 100644 --- a/packages/viewer-charts/build.mjs +++ b/packages/viewer-charts/build.mjs @@ -11,6 +11,7 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ 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"; @@ -72,7 +73,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 +109,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..abce52fd18 100644 --- a/packages/viewer-charts/package.json +++ b/packages/viewer-charts/package.json @@ -39,6 +39,7 @@ "@perspective-dev/viewer": "workspace:", "@perspective-dev/esbuild-plugin": "workspace:", "@perspective-dev/test": "workspace:", + "@types/node": "catalog:", "lightningcss": "catalog:", "typescript": "catalog:" } 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 73% rename from packages/viewer-charts/src/ts/chrome/bar-axis.ts rename to packages/viewer-charts/src/ts/axis/bar-axis.ts index 4c24a8c409..1c10e80ecb 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,9 +28,23 @@ 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[], +): (v: number) => string { + 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[], @@ -50,7 +65,7 @@ function drawNumericXAxis( axisY, side, (v) => layout.dataToPixel(v, 0).px, - formatTickValue, + tickFormatter(domain, ticks), ); ctx.font = `13px ${fontFamily}`; @@ -69,7 +84,7 @@ 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[], @@ -90,7 +105,7 @@ function drawYAxis( axisX, side, (v) => layout.dataToPixel(0, v).py, - formatTickValue, + tickFormatter(domain, ticks), ); ctx.font = `13px ${fontFamily}`; @@ -102,12 +117,48 @@ 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, +): void { + drawNumericXAxis(ctx, layout, domain, ticks, "bottom", theme); +} + +export function drawNumericCategoryY( + ctx: Context2D, + layout: PlotLayout, + domain: AxisDomain, + ticks: number[], + theme: Theme, +): void { + drawYAxis(ctx, layout, domain, ticks, "left", theme); +} + /** * 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 @@ -119,18 +170,21 @@ function drawYAxis( * numeric axis regardless of orientation. */ 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, ): 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,10 +202,22 @@ export function renderBarAxesChrome( ctx.lineTo(plot.x + plot.width, plot.y + plot.height); } } + ctx.stroke(); if (isHorizontal) { - renderCategoricalYTicks(ctx, layout, catDomain, theme); + if (catAxis.mode === "category") { + renderCategoricalYTicks(ctx, layout, catAxis.domain, theme); + } else { + drawNumericCategoryY( + ctx, + layout, + catAxis.domain, + catAxis.ticks, + theme, + ); + } + drawNumericXAxis(ctx, layout, valueDomain, valueTicks, "bottom", theme); if (altDomain && altTicks) { const origMin = layout.paddedXMin; @@ -163,7 +229,18 @@ export function renderBarAxesChrome( layout.paddedXMax = origMax; } } else { - renderCategoricalXTicks(ctx, layout, catDomain, theme); + if (catAxis.mode === "category") { + renderCategoricalXTicks(ctx, layout, catAxis.domain, theme); + } else { + drawNumericCategoryX( + ctx, + layout, + catAxis.domain, + catAxis.ticks, + theme, + ); + } + drawYAxis(ctx, layout, valueDomain, valueTicks, "left", theme); if (altDomain && altTicks) { const origMin = layout.paddedYMin; @@ -183,14 +260,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/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 86% rename from packages/viewer-charts/src/ts/chrome/legend.ts rename to packages/viewer-charts/src/ts/axis/legend.ts index c1615c0c45..c12ba3037e 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,11 @@ 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, ): void { const rect: PlotRect = { x: layout.plotRect.x + layout.plotRect.width + 12, @@ -46,7 +49,7 @@ export function renderLegend( ), height: Math.max(1, layout.plotRect.height), }; - renderLegendAt(canvas, rect, colorDomain, stops); + renderLegendAt(canvas, rect, colorDomain, stops, theme); } /** @@ -55,24 +58,20 @@ 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, ): 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 +79,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 +98,7 @@ export function renderLegendAt( const rgba = sampleGradient(stops, t); gradient.addColorStop(offset, rgbCss(rgba)); } + ctx.fillStyle = gradient; ctx.fillRect(x, y, barWidth, barHeight); @@ -147,10 +147,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 +162,7 @@ export function renderCategoricalLegend( ), height: Math.max(1, layout.plotRect.height), }; - renderCategoricalLegendAt(canvas, rect, labels, palette); + renderCategoricalLegendAt(canvas, rect, labels, palette, theme); } /** @@ -170,22 +171,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 +199,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..a5a2570b2d --- /dev/null +++ b/packages/viewer-charts/src/ts/axis/numeric-axis.ts @@ -0,0 +1,315 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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[]): (v: number) => string { + 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, +): void { + const { tickColor, labelColor, axisLineColor, fontFamily } = theme; + const fmt = tickFmt(xDomain, xTicks); + + 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, +): void { + const { tickColor, labelColor, axisLineColor, fontFamily } = theme; + const fmt = tickFmt(yDomain, yTicks); + + 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, +): 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, + ); +} + +export function renderCellYAxis( + canvas: Canvas2D, + yDomain: AxisDomain, + layout: PlotLayout, + yTicks: number[], + theme: Theme, + hasLabel: boolean, + dpr: number, +): 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, + ); +} + +export function renderAxesChrome( + canvas: Canvas2D, + xDomain: AxisDomain, + yDomain: AxisDomain, + layout: PlotLayout, + xTicks: number[], + yTicks: number[], + theme: Theme, + dpr: number, +): void { + renderCellYAxis(canvas, yDomain, layout, yTicks, theme, true, dpr); + renderCellXAxis(canvas, xDomain, layout, xTicks, theme, true, dpr); +} + +export function renderOuterXAxis( + canvas: Canvas2D, + rect: PlotRect, + xDomain: AxisDomain, + xTicks: number[], + colLayouts: PlotLayout[], + theme: Theme, + hasLabel: boolean, + dpr: number, +): 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, + ); +} + +export function renderOuterYAxis( + canvas: Canvas2D, + rect: PlotRect, + yDomain: AxisDomain, + yTicks: number[], + rowLayouts: PlotLayout[], + theme: Theme, + hasLabel: boolean, + dpr: number, +): 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, + ); +} 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..cd984358bd 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,161 @@ 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; + + /** + * 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 +214,22 @@ const EMPTY: CandlestickPipelineResult = { export function buildCandlestickPipeline( input: CandlestickPipelineInput, ): CandlestickPipelineResult { - const { columns, numRows, columnSlots, groupBy, splitBy } = input; + const { + columns, + numRows, + columnSlots, + groupBy, + splitBy, + groupByTypes, + 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 +239,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 +294,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); + 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 +339,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 +360,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 +431,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 +495,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..e493e47d69 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,43 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import type { CandlestickChart } from "./candlestick"; -import type { CandleRecord } from "./candlestick-build"; -import { formatTickValue } from "../../layout/ticks"; -import { - renderCandlestickChromeOverlay, - renderCandlestickFrame, -} from "./candlestick-render"; +import { formatTickValue, formatDateTickValue } from "../../layout/ticks"; +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 +55,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); + } + + return; + } - // Scan in reverse so the most-recently-added (frontmost) candle wins - // ties. At < 10k visible candles this linear scan is free. + 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 +156,100 @@ 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 label = chart._numericCategoryDomain.isDate + ? formatDateTickValue(v) + : formatTickValue(v); + lines.push(label); + } 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)}`); + lines.push(`Open: ${formatTickValue(open)}`); + lines.push(`Close: ${formatTickValue(close)}`); + lines.push(`High: ${formatTickValue(high)}`); + lines.push(`Low: ${formatTickValue(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..72bb3259c2 100644 --- a/packages/viewer-charts/src/ts/charts/candlestick/candlestick-render.ts +++ b/packages/viewer-charts/src/ts/charts/candlestick/candlestick-render.ts @@ -13,49 +13,110 @@ import type { WebGLContextManager } from "../../webgl/context-manager"; import type { CandlestickChart } 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 { + drawCandlesticks, + rebuildCandlestickBodyAndWickBuffers, + invalidateCandlestickBodyAndWickBuffers, +} from "./glyphs/draw-candlesticks"; +import { + drawOHLC, + rebuildOHLCBuffers, + invalidateOHLCBuffers, +} from "./glyphs/draw-ohlc"; 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 { + invalidateCandlestickBodyAndWickBuffers(chart); + invalidateOHLCBuffers(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 { + rebuildCandlestickBodyAndWickBuffers(chart, glManager); + rebuildOHLCBuffers(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; + } - const themeEl = (chart._gridlineCanvas!.getRootNode() as ShadowRoot).host; - const theme = resolveTheme(themeEl); + if (chart._numCategories === 0) { + return; + } + + 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 +125,7 @@ export function renderCandlestickFrame( chart._yDomain.max, ); } + const vis = chart._zoomController ? chart._zoomController.getVisibleDomain() : { @@ -90,27 +152,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 +193,10 @@ export function renderCandlestickFrame( vis.yMin, vis.yMax, "y", + undefined, + undefined, + chart._categoryOrigin, + 0, ); const yTicks = computeNiceTicks(vis.yMin, vis.yMax, 6); @@ -131,10 +210,16 @@ 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); } else { @@ -145,6 +230,9 @@ export function renderCandlestickFrame( chart._lastXDomain = xDomain; chart._lastYDomain = yDomain; chart._lastYTicks = yTicks; + chart._lastCatTicks = numericCat + ? computeNiceTicks(vis.xMin, vis.xMax, 6) + : null; renderCandlestickChromeOverlay(chart); } @@ -152,43 +240,79 @@ export function renderCandlestickChromeOverlay(chart: CandlestickChart): void { if ( !chart._chromeCanvas || !chart._lastLayout || - !chart._lastXDomain || !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; + } - const theme = resolveTheme(chart._chromeCanvas); renderBarAxesChrome( chart._chromeCanvas, - chart._lastXDomain, + catAxis, chart._lastYDomain, chart._lastYTicks, chart._lastLayout, theme, + chart._glManager?.dpr ?? 1, ); - 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 +322,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,7 +331,6 @@ 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; } @@ -216,17 +338,41 @@ function computeVisibleCandleExtent( cache ?? ({} as NonNullable); 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; } diff --git a/packages/viewer-charts/src/ts/charts/candlestick/candlestick.ts b/packages/viewer-charts/src/ts/charts/candlestick/candlestick.ts index 3befe6935b..af80d4075b 100644 --- a/packages/viewer-charts/src/ts/charts/candlestick/candlestick.ts +++ b/packages/viewer-charts/src/ts/charts/candlestick/candlestick.ts @@ -15,10 +15,18 @@ 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, @@ -55,22 +63,62 @@ 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. */ + /** + * 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. + /** + * Lazy program / shared-resource cache. Each glyph (candlestick + * body, wick, OHLC) lazily attaches its program + corner buffers + * here on first use. Persistent vertex buffers (built once per + * data load) live on `_glyphBuffers` instead. + */ _wickCache: unknown = undefined; - /** Uploaded instance count for the body shader. */ - _uploadedBodies = 0; + /** + * Persistent per-glyph vertex buffer state — built in + * `rebuildGlyphBuffers` (called from `uploadAndRender`) and reused + * across pan/zoom frames. Eliminates the legacy per-frame + * `bufferData(DYNAMIC_DRAW)` of body / wick / OHLC vertex data. + */ + _glyphBuffers: unknown = undefined; /** * Auto-fit the price (Y) axis to the `low`/`high` extent of @@ -85,31 +133,31 @@ 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), + 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: () => { @@ -117,19 +165,27 @@ export class CandlestickChart extends CategoricalYChart { showCandlestickPinnedTooltip(this, this._hoveredIdx); } }, - }); + }; + } + + 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; } - uploadAndRender( + 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,6 +193,8 @@ export class CandlestickChart extends CategoricalYChart { columnSlots: this._columnSlots, groupBy: this._groupBy, splitBy: this._splitBy, + groupByTypes: this._groupByTypes, + scratchCandles: this._candles, }); this._rowPaths = result.rowPaths; this._numCategories = result.numCategories; @@ -145,32 +203,104 @@ 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); - redraw(glManager: WebGLContextManager): void { - this._glManager = glManager; - this._fullRender(glManager); + await this.requestRender(glManager); } - protected _fullRender(glManager: WebGLContextManager): void { + _fullRender(glManager: WebGLContextManager): void { + this._glManager = glManager; renderCandlestickFrame(this, glManager); } 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); + } + + // Free the persistent vertex buffers and the program-local + // GPU resources stashed on `_wickCache`. Without this each + // `delete()` would leak ~6 GL buffers per chart instance. + invalidateGlyphBuffers(this); + destroyWickCache(this); } + this._program = null; this._locations = null; this._cornerBuffer = null; this._wickCache = undefined; - this._candles = []; + this._glyphBuffers = undefined; + this._candles = emptyCandleColumns(); this._series = []; this._rowPaths = []; this._numCategories = 0; + this._upDownColorKey = null; + } +} + +/** + * Free the program-local GPU buffers stashed on `_wickCache` (corner + * buffer + segment buffer for wick / OHLC, quad + instance buffer for + * the candlestick body shader). Programs themselves are owned by the + * `WebGLContextManager.shaders` cache and are not freed here. + */ +function destroyWickCache(chart: CandlestickChart): void { + if (!chart._glManager || !chart._wickCache) { + return; + } + + const gl = chart._glManager.gl; + const cache = chart._wickCache as { + body?: { quadBuffer?: WebGLBuffer; instanceBuffer?: WebGLBuffer }; + wick?: { cornerBuffer?: WebGLBuffer; segmentBuffer?: WebGLBuffer }; + ohlc?: { cornerBuffer?: WebGLBuffer; segmentBuffer?: WebGLBuffer }; + }; + + if (cache.body) { + if (cache.body.quadBuffer) { + gl.deleteBuffer(cache.body.quadBuffer); + } + + if (cache.body.instanceBuffer) { + gl.deleteBuffer(cache.body.instanceBuffer); + } + } + + if (cache.wick) { + if (cache.wick.cornerBuffer) { + gl.deleteBuffer(cache.wick.cornerBuffer); + } + + if (cache.wick.segmentBuffer) { + gl.deleteBuffer(cache.wick.segmentBuffer); + } + } + + if (cache.ohlc) { + if (cache.ohlc.cornerBuffer) { + gl.deleteBuffer(cache.ohlc.cornerBuffer); + } + + if (cache.ohlc.segmentBuffer) { + gl.deleteBuffer(cache.ohlc.segmentBuffer); + } } } 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..13aaa70a4a 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"; @@ -49,73 +53,232 @@ interface WickCache { a_end: number; } -interface Cache { +interface ProgramCache { body: BodyCache; wick: WickCache; } -function ensureCache( +/** + * Persistent body + wick vertex buffer state. Built once per data load + * by `rebuildCandlestickBodyAndWickBuffers`; pan/zoom redraws bind + + * dispatch with no uploads. + */ +interface BodyWickBuffers { + /** + * Number of body instances (= candle count). + */ + bodyCount: number; + + /** + * Number of up-wick instances (one segment per up candle). + */ + upWickCount: number; + + /** + * Number of down-wick instances. + */ + downWickCount: number; + + /** + * Persistent GPU buffer for up wicks. Layout: [x,low, x,high, ...]. + */ + upWickBuffer: WebGLBuffer; + + /** + * Persistent GPU buffer for down wicks. + */ + downWickBuffer: WebGLBuffer; +} + +function ensureProgramCache( chart: CandlestickChart, glManager: WebGLContextManager, -): Cache { - if (chart._wickCache) return chart._wickCache as Cache; +): ProgramCache { + if (chart._wickCache && (chart._wickCache as any).body) { + return chart._wickCache as ProgramCache; + } + const gl = glManager.gl; - // ── Body shader + buffers ──────────────────────────────────────── - const bodyProg = glManager.shaders.getOrCreate( + 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 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, + ...bodyPartial, 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"), + instanceBuffer: gl.createBuffer()!, }; - // ── Wick shader (reused for OHLC too via draw-ohlc) ────────────── - const wickProg = glManager.shaders.getOrCreate( + const cornerBuffer = createLineCornerBuffer(gl); + const wickPartial = compileProgram< + Omit + >( + glManager, "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, + ["u_projection", "u_color", "u_resolution", "u_line_width"], + ["a_corner", "a_start", "a_end"], ); const wick: WickCache = { - program: wickProg, + ...wickPartial, 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"), }; - chart._wickCache = { body, wick }; - return chart._wickCache as Cache; + const existing = (chart._wickCache as any) || {}; + chart._wickCache = { ...existing, body, wick }; + return chart._wickCache as ProgramCache; +} + +/** + * Drop persistent body + wick vertex buffers. Called from data-load + * (before `rebuild...`) and from chart-destroy paths. + */ +export function invalidateCandlestickBodyAndWickBuffers( + chart: CandlestickChart, +): void { + const buf = (chart._glyphBuffers as { bodyWick?: BodyWickBuffers }) + ?.bodyWick; + if (!buf || !chart._glManager) { + if (chart._glyphBuffers) { + (chart._glyphBuffers as { bodyWick?: BodyWickBuffers }).bodyWick = + undefined; + } + + return; + } + + const gl = chart._glManager.gl; + gl.deleteBuffer(buf.upWickBuffer); + gl.deleteBuffer(buf.downWickBuffer); + (chart._glyphBuffers as { bodyWick?: BodyWickBuffers }).bodyWick = + undefined; +} + +/** + * 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. + */ +export function rebuildCandlestickBodyAndWickBuffers( + chart: CandlestickChart, + glManager: WebGLContextManager, +): void { + const candles = chart._candles; + const cache = ensureProgramCache(chart, glManager); + const gl = glManager.gl; + const xOrigin = chart._categoryOrigin; + + if (candles.count === 0) { + if (!chart._glyphBuffers) { + chart._glyphBuffers = {}; + } + + const upBuf = gl.createBuffer()!; + const downBuf = gl.createBuffer()!; + (chart._glyphBuffers as { bodyWick?: BodyWickBuffers }).bodyWick = { + 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 = (chart._glyphBuffers as { bodyWick?: BodyWickBuffers }) + ?.bodyWick; + 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); + + if (!chart._glyphBuffers) { + chart._glyphBuffers = {}; + } + + (chart._glyphBuffers as { bodyWick?: BodyWickBuffers }).bodyWick = { + bodyCount: candles.count, + upWickCount: upCount, + downWickCount: downCount, + upWickBuffer: upBuf, + downWickBuffer: downBuf, + }; } export function drawCandlesticks( @@ -124,46 +287,32 @@ export function drawCandlesticks( glManager: WebGLContextManager, projection: Float32Array, ): void { - const candles = chart._candles; - if (candles.length === 0) return; + const buf = (chart._glyphBuffers as { bodyWick?: BodyWickBuffers }) + ?.bodyWick; + if (!buf || buf.bodyCount === 0) { + return; + } - const cache = ensureCache(chart, glManager); - drawBodies(chart, gl, glManager, cache.body, candles, projection); - drawWicks(chart, gl, glManager, cache.wick, candles, projection); + const cache = ensureProgramCache(chart, glManager); + drawBodies(gl, glManager, cache.body, buf.bodyCount, projection); + drawWicks(chart, gl, glManager, cache.wick, buf, projection); } /** - * 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 +326,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 +334,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 +351,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, 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 +425,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..3d86be6fe9 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,8 +12,11 @@ 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"; @@ -34,6 +37,23 @@ interface OHLCCache { a_end: number; } +/** + * 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. + */ +interface OHLCBuffers { + upBuffer: WebGLBuffer; + downBuffer: WebGLBuffer; + + /** + * Number of line-segment instances in the up buffer (= 3 × up candle count). + */ + upInstanceCount: number; + downInstanceCount: number; +} + function ensureCache( chart: CandlestickChart, glManager: WebGLContextManager, @@ -41,124 +61,172 @@ function ensureCache( 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( + const cornerBuffer = createLineCornerBuffer(gl); + const partial = compileProgram< + Omit + >( + glManager, "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, + ["u_projection", "u_color", "u_resolution", "u_line_width"], + ["a_corner", "a_start", "a_end"], ); const cache: OHLCCache = { - program: prog, + ...partial, 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. + * Drop persistent OHLC vertex buffers. Called from data-load (before + * `rebuild...`) and from chart-destroy paths. */ -export function drawOHLC( +export function invalidateOHLCBuffers(chart: CandlestickChart): void { + const buf = (chart._glyphBuffers as { ohlc?: OHLCBuffers })?.ohlc; + if (!buf || !chart._glManager) { + if (chart._glyphBuffers) { + (chart._glyphBuffers as { ohlc?: OHLCBuffers }).ohlc = undefined; + } + + return; + } + + const gl = chart._glManager.gl; + gl.deleteBuffer(buf.upBuffer); + gl.deleteBuffer(buf.downBuffer); + (chart._glyphBuffers as { ohlc?: OHLCBuffers }).ohlc = undefined; +} + +/** + * 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. + */ +export function rebuildOHLCBuffers( chart: CandlestickChart, - gl: GL, glManager: WebGLContextManager, - projection: Float32Array, ): 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; - if (candles.length === 0) return; + const gl = glManager.gl; + ensureCache(chart, glManager); - const cache = ensureCache(chart, 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; - drawOHLCGroup( - gl, - glManager, - cache, - candles.filter((c) => c.isUp), - chart._upColor, - projection, - ); - drawOHLCGroup( - gl, - glManager, - cache, - candles.filter((c) => !c.isUp), - chart._downColor, - projection, - ); + 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; + + // 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 = (chart._glyphBuffers as { ohlc?: OHLCBuffers })?.ohlc; + 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); + + if (!chart._glyphBuffers) { + chart._glyphBuffers = {}; + } + + (chart._glyphBuffers as { ohlc?: OHLCBuffers }).ohlc = { + upBuffer: upBuf, + downBuffer: downBuf, + upInstanceCount: upCount * 3, + downInstanceCount: downCount * 3, + }; } /** - * 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. + * Bind the persistent up/down OHLC buffers and dispatch one instanced + * draw per color group. */ -function drawOHLCGroup( +export function drawOHLC( + chart: CandlestickChart, 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; + const buf = (chart._glyphBuffers as { ohlc?: OHLCBuffers })?.ohlc; + if (!buf || (buf.upInstanceCount === 0 && buf.downInstanceCount === 0)) { + return; } - gl.bindBuffer(gl.ARRAY_BUFFER, cache.segmentBuffer); - gl.bufferData(gl.ARRAY_BUFFER, data, gl.DYNAMIC_DRAW); + const cache = ensureCache(chart, glManager); + const dpr = glManager.dpr; 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.uniform2f(cache.u_resolution, gl.canvas.width, gl.canvas.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 instancing = getInstancing(glManager); const { setDivisor } = instancing; @@ -168,10 +236,45 @@ function drawOHLCGroup( 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); +} + +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 +296,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/rust/perspective-viewer/src/rust/tasks/structural.rs b/packages/viewer-charts/src/ts/charts/canvas-types.ts similarity index 67% rename from rust/perspective-viewer/src/rust/tasks/structural.rs rename to packages/viewer-charts/src/ts/charts/canvas-types.ts index b507a1b765..7c4dd4dd0c 100644 --- a/rust/perspective-viewer/src/rust/tasks/structural.rs +++ b/packages/viewer-charts/src/ts/charts/canvas-types.ts @@ -10,44 +10,21 @@ // ┃ 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. - -use crate::custom_events::*; -use crate::dragdrop::*; -use crate::presentation::*; -use crate::renderer::*; -use crate::session::*; - -pub trait HasCustomEvents { - fn custom_events(&self) -> &'_ CustomEvents; -} - -pub trait HasDragDrop { - fn dragdrop(&self) -> &'_ DragDrop; -} - -pub trait HasPresentation { - fn presentation(&self) -> &'_ Presentation; -} - -pub trait HasRenderer { - fn renderer(&self) -> &'_ Renderer; -} - -pub trait HasSession { - fn session(&self) -> &'_ Session; -} - -impl HasSession for Session { - fn session(&self) -> &'_ Session { - self - } -} - -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; -} +/** + * 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 71% 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..c4b64a096b 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,33 @@ 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); + const prevColorName = chart._colorName; + const prevColorIsString = chart._colorIsString; + chart._xMin = Infinity; chart._xMax = -Infinity; chart._yMin = Infinity; chart._yMax = -Infinity; + chart._xOrigin = NaN; + chart._yOrigin = NaN; chart._colorMin = Infinity; chart._colorMax = -Infinity; chart._sizeMin = Infinity; chart._sizeMax = -Infinity; 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 +90,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 +103,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 +137,7 @@ export function initContinuousPipeline( ); chart._colorIsString = firstColorCol?.type === "string"; } + glManager.ensureBufferCapacity( rowsPerSeries * chart._splitGroups.length, ); @@ -123,6 +147,7 @@ export function initContinuousPipeline( chart._yName = yBase; chart._colorName = colorBase; chart._sizeName = sizeBase; + chart._labelName = labelBase; chart._colorIsString = false; if (chart._colorName) { @@ -131,6 +156,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 +180,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 +189,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 +219,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 +233,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 +251,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 +274,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 +288,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 +315,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,6 +329,7 @@ export function processContinuousChunk( } } } + if (chart._uniqueColorLabels.size > 0) { chart._colorMin = 0; chart._colorMax = chart._uniqueColorLabels.size - 1; @@ -269,31 +341,74 @@ export function processContinuousChunk( 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; + if (isNaN(x) || isNaN(y)) { + continue; + } + + if (x < chart._xMin) { + chart._xMin = x; + } - 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; + 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 +416,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 +447,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,9 +459,11 @@ 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 { @@ -345,17 +471,43 @@ export function processContinuousChunk( chart._colorData![flatIdx] = 0.5; } - // ── 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 +515,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 +551,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 76% 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..207c446431 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,12 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +import type { Canvas2D, Context2D } from "../canvas-types"; 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 { type Theme } from "../../theme/theme"; import { resolvePalette } from "../../theme/palette"; import { paletteToStops } from "../../theme/gradient"; import { @@ -33,14 +34,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 @@ -57,15 +65,17 @@ import { * 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; @@ -110,13 +120,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] || ""; @@ -142,8 +152,17 @@ export function renderContinuousFrame( const labelCount = hasNoColorSource ? 1 : 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 +172,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 +206,7 @@ export function renderContinuousFrame( }); } - renderContinuousChromeOverlay(chart); + renderCartesianChromeOverlay(chart); } interface RenderFrameCtx { @@ -199,7 +221,7 @@ interface SinglePlotCtx extends RenderFrameCtx { } function buildXDomain( - chart: ContinuousChart, + chart: CartesianChart, min: number, max: number, isDate: boolean, @@ -214,7 +236,7 @@ function buildXDomain( } function buildYDomain( - chart: ContinuousChart, + chart: CartesianChart, min: number, max: number, isDate: boolean, @@ -233,7 +255,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,13 +270,20 @@ 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); @@ -264,11 +293,19 @@ function renderSinglePlotFrame( if (chart._gridlineCanvas) { // 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, + ); } - renderInPlotFrame(gl, layout, () => { + renderInPlotFrame(gl, layout, glManager.dpr, () => { chart.glyph.draw(chart, glManager, projection); }); @@ -292,7 +329,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 +339,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,6 +351,7 @@ 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, @@ -357,8 +396,13 @@ function renderFacetedFrame( 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; + if (zc) { + zc.updateLayout(grid.cells[i].layout); + } + + if (!independent) { + break; + } } const xDomain = buildXDomain(chart, domain.xMin, domain.xMax, xIsDate); @@ -378,8 +422,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,6 +444,11 @@ 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, @@ -445,9 +495,11 @@ function renderFacetedFrame( localXTicks, localYTicks, theme, + glManager.dpr, ); } - withScissor(gl, cell.layout, () => { + + withScissor(gl, cell.layout, glManager.dpr, () => { chart.glyph.drawSeries(chart, glManager, projection, i); }); } @@ -463,20 +515,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,9 +538,10 @@ 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; renderAxesChrome( chart._chromeCanvas!, @@ -496,16 +551,14 @@ function renderSinglePlotChromeOverlay(chart: ContinuousChart): void { chart._lastXTicks!, chart._lastYTicks!, theme, + dpr, ); 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 +567,7 @@ function renderSinglePlotChromeOverlay(chart: ContinuousChart): void { layout, chart._uniqueColorLabels, palette, + theme, ); } else if (chart._colorName) { renderLegend( @@ -525,26 +579,30 @@ function renderSinglePlotChromeOverlay(chart: ContinuousChart): void { label: chart._colorName, }, stops, + theme, ); } } + 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`. + // independent-zoom mode by the render entry — see `renderCartesianFrame`. // 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; @@ -566,8 +624,10 @@ function renderFacetedChromeOverlay(chart: ContinuousChart): void { bottomRowLayouts, theme, !!chart._xLabel, + dpr, ); } + if (sharedY && grid.outerYAxisRect) { const leftColLayouts = grid.cells .filter((c) => c.isLeftEdge) @@ -580,6 +640,7 @@ function renderFacetedChromeOverlay(chart: ContinuousChart): void { leftColLayouts, theme, !!chart._yLabel, + dpr, ); } @@ -602,8 +663,10 @@ function renderFacetedChromeOverlay(chart: ContinuousChart): void { ticks.xTicks, theme, !!chart._xLabel, + dpr, ); } + if (!sharedY) { renderCellYAxis( canvas, @@ -612,12 +675,15 @@ function renderFacetedChromeOverlay(chart: ContinuousChart): void { ticks.yTicks, theme, !!chart._yLabel, + dpr, ); } 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 +692,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 +702,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 +722,7 @@ function renderFacetedChromeOverlay(chart: ContinuousChart): void { label: chart._colorName, }, stops, + theme, ); } } @@ -665,17 +731,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 +760,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, }); @@ -699,14 +772,17 @@ function renderFacetedChromeOverlay(chart: ContinuousChart): void { } function drawFacetTitle( - canvas: HTMLCanvasElement, + canvas: Canvas2D, label: string, rect: { x: number; y: number; width: number; height: number }, theme: Theme, + dpr: number, ): void { - const ctx = canvas.getContext("2d"); - if (!ctx) return; - const dpr = window.devicePixelRatio || 1; + 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); @@ -718,36 +794,142 @@ function drawFacetTitle( ctx.restore(); } -/** Map a flat slotted index back to its series (facet) index. */ +/** + * Map a flat slotted index back to its series (facet) index. + */ export function seriesFromIndex( - chart: ContinuousChart, + chart: CartesianChart, flatIdx: number, ): number { - if (chart._seriesCapacity <= 0) return 0; + 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 { + 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.font = `11px ${theme.fontFamily}`; + ctx.fillStyle = theme.labelColor; + ctx.textAlign = "left"; + ctx.textBaseline = "middle"; + + 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/continuous/continuous-chart.ts b/packages/viewer-charts/src/ts/charts/cartesian/cartesian.ts similarity index 64% rename from packages/viewer-charts/src/ts/charts/continuous/continuous-chart.ts rename to packages/viewer-charts/src/ts/charts/cartesian/cartesian.ts index a7783c3cce..4cedee6ed7 100644 --- a/packages/viewer-charts/src/ts/charts/continuous/continuous-chart.ts +++ b/packages/viewer-charts/src/ts/charts/cartesian/cartesian.ts @@ -15,22 +15,24 @@ 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 AxisDomain } from "../../axis/numeric-axis"; import type { GradientTextureCache } from "../../webgl/gradient-texture"; import type { Glyph } from "./glyph"; import { - initContinuousPipeline, - processContinuousChunk, -} from "./continuous-build"; + initCartesianPipeline, + processCartesianChunk, +} from "./cartesian-build"; import { - renderContinuousFrame, - renderContinuousChromeOverlay, -} from "./continuous-render"; + renderCartesianFrame, + renderCartesianChromeOverlay, +} from "./cartesian-render"; import { - handleContinuousHover, - showContinuousPinnedTooltip, - dismissContinuousPinnedTooltip, -} from "./continuous-interact"; + handleCartesianHover, + showCartesianPinnedTooltip, + dismissCartesianPinnedTooltip, +} from "./cartesian-interact"; +import type { LabelInterner } from "./label-interner"; +import { LazyTooltip } from "../../interaction/lazy-tooltip"; export interface SplitGroup { prefix: string; @@ -38,6 +40,7 @@ export interface SplitGroup { yColName: string; colorColName: string; sizeColName: string; + labelColName: string; } /** @@ -49,7 +52,7 @@ export interface SplitGroup { * Fields are package-internal (no `private`) so the split helper * modules and glyphs can read/write them. */ -export class ContinuousChart extends AbstractChart { +export class CartesianChart extends AbstractChart { readonly glyph: Glyph; constructor(glyph: Glyph) { @@ -57,13 +60,14 @@ export class ContinuousChart extends AbstractChart { this.glyph = glyph; } - // ── GL resources ────────────────────────────────────────────────────── + // 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 ────────────────────────────────────────────────────── + // Column roles _xName = ""; _yName = ""; _xLabel = ""; @@ -71,20 +75,33 @@ export class ContinuousChart extends AbstractChart { _xIsRowIndex = false; _colorName = ""; _sizeName = ""; + _labelName = ""; _colorIsString = false; _splitGroups: SplitGroup[] = []; - // ── Data extents ────────────────────────────────────────────────────── + // 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; - // ── Data buffers (per-series slotted) ───────────────────────────────── + // 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 @@ -98,6 +115,7 @@ export class ContinuousChart extends AbstractChart { _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 @@ -108,44 +126,52 @@ export class ContinuousChart extends AbstractChart { * 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(); /** - * 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. + * 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. */ - _hoveredTooltipLines: string[] | null = null; - _hoveredTooltipSerial = 0; - _pinnedTooltipSerial = 0; + _lazyTooltip = new LazyTooltip(); - // ── Staging scratch (reused across chunks) ─────────────────────────── + // Staging scratch (reused across chunks) _stagingPositions: Float32Array | null = null; _stagingColors: Float32Array | null = null; _stagingSizes: Float32Array | null = null; _stagingChunkSize = 0; - // ── Interaction ─────────────────────────────────────────────────────── + // 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) ──────────────────── + // Facet state (set when rendering in grid mode) _facetGrid: import("../../layout/facet-grid").FacetGrid | null = null; - // ── Last-frame cache (for chrome overlay-only redraws) ──────────────── + // Last-frame cache (for chrome overlay-only redraws) _lastXDomain: AxisDomain | null = null; _lastYDomain: AxisDomain | null = null; _lastXTicks: number[] | null = null; @@ -154,53 +180,54 @@ export class ContinuousChart extends AbstractChart { 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. + // 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; - _lastLutKey = ""; + _lastLutSeriesPalette: [number, number, number][] | null = null; + _lastLutLabelCount = -1; - attachTooltip(glCanvas: HTMLCanvasElement): void { - this._glCanvas = glCanvas; - this._tooltip.attach(glCanvas, { - onHover: (mx, my) => handleContinuousHover(this, mx, my), + protected override tooltipCallbacks() { + return { + onHover: (mx: number, my: number) => + handleCartesianHover(this, mx, my), onLeave: () => { if (this._hoveredIndex !== -1) { this._hoveredIndex = -1; - renderContinuousChromeOverlay(this); + renderCartesianChromeOverlay(this); } }, onPin: () => { if (this._hoveredIndex >= 0) { - showContinuousPinnedTooltip(this, this._hoveredIndex); + showCartesianPinnedTooltip(this, this._hoveredIndex); } }, - }); + }; } - uploadAndRender( + async uploadAndRender( glManager: WebGLContextManager, columns: ColumnDataMap, startRow: number, endRow: number, - ): void { + ): Promise { const chunkLength = endRow - startRow; this._glManager = glManager; if (startRow === 0) { - this._cancelScheduledRender(); - initContinuousPipeline(this, glManager, columns, endRow); + initCartesianPipeline(this, glManager, columns, endRow); } - if (chunkLength === 0) return; - processContinuousChunk( + if (chunkLength === 0) { + return; + } + + processCartesianChunk( this, glManager, columns, @@ -209,17 +236,16 @@ export class ContinuousChart extends AbstractChart { endRow, ); - this._scheduleRender(glManager); + await this.requestRender(glManager); } - redraw(glManager: WebGLContextManager): void { - if (glManager.uploadedCount === 0 && this._dataCount === 0) return; - this._glManager = glManager; - this._fullRender(glManager); - } + _fullRender(glManager: WebGLContextManager): void { + if (glManager.uploadedCount === 0 && this._dataCount === 0) { + return; + } - protected _fullRender(glManager: WebGLContextManager): void { - renderContinuousFrame(this, glManager); + this._glManager = glManager; + renderCartesianFrame(this, glManager); } protected destroyInternal(): void { @@ -230,7 +256,8 @@ export class ContinuousChart extends AbstractChart { this._yData = null; this._colorData = null; this._rowIndexData = null; - this._hoveredTooltipLines = null; + this._labels = null; + this._lazyTooltip.clearHover(); this._uniqueColorLabels.clear(); this._hitTest.clear(); this._stagingPositions = null; @@ -238,26 +265,30 @@ export class ContinuousChart extends AbstractChart { this._stagingSizes = null; this._splitGroups = []; this._seriesUploadedCounts = []; - dismissContinuousPinnedTooltip(this); + dismissCartesianPinnedTooltip(this); } } -// ── Convenience subclasses with nullary constructors ───────────────────── +// 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 { +/** + * 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 ContinuousChart { +/** + * X/Y Line — continuous chart with the line glyph. + */ +export class LineChart extends CartesianChart { constructor() { super(new LineGlyph()); } diff --git a/packages/viewer-charts/src/ts/charts/continuous/glyph.ts b/packages/viewer-charts/src/ts/charts/cartesian/glyph.ts similarity index 84% rename from packages/viewer-charts/src/ts/charts/continuous/glyph.ts rename to packages/viewer-charts/src/ts/charts/cartesian/glyph.ts index 29a8dcd741..f837b61c5b 100644 --- a/packages/viewer-charts/src/ts/charts/continuous/glyph.ts +++ b/packages/viewer-charts/src/ts/charts/cartesian/glyph.ts @@ -11,27 +11,31 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ 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. */ + /** + * `"point"` for scatter-style markers; `"line"` for polylines. + */ readonly name: "point" | "line"; /** * 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 +50,7 @@ export interface Glyph { * `seriesIdx`. */ drawSeries( - chart: ContinuousChart, + chart: CartesianChart, glManager: WebGLContextManager, projection: Float32Array, seriesIdx: number, @@ -60,13 +64,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/continuous/glyphs/lines.ts b/packages/viewer-charts/src/ts/charts/cartesian/glyphs/lines.ts similarity index 86% 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..49958dfefd 100644 --- a/packages/viewer-charts/src/ts/charts/continuous/glyphs/lines.ts +++ b/packages/viewer-charts/src/ts/charts/cartesian/glyphs/lines.ts @@ -11,10 +11,13 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ 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"; @@ -46,24 +49,18 @@ interface LineCache { export class LineGlyph implements Glyph { readonly name = "line" as const; - ensureProgram( - chart: ContinuousChart, - glManager: WebGLContextManager, - ): void { - if (chart._glyphCache) return; + ensureProgram(chart: CartesianChart, glManager: WebGLContextManager): void { + if (chart._glyphCache) { + 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 cornerBuffer = createLineCornerBuffer(gl); const cache: LineCache = { program, cornerBuffer, @@ -82,48 +79,64 @@ export class LineGlyph implements Glyph { } draw( - chart: ContinuousChart, + chart: CartesianChart, glManager: WebGLContextManager, projection: Float32Array, ): void { const cache = chart._glyphCache as LineCache | null; - if (!cache) return; + 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; + 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,7 +163,7 @@ export class LineGlyph implements Glyph { return { crosshair: true, highlightRadius: 5 }; } - destroy(chart: ContinuousChart): void { + destroy(chart: CartesianChart): void { const cache = chart._glyphCache as LineCache | null; if (cache?.cornerBuffer && chart._glManager) { chart._glManager.gl.deleteBuffer(cache.cornerBuffer); @@ -166,14 +179,16 @@ 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); @@ -219,28 +234,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 83% 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..aa0665a170 100644 --- a/packages/viewer-charts/src/ts/charts/continuous/glyphs/points.ts +++ b/packages/viewer-charts/src/ts/charts/cartesian/glyphs/points.ts @@ -11,7 +11,7 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ 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"; @@ -44,11 +44,11 @@ interface PointCache { export class PointGlyph implements Glyph { readonly name = "point" as const; - ensureProgram( - chart: ContinuousChart, - glManager: WebGLContextManager, - ): void { - if (chart._glyphCache) return; + ensureProgram(chart: CartesianChart, glManager: WebGLContextManager): void { + if (chart._glyphCache) { + return; + } + const gl = glManager.gl; const program = glManager.shaders.getOrCreate( "scatter", @@ -74,13 +74,18 @@ export class PointGlyph implements Glyph { } 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; + 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,36 +96,52 @@ 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; + 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, + chart: CartesianChart, flatIdx: number, ): Promise { const lines: string[] = []; - if (!chart._rowIndexData || !chart._lazyRows) return lines; + if (!chart._rowIndexData || !chart._lazyRows) { + return lines; + } + const rowIdx = chart._rowIndexData[flatIdx]; - if (rowIdx < 0) return lines; + 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 @@ -128,7 +149,9 @@ export class PointGlyph implements Glyph { 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); + if (sg?.prefix) { + lines.push(sg.prefix); + } } const row = await chart._lazyRows.fetchRow(rowIdx); @@ -153,17 +176,24 @@ export class PointGlyph implements Glyph { : null; for (const [colName, value] of row) { - if (value === null || value === undefined) continue; + if (value === null || value === undefined) { + continue; + } + let displayName = colName; if (prefixFilter !== null) { const expected = `${prefixFilter}|`; - if (!colName.startsWith(expected)) continue; + 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"; @@ -175,6 +205,7 @@ export class PointGlyph implements Glyph { lines.push(`${displayName}: ${value}`); } } + return lines; } @@ -182,10 +213,10 @@ export class PointGlyph implements Glyph { return { crosshair: true, highlightRadius: 6 }; } - destroy(_chart: ContinuousChart): void { + destroy(_chart: CartesianChart): void { // Program lifetime is owned by the shader registry; nothing glyph- // specific to free here beyond the cache reference itself, which - // `ContinuousChart.destroyInternal` clears. + // `CartesianChart.destroyInternal` clears. } } @@ -193,9 +224,9 @@ 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); @@ -221,15 +252,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 +270,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/chart-base.ts b/packages/viewer-charts/src/ts/charts/chart-base.ts index 2df03d2693..e6305c5e30 100644 --- a/packages/viewer-charts/src/ts/charts/chart-base.ts +++ b/packages/viewer-charts/src/ts/charts/chart-base.ts @@ -23,51 +23,54 @@ import { type ChartImplementation, type FacetConfig, } from "./chart"; -import { TooltipController } from "../interaction/tooltip-controller"; +import { + TooltipController, + type HostSink, + type TooltipCallbacks, +} from "../interaction/tooltip-controller"; +import { resolveThemeFromVars, type Theme } from "../theme/theme"; +import { requestRender as scheduleRender } from "../render/scheduler"; /** * 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. - * - * 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). + * 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. * - * `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 +82,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 +104,35 @@ 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 = {}; + + /** + * 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 = {}; _defaultChartType: string | undefined = undefined; _facetConfig: FacetConfig = { ...DEFAULT_FACET_CONFIG }; _tooltip = new TooltipController(); + /** + * 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 +145,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 +176,23 @@ 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. + * Set base domain on every zoom controller owned by this chart. */ setZoomBaseDomain( xMin: number, @@ -169,8 +203,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); + } } } @@ -196,6 +233,10 @@ export abstract class AbstractChart implements ChartImplementation { this._columnTypes = schema; } + setGroupByTypes(schema: Record): void { + this._groupByTypes = schema; + } + setColumnsConfig(cfg: Record): void { this._columnsConfig = cfg ?? {}; } @@ -208,74 +249,115 @@ export abstract class AbstractChart implements ChartImplementation { this._facetConfig = { ...cfg }; } + /** + * 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(); + this._tooltip.dismiss(); } - // ── Render batching ──────────────────────────────────────────────────── - - /** 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); - }); + /** + * 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: () => {}, + }; } - /** 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; - } + /** + * 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); + } + + // 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 ────────────────────────────────────────────────────────── + // 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; - - abstract redraw(glManager: WebGLContextManager): void; + ): Promise; - abstract attachTooltip(glCanvas: HTMLCanvasElement): void; + abstract _fullRender(glManager: WebGLContextManager): 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..141f00b69a 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 | OffscreenCanvas): void; - /** Set the chrome canvas (above WebGL, for axes/labels/legend/tooltip). */ - setChromeCanvas?(canvas: HTMLCanvasElement): void; + /** + * 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. */ + /** + * 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 @@ -84,21 +147,44 @@ export interface ChartImplementation { */ setFacetConfig?(cfg: FacetConfig): void; + /** + * Drop any cached theme values so the next render re-reads CSS + * variables. Driven from `plugin.restyle()`. + */ + invalidateTheme?(): 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; } 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..472295de76 100644 --- a/packages/viewer-charts/src/ts/charts/common/band-layout.ts +++ b/packages/viewer-charts/src/ts/charts/common/band-layout.ts @@ -17,20 +17,31 @@ * candles, …) are laid out side by side with a small inner padding. */ -/** Fraction of a category's band width actually covered by slots. */ +/** + * 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. */ +/** + * 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. */ +/** + * 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; 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..21084954e1 --- /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. + */ +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..ce74f0fdac 100644 --- a/packages/viewer-charts/src/ts/charts/common/tree-data.ts +++ b/packages/viewer-charts/src/ts/charts/common/tree-data.ts @@ -42,7 +42,7 @@ import { buildSplitGroups } from "../../data/split-groups"; 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 +73,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 +89,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 +128,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 +162,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 +179,7 @@ function readColor( // the end result is `palette[dictIdx % paletteSize]`. colorLabel = colorCol.dictionary[colorCol.indices[rowIdx]]; } + return { colorValue, colorLabel }; } @@ -183,8 +197,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 +214,7 @@ function seedColorLabels( } } -// ── Chunk processor ────────────────────────────────────────────────────── +// Chunk processor interface SplitSource { prefix: string; @@ -210,11 +230,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,7 +263,10 @@ 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; + if (!rp || rp.type !== "string" || !rp.indices || !rp.dictionary) { + break; + } + rpCols.push({ indices: rp.indices, dictionary: rp.dictionary }); } @@ -253,7 +282,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 +292,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 +305,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 +422,7 @@ export function processTreeChunk( ); } } + chart._rowCount = base + numRows; return; } @@ -356,10 +437,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 +487,11 @@ export function processTreeChunk( ); } } + chart._rowCount = base + numRows; } -// ── Finalize ───────────────────────────────────────────────────────────── +// Finalize /** * Post-chunk finalization. @@ -431,7 +519,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 +536,7 @@ export function finalizeTree(chart: TreeChartBase): void { bigger.set(stack); stack = bigger; } + stack[sp * 2] = c; stack[sp * 2 + 1] = 0; sp++; @@ -462,6 +553,7 @@ export function finalizeTree(chart: TreeChartBase): void { ) { sum += value[c]; } + value[id] = sum; } } @@ -476,6 +568,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 +578,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 +605,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/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..3deebb989b 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,9 @@ 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 +29,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 +43,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 +144,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 +174,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 +187,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 +197,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 +220,55 @@ 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); + if (xPath) { + lines.push(xPath); + } + + if (yPath) { + lines.push(yPath); + } + lines.push(`Value: ${formatTickValue(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..7fc69e32b5 100644 --- a/packages/viewer-charts/src/ts/charts/heatmap/heatmap-render.ts +++ b/packages/viewer-charts/src/ts/charts/heatmap/heatmap-render.ts @@ -10,28 +10,34 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +import type { Canvas2D } from "../canvas-types"; 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 { type Theme } from "../../theme/theme"; 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 +46,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 +60,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 +101,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 +128,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 +152,7 @@ export function renderHeatmapFrame( yDomainMax, ); } + const vis = chart._zoomController ? chart._zoomController.getVisibleDomain() : { @@ -134,29 +162,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 +210,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 +232,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 +246,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 +255,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 +271,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 +332,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 +349,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 +371,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 +389,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,14 +432,32 @@ 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); + if (chart._xAxisMode.mode === "numeric" && chart._xNumericDomain) { + const ticks = computeNiceTicks(layout.paddedXMin, layout.paddedXMax, 6); + drawNumericCategoryX(ctx, layout, chart._xNumericDomain, ticks, theme); + } 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); + } else { + renderCategoricalYTicks( + ctx, + layout, + yDomain, + theme, + HEATMAP_Y_AXIS_OPTS, + ); + } // Color legend on the right. renderLegend( @@ -343,6 +469,7 @@ export function renderHeatmapChromeOverlay(chart: HeatmapChart): void { label: chart._aggName, }, theme.gradientStops, + theme, ); if (chart._hoveredCell) { @@ -360,8 +487,7 @@ 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(); const grid = buildFacetGrid( chart._facets.map((f) => f.label), @@ -380,7 +506,9 @@ 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; + } } ensureProgram(chart, glManager); @@ -407,32 +535,61 @@ 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; const projection = layout.buildProjectionMatrix( xDomainMin, xDomainMax, yDomainMin, yDomainMax, + undefined, + undefined, + 0, + facet.xOrigin, + facet.yOrigin, ); const plot = layout.plotRect; const pxPerDataX = plot.width / (xDomainMax - xDomainMin); const pxPerDataY = plot.height / (yDomainMax - yDomainMin); 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,15 +603,24 @@ 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) { const layout = facet.layout; @@ -479,14 +645,51 @@ function renderFacetedHeatmapChromeOverlay(chart: HeatmapChart): void { levelLabels: [], }; - renderCategoricalXTicks(ctx, layout, xDomain, theme); - renderCategoricalYTicks( - ctx, - layout, - yDomain, - theme, - HEATMAP_Y_AXIS_OPTS, - ); + 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, + ); + } else { + renderCategoricalXTicks(ctx, layout, xDomain, theme); + } + + 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, + ); + } else { + renderCategoricalYTicks( + ctx, + layout, + yDomain, + theme, + HEATMAP_Y_AXIS_OPTS, + ); + } } // Per-facet titles sit in the grid cell's titleRect — one strip per @@ -496,8 +699,17 @@ function renderFacetedHeatmapChromeOverlay(chart: HeatmapChart): void { 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 +730,7 @@ function renderFacetedHeatmapChromeOverlay(chart: HeatmapChart): void { label: "", }, theme.gradientStops, + theme, ); } @@ -527,14 +740,17 @@ function renderFacetedHeatmapChromeOverlay(chart: HeatmapChart): void { } function drawFacetTitle( - canvas: HTMLCanvasElement, + canvas: Canvas2D, label: string, rect: { x: number; y: number; width: number; height: number }, theme: Theme, + dpr: number, ): void { - const ctx = canvas.getContext("2d"); - if (!ctx) return; - const dpr = window.devicePixelRatio || 1; + const ctx = canvas.getContext("2d") as CanvasRenderingContext2D; + if (!ctx) { + return; + } + ctx.save(); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.scale(dpr, dpr); 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..de68ea30f5 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,44 @@ 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(); } }, - }); + }; } - uploadAndRender( + async uploadAndRender( glManager: WebGLContextManager, columns: ColumnDataMap, startRow: number, endRow: number, - ): void { + ): Promise { this._glManager = glManager; if (startRow !== 0) { @@ -113,7 +157,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 +171,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 +187,7 @@ export class HeatmapChart extends AbstractChart { value: c.value, }); } + facets.push({ label: part.label, pipeline, @@ -151,6 +198,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 +207,7 @@ export class HeatmapChart extends AbstractChart { ) { globalMin = pipeline.colorMin; } + if ( isFinite(pipeline.colorMax) && pipeline.colorMax > globalMax @@ -165,6 +215,7 @@ export class HeatmapChart extends AbstractChart { globalMax = pipeline.colorMax; } } + if (!isFinite(globalMin) || !isFinite(globalMax)) { globalMin = 0; globalMax = 1; @@ -182,6 +233,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 +252,8 @@ export class HeatmapChart extends AbstractChart { columns, numRows: endRow, groupBy: this._groupBy, + splitBy: this._splitBy, + groupByTypes: this._groupByTypes, }); this._facets = []; @@ -208,20 +269,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 +297,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 +310,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/registry.ts b/packages/viewer-charts/src/ts/charts/registry.ts new file mode 100644 index 0000000000..f06f37e3fc --- /dev/null +++ b/packages/viewer-charts/src/ts/charts/registry.ts @@ -0,0 +1,51 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 } 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"; + +/** + * Map from `ChartTypeConfig.tag` to its chart-impl class. Used by + * `index.ts` (main thread, registers custom elements) and by + * `renderer.worker.ts` (worker thread, constructs the chart impl from + * a tag forwarded over the control channel) — both must agree on the + * tag → class binding. + */ +export const CHART_IMPLS: Record ChartImplementation> = { + 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": SeriesChart, + "y-line": SeriesChart, + "y-scatter": SeriesChart, + "y-area": SeriesChart, + + // 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, +}; 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..d9d34f7c97 --- /dev/null +++ b/packages/viewer-charts/src/ts/charts/series/glyphs/draw-areas.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"; +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 `rebuildAreaBuffers`. Each + * series owns one GPU buffer holding all of its strip vertices in + * `[x,y_bot, x,y_top, ...]` layout; draws rebind without uploading. + */ +export interface AreaBuffers { + program: WebGLProgram; + u_projection: WebGLUniformLocation | null; + u_color: WebGLUniformLocation | null; + u_opacity: WebGLUniformLocation | null; + a_position: number; + series: AreaSeriesEntry[]; + gpuBuffer?: WebGLBuffer | null; +} + +function ensureProgramCache( + chart: SeriesChart, + glManager: WebGLContextManager, +): AreaProgramCache { + if (chart._areaCache) { + return chart._areaCache as AreaProgramCache; + } + + const cache = compileProgram( + glManager, + "bar-area", + areaVert, + areaFrag, + ["u_projection", "u_color", "u_opacity"], + ["a_position"], + ); + chart._areaCache = cache; + return cache; +} + +/** + * Drop persistent area buffers. Subsequent draws no-op until rebuild. + */ +export function invalidateAreaBuffers(chart: SeriesChart): void { + const buf = chart._areaBuffers as AreaBuffers | undefined; + if (!buf || !chart._glManager) { + chart._areaBuffers = undefined; + return; + } + + const gl = chart._glManager.gl; + for (const s of buf.series) { + gl.deleteBuffer(s.gpuBuffer); + } + + chart._areaBuffers = undefined; +} + +/** + * 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; +} + +/** + * 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. + */ +export function rebuildAreaBuffers( + chart: SeriesChart, + glManager: WebGLContextManager, +): void { + const areaSeries = chart._areaSeries; + if (areaSeries.length === 0) { + chart._areaBuffers = undefined; + return; + } + + const N = chart._numCategories; + const S = chart._series.length; + if (N === 0 || S === 0) { + chart._areaBuffers = undefined; + return; + } + + const cache = ensureProgramCache(chart, 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, + }); + } + + chart._areaBuffers = { + program: cache.program, + u_projection: cache.u_projection, + u_color: cache.u_color, + u_opacity: cache.u_opacity, + a_position: cache.a_position, + series: entries, + gpuBuffer: null, + }; +} + +/** + * Bind persistent strip buffers and dispatch one TRIANGLE_STRIP per + * series-run. Skips hidden series. + */ +export function drawAreas( + chart: SeriesChart, + gl: GL, + glManager: WebGLContextManager, + projLeft: Float32Array, + projRight: Float32Array, + opacity: number, +): void { + const buf = chart._areaBuffers as AreaBuffers | undefined; + if (!buf || buf.series.length === 0) { + return; + } + + gl.useProgram(buf.program); + gl.uniform1f(buf.u_opacity, opacity); + + const hidden = chart._hiddenSeries; + for (const s of buf.series) { + if (hidden.has(s.seriesId)) { + continue; + } + + gl.uniformMatrix4fv( + buf.u_projection, + false, + s.axis === 1 ? projRight : projLeft, + ); + const color = chart._series[s.seriesId].color; + gl.uniform3f(buf.u_color, color[0], color[1], color[2]); + + gl.bindBuffer(gl.ARRAY_BUFFER, s.gpuBuffer); + gl.enableVertexAttribArray(buf.a_position); + gl.vertexAttribPointer(buf.a_position, 2, gl.FLOAT, false, 0, 0); + + for (const strip of s.strips) { + gl.bindBuffer(gl.ARRAY_BUFFER, s.gpuBuffer); + gl.vertexAttribPointer( + buf.a_position, + 2, + gl.FLOAT, + false, + 0, + strip.offsetBytes, + ); + gl.drawArrays(gl.TRIANGLE_STRIP, 0, strip.vertexCount); + } + } + + void glManager; +} + +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..53fcf266ee --- /dev/null +++ b/packages/viewer-charts/src/ts/charts/series/glyphs/draw-lines.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 { + 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 LINE_WIDTH_PX = 2.0; + +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 `rebuildLineBuffers` (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. + */ +export interface LineBuffers { + 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; + + /** + * One entry per line series (hidden series included; draw skips them). + */ + series: LineSeriesEntry[]; + + /** + * Optional GPU buffer ownership tag for cleanup. + */ + gpuBuffer?: WebGLBuffer | null; +} + +function ensureProgramCache( + chart: SeriesChart, + glManager: WebGLContextManager, +): LineProgramCache { + if (chart._lineCache) { + return chart._lineCache as LineProgramCache; + } + + 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"], + ); + const cache: LineProgramCache = { ...partial, cornerBuffer }; + chart._lineCache = cache; + return cache; +} + +/** + * Drop persistent line buffers. Subsequent draws will no-op until the + * next `rebuildLineBuffers` call. + */ +export function invalidateLineBuffers(chart: SeriesChart): void { + const buf = chart._lineBuffers as LineBuffers | undefined; + if (!buf || !chart._glManager) { + chart._lineBuffers = undefined; + return; + } + + const gl = chart._glManager.gl; + for (const s of buf.series) { + gl.deleteBuffer(s.gpuBuffer); + } + + chart._lineBuffers = undefined; +} + +/** + * 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; +} + +/** + * 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 `drawLines` call rebinds + dispatches with + * no further uploads until the next data load. + */ +export function rebuildLineBuffers( + chart: SeriesChart, + glManager: WebGLContextManager, +): void { + const lineSeries = chart._lineSeries; + if (lineSeries.length === 0) { + chart._lineBuffers = undefined; + return; + } + + const N = chart._numCategories; + if (N === 0) { + chart._lineBuffers = undefined; + return; + } + + const cache = ensureProgramCache(chart, 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, + }); + } + + chart._lineBuffers = { + program: cache.program, + cornerBuffer: cache.cornerBuffer, + u_projection: cache.u_projection, + u_color: cache.u_color, + u_resolution: cache.u_resolution, + u_line_width: cache.u_line_width, + a_start: cache.a_start, + a_end: cache.a_end, + a_corner: cache.a_corner, + series: entries, + gpuBuffer: null, + }; +} + +/** + * Bind the persistent vertex buffers and dispatch one instanced draw + * per (series, run). Skips hidden series via `_hiddenSeries`. + */ +export function drawLines( + chart: SeriesChart, + gl: GL, + glManager: WebGLContextManager, + projLeft: Float32Array, + projRight: Float32Array, +): void { + const buf = chart._lineBuffers as LineBuffers | undefined; + if (!buf || buf.series.length === 0) { + return; + } + + const dpr = glManager.dpr; + gl.useProgram(buf.program); + gl.uniform2f(buf.u_resolution, gl.canvas.width, gl.canvas.height); + gl.uniform1f(buf.u_line_width, LINE_WIDTH_PX * dpr); + + const instancing = getInstancing(glManager); + const { setDivisor, drawArraysInstanced } = instancing; + + gl.bindBuffer(gl.ARRAY_BUFFER, buf.cornerBuffer); + gl.enableVertexAttribArray(buf.a_corner); + gl.vertexAttribPointer(buf.a_corner, 1, gl.FLOAT, false, 0, 0); + setDivisor(buf.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( + buf.u_projection, + false, + s.axis === 1 ? projRight : projLeft, + ); + + const color = chart._series[s.seriesId].color; + gl.uniform4f(buf.u_color, color[0], color[1], color[2], 1.0); + + gl.enableVertexAttribArray(buf.a_start); + setDivisor(buf.a_start, 1); + gl.enableVertexAttribArray(buf.a_end); + setDivisor(buf.a_end, 1); + + for (const run of s.runs) { + gl.vertexAttribPointer( + buf.a_start, + 2, + gl.FLOAT, + false, + stride, + run.offsetBytes, + ); + gl.vertexAttribPointer( + buf.a_end, + 2, + gl.FLOAT, + false, + stride, + run.offsetBytes + stride, + ); + drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, run.count - 1); + } + } + + setDivisor(buf.a_start, 0); + setDivisor(buf.a_end, 0); +} 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..36d2414c15 --- /dev/null +++ b/packages/viewer-charts/src/ts/charts/series/glyphs/draw-scatter.ts @@ -0,0 +1,318 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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; + +const POINT_SIZE_PX = 8.0; + +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. + */ +export interface ScatterBuffers { + program: WebGLProgram; + posLeft: WebGLBuffer; + posRight: WebGLBuffer; + colLeft: WebGLBuffer; + colRight: WebGLBuffer; + u_projection: WebGLUniformLocation | null; + u_point_size: WebGLUniformLocation | null; + a_position: number; + a_color: number; + leftCount: number; + rightCount: number; +} + +function ensureProgramCache( + chart: SeriesChart, + glManager: WebGLContextManager, +): ScatterProgramCache { + if (chart._scatterCache) { + return chart._scatterCache as ScatterProgramCache; + } + + 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"], + ); + const cache: ScatterProgramCache = { + ...partial, + posLeftBuffer: gl.createBuffer()!, + posRightBuffer: gl.createBuffer()!, + colorLeftBuffer: gl.createBuffer()!, + colorRightBuffer: gl.createBuffer()!, + }; + chart._scatterCache = cache; + return cache; +} + +/** + * Drop persistent scatter buffer state. The underlying GL buffer + * objects on `_scatterCache` are reused (they're owned by the program + * cache, not the per-build buffer view). + */ +export function invalidateScatterBuffers(chart: SeriesChart): void { + chart._scatterBuffers = undefined; +} + +/** + * 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)); + } +} + +/** + * 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. + */ +export function rebuildScatterBuffers( + chart: SeriesChart, + glManager: WebGLContextManager, +): void { + const scatterSeries = chart._scatterSeries; + if (scatterSeries.length === 0) { + chart._scatterBuffers = undefined; + return; + } + + const N = chart._numCategories; + const S = chart._series.length; + if (N === 0 || S === 0) { + chart._scatterBuffers = undefined; + return; + } + + const cache = ensureProgramCache(chart, 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) { + chart._scatterBuffers = undefined; + 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, + ); + } + + chart._scatterBuffers = { + program: cache.program, + posLeft: cache.posLeftBuffer, + posRight: cache.posRightBuffer, + colLeft: cache.colorLeftBuffer, + colRight: cache.colorRightBuffer, + u_projection: cache.u_projection, + u_point_size: cache.u_point_size, + a_position: cache.a_position, + a_color: cache.a_color, + leftCount, + rightCount, + }; +} + +/** + * Bind the persistent left/right buffers and issue up to two draw + * calls. No per-frame allocations or buffer uploads. + */ +export function drawScatter( + chart: SeriesChart, + gl: GL, + glManager: WebGLContextManager, + projLeft: Float32Array, + projRight: Float32Array, +): void { + const buf = chart._scatterBuffers as ScatterBuffers | undefined; + if (!buf) { + return; + } + + if (buf.leftCount === 0 && buf.rightCount === 0) { + return; + } + + const dpr = glManager.dpr; + gl.useProgram(buf.program); + gl.uniform1f(buf.u_point_size, POINT_SIZE_PX * dpr); + + drawBucket(gl, buf, buf.posLeft, buf.colLeft, buf.leftCount, projLeft); + drawBucket(gl, buf, buf.posRight, buf.colRight, buf.rightCount, projRight); + + // Suppress unused-param warning for `glManager` — kept for symmetry + // with the other glyph entry points and for future use. + void glManager; +} + +function drawBucket( + gl: GL, + buf: ScatterBuffers, + posBuf: WebGLBuffer, + colBuf: WebGLBuffer, + count: number, + proj: Float32Array, +): void { + if (count === 0) { + return; + } + + gl.uniformMatrix4fv(buf.u_projection, false, proj); + + gl.bindBuffer(gl.ARRAY_BUFFER, posBuf); + gl.enableVertexAttribArray(buf.a_position); + gl.vertexAttribPointer(buf.a_position, 2, gl.FLOAT, false, 0, 0); + + gl.bindBuffer(gl.ARRAY_BUFFER, colBuf); + gl.enableVertexAttribArray(buf.a_color); + gl.vertexAttribPointer(buf.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..de6b182b5f --- /dev/null +++ b/packages/viewer-charts/src/ts/charts/series/series-build.ts @@ -0,0 +1,762 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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, + type ChartType, + type ColumnChartConfig, +} from "./series-type"; +import { AUTO_ALT_Y_AXIS } from "../../config"; + +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; + + /** + * 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, + 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, + }); + } + } + + 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). 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); + 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 (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 (AUTO_ALT_Y_AXIS && 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); + } + } + + // 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 (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; + } + } + } + + 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, + 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..d7e15c229f 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,40 @@ // ┃ 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 { formatTickValue, formatDateTickValue } from "../../layout/ticks"; 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 +52,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 +107,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 +120,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 +150,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 +190,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 +202,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 +210,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 +268,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 +375,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 +415,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,27 +436,54 @@ 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); + if (categoryPath) { + lines.push(categoryPath); + } + lines.push(`${s.aggName}: ${formatTickValue(b.value)}`); - if (s.splitKey) lines.push(`Split: ${s.splitKey}`); + if (s.splitKey) { + lines.push(`Split: ${s.splitKey}`); + } + if (b.y0 !== 0) { lines.push(`Base: ${formatTickValue(b.y0)}`); lines.push(`Top: ${formatTickValue(b.y1)}`); @@ -364,37 +496,85 @@ 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 label = chart._numericCategoryDomain.isDate + ? formatDateTickValue(v) + : formatTickValue(v); + return label; + } + + 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 +587,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..56b4b43fd6 --- /dev/null +++ b/packages/viewer-charts/src/ts/charts/series/series-render.ts @@ -0,0 +1,998 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 } 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 { + drawLines, + rebuildLineBuffers, + invalidateLineBuffers, +} from "./glyphs/draw-lines"; +import { + drawScatter, + rebuildScatterBuffers, + invalidateScatterBuffers, +} from "./glyphs/draw-scatter"; +import { + drawAreas, + rebuildAreaBuffers, + invalidateAreaBuffers, +} from "./glyphs/draw-areas"; +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 { + invalidateLineBuffers(chart); + invalidateScatterBuffers(chart); + invalidateAreaBuffers(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 { + rebuildLineBuffers(chart, glManager); + rebuildScatterBuffers(chart, glManager); + rebuildAreaBuffers(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; + } + } + + 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: 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, + undefined, + 0, + chart._categoryOrigin, + ) + : layout.buildProjectionMatrix( + visCatMin, + visCatMax, + visValMin, + visValMax, + "y", + true, + 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", + true, + 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) { + 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; + 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; + } + + renderBarAxesChrome( + chart._chromeCanvas, + catAxis, + chart._lastYDomain, + chart._lastYTicks, + chart._lastLayout, + theme, + chart._glManager?.dpr ?? 1, + chart._lastAltYDomain ?? undefined, + chart._lastAltYTicks ?? undefined, + chart._isHorizontal, + ); + + 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 ?? ({} as NonNullable); + 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; +} + +/** + * Build (or rebuild) the per-category extent buckets for the current + * `_bars` set, 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`) per-frame walk. + * + * Capacity-reused: typed arrays grown only when `_numCategories` + * exceeds prior capacity. The per-(cat, axis) loop is the same total + * cost as the prior per-frame walk, but it amortizes — 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; + } + } + + 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 95% 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..a526338dda 100644 --- a/packages/viewer-charts/src/ts/charts/bar/chart-type.ts +++ b/packages/viewer-charts/src/ts/charts/series/series-type.ts @@ -19,8 +19,11 @@ 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. @@ -52,6 +55,7 @@ export function resolveChartType( ) { return raw; } + return fallback; } @@ -66,6 +70,9 @@ 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"; } 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..650f63baac --- /dev/null +++ b/packages/viewer-charts/src/ts/charts/series/series.ts @@ -0,0 +1,703 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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, + 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 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 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; + + /** + * 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); + + // 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; + + /** + * 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; + + /** + * Persistent GPU buffer state for line / scatter / area glyphs. + * Built in `uploadAndRender` and reused across pan/zoom frames — + * the legacy code rebuilt these every frame which dominated the + * frame budget at scale. Invalidated on data load and on + * `_hiddenSeries` mutation; the latter only triggers a rebuild for + * scatter (per-axis merged buffers) — line and area are per-series + * and the draw paths just skip hidden entries. + */ + _lineBuffers: unknown = undefined; + _scatterBuffers: unknown = undefined; + _areaBuffers: unknown = undefined; + + /** + * 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; + + /** + * 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: () => { + if (this._hoveredBarIdx >= 0) { + showBarPinnedTooltip(this, this._hoveredBarIdx); + } else if (this._hoveredSample) { + showBarPinnedTooltipForSample(this, this._hoveredSample); + } + }, + }; + } + + 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, + scratchBars: this._bars, + scratchPosStack: this._posStackScratch, + scratchNegStack: this._negStackScratch, + }); + + 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); + } + + 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 the per-glyph GPU buffers built in `uploadAndRender`. Each + * glyph module owns its own resource set (line, scatter, area). + */ +function destroyGlyphBuffers(chart: SeriesChart): void { + if (!chart._glManager) { + return; + } + + const gl = chart._glManager.gl; + const lb = chart._lineBuffers as + | { gpuBuffer?: WebGLBuffer | null } + | null + | undefined; + if (lb?.gpuBuffer) { + gl.deleteBuffer(lb.gpuBuffer); + } + + chart._lineBuffers = undefined; + + const sb = chart._scatterBuffers as + | { + posLeft?: WebGLBuffer | null; + posRight?: WebGLBuffer | null; + colLeft?: WebGLBuffer | null; + colRight?: WebGLBuffer | null; + } + | null + | undefined; + if (sb) { + if (sb.posLeft) { + gl.deleteBuffer(sb.posLeft); + } + + if (sb.posRight) { + gl.deleteBuffer(sb.posRight); + } + + if (sb.colLeft) { + gl.deleteBuffer(sb.colLeft); + } + + if (sb.colRight) { + gl.deleteBuffer(sb.colRight); + } + } + + chart._scatterBuffers = undefined; + + const ab = chart._areaBuffers as + | { gpuBuffer?: WebGLBuffer | null } + | null + | undefined; + if (ab?.gpuBuffer) { + gl.deleteBuffer(ab.gpuBuffer); + } + + chart._areaBuffers = undefined; +} + +/** + * 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..f933161a1f 100644 --- a/packages/viewer-charts/src/ts/charts/sunburst/sunburst-interact.ts +++ b/packages/viewer-charts/src/ts/charts/sunburst/sunburst-interact.ts @@ -17,25 +17,25 @@ 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 +48,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 +58,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 +86,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 +130,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 +161,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 +173,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); } } @@ -195,6 +229,7 @@ export function handleSunburstClick( if (region.nodeId !== chart._currentRootId) { drillTo(chart, region.nodeId); } + return; } } @@ -215,17 +250,23 @@ export function handleSunburstClick( const facet = chart._facets.find( (f) => f.drillRoot === ctx.drillRoot, ); - if (facet) chart._facetDrillRoots.delete(facet.label); + if (facet) { + chart._facetDrillRoots.delete(facet.label); + } + 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); @@ -247,73 +288,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 +348,7 @@ export function showSunburstPinnedTooltip( } export function dismissSunburstPinnedTooltip(chart: SunburstChart): void { - chart._tooltip.dismissPinned(); + chart._tooltip.dismiss(); chart._pinnedNodeId = NULL_NODE; } @@ -339,6 +366,7 @@ export async function buildSunburstTooltipLines( pathNames.push(store.name[p]); p = store.parent[p]; } + pathNames.reverse(); if (pathNames.length > 0) { lines.push(pathNames.join(" › ")); @@ -365,10 +393,14 @@ 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)}`); } else { 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..7e94562c57 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,7 @@ function drawStaticChrome( label: chart._colorName, }, stops, + theme, ); } } else if ( @@ -693,6 +718,7 @@ function drawStaticChrome( legendLayout, chart._uniqueColorLabels, palette, + theme, ); } else if ( chart._colorMode === "numeric" && @@ -712,6 +738,7 @@ function drawStaticChrome( label: chart._colorName, }, stops, + theme, ); } @@ -732,11 +759,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 +779,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 +801,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 +818,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 +837,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 +847,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..e77f002882 100644 --- a/packages/viewer-charts/src/ts/charts/treemap/treemap-interact.ts +++ b/packages/viewer-charts/src/ts/charts/treemap/treemap-interact.ts @@ -60,9 +60,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 +74,7 @@ function hitTest(chart: TreemapChart, mx: number, my: number): HitResult { bestBranchArea = area; bestBranchId = id; } + const baseDepth = baseArr ? baseArr[i] : depth[chart._currentRootId]; @@ -77,6 +82,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 +108,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 +121,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 +132,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 +144,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); } } @@ -177,6 +186,7 @@ export function handleTreemapClick( if (region.nodeId !== chart._currentRootId) { drillTo(chart, region.nodeId); } + return; } } @@ -207,6 +217,7 @@ export function handleTreemapDblClick( target = parent; } } + if ( target !== NULL_NODE && target !== chart._currentRootId && @@ -238,50 +249,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 +308,7 @@ export function showTreemapPinnedTooltip( } export function dismissTreemapPinnedTooltip(chart: TreemapChart): void { - chart._tooltip.dismissPinned(); + chart._tooltip.dismiss(); chart._pinnedNodeId = NULL_NODE; } @@ -312,6 +331,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 ")); @@ -339,11 +359,15 @@ 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)}`); } else { 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..391b2f6805 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,7 @@ function drawStaticChrome( label: chart._colorName, }, stops, + theme, ); } @@ -759,7 +704,7 @@ function drawStaticChrome( function renderNodeLabel( chart: TreemapChart, - ctx: CanvasRenderingContext2D, + ctx: Context2D, nodeId: number, w: number, h: number, @@ -772,7 +717,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 +734,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 +761,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 +775,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 +794,10 @@ function renderBranchLabel( } } } - if (text.length <= 3) return; + + if (text.length <= 3) { + return; + } ctx.save(); ctx.beginPath(); @@ -914,16 +808,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 +837,7 @@ function renderBranchLabel( } } - ctx.fillStyle = textColor; + ctx.fillStyle = labelColor; ctx.globalAlpha = 0.85; ctx.textAlign = "left"; ctx.textBaseline = "top"; @@ -950,61 +846,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 +863,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..96d5350d9d --- /dev/null +++ b/packages/viewer-charts/src/ts/config.ts @@ -0,0 +1,46 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +/** + * Feature flag for auto-detecting Y alt-axes. + */ +export const AUTO_ALT_Y_AXIS = false; + +/** + * 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/index.ts b/packages/viewer-charts/src/ts/index.ts index fbedf58aae..4f3324b3d8 100644 --- a/packages/viewer-charts/src/ts/index.ts +++ b/packages/viewer-charts/src/ts/index.ts @@ -12,37 +12,6 @@ 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 function register(...plugin_names: string[]) { const plugins = new Set( @@ -54,17 +23,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..c10120485a --- /dev/null +++ b/packages/viewer-charts/src/ts/interaction/host-sink-message.ts @@ -0,0 +1,61 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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"; + +/** + * Envelope shape sent by `MessageHostSink`. The transport translates + * each one into a corresponding `WorkerMsg` (`pinTooltip` / + * `dismissTooltip` / `setCursor`). + */ +export type HostSinkEnvelope = + | { + kind: "pin"; + payload: { + lines: string[]; + pos: { px: number; py: number }; + bounds: CssBounds; + }; + } + | { kind: "dismiss" } + | { kind: "setCursor"; cursor: string }; + +/** + * `HostSink` that posts pin / dismiss / setCursor intents over a + * `postMessage`-style channel. The host-side transport listens for + * these envelopes and drives a `DomHostSink` on the host document — + * the renderer scope has no DOM in worker mode and uses the same + * channel in-process for symmetry. + */ +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 }); + } +} 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..75f41aeadc 100644 --- a/packages/viewer-charts/src/ts/interaction/tooltip-controller.ts +++ b/packages/viewer-charts/src/ts/interaction/tooltip-controller.ts @@ -10,32 +10,54 @@ // ┃ 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; } 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 +66,181 @@ 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; +} + +/** + * 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; + } + + /** + * 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) { + this.dismiss(); + return; + } + + this._callbacks.onPin?.(mx, my); } - /** Create a floating DOM tooltip and attach to `parent`. */ - showPinned( - parent: HTMLElement, + 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 +254,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 +280,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..b16d7eeb10 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; @@ -38,6 +39,7 @@ export const MIN_ZOOM = 1; export class ZoomController { private _scaleX = 1; private _scaleY = 1; + // Normalized translate: fraction of base domain range private _normTX = 0; private _normTY = 0; @@ -99,16 +101,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; } /** @@ -184,8 +232,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 +254,7 @@ export class ZoomController { Math.min(MAX_ZOOM, this._scaleX * factor), ); } + if (this._lockAxis !== "y") { this._scaleY = Math.max( MIN_ZOOM, @@ -228,6 +278,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 +306,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 +325,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 +349,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..4ac14a0e0a 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 @@ -117,7 +140,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 +162,7 @@ function pickGridShape( bestTotal = total; } } + return { cols: bestCols, rows: bestRows }; } @@ -180,6 +207,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/plugin/charts.ts b/packages/viewer-charts/src/ts/plugin/charts.ts index 1e1b000f83..882d77e24b 100644 --- a/packages/viewer-charts/src/ts/plugin/charts.ts +++ b/packages/viewer-charts/src/ts/plugin/charts.ts @@ -12,12 +12,6 @@ export type ChartType = "bar" | "line" | "scatter" | "area"; -/** - * 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. - */ export type PluginChartType = ChartType | "candlestick" | "ohlc"; export interface ChartTypeConfig { @@ -34,158 +28,90 @@ export interface ChartTypeConfig { default_chart_type?: PluginChartType; } -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 SERIES = "Series Charts"; +const CART = "Cartesian Charts"; +const HIER = "Hierarchical Charts"; +const FIN = "Financial Charts"; +const X_AXIS = ["X Axis"]; +const Y_AXIS = ["Y Axis"]; +const SELECT = "select"; +const TOGGLE = "toggle"; + +const DEFAULT_MAX_CELLS = 10_000_000; +const DEFAULT_MAX_COLUMNS = 50; + +function make( + name: string, + tag: string, + category: string, + selectMode: "select" | "toggle", + count: number, + names: readonly string[], + overrides?: Partial< + Pick< + ChartTypeConfig, + "max_cells" | "max_columns" | "default_chart_type" + > + >, +): 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, + ...(overrides?.default_chart_type + ? { default_chart_type: overrides.default_chart_type } + : {}), + }; +} + +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, { 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, + }), + make("Y Bar", "y-bar", SERIES, SELECT, 1, Y_AXIS, { 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, + }), + make("Y Line", "y-line", SERIES, SELECT, 1, Y_AXIS, { 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, { 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, { 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, + }), + make("X/Y Scatter", "scatter", CART, TOGGLE, 2, [ + "X Axis", + "Y Axis", + "Color", + "Size", + "Label", + "Tooltip", + ]), + make("X/Y Line", "line", CART, SELECT, 2, ["X Axis", "Y Axis"]), + make("Treemap", "treemap", HIER, TOGGLE, 1, HIER_NAMES, { + max_columns: 10, + }), + make("Sunburst", "sunburst", HIER, TOGGLE, 1, HIER_NAMES, { + max_columns: 10, + }), + make("Heatmap", "heatmap", HIER, SELECT, 1, ["Color"], { + max_columns: 500, + }), + make("Candlestick", "candlestick", FIN, TOGGLE, 1, FIN_NAMES, { default_chart_type: "candlestick", - }, - { - name: "OHLC", - tag: "ohlc", - category: "Charts", - selectMode: "toggle", - initial: { - count: 1, - names: ["Open", "Close", "High", "Low", "Tooltip"], - }, + }), + make("OHLC", "ohlc", FIN, TOGGLE, 1, FIN_NAMES, { max_cells: 100_000, - max_columns: 50, 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[]; + }), +]; 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..4fd50e4a2b 100644 --- a/packages/viewer-charts/src/ts/plugin/plugin.ts +++ b/packages/viewer-charts/src/ts/plugin/plugin.ts @@ -11,18 +11,16 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import type { View } from "@perspective-dev/client"; +import type { + HTMLPerspectiveViewerElement, + IPerspectiveViewerPlugin, +} from "@perspective-dev/viewer"; import { ChartTypeConfig } 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, - type FacetConfig, -} from "../charts/chart"; -import { ZoomController } from "../interaction/zoom-controller"; -import { ZoomRouter } from "../interaction/zoom-router"; -import { PlotLayout } from "../layout/plot-layout"; +import { DEFAULT_FACET_CONFIG, type FacetConfig } from "../charts/chart"; +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 — @@ -32,6 +30,7 @@ import { PlotLayout } from "../layout/plot-layout"; */ 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). @@ -39,6 +38,7 @@ const FACET_CONFIG: FacetConfig = { 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", @@ -50,7 +50,10 @@ const GLOBAL_STYLES = (() => { return [sheet]; })(); -export class HTMLPerspectiveViewerWebGLPluginElement extends HTMLElement { +export class HTMLPerspectiveViewerWebGLPluginElement + extends HTMLElement + implements IPerspectiveViewerPlugin +{ declare _chartType: ChartTypeConfig; declare static _chartType: ChartTypeConfig; @@ -58,223 +61,256 @@ export class HTMLPerspectiveViewerWebGLPluginElement extends HTMLElement { 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; + + /** + * Aborts the reset-zoom button click listener installed by + * `_setupInteraction`. The button is part of the one-time scaffold + * so it survives across renderer lifetimes, but each renderer + * installs its own listener that captures its own + * `RendererTransport` in a closure — without this, every + * disconnect/reconnect cycle leaks one listener that fires + * `resetAllZooms()` against a destroyed transport. + */ + private _resetClickAbort: AbortController | null = null; connectedCallback() { if (!this._initialized) { + // One-time scaffold: shadow root, adopted stylesheet, and + // the persistent `.webgl-container` + `.zoom-controls` + // subtree. The zoom controls live across renderer + // lifetimes; only the canvas children get torn down on + // disconnect because `transferControlToOffscreen` is a + // one-shot operation per `` element. this.attachShadow({ mode: "open" }); for (const sheet of GLOBAL_STYLES) { 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", - )!; + // (Re)build the canvas stack on every connect — the previous + // disconnect tore it down so its `transferControlToOffscreen` + // poisoning doesn't leak into the next renderer. + if (!this._glCanvas?.isConnected) { + this._buildCanvasStack(); + } + } - this._gridlineCanvas = - this.shadowRoot!.querySelector( - ".webgl-gridlines", - )!; + /** + * Insert a fresh canvas stack as the first children of + * `.webgl-container`, leaving the trailing `.zoom-controls` div + * untouched. Requeries the canvas references for the new + * elements. Called from `connectedCallback` whenever the prior + * stack has been removed (initial mount + every reconnect after + * `_clearCanvasStack`). + */ + 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; + /** + * Remove the canvas children of `.webgl-container` and clear the + * cached references. `transferControlToOffscreen` permanently + * marks a canvas element as having relinquished control; the + * elements are unrecoverable, so the only correct path is to + * discard them and rebuild on the next connect. + */ + 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. + * + * Order: `delete()` first so the worker tears down its + * `WorkerRenderer` (which holds the only references to the + * transferred `OffscreenCanvas` siblings); `_clearCanvasStack` + * after, so we're not removing canvas elements that a live worker + * is still painting into. The next `connectedCallback` rebuilds a + * fresh canvas stack — `transferControlToOffscreen` is a one-shot + * operation per element, so the prior canvases can't be reused. + * Pays the ~30-100ms shader-precompile cost per activation — same + * cost the `precompileShaders: true` flag was added to amortize on + * first draw. + */ + 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); + // The reset-zoom button is part of the persistent scaffold — + // not torn down on disconnect — so each new renderer must use + // a fresh `AbortController` to install its click handler, and + // `delete()` aborts it on teardown. Without the abort, every + // disconnect/reconnect cycle would leak a listener that + // captures the destroyed transport in its closure. + 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(); + + // Resolve the source table name once at init so the worker can + // open its own `Table` handle and serve `table.schema()` lookups + // on the render path without a host-side await. `getTable()` may + // be unavailable if the viewer hasn't loaded a table yet — pass + // through `undefined` and the worker falls back to an empty + // source schema. + const table = await (viewer as any)?.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, + + // Eagerly compile every program in `SHADER_MANIFEST` + // during renderer construction so the first-frame path + // doesn't pay the inline compile cost. Trade-off: ~30-100ms + // of init time per chart instance for a smoother first + // paint. Flip to `false` if a deployment ships a + // single-chart page where most shaders are dead weight. + 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); - } - }); - } - } + await transport.init({ + gl: this._glCanvas, + gridlines: this._gridlineCanvas, + chrome: this._chromeCanvas, + facetConfig: FACET_CONFIG, + defaultChartType: this._chartType.default_chart_type, + renderBlitMode: this._renderBlitMode, + }); - 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; + return transport; } - private _resetAllZooms(): void { - this._zoomController?.reset(); - const chart = this._chartImpl as any; - if (chart?._facetZoomControllers) { - for (const zc of chart._facetZoomControllers) { - zc?.reset(); - } - } + setBlitMode(mode: "direct" | "blit") { + console.assert(this._initialized, "Already initialized"); + this._renderBlitMode = mode; } get name() { @@ -324,74 +360,96 @@ export class HTMLPerspectiveViewerWebGLPluginElement extends HTMLElement { 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; + if (!this._chartType.default_chart_type) { + return false; + } + return column_type === "integer" || column_type === "float"; } - 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. + 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 def = this._chartType.default_chart_type; - if (!def) return {}; - if (column_type !== "integer" && column_type !== "float") return {}; - return { - number_series_style: { - chart_type: def, + if (!def) { + return { fields: [] }; + } + + if (column_type !== "integer" && column_type !== "float") { + return { fields: [] }; + } + + const fields: Array & { kind: string }> = [ + { + kind: "Enum", + key: "chart_type", + label: "Chart Type", + default: def, + variants: [ + { value: "bar", label: "Bar" }, + { value: "line", label: "Line" }, + { value: "scatter", label: "Scatter" }, + { value: "area", label: "Area" }, + ], }, - }; + ]; + + // Stack only meaningful for Bar / Area. Re-query model: when the + // user changes chart_type to line/scatter, the schema is fetched + // again and `stack` is dropped. + const effective_chart_type = + (current_value?.chart_type as string | undefined) ?? def; + const supports_stack = + effective_chart_type === "bar" || effective_chart_type === "area"; + if (supports_stack) { + fields.push({ + kind: "Bool", + key: "stack", + label: "Stack", + default: true, + }); + } + + return { fields }; } async draw(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); - } + // Install the current View on the chart impl in the worker so + // it can make on-demand per-row queries for lazy tooltip + // lookups. Hover/tooltip is the one path that still drives + // `View` calls outside `loadAndRender`. + renderer.setView(view); + renderer.setBufferMaxCapacity(this._chartType.max_cells); - const columnSlots: (string | null)[] = viewerConfig?.columns ?? []; - if (this._chartImpl?.setColumnSlots) { - this._chartImpl.setColumnSlots(columnSlots); + const viewer = this.parentElement as any; + const viewerConfig = (await viewer?.getViewConfig?.()) ?? {}; + if (this._generation !== gen) { + return; } - 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, + // The worker owns every `Client`/`Table`/`View` await on the + // render path now: row count, post-aggregation schema, expr + // schema, source-table schema, and the `with_typed_arrays` + // chunk fetch all run there. `viewerConfig` is a + // `` element API (not a `Client` method), + // so it stays host-side and ships in the trigger msg. + await renderer.loadAndRender({ + viewerConfig: { + group_by: viewerConfig?.group_by ?? [], + split_by: viewerConfig?.split_by ?? [], + columns: viewerConfig?.columns ?? [], + }, + options: { float32: true }, }); } @@ -401,86 +459,67 @@ export class HTMLPerspectiveViewerWebGLPluginElement extends HTMLElement { 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; } + return state; } + async render(view: View): Promise { + // Cold-export safe: ensure the renderer exists and has drawn + // the supplied view at least once before snapshotting. The + // plugin may be invoked while not focused (e.g. programmatic + // export from a viewer that hasn't yet displayed this chart). + 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 ?? {}); - } + 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; } + + // Clear the memoized init promise so re-activation rebuilds + // the renderer instead of handing back a resolved promise that + // points to the just-destroyed transport. + 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/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-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-charts/src/ts/theme/theme-snapshot.ts b/packages/viewer-charts/src/ts/theme/theme-snapshot.ts new file mode 100644 index 0000000000..fd08d0283e --- /dev/null +++ b/packages/viewer-charts/src/ts/theme/theme-snapshot.ts @@ -0,0 +1,66 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { ThemeSnapshot } from "./theme"; + +/** + * 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", +]; + +/** + * 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; + } + } + + for (let i = 1; ; i++) { + const key = `--psp-charts--series-${i}--color`; + const raw = style.getPropertyValue(key).trim(); + if (!raw) { + break; + } + + out[key] = raw; + } + + return out; +} diff --git a/packages/viewer-charts/src/ts/theme/theme.ts b/packages/viewer-charts/src/ts/theme/theme.ts index 471bf2bad1..14b0dbc19d 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..02a43d64d0 --- /dev/null +++ b/packages/viewer-charts/src/ts/transport/protocol.ts @@ -0,0 +1,429 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 } from "../charts/chart"; + +/** + * 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 + | SetBufferMaxCapacityMsg + | LoadAndRenderMsg + | RedrawMsg + | ResizeMsg + | ClearMsg + | InvalidateThemeMsg + | RestoreZoomMsg + | ResetAllZoomsMsg + | SaveZoomReqMsg + | SnapshotPngReqMsg + | InteractionMsg + | DestroyMsg; + +export type WorkerMsg = + | ReadyMsg + | ZoomChangedMsg + | SaveZoomReplyMsg + | SnapshotPngReplyMsg + | PinTooltipMsg + | DismissTooltipMsg + | SetCursorMsg + | 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; + 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; +} + +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"; +} + +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; +} + +/** + * 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..c6ea2612e8 --- /dev/null +++ b/packages/viewer-charts/src/ts/transport/renderer-transport.ts @@ -0,0 +1,687 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 } from "@perspective-dev/client"; +import type { FacetConfig } from "../charts/chart"; +import type { + ControlMsg, + InitMsg, + InteractionEvent, + LoadAndRenderMsg, + WorkerEnvelope, + WorkerMsg, +} from "./protocol"; +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; + + 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; + 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, + defaultChartType: opts.defaultChartType, + themeVars, + fontFaces, + cssWidth: rect.width, + cssHeight: rect.height, + dpr, + bufferMaxCapacity: this._maxCells, + 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 }); + } + + 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 }); + } + + 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" }); + } + + /** + * 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 "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(); + } + + /** + * 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-charts/src/ts/webgl/program-cache.ts b/packages/viewer-charts/src/ts/webgl/program-cache.ts new file mode 100644 index 0000000000..a0a4ce79ad --- /dev/null +++ b/packages/viewer-charts/src/ts/webgl/program-cache.ts @@ -0,0 +1,46 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 "./context-manager"; + +/** + * 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); + } + + for (const n of attrs) { + out[n] = gl.getAttribLocation(program, n); + } + + 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..2a78a3c591 --- /dev/null +++ b/packages/viewer-charts/src/ts/webgl/shader-manifest.ts @@ -0,0 +1,104 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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"; + +/** + * 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 }, +]; + +// 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, +}; 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..72e5e0993d --- /dev/null +++ b/packages/viewer-charts/src/ts/worker/dispatch.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 { 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 "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 "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..874b41255c --- /dev/null +++ b/packages/viewer-charts/src/ts/worker/renderer.worker.ts @@ -0,0 +1,690 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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, + type Table, + type View, +} from "@perspective-dev/viewer/dist/wasm/perspective-viewer.js"; + +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. + */ +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, + ) { + this.client = client; + this.view = view; + this.table = table; + this.controlPort = controlPort; + + const ImplClass = CHART_IMPLS[msg.chartTag]; + if (!ImplClass) { + throw new Error(`Unknown chart tag: ${msg.chartTag}`); + } + + 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); + + 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; + } + }); + + 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(); + } + } + } + + /** + * 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 renderer = new WorkerRenderer(msg, client, view, table, host); + 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 renderer = new WorkerRenderer( + opts.msg, + opts.client, + view, + table, + opts.controlPort, + ); + + // 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/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/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..be32cef4f0 100644 --- a/packages/viewer-datagrid/src/ts/custom_elements/datagrid.ts +++ b/packages/viewer-datagrid/src/ts/custom_elements/datagrid.ts @@ -15,17 +15,14 @@ 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 type { View, ViewWindow } from "@perspective-dev/client"; -import type { - HTMLPerspectiveViewerElement, - IPerspectiveViewerPlugin, -} from "@perspective-dev/viewer"; +import type { IPerspectiveViewerPlugin } from "@perspective-dev/viewer"; import type { DatagridModel, DatagridToolbarElement, @@ -151,8 +148,23 @@ export class HTMLPerspectiveViewerDatagridPluginElement 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 +184,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); @@ -206,6 +216,7 @@ export class HTMLPerspectiveViewerDatagridPluginElement out += col[ridx] + "\t"; } } + out += "\n"; } @@ -235,12 +246,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..c46a26d448 100644 --- a/packages/viewer-datagrid/src/ts/data_listener/formatter_cache.ts +++ b/packages/viewer-datagrid/src/ts/data_listener/formatter_cache.ts @@ -89,24 +89,30 @@ export class FormatterCache { 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"; } 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..9c71727425 100644 --- a/packages/viewer-datagrid/src/ts/model/toolbar.ts +++ b/packages/viewer-datagrid/src/ts/model/toolbar.ts @@ -26,7 +26,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,6 +48,7 @@ export function toggle_edit_mode( EDIT_MODES[idx] === "SELECT_ROW_TREE" && !isSelectRowTreeAvailable(this.model) ); + mode = EDIT_MODES[idx]; } 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..50d6f2b55e --- /dev/null +++ b/packages/viewer-datagrid/src/ts/plugin/column_config_schema.ts @@ -0,0 +1,203 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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", + label: "foreground", + 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", + label: "foreground", + 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", + label: "max-value", + default: column_stats?.abs_max ?? 0, + include: true, + }); + } + + fields.push({ + kind: "Enum", + key: "number_bg_mode", + label: "background", + 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", + label: "background", + 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", + label: "max-value", + include: true, + default: column_stats?.abs_max ?? 0, + }); + } + + fields.push({ kind: "NumberFormat" }); + } else if (type === "date" || type === "datetime") { + fields.push({ + kind: "DatetimeFormat", + default: { color: this.model!._color[0] }, + }); + + fields.push({ + kind: "Enum", + key: "datetime_color_mode", + label: "color", + 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", + label: dt_mode, + default: this.model!._color[0], + }); + } + } else if (type === "string") { + fields.push({ + kind: "StringFormat", + default: { color: this.model!._color[0] }, + }); + + fields.push({ + kind: "Enum", + key: "string_color_mode", + label: "color", + 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", + label: str_mode, + 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/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/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..184ca53224 100644 --- a/packages/viewer-datagrid/src/ts/style_handlers/body.ts +++ b/packages/viewer-datagrid/src/ts/style_handlers/body.ts @@ -212,6 +212,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..d156e33e8c 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("#background-pos").waitFor(); + await sidebar_locator.locator("#background-neg").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/src/js/plugin/plugin.js b/packages/viewer-openlayers/src/js/plugin/plugin.js index e3439d97c8..d21f4ef608 100644 --- a/packages/viewer-openlayers/src/js/plugin/plugin.js +++ b/packages/viewer-openlayers/src/js/plugin/plugin.js @@ -49,7 +49,7 @@ views.forEach(async (plugin) => { return "OpenStreetMap"; } - async restyle(view) { + restyle() { mapView.restyle(this.shadowRoot.children[1]); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e7f7f5089..6e44ac4252 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,6 +75,9 @@ catalogs: 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 @@ -159,6 +162,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 @@ -235,6 +241,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 +256,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: @@ -799,6 +811,9 @@ 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 @@ -1176,6 +1191,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 +1537,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 +1668,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 +2395,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 +2550,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 +2589,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==} @@ -2536,6 +2682,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: @@ -2611,6 +2761,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'} @@ -3005,6 +3159,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'} @@ -3173,11 +3330,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 +3420,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 +3448,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 +3467,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 +3485,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 +3596,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'} @@ -3548,6 +3761,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 +3965,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 +3978,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 +4000,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 +4029,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 +4044,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 +4157,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 +4188,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 +4288,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 +4331,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'} @@ -4164,6 +4415,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 +4427,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'} @@ -4339,6 +4602,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'} @@ -4745,6 +5012,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'} @@ -4839,6 +5110,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 +5127,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 +5157,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'} @@ -5122,6 +5410,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 +5509,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 +5736,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 +6003,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 +7189,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 +7395,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 +7428,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 +7549,8 @@ snapshots: balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + bare-events@2.8.1: {} bare-fs@4.5.0: @@ -7155,6 +7617,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 @@ -7586,6 +8052,8 @@ snapshots: dependencies: ms: 2.1.3 + deep-is@0.1.4: {} + deepmerge@4.3.1: {} define-data-property@1.1.4: @@ -7840,8 +8308,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 +8421,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 +8441,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 +8460,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 +8603,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 @@ -8235,6 +8785,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: @@ -8431,10 +8983,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 +9011,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 +9051,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: @@ -8512,6 +9076,11 @@ snapshots: needle: 3.3.1 source-map: 0.6.1 + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + lib0@0.2.114: dependencies: isomorphic.js: 0.2.5 @@ -8603,6 +9172,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 +9194,8 @@ snapshots: lodash.isstring@4.0.1: {} + lodash.merge@4.6.2: {} + lodash.once@4.1.1: {} lodash@4.17.21: {} @@ -8702,10 +9277,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 +9311,8 @@ snapshots: nanoid@3.3.11: {} + natural-compare@1.4.0: {} + needle@3.3.1: dependencies: iconv-lite: 0.6.3 @@ -8820,6 +9405,15 @@ snapshots: 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 +9424,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: @@ -8981,6 +9583,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: @@ -9485,6 +10089,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 +10192,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 +10207,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 +10255,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: @@ -9909,6 +10534,8 @@ snapshots: wildcard@2.0.1: {} + word-wrap@1.2.5: {} + wordwrap@1.0.0: {} wordwrapjs@5.1.1: {} @@ -9984,6 +10611,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..4ee5e41dba 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -22,8 +22,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 +32,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 +50,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 +67,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 +92,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/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/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/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-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/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}`); } From 6161d448d0b1c83ef577d1d7e10a0c81794ba72f Mon Sep 17 00:00:00 2001 From: Andrew Stein Date: Fri, 8 May 2026 19:29:14 -0400 Subject: [PATCH 2/4] Add configurable `columns_config` schema Signed-off-by: Andrew Stein --- examples/blocks/src/dataset/index.html | 7 + .../src/rust/virtual_server/data.rs | 75 ++- .../perspective-js/src/rust/virtual_server.rs | 8 +- .../src/ts/virtual_servers/duckdb.ts | 4 + .../src/rust/components/column_dropdown.rs | 4 +- .../src/rust/components/column_selector.rs | 6 +- .../column_selector/active_column.rs | 2 +- .../column_selector/filter_column.rs | 14 +- .../components/column_settings_sidebar.rs | 64 +-- .../column_settings_sidebar/style_tab.rs | 440 ++++++++++------ .../style_tab/agg_depth_selector.rs | 22 +- .../style_tab/primitive_field.rs | 336 ++++++++++++ .../style_tab/symbol.rs | 21 +- .../rust/components/containers/split_panel.rs | 6 +- .../src/rust/components/copy_dropdown.rs | 2 +- .../rust/components/datetime_column_style.rs | 95 +--- .../src/rust/components/editable_header.rs | 5 +- .../src/rust/components/export_dropdown.rs | 2 +- .../src/rust/components/expression_editor.rs | 8 +- .../src/rust/components/filter_dropdown.rs | 3 +- .../src/rust/components/form/debug.rs | 36 +- .../src/rust/components/main_panel.rs | 74 +-- .../src/rust/components/mod.rs | 1 - .../rust/components/number_column_style.rs | 491 ------------------ .../rust/components/number_series_style.rs | 30 +- .../src/rust/components/settings_panel.rs | 2 +- .../src/rust/components/status_bar.rs | 318 ++++++------ .../src/rust/components/status_indicator.rs | 37 +- .../rust/components/string_column_style.rs | 99 +--- .../style_controls/number_string_format.rs | 44 +- .../src/rust/components/viewer.rs | 120 +---- .../src/rust/config/column_config_schema.rs | 206 ++++++++ .../src/rust/config/columns_config.rs | 116 +---- .../rust/{tasks => config}/export_method.rs | 0 .../perspective-viewer/src/rust/config/mod.rs | 6 +- .../src/rust/config/number_column_style.rs | 136 ----- .../src/rust/config/number_series_style.rs | 11 +- .../src/rust/config/viewer_config.rs | 4 +- .../src/rust/custom_elements/copy_dropdown.rs | 48 +- .../rust/custom_elements/export_dropdown.rs | 44 +- .../src/rust/custom_elements/viewer.rs | 128 +++-- .../src/rust/custom_events.rs | 407 +++++++-------- rust/perspective-viewer/src/rust/dragdrop.rs | 18 +- rust/perspective-viewer/src/rust/js/plugin.rs | 11 +- rust/perspective-viewer/src/rust/lib.rs | 3 +- .../src/rust/presentation.rs | 71 ++- .../rust/{tasks => queries}/column_locator.rs | 45 -- .../src/rust/queries/column_values.rs | 99 ++-- .../{tasks => queries}/columns_iter_set.rs | 10 +- .../src/rust/{tasks => queries}/export_app.rs | 0 .../src/rust/queries/exports.rs | 96 ++++ .../src/rust/queries/fetch_column_stats.rs | 93 ++++ .../{tasks => queries}/get_viewer_config.rs | 71 +-- .../{tasks => queries}/is_invalid_drop.rs | 0 .../src/rust/queries/mod.rs | 44 ++ .../src/rust/queries/plugin_column_styles.rs | 216 ++++++++ .../validate_expression.rs} | 41 +- rust/perspective-viewer/src/rust/renderer.rs | 9 +- rust/perspective-viewer/src/rust/session.rs | 246 ++++----- .../src/rust/session/metadata.rs | 31 +- .../src/rust/session/props.rs | 11 +- .../src/rust/session/view_subscription.rs | 10 + .../src/rust/tasks/copy_export.rs | 334 ++++++------ .../src/rust/tasks/edit_expression.rs | 156 +++--- .../src/rust/tasks/eject.rs | 47 +- .../src/rust/tasks/intersection_observer.rs | 29 +- rust/perspective-viewer/src/rust/tasks/mod.rs | 33 +- .../src/rust/tasks/plugin_column_styles.rs | 98 ---- .../src/rust/tasks/reset_all.rs | 71 +++ .../src/rust/tasks/resize_observer.rs | 44 +- .../src/rust/tasks/restore_and_render.rs | 194 +++---- .../src/rust/tasks/send_plugin_config.rs | 67 +-- .../src/rust/tasks/update_and_render.rs | 91 ++-- .../src/rust/utils/browser/selection.rs | 8 +- rust/perspective-viewer/src/rust/utils/mod.rs | 63 --- rust/perspective-viewer/src/ts/bootstrap.ts | 8 +- .../src/ts/perspective-viewer.ts | 1 - .../test/js/column_settings/sidebar.spec.ts | 2 +- 78 files changed, 2961 insertions(+), 2892 deletions(-) create mode 100644 rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab/primitive_field.rs delete mode 100644 rust/perspective-viewer/src/rust/components/number_column_style.rs create mode 100644 rust/perspective-viewer/src/rust/config/column_config_schema.rs rename rust/perspective-viewer/src/rust/{tasks => config}/export_method.rs (100%) delete mode 100644 rust/perspective-viewer/src/rust/config/number_column_style.rs rename rust/perspective-viewer/src/rust/{tasks => queries}/column_locator.rs (76%) rename packages/viewer-datagrid/src/ts/plugin/column_style_controls.ts => rust/perspective-viewer/src/rust/queries/column_values.rs (51%) rename rust/perspective-viewer/src/rust/{tasks => queries}/columns_iter_set.rs (98%) rename rust/perspective-viewer/src/rust/{tasks => queries}/export_app.rs (100%) create mode 100644 rust/perspective-viewer/src/rust/queries/exports.rs create mode 100644 rust/perspective-viewer/src/rust/queries/fetch_column_stats.rs rename rust/perspective-viewer/src/rust/{tasks => queries}/get_viewer_config.rs (56%) rename rust/perspective-viewer/src/rust/{tasks => queries}/is_invalid_drop.rs (100%) create mode 100644 rust/perspective-viewer/src/rust/queries/mod.rs create mode 100644 rust/perspective-viewer/src/rust/queries/plugin_column_styles.rs rename rust/perspective-viewer/src/rust/{engines.rs => queries/validate_expression.rs} (68%) delete mode 100644 rust/perspective-viewer/src/rust/tasks/plugin_column_styles.rs create mode 100644 rust/perspective-viewer/src/rust/tasks/reset_all.rs 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/rust/perspective-client/src/rust/virtual_server/data.rs b/rust/perspective-client/src/rust/virtual_server/data.rs index 30079acec3..e8dd8e65f3 100644 --- a/rust/perspective-client/src/rust/virtual_server/data.rs +++ b/rust/perspective-client/src/rust/virtual_server/data.rs @@ -578,7 +578,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). @@ -660,7 +666,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; } @@ -801,6 +822,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() @@ -914,6 +954,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 +985,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-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/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-viewer/src/rust/components/column_dropdown.rs b/rust/perspective-viewer/src/rust/components/column_dropdown.rs index ff927d57e5..83b5ea8042 100644 --- a/rust/perspective-viewer/src/rust/components/column_dropdown.rs +++ b/rust/perspective-viewer/src/rust/components/column_dropdown.rs @@ -91,7 +91,9 @@ impl ColumnDropDownElement { let width = target.get_bounding_client_rect().width(); ApiFuture::spawn(async move { if !exclude.contains(&input) { - let is_expr = session.validate_expr(&input).await?.is_none(); + let is_expr = crate::queries::validate_expr(&session, &input) + .await? + .is_none(); if is_expr { values.push(InPlaceColumn::Expression(Expression::new( None, diff --git a/rust/perspective-viewer/src/rust/components/column_selector.rs b/rust/perspective-viewer/src/rust/components/column_selector.rs index 88748ce373..4196764d64 100644 --- a/rust/perspective-viewer/src/rust/components/column_selector.rs +++ b/rust/perspective-viewer/src/rust/components/column_selector.rs @@ -45,12 +45,12 @@ use crate::components::containers::scroll_panel_item::ScrollPanelItem; use crate::css; use crate::dragdrop::*; use crate::presentation::ColumnLocator; +use crate::queries::{ + ActiveColumnState, ActiveColumnStateData, ColumnsIteratorSet, can_render_column_styles, +}; use crate::renderer::*; use crate::session::drag_drop_update::*; use crate::session::*; -use crate::tasks::{ - ActiveColumnState, ActiveColumnStateData, ColumnsIteratorSet, can_render_column_styles, -}; use crate::utils::*; #[derive(Properties)] diff --git a/rust/perspective-viewer/src/rust/components/column_selector/active_column.rs b/rust/perspective-viewer/src/rust/components/column_selector/active_column.rs index 8044c449dc..3b8698b1c9 100644 --- a/rust/perspective-viewer/src/rust/components/column_selector/active_column.rs +++ b/rust/perspective-viewer/src/rust/components/column_selector/active_column.rs @@ -26,9 +26,9 @@ use crate::components::type_icon::TypeIcon; use crate::dragdrop::*; use crate::js::plugin::*; use crate::presentation::ColumnLocator; +use crate::queries::*; use crate::renderer::*; use crate::session::*; -use crate::tasks::*; use crate::utils::*; #[derive(Clone, Properties)] diff --git a/rust/perspective-viewer/src/rust/components/column_selector/filter_column.rs b/rust/perspective-viewer/src/rust/components/column_selector/filter_column.rs index 07e7b30ace..fd3082fad8 100644 --- a/rust/perspective-viewer/src/rust/components/column_selector/filter_column.rs +++ b/rust/perspective-viewer/src/rust/components/column_selector/filter_column.rs @@ -25,11 +25,11 @@ use crate::components::containers::select::*; use crate::components::filter_dropdown::FilterDropDownElement; use crate::components::style::LocalStyle; use crate::components::type_icon::TypeIcon; +use crate::css; use crate::dragdrop::*; use crate::renderer::*; use crate::session::*; use crate::utils::*; -use crate::{css, maybe}; #[derive(Clone, Properties)] pub struct FilterColumnProps { @@ -101,11 +101,11 @@ impl Component for FilterColumn { .unwrap_or_else(|| "".to_owned()); this.filter_ops = Rc::new( - maybe! { - Some(get_filter_ops(&ctx.props().metadata, col_type?)? + try { + get_filter_ops(&ctx.props().metadata, col_type?)? .into_iter() .map(SelectItem::Option) - .collect::>()) + .collect::>() } .unwrap_or_default(), ); @@ -189,11 +189,11 @@ impl Component for FilterColumn { if col_type != old_col_type { changed = true; self.filter_ops = Rc::new( - maybe! { - Some(get_filter_ops(&ctx.props().metadata, col_type?)? + try { + get_filter_ops(&ctx.props().metadata, col_type?)? .into_iter() .map(SelectItem::Option) - .collect::>()) + .collect::>() } .unwrap_or_default(), ); diff --git a/rust/perspective-viewer/src/rust/components/column_settings_sidebar.rs b/rust/perspective-viewer/src/rust/components/column_settings_sidebar.rs index 605cffb4d2..91cf19af8f 100644 --- a/rust/perspective-viewer/src/rust/components/column_settings_sidebar.rs +++ b/rust/perspective-viewer/src/rust/components/column_settings_sidebar.rs @@ -33,14 +33,11 @@ use crate::components::editable_header::EditableHeaderProps; use crate::components::expression_editor::ExpressionEditorProps; use crate::components::style::LocalStyle; use crate::components::type_icon::TypeIconType; -use crate::custom_events::CustomEvents; use crate::presentation::{ColumnLocator, ColumnSettingsTab, Presentation}; +use crate::queries::{can_render_column_styles, locator_name_or_default, locator_view_type}; use crate::renderer::Renderer; use crate::session::{Session, SessionMetadataRc}; -use crate::tasks::{ - EditExpression, HasCustomEvents, HasPresentation, HasRenderer, HasSession, - can_render_column_styles, locator_name_or_default, locator_view_type, -}; +use crate::tasks::{delete_expr, save_expr, update_expr}; use crate::utils::PtrEqRc; use crate::*; @@ -64,13 +61,13 @@ pub struct ColumnSettingsPanelProps { /// View config snapshot — threaded from `SessionProps`. pub view_config: PtrEqRc, + /// Per-column stats snapshot — threaded from `SessionProps`. + pub column_stats: PtrEqRc>, + /// Selected theme name, threaded for PortalModal consumers. pub selected_theme: Option, // State - #[derivative(Debug = "ignore")] - pub custom_events: CustomEvents, - #[derivative(Debug = "ignore")] pub presentation: Presentation, @@ -88,34 +85,11 @@ impl PartialEq for ColumnSettingsPanelProps { && self.plugin_name == other.plugin_name && self.metadata == other.metadata && self.view_config == other.view_config + && self.column_stats == other.column_stats && self.selected_theme == other.selected_theme } } -impl HasCustomEvents for ColumnSettingsPanelProps { - fn custom_events(&self) -> &CustomEvents { - &self.custom_events - } -} - -impl HasPresentation for ColumnSettingsPanelProps { - fn presentation(&self) -> &Presentation { - &self.presentation - } -} - -impl HasRenderer for ColumnSettingsPanelProps { - fn renderer(&self) -> &Renderer { - &self.renderer - } -} - -impl HasSession for ColumnSettingsPanelProps { - fn session(&self) -> &Session { - &self.session - } -} - #[derive(Debug)] pub enum ColumnSettingsPanelMsg { SetExprValue(Rc), @@ -239,11 +213,20 @@ impl Component for ColumnSettingsPanel { ColumnLocator::Table(_) => { tracing::error!("Tried to save non-expression column!") }, - ColumnLocator::Expression(name) => { - ctx.props().update_expr(name.clone(), new_expr) - }, + ColumnLocator::Expression(name) => update_expr( + &ctx.props().session, + &ctx.props().renderer, + &ctx.props().presentation, + name.clone(), + new_expr, + ), ColumnLocator::NewExpression => { - if let Err(err) = ctx.props().save_expr(new_expr) { + if let Err(err) = save_expr( + &ctx.props().session, + &ctx.props().renderer, + &ctx.props().presentation, + new_expr, + ) { tracing::warn!("{}", err); } }, @@ -258,7 +241,12 @@ impl Component for ColumnSettingsPanel { }, ColumnSettingsPanelMsg::OnDelete(()) => { if ctx.props().selected_column.is_saved_expr() { - ctx.props().delete_expr(&self.column_name).unwrap_or_log(); + delete_expr( + &ctx.props().session, + &ctx.props().renderer, + &self.column_name, + ) + .unwrap_or_log(); } ctx.props().on_close.emit(()); @@ -347,8 +335,8 @@ impl Component for ColumnSettingsPanel { group_by_depth: ctx.props().view_config.group_by.len() as u32, view_config: ctx.props().view_config.clone(), metadata: ctx.props().metadata.clone(), + column_stats: ctx.props().column_stats.clone(), selected_theme: ctx.props().selected_theme.clone(), - custom_events: ctx.props().custom_events.clone(), presentation: ctx.props().presentation.clone(), renderer: ctx.props().renderer.clone(), session: ctx.props().session.clone(), diff --git a/rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab.rs b/rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab.rs index 3df546dd68..bfdc18dfed 100644 --- a/rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab.rs +++ b/rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab.rs @@ -11,29 +11,35 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ mod agg_depth_selector; +mod primitive_field; mod stub; mod symbol; +use std::collections::HashMap; + use itertools::Itertools; -use perspective_client::config::{ColumnType, GroupRollupMode}; +use perspective_client::config::ColumnType; use yew::{Html, Properties, function_component, html}; use self::agg_depth_selector::*; +use self::primitive_field::{ + BoolField, ColorField, ColorRangeField, EnumField, NumberFieldPrimitive, +}; use crate::components::column_settings_sidebar::style_tab::stub::Stub; use crate::components::column_settings_sidebar::style_tab::symbol::SymbolStyle; use crate::components::datetime_column_style::DatetimeColumnStyle; -use crate::components::number_column_style::NumberColumnStyle; use crate::components::number_series_style::NumberSeriesStyle; use crate::components::string_column_style::StringColumnStyle; use crate::components::style_controls::CustomNumberFormat; -use crate::custom_events::CustomEvents; +use crate::config::{ + ControlSpec, CustomNumberFormatConfig, DatetimeColumnStyleConfig, NumberSeriesStyleConfig, + StringColumnStyleConfig, +}; use crate::presentation::Presentation; +use crate::queries::{fetch_column_abs_max, get_column_config_schema}; use crate::renderer::Renderer; use crate::session::Session; -use crate::tasks::{ - HasCustomEvents, HasPresentation, HasRenderer, HasSession, SendPluginConfig, - get_column_style_control_options, -}; +use crate::tasks::send_plugin_config; use crate::utils::PtrEqRc; #[derive(Clone, PartialEq, Properties)] @@ -48,169 +54,309 @@ pub struct StyleTabProps { /// Session metadata snapshot — threaded from parent. pub metadata: PtrEqRc, + /// Per-column stats snapshot — threaded from `SessionProps`. + pub column_stats: PtrEqRc>, + /// Selected theme name, threaded for PortalModal consumers. pub selected_theme: Option, // State - pub custom_events: CustomEvents, pub presentation: Presentation, pub renderer: Renderer, pub session: Session, } -impl HasCustomEvents for StyleTabProps { - fn custom_events(&self) -> &CustomEvents { - &self.custom_events - } -} +#[function_component] +pub fn StyleTab(props: &StyleTabProps) -> Html { + // Bumped on every primitive field change so Yew re-renders the tab + // and re-queries `column_config_schema` with the new value. Without + // this, dynamic field gating (e.g. show `Color` only when + // `string_color_mode != none`) wouldn't surface until the user + // closed and reopened the sidebar. + let revision = yew::use_state(|| 0u32); -impl HasPresentation for StyleTabProps { - fn presentation(&self) -> &Presentation { - &self.presentation - } -} + // `abs_max` lives in `Session`'s shared cache, propagated as a + // value-semantic snapshot through `props.column_stats`. The cache + // is cleared on `view_config_changed`; on cache miss we spawn the + // fetch and let `column_stats_changed` drive the re-render via + // `SessionProps`. + let abs_max = props + .column_stats + .get(&props.column_name) + .and_then(|s| s.abs_max); -impl HasRenderer for StyleTabProps { - fn renderer(&self) -> &Renderer { - &self.renderer - } -} + yew::use_effect_with((props.column_name.clone(), props.view_config.clone()), { + let session = props.session.clone(); + let column_name = props.column_name.clone(); + move |_| { + fetch_column_abs_max(&session, column_name); + || () + } + }); -impl HasSession for StyleTabProps { - fn session(&self) -> &Session { - &self.session + let raw_config = props.presentation.get_columns_config(&props.column_name); + let on_change = { + let state = props.clone(); + let column_name = props.column_name.clone(); + let revision = revision.clone(); + yew::Callback::from(move |config: crate::config::ColumnConfigFieldUpdate| { + send_plugin_config( + &state.session, + &state.renderer, + &state.presentation, + &column_name, + config, + ); + revision.set(*revision + 1); + }) + }; + + fn deser_sub( + raw: &Option>, + ) -> Option { + raw.as_ref() + .and_then(|m| serde_json::from_value::(serde_json::Value::Object(m.clone())).ok()) } -} -#[function_component] -pub fn StyleTab(props: &StyleTabProps) -> Html { - let config = props.presentation.get_columns_config(&props.column_name); - let on_change = yew::use_callback( - (props.clone(), props.column_name.clone()), - |config, (state, column_name)| { - state.send_plugin_config(column_name, config); - }, - ); - - let components = get_column_style_control_options( + let components = get_column_config_schema( &props.renderer, &props.view_config, &props.metadata, &props.column_name, + raw_config.as_ref(), + abs_max, ) - .map(|opts| { - let mut components = vec![]; - // Aggregate Depth only applies when group_rollup_mode is Rollup — - // Flat emits only leaves (depth == group_by.len()) and Total emits - // only the grand-total (depth 0), so no depth is selectable. The - // stored value persists in ColumnConfigValues across mode changes - // and resurfaces when the user returns to Rollup. - let is_rollup = props.view_config.group_rollup_mode == GroupRollupMode::Rollup; - if !props.view_config.group_by.is_empty() && is_rollup { - let aggregate_depth = config.as_ref().map(|x| x.aggregate_depth as f64); - components.push(("Aggregate Depth", html! { - - })); - } - - if let Some(default_config) = opts.datagrid_number_style { - let config = config - .as_ref() - .map(|config| config.datagrid_number_style.clone()); - - components.push(("Number Styles", html! { - - })); - } - if let Some(default_config) = opts.number_series_style { - let config = config - .as_ref() - .map(|config| config.number_series_style.clone()); - - components.push(("Chart Type", html! { - - })); - } - if let Some(default_config) = opts.datagrid_string_style { - let config = config - .as_ref() - .map(|config| config.datagrid_string_style.clone()); - - components.push(("String Styles", html! { - - })); - } + .map(|schema| { + schema + .fields + .into_iter() + .filter_map(|spec| { + let keys: Vec = spec + .serialized_keys() + .into_iter() + .map(|s| s.to_string()) + .collect(); + let component = match spec { + ControlSpec::AggregateDepth => { + let aggregate_depth = raw_config + .as_ref() + .and_then(|m| m.get("aggregate_depth")) + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32; + html! { + + } + }, + ControlSpec::NumberSeriesStyle { + default: default_config, + } => { + let config: Option = deser_sub(&raw_config); + html! { + + } + }, + ControlSpec::DatetimeFormat { + default: default_config, + } => { + let config: Option = deser_sub(&raw_config); + let enable_time_config = props.ty.unwrap() == ColumnType::Datetime; + html! { + + } + }, + ControlSpec::StringFormat { + default: default_config, + } => { + let config: Option = deser_sub(&raw_config); + html! { + + } + }, + ControlSpec::Symbols { + default: default_config, + } => { + let restored_config: HashMap = raw_config + .as_ref() + .and_then(|m| m.get("symbols")) + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default(); - if let Some(default_config) = opts.datagrid_datetime_style { - let config = config - .as_ref() - .map(|config| config.datagrid_datetime_style.clone()); - - let enable_time_config = props.ty.unwrap() == ColumnType::Datetime; - components.push(("Datetime Styles", html! { - - })) - } + html! { + + } + }, + ControlSpec::NumberFormat => { + let restored_config: CustomNumberFormatConfig = raw_config + .as_ref() + .and_then(|m| m.get("number_format")) + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default(); - if let Some(default_config) = opts.symbols { - let restored_config = config - .as_ref() - .map(|config| config.symbols.clone()) - .unwrap_or_default(); - - components.push(("Symbols", html! { - - })) - } + html! { + + } + }, + ControlSpec::Enum { + key, + label, + variants, + default, + } => { + let current = raw_config + .as_ref() + .and_then(|m| m.get(&key)) + .and_then(|v| v.as_str().map(|s| s.to_string())); - if opts.number_string_format.unwrap_or_default() { - let restored_config = config - .as_ref() - .and_then(|config| config.number_format.clone()) - .unwrap_or_default(); - - components.push(("Number Formatting", html! { - - })); - } + html! { + + } + }, + ControlSpec::Bool { + key, + label, + default, + } => { + let current = raw_config + .as_ref() + .and_then(|m| m.get(&key)) + .and_then(|v| v.as_bool()); + html! { + + } + }, + ControlSpec::Color { + key, + label, + default, + } => { + let current = raw_config + .as_ref() + .and_then(|m| m.get(&key)) + .and_then(|v| v.as_str().map(|s| s.to_string())); + html! { + + } + }, + ControlSpec::ColorRange { + key_pos, + key_neg, + label, + default_pos, + default_neg, + is_gradient, + } => { + let current_pos = raw_config + .as_ref() + .and_then(|m| m.get(&key_pos)) + .and_then(|v| v.as_str().map(|s| s.to_string())); + let current_neg = raw_config + .as_ref() + .and_then(|m| m.get(&key_neg)) + .and_then(|v| v.as_str().map(|s| s.to_string())); + html! { + + } + }, + ControlSpec::Number { + key, + label, + default, + min, + max, + step, + include, + } => { + let current = raw_config + .as_ref() + .and_then(|m| m.get(&key)) + .and_then(|v| v.as_f64()); + html! { + + } + }, + // String primitive has no caller yet — wire when a + // plugin emits one. + ControlSpec::String { .. } => { + return None; + }, + }; - components - .into_iter() - .map(|(_title, component)| { - html! { -
- // { title } - { component } -
- } + Some(html! {
{ component }
}) }) .collect_vec() }) diff --git a/rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab/agg_depth_selector.rs b/rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab/agg_depth_selector.rs index dc4943d950..16e34d26b3 100644 --- a/rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab/agg_depth_selector.rs +++ b/rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab/agg_depth_selector.rs @@ -14,17 +14,19 @@ use perspective_client::clone; use yew::{Callback, Html, Properties, function_component, html}; use crate::components::form::number_field::NumberField; -use crate::config::ColumnConfigValueUpdate; +use crate::config::ColumnConfigFieldUpdate; // ░░░█▀█░█▀▄░█▀█░█▀█░█▀▀░█▀▄░▀█▀░▀█▀░█▀▀░█▀▀░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ // ░░░█▀▀░█▀▄░█░█░█▀▀░█▀▀░█▀▄░░█░░░█░░█▀▀░▀▀█░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ // ░░░▀░░░▀░▀░▀▀▀░▀░░░▀▀▀░▀░▀░░▀░░▀▀▀░▀▀▀░▀▀▀░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ #[derive(Properties, PartialEq)] pub struct AggregateDepthSelectorProps { - pub on_change: Callback, + pub on_change: Callback, pub value: u32, pub group_by_depth: u32, pub column_name: String, + #[prop_or_default] + pub keys: Vec, } #[function_component] @@ -36,12 +38,18 @@ pub fn AggregateDepthSelector(props: &AggregateDepthSelectorProps) -> Html { }); let on_change = yew::use_callback( - (state.setter(), props.on_change.clone()), + (state.setter(), props.on_change.clone(), props.keys.clone()), |x: Option, deps| { - deps.0.set(x.unwrap_or_default() as u32); - deps.1.emit(ColumnConfigValueUpdate::AggregateDepth( - x.unwrap_or_default() as u32, - )) + let depth = x.unwrap_or_default() as u32; + deps.0.set(depth); + let mut value = serde_json::Map::new(); + if depth > 0 { + value.insert("aggregate_depth".to_owned(), serde_json::json!(depth)); + } + deps.1.emit(ColumnConfigFieldUpdate { + keys: deps.2.clone(), + value, + }) }, ); diff --git a/rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab/primitive_field.rs b/rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab/primitive_field.rs new file mode 100644 index 0000000000..5c40d6099a --- /dev/null +++ b/rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab/primitive_field.rs @@ -0,0 +1,336 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +//! Schema-driven generic widgets for the Style tab. Each widget renders a +//! single primitive [`crate::config::ControlSpec`] variant and emits a +//! [`crate::config::ColumnConfigFieldUpdate`] on change. Built on top of +//! the existing form components ([`Select`], [`OptionalField`], +//! [`ColorSelector`]) so that they visually match the rich Yew widgets in +//! the same sidebar. + +use std::rc::Rc; + +use itertools::Itertools; +use serde_json::Value; +use wasm_bindgen::JsCast; +use web_sys::HtmlInputElement; +use yew::{Callback, Html, Properties, function_component, html, use_callback}; + +use crate::components::containers::select::{Select, SelectItem}; +use crate::components::form::color_range_selector::ColorRangeSelector; +use crate::components::form::color_selector::ColorSelector; +use crate::components::form::number_field::NumberField; +use crate::components::form::optional_field::OptionalField; +use crate::config::{ColumnConfigFieldUpdate, EnumVariant}; + +fn emit(on_change: &Callback, key: &str, value: Option) { + let mut map = serde_json::Map::new(); + if let Some(v) = value { + map.insert(key.to_owned(), v); + } + + on_change.emit(ColumnConfigFieldUpdate { + keys: vec![key.to_owned()], + value: map, + }); +} + +#[derive(Properties, PartialEq)] +pub struct EnumFieldProps { + pub field_key: String, + pub label: String, + pub variants: Vec, + pub default: String, + pub current: Option, + pub on_change: Callback, +} + +#[function_component] +pub fn EnumField(props: &EnumFieldProps) -> Html { + let selected = props + .current + .clone() + .unwrap_or_else(|| props.default.clone()); + + let checked = selected != props.default; + let values: Rc>> = Rc::new( + props + .variants + .iter() + .map(|v| SelectItem::Option(v.value.clone())) + .collect_vec(), + ); + + let on_select = { + let key = props.field_key.clone(); + let default = props.default.clone(); + let on_change = props.on_change.clone(); + Callback::from(move |value: String| { + if value == default { + emit(&on_change, &key, None); + } else { + emit(&on_change, &key, Some(Value::String(value))); + } + }) + }; + + let on_reset = { + let key = props.field_key.clone(); + let on_change = props.on_change.clone(); + Callback::from(move |_| emit(&on_change, &key, None)) + }; + + html! { +
+ + {values} {selected} {on_select} /> + +
+ } +} + +#[derive(Properties, PartialEq)] +pub struct BoolFieldProps { + pub field_key: String, + pub label: String, + pub default: bool, + pub current: Option, + pub on_change: Callback, +} + +#[function_component] +pub fn BoolField(props: &BoolFieldProps) -> Html { + let current = props.current.unwrap_or(props.default); + let oninput = { + let key = props.field_key.clone(); + let default = props.default; + let on_change = props.on_change.clone(); + use_callback((), move |e: yew::events::InputEvent, _| { + let target: HtmlInputElement = e.target().unwrap().unchecked_into(); + let next = target.checked(); + if next == default { + emit(&on_change, &key, None); + } else { + emit(&on_change, &key, Some(Value::Bool(next))); + } + }) + }; + + html! { +
+
+ } +} + +#[derive(Properties, PartialEq)] +pub struct NumberFieldPrimitiveProps { + pub field_key: String, + pub label: String, + pub default: f64, + pub current: Option, + pub on_change: Callback, + + #[prop_or_default] + pub include: Option, + + #[prop_or_default] + pub min: Option, + + #[prop_or_default] + pub max: Option, + + #[prop_or_default] + pub step: Option, +} + +#[function_component] +pub fn NumberFieldPrimitive(props: &NumberFieldPrimitiveProps) -> Html { + let on_change_inner = { + let key = props.field_key.clone(); + let default = props.default; + let on_change = props.on_change.clone(); + let include = props.include; + Callback::from(move |value: Option| match value { + Some(v) if include.unwrap_or_default() || v != default => emit( + &on_change, + &key, + Some( + serde_json::Number::from_f64(v) + .map(Value::Number) + .unwrap_or(Value::Null), + ), + ), + _ => emit(&on_change, &key, None), + }) + }; + + html! { + + } +} + +#[derive(Properties, PartialEq)] +pub struct ColorRangeFieldProps { + pub field_key_pos: String, + pub field_key_neg: String, + pub label: String, + pub default_pos: String, + pub default_neg: String, + pub current_pos: Option, + pub current_neg: Option, + pub is_gradient: bool, + pub on_change: Callback, +} + +#[function_component] +pub fn ColorRangeField(props: &ColorRangeFieldProps) -> Html { + let pos = props + .current_pos + .clone() + .unwrap_or_else(|| props.default_pos.clone()); + let neg = props + .current_neg + .clone() + .unwrap_or_else(|| props.default_neg.clone()); + let is_modified = (props.current_pos.is_some() + && props.current_pos.as_deref() != Some(props.default_pos.as_str())) + || (props.current_neg.is_some() + && props.current_neg.as_deref() != Some(props.default_neg.as_str())); + + // Multi-key emit: write whichever side(s) differ from default, + // clear the others. Mirrors the apply semantics of + // `ColumnConfigFieldUpdate { keys, value }` with both keys owned. + let emit_pair = { + let key_pos = props.field_key_pos.clone(); + let key_neg = props.field_key_neg.clone(); + let default_pos = props.default_pos.clone(); + let default_neg = props.default_neg.clone(); + let on_change = props.on_change.clone(); + move |new_pos: String, new_neg: String| { + let mut value = serde_json::Map::new(); + if new_pos != default_pos { + value.insert(key_pos.clone(), Value::String(new_pos)); + } + if new_neg != default_neg { + value.insert(key_neg.clone(), Value::String(new_neg)); + } + on_change.emit(ColumnConfigFieldUpdate { + keys: vec![key_pos.clone(), key_neg.clone()], + value, + }); + } + }; + + let on_pos_color = { + let neg = neg.clone(); + let emit_pair = emit_pair.clone(); + Callback::from(move |new_pos: String| emit_pair(new_pos, neg.clone())) + }; + + let on_neg_color = { + let pos = pos.clone(); + let emit_pair = emit_pair.clone(); + Callback::from(move |new_neg: String| emit_pair(pos.clone(), new_neg)) + }; + + let on_reset = { + let key_pos = props.field_key_pos.clone(); + let key_neg = props.field_key_neg.clone(); + let on_change = props.on_change.clone(); + Callback::from(move |_| { + on_change.emit(ColumnConfigFieldUpdate { + keys: vec![key_pos.clone(), key_neg.clone()], + value: serde_json::Map::new(), + }); + }) + }; + + html! { +
+ +
+ } +} + +#[derive(Properties, PartialEq)] +pub struct ColorFieldProps { + pub field_key: String, + pub label: String, + pub default: String, + pub current: Option, + pub on_change: Callback, +} + +#[function_component] +pub fn ColorField(props: &ColorFieldProps) -> Html { + let color = props + .current + .clone() + .unwrap_or_else(|| props.default.clone()); + let is_modified = + props.current.as_deref() != Some(props.default.as_str()) && props.current.is_some(); + + let on_color = { + let key = props.field_key.clone(); + let default = props.default.clone(); + let on_change = props.on_change.clone(); + Callback::from(move |value: String| { + if value == default { + emit(&on_change, &key, None); + } else { + emit(&on_change, &key, Some(Value::String(value))); + } + }) + }; + + let on_reset = { + let key = props.field_key.clone(); + let on_change = props.on_change.clone(); + Callback::from(move |_| emit(&on_change, &key, None)) + }; + + html! { +
+ +
+ } +} diff --git a/rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab/symbol.rs b/rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab/symbol.rs index c0b9f828df..8b69dac757 100644 --- a/rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab/symbol.rs +++ b/rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab/symbol.rs @@ -25,7 +25,7 @@ use yew::{Callback, Html, Properties, html}; use crate::components::column_settings_sidebar::style_tab::symbol::symbol_pairs::PairsList; use crate::components::filter_dropdown::{FilterDropDownElement, FilterDropDownPortal}; use crate::components::style::LocalStyle; -use crate::config::{ColumnConfigValueUpdate, KeyValueOpts, SymbolKVPair}; +use crate::config::{ColumnConfigFieldUpdate, KeyValueOpts, SymbolKVPair}; use crate::css; use crate::session::Session; @@ -34,10 +34,12 @@ pub struct SymbolAttrProps { pub session: Session, pub column_name: String, pub restored_config: Option>, - pub on_change: Callback, + pub on_change: Callback, pub default_config: KeyValueOpts, /// Selected theme name, threaded for PortalModal consumers. pub selected_theme: Option, + #[prop_or_default] + pub keys: Vec, } impl SymbolAttrProps { pub fn next_default_symbol(&self, pairs_len: usize) -> String { @@ -92,10 +94,17 @@ impl yew::Component for SymbolStyle { .into_iter() .filter_map(|pair| Some((pair.key?, pair.value))) .collect::>(); - let update = Some(symbols).filter(|x| !x.is_empty()); - ctx.props() - .on_change - .emit(ColumnConfigValueUpdate::Symbols(update)); + let mut value = serde_json::Map::new(); + if !symbols.is_empty() { + value.insert( + "symbols".to_owned(), + serde_json::to_value(&symbols).unwrap_or(serde_json::Value::Null), + ); + } + ctx.props().on_change.emit(ColumnConfigFieldUpdate { + keys: ctx.props().keys.clone(), + value, + }); let has_last_key = new_pairs .last() diff --git a/rust/perspective-viewer/src/rust/components/containers/split_panel.rs b/rust/perspective-viewer/src/rust/components/containers/split_panel.rs index 02ee1e86cd..abac636c2e 100644 --- a/rust/perspective-viewer/src/rust/components/containers/split_panel.rs +++ b/rust/perspective-viewer/src/rust/components/containers/split_panel.rs @@ -20,7 +20,7 @@ use yew::html::Scope; use yew::prelude::*; use crate::components::style::LocalStyle; -use crate::{css, maybe}; +use crate::css; #[derive(Properties, Default)] pub struct SplitPanelProps { @@ -359,14 +359,14 @@ impl Drop for ResizingState { /// `body`. Without this, the `Closure` objects would not leak, but the /// document will continue to call them, causing runtime exceptions. fn drop(&mut self) { - let result: ApiResult<()> = maybe! { + let result: ApiResult<()> = (|| { let mousemove = self.mousemove.as_ref().unchecked_ref(); global::body().remove_event_listener_with_callback("mousemove", mousemove)?; let mouseup = self.mouseup.as_ref().unchecked_ref(); global::body().remove_event_listener_with_callback("mouseup", mouseup)?; self.release_cursor()?; Ok(()) - }; + })(); result.expect("Drop failed") } diff --git a/rust/perspective-viewer/src/rust/components/copy_dropdown.rs b/rust/perspective-viewer/src/rust/components/copy_dropdown.rs index 77db116142..fca29478af 100644 --- a/rust/perspective-viewer/src/rust/components/copy_dropdown.rs +++ b/rust/perspective-viewer/src/rust/components/copy_dropdown.rs @@ -15,8 +15,8 @@ use std::rc::Rc; use yew::prelude::*; use super::containers::dropdown_menu::*; +use crate::config::*; use crate::renderer::*; -use crate::tasks::*; type CopyDropDownMenuItem = DropDownMenuItem; diff --git a/rust/perspective-viewer/src/rust/components/datetime_column_style.rs b/rust/perspective-viewer/src/rust/components/datetime_column_style.rs index 52494d5ba3..3a69a6e86f 100644 --- a/rust/perspective-viewer/src/rust/components/datetime_column_style.rs +++ b/rust/perspective-viewer/src/rust/components/datetime_column_style.rs @@ -21,19 +21,19 @@ use perspective_js::json; use perspective_js::utils::global::navigator; use wasm_bindgen::prelude::*; use yew::prelude::*; -use yew::*; -use super::form::color_selector::*; use super::modal::{ModalLink, SetModalLink}; use super::style::LocalStyle; use crate::components::datetime_column_style::custom::DatetimeStyleCustom; use crate::components::datetime_column_style::simple::DatetimeStyleSimple; -use crate::components::form::select_enum_field::SelectEnumField; use crate::components::form::select_value_field::SelectValueField; use crate::config::*; use crate::css; use crate::utils::WeakScope; +/// Format-only widget for `datetime` columns. Renders the `date_format` +/// hierarchy (Simple|Custom + timezone); color/color-mode UI is provided +/// externally as primitive `Enum` + `Color` schema fields. #[derive(Properties, Derivative)] #[derivative(Debug)] pub struct DatetimeColumnStyleProps { @@ -42,7 +42,10 @@ pub struct DatetimeColumnStyleProps { pub default_config: DatetimeColumnStyleDefaultConfig, #[prop_or_default] - pub on_change: Callback, + pub on_change: Callback, + + #[prop_or_default] + pub keys: Vec, #[prop_or_default] #[derivative(Debug = "ignore")] @@ -67,16 +70,11 @@ pub enum DatetimeColumnStyleMsg { SimpleDatetimeStyleConfigChanged(SimpleDatetimeStyleConfig), CustomDatetimeStyleConfigChanged(CustomDatetimeStyleConfig), TimezoneChanged(Option), - ColorModeChanged(Option), - ColorChanged(String), - ColorReset, } -/// Column style controls for the `datetime` type. #[derive(Debug)] pub struct DatetimeColumnStyle { config: DatetimeColumnStyleConfig, - default_config: DatetimeColumnStyleDefaultConfig, } impl Component for DatetimeColumnStyle { @@ -87,11 +85,9 @@ impl Component for DatetimeColumnStyle { ctx.set_modal_link(); Self { config: ctx.props().config.clone().unwrap_or_default(), - default_config: ctx.props().default_config.clone(), } } - // Always re-render when config changes. fn changed(&mut self, ctx: &Context, old: &Self::Properties) -> bool { let mut rerender = false; let mut new_config = ctx.props().config.clone().unwrap_or_default(); @@ -105,7 +101,6 @@ impl Component for DatetimeColumnStyle { rerender } - // TODO could be more conservative here with re-rendering fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { match msg { DatetimeColumnStyleMsg::TimezoneChanged(val) => { @@ -118,17 +113,6 @@ impl Component for DatetimeColumnStyle { self.dispatch_config(ctx); true }, - DatetimeColumnStyleMsg::ColorModeChanged(mode) => { - self.config.datetime_color_mode = mode.unwrap_or_default(); - self.dispatch_config(ctx); - true - }, - DatetimeColumnStyleMsg::ColorChanged(color) => { - self.config.color = Some(color); - self.dispatch_config(ctx); - true - }, - DatetimeColumnStyleMsg::SimpleDatetimeStyleConfigChanged(simple) => { self.config.date_format = DatetimeFormatType::Simple(simple); self.dispatch_config(ctx); @@ -139,40 +123,14 @@ impl Component for DatetimeColumnStyle { self.dispatch_config(ctx); true }, - DatetimeColumnStyleMsg::ColorReset => { - self.config.color = Some(self.default_config.color.clone()); - self.dispatch_config(ctx); - true - }, } } fn view(&self, ctx: &Context) -> Html { - let selected_color_mode = self.config.datetime_color_mode; - let color_mode_changed = ctx - .link() - .callback(DatetimeColumnStyleMsg::ColorModeChanged); - - let color_controls = match selected_color_mode { - DatetimeColorMode::None => html! {}, - DatetimeColorMode::Foreground => { - self.color_select_row(ctx, &DatetimeColorMode::Foreground, "foreground-label") - }, - DatetimeColorMode::Background => { - self.color_select_row(ctx, &DatetimeColorMode::Background, "background-label") - }, - }; - html! { <>
- - label="color" - on_change={color_mode_changed} - current_value={selected_color_mode} - /> - { color_controls } if ctx.props().enable_time_config { label="timezone" @@ -251,35 +209,18 @@ static USER_TIMEZONE: LazyLock = LazyLock::new(|| { impl DatetimeColumnStyle { /// When this config has changed, we must signal the wrapper element. fn dispatch_config(&self, ctx: &Context) { - let update = - Some(self.config.clone()).filter(|x| x != &DatetimeColumnStyleConfig::default()); - ctx.props() - .on_change - .emit(ColumnConfigValueUpdate::DatagridDatetimeStyle(update)); - } - - /// Generate a color selector component for a specific `StringColorMode` - /// variant. - fn color_select_row(&self, ctx: &Context, mode: &DatetimeColorMode, title: &str) -> Html { - let on_color = ctx.link().callback(DatetimeColumnStyleMsg::ColorChanged); - let color = self - .config - .color - .clone() - .unwrap_or_else(|| self.default_config.color.to_owned()); + let value = if self.config == DatetimeColumnStyleConfig::default() { + serde_json::Map::new() + } else { + match serde_json::to_value(&self.config) { + Ok(serde_json::Value::Object(m)) => m, + _ => serde_json::Map::new(), + } + }; - let color_props = props!(ColorProps { - title: title.to_owned(), - on_color, - is_modified: color != self.default_config.color, - color, - on_reset: ctx.link().callback(|_| DatetimeColumnStyleMsg::ColorReset) + ctx.props().on_change.emit(ColumnConfigFieldUpdate { + keys: ctx.props().keys.clone(), + value, }); - - if &self.config.datetime_color_mode == mode { - html! {
} - } else { - html! {} - } } } diff --git a/rust/perspective-viewer/src/rust/components/editable_header.rs b/rust/perspective-viewer/src/rust/components/editable_header.rs index 93049fc6fc..29f974799a 100644 --- a/rust/perspective-viewer/src/rust/components/editable_header.rs +++ b/rust/perspective-viewer/src/rust/components/editable_header.rs @@ -18,7 +18,6 @@ use yew::{Callback, Component, Html, NodeRef, Properties, TargetCast, classes, h use super::type_icon::TypeIconType; use crate::components::type_icon::TypeIcon; -use crate::maybe; use crate::session::{Session, SessionMetadataRc}; #[derive(Clone, PartialEq, Properties)] @@ -103,7 +102,7 @@ impl Component for EditableHeader { let maybe_value = (!new_value.is_empty()).then_some(new_value.clone()); self.edited = ctx.props().initial_value != maybe_value; - self.valid = maybe!({ + self.valid = (|| -> Option { if maybe_value .as_ref() .map(|v| v == &self.placeholder) @@ -122,7 +121,7 @@ impl Component for EditableHeader { .chain(expressions) .contains(&new_value); Some(!found) - }) + })() .unwrap_or(true); self.value.clone_from(&maybe_value); diff --git a/rust/perspective-viewer/src/rust/components/export_dropdown.rs b/rust/perspective-viewer/src/rust/components/export_dropdown.rs index d7ee7533b2..68d92acf36 100644 --- a/rust/perspective-viewer/src/rust/components/export_dropdown.rs +++ b/rust/perspective-viewer/src/rust/components/export_dropdown.rs @@ -15,9 +15,9 @@ use std::rc::Rc; use yew::prelude::*; use super::containers::dropdown_menu::*; +use crate::config::*; use crate::renderer::*; use crate::session::Session; -use crate::tasks::*; pub type ExportDropDownMenuItem = DropDownMenuItem; diff --git a/rust/perspective-viewer/src/rust/components/expression_editor.rs b/rust/perspective-viewer/src/rust/components/expression_editor.rs index 6b072dbd71..861226e29a 100644 --- a/rust/perspective-viewer/src/rust/components/expression_editor.rs +++ b/rust/perspective-viewer/src/rust/components/expression_editor.rs @@ -79,7 +79,7 @@ impl Component for ExpressionEditor { self.expr = val.clone(); clone!(ctx.props().session); ctx.link().send_future(async move { - match session.validate_expr(&val).await { + match crate::queries::validate_expr(&session, &val).await { Ok(x) => ExpressionEditorMsg::ValidateComplete(x), Err(err) => { web_sys::console::error_1(&format!("{err:?}").into()); @@ -93,7 +93,7 @@ impl Component for ExpressionEditor { ExpressionEditorMsg::ValidateComplete(err) => { self.error = err; if self.error.is_none() { - maybe!({ + let _: Option = try { let alias = ctx.props().alias.as_ref()?; let session = &ctx.props().session; let old = ctx.props().metadata.get_expression_by_alias(alias)?; @@ -102,8 +102,8 @@ impl Component for ExpressionEditor { .metadata_mut() .set_edit_by_alias(alias, self.expr.to_string()); - Some(is_edited) - }); + is_edited + }; ctx.props().on_validate.emit(true); } else { diff --git a/rust/perspective-viewer/src/rust/components/filter_dropdown.rs b/rust/perspective-viewer/src/rust/components/filter_dropdown.rs index e1b9ecd7c0..6f9132932c 100644 --- a/rust/perspective-viewer/src/rust/components/filter_dropdown.rs +++ b/rust/perspective-viewer/src/rust/components/filter_dropdown.rs @@ -103,7 +103,8 @@ impl FilterDropDownElement { old_column = self.column ); ApiFuture::spawn(async move { - let fetched = session.get_column_values(column.1.clone()).await?; + let fetched = + crate::queries::get_column_values(&session, column.1.clone()).await?; *all_values.borrow_mut() = Some(fetched); let values = filter_values(&input, &all_values, &exclude); let should_hide = values.len() == 1 && values[0] == input; diff --git a/rust/perspective-viewer/src/rust/components/form/debug.rs b/rust/perspective-viewer/src/rust/components/form/debug.rs index 2e1c7bf7b2..4e96aaf84b 100644 --- a/rust/perspective-viewer/src/rust/components/form/debug.rs +++ b/rust/perspective-viewer/src/rust/components/form/debug.rs @@ -25,7 +25,6 @@ use crate::js::{MimeType, copy_to_clipboard, paste_from_clipboard}; use crate::presentation::*; use crate::renderer::*; use crate::session::*; -use crate::tasks::*; use crate::utils::*; #[derive(Clone, PartialEq, Properties)] @@ -35,24 +34,6 @@ pub struct DebugPanelProps { pub session: Session, } -impl HasPresentation for DebugPanelProps { - fn presentation(&self) -> &Presentation { - &self.presentation - } -} - -impl HasRenderer for DebugPanelProps { - fn renderer(&self) -> &Renderer { - &self.renderer - } -} - -impl HasSession for DebugPanelProps { - fn session(&self) -> &Session { - &self.session - } -} - #[function_component(DebugPanel)] pub fn debug_panel(props: &DebugPanelProps) -> Html { let expr = use_state_eq(|| Rc::new("".to_string())); @@ -203,7 +184,12 @@ impl DebugPanelProps { fn set_text(&self, setter: UseStateSetter>) { let props = self.clone(); ApiFuture::spawn(async move { - let config = props.get_viewer_config().await?; + let config = crate::queries::get_viewer_config( + &props.session, + &props.renderer, + &props.presentation, + ) + .await?; let json = JsValue::from_serde_ext(&config)?; let js_string = js_sys::JSON::stringify_with_replacer_and_space(&json, &JsValue::NULL, &2.into())?; @@ -238,7 +224,15 @@ impl DebugPanelProps { ApiFuture::spawn(async move { match serde_json::from_str(&text) { Ok(config) => { - match props.restore_and_render(config, async { Ok(()) }).await { + match crate::tasks::restore_and_render( + &props.session, + &props.renderer, + &props.presentation, + config, + async { Ok(()) }, + ) + .await + { Ok(_) => { modified.set(false); }, diff --git a/rust/perspective-viewer/src/rust/components/main_panel.rs b/rust/perspective-viewer/src/rust/components/main_panel.rs index beb50ec280..c9a71adb03 100644 --- a/rust/perspective-viewer/src/rust/components/main_panel.rs +++ b/rust/perspective-viewer/src/rust/components/main_panel.rs @@ -16,11 +16,9 @@ use yew::prelude::*; use super::render_warning::RenderWarning; use super::status_bar::StatusBar; -use crate::custom_events::CustomEvents; -use crate::presentation::Presentation; -use crate::renderer::limits::RenderLimits; +use crate::presentation::{Presentation, PresentationProps}; use crate::renderer::*; -use crate::session::{Session, TableErrorState, TableLoadState, ViewStats}; +use crate::session::{Session, SessionProps}; use crate::utils::*; #[derive(Clone, Properties)] @@ -32,28 +30,19 @@ pub struct MainPanelProps { /// + column configs), `false` for config-only. pub on_reset: Callback, - /// Render-limit dimensions forwarded from the root's `RendererProps`. - /// `Some` when the active plugin is capping the rendered row/column count; - /// `None` when no limits are active (e.g. after a plugin change). - pub render_limits: Option, + /// Snapshots threaded from root. Read for `has_table`, `title` here in + /// the panel itself; threaded wholesale to `StatusBar`/`StatusIndicator`. + pub session_props: SessionProps, + pub renderer_props: RendererProps, + pub presentation_props: PresentationProps, - /// Value props from root's `SessionProps`, threaded to `StatusBar` / - /// `StatusIndicator`. - pub has_table: Option, - pub is_errored: bool, - pub stats: Option, - pub update_count: u32, - pub error: Option, - pub title: Option, - - /// Value props from root's `PresentationProps`, threaded to `StatusBar`. + /// Derived from root: `settings_open && has_table_loaded`. pub is_settings_open: bool, - pub selected_theme: Option, - pub available_themes: PtrEqRc>, - pub is_workspace: bool, + + /// Root-managed in-flight render counter (not engine state). + pub update_count: u32, /// State - pub custom_events: CustomEvents, pub session: Session, pub renderer: Renderer, pub presentation: Presentation, @@ -61,23 +50,17 @@ pub struct MainPanelProps { impl PartialEq for MainPanelProps { fn eq(&self, rhs: &Self) -> bool { - self.has_table == rhs.has_table - && self.is_errored == rhs.is_errored - && self.stats == rhs.stats - && self.update_count == rhs.update_count - && self.error == rhs.error - && self.title == rhs.title + self.session_props == rhs.session_props + && self.renderer_props == rhs.renderer_props + && self.presentation_props == rhs.presentation_props && self.is_settings_open == rhs.is_settings_open - && self.selected_theme == rhs.selected_theme - && self.available_themes == rhs.available_themes - && self.is_workspace == rhs.is_workspace - && self.render_limits == rhs.render_limits + && self.update_count == rhs.update_count } } impl MainPanelProps { fn is_title(&self) -> bool { - self.title.is_some() + self.session_props.title.is_some() } } @@ -109,10 +92,7 @@ impl Component for MainPanel { .cast::() .map(JsValue::from) { - ctx.props() - .custom_events - .dispatch_event(format!("statusbar-{}", event.type_()).as_str(), &event) - .unwrap(); + ctx.props().presentation.statusbar_pointer_event.emit(event); } false @@ -126,16 +106,13 @@ impl Component for MainPanel { fn view(&self, ctx: &Context) -> Html { let Self::Properties { - custom_events, presentation, renderer, session, .. } = ctx.props(); - let is_settings_open = ctx.props().is_settings_open - && matches!(ctx.props().has_table, Some(TableLoadState::Loaded)); - + let is_settings_open = ctx.props().is_settings_open; let on_settings = (!is_settings_open).then(|| ctx.props().on_settings.clone()); let mut class = classes!(); @@ -166,17 +143,10 @@ impl Component for MainPanel { id="status_bar" {on_settings} on_reset={ctx.props().on_reset.clone()} - has_table={ctx.props().has_table.clone()} - is_errored={ctx.props().is_errored} - stats={ctx.props().stats.clone()} - update_count={ctx.props().update_count} - error={ctx.props().error.clone()} - title={ctx.props().title.clone()} + session_props={ctx.props().session_props.clone()} + presentation_props={ctx.props().presentation_props.clone()} is_settings_open={ctx.props().is_settings_open} - selected_theme={ctx.props().selected_theme.clone()} - available_themes={ctx.props().available_themes.clone()} - is_workspace={ctx.props().is_workspace} - {custom_events} + update_count={ctx.props().update_count} {presentation} {renderer} {session} @@ -189,7 +159,7 @@ impl Component for MainPanel { >
diff --git a/rust/perspective-viewer/src/rust/components/mod.rs b/rust/perspective-viewer/src/rust/components/mod.rs index 227427436f..71b6f2ed4f 100644 --- a/rust/perspective-viewer/src/rust/components/mod.rs +++ b/rust/perspective-viewer/src/rust/components/mod.rs @@ -30,7 +30,6 @@ pub mod form; pub mod function_dropdown; pub mod main_panel; pub mod modal; -pub mod number_column_style; pub mod number_series_style; pub mod plugin_selector; pub mod portal; diff --git a/rust/perspective-viewer/src/rust/components/number_column_style.rs b/rust/perspective-viewer/src/rust/components/number_column_style.rs deleted file mode 100644 index 11b892b393..0000000000 --- a/rust/perspective-viewer/src/rust/components/number_column_style.rs +++ /dev/null @@ -1,491 +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). ┃ -// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - -use perspective_client::config::Scalar; -use yew::prelude::*; -use yew::*; - -use super::form::color_range_selector::*; -use super::form::number_field::NumberFieldProps; -use super::modal::*; -use super::style::LocalStyle; -use crate::components::form::number_field::NumberField; -use crate::components::form::select_enum_field::SelectEnumField; -use crate::config::*; -use crate::session::Session; -use crate::utils::*; -use crate::*; - -#[derive(PartialEq, Eq, Copy, Clone, Debug)] -pub enum Side { - Fg, - Bg, -} - -use Side::*; - -/// A `ColumnStyle` component is mounted to the window anchored at the screen -/// position of `elem`. -/// -/// It needs two input configs, the current configuration object and a default -/// version without `Option<>` -#[derive(Properties)] -pub struct NumberColumnStyleProps { - #[cfg_attr(test, prop_or_default)] - pub config: Option, - - #[cfg_attr(test, prop_or_default)] - pub default_config: NumberColumnStyleDefaultConfig, - - #[prop_or_default] - pub on_change: Callback, - - #[prop_or_default] - pub weak_link: WeakScope, - - #[prop_or_default] - pub column_name: Option, - - // State - pub session: Session, -} - -impl ModalLink for NumberColumnStyleProps { - fn weak_link(&self) -> &'_ WeakScope { - &self.weak_link - } -} - -impl PartialEq for NumberColumnStyleProps { - fn eq(&self, other: &Self) -> bool { - self.config == other.config - && self.default_config == other.default_config - && self.column_name == other.column_name - } -} - -fn scalar_to_f64(scalar: &Scalar) -> f64 { - match scalar { - Scalar::Float(x) => *x, - Scalar::String(x) => x.parse::().unwrap_or_default(), - Scalar::Bool(x) => { - if *x { - 1.0 - } else { - 0.0 - } - }, - Scalar::Null => 0.0, - } -} - -fn set_default_gradient(session: &Session, ctx: &Context) { - if let Some(column_name) = ctx.props().column_name.clone() { - let session = session.clone(); - ctx.link().send_future(async move { - let view = session.get_view().unwrap(); - let min_max = view.get_min_max(column_name).await.unwrap(); - let abs_max = scalar_to_f64(&min_max.0) - .abs() - .max(scalar_to_f64(&min_max.1).abs()); - - let gradient = (abs_max * 100.).round() / 100.; - NumberColumnStyleMsg::DefaultGradientChanged(gradient) - }); - } -} - -#[derive(Debug)] -pub enum NumberColumnStyleMsg { - PosColorChanged(Side, String), - NegColorChanged(Side, String), - NumberForeModeChanged(NumberForegroundMode), - NumberBackModeChanged(NumberBackgroundMode), - GradientChanged(Side, Option), - DefaultGradientChanged(f64), -} - -/// A column style form control for `number` columns. -pub struct NumberColumnStyle { - config: NumberColumnStyleConfig, - default_config: NumberColumnStyleDefaultConfig, - fg_mode: NumberForegroundMode, - bg_mode: NumberBackgroundMode, - pos_fg_color: String, - neg_fg_color: String, - pos_bg_color: String, - neg_bg_color: String, - fg_gradient: Option, - bg_gradient: Option, -} - -impl Component for NumberColumnStyle { - type Message = NumberColumnStyleMsg; - type Properties = NumberColumnStyleProps; - - fn create(ctx: &Context) -> Self { - ctx.set_modal_link(); - set_default_gradient(&ctx.props().session, ctx); - Self::reset( - &ctx.props().config.clone().unwrap_or_default(), - &ctx.props().default_config.clone(), - ) - } - - fn changed(&mut self, ctx: &Context, _old: &Self::Properties) -> bool { - let mut new = Self::reset( - &ctx.props().config.clone().unwrap_or_default(), - &ctx.props().default_config.clone(), - ); - - set_default_gradient(&ctx.props().session, ctx); - std::mem::swap(self, &mut new); - true - } - - fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { - match msg { - NumberColumnStyleMsg::PosColorChanged(side, val) => { - if side == Fg { - self.pos_fg_color = val; - self.config.pos_fg_color = Some(self.pos_fg_color.to_owned()); - } else { - self.pos_bg_color = val; - self.config.pos_bg_color = Some(self.pos_bg_color.to_owned()); - } - - self.dispatch_config(ctx); - true - }, - NumberColumnStyleMsg::NegColorChanged(side, val) => { - if side == Fg { - self.neg_fg_color = val; - self.config.neg_fg_color = Some(self.neg_fg_color.to_owned()); - } else { - self.neg_bg_color = val; - self.config.neg_bg_color = Some(self.neg_bg_color.to_owned()); - } - - self.dispatch_config(ctx); - true - }, - NumberColumnStyleMsg::NumberForeModeChanged(val) => { - self.fg_mode = val; - self.config.number_fg_mode = val; - if self.fg_mode.needs_gradient() { - self.config.fg_gradient = Some(self.fg_gradient.unwrap()); - } else { - self.config.fg_gradient = None; - } - - self.dispatch_config(ctx); - true - }, - NumberColumnStyleMsg::NumberBackModeChanged(val) => { - self.bg_mode = val; - self.config.number_bg_mode = val; - if self.bg_mode.needs_gradient() { - self.config.bg_gradient = Some(self.bg_gradient.unwrap()); - } else { - self.config.bg_gradient = None; - } - - self.dispatch_config(ctx); - true - }, - NumberColumnStyleMsg::GradientChanged(side, gradient) => { - match (side, gradient) { - (Fg, Some(x)) => { - self.fg_gradient = Some(x); - self.config.fg_gradient = Some(x); - }, - (Fg, None) => { - self.fg_gradient = Some(self.default_config.fg_gradient); - self.config.fg_gradient = None; - }, - (Bg, Some(x)) => { - self.bg_gradient = Some(x); - self.config.bg_gradient = Some(x); - }, - (Bg, None) => { - self.bg_gradient = Some(self.default_config.bg_gradient); - self.config.bg_gradient = None; - }, - }; - - self.dispatch_config(ctx); - false - }, - NumberColumnStyleMsg::DefaultGradientChanged(gradient) => { - self.fg_gradient.get_or_insert(gradient); - self.bg_gradient.get_or_insert(gradient); - self.default_config.fg_gradient = gradient; - self.default_config.bg_gradient = gradient; - true - }, - } - } - - fn view(&self, ctx: &Context) -> Html { - let fg_mode_changed = ctx.link().callback(|x: Option<_>| { - NumberColumnStyleMsg::NumberForeModeChanged(x.unwrap_or_default()) - }); - - let bg_mode_changed = ctx.link().callback(|x: Option<_>| { - NumberColumnStyleMsg::NumberBackModeChanged(x.unwrap_or_default()) - }); - - let fg_controls = match self.fg_mode { - NumberForegroundMode::Disabled => html! {}, - NumberForegroundMode::Color => html! { -
- -
- }, - NumberForegroundMode::Bar => html! { - <> -
- -
- - - }, - NumberForegroundMode::LabelBar => html! { - <> -
- -
- - - }, - }; - - let bg_controls = match self.bg_mode { - NumberBackgroundMode::Disabled => html! {}, - NumberBackgroundMode::Color => html! { -
- -
- }, - NumberBackgroundMode::Gradient => html! { - <> -
- -
- - - }, - NumberBackgroundMode::Pulse => html! { -
- -
- }, - }; - - html! { - <> - -
- - label="foreground" - on_change={fg_mode_changed} - current_value={self.fg_mode} - /> - { fg_controls } - - label="background" - on_change={bg_mode_changed} - current_value={self.bg_mode} - /> - { bg_controls } -
- - } - } -} - -impl NumberColumnStyle { - /// When this config has changed, we must signal the wrapper element. - fn dispatch_config(&self, ctx: &Context) { - let mut config = self.config.clone(); - match &self.config { - NumberColumnStyleConfig { - pos_fg_color: Some(pos_color), - neg_fg_color: Some(neg_color), - .. - } if *pos_color == self.default_config.pos_fg_color - && *neg_color == self.default_config.neg_fg_color => - { - config.pos_fg_color = None; - config.neg_fg_color = None; - }, - _ => {}, - }; - - match &self.config { - NumberColumnStyleConfig { - pos_bg_color: Some(pos_color), - neg_bg_color: Some(neg_color), - .. - } if *pos_color == self.default_config.pos_bg_color - && *neg_color == self.default_config.neg_bg_color => - { - config.pos_bg_color = None; - config.neg_bg_color = None; - }, - _ => {}, - }; - - let update = Some(config).filter(|config| config != &NumberColumnStyleConfig::default()); - - ctx.props() - .on_change - .emit(ColumnConfigValueUpdate::DatagridNumberStyle(update)); - } - - fn color_props( - &self, - id: &str, - side: Side, - is_gradient: bool, - ctx: &Context, - ) -> ColorRangeProps { - let on_pos_color = ctx - .link() - .callback(move |x| NumberColumnStyleMsg::PosColorChanged(side, x)); - let on_neg_color = ctx - .link() - .callback(move |x| NumberColumnStyleMsg::NegColorChanged(side, x)); - - let default_config = self.default_config.clone(); - - props!(ColorRangeProps { - id: id.to_string(), - is_gradient, - pos_color: if side == Fg { - &self.pos_fg_color - } else { - &self.pos_bg_color - } - .to_owned(), - neg_color: if side == Fg { - &self.neg_fg_color - } else { - &self.neg_bg_color - } - .to_owned(), - on_pos_color, - on_neg_color, - on_reset: ctx.link().batch_callback(move |_| if side == Fg { - vec![ - NumberColumnStyleMsg::PosColorChanged( - side, - default_config.pos_fg_color.clone(), - ), - NumberColumnStyleMsg::NegColorChanged( - side, - default_config.neg_fg_color.clone(), - ), - ] - } else { - vec![ - NumberColumnStyleMsg::PosColorChanged( - side, - default_config.pos_bg_color.clone(), - ), - NumberColumnStyleMsg::NegColorChanged( - side, - default_config.neg_bg_color.clone(), - ), - ] - }), - is_modified: if side == Fg { - self.pos_fg_color != self.default_config.pos_fg_color - || self.neg_fg_color != self.default_config.neg_fg_color - } else { - self.pos_bg_color != self.default_config.pos_bg_color - || self.neg_bg_color != self.default_config.neg_bg_color - }, - }) - } - - fn max_value_props(&self, side: Side, ctx: &Context) -> NumberFieldProps { - let on_change = ctx - .link() - .callback(move |x| NumberColumnStyleMsg::GradientChanged(side, x)); - - let value = if side == Fg { - self.fg_gradient.unwrap_or_default() - } else { - self.bg_gradient.unwrap_or_default() - }; - - props!(NumberFieldProps { - default: value, - current_value: value, - label: "max-value", - on_change - }) - } - - fn reset( - config: &NumberColumnStyleConfig, - default_config: &NumberColumnStyleDefaultConfig, - ) -> Self { - let mut config = config.clone(); - let fg_gradient = config.fg_gradient; - let bg_gradient = config.bg_gradient; - - let pos_fg_color = config - .pos_fg_color - .as_ref() - .unwrap_or(&default_config.pos_fg_color) - .to_owned(); - - let neg_fg_color = config - .neg_fg_color - .as_ref() - .unwrap_or(&default_config.neg_fg_color) - .to_owned(); - - let pos_bg_color = config - .pos_bg_color - .as_ref() - .unwrap_or(&default_config.pos_bg_color) - .to_owned(); - - let neg_bg_color = config - .neg_bg_color - .as_ref() - .unwrap_or(&default_config.neg_bg_color) - .to_owned(); - - config.pos_fg_color = Some(pos_fg_color.to_owned()); - config.neg_fg_color = Some(neg_fg_color.to_owned()); - let fg_mode = config.number_fg_mode; - config.pos_bg_color = Some(pos_bg_color.to_owned()); - config.neg_bg_color = Some(neg_bg_color.to_owned()); - let bg_mode = config.number_bg_mode; - Self { - config, - default_config: default_config.clone(), - fg_mode, - bg_mode, - pos_fg_color, - neg_fg_color, - pos_bg_color, - neg_bg_color, - fg_gradient, - bg_gradient, - } - } -} diff --git a/rust/perspective-viewer/src/rust/components/number_series_style.rs b/rust/perspective-viewer/src/rust/components/number_series_style.rs index 448c244c31..1c1b8bd092 100644 --- a/rust/perspective-viewer/src/rust/components/number_series_style.rs +++ b/rust/perspective-viewer/src/rust/components/number_series_style.rs @@ -26,7 +26,10 @@ pub struct NumberSeriesStyleProps { pub default_config: NumberSeriesStyleDefaultConfig, #[prop_or_default] - pub on_change: Callback, + pub on_change: Callback, + + #[prop_or_default] + pub keys: Vec, #[prop_or_default] weak_link: WeakScope, @@ -51,7 +54,7 @@ pub enum NumberSeriesStyleMsg { /// Form control for the per-column `chart_type` + `stack` picker. Rendered /// inside the column-settings sidebar when the active plugin returns a -/// `NumberSeriesStyleDefaultConfig` from its `column_style_controls` hook. +/// `ControlSpec::NumberSeriesStyle` from its `column_config_schema` hook. pub struct NumberSeriesStyle { config: NumberSeriesStyleConfig, } @@ -137,13 +140,22 @@ impl Component for NumberSeriesStyle { } impl NumberSeriesStyle { - /// Dispatch the current config as an update. When the config matches - /// the default (Bar + no stack override), send `None` so the field is - /// omitted entirely from the serialized `ColumnConfigValues`. + /// Dispatch the current config as an update. The default (Bar + no + /// stack override) round-trips as an empty JSON object via + /// `skip_serializing_if`, which means a field-level reset for this + /// schema field. fn dispatch_config(&self, ctx: &Context) { - let update = Some(self.config.clone()).filter(|c| c != &NumberSeriesStyleConfig::default()); - ctx.props() - .on_change - .emit(ColumnConfigValueUpdate::NumberSeriesStyle(update)); + let value = if self.config == NumberSeriesStyleConfig::default() { + serde_json::Map::new() + } else { + match serde_json::to_value(&self.config) { + Ok(serde_json::Value::Object(m)) => m, + _ => serde_json::Map::new(), + } + }; + ctx.props().on_change.emit(ColumnConfigFieldUpdate { + keys: ctx.props().keys.clone(), + value, + }); } } diff --git a/rust/perspective-viewer/src/rust/components/settings_panel.rs b/rust/perspective-viewer/src/rust/components/settings_panel.rs index 0d80dd96aa..90dfd69c7d 100644 --- a/rust/perspective-viewer/src/rust/components/settings_panel.rs +++ b/rust/perspective-viewer/src/rust/components/settings_panel.rs @@ -22,10 +22,10 @@ use crate::components::containers::sidebar_close_button::SidebarCloseButton; use crate::config::PluginUpdate; use crate::dragdrop::*; use crate::presentation::{ColumnLocator, OpenColumnSettings, Presentation}; +use crate::queries::can_render_column_styles; use crate::renderer::*; use crate::session::column_defaults_update::*; use crate::session::*; -use crate::tasks::can_render_column_styles; use crate::utils::*; #[derive(Clone, Properties)] diff --git a/rust/perspective-viewer/src/rust/components/status_bar.rs b/rust/perspective-viewer/src/rust/components/status_bar.rs index 66a1c7c825..2476485cc9 100644 --- a/rust/perspective-viewer/src/rust/components/status_bar.rs +++ b/rust/perspective-viewer/src/rust/components/status_bar.rs @@ -21,9 +21,9 @@ use crate::components::copy_dropdown::CopyDropDownMenu; use crate::components::export_dropdown::ExportDropDownMenu; use crate::components::portal::PortalModal; use crate::components::status_bar_counter::StatusBarRowsCounter; -use crate::custom_events::CustomEvents; +use crate::config::*; use crate::js::*; -use crate::presentation::Presentation; +use crate::presentation::{Presentation, PresentationProps}; use crate::renderer::*; use crate::session::*; use crate::tasks::*; @@ -42,27 +42,21 @@ pub struct StatusBarProps { #[prop_or_default] pub on_settings: Option>, - // Value props threaded from the root's `SessionProps`. - // Using these avoids PubSub subscriptions for table_loaded / table_errored. - pub has_table: Option, - pub is_errored: bool, - pub stats: Option, - /// In-flight render counter and full error, threaded to `StatusIndicator`. - pub update_count: u32, - pub error: Option, - /// Title string from session — threaded to avoid title_changed - /// subscription. - pub title: Option, - /// Theme state from presentation — threaded to avoid theme_config_updated / - /// visibility_changed subscriptions. + /// Snapshots threaded from root. Component reads `has_table`, `stats`, + /// `error`, `title` from session_props; `selected_theme`, + /// `available_themes`, `is_workspace` from presentation_props. + pub session_props: SessionProps, + pub presentation_props: PresentationProps, + + /// Derived from root: `settings_open && has_table_loaded`. Used + /// here to drive the title-input enabled state and the theme picker + /// visibility. pub is_settings_open: bool, - pub selected_theme: Option, - pub available_themes: PtrEqRc>, - /// Whether this viewer is hosted inside a ``. - pub is_workspace: bool, + + /// In-flight render counter, threaded to `StatusIndicator`. + pub update_count: u32, // State - pub custom_events: CustomEvents, pub session: Session, pub renderer: Renderer, pub presentation: Presentation, @@ -71,48 +65,10 @@ pub struct StatusBarProps { impl PartialEq for StatusBarProps { fn eq(&self, other: &Self) -> bool { self.id == other.id - && self.has_table == other.has_table - && self.is_errored == other.is_errored - && self.stats == other.stats - && self.update_count == other.update_count - && self.error == other.error - && self.title == other.title + && self.session_props == other.session_props + && self.presentation_props == other.presentation_props && self.is_settings_open == other.is_settings_open - && self.selected_theme == other.selected_theme - && self.available_themes == other.available_themes - && self.is_workspace == other.is_workspace - } -} - -impl HasCustomEvents for StatusBarProps { - fn custom_events(&self) -> &CustomEvents { - &self.custom_events - } -} - -impl HasPresentation for StatusBarProps { - fn presentation(&self) -> &Presentation { - &self.presentation - } -} - -impl HasRenderer for StatusBarProps { - fn renderer(&self) -> &Renderer { - &self.renderer - } -} - -impl HasSession for StatusBarProps { - fn session(&self) -> &Session { - &self.session - } -} - -impl StateProvider for StatusBarProps { - type State = StatusBarProps; - - fn clone_state(&self) -> Self::State { - self.clone() + && self.update_count == other.update_count } } @@ -155,7 +111,7 @@ impl Component for StatusBar { export_ref: NodeRef::default(), input_ref: NodeRef::default(), statusbar_ref: NodeRef::default(), - title: ctx.props().title.clone(), + title: ctx.props().session_props.title.clone(), copy_target: None, export_target: None, } @@ -165,119 +121,122 @@ impl Component for StatusBar { // Keep the local title in sync with the prop whenever the session title // changes externally (e.g. restore() call) or the settings panel opens / // closes (which resets the input element). - if ctx.props().title != old_props.title + if ctx.props().session_props.title != old_props.session_props.title || ctx.props().is_settings_open != old_props.is_settings_open { - self.title = ctx.props().title.clone(); + self.title = ctx.props().session_props.title.clone(); } true } fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { - maybe_log_or_default!(Ok(match msg { - StatusBarMsg::Reset(event) => { - let all = event.shift_key(); - ctx.props().on_reset.emit(all); - false - }, - StatusBarMsg::ResetTheme => { - let presentation = ctx.props().presentation.clone(); - let session = ctx.props().session.clone(); - let renderer = ctx.props().renderer.clone(); - ApiFuture::spawn(async move { - presentation.reset_theme().await?; - let view = session.get_view().into_apierror()?; - renderer.restyle_all(&view).await - }); - true - }, - StatusBarMsg::SetTheme(theme_name) => { - let presentation = ctx.props().presentation.clone(); - let session = ctx.props().session.clone(); - let renderer = ctx.props().renderer.clone(); - ApiFuture::spawn(async move { - presentation.set_theme_name(Some(&theme_name)).await?; - let view = session.get_view().into_apierror()?; - renderer.restyle_all(&view).await - }); - - false - }, - StatusBarMsg::Export => { - self.export_target = self.export_ref.cast::(); - true - }, - StatusBarMsg::Copy => { - self.copy_target = self.copy_ref.cast::(); - true - }, - StatusBarMsg::CloseExport => { - self.export_target = None; - true - }, - StatusBarMsg::CloseCopy => { - self.copy_target = None; - true - }, - StatusBarMsg::Eject => { - ctx.props().presentation().on_eject.emit(()); - false - }, - StatusBarMsg::Noop => { - self.title = ctx.props().title.clone(); - true - }, - StatusBarMsg::TitleInputEvent => { - let elem = self.input_ref.cast::().into_apierror()?; - let title = elem.value(); - let title = if title.trim().is_empty() { - None - } else { - Some(title) - }; - - self.title = title; - true - }, - StatusBarMsg::TitleChangeEvent => { - let elem = self.input_ref.cast::().into_apierror()?; - let title = elem.value(); - let title = if title.trim().is_empty() { - None - } else { - Some(title) - }; - - ctx.props().session().set_title(title); - false - }, - StatusBarMsg::PointerEvent(event) => { - if event.target().map(JsValue::from) - == self.statusbar_ref.cast::().map(JsValue::from) - { - ctx.props() - .custom_events() - .dispatch_event(format!("statusbar-{}", event.type_()).as_str(), &event)?; - } - - false - }, - })) + let r: ApiResult = (|| { + Ok(match msg { + StatusBarMsg::Reset(event) => { + let all = event.shift_key(); + ctx.props().on_reset.emit(all); + false + }, + StatusBarMsg::ResetTheme => { + let presentation = ctx.props().presentation.clone(); + let session = ctx.props().session.clone(); + let renderer = ctx.props().renderer.clone(); + ApiFuture::spawn(async move { + presentation.reset_theme().await?; + let view = session.get_view().into_apierror()?; + renderer.restyle_all(&view).await + }); + true + }, + StatusBarMsg::SetTheme(theme_name) => { + let presentation = ctx.props().presentation.clone(); + let session = ctx.props().session.clone(); + let renderer = ctx.props().renderer.clone(); + ApiFuture::spawn(async move { + presentation.set_theme_name(Some(&theme_name)).await?; + let view = session.get_view().into_apierror()?; + renderer.restyle_all(&view).await + }); + + false + }, + StatusBarMsg::Export => { + self.export_target = self.export_ref.cast::(); + true + }, + StatusBarMsg::Copy => { + self.copy_target = self.copy_ref.cast::(); + true + }, + StatusBarMsg::CloseExport => { + self.export_target = None; + true + }, + StatusBarMsg::CloseCopy => { + self.copy_target = None; + true + }, + StatusBarMsg::Eject => { + ctx.props().presentation.on_eject.emit(()); + false + }, + StatusBarMsg::Noop => { + self.title = ctx.props().session_props.title.clone(); + true + }, + StatusBarMsg::TitleInputEvent => { + let elem = self.input_ref.cast::().into_apierror()?; + let title = elem.value(); + let title = if title.trim().is_empty() { + None + } else { + Some(title) + }; + + self.title = title; + true + }, + StatusBarMsg::TitleChangeEvent => { + let elem = self.input_ref.cast::().into_apierror()?; + let title = elem.value(); + let title = if title.trim().is_empty() { + None + } else { + Some(title) + }; + + ctx.props().session.set_title(title); + false + }, + StatusBarMsg::PointerEvent(event) => { + if event.target().map(JsValue::from) + == self.statusbar_ref.cast::().map(JsValue::from) + { + ctx.props().presentation.statusbar_pointer_event.emit(event); + } + + false + }, + }) + })(); + r.unwrap_or_else(|e| { + web_sys::console::warn_1(&e.into()); + Default::default() + }) } fn view(&self, ctx: &Context) -> Html { let Self::Properties { - custom_events, presentation, renderer, session, .. } = ctx.props(); - let has_table = ctx.props().has_table.clone(); - let is_errored = ctx.props().is_errored; + let has_table = ctx.props().session_props.has_table.clone(); + let is_errored = ctx.props().session_props.is_errored(); let is_settings_open = ctx.props().is_settings_open; - let title = &ctx.props().title; + let title = &ctx.props().session_props.title; let mut is_updating_class_name = classes!(); if title.is_some() { @@ -310,13 +269,13 @@ impl Component for StatusBar { let is_menu = matches!(has_table, Some(TableLoadState::Loaded)) && ctx.props().on_settings.as_ref().is_none(); let is_title = is_menu - || ctx.props().is_workspace + || ctx.props().presentation_props.is_workspace || title.is_some() || is_errored || presentation.is_active(&self.input_ref.cast::()); let is_settings = title.is_some() - || ctx.props().is_workspace + || ctx.props().presentation_props.is_workspace || !matches!(has_table, Some(TableLoadState::Loaded)) || is_errored || is_settings_open @@ -330,12 +289,21 @@ impl Component for StatusBar { let link = link.clone(); spawn_local(async move { let mime = x.method.mimetype(x.is_chart); - let task = props.export_method_to_blob(x.method); + let task = export_method_to_blob( + &props.session, + &props.renderer, + &props.presentation, + x.method, + ); let result = copy_to_clipboard(task, mime).await; - crate::maybe_log!({ + let r = (|| -> ApiResult<()> { result?; link.send_message(StatusBarMsg::CloseCopy); - }) + Ok(()) + })(); + if let Err(e) = r { + web_sys::console::warn_1(&e.into()); + } }) }) }; @@ -347,8 +315,15 @@ impl Component for StatusBar { if !x.name.is_empty() { clone!(props, link); spawn_local(async move { - let val = props.export_method_to_blob(x.method).await.unwrap(); - let is_chart = props.renderer().is_chart(); + let val = export_method_to_blob( + &props.session, + &props.renderer, + &props.presentation, + x.method, + ) + .await + .unwrap(); + let is_chart = props.renderer.is_chart(); download(&x.as_filename(is_chart), &val).unwrap(); link.send_message(StatusBarMsg::CloseExport); }) @@ -370,13 +345,10 @@ impl Component for StatusBar { {onpointerdown} > if is_title {