@perspective-dev/viewer-charts 4.5.2 → 5.1.0

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 (61) 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/charts/candlestick/candlestick.d.ts +8 -10
  4. package/dist/esm/charts/chart-base.d.ts +58 -4
  5. package/dist/esm/charts/chart.d.ts +45 -9
  6. package/dist/esm/charts/series/glyphs/draw-areas.d.ts +8 -2
  7. package/dist/esm/charts/series/glyphs/draw-bars.d.ts +8 -1
  8. package/dist/esm/charts/series/glyphs/draw-lines.d.ts +8 -2
  9. package/dist/esm/charts/series/glyphs/draw-scatter.d.ts +8 -2
  10. package/dist/esm/charts/series/series-build.d.ts +11 -0
  11. package/dist/esm/charts/series/series-interact.d.ts +13 -2
  12. package/dist/esm/charts/series/series-render.d.ts +17 -1
  13. package/dist/esm/charts/series/series.d.ts +104 -10
  14. package/dist/esm/data/view-reader.d.ts +10 -1
  15. package/dist/esm/event-detail.d.ts +7 -0
  16. package/dist/esm/layout/ticks.d.ts +5 -0
  17. package/dist/esm/perspective-viewer-charts.js +2 -2
  18. package/dist/esm/perspective-viewer-charts.js.map +3 -3
  19. package/dist/esm/plugin/charts.d.ts +6 -3
  20. package/dist/esm/plugin/plugin.d.ts +40 -2
  21. package/dist/esm/render/scheduler.d.ts +11 -8
  22. package/dist/esm/transport/protocol.d.ts +39 -2
  23. package/dist/esm/transport/renderer-transport.d.ts +75 -3
  24. package/dist/esm/webgl/instanced-attrs.d.ts +7 -1
  25. package/dist/esm/worker/renderer.worker.d.ts +12 -0
  26. package/package.json +1 -1
  27. package/src/ts/charts/candlestick/candlestick-render.ts +4 -1
  28. package/src/ts/charts/candlestick/candlestick.ts +12 -17
  29. package/src/ts/charts/cartesian/cartesian-render.ts +80 -63
  30. package/src/ts/charts/chart-base.ts +85 -5
  31. package/src/ts/charts/chart.ts +47 -9
  32. package/src/ts/charts/common/category-axis-resolver.ts +5 -1
  33. package/src/ts/charts/common/tree-data.ts +61 -16
  34. package/src/ts/charts/heatmap/heatmap-render.ts +6 -2
  35. package/src/ts/charts/series/glyphs/draw-areas.ts +21 -1
  36. package/src/ts/charts/series/glyphs/draw-bars.ts +30 -9
  37. package/src/ts/charts/series/glyphs/draw-lines.ts +21 -1
  38. package/src/ts/charts/series/glyphs/draw-scatter.ts +68 -3
  39. package/src/ts/charts/series/series-build.ts +21 -3
  40. package/src/ts/charts/series/series-interact.ts +210 -78
  41. package/src/ts/charts/series/series-render.ts +779 -49
  42. package/src/ts/charts/series/series.ts +209 -26
  43. package/src/ts/charts/sunburst/sunburst-interact.ts +2 -3
  44. package/src/ts/charts/sunburst/sunburst-render.ts +3 -1
  45. package/src/ts/charts/treemap/treemap-interact.ts +2 -5
  46. package/src/ts/charts/treemap/treemap-render.ts +3 -1
  47. package/src/ts/charts/treemap/treemap.ts +6 -1
  48. package/src/ts/data/view-reader.ts +22 -2
  49. package/src/ts/event-detail.ts +8 -0
  50. package/src/ts/index.ts +13 -7
  51. package/src/ts/interaction/raw-event-forwarder.ts +7 -0
  52. package/src/ts/interaction/zoom-controller.ts +8 -0
  53. package/src/ts/layout/ticks.ts +9 -0
  54. package/src/ts/plugin/charts.ts +20 -4
  55. package/src/ts/plugin/plugin.ts +99 -17
  56. package/src/ts/render/scheduler.ts +39 -13
  57. package/src/ts/transport/protocol.ts +42 -0
  58. package/src/ts/transport/renderer-transport.ts +198 -9
  59. package/src/ts/webgl/instanced-attrs.ts +15 -1
  60. package/src/ts/worker/dispatch.ts +4 -1
  61. package/src/ts/worker/renderer.worker.ts +35 -3
@@ -14,17 +14,26 @@ import type { Context2D } from "../canvas-types";
14
14
  import type { WebGLContextManager } from "../../webgl/context-manager";
15
15
  import {
16
16
  ensurePalette,
17
+ type GlyphRun,
17
18
  type SeriesChart,
18
19
  type SeriesAutoFitCache,
19
20
  } from "./series";
20
21
  import type { PlotRect } from "../../layout/plot-layout";
21
22
  import { PlotLayout } from "../../layout/plot-layout";
22
- import { renderInPlotFrame } from "../../webgl/plot-frame";
23
+ import {
24
+ clearAndSetupFrame,
25
+ renderInPlotFrame,
26
+ withScissor,
27
+ } from "../../webgl/plot-frame";
23
28
  import { renderCanvasTooltip } from "../../interaction/tooltip-controller";
24
29
  import { drawBars, BAR_TYPE_BAR_VAL as BAR_TYPE_BAR } from "./glyphs/draw-bars";
25
30
  import { getHoveredBar } from "./series-interact";
26
31
  import { computeNiceTicks } from "../../layout/ticks";
27
- import { type AxisDomain } from "../../axis/numeric-axis";
32
+ import {
33
+ renderOuterXAxis,
34
+ renderOuterYAxis,
35
+ type AxisDomain,
36
+ } from "../../axis/numeric-axis";
28
37
  import {
29
38
  renderBarAxesChrome,
30
39
  renderBarGridlines,
@@ -34,8 +43,19 @@ import {
34
43
  import {
35
44
  measureCategoricalAxisHeight,
36
45
  measureCategoricalAxisWidth,
46
+ renderCategoricalXTicks,
47
+ renderCategoricalYTicks,
37
48
  type CategoricalDomain,
38
49
  } from "../../axis/categorical-axis";
50
+ import {
51
+ bottomRowLayouts,
52
+ buildFacetGrid,
53
+ leftColumnLayouts,
54
+ type FacetGrid,
55
+ } from "../../layout/facet-grid";
56
+ import { drawFacetTitle } from "../../axis/facet-chrome";
57
+ import { getScaledContext, initCanvas } from "../../axis/canvas";
58
+ import { drawGridlinesX, drawGridlinesY } from "../../axis/axis-primitives";
39
59
  import { buildBarTooltipLines } from "./series-interact";
40
60
 
41
61
  /**
@@ -77,6 +97,12 @@ function ensureBarInstanceScratch(n: number): BarInstanceScratch {
77
97
 
78
98
  /**
79
99
  * Upload bar instance buffers from the columnar `_bars` storage.
100
+ *
101
+ * Overlay mode emits instances in `_bars` order. Facet-grid mode
102
+ * (`_facetActive`) emits them split-major via a counting sort so each
103
+ * facet's instances form a contiguous range — recorded on
104
+ * `chart._facetBarRanges` and drawn per facet with an instance-offset
105
+ * `drawBars` call.
80
106
  */
81
107
  export function uploadBarInstances(
82
108
  chart: SeriesChart,
@@ -84,8 +110,13 @@ export function uploadBarInstances(
84
110
  ): void {
85
111
  const bars = chart._bars;
86
112
  const total = bars.count;
113
+ const P = chart._splitPrefixes.length;
114
+ const faceted = chart._facetActive && P > 0;
87
115
  let n = 0;
88
116
 
117
+ chart._facetBarRanges = null;
118
+ chart._facetBarAggRanges = null;
119
+ chart._barAggRanges = null;
89
120
  if (total > 0) {
90
121
  const scratch = ensureBarInstanceScratch(total);
91
122
  if (
@@ -108,6 +139,61 @@ export function uploadBarInstances(
108
139
  const by0 = bars.y0;
109
140
  const by1 = bars.y1;
110
141
  const ax = bars.axis;
142
+
143
+ // Counting sort so contiguous instance ranges exist for every
144
+ // draw grouping. Overlay: key = aggIdx, publishing
145
+ // `_barAggRanges` — a glyph run `[aggStart, aggEnd]` is one
146
+ // contiguous slice, and within-call overlap Z follows `columns`
147
+ // declaration order. Faceted: key = splitIdx-major then aggIdx,
148
+ // publishing both the per-split `_facetBarRanges` (facet
149
+ // dispatch) and the per-(split, agg) `_facetBarAggRanges` (run
150
+ // slices within a facet). Pass 1 counts eligible instances per
151
+ // bucket; prefix sums become the write cursors AND the ranges.
152
+ const M = Math.max(1, chart._aggregates.length);
153
+ const bucketOf = faceted
154
+ ? (seriesId: number) =>
155
+ (seriesId % P) * M + Math.floor(seriesId / P)
156
+ : (seriesId: number) =>
157
+ P > 0 ? Math.floor(seriesId / P) : seriesId;
158
+ const numBuckets = faceted ? P * M : M;
159
+ const counts = new Array<number>(numBuckets).fill(0);
160
+ for (let i = 0; i < total; i++) {
161
+ if (ct[i] !== BAR_TYPE_BAR || hidden.has(sid[i])) {
162
+ continue;
163
+ }
164
+
165
+ counts[bucketOf(sid[i])]++;
166
+ }
167
+
168
+ const ranges: { start: number; count: number }[] = [];
169
+ const cursors = new Int32Array(numBuckets);
170
+ let acc = 0;
171
+ for (let b = 0; b < numBuckets; b++) {
172
+ ranges.push({ start: acc, count: counts[b] });
173
+ cursors[b] = acc;
174
+ acc += counts[b];
175
+ }
176
+
177
+ if (faceted) {
178
+ chart._barAggRanges = null;
179
+ chart._facetBarAggRanges = Array.from({ length: P }, (_, p) =>
180
+ ranges.slice(p * M, (p + 1) * M),
181
+ );
182
+ chart._facetBarRanges = Array.from({ length: P }, (_, p) => {
183
+ const first = ranges[p * M];
184
+ const last = ranges[(p + 1) * M - 1];
185
+ return {
186
+ start: first.start,
187
+ count: last.start + last.count - first.start,
188
+ };
189
+ });
190
+ } else {
191
+ chart._barAggRanges = ranges;
192
+ chart._facetBarAggRanges = null;
193
+ }
194
+
195
+ const writeAt = (seriesId: number) => cursors[bucketOf(seriesId)]++;
196
+
111
197
  for (let i = 0; i < total; i++) {
112
198
  if (ct[i] !== BAR_TYPE_BAR) {
113
199
  continue;
@@ -118,19 +204,25 @@ export function uploadBarInstances(
118
204
  continue;
119
205
  }
120
206
 
121
- scratch.xCenters[n] = xC[i] - xOrigin;
122
- scratch.halfWidths[n] = hw[i];
123
- scratch.y0s[n] = by0[i];
124
- scratch.y1s[n] = by1[i];
125
- scratch.seriesIds[n] = seriesId;
126
- scratch.axes[n] = ax[i];
207
+ const w = writeAt(seriesId);
208
+ scratch.xCenters[w] = xC[i] - xOrigin;
209
+ scratch.halfWidths[w] = hw[i];
210
+ scratch.y0s[w] = by0[i];
211
+ scratch.y1s[w] = by1[i];
212
+ scratch.seriesIds[w] = seriesId;
213
+ scratch.axes[w] = ax[i];
127
214
  const color = series[seriesId].color;
128
- scratch.colors[n * 3] = color[0];
129
- scratch.colors[n * 3 + 1] = color[1];
130
- scratch.colors[n * 3 + 2] = color[2];
131
- indices[n] = i;
215
+ scratch.colors[w * 3] = color[0];
216
+ scratch.colors[w * 3 + 1] = color[1];
217
+ scratch.colors[w * 3 + 2] = color[2];
218
+ indices[w] = i;
132
219
  n++;
133
220
  }
221
+ } else if (faceted) {
222
+ chart._facetBarRanges = Array.from({ length: P }, () => ({
223
+ start: 0,
224
+ count: 0,
225
+ }));
134
226
  }
135
227
 
136
228
  chart._uploadedBars = n;
@@ -386,6 +478,28 @@ export function renderBarFrame(
386
478
  }
387
479
  }
388
480
 
481
+ // Facet-grid branch — one sub-plot per split group. Keys off the
482
+ // build-time `_facetActive` stamp (NOT the live `_facetConfig`) so
483
+ // this frame's render mode always matches the stack geometry the
484
+ // current `_bars` were built with.
485
+ if (chart._facetActive && chart._splitPrefixes.length > 1) {
486
+ renderFacetedBarFrame(chart, glManager, {
487
+ horizontal,
488
+ numericCat,
489
+ cssWidth,
490
+ cssHeight,
491
+ visCatMin,
492
+ visCatMax,
493
+ visValMin,
494
+ visValMax,
495
+ visRightMin,
496
+ visRightMax,
497
+ });
498
+ return;
499
+ }
500
+
501
+ chart._facetGrid = null;
502
+
389
503
  const hasLegend = chart._series.length > 1;
390
504
  const hasCatLabel = chart._groupBy.length > 0;
391
505
 
@@ -559,52 +673,415 @@ export function renderBarFrame(
559
673
  );
560
674
  }
561
675
 
676
+ const hovered = chart._series.length > 1 ? getHoveredBar(chart) : null;
562
677
  renderInPlotFrame(gl, layout, glManager.dpr, () => {
563
- // Paint order: areas behind bars (so bar borders stay crisp),
564
- // bars above, lines above those, scatter points on top. X Bar
565
- // only paints bars — the other glyphs bake in vertical geometry
566
- // and aren't supported for horizontal orientation.
567
- if (!horizontal) {
568
- chart._glyphs.areas.draw(
569
- chart,
570
- gl,
571
- glManager,
572
- projLeft,
573
- projRight,
574
- theme.areaOpacity,
678
+ drawGlyphRuns(
679
+ chart,
680
+ gl,
681
+ glManager,
682
+ projLeft,
683
+ projRight,
684
+ theme.areaOpacity,
685
+ horizontal,
686
+ hovered ? hovered.seriesId : -1,
687
+ );
688
+ });
689
+
690
+ chart._lastXDomain = catDomain;
691
+ chart._lastYDomain = valueDomain;
692
+ chart._lastYTicks = leftValueTicks;
693
+ chart._lastAltYDomain = altValueDomain;
694
+ chart._lastAltYTicks = rightValueTicks;
695
+ chart._lastCatTicks = numericCat
696
+ ? computeNiceTicks(visCatMin, visCatMax, 6)
697
+ : null;
698
+ // Deferred past the GPU fence (see `_defer2D`) so the chrome canvas
699
+ // doesn't present ahead of the GL glyphs on resize. Reads the
700
+ // `_last*` frame state set above.
701
+ chart._defer2D(() => renderBarChromeOverlay(chart));
702
+ }
703
+
704
+ /**
705
+ * Paint every glyph in `columns` declaration Z-order — one pass per
706
+ * {@link SeriesChart._glyphRuns} entry, ascending `aggIdx`, later
707
+ * columns on top; splits within an aggregate paint in `splitIdx`
708
+ * order. A homogeneous chart is a single run and takes exactly the
709
+ * legacy one-pass path (no per-series filtering, single instanced bar
710
+ * draw). X Bar (horizontal) paints bars only — the other glyphs bake
711
+ * vertical geometry.
712
+ *
713
+ * The caller wraps this in its plot clip (`renderInPlotFrame` /
714
+ * `withScissor`); `splitFilter` is the facet index in faceted frames.
715
+ */
716
+ function drawGlyphRuns(
717
+ chart: SeriesChart,
718
+ gl: WebGL2RenderingContext | WebGLRenderingContext,
719
+ glManager: WebGLContextManager,
720
+ projLeft: Float32Array,
721
+ projRight: Float32Array,
722
+ areaOpacity: number,
723
+ horizontal: boolean,
724
+ hoveredSeriesId: number,
725
+ splitFilter?: number,
726
+ ): void {
727
+ const runs = chart._glyphRuns;
728
+ const single = runs.length <= 1;
729
+ for (const run of runs) {
730
+ const aggRange = single
731
+ ? undefined
732
+ : { start: run.aggStart, end: run.aggEnd };
733
+ switch (run.chartType) {
734
+ case "area":
735
+ if (!horizontal) {
736
+ chart._glyphs.areas.draw(
737
+ chart,
738
+ gl,
739
+ glManager,
740
+ projLeft,
741
+ projRight,
742
+ areaOpacity,
743
+ splitFilter,
744
+ aggRange,
745
+ );
746
+ }
747
+
748
+ break;
749
+ case "bar": {
750
+ gl.useProgram(chart._program!);
751
+ const loc = chart._locations!;
752
+ gl.uniformMatrix4fv(loc.u_proj_left, false, projLeft);
753
+ gl.uniformMatrix4fv(loc.u_proj_right, false, projRight);
754
+ gl.uniform1f(loc.u_horizontal, horizontal ? 1.0 : 0.0);
755
+ gl.uniform1f(loc.u_hover_series, hoveredSeriesId);
756
+ drawBars(
757
+ chart,
758
+ gl,
759
+ glManager,
760
+ barRunRange(chart, run, splitFilter),
761
+ );
762
+ break;
763
+ }
764
+
765
+ case "line":
766
+ if (!horizontal) {
767
+ chart._glyphs.lines.draw(
768
+ chart,
769
+ gl,
770
+ glManager,
771
+ projLeft,
772
+ projRight,
773
+ splitFilter,
774
+ aggRange,
775
+ );
776
+ }
777
+
778
+ break;
779
+ case "scatter":
780
+ if (!horizontal) {
781
+ chart._glyphs.scatter.draw(
782
+ chart,
783
+ gl,
784
+ glManager,
785
+ projLeft,
786
+ projRight,
787
+ splitFilter,
788
+ aggRange,
789
+ );
790
+ }
791
+
792
+ break;
793
+ }
794
+ }
795
+ }
796
+
797
+ /**
798
+ * The contiguous uploaded-instance slice for a bar run — per-aggregate
799
+ * ranges are adjacent by construction (`uploadBarInstances` counting
800
+ * sort), so the run `[aggStart, aggEnd]` spans from the first range's
801
+ * start through the last range's end. Faceted frames slice within the
802
+ * facet's split-major block.
803
+ */
804
+ function barRunRange(
805
+ chart: SeriesChart,
806
+ run: GlyphRun,
807
+ splitFilter?: number,
808
+ ): { start: number; count: number } | undefined {
809
+ const table =
810
+ splitFilter !== undefined
811
+ ? chart._facetBarAggRanges?.[splitFilter]
812
+ : chart._barAggRanges;
813
+ if (!table) {
814
+ return splitFilter !== undefined ? { start: 0, count: 0 } : undefined;
815
+ }
816
+
817
+ const first = table[run.aggStart];
818
+ const last = table[run.aggEnd];
819
+ return { start: first.start, count: last.start + last.count - first.start };
820
+ }
821
+
822
+ /**
823
+ * Domain window computed by `renderBarFrame`'s shared prologue (zoom
824
+ * window + auto-fit + `include_zero`), handed to the faceted branch.
825
+ */
826
+ interface FacetedFrameCtx {
827
+ horizontal: boolean;
828
+ numericCat: boolean;
829
+ cssWidth: number;
830
+ cssHeight: number;
831
+ visCatMin: number;
832
+ visCatMax: number;
833
+ visValMin: number;
834
+ visValMax: number;
835
+ visRightMin: number;
836
+ visRightMax: number;
837
+ }
838
+
839
+ /**
840
+ * Faceted frame — one sub-plot per split group, laid out by
841
+ * `buildFacetGrid`. Geometry contract with the build pipeline
842
+ * (`facetSplits`): every split's records share the same band-slot
843
+ * coordinates and per-split stack baselines, so a facet is exactly
844
+ * "the single-plot render restricted to one split" — same domains,
845
+ * same projection math, different `PlotLayout` + instance subset.
846
+ *
847
+ * Axis policy: the value axis is shared (one outer band, one domain
848
+ * for every facet); the category axis paints per-cell when
849
+ * categorical (the outer painters are numeric-only — same compromise
850
+ * as the cartesian faceted path) and shared-outer when numeric. Zoom
851
+ * is shared: `syncFacetZoomLayouts` keeps the single controller's
852
+ * layout on cell 0, and every facet renders the same visible window.
853
+ *
854
+ * Known limitation: the grid reserves no outer band for a secondary
855
+ * value axis, so dual-axis series project correctly inside each facet
856
+ * but the right-axis chrome is suppressed (`_lastAltYDomain = null`).
857
+ */
858
+ function renderFacetedBarFrame(
859
+ chart: SeriesChart,
860
+ glManager: WebGLContextManager,
861
+ ctx: FacetedFrameCtx,
862
+ ): void {
863
+ const gl = glManager.gl;
864
+ const dpr = glManager.dpr;
865
+ const theme = chart._resolveTheme();
866
+ const {
867
+ horizontal,
868
+ numericCat,
869
+ cssWidth,
870
+ cssHeight,
871
+ visCatMin,
872
+ visCatMax,
873
+ visValMin,
874
+ visValMax,
875
+ visRightMin,
876
+ visRightMax,
877
+ } = ctx;
878
+
879
+ const hasCatLabel = chart._groupBy.length > 0;
880
+ const valueCatDomain = chart._leftValueCategoryDomain;
881
+ const valueCatActive =
882
+ chart._leftValueAxisMode === "category" &&
883
+ valueCatDomain !== null &&
884
+ valueCatDomain.numRows > 0;
885
+
886
+ // Facets absorb the split dimension and color follows the
887
+ // aggregate, so only multiple aggregates warrant a legend.
888
+ const hasLegend = chart._aggregates.length > 1;
889
+
890
+ // `buildFacetGrid` axis modes are named in CANVAS orientation
891
+ // (x = bottom band, y = left band); map the logical category /
892
+ // value sides onto them per chart orientation.
893
+ const catAxisMode = numericCat ? ("outer" as const) : ("cell" as const);
894
+ const valAxisMode = valueCatActive ? ("cell" as const) : ("outer" as const);
895
+
896
+ const grid: FacetGrid = buildFacetGrid(chart._splitPrefixes, {
897
+ cssWidth,
898
+ cssHeight,
899
+ xAxis: horizontal ? valAxisMode : catAxisMode,
900
+ yAxis: horizontal ? catAxisMode : valAxisMode,
901
+ hasLegend,
902
+ hasXLabel: horizontal ? true : hasCatLabel,
903
+ hasYLabel: horizontal ? hasCatLabel : true,
904
+ gap: chart._facetConfig.facet_padding,
905
+ });
906
+ chart._facetGrid = grid;
907
+ chart._lastLayout = grid.cells[0]?.layout ?? null;
908
+ if (grid.cells.length === 0 || !chart._lastLayout) {
909
+ return;
910
+ }
911
+
912
+ chart.syncFacetZoomLayouts(grid.cells);
913
+
914
+ const leftValueTicks = computeNiceTicks(visValMin, visValMax, 6);
915
+ const catTicks = numericCat
916
+ ? computeNiceTicks(visCatMin, visCatMax, 6)
917
+ : null;
918
+
919
+ const sampleLayout = grid.cells[0].layout;
920
+ const gridlineCanvas = chart._gridlineCanvas;
921
+ if (gridlineCanvas) {
922
+ // Deferred FIRST so the destructive resize/clear runs before
923
+ // the per-cell gridline closures below (FIFO flush order).
924
+ chart._defer2D(() => initCanvas(gridlineCanvas, sampleLayout, dpr));
925
+ }
926
+
927
+ const requireZero = chart._pluginConfig.include_zero;
928
+ const hovered = chart._series.length > 1 ? getHoveredBar(chart) : null;
929
+ const hasRight =
930
+ chart._hasRightAxis && chart._rightDomain !== null && !horizontal;
931
+
932
+ clearAndSetupFrame(gl);
933
+ for (let p = 0; p < grid.cells.length; p++) {
934
+ const cell = grid.cells[p];
935
+ const layout = cell.layout;
936
+
937
+ // Same projection args as the single-plot path, on the cell's
938
+ // layout. Seeds the cell's padded domain, which the deferred
939
+ // gridline closure, chrome overlay, and hover hit-test read.
940
+ const projLeft = horizontal
941
+ ? layout.buildProjectionMatrix(
942
+ visValMin,
943
+ visValMax,
944
+ visCatMax,
945
+ visCatMin,
946
+ "x",
947
+ requireZero,
948
+ undefined,
949
+ 0,
950
+ chart._categoryOrigin,
951
+ )
952
+ : layout.buildProjectionMatrix(
953
+ visCatMin,
954
+ visCatMax,
955
+ visValMin,
956
+ visValMax,
957
+ "y",
958
+ requireZero,
959
+ undefined,
960
+ chart._categoryOrigin,
961
+ 0,
962
+ );
963
+
964
+ let projRight = projLeft;
965
+ if (hasRight) {
966
+ const savedPadXMin = layout.paddedXMin;
967
+ const savedPadXMax = layout.paddedXMax;
968
+ const savedPadYMin = layout.paddedYMin;
969
+ const savedPadYMax = layout.paddedYMax;
970
+ projRight = layout.buildProjectionMatrix(
971
+ visCatMin,
972
+ visCatMax,
973
+ visRightMin,
974
+ visRightMax,
975
+ "y",
976
+ requireZero,
977
+ undefined,
978
+ chart._categoryOrigin,
979
+ 0,
575
980
  );
981
+ layout.paddedXMin = savedPadXMin;
982
+ layout.paddedXMax = savedPadXMax;
983
+ layout.paddedYMin = savedPadYMin;
984
+ layout.paddedYMax = savedPadYMax;
576
985
  }
577
986
 
578
- gl.useProgram(chart._program!);
579
- const loc = chart._locations!;
580
- gl.uniformMatrix4fv(loc.u_proj_left, false, projLeft);
581
- gl.uniformMatrix4fv(loc.u_proj_right, false, projRight);
582
- gl.uniform1f(loc.u_horizontal, horizontal ? 1.0 : 0.0);
583
- const hovered = chart._series.length > 1 ? getHoveredBar(chart) : null;
584
- gl.uniform1f(loc.u_hover_series, hovered ? hovered.seriesId : -1);
585
- drawBars(chart, gl, glManager);
987
+ if (gridlineCanvas) {
988
+ chart._defer2D(() =>
989
+ renderBarGridlinesCell(
990
+ gridlineCanvas,
991
+ layout,
992
+ leftValueTicks,
993
+ theme,
994
+ dpr,
995
+ horizontal,
996
+ ),
997
+ );
998
+ }
586
999
 
587
- if (!horizontal) {
588
- chart._glyphs.lines.draw(chart, gl, glManager, projLeft, projRight);
589
- chart._glyphs.scatter.draw(
1000
+ withScissor(gl, layout, dpr, () => {
1001
+ drawGlyphRuns(
590
1002
  chart,
591
1003
  gl,
592
1004
  glManager,
593
1005
  projLeft,
594
1006
  projRight,
1007
+ theme.areaOpacity,
1008
+ horizontal,
1009
+ hovered && hovered.splitIdx === p ? hovered.seriesId : -1,
1010
+ p,
595
1011
  );
596
- }
597
- });
1012
+ });
1013
+ }
598
1014
 
599
- chart._lastXDomain = catDomain;
600
- chart._lastYDomain = valueDomain;
1015
+ chart._lastXDomain = {
1016
+ levels: chart._rowPaths,
1017
+ numRows: chart._numCategories,
1018
+ levelLabels: chart._groupBy.slice(),
1019
+ };
1020
+ chart._lastYDomain = {
1021
+ min: visValMin,
1022
+ max: visValMax,
1023
+ label: chart._primaryValueLabel,
1024
+ };
601
1025
  chart._lastYTicks = leftValueTicks;
602
- chart._lastAltYDomain = altValueDomain;
603
- chart._lastAltYTicks = rightValueTicks;
604
- chart._lastCatTicks = numericCat
605
- ? computeNiceTicks(visCatMin, visCatMax, 6)
606
- : null;
607
- renderBarChromeOverlay(chart);
1026
+ chart._lastAltYDomain = null;
1027
+ chart._lastAltYTicks = null;
1028
+ chart._lastCatTicks = catTicks;
1029
+ chart._defer2D(() => renderBarChromeOverlay(chart));
1030
+ }
1031
+
1032
+ /**
1033
+ * Per-cell value-axis gridlines for the faceted frame. Non-destructive
1034
+ * counterpart to {@link renderBarGridlines} — the shared gridline
1035
+ * canvas is `initCanvas`'d once per frame, then each facet paints into
1036
+ * it via `getScaledContext`.
1037
+ */
1038
+ function renderBarGridlinesCell(
1039
+ canvas: NonNullable<SeriesChart["_gridlineCanvas"]>,
1040
+ layout: PlotLayout,
1041
+ valueTicks: number[],
1042
+ theme: ReturnType<SeriesChart["_resolveTheme"]>,
1043
+ dpr: number,
1044
+ horizontal: boolean,
1045
+ ): void {
1046
+ const ctx = getScaledContext(canvas, dpr);
1047
+ if (!ctx) {
1048
+ return;
1049
+ }
1050
+
1051
+ ctx.strokeStyle = theme.gridlineColor;
1052
+ ctx.lineWidth = 1;
1053
+ if (horizontal) {
1054
+ drawGridlinesX(
1055
+ ctx,
1056
+ layout.plotRect,
1057
+ valueTicks,
1058
+ (v) => layout.dataToPixel(v, 0).px,
1059
+ );
1060
+ } else {
1061
+ drawGridlinesY(
1062
+ ctx,
1063
+ layout.plotRect,
1064
+ valueTicks,
1065
+ (v) => layout.dataToPixel(0, v).py,
1066
+ );
1067
+ }
1068
+ }
1069
+
1070
+ /**
1071
+ * Resolve the `PlotLayout` a record renders in: the record's facet
1072
+ * cell in faceted frames, the single plot layout otherwise. Tooltip /
1073
+ * pin positioning must map through this so faceted anchors land in the
1074
+ * record's own cell.
1075
+ */
1076
+ export function layoutForRecord(
1077
+ chart: SeriesChart,
1078
+ b: { splitIdx: number },
1079
+ ): PlotLayout | null {
1080
+ if (chart._facetGrid) {
1081
+ return chart._facetGrid.cells[b.splitIdx]?.layout ?? null;
1082
+ }
1083
+
1084
+ return chart._lastLayout;
608
1085
  }
609
1086
 
610
1087
  /**
@@ -620,6 +1097,11 @@ export function renderBarChromeOverlay(chart: SeriesChart): void {
620
1097
  return;
621
1098
  }
622
1099
 
1100
+ if (chart._facetGrid) {
1101
+ renderFacetedBarChromeOverlay(chart);
1102
+ return;
1103
+ }
1104
+
623
1105
  const theme = chart._resolveTheme();
624
1106
  let catAxis: BarCategoryAxis;
625
1107
  if (
@@ -708,6 +1190,248 @@ export function renderBarChromeOverlay(chart: SeriesChart): void {
708
1190
  }
709
1191
  }
710
1192
 
1193
+ /**
1194
+ * Chrome overlay for the faceted frame. The canvas is `initCanvas`'d
1195
+ * once, then every painter goes through `getScaledContext` — per-cell
1196
+ * axis frames, per-cell categorical ticks, shared outer numeric
1197
+ * bands, facet titles, the aggregate legend, and the tooltip (mapped
1198
+ * through the hovered record's own cell).
1199
+ */
1200
+ function renderFacetedBarChromeOverlay(chart: SeriesChart): void {
1201
+ const grid = chart._facetGrid!;
1202
+ const canvas = chart._chromeCanvas!;
1203
+ const theme = chart._resolveTheme();
1204
+ const dpr = chart._glManager?.dpr ?? 1;
1205
+ const horizontal = chart._isHorizontal;
1206
+ const hasCatLabel = chart._groupBy.length > 0;
1207
+ const numericCat =
1208
+ chart._categoryAxisMode === "numeric" &&
1209
+ chart._numericCategoryDomain !== null &&
1210
+ chart._lastCatTicks !== null;
1211
+
1212
+ if (!initCanvas(canvas, chart._lastLayout!, dpr)) {
1213
+ return;
1214
+ }
1215
+
1216
+ const catDomain = chart._lastXDomain;
1217
+ const valueDomain = chart._lastYDomain!;
1218
+ const valueTicks = chart._lastYTicks!;
1219
+ const valueCatDomain = chart._leftValueCategoryDomain;
1220
+ const valueCatActive =
1221
+ chart._leftValueAxisMode === "category" &&
1222
+ valueCatDomain !== null &&
1223
+ valueCatDomain.numRows > 0;
1224
+
1225
+ const primarySeries = chart._series.find((s) => s.axis === 0);
1226
+ const valueFmt = chart.getColumnFormatter(
1227
+ primarySeries?.aggName ?? null,
1228
+ "tick",
1229
+ );
1230
+ const catFmt = chart.getColumnFormatter(chart._groupBy[0], "tick");
1231
+
1232
+ for (const cell of grid.cells) {
1233
+ const layout = cell.layout;
1234
+ const plot = layout.plotRect;
1235
+ const ctx = getScaledContext(canvas, dpr);
1236
+ if (!ctx) {
1237
+ continue;
1238
+ }
1239
+
1240
+ ctx.strokeStyle = theme.axisLineColor;
1241
+ ctx.lineWidth = 1;
1242
+ ctx.beginPath();
1243
+ ctx.moveTo(plot.x, plot.y);
1244
+ ctx.lineTo(plot.x, plot.y + plot.height);
1245
+ ctx.lineTo(plot.x + plot.width, plot.y + plot.height);
1246
+ ctx.stroke();
1247
+
1248
+ // Categorical sides paint per cell (the outer band painters
1249
+ // are numeric-only); numeric sides are painted once below
1250
+ // into the shared outer bands.
1251
+ if (!horizontal) {
1252
+ if (!numericCat && catDomain) {
1253
+ renderCategoricalXTicks(ctx, layout, catDomain, theme);
1254
+ }
1255
+
1256
+ if (valueCatActive) {
1257
+ renderCategoricalYTicks(ctx, layout, valueCatDomain!, theme);
1258
+ }
1259
+ } else {
1260
+ if (!numericCat && catDomain) {
1261
+ renderCategoricalYTicks(ctx, layout, catDomain, theme);
1262
+ }
1263
+
1264
+ if (valueCatActive) {
1265
+ renderCategoricalXTicks(ctx, layout, valueCatDomain!, theme);
1266
+ }
1267
+ }
1268
+
1269
+ if (cell.titleRect) {
1270
+ drawFacetTitle(canvas, cell.label, cell.titleRect, theme, dpr);
1271
+ }
1272
+ }
1273
+
1274
+ const numericCatAxisDomain: AxisDomain | null = numericCat
1275
+ ? {
1276
+ min: chart._numericCategoryDomain!.min,
1277
+ max: chart._numericCategoryDomain!.max,
1278
+ isDate: chart._numericCategoryDomain!.isDate,
1279
+ label: chart._numericCategoryDomain!.label,
1280
+ }
1281
+ : null;
1282
+
1283
+ if (!horizontal) {
1284
+ if (numericCatAxisDomain && grid.outerXAxisRect) {
1285
+ renderOuterXAxis(
1286
+ canvas,
1287
+ grid.outerXAxisRect,
1288
+ numericCatAxisDomain,
1289
+ chart._lastCatTicks!,
1290
+ bottomRowLayouts(grid),
1291
+ theme,
1292
+ hasCatLabel,
1293
+ dpr,
1294
+ catFmt,
1295
+ );
1296
+ }
1297
+
1298
+ if (!valueCatActive && grid.outerYAxisRect) {
1299
+ renderOuterYAxis(
1300
+ canvas,
1301
+ grid.outerYAxisRect,
1302
+ valueDomain,
1303
+ valueTicks,
1304
+ leftColumnLayouts(grid),
1305
+ theme,
1306
+ true,
1307
+ dpr,
1308
+ valueFmt,
1309
+ );
1310
+ }
1311
+ } else {
1312
+ if (numericCatAxisDomain && grid.outerYAxisRect) {
1313
+ renderOuterYAxis(
1314
+ canvas,
1315
+ grid.outerYAxisRect,
1316
+ numericCatAxisDomain,
1317
+ chart._lastCatTicks!,
1318
+ leftColumnLayouts(grid),
1319
+ theme,
1320
+ hasCatLabel,
1321
+ dpr,
1322
+ catFmt,
1323
+ );
1324
+ }
1325
+
1326
+ if (!valueCatActive && grid.outerXAxisRect) {
1327
+ renderOuterXAxis(
1328
+ canvas,
1329
+ grid.outerXAxisRect,
1330
+ valueDomain,
1331
+ valueTicks,
1332
+ bottomRowLayouts(grid),
1333
+ theme,
1334
+ true,
1335
+ dpr,
1336
+ valueFmt,
1337
+ );
1338
+ }
1339
+ }
1340
+
1341
+ renderFacetedBarLegend(chart, grid);
1342
+
1343
+ if (getHoveredBar(chart)) {
1344
+ renderBarTooltipCanvas(chart);
1345
+ }
1346
+ }
1347
+
1348
+ /**
1349
+ * Aggregate-level legend for the faceted frame, painted into the
1350
+ * grid's shared right gutter. One entry per aggregate (facets absorb
1351
+ * the split dimension; every split of an aggregate shares its color —
1352
+ * see `ensurePalette`). A legend toggle targets the aggregate's full
1353
+ * seriesId set, so hiding "Sales" hides it in every facet at once; an
1354
+ * entry reads as hidden only when ALL of its series are hidden.
1355
+ */
1356
+ function renderFacetedBarLegend(chart: SeriesChart, grid: FacetGrid): void {
1357
+ chart._legendRects = [];
1358
+ if (!chart._chromeCanvas || !grid.legendRect) {
1359
+ return;
1360
+ }
1361
+
1362
+ const M = chart._aggregates.length;
1363
+ if (M <= 1) {
1364
+ return;
1365
+ }
1366
+
1367
+ const ctx = chart._chromeCanvas.getContext("2d") as Context2D | null;
1368
+ if (!ctx) {
1369
+ return;
1370
+ }
1371
+
1372
+ ctx.save();
1373
+
1374
+ const theme = chart._resolveTheme();
1375
+ const swatchSize = 10;
1376
+ const lineHeight = 18;
1377
+ const x = grid.legendRect.x + 12;
1378
+ let y = grid.legendRect.y + 10;
1379
+
1380
+ ctx.font = `11px ${theme.fontFamily}`;
1381
+ ctx.textAlign = "left";
1382
+ ctx.textBaseline = "middle";
1383
+
1384
+ for (let k = 0; k < M; k++) {
1385
+ const seriesIds: number[] = [];
1386
+ let color: [number, number, number] = [0.5, 0.5, 0.5];
1387
+ for (const s of chart._series) {
1388
+ if (s.aggIdx === k) {
1389
+ seriesIds.push(s.seriesId);
1390
+ color = s.color;
1391
+ }
1392
+ }
1393
+
1394
+ const label = chart._aggregates[k];
1395
+ const hidden = seriesIds.every((sid) => chart._hiddenSeries.has(sid));
1396
+ const r = Math.round(color[0] * 255);
1397
+ const g = Math.round(color[1] * 255);
1398
+ const b = Math.round(color[2] * 255);
1399
+
1400
+ ctx.globalAlpha = hidden ? 0.3 : 1.0;
1401
+ ctx.fillStyle = `rgb(${r},${g},${b})`;
1402
+ ctx.fillRect(x, y - swatchSize / 2, swatchSize, swatchSize);
1403
+
1404
+ ctx.fillStyle = theme.legendText;
1405
+ ctx.fillText(label, x + swatchSize + 6, y);
1406
+
1407
+ const textW = ctx.measureText(label).width;
1408
+ if (hidden) {
1409
+ ctx.strokeStyle = theme.legendText;
1410
+ ctx.lineWidth = 1;
1411
+ ctx.beginPath();
1412
+ ctx.moveTo(x + swatchSize + 6, y);
1413
+ ctx.lineTo(x + swatchSize + 6 + textW, y);
1414
+ ctx.stroke();
1415
+ }
1416
+
1417
+ ctx.globalAlpha = 1.0;
1418
+
1419
+ chart._legendRects.push({
1420
+ seriesIds,
1421
+ rect: {
1422
+ x: x - 2,
1423
+ y: y - lineHeight / 2,
1424
+ width: swatchSize + 6 + textW + 4,
1425
+ height: lineHeight,
1426
+ },
1427
+ });
1428
+
1429
+ y += lineHeight;
1430
+ }
1431
+
1432
+ ctx.restore();
1433
+ }
1434
+
711
1435
  /**
712
1436
  * Cached parallel array of measured legend text widths. The legend
713
1437
  * renderer reads from this each frame instead of re-running
@@ -807,7 +1531,7 @@ function renderBarLegend(chart: SeriesChart): void {
807
1531
  width: swatchSize + 6 + textW + 4,
808
1532
  height: lineHeight,
809
1533
  };
810
- chart._legendRects.push({ seriesId: s.seriesId, rect });
1534
+ chart._legendRects.push({ seriesIds: [s.seriesId], rect });
811
1535
 
812
1536
  y += lineHeight;
813
1537
  }
@@ -825,7 +1549,12 @@ function renderBarTooltipCanvas(chart: SeriesChart): void {
825
1549
  return;
826
1550
  }
827
1551
 
828
- const layout = chart._lastLayout;
1552
+ // Faceted frames anchor in the record's own cell; single-plot
1553
+ // frames fall through to `_lastLayout`.
1554
+ const layout = layoutForRecord(chart, b);
1555
+ if (!layout) {
1556
+ return;
1557
+ }
829
1558
 
830
1559
  // Bar glyphs anchor the tooltip at the midpoint of the bar body so
831
1560
  // it reads against a solid swatch. Line / scatter / area glyphs
@@ -842,7 +1571,7 @@ function renderBarTooltipCanvas(chart: SeriesChart): void {
842
1571
  ? chart._isHorizontal
843
1572
  ? layout.dataToPixel(anchorV, b.xCenter)
844
1573
  : layout.dataToPixel(b.xCenter, anchorV)
845
- : rightAxisDataToPixel(chart, b.xCenter, anchorV);
1574
+ : rightAxisDataToPixel(chart, b.xCenter, anchorV, layout);
846
1575
 
847
1576
  const lines = buildBarTooltipLines(chart, b);
848
1577
  const theme = chart._resolveTheme();
@@ -860,8 +1589,9 @@ export function rightAxisDataToPixel(
860
1589
  chart: SeriesChart,
861
1590
  x: number,
862
1591
  y: number,
1592
+ layoutOverride?: PlotLayout,
863
1593
  ): { px: number; py: number } {
864
- const layout = chart._lastLayout!;
1594
+ const layout = layoutOverride ?? chart._lastLayout!;
865
1595
  const { x: px, y: py, width, height } = layout.plotRect;
866
1596
  const tx =
867
1597
  (x - layout.paddedXMin) / (layout.paddedXMax - layout.paddedXMin);