@opendata-ai/openchart-engine 7.6.1 → 7.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/index.d.ts +13 -0
  2. package/dist/index.js +1031 -187
  3. package/dist/index.js.map +1 -1
  4. package/package.json +2 -2
  5. package/src/__test-fixtures__/specs.ts +6 -0
  6. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +16 -0
  7. package/src/__tests__/axes.test.ts +3 -2
  8. package/src/__tests__/facet.test.ts +215 -0
  9. package/src/annotations/__tests__/compute.test.ts +18 -0
  10. package/src/annotations/__tests__/thinning.test.ts +137 -0
  11. package/src/annotations/compute.ts +3 -1
  12. package/src/annotations/thinning.ts +116 -0
  13. package/src/barlist/compile-barlist.ts +1 -0
  14. package/src/charts/bar/labels.ts +1 -0
  15. package/src/charts/column/labels.ts +1 -0
  16. package/src/charts/dot/labels.ts +1 -0
  17. package/src/charts/line/area.ts +16 -2
  18. package/src/charts/line/compute.ts +17 -10
  19. package/src/charts/pie/labels.ts +1 -0
  20. package/src/compile/__tests__/color-scale-range.test.ts +80 -1
  21. package/src/compile/color-scale-range.ts +44 -2
  22. package/src/compile.ts +314 -5
  23. package/src/compiler/__tests__/normalize-highlight.test.ts +103 -0
  24. package/src/compiler/__tests__/validate-highlight.test.ts +112 -0
  25. package/src/compiler/normalize.ts +67 -2
  26. package/src/compiler/types.ts +10 -0
  27. package/src/compiler/validate.ts +79 -0
  28. package/src/endpoint-labels/__tests__/compute.test.ts +57 -0
  29. package/src/endpoint-labels/compute.ts +179 -5
  30. package/src/endpoint-labels/predict.ts +17 -0
  31. package/src/layout/__tests__/scales-highlight.test.ts +192 -0
  32. package/src/layout/axes.ts +8 -2
  33. package/src/layout/dimensions.ts +6 -1
  34. package/src/layout/facet.ts +80 -0
  35. package/src/layout/plan.ts +16 -0
  36. package/src/legend/__tests__/redundancy.test.ts +108 -0
  37. package/src/legend/__tests__/suppression.test.ts +72 -0
  38. package/src/legend/compute.ts +18 -0
  39. package/src/legend/suppression.ts +27 -6
  40. package/src/tilemap/__tests__/compile-tilemap.test.ts +210 -0
  41. package/src/tilemap/compile-tilemap.ts +371 -117
  42. package/src/tilemap/types.ts +1 -0
package/src/compile.ts CHANGED
@@ -22,8 +22,12 @@ import type {
22
22
  CompileTableOptions,
23
23
  Encoding,
24
24
  EncodingChannel,
25
+ FacetChannel,
26
+ FacetPanelLayout,
25
27
  LayerSpec,
28
+ LayoutStrategy,
26
29
  Mark,
30
+ ResolvedAnimation,
27
31
  ResolvedAnnotation,
28
32
  ResolvedTheme,
29
33
  TableLayout,
@@ -44,6 +48,7 @@ import {
44
48
  import { computeAnnotations } from './annotations/compute';
45
49
  import { type AnnotationMeasureTextFn, heuristicMeasure } from './annotations/geometry';
46
50
  import type { PlacementObstacle } from './annotations/placement';
51
+ import { thinAnnotations } from './annotations/thinning';
47
52
  import { computeEndpointLabels } from './endpoint-labels/compute';
48
53
  // Side-effect import: registers all built-in chart renderers with the
49
54
  // registry on module load. Tests that clear the registry can import
@@ -56,6 +61,7 @@ import {
56
61
  resolveRendererKey,
57
62
  } from './charts/post-process';
58
63
  import { getChartRenderer } from './charts/registry';
64
+ import { groupByField } from './charts/utils';
59
65
  import { applyColorScaleRange } from './compile/color-scale-range';
60
66
  import { filterClippedDomains } from './compile/data-clip';
61
67
  import { compileLayer as compileLayerImpl } from './compile/layer';
@@ -73,6 +79,7 @@ import { compileGraph as compileGraphImpl } from './graphs/compile-graph';
73
79
  import type { GraphCompilation } from './graphs/types';
74
80
  import { computeAxes } from './layout/axes';
75
81
  import { computeDimensions } from './layout/dimensions';
82
+ import { computeFacetGrid } from './layout/facet';
76
83
  import { computeGridlines } from './layout/gridlines';
77
84
  import { createMeasureFn, resolveLayoutPlan } from './layout/plan';
78
85
  import { computeScales } from './layout/scales';
@@ -585,6 +592,24 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
585
592
  };
586
593
  }
587
594
 
595
+ // ---------------------------------------------------------------------------
596
+ // Faceted compilation: when encoding.facet is present, take the faceted path
597
+ // ---------------------------------------------------------------------------
598
+ const facetChannel = renderSpec.encoding.facet;
599
+ if (facetChannel?.field) {
600
+ return compileFaceted(
601
+ chartSpec,
602
+ renderSpec,
603
+ facetChannel,
604
+ options,
605
+ theme,
606
+ strategy,
607
+ watermark,
608
+ resolvedAnimation,
609
+ crosshair,
610
+ );
611
+ }
612
+
588
613
  // ---------------------------------------------------------------------------
589
614
  // Layout plan: measure real labels, freeze gutter + chrome + legend content
590
615
  // ---------------------------------------------------------------------------
@@ -639,8 +664,9 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
639
664
  // Compute scales
640
665
  const scales = computeScales(renderSpec, scaleArea, renderSpec.data);
641
666
 
642
- // Update color scale to use theme palette (only when user hasn't provided an explicit range)
643
- applyColorScaleRange(scales, renderSpec.encoding, theme);
667
+ // Update color scale to use theme palette (only when user hasn't provided an explicit range).
668
+ // When highlight is active, assign palette colors to highlighted series and mute the rest.
669
+ applyColorScaleRange(scales, renderSpec.encoding, theme, renderSpec.highlight);
644
670
 
645
671
  // INVARIANT 3 -- post-hoc defaultColor: must run AFTER computeScales since resolution needs
646
672
  // theme context. Do not move into computeScales (would require threading theme through).
@@ -692,7 +718,14 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
692
718
  // Compute the right-side endpoint labels column for multi-series line/area
693
719
  // charts. Reads `mark.dataPoints` so it must run AFTER marks are computed.
694
720
  // dimensions.ts already reserved the right margin via predictEndpointLabelsWidth.
695
- const endpointLabels = computeEndpointLabels(chartSpec, marks, theme, chartArea, strategy);
721
+ const endpointLabels = computeEndpointLabels(
722
+ chartSpec,
723
+ marks,
724
+ theme,
725
+ chartArea,
726
+ strategy,
727
+ plan.endpointLabelsDemoted,
728
+ );
696
729
 
697
730
  // Compute annotations from spec, passing legend + mark + brand bounds as obstacles
698
731
  const obstacles: PlacementObstacle[] = [];
@@ -721,7 +754,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
721
754
  ? (text, font) => options.measureText!(text, font.fontSize, font.fontWeight).width
722
755
  : heuristicMeasure;
723
756
 
724
- const annotations: ResolvedAnnotation[] = computeAnnotations(chartSpec, {
757
+ let annotations: ResolvedAnnotation[] = computeAnnotations(chartSpec, {
725
758
  scales,
726
759
  chartArea,
727
760
  strategy,
@@ -729,8 +762,21 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
729
762
  obstacles,
730
763
  svg: { width: dims.total.width, height: dims.total.height },
731
764
  measure: annotationMeasure,
765
+ autoThin: chartSpec.autoThin,
732
766
  });
733
767
 
768
+ // Auto-thinning: demote overlapping text annotations to footnote markers.
769
+ // Pass the full spec annotations (not filtered to text-only) so index
770
+ // alignment with the resolved array is preserved.
771
+ let footnotes: import('@opendata-ai/openchart-core').AnnotationFootnote[] | undefined;
772
+ if (chartSpec.autoThin && annotations.length > 1) {
773
+ const result = thinAnnotations(annotations, chartSpec.annotations, annotationMeasure);
774
+ annotations = result.annotations;
775
+ if (result.footnotes.length > 0) {
776
+ footnotes = result.footnotes;
777
+ }
778
+ }
779
+
734
780
  // Compute tooltip descriptors from marks and encoding
735
781
  const tooltipDescriptors = computeTooltipDescriptors(chartSpec, marks);
736
782
 
@@ -756,9 +802,11 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
756
802
  // Assign animationIndex for stagger ordering when animation is enabled
757
803
  assignAnimationIndices(marks, resolvedAnimation);
758
804
 
805
+ const chrome = footnotes ? { ...dims.chrome, footnotes } : dims.chrome;
806
+
759
807
  return {
760
808
  area: chartArea,
761
- chrome: dims.chrome,
809
+ chrome,
762
810
  metrics: dims.metrics,
763
811
  axes: {
764
812
  x: axes.x,
@@ -788,6 +836,267 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
788
836
  };
789
837
  }
790
838
 
839
+ // ---------------------------------------------------------------------------
840
+ // Faceted (small-multiples) compilation
841
+ // ---------------------------------------------------------------------------
842
+
843
+ function compileFaceted(
844
+ chartSpec: NormalizedChartSpec,
845
+ renderSpec: NormalizedChartSpec,
846
+ facetChannel: FacetChannel,
847
+ options: CompileOptions,
848
+ theme: ResolvedTheme,
849
+ strategy: LayoutStrategy,
850
+ watermark: boolean,
851
+ resolvedAnimation: ResolvedAnimation | undefined,
852
+ crosshair: boolean,
853
+ ): ChartLayout {
854
+ const measure = createMeasureFn(options.measureText);
855
+
856
+ // Strip facet from encoding before passing to per-panel pipeline (panels
857
+ // don't know they're faceted -- they compile as regular single charts).
858
+ const panelEncoding = { ...renderSpec.encoding };
859
+ delete (panelEncoding as Record<string, unknown>).facet;
860
+ const panelSpec = { ...renderSpec, encoding: panelEncoding };
861
+ const panelChartSpec = { ...chartSpec, encoding: panelEncoding };
862
+
863
+ // Partition data by facet field
864
+ const groups = groupByField(renderSpec.data, facetChannel.field);
865
+ const facetValues = Array.from(groups.keys());
866
+ if (facetChannel.sort !== null) {
867
+ const dir = facetChannel.sort === 'descending' ? -1 : 1;
868
+ facetValues.sort((a, b) => dir * a.localeCompare(b));
869
+ }
870
+
871
+ // Compute figure-level chrome and dimensions using the full dataset
872
+ // (no facet channel so it compiles normally). We use a representative
873
+ // single-panel spec to get chrome + legend measurements.
874
+ const plan = resolveLayoutPlan(
875
+ panelChartSpec,
876
+ panelSpec,
877
+ options,
878
+ theme,
879
+ strategy,
880
+ watermark,
881
+ measure,
882
+ );
883
+ const legendStub = placeLegend(
884
+ plan.legendContent,
885
+ { x: 0, y: 0, width: options.width, height: options.height },
886
+ options.width,
887
+ theme,
888
+ plan.xAxisExtent,
889
+ );
890
+ const dims = computeDimensions(
891
+ panelChartSpec,
892
+ options,
893
+ legendStub,
894
+ theme,
895
+ strategy,
896
+ watermark,
897
+ plan,
898
+ );
899
+ const chartArea = dims.chartArea;
900
+
901
+ const finalLegend = placeLegend(
902
+ plan.legendContent,
903
+ chartArea,
904
+ options.width,
905
+ theme,
906
+ plan.xAxisExtent,
907
+ );
908
+
909
+ // Estimate y-axis gutter width (used as left reservation for outer axes)
910
+ const leftReservation = plan.leftGutter;
911
+ // Estimate x-axis extent (used as bottom reservation for outer axes)
912
+ const bottomReservation = plan.xAxisExtent;
913
+
914
+ // Compute facet grid geometry
915
+ const grid = computeFacetGrid(facetValues, facetChannel.columns, chartArea, {
916
+ left: leftReservation,
917
+ bottom: bottomReservation,
918
+ });
919
+
920
+ // Resolve scale for determining if we use 'independent' or 'shared'
921
+ const resolveConfig = chartSpec.resolve;
922
+ const yResolve = resolveConfig?.scale?.y ?? 'shared';
923
+ const xResolve = resolveConfig?.scale?.x ?? 'shared';
924
+
925
+ // Compute shared scale domains from the full dataset (when shared)
926
+ const fullScales = computeScales(panelSpec, chartArea, renderSpec.data);
927
+ const sharedXDomain =
928
+ xResolve === 'shared' && fullScales.x ? fullScales.x.scale.domain() : undefined;
929
+ const sharedYDomain =
930
+ yResolve === 'shared' && fullScales.y ? fullScales.y.scale.domain() : undefined;
931
+
932
+ const isRadial = chartSpec.markType === 'arc';
933
+ const rendererKey = resolveRendererKey(
934
+ renderSpec.markType,
935
+ panelEncoding as Encoding,
936
+ renderSpec.markDef,
937
+ );
938
+ const renderer = getChartRenderer(rendererKey);
939
+
940
+ const allMarks: Mark[] = [];
941
+ const panelLayouts: FacetPanelLayout[] = [];
942
+
943
+ for (const gridPanel of grid.panels) {
944
+ const panelData = groups.get(gridPanel.key) ?? [];
945
+ const panelSpecLocal = { ...panelSpec, data: panelData };
946
+
947
+ // Inject shared domains via scale.domain on the encoding channels
948
+ let panelEnc = { ...panelEncoding } as Record<string, unknown>;
949
+ if (sharedXDomain && panelEnc.x) {
950
+ const xCh = panelEnc.x as Record<string, unknown>;
951
+ panelEnc = {
952
+ ...panelEnc,
953
+ x: { ...xCh, scale: { ...((xCh.scale as object) ?? {}), domain: sharedXDomain } },
954
+ };
955
+ }
956
+ if (sharedYDomain && panelEnc.y) {
957
+ const yCh = panelEnc.y as Record<string, unknown>;
958
+ panelEnc = {
959
+ ...panelEnc,
960
+ y: { ...yCh, scale: { ...((yCh.scale as object) ?? {}), domain: sharedYDomain } },
961
+ };
962
+ }
963
+ const panelSpecWithDomains = {
964
+ ...panelSpecLocal,
965
+ encoding: panelEnc as unknown as typeof panelSpec.encoding,
966
+ };
967
+
968
+ // Compute scales for this panel's area
969
+ const panelScales = computeScales(panelSpecWithDomains, gridPanel.area, panelData);
970
+ applyColorScaleRange(panelScales, panelSpecWithDomains.encoding, theme);
971
+ panelScales.defaultColor =
972
+ chartSpec.markDef.fill ?? chartSpec.markDef.stroke ?? theme.colors.categorical[0];
973
+
974
+ // Outer-axis economy: only leftmost column gets y ticks, only bottom row gets x ticks.
975
+ // Exception: when scales are independent, every panel needs its own axis ticks.
976
+ const isLeftCol = gridPanel.col === 0;
977
+ const isBottomRow = gridPanel.row === grid.rows - 1;
978
+
979
+ const panelAxes = isRadial
980
+ ? { x: undefined, y: undefined }
981
+ : computeAxes(panelScales, gridPanel.area, strategy, theme, options.measureText, {
982
+ data: panelData,
983
+ encoding: panelSpecWithDomains.encoding as Encoding,
984
+ skipX: xResolve === 'shared' ? !isBottomRow : false,
985
+ skipY: yResolve === 'shared' ? !isLeftCol : false,
986
+ markType: chartSpec.markType,
987
+ totalWidth: gridPanel.area.width,
988
+ });
989
+
990
+ if (!isRadial) {
991
+ computeGridlines(panelAxes, gridPanel.area);
992
+ }
993
+
994
+ // Compute marks for this panel
995
+ const panelMarks: Mark[] = renderer
996
+ ? renderer(
997
+ panelSpecWithDomains,
998
+ panelScales,
999
+ gridPanel.area,
1000
+ strategy,
1001
+ theme,
1002
+ gridPanel.area.width,
1003
+ )
1004
+ : [];
1005
+
1006
+ // Compute annotations for this panel
1007
+ const annotationMeasure: AnnotationMeasureTextFn = options.measureText
1008
+ ? (text, font) => options.measureText!(text, font.fontSize, font.fontWeight).width
1009
+ : heuristicMeasure;
1010
+ const panelAnnotations = computeAnnotations(panelSpecWithDomains, {
1011
+ scales: panelScales,
1012
+ chartArea: gridPanel.area,
1013
+ strategy,
1014
+ isDark: theme.isDark,
1015
+ obstacles: [],
1016
+ svg: { width: options.width, height: options.height },
1017
+ measure: annotationMeasure,
1018
+ autoThin: panelSpecWithDomains.autoThin,
1019
+ });
1020
+
1021
+ allMarks.push(...panelMarks);
1022
+
1023
+ panelLayouts.push({
1024
+ key: gridPanel.key,
1025
+ area: gridPanel.area,
1026
+ marks: panelMarks,
1027
+ axes: { x: panelAxes.x, y: panelAxes.y },
1028
+ annotations: panelAnnotations,
1029
+ header: {
1030
+ text: gridPanel.key,
1031
+ x: gridPanel.headerPos.x,
1032
+ y: gridPanel.headerPos.y,
1033
+ fontSize: theme.fonts.sizes.body,
1034
+ fontWeight: theme.fonts.weights.medium,
1035
+ },
1036
+ });
1037
+ }
1038
+
1039
+ // Compute tooltip descriptors from all marks across panels
1040
+ const tooltipDescriptors = computeTooltipDescriptors(panelChartSpec, allMarks);
1041
+
1042
+ // A11y: mention faceting in alt text
1043
+ const panelCount = facetValues.length;
1044
+ const baseAltText = generateAltText(
1045
+ {
1046
+ mark: chartSpec.markType,
1047
+ data: chartSpec.data,
1048
+ encoding: chartSpec.encoding,
1049
+ chrome: chartSpec.chrome,
1050
+ } as ChartSpec,
1051
+ chartSpec.data,
1052
+ );
1053
+ const altText = `${baseAltText} Faceted into ${panelCount} panels by ${facetChannel.field}.`;
1054
+
1055
+ const dataTableFallback = generateDataTable(
1056
+ {
1057
+ mark: chartSpec.markType,
1058
+ data: chartSpec.data,
1059
+ encoding: chartSpec.encoding,
1060
+ } as ChartSpec,
1061
+ chartSpec.data,
1062
+ );
1063
+
1064
+ // Assign animation indices across all panels
1065
+ assignAnimationIndices(allMarks, resolvedAnimation);
1066
+
1067
+ // Figure-level axes are null (axes live in panels)
1068
+ // Figure-level marks are the union of all panel marks (for tooltip/keyboard nav)
1069
+ return {
1070
+ area: chartArea,
1071
+ chrome: dims.chrome,
1072
+ metrics: dims.metrics,
1073
+ axes: { x: undefined, y: undefined },
1074
+ marks: allMarks,
1075
+ annotations: [],
1076
+ legend: finalLegend,
1077
+ tooltipDescriptors,
1078
+ a11y: {
1079
+ altText,
1080
+ dataTableFallback,
1081
+ role: 'img',
1082
+ keyboardNavigable: allMarks.length > 0,
1083
+ },
1084
+ theme,
1085
+ dimensions: { width: options.width, height: options.height },
1086
+ animation: resolvedAnimation,
1087
+ watermark,
1088
+ display: chartSpec.display,
1089
+ crosshair,
1090
+ measureText: options.measureText,
1091
+ facet: {
1092
+ panels: panelLayouts,
1093
+ facetField: facetChannel.field,
1094
+ columns: grid.columns,
1095
+ sharedScales: yResolve === 'shared' && xResolve === 'shared',
1096
+ },
1097
+ };
1098
+ }
1099
+
791
1100
  // ---------------------------------------------------------------------------
792
1101
  // Layer compilation
793
1102
  // ---------------------------------------------------------------------------
@@ -0,0 +1,103 @@
1
+ import type { ChartSpec } from '@opendata-ai/openchart-core';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { compile } from '../index';
4
+ import { normalizeSpec } from '../normalize';
5
+ import type { NormalizedChartSpec } from '../types';
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Test data
9
+ // ---------------------------------------------------------------------------
10
+
11
+ const data = [
12
+ { date: '2020', country: 'Germany', value: 100 },
13
+ { date: '2020', country: 'France', value: 90 },
14
+ { date: '2020', country: 'Italy', value: 80 },
15
+ { date: '2021', country: 'Germany', value: 110 },
16
+ { date: '2021', country: 'France', value: 95 },
17
+ { date: '2021', country: 'Italy', value: 85 },
18
+ ];
19
+
20
+ function makeSpec(highlight?: string | string[]): ChartSpec {
21
+ const colorChannel: Record<string, unknown> = {
22
+ field: 'country',
23
+ type: 'nominal',
24
+ };
25
+ if (highlight !== undefined) {
26
+ colorChannel.highlight = highlight;
27
+ }
28
+ return {
29
+ mark: 'line',
30
+ data,
31
+ encoding: {
32
+ x: { field: 'date', type: 'temporal' },
33
+ y: { field: 'value', type: 'quantitative' },
34
+ color: colorChannel,
35
+ },
36
+ } as ChartSpec;
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Tests
41
+ // ---------------------------------------------------------------------------
42
+
43
+ describe('normalizeSpec — highlight', () => {
44
+ it('normalizes a single string highlight to an array', () => {
45
+ const result = normalizeSpec(makeSpec('Germany')) as NormalizedChartSpec;
46
+ expect(result.highlight).toEqual(['Germany']);
47
+ });
48
+
49
+ it('preserves an array highlight as-is', () => {
50
+ const result = normalizeSpec(makeSpec(['Germany', 'France'])) as NormalizedChartSpec;
51
+ expect(result.highlight).toEqual(['Germany', 'France']);
52
+ });
53
+
54
+ it('defaults to an empty array when no highlight is specified', () => {
55
+ const result = normalizeSpec(makeSpec()) as NormalizedChartSpec;
56
+ expect(result.highlight).toEqual([]);
57
+ });
58
+
59
+ it('defaults to an empty array when there is no color encoding', () => {
60
+ const spec: ChartSpec = {
61
+ mark: 'line',
62
+ data,
63
+ encoding: {
64
+ x: { field: 'date', type: 'temporal' },
65
+ y: { field: 'value', type: 'quantitative' },
66
+ },
67
+ };
68
+ const result = normalizeSpec(spec) as NormalizedChartSpec;
69
+ expect(result.highlight).toEqual([]);
70
+ });
71
+
72
+ it('warns when a highlight value does not appear in data', () => {
73
+ const { warnings } = compile(makeSpec(['Spain']));
74
+ const highlightWarning = warnings.find((w) => w.includes('Spain') && w.includes('highlight'));
75
+ expect(highlightWarning).toBeDefined();
76
+ });
77
+
78
+ it('does not warn when all highlight values appear in data', () => {
79
+ const { warnings } = compile(makeSpec(['Germany', 'France']));
80
+ const highlightWarning = warnings.find((w) => w.includes('highlight'));
81
+ expect(highlightWarning).toBeUndefined();
82
+ });
83
+
84
+ it('warns when highlight is combined with explicit scale.range', () => {
85
+ const spec: ChartSpec = {
86
+ mark: 'line',
87
+ data,
88
+ encoding: {
89
+ x: { field: 'date', type: 'temporal' },
90
+ y: { field: 'value', type: 'quantitative' },
91
+ color: {
92
+ field: 'country',
93
+ type: 'nominal',
94
+ highlight: ['Germany'],
95
+ scale: { range: ['red', 'blue', 'green'] },
96
+ },
97
+ },
98
+ };
99
+ const { warnings } = compile(spec);
100
+ const rangeWarning = warnings.find((w) => w.includes('scale.range') && w.includes('highlight'));
101
+ expect(rangeWarning).toBeDefined();
102
+ });
103
+ });
@@ -0,0 +1,112 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { validateSpec } from '../validate';
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Test data
6
+ // ---------------------------------------------------------------------------
7
+
8
+ const data = [
9
+ { date: '2020', country: 'Germany', value: 100 },
10
+ { date: '2020', country: 'France', value: 90 },
11
+ { date: '2020', country: 'Italy', value: 80 },
12
+ { date: '2021', country: 'Germany', value: 110 },
13
+ { date: '2021', country: 'France', value: 95 },
14
+ { date: '2021', country: 'Italy', value: 85 },
15
+ ];
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Tests
19
+ // ---------------------------------------------------------------------------
20
+
21
+ describe('validateSpec — highlight channel', () => {
22
+ it('accepts highlight on a nominal color channel with valid values', () => {
23
+ const result = validateSpec({
24
+ mark: 'line',
25
+ data,
26
+ encoding: {
27
+ x: { field: 'date', type: 'temporal' },
28
+ y: { field: 'value', type: 'quantitative' },
29
+ color: { field: 'country', type: 'nominal', highlight: ['Germany'] },
30
+ },
31
+ });
32
+ expect(result.valid).toBe(true);
33
+ expect(result.errors).toHaveLength(0);
34
+ });
35
+
36
+ it('accepts highlight as a single string (not array)', () => {
37
+ const result = validateSpec({
38
+ mark: 'line',
39
+ data,
40
+ encoding: {
41
+ x: { field: 'date', type: 'temporal' },
42
+ y: { field: 'value', type: 'quantitative' },
43
+ color: { field: 'country', type: 'nominal', highlight: 'Germany' },
44
+ },
45
+ });
46
+ expect(result.valid).toBe(true);
47
+ expect(result.errors).toHaveLength(0);
48
+ });
49
+
50
+ it('accepts highlight with multiple valid values', () => {
51
+ const result = validateSpec({
52
+ mark: 'line',
53
+ data,
54
+ encoding: {
55
+ x: { field: 'date', type: 'temporal' },
56
+ y: { field: 'value', type: 'quantitative' },
57
+ color: { field: 'country', type: 'nominal', highlight: ['Germany', 'France'] },
58
+ },
59
+ });
60
+ expect(result.valid).toBe(true);
61
+ expect(result.errors).toHaveLength(0);
62
+ });
63
+
64
+ it('rejects highlight on a quantitative color channel', () => {
65
+ const result = validateSpec({
66
+ mark: 'point',
67
+ data: [
68
+ { x: 1, y: 2, temp: 30 },
69
+ { x: 3, y: 4, temp: 50 },
70
+ ],
71
+ encoding: {
72
+ x: { field: 'x', type: 'quantitative' },
73
+ y: { field: 'y', type: 'quantitative' },
74
+ color: { field: 'temp', type: 'quantitative', highlight: ['30'] },
75
+ },
76
+ });
77
+ expect(result.valid).toBe(false);
78
+ const highlightError = result.errors.find((e) => e.message.toLowerCase().includes('highlight'));
79
+ expect(highlightError).toBeDefined();
80
+ // Implementation may use ENCODING_MISMATCH or INVALID_VALUE
81
+ expect(['ENCODING_MISMATCH', 'INVALID_VALUE']).toContain(highlightError!.code);
82
+ });
83
+
84
+ it('does not reject highlight with unknown values at the validation level', () => {
85
+ // Unknown highlight values are a warning (surfaced during normalization),
86
+ // not a validation error. The spec should still pass validation.
87
+ const result = validateSpec({
88
+ mark: 'line',
89
+ data,
90
+ encoding: {
91
+ x: { field: 'date', type: 'temporal' },
92
+ y: { field: 'value', type: 'quantitative' },
93
+ color: { field: 'country', type: 'nominal', highlight: ['Spain'] },
94
+ },
95
+ });
96
+ expect(result.valid).toBe(true);
97
+ });
98
+
99
+ it('does not produce highlight errors when no highlight is specified', () => {
100
+ const result = validateSpec({
101
+ mark: 'line',
102
+ data,
103
+ encoding: {
104
+ x: { field: 'date', type: 'temporal' },
105
+ y: { field: 'value', type: 'quantitative' },
106
+ color: { field: 'country', type: 'nominal' },
107
+ },
108
+ });
109
+ expect(result.valid).toBe(true);
110
+ expect(result.errors).toHaveLength(0);
111
+ });
112
+ });