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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/dist/index.d.ts +123 -7
  2. package/dist/index.js +16159 -10024
  3. package/dist/index.js.map +1 -1
  4. package/package.json +11 -4
  5. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +170 -56
  6. package/src/__tests__/annotation-behavior.test.ts +721 -0
  7. package/src/__tests__/axes.test.ts +50 -3
  8. package/src/__tests__/compile-chart.test.ts +30 -0
  9. package/src/__tests__/compile-footnotes.test.ts +277 -0
  10. package/src/__tests__/compile-layer.test.ts +120 -1
  11. package/src/__tests__/helpers/invariants.ts +54 -20
  12. package/src/__tests__/known-bugs.test.ts +455 -2
  13. package/src/__tests__/legend.test.ts +69 -4
  14. package/src/__tests__/ranking-recipes.test.ts +260 -0
  15. package/src/__tests__/scales.test.ts +9 -3
  16. package/src/__tests__/series-search.test.ts +92 -0
  17. package/src/__tests__/spec-sugar.test.ts +801 -0
  18. package/src/__tests__/you-draw-it.test.ts +160 -0
  19. package/src/annotations/__tests__/compute.test.ts +207 -92
  20. package/src/annotations/__tests__/geometry.test.ts +66 -0
  21. package/src/annotations/__tests__/resolve-text.test.ts +527 -27
  22. package/src/annotations/__tests__/rich-text.test.ts +91 -0
  23. package/src/annotations/__tests__/thinning.test.ts +148 -0
  24. package/src/annotations/collisions.ts +130 -84
  25. package/src/annotations/compute.ts +83 -85
  26. package/src/annotations/constants.ts +116 -6
  27. package/src/annotations/geometry.ts +222 -77
  28. package/src/annotations/move.ts +135 -0
  29. package/src/annotations/placement.ts +62 -14
  30. package/src/annotations/resolve-range.ts +2 -0
  31. package/src/annotations/resolve-refline.ts +2 -0
  32. package/src/annotations/resolve-text.ts +312 -111
  33. package/src/annotations/rich-text.ts +65 -0
  34. package/src/annotations/thinning.ts +84 -10
  35. package/src/barlist/compile-barlist.ts +16 -6
  36. package/src/charts/_shared/format-label-value.ts +9 -8
  37. package/src/charts/bar/__tests__/compute.test.ts +57 -10
  38. package/src/charts/bar/__tests__/labels.test.ts +5 -4
  39. package/src/charts/bar/compute.ts +2 -6
  40. package/src/charts/bar/index.ts +8 -1
  41. package/src/charts/bar/labels.ts +3 -3
  42. package/src/charts/beeswarm/__tests__/compute.test.ts +280 -0
  43. package/src/charts/beeswarm/__tests__/dodge.test.ts +148 -0
  44. package/src/charts/beeswarm/compute.ts +203 -0
  45. package/src/charts/beeswarm/dodge.ts +155 -0
  46. package/src/charts/beeswarm/index.ts +32 -0
  47. package/src/charts/builtin.ts +12 -1
  48. package/src/charts/calendar/__tests__/compute.test.ts +417 -0
  49. package/src/charts/calendar/compute.ts +319 -0
  50. package/src/charts/calendar/index.ts +32 -0
  51. package/src/charts/column/__tests__/compute.test.ts +21 -7
  52. package/src/charts/column/__tests__/labels.test.ts +76 -1
  53. package/src/charts/column/compute.ts +2 -6
  54. package/src/charts/column/index.ts +10 -2
  55. package/src/charts/column/labels.ts +95 -21
  56. package/src/charts/dot/index.ts +8 -1
  57. package/src/charts/dot/labels.ts +3 -3
  58. package/src/charts/line/__tests__/compute.test.ts +15 -19
  59. package/src/charts/line/area.ts +8 -23
  60. package/src/charts/line/compute.ts +32 -2
  61. package/src/charts/parliament/__tests__/compute.test.ts +411 -0
  62. package/src/charts/parliament/compute.ts +313 -0
  63. package/src/charts/parliament/index.ts +32 -0
  64. package/src/charts/pie/__tests__/compute.test.ts +103 -0
  65. package/src/charts/pie/compute.ts +63 -8
  66. package/src/charts/pie/labels.ts +1 -0
  67. package/src/charts/range/__tests__/compute.test.ts +486 -0
  68. package/src/charts/range/compute.ts +273 -0
  69. package/src/charts/range/index.ts +82 -0
  70. package/src/charts/range/labels.ts +169 -0
  71. package/src/charts/rect/__tests__/compute.test.ts +120 -0
  72. package/src/charts/rect/index.ts +127 -0
  73. package/src/charts/scatter/compute.ts +7 -21
  74. package/src/charts/text/__tests__/compute.test.ts +307 -0
  75. package/src/charts/text/index.ts +44 -7
  76. package/src/charts/tick/__tests__/compute.test.ts +75 -0
  77. package/src/charts/tick/index.ts +7 -4
  78. package/src/charts/utils.ts +5 -3
  79. package/src/charts/waffle/__tests__/compute.test.ts +305 -0
  80. package/src/charts/waffle/compute.ts +197 -0
  81. package/src/charts/waffle/index.ts +31 -0
  82. package/src/compile/__tests__/color-scale-reverse.test.ts +89 -0
  83. package/src/compile/__tests__/data-clip.test.ts +68 -0
  84. package/src/compile/__tests__/fill-patterns.test.ts +154 -0
  85. package/src/compile/__tests__/size-scale.test.ts +108 -0
  86. package/src/compile/color-scale-range.ts +54 -33
  87. package/src/compile/data-clip.ts +21 -6
  88. package/src/compile/fill-patterns.ts +74 -0
  89. package/src/compile/layer.ts +117 -21
  90. package/src/compile/size-scale.ts +139 -0
  91. package/src/compile/spec-sugar.ts +758 -0
  92. package/src/compile/you-draw-it.ts +123 -0
  93. package/src/compile.ts +310 -103
  94. package/src/compiler/__tests__/a11y-warnings.test.ts +148 -0
  95. package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +19 -0
  96. package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +20 -0
  97. package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +22 -0
  98. package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +19 -0
  99. package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +14 -0
  100. package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +34 -0
  101. package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +22 -0
  102. package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +41 -0
  103. package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +38 -0
  104. package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +28 -0
  105. package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +28 -0
  106. package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +30 -0
  107. package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +29 -0
  108. package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +32 -0
  109. package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +25 -0
  110. package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +19 -0
  111. package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +19 -0
  112. package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +21 -0
  113. package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +23 -0
  114. package/src/compiler/__tests__/normalize.test.ts +65 -1
  115. package/src/compiler/__tests__/validate-did-you-mean.test.ts +86 -0
  116. package/src/compiler/__tests__/validate-near-miss.test.ts +89 -0
  117. package/src/compiler/__tests__/validate.test.ts +156 -0
  118. package/src/compiler/__tests__/vl-parity.test.ts +68 -0
  119. package/src/compiler/a11y-warnings.ts +110 -0
  120. package/src/compiler/normalize.ts +150 -7
  121. package/src/compiler/types.ts +11 -0
  122. package/src/compiler/validate.ts +664 -18
  123. package/src/endpoint-labels/__tests__/compute.test.ts +88 -0
  124. package/src/endpoint-labels/compute.ts +40 -12
  125. package/src/endpoint-labels/format.ts +45 -15
  126. package/src/endpoint-labels/predict.ts +50 -36
  127. package/src/format/__tests__/field-format.test.ts +164 -0
  128. package/src/format/field-format.ts +27 -0
  129. package/src/geo/__tests__/compile-map.test.ts +621 -0
  130. package/src/geo/compile-map.ts +856 -0
  131. package/src/geo/join.ts +115 -0
  132. package/src/geo/projections.ts +45 -0
  133. package/src/geo/types.ts +24 -0
  134. package/src/graphs/__tests__/compile-graph.test.ts +25 -0
  135. package/src/graphs/compile-graph.ts +27 -18
  136. package/src/index.ts +20 -2
  137. package/src/layout/axes/ticks.ts +59 -10
  138. package/src/layout/axes.ts +30 -2
  139. package/src/layout/dimensions.ts +139 -83
  140. package/src/layout/facet.ts +14 -3
  141. package/src/layout/plan.ts +83 -33
  142. package/src/layout/scales.ts +132 -15
  143. package/src/legend/__tests__/continuous.test.ts +318 -0
  144. package/src/legend/__tests__/size.test.ts +348 -0
  145. package/src/legend/__tests__/suppression.test.ts +17 -1
  146. package/src/legend/compute.ts +119 -13
  147. package/src/legend/continuous.ts +327 -0
  148. package/src/legend/size.ts +407 -0
  149. package/src/legend/suppression.ts +8 -7
  150. package/src/sankey/compile-sankey.ts +31 -19
  151. package/src/tables/format-cells.ts +16 -7
  152. package/src/tilemap/__tests__/compile-tilemap.test.ts +39 -0
  153. package/src/tilemap/compile-tilemap.ts +24 -7
  154. package/src/tooltips/__tests__/compute.test.ts +2 -2
  155. package/src/tooltips/compute.ts +422 -41
  156. package/src/transforms/__tests__/conditional.test.ts +18 -0
  157. package/src/transforms/aggregate.ts +2 -1
@@ -2,6 +2,7 @@ import type {
2
2
  ChartSpec,
3
3
  GraphSpec,
4
4
  LayerSpec,
5
+ MapSpec,
5
6
  RangeAnnotation,
6
7
  RefLineAnnotation,
7
8
  SankeySpec,
@@ -9,6 +10,7 @@ import type {
9
10
  TextAnnotation,
10
11
  } from '@opendata-ai/openchart-core';
11
12
  import { describe, expect, it } from 'vitest';
13
+ import type { NormalizedMapSpec } from '../../geo/types';
12
14
  import type { NormalizedSankeySpec } from '../../sankey/types';
13
15
  import { normalizeSpec } from '../normalize';
14
16
  import type { NormalizedChartSpec, NormalizedGraphSpec, NormalizedTableSpec } from '../types';
@@ -195,12 +197,33 @@ describe('normalizeSpec', () => {
195
197
  expect(refline.style).toBe('dashed');
196
198
  expect(refline.strokeWidth).toBe(1);
197
199
 
200
+ // Normalize must NOT stamp typography onto text annotations. The
201
+ // annotation layer owns fontSize (DEFAULT_ANNOTATION_FONT_SIZE) and
202
+ // fontWeight (the lede rule, which promotes a subtitle-bearing primary
203
+ // line to bold only when the author left the weight unset). Defaulting
204
+ // them here makes `fontWeight` always defined, so the lede rule can
205
+ // never fire and the size default can never apply.
198
206
  const text = result.annotations[1] as TextAnnotation;
199
- expect(text.fontSize).toBe(12);
207
+ expect(text.fontSize).toBeUndefined();
208
+ expect(text.fontWeight).toBeUndefined();
209
+ expect(text.opacity).toBe(1);
200
210
 
201
211
  const range = result.annotations[2] as RangeAnnotation;
202
212
  expect(range.opacity).toBe(0.1);
203
213
  });
214
+
215
+ it('preserves author-set annotation typography through normalization', () => {
216
+ const spec: ChartSpec = {
217
+ ...lineSpec,
218
+ annotations: [
219
+ { type: 'text', x: '2020-01-01', y: 10, text: 'Start', fontSize: 18, fontWeight: 300 },
220
+ ],
221
+ };
222
+
223
+ const text = (normalizeSpec(spec) as NormalizedChartSpec).annotations[0] as TextAnnotation;
224
+ expect(text.fontSize).toBe(18);
225
+ expect(text.fontWeight).toBe(300);
226
+ });
204
227
  });
205
228
 
206
229
  describe('table spec normalization', () => {
@@ -332,4 +355,45 @@ describe('normalizeSpec', () => {
332
355
  expect(result.watermark).toBe(true);
333
356
  });
334
357
  });
358
+
359
+ describe('map spec normalization', () => {
360
+ const mapSpec: MapSpec = {
361
+ type: 'map',
362
+ geo: {
363
+ features: {
364
+ type: 'Topology',
365
+ objects: { x: { type: 'GeometryCollection', geometries: [] } },
366
+ arcs: [],
367
+ },
368
+ },
369
+ data: [],
370
+ encoding: {
371
+ key: { field: 'id', type: 'nominal' },
372
+ color: { field: 'value', type: 'quantitative' },
373
+ },
374
+ };
375
+
376
+ it('adds focus: null when not specified', () => {
377
+ const result = normalizeSpec(mapSpec) as NormalizedMapSpec;
378
+ expect(result.geo.focus).toBeNull();
379
+ });
380
+
381
+ it('preserves explicit focus value', () => {
382
+ const specWithFocus: MapSpec = {
383
+ ...mapSpec,
384
+ geo: { ...mapSpec.geo, focus: '36' },
385
+ };
386
+ const result = normalizeSpec(specWithFocus) as NormalizedMapSpec;
387
+ expect(result.geo.focus).toBe('36');
388
+ });
389
+
390
+ it('preserves focus: null (explicit clear)', () => {
391
+ const specWithNull: MapSpec = {
392
+ ...mapSpec,
393
+ geo: { ...mapSpec.geo, focus: null },
394
+ };
395
+ const result = normalizeSpec(specWithNull) as NormalizedMapSpec;
396
+ expect(result.geo.focus).toBeNull();
397
+ });
398
+ });
335
399
  });
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Levenshtein "did you mean" repair hints for misspelled field references.
3
+ *
4
+ * When a spec references a field that is not a column in the provided data,
5
+ * validation suggests the nearest actual column (by edit distance) so an LLM
6
+ * or author can self-correct a typo in one step. Short/unrelated names must
7
+ * NOT produce a spurious suggestion.
8
+ */
9
+
10
+ import { describe, expect, it } from 'vitest';
11
+ import { validateSpec } from '../validate';
12
+
13
+ const data = [
14
+ { region: 'West', revenue: 100, quarter: '2024-Q1' },
15
+ { region: 'East', revenue: 200, quarter: '2024-Q2' },
16
+ ];
17
+
18
+ /** Pull the suggestion for the field-missing error on the given path. */
19
+ function suggestionFor(result: ReturnType<typeof validateSpec>, path: string): string {
20
+ const error = result.errors.find((e) => e.path === path);
21
+ return error?.suggestion ?? '';
22
+ }
23
+
24
+ describe('did-you-mean field suggestions', () => {
25
+ it('suggests the nearest column for a one-edit typo in an encoding field', () => {
26
+ const result = validateSpec({
27
+ mark: 'bar',
28
+ data,
29
+ encoding: {
30
+ x: { field: 'region', type: 'nominal' },
31
+ y: { field: 'reveune', type: 'quantitative' }, // typo of "revenue"
32
+ },
33
+ });
34
+ expect(result.valid).toBe(false);
35
+ const suggestion = suggestionFor(result, 'encoding.y.field');
36
+ expect(suggestion).toContain('Did you mean "revenue"?');
37
+ // The full available-column list is still present alongside the hint.
38
+ expect(suggestion).toContain('revenue');
39
+ expect(suggestion).toContain('region');
40
+ });
41
+
42
+ it('does not invent a suggestion for a field with no close match', () => {
43
+ const result = validateSpec({
44
+ mark: 'bar',
45
+ data,
46
+ encoding: {
47
+ x: { field: 'region', type: 'nominal' },
48
+ y: { field: 'xyzqwerty', type: 'quantitative' },
49
+ },
50
+ });
51
+ expect(result.valid).toBe(false);
52
+ const suggestion = suggestionFor(result, 'encoding.y.field');
53
+ expect(suggestion).not.toContain('Did you mean');
54
+ // Still lists the real columns so the author can pick the right one.
55
+ expect(suggestion).toContain('revenue');
56
+ });
57
+
58
+ it('suggests the nearest column for a misspelled table column key', () => {
59
+ const result = validateSpec({
60
+ type: 'table',
61
+ data,
62
+ columns: [{ key: 'quater' }], // typo of "quarter"
63
+ });
64
+ expect(result.valid).toBe(false);
65
+ const suggestion = suggestionFor(result, 'columns[0].key');
66
+ expect(suggestion).toContain('Did you mean "quarter"?');
67
+ });
68
+
69
+ it('does not match on a short unrelated field where the edit distance exceeds the threshold', () => {
70
+ const shortData = [
71
+ { a: 1, b: 2 },
72
+ { a: 3, b: 4 },
73
+ ];
74
+ const result = validateSpec({
75
+ mark: 'bar',
76
+ data: shortData,
77
+ encoding: {
78
+ x: { field: 'zzz', type: 'nominal' },
79
+ y: { field: 'a', type: 'quantitative' },
80
+ },
81
+ });
82
+ expect(result.valid).toBe(false);
83
+ const suggestion = suggestionFor(result, 'encoding.x.field');
84
+ expect(suggestion).not.toContain('Did you mean');
85
+ });
86
+ });
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Near-miss validation messages for VL idioms that are unsupported by
3
+ * decision. Each rejection must point the author at the openchart
4
+ * equivalent instead of failing with a generic type error.
5
+ */
6
+
7
+ import { describe, expect, it } from 'vitest';
8
+ import { validateSpec } from '../validate';
9
+
10
+ const encoding = {
11
+ x: { field: 'cat', type: 'nominal' },
12
+ y: { field: 'value', type: 'quantitative' },
13
+ };
14
+ const data = [
15
+ { cat: 'A', value: 10 },
16
+ { cat: 'B', value: 20 },
17
+ ];
18
+
19
+ describe('near-miss validation messages', () => {
20
+ it('rejects data.url with a pointer to inline data', () => {
21
+ const result = validateSpec({ mark: 'bar', data: { url: '/data.json' }, encoding });
22
+ expect(result.valid).toBe(false);
23
+ const error = result.errors.find((e) => e.path === 'data.url');
24
+ expect(error?.message).toContain('does not fetch remote data');
25
+ expect(error?.suggestion).toContain('inline');
26
+ });
27
+
28
+ it('rejects the string form of calculate with the structured equivalent', () => {
29
+ const result = validateSpec({
30
+ mark: 'bar',
31
+ data,
32
+ transform: [{ calculate: 'datum.a / datum.b', as: 'ratio' }],
33
+ encoding,
34
+ });
35
+ expect(result.valid).toBe(false);
36
+ const error = result.errors.find((e) => e.path === 'transform[0].calculate');
37
+ expect(error?.message).toContain('structured expression object');
38
+ expect(error?.suggestion).toContain('calculate: { op:');
39
+ });
40
+
41
+ it('rejects hconcat/vconcat with the side-by-side containers suggestion', () => {
42
+ for (const key of ['hconcat', 'vconcat']) {
43
+ const result = validateSpec({ [key]: [] });
44
+ expect(result.valid).toBe(false);
45
+ const error = result.errors.find((e) => e.path === key);
46
+ expect(error?.message).toContain(`"${key}" composition is not supported`);
47
+ expect(error?.suggestion).toContain('facet');
48
+ }
49
+ });
50
+
51
+ it('rejects the top-level facet operator with the facet channel suggestion', () => {
52
+ const result = validateSpec({
53
+ facet: { field: 'cat', type: 'nominal' },
54
+ spec: { mark: 'bar', encoding },
55
+ });
56
+ expect(result.valid).toBe(false);
57
+ const error = result.errors.find((e) => e.path === 'facet');
58
+ expect(error?.message).toContain('"facet" operator is not supported');
59
+ expect(error?.suggestion).toContain('encoding.facet');
60
+ });
61
+
62
+ it('rejects unknown scheme names with the supported list', () => {
63
+ const result = validateSpec({
64
+ mark: 'bar',
65
+ data,
66
+ encoding: {
67
+ ...encoding,
68
+ color: { field: 'value', type: 'quantitative', scale: { scheme: 'viridis' } },
69
+ },
70
+ });
71
+ expect(result.valid).toBe(false);
72
+ const error = result.errors.find((e) => e.path === 'encoding.color.scale.scheme');
73
+ expect(error?.message).toContain('"viridis" is not a supported scheme name');
74
+ expect(error?.suggestion).toContain('blues');
75
+ expect(error?.suggestion).toContain('scale.range');
76
+ });
77
+
78
+ it('accepts known scheme names (resolved by sugar before validation on the compile path)', () => {
79
+ const result = validateSpec({
80
+ mark: 'bar',
81
+ data,
82
+ encoding: {
83
+ ...encoding,
84
+ color: { field: 'value', type: 'quantitative', scale: { scheme: 'blues' } },
85
+ },
86
+ });
87
+ expect(result.errors.filter((e) => e.path === 'encoding.color.scale.scheme')).toHaveLength(0);
88
+ });
89
+ });
@@ -308,6 +308,122 @@ describe('validateSpec', () => {
308
308
  });
309
309
  });
310
310
 
311
+ describe('range marks', () => {
312
+ const rangeData = [
313
+ { country: 'Japan', y2000: 81.1, y2024: 87.9 },
314
+ { country: 'USA', y2000: 79.5, y2024: 81.3 },
315
+ ];
316
+
317
+ it('accepts a valid horizontal range spec (x + x2, nominal y)', () => {
318
+ const result = validateSpec({
319
+ mark: 'range',
320
+ data: rangeData,
321
+ encoding: {
322
+ y: { field: 'country', type: 'nominal' },
323
+ x: { field: 'y2000', type: 'quantitative' },
324
+ x2: { field: 'y2024' },
325
+ },
326
+ });
327
+ expect(result.valid).toBe(true);
328
+ expect(result.errors).toHaveLength(0);
329
+ });
330
+
331
+ it('accepts a valid vertical range spec (y + y2, nominal x)', () => {
332
+ const result = validateSpec({
333
+ mark: 'range',
334
+ data: [{ month: 'Jan', lo: 27, hi: 39 }],
335
+ encoding: {
336
+ x: { field: 'month', type: 'nominal' },
337
+ y: { field: 'lo', type: 'quantitative' },
338
+ y2: { field: 'hi' },
339
+ },
340
+ });
341
+ expect(result.valid).toBe(true);
342
+ });
343
+
344
+ it('rejects a horizontal range spec without x2, naming encoding.x2', () => {
345
+ const result = validateSpec({
346
+ mark: 'range',
347
+ data: rangeData,
348
+ encoding: {
349
+ y: { field: 'country', type: 'nominal' },
350
+ x: { field: 'y2000', type: 'quantitative' },
351
+ },
352
+ });
353
+ expect(result.valid).toBe(false);
354
+ const err = result.errors.find((e) => e.path === 'encoding.x2');
355
+ expect(err).toBeDefined();
356
+ expect(err!.code).toBe('MISSING_FIELD');
357
+ expect(err!.message).toContain('encoding.x2');
358
+ expect(err!.suggestion).toContain('x2: { field:');
359
+ });
360
+
361
+ it('rejects a vertical range spec without y2, naming encoding.y2', () => {
362
+ const result = validateSpec({
363
+ mark: 'range',
364
+ data: [{ month: 'Jan', lo: 27, hi: 39 }],
365
+ encoding: {
366
+ x: { field: 'month', type: 'nominal' },
367
+ y: { field: 'lo', type: 'quantitative' },
368
+ },
369
+ });
370
+ expect(result.valid).toBe(false);
371
+ const err = result.errors.find((e) => e.path === 'encoding.y2');
372
+ expect(err).toBeDefined();
373
+ expect(err!.code).toBe('MISSING_FIELD');
374
+ expect(err!.message).toContain('encoding.y2');
375
+ });
376
+
377
+ it('rejects y2 on a horizontal range spec', () => {
378
+ const result = validateSpec({
379
+ mark: 'range',
380
+ data: rangeData,
381
+ encoding: {
382
+ y: { field: 'country', type: 'nominal' },
383
+ x: { field: 'y2000', type: 'quantitative' },
384
+ x2: { field: 'y2024' },
385
+ y2: { field: 'y2024' },
386
+ },
387
+ });
388
+ expect(result.valid).toBe(false);
389
+ const err = result.errors.find((e) => e.path === 'encoding.y2');
390
+ expect(err).toBeDefined();
391
+ expect(err!.code).toBe('INVALID_VALUE');
392
+ });
393
+
394
+ it('rejects a range spec without any categorical axis', () => {
395
+ const result = validateSpec({
396
+ mark: 'range',
397
+ data: rangeData,
398
+ encoding: {
399
+ y: { field: 'y2000', type: 'quantitative' },
400
+ x: { field: 'y2024', type: 'quantitative' },
401
+ },
402
+ });
403
+ expect(result.valid).toBe(false);
404
+ const err = result.errors.find((e) => e.code === 'ENCODING_MISMATCH');
405
+ expect(err).toBeDefined();
406
+ expect(err!.message).toContain('category axis');
407
+ });
408
+
409
+ it('rejects an unknown mark.style value', () => {
410
+ const result = validateSpec({
411
+ mark: { type: 'range', style: 'ribbon' },
412
+ data: rangeData,
413
+ encoding: {
414
+ y: { field: 'country', type: 'nominal' },
415
+ x: { field: 'y2000', type: 'quantitative' },
416
+ x2: { field: 'y2024' },
417
+ },
418
+ });
419
+ expect(result.valid).toBe(false);
420
+ const err = result.errors.find((e) => e.path === 'mark.style');
421
+ expect(err).toBeDefined();
422
+ expect(err!.code).toBe('INVALID_VALUE');
423
+ expect(err!.message).toContain('dumbbell, arrow, bar');
424
+ });
425
+ });
426
+
311
427
  describe('table specs', () => {
312
428
  it('accepts a valid table spec', () => {
313
429
  const result = validateSpec({
@@ -470,4 +586,44 @@ describe('validateSpec', () => {
470
586
  expect(fieldError!.suggestion).toContain('gamma');
471
587
  });
472
588
  });
589
+
590
+ describe('transform-created fields', () => {
591
+ it('accepts encoding fields produced by a chained bin + aggregate (histogram)', () => {
592
+ const result = validateSpec({
593
+ mark: 'bar',
594
+ data: [{ hours: 3.1 }, { hours: 3.4 }, { hours: 4.0 }],
595
+ transform: [
596
+ { bin: { step: 0.5 }, field: 'hours', as: 'binStart' },
597
+ { aggregate: [{ op: 'count', field: 'hours', as: 'finishers' }], groupby: ['binStart'] },
598
+ ],
599
+ encoding: {
600
+ x: { field: 'binStart', type: 'ordinal' },
601
+ y: { field: 'finishers', type: 'quantitative' },
602
+ },
603
+ });
604
+
605
+ expect(result.valid).toBe(true);
606
+ expect(result.errors.find((e) => e.code === 'DATA_FIELD_MISSING')).toBeUndefined();
607
+ });
608
+
609
+ it('accepts an encoding field produced by a window transform', () => {
610
+ const result = validateSpec({
611
+ mark: 'bar',
612
+ data: [
613
+ { day: '1', value: 10 },
614
+ { day: '2', value: 20 },
615
+ ],
616
+ transform: [
617
+ { window: [{ op: 'cumsum', field: 'value', as: 'running' }], sort: [{ field: 'day' }] },
618
+ ],
619
+ encoding: {
620
+ x: { field: 'day', type: 'ordinal' },
621
+ y: { field: 'running', type: 'quantitative' },
622
+ },
623
+ });
624
+
625
+ expect(result.valid).toBe(true);
626
+ expect(result.errors.find((e) => e.code === 'DATA_FIELD_MISSING')).toBeUndefined();
627
+ });
628
+ });
473
629
  });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * VL-idiom guessability suite.
3
+ *
4
+ * Each fixture in fixtures/vl-parity pairs a Vega-Lite-style spec ("vl") with
5
+ * the canonical openchart spec ("canonical") for one accepted idiom, and
6
+ * asserts they compile to deep-equal ChartLayouts. Layered fixtures
7
+ * (layered: true) run through compileLayer to prove the same sugar works on
8
+ * LayerSpec children.
9
+ */
10
+
11
+ import { readdirSync, readFileSync } from 'node:fs';
12
+ import { dirname, join } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import type { CompileOptions, LayerSpec } from '@opendata-ai/openchart-core';
15
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
16
+ import { compileChart, compileLayer } from '../../compile';
17
+
18
+ interface ParityFixture {
19
+ file: string;
20
+ layered?: boolean;
21
+ options: CompileOptions;
22
+ /** Per-side option overrides (e.g. the width/height fixture compiles the VL side in a larger container). */
23
+ vlOptions?: CompileOptions;
24
+ canonicalOptions?: CompileOptions;
25
+ vl: Record<string, unknown>;
26
+ canonical: Record<string, unknown>;
27
+ }
28
+
29
+ const fixturesDir = join(dirname(fileURLToPath(import.meta.url)), 'fixtures', 'vl-parity');
30
+ const fixtures: ParityFixture[] = readdirSync(fixturesDir)
31
+ .filter((file) => file.endsWith('.json'))
32
+ .sort()
33
+ .map((file) => ({
34
+ file,
35
+ ...(JSON.parse(readFileSync(join(fixturesDir, file), 'utf8')) as Omit<ParityFixture, 'file'>),
36
+ }));
37
+
38
+ let warnSpy: ReturnType<typeof vi.spyOn>;
39
+
40
+ beforeEach(() => {
41
+ warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
42
+ });
43
+
44
+ afterEach(() => {
45
+ warnSpy.mockRestore();
46
+ });
47
+
48
+ describe('VL-idiom parity fixtures', () => {
49
+ it('loads at least one flat and one layered fixture', () => {
50
+ expect(fixtures.some((f) => !f.layered)).toBe(true);
51
+ expect(fixtures.some((f) => f.layered)).toBe(true);
52
+ });
53
+
54
+ for (const fixture of fixtures) {
55
+ it(`${fixture.file}: VL form compiles to the same layout as the canonical form`, () => {
56
+ const vlLayout = fixture.layered
57
+ ? compileLayer(fixture.vl as unknown as LayerSpec, fixture.vlOptions ?? fixture.options)
58
+ : compileChart(fixture.vl, fixture.vlOptions ?? fixture.options);
59
+ const canonicalLayout = fixture.layered
60
+ ? compileLayer(
61
+ fixture.canonical as unknown as LayerSpec,
62
+ fixture.canonicalOptions ?? fixture.options,
63
+ )
64
+ : compileChart(fixture.canonical, fixture.canonicalOptions ?? fixture.options);
65
+ expect(vlLayout).toEqual(canonicalLayout);
66
+ });
67
+ }
68
+ });
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Development-time WCAG contrast diagnostics.
3
+ *
4
+ * Checks the resolved series colors and theme text tokens against WCAG 2.1
5
+ * thresholds and returns advisory warning strings:
6
+ *
7
+ * - 1.4.11 (non-text contrast, 3:1): adjacent stacked/grouped series pairs
8
+ * and each series color against the chart background.
9
+ * - 1.4.3 (text contrast, 4.5:1): theme text tokens against the background.
10
+ *
11
+ * Purely advisory: this module never throws and produces nothing unless the
12
+ * host opts in via `compileChart(spec, { dev: true })`. The gate is a compile
13
+ * option, NOT an environment sniff, because the engine is isomorphic and
14
+ * `process.env` checks would leak a Node assumption into browser bundles.
15
+ */
16
+
17
+ import type { MarkType, ResolvedTheme } from '@opendata-ai/openchart-core';
18
+ import { contrastRatio, findAccessibleColor, isOpaqueColor } from '@opendata-ai/openchart-core';
19
+ import type { ScaleOrdinal } from 'd3-scale';
20
+ import type { ResolvedScales } from '../layout/scales';
21
+
22
+ /** WCAG 1.4.11 minimum for graphical objects against adjacent colors. */
23
+ const GRAPHIC_MIN = 3;
24
+ /** WCAG 1.4.3 minimum for normal-size text. */
25
+ const TEXT_MIN = 4.5;
26
+
27
+ /** Mark types whose series render as adjacent filled regions when stacked/grouped. */
28
+ const ADJACENT_FILL_MARKS: ReadonlySet<MarkType> = new Set(['bar', 'area', 'arc', 'waffle']);
29
+
30
+ const fmt = (ratio: number): string => `${ratio.toFixed(2)}:1`;
31
+
32
+ /**
33
+ * Collect WCAG contrast warnings for a compiled chart.
34
+ *
35
+ * @param scales - Resolved scales (series colors come from the color scale).
36
+ * @param markType - Resolved mark type (adjacency only applies to filled marks).
37
+ * @param theme - Resolved theme (background and text tokens).
38
+ * @returns Advisory warning strings; empty when everything passes.
39
+ */
40
+ export function collectContrastWarnings(
41
+ scales: ResolvedScales,
42
+ markType: MarkType,
43
+ theme: ResolvedTheme,
44
+ ): string[] {
45
+ const warnings: string[] = [];
46
+
47
+ // The default background is 'transparent' (the host page shows through),
48
+ // which makes background-relative ratios meaningless at compile time.
49
+ // Adjacent-series checks below don't need the background and always run.
50
+ const bg = theme.colors.background;
51
+ const bgIsCheckable = isOpaqueColor(bg);
52
+
53
+ // Resolved per-series colors in domain (render) order. Sequential color
54
+ // scales encode magnitude, not series identity; adjacency checks there
55
+ // would flag every neighboring ramp stop, so only categorical scales apply.
56
+ const series: Array<{ key: string; color: string }> = [];
57
+ if (scales.color && scales.color.type !== 'sequential') {
58
+ const ordinal = scales.color.scale as unknown as ScaleOrdinal<string, string>;
59
+ for (const key of ordinal.domain()) {
60
+ series.push({ key, color: ordinal(key) });
61
+ }
62
+ }
63
+
64
+ // Adjacent series pairs (stacked segments / grouped neighbors render in
65
+ // domain order, so consecutive series sit next to each other).
66
+ if (ADJACENT_FILL_MARKS.has(markType)) {
67
+ for (let i = 0; i < series.length - 1; i++) {
68
+ const a = series[i];
69
+ const b = series[i + 1];
70
+ const ratio = contrastRatio(a.color, b.color);
71
+ if (ratio < GRAPHIC_MIN) {
72
+ const suggestion = findAccessibleColor(b.color, a.color, GRAPHIC_MIN);
73
+ warnings.push(
74
+ `[openchart a11y] Adjacent series "${a.key}" (${a.color}) and "${b.key}" (${b.color}) have ${fmt(ratio)} contrast, below the 3:1 WCAG 1.4.11 minimum for graphical objects. Nearest passing color for "${b.key}": ${suggestion}.`,
75
+ );
76
+ }
77
+ }
78
+ }
79
+
80
+ if (bgIsCheckable) {
81
+ // Each series against the chart background.
82
+ for (const s of series) {
83
+ const ratio = contrastRatio(s.color, bg);
84
+ if (ratio < GRAPHIC_MIN) {
85
+ const suggestion = findAccessibleColor(s.color, bg, GRAPHIC_MIN);
86
+ warnings.push(
87
+ `[openchart a11y] Series "${s.key}" (${s.color}) has ${fmt(ratio)} contrast against the background (${bg}), below the 3:1 WCAG 1.4.11 minimum. Nearest passing color: ${suggestion}.`,
88
+ );
89
+ }
90
+ }
91
+
92
+ // Text tokens at the 4.5:1 body-text threshold. Gridline/axis rules are
93
+ // exempt: they are decorative per WCAG 1.4.11.
94
+ const textTokens: Array<{ name: string; color: string }> = [
95
+ { name: 'theme.colors.text', color: theme.colors.text },
96
+ { name: 'theme.colors.annotationText', color: theme.colors.annotationText },
97
+ ];
98
+ for (const token of textTokens) {
99
+ const ratio = contrastRatio(token.color, bg);
100
+ if (ratio < TEXT_MIN) {
101
+ const suggestion = findAccessibleColor(token.color, bg, TEXT_MIN);
102
+ warnings.push(
103
+ `[openchart a11y] ${token.name} (${token.color}) has ${fmt(ratio)} contrast against the background (${bg}), below the 4.5:1 WCAG 1.4.3 minimum for text. Nearest passing color: ${suggestion}.`,
104
+ );
105
+ }
106
+ }
107
+ }
108
+
109
+ return warnings;
110
+ }