@opendata-ai/openchart-engine 7.6.1 → 7.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/index.d.ts +13 -0
  2. package/dist/index.js +1031 -187
  3. package/dist/index.js.map +1 -1
  4. package/package.json +2 -2
  5. package/src/__test-fixtures__/specs.ts +6 -0
  6. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +16 -0
  7. package/src/__tests__/axes.test.ts +3 -2
  8. package/src/__tests__/facet.test.ts +215 -0
  9. package/src/annotations/__tests__/compute.test.ts +18 -0
  10. package/src/annotations/__tests__/thinning.test.ts +137 -0
  11. package/src/annotations/compute.ts +3 -1
  12. package/src/annotations/thinning.ts +116 -0
  13. package/src/barlist/compile-barlist.ts +1 -0
  14. package/src/charts/bar/labels.ts +1 -0
  15. package/src/charts/column/labels.ts +1 -0
  16. package/src/charts/dot/labels.ts +1 -0
  17. package/src/charts/line/area.ts +16 -2
  18. package/src/charts/line/compute.ts +17 -10
  19. package/src/charts/pie/labels.ts +1 -0
  20. package/src/compile/__tests__/color-scale-range.test.ts +80 -1
  21. package/src/compile/color-scale-range.ts +44 -2
  22. package/src/compile.ts +314 -5
  23. package/src/compiler/__tests__/normalize-highlight.test.ts +103 -0
  24. package/src/compiler/__tests__/validate-highlight.test.ts +112 -0
  25. package/src/compiler/normalize.ts +67 -2
  26. package/src/compiler/types.ts +10 -0
  27. package/src/compiler/validate.ts +79 -0
  28. package/src/endpoint-labels/__tests__/compute.test.ts +57 -0
  29. package/src/endpoint-labels/compute.ts +179 -5
  30. package/src/endpoint-labels/predict.ts +17 -0
  31. package/src/layout/__tests__/scales-highlight.test.ts +192 -0
  32. package/src/layout/axes.ts +8 -2
  33. package/src/layout/dimensions.ts +6 -1
  34. package/src/layout/facet.ts +80 -0
  35. package/src/layout/plan.ts +16 -0
  36. package/src/legend/__tests__/redundancy.test.ts +108 -0
  37. package/src/legend/__tests__/suppression.test.ts +72 -0
  38. package/src/legend/compute.ts +18 -0
  39. package/src/legend/suppression.ts +27 -6
  40. package/src/tilemap/__tests__/compile-tilemap.test.ts +210 -0
  41. package/src/tilemap/compile-tilemap.ts +371 -117
  42. package/src/tilemap/types.ts +1 -0
@@ -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
+ }
@@ -258,6 +258,7 @@ export function compileBarList(spec: unknown, options: CompileOptions): BarListL
258
258
  fontWeight: VALUE_FONT_WEIGHT,
259
259
  fill: valueColor,
260
260
  lineHeight: 1.4,
261
+ fontVariant: 'tabular-nums',
261
262
  };
262
263
 
263
264
  const rowMark: BarListRowMark = {
@@ -227,6 +227,7 @@ export function computeBarLabels(
227
227
  lineHeight: 1.2,
228
228
  textAnchor,
229
229
  dominantBaseline: 'central',
230
+ fontVariant: 'tabular-nums',
230
231
  },
231
232
  });
232
233
  }
@@ -123,6 +123,7 @@ export function computeColumnLabels(
123
123
  fill: labelColor ?? getRepresentativeColor(mark.fill),
124
124
  lineHeight: 1.2,
125
125
  textAnchor: 'middle',
126
+ fontVariant: 'tabular-nums',
126
127
  },
127
128
  });
128
129
  }
@@ -99,6 +99,7 @@ export function computeDotLabels(
99
99
  lineHeight: 1.2,
100
100
  textAnchor: 'start',
101
101
  dominantBaseline: 'central',
102
+ fontVariant: 'tabular-nums',
102
103
  },
103
104
  });
104
105
  }
@@ -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
- return computeStackedArea(spec, scales, chartArea, darkMode);
462
+ marks = computeStackedArea(spec, scales, chartArea, darkMode);
463
+ } else {
464
+ marks = computeSingleArea(spec, scales, chartArea);
462
465
  }
463
466
 
464
- return computeSingleArea(spec, scales, chartArea);
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 marks: (LineMark | PointMark)[] = [];
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: styleOverride?.strokeWidth ?? spec.markDef.strokeWidth ?? DEFAULT_STROKE_WIDTH,
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
- marks.push(lineMark);
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
- marks.push({
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
- marks.push(pointMark);
274
+ bucket.push(pointMark);
268
275
  }
269
276
  }
270
277
  }
271
278
 
272
- return marks;
279
+ return [...mutedMarks, ...highlightedMarks];
273
280
  }
274
281
 
275
282
  // ---------------------------------------------------------------------------
@@ -99,6 +99,7 @@ export function computePieLabels(
99
99
  lineHeight: 1.2,
100
100
  textAnchor: isRight ? 'start' : 'end',
101
101
  dominantBaseline: 'central',
102
+ fontVariant: 'tabular-nums',
102
103
  },
103
104
  });
104
105
 
@@ -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 { Encoding, ResolvedTheme } from '@opendata-ai/openchart-core';
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
- (scales.color.scale as ScaleOrdinal<string, string>).range(theme.colors.categorical);
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
  }