@opendata-ai/openchart-engine 7.11.0 → 8.0.0-rc.10

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 (160) hide show
  1. package/dist/index.d.ts +124 -14
  2. package/dist/index.js +16254 -9417
  3. package/dist/index.js.map +1 -1
  4. package/package.json +11 -4
  5. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +232 -56
  6. package/src/__tests__/annotation-behavior.test.ts +852 -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__/facet.test.ts +144 -0
  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 +69 -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 +228 -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 +318 -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 +121 -21
  91. package/src/compile/size-scale.ts +141 -0
  92. package/src/compile/spec-sugar.ts +767 -0
  93. package/src/compile/you-draw-it.ts +123 -0
  94. package/src/compile.ts +415 -110
  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__/keys.test.ts +3 -9
  116. package/src/compiler/__tests__/normalize.test.ts +65 -1
  117. package/src/compiler/__tests__/validate-did-you-mean.test.ts +170 -0
  118. package/src/compiler/__tests__/validate-near-miss.test.ts +251 -0
  119. package/src/compiler/__tests__/validate.test.ts +258 -0
  120. package/src/compiler/__tests__/vl-parity.test.ts +68 -0
  121. package/src/compiler/a11y-warnings.ts +110 -0
  122. package/src/compiler/keys.ts +5 -4
  123. package/src/compiler/normalize.ts +151 -7
  124. package/src/compiler/types.ts +11 -0
  125. package/src/compiler/validate.ts +961 -32
  126. package/src/endpoint-labels/__tests__/compute.test.ts +88 -0
  127. package/src/endpoint-labels/compute.ts +40 -12
  128. package/src/endpoint-labels/format.ts +45 -15
  129. package/src/endpoint-labels/predict.ts +50 -36
  130. package/src/format/__tests__/field-format.test.ts +164 -0
  131. package/src/format/field-format.ts +27 -0
  132. package/src/geo/__tests__/compile-map.test.ts +1128 -0
  133. package/src/geo/compile-map.ts +1376 -0
  134. package/src/geo/join.ts +115 -0
  135. package/src/geo/projections.ts +65 -0
  136. package/src/geo/types.ts +26 -0
  137. package/src/graphs/__tests__/compile-graph.test.ts +25 -0
  138. package/src/graphs/compile-graph.ts +27 -18
  139. package/src/index.ts +20 -2
  140. package/src/layout/axes/ticks.ts +59 -10
  141. package/src/layout/axes.ts +35 -6
  142. package/src/layout/dimensions.ts +139 -83
  143. package/src/layout/facet.ts +14 -3
  144. package/src/layout/plan.ts +83 -33
  145. package/src/layout/scales.ts +132 -15
  146. package/src/legend/__tests__/continuous.test.ts +318 -0
  147. package/src/legend/__tests__/size.test.ts +348 -0
  148. package/src/legend/__tests__/suppression.test.ts +17 -1
  149. package/src/legend/compute.ts +123 -16
  150. package/src/legend/continuous.ts +327 -0
  151. package/src/legend/size.ts +407 -0
  152. package/src/legend/suppression.ts +8 -7
  153. package/src/sankey/compile-sankey.ts +31 -19
  154. package/src/tables/format-cells.ts +16 -7
  155. package/src/tilemap/__tests__/compile-tilemap.test.ts +39 -0
  156. package/src/tilemap/compile-tilemap.ts +24 -7
  157. package/src/tooltips/__tests__/compute.test.ts +2 -2
  158. package/src/tooltips/compute.ts +422 -41
  159. package/src/transforms/__tests__/conditional.test.ts +18 -0
  160. package/src/transforms/aggregate.ts +2 -1
@@ -0,0 +1,486 @@
1
+ import type {
2
+ LayoutStrategy,
3
+ PointMark,
4
+ Rect,
5
+ RectMark,
6
+ RuleMarkLayout,
7
+ } from '@opendata-ai/openchart-core';
8
+ import { resolveTheme } from '@opendata-ai/openchart-core';
9
+ import { describe, expect, it } from 'vitest';
10
+ import { compileChart } from '../../../compile';
11
+ import type { NormalizedChartSpec } from '../../../compiler/types';
12
+ import { computeScales } from '../../../layout/scales';
13
+ import { computeRangeMarks, resolveRangeOrientation } from '../compute';
14
+ import { computeRangeLabels, type RangeDotPair } from '../labels';
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Shared fixtures
18
+ // ---------------------------------------------------------------------------
19
+
20
+ const chartArea: Rect = { x: 80, y: 20, width: 500, height: 300 };
21
+
22
+ const fullStrategy: LayoutStrategy = {
23
+ labelMode: 'all',
24
+ legendPosition: 'right',
25
+ annotationPosition: 'inline',
26
+ axisLabelDensity: 'full',
27
+ };
28
+
29
+ const theme = resolveTheme();
30
+
31
+ const lifeExpectancy = [
32
+ { country: 'Japan', y2000: 81.1, y2024: 87.9 },
33
+ { country: 'USA', y2000: 79.5, y2024: 81.3 },
34
+ { country: 'Brazil', y2000: 74.1, y2024: 80.3 },
35
+ ];
36
+
37
+ /** Data with both increases and decreases for direction coloring. */
38
+ const mixedDirection = [
39
+ { source: 'Coal', y2010: 45, y2024: 15 },
40
+ { source: 'Gas', y2010: 24, y2024: 43 },
41
+ { source: 'Wind', y2010: 2, y2024: 11 },
42
+ ];
43
+
44
+ function makeHorizontalRangeSpec(
45
+ overrides: Partial<NormalizedChartSpec> = {},
46
+ ): NormalizedChartSpec {
47
+ return {
48
+ markType: 'range',
49
+ markDef: { type: 'range' },
50
+ data: [...lifeExpectancy],
51
+ encoding: {
52
+ y: { field: 'country', type: 'nominal' },
53
+ x: { field: 'y2000', type: 'quantitative' },
54
+ x2: { field: 'y2024' },
55
+ },
56
+ chrome: {},
57
+ annotations: [],
58
+ responsive: true,
59
+ theme: {},
60
+ darkMode: 'off',
61
+ labels: { density: 'auto', format: '' },
62
+ ...overrides,
63
+ } as NormalizedChartSpec;
64
+ }
65
+
66
+ function makeVerticalRangeSpec(): NormalizedChartSpec {
67
+ return makeHorizontalRangeSpec({
68
+ data: [
69
+ { month: 'Jan', lo: 27, hi: 39 },
70
+ { month: 'Jul', lo: 69, hi: 85 },
71
+ ],
72
+ encoding: {
73
+ x: { field: 'month', type: 'nominal' },
74
+ y: { field: 'lo', type: 'quantitative' },
75
+ y2: { field: 'hi' },
76
+ },
77
+ });
78
+ }
79
+
80
+ function marksFor(spec: NormalizedChartSpec) {
81
+ const scales = computeScales(spec, chartArea, spec.data);
82
+ return computeRangeMarks(spec, scales, chartArea, fullStrategy, theme);
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // computeRangeMarks: dumbbell (default)
87
+ // ---------------------------------------------------------------------------
88
+
89
+ describe('computeRangeMarks', () => {
90
+ describe('dumbbell style (default)', () => {
91
+ it('emits a connector rule plus start/end dots per row', () => {
92
+ const marks = marksFor(makeHorizontalRangeSpec());
93
+
94
+ const rules = marks.filter((m): m is RuleMarkLayout => m.type === 'rule');
95
+ const points = marks.filter((m): m is PointMark => m.type === 'point');
96
+ expect(rules).toHaveLength(3);
97
+ expect(points).toHaveLength(6);
98
+ });
99
+
100
+ it('positions dots at the start and end values on the x scale', () => {
101
+ const spec = makeHorizontalRangeSpec();
102
+ const scales = computeScales(spec, chartArea, spec.data);
103
+ const marks = computeRangeMarks(spec, scales, chartArea, fullStrategy, theme);
104
+
105
+ const points = marks.filter((m): m is PointMark => m.type === 'point');
106
+ const japanStart = points.find((m) => m.key === 'Japan|start')!;
107
+ const japanEnd = points.find((m) => m.key === 'Japan|end')!;
108
+
109
+ // 2024 value is larger, so the end dot sits further right
110
+ expect(japanEnd.cx).toBeGreaterThan(japanStart.cx);
111
+
112
+ // Both dots share the band-center y
113
+ expect(japanEnd.cy).toBe(japanStart.cy);
114
+
115
+ // Connector spans exactly between the dots
116
+ const connector = marks.find(
117
+ (m): m is RuleMarkLayout => m.type === 'rule' && m.key === 'Japan|range',
118
+ )!;
119
+ expect(connector.x1).toBe(japanStart.cx);
120
+ expect(connector.x2).toBe(japanEnd.cx);
121
+ });
122
+
123
+ it('mutes the start dot and puts the accent on the end dot', () => {
124
+ const marks = marksFor(makeHorizontalRangeSpec());
125
+ const points = marks.filter((m): m is PointMark => m.type === 'point');
126
+
127
+ const start = points.find((m) => m.key === 'Japan|start')!;
128
+ const end = points.find((m) => m.key === 'Japan|end')!;
129
+ expect(start.fill).toBe(theme.colors.axis);
130
+ expect(end.fill).not.toBe(theme.colors.axis);
131
+ });
132
+
133
+ it('skips rows with non-finite endpoint values', () => {
134
+ const spec = makeHorizontalRangeSpec({
135
+ data: [
136
+ { country: 'Japan', y2000: 81.1, y2024: 87.9 },
137
+ { country: 'Nowhere', y2000: null, y2024: 87 },
138
+ ],
139
+ });
140
+ const marks = marksFor(spec);
141
+ expect(marks.filter((m) => m.type === 'point')).toHaveLength(2);
142
+ });
143
+
144
+ it('colors connector and end dot by an explicit color encoding, keeping the start dot muted', () => {
145
+ const spec = makeHorizontalRangeSpec({
146
+ data: [
147
+ { country: 'Japan', y2000: 81.1, y2024: 87.9, region: 'Asia' },
148
+ { country: 'USA', y2000: 79.5, y2024: 81.3, region: 'Americas' },
149
+ ],
150
+ encoding: {
151
+ y: { field: 'country', type: 'nominal' },
152
+ x: { field: 'y2000', type: 'quantitative' },
153
+ x2: { field: 'y2024' },
154
+ color: { field: 'region', type: 'nominal' },
155
+ },
156
+ });
157
+ const scales = computeScales(spec, chartArea, spec.data);
158
+ const colorScale = scales.color!.scale as (v: string) => string;
159
+ const marks = computeRangeMarks(spec, scales, chartArea, fullStrategy, theme);
160
+
161
+ const points = marks.filter((m): m is PointMark => m.type === 'point');
162
+ const japanEnd = points.find((m) => m.key === 'Japan|end')!;
163
+ const japanStart = points.find((m) => m.key === 'Japan|start')!;
164
+ const connector = marks.find(
165
+ (m): m is RuleMarkLayout => m.type === 'rule' && m.key === 'Japan|range',
166
+ )!;
167
+
168
+ expect(japanEnd.fill).toBe(colorScale('Asia'));
169
+ expect(connector.stroke).toBe(colorScale('Asia'));
170
+ expect(japanStart.fill).toBe(theme.colors.axis);
171
+ });
172
+ });
173
+
174
+ // -------------------------------------------------------------------------
175
+ // Arrow style
176
+ // -------------------------------------------------------------------------
177
+
178
+ describe('arrow style', () => {
179
+ it('emits shaft plus two arrowhead segments per row, no dots', () => {
180
+ const spec = makeHorizontalRangeSpec({ markDef: { type: 'range', style: 'arrow' } });
181
+ const marks = marksFor(spec);
182
+
183
+ expect(marks.filter((m) => m.type === 'point')).toHaveLength(0);
184
+ const rules = marks.filter((m): m is RuleMarkLayout => m.type === 'rule');
185
+ expect(rules).toHaveLength(9); // 3 rows x (shaft + 2 head segments)
186
+ });
187
+
188
+ it('puts the arrowhead tip at the x2 end', () => {
189
+ const spec = makeHorizontalRangeSpec({ markDef: { type: 'range', style: 'arrow' } });
190
+ const marks = marksFor(spec);
191
+
192
+ const shaft = marks.find(
193
+ (m): m is RuleMarkLayout => m.type === 'rule' && m.key === 'Japan|range',
194
+ )!;
195
+ const headLeft = marks.find(
196
+ (m): m is RuleMarkLayout => m.type === 'rule' && m.key === 'Japan|head-left',
197
+ )!;
198
+ const headRight = marks.find(
199
+ (m): m is RuleMarkLayout => m.type === 'rule' && m.key === 'Japan|head-right',
200
+ )!;
201
+
202
+ // Both head segments converge on the shaft's end point
203
+ expect(headLeft.x2).toBe(shaft.x2);
204
+ expect(headLeft.y2).toBe(shaft.y2);
205
+ expect(headRight.x2).toBe(shaft.x2);
206
+ expect(headRight.y2).toBe(shaft.y2);
207
+
208
+ // Head segments are decorative for screen readers (the shaft carries the label)
209
+ expect(headLeft.aria.decorative).toBe(true);
210
+ expect(headRight.aria.decorative).toBe(true);
211
+ });
212
+
213
+ it('colors increases positive and decreases negative with colorByDirection', () => {
214
+ const spec = makeHorizontalRangeSpec({
215
+ markDef: { type: 'range', style: 'arrow', colorByDirection: true },
216
+ data: [...mixedDirection],
217
+ encoding: {
218
+ y: { field: 'source', type: 'nominal' },
219
+ x: { field: 'y2010', type: 'quantitative' },
220
+ x2: { field: 'y2024' },
221
+ },
222
+ });
223
+ const marks = marksFor(spec);
224
+
225
+ const coal = marks.find(
226
+ (m): m is RuleMarkLayout => m.type === 'rule' && m.key === 'Coal|range',
227
+ )!;
228
+ const gas = marks.find(
229
+ (m): m is RuleMarkLayout => m.type === 'rule' && m.key === 'Gas|range',
230
+ )!;
231
+ expect(coal.stroke).toBe(theme.colors.negative); // 45 -> 15
232
+ expect(gas.stroke).toBe(theme.colors.positive); // 24 -> 43
233
+ });
234
+
235
+ it('lets a field-based color encoding win over colorByDirection', () => {
236
+ const spec = makeHorizontalRangeSpec({
237
+ markDef: { type: 'range', style: 'arrow', colorByDirection: true },
238
+ data: [{ source: 'Coal', y2010: 45, y2024: 15, kind: 'fossil' }],
239
+ encoding: {
240
+ y: { field: 'source', type: 'nominal' },
241
+ x: { field: 'y2010', type: 'quantitative' },
242
+ x2: { field: 'y2024' },
243
+ color: { field: 'kind', type: 'nominal' },
244
+ },
245
+ });
246
+ const scales = computeScales(spec, chartArea, spec.data);
247
+ const colorScale = scales.color!.scale as (v: string) => string;
248
+ const marks = computeRangeMarks(spec, scales, chartArea, fullStrategy, theme);
249
+
250
+ const shaft = marks.find(
251
+ (m): m is RuleMarkLayout => m.type === 'rule' && m.key === 'Coal|range',
252
+ )!;
253
+ expect(shaft.stroke).toBe(colorScale('fossil'));
254
+ expect(shaft.stroke).not.toBe(theme.colors.negative);
255
+ });
256
+ });
257
+
258
+ // -------------------------------------------------------------------------
259
+ // Bar style
260
+ // -------------------------------------------------------------------------
261
+
262
+ describe('bar style', () => {
263
+ it('emits one floating rect per row spanning start to end', () => {
264
+ const spec = makeHorizontalRangeSpec({ markDef: { type: 'range', style: 'bar' } });
265
+ const scales = computeScales(spec, chartArea, spec.data);
266
+ const marks = computeRangeMarks(spec, scales, chartArea, fullStrategy, theme);
267
+
268
+ const rects = marks.filter((m): m is RectMark => m.type === 'rect');
269
+ expect(rects).toHaveLength(3);
270
+ expect(marks.filter((m) => m.type === 'point')).toHaveLength(0);
271
+ expect(marks.filter((m) => m.type === 'rule')).toHaveLength(0);
272
+
273
+ const japan = rects.find((m) => m.key === 'Japan')!;
274
+ const xScale = scales.x!.scale as (v: number) => number;
275
+ expect(japan.x).toBeCloseTo(Math.min(xScale(81.1), xScale(87.9)));
276
+ expect(japan.width).toBeCloseTo(Math.abs(xScale(87.9) - xScale(81.1)));
277
+ });
278
+ });
279
+
280
+ // -------------------------------------------------------------------------
281
+ // Vertical orientation (y + y2, nominal x)
282
+ // -------------------------------------------------------------------------
283
+
284
+ describe('vertical orientation', () => {
285
+ it('resolves orientation from the band scale axis', () => {
286
+ const hSpec = makeHorizontalRangeSpec();
287
+ const hScales = computeScales(hSpec, chartArea, hSpec.data);
288
+ expect(resolveRangeOrientation(hScales)).toBe('horizontal');
289
+
290
+ const vSpec = makeVerticalRangeSpec();
291
+ const vScales = computeScales(vSpec, chartArea, vSpec.data);
292
+ expect(resolveRangeOrientation(vScales)).toBe('vertical');
293
+ });
294
+
295
+ it('spans dots vertically at the band center x', () => {
296
+ const spec = makeVerticalRangeSpec();
297
+ const marks = marksFor(spec);
298
+
299
+ const points = marks.filter((m): m is PointMark => m.type === 'point');
300
+ expect(points).toHaveLength(4);
301
+
302
+ const janStart = points.find((m) => m.key === 'Jan|start')!;
303
+ const janEnd = points.find((m) => m.key === 'Jan|end')!;
304
+ // Same band center on x; the higher value has a smaller (inverted) y
305
+ expect(janEnd.cx).toBe(janStart.cx);
306
+ expect(janEnd.cy).toBeLessThan(janStart.cy);
307
+ });
308
+ });
309
+ });
310
+
311
+ // ---------------------------------------------------------------------------
312
+ // computeRangeLabels
313
+ // ---------------------------------------------------------------------------
314
+
315
+ describe('computeRangeLabels', () => {
316
+ function dotPairsFor(spec: NormalizedChartSpec): RangeDotPair[] {
317
+ const marks = marksFor(spec);
318
+ const dots = marks.filter((m): m is PointMark => m.type === 'point');
319
+ const pairs: RangeDotPair[] = [];
320
+ for (let i = 0; i + 1 < dots.length; i += 2) {
321
+ pairs.push({ start: dots[i], end: dots[i + 1] });
322
+ }
323
+ return pairs;
324
+ }
325
+
326
+ it('produces two labels per pair with density all', () => {
327
+ const pairs = dotPairsFor(makeHorizontalRangeSpec());
328
+ const labels = computeRangeLabels(pairs, true, 'all', '', '', 'y2000', 'y2024');
329
+ expect(labels).toHaveLength(6);
330
+ expect(labels.every((l) => l.visible)).toBe(true);
331
+ });
332
+
333
+ it('places the lower-value label left of its dot and the higher right', () => {
334
+ const pairs = dotPairsFor(makeHorizontalRangeSpec());
335
+ const labels = computeRangeLabels(pairs, true, 'all', '', '', 'y2000', 'y2024');
336
+
337
+ const startLabel = labels.find((l) => l.index === 0)!;
338
+ const endLabel = labels.find((l) => l.index === 1)!;
339
+ expect(startLabel.x).toBeLessThan(pairs[0].start.cx);
340
+ expect(endLabel.x).toBeGreaterThan(pairs[0].end.cx);
341
+ });
342
+
343
+ it('returns nothing for density none', () => {
344
+ const pairs = dotPairsFor(makeHorizontalRangeSpec());
345
+ expect(computeRangeLabels(pairs, true, 'none', '', '', 'y2000', 'y2024')).toHaveLength(0);
346
+ });
347
+ });
348
+
349
+ // ---------------------------------------------------------------------------
350
+ // compileChart integration (spec in, layout out)
351
+ // ---------------------------------------------------------------------------
352
+
353
+ describe('compileChart with range marks', () => {
354
+ const baseSpec = {
355
+ mark: 'range' as const,
356
+ data: [...lifeExpectancy],
357
+ encoding: {
358
+ y: { field: 'country', type: 'nominal' as const },
359
+ x: { field: 'y2000', type: 'quantitative' as const },
360
+ x2: { field: 'y2024' },
361
+ },
362
+ };
363
+
364
+ it('renders horizontal dumbbells sorted by the end value', () => {
365
+ const layout = compileChart(
366
+ {
367
+ ...baseSpec,
368
+ encoding: {
369
+ ...baseSpec.encoding,
370
+ y: {
371
+ field: 'country',
372
+ type: 'nominal' as const,
373
+ sort: { field: 'y2024', order: 'descending' as const },
374
+ },
375
+ },
376
+ },
377
+ { width: 600, height: 400 },
378
+ );
379
+
380
+ const points = layout.marks.filter((m): m is PointMark => m.type === 'point');
381
+ expect(points).toHaveLength(6);
382
+
383
+ // Descending sort by y2024 resolves to band domain [Japan, USA, Brazil].
384
+ // Band domain[0] maps to the bottom of the inverted y range (same
385
+ // convention horizontal bars follow), so Japan gets the largest cy.
386
+ const japanEnd = points.find((m) => m.key === 'Japan|end')!;
387
+ const usaEnd = points.find((m) => m.key === 'USA|end')!;
388
+ const brazilEnd = points.find((m) => m.key === 'Brazil|end')!;
389
+ expect(japanEnd.cy).toBeGreaterThan(usaEnd.cy);
390
+ expect(usaEnd.cy).toBeGreaterThan(brazilEnd.cy);
391
+ });
392
+
393
+ it('produces tooltips with start, end, and signed delta on every range mark', () => {
394
+ const layout = compileChart(baseSpec, { width: 600, height: 400 });
395
+
396
+ const pointIndex = layout.marks.findIndex(
397
+ (m) => m.type === 'point' && (m as PointMark).key === 'Japan|end',
398
+ );
399
+ expect(pointIndex).toBeGreaterThanOrEqual(0);
400
+
401
+ const content = layout.tooltipDescriptors.get(`point-${pointIndex}`)!;
402
+ expect(content).toBeDefined();
403
+ expect(content.title).toBe('Japan');
404
+ const labels = content.fields.map((f) => f.label);
405
+ expect(labels).toEqual(['y2000', 'y2024', 'Change']);
406
+ const delta = content.fields[2].value;
407
+ expect(delta.startsWith('+')).toBe(true);
408
+
409
+ // The connector rule shares the same tooltip content
410
+ const ruleIndex = layout.marks.findIndex(
411
+ (m) => m.type === 'rule' && (m as RuleMarkLayout).key === 'Japan|range',
412
+ );
413
+ expect(layout.tooltipDescriptors.get(`rule-${ruleIndex}`)).toBeDefined();
414
+ });
415
+
416
+ it('compiles the vertical form with y/y2 and nominal x', () => {
417
+ const layout = compileChart(
418
+ {
419
+ mark: 'range',
420
+ data: [
421
+ { month: 'Jan', lo: 27, hi: 39 },
422
+ { month: 'Jul', lo: 69, hi: 85 },
423
+ ],
424
+ encoding: {
425
+ x: { field: 'month', type: 'nominal' },
426
+ y: { field: 'lo', type: 'quantitative' },
427
+ y2: { field: 'hi' },
428
+ },
429
+ },
430
+ { width: 600, height: 400 },
431
+ );
432
+
433
+ const points = layout.marks.filter((m): m is PointMark => m.type === 'point');
434
+ expect(points).toHaveLength(4);
435
+ const julStart = points.find((m) => m.key === 'Jul|start')!;
436
+ const julEnd = points.find((m) => m.key === 'Jul|end')!;
437
+ expect(julEnd.cy).toBeLessThan(julStart.cy);
438
+ });
439
+
440
+ it('extends the quantitative domain to cover x2 values', () => {
441
+ const layout = compileChart(baseSpec, { width: 600, height: 400 });
442
+
443
+ // Japan's end dot (87.9, the data max, only present in the x2 field) must
444
+ // sit inside the chart area, not clip past its right edge.
445
+ const points = layout.marks.filter((m): m is PointMark => m.type === 'point');
446
+ const japanEnd = points.find((m) => m.key === 'Japan|end')!;
447
+ expect(japanEnd.cx).toBeLessThanOrEqual(layout.area.x + layout.area.width);
448
+ });
449
+
450
+ it('renders arrows with direction coloring end to end', () => {
451
+ const layout = compileChart(
452
+ {
453
+ mark: { type: 'range', style: 'arrow', colorByDirection: true },
454
+ data: [...mixedDirection],
455
+ encoding: {
456
+ y: { field: 'source', type: 'nominal' },
457
+ x: { field: 'y2010', type: 'quantitative' },
458
+ x2: { field: 'y2024' },
459
+ },
460
+ },
461
+ { width: 600, height: 400 },
462
+ );
463
+
464
+ const rules = layout.marks.filter((m): m is RuleMarkLayout => m.type === 'rule');
465
+ expect(rules).toHaveLength(9);
466
+ const coal = rules.find((m) => m.key === 'Coal|range')!;
467
+ const gas = rules.find((m) => m.key === 'Gas|range')!;
468
+ expect(coal.stroke).not.toBe(gas.stroke);
469
+ });
470
+
471
+ it('rejects a horizontal range spec without x2, naming encoding.x2', () => {
472
+ expect(() =>
473
+ compileChart(
474
+ {
475
+ mark: 'range',
476
+ data: [...lifeExpectancy],
477
+ encoding: {
478
+ y: { field: 'country', type: 'nominal' },
479
+ x: { field: 'y2000', type: 'quantitative' },
480
+ },
481
+ },
482
+ { width: 600, height: 400 },
483
+ ),
484
+ ).toThrow(/encoding\.x2/);
485
+ });
486
+ });