@opendata-ai/openchart-engine 7.6.1 → 7.8.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.
- package/dist/index.d.ts +13 -0
- package/dist/index.js +1037 -187
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__test-fixtures__/specs.ts +6 -0
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +16 -0
- package/src/__tests__/axes.test.ts +3 -2
- package/src/__tests__/facet.test.ts +215 -0
- package/src/annotations/__tests__/compute.test.ts +18 -0
- package/src/annotations/__tests__/thinning.test.ts +137 -0
- package/src/annotations/compute.ts +3 -1
- package/src/annotations/thinning.ts +116 -0
- package/src/barlist/compile-barlist.ts +1 -0
- package/src/charts/bar/labels.ts +1 -0
- package/src/charts/column/labels.ts +1 -0
- package/src/charts/dot/labels.ts +1 -0
- package/src/charts/line/area.ts +16 -2
- package/src/charts/line/compute.ts +17 -10
- package/src/charts/pie/labels.ts +1 -0
- package/src/compile/__tests__/color-scale-range.test.ts +80 -1
- package/src/compile/color-scale-range.ts +44 -2
- package/src/compile.ts +314 -5
- package/src/compiler/__tests__/normalize-highlight.test.ts +103 -0
- package/src/compiler/__tests__/validate-highlight.test.ts +112 -0
- package/src/compiler/normalize.ts +67 -2
- package/src/compiler/types.ts +10 -0
- package/src/compiler/validate.ts +79 -0
- package/src/endpoint-labels/__tests__/compute.test.ts +57 -0
- package/src/endpoint-labels/compute.ts +179 -5
- package/src/endpoint-labels/predict.ts +17 -0
- package/src/layout/__tests__/scales-highlight.test.ts +192 -0
- package/src/layout/axes.ts +8 -2
- package/src/layout/dimensions.ts +6 -1
- package/src/layout/facet.ts +80 -0
- package/src/layout/plan.ts +16 -0
- package/src/legend/__tests__/redundancy.test.ts +108 -0
- package/src/legend/__tests__/suppression.test.ts +72 -0
- package/src/legend/compute.ts +18 -0
- package/src/legend/suppression.ts +27 -6
- package/src/tilemap/__tests__/compile-tilemap.test.ts +210 -0
- package/src/tilemap/compile-tilemap.ts +379 -117
- 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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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, renderSpec.highlight);
|
|
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
|
+
});
|