@perspective-dev/viewer-charts 4.5.1 → 5.0.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 (70) 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 +3 -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 +3 -2
  9. package/dist/esm/charts/series/glyphs/draw-scatter.d.ts +5 -2
  10. package/dist/esm/charts/series/series-build.d.ts +11 -0
  11. package/dist/esm/charts/series/series-interact.d.ts +10 -0
  12. package/dist/esm/charts/series/series-render.d.ts +17 -1
  13. package/dist/esm/charts/series/series.d.ts +53 -10
  14. package/dist/esm/config.d.ts +19 -0
  15. package/dist/esm/data/view-reader.d.ts +10 -1
  16. package/dist/esm/event-detail.d.ts +7 -0
  17. package/dist/esm/layout/ticks.d.ts +5 -0
  18. package/dist/esm/perspective-viewer-charts.js +2 -2
  19. package/dist/esm/perspective-viewer-charts.js.map +3 -3
  20. package/dist/esm/plugin/charts.d.ts +6 -3
  21. package/dist/esm/plugin/plugin.d.ts +46 -4
  22. package/dist/esm/render/scheduler.d.ts +34 -8
  23. package/dist/esm/transport/protocol.d.ts +31 -2
  24. package/dist/esm/transport/renderer-transport.d.ts +74 -3
  25. package/dist/esm/webgl/context-manager.d.ts +100 -117
  26. package/dist/esm/webgl/context-pool.d.ts +40 -0
  27. package/dist/esm/webgl/gl-context.d.ts +54 -0
  28. package/dist/esm/webgl/instanced-attrs.d.ts +7 -1
  29. package/dist/esm/worker/renderer.worker.d.ts +12 -0
  30. package/package.json +1 -1
  31. package/src/ts/charts/candlestick/candlestick-render.ts +4 -1
  32. package/src/ts/charts/candlestick/candlestick.ts +12 -17
  33. package/src/ts/charts/cartesian/cartesian-render.ts +80 -63
  34. package/src/ts/charts/chart-base.ts +85 -5
  35. package/src/ts/charts/chart.ts +47 -9
  36. package/src/ts/charts/common/category-axis-resolver.ts +5 -1
  37. package/src/ts/charts/common/tree-data.ts +61 -16
  38. package/src/ts/charts/heatmap/heatmap-render.ts +6 -2
  39. package/src/ts/charts/series/glyphs/draw-areas.ts +10 -1
  40. package/src/ts/charts/series/glyphs/draw-bars.ts +30 -9
  41. package/src/ts/charts/series/glyphs/draw-lines.ts +10 -1
  42. package/src/ts/charts/series/glyphs/draw-scatter.ts +56 -3
  43. package/src/ts/charts/series/series-build.ts +21 -3
  44. package/src/ts/charts/series/series-interact.ts +86 -13
  45. package/src/ts/charts/series/series-render.ts +660 -17
  46. package/src/ts/charts/series/series.ts +96 -26
  47. package/src/ts/charts/sunburst/sunburst-interact.ts +2 -3
  48. package/src/ts/charts/sunburst/sunburst-render.ts +3 -1
  49. package/src/ts/charts/treemap/treemap-interact.ts +2 -5
  50. package/src/ts/charts/treemap/treemap-render.ts +3 -1
  51. package/src/ts/charts/treemap/treemap.ts +6 -1
  52. package/src/ts/config.ts +21 -1
  53. package/src/ts/data/view-reader.ts +22 -2
  54. package/src/ts/event-detail.ts +8 -0
  55. package/src/ts/index.ts +13 -7
  56. package/src/ts/interaction/raw-event-forwarder.ts +7 -0
  57. package/src/ts/interaction/zoom-controller.ts +8 -0
  58. package/src/ts/layout/ticks.ts +9 -0
  59. package/src/ts/plugin/charts.ts +20 -4
  60. package/src/ts/plugin/plugin.ts +157 -23
  61. package/src/ts/render/scheduler.ts +164 -55
  62. package/src/ts/transport/protocol.ts +33 -0
  63. package/src/ts/transport/renderer-transport.ts +185 -9
  64. package/src/ts/webgl/context-manager.ts +195 -189
  65. package/src/ts/webgl/context-pool.ts +78 -0
  66. package/src/ts/webgl/gl-context.ts +191 -0
  67. package/src/ts/webgl/instanced-attrs.ts +15 -1
  68. package/src/ts/worker/dispatch.ts +4 -1
  69. package/src/ts/worker/renderer.worker.ts +94 -22
  70. package/src/ts/worker/session-host.ts +29 -0
@@ -19,12 +19,20 @@ import {
19
19
  } from "./series";
20
20
  import type { PlotRect } from "../../layout/plot-layout";
21
21
  import { PlotLayout } from "../../layout/plot-layout";
22
- import { renderInPlotFrame } from "../../webgl/plot-frame";
22
+ import {
23
+ clearAndSetupFrame,
24
+ renderInPlotFrame,
25
+ withScissor,
26
+ } from "../../webgl/plot-frame";
23
27
  import { renderCanvasTooltip } from "../../interaction/tooltip-controller";
24
28
  import { drawBars, BAR_TYPE_BAR_VAL as BAR_TYPE_BAR } from "./glyphs/draw-bars";
25
29
  import { getHoveredBar } from "./series-interact";
26
30
  import { computeNiceTicks } from "../../layout/ticks";
27
- import { type AxisDomain } from "../../axis/numeric-axis";
31
+ import {
32
+ renderOuterXAxis,
33
+ renderOuterYAxis,
34
+ type AxisDomain,
35
+ } from "../../axis/numeric-axis";
28
36
  import {
29
37
  renderBarAxesChrome,
30
38
  renderBarGridlines,
@@ -34,8 +42,19 @@ import {
34
42
  import {
35
43
  measureCategoricalAxisHeight,
36
44
  measureCategoricalAxisWidth,
45
+ renderCategoricalXTicks,
46
+ renderCategoricalYTicks,
37
47
  type CategoricalDomain,
38
48
  } from "../../axis/categorical-axis";
49
+ import {
50
+ bottomRowLayouts,
51
+ buildFacetGrid,
52
+ leftColumnLayouts,
53
+ type FacetGrid,
54
+ } from "../../layout/facet-grid";
55
+ import { drawFacetTitle } from "../../axis/facet-chrome";
56
+ import { getScaledContext, initCanvas } from "../../axis/canvas";
57
+ import { drawGridlinesX, drawGridlinesY } from "../../axis/axis-primitives";
39
58
  import { buildBarTooltipLines } from "./series-interact";
40
59
 
41
60
  /**
@@ -77,6 +96,12 @@ function ensureBarInstanceScratch(n: number): BarInstanceScratch {
77
96
 
78
97
  /**
79
98
  * Upload bar instance buffers from the columnar `_bars` storage.
99
+ *
100
+ * Overlay mode emits instances in `_bars` order. Facet-grid mode
101
+ * (`_facetActive`) emits them split-major via a counting sort so each
102
+ * facet's instances form a contiguous range — recorded on
103
+ * `chart._facetBarRanges` and drawn per facet with an instance-offset
104
+ * `drawBars` call.
80
105
  */
81
106
  export function uploadBarInstances(
82
107
  chart: SeriesChart,
@@ -84,8 +109,11 @@ export function uploadBarInstances(
84
109
  ): void {
85
110
  const bars = chart._bars;
86
111
  const total = bars.count;
112
+ const P = chart._splitPrefixes.length;
113
+ const faceted = chart._facetActive && P > 0;
87
114
  let n = 0;
88
115
 
116
+ chart._facetBarRanges = null;
89
117
  if (total > 0) {
90
118
  const scratch = ensureBarInstanceScratch(total);
91
119
  if (
@@ -108,6 +136,34 @@ export function uploadBarInstances(
108
136
  const by0 = bars.y0;
109
137
  const by1 = bars.y1;
110
138
  const ax = bars.axis;
139
+
140
+ // Facet mode: counting sort by splitIdx (`seriesId % P`). Pass
141
+ // 1 counts eligible instances per split; prefix sums become the
142
+ // per-split write cursors AND the published ranges.
143
+ let writeAt: ((seriesId: number) => number) | null = null;
144
+ if (faceted) {
145
+ const counts = new Array<number>(P).fill(0);
146
+ for (let i = 0; i < total; i++) {
147
+ if (ct[i] !== BAR_TYPE_BAR || hidden.has(sid[i])) {
148
+ continue;
149
+ }
150
+
151
+ counts[sid[i] % P]++;
152
+ }
153
+
154
+ const ranges: { start: number; count: number }[] = [];
155
+ const cursors = new Int32Array(P);
156
+ let acc = 0;
157
+ for (let p = 0; p < P; p++) {
158
+ ranges.push({ start: acc, count: counts[p] });
159
+ cursors[p] = acc;
160
+ acc += counts[p];
161
+ }
162
+
163
+ chart._facetBarRanges = ranges;
164
+ writeAt = (seriesId: number) => cursors[seriesId % P]++;
165
+ }
166
+
111
167
  for (let i = 0; i < total; i++) {
112
168
  if (ct[i] !== BAR_TYPE_BAR) {
113
169
  continue;
@@ -118,19 +174,25 @@ export function uploadBarInstances(
118
174
  continue;
119
175
  }
120
176
 
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];
177
+ const w = writeAt ? writeAt(seriesId) : n;
178
+ scratch.xCenters[w] = xC[i] - xOrigin;
179
+ scratch.halfWidths[w] = hw[i];
180
+ scratch.y0s[w] = by0[i];
181
+ scratch.y1s[w] = by1[i];
182
+ scratch.seriesIds[w] = seriesId;
183
+ scratch.axes[w] = ax[i];
127
184
  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;
185
+ scratch.colors[w * 3] = color[0];
186
+ scratch.colors[w * 3 + 1] = color[1];
187
+ scratch.colors[w * 3 + 2] = color[2];
188
+ indices[w] = i;
132
189
  n++;
133
190
  }
191
+ } else if (faceted) {
192
+ chart._facetBarRanges = Array.from({ length: P }, () => ({
193
+ start: 0,
194
+ count: 0,
195
+ }));
134
196
  }
135
197
 
136
198
  chart._uploadedBars = n;
@@ -386,6 +448,28 @@ export function renderBarFrame(
386
448
  }
387
449
  }
388
450
 
451
+ // Facet-grid branch — one sub-plot per split group. Keys off the
452
+ // build-time `_facetActive` stamp (NOT the live `_facetConfig`) so
453
+ // this frame's render mode always matches the stack geometry the
454
+ // current `_bars` were built with.
455
+ if (chart._facetActive && chart._splitPrefixes.length > 1) {
456
+ renderFacetedBarFrame(chart, glManager, {
457
+ horizontal,
458
+ numericCat,
459
+ cssWidth,
460
+ cssHeight,
461
+ visCatMin,
462
+ visCatMax,
463
+ visValMin,
464
+ visValMax,
465
+ visRightMin,
466
+ visRightMax,
467
+ });
468
+ return;
469
+ }
470
+
471
+ chart._facetGrid = null;
472
+
389
473
  const hasLegend = chart._series.length > 1;
390
474
  const hasCatLabel = chart._groupBy.length > 0;
391
475
 
@@ -604,7 +688,313 @@ export function renderBarFrame(
604
688
  chart._lastCatTicks = numericCat
605
689
  ? computeNiceTicks(visCatMin, visCatMax, 6)
606
690
  : null;
607
- renderBarChromeOverlay(chart);
691
+ // Deferred past the GPU fence (see `_defer2D`) so the chrome canvas
692
+ // doesn't present ahead of the GL glyphs on resize. Reads the
693
+ // `_last*` frame state set above.
694
+ chart._defer2D(() => renderBarChromeOverlay(chart));
695
+ }
696
+
697
+ /**
698
+ * Domain window computed by `renderBarFrame`'s shared prologue (zoom
699
+ * window + auto-fit + `include_zero`), handed to the faceted branch.
700
+ */
701
+ interface FacetedFrameCtx {
702
+ horizontal: boolean;
703
+ numericCat: boolean;
704
+ cssWidth: number;
705
+ cssHeight: number;
706
+ visCatMin: number;
707
+ visCatMax: number;
708
+ visValMin: number;
709
+ visValMax: number;
710
+ visRightMin: number;
711
+ visRightMax: number;
712
+ }
713
+
714
+ /**
715
+ * Faceted frame — one sub-plot per split group, laid out by
716
+ * `buildFacetGrid`. Geometry contract with the build pipeline
717
+ * (`facetSplits`): every split's records share the same band-slot
718
+ * coordinates and per-split stack baselines, so a facet is exactly
719
+ * "the single-plot render restricted to one split" — same domains,
720
+ * same projection math, different `PlotLayout` + instance subset.
721
+ *
722
+ * Axis policy: the value axis is shared (one outer band, one domain
723
+ * for every facet); the category axis paints per-cell when
724
+ * categorical (the outer painters are numeric-only — same compromise
725
+ * as the cartesian faceted path) and shared-outer when numeric. Zoom
726
+ * is shared: `syncFacetZoomLayouts` keeps the single controller's
727
+ * layout on cell 0, and every facet renders the same visible window.
728
+ *
729
+ * Known limitation: the grid reserves no outer band for a secondary
730
+ * value axis, so dual-axis series project correctly inside each facet
731
+ * but the right-axis chrome is suppressed (`_lastAltYDomain = null`).
732
+ */
733
+ function renderFacetedBarFrame(
734
+ chart: SeriesChart,
735
+ glManager: WebGLContextManager,
736
+ ctx: FacetedFrameCtx,
737
+ ): void {
738
+ const gl = glManager.gl;
739
+ const dpr = glManager.dpr;
740
+ const theme = chart._resolveTheme();
741
+ const {
742
+ horizontal,
743
+ numericCat,
744
+ cssWidth,
745
+ cssHeight,
746
+ visCatMin,
747
+ visCatMax,
748
+ visValMin,
749
+ visValMax,
750
+ visRightMin,
751
+ visRightMax,
752
+ } = ctx;
753
+
754
+ const hasCatLabel = chart._groupBy.length > 0;
755
+ const valueCatDomain = chart._leftValueCategoryDomain;
756
+ const valueCatActive =
757
+ chart._leftValueAxisMode === "category" &&
758
+ valueCatDomain !== null &&
759
+ valueCatDomain.numRows > 0;
760
+
761
+ // Facets absorb the split dimension and color follows the
762
+ // aggregate, so only multiple aggregates warrant a legend.
763
+ const hasLegend = chart._aggregates.length > 1;
764
+
765
+ // `buildFacetGrid` axis modes are named in CANVAS orientation
766
+ // (x = bottom band, y = left band); map the logical category /
767
+ // value sides onto them per chart orientation.
768
+ const catAxisMode = numericCat ? ("outer" as const) : ("cell" as const);
769
+ const valAxisMode = valueCatActive ? ("cell" as const) : ("outer" as const);
770
+
771
+ const grid: FacetGrid = buildFacetGrid(chart._splitPrefixes, {
772
+ cssWidth,
773
+ cssHeight,
774
+ xAxis: horizontal ? valAxisMode : catAxisMode,
775
+ yAxis: horizontal ? catAxisMode : valAxisMode,
776
+ hasLegend,
777
+ hasXLabel: horizontal ? true : hasCatLabel,
778
+ hasYLabel: horizontal ? hasCatLabel : true,
779
+ gap: chart._facetConfig.facet_padding,
780
+ });
781
+ chart._facetGrid = grid;
782
+ chart._lastLayout = grid.cells[0]?.layout ?? null;
783
+ if (grid.cells.length === 0 || !chart._lastLayout) {
784
+ return;
785
+ }
786
+
787
+ chart.syncFacetZoomLayouts(grid.cells);
788
+
789
+ const leftValueTicks = computeNiceTicks(visValMin, visValMax, 6);
790
+ const catTicks = numericCat
791
+ ? computeNiceTicks(visCatMin, visCatMax, 6)
792
+ : null;
793
+
794
+ const sampleLayout = grid.cells[0].layout;
795
+ const gridlineCanvas = chart._gridlineCanvas;
796
+ if (gridlineCanvas) {
797
+ // Deferred FIRST so the destructive resize/clear runs before
798
+ // the per-cell gridline closures below (FIFO flush order).
799
+ chart._defer2D(() => initCanvas(gridlineCanvas, sampleLayout, dpr));
800
+ }
801
+
802
+ const requireZero = chart._pluginConfig.include_zero;
803
+ const hovered = chart._series.length > 1 ? getHoveredBar(chart) : null;
804
+ const hasRight =
805
+ chart._hasRightAxis && chart._rightDomain !== null && !horizontal;
806
+
807
+ clearAndSetupFrame(gl);
808
+ for (let p = 0; p < grid.cells.length; p++) {
809
+ const cell = grid.cells[p];
810
+ const layout = cell.layout;
811
+
812
+ // Same projection args as the single-plot path, on the cell's
813
+ // layout. Seeds the cell's padded domain, which the deferred
814
+ // gridline closure, chrome overlay, and hover hit-test read.
815
+ const projLeft = horizontal
816
+ ? layout.buildProjectionMatrix(
817
+ visValMin,
818
+ visValMax,
819
+ visCatMax,
820
+ visCatMin,
821
+ "x",
822
+ requireZero,
823
+ undefined,
824
+ 0,
825
+ chart._categoryOrigin,
826
+ )
827
+ : layout.buildProjectionMatrix(
828
+ visCatMin,
829
+ visCatMax,
830
+ visValMin,
831
+ visValMax,
832
+ "y",
833
+ requireZero,
834
+ undefined,
835
+ chart._categoryOrigin,
836
+ 0,
837
+ );
838
+
839
+ let projRight = projLeft;
840
+ if (hasRight) {
841
+ const savedPadXMin = layout.paddedXMin;
842
+ const savedPadXMax = layout.paddedXMax;
843
+ const savedPadYMin = layout.paddedYMin;
844
+ const savedPadYMax = layout.paddedYMax;
845
+ projRight = layout.buildProjectionMatrix(
846
+ visCatMin,
847
+ visCatMax,
848
+ visRightMin,
849
+ visRightMax,
850
+ "y",
851
+ requireZero,
852
+ undefined,
853
+ chart._categoryOrigin,
854
+ 0,
855
+ );
856
+ layout.paddedXMin = savedPadXMin;
857
+ layout.paddedXMax = savedPadXMax;
858
+ layout.paddedYMin = savedPadYMin;
859
+ layout.paddedYMax = savedPadYMax;
860
+ }
861
+
862
+ if (gridlineCanvas) {
863
+ chart._defer2D(() =>
864
+ renderBarGridlinesCell(
865
+ gridlineCanvas,
866
+ layout,
867
+ leftValueTicks,
868
+ theme,
869
+ dpr,
870
+ horizontal,
871
+ ),
872
+ );
873
+ }
874
+
875
+ withScissor(gl, layout, dpr, () => {
876
+ // Same paint order as the single-plot path: areas behind
877
+ // bars, lines above, scatter on top. X Bar draws bars only
878
+ // (the other glyphs bake vertical geometry).
879
+ if (!horizontal) {
880
+ chart._glyphs.areas.draw(
881
+ chart,
882
+ gl,
883
+ glManager,
884
+ projLeft,
885
+ projRight,
886
+ theme.areaOpacity,
887
+ p,
888
+ );
889
+ }
890
+
891
+ gl.useProgram(chart._program!);
892
+ const loc = chart._locations!;
893
+ gl.uniformMatrix4fv(loc.u_proj_left, false, projLeft);
894
+ gl.uniformMatrix4fv(loc.u_proj_right, false, projRight);
895
+ gl.uniform1f(loc.u_horizontal, horizontal ? 1.0 : 0.0);
896
+ gl.uniform1f(
897
+ loc.u_hover_series,
898
+ hovered && hovered.splitIdx === p ? hovered.seriesId : -1,
899
+ );
900
+ drawBars(
901
+ chart,
902
+ gl,
903
+ glManager,
904
+ chart._facetBarRanges?.[p] ?? { start: 0, count: 0 },
905
+ );
906
+
907
+ if (!horizontal) {
908
+ chart._glyphs.lines.draw(
909
+ chart,
910
+ gl,
911
+ glManager,
912
+ projLeft,
913
+ projRight,
914
+ p,
915
+ );
916
+ chart._glyphs.scatter.draw(
917
+ chart,
918
+ gl,
919
+ glManager,
920
+ projLeft,
921
+ projRight,
922
+ p,
923
+ );
924
+ }
925
+ });
926
+ }
927
+
928
+ chart._lastXDomain = {
929
+ levels: chart._rowPaths,
930
+ numRows: chart._numCategories,
931
+ levelLabels: chart._groupBy.slice(),
932
+ };
933
+ chart._lastYDomain = {
934
+ min: visValMin,
935
+ max: visValMax,
936
+ label: chart._primaryValueLabel,
937
+ };
938
+ chart._lastYTicks = leftValueTicks;
939
+ chart._lastAltYDomain = null;
940
+ chart._lastAltYTicks = null;
941
+ chart._lastCatTicks = catTicks;
942
+ chart._defer2D(() => renderBarChromeOverlay(chart));
943
+ }
944
+
945
+ /**
946
+ * Per-cell value-axis gridlines for the faceted frame. Non-destructive
947
+ * counterpart to {@link renderBarGridlines} — the shared gridline
948
+ * canvas is `initCanvas`'d once per frame, then each facet paints into
949
+ * it via `getScaledContext`.
950
+ */
951
+ function renderBarGridlinesCell(
952
+ canvas: NonNullable<SeriesChart["_gridlineCanvas"]>,
953
+ layout: PlotLayout,
954
+ valueTicks: number[],
955
+ theme: ReturnType<SeriesChart["_resolveTheme"]>,
956
+ dpr: number,
957
+ horizontal: boolean,
958
+ ): void {
959
+ const ctx = getScaledContext(canvas, dpr);
960
+ if (!ctx) {
961
+ return;
962
+ }
963
+
964
+ ctx.strokeStyle = theme.gridlineColor;
965
+ ctx.lineWidth = 1;
966
+ if (horizontal) {
967
+ drawGridlinesX(
968
+ ctx,
969
+ layout.plotRect,
970
+ valueTicks,
971
+ (v) => layout.dataToPixel(v, 0).px,
972
+ );
973
+ } else {
974
+ drawGridlinesY(
975
+ ctx,
976
+ layout.plotRect,
977
+ valueTicks,
978
+ (v) => layout.dataToPixel(0, v).py,
979
+ );
980
+ }
981
+ }
982
+
983
+ /**
984
+ * Resolve the `PlotLayout` a record renders in: the record's facet
985
+ * cell in faceted frames, the single plot layout otherwise. Tooltip /
986
+ * pin positioning must map through this so faceted anchors land in the
987
+ * record's own cell.
988
+ */
989
+ export function layoutForRecord(
990
+ chart: SeriesChart,
991
+ b: { splitIdx: number },
992
+ ): PlotLayout | null {
993
+ if (chart._facetGrid) {
994
+ return chart._facetGrid.cells[b.splitIdx]?.layout ?? null;
995
+ }
996
+
997
+ return chart._lastLayout;
608
998
  }
609
999
 
610
1000
  /**
@@ -620,6 +1010,11 @@ export function renderBarChromeOverlay(chart: SeriesChart): void {
620
1010
  return;
621
1011
  }
622
1012
 
1013
+ if (chart._facetGrid) {
1014
+ renderFacetedBarChromeOverlay(chart);
1015
+ return;
1016
+ }
1017
+
623
1018
  const theme = chart._resolveTheme();
624
1019
  let catAxis: BarCategoryAxis;
625
1020
  if (
@@ -708,6 +1103,248 @@ export function renderBarChromeOverlay(chart: SeriesChart): void {
708
1103
  }
709
1104
  }
710
1105
 
1106
+ /**
1107
+ * Chrome overlay for the faceted frame. The canvas is `initCanvas`'d
1108
+ * once, then every painter goes through `getScaledContext` — per-cell
1109
+ * axis frames, per-cell categorical ticks, shared outer numeric
1110
+ * bands, facet titles, the aggregate legend, and the tooltip (mapped
1111
+ * through the hovered record's own cell).
1112
+ */
1113
+ function renderFacetedBarChromeOverlay(chart: SeriesChart): void {
1114
+ const grid = chart._facetGrid!;
1115
+ const canvas = chart._chromeCanvas!;
1116
+ const theme = chart._resolveTheme();
1117
+ const dpr = chart._glManager?.dpr ?? 1;
1118
+ const horizontal = chart._isHorizontal;
1119
+ const hasCatLabel = chart._groupBy.length > 0;
1120
+ const numericCat =
1121
+ chart._categoryAxisMode === "numeric" &&
1122
+ chart._numericCategoryDomain !== null &&
1123
+ chart._lastCatTicks !== null;
1124
+
1125
+ if (!initCanvas(canvas, chart._lastLayout!, dpr)) {
1126
+ return;
1127
+ }
1128
+
1129
+ const catDomain = chart._lastXDomain;
1130
+ const valueDomain = chart._lastYDomain!;
1131
+ const valueTicks = chart._lastYTicks!;
1132
+ const valueCatDomain = chart._leftValueCategoryDomain;
1133
+ const valueCatActive =
1134
+ chart._leftValueAxisMode === "category" &&
1135
+ valueCatDomain !== null &&
1136
+ valueCatDomain.numRows > 0;
1137
+
1138
+ const primarySeries = chart._series.find((s) => s.axis === 0);
1139
+ const valueFmt = chart.getColumnFormatter(
1140
+ primarySeries?.aggName ?? null,
1141
+ "tick",
1142
+ );
1143
+ const catFmt = chart.getColumnFormatter(chart._groupBy[0], "tick");
1144
+
1145
+ for (const cell of grid.cells) {
1146
+ const layout = cell.layout;
1147
+ const plot = layout.plotRect;
1148
+ const ctx = getScaledContext(canvas, dpr);
1149
+ if (!ctx) {
1150
+ continue;
1151
+ }
1152
+
1153
+ ctx.strokeStyle = theme.axisLineColor;
1154
+ ctx.lineWidth = 1;
1155
+ ctx.beginPath();
1156
+ ctx.moveTo(plot.x, plot.y);
1157
+ ctx.lineTo(plot.x, plot.y + plot.height);
1158
+ ctx.lineTo(plot.x + plot.width, plot.y + plot.height);
1159
+ ctx.stroke();
1160
+
1161
+ // Categorical sides paint per cell (the outer band painters
1162
+ // are numeric-only); numeric sides are painted once below
1163
+ // into the shared outer bands.
1164
+ if (!horizontal) {
1165
+ if (!numericCat && catDomain) {
1166
+ renderCategoricalXTicks(ctx, layout, catDomain, theme);
1167
+ }
1168
+
1169
+ if (valueCatActive) {
1170
+ renderCategoricalYTicks(ctx, layout, valueCatDomain!, theme);
1171
+ }
1172
+ } else {
1173
+ if (!numericCat && catDomain) {
1174
+ renderCategoricalYTicks(ctx, layout, catDomain, theme);
1175
+ }
1176
+
1177
+ if (valueCatActive) {
1178
+ renderCategoricalXTicks(ctx, layout, valueCatDomain!, theme);
1179
+ }
1180
+ }
1181
+
1182
+ if (cell.titleRect) {
1183
+ drawFacetTitle(canvas, cell.label, cell.titleRect, theme, dpr);
1184
+ }
1185
+ }
1186
+
1187
+ const numericCatAxisDomain: AxisDomain | null = numericCat
1188
+ ? {
1189
+ min: chart._numericCategoryDomain!.min,
1190
+ max: chart._numericCategoryDomain!.max,
1191
+ isDate: chart._numericCategoryDomain!.isDate,
1192
+ label: chart._numericCategoryDomain!.label,
1193
+ }
1194
+ : null;
1195
+
1196
+ if (!horizontal) {
1197
+ if (numericCatAxisDomain && grid.outerXAxisRect) {
1198
+ renderOuterXAxis(
1199
+ canvas,
1200
+ grid.outerXAxisRect,
1201
+ numericCatAxisDomain,
1202
+ chart._lastCatTicks!,
1203
+ bottomRowLayouts(grid),
1204
+ theme,
1205
+ hasCatLabel,
1206
+ dpr,
1207
+ catFmt,
1208
+ );
1209
+ }
1210
+
1211
+ if (!valueCatActive && grid.outerYAxisRect) {
1212
+ renderOuterYAxis(
1213
+ canvas,
1214
+ grid.outerYAxisRect,
1215
+ valueDomain,
1216
+ valueTicks,
1217
+ leftColumnLayouts(grid),
1218
+ theme,
1219
+ true,
1220
+ dpr,
1221
+ valueFmt,
1222
+ );
1223
+ }
1224
+ } else {
1225
+ if (numericCatAxisDomain && grid.outerYAxisRect) {
1226
+ renderOuterYAxis(
1227
+ canvas,
1228
+ grid.outerYAxisRect,
1229
+ numericCatAxisDomain,
1230
+ chart._lastCatTicks!,
1231
+ leftColumnLayouts(grid),
1232
+ theme,
1233
+ hasCatLabel,
1234
+ dpr,
1235
+ catFmt,
1236
+ );
1237
+ }
1238
+
1239
+ if (!valueCatActive && grid.outerXAxisRect) {
1240
+ renderOuterXAxis(
1241
+ canvas,
1242
+ grid.outerXAxisRect,
1243
+ valueDomain,
1244
+ valueTicks,
1245
+ bottomRowLayouts(grid),
1246
+ theme,
1247
+ true,
1248
+ dpr,
1249
+ valueFmt,
1250
+ );
1251
+ }
1252
+ }
1253
+
1254
+ renderFacetedBarLegend(chart, grid);
1255
+
1256
+ if (getHoveredBar(chart)) {
1257
+ renderBarTooltipCanvas(chart);
1258
+ }
1259
+ }
1260
+
1261
+ /**
1262
+ * Aggregate-level legend for the faceted frame, painted into the
1263
+ * grid's shared right gutter. One entry per aggregate (facets absorb
1264
+ * the split dimension; every split of an aggregate shares its color —
1265
+ * see `ensurePalette`). A legend toggle targets the aggregate's full
1266
+ * seriesId set, so hiding "Sales" hides it in every facet at once; an
1267
+ * entry reads as hidden only when ALL of its series are hidden.
1268
+ */
1269
+ function renderFacetedBarLegend(chart: SeriesChart, grid: FacetGrid): void {
1270
+ chart._legendRects = [];
1271
+ if (!chart._chromeCanvas || !grid.legendRect) {
1272
+ return;
1273
+ }
1274
+
1275
+ const M = chart._aggregates.length;
1276
+ if (M <= 1) {
1277
+ return;
1278
+ }
1279
+
1280
+ const ctx = chart._chromeCanvas.getContext("2d") as Context2D | null;
1281
+ if (!ctx) {
1282
+ return;
1283
+ }
1284
+
1285
+ ctx.save();
1286
+
1287
+ const theme = chart._resolveTheme();
1288
+ const swatchSize = 10;
1289
+ const lineHeight = 18;
1290
+ const x = grid.legendRect.x + 12;
1291
+ let y = grid.legendRect.y + 10;
1292
+
1293
+ ctx.font = `11px ${theme.fontFamily}`;
1294
+ ctx.textAlign = "left";
1295
+ ctx.textBaseline = "middle";
1296
+
1297
+ for (let k = 0; k < M; k++) {
1298
+ const seriesIds: number[] = [];
1299
+ let color: [number, number, number] = [0.5, 0.5, 0.5];
1300
+ for (const s of chart._series) {
1301
+ if (s.aggIdx === k) {
1302
+ seriesIds.push(s.seriesId);
1303
+ color = s.color;
1304
+ }
1305
+ }
1306
+
1307
+ const label = chart._aggregates[k];
1308
+ const hidden = seriesIds.every((sid) => chart._hiddenSeries.has(sid));
1309
+ const r = Math.round(color[0] * 255);
1310
+ const g = Math.round(color[1] * 255);
1311
+ const b = Math.round(color[2] * 255);
1312
+
1313
+ ctx.globalAlpha = hidden ? 0.3 : 1.0;
1314
+ ctx.fillStyle = `rgb(${r},${g},${b})`;
1315
+ ctx.fillRect(x, y - swatchSize / 2, swatchSize, swatchSize);
1316
+
1317
+ ctx.fillStyle = theme.legendText;
1318
+ ctx.fillText(label, x + swatchSize + 6, y);
1319
+
1320
+ const textW = ctx.measureText(label).width;
1321
+ if (hidden) {
1322
+ ctx.strokeStyle = theme.legendText;
1323
+ ctx.lineWidth = 1;
1324
+ ctx.beginPath();
1325
+ ctx.moveTo(x + swatchSize + 6, y);
1326
+ ctx.lineTo(x + swatchSize + 6 + textW, y);
1327
+ ctx.stroke();
1328
+ }
1329
+
1330
+ ctx.globalAlpha = 1.0;
1331
+
1332
+ chart._legendRects.push({
1333
+ seriesIds,
1334
+ rect: {
1335
+ x: x - 2,
1336
+ y: y - lineHeight / 2,
1337
+ width: swatchSize + 6 + textW + 4,
1338
+ height: lineHeight,
1339
+ },
1340
+ });
1341
+
1342
+ y += lineHeight;
1343
+ }
1344
+
1345
+ ctx.restore();
1346
+ }
1347
+
711
1348
  /**
712
1349
  * Cached parallel array of measured legend text widths. The legend
713
1350
  * renderer reads from this each frame instead of re-running
@@ -807,7 +1444,7 @@ function renderBarLegend(chart: SeriesChart): void {
807
1444
  width: swatchSize + 6 + textW + 4,
808
1445
  height: lineHeight,
809
1446
  };
810
- chart._legendRects.push({ seriesId: s.seriesId, rect });
1447
+ chart._legendRects.push({ seriesIds: [s.seriesId], rect });
811
1448
 
812
1449
  y += lineHeight;
813
1450
  }
@@ -825,7 +1462,12 @@ function renderBarTooltipCanvas(chart: SeriesChart): void {
825
1462
  return;
826
1463
  }
827
1464
 
828
- const layout = chart._lastLayout;
1465
+ // Faceted frames anchor in the record's own cell; single-plot
1466
+ // frames fall through to `_lastLayout`.
1467
+ const layout = layoutForRecord(chart, b);
1468
+ if (!layout) {
1469
+ return;
1470
+ }
829
1471
 
830
1472
  // Bar glyphs anchor the tooltip at the midpoint of the bar body so
831
1473
  // it reads against a solid swatch. Line / scatter / area glyphs
@@ -842,7 +1484,7 @@ function renderBarTooltipCanvas(chart: SeriesChart): void {
842
1484
  ? chart._isHorizontal
843
1485
  ? layout.dataToPixel(anchorV, b.xCenter)
844
1486
  : layout.dataToPixel(b.xCenter, anchorV)
845
- : rightAxisDataToPixel(chart, b.xCenter, anchorV);
1487
+ : rightAxisDataToPixel(chart, b.xCenter, anchorV, layout);
846
1488
 
847
1489
  const lines = buildBarTooltipLines(chart, b);
848
1490
  const theme = chart._resolveTheme();
@@ -860,8 +1502,9 @@ export function rightAxisDataToPixel(
860
1502
  chart: SeriesChart,
861
1503
  x: number,
862
1504
  y: number,
1505
+ layoutOverride?: PlotLayout,
863
1506
  ): { px: number; py: number } {
864
- const layout = chart._lastLayout!;
1507
+ const layout = layoutOverride ?? chart._lastLayout!;
865
1508
  const { x: px, y: py, width, height } = layout.plotRect;
866
1509
  const tx =
867
1510
  (x - layout.paddedXMin) / (layout.paddedXMax - layout.paddedXMin);