@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
@@ -12,21 +12,28 @@
12
12
 
13
13
  import type { ColumnDataMap } from "../../data/view-reader";
14
14
  import { buildSplitGroups } from "../../data/split-groups";
15
- import type { CategoricalLevel } from "../../axis/categorical-axis";
15
+ import type {
16
+ CategoricalDomain,
17
+ CategoricalLevel,
18
+ } from "../../axis/categorical-axis";
16
19
  import {
17
20
  resolveAxisMode,
18
21
  resolveCategoryAxis,
19
22
  resolveNumericCategoryDomain,
23
+ resolveValueCategoryDomain,
20
24
  type AxisMode,
21
25
  type NumericCategoryDomain,
26
+ type ValueCategoryColumn,
22
27
  } from "../common/category-axis-resolver";
23
28
  import { computeSlotGeometry } from "../common/band-layout";
24
29
  import {
25
30
  resolveChartType,
26
31
  resolveStack,
27
32
  resolveAltAxis,
33
+ resolveInterpolate,
28
34
  type ChartType,
29
35
  type ColumnChartConfig,
36
+ type InterpolateMode,
30
37
  } from "./series-type";
31
38
 
32
39
  const DUAL_Y_RATIO_THRESHOLD = 50;
@@ -42,6 +49,26 @@ export interface SeriesInfo {
42
49
  axis: 0 | 1;
43
50
  chartType: ChartType;
44
51
  stack: boolean;
52
+
53
+ /**
54
+ * First / last category index this series contributes data to, in
55
+ * the post-Pass-2 sample grid. For line+any mode and area+solid:
56
+ * every cell in `[start, end]` has a value (real or synthesized).
57
+ * For area+skip: `[start, end]` is the real-data extent; interior
58
+ * cells with `sampleValid=0` are gaps. `start = -1` (with `end = -1`)
59
+ * means the series has no real samples — downstream skips it.
60
+ */
61
+ start: number;
62
+ end: number;
63
+
64
+ /**
65
+ * Resolved interpolation mode for this aggregate. The build
66
+ * pipeline reads it to decide whether Pass 2 runs for area
67
+ * (and which fills to apply); the line glyph reads it at draw
68
+ * time to set `u_interp_alpha`. Always one of the three modes;
69
+ * never the legacy boolean form.
70
+ */
71
+ interpolateMode: InterpolateMode;
45
72
  }
46
73
 
47
74
  /**
@@ -326,6 +353,26 @@ export interface SeriesPipelineResult {
326
353
  leftDomain: { min: number; max: number };
327
354
  rightDomain: { min: number; max: number } | null;
328
355
  hasRightAxis: boolean;
356
+
357
+ /**
358
+ * Per-axis-side value mode discriminator. `"category"` fires when
359
+ * every aggregate on that side is post-aggregation `string`-typed
360
+ * (all-or-nothing rule). Bar y0/y1 then hold dictionary slot
361
+ * indices and the chrome overlay paints a categorical axis on
362
+ * that side. `null` for the alt side when there are no series
363
+ * pinned to alt.
364
+ */
365
+ leftValueAxisMode: "numeric" | "category";
366
+ rightValueAxisMode: "numeric" | "category" | null;
367
+
368
+ /**
369
+ * Single-level `CategoricalDomain` shared across every aggregate
370
+ * on the corresponding side. Set only when that side's mode is
371
+ * `"category"`; the chrome renderer in `series-render` materializes
372
+ * the side's `BarCategoryAxis` from this.
373
+ */
374
+ leftValueCategoryDomain: CategoricalDomain | null;
375
+ rightValueCategoryDomain: CategoricalDomain | null;
329
376
  }
330
377
 
331
378
  function setValidBit(valid: Uint8Array, idx: number): void {
@@ -381,6 +428,10 @@ export function buildSeriesPipeline(
381
428
  leftDomain: { min: 0, max: 0 },
382
429
  rightDomain: null,
383
430
  hasRightAxis: false,
431
+ leftValueAxisMode: "numeric",
432
+ rightValueAxisMode: null,
433
+ leftValueCategoryDomain: null,
434
+ rightValueCategoryDomain: null,
384
435
  };
385
436
 
386
437
  const aggregates = columnSlots.filter((s): s is string => !!s);
@@ -438,6 +489,11 @@ export function buildSeriesPipeline(
438
489
  defaultChartType,
439
490
  );
440
491
  const stack = resolveStack(aggName, chartType, columnsConfig);
492
+ const interpolateMode = resolveInterpolate(
493
+ aggName,
494
+ chartType,
495
+ columnsConfig,
496
+ );
441
497
  series.push({
442
498
  seriesId: k * P + p,
443
499
  aggIdx: k,
@@ -449,6 +505,9 @@ export function buildSeriesPipeline(
449
505
  axis: 0,
450
506
  chartType,
451
507
  stack,
508
+ start: -1,
509
+ end: -1,
510
+ interpolateMode,
452
511
  });
453
512
  }
454
513
  }
@@ -539,6 +598,102 @@ export function buildSeriesPipeline(
539
598
  }
540
599
  }
541
600
 
601
+ // Per-aggregate string-ness flag, plus default axis side from the
602
+ // `columns_config.alt_axis` pin. `series[].axis` may still flip
603
+ // again via the auto-alt heuristic below, but we suppress that
604
+ // heuristic entirely once a string aggregate is present (numeric
605
+ // extent ratios are not defined on categorical data).
606
+ const aggIsString = new Array<boolean>(M);
607
+ const defaultAxisSide = new Array<number>(M);
608
+ let anyStringAgg = false;
609
+ for (let k = 0; k < M; k++) {
610
+ const aggName = aggregates[k];
611
+ const splitKey = splitPrefixes[0];
612
+ const colName = splitKey === "" ? aggName : `${splitKey}|${aggName}`;
613
+ aggIsString[k] = columns.get(colName)?.type === "string";
614
+ defaultAxisSide[k] = resolveAltAxis(aggName, columnsConfig) ? 1 : 0;
615
+ if (aggIsString[k]) {
616
+ anyStringAgg = true;
617
+ }
618
+ }
619
+
620
+ // Per-side categorical resolution. Apply the all-or-nothing rule:
621
+ // a side becomes categorical only if every aggregate currently
622
+ // assigned to it (by `defaultAxisSide` — the alt_axis pin) is
623
+ // string-typed. Auto-alt-axis can't re-assign across modes since
624
+ // we disable it whenever any string aggregate exists.
625
+ const primaryAggs: ValueCategoryColumn[] = [];
626
+ const altAggs: ValueCategoryColumn[] = [];
627
+ const primaryAggColIdx: number[] = [];
628
+ const altAggColIdx: number[] = [];
629
+ for (let k = 0; k < M; k++) {
630
+ const aggName = aggregates[k];
631
+ for (let p = 0; p < P; p++) {
632
+ const splitKey = splitPrefixes[p];
633
+ const colName =
634
+ splitKey === "" ? aggName : `${splitKey}|${aggName}`;
635
+ const colIdx = k * P + p;
636
+ const entry: ValueCategoryColumn = {
637
+ name: colName,
638
+ type: aggIsString[k] ? "string" : "numeric",
639
+ data: columns.get(colName),
640
+ };
641
+ if (defaultAxisSide[k] === 0) {
642
+ primaryAggs.push(entry);
643
+ primaryAggColIdx.push(colIdx);
644
+ } else {
645
+ altAggs.push(entry);
646
+ altAggColIdx.push(colIdx);
647
+ }
648
+ }
649
+ }
650
+
651
+ const primaryValueAxisLabel = primaryAggs
652
+ .map((c) => c.name)
653
+ .filter((s, i, arr) => arr.indexOf(s) === i)
654
+ .join(", ");
655
+ const altValueAxisLabel = altAggs
656
+ .map((c) => c.name)
657
+ .filter((s, i, arr) => arr.indexOf(s) === i)
658
+ .join(", ");
659
+
660
+ const primaryCategorical =
661
+ primaryAggs.length > 0
662
+ ? resolveValueCategoryDomain(
663
+ primaryAggs,
664
+ numRows,
665
+ rowOffset,
666
+ primaryValueAxisLabel,
667
+ )
668
+ : null;
669
+ const altCategorical =
670
+ altAggs.length > 0
671
+ ? resolveValueCategoryDomain(
672
+ altAggs,
673
+ numRows,
674
+ rowOffset,
675
+ altValueAxisLabel,
676
+ )
677
+ : null;
678
+
679
+ // Per-column slot buffers indexed in the same `colIdx = k * P + p`
680
+ // space as `colValues`. `colSlots[colIdx]` is non-null exactly when
681
+ // the side `defaultAxisSide[k]` is categorical and that side's
682
+ // resolver returned slot buffers.
683
+ const colSlots: (Int32Array | null)[] = new Array(M * P).fill(null);
684
+ if (primaryCategorical) {
685
+ for (let i = 0; i < primaryAggColIdx.length; i++) {
686
+ colSlots[primaryAggColIdx[i]] =
687
+ primaryCategorical.perColumnSlots[i];
688
+ }
689
+ }
690
+
691
+ if (altCategorical) {
692
+ for (let i = 0; i < altAggColIdx.length; i++) {
693
+ colSlots[altAggColIdx[i]] = altCategorical.perColumnSlots[i];
694
+ }
695
+ }
696
+
542
697
  // Pre-allocate columnar bar storage at N*M*P upper bound. The
543
698
  // pipeline emits at most one record per (cat, agg, split) cell;
544
699
  // `bars.count` tracks the active prefix.
@@ -546,22 +701,32 @@ export function buildSeriesPipeline(
546
701
  const bars = ensureBarColumnsCapacity(scratchBars ?? null, barCap);
547
702
  let barWrite = 0;
548
703
 
704
+ // Pass 1 — populate the raw sample grid + valid bitset. Stacking
705
+ // and bar-record emission run in pass 3 (below) so that pass 2
706
+ // can interpolate interior nulls for line/area series before the
707
+ // stack accumulator sees them; otherwise an interpolated cell in
708
+ // a stacked area would not contribute to the running y0/y1 of
709
+ // subsequent series at the same catIdx.
549
710
  for (let catI = 0; catI < N; catI++) {
550
711
  const row = catI + rowOffset;
551
-
552
- // Hoist the category center — same value across all (k, p) for
553
- // the current catI.
554
- const catCenter = categoryPositions ? categoryPositions[catI] : catI;
555
-
556
712
  for (let k = 0; k < M; k++) {
557
- const slotOffset = slotOffsets[k];
558
- const xCenter = catCenter + slotOffset;
559
- const ext = aggExtents[k];
560
-
561
713
  for (let p = 0; p < P; p++) {
562
- const seriesId = k * P + p;
563
- const s = series[seriesId];
564
714
  const colIdx = k * P + p;
715
+
716
+ // Categorical value-axis branch: `colSlots[colIdx]` is
717
+ // a per-catI Int32Array of dictionary slot indices, with
718
+ // `(null)` already routed to its own slot — every row
719
+ // is a valid sample and stack/extent logic just runs
720
+ // against the slot integer.
721
+ const slots = colSlots[colIdx];
722
+ if (slots) {
723
+ const seriesId = k * P + p;
724
+ const sampleIdx = catI * S + seriesId;
725
+ samples[sampleIdx] = slots[catI];
726
+ setValidBit(sampleValid, sampleIdx);
727
+ continue;
728
+ }
729
+
565
730
  const values = colValues[colIdx];
566
731
  if (!values) {
567
732
  continue;
@@ -580,11 +745,155 @@ export function buildSeriesPipeline(
580
745
  continue;
581
746
  }
582
747
 
583
- // Record the raw value in the unstacked grid for every
584
- // glyph that needs it (line, scatter, non-stacking bar/area).
748
+ const seriesId = k * P + p;
585
749
  const sampleIdx = catI * S + seriesId;
586
750
  samples[sampleIdx] = v;
587
751
  setValidBit(sampleValid, sampleIdx);
752
+ }
753
+ }
754
+ }
755
+
756
+ // Compute per-series [start, end] from sampleValid (post-Pass 1).
757
+ // Drives Pass 2's interpolation range, Pass 3's stack/bar emission,
758
+ // axis-extent calc, and downstream rendering. Series with no real
759
+ // samples keep start = end = -1 and are skipped everywhere.
760
+ for (let seriesId = 0; seriesId < S; seriesId++) {
761
+ let first = -1;
762
+ let last = -1;
763
+ for (let c = 0; c < N; c++) {
764
+ const idx = c * S + seriesId;
765
+ if ((sampleValid[idx >> 3] >> (idx & 7)) & 1) {
766
+ if (first === -1) {
767
+ first = c;
768
+ }
769
+
770
+ last = c;
771
+ }
772
+ }
773
+
774
+ series[seriesId].start = first;
775
+ series[seriesId].end = last;
776
+ }
777
+
778
+ // Pass 2 — synthesize values for nulls covered by interpolation.
779
+ // Writes `samples[c]` but deliberately does NOT touch `sampleValid`:
780
+ // the renderer derives "synthesized cell" from
781
+ // `c in [start, end] && sampleValid[c] === 0`, so the bit must stay
782
+ // 0 at synthesized cells. Per-series gating:
783
+ //
784
+ // - line, solid / transparent: interior linear interpolation.
785
+ // - area, solid: every synthesized cell (interior null,
786
+ // leading/trailing null) gets value 0. Stacked areas above the
787
+ // null sit on the unchanged baseline — interpolating to a
788
+ // non-zero value here would phantom-lift the upper series at
789
+ // the gap. Range collapses to [0, N-1].
790
+ // - any series with mode = "skip": skipped (the renderer's
791
+ // [start, end] iteration treats interior nulls correctly via
792
+ // the sampleValid lookup for area, and shader alpha=0 for line).
793
+ // - other chart types: skipped.
794
+ //
795
+ // X-axis units for line interpolation match the rendering: numeric
796
+ // mode uses `categoryPositions[c]`, category mode uses the cat
797
+ // index. `samples` is freshly allocated each build (Float32Array
798
+ // zero-init), so interior null cells already hold 0 — area's
799
+ // "zero-fill interior" is implicit and needs no explicit writes
800
+ // there; only the leading/trailing range extension is written.
801
+ for (let seriesId = 0; seriesId < S; seriesId++) {
802
+ const s = series[seriesId];
803
+ if (s.start < 0) {
804
+ continue;
805
+ }
806
+
807
+ if (s.interpolateMode === "skip") {
808
+ continue;
809
+ }
810
+
811
+ if (s.chartType === "line") {
812
+ let lastValid = s.start;
813
+ for (let c = s.start + 1; c <= s.end; c++) {
814
+ const idx = c * S + seriesId;
815
+ const ok = (sampleValid[idx >> 3] >> (idx & 7)) & 1;
816
+ if (!ok) {
817
+ continue;
818
+ }
819
+
820
+ if (c - lastValid > 1) {
821
+ const startIdx = lastValid * S + seriesId;
822
+ const startV = samples[startIdx];
823
+ const endV = samples[idx];
824
+ const xStart = categoryPositions
825
+ ? categoryPositions[lastValid]
826
+ : lastValid;
827
+ const xEnd = categoryPositions ? categoryPositions[c] : c;
828
+ const dx = xEnd - xStart;
829
+ for (let g = 1; g < c - lastValid; g++) {
830
+ const cc = lastValid + g;
831
+ const xMid = categoryPositions
832
+ ? categoryPositions[cc]
833
+ : cc;
834
+ const t = dx === 0 ? 0 : (xMid - xStart) / dx;
835
+ samples[cc * S + seriesId] =
836
+ startV + (endV - startV) * t;
837
+ }
838
+ }
839
+
840
+ lastValid = c;
841
+ }
842
+ } else if (s.chartType === "area") {
843
+ // Leading / trailing zero-fill. Interior nulls already sit
844
+ // at 0 from Float32Array zero-init; no per-cell write
845
+ // needed in (s.start, s.end). Range collapses to [0, N-1]
846
+ // so Pass 3 and the area glyph treat the whole span as
847
+ // renderable (continuous strip resting on the baseline at
848
+ // synthesized cells).
849
+ for (let c = 0; c < s.start; c++) {
850
+ samples[c * S + seriesId] = 0;
851
+ }
852
+
853
+ for (let c = s.end + 1; c < N; c++) {
854
+ samples[c * S + seriesId] = 0;
855
+ }
856
+
857
+ s.start = 0;
858
+ s.end = N - 1;
859
+ }
860
+ }
861
+
862
+ // Pass 3 — emit stack/bar records and update per-aggregate extents
863
+ // from the (possibly synthesized) samples grid. The cell-validity
864
+ // predicate is mode-aware: for line (any mode) and area+solid,
865
+ // Pass 2 has guaranteed every cell in `[start, end]` carries a
866
+ // meaningful value (real or synthesized) — `sampleValid` is the
867
+ // "is real" mask, not the "has value" mask, so we trust the range
868
+ // alone. For area+skip and bar/scatter, fall back to the original
869
+ // per-cell `sampleValid` check.
870
+ for (let catI = 0; catI < N; catI++) {
871
+ const catCenter = categoryPositions ? categoryPositions[catI] : catI;
872
+ for (let k = 0; k < M; k++) {
873
+ const slotOffset = slotOffsets[k];
874
+ const xCenter = catCenter + slotOffset;
875
+ const ext = aggExtents[k];
876
+
877
+ for (let p = 0; p < P; p++) {
878
+ const seriesId = k * P + p;
879
+ const s = series[seriesId];
880
+ if (catI < s.start || catI > s.end) {
881
+ continue;
882
+ }
883
+
884
+ const treatRangeAsValid =
885
+ s.chartType === "line" ||
886
+ (s.chartType === "area" && s.interpolateMode !== "skip");
887
+ const sampleIdx = catI * S + seriesId;
888
+ if (!treatRangeAsValid) {
889
+ if (
890
+ !((sampleValid[sampleIdx >> 3] >> (sampleIdx & 7)) & 1)
891
+ ) {
892
+ continue;
893
+ }
894
+ }
895
+
896
+ const v = samples[sampleIdx];
588
897
 
589
898
  // Stacking-glyph path: emit a record with running y0/y1.
590
899
  if (
@@ -592,7 +901,25 @@ export function buildSeriesPipeline(
592
901
  s.stack
593
902
  ) {
594
903
  if (v === 0) {
595
- continue;
904
+ // Non-area, or area+skip: a zero-value record
905
+ // is degenerate (zero-height bar / invisible
906
+ // strip wedge) and just costs allocation —
907
+ // drop it.
908
+ //
909
+ // Area + non-skip: keep the record so the
910
+ // stacked strip stays continuous through
911
+ // synthesized cells (interior zero-fill +
912
+ // leading / trailing zero-fill). `y1 = y0`
913
+ // makes it a zero-height vertex pair in the
914
+ // strip; posStack doesn't increment, so the
915
+ // series above stacks on the unchanged
916
+ // baseline.
917
+ if (
918
+ s.chartType !== "area" ||
919
+ s.interpolateMode === "skip"
920
+ ) {
921
+ continue;
922
+ }
596
923
  }
597
924
 
598
925
  const stackIdx = catI * M + k;
@@ -684,7 +1011,12 @@ export function buildSeriesPipeline(
684
1011
  bars.count = barWrite;
685
1012
 
686
1013
  let hasRightAxis = false;
687
- if (autoAltYAxis && M >= 2) {
1014
+ // Auto-alt-axis compares numeric magnitudes; a string aggregate
1015
+ // contributes no extent and would always land on the smaller side.
1016
+ // Skip the heuristic when any aggregate is string and let the
1017
+ // user's explicit `columns_config.alt_axis` pin (resolved below)
1018
+ // be the only axis-side override.
1019
+ if (autoAltYAxis && M >= 2 && !anyStringAgg) {
688
1020
  const extents: number[] = new Array(M);
689
1021
  let maxExt = 0;
690
1022
  let minExt = Infinity;
@@ -784,11 +1116,20 @@ export function buildSeriesPipeline(
784
1116
  continue; // already counted via bars
785
1117
  }
786
1118
 
1119
+ if (s.start < 0) {
1120
+ continue;
1121
+ }
1122
+
1123
+ const treatRangeAsValid =
1124
+ s.chartType === "line" ||
1125
+ (s.chartType === "area" && s.interpolateMode !== "skip");
787
1126
  const ext = s.axis === 0 ? leftExtent : rightExtent;
788
- for (let catI = 0; catI < N; catI++) {
1127
+ for (let catI = s.start; catI <= s.end; catI++) {
789
1128
  const sampleIdx = catI * S + seriesId;
790
- if (!((sampleValid[sampleIdx >> 3] >> (sampleIdx & 7)) & 1)) {
791
- continue;
1129
+ if (!treatRangeAsValid) {
1130
+ if (!((sampleValid[sampleIdx >> 3] >> (sampleIdx & 7)) & 1)) {
1131
+ continue;
1132
+ }
792
1133
  }
793
1134
 
794
1135
  const v = samples[sampleIdx];
@@ -826,6 +1167,34 @@ export function buildSeriesPipeline(
826
1167
  : rightExtent
827
1168
  : null;
828
1169
 
1170
+ // Categorical value-axis: override the numeric extent with the
1171
+ // slot-index range `[0, dictLen-1]`. The chrome renderer paints
1172
+ // the dictionary; the numeric domain we surface is only used by
1173
+ // the projection matrix and pixel mapping, both of which work on
1174
+ // raw slot indices when `*ValueAxisMode === "category"`.
1175
+ const leftValueAxisMode: "numeric" | "category" = primaryCategorical
1176
+ ? "category"
1177
+ : "numeric";
1178
+ const rightValueAxisMode: "numeric" | "category" | null = hasRightAxis
1179
+ ? altCategorical
1180
+ ? "category"
1181
+ : "numeric"
1182
+ : null;
1183
+ const finalLeftDomain =
1184
+ primaryCategorical && primaryCategorical.domain.numRows > 0
1185
+ ? {
1186
+ min: 0,
1187
+ max: Math.max(0, primaryCategorical.domain.numRows - 1),
1188
+ }
1189
+ : leftExtent;
1190
+ const finalRightDomain =
1191
+ altCategorical && altCategorical.domain.numRows > 0 && hasRightAxis
1192
+ ? {
1193
+ min: 0,
1194
+ max: Math.max(0, altCategorical.domain.numRows - 1),
1195
+ }
1196
+ : rightDomain;
1197
+
829
1198
  return {
830
1199
  aggregates,
831
1200
  splitPrefixes,
@@ -841,8 +1210,12 @@ export function buildSeriesPipeline(
841
1210
  negStack,
842
1211
  samples,
843
1212
  sampleValid,
844
- leftDomain: leftExtent,
845
- rightDomain,
1213
+ leftDomain: finalLeftDomain,
1214
+ rightDomain: finalRightDomain,
846
1215
  hasRightAxis,
1216
+ leftValueAxisMode,
1217
+ rightValueAxisMode,
1218
+ leftValueCategoryDomain: primaryCategorical?.domain ?? null,
1219
+ rightValueCategoryDomain: altCategorical?.domain ?? null,
847
1220
  };
848
1221
  }