@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.
- package/dist/index.d.ts +13 -0
- package/dist/index.js +1031 -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 +371 -117
- package/src/tilemap/types.ts +1 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Annotation auto-thinning: demotes text annotations that can't fit without
|
|
3
|
+
* overlap to numbered dot markers with footnote text below the chart.
|
|
4
|
+
*
|
|
5
|
+
* Single deterministic pass: run collision resolution once at the full chart
|
|
6
|
+
* area, then greedily place by priority. Annotations that overlap an
|
|
7
|
+
* already-placed one get demoted. No iterative layout convergence.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
Annotation,
|
|
12
|
+
AnnotationFootnote,
|
|
13
|
+
Rect,
|
|
14
|
+
ResolvedAnnotation,
|
|
15
|
+
TextAnnotation,
|
|
16
|
+
} from '@opendata-ai/openchart-core';
|
|
17
|
+
import { detectCollision } from '@opendata-ai/openchart-core';
|
|
18
|
+
import { type AnnotationMeasureTextFn, estimateLabelBounds } from './geometry';
|
|
19
|
+
|
|
20
|
+
export interface ThinningResult {
|
|
21
|
+
annotations: ResolvedAnnotation[];
|
|
22
|
+
footnotes: AnnotationFootnote[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Apply auto-thinning to resolved annotations. Text annotations that still
|
|
27
|
+
* overlap after collision resolution are demoted to footnote markers.
|
|
28
|
+
*
|
|
29
|
+
* Priority ordering: annotations with lower `priority` values are kept in
|
|
30
|
+
* place first. When priority is equal (or omitted), spec order wins (earlier
|
|
31
|
+
* annotations are kept). Non-text annotations are never thinned.
|
|
32
|
+
*
|
|
33
|
+
* Explicit `responsive: false` on an annotation exempts it from thinning
|
|
34
|
+
* (it is always placed first and never demoted).
|
|
35
|
+
*
|
|
36
|
+
* @param annotations - Resolved annotations (text + range + refline, in spec order
|
|
37
|
+
* minus any that were skipped by `computeAnnotations`).
|
|
38
|
+
* @param specs - The full spec annotations array, index-aligned with `annotations`.
|
|
39
|
+
* Non-text specs are ignored; text specs provide `priority` and `responsive`.
|
|
40
|
+
*/
|
|
41
|
+
export function thinAnnotations(
|
|
42
|
+
annotations: ResolvedAnnotation[],
|
|
43
|
+
specs: Annotation[],
|
|
44
|
+
measure: AnnotationMeasureTextFn,
|
|
45
|
+
): ThinningResult {
|
|
46
|
+
if (annotations.length <= 1) {
|
|
47
|
+
return { annotations, footnotes: [] };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Build index pairs: (resolved, spec, original index).
|
|
51
|
+
// Only text specs carry priority/responsive; non-text specs are ignored.
|
|
52
|
+
const entries = annotations.map((a, i) => ({
|
|
53
|
+
resolved: a,
|
|
54
|
+
spec: i < specs.length && specs[i].type === 'text' ? (specs[i] as TextAnnotation) : undefined,
|
|
55
|
+
index: i,
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
// Separate pinned (responsive: false) from candidates
|
|
59
|
+
const pinned: typeof entries = [];
|
|
60
|
+
const candidates: typeof entries = [];
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
if (entry.resolved.type !== 'text' || !entry.resolved.label) {
|
|
63
|
+
pinned.push(entry);
|
|
64
|
+
} else if (entry.spec?.responsive === false) {
|
|
65
|
+
pinned.push(entry);
|
|
66
|
+
} else {
|
|
67
|
+
candidates.push(entry);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Sort candidates by priority (lower first), then by spec order
|
|
72
|
+
candidates.sort((a, b) => {
|
|
73
|
+
const pa = a.spec?.priority ?? Number.MAX_SAFE_INTEGER;
|
|
74
|
+
const pb = b.spec?.priority ?? Number.MAX_SAFE_INTEGER;
|
|
75
|
+
if (pa !== pb) return pa - pb;
|
|
76
|
+
return a.index - b.index;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Place pinned annotations first (they're always kept)
|
|
80
|
+
const placedBounds: Rect[] = [];
|
|
81
|
+
for (const entry of pinned) {
|
|
82
|
+
if (entry.resolved.type === 'text' && entry.resolved.label) {
|
|
83
|
+
const bounds = entry.resolved.bounds ?? estimateLabelBounds(entry.resolved.label, measure);
|
|
84
|
+
placedBounds.push(bounds);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Greedily place candidates; demote overlapping ones to footnotes
|
|
89
|
+
const footnotes: AnnotationFootnote[] = [];
|
|
90
|
+
|
|
91
|
+
for (const entry of candidates) {
|
|
92
|
+
const bounds = entry.resolved.bounds ?? estimateLabelBounds(entry.resolved.label!, measure);
|
|
93
|
+
|
|
94
|
+
if (bounds.width <= 0 || bounds.height <= 0) {
|
|
95
|
+
placedBounds.push(bounds);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const overlaps = placedBounds.some(
|
|
100
|
+
(pb) => pb.width > 0 && pb.height > 0 && detectCollision(bounds, pb),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
if (overlaps) {
|
|
104
|
+
const footnoteIndex = footnotes.length + 1;
|
|
105
|
+
footnotes.push({
|
|
106
|
+
index: footnoteIndex,
|
|
107
|
+
text: entry.resolved.label!.text,
|
|
108
|
+
});
|
|
109
|
+
entry.resolved.footnoteIndex = footnoteIndex;
|
|
110
|
+
} else {
|
|
111
|
+
placedBounds.push(bounds);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { annotations, footnotes };
|
|
116
|
+
}
|
package/src/charts/bar/labels.ts
CHANGED
package/src/charts/dot/labels.ts
CHANGED
package/src/charts/line/area.ts
CHANGED
|
@@ -457,9 +457,23 @@ export function computeAreaMarks(
|
|
|
457
457
|
const encoding = spec.encoding as Encoding;
|
|
458
458
|
const yChannel = encoding.y;
|
|
459
459
|
|
|
460
|
+
let marks: AreaMark[];
|
|
460
461
|
if (yChannel && isStacked(yChannel.stack)) {
|
|
461
|
-
|
|
462
|
+
marks = computeStackedArea(spec, scales, chartArea, darkMode);
|
|
463
|
+
} else {
|
|
464
|
+
marks = computeSingleArea(spec, scales, chartArea);
|
|
462
465
|
}
|
|
463
466
|
|
|
464
|
-
|
|
467
|
+
const highlight = spec.highlight ?? [];
|
|
468
|
+
if (highlight.length > 0) {
|
|
469
|
+
const highlightSet = new Set(highlight);
|
|
470
|
+
marks.sort((a, b) => {
|
|
471
|
+
const aMuted = a.seriesKey != null && !highlightSet.has(a.seriesKey);
|
|
472
|
+
const bMuted = b.seriesKey != null && !highlightSet.has(b.seriesKey);
|
|
473
|
+
if (aMuted === bMuted) return 0;
|
|
474
|
+
return aMuted ? -1 : 1;
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return marks;
|
|
465
479
|
}
|
|
@@ -67,7 +67,10 @@ export function computeLineMarks(
|
|
|
67
67
|
const colorField = isSequentialColor ? undefined : colorEnc?.field;
|
|
68
68
|
const sequentialColorField = isSequentialColor ? colorEnc.field : undefined;
|
|
69
69
|
const groups = groupByField(spec.data, colorField);
|
|
70
|
-
const
|
|
70
|
+
const mutedMarks: (LineMark | PointMark)[] = [];
|
|
71
|
+
const highlightedMarks: (LineMark | PointMark)[] = [];
|
|
72
|
+
const highlight = spec.highlight ?? [];
|
|
73
|
+
const highlightActive = highlight.length > 0;
|
|
71
74
|
|
|
72
75
|
for (const [seriesKey, rows] of groups) {
|
|
73
76
|
// For sequential color, use a mid-range color for the line stroke
|
|
@@ -165,6 +168,13 @@ export function computeLineMarks(
|
|
|
165
168
|
const seriesStyleKey = seriesKey === '__default__' ? undefined : seriesKey;
|
|
166
169
|
const styleOverride = seriesStyleKey ? spec.seriesStyles?.[seriesStyleKey] : undefined;
|
|
167
170
|
|
|
171
|
+
const isMuted = highlightActive && !highlight.includes(seriesKey);
|
|
172
|
+
let resolvedStrokeWidth =
|
|
173
|
+
styleOverride?.strokeWidth ?? spec.markDef.strokeWidth ?? DEFAULT_STROKE_WIDTH;
|
|
174
|
+
if (isMuted) {
|
|
175
|
+
resolvedStrokeWidth = Math.max(1, resolvedStrokeWidth * 0.75);
|
|
176
|
+
}
|
|
177
|
+
|
|
168
178
|
// Map lineStyle to SVG strokeDasharray
|
|
169
179
|
let strokeDasharray: string | undefined;
|
|
170
180
|
if (styleOverride?.lineStyle === 'dashed') strokeDasharray = '6 4';
|
|
@@ -178,7 +188,7 @@ export function computeLineMarks(
|
|
|
178
188
|
points: allPoints,
|
|
179
189
|
path: combinedPath,
|
|
180
190
|
stroke: strokeColor,
|
|
181
|
-
strokeWidth:
|
|
191
|
+
strokeWidth: resolvedStrokeWidth,
|
|
182
192
|
strokeDasharray,
|
|
183
193
|
opacity: styleOverride?.opacity,
|
|
184
194
|
seriesKey: seriesStyleKey,
|
|
@@ -187,7 +197,8 @@ export function computeLineMarks(
|
|
|
187
197
|
aria,
|
|
188
198
|
};
|
|
189
199
|
|
|
190
|
-
|
|
200
|
+
const bucket = isMuted ? mutedMarks : highlightedMarks;
|
|
201
|
+
bucket.push(lineMark);
|
|
191
202
|
|
|
192
203
|
// Emit PointMark objects when markDef.point is truthy, or when sequential
|
|
193
204
|
// color is active (points carry the gradient since SVG paths are single-color).
|
|
@@ -232,20 +243,16 @@ export function computeLineMarks(
|
|
|
232
243
|
// data point already exists on the line and gets described by the a11y
|
|
233
244
|
// data table; the dot is purely visual.
|
|
234
245
|
if (isSingleEndpoint) {
|
|
235
|
-
|
|
246
|
+
bucket.push({
|
|
236
247
|
type: 'point',
|
|
237
248
|
cx: p.x,
|
|
238
249
|
cy: p.y,
|
|
239
|
-
// 3.5 reads as a deliberate terminator against the 2px sparkline
|
|
240
|
-
// line — smaller dots blur into the stroke at typical card sizes.
|
|
241
250
|
r: 3.5,
|
|
242
251
|
fill: strokeColor,
|
|
243
252
|
stroke: 'transparent',
|
|
244
253
|
strokeWidth: 0,
|
|
245
254
|
fillOpacity: 1,
|
|
246
255
|
data: p.row,
|
|
247
|
-
// No label: decorative marks render with aria-hidden="true" and
|
|
248
|
-
// don't participate in the accessible data table.
|
|
249
256
|
aria: { decorative: true },
|
|
250
257
|
});
|
|
251
258
|
continue;
|
|
@@ -264,12 +271,12 @@ export function computeLineMarks(
|
|
|
264
271
|
label: `Data point: ${xChannel.field}=${String(p.row[xChannel.field])}, ${yChannel.field}=${String(p.row[yChannel.field])}`,
|
|
265
272
|
},
|
|
266
273
|
};
|
|
267
|
-
|
|
274
|
+
bucket.push(pointMark);
|
|
268
275
|
}
|
|
269
276
|
}
|
|
270
277
|
}
|
|
271
278
|
|
|
272
|
-
return
|
|
279
|
+
return [...mutedMarks, ...highlightedMarks];
|
|
273
280
|
}
|
|
274
281
|
|
|
275
282
|
// ---------------------------------------------------------------------------
|
package/src/charts/pie/labels.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import type { Encoding, ResolvedTheme } from '@opendata-ai/openchart-core';
|
|
2
|
-
import { resolveTheme } from '@opendata-ai/openchart-core';
|
|
2
|
+
import { ACHROMATIC_RAMP, adaptTheme, resolveTheme } from '@opendata-ai/openchart-core';
|
|
3
3
|
import { scaleLinear, scaleOrdinal } from 'd3-scale';
|
|
4
4
|
import { describe, expect, it } from 'vitest';
|
|
5
5
|
import type { ResolvedScales } from '../../layout/scales';
|
|
6
6
|
import { applyColorScaleRange } from '../color-scale-range';
|
|
7
7
|
|
|
8
8
|
const theme: ResolvedTheme = resolveTheme();
|
|
9
|
+
const accentNeutralTheme: ResolvedTheme = resolveTheme({ seriesStrategy: 'accent-neutral' });
|
|
10
|
+
const darkAccentNeutralTheme: ResolvedTheme = adaptTheme(accentNeutralTheme);
|
|
9
11
|
|
|
10
12
|
describe('applyColorScaleRange', () => {
|
|
11
13
|
it('is a no-op when no color scale is present', () => {
|
|
@@ -77,3 +79,80 @@ describe('applyColorScaleRange', () => {
|
|
|
77
79
|
expect(linear.range()).toEqual([firstSeq[0], firstSeq[firstSeq.length - 1]]);
|
|
78
80
|
});
|
|
79
81
|
});
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// seriesStrategy
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
describe('applyColorScaleRange seriesStrategy', () => {
|
|
88
|
+
function makeOrdinalScales(domain: string[]): {
|
|
89
|
+
scales: ResolvedScales;
|
|
90
|
+
ordinal: ReturnType<typeof scaleOrdinal<string, string>>;
|
|
91
|
+
} {
|
|
92
|
+
const ordinal = scaleOrdinal<string, string>().domain(domain);
|
|
93
|
+
const scales: ResolvedScales = {
|
|
94
|
+
color: { scale: ordinal, type: 'ordinal', channel: 'color' },
|
|
95
|
+
};
|
|
96
|
+
return { scales, ordinal };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const encoding: Encoding = {
|
|
100
|
+
x: { field: 'x', type: 'nominal' },
|
|
101
|
+
y: { field: 'y', type: 'quantitative' },
|
|
102
|
+
color: { field: 'c', type: 'nominal' },
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
it('palette strategy assigns full categorical palette', () => {
|
|
106
|
+
const { scales, ordinal } = makeOrdinalScales(['a', 'b', 'c']);
|
|
107
|
+
applyColorScaleRange(scales, encoding, theme);
|
|
108
|
+
expect(ordinal.range()).toEqual(theme.colors.categorical);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('accent-neutral: single series gets accent only', () => {
|
|
112
|
+
const { scales, ordinal } = makeOrdinalScales(['solo']);
|
|
113
|
+
applyColorScaleRange(scales, encoding, accentNeutralTheme);
|
|
114
|
+
expect(ordinal.range()).toEqual([accentNeutralTheme.colors.categorical[0]]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('accent-neutral: 3 series gets accent + neutral grays (darkest first on light surface)', () => {
|
|
118
|
+
const { scales, ordinal } = makeOrdinalScales(['a', 'b', 'c']);
|
|
119
|
+
applyColorScaleRange(scales, encoding, accentNeutralTheme);
|
|
120
|
+
const range = ordinal.range();
|
|
121
|
+
expect(range[0]).toBe(accentNeutralTheme.colors.categorical[0]);
|
|
122
|
+
expect(range[1]).toBe(ACHROMATIC_RAMP.fgFaint);
|
|
123
|
+
expect(range[2]).toBe(ACHROMATIC_RAMP.fgSubtle);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('accent-neutral: dark surface reverses neutral prominence (brightest first)', () => {
|
|
127
|
+
const { scales, ordinal } = makeOrdinalScales(['a', 'b', 'c']);
|
|
128
|
+
applyColorScaleRange(scales, encoding, darkAccentNeutralTheme);
|
|
129
|
+
const range = ordinal.range();
|
|
130
|
+
expect(range[0]).toBe(darkAccentNeutralTheme.colors.categorical[0]);
|
|
131
|
+
expect(range[1]).toBe(ACHROMATIC_RAMP.fgMuted);
|
|
132
|
+
expect(range[2]).toBe(ACHROMATIC_RAMP.fgSubtle);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('accent-neutral: 4 series is the last accent-neutral count', () => {
|
|
136
|
+
const { scales, ordinal } = makeOrdinalScales(['a', 'b', 'c', 'd']);
|
|
137
|
+
applyColorScaleRange(scales, encoding, accentNeutralTheme);
|
|
138
|
+
const range = ordinal.range();
|
|
139
|
+
expect(range[0]).toBe(accentNeutralTheme.colors.categorical[0]);
|
|
140
|
+
expect(range.slice(1)).toEqual([
|
|
141
|
+
ACHROMATIC_RAMP.fgFaint,
|
|
142
|
+
ACHROMATIC_RAMP.fgSubtle,
|
|
143
|
+
ACHROMATIC_RAMP.fgMuted,
|
|
144
|
+
]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('accent-neutral: 5 series falls back to full palette', () => {
|
|
148
|
+
const { scales, ordinal } = makeOrdinalScales(['a', 'b', 'c', 'd', 'e']);
|
|
149
|
+
applyColorScaleRange(scales, encoding, accentNeutralTheme);
|
|
150
|
+
expect(ordinal.range()).toEqual(accentNeutralTheme.colors.categorical);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('accent-neutral: 6 series falls back to full palette', () => {
|
|
154
|
+
const { scales, ordinal } = makeOrdinalScales(['a', 'b', 'c', 'd', 'e', 'f']);
|
|
155
|
+
applyColorScaleRange(scales, encoding, accentNeutralTheme);
|
|
156
|
+
expect(ordinal.range()).toEqual(accentNeutralTheme.colors.categorical);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -5,17 +5,27 @@
|
|
|
5
5
|
* (or the categorical endpoints as a fallback). Categorical scales get the
|
|
6
6
|
* full categorical palette. A user-provided `encoding.color.scale.range`
|
|
7
7
|
* always wins.
|
|
8
|
+
*
|
|
9
|
+
* When `highlight` is non-empty, highlighted series get sequential palette
|
|
10
|
+
* colors and muted series get a neutral gray.
|
|
11
|
+
*
|
|
12
|
+
* When `theme.seriesStrategy` is `'accent-neutral'`, low-cardinality
|
|
13
|
+
* charts use accent + neutral grays instead of the full palette.
|
|
8
14
|
*/
|
|
9
15
|
|
|
10
|
-
import type
|
|
16
|
+
import { ACHROMATIC_RAMP, type Encoding, type ResolvedTheme } from '@opendata-ai/openchart-core';
|
|
11
17
|
import type { ScaleLinear, ScaleOrdinal } from 'd3-scale';
|
|
12
18
|
import type { ResolvedScales } from '../layout/scales';
|
|
13
19
|
|
|
20
|
+
/** Neutral gray applied to muted (non-highlighted) series. */
|
|
21
|
+
const MUTED_COLOR = '#bfc3c8';
|
|
22
|
+
|
|
14
23
|
/** Mutates `scales.color.scale.range` in place when no explicit palette was set. */
|
|
15
24
|
export function applyColorScaleRange(
|
|
16
25
|
scales: ResolvedScales,
|
|
17
26
|
encoding: Encoding,
|
|
18
27
|
theme: ResolvedTheme,
|
|
28
|
+
highlight?: string[],
|
|
19
29
|
): void {
|
|
20
30
|
if (!scales.color) return;
|
|
21
31
|
|
|
@@ -33,6 +43,38 @@ export function applyColorScaleRange(
|
|
|
33
43
|
seqStops[seqStops.length - 1],
|
|
34
44
|
]);
|
|
35
45
|
} else {
|
|
36
|
-
|
|
46
|
+
const ordinalScale = scales.color.scale as ScaleOrdinal<string, string>;
|
|
47
|
+
const palette = theme.colors.categorical;
|
|
48
|
+
const domain = ordinalScale.domain();
|
|
49
|
+
|
|
50
|
+
if (highlight && highlight.length > 0) {
|
|
51
|
+
const highlightSet = new Set(highlight);
|
|
52
|
+
const colors = domain.map((v, i) =>
|
|
53
|
+
highlightSet.has(v) ? palette[i % palette.length] : MUTED_COLOR,
|
|
54
|
+
);
|
|
55
|
+
ordinalScale.range(colors);
|
|
56
|
+
} else {
|
|
57
|
+
const strategy = theme.seriesStrategy ?? 'palette';
|
|
58
|
+
if (strategy !== 'accent-neutral') {
|
|
59
|
+
ordinalScale.range(palette);
|
|
60
|
+
} else {
|
|
61
|
+
const count = domain.length;
|
|
62
|
+
if (count <= 1) {
|
|
63
|
+
ordinalScale.range([palette[0]]);
|
|
64
|
+
} else if (count <= 4) {
|
|
65
|
+
// Prominence tracks contrast against the surface: on dark canvases
|
|
66
|
+
// the brightest gray reads strongest, on light canvases the darkest.
|
|
67
|
+
const neutrals = theme.isDark
|
|
68
|
+
? [ACHROMATIC_RAMP.fgMuted, ACHROMATIC_RAMP.fgSubtle, ACHROMATIC_RAMP.fgFaint]
|
|
69
|
+
: [ACHROMATIC_RAMP.fgFaint, ACHROMATIC_RAMP.fgSubtle, ACHROMATIC_RAMP.fgMuted];
|
|
70
|
+
const colors = domain.map((_, i) =>
|
|
71
|
+
i === 0 ? palette[0] : neutrals[(i - 1) % neutrals.length],
|
|
72
|
+
);
|
|
73
|
+
ordinalScale.range(colors);
|
|
74
|
+
} else {
|
|
75
|
+
ordinalScale.range(palette);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
37
79
|
}
|
|
38
80
|
}
|