@opendata-ai/openchart-engine 7.6.1 → 7.7.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 (42) hide show
  1. package/dist/index.d.ts +13 -0
  2. package/dist/index.js +1031 -187
  3. package/dist/index.js.map +1 -1
  4. package/package.json +2 -2
  5. package/src/__test-fixtures__/specs.ts +6 -0
  6. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +16 -0
  7. package/src/__tests__/axes.test.ts +3 -2
  8. package/src/__tests__/facet.test.ts +215 -0
  9. package/src/annotations/__tests__/compute.test.ts +18 -0
  10. package/src/annotations/__tests__/thinning.test.ts +137 -0
  11. package/src/annotations/compute.ts +3 -1
  12. package/src/annotations/thinning.ts +116 -0
  13. package/src/barlist/compile-barlist.ts +1 -0
  14. package/src/charts/bar/labels.ts +1 -0
  15. package/src/charts/column/labels.ts +1 -0
  16. package/src/charts/dot/labels.ts +1 -0
  17. package/src/charts/line/area.ts +16 -2
  18. package/src/charts/line/compute.ts +17 -10
  19. package/src/charts/pie/labels.ts +1 -0
  20. package/src/compile/__tests__/color-scale-range.test.ts +80 -1
  21. package/src/compile/color-scale-range.ts +44 -2
  22. package/src/compile.ts +314 -5
  23. package/src/compiler/__tests__/normalize-highlight.test.ts +103 -0
  24. package/src/compiler/__tests__/validate-highlight.test.ts +112 -0
  25. package/src/compiler/normalize.ts +67 -2
  26. package/src/compiler/types.ts +10 -0
  27. package/src/compiler/validate.ts +79 -0
  28. package/src/endpoint-labels/__tests__/compute.test.ts +57 -0
  29. package/src/endpoint-labels/compute.ts +179 -5
  30. package/src/endpoint-labels/predict.ts +17 -0
  31. package/src/layout/__tests__/scales-highlight.test.ts +192 -0
  32. package/src/layout/axes.ts +8 -2
  33. package/src/layout/dimensions.ts +6 -1
  34. package/src/layout/facet.ts +80 -0
  35. package/src/layout/plan.ts +16 -0
  36. package/src/legend/__tests__/redundancy.test.ts +108 -0
  37. package/src/legend/__tests__/suppression.test.ts +72 -0
  38. package/src/legend/compute.ts +18 -0
  39. package/src/legend/suppression.ts +27 -6
  40. package/src/tilemap/__tests__/compile-tilemap.test.ts +210 -0
  41. package/src/tilemap/compile-tilemap.ts +371 -117
  42. package/src/tilemap/types.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "7.6.1",
3
+ "version": "7.7.0",
4
4
  "description": "Headless compiler for openchart: spec validation, data compilation, scales, and layout",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Riley Hilliard",
@@ -48,7 +48,7 @@
48
48
  "typecheck": "tsc --noEmit"
49
49
  },
50
50
  "dependencies": {
51
- "@opendata-ai/openchart-core": "7.6.1",
51
+ "@opendata-ai/openchart-core": "7.7.0",
52
52
  "d3-array": "^3.2.0",
53
53
  "d3-format": "^3.1.2",
54
54
  "d3-interpolate": "^3.0.0",
@@ -68,6 +68,7 @@ export function makeLineSpec(): NormalizedChartSpec {
68
68
  },
69
69
  annotations: [],
70
70
  responsive: true,
71
+ autoThin: true,
71
72
  theme: {},
72
73
  darkMode: 'off',
73
74
  labels: { density: 'auto', format: '', prefix: '' },
@@ -75,6 +76,7 @@ export function makeLineSpec(): NormalizedChartSpec {
75
76
  seriesStyles: {},
76
77
  watermark: true,
77
78
  display: 'full',
79
+ highlight: [],
78
80
  userExplicit: {
79
81
  chrome: false,
80
82
  legend: false,
@@ -110,6 +112,7 @@ export function makeBarSpec(): NormalizedChartSpec {
110
112
  chrome: {},
111
113
  annotations: [],
112
114
  responsive: true,
115
+ autoThin: true,
113
116
  theme: {},
114
117
  darkMode: 'off',
115
118
  labels: { density: 'auto', format: '', prefix: '' },
@@ -117,6 +120,7 @@ export function makeBarSpec(): NormalizedChartSpec {
117
120
  seriesStyles: {},
118
121
  watermark: true,
119
122
  display: 'full',
123
+ highlight: [],
120
124
  userExplicit: {
121
125
  chrome: false,
122
126
  legend: false,
@@ -154,6 +158,7 @@ export function makeScatterSpec(): NormalizedChartSpec {
154
158
  chrome: {},
155
159
  annotations: [],
156
160
  responsive: true,
161
+ autoThin: true,
157
162
  theme: {},
158
163
  darkMode: 'off',
159
164
  labels: { density: 'auto', format: '', prefix: '' },
@@ -161,6 +166,7 @@ export function makeScatterSpec(): NormalizedChartSpec {
161
166
  seriesStyles: {},
162
167
  watermark: true,
163
168
  display: 'full',
169
+ highlight: [],
164
170
  userExplicit: {
165
171
  chrome: false,
166
172
  legend: false,
@@ -231,6 +231,7 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
231
231
  "fill": "#ffffff",
232
232
  "fontFamily": "system-ui, -apple-system, sans-serif",
233
233
  "fontSize": 11,
234
+ "fontVariant": "tabular-nums",
234
235
  "fontWeight": 600,
235
236
  "lineHeight": 1.2,
236
237
  "textAnchor": "end",
@@ -264,6 +265,7 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
264
265
  "fill": "#ffffff",
265
266
  "fontFamily": "system-ui, -apple-system, sans-serif",
266
267
  "fontSize": 11,
268
+ "fontVariant": "tabular-nums",
267
269
  "fontWeight": 600,
268
270
  "lineHeight": 1.2,
269
271
  "textAnchor": "end",
@@ -297,6 +299,7 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
297
299
  "fill": "#ffffff",
298
300
  "fontFamily": "system-ui, -apple-system, sans-serif",
299
301
  "fontSize": 11,
302
+ "fontVariant": "tabular-nums",
300
303
  "fontWeight": 600,
301
304
  "lineHeight": 1.2,
302
305
  "textAnchor": "end",
@@ -315,6 +318,7 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
315
318
  ],
316
319
  "metrics": undefined,
317
320
  "theme": {
321
+ "_tokenPairs": undefined,
318
322
  "borderRadius": 2,
319
323
  "chrome": {
320
324
  "byline": {
@@ -458,6 +462,7 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
458
462
  },
459
463
  },
460
464
  "isDark": false,
465
+ "seriesStrategy": "palette",
461
466
  "spacing": {
462
467
  "axisMargin": 6,
463
468
  "chartToFooter": 8,
@@ -861,6 +866,8 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
861
866
  "fontWeight": 600,
862
867
  "lineHeight": 1.25,
863
868
  },
869
+ "leading": undefined,
870
+ "leadingBounds": undefined,
864
871
  "swatchChipFill": "rgba(0,0,0,0.04)",
865
872
  "swatchSize": 18,
866
873
  "valueGap": 4,
@@ -868,6 +875,7 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
868
875
  "fill": "#71717a",
869
876
  "fontFamily": ""Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif",
870
877
  "fontSize": 12,
878
+ "fontVariant": "tabular-nums",
871
879
  "fontWeight": 400,
872
880
  "lineHeight": 1.25,
873
881
  },
@@ -1326,6 +1334,7 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
1326
1334
  ],
1327
1335
  "metrics": undefined,
1328
1336
  "theme": {
1337
+ "_tokenPairs": undefined,
1329
1338
  "borderRadius": 2,
1330
1339
  "chrome": {
1331
1340
  "byline": {
@@ -1469,6 +1478,7 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
1469
1478
  },
1470
1479
  },
1471
1480
  "isDark": false,
1481
+ "seriesStrategy": "palette",
1472
1482
  "spacing": {
1473
1483
  "axisMargin": 6,
1474
1484
  "chartToFooter": 8,
@@ -1800,6 +1810,7 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
1800
1810
  "fill": "#0000ff",
1801
1811
  "fontFamily": "system-ui, -apple-system, sans-serif",
1802
1812
  "fontSize": 10,
1813
+ "fontVariant": "tabular-nums",
1803
1814
  "fontWeight": 600,
1804
1815
  "lineHeight": 1.2,
1805
1816
  "textAnchor": "middle",
@@ -1844,6 +1855,7 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
1844
1855
  "fill": "#0000ff",
1845
1856
  "fontFamily": "system-ui, -apple-system, sans-serif",
1846
1857
  "fontSize": 10,
1858
+ "fontVariant": "tabular-nums",
1847
1859
  "fontWeight": 600,
1848
1860
  "lineHeight": 1.2,
1849
1861
  "textAnchor": "middle",
@@ -1888,6 +1900,7 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
1888
1900
  "fill": "#0000ff",
1889
1901
  "fontFamily": "system-ui, -apple-system, sans-serif",
1890
1902
  "fontSize": 10,
1903
+ "fontVariant": "tabular-nums",
1891
1904
  "fontWeight": 600,
1892
1905
  "lineHeight": 1.2,
1893
1906
  "textAnchor": "middle",
@@ -1932,6 +1945,7 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
1932
1945
  "fill": "#0000ff",
1933
1946
  "fontFamily": "system-ui, -apple-system, sans-serif",
1934
1947
  "fontSize": 10,
1948
+ "fontVariant": "tabular-nums",
1935
1949
  "fontWeight": 600,
1936
1950
  "lineHeight": 1.2,
1937
1951
  "textAnchor": "middle",
@@ -1950,6 +1964,7 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
1950
1964
  ],
1951
1965
  "metrics": undefined,
1952
1966
  "theme": {
1967
+ "_tokenPairs": undefined,
1953
1968
  "borderRadius": 2,
1954
1969
  "chrome": {
1955
1970
  "byline": {
@@ -2093,6 +2108,7 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
2093
2108
  },
2094
2109
  },
2095
2110
  "isDark": false,
2111
+ "seriesStrategy": "palette",
2096
2112
  "spacing": {
2097
2113
  "axisMargin": 6,
2098
2114
  "chartToFooter": 8,
@@ -668,9 +668,10 @@ describe('axis config properties', () => {
668
668
  const scales = computeScales(lineSpec, chartArea, lineSpec.data);
669
669
  const axes = computeAxes(scales, chartArea, fullStrategy, theme);
670
670
 
671
- // All new properties should be undefined when not set
671
+ // All new properties should be undefined when not set (except domainLine,
672
+ // which defaults to false for non-grounded marks like line)
672
673
  expect(axes.x!.orient).toBeUndefined();
673
- expect(axes.x!.domainLine).toBeUndefined();
674
+ expect(axes.x!.domainLine).toBe(false);
674
675
  expect(axes.x!.tickMarks).toBeUndefined();
675
676
  expect(axes.x!.offset).toBeUndefined();
676
677
  expect(axes.x!.titlePadding).toBeUndefined();
@@ -0,0 +1,215 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { compileChart } from '../compile';
3
+ import { autoColumns, computeFacetGrid } from '../layout/facet';
4
+
5
+ const facetData = [
6
+ { year: '2020', country: 'US', value: 100 },
7
+ { year: '2021', country: 'US', value: 120 },
8
+ { year: '2020', country: 'UK', value: 80 },
9
+ { year: '2021', country: 'UK', value: 90 },
10
+ { year: '2020', country: 'DE', value: 70 },
11
+ { year: '2021', country: 'DE', value: 85 },
12
+ ];
13
+
14
+ const facetSpec = {
15
+ mark: 'line' as const,
16
+ data: facetData,
17
+ encoding: {
18
+ x: { field: 'year', type: 'ordinal' as const },
19
+ y: { field: 'value', type: 'quantitative' as const },
20
+ facet: { field: 'country', type: 'nominal' as const },
21
+ },
22
+ };
23
+
24
+ describe('autoColumns', () => {
25
+ it('returns sqrt-based columns for moderate panel counts', () => {
26
+ expect(autoColumns(4, 800)).toBe(2);
27
+ expect(autoColumns(9, 1200)).toBe(3);
28
+ });
29
+
30
+ it('caps columns by available width', () => {
31
+ expect(autoColumns(9, 400)).toBe(2);
32
+ });
33
+
34
+ it('returns 1 for single panel', () => {
35
+ expect(autoColumns(1, 800)).toBe(1);
36
+ });
37
+
38
+ it('does not exceed panel count', () => {
39
+ expect(autoColumns(2, 2000)).toBe(2);
40
+ });
41
+ });
42
+
43
+ describe('computeFacetGrid', () => {
44
+ it('returns empty result for 0 panels', () => {
45
+ const result = computeFacetGrid(
46
+ [],
47
+ undefined,
48
+ { x: 0, y: 0, width: 600, height: 400 },
49
+ {
50
+ left: 40,
51
+ bottom: 30,
52
+ },
53
+ );
54
+ expect(result.panels).toEqual([]);
55
+ expect(result.columns).toBe(0);
56
+ expect(result.rows).toBe(0);
57
+ });
58
+
59
+ it('computes correct number of panels', () => {
60
+ const result = computeFacetGrid(
61
+ ['A', 'B', 'C', 'D'],
62
+ 2,
63
+ { x: 0, y: 0, width: 600, height: 400 },
64
+ { left: 40, bottom: 30 },
65
+ );
66
+ expect(result.panels).toHaveLength(4);
67
+ expect(result.columns).toBe(2);
68
+ expect(result.rows).toBe(2);
69
+ });
70
+
71
+ it('assigns correct row and col indices', () => {
72
+ const result = computeFacetGrid(
73
+ ['A', 'B', 'C'],
74
+ 2,
75
+ { x: 0, y: 0, width: 600, height: 400 },
76
+ { left: 40, bottom: 30 },
77
+ );
78
+ expect(result.panels[0]).toMatchObject({ key: 'A', row: 0, col: 0 });
79
+ expect(result.panels[1]).toMatchObject({ key: 'B', row: 0, col: 1 });
80
+ expect(result.panels[2]).toMatchObject({ key: 'C', row: 1, col: 0 });
81
+ });
82
+
83
+ it('panels do not overlap', () => {
84
+ const result = computeFacetGrid(
85
+ ['A', 'B', 'C', 'D', 'E', 'F'],
86
+ 3,
87
+ { x: 0, y: 0, width: 800, height: 600 },
88
+ { left: 40, bottom: 30 },
89
+ );
90
+ for (let i = 0; i < result.panels.length; i++) {
91
+ for (let j = i + 1; j < result.panels.length; j++) {
92
+ const a = result.panels[i].area;
93
+ const b = result.panels[j].area;
94
+ const xOverlap = a.x < b.x + b.width && a.x + a.width > b.x;
95
+ const yOverlap = a.y < b.y + b.height && a.y + a.height > b.y;
96
+ expect(xOverlap && yOverlap).toBe(false);
97
+ }
98
+ }
99
+ });
100
+
101
+ it('degrades columns when panels would be too narrow', () => {
102
+ const result = computeFacetGrid(
103
+ ['A', 'B', 'C', 'D'],
104
+ 4,
105
+ { x: 0, y: 0, width: 400, height: 400 },
106
+ { left: 40, bottom: 30 },
107
+ );
108
+ expect(result.columns).toBeLessThan(4);
109
+ for (const panel of result.panels) {
110
+ expect(panel.area.width).toBeGreaterThanOrEqual(0);
111
+ }
112
+ });
113
+
114
+ it('clamps panel height to zero for tiny containers', () => {
115
+ const result = computeFacetGrid(
116
+ ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J'],
117
+ 1,
118
+ { x: 0, y: 0, width: 400, height: 100 },
119
+ { left: 40, bottom: 30 },
120
+ );
121
+ for (const panel of result.panels) {
122
+ expect(panel.area.height).toBeGreaterThanOrEqual(0);
123
+ }
124
+ });
125
+ });
126
+
127
+ describe('compileChart with facet', () => {
128
+ it('populates layout.facet with correct panel count', () => {
129
+ const layout = compileChart(facetSpec, { width: 600, height: 400 });
130
+ expect(layout.facet).toBeDefined();
131
+ expect(layout.facet!.panels).toHaveLength(3);
132
+ expect(layout.facet!.facetField).toBe('country');
133
+ });
134
+
135
+ it('sorts panels ascending by default', () => {
136
+ const layout = compileChart(facetSpec, { width: 600, height: 400 });
137
+ const keys = layout.facet!.panels.map((p) => p.key);
138
+ expect(keys).toEqual(['DE', 'UK', 'US']);
139
+ });
140
+
141
+ it('respects descending sort', () => {
142
+ const spec = {
143
+ ...facetSpec,
144
+ encoding: {
145
+ ...facetSpec.encoding,
146
+ facet: { ...facetSpec.encoding.facet, sort: 'descending' as const },
147
+ },
148
+ };
149
+ const layout = compileChart(spec, { width: 600, height: 400 });
150
+ const keys = layout.facet!.panels.map((p) => p.key);
151
+ expect(keys).toEqual(['US', 'UK', 'DE']);
152
+ });
153
+
154
+ it('preserves data order with sort: null', () => {
155
+ const spec = {
156
+ ...facetSpec,
157
+ encoding: {
158
+ ...facetSpec.encoding,
159
+ facet: { ...facetSpec.encoding.facet, sort: null },
160
+ },
161
+ };
162
+ const layout = compileChart(spec, { width: 600, height: 400 });
163
+ const keys = layout.facet!.panels.map((p) => p.key);
164
+ expect(keys).toEqual(['US', 'UK', 'DE']);
165
+ });
166
+
167
+ it('uses shared scales by default', () => {
168
+ const layout = compileChart(facetSpec, { width: 600, height: 400 });
169
+ expect(layout.facet!.sharedScales).toBe(true);
170
+ });
171
+
172
+ it('supports independent y scales via resolve', () => {
173
+ const spec = {
174
+ ...facetSpec,
175
+ resolve: { scale: { y: 'independent' as const } },
176
+ };
177
+ const layout = compileChart(spec, { width: 600, height: 400 });
178
+ expect(layout.facet!.sharedScales).toBe(false);
179
+ });
180
+
181
+ it('each panel has marks and axes', () => {
182
+ const layout = compileChart(facetSpec, { width: 600, height: 400 });
183
+ for (const panel of layout.facet!.panels) {
184
+ expect(panel.marks.length).toBeGreaterThan(0);
185
+ expect(panel.area.width).toBeGreaterThan(0);
186
+ expect(panel.area.height).toBeGreaterThan(0);
187
+ expect(panel.header.text).toBeTruthy();
188
+ }
189
+ });
190
+
191
+ it('figure-level axes are undefined when faceted', () => {
192
+ const layout = compileChart(facetSpec, { width: 600, height: 400 });
193
+ expect(layout.axes.x).toBeUndefined();
194
+ expect(layout.axes.y).toBeUndefined();
195
+ });
196
+
197
+ it('marks union includes all panel marks', () => {
198
+ const layout = compileChart(facetSpec, { width: 600, height: 400 });
199
+ const panelMarkCount = layout.facet!.panels.reduce((sum, p) => sum + p.marks.length, 0);
200
+ expect(layout.marks.length).toBe(panelMarkCount);
201
+ });
202
+
203
+ it('non-faceted spec produces no facet layout', () => {
204
+ const spec = {
205
+ mark: 'line' as const,
206
+ data: facetData,
207
+ encoding: {
208
+ x: { field: 'year', type: 'ordinal' as const },
209
+ y: { field: 'value', type: 'quantitative' as const },
210
+ },
211
+ };
212
+ const layout = compileChart(spec, { width: 600, height: 400 });
213
+ expect(layout.facet).toBeUndefined();
214
+ });
215
+ });
@@ -672,6 +672,24 @@ describe('computeAnnotations', () => {
672
672
 
673
673
  expect(annotations).toHaveLength(0);
674
674
  });
675
+
676
+ it('keeps annotations at compact breakpoint when autoThin is on', () => {
677
+ const spec = makeSpec([{ type: 'text', x: '2020-01-01', y: 20, text: 'Kept' }]);
678
+ const scales = computeScales(spec, chartArea, spec.data);
679
+ const annotations = computeAnnotations(spec, {
680
+ scales,
681
+ chartArea,
682
+ strategy: compactStrategy,
683
+ isDark: false,
684
+ obstacles: [],
685
+ svg: { width: 320, height: 300 },
686
+ measure: (text, font) => text.length * font.fontSize * 0.6,
687
+ autoThin: true,
688
+ });
689
+
690
+ expect(annotations).toHaveLength(1);
691
+ expect(annotations[0].label!.text).toBe('Kept');
692
+ });
675
693
  });
676
694
 
677
695
  describe('multiple annotations', () => {
@@ -0,0 +1,137 @@
1
+ import type { Annotation, ResolvedAnnotation, TextAnnotation } from '@opendata-ai/openchart-core';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { thinAnnotations } from '../thinning';
4
+
5
+ const measure = (text: string, font: { fontSize: number }) => text.length * font.fontSize * 0.6;
6
+
7
+ function makeResolved(
8
+ x: number,
9
+ y: number,
10
+ text: string,
11
+ overrides?: Partial<ResolvedAnnotation>,
12
+ ): ResolvedAnnotation {
13
+ return {
14
+ type: 'text',
15
+ label: {
16
+ x,
17
+ y,
18
+ text,
19
+ visible: true,
20
+ style: { fontSize: 12, fontWeight: 400, textAnchor: 'start', lineHeight: 1.3 },
21
+ },
22
+ ...overrides,
23
+ } as ResolvedAnnotation;
24
+ }
25
+
26
+ function makeSpec(text: string, overrides?: Partial<TextAnnotation>): TextAnnotation {
27
+ return {
28
+ type: 'text',
29
+ x: 0,
30
+ y: 0,
31
+ text,
32
+ ...overrides,
33
+ } as TextAnnotation;
34
+ }
35
+
36
+ describe('thinAnnotations', () => {
37
+ it('returns unchanged when 0 or 1 annotations', () => {
38
+ const single = [makeResolved(100, 100, 'A')];
39
+ const result = thinAnnotations(single, [makeSpec('A')], measure);
40
+ expect(result.footnotes).toHaveLength(0);
41
+ expect(result.annotations).toBe(single);
42
+ });
43
+
44
+ it('keeps non-overlapping annotations in place', () => {
45
+ const annotations = [makeResolved(0, 0, 'First'), makeResolved(500, 500, 'Second')];
46
+ const specs: Annotation[] = [makeSpec('First'), makeSpec('Second')];
47
+ const result = thinAnnotations(annotations, specs, measure);
48
+ expect(result.footnotes).toHaveLength(0);
49
+ expect(result.annotations[0].footnoteIndex).toBeUndefined();
50
+ expect(result.annotations[1].footnoteIndex).toBeUndefined();
51
+ });
52
+
53
+ it('demotes overlapping annotations to footnotes', () => {
54
+ const annotations = [
55
+ makeResolved(100, 100, 'First label'),
56
+ makeResolved(105, 100, 'Second label'),
57
+ ];
58
+ const specs: Annotation[] = [makeSpec('First label'), makeSpec('Second label')];
59
+ const result = thinAnnotations(annotations, specs, measure);
60
+ expect(result.footnotes).toHaveLength(1);
61
+ expect(result.footnotes[0]).toEqual({ index: 1, text: 'Second label' });
62
+ expect(result.annotations[1].footnoteIndex).toBe(1);
63
+ expect(result.annotations[0].footnoteIndex).toBeUndefined();
64
+ });
65
+
66
+ it('respects priority ordering (lower priority kept first)', () => {
67
+ const annotations = [
68
+ makeResolved(100, 100, 'Low priority'),
69
+ makeResolved(105, 100, 'High priority'),
70
+ ];
71
+ const specs: Annotation[] = [
72
+ makeSpec('Low priority', { priority: 10 }),
73
+ makeSpec('High priority', { priority: 1 }),
74
+ ];
75
+ const result = thinAnnotations(annotations, specs, measure);
76
+ expect(result.footnotes).toHaveLength(1);
77
+ expect(result.footnotes[0].text).toBe('Low priority');
78
+ expect(result.annotations[1].footnoteIndex).toBeUndefined();
79
+ expect(result.annotations[0].footnoteIndex).toBe(1);
80
+ });
81
+
82
+ it('pins annotations with responsive: false', () => {
83
+ const annotations = [makeResolved(100, 100, 'Pinned'), makeResolved(105, 100, 'Candidate')];
84
+ const specs: Annotation[] = [makeSpec('Pinned', { responsive: false }), makeSpec('Candidate')];
85
+ const result = thinAnnotations(annotations, specs, measure);
86
+ expect(result.footnotes).toHaveLength(1);
87
+ expect(result.footnotes[0].text).toBe('Candidate');
88
+ expect(result.annotations[0].footnoteIndex).toBeUndefined();
89
+ });
90
+
91
+ it('does not thin non-text annotations', () => {
92
+ const annotations: ResolvedAnnotation[] = [
93
+ {
94
+ type: 'refline',
95
+ line: { start: { x: 0, y: 0 }, end: { x: 100, y: 0 } },
96
+ } as ResolvedAnnotation,
97
+ makeResolved(100, 100, 'Text'),
98
+ ];
99
+ const specs: Annotation[] = [{ type: 'refline', y: 0 }, makeSpec('Text')];
100
+ const result = thinAnnotations(annotations, specs, measure);
101
+ expect(result.footnotes).toHaveLength(0);
102
+ });
103
+
104
+ it('assigns sequential footnote indices', () => {
105
+ const annotations = [
106
+ makeResolved(100, 100, 'Keep this'),
107
+ makeResolved(105, 100, 'Demote 1'),
108
+ makeResolved(110, 100, 'Demote 2'),
109
+ ];
110
+ const specs: Annotation[] = [makeSpec('Keep this'), makeSpec('Demote 1'), makeSpec('Demote 2')];
111
+ const result = thinAnnotations(annotations, specs, measure);
112
+ expect(result.footnotes).toHaveLength(2);
113
+ expect(result.footnotes[0].index).toBe(1);
114
+ expect(result.footnotes[1].index).toBe(2);
115
+ });
116
+
117
+ it('maintains correct index alignment with mixed annotation types', () => {
118
+ const annotations: ResolvedAnnotation[] = [
119
+ {
120
+ type: 'refline',
121
+ line: { start: { x: 0, y: 0 }, end: { x: 100, y: 0 } },
122
+ } as ResolvedAnnotation,
123
+ makeResolved(100, 100, 'First text'),
124
+ makeResolved(105, 100, 'Second text'),
125
+ ];
126
+ const specs: Annotation[] = [
127
+ { type: 'refline', y: 0 },
128
+ makeSpec('First text', { priority: 1 }),
129
+ makeSpec('Second text', { priority: 10 }),
130
+ ];
131
+ const result = thinAnnotations(annotations, specs, measure);
132
+ expect(result.footnotes).toHaveLength(1);
133
+ expect(result.footnotes[0].text).toBe('Second text');
134
+ expect(result.annotations[1].footnoteIndex).toBeUndefined();
135
+ expect(result.annotations[2].footnoteIndex).toBe(1);
136
+ });
137
+ });
@@ -47,6 +47,8 @@ export interface AnnotationContext {
47
47
  svg: { width: number; height: number };
48
48
  measure: AnnotationMeasureTextFn;
49
49
  debugPlacement?: boolean;
50
+ /** When true, annotations that overlap are demoted to footnote markers instead of being hidden. */
51
+ autoThin?: boolean;
50
52
  }
51
53
 
52
54
  /**
@@ -105,7 +107,7 @@ export function computeAnnotations(
105
107
  };
106
108
  }
107
109
 
108
- const isCompact = ctx.strategy.annotationPosition === 'tooltip-only';
110
+ const isCompact = ctx.strategy.annotationPosition === 'tooltip-only' && !ctx.autoThin;
109
111
 
110
112
  const svgRect: Rect = { x: 0, y: 0, width: ctx.svg.width, height: ctx.svg.height };
111
113
  const annotations: ResolvedAnnotation[] = [];