@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
@@ -142,6 +142,10 @@ export type BarCategoryAxis =
142
142
  | { mode: "category"; domain: CategoricalDomain }
143
143
  | { mode: "numeric"; domain: AxisDomain; ticks: number[] };
144
144
 
145
+ export type BarValueAxis =
146
+ | { mode: "category"; domain: CategoricalDomain }
147
+ | { mode: "numeric"; domain: AxisDomain; ticks: number[] };
148
+
145
149
  /**
146
150
  * Render a numeric date-aware axis along the bottom of the plot. Aliases
147
151
  * the bar-axis bottom variant so heatmap can share the implementation.
@@ -190,13 +194,11 @@ export interface BarAxesFormatters {
190
194
  export function renderBarAxesChrome(
191
195
  canvas: Canvas2D,
192
196
  catAxis: BarCategoryAxis,
193
- valueDomain: AxisDomain,
194
- valueTicks: number[],
197
+ valueAxis: BarValueAxis,
195
198
  layout: PlotLayout,
196
199
  theme: Theme,
197
200
  dpr: number,
198
- altDomain?: AxisDomain,
199
- altTicks?: number[],
201
+ altAxis: BarValueAxis | undefined,
200
202
  isHorizontal = false,
201
203
  formatters: BarAxesFormatters = {},
202
204
  ): void {
@@ -212,7 +214,7 @@ export function renderBarAxesChrome(
212
214
  ctx.moveTo(plot.x, plot.y);
213
215
  ctx.lineTo(plot.x, plot.y + plot.height);
214
216
  ctx.lineTo(plot.x + plot.width, plot.y + plot.height);
215
- if (altDomain) {
217
+ if (altAxis) {
216
218
  if (isHorizontal) {
217
219
  ctx.moveTo(plot.x, plot.y);
218
220
  ctx.lineTo(plot.x + plot.width, plot.y);
@@ -238,31 +240,49 @@ export function renderBarAxesChrome(
238
240
  );
239
241
  }
240
242
 
241
- drawNumericXAxis(
242
- ctx,
243
- layout,
244
- valueDomain,
245
- valueTicks,
246
- "bottom",
247
- theme,
248
- formatters.value,
249
- );
250
- if (altDomain && altTicks) {
251
- const origMin = layout.paddedXMin;
252
- const origMax = layout.paddedXMax;
253
- layout.paddedXMin = altDomain.min;
254
- layout.paddedXMax = altDomain.max;
243
+ if (valueAxis.mode === "category") {
244
+ // Categorical value axis on the bottom: reuse the X
245
+ // categorical painter. Slot indices on the layout's X
246
+ // domain already place each category at its slot pixel.
247
+ renderCategoricalXTicks(ctx, layout, valueAxis.domain, theme);
248
+ } else {
255
249
  drawNumericXAxis(
256
250
  ctx,
257
251
  layout,
258
- altDomain,
259
- altTicks,
260
- "top",
252
+ valueAxis.domain,
253
+ valueAxis.ticks,
254
+ "bottom",
261
255
  theme,
262
- formatters.alt,
256
+ formatters.value,
263
257
  );
264
- layout.paddedXMin = origMin;
265
- layout.paddedXMax = origMax;
258
+ }
259
+
260
+ if (altAxis) {
261
+ // Alt-axis painter expects the layout's X domain to match
262
+ // the alt domain — temporarily swap in `altDomain.min/max`
263
+ // for the duration of the call. Categorical alt has no
264
+ // top-side painter; render with the bottom-side painter
265
+ // (visual overlap with the primary side — documented
266
+ // limitation; user-pinned categorical alt is rare).
267
+ if (altAxis.mode === "category") {
268
+ renderCategoricalXTicks(ctx, layout, altAxis.domain, theme);
269
+ } else {
270
+ const origMin = layout.paddedXMin;
271
+ const origMax = layout.paddedXMax;
272
+ layout.paddedXMin = altAxis.domain.min;
273
+ layout.paddedXMax = altAxis.domain.max;
274
+ drawNumericXAxis(
275
+ ctx,
276
+ layout,
277
+ altAxis.domain,
278
+ altAxis.ticks,
279
+ "top",
280
+ theme,
281
+ formatters.alt,
282
+ );
283
+ layout.paddedXMin = origMin;
284
+ layout.paddedXMax = origMax;
285
+ }
266
286
  }
267
287
  } else {
268
288
  if (catAxis.mode === "category") {
@@ -278,31 +298,40 @@ export function renderBarAxesChrome(
278
298
  );
279
299
  }
280
300
 
281
- drawYAxis(
282
- ctx,
283
- layout,
284
- valueDomain,
285
- valueTicks,
286
- "left",
287
- theme,
288
- formatters.value,
289
- );
290
- if (altDomain && altTicks) {
291
- const origMin = layout.paddedYMin;
292
- const origMax = layout.paddedYMax;
293
- layout.paddedYMin = altDomain.min;
294
- layout.paddedYMax = altDomain.max;
301
+ if (valueAxis.mode === "category") {
302
+ renderCategoricalYTicks(ctx, layout, valueAxis.domain, theme);
303
+ } else {
295
304
  drawYAxis(
296
305
  ctx,
297
306
  layout,
298
- altDomain,
299
- altTicks,
300
- "right",
307
+ valueAxis.domain,
308
+ valueAxis.ticks,
309
+ "left",
301
310
  theme,
302
- formatters.alt,
311
+ formatters.value,
303
312
  );
304
- layout.paddedYMin = origMin;
305
- layout.paddedYMax = origMax;
313
+ }
314
+
315
+ if (altAxis) {
316
+ if (altAxis.mode === "category") {
317
+ renderCategoricalYTicks(ctx, layout, altAxis.domain, theme);
318
+ } else {
319
+ const origMin = layout.paddedYMin;
320
+ const origMax = layout.paddedYMax;
321
+ layout.paddedYMin = altAxis.domain.min;
322
+ layout.paddedYMax = altAxis.domain.max;
323
+ drawYAxis(
324
+ ctx,
325
+ layout,
326
+ altAxis.domain,
327
+ altAxis.ticks,
328
+ "right",
329
+ theme,
330
+ formatters.alt,
331
+ );
332
+ layout.paddedYMin = origMin;
333
+ layout.paddedYMax = origMax;
334
+ }
306
335
  }
307
336
  }
308
337
  }
@@ -56,8 +56,6 @@ function categoryIndexToPixelY(layout: PlotLayout, index: number): number {
56
56
  return layout.dataToPixel(0, index).py;
57
57
  }
58
58
 
59
- export const categoryIndexToPixel = categoryIndexToPixelX;
60
-
61
59
  function leafLevelLayout(
62
60
  numRows: number,
63
61
  longestCharCount: number,
@@ -125,13 +125,14 @@ export function renderCandlestickFrame(
125
125
  yMax: chart._yDomain.max,
126
126
  };
127
127
 
128
- // Auto-fit the price axis to the visible X window. Skipped at
129
- // default zoom (the refit equals `_yDomain` there and would only
130
- // churn baselines).
128
+ // Auto-fit the price axis to the visible X window. Skipped when X
129
+ // is at default zoom (the refit equals `_yDomain` there and would
130
+ // only churn baselines) — Y-axis pan/zoom alone shouldn't trigger
131
+ // an X-window refit.
131
132
  if (
132
133
  chart._autoFitValue &&
133
134
  chart._zoomController &&
134
- !chart._zoomController.isDefault()
135
+ !chart._zoomController.isXDefault()
135
136
  ) {
136
137
  const fit = computeVisibleCandleExtent(chart, vis.xMin, vis.xMax);
137
138
  if (fit.hasFit) {
@@ -271,13 +272,15 @@ export function renderCandlestickChromeOverlay(chart: CandlestickChart): void {
271
272
  renderBarAxesChrome(
272
273
  chart._chromeCanvas,
273
274
  catAxis,
274
- chart._lastYDomain,
275
- chart._lastYTicks,
275
+ {
276
+ mode: "numeric",
277
+ domain: chart._lastYDomain,
278
+ ticks: chart._lastYTicks,
279
+ },
276
280
  chart._lastLayout,
277
281
  theme,
278
282
  chart._glManager?.dpr ?? 1,
279
283
  undefined,
280
- undefined,
281
284
  false,
282
285
  {
283
286
  value: chart.getColumnFormatter(valueColumn, "tick"),
@@ -33,6 +33,7 @@ import {
33
33
  } from "./candlestick-interact";
34
34
  import { BodyWickGlyph } from "./glyphs/draw-candlesticks";
35
35
  import { OHLCGlyph } from "./glyphs/draw-ohlc";
36
+ import { expandDomainInPlace } from "../common/expand-domain";
36
37
 
37
38
  /**
38
39
  * Per-frame memo of the auto-fit Y extent for a {@link CandlestickChart},
@@ -266,38 +267,18 @@ export class CandlestickChart extends CategoricalYChart {
266
267
  scratchCandles: this._candles,
267
268
  });
268
269
  // `domain_mode: "expand"` post-build union — mirrors the series
269
- // pipeline. Mutate the pipeline result in place so the
270
+ // pipeline. `expandDomainInPlace` mutates `result.*` so the
270
271
  // assignments below pick up the grown extent automatically.
271
272
  if (this._pluginConfig.domain_mode === "expand") {
272
- if (this._expandedYDomain) {
273
- result.yDomain.min = Math.min(
274
- this._expandedYDomain.min,
275
- result.yDomain.min,
276
- );
277
- result.yDomain.max = Math.max(
278
- this._expandedYDomain.max,
279
- result.yDomain.max,
280
- );
281
- }
282
-
283
- this._expandedYDomain = { ...result.yDomain };
284
-
273
+ this._expandedYDomain = expandDomainInPlace(
274
+ this._expandedYDomain,
275
+ result.yDomain,
276
+ );
285
277
  if (result.numericCategoryDomain) {
286
- if (this._expandedCategoryDomain) {
287
- result.numericCategoryDomain.min = Math.min(
288
- this._expandedCategoryDomain.min,
289
- result.numericCategoryDomain.min,
290
- );
291
- result.numericCategoryDomain.max = Math.max(
292
- this._expandedCategoryDomain.max,
293
- result.numericCategoryDomain.max,
294
- );
295
- }
296
-
297
- this._expandedCategoryDomain = {
298
- min: result.numericCategoryDomain.min,
299
- max: result.numericCategoryDomain.max,
300
- };
278
+ this._expandedCategoryDomain = expandDomainInPlace(
279
+ this._expandedCategoryDomain,
280
+ result.numericCategoryDomain,
281
+ );
301
282
  }
302
283
  } else {
303
284
  this._expandedYDomain = null;
@@ -46,9 +46,23 @@ interface WickCache {
46
46
  u_color: WebGLUniformLocation | null;
47
47
  u_resolution: WebGLUniformLocation | null;
48
48
  u_line_width: WebGLUniformLocation | null;
49
+
50
+ /**
51
+ * `line-uniform` was extended to support the Y-Line `interpolate`
52
+ * feature with a per-segment alpha multiplier driven by
53
+ * `a_real_start * a_real_end` and `u_interp_alpha`. Wicks are
54
+ * always "real" data — every segment renders fully — so we hold
55
+ * these locations to neutralize them at draw time (uniform = 1.0,
56
+ * constant attribute values = 1.0). Without that, an unset uniform
57
+ * defaults to 0 and the fragment alpha collapses to 0, rendering
58
+ * the wicks invisible.
59
+ */
60
+ u_interp_alpha: WebGLUniformLocation | null;
49
61
  a_corner: number;
50
62
  a_start: number;
51
63
  a_end: number;
64
+ a_real_start: number;
65
+ a_real_end: number;
52
66
  }
53
67
 
54
68
  interface ProgramCache {
@@ -124,8 +138,14 @@ export class BodyWickGlyph {
124
138
  "line-uniform",
125
139
  lineVert,
126
140
  lineFrag,
127
- ["u_projection", "u_color", "u_resolution", "u_line_width"],
128
- ["a_corner", "a_start", "a_end"],
141
+ [
142
+ "u_projection",
143
+ "u_color",
144
+ "u_resolution",
145
+ "u_line_width",
146
+ "u_interp_alpha",
147
+ ],
148
+ ["a_corner", "a_start", "a_end", "a_real_start", "a_real_end"],
129
149
  );
130
150
  const wick: WickCache = {
131
151
  ...wickPartial,
@@ -375,6 +395,20 @@ function drawWicks(
375
395
  gl.uniform2f(cache.u_resolution, gl.canvas.width, gl.canvas.height);
376
396
  gl.uniform1f(cache.u_line_width, chart._pluginConfig.wick_width_px * dpr);
377
397
 
398
+ // `line-uniform` was extended for the Y-Line interpolate feature
399
+ // with a per-segment alpha multiplier; neutralize it here.
400
+ // Constant attribute values (used when the array is disabled) and
401
+ // uniform are stable for every draw, so set once after
402
+ // `useProgram`. Disabling the arrays first guards against a prior
403
+ // Y-Line draw that left them enabled at the same attribute index
404
+ // (locations are shared because both programs link from the same
405
+ // source).
406
+ gl.disableVertexAttribArray(cache.a_real_start);
407
+ gl.disableVertexAttribArray(cache.a_real_end);
408
+ gl.vertexAttrib1f(cache.a_real_start, 1.0);
409
+ gl.vertexAttrib1f(cache.a_real_end, 1.0);
410
+ gl.uniform1f(cache.u_interp_alpha, 1.0);
411
+
378
412
  const instancing = getInstancing(glManager);
379
413
  const { setDivisor } = instancing;
380
414
 
@@ -30,9 +30,23 @@ interface OHLCCache {
30
30
  u_color: WebGLUniformLocation | null;
31
31
  u_resolution: WebGLUniformLocation | null;
32
32
  u_line_width: WebGLUniformLocation | null;
33
+
34
+ /**
35
+ * `line-uniform` was extended to support the Y-Line `interpolate`
36
+ * feature with a per-segment alpha multiplier driven by
37
+ * `a_real_start * a_real_end` and `u_interp_alpha`. The OHLC glyph
38
+ * doesn't need that — every segment is "real" — so we hold these
39
+ * locations to neutralize them at draw time (uniform = 1.0,
40
+ * constant attribute values = 1.0). Without that, an unset uniform
41
+ * defaults to 0 and the fragment alpha collapses to 0, rendering
42
+ * the entire OHLC glyph invisible.
43
+ */
44
+ u_interp_alpha: WebGLUniformLocation | null;
33
45
  a_corner: number;
34
46
  a_start: number;
35
47
  a_end: number;
48
+ a_real_start: number;
49
+ a_real_end: number;
36
50
  }
37
51
 
38
52
  /**
@@ -76,8 +90,14 @@ export class OHLCGlyph {
76
90
  "line-uniform",
77
91
  lineVert,
78
92
  lineFrag,
79
- ["u_projection", "u_color", "u_resolution", "u_line_width"],
80
- ["a_corner", "a_start", "a_end"],
93
+ [
94
+ "u_projection",
95
+ "u_color",
96
+ "u_resolution",
97
+ "u_line_width",
98
+ "u_interp_alpha",
99
+ ],
100
+ ["a_corner", "a_start", "a_end", "a_real_start", "a_real_end"],
81
101
  );
82
102
  this._program = {
83
103
  ...partial,
@@ -229,6 +249,20 @@ export class OHLCGlyph {
229
249
  chart._pluginConfig.ohlc_line_width_px * dpr,
230
250
  );
231
251
 
252
+ // `line-uniform` was extended for the Y-Line interpolate
253
+ // feature with a per-segment alpha multiplier; neutralize it
254
+ // here. Constant attribute values (used when the array is
255
+ // disabled) and uniform are stable for every draw, so set
256
+ // once after `useProgram`. Disabling the arrays first guards
257
+ // against a prior Y-Line draw that left them enabled at the
258
+ // same attribute index (locations are shared because both
259
+ // programs link from the same source).
260
+ gl.disableVertexAttribArray(cache.a_real_start);
261
+ gl.disableVertexAttribArray(cache.a_real_end);
262
+ gl.vertexAttrib1f(cache.a_real_start, 1.0);
263
+ gl.vertexAttrib1f(cache.a_real_end, 1.0);
264
+ gl.uniform1f(cache.u_interp_alpha, 1.0);
265
+
232
266
  const instancing = getInstancing(glManager);
233
267
  const { setDivisor } = instancing;
234
268
 
@@ -16,6 +16,49 @@ import type { WebGLContextManager } from "../../webgl/context-manager";
16
16
  import type { CartesianChart, SplitGroup } from "./cartesian";
17
17
  import { LabelInterner } from "./label-interner";
18
18
 
19
+ /**
20
+ * Resolve a row's string value into a slot index in `dictionary`,
21
+ * inserting on first encounter. Invalid / missing values land in a
22
+ * lazily-added `"(null)"` slot — no reserved slot 0 when the data has
23
+ * no nulls. Shared by the X and Y categorical paths in
24
+ * `processCartesianChunk`.
25
+ */
26
+ function lookupCategorySlot(
27
+ col: ColumnData | null,
28
+ rowIdx: number,
29
+ dictionary: string[],
30
+ seen: Map<string, number>,
31
+ ): number {
32
+ let label: string;
33
+ if (!col) {
34
+ label = "(null)";
35
+ } else {
36
+ const valid = col.valid;
37
+ const isValid = valid
38
+ ? !!((valid[rowIdx >> 3] >> (rowIdx & 7)) & 1)
39
+ : true;
40
+ if (!isValid) {
41
+ label = "(null)";
42
+ } else if (col.indices && col.dictionary) {
43
+ label = col.dictionary[col.indices[rowIdx]] ?? "(null)";
44
+ } else if (col.values) {
45
+ const v = col.values[rowIdx];
46
+ label = v == null ? "(null)" : String(v);
47
+ } else {
48
+ label = "(null)";
49
+ }
50
+ }
51
+
52
+ let slot = seen.get(label);
53
+ if (slot === undefined) {
54
+ slot = dictionary.length;
55
+ dictionary.push(label);
56
+ seen.set(label, slot);
57
+ }
58
+
59
+ return slot;
60
+ }
61
+
19
62
  /**
20
63
  * Resolve per-split-prefix column-name tuples. `colorBase`/`sizeBase`
21
64
  * are optional (empty string when the corresponding slot is unset).
@@ -122,6 +165,41 @@ export function initCartesianPipeline(
122
165
  chart._yLabel = yBase;
123
166
  chart._xIsRowIndex = !xBase;
124
167
 
168
+ // Post-aggregation `string` columns on X / Y switch the axis to
169
+ // categorical: per-row slot indices are written into `_xData` /
170
+ // `_yData` instead of raw values, and the chrome overlay paints a
171
+ // categorical axis. Reset the per-frame dictionary state here at
172
+ // chunk 0 so slot 0 is always the first non-null label encountered
173
+ // in arrival order (matching the perspective view's sort).
174
+ chart._xIsString = !!xBase && chart._columnTypes[xBase] === "string";
175
+ chart._yIsString = !!yBase && chart._columnTypes[yBase] === "string";
176
+ chart._xCategoryDictionary = [];
177
+ chart._yCategoryDictionary = [];
178
+ chart._xCategorySeen = new Map();
179
+ chart._yCategorySeen = new Map();
180
+ chart._xCategoryDomain = null;
181
+ chart._yCategoryDomain = null;
182
+
183
+ // Categorical axes use 0-based slot indices, so the rebase origin
184
+ // is fixed at 0. Skipping the NaN-init guard below prevents the
185
+ // first-seen-slot from being adopted as the origin (which would
186
+ // shift every other slot's pixel position).
187
+ if (chart._xIsString) {
188
+ chart._xOrigin = 0;
189
+ chart._xMin = 0;
190
+ chart._xMax = 0;
191
+ chart._expandedXMin = Infinity;
192
+ chart._expandedXMax = -Infinity;
193
+ }
194
+
195
+ if (chart._yIsString) {
196
+ chart._yOrigin = 0;
197
+ chart._yMin = 0;
198
+ chart._yMax = 0;
199
+ chart._expandedYMin = Infinity;
200
+ chart._expandedYMax = -Infinity;
201
+ }
202
+
125
203
  // Capture the per-series row budget BEFORE any split expansion. When
126
204
  // split_by is active we grow `totalCapacity` to fit `numSplits`
127
205
  // parallel slot ranges; reading `totalCapacity` again after that
@@ -245,9 +323,16 @@ export function processCartesianChunk(
245
323
  // carries the user's selected color column. The color-resolution
246
324
  // logic in the inner loop reads uniformly from `ser.colorCol`
247
325
  // across both modes.
326
+ //
327
+ // `xColData` / `yColData` carry the full `ColumnData` so the
328
+ // categorical path can read `indices` + `dictionary` for slot
329
+ // lookup; `xCol` / `yCol` keep the numeric fast path zero-cost
330
+ // (and stay `null` on string columns where `values` is unset).
248
331
  type SeriesSrc = {
249
332
  xCol: Float32Array | Float64Array | Int32Array | null;
250
- yCol: Float32Array | Float64Array | Int32Array;
333
+ yCol: Float32Array | Float64Array | Int32Array | null;
334
+ xColData: ColumnData | null;
335
+ yColData: ColumnData | null;
251
336
  xValid: Uint8Array | undefined;
252
337
  yValid: Uint8Array | undefined;
253
338
  colorCol: ColumnData | null;
@@ -260,7 +345,11 @@ export function processCartesianChunk(
260
345
  for (const sg of chart._splitGroups) {
261
346
  const xc = sg.xColName ? columns.get(sg.xColName) : null;
262
347
  const yc = columns.get(sg.yColName);
263
- if (!yc?.values) {
348
+ if (!yc) {
349
+ continue;
350
+ }
351
+
352
+ if (!chart._yIsString && !yc.values) {
264
353
  continue;
265
354
  }
266
355
 
@@ -273,7 +362,9 @@ export function processCartesianChunk(
273
362
  : null;
274
363
  series.push({
275
364
  xCol: xc?.values ?? null,
276
- yCol: yc.values,
365
+ yCol: yc.values ?? null,
366
+ xColData: xc ?? null,
367
+ yColData: yc,
277
368
  xValid: xc?.valid,
278
369
  yValid: yc.valid,
279
370
  colorCol: cc,
@@ -284,7 +375,11 @@ export function processCartesianChunk(
284
375
  } else {
285
376
  const xc = chart._xName ? columns.get(chart._xName) : null;
286
377
  const yc = chart._yName ? columns.get(chart._yName) : null;
287
- if (!yc?.values) {
378
+ if (!yc) {
379
+ return;
380
+ }
381
+
382
+ if (!chart._yIsString && !yc.values) {
288
383
  return;
289
384
  }
290
385
 
@@ -296,7 +391,9 @@ export function processCartesianChunk(
296
391
  : null;
297
392
  series.push({
298
393
  xCol: xc?.values ?? null,
299
- yCol: yc.values,
394
+ yCol: yc.values ?? null,
395
+ xColData: xc ?? null,
396
+ yColData: yc,
300
397
  xValid: xc?.valid,
301
398
  yValid: yc?.valid,
302
399
  colorCol: cc,
@@ -386,11 +483,20 @@ export function processCartesianChunk(
386
483
  let writeIdx = 0;
387
484
  for (let j = 0; j < sourceLength && writeIdx < maxWrite; j++) {
388
485
  const i = j;
389
- if (ser.yValid && !((ser.yValid[i >> 3] >> (i & 7)) & 1)) {
486
+ // Numeric axes filter out null/invalid rows entirely;
487
+ // categorical axes route them into a `"(null)"` slot
488
+ // instead, so the validity / NaN guards only apply on
489
+ // the numeric branch.
490
+ if (
491
+ !chart._yIsString &&
492
+ ser.yValid &&
493
+ !((ser.yValid[i >> 3] >> (i & 7)) & 1)
494
+ ) {
390
495
  continue;
391
496
  }
392
497
 
393
498
  if (
499
+ !chart._xIsString &&
394
500
  ser.xCol &&
395
501
  ser.xValid &&
396
502
  !((ser.xValid[i >> 3] >> (i & 7)) & 1)
@@ -402,12 +508,40 @@ export function processCartesianChunk(
402
508
  colorValid !== undefined &&
403
509
  !((colorValid[i >> 3] >> (i & 7)) & 1);
404
510
 
405
- const rawY = ser.yCol[i] as number;
406
- const rawX = ser.xCol ? (ser.xCol[i] as number) : startRow + i;
407
- if (isNaN(rawX) || isNaN(rawY)) {
511
+ let rawY: number;
512
+ if (chart._yIsString) {
513
+ rawY = lookupCategorySlot(
514
+ ser.yColData,
515
+ i,
516
+ chart._yCategoryDictionary,
517
+ chart._yCategorySeen,
518
+ );
519
+ } else if (ser.yCol) {
520
+ rawY = ser.yCol[i] as number;
521
+ if (isNaN(rawY)) {
522
+ continue;
523
+ }
524
+ } else {
408
525
  continue;
409
526
  }
410
527
 
528
+ let rawX: number;
529
+ if (chart._xIsString) {
530
+ rawX = lookupCategorySlot(
531
+ ser.xColData,
532
+ i,
533
+ chart._xCategoryDictionary,
534
+ chart._xCategorySeen,
535
+ );
536
+ } else if (ser.xCol) {
537
+ rawX = ser.xCol[i] as number;
538
+ if (isNaN(rawX)) {
539
+ continue;
540
+ }
541
+ } else {
542
+ rawX = startRow + i;
543
+ }
544
+
411
545
  // Project raw (x, y) → data-space (x, y). Default is
412
546
  // identity for cartesian charts; map subclasses override
413
547
  // to apply Mercator. Second NaN guard catches projection