@opendata-ai/openchart-engine 2.4.0 → 2.6.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 +2 -0
- package/dist/index.js +76 -21
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__test-fixtures__/specs.ts +3 -0
- package/src/annotations/compute.ts +24 -5
- package/src/charts/bar/index.ts +1 -1
- package/src/charts/bar/labels.ts +33 -2
- package/src/charts/line/__tests__/compute.test.ts +109 -0
- package/src/charts/line/compute.ts +18 -6
- package/src/compiler/normalize.ts +1 -0
- package/src/compiler/types.ts +2 -0
- package/src/layout/axes.ts +16 -1
- package/src/layout/dimensions.ts +4 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.0",
|
|
4
4
|
"description": "Headless compiler for openchart: spec validation, data compilation, scales, and layout",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Riley Hilliard",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"typecheck": "tsc --noEmit"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@opendata-ai/openchart-core": "2.
|
|
48
|
+
"@opendata-ai/openchart-core": "2.6.0",
|
|
49
49
|
"d3-array": "^3.2.0",
|
|
50
50
|
"d3-format": "^3.1.2",
|
|
51
51
|
"d3-interpolate": "^3.0.0",
|
|
@@ -67,6 +67,7 @@ export function makeLineSpec(): NormalizedChartSpec {
|
|
|
67
67
|
darkMode: 'off',
|
|
68
68
|
labels: { density: 'auto', format: '' },
|
|
69
69
|
hiddenSeries: [],
|
|
70
|
+
seriesStyles: {},
|
|
70
71
|
};
|
|
71
72
|
}
|
|
72
73
|
|
|
@@ -94,6 +95,7 @@ export function makeBarSpec(): NormalizedChartSpec {
|
|
|
94
95
|
darkMode: 'off',
|
|
95
96
|
labels: { density: 'auto', format: '' },
|
|
96
97
|
hiddenSeries: [],
|
|
98
|
+
seriesStyles: {},
|
|
97
99
|
};
|
|
98
100
|
}
|
|
99
101
|
|
|
@@ -123,5 +125,6 @@ export function makeScatterSpec(): NormalizedChartSpec {
|
|
|
123
125
|
darkMode: 'off',
|
|
124
126
|
labels: { density: 'auto', format: '' },
|
|
125
127
|
hiddenSeries: [],
|
|
128
|
+
seriesStyles: {},
|
|
126
129
|
};
|
|
127
130
|
}
|
|
@@ -356,18 +356,33 @@ function resolveRangeAnnotation(
|
|
|
356
356
|
|
|
357
357
|
const rect: Rect = { x, y, width, height };
|
|
358
358
|
|
|
359
|
-
// Label
|
|
359
|
+
// Label positioned within the range, with optional offset.
|
|
360
|
+
// labelAnchor controls horizontal placement:
|
|
361
|
+
// "left" (default): left edge, text-anchor start
|
|
362
|
+
// "top"/"auto": horizontally centered, text-anchor middle
|
|
363
|
+
// "right": right edge, text-anchor end
|
|
360
364
|
let label: ResolvedLabel | undefined;
|
|
361
365
|
if (annotation.label) {
|
|
362
|
-
const
|
|
366
|
+
const anchor = annotation.labelAnchor ?? 'left';
|
|
367
|
+
const centered = anchor === 'top' || anchor === 'bottom' || anchor === 'auto';
|
|
368
|
+
const baseDx = centered ? 0 : anchor === 'right' ? -4 : 4;
|
|
363
369
|
const baseDy = 14;
|
|
364
370
|
const labelDelta = applyOffset({ dx: baseDx, dy: baseDy }, annotation.labelOffset);
|
|
365
371
|
|
|
372
|
+
const style = makeAnnotationLabelStyle(11, 500, undefined, isDark);
|
|
373
|
+
if (centered) {
|
|
374
|
+
style.textAnchor = 'middle';
|
|
375
|
+
} else if (anchor === 'right') {
|
|
376
|
+
style.textAnchor = 'end';
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const baseX = centered ? x + width / 2 : anchor === 'right' ? x + width : x;
|
|
380
|
+
|
|
366
381
|
label = {
|
|
367
382
|
text: annotation.label,
|
|
368
|
-
x:
|
|
383
|
+
x: baseX + labelDelta.dx,
|
|
369
384
|
y: y + labelDelta.dy,
|
|
370
|
-
style
|
|
385
|
+
style,
|
|
371
386
|
visible: true,
|
|
372
387
|
};
|
|
373
388
|
}
|
|
@@ -428,11 +443,15 @@ function resolveRefLineAnnotation(
|
|
|
428
443
|
|
|
429
444
|
// Label at the right end for horizontal, top end for vertical, with optional offset.
|
|
430
445
|
// Horizontal refline labels use text-anchor 'end' so text stays inside the chart.
|
|
446
|
+
// labelAnchor controls which side of the line the label sits on:
|
|
447
|
+
// "top" (default): above horizontal, left of vertical
|
|
448
|
+
// "bottom": below horizontal, right of vertical
|
|
431
449
|
let label: ResolvedLabel | undefined;
|
|
432
450
|
if (annotation.label) {
|
|
433
451
|
const isHorizontal = annotation.y !== undefined;
|
|
452
|
+
const anchor = annotation.labelAnchor ?? 'top';
|
|
434
453
|
const baseDx = isHorizontal ? -4 : 4;
|
|
435
|
-
const baseDy = -4;
|
|
454
|
+
const baseDy = anchor === 'bottom' ? 14 : -4;
|
|
436
455
|
const labelDelta = applyOffset({ dx: baseDx, dy: baseDy }, annotation.labelOffset);
|
|
437
456
|
|
|
438
457
|
const defaultStroke = isDark ? DARK_REFLINE_STROKE : LIGHT_REFLINE_STROKE;
|
package/src/charts/bar/index.ts
CHANGED
|
@@ -17,7 +17,7 @@ export const barRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _t
|
|
|
17
17
|
const marks = computeBarMarks(spec, scales, chartArea, strategy);
|
|
18
18
|
|
|
19
19
|
// Compute and attach value labels (respects spec.labels.density)
|
|
20
|
-
const labels = computeBarLabels(marks, chartArea, spec.labels.density);
|
|
20
|
+
const labels = computeBarLabels(marks, chartArea, spec.labels.density, spec.labels.format);
|
|
21
21
|
for (let i = 0; i < marks.length && i < labels.length; i++) {
|
|
22
22
|
marks[i].label = labels[i];
|
|
23
23
|
}
|
package/src/charts/bar/labels.ts
CHANGED
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
ResolvedLabel,
|
|
19
19
|
} from '@opendata-ai/openchart-core';
|
|
20
20
|
import { estimateTextWidth, resolveCollisions } from '@opendata-ai/openchart-core';
|
|
21
|
+
import { format as d3Format } from 'd3-format';
|
|
21
22
|
|
|
22
23
|
// ---------------------------------------------------------------------------
|
|
23
24
|
// Constants
|
|
@@ -43,6 +44,7 @@ export function computeBarLabels(
|
|
|
43
44
|
marks: RectMark[],
|
|
44
45
|
_chartArea: { x: number; y: number; width: number; height: number },
|
|
45
46
|
density: LabelDensity = 'auto',
|
|
47
|
+
labelFormat?: string,
|
|
46
48
|
): ResolvedLabel[] {
|
|
47
49
|
// 'none': no labels at all
|
|
48
50
|
if (density === 'none') return [];
|
|
@@ -53,14 +55,43 @@ export function computeBarLabels(
|
|
|
53
55
|
|
|
54
56
|
const candidates: LabelCandidate[] = [];
|
|
55
57
|
|
|
58
|
+
// Build a d3 formatter if a label format string was provided.
|
|
59
|
+
// Supports a literal suffix after the d3 format, e.g. ".1f%" formats as "12.5%"
|
|
60
|
+
// (the trailing "%" is appended literally, not d3's multiply-by-100 percent type).
|
|
61
|
+
let formatter: ((v: number) => string) | null = null;
|
|
62
|
+
if (labelFormat) {
|
|
63
|
+
try {
|
|
64
|
+
formatter = d3Format(labelFormat);
|
|
65
|
+
} catch {
|
|
66
|
+
// If d3-format rejects it, try stripping a trailing suffix
|
|
67
|
+
const suffixMatch = labelFormat.match(/^(.+[a-z])([^a-z]+)$/i);
|
|
68
|
+
if (suffixMatch) {
|
|
69
|
+
try {
|
|
70
|
+
const d3Fmt = d3Format(suffixMatch[1]);
|
|
71
|
+
const suffix = suffixMatch[2];
|
|
72
|
+
formatter = (v: number) => d3Fmt(v) + suffix;
|
|
73
|
+
} catch {
|
|
74
|
+
// Give up on formatting
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
56
80
|
for (const mark of targetMarks) {
|
|
57
81
|
// Extract the display value from the aria label.
|
|
58
82
|
// Format is "category: value" or "category, group: value".
|
|
59
83
|
// Use the last colon to split, which handles colons in category names.
|
|
60
84
|
const ariaLabel = mark.aria.label;
|
|
61
85
|
const lastColon = ariaLabel.lastIndexOf(':');
|
|
62
|
-
const
|
|
63
|
-
if (!
|
|
86
|
+
const rawValue = lastColon >= 0 ? ariaLabel.slice(lastColon + 1).trim() : '';
|
|
87
|
+
if (!rawValue) continue;
|
|
88
|
+
|
|
89
|
+
// Apply label format if provided (re-parse the number from the aria string)
|
|
90
|
+
let valuePart = rawValue;
|
|
91
|
+
if (formatter) {
|
|
92
|
+
const num = Number(rawValue.replace(/[^0-9.-]/g, ''));
|
|
93
|
+
if (!Number.isNaN(num)) valuePart = formatter(num);
|
|
94
|
+
}
|
|
64
95
|
|
|
65
96
|
const textWidth = estimateTextWidth(valuePart, LABEL_FONT_SIZE, LABEL_FONT_WEIGHT);
|
|
66
97
|
const textHeight = LABEL_FONT_SIZE * 1.2;
|
|
@@ -799,3 +799,112 @@ describe('computeLineLabels', () => {
|
|
|
799
799
|
expect(labelMap.size).toBe(0);
|
|
800
800
|
});
|
|
801
801
|
});
|
|
802
|
+
|
|
803
|
+
// ---------------------------------------------------------------------------
|
|
804
|
+
// seriesStyles tests
|
|
805
|
+
// ---------------------------------------------------------------------------
|
|
806
|
+
|
|
807
|
+
describe('seriesStyles', () => {
|
|
808
|
+
it('applies dashed line style to a specific series', () => {
|
|
809
|
+
const spec = makeMultiSeriesSpec();
|
|
810
|
+
spec.seriesStyles = { UK: { lineStyle: 'dashed' } };
|
|
811
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
812
|
+
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
813
|
+
|
|
814
|
+
const lineMarks = marks.filter((m): m is LineMark => m.type === 'line');
|
|
815
|
+
const ukLine = lineMarks.find((m) => m.seriesKey === 'UK');
|
|
816
|
+
const usLine = lineMarks.find((m) => m.seriesKey === 'US');
|
|
817
|
+
|
|
818
|
+
expect(ukLine?.strokeDasharray).toBe('6 4');
|
|
819
|
+
expect(usLine?.strokeDasharray).toBeUndefined();
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
it('applies dotted line style', () => {
|
|
823
|
+
const spec = makeMultiSeriesSpec();
|
|
824
|
+
spec.seriesStyles = { US: { lineStyle: 'dotted' } };
|
|
825
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
826
|
+
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
827
|
+
|
|
828
|
+
const usLine = marks.find((m): m is LineMark => m.type === 'line' && m.seriesKey === 'US');
|
|
829
|
+
expect(usLine?.strokeDasharray).toBe('2 3');
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
it('hides point markers when showPoints is false', () => {
|
|
833
|
+
const spec = makeMultiSeriesSpec();
|
|
834
|
+
spec.seriesStyles = { UK: { showPoints: false } };
|
|
835
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
836
|
+
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
837
|
+
|
|
838
|
+
const ukPoints = marks.filter(
|
|
839
|
+
(m): m is PointMark => m.type === 'point' && m.data.country === 'UK',
|
|
840
|
+
);
|
|
841
|
+
const usPoints = marks.filter(
|
|
842
|
+
(m): m is PointMark => m.type === 'point' && m.data.country === 'US',
|
|
843
|
+
);
|
|
844
|
+
|
|
845
|
+
// UK points should have r=0 (hidden)
|
|
846
|
+
expect(ukPoints.every((p) => p.r === 0)).toBe(true);
|
|
847
|
+
// US points should still have default radius
|
|
848
|
+
expect(usPoints.every((p) => p.r > 0)).toBe(true);
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
it('overrides strokeWidth for a series', () => {
|
|
852
|
+
const spec = makeMultiSeriesSpec();
|
|
853
|
+
spec.seriesStyles = { UK: { strokeWidth: 1.5 } };
|
|
854
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
855
|
+
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
856
|
+
|
|
857
|
+
const lineMarks = marks.filter((m): m is LineMark => m.type === 'line');
|
|
858
|
+
const ukLine = lineMarks.find((m) => m.seriesKey === 'UK');
|
|
859
|
+
const usLine = lineMarks.find((m) => m.seriesKey === 'US');
|
|
860
|
+
|
|
861
|
+
expect(ukLine?.strokeWidth).toBe(1.5);
|
|
862
|
+
expect(usLine?.strokeWidth).toBe(2.5); // default
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
it('sets opacity on a series', () => {
|
|
866
|
+
const spec = makeMultiSeriesSpec();
|
|
867
|
+
spec.seriesStyles = { UK: { opacity: 0.5 } };
|
|
868
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
869
|
+
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
870
|
+
|
|
871
|
+
const ukLine = marks.find((m): m is LineMark => m.type === 'line' && m.seriesKey === 'UK');
|
|
872
|
+
const usLine = marks.find((m): m is LineMark => m.type === 'line' && m.seriesKey === 'US');
|
|
873
|
+
|
|
874
|
+
expect(ukLine?.opacity).toBe(0.5);
|
|
875
|
+
expect(usLine?.opacity).toBeUndefined();
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
it('combines multiple style overrides on the same series', () => {
|
|
879
|
+
const spec = makeMultiSeriesSpec();
|
|
880
|
+
spec.seriesStyles = {
|
|
881
|
+
UK: { lineStyle: 'dashed', showPoints: false, strokeWidth: 1, opacity: 0.6 },
|
|
882
|
+
};
|
|
883
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
884
|
+
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
885
|
+
|
|
886
|
+
const ukLine = marks.find((m): m is LineMark => m.type === 'line' && m.seriesKey === 'UK');
|
|
887
|
+
expect(ukLine?.strokeDasharray).toBe('6 4');
|
|
888
|
+
expect(ukLine?.strokeWidth).toBe(1);
|
|
889
|
+
expect(ukLine?.opacity).toBe(0.6);
|
|
890
|
+
|
|
891
|
+
const ukPoints = marks.filter(
|
|
892
|
+
(m): m is PointMark => m.type === 'point' && m.data.country === 'UK',
|
|
893
|
+
);
|
|
894
|
+
expect(ukPoints.every((p) => p.r === 0)).toBe(true);
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
it('does not apply styles when seriesStyles is empty', () => {
|
|
898
|
+
const spec = makeMultiSeriesSpec();
|
|
899
|
+
spec.seriesStyles = {};
|
|
900
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
901
|
+
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
902
|
+
|
|
903
|
+
const lineMarks = marks.filter((m): m is LineMark => m.type === 'line');
|
|
904
|
+
for (const line of lineMarks) {
|
|
905
|
+
expect(line.strokeDasharray).toBeUndefined();
|
|
906
|
+
expect(line.opacity).toBeUndefined();
|
|
907
|
+
expect(line.strokeWidth).toBe(2.5);
|
|
908
|
+
}
|
|
909
|
+
});
|
|
910
|
+
});
|
|
@@ -140,6 +140,15 @@ export function computeLineMarks(
|
|
|
140
140
|
// segments are created by starting a new M command.
|
|
141
141
|
const combinedPath = pathParts.join(' ');
|
|
142
142
|
|
|
143
|
+
// Look up per-series style overrides
|
|
144
|
+
const seriesStyleKey = seriesKey === '__default__' ? undefined : seriesKey;
|
|
145
|
+
const styleOverride = seriesStyleKey ? spec.seriesStyles?.[seriesStyleKey] : undefined;
|
|
146
|
+
|
|
147
|
+
// Map lineStyle to SVG strokeDasharray
|
|
148
|
+
let strokeDasharray: string | undefined;
|
|
149
|
+
if (styleOverride?.lineStyle === 'dashed') strokeDasharray = '6 4';
|
|
150
|
+
else if (styleOverride?.lineStyle === 'dotted') strokeDasharray = '2 3';
|
|
151
|
+
|
|
143
152
|
// Create the LineMark with the combined path points.
|
|
144
153
|
// The points array includes all valid points across all segments.
|
|
145
154
|
const lineMark: LineMark = {
|
|
@@ -147,25 +156,28 @@ export function computeLineMarks(
|
|
|
147
156
|
points: allPoints,
|
|
148
157
|
path: combinedPath,
|
|
149
158
|
stroke: color,
|
|
150
|
-
strokeWidth: DEFAULT_STROKE_WIDTH,
|
|
151
|
-
|
|
159
|
+
strokeWidth: styleOverride?.strokeWidth ?? DEFAULT_STROKE_WIDTH,
|
|
160
|
+
strokeDasharray,
|
|
161
|
+
opacity: styleOverride?.opacity,
|
|
162
|
+
seriesKey: seriesStyleKey,
|
|
152
163
|
data: pointsWithData.map((p) => p.row),
|
|
153
164
|
aria,
|
|
154
165
|
};
|
|
155
166
|
|
|
156
167
|
marks.push(lineMark);
|
|
157
168
|
|
|
158
|
-
// Create point marks for hover targets
|
|
169
|
+
// Create point marks for hover targets (skip if showPoints is false)
|
|
170
|
+
const showPoints = styleOverride?.showPoints !== false;
|
|
159
171
|
for (let i = 0; i < pointsWithData.length; i++) {
|
|
160
172
|
const p = pointsWithData[i];
|
|
161
173
|
const pointMark: PointMark = {
|
|
162
174
|
type: 'point',
|
|
163
175
|
cx: p.x,
|
|
164
176
|
cy: p.y,
|
|
165
|
-
r: DEFAULT_POINT_RADIUS,
|
|
177
|
+
r: showPoints ? DEFAULT_POINT_RADIUS : 0,
|
|
166
178
|
fill: color,
|
|
167
|
-
stroke: '#ffffff',
|
|
168
|
-
strokeWidth: 1.5,
|
|
179
|
+
stroke: showPoints ? '#ffffff' : 'transparent',
|
|
180
|
+
strokeWidth: showPoints ? 1.5 : 0,
|
|
169
181
|
fillOpacity: 0,
|
|
170
182
|
data: p.row,
|
|
171
183
|
aria: {
|
package/src/compiler/types.ts
CHANGED
|
@@ -73,6 +73,8 @@ export interface NormalizedChartSpec {
|
|
|
73
73
|
darkMode: DarkMode;
|
|
74
74
|
/** Series names to hide from rendering. */
|
|
75
75
|
hiddenSeries: string[];
|
|
76
|
+
/** Per-series visual style overrides. */
|
|
77
|
+
seriesStyles: Record<string, import('@opendata-ai/openchart-core').SeriesStyle>;
|
|
76
78
|
}
|
|
77
79
|
|
|
78
80
|
/** A TableSpec with all optional fields filled with sensible defaults. */
|
package/src/layout/axes.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type {
|
|
|
16
16
|
TextStyle,
|
|
17
17
|
} from '@opendata-ai/openchart-core';
|
|
18
18
|
import { abbreviateNumber, formatDate, formatNumber } from '@opendata-ai/openchart-core';
|
|
19
|
+
import { format as d3Format } from 'd3-format';
|
|
19
20
|
import type { ScaleBand } from 'd3-scale';
|
|
20
21
|
import type {
|
|
21
22
|
D3CategoricalScale,
|
|
@@ -147,7 +148,21 @@ function formatTickLabel(value: unknown, resolvedScale: ResolvedScale): string {
|
|
|
147
148
|
|
|
148
149
|
if (resolvedScale.type === 'linear' || resolvedScale.type === 'log') {
|
|
149
150
|
const num = value as number;
|
|
150
|
-
if (formatStr)
|
|
151
|
+
if (formatStr) {
|
|
152
|
+
try {
|
|
153
|
+
return d3Format(formatStr)(num);
|
|
154
|
+
} catch {
|
|
155
|
+
// Support literal suffix after d3 format, e.g. ".1f%" → d3(".1f") + "%"
|
|
156
|
+
const suffixMatch = formatStr.match(/^(.+[a-z])([^a-z]+)$/i);
|
|
157
|
+
if (suffixMatch) {
|
|
158
|
+
try {
|
|
159
|
+
return d3Format(suffixMatch[1])(num) + suffixMatch[2];
|
|
160
|
+
} catch {
|
|
161
|
+
// Fall through to default formatting
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
151
166
|
// Abbreviate large numbers for axis labels
|
|
152
167
|
if (Math.abs(num) >= 1000) return abbreviateNumber(num);
|
|
153
168
|
return formatNumber(num);
|
package/src/layout/dimensions.ts
CHANGED
|
@@ -127,8 +127,10 @@ export function computeDimensions(
|
|
|
127
127
|
left: padding + (isRadial ? padding : axisMargin),
|
|
128
128
|
};
|
|
129
129
|
|
|
130
|
-
// Dynamic right margin for line/area end-of-line labels
|
|
131
|
-
|
|
130
|
+
// Dynamic right margin for line/area end-of-line labels.
|
|
131
|
+
// Only reserve space when labels will actually render (density != 'none').
|
|
132
|
+
const labelDensity = spec.labels.density;
|
|
133
|
+
if ((spec.type === 'line' || spec.type === 'area') && labelDensity !== 'none') {
|
|
132
134
|
// Estimate label width from longest series name (color encoding domain)
|
|
133
135
|
const colorField = encoding.color?.field;
|
|
134
136
|
if (colorField) {
|