@opendata-ai/openchart-engine 7.10.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 (159) hide show
  1. package/dist/index.d.ts +142 -7
  2. package/dist/index.js +16182 -10032
  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__/dimensions.test.ts +49 -4
  12. package/src/__tests__/helpers/invariants.ts +54 -20
  13. package/src/__tests__/known-bugs.test.ts +455 -2
  14. package/src/__tests__/legend.test.ts +120 -4
  15. package/src/__tests__/ranking-recipes.test.ts +260 -0
  16. package/src/__tests__/scales.test.ts +9 -3
  17. package/src/__tests__/series-search.test.ts +92 -0
  18. package/src/__tests__/spec-sugar.test.ts +801 -0
  19. package/src/__tests__/you-draw-it.test.ts +160 -0
  20. package/src/annotations/__tests__/compute.test.ts +207 -92
  21. package/src/annotations/__tests__/geometry.test.ts +66 -0
  22. package/src/annotations/__tests__/resolve-text.test.ts +527 -27
  23. package/src/annotations/__tests__/rich-text.test.ts +91 -0
  24. package/src/annotations/__tests__/thinning.test.ts +148 -0
  25. package/src/annotations/collisions.ts +130 -84
  26. package/src/annotations/compute.ts +83 -85
  27. package/src/annotations/constants.ts +116 -6
  28. package/src/annotations/geometry.ts +222 -77
  29. package/src/annotations/move.ts +135 -0
  30. package/src/annotations/placement.ts +62 -14
  31. package/src/annotations/resolve-range.ts +2 -0
  32. package/src/annotations/resolve-refline.ts +2 -0
  33. package/src/annotations/resolve-text.ts +312 -111
  34. package/src/annotations/rich-text.ts +65 -0
  35. package/src/annotations/thinning.ts +84 -10
  36. package/src/barlist/compile-barlist.ts +16 -6
  37. package/src/charts/_shared/format-label-value.ts +9 -8
  38. package/src/charts/bar/__tests__/compute.test.ts +57 -10
  39. package/src/charts/bar/__tests__/labels.test.ts +5 -4
  40. package/src/charts/bar/compute.ts +2 -6
  41. package/src/charts/bar/index.ts +8 -1
  42. package/src/charts/bar/labels.ts +3 -3
  43. package/src/charts/beeswarm/__tests__/compute.test.ts +280 -0
  44. package/src/charts/beeswarm/__tests__/dodge.test.ts +148 -0
  45. package/src/charts/beeswarm/compute.ts +203 -0
  46. package/src/charts/beeswarm/dodge.ts +155 -0
  47. package/src/charts/beeswarm/index.ts +32 -0
  48. package/src/charts/builtin.ts +12 -1
  49. package/src/charts/calendar/__tests__/compute.test.ts +417 -0
  50. package/src/charts/calendar/compute.ts +319 -0
  51. package/src/charts/calendar/index.ts +32 -0
  52. package/src/charts/column/__tests__/compute.test.ts +21 -7
  53. package/src/charts/column/__tests__/labels.test.ts +76 -1
  54. package/src/charts/column/compute.ts +2 -6
  55. package/src/charts/column/index.ts +10 -2
  56. package/src/charts/column/labels.ts +95 -21
  57. package/src/charts/dot/index.ts +8 -1
  58. package/src/charts/dot/labels.ts +3 -3
  59. package/src/charts/line/__tests__/compute.test.ts +15 -19
  60. package/src/charts/line/area.ts +8 -23
  61. package/src/charts/line/compute.ts +32 -2
  62. package/src/charts/parliament/__tests__/compute.test.ts +411 -0
  63. package/src/charts/parliament/compute.ts +313 -0
  64. package/src/charts/parliament/index.ts +32 -0
  65. package/src/charts/pie/__tests__/compute.test.ts +103 -0
  66. package/src/charts/pie/compute.ts +63 -8
  67. package/src/charts/pie/labels.ts +1 -0
  68. package/src/charts/range/__tests__/compute.test.ts +486 -0
  69. package/src/charts/range/compute.ts +273 -0
  70. package/src/charts/range/index.ts +82 -0
  71. package/src/charts/range/labels.ts +169 -0
  72. package/src/charts/rect/__tests__/compute.test.ts +120 -0
  73. package/src/charts/rect/index.ts +127 -0
  74. package/src/charts/scatter/compute.ts +7 -21
  75. package/src/charts/text/__tests__/compute.test.ts +307 -0
  76. package/src/charts/text/index.ts +44 -7
  77. package/src/charts/tick/__tests__/compute.test.ts +75 -0
  78. package/src/charts/tick/index.ts +7 -4
  79. package/src/charts/utils.ts +5 -3
  80. package/src/charts/waffle/__tests__/compute.test.ts +305 -0
  81. package/src/charts/waffle/compute.ts +197 -0
  82. package/src/charts/waffle/index.ts +31 -0
  83. package/src/compile/__tests__/color-scale-reverse.test.ts +89 -0
  84. package/src/compile/__tests__/data-clip.test.ts +68 -0
  85. package/src/compile/__tests__/fill-patterns.test.ts +154 -0
  86. package/src/compile/__tests__/size-scale.test.ts +108 -0
  87. package/src/compile/color-scale-range.ts +54 -33
  88. package/src/compile/data-clip.ts +21 -6
  89. package/src/compile/fill-patterns.ts +74 -0
  90. package/src/compile/layer.ts +117 -21
  91. package/src/compile/size-scale.ts +139 -0
  92. package/src/compile/spec-sugar.ts +758 -0
  93. package/src/compile/you-draw-it.ts +123 -0
  94. package/src/compile.ts +315 -103
  95. package/src/compiler/__tests__/a11y-warnings.test.ts +148 -0
  96. package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +19 -0
  97. package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +20 -0
  98. package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +22 -0
  99. package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +19 -0
  100. package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +14 -0
  101. package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +34 -0
  102. package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +22 -0
  103. package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +41 -0
  104. package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +38 -0
  105. package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +28 -0
  106. package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +28 -0
  107. package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +30 -0
  108. package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +29 -0
  109. package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +32 -0
  110. package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +25 -0
  111. package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +19 -0
  112. package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +19 -0
  113. package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +21 -0
  114. package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +23 -0
  115. package/src/compiler/__tests__/normalize.test.ts +65 -1
  116. package/src/compiler/__tests__/validate-did-you-mean.test.ts +86 -0
  117. package/src/compiler/__tests__/validate-near-miss.test.ts +89 -0
  118. package/src/compiler/__tests__/validate.test.ts +156 -0
  119. package/src/compiler/__tests__/vl-parity.test.ts +68 -0
  120. package/src/compiler/a11y-warnings.ts +110 -0
  121. package/src/compiler/normalize.ts +150 -7
  122. package/src/compiler/types.ts +11 -0
  123. package/src/compiler/validate.ts +664 -18
  124. package/src/endpoint-labels/__tests__/compute.test.ts +88 -0
  125. package/src/endpoint-labels/compute.ts +40 -12
  126. package/src/endpoint-labels/format.ts +45 -15
  127. package/src/endpoint-labels/predict.ts +50 -36
  128. package/src/format/__tests__/field-format.test.ts +164 -0
  129. package/src/format/field-format.ts +27 -0
  130. package/src/geo/__tests__/compile-map.test.ts +621 -0
  131. package/src/geo/compile-map.ts +856 -0
  132. package/src/geo/join.ts +115 -0
  133. package/src/geo/projections.ts +45 -0
  134. package/src/geo/types.ts +24 -0
  135. package/src/graphs/__tests__/compile-graph.test.ts +25 -0
  136. package/src/graphs/compile-graph.ts +27 -18
  137. package/src/index.ts +28 -2
  138. package/src/layout/axes/ticks.ts +59 -10
  139. package/src/layout/axes.ts +30 -2
  140. package/src/layout/dimensions.ts +178 -95
  141. package/src/layout/facet.ts +14 -3
  142. package/src/layout/plan.ts +86 -35
  143. package/src/layout/scales.ts +132 -15
  144. package/src/legend/__tests__/continuous.test.ts +318 -0
  145. package/src/legend/__tests__/size.test.ts +348 -0
  146. package/src/legend/__tests__/suppression.test.ts +17 -1
  147. package/src/legend/compute.ts +126 -14
  148. package/src/legend/continuous.ts +327 -0
  149. package/src/legend/size.ts +407 -0
  150. package/src/legend/suppression.ts +8 -7
  151. package/src/legend/wrap.ts +8 -0
  152. package/src/sankey/compile-sankey.ts +31 -19
  153. package/src/tables/format-cells.ts +16 -7
  154. package/src/tilemap/__tests__/compile-tilemap.test.ts +39 -0
  155. package/src/tilemap/compile-tilemap.ts +24 -7
  156. package/src/tooltips/__tests__/compute.test.ts +2 -2
  157. package/src/tooltips/compute.ts +422 -41
  158. package/src/transforms/__tests__/conditional.test.ts +18 -0
  159. package/src/transforms/aggregate.ts +2 -1
@@ -3,6 +3,7 @@ import { resolveTheme } from '@opendata-ai/openchart-core';
3
3
  import { describe, expect, it } from 'vitest';
4
4
  import { compileChart } from '../compile';
5
5
  import type { NormalizedChartSpec } from '../compiler/types';
6
+ import { INLINE_TICK_OVERHANG_PAD } from '../layout/shared';
6
7
  import { computeLegend } from '../legend/compute';
7
8
 
8
9
  const specWithColor: NormalizedChartSpec = {
@@ -418,16 +419,16 @@ describe('computeLegend', () => {
418
419
  expect(legend.entries).toHaveLength(3);
419
420
  });
420
421
 
421
- it('suppresses legend for default (overlap) area chart with labels', () => {
422
- // v6: area defaults to overlap. Endpoint labels identify series, so
423
- // the legend auto-suppresses just like line charts.
422
+ it('preserves legend for default (stacked) area chart', () => {
423
+ // v8: area stacks by default (Vega-Lite aligned). Stacked areas keep
424
+ // the traditional legend rather than auto-suppressing for endpoint labels.
424
425
  const areaSpec: NormalizedChartSpec = {
425
426
  ...lineWithLabels,
426
427
  markType: 'area',
427
428
  markDef: { type: 'area' },
428
429
  };
429
430
  const legend = computeLegend(areaSpec, fullStrategy, theme, chartArea);
430
- expect(legend.entries).toHaveLength(0);
431
+ expect(legend.entries).toHaveLength(3);
431
432
  });
432
433
 
433
434
  it('suppresses legend for explicit overlap area chart (stack: null)', () => {
@@ -510,6 +511,56 @@ describe('computeLegend', () => {
510
511
  const gap = layout.area.y - legendBottom;
511
512
  expect(gap).toBe(0);
512
513
  });
514
+
515
+ // Inline y ticks (line/area) draw their topmost label ABOVE chartArea.y,
516
+ // inside the inlineTickOverhang zone the margin stack reserved. The legend
517
+ // must sit above that zone, not flush against chartArea.y.
518
+ const inlineTopLegendSpec = {
519
+ mark: 'line' as const,
520
+ data: [
521
+ { date: '2020-01-01', value: 10, country: 'US' },
522
+ { date: '2021-01-01', value: 40, country: 'US' },
523
+ { date: '2020-01-01', value: 15, country: 'UK' },
524
+ { date: '2021-01-01', value: 35, country: 'UK' },
525
+ ],
526
+ encoding: {
527
+ x: { field: 'date', type: 'temporal' as const },
528
+ y: { field: 'value', type: 'quantitative' as const },
529
+ color: { field: 'country', type: 'nominal' as const },
530
+ },
531
+ legend: { show: true, position: 'top' as const },
532
+ };
533
+
534
+ it('top legend clears the inline y-tick overhang at compact width', () => {
535
+ const layout = compileChart(inlineTopLegendSpec, { width: 360, height: 400 });
536
+
537
+ expect(layout.legend.position).toBe('top');
538
+ expect(layout.legend.entries.length).toBeGreaterThan(0);
539
+
540
+ const inlineTickOverhang = layout.theme.fonts.sizes.axisTick + INLINE_TICK_OVERHANG_PAD;
541
+ const legendBottom = layout.legend.bounds.y + layout.legend.bounds.height;
542
+ expect(legendBottom).toBeLessThanOrEqual(layout.area.y - inlineTickOverhang + 1e-6);
543
+
544
+ // Pinned placement. Before the fix the legend bottom sat flush at
545
+ // chartArea.y (legend bottom was 62 = chartArea.y), overlapping the
546
+ // topmost inline tick label; it now sits the 17px overhang higher,
547
+ // while the 6px minimum gap above the legend shifts chartArea down.
548
+ expect(layout.legend.bounds.y).toBe(31);
549
+ });
550
+
551
+ it('gutter y-ticks keep the legend flush at the legendGap (no overhang shift)', () => {
552
+ // Bar charts use gutter y-tick labels, so inlineTickOverhang is 0 and
553
+ // the legend keeps its legendGap-only offset from the chart area.
554
+ const layout = compileChart(topLegendSpec, { width: 600, height: 400 });
555
+
556
+ const legendBottom = layout.legend.bounds.y + layout.legend.bounds.height;
557
+ expect(layout.area.y - legendBottom).toBe(8);
558
+
559
+ // Pinned placement: 17 before the placement fix; the +6 comes from the
560
+ // TOP_LEGEND_GAP_ABOVE margin (not from any overhang shift — gutter
561
+ // ticks have no overhang, so the legend-to-chart gap above stays 8).
562
+ expect(layout.legend.bounds.y).toBe(23);
563
+ });
513
564
  });
514
565
 
515
566
  describe('entryPositions contract', () => {
@@ -602,4 +653,69 @@ describe('computeLegend', () => {
602
653
  expect(legend.entries).toHaveLength(3);
603
654
  });
604
655
  });
656
+
657
+ describe('swatch colors match mark colors', () => {
658
+ const energySpec = {
659
+ mark: 'waffle' as const,
660
+ data: [
661
+ { source: 'Fossil fuels', share: 60 },
662
+ { source: 'Renewables', share: 30 },
663
+ { source: 'Nuclear', share: 10 },
664
+ ],
665
+ encoding: {
666
+ theta: { field: 'share', type: 'quantitative' as const },
667
+ color: { field: 'source', type: 'nominal' as const },
668
+ },
669
+ };
670
+
671
+ /**
672
+ * Map each category to the fill its marks actually render with. Waffle
673
+ * cells carry the category in `data`, not `seriesKey`, so key off the
674
+ * color field.
675
+ */
676
+ function markFillByLabel(layout: ReturnType<typeof compileChart>): Map<string, string> {
677
+ const fills = new Map<string, string>();
678
+ for (const mark of layout.marks) {
679
+ const label = mark.data?.source;
680
+ const fill = 'fill' in mark ? mark.fill : undefined;
681
+ if (typeof label === 'string' && typeof fill === 'string') fills.set(label, fill);
682
+ }
683
+ return fills;
684
+ }
685
+
686
+ it('mutes the swatches of non-highlighted series alongside their marks', () => {
687
+ const layout = compileChart(
688
+ {
689
+ ...energySpec,
690
+ encoding: {
691
+ ...energySpec.encoding,
692
+ color: { ...energySpec.encoding.color, highlight: 'Renewables' },
693
+ },
694
+ },
695
+ { width: 600, height: 400 },
696
+ );
697
+
698
+ const fills = markFillByLabel(layout);
699
+ expect(layout.legend.entries.length).toBe(3);
700
+ for (const entry of layout.legend.entries) {
701
+ expect(entry.color).toBe(fills.get(entry.label));
702
+ }
703
+
704
+ // The muted pair share one gray; the highlighted series does not.
705
+ const highlighted = layout.legend.entries.find((e) => e.label === 'Renewables');
706
+ const muted = layout.legend.entries.filter((e) => e.label !== 'Renewables');
707
+ expect(muted.map((e) => e.color)).toEqual([muted[0].color, muted[0].color]);
708
+ expect(highlighted?.color).not.toBe(muted[0].color);
709
+ });
710
+
711
+ it('matches mark colors when no highlight is active', () => {
712
+ const layout = compileChart(energySpec, { width: 600, height: 400 });
713
+
714
+ const fills = markFillByLabel(layout);
715
+ expect(layout.legend.entries.length).toBe(3);
716
+ for (const entry of layout.legend.entries) {
717
+ expect(entry.color).toBe(fills.get(entry.label));
718
+ }
719
+ });
720
+ });
605
721
  });
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Integration tests for the slope + bump chart recipes (plan 14).
3
+ *
4
+ * These are the exact specs published on docs/ranking-and-change.md and
5
+ * pinned as visual fixtures in examples/src/testing/fixtures-ranking.stories.tsx.
6
+ * They use only public spec surface: if a test here needs a private flag to
7
+ * pass, the recipe is broken.
8
+ */
9
+
10
+ import type { ChartSpec, LineMark, PointMark } from '@opendata-ai/openchart-core';
11
+ import { describe, expect, it } from 'vitest';
12
+
13
+ import { compileChart } from '../compile';
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Recipe specs (keep byte-identical to docs/ranking-and-change.md)
17
+ // ---------------------------------------------------------------------------
18
+
19
+ const slopeMarketShareSpec: ChartSpec = {
20
+ mark: 'line',
21
+ data: [
22
+ { year: '2019', brand: 'Samsung', share: 0.216 },
23
+ { year: '2024', brand: 'Samsung', share: 0.19 },
24
+ { year: '2019', brand: 'Huawei', share: 0.176 },
25
+ { year: '2024', brand: 'Huawei', share: 0.043 },
26
+ { year: '2019', brand: 'Apple', share: 0.139 },
27
+ { year: '2024', brand: 'Apple', share: 0.187 },
28
+ { year: '2019', brand: 'Xiaomi', share: 0.092 },
29
+ { year: '2024', brand: 'Xiaomi', share: 0.136 },
30
+ { year: '2019', brand: 'Oppo', share: 0.083 },
31
+ { year: '2024', brand: 'Oppo', share: 0.087 },
32
+ ],
33
+ encoding: {
34
+ x: { field: 'year', type: 'ordinal' },
35
+ y: {
36
+ field: 'share',
37
+ type: 'quantitative',
38
+ axis: false,
39
+ scale: { zero: false },
40
+ },
41
+ color: { field: 'brand', type: 'nominal' },
42
+ },
43
+ endpointLabels: {
44
+ ends: 'both',
45
+ content: 'label value',
46
+ format: '.0%',
47
+ },
48
+ legend: { show: false },
49
+ chrome: {
50
+ title: 'Apple and Xiaomi Split What Huawei Lost',
51
+ subtitle: 'Share of global smartphone shipments, 2019 vs 2024',
52
+ source: 'Source: IDC Worldwide Quarterly Mobile Phone Tracker',
53
+ },
54
+ };
55
+
56
+ const bumpConstructorsSpec: ChartSpec = {
57
+ mark: { type: 'line', interpolate: 'monotone', point: true },
58
+ data: [
59
+ { season: '2019', team: 'Mercedes', position: 1 },
60
+ { season: '2020', team: 'Mercedes', position: 1 },
61
+ { season: '2021', team: 'Mercedes', position: 1 },
62
+ { season: '2022', team: 'Mercedes', position: 3 },
63
+ { season: '2023', team: 'Mercedes', position: 2 },
64
+ { season: '2024', team: 'Mercedes', position: 4 },
65
+ { season: '2019', team: 'Red Bull', position: 3 },
66
+ { season: '2020', team: 'Red Bull', position: 2 },
67
+ { season: '2021', team: 'Red Bull', position: 2 },
68
+ { season: '2022', team: 'Red Bull', position: 1 },
69
+ { season: '2023', team: 'Red Bull', position: 1 },
70
+ { season: '2024', team: 'Red Bull', position: 3 },
71
+ { season: '2019', team: 'Ferrari', position: 2 },
72
+ { season: '2020', team: 'Ferrari', position: 6 },
73
+ { season: '2021', team: 'Ferrari', position: 3 },
74
+ { season: '2022', team: 'Ferrari', position: 2 },
75
+ { season: '2023', team: 'Ferrari', position: 3 },
76
+ { season: '2024', team: 'Ferrari', position: 2 },
77
+ { season: '2019', team: 'McLaren', position: 4 },
78
+ { season: '2020', team: 'McLaren', position: 3 },
79
+ { season: '2021', team: 'McLaren', position: 4 },
80
+ { season: '2022', team: 'McLaren', position: 5 },
81
+ { season: '2023', team: 'McLaren', position: 4 },
82
+ { season: '2024', team: 'McLaren', position: 1 },
83
+ { season: '2019', team: 'Alpine', position: 5 },
84
+ { season: '2020', team: 'Alpine', position: 5 },
85
+ { season: '2021', team: 'Alpine', position: 5 },
86
+ { season: '2022', team: 'Alpine', position: 4 },
87
+ { season: '2023', team: 'Alpine', position: 6 },
88
+ { season: '2024', team: 'Alpine', position: 6 },
89
+ ],
90
+ encoding: {
91
+ x: { field: 'season', type: 'ordinal' },
92
+ y: {
93
+ field: 'position',
94
+ type: 'quantitative',
95
+ scale: { reverse: true, zero: false },
96
+ axis: { values: [1, 2, 3, 4, 5, 6], format: 'ordinal' },
97
+ },
98
+ color: { field: 'team', type: 'nominal' },
99
+ },
100
+ endpointLabels: {
101
+ ends: 'both',
102
+ content: 'label',
103
+ showMarker: false,
104
+ },
105
+ legend: { show: false },
106
+ chrome: {
107
+ title: 'McLaren Went From Midfield to Champions',
108
+ subtitle: 'Formula 1 constructors championship, final position by season',
109
+ source: 'Source: FIA official standings',
110
+ },
111
+ };
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Slope recipe
115
+ // ---------------------------------------------------------------------------
116
+
117
+ describe('slope chart recipe', () => {
118
+ it('renders both-end labels for all five series', () => {
119
+ const layout = compileChart(slopeMarketShareSpec, { width: 640, height: 420 });
120
+
121
+ expect(layout.endpointLabels?.entries).toHaveLength(5);
122
+ expect(layout.endpointLabels?.leading).toHaveLength(5);
123
+ });
124
+
125
+ it("composes 'name value' on one line at both ends", () => {
126
+ const layout = compileChart(slopeMarketShareSpec, { width: 640, height: 420 });
127
+
128
+ const left = layout.endpointLabels!.leading!.find((e) => e.seriesKey === 'Samsung');
129
+ expect(left!.labelLines[0]).toBe('Samsung 22%');
130
+ expect(left!.value).toBe('');
131
+
132
+ const right = layout.endpointLabels!.entries.find((e) => e.seriesKey === 'Samsung');
133
+ expect(right!.labelLines[0]).toBe('Samsung 19%');
134
+ expect(right!.value).toBe('');
135
+ });
136
+
137
+ it('renders no y axis and no gridlines', () => {
138
+ const layout = compileChart(slopeMarketShareSpec, { width: 640, height: 420 });
139
+
140
+ expect(layout.axes.y).toBeUndefined();
141
+ });
142
+
143
+ it('orders labels by value at each end (Huawei falls from 2nd to last)', () => {
144
+ const layout = compileChart(slopeMarketShareSpec, { width: 640, height: 420 });
145
+
146
+ const byY = (a: { labelY: number }, b: { labelY: number }) => a.labelY - b.labelY;
147
+ const leftOrder = [...layout.endpointLabels!.leading!].sort(byY).map((e) => e.seriesKey);
148
+ expect(leftOrder).toEqual(['Samsung', 'Huawei', 'Apple', 'Xiaomi', 'Oppo']);
149
+
150
+ const rightOrder = [...layout.endpointLabels!.entries].sort(byY).map((e) => e.seriesKey);
151
+ expect(rightOrder).toEqual(['Samsung', 'Apple', 'Xiaomi', 'Oppo', 'Huawei']);
152
+ });
153
+
154
+ it('keeps both-end labels and a positive plot area at 320px (compact breakpoint)', () => {
155
+ const layout = compileChart(slopeMarketShareSpec, { width: 320, height: 360 });
156
+
157
+ expect(layout.endpointLabels?.entries).toHaveLength(5);
158
+ expect(layout.endpointLabels?.leading).toHaveLength(5);
159
+ expect(layout.area.width).toBeGreaterThan(0);
160
+ // Both label columns fit inside the 320px canvas.
161
+ expect(layout.endpointLabels!.leadingBounds!.x).toBeGreaterThanOrEqual(0);
162
+ expect(
163
+ layout.endpointLabels!.bounds.x + layout.endpointLabels!.bounds.width,
164
+ ).toBeLessThanOrEqual(320);
165
+ });
166
+ });
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // Bump recipe
170
+ // ---------------------------------------------------------------------------
171
+
172
+ describe('bump chart recipe', () => {
173
+ it('puts rank 1 at the top via scale.reverse', () => {
174
+ const layout = compileChart(bumpConstructorsSpec, { width: 640, height: 460 });
175
+
176
+ const ticks = layout.axes.y!.ticks;
177
+ const first = ticks.find((t) => t.value === 1);
178
+ const last = ticks.find((t) => t.value === 6);
179
+ expect(first).toBeDefined();
180
+ expect(last).toBeDefined();
181
+ expect(first!.position).toBeLessThan(last!.position);
182
+ });
183
+
184
+ it('labels rank ticks as ordinals, 1st through 6th top to bottom', () => {
185
+ const layout = compileChart(bumpConstructorsSpec, { width: 640, height: 460 });
186
+
187
+ const labels = [...layout.axes.y!.ticks]
188
+ .sort((a, b) => a.position - b.position)
189
+ .map((t) => t.label);
190
+ expect(labels).toEqual(['1st', '2nd', '3rd', '4th', '5th', '6th']);
191
+ });
192
+
193
+ it('draws a point marker at every step of every series', () => {
194
+ const layout = compileChart(bumpConstructorsSpec, { width: 640, height: 460 });
195
+
196
+ const points = layout.marks.filter(
197
+ (m): m is PointMark => m.type === 'point' && m.opacity !== 0,
198
+ );
199
+ // 5 teams x 6 seasons.
200
+ expect(points).toHaveLength(30);
201
+ });
202
+
203
+ it('orders both-end name labels by rank at each end', () => {
204
+ const layout = compileChart(bumpConstructorsSpec, { width: 640, height: 460 });
205
+
206
+ const byY = (a: { labelY: number }, b: { labelY: number }) => a.labelY - b.labelY;
207
+ // 2019: Mercedes 1st, Ferrari 2nd, Red Bull 3rd, McLaren 4th, Alpine 5th.
208
+ const leftOrder = [...layout.endpointLabels!.leading!].sort(byY).map((e) => e.seriesKey);
209
+ expect(leftOrder).toEqual(['Mercedes', 'Ferrari', 'Red Bull', 'McLaren', 'Alpine']);
210
+ // 2024: McLaren 1st, Ferrari 2nd, Red Bull 3rd, Mercedes 4th, Alpine 6th.
211
+ const rightOrder = [...layout.endpointLabels!.entries].sort(byY).map((e) => e.seriesKey);
212
+ expect(rightOrder).toEqual(['McLaren', 'Ferrari', 'Red Bull', 'Mercedes', 'Alpine']);
213
+ });
214
+
215
+ it('renders name-only labels (no value line)', () => {
216
+ const layout = compileChart(bumpConstructorsSpec, { width: 640, height: 460 });
217
+
218
+ for (const entry of layout.endpointLabels!.entries) {
219
+ expect(entry.labelLines).toEqual([entry.seriesKey]);
220
+ expect(entry.value).toBe('');
221
+ }
222
+ });
223
+
224
+ it('maps tied ranks to the same y (ties share a row)', () => {
225
+ const tieSpec: ChartSpec = {
226
+ ...bumpConstructorsSpec,
227
+ data: [
228
+ { season: '2023', team: 'A', position: 1 },
229
+ { season: '2024', team: 'A', position: 2 },
230
+ { season: '2023', team: 'B', position: 2 },
231
+ { season: '2024', team: 'B', position: 2 },
232
+ { season: '2023', team: 'C', position: 3 },
233
+ { season: '2024', team: 'C', position: 1 },
234
+ ],
235
+ encoding: {
236
+ ...bumpConstructorsSpec.encoding,
237
+ y: {
238
+ field: 'position',
239
+ type: 'quantitative',
240
+ scale: { reverse: true, zero: false },
241
+ axis: { values: [1, 2, 3], format: 'ordinal' },
242
+ },
243
+ },
244
+ };
245
+ const layout = compileChart(tieSpec, { width: 640, height: 400 });
246
+
247
+ const lines = layout.marks.filter(
248
+ (m): m is LineMark => m.type === 'line' && m.seriesKey != null,
249
+ );
250
+ const lastY = (key: string) => {
251
+ const mark = lines.find((m) => m.seriesKey === key)!;
252
+ const pts = mark.dataPoints ?? [];
253
+ return pts[pts.length - 1]!.y;
254
+ };
255
+ // A and B tie for 2nd in 2024: identical y.
256
+ expect(lastY('A')).toBeCloseTo(lastY('B'), 6);
257
+ // C is 1st, above the tied pair (smaller y = higher rank).
258
+ expect(lastY('C')).toBeLessThan(lastY('A'));
259
+ });
260
+ });
@@ -62,15 +62,21 @@ describe('computeScales', () => {
62
62
  expect(scales.y).toBeDefined();
63
63
  expect(scales.y!.type).toBe('linear');
64
64
 
65
- // Y is inverted (SVG coordinates)
65
+ // Y is inverted (SVG coordinates); line charts default to zero: false
66
66
  const domain = scales.y!.scale.domain();
67
- expect(domain[0]).toBeLessThanOrEqual(0); // Should include 0
67
+ expect(domain[0]).toBeLessThanOrEqual(10);
68
68
  expect(domain[1]).toBeGreaterThanOrEqual(50);
69
69
  });
70
70
 
71
- it('includes zero in quantitative domain by default', () => {
71
+ it('line charts exclude zero by default (zero: false)', () => {
72
72
  const scales = computeScales(lineSpec, chartArea, lineSpec.data);
73
73
  const domain = scales.y!.scale.domain();
74
+ expect(domain[0]).toBeGreaterThan(0);
75
+ });
76
+
77
+ it('bar charts include zero by default', () => {
78
+ const scales = computeScales(barSpec, chartArea, barSpec.data);
79
+ const domain = scales.x!.scale.domain();
74
80
  expect(domain[0]).toBe(0);
75
81
  });
76
82
 
@@ -0,0 +1,92 @@
1
+ /**
2
+ * seriesSearch compilation: band reservation and resolved content.
3
+ *
4
+ * The engine reserves an empty SVG band below chrome (and the metric bar)
5
+ * and exposes its rect + the searchable values on layout.seriesSearch; the
6
+ * vanilla adapter overlays the DOM combobox on that rect.
7
+ */
8
+
9
+ import { describe, expect, it, vi } from 'vitest';
10
+ import { compileChart } from '../compile';
11
+
12
+ const searchSpec = {
13
+ mark: 'line' as const,
14
+ data: [
15
+ { date: '2020-01-01', value: 10, country: 'US' },
16
+ { date: '2021-01-01', value: 40, country: 'US' },
17
+ { date: '2020-01-01', value: 15, country: 'UK' },
18
+ { date: '2021-01-01', value: 35, country: 'UK' },
19
+ { date: '2020-01-01', value: 22, country: 'France' },
20
+ { date: '2021-01-01', value: 28, country: 'France' },
21
+ ],
22
+ encoding: {
23
+ x: { field: 'date', type: 'temporal' as const },
24
+ y: { field: 'value', type: 'quantitative' as const },
25
+ color: { field: 'country', type: 'nominal' as const },
26
+ },
27
+ chrome: { title: 'Find your country' },
28
+ };
29
+
30
+ describe('compileChart seriesSearch', () => {
31
+ it('omits the band when seriesSearch is not set', () => {
32
+ const layout = compileChart(searchSpec, { width: 600, height: 400 });
33
+ expect(layout.seriesSearch).toBeUndefined();
34
+ });
35
+
36
+ it('reserves a band below chrome and shifts the chart area down', () => {
37
+ const base = compileChart(searchSpec, { width: 600, height: 400 });
38
+ const layout = compileChart({ ...searchSpec, seriesSearch: true }, { width: 600, height: 400 });
39
+
40
+ const band = layout.seriesSearch;
41
+ expect(band).toBeDefined();
42
+ expect(band!.height).toBeGreaterThan(0);
43
+ expect(band!.width).toBeGreaterThan(0);
44
+ // Band sits below the top chrome block and above the chart area
45
+ expect(band!.y).toBeGreaterThanOrEqual(layout.chrome.topHeight);
46
+ expect(band!.y + band!.height).toBeLessThanOrEqual(layout.area.y);
47
+ // The reservation pushes the plot down relative to the no-search layout
48
+ expect(layout.area.y).toBeGreaterThan(base.area.y);
49
+ });
50
+
51
+ it('exposes the distinct color values and resolved placeholder', () => {
52
+ const layout = compileChart(
53
+ { ...searchSpec, seriesSearch: { placeholder: 'Find a country' } },
54
+ { width: 600, height: 400 },
55
+ );
56
+ expect(layout.seriesSearch?.values).toEqual(['US', 'UK', 'France']);
57
+ expect(layout.seriesSearch?.placeholder).toBe('Find a country');
58
+
59
+ const defaulted = compileChart(
60
+ { ...searchSpec, seriesSearch: true },
61
+ { width: 600, height: 400 },
62
+ );
63
+ expect(defaulted.seriesSearch?.placeholder).toBe('Find a series');
64
+ });
65
+
66
+ it('warns and disables when there is no categorical color encoding', () => {
67
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
68
+ try {
69
+ const layout = compileChart(
70
+ {
71
+ mark: 'line' as const,
72
+ data: [
73
+ { date: '2020-01-01', value: 10 },
74
+ { date: '2021-01-01', value: 40 },
75
+ ],
76
+ encoding: {
77
+ x: { field: 'date', type: 'temporal' as const },
78
+ y: { field: 'value', type: 'quantitative' as const },
79
+ },
80
+ seriesSearch: true,
81
+ },
82
+ { width: 600, height: 400 },
83
+ );
84
+ expect(layout.seriesSearch).toBeUndefined();
85
+ expect(warn).toHaveBeenCalledWith(
86
+ expect.stringContaining('seriesSearch requires a categorical color encoding'),
87
+ );
88
+ } finally {
89
+ warn.mockRestore();
90
+ }
91
+ });
92
+ });