@opendata-ai/openchart-engine 6.26.0 → 6.27.2

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.
@@ -1,8 +1,10 @@
1
1
  import type { Annotation, LayoutStrategy, Rect } from '@opendata-ai/openchart-core';
2
+ import type { ScaleBand, ScalePoint } from 'd3-scale';
2
3
  import { describe, expect, it } from 'vitest';
3
4
  import type { NormalizedChartSpec } from '../../compiler/types';
4
5
  import { computeScales } from '../../layout/scales';
5
6
  import { computeAnnotations } from '../compute';
7
+ import { resolvePosition } from '../position';
6
8
 
7
9
  // ---------------------------------------------------------------------------
8
10
  // Fixtures
@@ -236,6 +238,179 @@ describe('computeAnnotations', () => {
236
238
  // Non-numeric domain can't interpolate, annotation is dropped
237
239
  expect(annotations).toHaveLength(0);
238
240
  });
241
+
242
+ it('two adjacent point-scale ranges have a non-zero pixel gap between them', () => {
243
+ // Regression: ranges ending/starting at point-scale centers would share the same
244
+ // pixel boundary, visually merging. resolvePositionEdge now extends each range
245
+ // by half a step so adjacent ranges are truly separated.
246
+ const ordinalSpec: NormalizedChartSpec = {
247
+ markType: 'line',
248
+ markDef: { type: 'line' },
249
+ data: [
250
+ { year: '2006', value: 10 },
251
+ { year: '2008', value: 20 },
252
+ { year: '2010', value: 30 },
253
+ { year: '2012', value: 40 },
254
+ { year: '2020', value: 50 },
255
+ { year: '2022', value: 60 },
256
+ ],
257
+ encoding: {
258
+ x: { field: 'year', type: 'ordinal' },
259
+ y: { field: 'value', type: 'quantitative' },
260
+ },
261
+ chrome: {},
262
+ annotations: [
263
+ { type: 'range', x1: '2008', x2: '2010', label: 'First range' },
264
+ { type: 'range', x1: '2020', x2: '2022', label: 'Second range' },
265
+ ],
266
+ responsive: true,
267
+ theme: {},
268
+ darkMode: 'off',
269
+ labels: { density: 'auto', format: '' },
270
+ };
271
+ const scales = computeScales(ordinalSpec, chartArea, ordinalSpec.data);
272
+ const annotations = computeAnnotations(ordinalSpec, scales, chartArea, fullStrategy);
273
+
274
+ expect(annotations).toHaveLength(2);
275
+ const rect1 = annotations[0].rect!;
276
+ const rect2 = annotations[1].rect!;
277
+
278
+ // First range must end before second range starts (non-zero gap)
279
+ expect(rect1.x + rect1.width).toBeLessThan(rect2.x);
280
+ });
281
+
282
+ it('single range on point-scale ordinal x covers at least a full step', () => {
283
+ // A range spanning a single domain value should be at least step-wide,
284
+ // since resolvePositionEdge extends by half a step on each side.
285
+ const domainValues = ['2006', '2008', '2010', '2012'];
286
+ const ordinalSpec: NormalizedChartSpec = {
287
+ markType: 'line',
288
+ markDef: { type: 'line' },
289
+ data: domainValues.map((year, i) => ({ year, value: i * 10 })),
290
+ encoding: {
291
+ x: { field: 'year', type: 'ordinal' },
292
+ y: { field: 'value', type: 'quantitative' },
293
+ },
294
+ chrome: {},
295
+ annotations: [{ type: 'range', x1: '2008', x2: '2010', label: 'Single step' }],
296
+ responsive: true,
297
+ theme: {},
298
+ darkMode: 'off',
299
+ labels: { density: 'auto', format: '' },
300
+ };
301
+ const scales = computeScales(ordinalSpec, chartArea, ordinalSpec.data);
302
+ const annotations = computeAnnotations(ordinalSpec, scales, chartArea, fullStrategy);
303
+
304
+ expect(annotations).toHaveLength(1);
305
+ const rect = annotations[0].rect!;
306
+
307
+ // step = chartArea.width / (numPoints - 1) roughly. With 4 points and default 0.5 padding,
308
+ // the step on a point scale = width / (n - 1 + 2*padding) -- but the key property is that
309
+ // the range width should be at least as wide as the distance between two domain centers.
310
+ const xScale = scales.x!.scale as ScalePoint<string>;
311
+ const step = xScale.step();
312
+ expect(rect.width).toBeGreaterThanOrEqual(step);
313
+ });
314
+
315
+ it('band-scale (bar chart) range covers full bands, not band centers', () => {
316
+ // On a bar chart the x scale is a band scale. resolvePositionEdge extends from the
317
+ // center (what resolvePosition returns) to the band edge.
318
+ const barSpec: NormalizedChartSpec = {
319
+ markType: 'bar',
320
+ markDef: { type: 'bar', orient: 'vertical' },
321
+ data: [
322
+ { year: '2008', value: 10 },
323
+ { year: '2010', value: 20 },
324
+ { year: '2012', value: 30 },
325
+ { year: '2014', value: 40 },
326
+ ],
327
+ encoding: {
328
+ x: { field: 'year', type: 'ordinal' },
329
+ y: { field: 'value', type: 'quantitative' },
330
+ },
331
+ chrome: {},
332
+ annotations: [{ type: 'range', x1: '2010', x2: '2012', label: 'Band range' }],
333
+ responsive: true,
334
+ theme: {},
335
+ darkMode: 'off',
336
+ labels: { density: 'auto', format: '' },
337
+ };
338
+ const scales = computeScales(barSpec, chartArea, barSpec.data);
339
+ const annotations = computeAnnotations(barSpec, scales, chartArea, fullStrategy);
340
+
341
+ expect(annotations).toHaveLength(1);
342
+ const rect = annotations[0].rect!;
343
+
344
+ const bandScale = scales.x!.scale as ScaleBand<string>;
345
+ const bandwidth = bandScale.bandwidth();
346
+ const x1BandStart = bandScale('2010')!;
347
+ const x2BandStart = bandScale('2012')!;
348
+
349
+ // Left edge should be at the start of the 2010 band (not the center)
350
+ expect(rect.x).toBeCloseTo(x1BandStart, 1);
351
+ // Right edge should be at the end of the 2012 band
352
+ expect(rect.x + rect.width).toBeCloseTo(x2BandStart + bandwidth, 1);
353
+ });
354
+
355
+ it('linear-scale range is unaffected by edge extension', () => {
356
+ // For linear scales, resolvePositionEdge is identical to resolvePosition.
357
+ // This ensures the fix doesn't introduce any drift on continuous axes.
358
+ const linearSpec = makeSpec([{ type: 'range', x1: '2020-01-01', x2: '2021-01-01' }]);
359
+ const scales = computeScales(linearSpec, chartArea, linearSpec.data);
360
+ const annotations = computeAnnotations(linearSpec, scales, chartArea, fullStrategy);
361
+
362
+ expect(annotations).toHaveLength(1);
363
+ const rect = annotations[0].rect!;
364
+
365
+ // The x positions should exactly match what resolvePosition would return
366
+ const x1Expected = resolvePosition('2020-01-01', scales.x)!;
367
+ const x2Expected = resolvePosition('2021-01-01', scales.x)!;
368
+
369
+ expect(rect.x).toBeCloseTo(Math.min(x1Expected, x2Expected), 1);
370
+ expect(rect.x + rect.width).toBeCloseTo(Math.max(x1Expected, x2Expected), 1);
371
+ });
372
+
373
+ it('y1/y2 range on ordinal point-scale y-axis has a non-zero pixel gap between two annotations', () => {
374
+ // Horizontal band: y-axis is ordinal (point scale), x-axis is quantitative.
375
+ // Two y-range annotations with a gap should produce two distinct rects.
376
+ const ordinalYSpec: NormalizedChartSpec = {
377
+ markType: 'line',
378
+ markDef: { type: 'line' },
379
+ data: [
380
+ { year: '2006', value: 10 },
381
+ { year: '2008', value: 20 },
382
+ { year: '2010', value: 30 },
383
+ { year: '2015', value: 40 },
384
+ { year: '2020', value: 50 },
385
+ { year: '2022', value: 60 },
386
+ ],
387
+ encoding: {
388
+ x: { field: 'value', type: 'quantitative' },
389
+ y: { field: 'year', type: 'ordinal' },
390
+ },
391
+ chrome: {},
392
+ annotations: [
393
+ { type: 'range', y1: '2006', y2: '2008' },
394
+ { type: 'range', y1: '2020', y2: '2022' },
395
+ ],
396
+ responsive: true,
397
+ theme: {},
398
+ darkMode: 'off',
399
+ labels: { density: 'auto', format: '' },
400
+ };
401
+ const scales = computeScales(ordinalYSpec, chartArea, ordinalYSpec.data);
402
+ const annotations = computeAnnotations(ordinalYSpec, scales, chartArea, fullStrategy);
403
+
404
+ expect(annotations).toHaveLength(2);
405
+ const rects = annotations.map((a) => a.rect!);
406
+
407
+ // In SVG, y increases downward. Sort by y so rect[0] is the top one.
408
+ rects.sort((a, b) => a.y - b.y);
409
+
410
+ // There must be a non-zero pixel gap between the bottom of rect[0] and top of rect[1]
411
+ const bottomOfFirst = rects[0].y + rects[0].height;
412
+ expect(bottomOfFirst).toBeLessThan(rects[1].y);
413
+ });
239
414
  });
240
415
 
241
416
  describe('reference line annotations', () => {
@@ -2,7 +2,7 @@
2
2
  * Data-coordinate to pixel-coordinate resolution for annotations.
3
3
  */
4
4
 
5
- import type { ScaleBand, ScaleLinear, ScaleTime } from 'd3-scale';
5
+ import type { ScaleBand, ScaleLinear, ScalePoint, ScaleTime } from 'd3-scale';
6
6
  import type { ResolvedScales } from '../layout/scales';
7
7
 
8
8
  /**
@@ -93,3 +93,39 @@ export function resolvePosition(
93
93
 
94
94
  return null;
95
95
  }
96
+
97
+ /**
98
+ * Like resolvePosition, but extends the result to the visual edge of the column
99
+ * for band and point scales. Used by range annotations to span complete columns.
100
+ *
101
+ * For point scales: shifts by ±step/2 using scalePoint.step()
102
+ * For band scales: shifts from center (what resolvePosition returns) to the band edge
103
+ * For all other scales: identical to resolvePosition
104
+ */
105
+ export function resolvePositionEdge(
106
+ value: string | number,
107
+ scale: ResolvedScales['x'] | ResolvedScales['y'],
108
+ edge: 'start' | 'end',
109
+ ): number | null {
110
+ const center = resolvePosition(value, scale);
111
+ if (center === null || !scale) return null;
112
+
113
+ const type = scale.type;
114
+
115
+ if (type === 'point') {
116
+ const s = scale.scale as ScalePoint<string>;
117
+ const halfStep = (s.step?.() ?? 0) / 2;
118
+ return edge === 'start' ? center - halfStep : center + halfStep;
119
+ }
120
+
121
+ if (type === 'band') {
122
+ // center = bandStart + bandwidth/2
123
+ // edge 'start' => bandStart = center - bandwidth/2
124
+ // edge 'end' => bandEnd = center + bandwidth/2
125
+ const s = scale.scale as ScaleBand<string>;
126
+ const halfBw = (s.bandwidth?.() ?? 0) / 2;
127
+ return edge === 'start' ? center - halfBw : center + halfBw;
128
+ }
129
+
130
+ return center;
131
+ }
@@ -12,7 +12,7 @@ import type {
12
12
  import type { ResolvedScales } from '../layout/scales';
13
13
  import { DEFAULT_RANGE_FILL, DEFAULT_RANGE_OPACITY } from './constants';
14
14
  import { applyOffset } from './geometry';
15
- import { resolvePosition } from './position';
15
+ import { resolvePositionEdge } from './position';
16
16
  import { makeAnnotationLabelStyle } from './resolve-text';
17
17
 
18
18
  export function resolveRangeAnnotation(
@@ -28,8 +28,8 @@ export function resolveRangeAnnotation(
28
28
 
29
29
  // X-range (vertical band)
30
30
  if (annotation.x1 !== undefined && annotation.x2 !== undefined) {
31
- const x1px = resolvePosition(annotation.x1, scales.x);
32
- const x2px = resolvePosition(annotation.x2, scales.x);
31
+ const x1px = resolvePositionEdge(annotation.x1, scales.x, 'start');
32
+ const x2px = resolvePositionEdge(annotation.x2, scales.x, 'end');
33
33
  if (x1px === null || x2px === null) return null;
34
34
 
35
35
  x = Math.min(x1px, x2px);
@@ -38,8 +38,8 @@ export function resolveRangeAnnotation(
38
38
 
39
39
  // Y-range (horizontal band)
40
40
  if (annotation.y1 !== undefined && annotation.y2 !== undefined) {
41
- const y1px = resolvePosition(annotation.y1, scales.y);
42
- const y2px = resolvePosition(annotation.y2, scales.y);
41
+ const y1px = resolvePositionEdge(annotation.y1, scales.y, 'end');
42
+ const y2px = resolvePositionEdge(annotation.y2, scales.y, 'start');
43
43
  if (y1px === null || y2px === null) return null;
44
44
 
45
45
  y = Math.min(y1px, y2px);
@@ -386,6 +386,108 @@ describe('computeBarMarks', () => {
386
386
  });
387
387
  });
388
388
 
389
+ describe('stacked vs grouped (wage data reproduction)', () => {
390
+ // 2 years × 2 firm-size categories — the canonical grouped-bar use case
391
+ const wageData = [
392
+ { size: '<5 employees', year: '2018', pay: 48200 },
393
+ { size: '<5 employees', year: '2022', pay: 56400 },
394
+ { size: '5,000+ employees', year: '2018', pay: 62300 },
395
+ { size: '5,000+ employees', year: '2022', pay: 74800 },
396
+ ];
397
+
398
+ function makeWageSpec(stackNull = false): NormalizedChartSpec {
399
+ return {
400
+ markType: 'bar',
401
+ markDef: { type: 'bar' },
402
+ data: wageData,
403
+ encoding: {
404
+ x: {
405
+ field: 'pay',
406
+ type: 'quantitative',
407
+ ...(stackNull ? { stack: null } : {}),
408
+ },
409
+ y: { field: 'size', type: 'nominal' },
410
+ color: { field: 'year', type: 'nominal' },
411
+ },
412
+ chrome: {},
413
+ annotations: [],
414
+ responsive: true,
415
+ theme: {},
416
+ darkMode: 'off',
417
+ labels: { density: 'auto', format: '' },
418
+ };
419
+ }
420
+
421
+ it('stacks by default: segments are contiguous end-to-end within each category', () => {
422
+ const spec = makeWageSpec(false);
423
+ const scales = computeScales(spec, chartArea, spec.data);
424
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
425
+
426
+ // 2 firm sizes × 2 years = 4 bars
427
+ expect(marks).toHaveLength(4);
428
+
429
+ // For stacked bars, the second segment starts exactly where the first ends.
430
+ const smallFirmMarks = marks
431
+ .filter((m) => m.aria.label.includes('<5'))
432
+ .sort((a, b) => a.x - b.x);
433
+ expect(smallFirmMarks).toHaveLength(2);
434
+ expect(smallFirmMarks[1].x).toBeCloseTo(smallFirmMarks[0].x + smallFirmMarks[0].width, 1);
435
+ });
436
+
437
+ it('stacks by default: segments share the same y position (stacked on same row)', () => {
438
+ const spec = makeWageSpec(false);
439
+ const scales = computeScales(spec, chartArea, spec.data);
440
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
441
+
442
+ const smallFirmMarks = marks.filter((m) => m.aria.label.includes('<5'));
443
+ expect(smallFirmMarks).toHaveLength(2);
444
+ expect(smallFirmMarks[0].y).toBe(smallFirmMarks[1].y);
445
+ });
446
+
447
+ it('grouped with stack:null: bar widths match individual pay values (not cumulative)', () => {
448
+ const stackedSpec = makeWageSpec(false);
449
+ const stackedScales = computeScales(stackedSpec, chartArea, stackedSpec.data);
450
+ const stackedMarks = computeBarMarks(stackedSpec, stackedScales, chartArea, fullStrategy);
451
+
452
+ const groupedSpec = makeWageSpec(true);
453
+ const groupedScales = computeScales(groupedSpec, chartArea, groupedSpec.data);
454
+ const groupedMarks = computeBarMarks(groupedSpec, groupedScales, chartArea, fullStrategy);
455
+
456
+ expect(groupedMarks).toHaveLength(4);
457
+
458
+ // Grouped bars each start from the baseline (same x for both years within a firm size)
459
+ const smallFirmGrouped = groupedMarks.filter((m) => m.aria.label.includes('<5'));
460
+ expect(smallFirmGrouped[0].x).toBe(smallFirmGrouped[1].x);
461
+
462
+ // Stacked bars for the same category have different x positions (end-to-end)
463
+ const smallFirmStacked = stackedMarks.filter((m) => m.aria.label.includes('<5'));
464
+ expect(smallFirmStacked[0].x).not.toBe(smallFirmStacked[1].x);
465
+ });
466
+
467
+ it('grouped with stack:null: bars sit at different y positions within each category', () => {
468
+ const spec = makeWageSpec(true);
469
+ const scales = computeScales(spec, chartArea, spec.data);
470
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
471
+
472
+ const smallFirmMarks = marks.filter((m) => m.aria.label.includes('<5'));
473
+ expect(smallFirmMarks).toHaveLength(2);
474
+ expect(smallFirmMarks[0].y).not.toBe(smallFirmMarks[1].y);
475
+ });
476
+
477
+ it('grouped with stack:null: scale domain covers max individual value, not stacked sum', () => {
478
+ const spec = makeWageSpec(true);
479
+ const scales = computeScales(spec, chartArea, spec.data);
480
+
481
+ // Max individual pay is 74800. Stacked sum for 5000+ employees = 62300 + 74800 = 137100.
482
+ // With stack:null the domain should NOT reach 137100.
483
+ const xScale = scales.x!.scale;
484
+ const domain = xScale.domain() as number[];
485
+ expect(domain[1]).toBeLessThan(137100);
486
+ // But it should cover the max individual value
487
+ expect(domain[1]).toBeGreaterThanOrEqual(74800);
488
+ });
489
+ });
490
+
389
491
  describe('edge cases', () => {
390
492
  it('returns empty array when no x encoding', () => {
391
493
  const spec: NormalizedChartSpec = {
@@ -118,6 +118,7 @@ export function computeBarMarks(
118
118
  const needsStacking = Array.from(categoryGroups.values()).some((rows) => rows.length > 1);
119
119
 
120
120
  if (needsStacking) {
121
+ // stack: null or false -> grouped (side-by-side) bars
121
122
  const stackDisabled = xChannel.stack === null || xChannel.stack === false;
122
123
 
123
124
  if (stackDisabled) {
@@ -151,7 +151,7 @@ function computeSingleArea(
151
151
  fill: fillValue,
152
152
  fillOpacity: fillOpacity,
153
153
  stroke: getRepresentativeColor(isGradientDef(fillValue) ? color : fillValue),
154
- strokeWidth: 2,
154
+ strokeWidth: spec.display === 'sparkline' ? 1.25 : 2,
155
155
  seriesKey: seriesKey === '__default__' ? undefined : seriesKey,
156
156
  data: validPoints.map((p) => p.row),
157
157
  dataPoints: validPoints.map((p) => ({ x: p.x, y: p.yTop, datum: p.row })),
@@ -32,6 +32,10 @@ import { resolveCurve } from './curves';
32
32
  /** Default stroke width for line marks. */
33
33
  const DEFAULT_STROKE_WIDTH = 2.5;
34
34
 
35
+ /** Sparkline mode uses a thinner stroke since the chart area is tiny and a
36
+ * 2.5px line reads as clunky. 1.25px keeps the trend legible without dominating. */
37
+ const SPARKLINE_STROKE_WIDTH = 1.25;
38
+
35
39
  /** Default radius for point marks (hover targets). */
36
40
  const DEFAULT_POINT_RADIUS = 3;
37
41
 
@@ -174,7 +178,9 @@ export function computeLineMarks(
174
178
  points: allPoints,
175
179
  path: combinedPath,
176
180
  stroke: strokeColor,
177
- strokeWidth: styleOverride?.strokeWidth ?? DEFAULT_STROKE_WIDTH,
181
+ strokeWidth:
182
+ styleOverride?.strokeWidth ??
183
+ (spec.display === 'sparkline' ? SPARKLINE_STROKE_WIDTH : DEFAULT_STROKE_WIDTH),
178
184
  strokeDasharray,
179
185
  opacity: styleOverride?.opacity,
180
186
  seriesKey: seriesStyleKey,
package/src/compile.ts CHANGED
@@ -199,7 +199,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
199
199
 
200
200
  // Resolve watermark: explicit spec value wins, then options fallback, then default true.
201
201
  const rawWatermark = (expandedSpec as Record<string, unknown>).watermark;
202
- const watermark = rawWatermark !== undefined ? chartSpec.watermark : (options.watermark ?? true);
202
+ let watermark = rawWatermark !== undefined ? chartSpec.watermark : (options.watermark ?? true);
203
203
 
204
204
  // Run data transforms (filter, bin, calculate, timeUnit) before any other data processing.
205
205
  // Transforms are defined on the expanded spec (which includes any auto-generated
@@ -223,10 +223,49 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
223
223
  | Partial<
224
224
  Record<
225
225
  string,
226
- { chrome?: unknown; labels?: unknown; legend?: unknown; annotations?: unknown }
226
+ {
227
+ chrome?: unknown;
228
+ labels?: unknown;
229
+ legend?: unknown;
230
+ annotations?: unknown;
231
+ animation?: unknown;
232
+ display?: unknown;
233
+ encoding?: unknown;
234
+ watermark?: unknown;
235
+ crosshair?: unknown;
236
+ }
227
237
  >
228
238
  >
229
239
  | undefined;
240
+
241
+ // Build userExplicit descriptor BEFORE applying any overrides so we capture
242
+ // the union of "user wrote this at top-level" and "user wrote this in the
243
+ // active breakpoint override." Sparkline display mode reads this to decide
244
+ // whether to suppress chrome/axes/legend/etc. by default vs. respecting an
245
+ // explicit user opt-in. Precedence: explicit at any level wins.
246
+ const rawEncoding = rawSpec.encoding as
247
+ | { x?: { axis?: unknown }; y?: { axis?: unknown } }
248
+ | undefined;
249
+ const bpForExplicit = overrides?.[breakpoint];
250
+ const bpEncoding = bpForExplicit?.encoding as
251
+ | { x?: { axis?: unknown }; y?: { axis?: unknown } }
252
+ | undefined;
253
+ // chrome: {} (empty object) is not "explicit" — it's an idiom users write to
254
+ // silence defaults. Require at least one chrome key set to count as opt-in.
255
+ const hasChromeKeys = (v: unknown): boolean =>
256
+ !!v && typeof v === 'object' && Object.keys(v as Record<string, unknown>).length > 0;
257
+ const userExplicit = {
258
+ chrome: hasChromeKeys(rawSpec.chrome) || hasChromeKeys(bpForExplicit?.chrome),
259
+ legend: rawSpec.legend !== undefined || bpForExplicit?.legend !== undefined,
260
+ xAxis: rawEncoding?.x?.axis !== undefined || bpEncoding?.x?.axis !== undefined,
261
+ yAxis: rawEncoding?.y?.axis !== undefined || bpEncoding?.y?.axis !== undefined,
262
+ labels: rawSpec.labels !== undefined || bpForExplicit?.labels !== undefined,
263
+ animation: rawSpec.animation !== undefined || bpForExplicit?.animation !== undefined,
264
+ watermark: rawSpec.watermark !== undefined || bpForExplicit?.watermark !== undefined,
265
+ crosshair: rawSpec.crosshair !== undefined || bpForExplicit?.crosshair !== undefined,
266
+ };
267
+ chartSpec = { ...chartSpec, userExplicit };
268
+
230
269
  if (overrides?.[breakpoint]) {
231
270
  const bp = overrides[breakpoint]!;
232
271
  if (bp.chrome) {
@@ -274,14 +313,138 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
274
313
  // responsive strategy so they render inline instead of being stripped.
275
314
  strategy = { ...strategy, annotationPosition: 'inline' };
276
315
  }
316
+ // New override branches for sparkline mode and related fields:
317
+ if (bp.display !== undefined) {
318
+ chartSpec = {
319
+ ...chartSpec,
320
+ display: bp.display as NormalizedChartSpec['display'],
321
+ };
322
+ }
323
+ if (bp.encoding !== undefined) {
324
+ // Merge encoding so a breakpoint can flip on/off encoding.x.axis or
325
+ // encoding.y.axis (used by sparkline display mode to opt back in to
326
+ // axes at a specific breakpoint). Channels merge per-key, and `axis`
327
+ // and `scale` deep-merge one level so a breakpoint can set
328
+ // `axis: { title: 'foo' }` without dropping the base spec's
329
+ // `axis.tickCount` / `axis.format`.
330
+ const bpEnc = bp.encoding as Record<string, Record<string, unknown> | undefined>;
331
+ const mergedEncoding = { ...chartSpec.encoding } as Record<
332
+ string,
333
+ Record<string, unknown> | undefined
334
+ >;
335
+ const NESTED_CHANNEL_KEYS = ['axis', 'scale'];
336
+ for (const channel of Object.keys(bpEnc)) {
337
+ const baseCh = mergedEncoding[channel];
338
+ const bpCh = bpEnc[channel];
339
+ if (bpCh && baseCh) {
340
+ const merged: Record<string, unknown> = { ...baseCh, ...bpCh };
341
+ for (const key of NESTED_CHANNEL_KEYS) {
342
+ const baseNested = baseCh[key];
343
+ const bpNested = bpCh[key];
344
+ if (
345
+ baseNested &&
346
+ bpNested &&
347
+ typeof baseNested === 'object' &&
348
+ typeof bpNested === 'object' &&
349
+ !Array.isArray(baseNested) &&
350
+ !Array.isArray(bpNested)
351
+ ) {
352
+ merged[key] = { ...baseNested, ...bpNested };
353
+ }
354
+ }
355
+ mergedEncoding[channel] = merged;
356
+ } else if (bpCh) {
357
+ mergedEncoding[channel] = bpCh;
358
+ }
359
+ }
360
+ chartSpec = {
361
+ ...chartSpec,
362
+ encoding: mergedEncoding as unknown as NormalizedChartSpec['encoding'],
363
+ };
364
+ }
365
+ if (typeof bp.watermark === 'boolean') {
366
+ // Update the resolved watermark value used downstream. ChartSpec carries
367
+ // this in its normalized shape; the local `watermark` variable controls
368
+ // chrome computation and rendering.
369
+ watermark = bp.watermark;
370
+ chartSpec = { ...chartSpec, watermark };
371
+ }
372
+ }
373
+
374
+ // Sparkline mode: default labels off. Mark renderers draw value labels per
375
+ // labels.density (default 'auto'), which fills tiny sparklines with text and
376
+ // is never what you want. Explicit user labels at any level wins via
377
+ // userExplicit.labels.
378
+ if (chartSpec.display === 'sparkline' && !chartSpec.userExplicit.labels) {
379
+ chartSpec = {
380
+ ...chartSpec,
381
+ labels: { ...chartSpec.labels, density: 'none' },
382
+ };
277
383
  }
278
384
 
279
385
  // Resolve animation spec. Breakpoint override wins over base spec (matching
280
386
  // chrome, labels, legend, and annotation override precedence).
281
- const rawAnimationSpec = ((overrides?.[breakpoint] as Record<string, unknown> | undefined)
387
+ // Precedence rule for sparkline mode: an explicit user animation at ANY
388
+ // level (top-level OR breakpoint) always wins, regardless of display mode.
389
+ // resolveAnimation handles the explicit-user value; the sparkline default-off
390
+ // behavior is applied below when no explicit value exists.
391
+ let rawAnimationSpec = ((overrides?.[breakpoint] as Record<string, unknown> | undefined)
282
392
  ?.animation ?? rawSpec.animation) as AnimationSpec | undefined;
393
+ if (rawAnimationSpec === undefined && chartSpec.display === 'sparkline') {
394
+ // Sparkline mode: animation defaults to false. User-explicit (top OR bp)
395
+ // already short-circuits this branch via userExplicit.animation.
396
+ rawAnimationSpec = false;
397
+ }
398
+ // Sparkline mode: when animation is on but the user didn't specify duration,
399
+ // bump to 1100ms so the line/area reveal feels paced rather than mechanical.
400
+ // The CSS override pairs this with an expo-out easing curve. AnimationConfig
401
+ // nests duration under `enter`, so we set it there.
402
+ if (
403
+ chartSpec.display === 'sparkline' &&
404
+ rawAnimationSpec !== false &&
405
+ rawAnimationSpec !== undefined
406
+ ) {
407
+ const SPARK_DURATION = 1100;
408
+ if (rawAnimationSpec === true) {
409
+ rawAnimationSpec = { enter: { duration: SPARK_DURATION } } as AnimationSpec;
410
+ } else if (typeof rawAnimationSpec === 'object') {
411
+ const cfg = rawAnimationSpec as { enter?: unknown; annotationDelay?: number };
412
+ const enter = cfg.enter;
413
+ if (enter === undefined || enter === true) {
414
+ rawAnimationSpec = {
415
+ ...cfg,
416
+ enter: { duration: SPARK_DURATION },
417
+ } as AnimationSpec;
418
+ } else if (
419
+ typeof enter === 'object' &&
420
+ enter !== null &&
421
+ (enter as { duration?: number }).duration === undefined
422
+ ) {
423
+ rawAnimationSpec = {
424
+ ...cfg,
425
+ enter: { ...(enter as object), duration: SPARK_DURATION },
426
+ } as AnimationSpec;
427
+ }
428
+ }
429
+ }
283
430
  const resolvedAnimation = resolveAnimation(rawAnimationSpec);
284
431
 
432
+ // Crosshair: explicit user value at any level wins. In sparkline mode the
433
+ // default is off, otherwise default is off too (crosshair is opt-in). The
434
+ // value is plumbed through ChartLayout so the renderer doesn't need to
435
+ // re-inspect the raw spec.
436
+ const rawCrosshair = (bpForExplicit?.crosshair ?? rawSpec.crosshair) as boolean | undefined;
437
+ const crosshair =
438
+ chartSpec.display === 'sparkline' && !chartSpec.userExplicit.crosshair
439
+ ? false
440
+ : rawCrosshair === true;
441
+
442
+ // Watermark default-off in sparkline mode unless user-explicit.
443
+ if (chartSpec.display === 'sparkline' && !chartSpec.userExplicit.watermark) {
444
+ watermark = false;
445
+ chartSpec = { ...chartSpec, watermark: false };
446
+ }
447
+
285
448
  // Resolve theme: merge spec-level theme with options-level overrides
286
449
  const mergedThemeConfig = options.theme
287
450
  ? { ...chartSpec.theme, ...options.theme }
@@ -365,12 +528,18 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
365
528
  // Arc charts (pie/donut) don't use axes or gridlines
366
529
  const isRadial = chartSpec.markType === 'arc';
367
530
 
368
- // Compute axes (skip for radial charts)
531
+ // Compute axes (skip for radial charts).
532
+ // Sparkline mode skips axes by default unless the user explicitly opted into
533
+ // an axis on a specific channel.
534
+ const skipX = chartSpec.display === 'sparkline' && !chartSpec.userExplicit.xAxis;
535
+ const skipY = chartSpec.display === 'sparkline' && !chartSpec.userExplicit.yAxis;
369
536
  const axes = isRadial
370
537
  ? { x: undefined, y: undefined }
371
538
  : computeAxes(scales, chartArea, strategy, theme, options.measureText, {
372
539
  data: renderSpec.data,
373
540
  encoding: renderSpec.encoding as Encoding,
541
+ skipX,
542
+ skipY,
374
543
  });
375
544
 
376
545
  // INVARIANT 2 — computeGridlines mutates `axes` in place. Downstream consumers read
@@ -464,6 +633,8 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
464
633
  },
465
634
  animation: resolvedAnimation,
466
635
  watermark,
636
+ display: chartSpec.display,
637
+ crosshair,
467
638
  measureText: options.measureText,
468
639
  };
469
640
  }