diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 7cba3a0601..0ce632949c 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -590,9 +590,12 @@ jobs: - name: Run Jupyter Tests if: ${{ runner.os == 'Linux' }} - run: pnpm run test + run: pnpm run test --fetch-snapshots env: PACKAGE: "jupyterlab" + PSP_SNAPSHOT_REPO: ${{ vars.PSP_SNAPSHOT_REPO }} + PSP_SNAPSHOT_TOKEN: ${{ secrets.PSP_SNAPSHOT_TOKEN }} + PSP_SNAPSHOT_REF: ${{ github.head_ref || github.ref_name }} # PSP_USE_CCACHE: 1 - name: Run Jupyter Integration Tests @@ -646,9 +649,12 @@ jobs: path: . - name: Run Tests - run: pnpm run test + run: pnpm run test -- --fetch-snapshots env: PACKAGE: "server,client,viewer,viewer-datagrid,viewer-charts,viewer-openlayers,workspace,react" + PSP_SNAPSHOT_REPO: ${{ vars.PSP_SNAPSHOT_REPO }} + PSP_SNAPSHOT_TOKEN: ${{ secrets.PSP_SNAPSHOT_TOKEN }} + PSP_SNAPSHOT_REF: ${{ github.head_ref || github.ref_name }} # PSP_USE_CCACHE: 1 # ,--,--' . .-,--. . . diff --git a/packages/viewer-charts/build.mjs b/packages/viewer-charts/build.mjs index 4df949bbe8..a8c630efd7 100644 --- a/packages/viewer-charts/build.mjs +++ b/packages/viewer-charts/build.mjs @@ -12,7 +12,59 @@ import { NodeModulesExternal } from "@perspective-dev/esbuild-plugin/external.js"; import { build } from "@perspective-dev/esbuild-plugin/build.js"; +import { transform as transformCss } from "lightningcss"; import { execSync } from "node:child_process"; +import * as fs from "node:fs/promises"; + +// TODO: if shader payload ever becomes a measured bottleneck, swap this +// regex minifier for an AST-based tool (e.g. `glsl-minifier`) to get +// identifier mangling on locals/varyings. Uniform/attribute names are +// resolved by string from JS via `getUniformLocation` / `getAttribLocation`, +// so only locals are safe to rename. +const GlslMinify = () => ({ + name: "glsl-minify", + setup(build) { + build.onLoad({ filter: /\.glsl$/ }, async (args) => { + const src = await fs.readFile(args.path, "utf8"); + if (process.env.PSP_DEBUG) { + return { contents: src, loader: "text" }; + } + const min = src + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/\/\/[^\n]*/g, "") + .replace(/\s+/g, " ") + .replace(/\s*([;,(){}\[\]=+\-*/<>!&|^~?])\s*/g, "$1") + .trim(); + return { contents: min, loader: "text" }; + }); + }, +}); + +// CSS is imported via `import style from "...css"` + the `.css: text` +// loader, so the final bundle embeds the source verbatim as a JS +// string literal — esbuild's own minifier doesn't touch string +// contents. Route `.css` loads through lightningcss so the embedded +// CSS is minified (whitespace collapse, selector shortening, value +// normalisation). +// +// Skipped in `PSP_DEBUG` builds to keep source maps useful. +const LightningCssMinify = () => ({ + name: "lightningcss-minify", + setup(build) { + build.onLoad({ filter: /\.css$/ }, async (args) => { + const src = await fs.readFile(args.path); + if (process.env.PSP_DEBUG) { + return { contents: src.toString("utf8"), loader: "text" }; + } + const { code } = transformCss({ + filename: args.path, + code: src, + minify: true, + }); + return { contents: code.toString("utf8"), loader: "text" }; + }); + }, +}); const BUILD = [ { @@ -20,7 +72,7 @@ const BUILD = [ define: { global: "window", }, - plugins: [NodeModulesExternal()], + plugins: [NodeModulesExternal(), GlslMinify(), LightningCssMinify()], format: "esm", loader: { ".css": "text", @@ -33,7 +85,10 @@ const BUILD = [ define: { global: "window", }, - plugins: [], + plugins: [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/src/css/perspective-viewer-charts.css b/packages/viewer-charts/src/css/perspective-viewer-charts.css index 5c1ff1051c..efd9d4476c 100644 --- a/packages/viewer-charts/src/css/perspective-viewer-charts.css +++ b/packages/viewer-charts/src/css/perspective-viewer-charts.css @@ -15,6 +15,20 @@ monospace ); + --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); + /* --psp-webgl--axis-ticks--color: var( --psp-d3fc--axis-ticks--color, rgba(160, 160, 160, 0.8) @@ -64,10 +78,10 @@ .webgl-container { position: absolute; - width: 100%; - height: 100%; - top: 0; - left: 0; + top: 6px; + left: 6px; + right: 6px; + bottom: 6px; } .webgl-canvas { @@ -126,3 +140,18 @@ .zoom-reset:hover { opacity: 1; } + +.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); + border-radius: 3px; + padding: 3px; + overflow-y: auto; + white-space: pre; + z-index: 10; + line-height: 16px; +} diff --git a/packages/viewer-charts/src/ts/charts/bar/bar-interact.ts b/packages/viewer-charts/src/ts/charts/bar/bar-interact.ts index 0c935a2183..2d0c349e5f 100644 --- a/packages/viewer-charts/src/ts/charts/bar/bar-interact.ts +++ b/packages/viewer-charts/src/ts/charts/bar/bar-interact.ts @@ -12,7 +12,6 @@ import type { BarChart } from "./bar"; import type { BarRecord } from "./bar-build"; -import { resolveTheme } from "../../theme/theme"; import { formatTickValue } from "../../layout/ticks"; import { renderBarFrame, @@ -357,6 +356,7 @@ export function buildBarTooltipLines(chart: BarChart, b: BarRecord): string[] { lines.push(`Base: ${formatTickValue(b.y0)}`); lines.push(`Top: ${formatTickValue(b.y1)}`); } + return lines; } @@ -368,7 +368,7 @@ export function formatBarCategoryPath(chart: BarChart, catIdx: number): string { if (chart._rowPaths.length === 0) return ""; const parts: string[] = []; for (const rp of chart._rowPaths) { - const s = rp.dictionary[rp.indices[catIdx]]; + const s = rp.labels[catIdx]; if (s != null && s !== "") parts.push(s); } return parts.join(" / "); @@ -409,13 +409,9 @@ function pinTooltip(chart: BarChart, b: BarRecord): void { const lines = buildBarTooltipLines(chart, b); if (lines.length === 0) return; - const themeEl = chart._gridlineCanvas || chart._chromeCanvas; - if (!themeEl) return; - const theme = resolveTheme(themeEl); - const parent = chart._glCanvas?.parentElement; if (!parent) return; - chart._tooltip.showPinned(parent, lines, pos, layout, theme); + chart._tooltip.showPinned(parent, lines, pos, layout); chart._hoveredBarIdx = -1; chart._hoveredSample = null; 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 701f22d40a..8e259035f9 100644 --- a/packages/viewer-charts/src/ts/charts/candlestick/candlestick-interact.ts +++ b/packages/viewer-charts/src/ts/charts/candlestick/candlestick-interact.ts @@ -12,7 +12,6 @@ import type { CandlestickChart } from "./candlestick"; import type { CandleRecord } from "./candlestick-build"; -import { resolveTheme } from "../../theme/theme"; import { formatTickValue } from "../../layout/ticks"; import { renderCandlestickChromeOverlay, @@ -86,10 +85,6 @@ export function showCandlestickPinnedTooltip( const candle = chart._candles[idx]; if (!candle || !chart._lastLayout) return; - const themeEl = chart._gridlineCanvas || chart._chromeCanvas; - if (!themeEl) return; - const theme = resolveTheme(themeEl); - const lines = buildCandlestickTooltipLines(chart, candle); if (lines.length === 0) return; @@ -104,13 +99,7 @@ export function showCandlestickPinnedTooltip( const cssWidth = (chart._glCanvas?.width || 100) / dpr; const cssHeight = (chart._glCanvas?.height || 100) / dpr; - chart._tooltip.showPinned( - parent, - lines, - pos, - { cssWidth, cssHeight }, - theme, - ); + chart._tooltip.showPinned(parent, lines, pos, { cssWidth, cssHeight }); chart._hoveredIdx = -1; if (chart._glManager) renderCandlestickFrame(chart, chart._glManager); @@ -131,7 +120,7 @@ export function buildCandlestickTooltipLines( if (chart._rowPaths.length > 0) { const parts: string[] = []; for (const rp of chart._rowPaths) { - const s = rp.dictionary[rp.indices[candle.catIdx]] ?? ""; + const s = rp.labels[candle.catIdx] ?? ""; if (s) parts.push(s); } if (parts.length > 0) lines.push(parts.join(" › ")); diff --git a/packages/viewer-charts/src/ts/charts/chart-base.ts b/packages/viewer-charts/src/ts/charts/chart-base.ts index 88bffe4a74..2df03d2693 100644 --- a/packages/viewer-charts/src/ts/charts/chart-base.ts +++ b/packages/viewer-charts/src/ts/charts/chart-base.ts @@ -10,13 +10,19 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +import type { View } from "@perspective-dev/client"; import type { ColumnDataMap } from "../data/view-reader"; +import { LazyRowFetcher } from "../data/lazy-row"; import type { WebGLContextManager } from "../webgl/context-manager"; -import type { - ZoomConfig, +import { ZoomController, + type ZoomConfig, } from "../interaction/zoom-controller"; -import type { ChartImplementation } from "./chart"; +import { + DEFAULT_FACET_CONFIG, + type ChartImplementation, + type FacetConfig, +} from "./chart"; import { TooltipController } from "../interaction/tooltip-controller"; /** @@ -76,6 +82,15 @@ export abstract class AbstractChart implements ChartImplementation { _gridlineCanvas: HTMLCanvasElement | null = null; _chromeCanvas: HTMLCanvasElement | null = null; _zoomController: ZoomController | null = null; + /** + * Per-facet zoom controllers. Populated when `zoom_mode === + * "independent"` and the chart enters faceted mode; each facet's + * render path reads its own viewport from the matching entry. + * + * Shared-zoom mode leaves this empty; `_zoomController` is the + * single domain used for every facet. + */ + _facetZoomControllers: ZoomController[] = []; _glCanvas: HTMLCanvasElement | null = null; _columnSlots: (string | null)[] = []; @@ -84,9 +99,22 @@ export abstract class AbstractChart implements ChartImplementation { _columnTypes: Record = {}; _columnsConfig: Record = {}; _defaultChartType: string | undefined = undefined; + _facetConfig: FacetConfig = { ...DEFAULT_FACET_CONFIG }; _tooltip = new TooltipController(); + /** + * On-demand single-row fetcher used by lazy tooltip column + * lookups. Reset on every `setView` call; subclasses read + * `_lazyRows.fetchRow(rowIdx)` from their hover/pin paths and + * compare a captured serial against the current hovered/pinned + * state at resolution time, so stale fetches never paint. + * + * Can be `null` on chart types that don't surface the View + * (unit-tested charts) or before the first `draw`. + */ + _lazyRows: LazyRowFetcher | null = null; + private _renderScheduled = false; private _renderRAFId = 0; @@ -105,6 +133,47 @@ export abstract class AbstractChart implements ChartImplementation { zc.configure(this.getZoomConfig()); } + /** + * Resolve the zoom controller that owns facet `idx`. In shared-zoom + * mode (default) this is always the chart's single `_zoomController`. + * In independent-zoom mode the router provisions one controller per + * facet; this returns the matching entry, allocating on demand so + * the render path never has to check `zoom_mode` itself. + */ + getZoomControllerForFacet(idx: number): ZoomController | null { + if (this._facetConfig.zoom_mode === "shared") { + return this._zoomController; + } + 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. + */ + setZoomBaseDomain( + xMin: number, + xMax: number, + yMin: number, + yMax: number, + ): void { + if (this._zoomController) { + this._zoomController.setBaseDomain(xMin, xMax, yMin, yMax); + } + for (const zc of this._facetZoomControllers) { + if (zc) zc.setBaseDomain(xMin, xMax, yMin, yMax); + } + } + /** * Zoom-controller config for this chart type. Subclasses override to * pin an axis (e.g. bar charts pin the categorical axis). Default: @@ -135,6 +204,28 @@ export abstract class AbstractChart implements ChartImplementation { this._defaultChartType = chartType; } + setFacetConfig(cfg: FacetConfig): void { + this._facetConfig = { ...cfg }; + } + + /** + * 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(); + } + // ── Render batching ──────────────────────────────────────────────────── /** Schedule one `_fullRender` on the next animation frame (idempotent). */ @@ -163,6 +254,10 @@ export abstract class AbstractChart implements ChartImplementation { this._tooltip.detach(); this._tooltip.dismissPinned(); this._cancelScheduledRender(); + if (this._lazyRows) { + this._lazyRows.dispose(); + this._lazyRows = null; + } this.destroyInternal(); } diff --git a/packages/viewer-charts/src/ts/charts/chart.ts b/packages/viewer-charts/src/ts/charts/chart.ts index b3658f20a0..ddce63753e 100644 --- a/packages/viewer-charts/src/ts/charts/chart.ts +++ b/packages/viewer-charts/src/ts/charts/chart.ts @@ -10,6 +10,7 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +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"; @@ -25,6 +26,19 @@ export interface ChartImplementation { /** Re-render with existing GPU buffer data (e.g., after resize). */ redraw(glManager: WebGLContextManager): void; + /** + * Hand the current View to the chart so it can make on-demand + * per-row queries (for lazy tooltip column lookups). Called on + * every `draw`; the chart disposes any prior fetcher and clears + * dependent UI (pinned tooltip) so stale rows never surface. + * + * TODO: pinned tooltips are dismissed on view update today. A + * future enhancement is to keep a pinned tooltip visible (with its + * captured data) until the user dismisses it, even after the + * underlying view no longer contains that row. + */ + setView?(view: View): void; + /** Set the gridline canvas (behind WebGL, for gridlines). */ setGridlineCanvas?(canvas: HTMLCanvasElement): void; @@ -61,5 +75,38 @@ export interface ChartImplementation { */ setDefaultChartType?(chartType: string): void; + /** + * Set the faceting config: one small-multiple sub-plot per + * `split_by` group, optional shared axes, coordinated tooltip, and + * zoom routing mode. Currently seeded from the `FACET_CONFIG` + * const in `plugin.ts`; eventually this will pass through + * `columns_config`. + */ + setFacetConfig?(cfg: FacetConfig): void; + destroy(): void; } + +export interface FacetConfig { + /** "grid" = small multiples (default); "overlay" = legacy single-plot. */ + facet_mode: "grid" | "overlay"; + /** Share one bottom X axis across all columns of facets. */ + shared_x_axis: boolean; + /** 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). */ + coordinated_tooltip: boolean; + /** "shared" = one viewport for all facets; "independent" = per-facet. */ + zoom_mode: "shared" | "independent"; + /** Pixel gap between adjacent facet cells in grid mode. */ + facet_padding: number; +} + +export const DEFAULT_FACET_CONFIG: FacetConfig = { + facet_mode: "grid", + shared_x_axis: true, + shared_y_axis: true, + coordinated_tooltip: false, + zoom_mode: "shared", + facet_padding: 6, +}; diff --git a/packages/viewer-charts/src/ts/charts/common/category-axis.ts b/packages/viewer-charts/src/ts/charts/common/category-axis.ts index 4d2316f79b..e488b30053 100644 --- a/packages/viewer-charts/src/ts/charts/common/category-axis.ts +++ b/packages/viewer-charts/src/ts/charts/common/category-axis.ts @@ -12,13 +12,14 @@ import type { ColumnDataMap } from "../../data/view-reader"; import type { CategoricalLevel } from "../../chrome/categorical-axis"; +import { buildGroupRuns } from "../../chrome/categorical-axis-core"; export interface CategoryAxisResult { /** - * Zero-copy views over the `__ROW_PATH_N__` dictionaries, sliced to - * skip leading empty rows (the "Total" aggregate header that the - * view produces when `group_by` is non-empty). Empty when `groupBy` - * is empty. + * 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). */ @@ -30,8 +31,9 @@ export interface CategoryAxisResult { /** * 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 zero- - * copy dictionary views plus the trimmed category count. + * 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 @@ -42,7 +44,8 @@ export function resolveCategoryAxis( numRows: number, groupByLen: number, ): CategoryAxisResult { - const rawRowPaths: CategoricalLevel[] = []; + 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; @@ -66,15 +69,34 @@ export function resolveCategoryAxis( } const numCategories = Math.max(0, numRows - rowOffset); + const L = rawRowPaths.length; const rowPaths: CategoricalLevel[] = - groupByLen > 0 && rawRowPaths.length > 0 - ? rawRowPaths.map((rp) => ({ - indices: - rowOffset === 0 - ? rp.indices - : rp.indices.subarray(rowOffset), - dictionary: rp.dictionary, - })) + 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/node-store.ts b/packages/viewer-charts/src/ts/charts/common/node-store.ts index 0da93bcfee..45f81d4ea5 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,14 @@ 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 + * magnitude so layout code continues to treat negatives as positive + * area; render code uses `sizeSign` to apply a lower alpha on + * negative leaves. Always `1` for branches. + */ + sizeSign!: Int8Array; // Rectangular layout (treemap). x0!: Float32Array; @@ -81,10 +89,29 @@ export class NodeStore { } reset(): void { + // Zero the layout typed arrays for slots that were occupied + // before. `squarify` / `partitionSunburst` overwrite visited + // nodes before any read, but early-bail branches (node area + // below `MIN_VISIBLE_AREA`) skip the recursion and leave + // descendants untouched. Re-using those slots in the next + // render with stale `x0/y0/x1/y1/a0/a1/r0/r1` leaks the + // previous render's rectangles / arcs through `collectVisible` + // into the new scene — the "leftover hoverable cells" bug. + // + // Fill only up to the prior `count` (the tree's logical size) + // — capacity can be much larger after growth and filling it + // whole is O(capacity) unnecessary work. + if (this.count > 0) { + this.x0.fill(0, 0, this.count); + this.y0.fill(0, 0, this.count); + this.x1.fill(0, 0, this.count); + this.y1.fill(0, 0, this.count); + this.a0.fill(0, 0, this.count); + this.a1.fill(0, 0, this.count); + this.r0.fill(0, 0, this.count); + this.r1.fill(0, 0, this.count); + } this.count = 0; - // Typed-array content doesn't need clearing — valid slots are - // always written before read. `name` / `colorLabel` get stale - // entries but `allocate()` clears them per-slot as reused. } /** @@ -105,6 +132,7 @@ export class NodeStore { this.size[id] = 0; this.value[id] = 0; this.colorValue[id] = NaN; + this.sizeSign[id] = 1; this.name[id] = ""; this.colorLabel[id] = ""; return id; @@ -133,7 +161,7 @@ export class NodeStore { private _allocate(newCapacity: number): void { const cap = Math.max(newCapacity, 1024); - const grow = ( + const grow = ( old: T | undefined, ctor: { new (n: number): T }, ): T => { @@ -145,6 +173,7 @@ export class NodeStore { this.size = grow(this.size, Float32Array); this.value = grow(this.value, Float32Array); this.colorValue = grow(this.colorValue, Float32Array); + this.sizeSign = grow(this.sizeSign, Int8Array); this.x0 = grow(this.x0, Float32Array); this.y0 = grow(this.y0, Float32Array); this.x1 = grow(this.x1, Float32Array); 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 07f3436c0f..1b22645b35 100644 --- a/packages/viewer-charts/src/ts/charts/common/tree-chart.ts +++ b/packages/viewer-charts/src/ts/charts/common/tree-chart.ts @@ -24,7 +24,6 @@ import { NodeStore, NULL_NODE } from "./node-store"; */ export abstract class TreeChartBase extends AbstractChart { // ── Shared column-slot resolution ──────────────────────────────────── - _allColumns: string[] = []; _sizeName = ""; _colorName = ""; @@ -50,11 +49,11 @@ export abstract class TreeChartBase extends AbstractChart { */ _childLookup: Map> = new Map(); - // ── Row-data column buffers (per-leaf tooltip lookup) ──────────────── + // ── 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; - _rowCapacity = 0; - _numericRowData: Map = new Map(); - _stringRowData: Map = new Map(); // ── Color extents / categorical key table ─────────────────────────── _colorMin = Infinity; @@ -64,4 +63,17 @@ export abstract class TreeChartBase extends AbstractChart { // ── 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. + */ + _hoveredTooltipLines: string[] | null = null; + _hoveredTooltipNodeId: number = -1; + _hoveredTooltipSerial = 0; + _pinnedTooltipSerial = 0; } 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 e44e722a29..c2260034f3 100644 --- a/packages/viewer-charts/src/ts/charts/common/tree-data.ts +++ b/packages/viewer-charts/src/ts/charts/common/tree-data.ts @@ -13,13 +13,32 @@ /** * Streaming tree pipeline shared by treemap and sunburst. Rows arrive * incrementally; each chunk inserts its rows directly into the SOA - * tree and appends to per-column row-data buffers for tooltip lookup. + * tree. Per-leaf tooltip columns are fetched lazily on pin via the + * chart's `_lazyRows`; the tree only retains `leafRowIdx` per leaf + * (small, O(leaves)) as the handle back to the source view row. * After a chunk is processed, `finalizeTree` recomputes `value` - * bottom-up and (in series mode) materializes `colorLabel` from the - * ancestor-name composite. + * bottom-up. + * + * Color mode: + * - `"numeric"` — `readColor` reads the row's numeric value; the + * render path maps it through the continuous gradient. + * - `"series"` — `readColor` reads the row's string value from the + * color column's dictionary, and `seedColorLabels` pre-populates + * `_uniqueColorLabels` in dictionary-index order. Render picks + * `palette[dictIdx % paletteSize]`. + * - `"empty"` — no color column; every leaf gets `palette[0]`. + * + * When `_splitBy` is populated, every row is duplicated — one insertion + * per split prefix, with that prefix pushed as the top-level path + * segment so the top-level children of the synthetic root become + * facet roots. The per-prefix `size` / `color` columns (named + * `${prefix}|${base}`) feed the facet's values. `seedColorLabels` + * runs once per split so every split's dictionary contributes to the + * shared legend. */ import type { ColumnDataMap, ColumnData } from "../../data/view-reader"; +import { buildSplitGroups } from "../../data/split-groups"; import { NULL_NODE } from "./node-store"; import type { TreeChartBase } from "./tree-chart"; @@ -45,9 +64,6 @@ export function resetTreeState(chart: TreeChartBase): void { chart._breadcrumbIds = [rootId]; chart._rowCount = 0; - chart._rowCapacity = 0; - chart._numericRowData.clear(); - chart._stringRowData.clear(); chart._colorMin = Infinity; chart._colorMax = -Infinity; @@ -57,75 +73,6 @@ export function resetTreeState(chart: TreeChartBase): void { chart._visibleNodeCount = 0; } -// ── Row-data buffer growth ─────────────────────────────────────────────── - -function ensureRowCapacity(chart: TreeChartBase, needed: number): void { - if (needed <= chart._rowCapacity) return; - const newCap = Math.max(needed, chart._rowCapacity * 2 || 1024); - for (const [name, old] of chart._numericRowData) { - const next = new Float32Array(newCap); - next.set(old); - chart._numericRowData.set(name, next); - } - for (const [, old] of chart._stringRowData) { - old.length = newCap; - } - chart._rowCapacity = newCap; -} - -function ensureNumericCol(chart: TreeChartBase, name: string): Float32Array { - let arr = chart._numericRowData.get(name); - if (!arr) { - arr = new Float32Array(chart._rowCapacity); - chart._numericRowData.set(name, arr); - } else if (arr.length < chart._rowCapacity) { - const next = new Float32Array(chart._rowCapacity); - next.set(arr); - chart._numericRowData.set(name, next); - arr = next; - } - return arr; -} - -function ensureStringCol(chart: TreeChartBase, name: string): string[] { - let arr = chart._stringRowData.get(name); - if (!arr) { - arr = new Array(chart._rowCapacity); - chart._stringRowData.set(name, arr); - } - return arr; -} - -/** - * Capture every non-`__` column's value at row `base + j` for - * `j` in `[0, sourceLength)`. Enables O(1) tooltip lookup without - * per-node `Map` allocations. - */ -function captureRowData( - chart: TreeChartBase, - columns: ColumnDataMap, - base: number, - sourceLength: number, -): void { - for (const [name, col] of columns) { - if (name.startsWith("__")) continue; - if (col.type === "string" && col.indices && col.dictionary) { - const arr = ensureStringCol(chart, name); - const ind = col.indices; - const dict = col.dictionary; - for (let j = 0; j < sourceLength; j++) { - arr[base + j] = dict[ind[j]]; - } - } else if (col.values) { - const arr = ensureNumericCol(chart, name); - const vals = col.values; - for (let j = 0; j < sourceLength; j++) { - arr[base + j] = vals[j] as number; - } - } - } -} - // ── Tree insertion ─────────────────────────────────────────────────────── /** @@ -171,7 +118,11 @@ function insertRow( if (d === depth - 1) { if (groupByLen === 0 || depth === groupByLen) { - chart._nodeStore.size[childId] = Math.max(0, sizeValue); + // Store `|size|` for layout; remember the sign so + // render can dim negative leaves (matches the area + // chart's `theme.areaOpacity`). + chart._nodeStore.size[childId] = Math.abs(sizeValue); + chart._nodeStore.sizeSign[childId] = sizeValue < 0 ? -1 : 1; chart._nodeStore.leafRowIdx[childId] = rowIdx; } if (!isNaN(colorValue)) { @@ -200,18 +151,81 @@ function readColor( rowIdx: number, ): { colorValue: number; colorLabel: string } { let colorValue = NaN; - const colorLabel = ""; + let colorLabel = ""; if (!colorCol) return { colorValue, colorLabel }; if (chart._colorMode === "numeric" && colorCol.values) { colorValue = colorCol.values[rowIdx] as number; + } else if ( + chart._colorMode === "series" && + colorCol.indices && + colorCol.dictionary + ) { + // Read the dictionary-decoded string for this row. The palette + // index that render uses is `_uniqueColorLabels.get(label)`, + // which `seedColorLabels` seeds in dictionary-index order so + // the end result is `palette[dictIdx % paletteSize]`. + colorLabel = colorCol.dictionary[colorCol.indices[rowIdx]]; } - // Series-mode colorLabel is populated in finalizeTree (post-pass) - // from the group-by path, so readColor just returns empty. return { colorValue, colorLabel }; } +/** + * Seed `_uniqueColorLabels` with the color column's dictionary in + * index order. Using insertion-order-guarded `.set` means later + * chunks (or later splits in split_by mode) append new entries + * without disturbing already-assigned indices; for a single stable + * dictionary this yields `_uniqueColorLabels.get(dict[i]) === i`. + * + * No-op outside `"series"` mode or when the column lacks a + * dictionary. + */ +function seedColorLabels( + chart: TreeChartBase, + colorCol: ColumnData | null | undefined, +): void { + 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]; + if (!chart._uniqueColorLabels.has(s)) { + chart._uniqueColorLabels.set(s, chart._uniqueColorLabels.size); + } + } +} + // ── Chunk processor ────────────────────────────────────────────────────── +interface SplitSource { + prefix: string; + sizeCol: ColumnData | null; + colorCol: ColumnData | null; +} + +/** + * Resolve the per-split size / color columns. Returns `null` when + * `_splitBy` is empty — callers then take the non-split fast path. + */ +function resolveSplitSources( + chart: TreeChartBase, + columns: ColumnDataMap, +): SplitSource[] | 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; + return groups.map((g) => ({ + prefix: g.prefix, + sizeCol: chart._sizeName + ? (columns.get(`${g.prefix}|${chart._sizeName}`) ?? null) + : null, + colorCol: chart._colorName + ? (columns.get(`${g.prefix}|${chart._colorName}`) ?? null) + : null, + })); +} + /** * Process one incoming chunk: grow row-data buffers, walk every row, * capture column values, and insert into the tree. @@ -229,18 +243,41 @@ export function processTreeChunk( const hasGroupBy = rpCols.length > 0; const groupByLen = chart._groupBy.length; + const splitSources = resolveSplitSources(chart, columns); + const hasSplits = splitSources !== null; const sizeCol = chart._sizeName ? columns.get(chart._sizeName) : null; const colorCol = chart._colorName ? columns.get(chart._colorName) : null; + const firstSizeCol = hasSplits ? splitSources![0].sizeCol : sizeCol; const numRows = hasGroupBy ? rpCols[0].indices.length - : (sizeCol?.values?.length ?? 0); + : (firstSizeCol?.values?.length ?? 0); 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 + // to whichever dict value it happens to reference. For splits we + // 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); + } else { + seedColorLabels(chart, colorCol); + } + + // `base` is the source-view row offset the tree should tag its + // leaves with. `_rowCount` tracks how many rows prior chunks + // occupied so `leafRowIdx[childId] = base + i` still points back + // to the correct view row after multiple chunk arrivals. const base = chart._rowCount; - ensureRowCapacity(chart, base + numRows); - captureRowData(chart, columns, base, numRows); + + // 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; if (!hasGroupBy) { // Flat fallback: synthesize a single-segment path per row from @@ -255,55 +292,113 @@ export function processTreeChunk( } } + const sources = hasSplits ? splitSources! : null; for (let i = 0; i < numRows; i++) { const label = labelCol?.indices && labelCol?.dictionary ? labelCol.dictionary[labelCol.indices[i]] : `Row ${base + i}`; - const sizeValue = sizeCol?.values - ? Math.max(0, sizeCol.values[i] as number) - : 1; - const { colorValue, colorLabel } = readColor(chart, colorCol, i); - insertRow( - chart, - [label], - sizeValue, - colorValue, - colorLabel, - base + i, - groupByLen, - ); + + if (sources) { + for (const src of sources) { + // Pass the signed value through; `insertRow` stores + // `|size|` and `sizeSign` separately so the + // render pass can dim negative leaves. + const sizeValue = src.sizeCol?.values + ? (src.sizeCol.values[i] as number) + : 1; + const { colorValue, colorLabel } = readColor( + chart, + src.colorCol, + i, + ); + insertRow( + chart, + [src.prefix, label], + sizeValue, + colorValue, + colorLabel, + base + i, + effectiveGroupLen, + ); + } + } else { + const sizeValue = sizeCol?.values + ? (sizeCol.values[i] as number) + : 1; + const { colorValue, colorLabel } = readColor( + chart, + colorCol, + i, + ); + insertRow( + chart, + [label], + sizeValue, + colorValue, + colorLabel, + base + i, + effectiveGroupLen, + ); + } } chart._rowCount = base + numRows; return; } // Hierarchical (group_by present): reuse a scratch path buffer - // across rows to avoid per-row array allocation. - const pathScratch: string[] = new Array(rpCols.length); + // across rows to avoid per-row array allocation. When splits are + // active the scratch is one slot longer to hold the leading prefix. + const extra = hasSplits ? 1 : 0; + const pathScratch: string[] = new Array(rpCols.length + extra); for (let i = 0; i < numRows; i++) { let pathLen = 0; for (let d = 0; d < rpCols.length; d++) { const rp = rpCols[d]; const label = rp.dictionary[rp.indices[i]]; if (!label && label !== "0") break; - pathScratch[pathLen++] = label; + pathScratch[extra + pathLen++] = label; } if (pathLen === 0) continue; // skip total row - const rowPath = pathScratch.slice(0, pathLen); - const sizeValue = sizeCol?.values ? (sizeCol.values[i] as number) : 1; - const { colorValue, colorLabel } = readColor(chart, colorCol, i); - - insertRow( - chart, - rowPath, - sizeValue, - colorValue, - colorLabel, - base + i, - groupByLen, - ); + if (hasSplits) { + for (const src of splitSources!) { + pathScratch[0] = src.prefix; + const rowPath = pathScratch.slice(0, pathLen + 1); + const sizeValue = src.sizeCol?.values + ? (src.sizeCol.values[i] as number) + : 1; + const { colorValue, colorLabel } = readColor( + chart, + src.colorCol, + i, + ); + insertRow( + chart, + rowPath, + sizeValue, + colorValue, + colorLabel, + base + i, + effectiveGroupLen, + ); + } + } else { + const rowPath = pathScratch.slice(0, pathLen); + const sizeValue = sizeCol?.values + ? (sizeCol.values[i] as number) + : 1; + const { colorValue, colorLabel } = readColor(chart, colorCol, i); + insertRow( + chart, + rowPath, + sizeValue, + colorValue, + colorLabel, + base + i, + effectiveGroupLen, + ); + } } chart._rowCount = base + numRows; } @@ -314,10 +409,12 @@ export function processTreeChunk( * Post-chunk finalization. * 1. Recompute `value` bottom-up from `size` via an iterative * post-order walk. - * 2. In series mode, materialize each leaf's `colorLabel` from its - * ancestor-name composite. - * 3. Re-resolve `_currentRootId` from the breadcrumb name-path so + * 2. Re-resolve `_currentRootId` from the breadcrumb name-path so * drill state survives incremental chunk arrivals. + * + * `colorLabel` is set at insert time (`readColor`) and needs no + * post-pass: in `"series"` mode it comes from the color column's + * dictionary, and in `"numeric"` / `"empty"` modes it's unused. */ export function finalizeTree(chart: TreeChartBase): void { const store = chart._nodeStore; @@ -325,7 +422,6 @@ export function finalizeTree(chart: TreeChartBase): void { const size = store.size; const firstChild = store.firstChild; const nextSibling = store.nextSibling; - const parent = store.parent; // Iterative post-order. Stack holds `(id, state)` pairs; state // 0 = pre-visit, 1 = post-visit. @@ -371,33 +467,6 @@ export function finalizeTree(chart: TreeChartBase): void { } } - // Series-mode colorLabel: composite of ancestor names (excluding - // the synthetic root). Walk only leaves; reuse a short path buffer. - if (chart._colorMode === "series") { - const pathBuf: string[] = []; - const colorLabels = store.colorLabel; - const name = store.name; - for (let id = 0; id < store.count; id++) { - if (firstChild[id] !== NULL_NODE) continue; - if (id === chart._rootId) continue; - pathBuf.length = 0; - let p = id; - while (parent[p] !== NULL_NODE) { - pathBuf.push(name[p]); - p = parent[p]; - } - pathBuf.reverse(); - const key = pathBuf.join(""); - colorLabels[id] = key; - if (!chart._uniqueColorLabels.has(key)) { - chart._uniqueColorLabels.set( - key, - chart._uniqueColorLabels.size, - ); - } - } - } - // Preserve drill state across incremental chunk arrivals: walk the // breadcrumb name-path from the root and re-resolve. If a segment // is missing (shouldn't happen with incremental build, but diff --git a/packages/viewer-charts/src/ts/charts/continuous/continuous-build.ts b/packages/viewer-charts/src/ts/charts/continuous/continuous-build.ts index 14a80a3357..d265141df3 100644 --- a/packages/viewer-charts/src/ts/charts/continuous/continuous-build.ts +++ b/packages/viewer-charts/src/ts/charts/continuous/continuous-build.ts @@ -10,7 +10,7 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import type { ColumnDataMap } from "../../data/view-reader"; +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"; @@ -51,9 +51,6 @@ export function initContinuousPipeline( ): void { chart.glyph.ensureProgram(chart, glManager); - chart._allColumns = Array.from(columns.keys()).filter( - (k) => !k.startsWith("__"), - ); chart._xMin = Infinity; chart._xMax = -Infinity; chart._yMin = Infinity; @@ -63,8 +60,6 @@ export function initContinuousPipeline( chart._sizeMin = Infinity; chart._sizeMax = -Infinity; chart._dataCount = 0; - chart._numericRowData = new Map(); - chart._stringRowData = new Map(); chart._uniqueColorLabels = new Map(); chart._hitTest.clear(); chart._maxSeriesUploaded = 0; @@ -101,20 +96,27 @@ export function initContinuousPipeline( chart._seriesUploadedCounts = []; return; } - chart._colorIsString = true; + // 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`. chart._xName = chart._splitGroups[0].xColName; chart._yName = chart._splitGroups[0].yColName; - chart._colorName = ""; - chart._sizeName = ""; + chart._colorName = colorBase; + chart._sizeName = sizeBase; + // Infer dtype from any split's color column — all splits + // share the same underlying column type. + chart._colorIsString = false; + if (colorBase) { + const firstColorCol = columns.get( + chart._splitGroups[0].colorColName, + ); + chart._colorIsString = firstColorCol?.type === "string"; + } glManager.ensureBufferCapacity( rowsPerSeries * chart._splitGroups.length, ); - const baseNames = new Set(); - for (const key of chart._allColumns) { - const pipeIdx = key.lastIndexOf("|"); - baseNames.add(pipeIdx === -1 ? key : key.substring(pipeIdx + 1)); - } - chart._tooltipColumns = ["Split", ...baseNames]; } else { chart._splitGroups = []; chart._xName = xBase; @@ -127,7 +129,6 @@ export function initContinuousPipeline( const colorCol = columns.get(chart._colorName); chart._colorIsString = colorCol?.type === "string"; } - chart._tooltipColumns = chart._allColumns.slice(0); } const numSeries = Math.max(1, chart._splitGroups.length); @@ -138,6 +139,7 @@ export function initContinuousPipeline( chart._xData = new Float32Array(cpuCap); chart._yData = new Float32Array(cpuCap); chart._colorData = new Float32Array(cpuCap); + chart._rowIndexData = new Int32Array(cpuCap); } /** @@ -160,12 +162,18 @@ export function processContinuousChunk( const hasSplits = chart._splitGroups.length > 0; + // Per-series data source. `colorCol` is the facet's color column + // reference — in split mode each series has its own + // `${prefix}|${colorBase}`, in non-split mode the single series + // carries the user's selected color column. The color-resolution + // logic in the inner loop reads uniformly from `ser.colorCol` + // across both modes. type SeriesSrc = { xCol: Float32Array | Int32Array | null; yCol: Float32Array | Int32Array; xValid: Uint8Array | undefined; yValid: Uint8Array | undefined; - colorLabel: string; + colorCol: ColumnData | null; sizeCol: (Float32Array | Int32Array) | null; }; const series: SeriesSrc[] = []; @@ -176,12 +184,15 @@ export function processContinuousChunk( const yc = columns.get(sg.yColName); if (!yc?.values) continue; const sc = sg.sizeColName ? columns.get(sg.sizeColName) : null; + const cc = sg.colorColName + ? (columns.get(sg.colorColName) ?? null) + : null; series.push({ xCol: xc?.values ?? null, yCol: yc.values, xValid: xc?.valid, yValid: yc.valid, - colorLabel: sg.prefix, + colorCol: cc, sizeCol: sc?.values ?? null, }); } @@ -189,20 +200,21 @@ export function processContinuousChunk( const xc = chart._xName ? columns.get(chart._xName) : null; const yc = chart._yName ? columns.get(chart._yName) : null; if (!yc?.values) return; + const cc = chart._colorName + ? (columns.get(chart._colorName) ?? null) + : null; series.push({ xCol: xc?.values ?? null, yCol: yc.values, xValid: xc?.valid, yValid: yc?.valid, - colorLabel: "", + colorCol: cc, sizeCol: null, }); } if (series.length === 0) return; - const totalCapacity = chart._seriesCapacity * series.length; - if (chart._stagingChunkSize < sourceLength) { chart._stagingPositions = new Float32Array(sourceLength * 2); chart._stagingColors = new Float32Array(sourceLength); @@ -213,73 +225,44 @@ export function processContinuousChunk( const colorValues = chart._stagingColors!; const sizeValues = chart._stagingSizes!; - // Aggregated row-path (for tooltips) when group_by is active. Resolve - // the `__ROW_PATH_N__` columns once per chunk; the inner row loop - // only indexes into these arrays. - let rowPathArr: string[] | null = null; - let rowPathCols: { indices: Int32Array; dictionary: string[] }[] | null = - null; - if (chart._groupBy.length > 0) { - const rpCols: { indices: Int32Array; dictionary: string[] }[] = []; - for (let n = 0; ; n++) { - const rp = columns.get(`__ROW_PATH_${n}__`); - if (!rp || rp.type !== "string" || !rp.indices || !rp.dictionary) - break; - rpCols.push({ indices: rp.indices, dictionary: rp.dictionary }); - } - if (rpCols.length > 0) { - if (!chart._stringRowData.has("__ROW_PATH__")) { - chart._stringRowData.set( - "__ROW_PATH__", - new Array(totalCapacity), - ); - } - rowPathArr = chart._stringRowData.get("__ROW_PATH__")!; - rowPathCols = rpCols; - } - } - - // Split-series bookkeeping: numeric X/Y per base name + split label. - let splitLabelArr: string[] | null = null; - let splitXArr: Float32Array | null = null; - let splitYArr: Float32Array | null = null; - if (hasSplits) { - if (!chart._stringRowData.has("Split")) { - chart._stringRowData.set("Split", new Array(totalCapacity)); - } - splitLabelArr = chart._stringRowData.get("Split")!; - if (chart._xLabel && !chart._numericRowData.has(chart._xLabel)) { - chart._numericRowData.set( - chart._xLabel, - new Float32Array(totalCapacity), - ); - } - if (chart._yLabel && !chart._numericRowData.has(chart._yLabel)) { - chart._numericRowData.set( - chart._yLabel, - new Float32Array(totalCapacity), - ); - } - splitXArr = chart._xLabel - ? chart._numericRowData.get(chart._xLabel)! - : null; - splitYArr = chart._yLabel - ? chart._numericRowData.get(chart._yLabel)! - : null; - } - - const colorCol = - !hasSplits && chart._colorName ? columns.get(chart._colorName) : null; - // Non-split size column: resolve once; inner loop reads values[i]. const nonSplitSizeValues = !hasSplits && chart._sizeName ? (columns.get(chart._sizeName)?.values ?? null) : null; - // Snapshot pre-chunk counts so tooltip-column capture can use them - // without depending on post-loop state. - const preChunkCounts = chart._seriesUploadedCounts.slice(); + // Seed `_uniqueColorLabels` from the color column's dictionary in + // index order. For a stable single dictionary this makes + // `palette[_uniqueColorLabels.get(label)] === palette[dictIdx % + // N]`. For splits (distinct dictionaries per facet) values that + // appear in multiple splits are inserted once — later splits + // extend the map without disturbing earlier indices, so the + // same string has the same color in every facet. + // + // Also pin `_colorMin` / `_colorMax` to the full palette-index + // domain. If the row loop only encountered a subset of indices + // we'd otherwise set a narrower range and the shader's + // `(v - min) / (max - min)` mapping would land on the wrong + // palette stop. + if (chart._colorIsString && chart._colorName) { + for (const ser of series) { + const dict = ser.colorCol?.dictionary; + if (!dict) continue; + for (let i = 0; i < dict.length; i++) { + const s = dict[i]; + if (!chart._uniqueColorLabels.has(s)) { + chart._uniqueColorLabels.set( + s, + chart._uniqueColorLabels.size, + ); + } + } + } + if (chart._uniqueColorLabels.size > 0) { + chart._colorMin = 0; + chart._colorMax = chart._uniqueColorLabels.size - 1; + } + } for (let s = 0; s < series.length; s++) { const ser = series[s]; @@ -311,47 +294,52 @@ export function processContinuousChunk( const flatIdx = slotBase + prevCount + writeIdx; chart._xData![flatIdx] = x; chart._yData![flatIdx] = y; + // 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 + // into its own slot, so `startRow + i` is the right view + // row regardless of `s`. + chart._rowIndexData![flatIdx] = startRow + i; positions[writeIdx * 2] = x; positions[writeIdx * 2 + 1] = y; - // ── Color: raw numeric, or discrete label index. - if (hasSplits) { - if (!chart._uniqueColorLabels.has(ser.colorLabel)) { - chart._uniqueColorLabels.set( - ser.colorLabel, - chart._uniqueColorLabels.size, - ); - } - const idx = chart._uniqueColorLabels.get(ser.colorLabel)!; - colorValues[writeIdx] = idx; - chart._colorData![flatIdx] = idx; - if (idx < chart._colorMin) chart._colorMin = idx; - if (idx > chart._colorMax) chart._colorMax = idx; - } else if (colorCol && !chart._colorIsString && colorCol.values) { - const v = colorCol.values[i] as number; + // ── 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) { + 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; } else if ( - colorCol && + cc && chart._colorIsString && - colorCol.indices && - colorCol.dictionary + cc.indices && + cc.dictionary ) { - const label = colorCol.dictionary[colorCol.indices[i]]; + 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 + // (shouldn't happen for Arrow dict columns). if (!chart._uniqueColorLabels.has(label)) { chart._uniqueColorLabels.set( label, chart._uniqueColorLabels.size, ); + chart._colorMax = chart._uniqueColorLabels.size - 1; } const idx = chart._uniqueColorLabels.get(label)!; colorValues[writeIdx] = idx; chart._colorData![flatIdx] = idx; - if (idx < chart._colorMin) chart._colorMin = idx; - if (idx > chart._colorMax) chart._colorMax = idx; + // Skip min/max updates — they were pinned to the full + // palette-index domain during seeding. } else { colorValues[writeIdx] = 0.5; chart._colorData![flatIdx] = 0.5; @@ -372,25 +360,6 @@ export function processContinuousChunk( sizeValues[writeIdx] = 0; } - if (splitLabelArr) { - splitLabelArr[flatIdx] = ser.colorLabel; - if (splitXArr) splitXArr[flatIdx] = x; - if (splitYArr) splitYArr[flatIdx] = y; - } - - if (rowPathArr && rowPathCols && s === 0) { - // Row-path is shared across all series for a given row; - // capture once during series 0. Columns are resolved - // above; build the composite string from cached refs. - let path = ""; - for (let n = 0; n < rowPathCols.length; n++) { - const rp = rowPathCols[n]; - const part = rp.dictionary[rp.indices[i]] ?? ""; - path = n === 0 ? part : `${path} / ${part}`; - } - rowPathArr[flatIdx] = path; - } - writeIdx++; } @@ -426,38 +395,6 @@ export function processContinuousChunk( } } - // Tooltip-column capture: non-split case copies one arrow row per - // source index j, keyed by `slot0Base + preCount0 + j`. This matches - // the behavior scatter had before the unification. - if (!hasSplits) { - const base = 0 + (preChunkCounts[0] ?? 0); - for (const [name, col] of columns) { - if (name.startsWith("__")) continue; - if (col.type === "string") { - if (!chart._stringRowData.has(name)) { - chart._stringRowData.set(name, new Array(totalCapacity)); - } - const arr = chart._stringRowData.get(name)!; - const indices = col.indices!; - const dictionary = col.dictionary!; - for (let j = 0; j < sourceLength; j++) { - arr[base + j] = dictionary[indices[j]]; - } - } else if (col.values) { - if (!chart._numericRowData.has(name)) { - chart._numericRowData.set( - name, - new Float32Array(totalCapacity), - ); - } - const arr = chart._numericRowData.get(name)!; - // TypedArray.set does the element copy + int→float coerce - // in one native call; much faster than a JS for-loop. - arr.set(col.values.subarray(0, sourceLength), base); - } - } - } - // Total dataCount = sum of all series' uploaded counts. let total = 0; for (const c of chart._seriesUploadedCounts) total += c; @@ -465,8 +402,8 @@ export function processContinuousChunk( glManager.uploadedCount = total; chart._hitTest.markDirty(); - if (chart._zoomController && isFinite(chart._xMin)) { - chart._zoomController.setBaseDomain( + if (isFinite(chart._xMin)) { + chart.setZoomBaseDomain( chart._xMin, chart._xMax, chart._yMin, diff --git a/packages/viewer-charts/src/ts/charts/continuous/continuous-chart.ts b/packages/viewer-charts/src/ts/charts/continuous/continuous-chart.ts index b69455a072..a7783c3cce 100644 --- a/packages/viewer-charts/src/ts/charts/continuous/continuous-chart.ts +++ b/packages/viewer-charts/src/ts/charts/continuous/continuous-chart.ts @@ -64,7 +64,6 @@ export class ContinuousChart extends AbstractChart { _glyphCache: any = null; // ── Column roles ────────────────────────────────────────────────────── - _allColumns: string[] = []; _xName = ""; _yName = ""; _xLabel = ""; @@ -73,7 +72,6 @@ export class ContinuousChart extends AbstractChart { _colorName = ""; _sizeName = ""; _colorIsString = false; - _tooltipColumns: string[] = []; _splitGroups: SplitGroup[] = []; // ── Data extents ────────────────────────────────────────────────────── @@ -100,11 +98,33 @@ export class ContinuousChart extends AbstractChart { _xData: Float32Array | null = null; _yData: Float32Array | null = null; _colorData: Float32Array | null = null; - _numericRowData: Map = new Map(); - _stringRowData: Map = new Map(); + /** + * Source view row index for each slot in `_xData` / `_yData`, + * sized and laid out identically. Split expansion duplicates the + * same arrow source row across every series; this sidecar stores + * that source index so lazy tooltip fetches can retrieve the + * original row. Int32 for compactness — at 1M points this is + * ~4 MB, a small fraction of the ~70 MB that the prior eager + * row-data buffers cost. + */ + _rowIndexData: Int32Array | null = null; _dataCount = 0; _uniqueColorLabels: Map = new Map(); + /** + * Hovered / pinned tooltip lines, filled in asynchronously when a + * lazy row fetch resolves. `null` means "not yet available" — the + * chrome overlay skips the tooltip box entirely in that state (it + * still paints the crosshair + highlight ring from geometry data + * so the hover cue is immediate). Each hover change bumps + * `_hoveredTooltipSerial`; resolutions that observe a stale serial + * are discarded so rapid mouse motion doesn't paint out-of-date + * data. + */ + _hoveredTooltipLines: string[] | null = null; + _hoveredTooltipSerial = 0; + _pinnedTooltipSerial = 0; + // ── Staging scratch (reused across chunks) ─────────────────────────── _stagingPositions: Float32Array | null = null; _stagingColors: Float32Array | null = null; @@ -116,6 +136,14 @@ export class ContinuousChart extends AbstractChart { _lastLayout: PlotLayout | null = null; _hoveredIndex = -1; _pinnedIndex = -1; + /** + * Source facet for the current hover (`-1` when not over any facet). + * Drives coordinated hover indicator painting in other facets. + */ + _hoveredFacet = -1; + + // ── Facet state (set when rendering in grid mode) ──────────────────── + _facetGrid: import("../../layout/facet-grid").FacetGrid | null = null; // ── Last-frame cache (for chrome overlay-only redraws) ──────────────── _lastXDomain: AxisDomain | null = null; @@ -198,12 +226,11 @@ export class ContinuousChart extends AbstractChart { this.glyph.destroy(this); this._glyphCache = null; this._gradientCache = null; - this._allColumns = []; this._xData = null; this._yData = null; this._colorData = null; - this._numericRowData.clear(); - this._stringRowData.clear(); + this._rowIndexData = null; + this._hoveredTooltipLines = null; this._uniqueColorLabels.clear(); this._hitTest.clear(); this._stagingPositions = null; diff --git a/packages/viewer-charts/src/ts/charts/continuous/continuous-interact.ts b/packages/viewer-charts/src/ts/charts/continuous/continuous-interact.ts index 6c91b8295b..1faac5da7b 100644 --- a/packages/viewer-charts/src/ts/charts/continuous/continuous-interact.ts +++ b/packages/viewer-charts/src/ts/charts/continuous/continuous-interact.ts @@ -11,7 +11,6 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import type { ContinuousChart } from "./continuous-chart"; -import { resolveTheme } from "../../theme/theme"; import { renderContinuousChromeOverlay } from "./continuous-render"; const TOOLTIP_RADIUS_PX = 24; @@ -50,27 +49,36 @@ function ensureContinuousSpatialGrid(chart: ContinuousChart): void { /** * Update {@link ContinuousChart._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 + * over, then restricts the search to that facet's series slice. This + * makes hover local to a facet; coordinated ghost indicators in other + * facets are painted by the chrome overlay. */ export function handleContinuousHover( chart: ContinuousChart, mx: number, my: number, ): void { - if (!chart._xData || !chart._yData || !chart._lastLayout) return; + if (!chart._xData || !chart._yData) return; - const layout = chart._lastLayout; - const plot = layout.plotRect; + // Resolve the facet (and its layout) under the cursor. Non-facet + // charts have `_facetGrid = null` and fall back to the cached + // `_lastLayout`; the hover then scans every series. + const { layout, facetIdx } = resolveHoverTarget(chart, mx, my); + if (!layout) { + clearHover(chart); + return; + } + const plot = layout.plotRect; if ( mx < plot.x || mx > plot.x + plot.width || my < plot.y || my > plot.y + plot.height ) { - if (chart._hoveredIndex !== -1) { - chart._hoveredIndex = -1; - renderContinuousChromeOverlay(chart); - } + clearHover(chart); return; } @@ -83,8 +91,90 @@ export function handleContinuousHover( const pxPerDataX = plot.width / (xMax - xMin); const pxPerDataY = plot.height / (yMax - yMin); + const bestIdx = + facetIdx < 0 + ? hoverAllSeries(chart, dataX, dataY, pxPerDataX, pxPerDataY) + : hoverOneSeries( + chart, + facetIdx, + dataX, + dataY, + pxPerDataX, + pxPerDataY, + ); + + 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). + chart.glyph.buildTooltipLines(chart, bestIdx).then((lines) => { + if (serial !== chart._hoveredTooltipSerial) return; + chart._hoveredTooltipLines = lines; + renderContinuousChromeOverlay(chart); + }); + } + renderContinuousChromeOverlay(chart); + } +} + +function clearHover(chart: ContinuousChart): void { + if (chart._hoveredIndex !== -1 || chart._hoveredFacet !== -1) { + chart._hoveredIndex = -1; + chart._hoveredFacet = -1; + renderContinuousChromeOverlay(chart); + } +} + +/** + * Return `(layout, facetIdx)` for the sub-plot under `(mx, my)`. + * `facetIdx` is `-1` in single-plot mode; the caller then scans every + * series (legacy behaviour). In faceted mode, `-1` also signals "mouse + * is in the grid frame but not inside any plot rect" — the caller + * clears hover in that case. + */ +function resolveHoverTarget( + chart: ContinuousChart, + mx: number, + my: number, +): { + layout: import("../../layout/plot-layout").PlotLayout | null; + facetIdx: number; +} { + if (chart._facetGrid) { + const cells = chart._facetGrid.cells; + for (let i = 0; i < cells.length; i++) { + const plot = cells[i].layout.plotRect; + if ( + mx >= plot.x && + mx <= plot.x + plot.width && + my >= plot.y && + my <= plot.y + plot.height + ) { + return { layout: cells[i].layout, facetIdx: i }; + } + } + return { layout: null, facetIdx: -1 }; + } + return { layout: chart._lastLayout, facetIdx: -1 }; +} + +function hoverAllSeries( + chart: ContinuousChart, + dataX: number, + dataY: number, + pxPerDataX: number, + pxPerDataY: number, +): number { ensureContinuousSpatialGrid(chart); - let bestIdx: number = chart._hitTest.query( + let bestIdx = chart._hitTest.query( dataX, dataY, TOOLTIP_RADIUS_PX, @@ -93,37 +183,74 @@ export function handleContinuousHover( chart._xData, chart._yData, ); - if (bestIdx < 0) { - // Brute-force fallback over every valid slot. - bestIdx = -1; - let bestDistSq = TOOLTIP_RADIUS_PX * TOOLTIP_RADIUS_PX; - const numSeries = Math.max(1, chart._splitGroups.length); - const cap = chart._seriesCapacity; - for (let s = 0; s < numSeries; s++) { - const count = chart._seriesUploadedCounts[s] ?? 0; - const base = s * cap; - for (let j = 0; j < count; j++) { - const idx = base + j; - const dx = (chart._xData[idx] - dataX) * pxPerDataX; - const dy = (chart._yData[idx] - dataY) * pxPerDataY; - const distSq = dx * dx + dy * dy; - if (distSq < bestDistSq) { - bestDistSq = distSq; - bestIdx = idx; - } + if (bestIdx >= 0) return bestIdx; + + // Brute-force fallback over every valid slot. + let bestDistSq = TOOLTIP_RADIUS_PX * TOOLTIP_RADIUS_PX; + const numSeries = Math.max(1, chart._splitGroups.length); + const cap = chart._seriesCapacity; + const xData = chart._xData!; + const yData = chart._yData!; + for (let s = 0; s < numSeries; s++) { + const count = chart._seriesUploadedCounts[s] ?? 0; + const base = s * cap; + for (let j = 0; j < count; j++) { + const idx = base + j; + const dx = (xData[idx] - dataX) * pxPerDataX; + const dy = (yData[idx] - dataY) * pxPerDataY; + const distSq = dx * dx + dy * dy; + if (distSq < bestDistSq) { + bestDistSq = distSq; + bestIdx = idx; } } } + return bestIdx; +} - if (bestIdx !== chart._hoveredIndex) { - chart._hoveredIndex = bestIdx; - renderContinuousChromeOverlay(chart); +/** + * Hit-test a single series' slot range. Faceted mode scopes hover to + * the series that owns the facet under the cursor; the spatial grid + * spans all series so we do a brute-force scan over just that series' + * slice — cheap even for dense datasets because only `count[s]` slots + * are read. + */ +function hoverOneSeries( + chart: ContinuousChart, + seriesIdx: number, + dataX: number, + dataY: number, + pxPerDataX: number, + pxPerDataY: number, +): number { + const count = chart._seriesUploadedCounts[seriesIdx] ?? 0; + if (count === 0) return -1; + const cap = chart._seriesCapacity; + const base = seriesIdx * cap; + const xData = chart._xData!; + const yData = chart._yData!; + + let bestDistSq = TOOLTIP_RADIUS_PX * TOOLTIP_RADIUS_PX; + let bestIdx = -1; + for (let j = 0; j < count; j++) { + const idx = base + j; + const dx = (xData[idx] - dataX) * pxPerDataX; + const dy = (yData[idx] - dataY) * pxPerDataY; + const distSq = dx * dx + dy * dy; + if (distSq < bestDistSq) { + bestDistSq = distSq; + bestIdx = idx; + } } + return bestIdx; } /** * Show a sticky (pinned) tooltip at the given point, anchored to the * GL canvas's parent via the tooltip controller. + * + * 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, @@ -131,29 +258,46 @@ export function showContinuousPinnedTooltip( ): void { chart._tooltip.dismissPinned(); chart._pinnedIndex = pointIdx; - if (pointIdx < 0 || !chart._xData || !chart._yData || !chart._lastLayout) - return; + if (pointIdx < 0 || !chart._xData || !chart._yData) return; + + const layout = layoutForIndex(chart, pointIdx); + if (!layout) return; - const layout = chart._lastLayout; const pos = layout.dataToPixel( chart._xData[pointIdx], chart._yData[pointIdx], ); - const lines = chart.glyph.buildTooltipLines(chart, pointIdx); - if (lines.length === 0) return; - - const themeEl = chart._gridlineCanvas || chart._chromeCanvas; - if (!themeEl) return; - const theme = resolveTheme(themeEl); const parent = chart._glCanvas?.parentElement; if (!parent) return; - chart._tooltip.showPinned(parent, lines, pos, layout, theme); + + const serial = ++chart._pinnedTooltipSerial; + 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); + }); chart._hoveredIndex = -1; + chart._hoveredFacet = -1; renderContinuousChromeOverlay(chart); } +function layoutForIndex( + chart: ContinuousChart, + 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; + } + return chart._lastLayout; +} + export function dismissContinuousPinnedTooltip(chart: ContinuousChart): void { chart._tooltip.dismissPinned(); chart._pinnedIndex = -1; diff --git a/packages/viewer-charts/src/ts/charts/continuous/continuous-render.ts b/packages/viewer-charts/src/ts/charts/continuous/continuous-render.ts index 371e75f1d9..3d9fae0cf7 100644 --- a/packages/viewer-charts/src/ts/charts/continuous/continuous-render.ts +++ b/packages/viewer-charts/src/ts/charts/continuous/continuous-render.ts @@ -13,23 +13,49 @@ import type { WebGLContextManager } from "../../webgl/context-manager"; import type { ContinuousChart } from "./continuous-chart"; import { PlotLayout } from "../../layout/plot-layout"; -import { resolveTheme, readSeriesPalette } from "../../theme/theme"; +import { buildFacetGrid, type FacetGrid } from "../../layout/facet-grid"; +import { resolveTheme, readSeriesPalette, type Theme } from "../../theme/theme"; import { resolvePalette } from "../../theme/palette"; import { paletteToStops } from "../../theme/gradient"; -import { renderInPlotFrame } from "../../webgl/plot-frame"; +import { + renderInPlotFrame, + clearAndSetupFrame, + withScissor, +} from "../../webgl/plot-frame"; import { ensureGradientTexture } from "../../webgl/gradient-texture"; import { renderCanvasTooltip } from "../../interaction/tooltip-controller"; import { computeTicks, renderGridlines, renderAxesChrome, + renderCellXAxis, + renderCellYAxis, + renderOuterXAxis, + renderOuterYAxis, type AxisDomain, } from "../../chrome/numeric-axis"; -import { renderLegend, renderCategoricalLegend } from "../../chrome/legend"; +import { initCanvas } from "../../chrome/canvas"; +import { + renderLegend, + renderLegendAt, + renderCategoricalLegend, + renderCategoricalLegendAt, +} from "../../chrome/legend"; /** * Full-frame render: gridlines → glyph draw inside the plot-frame * scissor → chrome overlay (axes + legend + tooltip). + * + * Branches on `_facetConfig.facet_mode`: + * + * - `"overlay"` (legacy): a single plot rect; all split series are + * drawn together, distinguished by color. This is the pre-facet + * behavior, preserved for manual opt-in via `FACET_CONFIG`. + * - `"grid"` (default): when splits are present, `_splitGroups` laid + * out as a grid of sub-plots by {@link buildFacetGrid}. When splits + * are absent, falls through to the single-plot path — identical to + * the `"overlay"` case with 0 splits, so the non-split render path + * is byte-for-byte unchanged from before this feature. */ export function renderContinuousFrame( chart: ContinuousChart, @@ -42,12 +68,39 @@ export function renderContinuousFrame( if (cssWidth <= 0 || cssHeight <= 0) return; const hasSplits = chart._splitGroups.length > 0; + const facetMode = chart._facetConfig.facet_mode; + const useGrid = hasSplits && facetMode === "grid"; + + // Shared axes and independent zoom are incompatible: the outer + // axis band would display domain values that don't match any + // single cell's zoom. Force shared axes off when independent zoom + // is active; per-cell axes then reflect each cell's own domain. + if (useGrid && chart._facetConfig.zoom_mode === "independent") { + if ( + chart._facetConfig.shared_x_axis || + chart._facetConfig.shared_y_axis + ) { + chart._facetConfig = { + ...chart._facetConfig, + shared_x_axis: false, + shared_y_axis: false, + }; + } + } + + // Legend appears only when the user wired a color column with a + // non-degenerate range. `split_by` alone no longer forces a + // legend — faceting is the axis of splitting, not coloring. const hasColorCol = - (chart._colorName !== "" || hasSplits) && - chart._colorMin < chart._colorMax; + chart._colorName !== "" && chart._colorMin < chart._colorMax; + // Overall domain = current viewport in shared-zoom mode, full data + // extents in independent-zoom mode (each facet consults its own + // controller inside `renderFacetedFrame`). + const independent = + useGrid && chart._facetConfig.zoom_mode === "independent"; let domain: { xMin: number; xMax: number; yMin: number; yMax: number }; - if (chart._zoomController) { + if (chart._zoomController && !independent) { domain = chart._zoomController.getVisibleDomain(); } else { domain = { @@ -59,27 +112,9 @@ export function renderContinuousFrame( } if (!isFinite(domain.xMin) || !isFinite(domain.yMin)) return; - const layout = new PlotLayout(cssWidth, cssHeight, { - hasXLabel: !!chart._xLabel, - hasYLabel: !!chart._yLabel, - hasLegend: hasColorCol || hasSplits, - }); - chart._lastLayout = layout; - if (chart._zoomController) chart._zoomController.updateLayout(layout); - - const projection = layout.buildProjectionMatrix( - domain.xMin, - domain.xMax, - domain.yMin, - domain.yMax, - ); - const themeEl = chart._gridlineCanvas!; const theme = resolveTheme(themeEl); chart._lastTheme = theme; - // Palette is only needed when a categorical color source is present, - // but we resolve it eagerly so the chrome overlay can reuse without a - // second getComputedStyle pass. const seriesPalette = readSeriesPalette(themeEl); chart._lastSeriesPalette = seriesPalette; @@ -88,38 +123,25 @@ export function renderContinuousFrame( const xIsDate = xType === "date" || xType === "datetime"; const yIsDate = yType === "date" || yType === "datetime"; - const xDomain: AxisDomain = { - min: domain.xMin, - max: domain.xMax, - label: - chart._xLabel || (chart._xIsRowIndex ? "Row" : chart._xName || ""), - isDate: xIsDate, - }; - const yDomain: AxisDomain = { - min: domain.yMin, - max: domain.yMax, - label: chart._yLabel || chart._yName, - isDate: yIsDate, - }; - - const { xTicks, yTicks } = computeTicks(xDomain, yDomain, layout); - - if (chart._gridlineCanvas) { - renderGridlines(chart._gridlineCanvas, layout, xTicks, yTicks, theme); - } - - // Pick the LUT source by color mode. Categorical data (split_by or - // a string color column) samples the discrete series palette so - // shader-rendered colors match the swatches in the legend; numeric - // color columns keep the continuous gradient. + // Prepare the shared gradient LUT once (used by all facets). // - // Memoize the categorical LUT by (theme reference, label count) so - // zoom-driven redraws hand the same array reference to - // `ensureGradientTexture` and skip the 256-sample rebuild. - const isCategorical = hasSplits || chart._colorIsString; + // Three color sources map to three LUT types: + // - split_by or string color column → multi-entry series palette + // keyed by `_uniqueColorLabels.size`. + // - no color source at all → single-entry series palette + // (`palette[0]`). Points are stored with `a_color_value = 0.5` + // in the build; a 1-color LUT returns the same RGB for every + // sample so the default value is harmless. + // - numeric color column → continuous theme gradient. + // Categorical only when a string color column was wired — + // `split_by` alone no longer implies categorical coloring. + const isCategorical = chart._colorIsString; + const hasNoColorSource = !isCategorical && !chart._colorName; let lutStops = theme.gradientStops; - if (isCategorical) { - const labelCount = Math.max(1, chart._uniqueColorLabels.size); + if (isCategorical || hasNoColorSource) { + const labelCount = hasNoColorSource + ? 1 + : Math.max(1, chart._uniqueColorLabels.size); const key = `${labelCount}|${seriesPalette.length}`; if (chart._lastLutStops && chart._lastLutKey === key) { lutStops = chart._lastLutStops; @@ -143,6 +165,109 @@ export function renderContinuousFrame( lutStops, ); + if (useGrid) { + renderFacetedFrame(chart, glManager, domain, theme, { + xIsDate, + yIsDate, + cssWidth, + cssHeight, + }); + } else { + // Single-plot path (no splits, or `"overlay"` mode). + chart._facetGrid = null; + renderSinglePlotFrame(chart, glManager, domain, theme, { + xIsDate, + yIsDate, + cssWidth, + cssHeight, + hasColorCol, + }); + } + + renderContinuousChromeOverlay(chart); +} + +interface RenderFrameCtx { + xIsDate: boolean; + yIsDate: boolean; + cssWidth: number; + cssHeight: number; +} + +interface SinglePlotCtx extends RenderFrameCtx { + hasColorCol: boolean; +} + +function buildXDomain( + chart: ContinuousChart, + min: number, + max: number, + isDate: boolean, +): AxisDomain { + return { + min, + max, + label: + chart._xLabel || (chart._xIsRowIndex ? "Row" : chart._xName || ""), + isDate, + }; +} + +function buildYDomain( + chart: ContinuousChart, + min: number, + max: number, + isDate: boolean, +): AxisDomain { + return { + min, + max, + label: chart._yLabel || chart._yName, + isDate, + }; +} + +/** + * Original single-plot render path — all series drawn into one + * `PlotLayout` with one projection matrix. Used when splits are absent + * or when `facet_mode === "overlay"`. + */ +function renderSinglePlotFrame( + chart: ContinuousChart, + glManager: WebGLContextManager, + domain: { xMin: number; xMax: number; yMin: number; yMax: number }, + theme: Theme, + ctx: SinglePlotCtx, +): void { + const gl = glManager.gl; + const { cssWidth, cssHeight, xIsDate, yIsDate, hasColorCol } = ctx; + + const layout = new PlotLayout(cssWidth, cssHeight, { + hasXLabel: !!chart._xLabel, + hasYLabel: !!chart._yLabel, + hasLegend: hasColorCol, + }); + chart._lastLayout = layout; + if (chart._zoomController) chart._zoomController.updateLayout(layout); + + const projection = layout.buildProjectionMatrix( + domain.xMin, + domain.xMax, + domain.yMin, + domain.yMax, + ); + + const xDomain = buildXDomain(chart, domain.xMin, domain.xMax, xIsDate); + const yDomain = buildYDomain(chart, domain.yMin, domain.yMax, yIsDate); + const { xTicks, yTicks } = computeTicks(xDomain, yDomain, layout); + + if (chart._gridlineCanvas) { + // 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); + } + renderInPlotFrame(gl, layout, () => { chart.glyph.draw(chart, glManager, projection); }); @@ -152,9 +277,187 @@ export function renderContinuousFrame( chart._lastXTicks = xTicks; chart._lastYTicks = yTicks; chart._lastGradientStops = theme.gradientStops; - chart._lastHasColorCol = hasColorCol || hasSplits; + chart._lastHasColorCol = hasColorCol; +} - renderContinuousChromeOverlay(chart); +/** + * Faceted render path — one sub-plot per split, laid out in a grid. + * Each facet gets its own `PlotLayout` (with canvas-absolute margins), + * its own projection matrix, and one `drawSeries(s)` dispatch inside + * its scissor rect. Shader, buffers, gradient texture, and zoom + * controller state are all shared. + * + * Shared-zoom mode uses one global domain for every facet's projection + * (current default). Independent-zoom mode (Stage 6) will consult a + * per-facet `ZoomController`. + */ +function renderFacetedFrame( + chart: ContinuousChart, + glManager: WebGLContextManager, + domain: { xMin: number; xMax: number; yMin: number; yMax: number }, + theme: Theme, + ctx: RenderFrameCtx, +): void { + const gl = glManager.gl; + 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`. + // - no color column: no legend (facets alone don't warrant one). + const hasCategoricalLegend = + chart._colorIsString && chart._uniqueColorLabels.size > 1; + const hasGradientLegend = + !!chart._colorName && + !chart._colorIsString && + chart._colorMin < chart._colorMax; + const hasLegend = hasCategoricalLegend || hasGradientLegend; + // `FacetConfig.shared_x_axis` / `shared_y_axis` are booleans; + // continuous charts always have both axes, so the false branch + // maps to the per-cell mode (never to the axis-less "none" mode, + // which is reserved for tree charts). + const grid: FacetGrid = buildFacetGrid(labels, { + cssWidth, + cssHeight, + xAxis: chart._facetConfig.shared_x_axis ? "outer" : "cell", + yAxis: chart._facetConfig.shared_y_axis ? "outer" : "cell", + hasLegend, + hasXLabel: !!chart._xLabel, + hasYLabel: !!chart._yLabel, + gap: chart._facetConfig.facet_padding, + }); + chart._facetGrid = grid; + + // Grid invariant: every cell has the same plot rect dimensions. + // Downstream code (tick sampling, projection math) depends on + // this. The O(N) comparison runs at most once per frame and bails + // at the first mismatch — cheap enough to leave on unconditionally. + if (grid.cells.length > 1) { + const r0 = grid.cells[0].layout.plotRect; + for (let i = 1; i < grid.cells.length; i++) { + const r = grid.cells[i].layout.plotRect; + if (r.width !== r0.width || r.height !== r0.height) { + console.warn( + `facet-grid: cell ${i} size (${r.width}×${r.height}) ` + + `differs from cell 0 (${r0.width}×${r0.height})`, + ); + break; + } + } + } + + // `_lastLayout` backs the hover hit-test in `continuous-interact.ts`. + // In faceted mode the hover routine resolves the facet under the + // cursor and consults that cell's layout directly; for legacy + // fallback (shouldn't fire), publish the first cell's layout. + chart._lastLayout = grid.cells[0]?.layout ?? null; + + // Keep every controller's layout pointer fresh for wheel/pan math. + const independent = chart._facetConfig.zoom_mode === "independent"; + for (let i = 0; i < grid.cells.length; i++) { + const zc = chart.getZoomControllerForFacet(i); + if (zc) zc.updateLayout(grid.cells[i].layout); + if (!independent) break; + } + + const xDomain = buildXDomain(chart, domain.xMin, domain.xMax, xIsDate); + const yDomain = buildYDomain(chart, domain.yMin, domain.yMax, yIsDate); + + // Gridlines + per-facet axes use the first cell's layout for tick + // sampling (all cells have identical plotRect dimensions). Per-facet + // rendering then reuses the same tick arrays. + const sampleLayout = grid.cells[0]?.layout; + const { xTicks, yTicks } = sampleLayout + ? computeTicks(xDomain, yDomain, sampleLayout) + : { xTicks: [], yTicks: [] }; + + // One-shot destructive prep for the gridline + WebGL canvases. + // Both phases below are per-facet; calling their destructive + // helpers (initCanvas / renderInPlotFrame) in the loop would wipe + // every previously-drawn facet, leaving only the last cell + // visible. + if (chart._gridlineCanvas && sampleLayout) { + initCanvas(chart._gridlineCanvas, sampleLayout); + } + clearAndSetupFrame(gl); + + for (let i = 0; i < grid.cells.length; i++) { + const cell = grid.cells[i]; + const zc = chart.getZoomControllerForFacet(i); + const facetDomain = independent && zc ? zc.getVisibleDomain() : domain; + + // `buildProjectionMatrix` must run before `renderGridlines`: + // it seeds the padded-domain fields on `cell.layout` that + // `dataToPixel` (used by gridline tick → pixel mapping) reads. + // Skipping this order leaves the layout on its default + // `[0, 1]` padded domain, and every tick pixel falls outside + // the cell's `plotRect`, so `drawGridlinesX/Y` filters them + // all out and the gridline canvas stays blank. + const projection = cell.layout.buildProjectionMatrix( + facetDomain.xMin, + facetDomain.xMax, + facetDomain.yMin, + facetDomain.yMax, + ); + + // Per-facet gridlines: reuse shared ticks in shared-zoom mode, + // compute fresh ticks in independent mode (each facet has its + // own domain). + if (chart._gridlineCanvas) { + const localXTicks = independent + ? computeTicks( + buildXDomain( + chart, + facetDomain.xMin, + facetDomain.xMax, + xIsDate, + ), + buildYDomain( + chart, + facetDomain.yMin, + facetDomain.yMax, + yIsDate, + ), + cell.layout, + ).xTicks + : xTicks; + const localYTicks = independent + ? computeTicks( + buildXDomain( + chart, + facetDomain.xMin, + facetDomain.xMax, + xIsDate, + ), + buildYDomain( + chart, + facetDomain.yMin, + facetDomain.yMax, + yIsDate, + ), + cell.layout, + ).yTicks + : yTicks; + renderGridlines( + chart._gridlineCanvas, + cell.layout, + localXTicks, + localYTicks, + theme, + ); + } + withScissor(gl, cell.layout, () => { + chart.glyph.drawSeries(chart, glManager, projection, i); + }); + } + + chart._lastXDomain = xDomain; + chart._lastYDomain = yDomain; + chart._lastXTicks = xTicks; + chart._lastYTicks = yTicks; + chart._lastGradientStops = theme.gradientStops; + chart._lastHasColorCol = hasLegend; } /** @@ -169,16 +472,26 @@ export function renderContinuousChromeOverlay(chart: ContinuousChart): void { ) return; - const layout = chart._lastLayout; - // Prefer the cached theme/palette populated by the last full frame. - // Falls back to a fresh read only if this overlay-only path ran - // before any full render (shouldn't happen in normal flow). - const theme = chart._lastTheme ?? resolveTheme(chart._chromeCanvas); + // 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); + if (chart._facetGrid) { + renderFacetedChromeOverlay(chart); + } else { + renderSinglePlotChromeOverlay(chart); + } +} + +function renderSinglePlotChromeOverlay(chart: ContinuousChart): void { + const layout = chart._lastLayout!; + const theme = chart._lastTheme ?? resolveTheme(chart._chromeCanvas!); renderAxesChrome( - chart._chromeCanvas, - chart._lastXDomain, - chart._lastYDomain, + chart._chromeCanvas!, + chart._lastXDomain!, + chart._lastYDomain!, layout, chart._lastXTicks!, chart._lastYTicks!, @@ -190,21 +503,21 @@ export function renderContinuousChromeOverlay(chart: ContinuousChart): void { if (chart._colorIsString && chart._uniqueColorLabels.size > 0) { const seriesPalette = chart._lastSeriesPalette ?? - readSeriesPalette(chart._chromeCanvas); + readSeriesPalette(chart._chromeCanvas!); const palette = resolvePalette( seriesPalette, stops, chart._uniqueColorLabels.size, ); renderCategoricalLegend( - chart._chromeCanvas, + chart._chromeCanvas!, layout, chart._uniqueColorLabels, palette, ); } else if (chart._colorName) { renderLegend( - chart._chromeCanvas, + chart._chromeCanvas!, layout, { min: chart._colorMin, @@ -217,10 +530,203 @@ export function renderContinuousChromeOverlay(chart: ContinuousChart): void { } if (chart._hoveredIndex >= 0 && chart._xData && chart._yData) { - renderTooltip(chart, chart._chromeCanvas, layout); + renderTooltip(chart, chart._chromeCanvas!, layout); + } +} + +function renderFacetedChromeOverlay(chart: ContinuousChart): void { + const grid = chart._facetGrid!; + const canvas = chart._chromeCanvas!; + const theme = chart._lastTheme ?? resolveTheme(canvas); + const sharedXTicks = chart._lastXTicks!; + const sharedYTicks = chart._lastYTicks!; + const xDomain = chart._lastXDomain!; + const yDomain = chart._lastYDomain!; + + // `shared_x_axis` / `shared_y_axis` are silently forced off in + // independent-zoom mode by the render entry — see `renderContinuousFrame`. + // So by the time we get here, shared = true implies shared-zoom too. + const sharedX = chart._facetConfig.shared_x_axis; + const sharedY = chart._facetConfig.shared_y_axis; + const independent = chart._facetConfig.zoom_mode === "independent"; + + // Shared X axis: one outer band across the bottom of the grid, + // with ticks painted per-column (one pass per bottom-row cell). + // Shared Y axis: one outer band down the left, ticks per-row + // (one pass per leftmost-column cell). + if (sharedX && grid.outerXAxisRect) { + const bottomRowLayouts = grid.cells + .filter((c) => c.isBottomEdge) + .map((c) => c.layout); + renderOuterXAxis( + canvas, + grid.outerXAxisRect, + xDomain, + sharedXTicks, + bottomRowLayouts, + theme, + !!chart._xLabel, + ); + } + if (sharedY && grid.outerYAxisRect) { + const leftColLayouts = grid.cells + .filter((c) => c.isLeftEdge) + .map((c) => c.layout); + renderOuterYAxis( + canvas, + grid.outerYAxisRect, + yDomain, + sharedYTicks, + leftColLayouts, + theme, + !!chart._yLabel, + ); + } + + // Per-facet axes for the non-shared sides + title strips. + for (let i = 0; i < grid.cells.length; i++) { + const cell = grid.cells[i]; + const zc = independent ? chart.getZoomControllerForFacet(i) : null; + const d = zc ? zc.getVisibleDomain() : null; + const localX = d ? { ...xDomain, min: d.xMin, max: d.xMax } : xDomain; + const localY = d ? { ...yDomain, min: d.yMin, max: d.yMax } : yDomain; + const ticks = independent + ? computeTicks(localX, localY, cell.layout) + : { xTicks: sharedXTicks, yTicks: sharedYTicks }; + + if (!sharedX) { + renderCellXAxis( + canvas, + localX, + cell.layout, + ticks.xTicks, + theme, + !!chart._xLabel, + ); + } + if (!sharedY) { + renderCellYAxis( + canvas, + localY, + cell.layout, + ticks.yTicks, + theme, + !!chart._yLabel, + ); + } + + if (cell.titleRect) { + drawFacetTitle(canvas, cell.label, cell.titleRect, theme); + } + } + + // Shared legend: categorical (string color) or gradient + // (numeric color). Position derives from `grid.legendRect` + // which `buildFacetGrid` populates when `hasLegend` was set. + 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, + stops, + Math.max(1, chart._uniqueColorLabels.size), + ); + renderCategoricalLegendAt( + canvas, + grid.legendRect, + chart._uniqueColorLabels, + palette, + ); + } else if (chart._colorName) { + // Numeric gradient legend in the shared outer rect. The + // label sits above the bar, so inset the rect's top by + // the usual 20 px that `renderLegend` reserves. + renderLegendAt( + canvas, + { + x: grid.legendRect.x, + y: grid.legendRect.y + 20, + width: grid.legendRect.width, + height: grid.legendRect.height - 20, + }, + { + min: chart._colorMin, + max: chart._colorMax, + label: chart._colorName, + }, + stops, + ); + } + } + + // Coordinated hover / click indicators across facets. The tooltip + // 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`. + if (chart._hoveredIndex >= 0 && chart._xData && chart._yData) { + const dataX = chart._xData[chart._hoveredIndex]; + const dataY = chart._yData[chart._hoveredIndex]; + const sourceFacet = seriesFromIndex(chart, chart._hoveredIndex); + const opts = chart.glyph.tooltipOptions(); + const tooltipLines = chart._hoveredTooltipLines ?? []; + + 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); + const plot = cell.layout.plotRect; + if ( + pos.px < plot.x || + pos.px > plot.x + plot.width || + pos.py < plot.y || + pos.py > plot.y + plot.height + ) { + continue; + } + const coordinated = chart._facetConfig.coordinated_tooltip; + const lines = isSource || coordinated ? tooltipLines : []; + renderCanvasTooltip(canvas, pos, lines, cell.layout, theme, { + crosshair: opts.crosshair, + highlightRadius: isSource ? opts.highlightRadius : 0, + }); + } } } +function drawFacetTitle( + canvas: HTMLCanvasElement, + label: string, + rect: { x: number; y: number; width: number; height: number }, + theme: Theme, +): void { + const ctx = canvas.getContext("2d"); + if (!ctx) return; + const dpr = window.devicePixelRatio || 1; + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.scale(dpr, dpr); + ctx.font = `11px ${theme.fontFamily}`; + ctx.fillStyle = theme.labelColor; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(label, rect.x + rect.width / 2, rect.y + rect.height / 2); + ctx.restore(); +} + +/** Map a flat slotted index back to its series (facet) index. */ +export function seriesFromIndex( + chart: ContinuousChart, + flatIdx: number, +): number { + if (chart._seriesCapacity <= 0) return 0; + return Math.floor(flatIdx / chart._seriesCapacity); +} + function renderTooltip( chart: ContinuousChart, canvas: HTMLCanvasElement, @@ -230,8 +736,11 @@ function renderTooltip( if (idx < 0 || !chart._xData || !chart._yData) return; const pos = layout.dataToPixel(chart._xData[idx], chart._yData[idx]); - const lines = chart.glyph.buildTooltipLines(chart, idx); - if (lines.length === 0) return; + // Lines come from the async lazy tooltip fetch kicked off in + // `handleContinuousHover`. 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); renderCanvasTooltip( canvas, diff --git a/packages/viewer-charts/src/ts/charts/continuous/glyph.ts b/packages/viewer-charts/src/ts/charts/continuous/glyph.ts index afee275f66..29a8dcd741 100644 --- a/packages/viewer-charts/src/ts/charts/continuous/glyph.ts +++ b/packages/viewer-charts/src/ts/charts/continuous/glyph.ts @@ -36,8 +36,33 @@ export interface Glyph { projection: Float32Array, ): void; - /** Per-hover tooltip content for the point at `flatIdx`. */ - buildTooltipLines(chart: ContinuousChart, flatIdx: number): string[]; + /** + * Issue draw calls for a single series' slice only. Used by + * faceted rendering: one facet per split, each facet's scissor + * clips to its plot rect and only that series rasterizes inside. + * + * Implementations should bind uniforms/buffers once (same as + * `draw`) and dispatch only the drawArrays call(s) for + * `seriesIdx`. + */ + drawSeries( + chart: ContinuousChart, + glManager: WebGLContextManager, + projection: Float32Array, + seriesIdx: number, + ): void; + + /** + * Per-hover tooltip content for the point at `flatIdx`. Returns a + * Promise because some glyphs (notably `PointGlyph`) need to fetch + * the source row from the view on demand for extra-column lookups. + * Glyphs whose tooltip is geometry-only (e.g. `LineGlyph`) return + * a microtask-resolved promise. + */ + buildTooltipLines( + chart: ContinuousChart, + flatIdx: number, + ): Promise; /** Hover-overlay options (crosshair, highlight radius). */ tooltipOptions(): { crosshair: boolean; highlightRadius: number }; diff --git a/packages/viewer-charts/src/ts/charts/continuous/glyphs/lines.ts b/packages/viewer-charts/src/ts/charts/continuous/glyphs/lines.ts index f1258c237d..acb1d4dc42 100644 --- a/packages/viewer-charts/src/ts/charts/continuous/glyphs/lines.ts +++ b/packages/viewer-charts/src/ts/charts/continuous/glyphs/lines.ts @@ -27,21 +27,21 @@ interface LineCache { u_projection: WebGLUniformLocation | null; u_resolution: WebGLUniformLocation | null; u_line_width: WebGLUniformLocation | null; - u_series_count: WebGLUniformLocation | null; + u_color_range: WebGLUniformLocation | null; u_gradient_lut: WebGLUniformLocation | null; a_start: number; a_end: number; - a_series_start: number; - a_series_end: number; + a_color_start: number; + a_color_end: number; a_corner: number; } /** * Polyline glyph — instanced triangle-strip segments between adjacent - * same-series points. Reads the shared `a_color_value` buffer as the - * per-point series index; segments whose endpoints straddle a series - * boundary (or land in unused slots tagged with `-1`) are discarded by - * the vertex shader. + * same-series points. Segments are scoped per-series via byte-offset + * rebinding (see `drawLineSeries`); the shader reads the endpoints' + * raw color values and samples the gradient LUT via the same sign- + * aware `(v - cmin) / (cmax - cmin)` mapping the scatter glyph uses. */ export class LineGlyph implements Glyph { readonly name = "line" as const; @@ -70,12 +70,12 @@ export class LineGlyph implements Glyph { u_projection: gl.getUniformLocation(program, "u_projection"), u_resolution: gl.getUniformLocation(program, "u_resolution"), u_line_width: gl.getUniformLocation(program, "u_line_width"), - u_series_count: gl.getUniformLocation(program, "u_series_count"), + u_color_range: gl.getUniformLocation(program, "u_color_range"), u_gradient_lut: gl.getUniformLocation(program, "u_gradient_lut"), a_start: gl.getAttribLocation(program, "a_start"), a_end: gl.getAttribLocation(program, "a_end"), - a_series_start: gl.getAttribLocation(program, "a_series_start"), - a_series_end: gl.getAttribLocation(program, "a_series_end"), + a_color_start: gl.getAttribLocation(program, "a_color_start"), + a_color_end: gl.getAttribLocation(program, "a_color_end"), a_corner: gl.getAttribLocation(program, "a_corner"), }; chart._glyphCache = cache; @@ -86,115 +86,37 @@ export class LineGlyph implements Glyph { glManager: WebGLContextManager, projection: Float32Array, ): void { - const gl = glManager.gl; const cache = chart._glyphCache as LineCache | null; if (!cache) return; + const bind = bindLineState(cache, chart, glManager, projection); + if (!bind) return; - const dpr = window.devicePixelRatio || 1; const numSeries = Math.max(1, chart._splitGroups.length); - - gl.useProgram(cache.program); - gl.uniformMatrix4fv(cache.u_projection, false, projection); - gl.uniform2f(cache.u_resolution, gl.canvas.width, gl.canvas.height); - gl.uniform1f(cache.u_line_width, LINE_WIDTH_PX * dpr); - gl.uniform1f(cache.u_series_count, numSeries); - - bindGradientTexture( - glManager, - chart._gradientCache!.texture, - cache.u_gradient_lut, - 0, - ); - - 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, - ); - - const instancing = getInstancing(glManager); - const { setDivisor, drawArraysInstanced: drawInstanced } = 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); - - // One dispatch per series. Each series occupies a contiguous - // `[s*cap, s*cap + count[s])` slice of the slotted buffer, so the - // start-vs-end stride trick (end = start + stride) works without - // straddling boundaries. Rebinding per series shifts the byte - // offset so instance 0 is the series' first segment. - const posStride = 2 * Float32Array.BYTES_PER_ELEMENT; - const idStride = Float32Array.BYTES_PER_ELEMENT; - - gl.enableVertexAttribArray(cache.a_start); - setDivisor(cache.a_start, 1); - gl.enableVertexAttribArray(cache.a_end); - setDivisor(cache.a_end, 1); - gl.enableVertexAttribArray(cache.a_series_start); - setDivisor(cache.a_series_start, 1); - gl.enableVertexAttribArray(cache.a_series_end); - setDivisor(cache.a_series_end, 1); - - const cap = chart._seriesCapacity; for (let s = 0; s < numSeries; s++) { - const count = chart._seriesUploadedCounts[s] ?? 0; - if (count < 2) continue; - - const posBase = s * cap * posStride; - gl.bindBuffer(gl.ARRAY_BUFFER, posBuf.buffer); - gl.vertexAttribPointer( - cache.a_start, - 2, - gl.FLOAT, - false, - posStride, - posBase, - ); - gl.vertexAttribPointer( - cache.a_end, - 2, - gl.FLOAT, - false, - posStride, - posBase + posStride, - ); - - const idBase = s * cap * idStride; - gl.bindBuffer(gl.ARRAY_BUFFER, idBuf.buffer); - gl.vertexAttribPointer( - cache.a_series_start, - 1, - gl.FLOAT, - false, - idStride, - idBase, - ); - gl.vertexAttribPointer( - cache.a_series_end, - 1, - gl.FLOAT, - false, - idStride, - idBase + idStride, - ); - - drawInstanced(gl.TRIANGLE_STRIP, 0, 4, count - 1); + drawLineSeries(cache, chart, glManager, s); } + unbindLineDivisors(cache, glManager); + } - setDivisor(cache.a_start, 0); - setDivisor(cache.a_end, 0); - setDivisor(cache.a_series_start, 0); - setDivisor(cache.a_series_end, 0); + drawSeries( + chart: ContinuousChart, + glManager: WebGLContextManager, + projection: Float32Array, + seriesIdx: number, + ): void { + const cache = chart._glyphCache as LineCache | null; + if (!cache) return; + if (!bindLineState(cache, chart, glManager, projection)) return; + drawLineSeries(cache, chart, glManager, seriesIdx); + unbindLineDivisors(cache, glManager); } - buildTooltipLines(chart: ContinuousChart, flatIdx: number): string[] { + // ── helpers ────────────────────────────────────────────────────────── + + async buildTooltipLines( + chart: ContinuousChart, + flatIdx: number, + ): Promise { const lines: string[] = []; if (!chart._xData || !chart._yData) return lines; @@ -235,3 +157,144 @@ export class LineGlyph implements Glyph { } } } + +/** + * Shared pre-draw state setup for `draw` and `drawSeries`. Binds the + * program, uploads uniforms + gradient texture, binds the static corner + * buffer, enables the instanced attributes. Returns false if the + * gradient cache is missing. + */ +function bindLineState( + cache: LineCache, + chart: ContinuousChart, + glManager: WebGLContextManager, + projection: Float32Array, +): boolean { + const gl = glManager.gl; + if (!chart._gradientCache) return false; + + const dpr = window.devicePixelRatio || 1; + + gl.useProgram(cache.program); + gl.uniformMatrix4fv(cache.u_projection, false, projection); + gl.uniform2f(cache.u_resolution, gl.canvas.width, gl.canvas.height); + gl.uniform1f(cache.u_line_width, LINE_WIDTH_PX * dpr); + if (chart._colorMin < chart._colorMax) { + gl.uniform2f(cache.u_color_range, chart._colorMin, chart._colorMax); + } else { + gl.uniform2f(cache.u_color_range, 0.0, 0.0); + } + + bindGradientTexture( + glManager, + chart._gradientCache.texture, + cache.u_gradient_lut, + 0, + ); + + 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); + + gl.enableVertexAttribArray(cache.a_start); + setDivisor(cache.a_start, 1); + gl.enableVertexAttribArray(cache.a_end); + setDivisor(cache.a_end, 1); + gl.enableVertexAttribArray(cache.a_color_start); + setDivisor(cache.a_color_start, 1); + gl.enableVertexAttribArray(cache.a_color_end); + setDivisor(cache.a_color_end, 1); + + return true; +} + +/** + * Dispatch one instanced draw for series `s`. Rebinds start/end attrib + * pointers with byte offsets into the slotted buffer so instance 0 is + * the series' first segment. + */ +function drawLineSeries( + cache: LineCache, + chart: ContinuousChart, + glManager: WebGLContextManager, + s: number, +): void { + const count = chart._seriesUploadedCounts[s] ?? 0; + 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, + ); + + const posBase = s * cap * posStride; + gl.bindBuffer(gl.ARRAY_BUFFER, posBuf.buffer); + gl.vertexAttribPointer( + cache.a_start, + 2, + gl.FLOAT, + false, + posStride, + posBase, + ); + gl.vertexAttribPointer( + cache.a_end, + 2, + gl.FLOAT, + false, + posStride, + posBase + posStride, + ); + + const idBase = s * cap * idStride; + gl.bindBuffer(gl.ARRAY_BUFFER, idBuf.buffer); + gl.vertexAttribPointer( + cache.a_color_start, + 1, + gl.FLOAT, + false, + idStride, + idBase, + ); + gl.vertexAttribPointer( + cache.a_color_end, + 1, + gl.FLOAT, + false, + idStride, + idBase + idStride, + ); + + getInstancing(glManager).drawArraysInstanced( + gl.TRIANGLE_STRIP, + 0, + 4, + count - 1, + ); +} + +function unbindLineDivisors( + cache: LineCache, + glManager: WebGLContextManager, +): void { + const { setDivisor } = getInstancing(glManager); + setDivisor(cache.a_start, 0); + setDivisor(cache.a_end, 0); + setDivisor(cache.a_color_start, 0); + setDivisor(cache.a_color_end, 0); +} diff --git a/packages/viewer-charts/src/ts/charts/continuous/glyphs/points.ts b/packages/viewer-charts/src/ts/charts/continuous/glyphs/points.ts index 232a463845..e3e04b5191 100644 --- a/packages/viewer-charts/src/ts/charts/continuous/glyphs/points.ts +++ b/packages/viewer-charts/src/ts/charts/continuous/glyphs/points.ts @@ -78,51 +78,15 @@ export class PointGlyph implements Glyph { glManager: WebGLContextManager, projection: Float32Array, ): void { - const gl = glManager.gl; const cache = chart._glyphCache as PointCache | null; if (!cache) return; - - gl.useProgram(cache.program); - setUniforms(cache, gl, projection, chart); - bindGradientTexture( - glManager, - chart._gradientCache!.texture, - cache.u_gradient_lut, - 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, - ); - - gl.bindBuffer(gl.ARRAY_BUFFER, posBuf.buffer); - gl.enableVertexAttribArray(cache.a_position); - gl.vertexAttribPointer(cache.a_position, 2, gl.FLOAT, false, 0, 0); - - gl.bindBuffer(gl.ARRAY_BUFFER, colorBuf.buffer); - gl.enableVertexAttribArray(cache.a_color_value); - gl.vertexAttribPointer(cache.a_color_value, 1, gl.FLOAT, false, 0, 0); - - gl.bindBuffer(gl.ARRAY_BUFFER, sizeBuf.buffer); - gl.enableVertexAttribArray(cache.a_size_value); - gl.vertexAttribPointer(cache.a_size_value, 1, gl.FLOAT, false, 0, 0); + 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 // rasterizing unused tail slots. All attribs have divisor=0 so // `first` shifts them together. + const gl = glManager.gl; const numSeries = Math.max(1, chart._splitGroups.length); const cap = chart._seriesCapacity; for (let s = 0; s < numSeries; s++) { @@ -132,26 +96,83 @@ export class PointGlyph implements Glyph { } } - buildTooltipLines(chart: ContinuousChart, flatIdx: number): string[] { + drawSeries( + chart: ContinuousChart, + glManager: WebGLContextManager, + projection: Float32Array, + seriesIdx: number, + ): void { + const cache = chart._glyphCache as PointCache | null; + if (!cache) return; + if (!bindPointState(cache, chart, glManager, projection)) return; + + const count = chart._seriesUploadedCounts[seriesIdx] ?? 0; + if (count <= 0) return; + const gl = glManager.gl; + const cap = chart._seriesCapacity; + gl.drawArrays(gl.POINTS, seriesIdx * cap, count); + } + + async buildTooltipLines( + chart: ContinuousChart, + flatIdx: number, + ): Promise { const lines: string[] = []; - const rowPath = chart._stringRowData.get("__ROW_PATH__"); - if (rowPath && rowPath[flatIdx] != null) { - lines.push(String(rowPath[flatIdx])); + if (!chart._rowIndexData || !chart._lazyRows) return lines; + const rowIdx = chart._rowIndexData[flatIdx]; + if (rowIdx < 0) return lines; + + // In split mode, the row the user hovered corresponds to one + // series — so surface the split prefix as the first line so + // the user can tell which facet's data this is. + if (chart._splitGroups.length > 0 && chart._seriesCapacity > 0) { + const seriesIdx = Math.floor(flatIdx / chart._seriesCapacity); + const sg = chart._splitGroups[seriesIdx]; + if (sg?.prefix) lines.push(sg.prefix); } - for (const colName of chart._tooltipColumns) { - const strData = chart._stringRowData.get(colName); - if (strData && strData[flatIdx] != null) { - lines.push(`${colName}: ${strData[flatIdx]}`); + + const row = await chart._lazyRows.fetchRow(rowIdx); + + // Row-path (group_by): the view emits `__ROW_PATH_0__` … + // `__ROW_PATH_N__` dictionary columns. `LazyRowFetcher` + // filters out `__` columns, so fetch the row-path from the + // levels we know: iterate the view schema via `_columnTypes` + // is costly; instead, reuse the column-type map to infer only + // the non-metadata columns. Row-path columns are metadata; we + // skip them here and the visual hierarchy is instead conveyed + // by the aggregated view already surfacing grouped columns. + // + // In split mode we only have per-split columns like + // `A|price`. Filter to the prefix the user hovered on so + // the tooltip shows only relevant facet values. + const prefixFilter = + chart._splitGroups.length > 0 && chart._seriesCapacity > 0 + ? (chart._splitGroups[ + Math.floor(flatIdx / chart._seriesCapacity) + ]?.prefix ?? null) + : null; + + for (const [colName, value] of row) { + if (value === null || value === undefined) continue; + let displayName = colName; + if (prefixFilter !== null) { + const expected = `${prefixFilter}|`; + if (!colName.startsWith(expected)) continue; + displayName = colName.substring(expected.length); + } else if (colName.includes("|")) { + // Non-split chart that somehow has pipe-prefixed + // columns (shouldn't happen, but defensively skip). continue; } - const numData = chart._numericRowData.get(colName); - if (numData) { + if (typeof value === "number") { const colType = chart._columnTypes[colName] || ""; const isDate = colType === "date" || colType === "datetime"; const formatted = isDate - ? formatDateTickValue(numData[flatIdx]) - : formatTickValue(numData[flatIdx]); - lines.push(`${colName}: ${formatted}`); + ? formatDateTickValue(value) + : formatTickValue(value); + lines.push(`${displayName}: ${formatted}`); + } else { + lines.push(`${displayName}: ${value}`); } } return lines; @@ -192,3 +213,57 @@ function setUniforms( gl.uniform2f(cache.u_point_size_range, 2.0 * dpr, 16.0 * dpr); } + +/** + * Shared pre-draw state setup for `draw` and `drawSeries`. Binds the + * program, uploads uniforms + gradient texture, wires the three per- + * vertex attributes. Returns false if the gradient cache is missing. + */ +function bindPointState( + cache: PointCache, + chart: ContinuousChart, + glManager: WebGLContextManager, + projection: Float32Array, +): boolean { + const gl = glManager.gl; + if (!chart._gradientCache) return false; + + gl.useProgram(cache.program); + setUniforms(cache, gl, projection, chart); + bindGradientTexture( + glManager, + chart._gradientCache.texture, + cache.u_gradient_lut, + 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, + ); + + gl.bindBuffer(gl.ARRAY_BUFFER, posBuf.buffer); + gl.enableVertexAttribArray(cache.a_position); + gl.vertexAttribPointer(cache.a_position, 2, gl.FLOAT, false, 0, 0); + + gl.bindBuffer(gl.ARRAY_BUFFER, colorBuf.buffer); + gl.enableVertexAttribArray(cache.a_color_value); + gl.vertexAttribPointer(cache.a_color_value, 1, gl.FLOAT, false, 0, 0); + + gl.bindBuffer(gl.ARRAY_BUFFER, sizeBuf.buffer); + gl.enableVertexAttribArray(cache.a_size_value); + gl.vertexAttribPointer(cache.a_size_value, 1, gl.FLOAT, false, 0, 0); + + return true; +} 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 0199489738..9125df73da 100644 --- a/packages/viewer-charts/src/ts/charts/heatmap/heatmap-build.ts +++ b/packages/viewer-charts/src/ts/charts/heatmap/heatmap-build.ts @@ -12,6 +12,7 @@ import type { ColumnDataMap } from "../../data/view-reader"; import type { CategoricalLevel } from "../../chrome/categorical-axis"; +import { buildGroupRuns } from "../../chrome/categorical-axis-core"; export interface HeatmapCell { xIdx: number; @@ -70,7 +71,8 @@ export function buildHeatmapPipeline( }; // Resolve group_by row-paths + grand-total offset (same as bar pipeline). - const rawRowPaths: CategoricalLevel[] = []; + 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; @@ -94,15 +96,32 @@ export function buildHeatmapPipeline( } const numX = Math.max(0, numRows - rowOffset); + const L = rawRowPaths.length; const xLevels: CategoricalLevel[] = - groupBy.length > 0 && rawRowPaths.length > 0 - ? rawRowPaths.map((rp) => ({ - indices: - rowOffset === 0 - ? rp.indices - : rp.indices.subarray(rowOffset), - dictionary: rp.dictionary, - })) + 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. @@ -173,6 +192,35 @@ export function buildHeatmapPipeline( }; } +/** + * Partition a `ColumnDataMap` into one sub-map per user column. Every + * arrow value column is assigned to the partition whose user column name + * matches its terminal segment (everything after the last `|`, which + * equals the whole name when there's no `split_by`). `__ROW_PATH_N__` + * and `__GROUPING_ID__` metadata columns are copied into every partition + * since they describe the shared X axis. + * + * Used to render one heatmap per user column in a facet grid. + */ +export function partitionColumnsPerFacet( + columns: ColumnDataMap, + userColumns: string[], +): Array<{ label: string; columns: ColumnDataMap }> { + return userColumns.map((userCol) => { + const partition: ColumnDataMap = new Map(); + for (const [name, col] of columns) { + if (name.startsWith("__ROW_PATH_") || name === "__GROUPING_ID__") { + 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); + } + return { label: userCol, columns: partition }; + }); +} + /** * Split each column name on `|` → hierarchical levels. Outermost segment * is index 0; leaf (terminal) segment is `levels.length - 1`. Runs of @@ -195,6 +243,8 @@ function buildYLevelsFromNames(names: string[]): CategoricalLevel[] { const dictionary: string[] = []; const dictIndex = new Map(); const indices = new Int32Array(names.length); + const labels = new Array(names.length); + let maxLabelChars = 0; for (let i = 0; i < names.length; i++) { const seg = segments[i][d] ?? ""; let idx = dictIndex.get(seg); @@ -204,8 +254,14 @@ function buildYLevelsFromNames(names: string[]): CategoricalLevel[] { dictIndex.set(seg, idx); } indices[i] = idx; + labels[i] = seg; + if (seg.length > maxLabelChars) maxLabelChars = seg.length; } - levels.push({ indices, dictionary }); + 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 4bbfc3f68c..5e89e6519c 100644 --- a/packages/viewer-charts/src/ts/charts/heatmap/heatmap-interact.ts +++ b/packages/viewer-charts/src/ts/charts/heatmap/heatmap-interact.ts @@ -21,51 +21,96 @@ import type { CategoricalLevel } from "../../chrome/categorical-axis"; * Find the heatmap cell under `(mx, my)`. O(1) via the prebuilt `cells2D` * index. Sets `chart._hoveredCell` and schedules a re-render when the * hovered cell changes. + * + * In multi-facet mode, iterates facets to find the one whose plot rect + * contains the cursor, then hit-tests against that facet's pipeline. */ export function handleHeatmapHover( chart: HeatmapChart, mx: number, my: number, ): void { + if (chart._facets.length > 0) { + for (let i = 0; i < chart._facets.length; i++) { + const facet = chart._facets[i]; + const plot = facet.layout.plotRect; + if ( + mx < plot.x || + mx > plot.x + plot.width || + my < plot.y || + my > plot.y + plot.height + ) { + continue; + } + const cell = hitCell( + facet.layout, + facet.pipeline.numX, + facet.pipeline.numY, + facet.pipeline.cells2D, + mx, + my, + ); + setHovered(chart, cell, i); + return; + } + setHovered(chart, null, -1); + return; + } + if (!chart._lastLayout) return; - const layout = chart._lastLayout; - const plot = layout.plotRect; + const cell = hitCell( + chart._lastLayout, + chart._numX, + chart._numY, + chart._cells2D, + mx, + my, + ); + setHovered(chart, cell, -1); +} +function hitCell( + layout: import("../../layout/plot-layout").PlotLayout, + numX: number, + numY: number, + cells2D: (HeatmapCell | null)[], + mx: number, + my: number, +): HeatmapCell | null { + const plot = layout.plotRect; if ( mx < plot.x || mx > plot.x + plot.width || my < plot.y || my > plot.y + plot.height ) { - setHovered(chart, null); - return; + 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 >= chart._numX || yIdx < 0 || yIdx >= chart._numY) { - setHovered(chart, null); - return; - } - - const cell = chart._cells2D[yIdx * chart._numX + xIdx] ?? null; - setHovered(chart, cell); + if (xIdx < 0 || xIdx >= numX || yIdx < 0 || yIdx >= numY) return null; + return cells2D[yIdx * numX + xIdx] ?? null; } -function setHovered(chart: HeatmapChart, next: HeatmapCell | null): void { +function setHovered( + chart: HeatmapChart, + next: HeatmapCell | null, + facetIdx: number, +): void { const prev = chart._hoveredCell; const same = (prev?.xIdx ?? -1) === (next?.xIdx ?? -1) && - (prev?.yIdx ?? -1) === (next?.yIdx ?? -1); + (prev?.yIdx ?? -1) === (next?.yIdx ?? -1) && + chart._hoveredFacetIdx === facetIdx; if (same) return; chart._hoveredCell = next; + chart._hoveredFacetIdx = facetIdx; if (chart._glManager && chart._renderChromeOverlay) { // Only the chrome overlay changes on hover — leave WebGL cells // alone to avoid a full re-upload on every mouse move. @@ -73,14 +118,14 @@ function setHovered(chart: HeatmapChart, next: HeatmapCell | null): void { } } -/** Format a hierarchical path from a dictionary-backed `CategoricalLevel` array. */ +/** Format a hierarchical path from a precomputed-label `CategoricalLevel` array. */ export function formatHierarchicalPath( levels: CategoricalLevel[], idx: number, ): string { const parts: string[] = []; for (const lev of levels) { - const s = lev.dictionary[lev.indices[idx]]; + const s = lev.labels[idx]; if (s != null && s !== "") parts.push(s); } return parts.join(" / "); @@ -88,15 +133,34 @@ export function formatHierarchicalPath( /** Render a tooltip for the currently hovered cell. */ export function renderHeatmapTooltip(chart: HeatmapChart): void { - if (!chart._chromeCanvas || !chart._lastLayout || !chart._hoveredCell) - return; - const layout = chart._lastLayout; + if (!chart._chromeCanvas || !chart._hoveredCell) return; + + let layout: import("../../layout/plot-layout").PlotLayout | null; + let xLevels: CategoricalLevel[]; + let yLevels: CategoricalLevel[]; + let facetLabel: string | null = null; + + if (chart._hoveredFacetIdx >= 0) { + const facet = chart._facets[chart._hoveredFacetIdx]; + if (!facet) return; + layout = facet.layout; + xLevels = facet.pipeline.xLevels; + yLevels = facet.pipeline.yLevels; + facetLabel = facet.label; + } else { + 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); const lines: string[] = []; - const xPath = formatHierarchicalPath(chart._xLevels, cell.xIdx); - const yPath = formatHierarchicalPath(chart._yLevels, cell.yIdx); + if (facetLabel) lines.push(facetLabel); + const xPath = formatHierarchicalPath(xLevels, cell.xIdx); + const yPath = formatHierarchicalPath(yLevels, cell.yIdx); if (xPath) lines.push(xPath); if (yPath) lines.push(yPath); lines.push(`Value: ${formatTickValue(cell.value)}`); 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 d3c91cd991..62bce4bfa0 100644 --- a/packages/viewer-charts/src/ts/charts/heatmap/heatmap-render.ts +++ b/packages/viewer-charts/src/ts/charts/heatmap/heatmap-render.ts @@ -11,12 +11,17 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import type { WebGLContextManager } from "../../webgl/context-manager"; -import type { HeatmapChart } from "./heatmap"; +import type { HeatmapChart, HeatmapFacet } from "./heatmap"; import { PlotLayout } from "../../layout/plot-layout"; -import { resolveTheme } from "../../theme/theme"; -import { renderInPlotFrame } from "../../webgl/plot-frame"; +import { resolveTheme, 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 { buildFacetGrid } from "../../layout/facet-grid"; import { measureCategoricalAxisHeight, renderCategoricalXTicks, @@ -35,7 +40,7 @@ import { const HEATMAP_Y_AXIS_OPTS: CategoricalYAxisOptions = { skipLeafLevel: true, }; -import { renderLegend } from "../../chrome/legend"; +import { renderLegend, renderLegendAt } from "../../chrome/legend"; import heatmapVert from "../../shaders/heatmap.vert.glsl"; import heatmapFrag from "../../shaders/heatmap.frag.glsl"; import { colorValueToT } from "../../theme/gradient"; @@ -64,6 +69,12 @@ export function renderHeatmapFrame( const cssWidth = gl.canvas.width / dpr; const cssHeight = gl.canvas.height / dpr; 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; const themeEl = (chart._gridlineCanvas!.getRootNode() as ShadowRoot).host; @@ -168,7 +179,7 @@ export function renderHeatmapFrame( loc.u_gradient_lut, 0, ); - drawCellsInstanced(chart, gl, glManager); + drawCellsInstanced(chart, gl, glManager, 0, chart._uploadedCells); }); renderHeatmapChromeOverlay(chart); @@ -233,11 +244,14 @@ function drawCellsInstanced( chart: HeatmapChart, gl: WebGL2RenderingContext | WebGLRenderingContext, glManager: WebGLContextManager, + instanceStart: number, + instanceCount: number, ): void { - if (chart._uploadedCells === 0) return; + if (instanceCount === 0) return; const loc = chart._locations!; const instancing = getInstancing(glManager); const { setDivisor } = instancing; + const f = Float32Array.BYTES_PER_ELEMENT; // Per-vertex corner buffer. gl.bindBuffer(gl.ARRAY_BUFFER, chart._cornerBuffer!); @@ -245,33 +259,35 @@ function drawCellsInstanced( gl.vertexAttribPointer(loc.a_corner, 2, gl.FLOAT, false, 0, 0); setDivisor(loc.a_corner, 0); - // Per-instance cell position. - const cellBuf = glManager.bufferPool.getOrCreate( - "heatmap_cell", - 2, - Float32Array.BYTES_PER_ELEMENT, - ); + // 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); gl.bindBuffer(gl.ARRAY_BUFFER, cellBuf.buffer); gl.enableVertexAttribArray(loc.a_cell); - gl.vertexAttribPointer(loc.a_cell, 2, gl.FLOAT, false, 0, 0); + gl.vertexAttribPointer( + loc.a_cell, + 2, + gl.FLOAT, + false, + 0, + instanceStart * 2 * f, + ); setDivisor(loc.a_cell, 1); - const tBuf = glManager.bufferPool.getOrCreate( - "heatmap_t", - 1, - Float32Array.BYTES_PER_ELEMENT, - ); + const tBuf = glManager.bufferPool.getOrCreate("heatmap_t", 1, f); gl.bindBuffer(gl.ARRAY_BUFFER, tBuf.buffer); gl.enableVertexAttribArray(loc.a_color_t); - gl.vertexAttribPointer(loc.a_color_t, 1, gl.FLOAT, false, 0, 0); - setDivisor(loc.a_color_t, 1); - - instancing.drawArraysInstanced( - gl.TRIANGLE_STRIP, + gl.vertexAttribPointer( + loc.a_color_t, + 1, + gl.FLOAT, + false, 0, - 4, - chart._uploadedCells, + instanceStart * f, ); + setDivisor(loc.a_color_t, 1); + + instancing.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, instanceCount); setDivisor(loc.a_cell, 0); setDivisor(loc.a_color_t, 0); @@ -279,7 +295,12 @@ function drawCellsInstanced( /** Chrome overlay: X axis + Y axis + color legend + (optional) tooltip. */ export function renderHeatmapChromeOverlay(chart: HeatmapChart): void { - if (!chart._chromeCanvas || !chart._lastLayout) return; + if (!chart._chromeCanvas) return; + if (chart._facets.length > 0) { + renderFacetedHeatmapChromeOverlay(chart); + return; + } + if (!chart._lastLayout) return; const layout = chart._lastLayout; const theme = resolveTheme(chart._chromeCanvas); @@ -328,3 +349,199 @@ export function renderHeatmapChromeOverlay(chart: HeatmapChart): void { renderHeatmapTooltip(chart); } } + +/** Multi-facet WebGL render. Packs all facets' cells into one instance + * buffer and dispatches once per facet with a rebound pointer offset, + * matching projection, and scissor to the facet's plot rect. */ +function renderFacetedHeatmap( + chart: HeatmapChart, + glManager: WebGLContextManager, + cssWidth: number, + cssHeight: number, +): void { + const gl = glManager.gl; + const themeEl = (chart._gridlineCanvas!.getRootNode() as ShadowRoot).host; + const theme = resolveTheme(themeEl); + + const grid = buildFacetGrid( + chart._facets.map((f) => f.label), + { + cssWidth, + cssHeight, + xAxis: "cell", + yAxis: "cell", + hasLegend: true, + hasXLabel: chart._groupBy.length > 0, + hasYLabel: false, + gap: 8, + }, + ); + chart._facetGrid = grid; + + for (let i = 0; i < chart._facets.length; i++) { + const cell = grid.cells[i]; + if (cell) chart._facets[i].layout = cell.layout; + } + + ensureProgram(chart, glManager); + uploadInstanceBuffers(chart, glManager); + chart._gradientCache = ensureGradientTexture( + glManager, + chart._gradientCache, + theme.gradientStops, + ); + + gl.useProgram(chart._program!); + const loc = chart._locations!; + bindGradientTexture( + glManager, + chart._gradientCache!.texture, + loc.u_gradient_lut, + 0, + ); + + // One clear for the whole frame; per-facet scissor keeps each + // facet's draw confined to its plot rect without wiping its + // neighbours. + clearAndSetupFrame(gl); + + for (let i = 0; i < chart._facets.length; i++) { + const facet = chart._facets[i]; + if (facet.instanceCount === 0) continue; + const { numX, numY } = facet.pipeline; + 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 projection = layout.buildProjectionMatrix( + xDomainMin, + xDomainMax, + yDomainMin, + yDomainMax, + ); + + 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); + + withScissor(gl, layout, () => { + gl.uniformMatrix4fv(loc.u_projection, false, projection); + gl.uniform2f(loc.u_cell_inset, insetX, insetY); + drawCellsInstanced( + chart, + gl, + glManager, + facet.instanceStart, + facet.instanceCount, + ); + }); + } + + renderHeatmapChromeOverlay(chart); +} + +/** 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); + // `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; + + for (const facet of chart._facets) { + const layout = facet.layout; + const plot = layout.plotRect; + + ctx.strokeStyle = theme.gridlineColor; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(plot.x, plot.y); + ctx.lineTo(plot.x, plot.y + plot.height); + ctx.lineTo(plot.x + plot.width, plot.y + plot.height); + ctx.stroke(); + + const xDomain: CategoricalDomain = { + levels: facet.pipeline.xLevels, + numRows: facet.pipeline.numX, + levelLabels: chart._groupBy.slice(), + }; + const yDomain: CategoricalDomain = { + levels: facet.pipeline.yLevels, + numRows: facet.pipeline.numY, + levelLabels: [], + }; + + renderCategoricalXTicks(ctx, layout, xDomain, theme); + renderCategoricalYTicks( + ctx, + layout, + yDomain, + theme, + HEATMAP_Y_AXIS_OPTS, + ); + } + + // Per-facet titles sit in the grid cell's titleRect — one strip per + // facet, above the plot rect. The grid's cells and the chart's + // facets are parallel arrays by construction. + const grid = chart._facetGrid; + for (let i = 0; i < grid.cells.length; i++) { + const cell = grid.cells[i]; + const facet = chart._facets[i]; + if (!facet || !cell.titleRect) continue; + drawFacetTitle(chart._chromeCanvas, facet.label, cell.titleRect, theme); + } + + // Shared colorbar at `grid.legendRect`. No meaningful single label — + // the facet titles already name each column, and a combined label + // would be ambiguous when columns differ. + if (grid.legendRect) { + renderLegendAt( + chart._chromeCanvas, + { + x: grid.legendRect.x, + y: grid.legendRect.y + 20, + width: grid.legendRect.width, + height: Math.max(1, grid.legendRect.height - 20), + }, + { + min: chart._colorMin, + max: chart._colorMax, + label: "", + }, + theme.gradientStops, + ); + } + + if (chart._hoveredCell) { + renderHeatmapTooltip(chart); + } +} + +function drawFacetTitle( + canvas: HTMLCanvasElement, + label: string, + rect: { x: number; y: number; width: number; height: number }, + theme: Theme, +): void { + const ctx = canvas.getContext("2d"); + if (!ctx) return; + const dpr = window.devicePixelRatio || 1; + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.scale(dpr, dpr); + ctx.font = `11px ${theme.fontFamily}`; + ctx.fillStyle = theme.labelColor; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(label, rect.x + rect.width / 2, rect.y + rect.height / 2); + ctx.restore(); +} diff --git a/packages/viewer-charts/src/ts/charts/heatmap/heatmap-y-axis.ts b/packages/viewer-charts/src/ts/charts/heatmap/heatmap-y-axis.ts index 5028d90285..e7ab754bb5 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 @@ -16,10 +16,7 @@ import type { CategoricalDomain, CategoricalLevel, } from "../../chrome/categorical-axis"; -import { - buildGroupRuns, - maxDictLength, -} from "../../chrome/categorical-axis-core"; +import { runsInRange } from "../../chrome/categorical-axis-core"; import { truncateLabel } from "../../chrome/label-geometry"; interface LevelTickLayout { @@ -86,7 +83,7 @@ export function measureCategoricalYLevels( const L = levels.length; const result: LevelTickLayout[] = []; for (let l = 0; l < L; l++) { - const longest = maxDictLength(levels[l].dictionary); + const longest = levels[l].maxLabelChars; if (l === L - 1) { const w = Math.max(LEAF_LEVEL_WIDTH, longest * 6.5 + 16); result.push({ size: w }); @@ -116,7 +113,7 @@ export function measureCategoricalAxisWidth( } function getLeafText(level: CategoricalLevel, row: number): string { - return level.dictionary[level.indices[row]] ?? ""; + return level.labels[row] ?? ""; } /** @@ -254,7 +251,7 @@ function renderOuterLevel( tickColor: string, ): void { const plot = layout.plotRect; - const runs = buildGroupRuns(level.indices, visMin, visMax + 1); + const runs = runsInRange(level.runs, visMin, visMax); if (runs.length === 0) return; ctx.strokeStyle = tickColor; @@ -296,7 +293,7 @@ function renderOuterLevel( const cy = (clippedHi + clippedLo) / 2; const cx = bandLeft + (bandRight - bandLeft - 3) / 2; - const text = level.dictionary[run.dictIdx] ?? ""; + const text = run.label; if (!text) continue; const span = clippedLo - clippedHi - 4; const truncated = truncateLabel(ctx, text, Math.max(0, span)); diff --git a/packages/viewer-charts/src/ts/charts/heatmap/heatmap.ts b/packages/viewer-charts/src/ts/charts/heatmap/heatmap.ts index 2dc4cf6b13..4192e63483 100644 --- a/packages/viewer-charts/src/ts/charts/heatmap/heatmap.ts +++ b/packages/viewer-charts/src/ts/charts/heatmap/heatmap.ts @@ -15,7 +15,13 @@ 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 { buildHeatmapPipeline, type HeatmapCell } from "./heatmap-build"; +import type { FacetGrid } from "../../layout/facet-grid"; +import { + buildHeatmapPipeline, + partitionColumnsPerFacet, + type HeatmapCell, + type HeatmapPipelineResult, +} from "./heatmap-build"; import { renderHeatmapFrame, renderHeatmapChromeOverlay, @@ -23,11 +29,30 @@ import { } from "./heatmap-render"; import { handleHeatmapHover } from "./heatmap-interact"; +/** + * One heatmap in a facet-grid layout. Each facet corresponds to one + * user-selected column in the `Color` slot; its `pipeline` holds the + * cell data, and `layout` is the cell's `PlotLayout` from `buildFacetGrid`. + * `instanceStart`/`instanceCount` give the range of the packed + * cell/colorT buffers that belong to this facet. + */ +export interface HeatmapFacet { + label: string; + pipeline: HeatmapPipelineResult; + layout: PlotLayout; + instanceStart: number; + instanceCount: number; +} + /** * Heatmap chart. `yIdx` maps 1:1 to the arrow column iteration order * (after skipping `__ROW_PATH_N__` metadata). `xIdx` is the row index - * post-`rowOffset`. The first column in the `Color` slot is the only one - * consumed; additional columns are ignored (enforced externally). + * post-`rowOffset`. + * + * With one user column in the `Color` slot the chart renders a single + * heatmap filling the canvas. With more than one, each column becomes + * its own heatmap in a facet grid; all facets share a common color + * scale and a single legend. */ export class HeatmapChart extends AbstractChart { _program: WebGLProgram | null = null; @@ -55,6 +80,10 @@ export class HeatmapChart extends AbstractChart { _hoveredCell: HeatmapCell | null = null; _lastLayout: PlotLayout | null = null; + _facets: HeatmapFacet[] = []; + _facetGrid: FacetGrid | null = null; + _hoveredFacetIdx = -1; + /** Bound accessor so the interact module can trigger a chrome redraw. */ _renderChromeOverlay = () => renderHeatmapChromeOverlay(this); @@ -86,31 +115,109 @@ export class HeatmapChart extends AbstractChart { } this._cancelScheduledRender(); - const result = buildHeatmapPipeline({ - columns, - numRows: endRow, - groupBy: this._groupBy, - }); - - this._xLevels = result.xLevels; - this._yLevels = result.yLevels; - this._yColumnNames = result.yColumnNames; - this._numX = result.numX; - this._numY = result.numY; - this._rowOffset = result.rowOffset; - this._cells = result.cells; - this._cells2D = result.cells2D; - this._colorMin = result.colorMin; - this._colorMax = result.colorMax; - this._aggName = - this._columnSlots.find((s): s is string => !!s) ?? "Color"; + const userColumns = this._columnSlots.filter((s): s is string => !!s); + + if (userColumns.length > 1) { + const partitions = partitionColumnsPerFacet(columns, userColumns); + const facets: HeatmapFacet[] = []; + const allCells: HeatmapCell[] = []; + let globalMin = Infinity; + let globalMax = -Infinity; + for (const part of partitions) { + const pipeline = buildHeatmapPipeline({ + columns: part.columns, + numRows: endRow, + groupBy: this._groupBy, + }); + 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 + // hit-testing via `cells2D`. + for (const c of pipeline.cells) { + allCells.push({ + xIdx: c.xIdx, + yIdx: c.yIdx, + value: c.value, + }); + } + facets.push({ + label: part.label, + pipeline, + layout: new PlotLayout(1, 1, { + hasXLabel: false, + hasYLabel: false, + hasLegend: false, + }), + instanceStart, + instanceCount: pipeline.cells.length, + }); + if ( + isFinite(pipeline.colorMin) && + pipeline.colorMin < globalMin + ) { + globalMin = pipeline.colorMin; + } + if ( + isFinite(pipeline.colorMax) && + pipeline.colorMax > globalMax + ) { + globalMax = pipeline.colorMax; + } + } + if (!isFinite(globalMin) || !isFinite(globalMax)) { + globalMin = 0; + globalMax = 1; + } else if (globalMin === globalMax) { + globalMax = globalMin + 1; + } + + // Reset single-plot state so render-time dispatch on + // `_facets.length > 0` is unambiguous. + this._xLevels = []; + this._yLevels = []; + this._yColumnNames = []; + this._numX = 0; + this._numY = 0; + this._rowOffset = 0; + this._cells2D = []; + this._lastLayout = null; + + this._facets = facets; + this._cells = allCells; + this._colorMin = globalMin; + this._colorMax = globalMax; + this._aggName = userColumns.join(", "); + } else { + const result = buildHeatmapPipeline({ + columns, + numRows: endRow, + groupBy: this._groupBy, + }); + + this._facets = []; + this._facetGrid = null; + this._xLevels = result.xLevels; + this._yLevels = result.yLevels; + this._yColumnNames = result.yColumnNames; + this._numX = result.numX; + this._numY = result.numY; + this._rowOffset = result.rowOffset; + this._cells = result.cells; + this._cells2D = result.cells2D; + this._colorMin = result.colorMin; + this._colorMax = result.colorMax; + this._aggName = userColumns[0] ?? "Color"; + } this._scheduleRender(glManager); } redraw(glManager: WebGLContextManager): void { this._glManager = glManager; - if (this._numX === 0 || this._numY === 0) return; + const hasSingle = this._numX > 0 && this._numY > 0; + const hasFacets = this._facets.length > 0; + if (!hasSingle && !hasFacets) return; this._fullRender(glManager); } @@ -131,5 +238,8 @@ export class HeatmapChart extends AbstractChart { this._cells = []; this._cells2D = []; this._hoveredCell = null; + this._facets = []; + this._facetGrid = null; + this._hoveredFacetIdx = -1; } } 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 9a30285dcc..2f823ca185 100644 --- a/packages/viewer-charts/src/ts/charts/sunburst/sunburst-interact.ts +++ b/packages/viewer-charts/src/ts/charts/sunburst/sunburst-interact.ts @@ -13,7 +13,6 @@ import type { SunburstChart } from "./sunburst"; import { NULL_NODE } from "../common/node-store"; import { rebuildBreadcrumbs } from "../common/tree-data"; -import { resolveTheme } from "../../theme/theme"; import { formatTickValue } from "../../layout/ticks"; import { renderSunburstFrame, @@ -28,38 +27,98 @@ export interface SunburstBreadcrumbRegion { y1: number; } +interface FacetHitContext { + centerX: number; + centerY: number; + drillRoot: number; + /** 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. */ +function facetUnderCursor( + chart: SunburstChart, + mx: number, + my: number, +): FacetHitContext | null { + if (chart._facets.length === 0) { + return { + centerX: chart._centerX, + centerY: chart._centerY, + drillRoot: chart._currentRootId, + }; + } + for (const facet of chart._facets) { + // Post-upload `instanceStart` / `instanceCount` are scan-index + // ranges into `_visibleNodeIds` — we need the *pre-upload* + // range to hit-test all arcs (including zero-width ones we + // skipped for draw). Walk the IDs and match by drill root + // ancestry instead. + const dx = mx - facet.centerX; + const dy = my - facet.centerY; + const r = Math.sqrt(dx * dx + dy * dy); + if (r > facet.maxRadius + 4) continue; + return { + centerX: facet.centerX, + centerY: facet.centerY, + drillRoot: facet.drillRoot, + }; + } + return null; +} + +/** + * Walk the ancestor chain from `id` up to (but not including) the + * synthetic `_rootId`, returning true if any step equals `anc`. + * Used to filter hit-test candidates to arcs that belong to a given + * facet's drill subtree. + */ +function isDescendantOf( + chart: SunburstChart, + id: number, + anc: number, +): boolean { + const store = chart._nodeStore; + let p = id; + while (p !== NULL_NODE) { + if (p === anc) return true; + p = store.parent[p]; + } + return false; +} + /** 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; const store = chart._nodeStore; const ids = chart._visibleNodeIds; const n = chart._visibleNodeCount; if (!ids) return NULL_NODE; - const dx = mx - chart._centerX; - const dy = my - chart._centerY; + 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; - // Center-circle hit — lets the user drill up by clicking the center. + // Center-circle hit — drill-up target. if (r < store.r1[chart._rootId] + 0.001) { - // Use `_rootId`'s r1 as the INNER_RING_PX boundary. - if (chart._currentRootId !== chart._rootId) { - // Inside the inner circle = drill-up target. - return chart._currentRootId; + if (ctx.drillRoot !== chart._rootId) { + return ctx.drillRoot; } } - // Linear scan (post-LOD, bounded). + const faceted = chart._facets.length > 0; for (let i = 0; i < n; i++) { const id = ids[i]; - if (id === chart._currentRootId) 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; - // Angular containment accounts for wrap-around by normalizing. if (theta < a0 || theta > a1) continue; return id; } @@ -101,6 +160,16 @@ export function handleSunburstHover( if (hit !== chart._hoveredNodeId) { chart._hoveredNodeId = hit; + chart._hoveredTooltipLines = null; + chart._hoveredTooltipNodeId = hit; + const serial = ++chart._hoveredTooltipSerial; + if (hit !== NULL_NODE) { + buildSunburstTooltipLines(chart, hit).then((lines) => { + if (serial !== chart._hoveredTooltipSerial) return; + chart._hoveredTooltipLines = lines; + renderSunburstChromeOverlay(chart); + }); + } renderSunburstChromeOverlay(chart); } } @@ -132,15 +201,27 @@ export function handleSunburstClick( // Center-circle click = drill up one level (parent of current root). const store = chart._nodeStore; - const dx = mx - chart._centerX; - const dy = my - chart._centerY; - const r = Math.sqrt(dx * dx + dy * dy); - if (r < store.r1[chart._rootId] + 0.001) { - const parent = store.parent[chart._currentRootId]; - if (parent !== NULL_NODE) { - drillTo(chart, parent); + const ctx = facetUnderCursor(chart, mx, my); + if (ctx) { + const dx = mx - ctx.centerX; + const dy = my - ctx.centerY; + const r = Math.sqrt(dx * dx + dy * dy); + if (r < store.r1[chart._rootId] + 0.001) { + const parent = store.parent[ctx.drillRoot]; + if (parent !== NULL_NODE && parent !== chart._rootId) { + drillTo(chart, parent); + } else if (chart._facets.length > 0) { + // Already at the facet root: reset this facet's drill. + const facet = chart._facets.find( + (f) => f.drillRoot === ctx.drillRoot, + ); + if (facet) chart._facetDrillRoots.delete(facet.label); + if (chart._glManager) { + renderSunburstFrame(chart, chart._glManager); + } + } + return; } - return; } const hit = polarHitTest(chart, mx, my); @@ -153,7 +234,26 @@ export function handleSunburstClick( } } +/** + * Drill the clicked facet (or the whole chart in non-facet mode). + * Faceted drill walks up to the facet root (top-level child of + * `_rootId`), records the new drill node under that facet's label, + * and re-renders. + */ function drillTo(chart: SunburstChart, nodeId: number): void { + const store = chart._nodeStore; + if (chart._splitBy.length > 0 && chart._facetConfig.facet_mode === "grid") { + let p = nodeId; + 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); + return; + } chart._currentRootId = nodeId; rebuildBreadcrumbs(chart, nodeId); chart._hoveredNodeId = NULL_NODE; @@ -167,33 +267,54 @@ export function showSunburstPinnedTooltip( chart._tooltip.dismissPinned(); chart._pinnedNodeId = nodeId; - const themeEl = chart._gridlineCanvas || chart._chromeCanvas; - if (!themeEl) return; - const theme = resolveTheme(themeEl); - - const lines = buildSunburstTooltipLines(chart, nodeId); - if (lines.length === 0) return; - 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; - const cx = chart._centerX + Math.cos(midA) * midR; - const cy = chart._centerY + Math.sin(midA) * midR; + // 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 dpr = window.devicePixelRatio || 1; const cssWidth = (chart._glCanvas?.width || 100) / dpr; const cssHeight = (chart._glCanvas?.height || 100) / dpr; - chart._tooltip.showPinned( - parent, - lines, - { px: cx, py: cy }, - { cssWidth, cssHeight }, - theme, - ); + // 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 }, + ); + }); chart._hoveredNodeId = NULL_NODE; renderSunburstChromeOverlay(chart); @@ -204,10 +325,10 @@ export function dismissSunburstPinnedTooltip(chart: SunburstChart): void { chart._pinnedNodeId = NULL_NODE; } -export function buildSunburstTooltipLines( +export async function buildSunburstTooltipLines( chart: SunburstChart, nodeId: number, -): string[] { +): Promise { const store = chart._nodeStore; const lines: string[] = []; @@ -227,40 +348,32 @@ export function buildSunburstTooltipLines( lines.push(`Value: ${formatTickValue(store.value[nodeId])}`); + // Color value (numeric branch): stored on the node at insert + // time, so it's always available without a view fetch. + if (chart._colorName && !isNaN(store.colorValue[nodeId])) { + lines.push( + `${chart._colorName}: ${formatTickValue(store.colorValue[nodeId])}`, + ); + } + const rowIdx = store.leafRowIdx[nodeId]; const isLeaf = store.firstChild[nodeId] === NULL_NODE && rowIdx !== NULL_NODE; - if (isLeaf && chart._sizeName) { - const numeric = chart._numericRowData.get(chart._sizeName); - if (numeric) { - lines.push( - `${chart._sizeName}: ${formatTickValue(numeric[rowIdx])}`, - ); - } - } - if (chart._colorName) { - if (!isNaN(store.colorValue[nodeId])) { - lines.push( - `${chart._colorName}: ${formatTickValue(store.colorValue[nodeId])}`, - ); - } else if (isLeaf) { - const str = chart._stringRowData.get(chart._colorName); - if (str && str[rowIdx] !== undefined) { - lines.push(`${chart._colorName}: ${str[rowIdx]}`); + // Extra tooltip columns fetched on demand — see the treemap + // counterpart for the same pattern. + if (isLeaf && chart._lazyRows) { + const row = await chart._lazyRows.fetchRow(rowIdx); + for (const [name, value] of row) { + 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 { + lines.push(`${name}: ${value}`); } - } - } - - if (isLeaf) { - for (const [name, arr] of chart._numericRowData) { - if (name === chart._sizeName || name === chart._colorName) continue; - lines.push(`${name}: ${formatTickValue(arr[rowIdx])}`); - } - for (const [name, arr] of chart._stringRowData) { - if (name === chart._sizeName || name === chart._colorName) continue; - const v = arr[rowIdx]; - if (v !== undefined) lines.push(`${name}: ${v}`); } } 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 4ad6671550..6d0eb08ba6 100644 --- a/packages/viewer-charts/src/ts/charts/sunburst/sunburst-layout.ts +++ b/packages/viewer-charts/src/ts/charts/sunburst/sunburst-layout.ts @@ -149,11 +149,26 @@ function partitionChildren( /** * Walk from `startId` depth-first, emitting every descendant whose * arc area exceeds `MIN_VISIBLE_ARC_AREA`. + * + * The single-facet entry point; faceted rendering uses + * {@link collectVisibleArcsAppend} to concatenate across facets. */ export function collectVisibleArcs( chart: SunburstChart, startId: number, ): void { + chart._visibleNodeCount = collectVisibleArcsAppend(chart, startId, 0); +} + +/** + * Append visible arcs under `startId` to `_visibleNodeIds` starting at + * `startOffset`, returning the new length. + */ +export function collectVisibleArcsAppend( + chart: SunburstChart, + startId: number, + startOffset: number, +): number { const store = chart._nodeStore; const a0 = store.a0; const a1 = store.a1; @@ -167,7 +182,7 @@ export function collectVisibleArcs( chart._visibleNodeIds = new Int32Array(store.count); } const out = chart._visibleNodeIds; - let outIdx = 0; + let outIdx = startOffset; let stack = new Int32Array(128); stack[0] = startId; @@ -196,7 +211,7 @@ export function collectVisibleArcs( } } - chart._visibleNodeCount = outIdx; + return outIdx; } export { INNER_RING_PX, MIN_VISIBLE_ARC_AREA }; 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 702903e700..aa20328380 100644 --- a/packages/viewer-charts/src/ts/charts/sunburst/sunburst-render.ts +++ b/packages/viewer-charts/src/ts/charts/sunburst/sunburst-render.ts @@ -28,9 +28,11 @@ import { getInstancing } from "../../webgl/instanced-attrs"; import { partitionSunburst, collectVisibleArcs, + collectVisibleArcsAppend, INNER_RING_PX, } from "./sunburst-layout"; -import { buildSunburstTooltipLines } from "./sunburst-interact"; +import { buildFacetGrid } from "../../layout/facet-grid"; +import { renderCategoricalLegendAt } from "../../chrome/legend"; /** * Triangle-strip template resolution. `N_STEPS` angular samples × 2 @@ -73,6 +75,23 @@ function leafColor( return palette[idx % palette.length] ?? [0, 0, 0]; } +/** + * `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. + */ +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, @@ -87,22 +106,35 @@ export function renderSunburstFrame( .height; if (cssWidth <= 0 || cssHeight <= 0) return; + const hasSplits = + chart._splitBy.length > 0 && chart._facetConfig.facet_mode === "grid"; const hasLegend = chart._colorMode === "series" ? chart._uniqueColorLabels.size > 1 : chart._colorMode === "numeric" && chart._colorMin < chart._colorMax; - const breadcrumbH = chart._breadcrumbIds.length > 1 ? BREADCRUMB_H : 0; + const breadcrumbH = + !hasSplits && chart._breadcrumbIds.length > 1 ? BREADCRUMB_H : 0; const legendW = hasLegend ? LEGEND_W : 0; - const plotW = cssWidth - legendW; - const plotH = cssHeight - breadcrumbH; - chart._centerX = plotW / 2; - chart._centerY = breadcrumbH + plotH / 2; - chart._maxRadius = Math.max(0, Math.min(plotW, plotH) / 2 - 4); - - partitionSunburst(chart._nodeStore, chart._currentRootId, chart._maxRadius); - collectVisibleArcs(chart, chart._currentRootId); + if (hasSplits) { + layoutFacetedSunburst(chart, cssWidth, cssHeight, legendW); + } else { + chart._facetGrid = null; + chart._facets = []; + const plotW = cssWidth - legendW; + const plotH = cssHeight - breadcrumbH; + chart._centerX = plotW / 2; + chart._centerY = breadcrumbH + plotH / 2; + chart._maxRadius = Math.max(0, Math.min(plotW, plotH) / 2 - 4); + + partitionSunburst( + chart._nodeStore, + chart._currentRootId, + chart._maxRadius, + ); + collectVisibleArcs(chart, chart._currentRootId); + } ensureProgram(chart, glManager); @@ -128,7 +160,7 @@ export function renderSunburstFrame( } chart._chromeCacheDirty = true; - uploadArcInstances(chart, gl, stops, palette); + uploadArcInstances(chart, gl, stops, palette, theme.areaOpacity); const dpr = window.devicePixelRatio || 1; gl.clearColor(0, 0, 0, 0); @@ -138,7 +170,6 @@ export function renderSunburstFrame( gl.useProgram(chart._program!); const loc = chart._locations!; - gl.uniform2f(loc.u_center, chart._centerX * dpr, chart._centerY * dpr); gl.uniform2f( loc.u_resolution, (gl.canvas as HTMLCanvasElement).width, @@ -146,11 +177,115 @@ export function renderSunburstFrame( ); gl.uniform1f(loc.u_border_px, theme.sunburstGapPx * dpr); - drawArcs(chart, gl, glManager); + if (chart._facets.length > 0) { + // Faceted: one dispatch per facet with the matching `u_center` + // 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; + gl.uniform2f( + loc.u_center, + facet.centerX * dpr, + facet.centerY * dpr, + ); + drawArcs( + chart, + gl, + glManager, + facet.instanceStart, + facet.instanceCount, + ); + } + } else { + gl.uniform2f(loc.u_center, chart._centerX * dpr, chart._centerY * dpr); + drawArcs(chart, gl, glManager, 0, chart._instanceCount); + } renderSunburstChromeOverlay(chart); } +/** + * Allocate the facet grid and compute per-facet (center, radius, drill + * root) triples. Also runs `partitionSunburst` + `collectVisibleArcs` + * per facet so the combined visible list is in `_visibleNodeIds` with + * facets in cell order (instance uploads walk this list). + */ +function layoutFacetedSunburst( + chart: SunburstChart, + cssWidth: number, + cssHeight: number, + legendW: number, +): void { + const store = chart._nodeStore; + const facetIds: number[] = []; + const labels: string[] = []; + for ( + let c = store.firstChild[chart._rootId]; + c !== NULL_NODE; + c = store.nextSibling[c] + ) { + if (store.value[c] <= 0) continue; + facetIds.push(c); + labels.push(store.name[c]); + } + + const gridWidth = Math.max(1, cssWidth - legendW); + const grid = buildFacetGrid(labels, { + cssWidth: gridWidth, + cssHeight, + hasLegend: false, + // Sunburst has no X/Y axes — no per-cell gutter reservation. + xAxis: "none", + yAxis: "none", + gap: chart._facetConfig.facet_padding, + }); + chart._facetGrid = grid; + + const facets: SunburstChart["_facets"] = []; + let outIdx = 0; + for (let i = 0; i < facetIds.length; i++) { + const facetId = facetIds[i]; + const cell = grid.cells[i]; + if (!cell) continue; + const label = store.name[facetId]; + const drillRoot = chart._facetDrillRoots.get(label) ?? facetId; + const plot = cell.layout.plotRect; + const centerX = plot.x + plot.width / 2; + const centerY = plot.y + plot.height / 2; + const maxRadius = Math.max( + 0, + Math.min(plot.width, plot.height) / 2 - 4, + ); + + partitionSunburst(store, drillRoot, maxRadius); + const nextIdx = collectVisibleArcsAppend(chart, drillRoot, outIdx); + const instanceStart = outIdx; + const instanceCount = nextIdx - outIdx; + + facets.push({ + label, + centerX, + centerY, + maxRadius, + drillRoot, + instanceStart, + instanceCount, + }); + outIdx = nextIdx; + } + chart._visibleNodeCount = outIdx; + chart._facets = facets; + + // Publish the first facet's center/radius to the legacy fields so + // chrome code paths that still read them (e.g. non-faceted label + // placement) pick sensible values. + if (facets.length > 0) { + chart._centerX = facets[0].centerX; + chart._centerY = facets[0].centerY; + chart._maxRadius = facets[0].maxRadius; + } +} + function ensureProgram( chart: SunburstChart, glManager: WebGLContextManager, @@ -198,49 +333,95 @@ function uploadArcInstances( gl: WebGL2RenderingContext | WebGLRenderingContext, stops: GradientStop[], palette: Vec3[], + negativeAlpha: number, ): void { const store = chart._nodeStore; const ids = chart._visibleNodeIds!; - const n = chart._visibleNodeCount; const dpr = window.devicePixelRatio || 1; - - // 7 floats per instance: [a0, a1, r0, r1, r, g, b]. - const data = new Float32Array(n * 7); + const faceted = chart._facets.length > 0; + + // Walk each facet's pre-upload visible range (instanceStart and + // instanceCount as set by `layoutFacetedSunburst`), skip the facet's + // drill root + any zero-width arcs, and emit one contiguous run per + // facet. Update `(instanceStart, instanceCount)` to the post-skip + // values so draw dispatch can offset into the shared buffer. + // + // 8 floats per instance: [a0, a1, r0, r1, r, g, b, a]. Alpha = 1 + // for positive-size arcs, `negativeAlpha` for arcs whose raw size + // column value was negative (keeps the arc visible but dimmer). + const totalCap = faceted + ? chart._facets.reduce((a, f) => a + f.instanceCount, 0) + : chart._visibleNodeCount; + const data = new Float32Array(totalCap * 8); let instance = 0; - for (let i = 0; i < n; i++) { - const id = ids[i]; - if (id === chart._currentRootId) continue; // center disc drawn below - 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; - const color = leafColor(chart, id, stops, palette); - const o = instance * 7; - data[o + 0] = a0; - data[o + 1] = a1; - data[o + 2] = r0 * dpr; - data[o + 3] = r1 * dpr; - data[o + 4] = color[0]; - data[o + 5] = color[1]; - data[o + 6] = color[2]; - instance++; + + const emitRange = (start: number, end: number, drillRoot: number) => { + const rangeStart = instance; + for (let i = start; i < end; i++) { + const id = ids[i]; + 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; + const color = leafRGBA(chart, id, stops, palette, negativeAlpha); + const o = instance * 8; + data[o + 0] = a0; + data[o + 1] = a1; + data[o + 2] = r0 * dpr; + data[o + 3] = r1 * dpr; + data[o + 4] = color[0]; + data[o + 5] = color[1]; + data[o + 6] = color[2]; + data[o + 7] = color[3]; + instance++; + } + return { rangeStart, rangeCount: instance - rangeStart }; + }; + + if (faceted) { + for (const facet of chart._facets) { + const preStart = facet.instanceStart; + const preEnd = preStart + facet.instanceCount; + const { rangeStart, rangeCount } = emitRange( + preStart, + preEnd, + facet.drillRoot, + ); + facet.instanceStart = rangeStart; + facet.instanceCount = rangeCount; + } + } else { + emitRange(0, chart._visibleNodeCount, chart._currentRootId); } + chart._instanceCount = instance; gl.bindBuffer(gl.ARRAY_BUFFER, chart._instanceBuffer); gl.bufferData( gl.ARRAY_BUFFER, - data.subarray(0, instance * 7), + data.subarray(0, instance * 8), gl.DYNAMIC_DRAW, ); } +/** + * Dispatch one instanced draw over `[instanceStart, instanceStart+count)` + * of the shared arc instance buffer. In single-plot mode the range is + * the whole buffer; in faceted mode the caller dispatches once per + * facet with the matching `u_center` uniform. + * + * Instance attribute pointers are rebound with a byte offset per call + * so instance 0 of the draw is the facet's first arc. + */ function drawArcs( chart: SunburstChart, gl: WebGL2RenderingContext | WebGLRenderingContext, glManager: WebGLContextManager, + instanceStart: number, + instanceCount: number, ): void { - if (chart._instanceCount === 0) return; + if (instanceCount === 0) return; const loc = chart._locations!; // Static strip: per-vertex (strip_t, side). @@ -263,25 +444,44 @@ function drawArcs( setDivisor(loc.a_strip_t, 0); setDivisor(loc.a_side, 0); - // Per-instance interleaved buffer. + // Per-instance interleaved buffer (rebind with byte offset so + // instance 0 of the draw is slot `instanceStart`). Layout: + // [0..1] a_angles (a0, a1) + // [2..3] a_radii (r0, r1) + // [4..7] a_color (r, g, b, a) gl.bindBuffer(gl.ARRAY_BUFFER, chart._instanceBuffer!); - const instStride = 7 * Float32Array.BYTES_PER_ELEMENT; + const instStride = 8 * Float32Array.BYTES_PER_ELEMENT; const f = Float32Array.BYTES_PER_ELEMENT; + const base = instanceStart * instStride; gl.enableVertexAttribArray(loc.a_angles); - gl.vertexAttribPointer(loc.a_angles, 2, gl.FLOAT, false, instStride, 0); + gl.vertexAttribPointer(loc.a_angles, 2, gl.FLOAT, false, instStride, base); setDivisor(loc.a_angles, 1); gl.enableVertexAttribArray(loc.a_radii); - gl.vertexAttribPointer(loc.a_radii, 2, gl.FLOAT, false, instStride, 2 * f); + gl.vertexAttribPointer( + loc.a_radii, + 2, + gl.FLOAT, + false, + instStride, + base + 2 * f, + ); setDivisor(loc.a_radii, 1); gl.enableVertexAttribArray(loc.a_color); - gl.vertexAttribPointer(loc.a_color, 3, gl.FLOAT, false, instStride, 4 * f); + gl.vertexAttribPointer( + loc.a_color, + 4, + gl.FLOAT, + false, + instStride, + base + 4 * f, + ); setDivisor(loc.a_color, 1); instancing.drawArraysInstanced( gl.TRIANGLE_STRIP, 0, 2 * (N_STEPS + 1), - chart._instanceCount, + instanceCount, ); setDivisor(loc.a_angles, 0); @@ -315,10 +515,14 @@ export function renderSunburstChromeOverlay(chart: SunburstChart): void { 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._chromeCacheDirty) { + if (chart._chromeCacheGen === gen) { + chart._chromeCache?.close(); chart._chromeCache = bmp; } else { bmp.close(); @@ -369,39 +573,116 @@ function drawStaticChrome( const store = chart._nodeStore; 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. + // 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 (id === chart._currentRootId) continue; + if (faceted) { + if (drillRoots!.has(id)) continue; + } else if (id === chart._currentRootId) { + continue; + } renderArcLabel(chart, ctx, id, fontFamily, stops, palette); } - // Inner drill-up circle. Shrunk by half the border so the - // disc-to-first-ring gap matches the inter-ring gap (the first - // arc ring's shader inset eats the other half). + // Inner drill-up circle(s). One per facet in faceted mode so each + // facet has its own center hit target. const innerDiscR = Math.max(0, INNER_RING_PX - theme.sunburstGapPx * 0.5); - ctx.beginPath(); ctx.fillStyle = tooltipBg; - ctx.arc(chart._centerX, chart._centerY, innerDiscR, 0, 2 * Math.PI); - ctx.fill(); - ctx.fillStyle = textColor; - ctx.font = `bold 11px ${fontFamily}`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; - ctx.fillText( - store.name[chart._currentRootId], - chart._centerX, - chart._centerY, - ); - // Breadcrumbs. - if (chart._breadcrumbIds.length > 1) { + if (faceted) { + for (const facet of chart._facets) { + ctx.beginPath(); + ctx.fillStyle = tooltipBg; + ctx.arc(facet.centerX, facet.centerY, innerDiscR, 0, 2 * Math.PI); + ctx.fill(); + ctx.fillStyle = textColor; + ctx.font = `11px ${fontFamily}`; + ctx.fillText( + store.name[facet.drillRoot], + facet.centerX, + facet.centerY, + ); + // Facet title band above the arcs. + if (chart._facetGrid) { + const cell = chart._facetGrid.cells.find( + (c) => c.label === facet.label, + ); + if (cell?.titleRect) { + ctx.fillStyle = textColor; + ctx.font = `11px ${fontFamily}`; + ctx.textBaseline = "middle"; + ctx.fillText( + facet.label, + cell.titleRect.x + cell.titleRect.width / 2, + cell.titleRect.y + cell.titleRect.height / 2, + ); + } + } + } + } else { + ctx.beginPath(); + ctx.arc(chart._centerX, chart._centerY, innerDiscR, 0, 2 * Math.PI); + ctx.fill(); + ctx.fillStyle = textColor; + ctx.font = `11px ${fontFamily}`; + ctx.fillText( + store.name[chart._currentRootId], + chart._centerX, + chart._centerY, + ); + } + + // 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); } - // Legend. - if (chart._colorMode === "series" && chart._uniqueColorLabels.size > 1) { + // Legend. In faceted mode use the grid's explicit rect; otherwise + // derive from a synthetic single-plot layout. + if (faceted && chart._facetGrid?.legendRect) { + if ( + chart._colorMode === "series" && + chart._uniqueColorLabels.size > 1 + ) { + renderCategoricalLegendAt( + canvas, + chart._facetGrid.legendRect, + chart._uniqueColorLabels, + palette, + ); + } else if ( + chart._colorMode === "numeric" && + chart._colorMin < chart._colorMax + ) { + const legendLayout = new PlotLayout(cssWidth, cssHeight, { + hasXLabel: false, + hasYLabel: false, + hasLegend: true, + }); + renderLegend( + canvas, + legendLayout, + { + min: chart._colorMin, + max: chart._colorMax, + label: chart._colorName, + }, + stops, + ); + } + } else if ( + chart._colorMode === "series" && + chart._uniqueColorLabels.size > 1 + ) { const legendLayout = new PlotLayout(cssWidth, cssHeight, { hasXLabel: false, hasYLabel: false, @@ -545,7 +826,7 @@ function renderBreadcrumbs( const label = store.name[crumbId]; ctx.fillStyle = textColor; - ctx.font = isLast ? `bold 11px ${fontFamily}` : `11px ${fontFamily}`; + ctx.font = isLast ? `11px ${fontFamily}` : `11px ${fontFamily}`; const textW = ctx.measureText(label).width; ctx.fillText(label, x, y); @@ -597,7 +878,12 @@ function renderSunburstTooltip( const theme = resolveTheme(chart._chromeCanvas!); const { tooltipBg, tooltipText, tooltipBorder } = theme; - const lines = buildSunburstTooltipLines(chart, nodeId); + // 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}`; diff --git a/packages/viewer-charts/src/ts/charts/sunburst/sunburst.ts b/packages/viewer-charts/src/ts/charts/sunburst/sunburst.ts index 6ccf1dbb55..82d8ede25e 100644 --- a/packages/viewer-charts/src/ts/charts/sunburst/sunburst.ts +++ b/packages/viewer-charts/src/ts/charts/sunburst/sunburst.ts @@ -42,6 +42,17 @@ export interface SunburstLocations { a_color: number; } +/** + * Sentinel fallback for the Size slot when the user hasn't picked one: + * use the first non-metadata column in the incoming view. + */ +function firstNonMetadataColumn(columns: ColumnDataMap): string { + for (const k of columns.keys()) { + if (!k.startsWith("__")) return k; + } + return ""; +} + /** * Sunburst chart. Shares tree storage + streaming pipeline + color * mode with `TreeChartBase`; adds polar layout + instanced-arc WebGL @@ -74,6 +85,29 @@ export class SunburstChart extends TreeChartBase { _chromeCache: ImageBitmap | null = null; _chromeCacheDirty = true; + /** See `TreemapChart._chromeCacheGen` — same race, same fix. */ + _chromeCacheGen = 0; + + // ── Faceted state ──────────────────────────────────────────────────── + _facetGrid: import("../../layout/facet-grid").FacetGrid | null = null; + /** 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. + */ + _facets: { + label: string; + centerX: number; + centerY: number; + maxRadius: number; + drillRoot: number; + instanceStart: number; + instanceCount: number; + }[] = []; attachTooltip(glCanvas: HTMLCanvasElement): void { this._glCanvas = glCanvas; @@ -106,12 +140,8 @@ export class SunburstChart extends TreeChartBase { if (startRow === 0) { this._cancelScheduledRender(); - this._allColumns = Array.from(columns.keys()).filter( - (k) => !k.startsWith("__"), - ); - const slots = this._columnSlots; - this._sizeName = slots[0] || this._allColumns[0] || ""; + this._sizeName = slots[0] || firstNonMetadataColumn(columns) || ""; this._colorName = slots[1] || ""; if (!this._colorName) { this._colorMode = "empty"; @@ -125,6 +155,29 @@ export class SunburstChart extends TreeChartBase { this._colorMode = isNumeric ? "numeric" : "series"; } + // Clear per-draw state tied to the old tree — see + // `TreemapChart.uploadAndRender` for the same pattern and + // rationale. + this._hoveredNodeId = NULL_NODE; + this._pinnedNodeId = NULL_NODE; + this._breadcrumbRegions = []; + 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++; + dismissSunburstPinnedTooltip(this); + this._chromeCache?.close(); + this._chromeCache = null; + this._chromeCacheDirty = true; + this._chromeCacheGen++; + resetTreeState(this); } @@ -159,10 +212,11 @@ export class SunburstChart extends TreeChartBase { this._currentRootId = NULL_NODE; this._breadcrumbIds = []; this._childLookup.clear(); - this._numericRowData.clear(); - this._stringRowData.clear(); this._visibleNodeIds = null; this._visibleNodeCount = 0; this._breadcrumbRegions = []; + this._facetGrid = null; + this._facetDrillRoots.clear(); + this._facets = []; } } 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 c6e43ab673..dca2fe6870 100644 --- a/packages/viewer-charts/src/ts/charts/treemap/treemap-interact.ts +++ b/packages/viewer-charts/src/ts/charts/treemap/treemap-interact.ts @@ -13,7 +13,6 @@ import type { TreemapChart } from "./treemap"; import { NULL_NODE } from "../common/node-store"; import { PADDING_LABEL, rebuildBreadcrumbs } from "./treemap-layout"; -import { resolveTheme } from "../../theme/theme"; import { formatTickValue } from "../../layout/ticks"; import { renderTreemapFrame, @@ -30,6 +29,10 @@ interface HitResult { * Find the smallest leaf AND deepest branch at `(mx, my)`. Walks the * (already LOD-filtered) `_visibleNodeIds` — at 2M total nodes this is * still a small linear scan because LOD keeps visible count bounded. + * + * In faceted mode `chart._visibleRootIds[i]` names the drill root that + * owns node `i`, so the "skip the root itself" check works regardless + * of which facet the node belongs to. */ function hitTest(chart: TreemapChart, mx: number, my: number): HitResult { const store = chart._nodeStore; @@ -41,13 +44,14 @@ function hitTest(chart: TreemapChart, mx: number, my: number): HitResult { const firstChild = store.firstChild; const ids = chart._visibleNodeIds; const n = chart._visibleNodeCount; + const baseArr = chart._visibleBaseDepths; + const rootArr = chart._visibleRootIds; let bestLeafId = NULL_NODE; let bestLeafArea = Infinity; let bestBranchId = NULL_NODE; let bestBranchArea = Infinity; let labelBranchId = NULL_NODE; - const baseDepth = depth[chart._currentRootId]; if (!ids) { return { leafId: NULL_NODE, branchId: NULL_NODE, inHeader: false }; @@ -55,7 +59,8 @@ function hitTest(chart: TreemapChart, mx: number, my: number): HitResult { for (let i = 0; i < n; i++) { const id = ids[i]; - if (id === chart._currentRootId) continue; + const rootId = rootArr ? rootArr[i] : chart._currentRootId; + if (id === rootId) continue; if (!(mx >= x0[id] && mx <= x1[id] && my >= y0[id] && my <= y1[id])) continue; @@ -65,6 +70,9 @@ function hitTest(chart: TreemapChart, mx: number, my: number): HitResult { bestBranchArea = area; bestBranchId = id; } + const baseDepth = baseArr + ? baseArr[i] + : depth[chart._currentRootId]; const relDepth = depth[id] - baseDepth; if (relDepth === 1 && my <= y0[id] + PADDING_LABEL) { labelBranchId = id; @@ -127,10 +135,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"; } + 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. + buildTreemapTooltipLines(chart, best).then((lines) => { + if (serial !== chart._hoveredTooltipSerial) return; + chart._hoveredTooltipLines = lines; + renderTreemapChromeOverlay(chart); + }); + } renderTreemapChromeOverlay(chart); } } @@ -197,7 +219,33 @@ export function handleTreemapDblClick( } } +/** + * Drill the current facet (or the whole chart in non-facet mode). + * + * In faceted mode, walks up the ancestor chain of `nodeId` until the + * facet root (a top-level child of `_rootId`) is found, then sets + * `_facetDrillRoots[facetLabel] = nodeId` so only that facet's + * subtree re-layouts. Non-facet mode keeps the existing single- + * `_currentRootId` behavior and rebuilds the breadcrumb trail. + */ function drillTo(chart: TreemapChart, nodeId: number): void { + const store = chart._nodeStore; + if (chart._splitBy.length > 0 && chart._facetConfig.facet_mode === "grid") { + // Walk up to find the facet-root ancestor (top-level child of + // `_rootId`). Guard against drills that target the synthetic + // root or a facet root itself — those would un-drill the facet. + let p = nodeId; + 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); + return; + } chart._currentRootId = nodeId; rebuildBreadcrumbs(chart, nodeId); chart._hoveredNodeId = NULL_NODE; @@ -211,13 +259,6 @@ export function showTreemapPinnedTooltip( chart._tooltip.dismissPinned(); chart._pinnedNodeId = nodeId; - const themeEl = chart._gridlineCanvas || chart._chromeCanvas; - if (!themeEl) return; - const theme = resolveTheme(themeEl); - - const lines = buildTreemapTooltipLines(chart, nodeId); - if (lines.length === 0) return; - const parent = chart._glCanvas?.parentElement; if (!parent) return; @@ -228,13 +269,20 @@ export function showTreemapPinnedTooltip( const cssWidth = (chart._glCanvas?.width || 100) / dpr; const cssHeight = (chart._glCanvas?.height || 100) / dpr; - chart._tooltip.showPinned( - parent, - lines, - { px: cx, py: cy }, - { cssWidth, cssHeight }, - theme, - ); + // 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 }, + ); + }); chart._hoveredNodeId = NULL_NODE; renderTreemapChromeOverlay(chart); @@ -250,10 +298,10 @@ export function dismissTreemapPinnedTooltip(chart: TreemapChart): void { * value are derived from the tree; per-row tooltip columns come from * the `leafRowIdx` → column-buffer lookup (no per-node `Map`). */ -export function buildTreemapTooltipLines( +export async function buildTreemapTooltipLines( chart: TreemapChart, nodeId: number, -): string[] { +): Promise { const store = chart._nodeStore; const lines: string[] = []; @@ -273,52 +321,37 @@ export function buildTreemapTooltipLines( lines.push(`Value: ${formatTickValue(store.value[nodeId])}`); + // Color value (numeric branch): stored on the node at insert + // time, so it's always available without a view fetch. + if (chart._colorName && !isNaN(store.colorValue[nodeId])) { + lines.push( + `${chart._colorName}: ${formatTickValue(store.colorValue[nodeId])}`, + ); + } + const rowIdx = store.leafRowIdx[nodeId]; const isLeaf = store.firstChild[nodeId] === NULL_NODE && rowIdx !== NULL_NODE; - // Size column value, from leaf's source row if available. - if (isLeaf && chart._sizeName) { - const numeric = chart._numericRowData.get(chart._sizeName); - if (numeric) { - lines.push( - `${chart._sizeName}: ${formatTickValue(numeric[rowIdx])}`, - ); - } else { - const str = chart._stringRowData.get(chart._sizeName); - if (str && str[rowIdx] !== undefined) { - lines.push(`${chart._sizeName}: ${str[rowIdx]}`); + // Extra tooltip columns come from the source view row, fetched on + // demand via `_lazyRows`. Only leaves correspond to a single view + // row; branch nodes aggregate rows and don't carry extra columns. + if (isLeaf && chart._lazyRows) { + const row = await chart._lazyRows.fetchRow(rowIdx); + for (const [name, value] of row) { + if (value === null || value === undefined) continue; + if (name === chart._colorName && !isNaN(store.colorValue[nodeId])) { + // Already emitted from the retained tree state above. + continue; } - } - } - - // Color column value / label. - if (chart._colorName) { - if (!isNaN(store.colorValue[nodeId])) { - lines.push( - `${chart._colorName}: ${formatTickValue(store.colorValue[nodeId])}`, - ); - } else if (isLeaf) { - const str = chart._stringRowData.get(chart._colorName); - if (str && str[rowIdx] !== undefined) { - lines.push(`${chart._colorName}: ${str[rowIdx]}`); + if (typeof value === "number") { + lines.push(`${name}: ${formatTickValue(value)}`); + } else { + lines.push(`${name}: ${value}`); } } } - // Extra tooltip columns (leaf-only). - if (isLeaf) { - for (const [name, arr] of chart._numericRowData) { - if (name === chart._sizeName || name === chart._colorName) continue; - lines.push(`${name}: ${formatTickValue(arr[rowIdx])}`); - } - for (const [name, arr] of chart._stringRowData) { - if (name === chart._sizeName || name === chart._colorName) continue; - const v = arr[rowIdx]; - if (v !== undefined) lines.push(`${name}: ${v}`); - } - } - if (store.firstChild[nodeId] !== NULL_NODE) { lines.push(`Children: ${store.childCount[nodeId]}`); } 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 81b3b5d224..190cfdbe17 100644 --- a/packages/viewer-charts/src/ts/charts/treemap/treemap-layout.ts +++ b/packages/viewer-charts/src/ts/charts/treemap/treemap-layout.ts @@ -56,6 +56,7 @@ export function squarify( y1: number, baseDepth: number, scratch: Int32Array, + showBranchHeader: boolean, ): void { store.x0[id] = Math.round(x0); store.y0[id] = Math.round(y0); @@ -68,7 +69,10 @@ export function squarify( if (area < MIN_VISIBLE_AREA) return; const relDepth = store.depth[id] - baseDepth; - const showHeader = relDepth === 1 && store.firstChild[id] !== NULL_NODE; + const showHeader = + showBranchHeader && + relDepth === 1 && + store.firstChild[id] !== NULL_NODE; const padTop = showHeader ? PADDING_LABEL : PADDING_INNER; const padOuter = showHeader ? PADDING_OUTER : PADDING_INNER; @@ -101,6 +105,7 @@ export function squarify( iy1, baseDepth, scratch.subarray(activeCount), + showBranchHeader, ); } @@ -115,11 +120,22 @@ function layoutOrdered( y1: number, baseDepth: number, childScratch: Int32Array, + showBranchHeader: boolean, ): void { const n = hi - lo; if (n === 0) return; if (n === 1) { - squarify(store, nodes[lo], x0, y0, x1, y1, baseDepth, childScratch); + squarify( + store, + nodes[lo], + x0, + y0, + x1, + y1, + baseDepth, + childScratch, + showBranchHeader, + ); return; } @@ -158,6 +174,7 @@ function layoutOrdered( y1, baseDepth, childScratch, + showBranchHeader, ); layoutOrdered( store, @@ -170,6 +187,7 @@ function layoutOrdered( y1, baseDepth, childScratch, + showBranchHeader, ); } else { const splitY = Math.round(y0 + rh * fraction); @@ -184,6 +202,7 @@ function layoutOrdered( splitY, baseDepth, childScratch, + showBranchHeader, ); layoutOrdered( store, @@ -196,6 +215,7 @@ function layoutOrdered( y1, baseDepth, childScratch, + showBranchHeader, ); } } @@ -205,6 +225,10 @@ function layoutOrdered( /** * Walk from `startId` depth-first, emitting every descendant whose rect * area is above `MIN_VISIBLE_AREA`. O(visible), not O(total). + * + * Faceted render paths call {@link collectVisibleAppend} once per facet + * and do the final `_visibleNodeCount` bookkeeping themselves; this + * single-facet entry point wraps that for non-split trees. */ export function collectVisible( chart: TreemapChart, @@ -212,6 +236,28 @@ export function collectVisible( maxDepth: number, baseDepth: number, ): void { + chart._visibleNodeCount = collectVisibleAppend( + chart, + startId, + maxDepth, + baseDepth, + 0, + ); +} + +/** + * Append the visible-node IDs below `startId` into `_visibleNodeIds` + * starting at `startOffset`. Returns the new length. Used by faceted + * treemap rendering to concatenate per-facet visibility without doing + * a second pass. + */ +export function collectVisibleAppend( + chart: TreemapChart, + startId: number, + maxDepth: number, + baseDepth: number, + startOffset: number, +): number { const store = chart._nodeStore; const x0 = store.x0; const y0 = store.y0; @@ -227,7 +273,7 @@ export function collectVisible( } const out = chart._visibleNodeIds; - let outIdx = 0; + let outIdx = startOffset; let stack = new Int32Array(128); stack[0] = startId; @@ -258,5 +304,5 @@ export function collectVisible( } } - chart._visibleNodeCount = outIdx; + return outIdx; } 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 c86409ac52..cbf47de265 100644 --- a/packages/viewer-charts/src/ts/charts/treemap/treemap-render.ts +++ b/packages/viewer-charts/src/ts/charts/treemap/treemap-render.ts @@ -13,7 +13,11 @@ import type { WebGLContextManager } from "../../webgl/context-manager"; import type { TreemapChart } from "./treemap"; import { NULL_NODE } from "../common/node-store"; -import { squarify, collectVisible } from "./treemap-layout"; +import { + squarify, + collectVisible, + collectVisibleAppend, +} from "./treemap-layout"; import { resolveTheme, readSeriesPalette } from "../../theme/theme"; import { resolvePalette, type Vec3 } from "../../theme/palette"; import { @@ -21,11 +25,15 @@ import { sampleGradient, type GradientStop, } from "../../theme/gradient"; -import { renderLegend, renderCategoricalLegend } from "../../chrome/legend"; +import { + renderLegend, + renderCategoricalLegend, + renderCategoricalLegendAt, +} from "../../chrome/legend"; import { PlotLayout } from "../../layout/plot-layout"; +import { buildFacetGrid } from "../../layout/facet-grid"; import treemapVert from "../../shaders/treemap.vert.glsl"; import treemapFrag from "../../shaders/treemap.frag.glsl"; -import { buildTreemapTooltipLines } from "./treemap-interact"; type GL = WebGL2RenderingContext | WebGLRenderingContext; @@ -44,6 +52,10 @@ function sampleRGB(stops: GradientStop[], t: number): [number, number, number] { * - `"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, @@ -67,8 +79,31 @@ function leafColor( 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. + * + * When `_splitBy` is populated the top-level children of `_rootId` + * become facet roots; each is squarified into its own cell rect via + * {@link buildFacetGrid}. The visible-node list is concatenated across + * facets so a single vertex buffer + draw call covers the whole scene. */ export function renderTreemapFrame( chart: TreemapChart, @@ -84,7 +119,8 @@ export function renderTreemapFrame( if (cssWidth <= 0 || cssHeight <= 0) return; const store = chart._nodeStore; - const baseDepth = store.depth[chart._currentRootId]; + const hasSplits = + chart._splitBy.length > 0 && chart._facetConfig.facet_mode === "grid"; const breadcrumbH = chart._breadcrumbIds.length > 1 ? 28 : 0; const hasLegend = @@ -99,18 +135,38 @@ export function renderTreemapFrame( // visible-id buffer as scratch when large enough. const scratch = new Int32Array(Math.max(store.count, 64)); - squarify( - store, - chart._currentRootId, - 0, - breadcrumbH, - cssWidth - legendW, - cssHeight, - baseDepth, - scratch, - ); - - collectVisible(chart, chart._currentRootId, 100, baseDepth); + if (hasSplits) { + layoutFaceted( + chart, + scratch, + cssWidth, + cssHeight, + breadcrumbH, + legendW, + ); + } else { + chart._facetGrid = null; + const baseDepth = store.depth[chart._currentRootId]; + squarify( + store, + chart._currentRootId, + 0, + breadcrumbH, + cssWidth - legendW, + cssHeight, + baseDepth, + scratch, + chart._showBranchHeader, + ); + collectVisible(chart, chart._currentRootId, 100, baseDepth); + ensureVisibleMetadata(chart); + const baseArr = chart._visibleBaseDepths!; + const rootArr = chart._visibleRootIds!; + for (let k = 0; k < chart._visibleNodeCount; k++) { + baseArr[k] = baseDepth; + rootArr[k] = chart._currentRootId; + } + } if (!chart._program) { chart._program = glManager.shaders.getOrCreate( @@ -148,7 +204,7 @@ export function renderTreemapFrame( chart._chromeCacheDirty = true; - generateAndUploadTreemap(chart, gl, stops, palette); + generateAndUploadTreemap(chart, gl, stops, palette, theme.areaOpacity); gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT); @@ -170,46 +226,162 @@ export function renderTreemapFrame( gl.bindBuffer(gl.ARRAY_BUFFER, chart._colorBuffer); gl.enableVertexAttribArray(chart._locations!.a_color); - gl.vertexAttribPointer(chart._locations!.a_color, 3, gl.FLOAT, false, 0, 0); + gl.vertexAttribPointer(chart._locations!.a_color, 4, gl.FLOAT, false, 0, 0); gl.drawArrays(gl.TRIANGLES, 0, chart._vertexCount); renderTreemapChromeOverlay(chart); } +/** + * Faceted layout: each top-level child of `_rootId` is one facet. + * Squarify per cell into the cell's rect and concatenate visible + * nodes, so downstream rendering and hit-testing treat the scene as + * one flat visible list. + * + * `_visibleBaseDepths` and `_visibleRootIds` are filled in parallel so + * render code can compute the relative depth of each node without + * knowing its owning facet. Non-facet callers leave these as copies of + * the single `_currentRootId` depth. + */ +function layoutFaceted( + chart: TreemapChart, + scratch: Int32Array, + cssWidth: number, + cssHeight: number, + breadcrumbH: number, + 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[] = []; + const labels: string[] = []; + for ( + let c = store.firstChild[chart._rootId]; + c !== NULL_NODE; + c = store.nextSibling[c] + ) { + if (store.value[c] <= 0) continue; + facetIds.push(c); + labels.push(store.name[c]); + } + + const gridHeight = Math.max(1, cssHeight - breadcrumbH); + const gridWidth = Math.max(1, cssWidth - legendW); + const grid = buildFacetGrid(labels, { + cssWidth: gridWidth, + cssHeight: gridHeight, + hasLegend: false, // legend rect handled separately by the chrome + // Treemap has no X/Y axes — skip the per-cell axis gutters and + // let adjacent cell plot rects sit flush. + xAxis: "none", + yAxis: "none", + gap: chart._facetConfig.facet_padding, + }); + chart._facetGrid = grid; + + 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; + 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. + squarify( + store, + drillRoot, + plot.x, + plot.y + breadcrumbH, + plot.x + plot.width, + plot.y + breadcrumbH + plot.height, + baseDepth, + scratch, + chart._showBranchHeader, + ); + const nextIdx = collectVisibleAppend( + chart, + drillRoot, + 100, + 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; +} + +function ensureVisibleMetadata(chart: TreemapChart): void { + const need = chart._visibleNodeIds?.length ?? chart._nodeStore.count; + if (!chart._visibleBaseDepths || chart._visibleBaseDepths.length < need) { + chart._visibleBaseDepths = new Int32Array(need); + } + if (!chart._visibleRootIds || chart._visibleRootIds.length < need) { + chart._visibleRootIds = new Int32Array(need); + } +} + function generateAndUploadTreemap( chart: TreemapChart, gl: GL, stops: GradientStop[], palette: Vec3[], + negativeAlpha: number, ): void { const store = chart._nodeStore; const ids = chart._visibleNodeIds!; const n = chart._visibleNodeCount; - const baseDepth = store.depth[chart._currentRootId]; + const baseArr = chart._visibleBaseDepths; + const rootArr = chart._visibleRootIds; + + const baseDepthOf = (i: number): number => + baseArr ? baseArr[i] : store.depth[chart._currentRootId]; + const rootOf = (i: number): number => + rootArr ? rootArr[i] : chart._currentRootId; // Count the rects we'll emit so we can size the buffers exactly. let rectCount = 0; for (let i = 0; i < n; i++) { const id = ids[i]; - if (id === chart._currentRootId) 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 (store.firstChild[id] === NULL_NODE) { rectCount++; - } else if (store.depth[id] - baseDepth === 1) { + } else if (store.depth[id] - baseDepthOf(i) === 1) { rectCount += 2; } } const positions = new Float32Array(rectCount * 6 * 2); - const colors = new Float32Array(rectCount * 6 * 3); + // 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); let vi = 0; for (let i = 0; i < n; i++) { const id = ids[i]; - if (id === chart._currentRootId) continue; + if (id === rootOf(i)) continue; const sx0 = store.x0[id]; const sy0 = store.y0[id]; const sx1 = store.x1[id]; @@ -219,7 +391,7 @@ function generateAndUploadTreemap( if (w < 1 || h < 1) continue; if (store.firstChild[id] === NULL_NODE) { - const color = leafColor(chart, id, stops, palette); + const color = leafRGBA(chart, id, stops, palette, negativeAlpha); vi = emitRect( positions, colors, @@ -231,10 +403,11 @@ function generateAndUploadTreemap( color, ); } else { - const relDepth = store.depth[id] - baseDepth; + const relDepth = store.depth[id] - baseDepthOf(i); if (relDepth === 1) { - const borderColor: [number, number, number] = [ - 0.25, 0.25, 0.25, + // Branch borders are structural; always opaque. + const borderColor: [number, number, number, number] = [ + 0.25, 0.25, 0.25, 1.0, ]; vi = emitRect( positions, @@ -272,7 +445,7 @@ function generateAndUploadTreemap( if (!chart._colorBuffer) chart._colorBuffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, chart._colorBuffer); - gl.bufferData(gl.ARRAY_BUFFER, colors.subarray(0, vi * 3), gl.DYNAMIC_DRAW); + gl.bufferData(gl.ARRAY_BUFFER, colors.subarray(0, vi * 4), gl.DYNAMIC_DRAW); } function emitRect( @@ -283,10 +456,10 @@ function emitRect( y0: number, x1: number, y1: number, - color: [number, number, number], + color: [number, number, number, number], ): number { const pi = vi * 2; - const ci = vi * 3; + const ci = vi * 4; positions[pi + 0] = x0; positions[pi + 1] = y0; @@ -303,9 +476,10 @@ function emitRect( positions[pi + 11] = y1; for (let v = 0; v < 6; v++) { - colors[ci + v * 3 + 0] = color[0]; - colors[ci + v * 3 + 1] = color[1]; - colors[ci + v * 3 + 2] = color[2]; + colors[ci + v * 4 + 0] = color[0]; + colors[ci + v * 4 + 1] = color[1]; + colors[ci + v * 4 + 2] = color[2]; + colors[ci + v * 4 + 3] = color[3]; } return vi + 6; @@ -341,10 +515,17 @@ export function renderTreemapChromeOverlay(chart: TreemapChart): void { 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._chromeCacheDirty) { + 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(); @@ -368,18 +549,19 @@ export function renderTreemapChromeOverlay(chart: TreemapChart): void { renderHoverHighlight(ctx, store, highlightId); - const baseDepth = store.depth[chart._currentRootId]; 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]; - if ( - id === chart._currentRootId || - store.firstChild[id] === NULL_NODE - ) - continue; + 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( @@ -390,7 +572,7 @@ export function renderTreemapChromeOverlay(chart: TreemapChart): void { nh, fontFamily, textColor, - false, + !chart._showBranchHeader, ); } else if (relDepth === 2) { renderBranchLabel( @@ -471,24 +653,28 @@ function drawStaticChrome( ); const store = chart._nodeStore; - const baseDepth = store.depth[chart._currentRootId]; 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]; - if (id === chart._currentRootId || store.firstChild[id] !== NULL_NODE) - continue; + const rootId = rootArr ? rootArr[i] : chart._currentRootId; + 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]; - if (id === chart._currentRootId || store.firstChild[id] === NULL_NODE) - continue; + const rootId = rootArr ? rootArr[i] : chart._currentRootId; + 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 + ? baseArr[i] + : store.depth[chart._currentRootId]; const relDepth = store.depth[id] - baseDepth; if (relDepth === 1) { renderBranchLabel( @@ -499,7 +685,7 @@ function drawStaticChrome( h, fontFamily, textColor, - false, + !chart._showBranchHeader, ); } else if (relDepth === 2) { renderBranchLabel( @@ -555,6 +741,19 @@ function drawStaticChrome( ); } + // Per-facet titles (rendered over the layout; painted in the static + // chrome bitmap so they appear alongside leaf labels). + if (chart._facetGrid) { + ctx.font = `11px ${fontFamily}`; + ctx.fillStyle = textColor; + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + for (const cell of chart._facetGrid.cells) { + const plot = cell.layout.plotRect; + ctx.fillText(cell.label, plot.x + plot.width / 2, plot.y - 14); + } + } + ctx.restore(); } @@ -690,7 +889,7 @@ function renderBranchLabel( if (w < 60 || h < 30) return; const fontSize = 12; - ctx.font = `bold ${fontSize}px ${fontFamily}`; + ctx.font = `${fontSize}px ${fontFamily}`; let text = name; const maxW = w - 16; @@ -727,7 +926,7 @@ function renderBranchLabel( if (w < 40 || h < 22) return; const fontSize = 11; - ctx.font = `bold ${fontSize}px ${fontFamily}`; + ctx.font = `${fontSize}px ${fontFamily}`; let text = name; const maxW = w - 10; @@ -779,7 +978,7 @@ function renderBreadcrumbs( const label = store.name[crumbId]; ctx.fillStyle = textColor; - ctx.font = isLast ? `bold 11px ${fontFamily}` : `11px ${fontFamily}`; + ctx.font = isLast ? `11px ${fontFamily}` : `11px ${fontFamily}`; const textW = ctx.measureText(label).width; ctx.fillText(label, x, y); @@ -830,7 +1029,14 @@ function renderTreemapTooltip( const theme = resolveTheme(chart._chromeCanvas!); const { tooltipBg, tooltipText, tooltipBorder } = theme; - const lines = buildTreemapTooltipLines(chart, nodeId); + // 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}`; diff --git a/packages/viewer-charts/src/ts/charts/treemap/treemap.ts b/packages/viewer-charts/src/ts/charts/treemap/treemap.ts index 0d0cbdc19c..611bd31891 100644 --- a/packages/viewer-charts/src/ts/charts/treemap/treemap.ts +++ b/packages/viewer-charts/src/ts/charts/treemap/treemap.ts @@ -34,6 +34,18 @@ export interface TreemapLocations { a_color: number; } +/** + * Sentinel fallback for the Size slot when the user hasn't picked one: + * use the first non-metadata column in the incoming view. Treemap + * still needs *some* numeric-ish column to size rects. + */ +function firstNonMetadataColumn(columns: ColumnDataMap): string { + for (const k of columns.keys()) { + if (!k.startsWith("__")) return k; + } + return ""; +} + /** * Treemap chart. Shares tree storage + streaming-pipeline + color-mode * state with `TreeChartBase`; adds rectangular layout + WebGL quad @@ -51,10 +63,53 @@ export class TreemapChart extends TreeChartBase { _pinnedNodeId: number = NULL_NODE; _breadcrumbRegions: BreadcrumbRegion[] = []; _dblClickHandler: ((e: MouseEvent) => void) | null = null; - _chromeCache: ImageBitmap | null = null; _chromeCacheDirty = true; + /** + * Monotonic generation counter bumped every time the static chrome + * content changes (a new `drawStaticChrome` call). The async + * `createImageBitmap` callback captures the current gen at kickoff + * and only installs the resulting bitmap if its gen is still the + * most-recent one. Without this, out-of-order bitmap resolutions + * can store a stale bitmap in `_chromeCache` — any subsequent + * hover-only overlay call then blits that stale snapshot over the + * fresh chart, producing "leftover labels / cells" artefacts. + */ + _chromeCacheGen = 0; + + // ── 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 + * node inside that facet's subtree. Missing keys mean the facet + * shows its full subtree. + */ + _facetDrillRoots: Map = new Map(); + _facetGrid: import("../../layout/facet-grid").FacetGrid | null = null; + + /** When `false`, branch nodes at relDepth=1 render as a centered + * overlay (same style as relDepth=2) and no top-of-rect label + * reservation is made in `squarify`. Default `true` preserves the + * legacy title-bar look. */ + _showBranchHeader = false; + + /** + * Parallel to `_visibleNodeIds`. Each entry stores the depth of the + * drill root that owns the corresponding visible node, so render + * paths can compute `relDepth` uniformly without knowing whether + * faceting is active. Populated in `renderTreemapFrame` during + * layout. + */ + _visibleBaseDepths: Int32Array | null = null; + /** + * Parallel to `_visibleNodeIds`. The drill-root node id that owns + * each visible node (= `_currentRootId` in non-facet mode, per- + * facet drill root in facet mode). Used by hit-testing and chrome + * to skip the drill-root itself without a separate equality check. + */ + _visibleRootIds: Int32Array | null = null; + attachTooltip(glCanvas: HTMLCanvasElement): void { this._glCanvas = glCanvas; this._tooltip.attach(glCanvas, { @@ -95,12 +150,8 @@ export class TreemapChart extends TreeChartBase { if (startRow === 0) { this._cancelScheduledRender(); - this._allColumns = Array.from(columns.keys()).filter( - (k) => !k.startsWith("__"), - ); - const slots = this._columnSlots; - this._sizeName = slots[0] || this._allColumns[0] || ""; + this._sizeName = slots[0] || firstNonMetadataColumn(columns) || ""; this._colorName = slots[1] || ""; if (!this._colorName) { this._colorMode = "empty"; @@ -114,6 +165,40 @@ export class TreemapChart extends TreeChartBase { this._colorMode = isNumeric ? "numeric" : "series"; } + // Clear per-draw state that's tied to the OLD tree. Node + // IDs from the previous render don't map to anything in + // the fresh tree; leaving them around lets stale drill + // roots, hovered/pinned IDs, breadcrumb regions, the + // cached chrome bitmap, or an old WebGL vertex count bleed + // into the new render as ghost rects / labels / hit + // targets. See tree-data.ts's `resetTreeState` for the + // shared fields; everything below is treemap-specific. + this._hoveredNodeId = NULL_NODE; + this._pinnedNodeId = NULL_NODE; + this._breadcrumbRegions = []; + this._facetDrillRoots.clear(); + 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++; + dismissTreemapPinnedTooltip(this); + this._chromeCache?.close(); + this._chromeCache = null; + this._chromeCacheDirty = true; + this._chromeCacheGen++; + resetTreemapState(this); } @@ -155,10 +240,12 @@ export class TreemapChart extends TreeChartBase { this._currentRootId = NULL_NODE; this._breadcrumbIds = []; this._childLookup.clear(); - this._numericRowData.clear(); - this._stringRowData.clear(); this._visibleNodeIds = null; this._visibleNodeCount = 0; this._breadcrumbRegions = []; + this._facetDrillRoots.clear(); + this._facetGrid = null; + this._visibleBaseDepths = null; + this._visibleRootIds = null; } } diff --git a/packages/viewer-charts/src/ts/chrome/axis-primitives.ts b/packages/viewer-charts/src/ts/chrome/axis-primitives.ts new file mode 100644 index 0000000000..0024cac82f --- /dev/null +++ b/packages/viewer-charts/src/ts/chrome/axis-primitives.ts @@ -0,0 +1,108 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { PlotRect } from "../layout/plot-layout"; + +export const TICK_SIZE = 5; + +/** + * One horizontal row of numeric axis ticks + labels at CSS-pixel `axisY`. + * `side` selects tick direction (down into the bottom margin or up into + * the top margin) and the corresponding label baseline. Caller owns + * `strokeStyle`, `fillStyle`, `font`, and `lineWidth`. + */ +export function drawXTickRow( + ctx: CanvasRenderingContext2D, + plot: PlotRect, + ticks: number[], + axisY: number, + side: "top" | "bottom", + xToPixel: (v: number) => number, + format: (v: number) => string, +): void { + const dir = side === "bottom" ? 1 : -1; + ctx.textAlign = "center"; + ctx.textBaseline = side === "bottom" ? "top" : "bottom"; + 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; + ctx.beginPath(); + ctx.moveTo(px, axisY); + ctx.lineTo(px, axisY + dir * TICK_SIZE); + ctx.stroke(); + ctx.fillText(format(tick), px, axisY + labelOffset); + } +} + +/** + * One vertical column of numeric axis ticks + labels at CSS-pixel `axisX`. + * `side` selects tick direction (out toward the left or right margin) and + * the corresponding label alignment. Caller owns styling state. + */ +export function drawYTickColumn( + ctx: CanvasRenderingContext2D, + plot: PlotRect, + ticks: number[], + axisX: number, + side: "left" | "right", + yToPixel: (v: number) => number, + format: (v: number) => string, +): void { + const dir = side === "left" ? -1 : 1; + ctx.textAlign = side === "left" ? "right" : "left"; + ctx.textBaseline = "middle"; + 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; + ctx.beginPath(); + ctx.moveTo(axisX, py); + ctx.lineTo(axisX + dir * TICK_SIZE, py); + ctx.stroke(); + ctx.fillText(format(tick), axisX + labelOffset, py); + } +} + +/** Vertical gridlines at numeric X ticks, clipped to `plot`. */ +export function drawGridlinesX( + ctx: CanvasRenderingContext2D, + 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; + ctx.beginPath(); + ctx.moveTo(px, plot.y); + ctx.lineTo(px, plot.y + plot.height); + ctx.stroke(); + } +} + +/** Horizontal gridlines at numeric Y ticks, clipped to `plot`. */ +export function drawGridlinesY( + ctx: CanvasRenderingContext2D, + 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; + ctx.beginPath(); + ctx.moveTo(plot.x, py); + ctx.lineTo(plot.x + plot.width, py); + ctx.stroke(); + } +} diff --git a/packages/viewer-charts/src/ts/chrome/bar-axis.ts b/packages/viewer-charts/src/ts/chrome/bar-axis.ts index 64009b881e..4c24a8c409 100644 --- a/packages/viewer-charts/src/ts/chrome/bar-axis.ts +++ b/packages/viewer-charts/src/ts/chrome/bar-axis.ts @@ -18,6 +18,12 @@ import { renderCategoricalYTicks, type CategoricalDomain, } from "./categorical-axis"; +import { + drawGridlinesX, + drawGridlinesY, + drawXTickRow, + drawYTickColumn, +} from "./axis-primitives"; import type { AxisDomain } from "./numeric-axis"; import type { Theme } from "../theme/theme"; @@ -30,53 +36,31 @@ function drawNumericXAxis( side: "top" | "bottom", theme: Theme, ): void { - const { tickColor, labelColor, fontFamily } = theme; + const { labelColor, fontFamily } = theme; const { plotRect: plot } = layout; - const TICK_SIZE = 5; const axisY = side === "bottom" ? plot.y + plot.height : plot.y; - const xToPixel = (val: number) => { - const t = - (val - layout.paddedXMin) / (layout.paddedXMax - layout.paddedXMin); - return plot.x + t * plot.width; - }; ctx.fillStyle = labelColor; ctx.font = `11px ${fontFamily}`; - ctx.textAlign = "center"; - ctx.textBaseline = side === "bottom" ? "top" : "bottom"; ctx.lineWidth = 1; + drawXTickRow( + ctx, + plot, + ticks, + axisY, + side, + (v) => layout.dataToPixel(v, 0).px, + formatTickValue, + ); - for (const tick of ticks) { - const px = xToPixel(tick); - if (px < plot.x - 1 || px > plot.x + plot.width + 1) continue; - ctx.beginPath(); - if (side === "bottom") { - ctx.moveTo(px, axisY); - ctx.lineTo(px, axisY + TICK_SIZE); - ctx.stroke(); - ctx.fillText(formatTickValue(tick), px, axisY + TICK_SIZE + 3); - } else { - ctx.moveTo(px, axisY - TICK_SIZE); - ctx.lineTo(px, axisY); - ctx.stroke(); - ctx.fillText(formatTickValue(tick), px, axisY - TICK_SIZE - 3); - } - } - - // Axis label - ctx.fillStyle = labelColor; ctx.font = `13px ${fontFamily}`; ctx.textAlign = "center"; ctx.textBaseline = "bottom"; - if (side === "bottom") { - ctx.fillText( - domain.label, - plot.x + plot.width / 2, - layout.cssHeight - 2, - ); - } else { - ctx.fillText(domain.label, plot.x + plot.width / 2, 10); - } + ctx.fillText( + domain.label, + plot.x + plot.width / 2, + side === "bottom" ? layout.cssHeight - 2 : 10, + ); } /** @@ -92,42 +76,23 @@ function drawYAxis( side: "left" | "right", theme: Theme, ): void { - const { tickColor, labelColor, fontFamily } = theme; - + const { labelColor, fontFamily } = theme; const { plotRect: plot } = layout; - const TICK_SIZE = 5; const axisX = side === "left" ? plot.x : plot.x + plot.width; - const yToPixel = (val: number) => { - const t = - (val - layout.paddedYMin) / (layout.paddedYMax - layout.paddedYMin); - return plot.y + (1 - t) * plot.height; - }; ctx.fillStyle = labelColor; ctx.font = `11px ${fontFamily}`; - ctx.textAlign = side === "left" ? "right" : "left"; - ctx.textBaseline = "middle"; ctx.lineWidth = 1; + drawYTickColumn( + ctx, + plot, + ticks, + axisX, + side, + (v) => layout.dataToPixel(0, v).py, + formatTickValue, + ); - for (const tick of ticks) { - const py = yToPixel(tick); - if (py < plot.y - 1 || py > plot.y + plot.height + 1) continue; - ctx.beginPath(); - if (side === "left") { - ctx.moveTo(axisX - TICK_SIZE, py); - ctx.lineTo(axisX, py); - ctx.stroke(); - ctx.fillText(formatTickValue(tick), axisX - TICK_SIZE - 3, py); - } else { - ctx.moveTo(axisX, py); - ctx.lineTo(axisX + TICK_SIZE, py); - ctx.stroke(); - ctx.fillText(formatTickValue(tick), axisX + TICK_SIZE + 3, py); - } - } - - // Axis label - ctx.fillStyle = labelColor; ctx.font = `13px ${fontFamily}`; ctx.save(); if (side === "left") { @@ -227,40 +192,21 @@ export function renderBarGridlines( const ctx = initCanvas(canvas, layout); if (!ctx) return; - const { plotRect: plot } = layout; - ctx.strokeStyle = theme.gridlineColor; ctx.lineWidth = 1; - if (isHorizontal) { - const xToPixel = (val: number) => { - const t = - (val - layout.paddedXMin) / - (layout.paddedXMax - layout.paddedXMin); - return plot.x + t * plot.width; - }; - for (const tick of valueTicks) { - const px = Math.round(xToPixel(tick)) + 0.5; - if (px < plot.x || px > plot.x + plot.width) continue; - ctx.beginPath(); - ctx.moveTo(px, plot.y); - ctx.lineTo(px, plot.y + plot.height); - ctx.stroke(); - } + drawGridlinesX( + ctx, + layout.plotRect, + valueTicks, + (v) => layout.dataToPixel(v, 0).px, + ); } else { - const yToPixel = (val: number) => { - const t = - (val - layout.paddedYMin) / - (layout.paddedYMax - layout.paddedYMin); - return plot.y + (1 - t) * plot.height; - }; - for (const tick of valueTicks) { - const py = Math.round(yToPixel(tick)) + 0.5; - if (py < plot.y || py > plot.y + plot.height) continue; - ctx.beginPath(); - ctx.moveTo(plot.x, py); - ctx.lineTo(plot.x + plot.width, py); - ctx.stroke(); - } + drawGridlinesY( + ctx, + layout.plotRect, + valueTicks, + (v) => layout.dataToPixel(0, v).py, + ); } } diff --git a/packages/viewer-charts/src/ts/chrome/canvas.ts b/packages/viewer-charts/src/ts/chrome/canvas.ts index 9b9bf44b0b..ec6f4960b2 100644 --- a/packages/viewer-charts/src/ts/chrome/canvas.ts +++ b/packages/viewer-charts/src/ts/chrome/canvas.ts @@ -13,8 +13,15 @@ import type { PlotLayout } from "../layout/plot-layout"; /** - * Resize a 2D canvas to CSS pixels scaled by DPR and return a pre-cleared, - * DPR-scaled context. Returns null if the 2D context cannot be obtained. + * Destructive per-frame canvas setup: resize to CSS pixels × DPR, + * clear, and return a DPR-scaled 2D context. Call this exactly once + * per canvas per frame — setting `canvas.width` / `canvas.height` + * always wipes the bitmap and resets the transform, so calling it in + * a per-facet loop wipes every previously-drawn facet. + * + * Faceted renderers call this once per frame and then + * {@link getScaledContext} per facet to obtain the same context + * without re-wiping. */ export function initCanvas( canvas: HTMLCanvasElement, @@ -29,3 +36,22 @@ export function initCanvas( ctx.clearRect(0, 0, layout.cssWidth, layout.cssHeight); return ctx; } + +/** + * Non-destructive variant: returns the 2D context with its transform + * forced to `scale(dpr, dpr)` via `setTransform` (idempotent — no + * stacking). Assumes `initCanvas` was already called on this canvas + * this frame; does NOT resize or clear. + * + * Intended for per-facet render helpers that must not wipe the shared + * 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; + 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/chrome/categorical-axis-core.ts index 0a51d08dba..e079a21153 100644 --- a/packages/viewer-charts/src/ts/chrome/categorical-axis-core.ts +++ b/packages/viewer-charts/src/ts/chrome/categorical-axis-core.ts @@ -19,24 +19,27 @@ /** * One run of consecutive equal dictionary indices in a level's `indices` - * array. Used by the outer-level bracket renderer to coalesce a span of - * contiguous cells into a single labelled group. + * array, with the label pre-resolved. Used by the outer-level bracket + * renderer to coalesce a span of contiguous cells into a single labelled + * group. */ export interface GroupRun { startIdx: number; /** Inclusive. */ endIdx: number; - dictIdx: number; + label: string; } /** - * Single-pass run-length encoding of `indices[startRow..endRow)`. Relies - * on perspective's guarantee that rows sharing an outer-level dictionary - * entry are emitted contiguously in traversal order — equal neighbours - * always belong to the same span. + * Single-pass run-length encoding of `indices[startRow..endRow)` keyed + * by `dictionary`. Relies on perspective's guarantee that rows sharing + * an outer-level dictionary entry are emitted contiguously in traversal + * order — equal neighbours always belong to the same span. The emitted + * `label` is a direct reference into `dictionary` (no per-row copy). */ export function buildGroupRuns( - indices: Int32Array, + indices: Int32Array | ArrayLike, + dictionary: string[], startRow: number, endRow: number, ): GroupRun[] { @@ -50,13 +53,17 @@ export function buildGroupRuns( runs.push({ startIdx: runStart, endIdx: r - 1, - dictIdx: runDict, + label: dictionary[runDict] ?? "", }); runStart = r; runDict = d; } } - runs.push({ startIdx: runStart, endIdx: endRow - 1, dictIdx: runDict }); + runs.push({ + startIdx: runStart, + endIdx: endRow - 1, + label: dictionary[runDict] ?? "", + }); return runs; } @@ -72,3 +79,30 @@ export function maxDictLength(dictionary: string[]): number { } return m; } + +/** + * Filter a precomputed `runs` array to those whose index range + * intersects `[visMin, visMax]` (inclusive on both sides). Runs that + * straddle an endpoint are clipped so the caller sees `startIdx`/ + * `endIdx` pinned to the visible slice — this matches the legacy + * `buildGroupRuns(indices, visMin, visMax + 1)` return shape. + */ +export function runsInRange( + runs: GroupRun[], + visMin: number, + visMax: number, +): GroupRun[] { + if (visMax < visMin) return []; + const out: GroupRun[] = []; + for (const run of runs) { + 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) { + out.push(run); + } else { + 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/chrome/categorical-axis.ts index c2840a8c7e..54ab5ed1d0 100644 --- a/packages/viewer-charts/src/ts/chrome/categorical-axis.ts +++ b/packages/viewer-charts/src/ts/chrome/categorical-axis.ts @@ -18,17 +18,30 @@ import { rotatedLabelsOverlap, truncateLabel, } from "./label-geometry"; -import { buildGroupRuns, maxDictLength } from "./categorical-axis-core"; +import { type GroupRun, runsInRange } from "./categorical-axis-core"; import type { Theme } from "../theme/theme"; /** - * A level of the group_by hierarchy. The same shape as the string columns - * in `ColumnDataMap`: `indices[r]` is the dictionary key for row `r`. - * Levels are ordered outermost-first (level 0 = outermost, level N-1 = leaf). + * 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 { - indices: Int32Array; - dictionary: string[]; + labels: string[]; + runs: GroupRun[]; + maxLabelChars: number; } export interface CategoricalDomain { @@ -90,9 +103,10 @@ export function measureCategoricalLevels( const result: LevelTickLayout[] = []; for (let l = 0; l < L; l++) { const lev = domain.levels[l]; - const longest = maxDictLength(lev.dictionary); if (l === L - 1) { - result.push(leafLevelLayout(domain.numRows, longest, plotWidth)); + result.push( + leafLevelLayout(domain.numRows, lev.maxLabelChars, plotWidth), + ); } else { result.push({ size: OUTER_LEVEL_HEIGHT, rotation: 0 }); } @@ -142,7 +156,7 @@ function selectLeafTickIndices( } function getLeafText(level: CategoricalLevel, row: number): string { - return level.dictionary[level.indices[row]] ?? ""; + return level.labels[row] ?? ""; } /** @@ -234,12 +248,13 @@ function renderLeafLevel( ): void { const { plotRect: plot } = layout; - // Estimate avg label width from the dictionary (cheap, one pass). + // 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 longest = maxDictLength(level.dictionary); const avgLabelPx = Math.max( 40, - Math.min(longest * avgCharWidth + 8, plot.width / 2), + Math.min(level.maxLabelChars * avgCharWidth + 8, plot.width / 2), ); const tickRows = @@ -311,7 +326,7 @@ function renderOuterLevel( tickColor: string, ): void { const { plotRect: plot } = layout; - const runs = buildGroupRuns(level.indices, visMin, visMax + 1); + const runs = runsInRange(level.runs, visMin, visMax); if (runs.length === 0) return; ctx.strokeStyle = tickColor; @@ -361,7 +376,7 @@ function renderOuterLevel( if (xRight <= xLeft) continue; const cx = (xLeft + xRight) / 2; - const text = level.dictionary[run.dictIdx] ?? ""; + const text = run.label; if (!text) continue; const available = xRight - xLeft - 4; @@ -413,7 +428,7 @@ export function measureCategoricalLevelWidths( const charPx = 6.2; for (let l = 0; l < L; l++) { if (l === L - 1) { - const longest = maxDictLength(domain.levels[l].dictionary); + const longest = domain.levels[l].maxLabelChars; widths.push( Math.max( LEAF_LEVEL_WIDTH_MIN, @@ -578,7 +593,7 @@ function renderOuterLevelY( tickColor: string, ): void { const { plotRect: plot } = layout; - const runs = buildGroupRuns(level.indices, visMin, visMax + 1); + const runs = runsInRange(level.runs, visMin, visMax); if (runs.length === 0) return; ctx.strokeStyle = tickColor; @@ -614,7 +629,7 @@ function renderOuterLevelY( if (yBot <= yTop) continue; const cy = (yTop + yBot) / 2; - const text = level.dictionary[run.dictIdx] ?? ""; + const text = run.label; if (!text) continue; const available = colWidth - 6; diff --git a/packages/viewer-charts/src/ts/chrome/legend.ts b/packages/viewer-charts/src/ts/chrome/legend.ts index 9351cb74e6..c1615c0c45 100644 --- a/packages/viewer-charts/src/ts/chrome/legend.ts +++ b/packages/viewer-charts/src/ts/chrome/legend.ts @@ -10,7 +10,7 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import type { PlotLayout } from "../layout/plot-layout"; +import type { PlotLayout, PlotRect } from "../layout/plot-layout"; import { formatTickValue } from "../layout/ticks"; import { colorValueToT, @@ -26,12 +26,39 @@ function rgbCss(c: [number, number, number, number]): string { * Render a vertical color gradient legend on the Canvas2D overlay. * Only call when a color column is active. When `colorDomain` crosses * zero the 50% stop (sign pivot) is annotated with a tick + `0` label. + * + * Per-facet wrapper; computes the anchor from `layout` and delegates + * to {@link renderLegendAt}. Facet grids render one shared gradient + * legend and pass an explicit rect to `renderLegendAt` directly. */ export function renderLegend( canvas: HTMLCanvasElement, layout: PlotLayout, colorDomain: { min: number; max: number; label: string }, stops: GradientStop[], +): void { + const rect: PlotRect = { + x: layout.plotRect.x + layout.plotRect.width + 12, + y: layout.margins.top + 20, + width: Math.max( + 1, + layout.cssWidth - layout.plotRect.x - layout.plotRect.width - 12, + ), + height: Math.max(1, layout.plotRect.height), + }; + renderLegendAt(canvas, rect, colorDomain, stops); +} + +/** + * Render a gradient legend at an explicit canvas-absolute rect. + * Used by facet grids that paint one legend for the whole grid and + * by single-plot charts through {@link renderLegend}. + */ +export function renderLegendAt( + canvas: HTMLCanvasElement, + rect: PlotRect, + colorDomain: { min: number; max: number; label: string }, + stops: GradientStop[], ): void { const ctx = canvas.getContext("2d"); if (!ctx) return; @@ -48,9 +75,9 @@ export function renderLegend( "monospace"; const barWidth = 16; - const barHeight = Math.min(120, layout.plotRect.height * 0.4); - const x = layout.plotRect.x + layout.plotRect.width + 12; - const y = layout.margins.top + 20; + const barHeight = Math.min(120, rect.height * 0.4); + const x = rect.x; + const y = rect.y; ctx.fillStyle = textColor; ctx.font = `11px ${fontFamily}`; @@ -113,12 +140,40 @@ export function renderLegend( /** * Render a categorical legend with discrete colored swatches. * Used when split_by or string color columns produce distinct categories. + * + * The per-facet wrapper; computes the anchor from `layout` and delegates + * to {@link renderCategoricalLegendAt}. Facet grids that render one + * shared legend pass an explicit rect to `renderCategoricalLegendAt` + * directly. */ export function renderCategoricalLegend( canvas: HTMLCanvasElement, layout: PlotLayout, labels: Map, palette: [number, number, number][], +): void { + const rect: PlotRect = { + x: layout.plotRect.x + layout.plotRect.width + 12, + y: layout.margins.top + 10, + width: Math.max( + 1, + layout.cssWidth - layout.plotRect.x - layout.plotRect.width - 12, + ), + height: Math.max(1, layout.plotRect.height), + }; + renderCategoricalLegendAt(canvas, rect, labels, palette); +} + +/** + * Render a categorical legend at an explicit canvas-absolute rect. + * Used by facet grids that paint one legend for the whole grid and by + * single-plot charts through {@link renderCategoricalLegend}. + */ +export function renderCategoricalLegendAt( + canvas: HTMLCanvasElement, + rect: PlotRect, + labels: Map, + palette: [number, number, number][], ): void { const ctx = canvas.getContext("2d"); if (!ctx) return; @@ -134,14 +189,15 @@ export function renderCategoricalLegend( const swatchSize = 10; const lineHeight = 18; - const x = layout.plotRect.x + layout.plotRect.width + 12; - let y = layout.margins.top + 10; + const x = rect.x; + let y = rect.y + lineHeight / 2; ctx.font = `11px ${fontFamily}`; ctx.textAlign = "left"; ctx.textBaseline = "middle"; for (const [label, idx] of labels) { + 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/chrome/numeric-axis.ts b/packages/viewer-charts/src/ts/chrome/numeric-axis.ts index cb90b113db..1523c9799c 100644 --- a/packages/viewer-charts/src/ts/chrome/numeric-axis.ts +++ b/packages/viewer-charts/src/ts/chrome/numeric-axis.ts @@ -10,13 +10,19 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import { PlotLayout } from "../layout/plot-layout"; +import { PlotLayout, type PlotRect } from "../layout/plot-layout"; import { computeNiceTicks, formatTickValue, formatDateTickValue, } from "../layout/ticks"; -import { initCanvas } from "./canvas"; +import { getScaledContext } from "./canvas"; +import { + drawGridlinesX, + drawGridlinesY, + drawXTickRow, + drawYTickColumn, +} from "./axis-primitives"; import type { Theme } from "../theme/theme"; export interface AxisDomain { @@ -51,6 +57,11 @@ export function computeTicks( /** * 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, @@ -59,38 +70,150 @@ export function renderGridlines( yTicks: number[], theme: Theme, ): void { - const ctx = initCanvas(canvas, layout); + const ctx = getScaledContext(canvas); if (!ctx) return; const { plotRect: plot } = layout; - const xToPixel = (val: number) => layout.dataToPixel(val, 0).px; - const yToPixel = (val: number) => layout.dataToPixel(0, val).py; - 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(); - for (const tick of xTicks) { - const px = Math.round(xToPixel(tick)) + 0.5; - if (px < plot.x || px > plot.x + plot.width) continue; - ctx.beginPath(); - ctx.moveTo(px, plot.y); - ctx.lineTo(px, 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; - for (const tick of yTicks) { - const py = Math.round(yToPixel(tick)) + 0.5; - if (py < plot.y || py > plot.y + plot.height) continue; - ctx.beginPath(); - ctx.moveTo(plot.x, py); - ctx.lineTo(plot.x + plot.width, py); - ctx.stroke(); + 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, @@ -101,84 +224,154 @@ export function renderAxesChrome( yTicks: number[], theme: Theme, ): void { - const ctx = initCanvas(canvas, layout); + // `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, - gridlineColor: lineColor, + 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 { plotRect: plot } = layout; - const TICK_SIZE = 5; - - const xToPixel = (val: number) => layout.dataToPixel(val, 0).px; - const yToPixel = (val: number) => layout.dataToPixel(0, val).py; + const axisY = rect.y; - // Axis lines + // Axis line: one span across the entire outer band. ctx.strokeStyle = lineColor; ctx.lineWidth = 1; ctx.beginPath(); - ctx.moveTo(plot.x, plot.y); - ctx.lineTo(plot.x, plot.y + plot.height); - ctx.lineTo(plot.x + plot.width, plot.y + plot.height); + ctx.moveTo(rect.x, axisY); + ctx.lineTo(rect.x + rect.width, axisY); ctx.stroke(); - // X tick marks and labels + // 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.font = `11px ${fontFamily}`; - ctx.textAlign = "center"; - ctx.textBaseline = "top"; 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, + ); + } - const xStep = xTicks.length > 1 ? xTicks[1] - xTicks[0] : 0; + // 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 fmtX = xDomain.isDate - ? (v: number) => formatDateTickValue(v, xStep) - : formatTickValue; const fmtY = yDomain.isDate ? (v: number) => formatDateTickValue(v, yStep) : formatTickValue; - for (const tick of xTicks) { - const px = xToPixel(tick); - if (px < plot.x - 1 || px > plot.x + plot.width + 1) continue; - ctx.beginPath(); - ctx.moveTo(px, plot.y + plot.height); - ctx.lineTo(px, plot.y + plot.height + TICK_SIZE); - ctx.stroke(); - ctx.fillText(fmtX(tick), px, plot.y + plot.height + TICK_SIZE + 3); - } + 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(); - // Y tick marks and labels - ctx.textAlign = "right"; - ctx.textBaseline = "middle"; - - for (const tick of yTicks) { - const py = yToPixel(tick); - if (py < plot.y - 1 || py > plot.y + plot.height + 1) continue; - ctx.beginPath(); - ctx.moveTo(plot.x - TICK_SIZE, py); - ctx.lineTo(plot.x, py); - ctx.stroke(); - ctx.fillText(fmtY(tick), plot.x - TICK_SIZE - 3, py); + 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, + ); } - // Axis labels - 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); - - 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(); + 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/data/lazy-row.ts b/packages/viewer-charts/src/ts/data/lazy-row.ts new file mode 100644 index 0000000000..ff30f701aa --- /dev/null +++ b/packages/viewer-charts/src/ts/data/lazy-row.ts @@ -0,0 +1,122 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { View } from "@perspective-dev/client"; + +/** + * A single row's column values, keyed by column name. Numeric columns + * yield a `number`; string (dictionary) columns yield the decoded + * `string`; invalid (null) cells yield `null`. + */ +export type LazyRow = Map; + +const DEFAULT_CACHE_SIZE = 128; + +/** + * On-demand single-row fetcher backing lazy tooltip lookups. Given a + * view row index, performs `view.with_typed_arrays({start_row, end_row: + * start_row+1})` and projects the result into a plain `Map`. Concurrent + * fetches for the same index are deduped into one Promise; resolved + * rows are cached in a bounded LRU keyed by rowIdx. + * + * Invalidation is lifecycle-driven: the owning chart disposes and + * constructs a new fetcher whenever its underlying view changes (i.e. + * on each `draw`). In-flight fetches from the prior fetcher still + * resolve, but callers stamp each fetch with a serial and discard + * results whose serial no longer matches — so stale rows never reach + * the tooltip. See the per-chart hover/pin paths for that plumbing. + */ +export class LazyRowFetcher { + private _view: View | null; + private _cache: Map = new Map(); + private _inFlight: Map> = new Map(); + private readonly _maxCacheSize: number; + + constructor(view: View, maxCacheSize: number = DEFAULT_CACHE_SIZE) { + this._view = view; + this._maxCacheSize = maxCacheSize; + } + + async fetchRow(rowIdx: number): Promise { + if (!this._view) throw new Error("LazyRowFetcher disposed"); + const cached = this._cache.get(rowIdx); + if (cached) { + // LRU touch: re-insert to move to tail. + this._cache.delete(rowIdx); + this._cache.set(rowIdx, cached); + return cached; + } + const inflight = this._inFlight.get(rowIdx); + 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 + 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); + } + return result; + } finally { + this._inFlight.delete(rowIdx); + } + } + + private async _fetch(rowIdx: number): Promise { + const view = this._view; + if (!view) throw new Error("LazyRowFetcher disposed"); + const row: LazyRow = new Map(); + await (view as any).with_typed_arrays( + { + start_row: rowIdx, + end_row: rowIdx + 1, + float32: true, + }, + ( + names: string[], + values: ArrayLike[], + validities: (Uint8Array | null)[], + dictionaries: (string[] | null)[], + ) => { + for (let i = 0; i < names.length; i++) { + const name = names[i]; + if (name.startsWith("__")) continue; + const vals = values[i]; + const valid = validities[i]; + const dict = dictionaries[i]; + const isInvalid = valid ? !((valid[0] >> 0) & 1) : false; + if (isInvalid) { + row.set(name, null); + } else if (dict) { + row.set(name, dict[vals[0] as number]); + } else { + row.set(name, vals[0] as number); + } + } + }, + ); + return row; + } + + dispose(): void { + this._view = null; + this._cache.clear(); + this._inFlight.clear(); + } + + get isDisposed(): boolean { + return this._view === null; + } +} diff --git a/packages/viewer-charts/src/ts/data/view-reader.ts b/packages/viewer-charts/src/ts/data/view-reader.ts index 9316cb0ec0..5393c6cfce 100644 --- a/packages/viewer-charts/src/ts/data/view-reader.ts +++ b/packages/viewer-charts/src/ts/data/view-reader.ts @@ -35,20 +35,23 @@ export interface TypedArrayWindowOptions { /** * Fetches all columns from a View using `with_typed_arrays` and - * builds a `ColumnDataMap`. The callback receives zero-copy typed array - * views; numeric data and validity bitmaps are used directly without - * copying. String columns copy their indices and dictionary. + * builds a `ColumnDataMap`. The `values` typed arrays and `valid` + * bitmaps are zero-copy views into WASM memory and remain valid only + * for the duration of the `render` callback — if `render` returns a + * `Promise`, the underlying `with_typed_arrays` call awaits it before + * releasing the backing Arrow buffer. Callers must not retain any + * `ColumnData` reference past `render`'s resolution. */ export async function viewToColumnDataMap( view: View, - render: (data: ColumnDataMap) => void, + render: (data: ColumnDataMap) => void | Promise, options?: TypedArrayWindowOptions, ): Promise { const result: ColumnDataMap = new Map(); await (view as any).with_typed_arrays( options ?? {}, - ( + async ( names: string[], values: ArrayLike[], validities: (Uint8Array | null)[], @@ -61,11 +64,10 @@ export async function viewToColumnDataMap( const dict = dictionaries[i]; if (dict !== null) { - // Dictionary (string) column — copy indices and dictionary result.set(name, { type: "string", - indices: new Int32Array(vals as Int32Array), - dictionary: Array.from(dict), + indices: vals as Int32Array, + dictionary: dict, valid, }); } else if (vals instanceof Float32Array) { @@ -81,6 +83,7 @@ export async function viewToColumnDataMap( }); } else { // Fallback: treat as float32 + // TODO: Instance check if this needs a copy? result.set(name, { type: "float32", values: new Float32Array(vals as any), @@ -89,7 +92,7 @@ export async function viewToColumnDataMap( } } - render(result); + await render(result); }, ); } diff --git a/packages/viewer-charts/src/ts/interaction/tooltip-controller.ts b/packages/viewer-charts/src/ts/interaction/tooltip-controller.ts index 4d8384420c..0d0516062f 100644 --- a/packages/viewer-charts/src/ts/interaction/tooltip-controller.ts +++ b/packages/viewer-charts/src/ts/interaction/tooltip-controller.ts @@ -113,10 +113,12 @@ export class TooltipController { 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; @@ -128,35 +130,34 @@ export class TooltipController { lines: string[], pos: { px: number; py: number }, bounds: CssBounds, - theme: Theme, ): void { this.dismissPinned(); if (lines.length === 0) return; - const div = document.createElement("div"); - div.style.cssText = [ - "position:absolute", - "pointer-events:auto", - `font:11px ${theme.fontFamily}`, - `background:${theme.tooltipBg}`, - `color:${theme.tooltipText}`, - `border:1px solid ${theme.tooltipBorder}`, - "border-radius:4px", - "padding:8px", - "overflow-y:auto", - `max-height:${Math.round(bounds.cssHeight * 0.6)}px`, - "white-space:pre", - "z-index:10", - "line-height:16px", - ].join(";"); + // 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"); - parent.style.position = "relative"; + // 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"; + } + 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; @@ -165,7 +166,6 @@ export class TooltipController { 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`; } @@ -201,7 +201,6 @@ export function renderCanvasTooltip( ctx.save(); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.scale(dpr, dpr); - ctx.font = `11px ${theme.fontFamily}`; const lineHeight = 16; const padding = 8; @@ -210,15 +209,17 @@ export function renderCanvasTooltip( 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 = 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; + const hasLines = lines.length > 0; + // Crosshair if (options.crosshair) { ctx.strokeStyle = theme.tickColor; @@ -246,21 +247,25 @@ export function renderCanvasTooltip( ctx.globalAlpha = 1.0; } - // Box - ctx.fillStyle = theme.tooltipBg; - ctx.strokeStyle = theme.tooltipBorder; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.roundRect(tx, ty, boxW, boxH, 4); - ctx.fill(); - ctx.stroke(); + // Box + text are only drawn when we have content. Callers pass an + // empty `lines` array while a lazy row fetch is still in flight — + // the crosshair / highlight ring above paint immediately so the + // hover remains visible, but the tooltip chrome waits for data. + if (hasLines) { + ctx.fillStyle = theme.tooltipBg; + ctx.strokeStyle = theme.tooltipBorder; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.roundRect(tx, ty, boxW, boxH, 4); + ctx.fill(); + ctx.stroke(); - // Text - ctx.fillStyle = theme.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); + ctx.fillStyle = theme.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); + } } ctx.restore(); diff --git a/packages/viewer-charts/src/ts/interaction/zoom-controller.ts b/packages/viewer-charts/src/ts/interaction/zoom-controller.ts index 4c8c69415f..9baed6329a 100644 --- a/packages/viewer-charts/src/ts/interaction/zoom-controller.ts +++ b/packages/viewer-charts/src/ts/interaction/zoom-controller.ts @@ -32,8 +32,8 @@ export interface ZoomConfig { lockAxis?: "x" | "y" | null; } -const MAX_ZOOM = 100_000; -const MIN_ZOOM = 1; +export const MAX_ZOOM = 100_000; +export const MIN_ZOOM = 1; export class ZoomController { private _scaleX = 1; @@ -62,6 +62,43 @@ export class ZoomController { private _onPointerMove: ((e: PointerEvent) => void) | null = null; private _onPointerUp: ((e: PointerEvent) => void) | null = null; + // Per-controller mutators used by `ZoomRouter` to apply wheel/pan + // events without going through `attach`. Live below under "Router + // helpers" for the facet-aware zoom path. + get lockedAxis(): "x" | "y" | null { + return this._lockAxis; + } + get scaleX(): number { + return this._scaleX; + } + get scaleY(): number { + return this._scaleY; + } + set scaleX(v: number) { + this._scaleX = v; + } + set scaleY(v: number) { + this._scaleY = v; + } + get normTranslateX(): number { + return this._normTX; + } + get normTranslateY(): number { + return this._normTY; + } + set normTranslateX(v: number) { + this._normTX = v; + } + set normTranslateY(v: number) { + this._normTY = v; + } + get baseXRange(): number { + return this._baseXMax - this._baseXMin; + } + get baseYRange(): number { + return this._baseYMax - this._baseYMin; + } + setBaseDomain( xMin: number, xMax: number, diff --git a/packages/viewer-charts/src/ts/interaction/zoom-router.ts b/packages/viewer-charts/src/ts/interaction/zoom-router.ts new file mode 100644 index 0000000000..2738484c33 --- /dev/null +++ b/packages/viewer-charts/src/ts/interaction/zoom-router.ts @@ -0,0 +1,199 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { PlotLayout } from "../layout/plot-layout"; +import { MAX_ZOOM, MIN_ZOOM, type ZoomController } from "./zoom-controller"; + +/** + * Resolver that maps a cursor position to `{ controller, layout }` — + * returns `null` when the cursor is not inside any facet. In + * independent-zoom mode the facet under the cursor owns its events; in + * shared-zoom mode the resolver always returns the same controller for + * every plot rect in the grid. + */ +export interface ZoomTarget { + controller: ZoomController; + layout: PlotLayout; +} +export type ZoomTargetResolver = (mx: number, my: number) => ZoomTarget | null; + +/** + * One set of wheel / pointer listeners on the GL canvas that dispatches + * zoom + pan events to a {@link ZoomController} resolved from the + * cursor position. Replaces `ZoomController.attach` so multiple + * controllers (one per facet) can coexist on a single canvas. + */ +export class ZoomRouter { + private _element: HTMLElement | null = null; + private _resolve: ZoomTargetResolver | null = null; + private _onUpdate: (() => void) | null = null; + + private _pointerDown = false; + private _pointerTarget: ZoomTarget | null = null; + private _lastPointerX = 0; + private _lastPointerY = 0; + + 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; + + attach( + element: HTMLElement, + resolve: ZoomTargetResolver, + onUpdate: () => void, + ): void { + this.detach(); + this._element = element; + this._resolve = resolve; + this._onUpdate = onUpdate; + + this._onWheel = (e: WheelEvent) => { + const rect = element.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + const target = resolve(mouseX, mouseY); + if (!target) return; + e.preventDefault(); + applyWheel(target, mouseX, mouseY, e.deltaY); + onUpdate(); + }; + + this._onPointerDown = (e: PointerEvent) => { + const rect = element.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + const target = resolve(mouseX, mouseY); + if (!target) return; + this._pointerDown = true; + this._pointerTarget = target; + this._lastPointerX = e.clientX; + this._lastPointerY = e.clientY; + element.setPointerCapture(e.pointerId); + }; + + this._onPointerMove = (e: PointerEvent) => { + if (!this._pointerDown || !this._pointerTarget) return; + const dx = e.clientX - this._lastPointerX; + const dy = e.clientY - this._lastPointerY; + this._lastPointerX = e.clientX; + this._lastPointerY = e.clientY; + applyPan(this._pointerTarget, dx, dy); + onUpdate(); + }; + + this._onPointerUp = () => { + this._pointerDown = false; + this._pointerTarget = null; + }; + + element.addEventListener("wheel", this._onWheel, { passive: false }); + element.addEventListener("pointerdown", this._onPointerDown); + element.addEventListener("pointermove", this._onPointerMove); + element.addEventListener("pointerup", this._onPointerUp); + } + + 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, + ); + } + this._element = null; + this._resolve = null; + this._onUpdate = null; + this._pointerDown = false; + this._pointerTarget = null; + } +} + +function applyWheel( + target: ZoomTarget, + mouseX: number, + mouseY: number, + deltaY: number, +): void { + const { controller, layout } = target; + const plot = layout.plotRect; + + const domain = controller.getVisibleDomain(); + const dataX = + domain.xMin + + ((mouseX - plot.x) / plot.width) * (domain.xMax - domain.xMin); + const dataY = + domain.yMax - + ((mouseY - plot.y) / plot.height) * (domain.yMax - domain.yMin); + + const factor = Math.pow(1.1, -deltaY / 100); + const locked = controller.lockedAxis; + if (locked !== "x") { + controller.scaleX = Math.max( + MIN_ZOOM, + Math.min(MAX_ZOOM, controller.scaleX * factor), + ); + } + if (locked !== "y") { + controller.scaleY = Math.max( + MIN_ZOOM, + Math.min(MAX_ZOOM, controller.scaleY * factor), + ); + } + + const newDomain = controller.getVisibleDomain(); + const newDataX = + newDomain.xMin + + ((mouseX - plot.x) / plot.width) * (newDomain.xMax - newDomain.xMin); + const newDataY = + newDomain.yMax - + ((mouseY - plot.y) / plot.height) * (newDomain.yMax - newDomain.yMin); + + const bxRange = controller.baseXRange; + const byRange = controller.baseYRange; + 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 { + const { controller, layout } = target; + const domain = controller.getVisibleDomain(); + const plot = layout.plotRect; + const dataPerPixelX = (domain.xMax - domain.xMin) / plot.width; + const dataPerPixelY = (domain.yMax - domain.yMin) / plot.height; + + const locked = controller.lockedAxis; + const bxRange = controller.baseXRange; + const byRange = controller.baseYRange; + 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 new file mode 100644 index 0000000000..26605ffbf3 --- /dev/null +++ b/packages/viewer-charts/src/ts/layout/facet-grid.ts @@ -0,0 +1,301 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 "./plot-layout"; + +/** + * Tri-state axis mode. + * + * - `"outer"` — one shared axis band reserved at the grid edge; + * `outerXAxisRect` / `outerYAxisRect` populated, per-cell gutter + * collapsed to 0 on that side. Caller paints the shared axis + * once per frame using the grid's outer rect. + * - `"cell"` — every cell reserves its own gutter on that side; + * caller paints one axis per cell. Outer rect is undefined. + * - `"none"` — no gutter anywhere on that side: neither an outer + * band nor a per-cell reservation. Intended for chart types with + * no numeric axis at all (treemap, sunburst). When BOTH axes are + * `"none"` cells are also made flush on the right so adjacent + * plot rects share a boundary. + * + * Defaults to `"cell"` when undefined. + */ +export type AxisMode = "outer" | "cell" | "none"; + +export interface FacetGridOptions { + cssWidth: number; + cssHeight: number; + /** See {@link AxisMode}. Default `"cell"`. */ + xAxis?: AxisMode; + /** See {@link AxisMode}. Default `"cell"`. */ + yAxis?: AxisMode; + /** 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. */ + titleBand?: number; + /** + * Pixel gap between adjacent cells. Carved out of the grid + * interior before cell sizing; outer edges of the leftmost / + * rightmost columns and top / bottom rows are unaffected. Default + * 0 (flush cells). + */ + gap?: number; +} + +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 + * do not vary by edge position. Shared-axis gutters live in + * `FacetGrid.outerXAxisRect` / `outerYAxisRect` instead, painted + * once per frame by the caller. + */ + layout: PlotLayout; + /** Title strip above the facet's plot rect, if `titleBand > 0`. */ + titleRect?: PlotRect; + isLeftEdge: boolean; + isBottomEdge: boolean; +} + +export interface FacetGrid { + cells: FacetCell[]; + /** 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 + * horizontal extent and sits immediately below the bottom row of + * cells. + */ + outerXAxisRect?: PlotRect; + /** + * Outer band reserved for the shared Y axis (ticks + label). Only + * set when `yAxis === "outer"`. Spans the grid interior's + * vertical extent and sits immediately left of the leftmost + * column of cells. + */ + outerYAxisRect?: PlotRect; +} + +// Per-cell internal gutter defaults mirror `PlotLayout`'s constants so +// that a cell with `leftExtra: undefined` reserves the same space the +// outer band would reserve when the axis is shared. Keep these in sync +// with `plot-layout.ts`. +const CELL_LEFT_GUTTER = 55; +const CELL_BOTTOM_GUTTER = 24; +const AXIS_LABEL_W = 16; +const AXIS_LABEL_H = 18; + +const TITLE_BAND_DEFAULT = 18; +const LEGEND_GUTTER = 96; + +/** + * Pick `(cols, rows)` so that each resulting cell's aspect ratio is as + * close to 1 as possible given the grid interior. Sweeps `cols ∈ [1, + * count]` with `rows = ceil(count / cols)` and minimizes + * `max(cellW/cellH, cellH/cellW)`. Ties break toward fewer total cells + * (less unused grid area). + */ +function pickGridShape( + count: number, + gridW: number, + gridH: number, + gap: number, +): { cols: number; rows: number } { + if (count <= 1) return { cols: 1, rows: 1 }; + let bestCols = 1; + let bestRows = count; + let bestCost = Infinity; + let bestTotal = count; + for (let cols = 1; cols <= count; cols++) { + const rows = Math.ceil(count / cols); + const cellW = Math.max(1, (gridW - (cols - 1) * gap) / cols); + const cellH = Math.max(1, (gridH - (rows - 1) * gap) / rows); + const aspect = cellW / cellH; + const cost = Math.max(aspect, 1 / aspect); + const total = cols * rows; + if (cost < bestCost || (cost === bestCost && total < bestTotal)) { + bestCols = cols; + bestRows = rows; + bestCost = cost; + bestTotal = total; + } + } + return { cols: bestCols, rows: bestRows }; +} + +/** + * Arrange `labels.length` sub-plots in a row-major grid sized to fit + * `(cssWidth, cssHeight)`. + * + * Grid shape is chosen to minimize cell aspect distance from square + * given the container's grid interior: `cols ∈ [1, count]`, + * `rows = ceil(count / cols)`, tie-broken toward fewer total cells. + * + * **Invariant:** every `cells[i].layout.plotRect` has the same + * `width` and `height`. Shared-axis gutters are carved out of the + * outer canvas BEFORE cell sizing, so a cell's edge position never + * affects its internal margins. This lets per-facet draws reuse the + * same projection scale and lets shared ticks line up with the + * interior cell boundaries exactly. + * + * Axis modes — see {@link AxisMode}: + * - `"outer"` → outer band rect is populated; per-cell gutter is 0. + * - `"cell"` → outer band is undefined; each cell owns its own gutter. + * - `"none"` → no gutter anywhere on that side; used by axis-less + * chart types. + * + * Because all cells are identical in size, callers can sample *any* + * cell's layout (e.g. `cells[0].layout`) for tick / scale + * computations. + */ +export function buildFacetGrid( + labels: string[], + opts: FacetGridOptions, +): FacetGrid { + const count = labels.length; + const { cssWidth, cssHeight } = opts; + + if (count <= 0 || cssWidth <= 0 || cssHeight <= 0) { + return { cells: [] }; + } + + const titleBand = opts.titleBand ?? TITLE_BAND_DEFAULT; + const legendW = opts.hasLegend ? LEGEND_GUTTER : 0; + + 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. + const cellsFlush = xMode === "none" && yMode === "none"; + + // Outer margins: shared-axis gutters + legend gutter live OUTSIDE + // the per-cell rects. + const outerLeft = + yMode === "outer" + ? CELL_LEFT_GUTTER + (opts.hasYLabel ? AXIS_LABEL_W : 0) + : 0; + const outerBottom = + xMode === "outer" + ? CELL_BOTTOM_GUTTER + (opts.hasXLabel ? AXIS_LABEL_H : 0) + : 0; + const outerTop = 0; + const outerRight = legendW; + + const gridX = outerLeft; + const gridY = outerTop; + const gridW = Math.max(1, cssWidth - outerLeft - outerRight); + const gridH = Math.max(1, cssHeight - outerTop - outerBottom); + + // Carve the total inter-cell gap out of the grid interior before + // sizing cells so every cell remains identical in size (the + // grid-uniform invariant). Gaps only sit BETWEEN neighbors — not + // against the outer edges. + const gap = Math.max(0, opts.gap ?? 0); + const { cols, rows } = pickGridShape(count, gridW, gridH, gap); + const totalGapX = Math.max(0, cols - 1) * gap; + const totalGapY = Math.max(0, rows - 1) * gap; + const cellW = Math.max(1, (gridW - totalGapX) / cols); + const cellH = Math.max(1, (gridH - totalGapY) / rows); + + const cells: FacetCell[] = []; + for (let i = 0; i < count; i++) { + const row = Math.floor(i / cols); + const col = i - row * cols; + const isBottomEdge = row === rows - 1 || i + cols >= count; + const isLeftEdge = col === 0; + + const cellX = gridX + col * (cellW + gap); + const cellY = gridY + row * (cellH + gap); + + // Carve a title strip from the top of each cell. The remaining + // rect becomes the per-cell `PlotLayout`. + const plotTop = cellY + titleBand; + const plotLeft = cellX; + const plotWidth = cellW; + const plotHeight = Math.max(1, cellH - titleBand); + + // Per-cell gutters: + // - "cell" → keep `PlotLayout` default (undefined). + // - "outer" / "none" → collapse to 0 (no internal gutter). + // Per-cell labels only paint when the axis is per-cell. + const layout = new PlotLayout(cssWidth, cssHeight, { + hasXLabel: xMode === "cell" && opts.hasXLabel === true, + hasYLabel: yMode === "cell" && opts.hasYLabel === true, + hasLegend: false, + leftExtra: yMode === "cell" ? undefined : 0, + bottomExtra: xMode === "cell" ? undefined : 0, + rightExtra: cellsFlush ? 0 : undefined, + originX: plotLeft, + originY: plotTop, + cellWidth: plotWidth, + cellHeight: plotHeight, + }); + + const titleRect: PlotRect | undefined = + titleBand > 0 + ? { + x: plotLeft, + y: cellY, + width: plotWidth, + height: titleBand, + } + : undefined; + + cells.push({ + index: i, + label: labels[i], + layout, + titleRect, + isLeftEdge, + isBottomEdge, + }); + } + + const legendRect: PlotRect | undefined = opts.hasLegend + ? { + x: gridX + gridW, + y: outerTop, + width: legendW, + height: gridH, + } + : undefined; + + const outerXAxisRect: PlotRect | undefined = + xMode === "outer" + ? { + x: gridX, + y: gridY + gridH, + width: gridW, + height: outerBottom, + } + : undefined; + + const outerYAxisRect: PlotRect | undefined = + yMode === "outer" + ? { + x: 0, + y: gridY, + width: outerLeft, + height: gridH, + } + : undefined; + + return { cells, legendRect, outerXAxisRect, outerYAxisRect }; +} diff --git a/packages/viewer-charts/src/ts/layout/plot-layout.ts b/packages/viewer-charts/src/ts/layout/plot-layout.ts index a0d80c3ca9..54f3cbb060 100644 --- a/packages/viewer-charts/src/ts/layout/plot-layout.ts +++ b/packages/viewer-charts/src/ts/layout/plot-layout.ts @@ -42,6 +42,34 @@ export interface PlotLayoutOptions { * is preserved. */ leftExtra?: number; + + /** + * Total CSS-pixel width reserved at the right of the plot. Overrides + * the default (`80` when `hasLegend`, else `16`). Faceted cells + * without axes (treemap / sunburst in grid mode) pass `0` to make + * adjacent cell plot rects flush; axis-bearing charts leave it + * unset to keep the default breathing-room margin. + */ + rightExtra?: number; + + /** + * Absolute canvas-coordinate offset for this layout's plot origin. + * When set, `cssWidth` / `cssHeight` describe the *outer* canvas, and + * `originX` / `originY` name the top-left corner of the cell this + * layout represents. The cell's own width/height come from + * `cellWidth` / `cellHeight`. `margins` are computed relative to the + * cell then shifted into canvas-absolute space so projection + * matrices, scissor, and `dataToPixel` all operate in full-canvas + * coordinates without branching per-facet. + * + * When any of these fields is unset, the layout is single-plot: the + * cell occupies the whole canvas and `originX` / `originY` default + * to 0. + */ + originX?: number; + originY?: number; + cellWidth?: number; + cellHeight?: number; } /** @@ -73,15 +101,40 @@ export class PlotLayout { const left = baseLeft + (options.hasYLabel ? 16 : 0); const baseBottom = options.bottomExtra ?? 24; const bottom = baseBottom + (options.hasXLabel ? 18 : 0); - const top = 12; - const right = options.hasLegend ? 80 : 16; + const top = 0; + const right = options.rightExtra ?? (options.hasLegend ? 80 : 16); + + // Facet cells: the sub-plot lives at `(originX, originY)` within a + // larger canvas of size `(cssWidth, cssHeight)`. Its own bounds are + // `cellWidth × cellHeight`. The gutters above are then interpreted + // inside that cell, and `margins` / `plotRect` are shifted into + // canvas-absolute coordinates. Single-plot layouts leave these + // unset, in which case `originX / originY = 0` and the cell + // occupies the whole canvas — identical to pre-facet semantics. + const originX = options.originX ?? 0; + const originY = options.originY ?? 0; + const cellW = options.cellWidth ?? cssWidth; + const cellH = options.cellHeight ?? cssHeight; + + const marginLeftAbs = originX + left; + const marginTopAbs = originY + top; + const plotW = Math.max(1, cellW - left - right); + const plotH = Math.max(1, cellH - top - bottom); + const marginRightAbs = cssWidth - (marginLeftAbs + plotW); + const marginBottomAbs = cssHeight - (marginTopAbs + plotH); + + this.margins = { + top: marginTopAbs, + right: marginRightAbs, + bottom: marginBottomAbs, + left: marginLeftAbs, + }; - this.margins = { top, right, bottom, left }; this.plotRect = { - x: left, - y: top, - width: Math.max(1, cssWidth - left - right), - height: Math.max(1, cssHeight - top - bottom), + x: marginLeftAbs, + y: marginTopAbs, + width: plotW, + height: plotH, }; } diff --git a/packages/viewer-charts/src/ts/plugin/charts.ts b/packages/viewer-charts/src/ts/plugin/charts.ts index cba519d1c9..1e1b000f83 100644 --- a/packages/viewer-charts/src/ts/plugin/charts.ts +++ b/packages/viewer-charts/src/ts/plugin/charts.ts @@ -31,16 +31,6 @@ export interface ChartTypeConfig { }; max_cells: number; max_columns: number; - - /** - * Default render glyph. For bar-family plugins (Y Bar / Y Line / Y - * Scatter / Y Area) this is the fallback glyph when a column has no - * explicit `chart_type` in `columns_config`. For candlestick-family - * plugins (Y Candlestick / Y OHLC) it selects between the two - * candlestick glyph modes. Presence of the field surfaces the - * Chart Type picker in the column-settings sidebar for bar-family - * plugins. - */ default_chart_type?: PluginChartType; } diff --git a/packages/viewer-charts/src/ts/plugin/plugin.ts b/packages/viewer-charts/src/ts/plugin/plugin.ts index 35f70ed5b0..c92ac6c817 100644 --- a/packages/viewer-charts/src/ts/plugin/plugin.ts +++ b/packages/viewer-charts/src/ts/plugin/plugin.ts @@ -15,10 +15,35 @@ 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 } from "../charts/chart"; +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"; +/** + * Compile-time facet configuration. Baked in at module load for now — + * flip values here + rebuild to toggle small-multiples behavior. When + * the UI wires `columns_config` through `restore`, this const seeds + * the default and per-column overrides win. + */ +const FACET_CONFIG: FacetConfig = { + ...DEFAULT_FACET_CONFIG, + // Flip to "overlay" to fall back to the pre-facet single-plot + // rendering of split_by (all splits drawn in one plot rect, + // differentiated by color). + facet_mode: "grid", + shared_x_axis: true, + shared_y_axis: true, + coordinated_tooltip: false, + // "independent" routes wheel/pan to the facet under the cursor and + // each facet draws its own viewport. + zoom_mode: "shared", +}; + const GLOBAL_STYLES = (() => { const sheet = new CSSStyleSheet(); sheet.replaceSync(style); @@ -26,16 +51,17 @@ const GLOBAL_STYLES = (() => { })(); export class HTMLPerspectiveViewerWebGLPluginElement extends HTMLElement { - _chartType: ChartTypeConfig; - static _chartType: ChartTypeConfig; + declare _chartType: ChartTypeConfig; + declare static _chartType: ChartTypeConfig; private _initialized = false; - private _glCanvas: HTMLCanvasElement; - private _gridlineCanvas: HTMLCanvasElement; - private _chromeCanvas: HTMLCanvasElement; + 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 _generation = 0; connectedCallback() { @@ -45,16 +71,17 @@ export class HTMLPerspectiveViewerWebGLPluginElement extends HTMLElement { this.shadowRoot!.adoptedStyleSheets.push(sheet); } - this.shadowRoot!.innerHTML = ` -
- - - -
- -
-
- `; + const zoom_button = ``; + const canvas_stack = + `` + + `` + + `` + + `
` + + zoom_button + + `
`; + + this.shadowRoot!.innerHTML = + `
` + canvas_stack + `
`; this._glCanvas = this.shadowRoot!.querySelector( @@ -108,61 +135,145 @@ export class HTMLPerspectiveViewerWebGLPluginElement extends HTMLElement { ); } - // Create and wire zoom controller + // 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); + } + + // Create and wire zoom controller(s) if (this._chartImpl.setZoomController && !this._zoomController) { this._zoomController = new ZoomController(); this._chartImpl.setZoomController(this._zoomController); + this._setupZoomRouter(); + } - // Create a dummy layout for initial attachment; it will be - // updated on each render via scatter's _fullRender. - const rect = this._glCanvas.getBoundingClientRect(); - const layout = new PlotLayout( - rect.width || 100, - rect.height || 100, - { - hasXLabel: true, - hasYLabel: true, - hasLegend: false, - }, - ); - - const zoomControls = this.shadowRoot!.querySelector( - ".zoom-controls", - ) as HTMLDivElement; + // Attach tooltip + if (this._chartImpl.attachTooltip) { + this._chartImpl.attachTooltip(this._glCanvas); + } + } - this._zoomController.attach(this._glCanvas, layout, () => { + /** + * 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; + + 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); } - // Show reset button when zoomed/panned - if (zoomControls && this._zoomController) { + if (zoomControls) { zoomControls.classList.toggle( "visible", - !this._zoomController.isDefault(), + !this._allZoomsDefault(), ); } + }, + ); + + 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); + } }); + } + } - // Wire reset button - const resetBtn = this.shadowRoot!.querySelector(".zoom-reset"); - if (resetBtn) { - resetBtn.addEventListener("click", () => { - if (this._zoomController) { - this._zoomController.reset(); - if (zoomControls) { - zoomControls.classList.remove("visible"); - } - if (this._chartImpl && this._glManager) { - this._chartImpl.redraw(this._glManager); - } - } - }); + private _allZoomsDefault(): boolean { + if (this._zoomController && !this._zoomController.isDefault()) { + return false; + } + const chart = this._chartImpl as any; + if (chart?._facetZoomControllers) { + for (const zc of chart._facetZoomControllers) { + if (zc && !zc.isDefault()) return false; } } + return true; + } - // Attach tooltip - if (this._chartImpl.attachTooltip) { - this._chartImpl.attachTooltip(this._glCanvas); + private _resetAllZooms(): void { + this._zoomController?.reset(); + const chart = this._chartImpl as any; + if (chart?._facetZoomControllers) { + for (const zc of chart._facetZoomControllers) { + zc?.reset(); + } } } @@ -245,6 +356,13 @@ export class HTMLPerspectiveViewerWebGLPluginElement extends HTMLElement { ]); 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) { @@ -325,6 +443,7 @@ export class HTMLPerspectiveViewerWebGLPluginElement extends HTMLElement { if (config?.zoom && this._zoomController) { this._zoomController.restore(config.zoom); } + if (this._chartImpl?.setColumnsConfig) { this._chartImpl.setColumnsConfig(columns_config ?? {}); } @@ -337,10 +456,11 @@ export class HTMLPerspectiveViewerWebGLPluginElement extends HTMLElement { this._chartImpl.destroy(); this._chartImpl = null; } - if (this._zoomController) { - this._zoomController.detach(); - this._zoomController = null; + if (this._zoomRouter) { + this._zoomRouter.detach(); + this._zoomRouter = null; } + this._zoomController = null; if (this._glManager) { this._glManager.destroy(); this._glManager = null; diff --git a/packages/viewer-charts/src/ts/shaders/line.vert.glsl b/packages/viewer-charts/src/ts/shaders/line.vert.glsl index 195ea797de..01c7543835 100644 --- a/packages/viewer-charts/src/ts/shaders/line.vert.glsl +++ b/packages/viewer-charts/src/ts/shaders/line.vert.glsl @@ -11,16 +11,23 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ // Per-instance attributes (advance once per segment via divisor=1). -// Both `a_start`/`a_end` and `a_series_start`/`a_series_end` are read -// from the same flat buffers using overlapping offsets: instance i reads -// vertex[i] into `*_start` and vertex[i+1] into `*_end`. The single big -// draw call covers all series; segments whose endpoints straddle a -// series boundary (or land in unused slots) are collapsed to degenerate -// quads by the discard branch below. +// Both `a_start`/`a_end` and `a_color_start`/`a_color_end` read from +// the same flat buffers using overlapping offsets: instance i reads +// vertex[i] into `*_start` and vertex[i+1] into `*_end`. Each per- +// series draw call binds into its own slot range, so segments never +// cross series boundaries — the CPU-side rebinding in `drawLineSeries` +// is the safeguard, so the shader contains no discard branch. +// +// `a_color_start` / `a_color_end` carry the segment endpoints' raw +// color values (numeric data value for gradient, dictionary index for +// categorical). The gradient LUT is sampled using the same mapping the +// scatter shader uses — `(v - cmin) / (cmax - cmin)` with sign-aware +// handling for zero-crossing domains. The two endpoints' colors are +// averaged so the segment reads as a single chord in gradient space. attribute vec2 a_start; attribute vec2 a_end; -attribute float a_series_start; -attribute float a_series_end; +attribute float a_color_start; +attribute float a_color_end; // Per-vertex attribute (advance every vertex, divisor=0) // 0 = start+left, 1 = start+right, 2 = end+left, 3 = end+right @@ -29,28 +36,30 @@ attribute float a_corner; uniform mat4 u_projection; uniform vec2 u_resolution; uniform float u_line_width; -uniform float u_series_count; +uniform vec2 u_color_range; uniform sampler2D u_gradient_lut; varying float v_edge_dist; varying vec3 v_color; -void main() { - // Cross-series segment or unused slot (sentinel series_id = -1). - // Collapse to a degenerate quad outside clip space so the rasterizer - // emits nothing. - if(a_series_start != a_series_end || a_series_start < 0.0) { - gl_Position = vec4(2.0, 2.0, 2.0, 1.0); - v_edge_dist = 0.0; - v_color = vec3(0.0); - return; +float colorT(float v, float cmin, float cmax) { + if(cmax <= cmin) { + return 0.5; + } else if(cmin < 0.0 && cmax > 0.0) { + float denom = max(-cmin, cmax); + return clamp(0.5 + 0.5 * (v / denom), 0.0, 1.0); } + return clamp((v - cmin) / (cmax - cmin), 0.0, 1.0); +} - // Sample the theme gradient at evenly-spaced offsets across series, - // matching the CPU `interpolatePalette` used by bar and by the prior - // per-series uniform path. - float denom = u_series_count - 1.0; - float t = denom > 0.0 ? a_series_start / denom : 0.5; +void main() { + // Average the two endpoints so the segment takes one color — keeps + // the fragment shader cheap (no interpolated t sample) and matches + // the single-tone feel of the old per-series palette. + float cmin = u_color_range.x; + float cmax = u_color_range.y; + float avgVal = 0.5 * (a_color_start + a_color_end); + float t = colorT(avgVal, cmin, cmax); v_color = texture2D(u_gradient_lut, vec2(t, 0.5)).rgb; vec4 clipStart = u_projection * vec4(a_start, 0.0, 1.0); diff --git a/packages/viewer-charts/src/ts/shaders/sunburst-arc.frag.glsl b/packages/viewer-charts/src/ts/shaders/sunburst-arc.frag.glsl index 84f0bccdd7..4bbc5fa5af 100644 --- a/packages/viewer-charts/src/ts/shaders/sunburst-arc.frag.glsl +++ b/packages/viewer-charts/src/ts/shaders/sunburst-arc.frag.glsl @@ -12,8 +12,8 @@ precision highp float; -varying vec3 v_color; +varying vec4 v_color; void main() { - gl_FragColor = vec4(v_color, 1.0); + gl_FragColor = v_color; } diff --git a/packages/viewer-charts/src/ts/shaders/sunburst-arc.vert.glsl b/packages/viewer-charts/src/ts/shaders/sunburst-arc.vert.glsl index 8fbb13b6ab..58eac68e61 100644 --- a/packages/viewer-charts/src/ts/shaders/sunburst-arc.vert.glsl +++ b/packages/viewer-charts/src/ts/shaders/sunburst-arc.vert.glsl @@ -19,13 +19,13 @@ attribute float a_side; // Per-instance (divisor=1) arc geometry. attribute vec2 a_angles; // (a0, a1) in radians attribute vec2 a_radii; // (r0, r1) inner / outer pixel radius -attribute vec3 a_color; +attribute vec4 a_color; uniform vec2 u_center; // chart center in pixel space uniform vec2 u_resolution; // viewport size in device pixels uniform float u_border_px; // symmetric inset, in device pixels -varying vec3 v_color; +varying vec4 v_color; varying vec2 v_edge; // (angular t, radial t) for optional AA fringe void main() { @@ -51,7 +51,7 @@ void main() { if(adjR1 <= adjR0) { // Arc thinner than the border radially — nothing to draw. gl_Position = vec4(2.0, 2.0, 2.0, 1.0); - v_color = vec3(0.0); + v_color = vec4(0.0); v_edge = vec2(0.0); return; } diff --git a/packages/viewer-charts/src/ts/shaders/treemap.frag.glsl b/packages/viewer-charts/src/ts/shaders/treemap.frag.glsl index 84f0bccdd7..4bbc5fa5af 100644 --- a/packages/viewer-charts/src/ts/shaders/treemap.frag.glsl +++ b/packages/viewer-charts/src/ts/shaders/treemap.frag.glsl @@ -12,8 +12,8 @@ precision highp float; -varying vec3 v_color; +varying vec4 v_color; void main() { - gl_FragColor = vec4(v_color, 1.0); + gl_FragColor = v_color; } diff --git a/packages/viewer-charts/src/ts/shaders/treemap.vert.glsl b/packages/viewer-charts/src/ts/shaders/treemap.vert.glsl index 48a665e9d6..3334d19e81 100644 --- a/packages/viewer-charts/src/ts/shaders/treemap.vert.glsl +++ b/packages/viewer-charts/src/ts/shaders/treemap.vert.glsl @@ -11,11 +11,11 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ attribute vec2 a_position; -attribute vec3 a_color; +attribute vec4 a_color; uniform vec2 u_resolution; -varying vec3 v_color; +varying vec4 v_color; void main() { vec2 clip = (a_position / u_resolution) * 2.0 - 1.0; diff --git a/packages/viewer-charts/src/ts/webgl/plot-frame.ts b/packages/viewer-charts/src/ts/webgl/plot-frame.ts index fffb2788ff..168f31a8cd 100644 --- a/packages/viewer-charts/src/ts/webgl/plot-frame.ts +++ b/packages/viewer-charts/src/ts/webgl/plot-frame.ts @@ -24,21 +24,34 @@ export function cssSize(gl: GL): { cssWidth: number; cssHeight: number } { } /** - * Set up the plot-area scissor + clear + blend state, invoke `draw`, then - * tear down scissor. Caller handles projection/uniforms/VBO bindings inside - * `draw`. Used by scatter/line/bar; treemap does its own full-canvas draw - * and does not use this helper. + * Clear the framebuffer + enable alpha blending. Call once per frame, + * before any per-plot-rect {@link withScissor} invocations. Faceted + * renderers call this once and then loop {@link withScissor} per cell + * so the inter-facet clears don't wipe each other's pixels. */ -export function renderInPlotFrame( +export function clearAndSetupFrame(gl: GL): void { + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); +} + +/** + * Scissor-constrain `draw` to `layout.plotRect`. Caller handles + * projection / uniforms / VBO bindings inside `draw`; this helper only + * manages the scissor enable/disable bracket. + * + * Unlike {@link renderInPlotFrame}, this does *not* clear the + * framebuffer — so it's safe to call repeatedly per frame (one per + * facet). Pair with {@link clearAndSetupFrame} at the start of each + * frame. + */ +export function withScissor( gl: GL, layout: PlotLayout, draw: () => void, ): void { const dpr = window.devicePixelRatio || 1; - gl.clearColor(0, 0, 0, 0); - gl.clear(gl.COLOR_BUFFER_BIT); - gl.enable(gl.BLEND); - gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); gl.enable(gl.SCISSOR_TEST); gl.scissor( Math.round(layout.margins.left * dpr), @@ -52,3 +65,22 @@ export function renderInPlotFrame( gl.disable(gl.SCISSOR_TEST); } } + +/** + * One-shot convenience: clear + setup blend + scissor + draw. Used by + * single-plot callers (bar / heatmap / candlestick / scatter-without- + * splits) that only draw into one plot rect per frame. + * + * Faceted callers must use {@link clearAndSetupFrame} + + * {@link withScissor} instead; calling this helper in a per-facet loop + * would clear the framebuffer on each invocation and wipe out every + * previously-drawn facet. + */ +export function renderInPlotFrame( + gl: GL, + layout: PlotLayout, + draw: () => void, +): void { + clearAndSetupFrame(gl); + withScissor(gl, layout, draw); +} diff --git a/packages/viewer-charts/test/js/candlestick.spec.ts b/packages/viewer-charts/test/js/candlestick.spec.ts index 3702c394da..013c614a6f 100644 --- a/packages/viewer-charts/test/js/candlestick.spec.ts +++ b/packages/viewer-charts/test/js/candlestick.spec.ts @@ -23,56 +23,40 @@ test.describe("Y Candlestick", () => { }) => { // Exercises the d3fc-inherited fallback path: close = next row's // open; high = max(open, close); low = min(open, close). - await renderAndCapture( - page, - { - plugin: "Candlestick", - columns: ["Sales"], - group_by: ["Order Date"], - }, - "open-only.png", - ); + await renderAndCapture(page, { + plugin: "Candlestick", + columns: ["Sales"], + group_by: ["Order Date"], + }); }); test("full OHLC four-column layout", async ({ page }) => { - await renderAndCapture( - page, - { - plugin: "Candlestick", - columns: ["Sales", "Profit", "Quantity", "Discount"], - group_by: ["Order Date"], - }, - "full-ohlc.png", - ); + await renderAndCapture(page, { + plugin: "Candlestick", + columns: ["Sales", "Profit", "Quantity", "Discount"], + group_by: ["Order Date"], + }); }); test("up/down colors sampled from gradient extremes", async ({ page }) => { // With Profit as Close and Sales as Open, positive Profit rows // (Close > Open) render at the gradient top; negative rows at // the bottom. Pins the bichromatic rendering. - await renderAndCapture( - page, - { - plugin: "Candlestick", - columns: ["Sales", "Profit"], - group_by: ["Category"], - }, - "up-down-colors.png", - ); + await renderAndCapture(page, { + plugin: "Candlestick", + columns: ["Sales", "Profit"], + group_by: ["Category"], + }); }); test("with split_by — side-by-side candles per category", async ({ page, }) => { - await renderAndCapture( - page, - { - plugin: "Candlestick", - columns: ["Sales"], - group_by: ["Category"], - split_by: ["Region"], - }, - "split_by.png", - ); + await renderAndCapture(page, { + plugin: "Candlestick", + columns: ["Sales"], + group_by: ["Category"], + split_by: ["Region"], + }); }); }); diff --git a/packages/viewer-charts/test/js/heatmap.spec.ts b/packages/viewer-charts/test/js/heatmap.spec.ts index b1f577a060..83580e6d13 100644 --- a/packages/viewer-charts/test/js/heatmap.spec.ts +++ b/packages/viewer-charts/test/js/heatmap.spec.ts @@ -19,45 +19,66 @@ test.describe("Heatmap", () => { }); test("basic", async ({ page }) => { - await renderAndCapture( - page, - { - plugin: "Heatmap", - columns: ["Sales"], - group_by: ["Region"], - split_by: ["Category"], - }, - "basic.png", - ); + await renderAndCapture(page, { + plugin: "Heatmap", + columns: ["Sales"], + group_by: ["Region"], + split_by: ["Category"], + }); await page.pause(); }); test("nested group_by rows", async ({ page }) => { - await renderAndCapture( - page, - { - plugin: "Heatmap", - columns: ["Sales"], - group_by: ["Region", "Category"], - split_by: ["Ship Mode"], - }, - "nested-rows.png", - ); + await renderAndCapture(page, { + plugin: "Heatmap", + columns: ["Sales"], + group_by: ["Region", "Category"], + split_by: ["Ship Mode"], + }); }); test("diverging data with Profit", async ({ page }) => { // Profit crosses zero — the sign-aware gradient should center // value=0 on the gradient midpoint. - await renderAndCapture( - page, - { - plugin: "Heatmap", - columns: ["Profit"], - group_by: ["Region"], - split_by: ["Category"], - }, - "diverging.png", - ); + await renderAndCapture(page, { + plugin: "Heatmap", + columns: ["Profit"], + group_by: ["Region"], + split_by: ["Category"], + }); + }); + + test("multi-faceted", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Heatmap", + columns: ["Sales", "Profit"], + group_by: ["Region"], + split_by: ["Category"], + }); + + await page.pause(); + }); + + test("hierarchial X axis", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Heatmap", + columns: ["Sales"], + group_by: ["Region", "State"], + split_by: ["Category"], + }); + + await page.pause(); + }); + + test("hierarchial Y axis", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Heatmap", + columns: ["Sales"], + group_by: ["Region"], + split_by: ["Category", "Sub-Category"], + }); + + await page.pause(); }); }); diff --git a/packages/viewer-charts/test/js/helpers.ts b/packages/viewer-charts/test/js/helpers.ts index 2336cab88c..4f353eccf3 100644 --- a/packages/viewer-charts/test/js/helpers.ts +++ b/packages/viewer-charts/test/js/helpers.ts @@ -11,16 +11,16 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import type { Page } from "@playwright/test"; -import { expect } from "@perspective-dev/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; 0.5% gives headroom - * without letting real regressions slip past. + * 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 @@ -40,24 +40,18 @@ export async function gotoBasic(page: Page): Promise { * 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. - * - * Why one RAF is enough: - * - The viewer-charts plugin's `draw()` awaits `viewToColumnDataMap` - * which invokes the plugin's render callback synchronously with - * the full column set (no chunk streaming at the chart layer). - * - The render callback calls `uploadAndRender` which synchronously - * processes the data and calls `_scheduleRender` (idempotent RAF). - * - `viewer.restore()` awaits the plugin's `draw()` transitively, so - * when `restore` resolves, exactly one RAF is pending. */ 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); + 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); } @@ -78,11 +72,23 @@ export async function waitOneFrame(page: Page): Promise { */ export async function expectViewerScreenshot( page: Page, - name: string, options: { maxDiffPixelRatio?: number } = {}, ): Promise { const viewer = page.locator("perspective-viewer"); - await expect(viewer).toHaveScreenshot(name, { + 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, }); @@ -90,15 +96,14 @@ export async function expectViewerScreenshot( /** * Full-flow convenience: go to the test page, restore the chart with - * `config`, wait for the render, and screenshot. Most specs are a - * one-liner over this. + * `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, - snapshotName: string, options?: { maxDiffPixelRatio?: number }, ): Promise { await restoreChart(page, config); - await expectViewerScreenshot(page, snapshotName, options); + await expectViewerScreenshot(page, options); } diff --git a/packages/viewer-charts/test/js/line.spec.ts b/packages/viewer-charts/test/js/line.spec.ts index 72d64f8aba..d3d631828a 100644 --- a/packages/viewer-charts/test/js/line.spec.ts +++ b/packages/viewer-charts/test/js/line.spec.ts @@ -19,40 +19,28 @@ test.describe("X/Y Line", () => { }); test("basic x/y", async ({ page }) => { - await renderAndCapture( - page, - { - plugin: "X/Y Line", - columns: ["Order Date", "Profit"], - group_by: ["Order Date"], - }, - "basic.png", - ); + await renderAndCapture(page, { + plugin: "X/Y Line", + columns: ["Order Date", "Profit"], + group_by: ["Order Date"], + }); }); test("split_by produces distinct series colors", async ({ page }) => { - await renderAndCapture( - page, - { - plugin: "X/Y Line", - columns: ["Order Date", "Profit"], - group_by: ["Order Date"], - split_by: ["Category"], - }, - "split_by.png", - ); + await renderAndCapture(page, { + plugin: "X/Y Line", + columns: ["Order Date", "Profit"], + group_by: ["Order Date"], + split_by: ["Category"], + }); }); test("date X axis with two splits", async ({ page }) => { - await renderAndCapture( - page, - { - plugin: "X/Y Line", - columns: ["Order Date", "Sales"], - group_by: ["Order Date"], - split_by: ["Region"], - }, - "date-x-split.png", - ); + await renderAndCapture(page, { + plugin: "X/Y Line", + columns: ["Order Date", "Sales"], + group_by: ["Order Date"], + split_by: ["Region"], + }); }); }); diff --git a/packages/viewer-charts/test/js/pan.spec.ts b/packages/viewer-charts/test/js/pan.spec.ts new file mode 100644 index 0000000000..8fc6d15674 --- /dev/null +++ b/packages/viewer-charts/test/js/pan.spec.ts @@ -0,0 +1,45 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import { test } from "@perspective-dev/test"; +import { + expectViewerScreenshot, + gotoBasic, + restoreChart, + waitOneFrame, +} from "./helpers"; + +const PLOT_CX = 640; +const PLOT_CY = 360; + +test.describe("Pan", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + test("drag pans after wheel zoom", async ({ page }) => { + await restoreChart(page, { + 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); + await waitOneFrame(page); + + await page.mouse.down(); + await page.mouse.move(PLOT_CX - 150, PLOT_CY - 80); + await page.mouse.up(); + await waitOneFrame(page); + await expectViewerScreenshot(page); + }); +}); diff --git a/packages/viewer-charts/test/js/regressions.spec.ts b/packages/viewer-charts/test/js/regressions.spec.ts index be72edab28..5a9ca7bf45 100644 --- a/packages/viewer-charts/test/js/regressions.spec.ts +++ b/packages/viewer-charts/test/js/regressions.spec.ts @@ -32,28 +32,20 @@ test.describe("Regressions", () => { // rendered. Fix: draw count is `numSeries * seriesCapacity` with // sentinel discard in the shader. test("scatter split_by renders every series", async ({ page }) => { - await renderAndCapture( - page, - { - plugin: "X/Y Scatter", - columns: ["Quantity", "Profit"], - split_by: ["Region"], - }, - "scatter-split-all-series.png", - ); + await renderAndCapture(page, { + plugin: "X/Y Scatter", + columns: ["Quantity", "Profit"], + split_by: ["Region"], + }); }); test("line split_by renders every series", async ({ page }) => { - await renderAndCapture( - page, - { - plugin: "X/Y Line", - columns: ["Order Date", "Profit"], - group_by: ["Order Date"], - split_by: ["Region"], - }, - "line-split-all-series.png", - ); + await renderAndCapture(page, { + plugin: "X/Y Line", + columns: ["Order Date", "Profit"], + group_by: ["Order Date"], + split_by: ["Region"], + }); }); // ── scatter categorical colors match legend ───────────────────────── @@ -63,14 +55,10 @@ test.describe("Regressions", () => { // legend sampled evenly across `[0, 1]`. Fix: shader uses linear // mapping for single-sign domains; sign-aware only when crossing 0. test("scatter string color matches legend swatches", async ({ page }) => { - await renderAndCapture( - page, - { - plugin: "X/Y Scatter", - columns: ["Quantity", "Profit", "Category"], - }, - "scatter-categorical-color-match.png", - ); + await renderAndCapture(page, { + plugin: "X/Y Scatter", + columns: ["Quantity", "Profit", "Category"], + }); }); // ── Y Line shows series colors (not all-black) ────────────────────── @@ -80,16 +68,12 @@ test.describe("Regressions", () => { // fragment fetched `(0, 0, 0, 1)`. Fix: dedicated uniform-color // shader pair (line-uniform.vert/frag.glsl) for the bar line glyph. test("Y Line shows series palette colors", async ({ page }) => { - await renderAndCapture( - page, - { - plugin: "Y Line", - columns: ["Sales"], - group_by: ["Category"], - split_by: ["Region"], - }, - "y-line-series-colors.png", - ); + await renderAndCapture(page, { + plugin: "Y Line", + columns: ["Sales"], + group_by: ["Category"], + split_by: ["Region"], + }); }); // ── Treemap transparent background ────────────────────────────────── @@ -97,15 +81,11 @@ test.describe("Regressions", () => { // transparent, so themed hosts got an opaque backdrop under the // chart. test("Treemap background is transparent", async ({ page }) => { - await renderAndCapture( - page, - { - plugin: "Treemap", - columns: ["Sales"], - group_by: ["Region"], - }, - "treemap-transparent-bg.png", - ); + await renderAndCapture(page, { + plugin: "Treemap", + columns: ["Sales"], + group_by: ["Region"], + }); }); // ── Treemap color-mode: date/datetime → numeric gradient ──────────── @@ -115,14 +95,10 @@ test.describe("Regressions", () => { // "series" instead of "numeric". Fix: use `_columnTypes` (view types // `float`/`integer`/`date`/`datetime`) with explicit numeric list. test("Treemap with date Color uses gradient mode", async ({ page }) => { - await renderAndCapture( - page, - { - plugin: "Treemap", - columns: ["Sales", "Order Date"], - group_by: ["Region", "Category"], - }, - "treemap-date-color-gradient.png", - ); + await renderAndCapture(page, { + plugin: "Treemap", + columns: ["Sales", "Order Date"], + group_by: ["Region", "Category"], + }); }); }); diff --git a/packages/viewer-charts/test/js/scatter.spec.ts b/packages/viewer-charts/test/js/scatter.spec.ts index a885dc31a5..57886118a1 100644 --- a/packages/viewer-charts/test/js/scatter.spec.ts +++ b/packages/viewer-charts/test/js/scatter.spec.ts @@ -19,81 +19,61 @@ test.describe("X/Y Scatter", () => { }); test("basic x/y", async ({ page }) => { - await renderAndCapture( - page, - { - plugin: "X/Y Scatter", - columns: ["Quantity", "Profit"], - }, - "basic.png", - ); + await renderAndCapture(page, { + plugin: "X/Y Scatter", + columns: ["Quantity", "Profit"], + }); }); test("with numeric color", async ({ page }) => { - await renderAndCapture( - page, - { - plugin: "X/Y Scatter", - columns: ["Quantity", "Profit", "Sales"], - }, - "color-numeric.png", - ); + await renderAndCapture(page, { + plugin: "X/Y Scatter", + columns: ["Quantity", "Profit", "Sales"], + }); }); test("with string color", async ({ page }) => { - await renderAndCapture( - page, - { - plugin: "X/Y Scatter", - columns: ["Quantity", "Profit", "Category"], - }, - "color-string.png", - ); + await renderAndCapture(page, { + plugin: "X/Y Scatter", + columns: ["Quantity", "Profit", "Category"], + }); }); test("with size column", async ({ page }) => { - await renderAndCapture( - page, - { - plugin: "X/Y Scatter", - columns: ["Quantity", "Profit", null, "Sales"], - }, - "size.png", - ); + await renderAndCapture(page, { + plugin: "X/Y Scatter", + columns: ["Quantity", "Profit", null, "Sales"], + }); }); test("split_by produces distinct series", async ({ page }) => { - await renderAndCapture( - page, - { - plugin: "X/Y Scatter", - columns: ["Quantity", "Profit"], - split_by: ["Category"], - }, - "split_by.png", - ); + await renderAndCapture(page, { + plugin: "X/Y Scatter", + columns: ["Quantity", "Profit"], + split_by: ["Category"], + }); + }); + + test("split_by respects color column", async ({ page }) => { + await renderAndCapture(page, { + plugin: "X/Y Scatter", + columns: ["Quantity", "Profit", null, "Sales"], + split_by: ["Category"], + }); }); test("group_by aggregates points", async ({ page }) => { - await renderAndCapture( - page, - { - plugin: "X/Y Scatter", - columns: ["Quantity", "Profit"], - group_by: ["State"], - }, - "group_by.png", - ); + await renderAndCapture(page, { + plugin: "X/Y Scatter", + columns: ["Quantity", "Profit"], + group_by: ["State"], + }); }); test("date X axis", async ({ page }) => { - await renderAndCapture( - page, - { - plugin: "X/Y Scatter", - columns: ["Order Date", "Profit"], - }, - "date-x-axis.png", - ); + await renderAndCapture(page, { + plugin: "X/Y Scatter", + columns: ["Order Date", "Profit"], + }); }); }); diff --git a/packages/viewer-charts/test/js/sunburst.spec.ts b/packages/viewer-charts/test/js/sunburst.spec.ts index 2761a2a97b..2c333a0690 100644 --- a/packages/viewer-charts/test/js/sunburst.spec.ts +++ b/packages/viewer-charts/test/js/sunburst.spec.ts @@ -19,62 +19,42 @@ test.describe("Sunburst", () => { }); test("basic hierarchy", async ({ page }) => { - await renderAndCapture( - page, - { - plugin: "Sunburst", - columns: ["Sales"], - group_by: ["Region", "Category"], - }, - "basic.png", - ); + await renderAndCapture(page, { + plugin: "Sunburst", + columns: ["Sales"], + group_by: ["Region", "Category"], + }); }); test("no color slot → single-palette series mode", async ({ page }) => { - await renderAndCapture( - page, - { - plugin: "Sunburst", - columns: ["Sales"], - group_by: ["Region"], - }, - "empty-color.png", - ); + await renderAndCapture(page, { + plugin: "Sunburst", + columns: ["Sales"], + group_by: ["Region"], + }); }); test("numeric color → gradient", async ({ page }) => { - await renderAndCapture( - page, - { - plugin: "Sunburst", - columns: ["Sales", "Profit"], - group_by: ["Region", "Category"], - }, - "numeric-color.png", - ); + await renderAndCapture(page, { + plugin: "Sunburst", + columns: ["Sales", "Profit"], + group_by: ["Region", "Category"], + }); }); test("string color → series palette", async ({ page }) => { - await renderAndCapture( - page, - { - plugin: "Sunburst", - columns: ["Sales", "Ship Mode"], - group_by: ["Region", "Category"], - }, - "string-color.png", - ); + await renderAndCapture(page, { + plugin: "Sunburst", + columns: ["Sales", "Ship Mode"], + group_by: ["Region", "Category"], + }); }); test("three-level group_by", async ({ page }) => { - await renderAndCapture( - page, - { - plugin: "Sunburst", - columns: ["Sales"], - group_by: ["Region", "Category", "Sub-Category"], - }, - "three-level.png", - ); + await renderAndCapture(page, { + plugin: "Sunburst", + columns: ["Sales"], + group_by: ["Region", "Category", "Sub-Category"], + }); }); }); diff --git a/packages/viewer-charts/test/js/tooltip.spec.ts b/packages/viewer-charts/test/js/tooltip.spec.ts new file mode 100644 index 0000000000..1eaf60aa1c --- /dev/null +++ b/packages/viewer-charts/test/js/tooltip.spec.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 { test } from "@perspective-dev/test"; +import { expectViewerScreenshot, gotoBasic, restoreChart } from "./helpers"; + +const PLOT_CX = 640; +const PLOT_CY = 360; + +// Hover dispatches `onHover` from a RAF, and the tooltip text is built +// via a `buildTooltipLines` Promise that repaints the chrome overlay +// once the row fetch resolves. 200ms covers both hops reliably under +// swiftshader without flaking on a slow CI machine. +const TOOLTIP_SETTLE_MS = 200; + +test.describe("Tooltip", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + test("hover paints tooltip chrome", async ({ page }) => { + await restoreChart(page, { + plugin: "X/Y Scatter", + columns: ["Quantity", "Profit"], + }); + await page.mouse.move(PLOT_CX, PLOT_CY); + await page.waitForTimeout(TOOLTIP_SETTLE_MS); + await expectViewerScreenshot(page, "scatter-hover.png"); + }); + + test("click pins tooltip", async ({ page }) => { + await restoreChart(page, { + plugin: "X/Y Scatter", + columns: ["Quantity", "Profit"], + }); + await page.mouse.move(PLOT_CX, PLOT_CY); + await page.waitForTimeout(TOOLTIP_SETTLE_MS); + await page.mouse.click(PLOT_CX, PLOT_CY); + await page.waitForTimeout(TOOLTIP_SETTLE_MS); + await expectViewerScreenshot(page, "scatter-pinned.png"); + }); +}); diff --git a/packages/viewer-charts/test/js/treemap.spec.ts b/packages/viewer-charts/test/js/treemap.spec.ts index ad026fc06b..2f12e70175 100644 --- a/packages/viewer-charts/test/js/treemap.spec.ts +++ b/packages/viewer-charts/test/js/treemap.spec.ts @@ -19,66 +19,46 @@ test.describe("Treemap", () => { }); test("basic hierarchy", async ({ page }) => { - await renderAndCapture( - page, - { - plugin: "Treemap", - columns: ["Sales"], - group_by: ["Region", "Category"], - }, - "basic.png", - ); + await renderAndCapture(page, { + plugin: "Treemap", + columns: ["Sales"], + group_by: ["Region", "Category"], + }); }); test("no color slot → single-palette series mode", async ({ page }) => { // Regression: when Color is empty, _colorMode is "empty", // every leaf gets palette[0], and the legend is suppressed. - await renderAndCapture( - page, - { - plugin: "Treemap", - columns: ["Sales"], - group_by: ["Region"], - }, - "empty-color.png", - ); + await renderAndCapture(page, { + plugin: "Treemap", + columns: ["Sales"], + group_by: ["Region"], + }); }); test("numeric color → gradient + gradient legend", async ({ page }) => { - await renderAndCapture( - page, - { - plugin: "Treemap", - columns: ["Sales", "Profit"], - group_by: ["Region", "Category"], - }, - "numeric-color.png", - ); + await renderAndCapture(page, { + plugin: "Treemap", + columns: ["Sales", "Profit"], + group_by: ["Region", "Category"], + }); }); test("string color → series palette + categorical legend", async ({ page, }) => { - await renderAndCapture( - page, - { - plugin: "Treemap", - columns: ["Sales", "Ship Mode"], - group_by: ["Region", "Category"], - }, - "string-color.png", - ); + await renderAndCapture(page, { + plugin: "Treemap", + columns: ["Sales", "Ship Mode"], + group_by: ["Region", "Category"], + }); }); test("three-level group_by", async ({ page }) => { - await renderAndCapture( - page, - { - plugin: "Treemap", - columns: ["Sales"], - group_by: ["Region", "Category", "Sub-Category"], - }, - "three-level.png", - ); + await renderAndCapture(page, { + plugin: "Treemap", + columns: ["Sales"], + group_by: ["Region", "Category", "Sub-Category"], + }); }); }); diff --git a/packages/viewer-charts/test/js/x-bar.spec.ts b/packages/viewer-charts/test/js/x-bar.spec.ts index 99f64934e6..cb2c947c08 100644 --- a/packages/viewer-charts/test/js/x-bar.spec.ts +++ b/packages/viewer-charts/test/js/x-bar.spec.ts @@ -19,51 +19,35 @@ test.describe("X Bar", () => { }); test("basic single series", async ({ page }) => { - await renderAndCapture( - page, - { - plugin: "X Bar", - columns: ["Sales"], - group_by: ["Category"], - }, - "basic.png", - ); + await renderAndCapture(page, { + plugin: "X Bar", + columns: ["Sales"], + group_by: ["Category"], + }); }); test("split_by series colors", async ({ page }) => { - await renderAndCapture( - page, - { - plugin: "X Bar", - columns: ["Sales"], - group_by: ["Category"], - split_by: ["Region"], - }, - "split_by.png", - ); + await renderAndCapture(page, { + plugin: "X Bar", + columns: ["Sales"], + group_by: ["Category"], + split_by: ["Region"], + }); }); test("multiple X columns", async ({ page }) => { - await renderAndCapture( - page, - { - plugin: "X Bar", - columns: ["Sales", "Profit"], - group_by: ["Category"], - }, - "multi-x.png", - ); + await renderAndCapture(page, { + plugin: "X Bar", + columns: ["Sales", "Profit"], + group_by: ["Category"], + }); }); test("nested group_by", async ({ page }) => { - await renderAndCapture( - page, - { - plugin: "X Bar", - columns: ["Sales"], - group_by: ["Region", "Category"], - }, - "nested-group.png", - ); + await renderAndCapture(page, { + plugin: "X Bar", + columns: ["Sales"], + group_by: ["Region", "Category"], + }); }); }); diff --git a/packages/viewer-charts/test/js/y-area.spec.ts b/packages/viewer-charts/test/js/y-area.spec.ts index af0aab1aa7..d5558a5d36 100644 --- a/packages/viewer-charts/test/js/y-area.spec.ts +++ b/packages/viewer-charts/test/js/y-area.spec.ts @@ -19,27 +19,19 @@ test.describe("Y Area", () => { }); test("basic", async ({ page }) => { - await renderAndCapture( - page, - { - plugin: "Y Area", - columns: ["Sales"], - group_by: ["Category"], - }, - "basic.png", - ); + await renderAndCapture(page, { + plugin: "Y Area", + columns: ["Sales"], + group_by: ["Category"], + }); }); test("split_by", async ({ page }) => { - await renderAndCapture( - page, - { - plugin: "Y Area", - columns: ["Sales"], - group_by: ["Category"], - split_by: ["Region"], - }, - "split_by.png", - ); + await renderAndCapture(page, { + plugin: "Y Area", + columns: ["Sales"], + group_by: ["Category"], + split_by: ["Region"], + }); }); }); diff --git a/packages/viewer-charts/test/js/y-bar.spec.ts b/packages/viewer-charts/test/js/y-bar.spec.ts index effc346543..e2d860bc31 100644 --- a/packages/viewer-charts/test/js/y-bar.spec.ts +++ b/packages/viewer-charts/test/js/y-bar.spec.ts @@ -19,51 +19,35 @@ test.describe("Y Bar", () => { }); test("basic single series", async ({ page }) => { - await renderAndCapture( - page, - { - plugin: "Y Bar", - columns: ["Sales"], - group_by: ["Category"], - }, - "basic.png", - ); + await renderAndCapture(page, { + plugin: "Y Bar", + columns: ["Sales"], + group_by: ["Category"], + }); }); test("split_by series colors", async ({ page }) => { - await renderAndCapture( - page, - { - plugin: "Y Bar", - columns: ["Sales"], - group_by: ["Category"], - split_by: ["Region"], - }, - "split_by.png", - ); + await renderAndCapture(page, { + plugin: "Y Bar", + columns: ["Sales"], + group_by: ["Category"], + split_by: ["Region"], + }); }); test("multiple Y columns", async ({ page }) => { - await renderAndCapture( - page, - { - plugin: "Y Bar", - columns: ["Sales", "Profit"], - group_by: ["Category"], - }, - "multi-y.png", - ); + await renderAndCapture(page, { + plugin: "Y Bar", + columns: ["Sales", "Profit"], + group_by: ["Category"], + }); }); test("nested group_by", async ({ page }) => { - await renderAndCapture( - page, - { - plugin: "Y Bar", - columns: ["Sales"], - group_by: ["Region", "Category"], - }, - "nested-group.png", - ); + await renderAndCapture(page, { + plugin: "Y Bar", + columns: ["Sales"], + group_by: ["Region", "Category"], + }); }); }); diff --git a/packages/viewer-charts/test/js/y-line.spec.ts b/packages/viewer-charts/test/js/y-line.spec.ts index 86105394d6..d24c5a05ae 100644 --- a/packages/viewer-charts/test/js/y-line.spec.ts +++ b/packages/viewer-charts/test/js/y-line.spec.ts @@ -19,15 +19,11 @@ test.describe("Y Line", () => { }); test("basic", async ({ page }) => { - await renderAndCapture( - page, - { - plugin: "Y Line", - columns: ["Sales"], - group_by: ["Category"], - }, - "basic.png", - ); + await renderAndCapture(page, { + plugin: "Y Line", + columns: ["Sales"], + group_by: ["Category"], + }); }); test("split_by shows distinct per-series colors (regression)", async ({ @@ -36,15 +32,11 @@ test.describe("Y Line", () => { // This spec pins the fix for the Y-Line-renders-all-black bug: // bar/glyphs/draw-lines.ts must use the uniform-color shader // variant and set u_color per series. - await renderAndCapture( - page, - { - plugin: "Y Line", - columns: ["Sales"], - group_by: ["Category"], - split_by: ["Region"], - }, - "split_by-colors.png", - ); + await renderAndCapture(page, { + plugin: "Y Line", + columns: ["Sales"], + group_by: ["Category"], + split_by: ["Region"], + }); }); }); diff --git a/packages/viewer-charts/test/js/y-ohlc.spec.ts b/packages/viewer-charts/test/js/y-ohlc.spec.ts index 038bf053aa..f966c3bd4f 100644 --- a/packages/viewer-charts/test/js/y-ohlc.spec.ts +++ b/packages/viewer-charts/test/js/y-ohlc.spec.ts @@ -19,39 +19,27 @@ test.describe("Y OHLC", () => { }); test("basic four-column OHLC", async ({ page }) => { - await renderAndCapture( - page, - { - plugin: "OHLC", - columns: ["Sales", "Profit", "Quantity", "Discount"], - group_by: ["Order Date"], - }, - "basic.png", - ); + await renderAndCapture(page, { + plugin: "OHLC", + columns: ["Sales", "Profit", "Quantity", "Discount"], + group_by: ["Order Date"], + }); }); test("open-only falls back to next-row open", async ({ page }) => { - await renderAndCapture( - page, - { - plugin: "OHLC", - columns: ["Sales"], - group_by: ["Order Date"], - }, - "open-only.png", - ); + await renderAndCapture(page, { + plugin: "OHLC", + columns: ["Sales"], + group_by: ["Order Date"], + }); }); test("with split_by", async ({ page }) => { - await renderAndCapture( - page, - { - plugin: "OHLC", - columns: ["Sales"], - group_by: ["Category"], - split_by: ["Region"], - }, - "split_by.png", - ); + await renderAndCapture(page, { + plugin: "OHLC", + columns: ["Sales"], + group_by: ["Category"], + split_by: ["Region"], + }); }); }); diff --git a/packages/viewer-charts/test/js/y-scatter.spec.ts b/packages/viewer-charts/test/js/y-scatter.spec.ts index 58225ed54b..0c3f2d530c 100644 --- a/packages/viewer-charts/test/js/y-scatter.spec.ts +++ b/packages/viewer-charts/test/js/y-scatter.spec.ts @@ -19,27 +19,19 @@ test.describe("Y Scatter", () => { }); test("basic", async ({ page }) => { - await renderAndCapture( - page, - { - plugin: "Y Scatter", - columns: ["Sales"], - group_by: ["Category"], - }, - "basic.png", - ); + await renderAndCapture(page, { + plugin: "Y Scatter", + columns: ["Sales"], + group_by: ["Category"], + }); }); test("split_by", async ({ page }) => { - await renderAndCapture( - page, - { - plugin: "Y Scatter", - columns: ["Sales"], - group_by: ["Category"], - split_by: ["Region"], - }, - "split_by.png", - ); + await renderAndCapture(page, { + plugin: "Y Scatter", + columns: ["Sales"], + group_by: ["Category"], + split_by: ["Region"], + }); }); }); diff --git a/packages/viewer-charts/test/js/zoom.spec.ts b/packages/viewer-charts/test/js/zoom.spec.ts new file mode 100644 index 0000000000..604f2fa984 --- /dev/null +++ b/packages/viewer-charts/test/js/zoom.spec.ts @@ -0,0 +1,81 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import { test } from "@perspective-dev/test"; +import { + expectViewerScreenshot, + gotoBasic, + restoreChart, + waitOneFrame, +} 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. +const PLOT_CX = 640; +const PLOT_CY = 360; + +test.describe("Zoom", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + test("wheel zooms in on scatter", async ({ page }) => { + await restoreChart(page, { + plugin: "X/Y Scatter", + columns: ["Quantity", "Profit"], + }); + + await page.mouse.move(PLOT_CX, PLOT_CY); + await page.mouse.wheel(0, -500); + await waitOneFrame(page); + await expectViewerScreenshot(page, "scatter-wheel-in.png"); + }); + + test("wheel zooms in on line with date axis", async ({ page }) => { + await restoreChart(page, { + plugin: "X/Y Line", + columns: ["Order Date", "Profit"], + group_by: ["Order Date"], + }); + + await page.mouse.move(PLOT_CX, PLOT_CY); + await page.mouse.wheel(0, -500); + await waitOneFrame(page); + await expectViewerScreenshot(page, "line-wheel-in.png"); + }); + + test("wheel zooms in on Y Bar", async ({ page }) => { + await restoreChart(page, { + plugin: "Y Bar", + columns: ["Sales"], + group_by: ["State"], + }); + + await page.mouse.move(PLOT_CX, PLOT_CY); + await page.mouse.wheel(0, -500); + await waitOneFrame(page); + await expectViewerScreenshot(page, "bar-wheel-in.png"); + }); + + test("wheel zooms in on Candlestick", async ({ page }) => { + await restoreChart(page, { + plugin: "Candlestick", + columns: ["Sales"], + group_by: ["Order Date"], + }); + + await page.mouse.move(PLOT_CX, PLOT_CY); + await page.mouse.wheel(0, -500); + await waitOneFrame(page); + await expectViewerScreenshot(page, "candlestick-wheel-in.png"); + }); +}); diff --git a/packages/viewer-charts/tsconfig.json b/packages/viewer-charts/tsconfig.json index ac3b3caa92..a6c436932c 100644 --- a/packages/viewer-charts/tsconfig.json +++ b/packages/viewer-charts/tsconfig.json @@ -8,7 +8,10 @@ "rootDir": "./src/ts", "moduleResolution": "bundler", "skipLibCheck": true, - "resolveJsonModule": true + "resolveJsonModule": true, + "strict": true, + "noImplicitAny": true, + "esModuleInterop": true }, "include": ["./src/ts/**/*", "./types.d.ts"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7aacd496bb..5e7f7f5089 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1248,9 +1248,6 @@ importers: superstore-arrow: specifier: 'catalog:' version: 3.2.0 - tar: - specifier: 'catalog:' - version: 7.5.1 tsx: specifier: 'catalog:' version: 4.20.6 diff --git a/rust/perspective-js/src/rust/typed_array.rs b/rust/perspective-js/src/rust/typed_array.rs index 1083de5751..361f27970f 100644 --- a/rust/perspective-js/src/rust/typed_array.rs +++ b/rust/perspective-js/src/rust/typed_array.rs @@ -20,6 +20,7 @@ use arrow_schema::{DataType, TimeUnit}; use js_sys::{Array, Function, JsString, Uint8Array}; use perspective_client::ViewWindow; use ts_rs::TS; +use wasm_bindgen::JsCast; use wasm_bindgen::prelude::*; #[wasm_bindgen] @@ -52,9 +53,14 @@ impl From for ViewWindow { /// /// Callback signature: /// ```js -/// callback(names: string[], values: TypedArray[], validities: (Uint8Array|null)[], dictionaries: (string[]|null)[]) +/// callback(names: string[], values: TypedArray[], validities: (Uint8Array|null)[], dictionaries: (string[]|null)[]) => void | Promise /// ``` -pub(crate) fn decode_and_call( +/// +/// If the callback returns a `Promise`, it is awaited before the Arrow +/// batch (and therefore the zero-copy typed-array views into it) is +/// dropped. A synchronous callback returning `undefined` is supported +/// with no promise-handling overhead. +pub(crate) async fn decode_and_call( arrow: &[u8], float32: bool, callback: &Function, @@ -224,7 +230,7 @@ pub(crate) fn decode_and_call( } } - callback.call4( + let ret = callback.call4( &JsValue::UNDEFINED, &js_names.into(), &js_values.into(), @@ -232,7 +238,17 @@ pub(crate) fn decode_and_call( &js_dicts.into(), )?; - // Keep storage alive until after the callback returns. + // If the callback returned a Promise, await it before releasing the + // batch — zero-copy TypedArray views into `batch`/`f32_storage`/ + // `f64_storage` must remain valid for the full lifetime of the + // awaited work. + if ret.is_instance_of::() { + let promise: js_sys::Promise = ret.unchecked_into(); + wasm_bindgen_futures::JsFuture::from(promise).await?; + } + + // Keep storage alive until after the callback (and its awaited + // promise, if any) returns. drop(f32_storage); drop(f64_storage); diff --git a/rust/perspective-js/src/rust/view.rs b/rust/perspective-js/src/rust/view.rs index edfd281a42..84e1b49b87 100644 --- a/rust/perspective-js/src/rust/view.rs +++ b/rust/perspective-js/src/rust/view.rs @@ -249,7 +249,10 @@ impl View { /// Fetches columns from the [`View`] in Arrow format, decodes them, and /// passes typed array views to `callback`. All arrays are only valid for - /// the duration of the callback. + /// the duration of the callback — if `callback` returns a `Promise`, it + /// is awaited before the backing Arrow buffer is released, so async + /// callbacks may use the views for the full duration of the awaited + /// work (e.g. across an `await requestAnimationFrame`-backed promise). /// /// # Arguments /// @@ -257,7 +260,7 @@ impl View { /// windowing and output options (e.g., `float32` mode). /// - `callback` - A JS function called with `(names: string[], values: /// TypedArray[], validities: (Uint8Array|null)[], dictionaries: - /// (string[]|null)[])`. + /// (string[]|null)[]) => void | Promise`. #[wasm_bindgen] pub async fn with_typed_arrays( &self, @@ -272,7 +275,7 @@ impl View { let mut view_window: ViewWindow = opts.into(); view_window.emit_legacy_row_path_names = Some(false); let arrow = self.0.to_arrow(view_window).await?; - crate::typed_array::decode_and_call(&arrow, float32, &callback)?; + crate::typed_array::decode_and_call(&arrow, float32, &callback).await?; Ok(()) } diff --git a/rust/perspective-js/test/js/to_format/with_column_typed_array.spec.ts b/rust/perspective-js/test/js/to_format/with_column_typed_array.spec.ts index 7efa010102..b801103be5 100644 --- a/rust/perspective-js/test/js/to_format/with_column_typed_array.spec.ts +++ b/rust/perspective-js/test/js/to_format/with_column_typed_array.spec.ts @@ -14,6 +14,55 @@ import { test, expect } from "@perspective-dev/test"; import perspective from "../perspective_client"; test.describe("with_typed_arrays()", () => { + test("awaits promise returned by async callback before releasing the batch", async () => { + // The callback returns a Promise that only resolves after a + // microtask tick; the zero-copy views must remain valid for the + // full awaited duration. We copy *after* the tick to prove the + // backing WASM memory is still readable. + const table = await perspective.table({ + x: ["a", "b", "a", "c"], + }); + const view = await table.view(); + let resolved: string[] | null = null; + await view.with_typed_arrays( + {}, + async ( + _n: string[], + vals: ArrayLike[], + _valids: any[], + dicts: (string[] | null)[], + ) => { + const keys = vals[0] as Int32Array; + const dict = dicts[0]!; + // Yield twice to prove the Rust side is actually awaiting + // the returned promise rather than firing a sync call and + // dropping the batch immediately. + await new Promise((r) => setTimeout(r, 0)); + await new Promise((r) => setTimeout(r, 0)); + resolved = Array.from(keys).map((k) => dict[k]); + }, + ); + expect(resolved).toEqual(["a", "b", "a", "c"]); + await view.delete(); + await table.delete(); + }); + + test("rejected promise from async callback surfaces as a rejection", async () => { + const table = await perspective.table({ x: [1, 2, 3] }); + const view = await table.view(); + let caught: unknown = null; + try { + await view.with_typed_arrays({}, async () => { + throw new Error("callback boom"); + }); + } catch (e) { + caught = e; + } + expect(String(caught)).toContain("callback boom"); + await view.delete(); + await table.delete(); + }); + test("returns all columns with names, values, validities, dictionaries", async () => { const table = await perspective.table({ a: [1, 2, 3], diff --git a/rust/perspective-viewer/src/ts/plugin.ts b/rust/perspective-viewer/src/ts/plugin.ts index fcee700672..75d8aee8b1 100644 --- a/rust/perspective-viewer/src/ts/plugin.ts +++ b/rust/perspective-viewer/src/ts/plugin.ts @@ -12,31 +12,6 @@ import type { View } from "@perspective-dev/client"; -// import type * as perspective from "@perspective-dev/client"; - -/** - * Metadata returned by each iteration of a streaming render. - */ -export interface RenderChunk { - /** True when this is the first chunk (axes/layout are ready to show). */ - isFirst: boolean; - /** True when all chunks have been rendered. */ - isComplete: boolean; - /** Progress as a value between 0.0 and 1.0. */ - progress: number; -} - -/** - * A handle returned by `drawStreaming()` / `updateStreaming()` that the - * renderer uses to drive chunk-by-chunk rendering and cancel in-flight work. - */ -export interface StreamingRenderHandle { - /** Render the next chunk. Resolves with chunk metadata, or null when done. */ - next(): Promise; - /** Cancel all in-flight work and clean up partial state. */ - cancel(): void; -} - /** * The `IPerspectiveViewerPlugin` interface defines the necessary API for a * `` plugin, which also must be an `HTMLElement` via the diff --git a/tools/scripts/test_js.mjs b/tools/scripts/test_js.mjs index 6ba0829ed5..8ed1562a3c 100644 --- a/tools/scripts/test_js.mjs +++ b/tools/scripts/test_js.mjs @@ -56,7 +56,10 @@ function playwright(pkg, is_jlab) { console.log(`-- Running ${pkg_name}Playwright test suite`); const args = process.argv .slice(2) - .filter((x) => x !== "--ci" && x !== "--jupyter"); + .filter( + (x) => + x !== "--ci" && x !== "--jupyter" && x !== "--fetch-snapshots", + ); const env = { ...process.env, TZ: "UTC" }; if (is_jlab) { @@ -64,6 +67,14 @@ function playwright(pkg, is_jlab) { env.__JUPYTERLAB_PORT__ = "6538"; } + if (getarg("--fetch-snapshots")) { + env.PSP_FETCH_SNAPSHOTS = "1"; + } + + if (getarg("--update-snapshots")) { + env.PSP_UPDATE_SNAPSHOTS = "1"; + } + if (IS_CI) { env.CI = "1"; } diff --git a/tools/test/package.json b/tools/test/package.json index 0bb4e34193..6e51f71853 100644 --- a/tools/test/package.json +++ b/tools/test/package.json @@ -22,7 +22,6 @@ "dependencies": { "react": "catalog:", "react-dom": "catalog:", - "tar": "catalog:", "tsx": "catalog:", "prettier": "catalog:", "superstore-arrow": "catalog:", diff --git a/tools/test/src/js/global_startup.ts b/tools/test/src/js/global_startup.ts index fe083520a8..f65e065dfd 100644 --- a/tools/test/src/js/global_startup.ts +++ b/tools/test/src/js/global_startup.ts @@ -10,19 +10,10 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import * as tar from "tar"; -import fs from "node:fs"; -import path from "node:path"; -import url from "node:url"; - -const __filename = url.fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); +import { fetchSnapshots } from "./snapshot-sync.js"; export default async function run() { - const RESULTS_PATH = path.join(__dirname, "../../results.tar.gz"); - const cwd = path.join(__dirname, "..", ".."); - if (fs.existsSync(RESULTS_PATH)) { - console.log("Using results.tar.gz"); - await tar.extract({ file: RESULTS_PATH, gzip: true, cwd }); + if (process.env.PSP_FETCH_SNAPSHOTS) { + await fetchSnapshots(); } } diff --git a/tools/test/src/js/global_teardown.ts b/tools/test/src/js/global_teardown.ts index 53e18ff298..16ecc07c0b 100644 --- a/tools/test/src/js/global_teardown.ts +++ b/tools/test/src/js/global_teardown.ts @@ -10,52 +10,10 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import * as tar from "tar"; -import fs from "fs"; -import path from "path"; -import url from "node:url"; - -import "zx/globals"; - -const __filename = url.fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const RESULTS_PATH = path.join(__dirname, "../../results.tar.gz"); +import { writebackSnapshots } from "./snapshot-sync.js"; export default async function run() { - if (fs.existsSync(RESULTS_PATH)) { - console.log("\nReplacing results.tar.gz"); - } else { - console.log("\nCreating results.tar.gz"); + if (process.env.PSP_UPDATE_SNAPSHOTS) { + await writebackSnapshots(); } - - const cwd = path.join(__dirname, "..", ".."); - await new Promise((x) => - tar.create( - { - cwd, - gzip: true, - file: RESULTS_PATH, - sync: false, - portable: true, - noMtime: true, - strip: 2, - filter: (path, stat) => { - stat.mtime = null; - stat.atime = null; - stat.ctime = null; - // stat.birthtime = null; - return !path.endsWith(".DS_Store"); - }, - }, - [ - ...glob.sync("dist/snapshots/**/*.txt", { cwd }), - ...glob.sync("dist/snapshots/**/*.html", { cwd }), - // Image baselines for visual-regression specs. Without - // these, CI has no Playwright PNG comparison target and - // every `toHaveScreenshot` call fails. - ...glob.sync("dist/snapshots/**/*.png", { cwd }), - ], - x, - ), - ); } diff --git a/tools/test/src/js/snapshot-sync.ts b/tools/test/src/js/snapshot-sync.ts new file mode 100644 index 0000000000..b49d5ce092 --- /dev/null +++ b/tools/test/src/js/snapshot-sync.ts @@ -0,0 +1,136 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import url from "node:url"; + +const __filename = url.fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const TEST_ROOT = path.resolve(__dirname, "..", ".."); +const CACHE_DIR = path.join(TEST_ROOT, "dist", "git_snapshots"); +const DEST_DIR = path.join(TEST_ROOT, "dist", "snapshots"); +const DEFAULT_REF = "master"; + +function git(args: string[], cwd: string) { + execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] }); +} + +function remoteHasRef(remoteUrl: string, ref: string): boolean { + try { + const out = execFileSync( + "git", + ["ls-remote", "--heads", remoteUrl, ref], + { stdio: ["ignore", "pipe", "pipe"] }, + ) + .toString() + .trim(); + return out.length > 0; + } catch { + return false; + } +} + +function buildRemoteUrl(repo: string, token: string | undefined): string { + if (token) { + return `https://x-access-token:${token}@github.com/${repo}.git`; + } + return `git@github.com:${repo}.git`; +} + +function mirrorSnapshots(srcRoot: string, dest: string) { + const srcSnapshots = path.join(srcRoot); + if (!fs.existsSync(srcSnapshots)) { + throw new Error( + `Snapshot clone at ${srcRoot} does not contain dist/snapshots/`, + ); + } + + fs.rmSync(dest, { recursive: true, force: true }); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.cpSync(srcSnapshots, dest, { recursive: true }); +} + +export async function fetchSnapshots(): Promise { + const repo = process.env.PSP_SNAPSHOT_REPO; + if (!repo) { + throw new Error( + "PSP_SNAPSHOT_REPO is required when fetching snapshots (e.g. 'perspective-dev/perspective-snapshots').", + ); + } + + const token = + process.env.PSP_SNAPSHOT_TOKEN || process.env.GITHUB_TOKEN || undefined; + const requestedRef = process.env.PSP_SNAPSHOT_REF || DEFAULT_REF; + const remoteUrl = buildRemoteUrl(repo, token); + + let ref = requestedRef; + if ( + requestedRef !== DEFAULT_REF && + !remoteHasRef(remoteUrl, requestedRef) + ) { + console.log( + `Snapshot branch '${requestedRef}' not found on ${repo}; falling back to '${DEFAULT_REF}'.`, + ); + ref = DEFAULT_REF; + } + + const cacheGitDir = path.join(CACHE_DIR, ".git"); + if (fs.existsSync(cacheGitDir)) { + try { + git(["remote", "set-url", "origin", remoteUrl], CACHE_DIR); + git(["fetch", "--depth", "1", "origin", ref], CACHE_DIR); + git(["checkout", "-B", ref, "FETCH_HEAD"], CACHE_DIR); + git(["reset", "--hard", "FETCH_HEAD"], CACHE_DIR); + git(["clean", "-fdx"], CACHE_DIR); + } catch { + fs.rmSync(CACHE_DIR, { recursive: true, force: true }); + } + } + + if (!fs.existsSync(cacheGitDir)) { + fs.mkdirSync(path.dirname(CACHE_DIR), { recursive: true }); + execFileSync( + "git", + [ + "clone", + "--depth", + "1", + "--filter=blob:none", + "--branch", + ref, + remoteUrl, + CACHE_DIR, + ], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + } + + console.log(`Fetched snapshots from ${repo}@${ref}`); + mirrorSnapshots(CACHE_DIR, DEST_DIR); +} + +export async function writebackSnapshots(): Promise { + if (!fs.existsSync(path.join(CACHE_DIR, ".git"))) { + 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}`); +} diff --git a/tools/test/src/js/utils.ts b/tools/test/src/js/utils.ts index 83f2dec88d..2978f18ca3 100644 --- a/tools/test/src/js/utils.ts +++ b/tools/test/src/js/utils.ts @@ -11,7 +11,7 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import type { ViewerConfigUpdate } from "@perspective-dev/viewer"; -import test, { expect, Locator, Page } from "@playwright/test"; +import { test, expect, Locator, Page } from "@playwright/test"; import * as fs from "node:fs"; import path from "node:path"; import url from "node:url"; @@ -206,12 +206,12 @@ export function getWorkspaceShadowDOMContents(page: Page): Promise { }); } -export async function compareLightDOMContents(page) { +export async function compareLightDOMContents(page: Page) { const contents = await getWorkspaceLightDOMContents(page); await compareContentsToSnapshot(contents); } -export async function compareShadowDOMContents(page) { +export async function compareShadowDOMContents(page: Page) { const contents = await getWorkspaceShadowDOMContents(page); await compareContentsToSnapshot(contents); } @@ -222,30 +222,35 @@ export async function compareShadowDOMContents(page) { * @param page * @param path */ -export async function shadow_click(page, ...path): Promise { +export async function shadow_click( + page: Page, + ...path: string[] +): Promise { await page.evaluate( ({ path }) => { let elem: ShadowRoot | Element | Document | null | undefined = document; while (path.length > 0) { - let elem2 = elem; - if (elem2 instanceof Element && elem2.shadowRoot !== null) { - elem = elem2.shadowRoot; + if ( + elem instanceof Element && + (elem as Element).shadowRoot !== null + ) { + elem = (elem as Element).shadowRoot; } - elem = elem?.querySelector(path.shift()); + elem = elem?.querySelector(path.shift()!); } - function triggerMouseEvent(node, eventType) { + function triggerMouseEvent(node: EventTarget, eventType: string) { var clickEvent = document.createEvent("MouseEvent"); clickEvent.initEvent(eventType, true, true); node.dispatchEvent(clickEvent); } - triggerMouseEvent(elem, "mouseover"); - triggerMouseEvent(elem, "mousedown"); - triggerMouseEvent(elem, "mouseup"); - triggerMouseEvent(elem, "click"); + triggerMouseEvent(elem as EventTarget, "mouseover"); + triggerMouseEvent(elem as EventTarget, "mousedown"); + triggerMouseEvent(elem as EventTarget, "mouseup"); + triggerMouseEvent(elem as EventTarget, "click"); }, { path }, ); @@ -260,10 +265,10 @@ export async function shadow_click(page, ...path): Promise { * @param path */ export async function shadow_type( - page, - content, - is_incremental, - ...path + page: Page, + content: string, + is_incremental: boolean | string, + ...path: string[] ): Promise { if (typeof is_incremental !== "boolean") { path.unshift(is_incremental); @@ -275,19 +280,24 @@ export async function shadow_type( let elem: ShadowRoot | Element | Document | null | undefined = document; while (path.length > 0) { - let elem2 = elem; - if (elem2 instanceof Element && elem2.shadowRoot !== null) { - elem = elem2.shadowRoot; + if ( + elem instanceof Element && + (elem as Element).shadowRoot !== null + ) { + elem = (elem as Element).shadowRoot; } - elem = elem?.querySelector(path.shift()); + elem = elem?.querySelector(path.shift()!); } if (elem instanceof HTMLElement) { elem.focus(); } - function triggerInputEvent(element) { + function triggerInputEvent( + element: EventTarget | null | undefined, + ) { + if (!element) return; const event = new Event("input", { bubbles: true, cancelable: true, @@ -347,7 +357,7 @@ export async function shadow_type( * TODO: Playwright already does this with locators. * @param page */ -export async function shadow_blur(page): Promise { +export async function shadow_blur(page: Page): Promise { await page.evaluate(() => { let elem = document.activeElement; while (elem) {