@opendata-ai/openchart-engine 1.2.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 (85) hide show
  1. package/dist/index.d.ts +366 -0
  2. package/dist/index.js +4227 -0
  3. package/dist/index.js.map +1 -0
  4. package/package.json +62 -0
  5. package/src/__test-fixtures__/specs.ts +124 -0
  6. package/src/__tests__/axes.test.ts +114 -0
  7. package/src/__tests__/compile-chart.test.ts +337 -0
  8. package/src/__tests__/dimensions.test.ts +151 -0
  9. package/src/__tests__/legend.test.ts +113 -0
  10. package/src/__tests__/scales.test.ts +109 -0
  11. package/src/annotations/__tests__/compute.test.ts +454 -0
  12. package/src/annotations/compute.ts +603 -0
  13. package/src/charts/__tests__/registry.test.ts +110 -0
  14. package/src/charts/bar/__tests__/compute.test.ts +294 -0
  15. package/src/charts/bar/__tests__/labels.test.ts +75 -0
  16. package/src/charts/bar/compute.ts +205 -0
  17. package/src/charts/bar/index.ts +33 -0
  18. package/src/charts/bar/labels.ts +132 -0
  19. package/src/charts/column/__tests__/compute.test.ts +277 -0
  20. package/src/charts/column/compute.ts +282 -0
  21. package/src/charts/column/index.ts +33 -0
  22. package/src/charts/column/labels.ts +108 -0
  23. package/src/charts/dot/__tests__/compute.test.ts +344 -0
  24. package/src/charts/dot/compute.ts +257 -0
  25. package/src/charts/dot/index.ts +46 -0
  26. package/src/charts/dot/labels.ts +97 -0
  27. package/src/charts/line/__tests__/compute.test.ts +437 -0
  28. package/src/charts/line/__tests__/labels.test.ts +93 -0
  29. package/src/charts/line/area.ts +288 -0
  30. package/src/charts/line/compute.ts +177 -0
  31. package/src/charts/line/index.ts +68 -0
  32. package/src/charts/line/labels.ts +144 -0
  33. package/src/charts/pie/__tests__/compute.test.ts +276 -0
  34. package/src/charts/pie/compute.ts +234 -0
  35. package/src/charts/pie/index.ts +49 -0
  36. package/src/charts/pie/labels.ts +142 -0
  37. package/src/charts/registry.ts +64 -0
  38. package/src/charts/scatter/__tests__/compute.test.ts +304 -0
  39. package/src/charts/scatter/__tests__/trendline.test.ts +191 -0
  40. package/src/charts/scatter/compute.ts +124 -0
  41. package/src/charts/scatter/index.ts +41 -0
  42. package/src/charts/scatter/trendline.ts +100 -0
  43. package/src/charts/utils.ts +120 -0
  44. package/src/compile.ts +368 -0
  45. package/src/compiler/__tests__/compile.test.ts +87 -0
  46. package/src/compiler/__tests__/normalize.test.ts +210 -0
  47. package/src/compiler/__tests__/validate.test.ts +440 -0
  48. package/src/compiler/index.ts +47 -0
  49. package/src/compiler/normalize.ts +269 -0
  50. package/src/compiler/types.ts +148 -0
  51. package/src/compiler/validate.ts +581 -0
  52. package/src/graphs/__tests__/community.test.ts +228 -0
  53. package/src/graphs/__tests__/compile-graph.test.ts +315 -0
  54. package/src/graphs/__tests__/encoding.test.ts +314 -0
  55. package/src/graphs/community.ts +92 -0
  56. package/src/graphs/compile-graph.ts +291 -0
  57. package/src/graphs/encoding.ts +302 -0
  58. package/src/graphs/types.ts +98 -0
  59. package/src/index.ts +74 -0
  60. package/src/layout/axes.ts +194 -0
  61. package/src/layout/dimensions.ts +199 -0
  62. package/src/layout/gridlines.ts +84 -0
  63. package/src/layout/scales.ts +426 -0
  64. package/src/legend/compute.ts +186 -0
  65. package/src/tables/__tests__/bar-column.test.ts +147 -0
  66. package/src/tables/__tests__/category-colors.test.ts +153 -0
  67. package/src/tables/__tests__/compile-table.test.ts +208 -0
  68. package/src/tables/__tests__/format-cells.test.ts +126 -0
  69. package/src/tables/__tests__/heatmap.test.ts +124 -0
  70. package/src/tables/__tests__/pagination.test.ts +78 -0
  71. package/src/tables/__tests__/search.test.ts +94 -0
  72. package/src/tables/__tests__/sort.test.ts +107 -0
  73. package/src/tables/__tests__/sparkline.test.ts +122 -0
  74. package/src/tables/bar-column.ts +94 -0
  75. package/src/tables/category-colors.ts +67 -0
  76. package/src/tables/compile-table.ts +420 -0
  77. package/src/tables/format-cells.ts +110 -0
  78. package/src/tables/heatmap.ts +121 -0
  79. package/src/tables/pagination.ts +46 -0
  80. package/src/tables/search.ts +66 -0
  81. package/src/tables/sort.ts +69 -0
  82. package/src/tables/sparkline.ts +113 -0
  83. package/src/tables/utils.ts +16 -0
  84. package/src/tooltips/__tests__/compute.test.ts +328 -0
  85. package/src/tooltips/compute.ts +231 -0
@@ -0,0 +1,437 @@
1
+ import type { LayoutStrategy, LineMark, PointMark, Rect } from '@opendata-ai/openchart-core';
2
+ import { describe, expect, it } from 'vitest';
3
+ import type { NormalizedChartSpec } from '../../../compiler/types';
4
+ import { computeScales } from '../../../layout/scales';
5
+ import { computeAreaMarks } from '../area';
6
+ import { computeLineMarks } from '../compute';
7
+ import { computeLineLabels } from '../labels';
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Shared fixtures
11
+ // ---------------------------------------------------------------------------
12
+
13
+ const chartArea: Rect = { x: 50, y: 20, width: 500, height: 300 };
14
+
15
+ const fullStrategy: LayoutStrategy = {
16
+ labelMode: 'all',
17
+ legendPosition: 'right',
18
+ annotationPosition: 'inline',
19
+ axisLabelDensity: 'full',
20
+ };
21
+
22
+ const compactStrategy: LayoutStrategy = {
23
+ labelMode: 'none',
24
+ legendPosition: 'top',
25
+ annotationPosition: 'tooltip-only',
26
+ axisLabelDensity: 'minimal',
27
+ };
28
+
29
+ function makeSingleSeriesSpec(): NormalizedChartSpec {
30
+ return {
31
+ type: 'line',
32
+ data: [
33
+ { date: '2020-01-01', value: 10 },
34
+ { date: '2021-01-01', value: 40 },
35
+ { date: '2022-01-01', value: 30 },
36
+ ],
37
+ encoding: {
38
+ x: { field: 'date', type: 'temporal' },
39
+ y: { field: 'value', type: 'quantitative' },
40
+ },
41
+ chrome: {},
42
+ annotations: [],
43
+ responsive: true,
44
+ theme: {},
45
+ darkMode: 'off',
46
+ labels: { density: 'auto', format: '' },
47
+ };
48
+ }
49
+
50
+ function makeMultiSeriesSpec(): NormalizedChartSpec {
51
+ return {
52
+ type: 'line',
53
+ data: [
54
+ { date: '2020-01-01', value: 10, country: 'US' },
55
+ { date: '2021-01-01', value: 40, country: 'US' },
56
+ { date: '2022-01-01', value: 30, country: 'US' },
57
+ { date: '2020-01-01', value: 15, country: 'UK' },
58
+ { date: '2021-01-01', value: 35, country: 'UK' },
59
+ { date: '2022-01-01', value: 45, country: 'UK' },
60
+ ],
61
+ encoding: {
62
+ x: { field: 'date', type: 'temporal' },
63
+ y: { field: 'value', type: 'quantitative' },
64
+ color: { field: 'country', type: 'nominal' },
65
+ },
66
+ chrome: {},
67
+ annotations: [],
68
+ responsive: true,
69
+ theme: {},
70
+ darkMode: 'off',
71
+ labels: { density: 'auto', format: '' },
72
+ };
73
+ }
74
+
75
+ function makeMissingDataSpec(): NormalizedChartSpec {
76
+ return {
77
+ type: 'line',
78
+ data: [
79
+ { date: '2020-01-01', value: 10 },
80
+ { date: '2021-01-01', value: null },
81
+ { date: '2022-01-01', value: 30 },
82
+ ],
83
+ encoding: {
84
+ x: { field: 'date', type: 'temporal' },
85
+ y: { field: 'value', type: 'quantitative' },
86
+ },
87
+ chrome: {},
88
+ annotations: [],
89
+ responsive: true,
90
+ theme: {},
91
+ darkMode: 'off',
92
+ labels: { density: 'auto', format: '' },
93
+ };
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Line mark computation tests
98
+ // ---------------------------------------------------------------------------
99
+
100
+ describe('computeLineMarks', () => {
101
+ describe('single series', () => {
102
+ it('produces one LineMark and PointMarks for each data point', () => {
103
+ const spec = makeSingleSeriesSpec();
104
+ const scales = computeScales(spec, chartArea, spec.data);
105
+ const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
106
+
107
+ const lineMarks = marks.filter((m): m is LineMark => m.type === 'line');
108
+ const pointMarks = marks.filter((m): m is PointMark => m.type === 'point');
109
+
110
+ expect(lineMarks).toHaveLength(1);
111
+ expect(pointMarks).toHaveLength(3);
112
+ });
113
+
114
+ it('line mark has correct number of points', () => {
115
+ const spec = makeSingleSeriesSpec();
116
+ const scales = computeScales(spec, chartArea, spec.data);
117
+ const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
118
+
119
+ const lineMark = marks.find((m): m is LineMark => m.type === 'line')!;
120
+ expect(lineMark.points).toHaveLength(3);
121
+ });
122
+
123
+ it('line mark points are within chart area bounds', () => {
124
+ const spec = makeSingleSeriesSpec();
125
+ const scales = computeScales(spec, chartArea, spec.data);
126
+ const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
127
+
128
+ const lineMark = marks.find((m): m is LineMark => m.type === 'line')!;
129
+ for (const point of lineMark.points) {
130
+ expect(point.x).toBeGreaterThanOrEqual(chartArea.x);
131
+ expect(point.x).toBeLessThanOrEqual(chartArea.x + chartArea.width);
132
+ // Y axis is inverted (higher values = lower y)
133
+ expect(point.y).toBeGreaterThanOrEqual(chartArea.y);
134
+ expect(point.y).toBeLessThanOrEqual(chartArea.y + chartArea.height);
135
+ }
136
+ });
137
+
138
+ it('single series has no seriesKey', () => {
139
+ const spec = makeSingleSeriesSpec();
140
+ const scales = computeScales(spec, chartArea, spec.data);
141
+ const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
142
+
143
+ const lineMark = marks.find((m): m is LineMark => m.type === 'line')!;
144
+ expect(lineMark.seriesKey).toBeUndefined();
145
+ });
146
+
147
+ it('point marks have invisible fill (for hover only)', () => {
148
+ const spec = makeSingleSeriesSpec();
149
+ const scales = computeScales(spec, chartArea, spec.data);
150
+ const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
151
+
152
+ const pointMarks = marks.filter((m): m is PointMark => m.type === 'point');
153
+ for (const pm of pointMarks) {
154
+ expect(pm.fillOpacity).toBe(0);
155
+ }
156
+ });
157
+ });
158
+
159
+ describe('multi-series', () => {
160
+ it('produces separate LineMarks for each series', () => {
161
+ const spec = makeMultiSeriesSpec();
162
+ const scales = computeScales(spec, chartArea, spec.data);
163
+ const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
164
+
165
+ const lineMarks = marks.filter((m): m is LineMark => m.type === 'line');
166
+ expect(lineMarks).toHaveLength(2);
167
+ });
168
+
169
+ it('each series has a distinct seriesKey', () => {
170
+ const spec = makeMultiSeriesSpec();
171
+ const scales = computeScales(spec, chartArea, spec.data);
172
+ const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
173
+
174
+ const lineMarks = marks.filter((m): m is LineMark => m.type === 'line');
175
+ const keys = lineMarks.map((m) => m.seriesKey);
176
+ expect(keys).toContain('US');
177
+ expect(keys).toContain('UK');
178
+ });
179
+
180
+ it('each series has different stroke colors', () => {
181
+ const spec = makeMultiSeriesSpec();
182
+ const scales = computeScales(spec, chartArea, spec.data);
183
+ const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
184
+
185
+ const lineMarks = marks.filter((m): m is LineMark => m.type === 'line');
186
+ expect(lineMarks[0].stroke).not.toBe(lineMarks[1].stroke);
187
+ });
188
+
189
+ it('produces point marks for all data points across all series', () => {
190
+ const spec = makeMultiSeriesSpec();
191
+ const scales = computeScales(spec, chartArea, spec.data);
192
+ const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
193
+
194
+ const pointMarks = marks.filter((m): m is PointMark => m.type === 'point');
195
+ // 3 points per series * 2 series = 6
196
+ expect(pointMarks).toHaveLength(6);
197
+ });
198
+ });
199
+
200
+ describe('missing data', () => {
201
+ it('breaks line at null values - fewer points in the line', () => {
202
+ const spec = makeMissingDataSpec();
203
+ const scales = computeScales(spec, chartArea, spec.data);
204
+ const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
205
+
206
+ const lineMark = marks.find((m): m is LineMark => m.type === 'line')!;
207
+ // null value is excluded, so only 2 valid points remain
208
+ expect(lineMark.points).toHaveLength(2);
209
+ });
210
+
211
+ it('produces point marks only for valid data points', () => {
212
+ const spec = makeMissingDataSpec();
213
+ const scales = computeScales(spec, chartArea, spec.data);
214
+ const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
215
+
216
+ const pointMarks = marks.filter((m): m is PointMark => m.type === 'point');
217
+ expect(pointMarks).toHaveLength(2);
218
+ });
219
+ });
220
+
221
+ describe('edge cases', () => {
222
+ it('returns empty array when no x encoding', () => {
223
+ const spec: NormalizedChartSpec = {
224
+ type: 'line',
225
+ data: [{ value: 10 }],
226
+ encoding: {
227
+ y: { field: 'value', type: 'quantitative' },
228
+ },
229
+ chrome: {},
230
+ annotations: [],
231
+ responsive: true,
232
+ theme: {},
233
+ darkMode: 'off',
234
+ labels: { density: 'auto', format: '' },
235
+ };
236
+ const scales = computeScales(spec, chartArea, spec.data);
237
+ const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
238
+ expect(marks).toHaveLength(0);
239
+ });
240
+
241
+ it('returns empty array for empty data', () => {
242
+ const spec: NormalizedChartSpec = {
243
+ type: 'line',
244
+ data: [],
245
+ encoding: {
246
+ x: { field: 'date', type: 'temporal' },
247
+ y: { field: 'value', type: 'quantitative' },
248
+ },
249
+ chrome: {},
250
+ annotations: [],
251
+ responsive: true,
252
+ theme: {},
253
+ darkMode: 'off',
254
+ labels: { density: 'auto', format: '' },
255
+ };
256
+ const scales = computeScales(spec, chartArea, spec.data);
257
+ const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
258
+ expect(marks).toHaveLength(0);
259
+ });
260
+ });
261
+ });
262
+
263
+ // ---------------------------------------------------------------------------
264
+ // Area mark computation tests
265
+ // ---------------------------------------------------------------------------
266
+
267
+ describe('computeAreaMarks', () => {
268
+ it('produces an AreaMark with a non-empty path for single series', () => {
269
+ const spec = makeSingleSeriesSpec();
270
+ // Change type to 'area' for the renderer (though compute doesn't check type)
271
+ const scales = computeScales(spec, chartArea, spec.data);
272
+ const marks = computeAreaMarks(spec, scales, chartArea);
273
+
274
+ expect(marks).toHaveLength(1);
275
+ expect(marks[0].type).toBe('area');
276
+ expect(marks[0].path).toBeTruthy();
277
+ expect(marks[0].path.length).toBeGreaterThan(0);
278
+ });
279
+
280
+ it('area mark has top and bottom boundary points', () => {
281
+ const spec = makeSingleSeriesSpec();
282
+ const scales = computeScales(spec, chartArea, spec.data);
283
+ const marks = computeAreaMarks(spec, scales, chartArea);
284
+
285
+ const area = marks[0];
286
+ expect(area.topPoints).toHaveLength(3);
287
+ expect(area.bottomPoints).toHaveLength(3);
288
+ });
289
+
290
+ it('area fill has appropriate opacity', () => {
291
+ const spec = makeSingleSeriesSpec();
292
+ const scales = computeScales(spec, chartArea, spec.data);
293
+ const marks = computeAreaMarks(spec, scales, chartArea);
294
+
295
+ expect(marks[0].fillOpacity).toBeGreaterThan(0);
296
+ expect(marks[0].fillOpacity).toBeLessThanOrEqual(1);
297
+ });
298
+
299
+ it('stacked areas: produces multiple AreaMarks for multi-series', () => {
300
+ const spec = makeMultiSeriesSpec();
301
+ const scales = computeScales(spec, chartArea, spec.data);
302
+ const marks = computeAreaMarks(spec, scales, chartArea);
303
+
304
+ expect(marks.length).toBeGreaterThanOrEqual(2);
305
+ const seriesKeys = marks.map((m) => m.seriesKey).filter(Boolean);
306
+ expect(seriesKeys).toContain('US');
307
+ expect(seriesKeys).toContain('UK');
308
+ });
309
+
310
+ it('stacked areas: each layer has different baselines', () => {
311
+ const spec = makeMultiSeriesSpec();
312
+ const scales = computeScales(spec, chartArea, spec.data);
313
+ const marks = computeAreaMarks(spec, scales, chartArea);
314
+
315
+ // First layer should start at y=0 baseline, second layer starts higher
316
+ if (marks.length >= 2) {
317
+ const firstBottom = marks[0].bottomPoints[0]?.y;
318
+ const secondBottom = marks[1].bottomPoints[0]?.y;
319
+ // They should be different (stacked offset)
320
+ expect(firstBottom).not.toBe(secondBottom);
321
+ }
322
+ });
323
+ });
324
+
325
+ // ---------------------------------------------------------------------------
326
+ // Label computation tests
327
+ // ---------------------------------------------------------------------------
328
+
329
+ describe('computeLineLabels', () => {
330
+ it('produces labels for multi-series line marks', () => {
331
+ const spec = makeMultiSeriesSpec();
332
+ const scales = computeScales(spec, chartArea, spec.data);
333
+ const allMarks = computeLineMarks(spec, scales, chartArea, fullStrategy);
334
+
335
+ const lineMarks = allMarks.filter((m): m is LineMark => m.type === 'line');
336
+ const labelMap = computeLineLabels(lineMarks, fullStrategy);
337
+
338
+ expect(labelMap.size).toBe(2);
339
+ expect(labelMap.has('US')).toBe(true);
340
+ expect(labelMap.has('UK')).toBe(true);
341
+ });
342
+
343
+ it('labels are positioned at end of line', () => {
344
+ const spec = makeMultiSeriesSpec();
345
+ const scales = computeScales(spec, chartArea, spec.data);
346
+ const allMarks = computeLineMarks(spec, scales, chartArea, fullStrategy);
347
+
348
+ const lineMarks = allMarks.filter((m): m is LineMark => m.type === 'line');
349
+ const labelMap = computeLineLabels(lineMarks, fullStrategy);
350
+
351
+ for (const lineMark of lineMarks) {
352
+ if (!lineMark.seriesKey) continue;
353
+ const lastPoint = lineMark.points[lineMark.points.length - 1];
354
+ const label = labelMap.get(lineMark.seriesKey);
355
+ expect(label).toBeDefined();
356
+ // Label x should be to the right of the last point
357
+ expect(label!.x).toBeGreaterThan(lastPoint.x);
358
+ }
359
+ });
360
+
361
+ it('labels are visible at full width', () => {
362
+ const spec = makeMultiSeriesSpec();
363
+ const scales = computeScales(spec, chartArea, spec.data);
364
+ const allMarks = computeLineMarks(spec, scales, chartArea, fullStrategy);
365
+
366
+ const lineMarks = allMarks.filter((m): m is LineMark => m.type === 'line');
367
+ const labelMap = computeLineLabels(lineMarks, fullStrategy);
368
+
369
+ // At least some labels should be visible
370
+ const labels = Array.from(labelMap.values());
371
+ expect(labels.some((l) => l.visible)).toBe(true);
372
+ });
373
+
374
+ it('labels collapse at compact breakpoint', () => {
375
+ const spec = makeMultiSeriesSpec();
376
+ const scales = computeScales(spec, chartArea, spec.data);
377
+ const allMarks = computeLineMarks(spec, scales, chartArea, compactStrategy);
378
+
379
+ const lineMarks = allMarks.filter((m): m is LineMark => m.type === 'line');
380
+ const labelMap = computeLineLabels(lineMarks, compactStrategy);
381
+
382
+ // At compact, no labels should be produced
383
+ expect(labelMap.size).toBe(0);
384
+ });
385
+
386
+ it('collision detection resolves overlapping labels', () => {
387
+ // Create a spec where series end at the same y position
388
+ const spec: NormalizedChartSpec = {
389
+ type: 'line',
390
+ data: [
391
+ { date: '2020-01-01', value: 10, country: 'A' },
392
+ { date: '2021-01-01', value: 30, country: 'A' },
393
+ { date: '2020-01-01', value: 10, country: 'B' },
394
+ { date: '2021-01-01', value: 30, country: 'B' },
395
+ { date: '2020-01-01', value: 10, country: 'C' },
396
+ { date: '2021-01-01', value: 30, country: 'C' },
397
+ ],
398
+ encoding: {
399
+ x: { field: 'date', type: 'temporal' },
400
+ y: { field: 'value', type: 'quantitative' },
401
+ color: { field: 'country', type: 'nominal' },
402
+ },
403
+ chrome: {},
404
+ annotations: [],
405
+ responsive: true,
406
+ theme: {},
407
+ darkMode: 'off',
408
+ labels: { density: 'auto', format: '' },
409
+ };
410
+
411
+ const scales = computeScales(spec, chartArea, spec.data);
412
+ const allMarks = computeLineMarks(spec, scales, chartArea, fullStrategy);
413
+ const lineMarks = allMarks.filter((m): m is LineMark => m.type === 'line');
414
+ const labelMap = computeLineLabels(lineMarks, fullStrategy);
415
+
416
+ expect(labelMap.size).toBe(3);
417
+
418
+ // At least one label should be offset or demoted due to collision
419
+ const labels = Array.from(labelMap.values());
420
+ const positions = labels.map((l) => `${l.x},${l.y}`);
421
+ const uniquePositions = new Set(positions);
422
+ // If collision worked, positions should differ even though anchor points are the same
423
+ expect(uniquePositions.size).toBeGreaterThanOrEqual(2);
424
+ });
425
+
426
+ it('skips single series with no seriesKey', () => {
427
+ const spec = makeSingleSeriesSpec();
428
+ const scales = computeScales(spec, chartArea, spec.data);
429
+ const allMarks = computeLineMarks(spec, scales, chartArea, fullStrategy);
430
+
431
+ const lineMarks = allMarks.filter((m): m is LineMark => m.type === 'line');
432
+ const labelMap = computeLineLabels(lineMarks, fullStrategy);
433
+
434
+ // Single series has no seriesKey, so no label
435
+ expect(labelMap.size).toBe(0);
436
+ });
437
+ });
@@ -0,0 +1,93 @@
1
+ import type { LayoutStrategy, LineMark } from '@opendata-ai/openchart-core';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { computeLineLabels } from '../labels';
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Fixtures
7
+ // ---------------------------------------------------------------------------
8
+
9
+ const fullStrategy: LayoutStrategy = {
10
+ labelMode: 'all',
11
+ legendPosition: 'right',
12
+ annotationPosition: 'inline',
13
+ axisLabelDensity: 'full',
14
+ };
15
+
16
+ const compactStrategy: LayoutStrategy = {
17
+ labelMode: 'none',
18
+ legendPosition: 'top',
19
+ annotationPosition: 'tooltip-only',
20
+ axisLabelDensity: 'minimal',
21
+ };
22
+
23
+ function makeLine(series: string, color: string, yOffset: number): LineMark {
24
+ return {
25
+ type: 'line',
26
+ points: [
27
+ { x: 50, y: 100 + yOffset },
28
+ { x: 150, y: 80 + yOffset },
29
+ { x: 250, y: 120 + yOffset },
30
+ { x: 350, y: 60 + yOffset },
31
+ ],
32
+ stroke: color,
33
+ strokeWidth: 2,
34
+ seriesKey: series,
35
+ data: [],
36
+ aria: { label: `${series} trend line` },
37
+ };
38
+ }
39
+
40
+ const marks: LineMark[] = [
41
+ makeLine('US', '#4e79a7', 0),
42
+ makeLine('UK', '#f28e2c', 30),
43
+ makeLine('Canada', '#e15759', 60),
44
+ ];
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Tests
48
+ // ---------------------------------------------------------------------------
49
+
50
+ describe('computeLineLabels density modes', () => {
51
+ it('density "auto" produces labels with collision detection', () => {
52
+ const labelMap = computeLineLabels(marks, fullStrategy, 'auto');
53
+ expect(labelMap.size).toBeGreaterThan(0);
54
+ // Each label should have a visibility flag
55
+ for (const label of labelMap.values()) {
56
+ expect(typeof label.visible).toBe('boolean');
57
+ }
58
+ });
59
+
60
+ it('density "all" shows every series label as visible', () => {
61
+ const labelMap = computeLineLabels(marks, fullStrategy, 'all');
62
+ expect(labelMap.size).toBe(3);
63
+ expect(labelMap.has('US')).toBe(true);
64
+ expect(labelMap.has('UK')).toBe(true);
65
+ expect(labelMap.has('Canada')).toBe(true);
66
+ for (const label of labelMap.values()) {
67
+ expect(label.visible).toBe(true);
68
+ }
69
+ });
70
+
71
+ it('density "none" returns empty map', () => {
72
+ const labelMap = computeLineLabels(marks, fullStrategy, 'none');
73
+ expect(labelMap.size).toBe(0);
74
+ });
75
+
76
+ it('density "endpoints" works like auto for line charts', () => {
77
+ const autoMap = computeLineLabels(marks, fullStrategy, 'auto');
78
+ const endpointsMap = computeLineLabels(marks, fullStrategy, 'endpoints');
79
+ // Line labels are already endpoint labels (end-of-line), so same behavior
80
+ expect(endpointsMap.size).toBe(autoMap.size);
81
+ });
82
+
83
+ it('compact strategy suppresses labels regardless of density', () => {
84
+ const labelMap = computeLineLabels(marks, compactStrategy, 'all');
85
+ expect(labelMap.size).toBe(0);
86
+ });
87
+
88
+ it('default density is "auto"', () => {
89
+ const withAuto = computeLineLabels(marks, fullStrategy, 'auto');
90
+ const withDefault = computeLineLabels(marks, fullStrategy);
91
+ expect(withDefault.size).toBe(withAuto.size);
92
+ });
93
+ });