@perspective-dev/viewer-charts 4.5.0 → 4.5.1

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 (73) 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/interaction/host-sink-message.d.ts +10 -28
  20. package/dist/esm/interaction/raw-event-forwarder.d.ts +6 -7
  21. package/dist/esm/interaction/zoom-controller.d.ts +31 -20
  22. package/dist/esm/interaction/zoom-router.d.ts +3 -26
  23. package/dist/esm/perspective-viewer-charts.js +2 -2
  24. package/dist/esm/perspective-viewer-charts.js.map +3 -3
  25. package/dist/esm/plugin/plugin.d.ts +0 -1
  26. package/dist/esm/theme/palette.d.ts +0 -5
  27. package/dist/esm/transport/protocol.d.ts +2 -7
  28. package/dist/esm/worker/renderer.worker.d.ts +2 -4
  29. package/package.json +45 -45
  30. package/src/ts/axis/bar-axis.ts +74 -45
  31. package/src/ts/axis/categorical-axis.ts +0 -2
  32. package/src/ts/charts/candlestick/candlestick-render.ts +10 -7
  33. package/src/ts/charts/candlestick/candlestick.ts +10 -29
  34. package/src/ts/charts/candlestick/glyphs/draw-candlesticks.ts +36 -2
  35. package/src/ts/charts/candlestick/glyphs/draw-ohlc.ts +36 -2
  36. package/src/ts/charts/cartesian/cartesian-build.ts +143 -9
  37. package/src/ts/charts/cartesian/cartesian-render.ts +205 -30
  38. package/src/ts/charts/cartesian/cartesian.ts +43 -4
  39. package/src/ts/charts/cartesian/glyphs/density.ts +36 -41
  40. package/src/ts/charts/cartesian/glyphs/lines.ts +13 -15
  41. package/src/ts/charts/cartesian/glyphs/points.ts +12 -17
  42. package/src/ts/charts/chart-base.ts +20 -6
  43. package/src/ts/charts/chart.ts +1 -1
  44. package/src/ts/charts/common/category-axis-resolver.ts +135 -1
  45. package/src/ts/charts/common/expand-domain.ts +40 -0
  46. package/src/ts/charts/common/tree-chart.ts +16 -0
  47. package/src/ts/charts/common/tree-chrome.ts +86 -1
  48. package/src/ts/charts/common/tree-interact.ts +209 -0
  49. package/src/ts/charts/heatmap/heatmap-render.ts +9 -11
  50. package/src/ts/charts/series/glyphs/draw-areas.ts +30 -1
  51. package/src/ts/charts/series/glyphs/draw-lines.ts +151 -76
  52. package/src/ts/charts/series/series-build.ts +394 -21
  53. package/src/ts/charts/series/series-render.ts +159 -38
  54. package/src/ts/charts/series/series-type.ts +37 -17
  55. package/src/ts/charts/series/series.ts +63 -68
  56. package/src/ts/charts/sunburst/sunburst-interact.ts +18 -162
  57. package/src/ts/charts/sunburst/sunburst-render.ts +24 -89
  58. package/src/ts/charts/sunburst/sunburst.ts +1 -15
  59. package/src/ts/charts/treemap/treemap-interact.ts +22 -189
  60. package/src/ts/charts/treemap/treemap-render.ts +19 -46
  61. package/src/ts/charts/treemap/treemap.ts +1 -16
  62. package/src/ts/interaction/host-sink-message.ts +33 -22
  63. package/src/ts/interaction/raw-event-forwarder.ts +10 -12
  64. package/src/ts/interaction/zoom-controller.ts +120 -83
  65. package/src/ts/interaction/zoom-router.ts +3 -126
  66. package/src/ts/map/tile-layer.ts +13 -13
  67. package/src/ts/plugin/plugin.ts +100 -184
  68. package/src/ts/shaders/line-uniform.frag.glsl +2 -1
  69. package/src/ts/shaders/line-uniform.vert.glsl +19 -0
  70. package/src/ts/theme/palette.ts +1 -4
  71. package/src/ts/transport/protocol.ts +3 -8
  72. package/src/ts/worker/dispatch.ts +0 -1
  73. package/src/ts/worker/renderer.worker.ts +10 -46
@@ -15,6 +15,7 @@ import type { CartesianChart } from "../cartesian";
15
15
  import type { Glyph } from "../glyph";
16
16
  import { bindGradientTexture } from "../../../webgl/gradient-texture";
17
17
  import { getInstancing } from "../../../webgl/instanced-attrs";
18
+ import { compileProgram } from "../../../webgl/program-cache";
18
19
  import { buildPointRowTooltipLines } from "../tooltip-lines";
19
20
  import splatVert from "../../../shaders/density-splat.vert.glsl";
20
21
  import splatFrag from "../../../shaders/density-splat.frag.glsl";
@@ -183,16 +184,6 @@ export class DensityGlyph implements Glyph {
183
184
  }
184
185
 
185
186
  const gl = glManager.gl;
186
- const splatProgram = glManager.shaders.getOrCreate(
187
- "density-splat",
188
- splatVert,
189
- splatFrag,
190
- );
191
- const resolveProgram = glManager.shaders.getOrCreate(
192
- "density-resolve",
193
- resolveVert,
194
- resolveFrag,
195
- );
196
187
 
197
188
  const quadCornerBuffer = gl.createBuffer()!;
198
189
  gl.bindBuffer(gl.ARRAY_BUFFER, quadCornerBuffer);
@@ -217,24 +208,28 @@ export class DensityGlyph implements Glyph {
217
208
  const heatFramebuffer = gl.createFramebuffer()!;
218
209
 
219
210
  this._cache = {
220
- splat: extractSplatLocations(gl, splatProgram),
211
+ splat: compileSplatProgram(
212
+ glManager,
213
+ "density-splat",
214
+ splatVert,
215
+ splatFrag,
216
+ ),
221
217
  extremeSplat: null,
222
218
  mrtSplat: null,
223
- resolve: {
224
- program: resolveProgram,
225
- u_heat: gl.getUniformLocation(resolveProgram, "u_heat"),
226
- u_extreme: gl.getUniformLocation(resolveProgram, "u_extreme"),
227
- u_gradient_lut: gl.getUniformLocation(
228
- resolveProgram,
219
+ resolve: compileProgram<DensityCache["resolve"]>(
220
+ glManager,
221
+ "density-resolve",
222
+ resolveVert,
223
+ resolveFrag,
224
+ [
225
+ "u_heat",
226
+ "u_extreme",
229
227
  "u_gradient_lut",
230
- ),
231
- u_heat_max: gl.getUniformLocation(resolveProgram, "u_heat_max"),
232
- u_color_mode: gl.getUniformLocation(
233
- resolveProgram,
228
+ "u_heat_max",
234
229
  "u_color_mode",
235
- ),
236
- a_corner: gl.getAttribLocation(resolveProgram, "a_corner"),
237
- },
230
+ ],
231
+ ["a_corner"],
232
+ ),
238
233
  quadCornerBuffer,
239
234
  tripleCornerBuffer,
240
235
  heatTexture,
@@ -541,12 +536,12 @@ export class DensityGlyph implements Glyph {
541
536
  return cache.extremeSplat;
542
537
  }
543
538
 
544
- const program = glManager.shaders.getOrCreate(
539
+ cache.extremeSplat = compileSplatProgram(
540
+ glManager,
545
541
  "density-extreme",
546
542
  splatVert,
547
543
  extremeFrag,
548
544
  );
549
- cache.extremeSplat = extractSplatLocations(glManager.gl, program);
550
545
  return cache.extremeSplat;
551
546
  }
552
547
 
@@ -568,12 +563,12 @@ export class DensityGlyph implements Glyph {
568
563
  // the legacy GLSL 100 splat vert can't link against it because
569
564
  // a program's shaders must share a version. Use the paired
570
565
  // `density-mrt.vert.glsl` instead — same math, 300 ES dialect.
571
- const program = glManager.shaders.getOrCreate(
566
+ cache.mrtSplat = compileSplatProgram(
567
+ glManager,
572
568
  "density-mrt",
573
569
  mrtVert,
574
570
  mrtFrag,
575
571
  );
576
- cache.mrtSplat = extractSplatLocations(glManager.gl, program);
577
572
  return cache.mrtSplat;
578
573
  }
579
574
 
@@ -1088,20 +1083,20 @@ function createAccumTexture(
1088
1083
  return tex;
1089
1084
  }
1090
1085
 
1091
- function extractSplatLocations(
1092
- gl: WebGL2RenderingContext | WebGLRenderingContext,
1093
- program: WebGLProgram,
1086
+ function compileSplatProgram(
1087
+ glManager: WebGLContextManager,
1088
+ key: string,
1089
+ vert: string,
1090
+ frag: string,
1094
1091
  ): SplatProgramCache {
1095
- return {
1096
- program,
1097
- u_projection: gl.getUniformLocation(program, "u_projection"),
1098
- u_radius_ndc: gl.getUniformLocation(program, "u_radius_ndc"),
1099
- u_intensity: gl.getUniformLocation(program, "u_intensity"),
1100
- u_color_range: gl.getUniformLocation(program, "u_color_range"),
1101
- a_corner: gl.getAttribLocation(program, "a_corner"),
1102
- a_position: gl.getAttribLocation(program, "a_position"),
1103
- a_color_value: gl.getAttribLocation(program, "a_color_value"),
1104
- };
1092
+ return compileProgram<SplatProgramCache>(
1093
+ glManager,
1094
+ key,
1095
+ vert,
1096
+ frag,
1097
+ ["u_projection", "u_radius_ndc", "u_intensity", "u_color_range"],
1098
+ ["a_corner", "a_position", "a_color_value"],
1099
+ );
1105
1100
  }
1106
1101
 
1107
1102
  /**
@@ -18,6 +18,7 @@ import {
18
18
  createLineCornerBuffer,
19
19
  getInstancing,
20
20
  } from "../../../webgl/instanced-attrs";
21
+ import { compileProgram } from "../../../webgl/program-cache";
21
22
  import { formatTickValue, formatDateTickValue } from "../../../layout/ticks";
22
23
  import lineVert from "../../../shaders/line.vert.glsl";
23
24
  import lineFrag from "../../../shaders/line.frag.glsl";
@@ -56,26 +57,23 @@ export class LineGlyph implements Glyph {
56
57
  return;
57
58
  }
58
59
 
59
- const gl = glManager.gl;
60
- const program = glManager.shaders.getOrCreate(
60
+ const partial = compileProgram<Omit<LineCache, "cornerBuffer">>(
61
+ glManager,
61
62
  "line",
62
63
  lineVert,
63
64
  lineFrag,
65
+ [
66
+ "u_projection",
67
+ "u_resolution",
68
+ "u_line_width",
69
+ "u_color_range",
70
+ "u_gradient_lut",
71
+ ],
72
+ ["a_start", "a_end", "a_color_start", "a_color_end", "a_corner"],
64
73
  );
65
- const cornerBuffer = createLineCornerBuffer(gl);
66
74
  this._cache = {
67
- program,
68
- cornerBuffer,
69
- u_projection: gl.getUniformLocation(program, "u_projection"),
70
- u_resolution: gl.getUniformLocation(program, "u_resolution"),
71
- u_line_width: gl.getUniformLocation(program, "u_line_width"),
72
- u_color_range: gl.getUniformLocation(program, "u_color_range"),
73
- u_gradient_lut: gl.getUniformLocation(program, "u_gradient_lut"),
74
- a_start: gl.getAttribLocation(program, "a_start"),
75
- a_end: gl.getAttribLocation(program, "a_end"),
76
- a_color_start: gl.getAttribLocation(program, "a_color_start"),
77
- a_color_end: gl.getAttribLocation(program, "a_color_end"),
78
- a_corner: gl.getAttribLocation(program, "a_corner"),
75
+ ...partial,
76
+ cornerBuffer: createLineCornerBuffer(glManager.gl),
79
77
  };
80
78
  }
81
79
 
@@ -14,6 +14,7 @@ import type { WebGLContextManager } from "../../../webgl/context-manager";
14
14
  import type { CartesianChart } from "../cartesian";
15
15
  import type { Glyph } from "../glyph";
16
16
  import { bindGradientTexture } from "../../../webgl/gradient-texture";
17
+ import { compileProgram } from "../../../webgl/program-cache";
17
18
  import { buildPointRowTooltipLines } from "../tooltip-lines";
18
19
  import scatterVert from "../../../shaders/scatter.vert.glsl";
19
20
  import scatterFrag from "../../../shaders/scatter.frag.glsl";
@@ -53,27 +54,21 @@ export class PointGlyph implements Glyph {
53
54
  return;
54
55
  }
55
56
 
56
- const gl = glManager.gl;
57
- const program = glManager.shaders.getOrCreate(
57
+ this._cache = compileProgram<PointCache>(
58
+ glManager,
58
59
  "scatter",
59
60
  scatterVert,
60
61
  scatterFrag,
61
- );
62
- this._cache = {
63
- program,
64
- u_projection: gl.getUniformLocation(program, "u_projection"),
65
- u_point_size: gl.getUniformLocation(program, "u_point_size"),
66
- u_color_range: gl.getUniformLocation(program, "u_color_range"),
67
- u_gradient_lut: gl.getUniformLocation(program, "u_gradient_lut"),
68
- u_size_range: gl.getUniformLocation(program, "u_size_range"),
69
- u_point_size_range: gl.getUniformLocation(
70
- program,
62
+ [
63
+ "u_projection",
64
+ "u_point_size",
65
+ "u_color_range",
66
+ "u_gradient_lut",
67
+ "u_size_range",
71
68
  "u_point_size_range",
72
- ),
73
- a_position: gl.getAttribLocation(program, "a_position"),
74
- a_color_value: gl.getAttribLocation(program, "a_color_value"),
75
- a_size_value: gl.getAttribLocation(program, "a_size_value"),
76
- };
69
+ ],
70
+ ["a_position", "a_color_value", "a_size_value"],
71
+ );
77
72
  }
78
73
 
79
74
  draw(
@@ -46,6 +46,14 @@ import type { ViewConfig } from "@perspective-dev/client";
46
46
  import { resolveThemeFromVars, type Theme } from "../theme/theme";
47
47
  import { requestRender as scheduleRender } from "../render/scheduler";
48
48
 
49
+ // TODO I don't know if this is the behavior we want. On the plus side, this
50
+ // ad-hoc formatter scales well to small and large data ranges, making a good
51
+ // guess at the right format without user input. On the minus side, this
52
+ // behavior is inconsistent with datagrid and the rest of the app, and the ad-hoc
53
+ // surprising behavior when overriding one field in `number_format` and suddenly
54
+ // the entire formatter is replaced.
55
+ const REGRESSION_BEHAVIOR = true;
56
+
49
57
  /**
50
58
  * Locale-aware fallback formatter applied to numeric tooltip / legend
51
59
  * values when the column has no `number_format` configured. Two
@@ -55,9 +63,12 @@ import { requestRender as scheduleRender } from "../render/scheduler";
55
63
  const DEFAULT_VALUE_FORMATTER: (v: number) => string = ((): ((
56
64
  v: number,
57
65
  ) => string) => {
58
- return formatTickValue;
59
- // const intl = createNumberFormatter("float");
60
- // return (v) => intl.format(v);
66
+ if (REGRESSION_BEHAVIOR) {
67
+ return formatTickValue;
68
+ } else {
69
+ const intl = createNumberFormatter("float");
70
+ return (v) => intl.format(v);
71
+ }
61
72
  })();
62
73
 
63
74
  /**
@@ -69,9 +80,12 @@ const DEFAULT_VALUE_FORMATTER: (v: number) => string = ((): ((
69
80
  const DEFAULT_DATETIME_FORMATTER: (v: number) => string = ((): ((
70
81
  v: number,
71
82
  ) => string) => {
72
- return formatDateTickValue;
73
- // const intl = createDatetimeFormatter();
74
- // return (v) => intl.format(v);
83
+ if (REGRESSION_BEHAVIOR) {
84
+ return formatDateTickValue;
85
+ } else {
86
+ const intl = createDatetimeFormatter();
87
+ return (v) => intl.format(v);
88
+ }
75
89
  })();
76
90
 
77
91
  /**
@@ -411,7 +411,7 @@ export const DEFAULT_PLUGIN_CONFIG: PluginConfig = {
411
411
  facet_zoom_mode: "shared",
412
412
  series_zoom_mode: "dynamic",
413
413
  include_zero: false,
414
- domain_mode: "fit",
414
+ domain_mode: "expand",
415
415
  line_width_px: 2.0,
416
416
  point_size_px: 8.0,
417
417
  band_inner_frac: 0.5,
@@ -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
+ }