@perspective-dev/viewer-charts 4.5.0 → 4.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/LICENSE.md +193 -0
  2. package/dist/cdn/perspective-viewer-charts.js +2 -2
  3. package/dist/cdn/perspective-viewer-charts.js.map +3 -3
  4. package/dist/esm/axis/bar-axis.d.ts +9 -1
  5. package/dist/esm/axis/categorical-axis.d.ts +0 -2
  6. package/dist/esm/charts/cartesian/cartesian.d.ts +26 -0
  7. package/dist/esm/charts/common/category-axis-resolver.d.ts +43 -1
  8. package/dist/esm/charts/common/expand-domain.d.ts +20 -0
  9. package/dist/esm/charts/common/tree-chart.d.ts +7 -0
  10. package/dist/esm/charts/common/tree-chrome.d.ts +23 -1
  11. package/dist/esm/charts/common/tree-interact.d.ts +46 -0
  12. package/dist/esm/charts/series/glyphs/draw-lines.d.ts +11 -4
  13. package/dist/esm/charts/series/series-build.d.ts +38 -2
  14. package/dist/esm/charts/series/series-render.d.ts +1 -4
  15. package/dist/esm/charts/series/series-type.d.ts +19 -17
  16. package/dist/esm/charts/series/series.d.ts +16 -0
  17. package/dist/esm/charts/sunburst/sunburst-interact.d.ts +1 -1
  18. package/dist/esm/charts/treemap/treemap-interact.d.ts +1 -6
  19. package/dist/esm/config.d.ts +19 -0
  20. package/dist/esm/interaction/host-sink-message.d.ts +10 -28
  21. package/dist/esm/interaction/raw-event-forwarder.d.ts +6 -7
  22. package/dist/esm/interaction/zoom-controller.d.ts +31 -20
  23. package/dist/esm/interaction/zoom-router.d.ts +3 -26
  24. package/dist/esm/perspective-viewer-charts.js +2 -2
  25. package/dist/esm/perspective-viewer-charts.js.map +3 -3
  26. package/dist/esm/plugin/plugin.d.ts +15 -3
  27. package/dist/esm/render/scheduler.d.ts +23 -0
  28. package/dist/esm/theme/palette.d.ts +0 -5
  29. package/dist/esm/transport/protocol.d.ts +2 -7
  30. package/dist/esm/webgl/context-manager.d.ts +100 -117
  31. package/dist/esm/webgl/context-pool.d.ts +40 -0
  32. package/dist/esm/webgl/gl-context.d.ts +54 -0
  33. package/dist/esm/worker/renderer.worker.d.ts +2 -4
  34. package/package.json +45 -45
  35. package/src/ts/axis/bar-axis.ts +74 -45
  36. package/src/ts/axis/categorical-axis.ts +0 -2
  37. package/src/ts/charts/candlestick/candlestick-render.ts +10 -7
  38. package/src/ts/charts/candlestick/candlestick.ts +10 -29
  39. package/src/ts/charts/candlestick/glyphs/draw-candlesticks.ts +36 -2
  40. package/src/ts/charts/candlestick/glyphs/draw-ohlc.ts +36 -2
  41. package/src/ts/charts/cartesian/cartesian-build.ts +143 -9
  42. package/src/ts/charts/cartesian/cartesian-render.ts +205 -30
  43. package/src/ts/charts/cartesian/cartesian.ts +43 -4
  44. package/src/ts/charts/cartesian/glyphs/density.ts +36 -41
  45. package/src/ts/charts/cartesian/glyphs/lines.ts +13 -15
  46. package/src/ts/charts/cartesian/glyphs/points.ts +12 -17
  47. package/src/ts/charts/chart-base.ts +20 -6
  48. package/src/ts/charts/chart.ts +1 -1
  49. package/src/ts/charts/common/category-axis-resolver.ts +135 -1
  50. package/src/ts/charts/common/expand-domain.ts +40 -0
  51. package/src/ts/charts/common/tree-chart.ts +16 -0
  52. package/src/ts/charts/common/tree-chrome.ts +86 -1
  53. package/src/ts/charts/common/tree-interact.ts +209 -0
  54. package/src/ts/charts/heatmap/heatmap-render.ts +9 -11
  55. package/src/ts/charts/series/glyphs/draw-areas.ts +30 -1
  56. package/src/ts/charts/series/glyphs/draw-lines.ts +151 -76
  57. package/src/ts/charts/series/series-build.ts +394 -21
  58. package/src/ts/charts/series/series-render.ts +159 -38
  59. package/src/ts/charts/series/series-type.ts +37 -17
  60. package/src/ts/charts/series/series.ts +63 -68
  61. package/src/ts/charts/sunburst/sunburst-interact.ts +18 -162
  62. package/src/ts/charts/sunburst/sunburst-render.ts +24 -89
  63. package/src/ts/charts/sunburst/sunburst.ts +1 -15
  64. package/src/ts/charts/treemap/treemap-interact.ts +22 -189
  65. package/src/ts/charts/treemap/treemap-render.ts +19 -46
  66. package/src/ts/charts/treemap/treemap.ts +1 -16
  67. package/src/ts/config.ts +21 -1
  68. package/src/ts/interaction/host-sink-message.ts +33 -22
  69. package/src/ts/interaction/raw-event-forwarder.ts +10 -12
  70. package/src/ts/interaction/zoom-controller.ts +120 -83
  71. package/src/ts/interaction/zoom-router.ts +3 -126
  72. package/src/ts/map/tile-layer.ts +13 -13
  73. package/src/ts/plugin/plugin.ts +176 -196
  74. package/src/ts/render/scheduler.ts +125 -42
  75. package/src/ts/shaders/line-uniform.frag.glsl +2 -1
  76. package/src/ts/shaders/line-uniform.vert.glsl +19 -0
  77. package/src/ts/theme/palette.ts +1 -4
  78. package/src/ts/transport/protocol.ts +3 -8
  79. package/src/ts/webgl/context-manager.ts +195 -189
  80. package/src/ts/webgl/context-pool.ts +78 -0
  81. package/src/ts/webgl/gl-context.ts +191 -0
  82. package/src/ts/worker/dispatch.ts +0 -1
  83. package/src/ts/worker/renderer.worker.ts +72 -65
  84. package/src/ts/worker/session-host.ts +29 -0
@@ -11,7 +11,10 @@
11
11
  // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
12
 
13
13
  import type { ColumnDataMap, ColumnData } from "../../data/view-reader";
14
- import type { CategoricalLevel } from "../../axis/categorical-axis";
14
+ import type {
15
+ CategoricalDomain,
16
+ CategoricalLevel,
17
+ } from "../../axis/categorical-axis";
15
18
  import { buildGroupRuns } from "../../axis/categorical-axis-core";
16
19
  import { formatTickValue, formatDateTickValue } from "../../layout/ticks";
17
20
 
@@ -312,3 +315,134 @@ export function resolveCategoryAxis(
312
315
 
313
316
  return { rowPaths, numCategories, rowOffset };
314
317
  }
318
+
319
+ export interface ValueCategoryColumn {
320
+ /**
321
+ * Source aggregate column name; used only for the axis label fallback.
322
+ */
323
+ name: string;
324
+
325
+ /**
326
+ * Post-aggregation perspective type string from `chart._columnTypes`
327
+ * (`"string"` is what triggers categorical mode).
328
+ */
329
+ type: string;
330
+
331
+ /**
332
+ * The actual `ColumnData` from the view. May be undefined when the
333
+ * caller couldn't resolve the column (treated as all-null).
334
+ */
335
+ data: ColumnData | undefined;
336
+ }
337
+
338
+ export interface ValueCategoryDomain {
339
+ /**
340
+ * Single-level `CategoricalDomain` shared across all input columns.
341
+ * `levels[0].labels` is the dictionary in slot order.
342
+ */
343
+ domain: CategoricalDomain;
344
+
345
+ /**
346
+ * Per-column slot-index buffers. Length === `numCategories`.
347
+ * Indexed in the same order as the input `columns` array.
348
+ */
349
+ perColumnSlots: Int32Array[];
350
+ }
351
+
352
+ /**
353
+ * Build a single shared categorical domain across one or more aggregate
354
+ * columns that land on the same axis side (primary or alt). Implements
355
+ * the "all-or-nothing per axis side" rule: returns `null` (= caller stays
356
+ * numeric) when any column is non-string; otherwise returns a single-
357
+ * level domain with the dictionary built in first-seen row order plus
358
+ * per-column slot indices the build pipeline writes into its pixel/slot
359
+ * buffer.
360
+ *
361
+ * Null / invalid rows surface as a `"(null)"` slot that's lazily added
362
+ * to the dictionary on first encounter — no reserved slot 0 when the
363
+ * data has no missing values.
364
+ */
365
+ export function resolveValueCategoryDomain(
366
+ columns: ValueCategoryColumn[],
367
+ numRows: number,
368
+ rowOffset: number,
369
+ axisLabel: string,
370
+ ): ValueCategoryDomain | null {
371
+ if (columns.length === 0) {
372
+ return null;
373
+ }
374
+
375
+ for (const c of columns) {
376
+ if (c.type !== "string") {
377
+ return null;
378
+ }
379
+ }
380
+
381
+ const numCategories = Math.max(0, numRows - rowOffset);
382
+ const dictionary: string[] = [];
383
+ const seen = new Map<string, number>();
384
+ const perColumnSlots: Int32Array[] = columns.map(
385
+ () => new Int32Array(numCategories),
386
+ );
387
+
388
+ const slotFor = (s: string): number => {
389
+ let slot = seen.get(s);
390
+ if (slot === undefined) {
391
+ slot = dictionary.length;
392
+ dictionary.push(s);
393
+ seen.set(s, slot);
394
+ }
395
+
396
+ return slot;
397
+ };
398
+
399
+ for (let ci = 0; ci < columns.length; ci++) {
400
+ const col = columns[ci].data;
401
+ const slots = perColumnSlots[ci];
402
+ for (let r = 0; r < numCategories; r++) {
403
+ const rowIdx = r + rowOffset;
404
+ let label: string;
405
+ if (!col) {
406
+ label = "(null)";
407
+ } else {
408
+ const valid = col.valid;
409
+ const isValid = valid
410
+ ? !!((valid[rowIdx >> 3] >> (rowIdx & 7)) & 1)
411
+ : true;
412
+ if (!isValid) {
413
+ label = "(null)";
414
+ } else if (col.indices && col.dictionary) {
415
+ label = col.dictionary[col.indices[rowIdx]] ?? "(null)";
416
+ } else if (col.values) {
417
+ const v = col.values[rowIdx];
418
+ label = v == null ? "(null)" : String(v);
419
+ } else {
420
+ label = "(null)";
421
+ }
422
+ }
423
+
424
+ slots[r] = slotFor(label);
425
+ }
426
+ }
427
+
428
+ let maxLabelChars = 0;
429
+ for (const s of dictionary) {
430
+ if (s.length > maxLabelChars) {
431
+ maxLabelChars = s.length;
432
+ }
433
+ }
434
+
435
+ const level: CategoricalLevel = {
436
+ labels: dictionary.slice(),
437
+ runs: [],
438
+ maxLabelChars,
439
+ };
440
+
441
+ const domain: CategoricalDomain = {
442
+ levels: [level],
443
+ numRows: dictionary.length,
444
+ levelLabels: [axisLabel],
445
+ };
446
+
447
+ return { domain, perColumnSlots };
448
+ }
@@ -0,0 +1,40 @@
1
+ // ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2
+ // ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
3
+ // ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
4
+ // ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
5
+ // ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
6
+ // ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7
+ // ┃ Copyright (c) 2017, the Perspective Authors. ┃
8
+ // ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9
+ // ┃ This file is part of the Perspective library, distributed under the terms ┃
10
+ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11
+ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
+
13
+ /**
14
+ * Numeric extent — used by series + candlestick build pipelines for
15
+ * value / category axis domains.
16
+ */
17
+ export interface Domain {
18
+ min: number;
19
+ max: number;
20
+ }
21
+
22
+ /**
23
+ * Union `next` (a freshly-computed extent) with `prev` (the prior
24
+ * accumulator) IN PLACE on `next`, then return a fresh copy to store
25
+ * back as the new accumulator. Idempotent when `prev` is null — `next`
26
+ * is left untouched.
27
+ *
28
+ * Used by the `domain_mode: "expand"` mirror-back step in the series /
29
+ * candlestick / cartesian build pipelines: mutating `next` in place
30
+ * means every downstream assignment that reads from the pipeline
31
+ * result struct automatically picks up the grown extent.
32
+ */
33
+ export function expandDomainInPlace(prev: Domain | null, next: Domain): Domain {
34
+ if (prev) {
35
+ next.min = Math.min(prev.min, next.min);
36
+ next.max = Math.max(prev.max, next.max);
37
+ }
38
+
39
+ return { min: next.min, max: next.max };
40
+ }
@@ -11,9 +11,25 @@
11
11
  // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
12
 
13
13
  import { AbstractChart } from "../chart-base";
14
+ import type { ColumnDataMap } from "../../data/view-reader";
14
15
  import { NodeStore, NULL_NODE } from "./node-store";
15
16
  import { LazyTooltip } from "../../interaction/lazy-tooltip";
16
17
 
18
+ /**
19
+ * Sentinel fallback for the Size slot when the user hasn't picked one:
20
+ * use the first non-metadata column in the incoming view. Tree charts
21
+ * still need *some* numeric-ish column to size geometry.
22
+ */
23
+ export function firstNonMetadataColumn(columns: ColumnDataMap): string {
24
+ for (const k of columns.keys()) {
25
+ if (!k.startsWith("__")) {
26
+ return k;
27
+ }
28
+ }
29
+
30
+ return "";
31
+ }
32
+
17
33
  /**
18
34
  * Shared state for hierarchical charts (treemap, sunburst). Holds the
19
35
  * tree store + streaming-insert scaffolding + per-row tooltip data
@@ -10,7 +10,17 @@
10
10
  // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11
11
  // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
12
 
13
- import type { Context2D } from "../canvas-types";
13
+ import type { Canvas2D, Context2D } from "../canvas-types";
14
+ import type { PlotRect } from "../../layout/plot-layout";
15
+ import { PlotLayout } from "../../layout/plot-layout";
16
+ import type { GradientStop } from "../../theme/gradient";
17
+ import type { Vec3 } from "../../theme/palette";
18
+ import type { Theme } from "../../theme/theme";
19
+ import {
20
+ renderCategoricalLegend,
21
+ renderCategoricalLegendAt,
22
+ renderLegend,
23
+ } from "../../axis/legend";
14
24
  import type { TreeChartBase } from "./tree-chart";
15
25
  import { drawTooltipBox } from "./draw-tooltip-box";
16
26
 
@@ -121,3 +131,78 @@ export function renderTreeTooltip(
121
131
  fontFamily,
122
132
  );
123
133
  }
134
+
135
+ /**
136
+ * Paint a color legend (categorical swatches or numeric gradient bar)
137
+ * for a tree chart. Shared by sunburst + treemap; both consult
138
+ * `_colorMode` / `_uniqueColorLabels.size` / `_colorMin..max` the same
139
+ * way.
140
+ *
141
+ * `categoricalRect`, when non-null, is used as the explicit rect for
142
+ * the categorical-swatch variant (sunburst's faceted mode passes
143
+ * `FacetGrid.legendRect` here). Numeric mode always derives from a
144
+ * synthetic single-plot `PlotLayout` to match the legacy per-chart
145
+ * branch — its gradient bar's vertical span doesn't fit the
146
+ * categorical legend's compact rect.
147
+ *
148
+ * Returns silently when the color slot is empty, when categorical mode
149
+ * has only one label, or when numeric mode has a degenerate
150
+ * (`min >= max`) extent.
151
+ */
152
+ export function renderTreeColorLegend(
153
+ chart: TreeChartBase,
154
+ canvas: Canvas2D,
155
+ palette: Vec3[],
156
+ stops: GradientStop[],
157
+ theme: Theme,
158
+ cssWidth: number,
159
+ cssHeight: number,
160
+ categoricalRect: PlotRect | null = null,
161
+ ): void {
162
+ if (chart._colorMode === "series" && chart._uniqueColorLabels.size > 1) {
163
+ if (categoricalRect) {
164
+ renderCategoricalLegendAt(
165
+ canvas,
166
+ categoricalRect,
167
+ chart._uniqueColorLabels,
168
+ palette,
169
+ theme,
170
+ );
171
+ } else {
172
+ renderCategoricalLegend(
173
+ canvas,
174
+ syntheticLegendLayout(cssWidth, cssHeight),
175
+ chart._uniqueColorLabels,
176
+ palette,
177
+ theme,
178
+ );
179
+ }
180
+ } else if (
181
+ chart._colorMode === "numeric" &&
182
+ chart._colorMin < chart._colorMax
183
+ ) {
184
+ renderLegend(
185
+ canvas,
186
+ syntheticLegendLayout(cssWidth, cssHeight),
187
+ {
188
+ min: chart._colorMin,
189
+ max: chart._colorMax,
190
+ label: chart._colorName,
191
+ },
192
+ stops,
193
+ theme,
194
+ chart.getColumnFormatter(chart._colorName, "value"),
195
+ );
196
+ }
197
+ }
198
+
199
+ function syntheticLegendLayout(
200
+ cssWidth: number,
201
+ cssHeight: number,
202
+ ): PlotLayout {
203
+ return new PlotLayout(cssWidth, cssHeight, {
204
+ hasXLabel: false,
205
+ hasYLabel: false,
206
+ hasLegend: true,
207
+ });
208
+ }
@@ -0,0 +1,209 @@
1
+ // ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2
+ // ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
3
+ // ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
4
+ // ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
5
+ // ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
6
+ // ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7
+ // ┃ Copyright (c) 2017, the Perspective Authors. ┃
8
+ // ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9
+ // ┃ This file is part of the Perspective library, distributed under the terms ┃
10
+ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11
+ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
+
13
+ import type { TreeChartBase } from "./tree-chart";
14
+ import { NULL_NODE, ancestorNames } from "./node-store";
15
+ import { rebuildBreadcrumbs } from "./tree-data";
16
+
17
+ /**
18
+ * Common subset of `TreemapChart` / `SunburstChart` reached by the
19
+ * shared interaction helpers — anything that lives on `TreeChartBase`
20
+ * plus the pinned/hover/facet-drill state the two charts both declare
21
+ * with identical shape but on the subclass (so we type it as an
22
+ * intersection).
23
+ */
24
+ export type TreeInteractChart = TreeChartBase & {
25
+ _pinnedNodeId: number;
26
+ _hoveredNodeId: number;
27
+ _facetDrillRoots: Map<string, number>;
28
+ };
29
+
30
+ /**
31
+ * Emit `perspective-click` + `perspective-global-filter selected:true`
32
+ * for a treemap/sunburst node. The path is walked via `ancestorNames`
33
+ * and split into split-by prefix + group-by levels using
34
+ * `_splitBy.length` as the boundary; faceted mode keeps the depth-0
35
+ * ancestor as the split prefix.
36
+ */
37
+ export async function emitTreeNodeEvent(
38
+ chart: TreeInteractChart,
39
+ nodeId: number,
40
+ kind: "leaf" | "branch",
41
+ ): Promise<void> {
42
+ const store = chart._nodeStore;
43
+ const path = ancestorNames(store, nodeId);
44
+ const isFaceted =
45
+ chart._splitBy.length > 0 && chart._facetConfig.facet_mode === "grid";
46
+ const splitByValues: (string | null)[] = isFaceted
47
+ ? path.slice(0, chart._splitBy.length)
48
+ : [];
49
+ const groupByValues: (string | null)[] = isFaceted
50
+ ? path.slice(
51
+ chart._splitBy.length,
52
+ chart._splitBy.length + chart._groupBy.length,
53
+ )
54
+ : path.slice(0, chart._groupBy.length);
55
+
56
+ const rowIdx = kind === "leaf" ? (store.leafRowIdx[nodeId] ?? null) : null;
57
+
58
+ await chart.emitClickAndSelect({
59
+ rowIdx: rowIdx != null && rowIdx >= 0 ? rowIdx : null,
60
+ columnName: chart._sizeName,
61
+ groupByValues,
62
+ splitByValues,
63
+ });
64
+ }
65
+
66
+ /**
67
+ * Build tooltip lines for `nodeId`: ancestor name path + aggregate
68
+ * value + (numeric) color value + per-row tooltip columns from
69
+ * `_lazyRows` for leaves. The leaf branch awaits the source-view row
70
+ * fetch; branch nodes have no underlying row so they emit a Children
71
+ * count instead.
72
+ */
73
+ export async function buildTreeTooltipLines(
74
+ chart: TreeInteractChart,
75
+ nodeId: number,
76
+ ): Promise<string[]> {
77
+ const store = chart._nodeStore;
78
+ const lines: string[] = [];
79
+
80
+ const pathNames: string[] = [];
81
+ let p = nodeId;
82
+ while (store.parent[p] !== NULL_NODE) {
83
+ pathNames.push(store.name[p]);
84
+ p = store.parent[p];
85
+ }
86
+
87
+ pathNames.reverse();
88
+ if (pathNames.length > 0) {
89
+ lines.push(pathNames.join(" › "));
90
+ } else {
91
+ lines.push(store.name[nodeId]);
92
+ }
93
+
94
+ const sizeFmt = chart.getColumnFormatter(chart._sizeName, "value");
95
+ lines.push(`Value: ${sizeFmt(store.value[nodeId])}`);
96
+
97
+ if (chart._colorName && !isNaN(store.colorValue[nodeId])) {
98
+ const colorFmt = chart.getColumnFormatter(chart._colorName, "value");
99
+ lines.push(
100
+ `${chart._colorName}: ${colorFmt(store.colorValue[nodeId])}`,
101
+ );
102
+ }
103
+
104
+ const rowIdx = store.leafRowIdx[nodeId];
105
+ const isLeaf =
106
+ store.firstChild[nodeId] === NULL_NODE && rowIdx !== NULL_NODE;
107
+
108
+ if (isLeaf && chart._lazyRows) {
109
+ const row = await chart._lazyRows.fetchRow(rowIdx);
110
+ for (const [name, value] of row) {
111
+ if (value === null || value === undefined) {
112
+ continue;
113
+ }
114
+
115
+ if (name === chart._colorName && !isNaN(store.colorValue[nodeId])) {
116
+ continue;
117
+ }
118
+
119
+ if (typeof value === "number") {
120
+ lines.push(
121
+ `${name}: ${chart.getColumnFormatter(name, "value")(value)}`,
122
+ );
123
+ } else {
124
+ lines.push(`${name}: ${value}`);
125
+ }
126
+ }
127
+ }
128
+
129
+ if (store.firstChild[nodeId] !== NULL_NODE) {
130
+ lines.push(`Children: ${store.childCount[nodeId]}`);
131
+ }
132
+
133
+ return lines;
134
+ }
135
+
136
+ /**
137
+ * Pin a tooltip at the chart-supplied anchor. Lines are fetched lazily;
138
+ * the `_pinnedNodeId` check on resolve discards stale results from a
139
+ * prior pin or dismissal.
140
+ */
141
+ export function showTreePinnedTooltip(
142
+ chart: TreeInteractChart,
143
+ nodeId: number,
144
+ anchor: { cx: number; cy: number },
145
+ renderChromeOverlay: () => void,
146
+ ): void {
147
+ chart._tooltip.dismiss();
148
+ chart._pinnedNodeId = nodeId;
149
+
150
+ const cssWidth = chart._glManager?.cssWidth ?? 0;
151
+ const cssHeight = chart._glManager?.cssHeight ?? 0;
152
+
153
+ buildTreeTooltipLines(chart, nodeId).then((lines) => {
154
+ if (chart._pinnedNodeId !== nodeId) {
155
+ return;
156
+ }
157
+
158
+ if (lines.length === 0) {
159
+ return;
160
+ }
161
+
162
+ chart._tooltip.pin(
163
+ lines,
164
+ { px: anchor.cx, py: anchor.cy },
165
+ { cssWidth, cssHeight },
166
+ );
167
+ });
168
+
169
+ chart._hoveredNodeId = NULL_NODE;
170
+ renderChromeOverlay();
171
+ }
172
+
173
+ export function dismissTreePinnedTooltip(chart: TreeInteractChart): void {
174
+ chart._tooltip.dismiss();
175
+ chart._pinnedNodeId = NULL_NODE;
176
+ }
177
+
178
+ /**
179
+ * Drill the clicked facet (or the whole chart in non-facet mode).
180
+ * Faceted drill walks up to the facet root (top-level child of
181
+ * `_rootId`), records the new drill node under that facet's label, and
182
+ * re-renders.
183
+ */
184
+ export function treeDrillTo(
185
+ chart: TreeInteractChart,
186
+ nodeId: number,
187
+ renderFrame: () => void,
188
+ ): void {
189
+ const store = chart._nodeStore;
190
+ if (chart._splitBy.length > 0 && chart._facetConfig.facet_mode === "grid") {
191
+ let p = nodeId;
192
+ while (p !== NULL_NODE && store.parent[p] !== chart._rootId) {
193
+ p = store.parent[p];
194
+ }
195
+
196
+ if (p !== NULL_NODE) {
197
+ chart._facetDrillRoots.set(store.name[p], nodeId);
198
+ }
199
+
200
+ chart._hoveredNodeId = NULL_NODE;
201
+ renderFrame();
202
+ return;
203
+ }
204
+
205
+ chart._currentRootId = nodeId;
206
+ rebuildBreadcrumbs(chart, nodeId);
207
+ chart._hoveredNodeId = NULL_NODE;
208
+ renderFrame();
209
+ }
@@ -20,6 +20,7 @@ import {
20
20
  withScissor,
21
21
  } from "../../webgl/plot-frame";
22
22
  import { getInstancing } from "../../webgl/instanced-attrs";
23
+ import { compileProgram } from "../../webgl/program-cache";
23
24
  import { initCanvas } from "../../axis/canvas";
24
25
  import { buildFacetGrid } from "../../layout/facet-grid";
25
26
  import {
@@ -236,21 +237,18 @@ function ensureProgram(
236
237
  }
237
238
 
238
239
  const gl = glManager.gl;
239
- const program = glManager.shaders.getOrCreate(
240
+ const compiled = compileProgram<
241
+ { program: WebGLProgram } & NonNullable<HeatmapChart["_locations"]>
242
+ >(
243
+ glManager,
240
244
  "heatmap",
241
245
  heatmapVert,
242
246
  heatmapFrag,
247
+ ["u_projection", "u_cell_inset", "u_cell_size", "u_gradient_lut"],
248
+ ["a_corner", "a_cell", "a_color_t"],
243
249
  );
244
- chart._program = program;
245
- chart._locations = {
246
- u_projection: gl.getUniformLocation(program, "u_projection"),
247
- u_cell_inset: gl.getUniformLocation(program, "u_cell_inset"),
248
- u_cell_size: gl.getUniformLocation(program, "u_cell_size"),
249
- u_gradient_lut: gl.getUniformLocation(program, "u_gradient_lut"),
250
- a_corner: gl.getAttribLocation(program, "a_corner"),
251
- a_cell: gl.getAttribLocation(program, "a_cell"),
252
- a_color_t: gl.getAttribLocation(program, "a_color_t"),
253
- };
250
+ chart._program = compiled.program;
251
+ chart._locations = compiled;
254
252
 
255
253
  const cornerBuffer = gl.createBuffer()!;
256
254
  gl.bindBuffer(gl.ARRAY_BUFFER, cornerBuffer);
@@ -13,6 +13,7 @@
13
13
  import type { WebGLContextManager } from "../../../webgl/context-manager";
14
14
  import type { SeriesChart } from "../series";
15
15
  import type { SeriesInfo } from "../series-build";
16
+ import type { InterpolateMode } from "../series-type";
16
17
  import { compileProgram } from "../../../webgl/program-cache";
17
18
  import areaVert from "../../../shaders/area.vert.glsl";
18
19
  import areaFrag from "../../../shaders/area.frag.glsl";
@@ -144,6 +145,7 @@ export class AreaGlyph {
144
145
 
145
146
  const entries: AreaSeriesEntry[] = [];
146
147
  for (const s of areaSeries) {
148
+ const seriesInfo = chart._series[s.seriesId];
147
149
  const strips = collectAreaStrips(
148
150
  s,
149
151
  N,
@@ -155,6 +157,9 @@ export class AreaGlyph {
155
157
  bars.y1,
156
158
  positions,
157
159
  xOrigin,
160
+ seriesInfo.start,
161
+ seriesInfo.end,
162
+ seriesInfo.interpolateMode,
158
163
  );
159
164
  if (strips.totalVertices === 0) {
160
165
  continue;
@@ -254,6 +259,21 @@ interface CollectedStrips {
254
259
  * Reads stacked y0/y1 from the pre-built `barIndex` (cached on the
255
260
  * chart at data load) so this hot path doesn't rebuild the map each
256
261
  * call.
262
+ *
263
+ * The "present" predicate is mode-aware for the unstacked branch:
264
+ *
265
+ * - `mode = "solid"` (also coerced from `"transparent"` for area):
266
+ * Pass 2 has populated every cell in `[start, end]`, including
267
+ * leading/trailing zero-fills. Treat `c in [start, end]` as
268
+ * present and ignore `sampleValid` (the bit is still 0 at
269
+ * synthesized cells).
270
+ * - `mode = "skip"`: Pass 2 didn't run for this series. Interior
271
+ * nulls inside `[start, end]` remain and the strip must break at
272
+ * them — use `sampleValid` as the "is present" check, matching
273
+ * the pre-feature behavior.
274
+ *
275
+ * The stacked branch is unchanged: the `barIndex` lookup already
276
+ * encodes presence post-stacking.
257
277
  */
258
278
  function collectAreaStrips(
259
279
  s: SeriesInfo,
@@ -266,10 +286,14 @@ function collectAreaStrips(
266
286
  barY1: Float64Array,
267
287
  positions: Float64Array | null,
268
288
  xOrigin: number,
289
+ seriesStart: number,
290
+ seriesEnd: number,
291
+ interpolateMode: InterpolateMode,
269
292
  ): CollectedStrips {
270
293
  const scratch = ensureStripScratch(N * 4);
271
294
  const descriptors: AreaStrip[] = [];
272
295
  const seriesBase = s.seriesId * 1_000_000_000;
296
+ const trustRange = interpolateMode !== "skip";
273
297
 
274
298
  let write = 0;
275
299
  let runStart = 0;
@@ -288,7 +312,12 @@ function collectAreaStrips(
288
312
  }
289
313
  } else {
290
314
  const idx = c * S + s.seriesId;
291
- if ((valid[idx >> 3] >> (idx & 7)) & 1) {
315
+ if (trustRange) {
316
+ if (c >= seriesStart && c <= seriesEnd) {
317
+ top = samples[idx];
318
+ present = true;
319
+ }
320
+ } else if ((valid[idx >> 3] >> (idx & 7)) & 1) {
292
321
  top = samples[idx];
293
322
  present = true;
294
323
  }