@opendata-ai/openchart-engine 7.6.0 → 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.
- package/dist/index.d.ts +13 -0
- package/dist/index.js +1039 -189
- 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__/compile-layer.test.ts +94 -0
- 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/layer.ts +11 -1
- package/src/compile.ts +317 -6
- 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 +371 -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
|
// ---------------------------------------------------------------------------
|
|
@@ -610,7 +635,9 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
610
635
|
plan.xAxisExtent,
|
|
611
636
|
);
|
|
612
637
|
const dims = computeDimensions(chartSpec, options, legendStub, theme, strategy, watermark, plan);
|
|
613
|
-
|
|
638
|
+
// A frozen area (set by compileLayer for leaf layers) overrides the computed
|
|
639
|
+
// one so all layers share the primary layout's coordinate space.
|
|
640
|
+
const chartArea = options.frozenChartArea ?? dims.chartArea;
|
|
614
641
|
|
|
615
642
|
// Place legend in its final position relative to the computed chart area.
|
|
616
643
|
const finalLegend = placeLegend(
|
|
@@ -637,8 +664,9 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
637
664
|
// Compute scales
|
|
638
665
|
const scales = computeScales(renderSpec, scaleArea, renderSpec.data);
|
|
639
666
|
|
|
640
|
-
// Update color scale to use theme palette (only when user hasn't provided an explicit range)
|
|
641
|
-
|
|
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);
|
|
642
670
|
|
|
643
671
|
// INVARIANT 3 -- post-hoc defaultColor: must run AFTER computeScales since resolution needs
|
|
644
672
|
// theme context. Do not move into computeScales (would require threading theme through).
|
|
@@ -690,7 +718,14 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
690
718
|
// Compute the right-side endpoint labels column for multi-series line/area
|
|
691
719
|
// charts. Reads `mark.dataPoints` so it must run AFTER marks are computed.
|
|
692
720
|
// dimensions.ts already reserved the right margin via predictEndpointLabelsWidth.
|
|
693
|
-
const endpointLabels = computeEndpointLabels(
|
|
721
|
+
const endpointLabels = computeEndpointLabels(
|
|
722
|
+
chartSpec,
|
|
723
|
+
marks,
|
|
724
|
+
theme,
|
|
725
|
+
chartArea,
|
|
726
|
+
strategy,
|
|
727
|
+
plan.endpointLabelsDemoted,
|
|
728
|
+
);
|
|
694
729
|
|
|
695
730
|
// Compute annotations from spec, passing legend + mark + brand bounds as obstacles
|
|
696
731
|
const obstacles: PlacementObstacle[] = [];
|
|
@@ -719,7 +754,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
719
754
|
? (text, font) => options.measureText!(text, font.fontSize, font.fontWeight).width
|
|
720
755
|
: heuristicMeasure;
|
|
721
756
|
|
|
722
|
-
|
|
757
|
+
let annotations: ResolvedAnnotation[] = computeAnnotations(chartSpec, {
|
|
723
758
|
scales,
|
|
724
759
|
chartArea,
|
|
725
760
|
strategy,
|
|
@@ -727,8 +762,21 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
727
762
|
obstacles,
|
|
728
763
|
svg: { width: dims.total.width, height: dims.total.height },
|
|
729
764
|
measure: annotationMeasure,
|
|
765
|
+
autoThin: chartSpec.autoThin,
|
|
730
766
|
});
|
|
731
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
|
+
|
|
732
780
|
// Compute tooltip descriptors from marks and encoding
|
|
733
781
|
const tooltipDescriptors = computeTooltipDescriptors(chartSpec, marks);
|
|
734
782
|
|
|
@@ -754,9 +802,11 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
754
802
|
// Assign animationIndex for stagger ordering when animation is enabled
|
|
755
803
|
assignAnimationIndices(marks, resolvedAnimation);
|
|
756
804
|
|
|
805
|
+
const chrome = footnotes ? { ...dims.chrome, footnotes } : dims.chrome;
|
|
806
|
+
|
|
757
807
|
return {
|
|
758
808
|
area: chartArea,
|
|
759
|
-
chrome
|
|
809
|
+
chrome,
|
|
760
810
|
metrics: dims.metrics,
|
|
761
811
|
axes: {
|
|
762
812
|
x: axes.x,
|
|
@@ -786,6 +836,267 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
786
836
|
};
|
|
787
837
|
}
|
|
788
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
|
+
|
|
789
1100
|
// ---------------------------------------------------------------------------
|
|
790
1101
|
// Layer compilation
|
|
791
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
|
+
});
|