@opendata-ai/openchart-engine 7.11.0 → 8.0.0-rc.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 (157) hide show
  1. package/dist/index.d.ts +123 -7
  2. package/dist/index.js +16159 -10024
  3. package/dist/index.js.map +1 -1
  4. package/package.json +11 -4
  5. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +170 -56
  6. package/src/__tests__/annotation-behavior.test.ts +721 -0
  7. package/src/__tests__/axes.test.ts +50 -3
  8. package/src/__tests__/compile-chart.test.ts +30 -0
  9. package/src/__tests__/compile-footnotes.test.ts +277 -0
  10. package/src/__tests__/compile-layer.test.ts +120 -1
  11. package/src/__tests__/helpers/invariants.ts +54 -20
  12. package/src/__tests__/known-bugs.test.ts +455 -2
  13. package/src/__tests__/legend.test.ts +69 -4
  14. package/src/__tests__/ranking-recipes.test.ts +260 -0
  15. package/src/__tests__/scales.test.ts +9 -3
  16. package/src/__tests__/series-search.test.ts +92 -0
  17. package/src/__tests__/spec-sugar.test.ts +801 -0
  18. package/src/__tests__/you-draw-it.test.ts +160 -0
  19. package/src/annotations/__tests__/compute.test.ts +207 -92
  20. package/src/annotations/__tests__/geometry.test.ts +66 -0
  21. package/src/annotations/__tests__/resolve-text.test.ts +527 -27
  22. package/src/annotations/__tests__/rich-text.test.ts +91 -0
  23. package/src/annotations/__tests__/thinning.test.ts +148 -0
  24. package/src/annotations/collisions.ts +130 -84
  25. package/src/annotations/compute.ts +83 -85
  26. package/src/annotations/constants.ts +116 -6
  27. package/src/annotations/geometry.ts +222 -77
  28. package/src/annotations/move.ts +135 -0
  29. package/src/annotations/placement.ts +62 -14
  30. package/src/annotations/resolve-range.ts +2 -0
  31. package/src/annotations/resolve-refline.ts +2 -0
  32. package/src/annotations/resolve-text.ts +312 -111
  33. package/src/annotations/rich-text.ts +65 -0
  34. package/src/annotations/thinning.ts +84 -10
  35. package/src/barlist/compile-barlist.ts +16 -6
  36. package/src/charts/_shared/format-label-value.ts +9 -8
  37. package/src/charts/bar/__tests__/compute.test.ts +57 -10
  38. package/src/charts/bar/__tests__/labels.test.ts +5 -4
  39. package/src/charts/bar/compute.ts +2 -6
  40. package/src/charts/bar/index.ts +8 -1
  41. package/src/charts/bar/labels.ts +3 -3
  42. package/src/charts/beeswarm/__tests__/compute.test.ts +280 -0
  43. package/src/charts/beeswarm/__tests__/dodge.test.ts +148 -0
  44. package/src/charts/beeswarm/compute.ts +203 -0
  45. package/src/charts/beeswarm/dodge.ts +155 -0
  46. package/src/charts/beeswarm/index.ts +32 -0
  47. package/src/charts/builtin.ts +12 -1
  48. package/src/charts/calendar/__tests__/compute.test.ts +417 -0
  49. package/src/charts/calendar/compute.ts +319 -0
  50. package/src/charts/calendar/index.ts +32 -0
  51. package/src/charts/column/__tests__/compute.test.ts +21 -7
  52. package/src/charts/column/__tests__/labels.test.ts +76 -1
  53. package/src/charts/column/compute.ts +2 -6
  54. package/src/charts/column/index.ts +10 -2
  55. package/src/charts/column/labels.ts +95 -21
  56. package/src/charts/dot/index.ts +8 -1
  57. package/src/charts/dot/labels.ts +3 -3
  58. package/src/charts/line/__tests__/compute.test.ts +15 -19
  59. package/src/charts/line/area.ts +8 -23
  60. package/src/charts/line/compute.ts +32 -2
  61. package/src/charts/parliament/__tests__/compute.test.ts +411 -0
  62. package/src/charts/parliament/compute.ts +313 -0
  63. package/src/charts/parliament/index.ts +32 -0
  64. package/src/charts/pie/__tests__/compute.test.ts +103 -0
  65. package/src/charts/pie/compute.ts +63 -8
  66. package/src/charts/pie/labels.ts +1 -0
  67. package/src/charts/range/__tests__/compute.test.ts +486 -0
  68. package/src/charts/range/compute.ts +273 -0
  69. package/src/charts/range/index.ts +82 -0
  70. package/src/charts/range/labels.ts +169 -0
  71. package/src/charts/rect/__tests__/compute.test.ts +120 -0
  72. package/src/charts/rect/index.ts +127 -0
  73. package/src/charts/scatter/compute.ts +7 -21
  74. package/src/charts/text/__tests__/compute.test.ts +307 -0
  75. package/src/charts/text/index.ts +44 -7
  76. package/src/charts/tick/__tests__/compute.test.ts +75 -0
  77. package/src/charts/tick/index.ts +7 -4
  78. package/src/charts/utils.ts +5 -3
  79. package/src/charts/waffle/__tests__/compute.test.ts +305 -0
  80. package/src/charts/waffle/compute.ts +197 -0
  81. package/src/charts/waffle/index.ts +31 -0
  82. package/src/compile/__tests__/color-scale-reverse.test.ts +89 -0
  83. package/src/compile/__tests__/data-clip.test.ts +68 -0
  84. package/src/compile/__tests__/fill-patterns.test.ts +154 -0
  85. package/src/compile/__tests__/size-scale.test.ts +108 -0
  86. package/src/compile/color-scale-range.ts +54 -33
  87. package/src/compile/data-clip.ts +21 -6
  88. package/src/compile/fill-patterns.ts +74 -0
  89. package/src/compile/layer.ts +117 -21
  90. package/src/compile/size-scale.ts +139 -0
  91. package/src/compile/spec-sugar.ts +758 -0
  92. package/src/compile/you-draw-it.ts +123 -0
  93. package/src/compile.ts +310 -103
  94. package/src/compiler/__tests__/a11y-warnings.test.ts +148 -0
  95. package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +19 -0
  96. package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +20 -0
  97. package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +22 -0
  98. package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +19 -0
  99. package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +14 -0
  100. package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +34 -0
  101. package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +22 -0
  102. package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +41 -0
  103. package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +38 -0
  104. package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +28 -0
  105. package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +28 -0
  106. package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +30 -0
  107. package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +29 -0
  108. package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +32 -0
  109. package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +25 -0
  110. package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +19 -0
  111. package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +19 -0
  112. package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +21 -0
  113. package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +23 -0
  114. package/src/compiler/__tests__/normalize.test.ts +65 -1
  115. package/src/compiler/__tests__/validate-did-you-mean.test.ts +86 -0
  116. package/src/compiler/__tests__/validate-near-miss.test.ts +89 -0
  117. package/src/compiler/__tests__/validate.test.ts +156 -0
  118. package/src/compiler/__tests__/vl-parity.test.ts +68 -0
  119. package/src/compiler/a11y-warnings.ts +110 -0
  120. package/src/compiler/normalize.ts +150 -7
  121. package/src/compiler/types.ts +11 -0
  122. package/src/compiler/validate.ts +664 -18
  123. package/src/endpoint-labels/__tests__/compute.test.ts +88 -0
  124. package/src/endpoint-labels/compute.ts +40 -12
  125. package/src/endpoint-labels/format.ts +45 -15
  126. package/src/endpoint-labels/predict.ts +50 -36
  127. package/src/format/__tests__/field-format.test.ts +164 -0
  128. package/src/format/field-format.ts +27 -0
  129. package/src/geo/__tests__/compile-map.test.ts +621 -0
  130. package/src/geo/compile-map.ts +856 -0
  131. package/src/geo/join.ts +115 -0
  132. package/src/geo/projections.ts +45 -0
  133. package/src/geo/types.ts +24 -0
  134. package/src/graphs/__tests__/compile-graph.test.ts +25 -0
  135. package/src/graphs/compile-graph.ts +27 -18
  136. package/src/index.ts +20 -2
  137. package/src/layout/axes/ticks.ts +59 -10
  138. package/src/layout/axes.ts +30 -2
  139. package/src/layout/dimensions.ts +139 -83
  140. package/src/layout/facet.ts +14 -3
  141. package/src/layout/plan.ts +83 -33
  142. package/src/layout/scales.ts +132 -15
  143. package/src/legend/__tests__/continuous.test.ts +318 -0
  144. package/src/legend/__tests__/size.test.ts +348 -0
  145. package/src/legend/__tests__/suppression.test.ts +17 -1
  146. package/src/legend/compute.ts +119 -13
  147. package/src/legend/continuous.ts +327 -0
  148. package/src/legend/size.ts +407 -0
  149. package/src/legend/suppression.ts +8 -7
  150. package/src/sankey/compile-sankey.ts +31 -19
  151. package/src/tables/format-cells.ts +16 -7
  152. package/src/tilemap/__tests__/compile-tilemap.test.ts +39 -0
  153. package/src/tilemap/compile-tilemap.ts +24 -7
  154. package/src/tooltips/__tests__/compute.test.ts +2 -2
  155. package/src/tooltips/compute.ts +422 -41
  156. package/src/transforms/__tests__/conditional.test.ts +18 -0
  157. package/src/transforms/aggregate.ts +2 -1
@@ -1,5 +1,5 @@
1
1
  import type { AxisTick, Encoding, LayoutStrategy } from '@opendata-ai/openchart-core';
2
- import { resolveTheme } from '@opendata-ai/openchart-core';
2
+ import { maxRotatedLabelWidth, resolveTheme } from '@opendata-ai/openchart-core';
3
3
  import { scaleLinear, scaleLog } from 'd3-scale';
4
4
  import { describe, expect, it } from 'vitest';
5
5
  import type { NormalizedChartSpec } from '../compiler/types';
@@ -600,11 +600,58 @@ describe('text-aware tick density', () => {
600
600
  const axes = computeAxes(scales, narrowArea, fullStrategy, theme, measure);
601
601
  expect(axes.x!.tickAngle).toBe(-45);
602
602
 
603
- // Every category keeps its label — the diagonals never touch.
604
- const kept = axes.x!.ticks.map((t) => t.label);
603
+ // Every category keeps a tick — the diagonals never touch, so no label is
604
+ // DROPPED. (The long one is ellipsized to fit the capped rotated-label
605
+ // band, which is a separate concern from decimation; its full text is
606
+ // preserved on `fullLabel`.)
607
+ expect(axes.x!.ticks).toHaveLength(categories.length);
608
+ const kept = axes.x!.ticks.map((t) => t.fullLabel ?? t.label);
605
609
  expect(kept).toEqual(categories);
606
610
  });
607
611
 
612
+ it('truncates rotated labels that would overflow the capped reservation', () => {
613
+ // Regression: the layout clamps the reserved rotated-label band to
614
+ // X_AXIS_ROTATED_EXTENT_CAP (120px) but nothing shortened the drawn string,
615
+ // so a long label rendered straight past the axis and through the source
616
+ // line. Long labels must now be ellipsized to fit that band, with the full
617
+ // text retained on `fullLabel` for tooltips and the a11y table.
618
+ const categories = ['Short', 'An Extremely Long Category Label That Cannot Possibly Fit'];
619
+ const barSpec: NormalizedChartSpec = {
620
+ ...lineSpec,
621
+ markType: 'bar',
622
+ markDef: { type: 'bar', orient: 'vertical' },
623
+ data: categories.map((cat, i) => ({ cat, val: (i + 1) * 10 })),
624
+ encoding: {
625
+ x: { field: 'cat', type: 'nominal' },
626
+ y: { field: 'val', type: 'quantitative' },
627
+ },
628
+ };
629
+ const measure = (text: string) => ({ width: text.length * 8, height: 12 });
630
+ const area = { x: 50, y: 50, width: 260, height: 300 };
631
+ const scales = computeScales(barSpec, area, barSpec.data);
632
+ const axes = computeAxes(scales, area, fullStrategy, theme, measure);
633
+
634
+ const ticks = axes.x!.ticks;
635
+ const angle = axes.x!.tickAngle!;
636
+ expect(Math.abs(angle)).toBeGreaterThan(10);
637
+
638
+ // The short label is untouched and carries no fullLabel.
639
+ const short = ticks.find((t) => t.value === 'Short')!;
640
+ expect(short.label).toBe('Short');
641
+ expect(short.fullLabel).toBeUndefined();
642
+
643
+ // The long one is ellipsized, but its full text survives on fullLabel.
644
+ const long = ticks.find((t) => t.value === categories[1])!;
645
+ expect(long.label).not.toBe(categories[1]);
646
+ expect(long.label.endsWith('…')).toBe(true);
647
+ expect(long.fullLabel).toBe(categories[1]);
648
+
649
+ // And the drawn label now fits the budget the reservation is computed from,
650
+ // so it cannot overflow the band.
651
+ const budget = maxRotatedLabelWidth(angle, theme.fonts.sizes.axisTick);
652
+ expect(measure(long.label).width).toBeLessThanOrEqual(budget);
653
+ });
654
+
608
655
  it('measures collision at the drawn tick spacing when an explicit tickCount thins upstream', () => {
609
656
  // 40 categories with tickCount: 8 — categoricalTicks retains every 5th
610
657
  // category, so the drawn ticks sit 5 band steps apart. The angle and
@@ -272,6 +272,36 @@ describe('compileChart', () => {
272
272
  expect(layout.a11y.dataTableFallback.length).toBeGreaterThan(0);
273
273
  // With marks present, keyboard navigation should be enabled
274
274
  expect(layout.a11y.keyboardNavigable).toBe(true);
275
+ // No author opt-out by default
276
+ expect(layout.a11y.hidden).toBeUndefined();
277
+ });
278
+
279
+ it('a11y.description replaces the auto-generated alt text', () => {
280
+ const spec = { ...lineSpec, a11y: { description: 'GDP growth for US and UK, 2020 to 2021.' } };
281
+ const layout = compileChart(spec, { width: 600, height: 400 });
282
+ expect(layout.a11y.altText).toBe('GDP growth for US and UK, 2020 to 2021.');
283
+ });
284
+
285
+ it('top-level description (VL alias) replaces the auto-generated alt text', () => {
286
+ const spec = { ...lineSpec, description: 'Two-country GDP comparison.' };
287
+ const layout = compileChart(spec, { width: 600, height: 400 });
288
+ expect(layout.a11y.altText).toBe('Two-country GDP comparison.');
289
+ });
290
+
291
+ it('a11y.description wins over the top-level description', () => {
292
+ const spec = {
293
+ ...lineSpec,
294
+ description: 'Alias text',
295
+ a11y: { description: 'Canonical text' },
296
+ };
297
+ const layout = compileChart(spec, { width: 600, height: 400 });
298
+ expect(layout.a11y.altText).toBe('Canonical text');
299
+ });
300
+
301
+ it('a11y.hidden flows into the layout metadata', () => {
302
+ const spec = { ...lineSpec, a11y: { hidden: true } };
303
+ const layout = compileChart(spec, { width: 600, height: 400 });
304
+ expect(layout.a11y.hidden).toBe(true);
275
305
  });
276
306
 
277
307
  it('produces different strategies at different widths', () => {
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Auto-thinning footnote reserve.
3
+ *
4
+ * The footnote list renders below the plot, but thinning only discovers how many
5
+ * lines there are after the layout has already been computed against a plot that
6
+ * reserved no room for them. compileChart resolves that circularity by recompiling
7
+ * with the band reserved. These tests pin the two things that used to break:
8
+ * the list overrunning the footer row, and the reserve failing to converge.
9
+ */
10
+
11
+ import type { ChartSpec } from '@opendata-ai/openchart-core';
12
+ import { describe, expect, it } from 'vitest';
13
+ import { compileChart } from '../compile';
14
+
15
+ /**
16
+ * Six callouts in a narrow plot. At this width the labels are far wider than the
17
+ * plot, so most of them must demote — but they sit at different y positions and
18
+ * never pairwise-collide, which is precisely the case collision-only thinning
19
+ * missed.
20
+ */
21
+ const crowdedSpec: ChartSpec = {
22
+ mark: 'line',
23
+ data: [
24
+ { date: '2019-01-01', value: 10 },
25
+ { date: '2019-06-01', value: 18 },
26
+ { date: '2020-01-01', value: 15 },
27
+ { date: '2020-06-01', value: 8 },
28
+ { date: '2021-01-01', value: 22 },
29
+ { date: '2021-06-01', value: 28 },
30
+ { date: '2022-01-01', value: 35 },
31
+ { date: '2023-01-01', value: 42 },
32
+ ],
33
+ encoding: {
34
+ x: { field: 'date', type: 'temporal' },
35
+ y: { field: 'value', type: 'quantitative' },
36
+ },
37
+ annotations: [
38
+ { type: 'text', x: '2021-01-01', y: 22, text: 'Recovery begins' },
39
+ { type: 'text', x: '2019-06-01', y: 18, text: 'Early peak' },
40
+ { type: 'text', x: '2021-06-01', y: 28, text: 'Strong rebound' },
41
+ { type: 'text', x: '2022-01-01', y: 35, text: 'New high' },
42
+ { type: 'text', x: '2023-01-01', y: 42, text: 'Record territory' },
43
+ ],
44
+ chrome: { title: 'Crowded', source: 'Illustrative' },
45
+ };
46
+
47
+ describe('auto-thinning footnote reserve', () => {
48
+ it('demotes labels that overflow a narrow plot, even though none collide', () => {
49
+ const layout = compileChart(crowdedSpec, { width: 200, height: 340 });
50
+
51
+ // Collision-only thinning left these inline, overflowing the plot. They
52
+ // should now be footnotes instead.
53
+ expect(layout.chrome.footnotes?.length ?? 0).toBeGreaterThan(0);
54
+ });
55
+
56
+ it('reserves bottom space so the footnote list clears the footer row', () => {
57
+ const withFootnotes = compileChart(crowdedSpec, { width: 200, height: 340 });
58
+ const footnotes = withFootnotes.chrome.footnotes ?? [];
59
+ expect(footnotes.length).toBeGreaterThan(0);
60
+
61
+ // Same chart, same box, no annotations to demote — so no band to reserve.
62
+ // Comparing against it isolates the reserve from the bottom chrome margin
63
+ // that both layouts already pay for. Asserting only "there is space below
64
+ // the plot" would pass with the reserve ripped out; this does not.
65
+ const { annotations: _dropped, ...noAnnotations } = crowdedSpec;
66
+ const baseline = compileChart(noAnnotations, { width: 200, height: 340 });
67
+
68
+ const plotBottom = (l: typeof withFootnotes) => l.area.y + l.area.height;
69
+ const pulledUpBy = plotBottom(baseline) - plotBottom(withFootnotes);
70
+
71
+ // The band the renderer will draw, measured the way core measures it.
72
+ const band = footnotes.length * withFootnotes.theme.fonts.sizes.small * 1.3 + 4;
73
+
74
+ // Epsilon, not a loosened bound: the engine and this line reach the same
75
+ // band through different float op orders, so an exact >= trips on ~1e-14 of
76
+ // accumulated error. A reserve that actually went missing would be short by
77
+ // a whole line (~14px), which this still catches.
78
+ expect(pulledUpBy).toBeGreaterThanOrEqual(band - 1e-9);
79
+ });
80
+
81
+ it('leaves the plot alone when nothing demotes', () => {
82
+ const roomy = compileChart(crowdedSpec, { width: 900, height: 500 });
83
+ // Wide enough that the labels fit; no footnotes, so no reserve.
84
+ expect(roomy.chrome.footnotes).toBeUndefined();
85
+ });
86
+
87
+ it('converges instead of recursing forever', () => {
88
+ // Reserving space shrinks the plot, which can demote one more label and grow
89
+ // the band again. This must settle, not spin.
90
+ const layout = compileChart(crowdedSpec, { width: 160, height: 260 });
91
+ expect(layout.chrome.footnotes?.length ?? 0).toBeLessThanOrEqual(
92
+ crowdedSpec.annotations?.length ?? 0,
93
+ );
94
+ expect(layout.area.height).toBeGreaterThan(0);
95
+ });
96
+ });
97
+
98
+ /**
99
+ * Faceted charts take a separate compile path that used to skip thinning
100
+ * entirely: every panel kept every label inline, and labels routinely sprawled
101
+ * out of their panel and across the neighbouring one. A panel is its own little
102
+ * chart, so it thins against its own rect, not the figure's.
103
+ */
104
+ describe('auto-thinning in faceted charts', () => {
105
+ // Deliberately long labels: at 640px each panel is only ~280px wide, so a label
106
+ // wider than that cannot fit inline no matter where it's placed. Short labels
107
+ // would leave the containment assertion below vacuous.
108
+ const facetedSpec: ChartSpec = {
109
+ ...crowdedSpec,
110
+ data: ['North', 'South'].flatMap((region) =>
111
+ (crowdedSpec.data as Array<Record<string, unknown>>).map((row) => ({ ...row, region })),
112
+ ),
113
+ encoding: {
114
+ ...crowdedSpec.encoding,
115
+ facet: { field: 'region', type: 'nominal', columns: 2 },
116
+ },
117
+ annotations: [
118
+ { type: 'text', x: '2021-01-01', y: 22, text: 'Recovery begins in earnest here' },
119
+ { type: 'text', x: '2019-06-01', y: 18, text: 'An unusually early peak arrives' },
120
+ { type: 'text', x: '2021-06-01', y: 28, text: 'A strong rebound takes hold' },
121
+ { type: 'text', x: '2022-01-01', y: 35, text: 'The series posts a new high' },
122
+ { type: 'text', x: '2023-01-01', y: 42, text: 'Firmly in record territory now' },
123
+ ],
124
+ } as ChartSpec;
125
+
126
+ it('demotes labels that escape their panel', () => {
127
+ const layout = compileChart(facetedSpec, { width: 640, height: 400 });
128
+ expect(layout.facet).toBeDefined();
129
+ expect(layout.chrome.footnotes?.length ?? 0).toBeGreaterThan(0);
130
+
131
+ const demoted = layout.facet!.panels.flatMap((p) =>
132
+ p.annotations.filter((a) => a.footnoteIndex != null),
133
+ );
134
+ expect(demoted.length).toBeGreaterThan(0);
135
+ });
136
+
137
+ it('keeps every inline label inside its own panel', () => {
138
+ const layout = compileChart(facetedSpec, { width: 640, height: 400 });
139
+
140
+ for (const panel of layout.facet!.panels) {
141
+ for (const a of panel.annotations) {
142
+ if (a.footnoteIndex != null || !a.bounds) continue;
143
+ expect(a.bounds.x).toBeGreaterThanOrEqual(panel.area.x);
144
+ expect(a.bounds.x + a.bounds.width).toBeLessThanOrEqual(
145
+ panel.area.x + panel.area.width + 1e-9,
146
+ );
147
+ }
148
+ }
149
+ });
150
+
151
+ it('numbers a demoted annotation once, not once per panel', () => {
152
+ const layout = compileChart(facetedSpec, { width: 640, height: 400 });
153
+ const footnotes = layout.chrome.footnotes ?? [];
154
+
155
+ // The spec's annotations resolve into every panel, so a naive per-panel pool
156
+ // would repeat each footnote once per panel.
157
+ const texts = footnotes.map((f) => f.text);
158
+ expect(new Set(texts).size).toBe(texts.length);
159
+ expect(footnotes.length).toBeLessThanOrEqual(facetedSpec.annotations?.length ?? 0);
160
+
161
+ // The same spec annotation gets the same number in every panel it demoted in.
162
+ //
163
+ // Pair on `specIndex`, not on array position. A panel's annotation list is a
164
+ // FILTERED, zIndex-SORTED view of the spec's, so `first.annotations[i]` and
165
+ // `second.annotations[i]` are only the same authored annotation by luck --
166
+ // and the moment one panel drops one, this silently starts comparing two
167
+ // different callouts and asserting nothing.
168
+ const [first, second] = layout.facet!.panels;
169
+ const numbering = new Map<number, number>();
170
+ let pairsChecked = 0;
171
+
172
+ for (const panel of [first, second]) {
173
+ for (const a of panel.annotations) {
174
+ if (a.footnoteIndex == null || a.specIndex == null) continue;
175
+ const seen = numbering.get(a.specIndex);
176
+ if (seen === undefined) {
177
+ numbering.set(a.specIndex, a.footnoteIndex);
178
+ } else {
179
+ expect(a.footnoteIndex).toBe(seen);
180
+ pairsChecked++;
181
+ }
182
+ }
183
+ }
184
+
185
+ // Guard the guard: if nothing demoted in both panels there is no pairing to
186
+ // check, and every assertion above is vacuous.
187
+ expect(pairsChecked).toBeGreaterThan(0);
188
+ });
189
+
190
+ it('reserves a band for the footnote list below the grid', () => {
191
+ const withFootnotes = compileChart(facetedSpec, { width: 640, height: 400 });
192
+ const footnotes = withFootnotes.chrome.footnotes ?? [];
193
+ expect(footnotes.length).toBeGreaterThan(0);
194
+
195
+ const { annotations: _dropped, ...noAnnotations } = facetedSpec;
196
+ const baseline = compileChart(noAnnotations as ChartSpec, { width: 640, height: 400 });
197
+
198
+ const plotBottom = (l: typeof withFootnotes) => l.area.y + l.area.height;
199
+ const pulledUpBy = plotBottom(baseline) - plotBottom(withFootnotes);
200
+ const band = footnotes.length * withFootnotes.theme.fonts.sizes.small * 1.3 + 4;
201
+
202
+ expect(pulledUpBy).toBeGreaterThanOrEqual(band - 1e-9);
203
+ });
204
+
205
+ // Regression: footnote numbers were keyed on the annotation's position in the
206
+ // *resolved* array. With `resolve.scale.x: 'independent'` each panel derives its
207
+ // own domain, so an annotation on a category that only exists in one panel
208
+ // resolves there and is dropped from the other. Position i then means a
209
+ // different spec annotation in each panel, and the markers pointed at other
210
+ // panels' text: South's callout carried marker 1, and footnote 1 read "AAA…".
211
+ // Keying on the stamped `specIndex` is what fixes it.
212
+ const long = (s: string) => s.repeat(12);
213
+
214
+ // Each panel gets its own x domain, so the North categories don't exist in South
215
+ // and vice versa: every annotation resolves in exactly ONE panel and is dropped
216
+ // from the other. That's what makes resolved-array position stop meaning spec
217
+ // position, which is the whole point of these two tests.
218
+ const independentDomainSpec: ChartSpec = {
219
+ mark: 'bar',
220
+ autoThin: true,
221
+ resolve: { scale: { x: 'independent' } },
222
+ data: [
223
+ { region: 'North', cat: 'A', v: 10 },
224
+ { region: 'North', cat: 'B', v: 20 },
225
+ { region: 'North', cat: 'C', v: 15 },
226
+ { region: 'South', cat: 'X', v: 12 },
227
+ { region: 'South', cat: 'Y', v: 22 },
228
+ { region: 'South', cat: 'Z', v: 17 },
229
+ ],
230
+ encoding: {
231
+ x: { field: 'cat', type: 'nominal' },
232
+ y: { field: 'v', type: 'quantitative' },
233
+ facet: { field: 'region', type: 'nominal', columns: 2 },
234
+ },
235
+ // Interleaved on purpose: North, South, North, South. If numbering keys on
236
+ // resolved position, South's first annotation takes North's first number.
237
+ annotations: [
238
+ { type: 'text', x: 'A', y: 10, text: long('AAA ') },
239
+ { type: 'text', x: 'X', y: 12, text: long('XXX ') },
240
+ { type: 'text', x: 'B', y: 20, text: long('BBB ') },
241
+ { type: 'text', x: 'Y', y: 22, text: long('YYY ') },
242
+ ],
243
+ } as ChartSpec;
244
+
245
+ it('points every marker at its own text when panels resolve different annotations', () => {
246
+ const layout = compileChart(independentDomainSpec, { width: 420, height: 300 });
247
+
248
+ const footnotes = layout.chrome.footnotes ?? [];
249
+ expect(footnotes.length).toBeGreaterThan(0);
250
+ const textByNumber = new Map(footnotes.map((f) => [f.index, f.text]));
251
+
252
+ let checked = 0;
253
+ for (const panel of layout.facet!.panels) {
254
+ for (const a of panel.annotations) {
255
+ if (a.footnoteIndex == null) continue;
256
+ // The whole contract: follow the marker, land on your own words.
257
+ expect(textByNumber.get(a.footnoteIndex)).toBe(a.label?.text);
258
+ checked++;
259
+ }
260
+ }
261
+ expect(checked).toBeGreaterThan(0);
262
+ });
263
+
264
+ it('lists every demoted annotation exactly once, in every panel', () => {
265
+ const layout = compileChart(independentDomainSpec, { width: 420, height: 300 });
266
+
267
+ const texts = (layout.chrome.footnotes ?? []).map((f) => f.text);
268
+ expect(texts.length).toBeGreaterThan(0);
269
+ // No duplicates: a panel that demotes the same spec annotation reuses its
270
+ // number rather than appending a second copy of the text.
271
+ expect(new Set(texts).size).toBe(texts.length);
272
+ // And both panels' annotations are represented -- the pre-fix bug listed only
273
+ // the first panel's, and pointed the second panel's markers at them.
274
+ expect(texts.some((t) => t.startsWith('AAA'))).toBe(true);
275
+ expect(texts.some((t) => t.startsWith('XXX'))).toBe(true);
276
+ });
277
+ });
@@ -1,6 +1,6 @@
1
1
  import type { ChartSpec, LayerSpec } from '@opendata-ai/openchart-core';
2
2
  import { describe, expect, it } from 'vitest';
3
- import { compileLayer } from '../compile';
3
+ import { compileChart, compileLayer } from '../compile';
4
4
  import { flattenLayers } from '../compiler/normalize';
5
5
 
6
6
  // ---------------------------------------------------------------------------
@@ -322,6 +322,79 @@ describe('compileLayer', () => {
322
322
  expect(layout.area.width).toBeGreaterThan(0);
323
323
  });
324
324
 
325
+ it('unions the y domain even when x is nominal', () => {
326
+ // The channel loop used to `return` on the first channel that couldn't take
327
+ // part. x is visited first, so a nominal x abandoned y as well: each leaf
328
+ // then re-fit y to its own rows and every bar rendered full-height,
329
+ // regardless of value. Bars-plus-labels is the most common layered shape
330
+ // there is, so the function missed its own headline case.
331
+ const spec: LayerSpec = {
332
+ layer: [
333
+ {
334
+ mark: 'bar' as const,
335
+ data: [{ n: 'A', v: 50 }],
336
+ encoding: {
337
+ x: { field: 'n', type: 'nominal' as const },
338
+ y: { field: 'v', type: 'quantitative' as const },
339
+ },
340
+ },
341
+ {
342
+ mark: 'bar' as const,
343
+ data: [{ n: 'B', v: 100 }],
344
+ encoding: {
345
+ x: { field: 'n', type: 'nominal' as const },
346
+ y: { field: 'v', type: 'quantitative' as const },
347
+ },
348
+ },
349
+ ],
350
+ };
351
+
352
+ const rects = compileLayer(spec, compileOpts).marks.filter((m) => m.type === 'rect');
353
+ expect(rects).toHaveLength(2);
354
+
355
+ // Sharing a zero-based domain, the 50-bar is exactly half the 100-bar.
356
+ const [half, full] = rects.map((r) => (r as { height: number }).height);
357
+ expect(half).toBeCloseTo(full / 2, 1);
358
+ });
359
+
360
+ it('gives layered bars the same geometry as the equivalent single chart', () => {
361
+ // The shared domain is pinned onto each leaf as `scale.domain`, and an
362
+ // explicit domain skips the `zero !== false` baselining a normal scale
363
+ // applies. Without folding zero back in, the union lands as a literal
364
+ // [50, 100] and the 50-bar collapses to a 1px sliver against its baseline.
365
+ const encoding = {
366
+ x: { field: 'n', type: 'nominal' as const },
367
+ y: { field: 'v', type: 'quantitative' as const },
368
+ };
369
+ const heights = (layout: { marks: Array<{ type: string }> }) =>
370
+ layout.marks
371
+ .filter((m) => m.type === 'rect')
372
+ .map((r) => Number((r as unknown as { height: number }).height.toFixed(1)));
373
+
374
+ const single = compileChart(
375
+ {
376
+ mark: 'bar',
377
+ data: [
378
+ { n: 'A', v: 50 },
379
+ { n: 'B', v: 100 },
380
+ ],
381
+ encoding,
382
+ },
383
+ compileOpts,
384
+ );
385
+ const layered = compileLayer(
386
+ {
387
+ layer: [
388
+ { mark: 'bar' as const, data: [{ n: 'A', v: 50 }], encoding },
389
+ { mark: 'bar' as const, data: [{ n: 'B', v: 100 }], encoding },
390
+ ],
391
+ },
392
+ compileOpts,
393
+ );
394
+
395
+ expect(heights(layered)).toEqual(heights(single));
396
+ });
397
+
325
398
  it('compiles a single-layer LayerSpec identically to a ChartSpec', () => {
326
399
  const chartData = [
327
400
  { name: 'A', value: 10 },
@@ -654,6 +727,52 @@ describe('compileLayer', () => {
654
727
  expect(uniqueLabels.has('Y')).toBe(true);
655
728
  });
656
729
 
730
+ /**
731
+ * `legends` is the slot the renderer iterates; `legend` is the back-compat
732
+ * alias. The merge used to rebuild only `legend`, leaving `legends[0]` as the
733
+ * primary leaf's *pre-merge* legend -- so the second layer's series silently
734
+ * vanished from the drawn key while `layout.legend` still listed it.
735
+ *
736
+ * The leaves must color on DIFFERENT fields to exercise this: sharing one field
737
+ * makes the primary spec's concatenated data yield both entries on its own, and
738
+ * the merge becomes a no-op that proves nothing.
739
+ */
740
+ it('merges layer legend entries into `legends`, not just `legend`', () => {
741
+ const spec: LayerSpec = {
742
+ layer: [
743
+ {
744
+ mark: 'bar' as const,
745
+ data: [{ name: 'A', value: 10, cat: 'Bars' }],
746
+ encoding: {
747
+ x: { field: 'name', type: 'ordinal' as const },
748
+ y: { field: 'value', type: 'quantitative' as const },
749
+ color: { field: 'cat', type: 'nominal' as const },
750
+ },
751
+ },
752
+ {
753
+ mark: 'line' as const,
754
+ data: [{ name: 'A', value: 8, series: 'Trend' }],
755
+ encoding: {
756
+ x: { field: 'name', type: 'ordinal' as const },
757
+ y: { field: 'value', type: 'quantitative' as const },
758
+ color: { field: 'series', type: 'nominal' as const },
759
+ },
760
+ },
761
+ ],
762
+ };
763
+
764
+ const layout = compileLayer(spec, compileOpts);
765
+ const rendered = layout.legends[0];
766
+ expect('entries' in rendered).toBe(true);
767
+ if (!('entries' in rendered)) return;
768
+
769
+ const labels = rendered.entries.map((e) => e.label);
770
+ expect(labels).toContain('Bars');
771
+ expect(labels).toContain('Trend');
772
+ // What renders is what `legend` advertises.
773
+ expect(labels).toEqual(layout.legend.entries.map((e) => e.label));
774
+ });
775
+
657
776
  it('remaps x-coordinates when area is layer 0 and bars are layer 1', () => {
658
777
  // Inverse ordering: area/line on left axis, bars on right. The x-remapping
659
778
  // logic must detect that layer 1 has bars and remap layer 0's area mark instead.
@@ -28,6 +28,16 @@ function isCategoricalLegend(legend: LegendLayout): legend is CategoricalLegendL
28
28
  return 'entries' in legend && (!legend.type || legend.type === 'categorical');
29
29
  }
30
30
 
31
+ /**
32
+ * Every legend on the layout. Falls back to the singular `legend` slot so this
33
+ * still works against layouts built by older code paths (and by tests that
34
+ * hand-assemble a ChartLayout).
35
+ */
36
+ function allLegends(layout: ChartLayout): LegendLayout[] {
37
+ if (layout.legends?.length) return layout.legends;
38
+ return layout.legend ? [layout.legend] : [];
39
+ }
40
+
31
41
  // ---------------------------------------------------------------------------
32
42
  // Box extraction
33
43
  // ---------------------------------------------------------------------------
@@ -51,21 +61,27 @@ export function extractBoxes(layout: ChartLayout): NamedRect[] {
51
61
  // Chart area
52
62
  boxes.push({ ...layout.area, name: 'chartArea' });
53
63
 
54
- // Legend bounds
55
- boxes.push({ ...layout.legend.bounds, name: 'legend' });
56
-
57
- // Legend entry positions (categorical only)
58
- if (isCategoricalLegend(layout.legend) && layout.legend.entryPositions) {
59
- const rowHeight = layout.legend.rowHeight ?? 16;
60
- for (let i = 0; i < layout.legend.entryPositions.length; i++) {
61
- const pos = layout.legend.entryPositions[i];
62
- boxes.push({
63
- x: pos.x,
64
- y: pos.y,
65
- width: pos.width,
66
- height: rowHeight,
67
- name: `legend-entry-${i}`,
68
- });
64
+ // Legend bounds -- EVERY legend, not just the primary. A chart can carry both
65
+ // a color legend and a size legend; registering only `layout.legend` would
66
+ // leave the overlap invariants blind to exactly the element most likely to be
67
+ // mis-reserved, and they would pass a chart whose size legend sits on the plot.
68
+ for (const legend of allLegends(layout)) {
69
+ const suffix = legend.channel && legend.channel !== 'color' ? `-${legend.channel}` : '';
70
+ boxes.push({ ...legend.bounds, name: `legend${suffix}` });
71
+
72
+ // Legend entry positions (categorical only)
73
+ if (isCategoricalLegend(legend) && legend.entryPositions) {
74
+ const rowHeight = legend.rowHeight ?? 16;
75
+ for (let i = 0; i < legend.entryPositions.length; i++) {
76
+ const pos = legend.entryPositions[i];
77
+ boxes.push({
78
+ x: pos.x,
79
+ y: pos.y,
80
+ width: pos.width,
81
+ height: rowHeight,
82
+ name: `legend${suffix}-entry-${i}`,
83
+ });
84
+ }
69
85
  }
70
86
  }
71
87
 
@@ -179,14 +195,32 @@ export function checkLayoutInvariants(
179
195
  ): string[] {
180
196
  const violations: string[] = [];
181
197
 
182
- // 1. Legend doesn't intrude into chartArea (top/bottom only; right/bottom-right
198
+ // 1. No legend intrudes into chartArea (top/bottom only; right/bottom-right
183
199
  // use area.width reduction, so overlap is a horizontal concern checked by rule 4)
184
- if (isCategoricalLegend(layout.legend)) {
185
- const pos = layout.legend.position;
200
+ //
201
+ // Checked for EVERY legend and every legend type. A size legend is not
202
+ // categorical, and it is the one whose space is most likely to be
203
+ // under-reserved -- gating this on `isCategoricalLegend` would skip it.
204
+ for (const legend of allLegends(layout)) {
205
+ if (legend.bounds.width <= 0 || legend.bounds.height <= 0) continue;
206
+ const pos = legend.position;
186
207
  if (pos === 'top' || pos === 'bottom') {
187
- if (rectsOverlap(layout.legend.bounds, layout.area, 0.5)) {
208
+ if (rectsOverlap(legend.bounds, layout.area, 0.5)) {
209
+ const name = legend.channel ?? 'color';
210
+ violations.push(
211
+ `${name} legend intrudes into chartArea: legend=${formatRect(legend.bounds)} chartArea=${formatRect(layout.area)}`,
212
+ );
213
+ }
214
+ }
215
+ }
216
+
217
+ // 1b. Legends don't overlap each other.
218
+ const legends = allLegends(layout).filter((l) => l.bounds.width > 0 && l.bounds.height > 0);
219
+ for (let i = 0; i < legends.length; i++) {
220
+ for (let j = i + 1; j < legends.length; j++) {
221
+ if (rectsOverlap(legends[i].bounds, legends[j].bounds, 0.5)) {
188
222
  violations.push(
189
- `legend intrudes into chartArea: legend=${formatRect(layout.legend.bounds)} chartArea=${formatRect(layout.area)}`,
223
+ `legends overlap: ${legends[i].channel ?? 'color'}=${formatRect(legends[i].bounds)} ${legends[j].channel ?? 'color'}=${formatRect(legends[j].bounds)}`,
190
224
  );
191
225
  }
192
226
  }