@perspective-dev/viewer-charts 4.3.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 (72) hide show
  1. package/dist/cdn/perspective-viewer-charts.js +2 -2
  2. package/dist/cdn/perspective-viewer-charts.js.map +3 -3
  3. package/dist/esm/axis/bar-axis.d.ts +9 -1
  4. package/dist/esm/axis/categorical-axis.d.ts +0 -2
  5. package/dist/esm/charts/cartesian/cartesian.d.ts +26 -0
  6. package/dist/esm/charts/common/category-axis-resolver.d.ts +43 -1
  7. package/dist/esm/charts/common/expand-domain.d.ts +20 -0
  8. package/dist/esm/charts/common/tree-chart.d.ts +7 -0
  9. package/dist/esm/charts/common/tree-chrome.d.ts +23 -1
  10. package/dist/esm/charts/common/tree-interact.d.ts +46 -0
  11. package/dist/esm/charts/series/glyphs/draw-lines.d.ts +11 -4
  12. package/dist/esm/charts/series/series-build.d.ts +38 -2
  13. package/dist/esm/charts/series/series-render.d.ts +1 -4
  14. package/dist/esm/charts/series/series-type.d.ts +19 -17
  15. package/dist/esm/charts/series/series.d.ts +16 -0
  16. package/dist/esm/charts/sunburst/sunburst-interact.d.ts +1 -1
  17. package/dist/esm/charts/treemap/treemap-interact.d.ts +1 -6
  18. package/dist/esm/interaction/host-sink-message.d.ts +10 -28
  19. package/dist/esm/interaction/raw-event-forwarder.d.ts +6 -7
  20. package/dist/esm/interaction/zoom-controller.d.ts +31 -20
  21. package/dist/esm/interaction/zoom-router.d.ts +3 -26
  22. package/dist/esm/perspective-viewer-charts.js +2 -2
  23. package/dist/esm/perspective-viewer-charts.js.map +3 -3
  24. package/dist/esm/plugin/plugin.d.ts +0 -1
  25. package/dist/esm/theme/palette.d.ts +0 -5
  26. package/dist/esm/transport/protocol.d.ts +2 -7
  27. package/dist/esm/worker/renderer.worker.d.ts +2 -4
  28. package/package.json +1 -1
  29. package/src/ts/axis/bar-axis.ts +74 -45
  30. package/src/ts/axis/categorical-axis.ts +0 -2
  31. package/src/ts/charts/candlestick/candlestick-render.ts +10 -7
  32. package/src/ts/charts/candlestick/candlestick.ts +10 -29
  33. package/src/ts/charts/candlestick/glyphs/draw-candlesticks.ts +36 -2
  34. package/src/ts/charts/candlestick/glyphs/draw-ohlc.ts +36 -2
  35. package/src/ts/charts/cartesian/cartesian-build.ts +143 -9
  36. package/src/ts/charts/cartesian/cartesian-render.ts +205 -30
  37. package/src/ts/charts/cartesian/cartesian.ts +43 -4
  38. package/src/ts/charts/cartesian/glyphs/density.ts +36 -41
  39. package/src/ts/charts/cartesian/glyphs/lines.ts +13 -15
  40. package/src/ts/charts/cartesian/glyphs/points.ts +12 -17
  41. package/src/ts/charts/chart-base.ts +20 -6
  42. package/src/ts/charts/chart.ts +1 -1
  43. package/src/ts/charts/common/category-axis-resolver.ts +135 -1
  44. package/src/ts/charts/common/expand-domain.ts +40 -0
  45. package/src/ts/charts/common/tree-chart.ts +16 -0
  46. package/src/ts/charts/common/tree-chrome.ts +86 -1
  47. package/src/ts/charts/common/tree-interact.ts +209 -0
  48. package/src/ts/charts/heatmap/heatmap-render.ts +9 -11
  49. package/src/ts/charts/series/glyphs/draw-areas.ts +30 -1
  50. package/src/ts/charts/series/glyphs/draw-lines.ts +151 -76
  51. package/src/ts/charts/series/series-build.ts +394 -21
  52. package/src/ts/charts/series/series-render.ts +159 -38
  53. package/src/ts/charts/series/series-type.ts +37 -17
  54. package/src/ts/charts/series/series.ts +63 -68
  55. package/src/ts/charts/sunburst/sunburst-interact.ts +18 -162
  56. package/src/ts/charts/sunburst/sunburst-render.ts +24 -89
  57. package/src/ts/charts/sunburst/sunburst.ts +1 -15
  58. package/src/ts/charts/treemap/treemap-interact.ts +22 -189
  59. package/src/ts/charts/treemap/treemap-render.ts +19 -46
  60. package/src/ts/charts/treemap/treemap.ts +1 -16
  61. package/src/ts/interaction/host-sink-message.ts +33 -22
  62. package/src/ts/interaction/raw-event-forwarder.ts +10 -12
  63. package/src/ts/interaction/zoom-controller.ts +120 -83
  64. package/src/ts/interaction/zoom-router.ts +3 -126
  65. package/src/ts/map/tile-layer.ts +13 -13
  66. package/src/ts/plugin/plugin.ts +100 -184
  67. package/src/ts/shaders/line-uniform.frag.glsl +2 -1
  68. package/src/ts/shaders/line-uniform.vert.glsl +19 -0
  69. package/src/ts/theme/palette.ts +1 -4
  70. package/src/ts/transport/protocol.ts +3 -8
  71. package/src/ts/worker/dispatch.ts +0 -1
  72. package/src/ts/worker/renderer.worker.ts +10 -46
@@ -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
@@ -34,7 +34,6 @@ import { renderCanvasTooltip } from "../../interaction/tooltip-controller";
34
34
  import {
35
35
  computeTicks,
36
36
  renderGridlines,
37
- renderAxesChrome,
38
37
  renderCellXAxis,
39
38
  renderCellYAxis,
40
39
  renderOuterXAxis,
@@ -42,6 +41,14 @@ import {
42
41
  type AxisDomain,
43
42
  } from "../../axis/numeric-axis";
44
43
  import { initCanvas, getScaledContext } from "../../axis/canvas";
44
+ import {
45
+ type CategoricalDomain,
46
+ type CategoricalLevel,
47
+ measureCategoricalAxisHeight,
48
+ measureCategoricalAxisWidth,
49
+ renderCategoricalXTicks,
50
+ renderCategoricalYTicks,
51
+ } from "../../axis/categorical-axis";
45
52
  import {
46
53
  renderLegend,
47
54
  renderLegendAt,
@@ -240,6 +247,88 @@ function buildYDomain(
240
247
  };
241
248
  }
242
249
 
250
+ /**
251
+ * Wrap a value-axis dictionary into the single-level `CategoricalDomain`
252
+ * the categorical axis painter expects. Caches by reference identity on
253
+ * the chart so chrome-overlay redraws on hover don't rebuild the domain.
254
+ */
255
+ function buildCategoricalDomainFromDict(
256
+ dictionary: string[],
257
+ label: string,
258
+ ): CategoricalDomain {
259
+ let maxLabelChars = 0;
260
+ for (const s of dictionary) {
261
+ if (s.length > maxLabelChars) {
262
+ maxLabelChars = s.length;
263
+ }
264
+ }
265
+
266
+ const level: CategoricalLevel = {
267
+ labels: dictionary.slice(),
268
+ runs: [],
269
+ maxLabelChars,
270
+ };
271
+ return {
272
+ levels: [level],
273
+ numRows: dictionary.length,
274
+ levelLabels: [label],
275
+ };
276
+ }
277
+
278
+ /**
279
+ * Dispatch each axis side to the categorical painter when its source
280
+ * column is post-aggregation `string`-typed, or to the numeric painter
281
+ * otherwise. Used by both single-plot and faceted (per-cell) chrome
282
+ * overlays.
283
+ */
284
+ function renderCartesianCellAxes(
285
+ chart: CartesianChart,
286
+ canvas: Canvas2D,
287
+ layout: PlotLayout,
288
+ xDomain: AxisDomain,
289
+ yDomain: AxisDomain,
290
+ xTicks: number[],
291
+ yTicks: number[],
292
+ theme: Theme,
293
+ dpr: number,
294
+ ): void {
295
+ if (chart._xIsString && chart._xCategoryDomain) {
296
+ const ctx = getScaledContext(canvas, dpr);
297
+ if (ctx) {
298
+ renderCategoricalXTicks(ctx, layout, chart._xCategoryDomain, theme);
299
+ }
300
+ } else {
301
+ renderCellXAxis(
302
+ canvas,
303
+ xDomain,
304
+ layout,
305
+ xTicks,
306
+ theme,
307
+ true,
308
+ dpr,
309
+ chart.getColumnFormatter(chart._xName, "tick"),
310
+ );
311
+ }
312
+
313
+ if (chart._yIsString && chart._yCategoryDomain) {
314
+ const ctx = getScaledContext(canvas, dpr);
315
+ if (ctx) {
316
+ renderCategoricalYTicks(ctx, layout, chart._yCategoryDomain, theme);
317
+ }
318
+ } else {
319
+ renderCellYAxis(
320
+ canvas,
321
+ yDomain,
322
+ layout,
323
+ yTicks,
324
+ theme,
325
+ true,
326
+ dpr,
327
+ chart.getColumnFormatter(chart._yName, "tick"),
328
+ );
329
+ }
330
+ }
331
+
243
332
  /**
244
333
  * Original single-plot render path — all series drawn into one
245
334
  * `PlotLayout` with one projection matrix. Used when splits are absent
@@ -255,10 +344,42 @@ function renderSinglePlotFrame(
255
344
  const gl = glManager.gl;
256
345
  const { cssWidth, cssHeight, xIsDate, yIsDate, hasColorCol } = ctx;
257
346
 
347
+ // Materialize per-axis categorical domains from the build-pass
348
+ // dictionaries before measuring gutters — the leaf-rotation budget
349
+ // in `measureCategoricalAxisHeight` depends on `maxLabelChars`.
350
+ chart._xCategoryDomain =
351
+ chart._xIsString && chart._xCategoryDictionary.length > 0
352
+ ? buildCategoricalDomainFromDict(
353
+ chart._xCategoryDictionary,
354
+ chart._xLabel || chart._xName || "",
355
+ )
356
+ : null;
357
+ chart._yCategoryDomain =
358
+ chart._yIsString && chart._yCategoryDictionary.length > 0
359
+ ? buildCategoricalDomainFromDict(
360
+ chart._yCategoryDictionary,
361
+ chart._yLabel || chart._yName || "",
362
+ )
363
+ : null;
364
+
365
+ // One-pass plot-width / plot-height estimate to size the
366
+ // categorical gutter overrides; same approach as series-render.
367
+ const estRight = hasColorCol ? 80 : 16;
368
+ const estLeftPlain = 55 + (chart._yLabel ? 16 : 0);
369
+ const estPlotWidth = Math.max(1, cssWidth - estLeftPlain - estRight);
370
+ const leftExtra = chart._yCategoryDomain
371
+ ? measureCategoricalAxisWidth(chart._yCategoryDomain)
372
+ : undefined;
373
+ const bottomExtra = chart._xCategoryDomain
374
+ ? measureCategoricalAxisHeight(chart._xCategoryDomain, estPlotWidth)
375
+ : undefined;
376
+
258
377
  const layout = new PlotLayout(cssWidth, cssHeight, {
259
378
  hasXLabel: !!chart._xLabel,
260
379
  hasYLabel: !!chart._yLabel,
261
380
  hasLegend: hasColorCol,
381
+ leftExtra,
382
+ bottomExtra,
262
383
  });
263
384
  chart._lastLayout = layout;
264
385
  if (chart._zoomController) {
@@ -279,7 +400,9 @@ function renderSinglePlotFrame(
279
400
 
280
401
  const xDomain = buildXDomain(chart, domain.xMin, domain.xMax, xIsDate);
281
402
  const yDomain = buildYDomain(chart, domain.yMin, domain.yMax, yIsDate);
282
- const { xTicks, yTicks } = computeTicks(xDomain, yDomain, layout);
403
+ const numericTicks = computeTicks(xDomain, yDomain, layout);
404
+ const xTicks = chart._xIsString ? [] : numericTicks.xTicks;
405
+ const yTicks = chart._yIsString ? [] : numericTicks.yTicks;
283
406
 
284
407
  const isMap = chart._renderMode === "map";
285
408
 
@@ -349,6 +472,26 @@ function renderFacetedFrame(
349
472
  const gl = glManager.gl;
350
473
  const { cssWidth, cssHeight, xIsDate, yIsDate } = ctx;
351
474
 
475
+ // Materialize per-axis categorical domains (shared across facets:
476
+ // the build dictionary is global, so slot N refers to the same
477
+ // string in every cell). Faceted layout still uses the default
478
+ // per-cell gutters from `buildFacetGrid` — rotated leaf labels in
479
+ // tight cells may overflow but won't crash.
480
+ chart._xCategoryDomain =
481
+ chart._xIsString && chart._xCategoryDictionary.length > 0
482
+ ? buildCategoricalDomainFromDict(
483
+ chart._xCategoryDictionary,
484
+ chart._xLabel || chart._xName || "",
485
+ )
486
+ : null;
487
+ chart._yCategoryDomain =
488
+ chart._yIsString && chart._yCategoryDictionary.length > 0
489
+ ? buildCategoricalDomainFromDict(
490
+ chart._yCategoryDictionary,
491
+ chart._yLabel || chart._yName || "",
492
+ )
493
+ : null;
494
+
352
495
  const labels = chart._splitGroups.map((g) => g.prefix);
353
496
 
354
497
  // Legend: reserve space only when the user wired a color column.
@@ -415,11 +558,15 @@ function renderFacetedFrame(
415
558
 
416
559
  // Gridlines + per-facet axes use the first cell's layout for tick
417
560
  // sampling (all cells have identical plotRect dimensions). Per-facet
418
- // rendering then reuses the same tick arrays.
561
+ // rendering then reuses the same tick arrays. Categorical sides
562
+ // skip numeric tick computation; the categorical painter handles
563
+ // its own label placement off the dictionary.
419
564
  const sampleLayout = grid.cells[0]?.layout;
420
- const { xTicks, yTicks } = sampleLayout
565
+ const numericFacetTicks = sampleLayout
421
566
  ? computeTicks(xDomain, yDomain, sampleLayout)
422
567
  : { xTicks: [], yTicks: [] };
568
+ const xTicks = chart._xIsString ? [] : numericFacetTicks.xTicks;
569
+ const yTicks = chart._yIsString ? [] : numericFacetTicks.yTicks;
423
570
 
424
571
  // One-shot destructive prep for the gridline + WebGL canvases.
425
572
  // Both phases below are per-facet; calling their destructive
@@ -566,17 +713,16 @@ function renderSinglePlotChromeOverlay(chart: CartesianChart): void {
566
713
  if (isMap) {
567
714
  chart.renderMapChrome(chart._chromeCanvas!, layout, theme, dpr);
568
715
  } else {
569
- renderAxesChrome(
716
+ renderCartesianCellAxes(
717
+ chart,
570
718
  chart._chromeCanvas!,
719
+ layout,
571
720
  chart._lastXDomain!,
572
721
  chart._lastYDomain!,
573
- layout,
574
722
  chart._lastXTicks!,
575
723
  chart._lastYTicks!,
576
724
  theme,
577
725
  dpr,
578
- chart.getColumnFormatter(chart._xName, "tick"),
579
- chart.getColumnFormatter(chart._yName, "tick"),
580
726
  );
581
727
  }
582
728
 
@@ -633,8 +779,13 @@ function renderFacetedChromeOverlay(chart: CartesianChart): void {
633
779
  // — these already fold in the independent-zoom override (outer
634
780
  // axes are incompatible with per-cell viewports), so `sharedX` /
635
781
  // `sharedY` true here implies shared-zoom too.
636
- const sharedX = chart._lastEffectiveSharedX;
637
- const sharedY = chart._lastEffectiveSharedY;
782
+ //
783
+ // Categorical axes additionally force per-cell rendering: the
784
+ // outer-axis painter is numeric-only and the shared dictionary
785
+ // already produces the same labels in every cell, so a per-cell
786
+ // categorical axis is equivalent to a shared one visually.
787
+ const sharedX = chart._lastEffectiveSharedX && !chart._xIsString;
788
+ const sharedY = chart._lastEffectiveSharedY && !chart._yIsString;
638
789
  const independent = chart._facetConfig.zoom_mode === "independent";
639
790
 
640
791
  // Shared X axis: one outer band across the bottom of the grid,
@@ -689,29 +840,53 @@ function renderFacetedChromeOverlay(chart: CartesianChart): void {
689
840
  : { xTicks: sharedXTicks, yTicks: sharedYTicks };
690
841
 
691
842
  if (!isMap && !sharedX) {
692
- renderCellXAxis(
693
- canvas,
694
- localX,
695
- cell.layout,
696
- ticks.xTicks,
697
- theme,
698
- !!chart._xLabel,
699
- dpr,
700
- chart.getColumnFormatter(chart._xName, "tick"),
701
- );
843
+ if (chart._xIsString && chart._xCategoryDomain) {
844
+ const cellCtx = getScaledContext(canvas, dpr);
845
+ if (cellCtx) {
846
+ renderCategoricalXTicks(
847
+ cellCtx,
848
+ cell.layout,
849
+ chart._xCategoryDomain,
850
+ theme,
851
+ );
852
+ }
853
+ } else {
854
+ renderCellXAxis(
855
+ canvas,
856
+ localX,
857
+ cell.layout,
858
+ ticks.xTicks,
859
+ theme,
860
+ !!chart._xLabel,
861
+ dpr,
862
+ chart.getColumnFormatter(chart._xName, "tick"),
863
+ );
864
+ }
702
865
  }
703
866
 
704
867
  if (!isMap && !sharedY) {
705
- renderCellYAxis(
706
- canvas,
707
- localY,
708
- cell.layout,
709
- ticks.yTicks,
710
- theme,
711
- !!chart._yLabel,
712
- dpr,
713
- chart.getColumnFormatter(chart._yName, "tick"),
714
- );
868
+ if (chart._yIsString && chart._yCategoryDomain) {
869
+ const cellCtx = getScaledContext(canvas, dpr);
870
+ if (cellCtx) {
871
+ renderCategoricalYTicks(
872
+ cellCtx,
873
+ cell.layout,
874
+ chart._yCategoryDomain,
875
+ theme,
876
+ );
877
+ }
878
+ } else {
879
+ renderCellYAxis(
880
+ canvas,
881
+ localY,
882
+ cell.layout,
883
+ ticks.yTicks,
884
+ theme,
885
+ !!chart._yLabel,
886
+ dpr,
887
+ chart.getColumnFormatter(chart._yName, "tick"),
888
+ );
889
+ }
715
890
  }
716
891
 
717
892
  if (cell.titleRect) {
@@ -16,6 +16,7 @@ import { AbstractChart } from "../chart-base";
16
16
  import { SpatialHitTester } from "../../interaction/hit-test";
17
17
  import { PlotLayout } from "../../layout/plot-layout";
18
18
  import { type AxisDomain } from "../../axis/numeric-axis";
19
+ import type { CategoricalDomain } from "../../axis/categorical-axis";
19
20
  import type { GradientTextureCache } from "../../webgl/gradient-texture";
20
21
  import type { Glyph } from "./glyph";
21
22
  import {
@@ -146,6 +147,32 @@ export class CartesianChart extends AbstractChart {
146
147
  _sizeName = "";
147
148
  _labelName = "";
148
149
  _colorIsString = false;
150
+
151
+ /**
152
+ * When the X (or Y) axis source column is post-aggregation
153
+ * `string`-typed, the build pipeline writes per-row dictionary slot
154
+ * indices into `_xData` (or `_yData`) instead of numeric values,
155
+ * and the render pass dispatches `renderCategoricalXTicks` /
156
+ * `renderCategoricalYTicks` instead of the numeric axis painter.
157
+ *
158
+ * The companion `_xCategoryDictionary` / `_xCategorySeen` pair is
159
+ * built lazily during `processCartesianChunk` in first-seen row
160
+ * order; `(null)` is appended on first encounter of an invalid row
161
+ * rather than reserved at slot 0, so charts without missing values
162
+ * don't get a phantom slot.
163
+ *
164
+ * `_xCategoryDomain` is materialized once per frame in
165
+ * `cartesian-render` and held for chrome overlay redraws (same
166
+ * lifecycle as `_lastXDomain` on the numeric path).
167
+ */
168
+ _xIsString = false;
169
+ _yIsString = false;
170
+ _xCategoryDictionary: string[] = [];
171
+ _yCategoryDictionary: string[] = [];
172
+ _xCategorySeen: Map<string, number> = new Map();
173
+ _yCategorySeen: Map<string, number> = new Map();
174
+ _xCategoryDomain: CategoricalDomain | null = null;
175
+ _yCategoryDomain: CategoricalDomain | null = null;
149
176
  _splitGroups: SplitGroup[] = [];
150
177
 
151
178
  // Data extents
@@ -375,11 +402,23 @@ export class CartesianChart extends AbstractChart {
375
402
  // (the prior accumulator); the union is in `_xMin` etc., so we
376
403
  // copy it back. Idempotent across multi-chunk uploads — every
377
404
  // chunk leaves the accumulator equal to the running union.
405
+ //
406
+ // Categorical axes opt out: slot indices are first-seen-order
407
+ // and only meaningful within a single frame's dictionary, so
408
+ // expanding across frames would mix dictionaries and shift
409
+ // every category's slot. Force-fit those axes per frame
410
+ // instead.
378
411
  if (this._pluginConfig.domain_mode === "expand") {
379
- this._expandedXMin = this._xMin;
380
- this._expandedXMax = this._xMax;
381
- this._expandedYMin = this._yMin;
382
- this._expandedYMax = this._yMax;
412
+ if (!this._xIsString) {
413
+ this._expandedXMin = this._xMin;
414
+ this._expandedXMax = this._xMax;
415
+ }
416
+
417
+ if (!this._yIsString) {
418
+ this._expandedYMin = this._yMin;
419
+ this._expandedYMax = this._yMax;
420
+ }
421
+
383
422
  this._expandedColorMin = this._colorMin;
384
423
  this._expandedColorMax = this._colorMax;
385
424
  this._expandedSizeMin = this._sizeMin;