@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
@@ -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,146 @@ 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
+ });
629
+
630
+ describe('row/column facet validation', () => {
631
+ it('validates row channel field and type', () => {
632
+ const result = validateSpec({
633
+ mark: 'bar',
634
+ data: validLineData,
635
+ encoding: {
636
+ x: { field: 'value', type: 'quantitative' },
637
+ y: { field: 'country', type: 'nominal' },
638
+ row: { field: 'nonexistent', type: 'nominal' },
639
+ },
640
+ });
641
+ expect(result.errors.some((e) => e.path === 'encoding.row.field')).toBe(true);
642
+ });
643
+
644
+ it('rejects row with invalid type', () => {
645
+ const result = validateSpec({
646
+ mark: 'bar',
647
+ data: validLineData,
648
+ encoding: {
649
+ x: { field: 'value', type: 'quantitative' },
650
+ y: { field: 'country', type: 'nominal' },
651
+ row: { field: 'country', type: 'quantitative' as 'nominal' },
652
+ },
653
+ });
654
+ expect(result.errors.some((e) => e.path === 'encoding.row.type')).toBe(true);
655
+ });
656
+
657
+ it('rejects row + facet together', () => {
658
+ const result = validateSpec({
659
+ mark: 'bar',
660
+ data: validLineData,
661
+ encoding: {
662
+ x: { field: 'value', type: 'quantitative' },
663
+ y: { field: 'country', type: 'nominal' },
664
+ row: { field: 'country', type: 'nominal' },
665
+ facet: { field: 'country', type: 'nominal' },
666
+ },
667
+ });
668
+ expect(result.errors.some((e) => e.message.includes('encoding.row and encoding.facet'))).toBe(
669
+ true,
670
+ );
671
+ });
672
+
673
+ it('rejects row + column together', () => {
674
+ const result = validateSpec({
675
+ mark: 'bar',
676
+ data: validLineData,
677
+ encoding: {
678
+ x: { field: 'value', type: 'quantitative' },
679
+ y: { field: 'country', type: 'nominal' },
680
+ row: { field: 'country', type: 'nominal' },
681
+ column: { field: 'date', type: 'ordinal' },
682
+ },
683
+ });
684
+ expect(
685
+ result.errors.some((e) => e.message.includes('encoding.row and encoding.column')),
686
+ ).toBe(true);
687
+ });
688
+
689
+ it('accepts valid row channel', () => {
690
+ const result = validateSpec({
691
+ mark: 'bar',
692
+ data: validLineData,
693
+ encoding: {
694
+ x: { field: 'value', type: 'quantitative' },
695
+ y: { field: 'country', type: 'nominal' },
696
+ row: { field: 'country', type: 'nominal' },
697
+ },
698
+ });
699
+ expect(result.errors.filter((e) => e.path?.startsWith('encoding.row'))).toHaveLength(0);
700
+ });
701
+
702
+ it('rejects column + facet together', () => {
703
+ const result = validateSpec({
704
+ mark: 'bar',
705
+ data: validLineData,
706
+ encoding: {
707
+ x: { field: 'value', type: 'quantitative' },
708
+ y: { field: 'country', type: 'nominal' },
709
+ column: { field: 'country', type: 'nominal' },
710
+ facet: { field: 'country', type: 'nominal' },
711
+ },
712
+ });
713
+ expect(
714
+ result.errors.some((e) => e.message.includes('encoding.column and encoding.facet')),
715
+ ).toBe(true);
716
+ });
717
+
718
+ it('accepts valid column channel', () => {
719
+ const result = validateSpec({
720
+ mark: 'bar',
721
+ data: validLineData,
722
+ encoding: {
723
+ x: { field: 'country', type: 'nominal' },
724
+ y: { field: 'value', type: 'quantitative' },
725
+ column: { field: 'country', type: 'nominal' },
726
+ },
727
+ });
728
+ expect(result.errors.filter((e) => e.path?.startsWith('encoding.column'))).toHaveLength(0);
729
+ });
730
+ });
473
731
  });
@@ -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
+ }
@@ -28,22 +28,23 @@ export function serializeKeyValue(v: unknown): string {
28
28
  *
29
29
  * Only keys that appear more than once get suffixed. Unique keys pass
30
30
  * through unchanged so keys remain human-readable when possible.
31
- * Uses SOH (\x01) as separator to avoid collisions with real data values.
31
+ * Uses U+2E31 (word separator middle dot) as separator - XML-safe and
32
+ * unlikely to appear in real data values.
32
33
  */
34
+ const KEY_DEDUP_SEP = '⸱';
35
+
33
36
  export function dedupeKeys(keys: string[]): string[] {
34
- // Count occurrences
35
37
  const counts = new Map<string, number>();
36
38
  for (const k of keys) {
37
39
  counts.set(k, (counts.get(k) ?? 0) + 1);
38
40
  }
39
41
 
40
- // Assign suffixes only to duplicates
41
42
  const seen = new Map<string, number>();
42
43
  const result: string[] = [];
43
44
  for (const k of keys) {
44
45
  if (counts.get(k)! > 1) {
45
46
  const idx = seen.get(k) ?? 0;
46
- result.push(`${k}\x01${idx}`);
47
+ result.push(`${k}${KEY_DEDUP_SEP}${idx}`);
47
48
  seen.set(k, idx + 1);
48
49
  } else {
49
50
  result.push(k);