@opendata-ai/openchart-engine 6.26.0 → 6.27.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "6.26.0",
3
+ "version": "6.27.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",
@@ -48,7 +48,7 @@
48
48
  "typecheck": "tsc --noEmit"
49
49
  },
50
50
  "dependencies": {
51
- "@opendata-ai/openchart-core": "6.26.0",
51
+ "@opendata-ai/openchart-core": "6.27.0",
52
52
  "d3-array": "^3.2.0",
53
53
  "d3-format": "^3.1.2",
54
54
  "d3-interpolate": "^3.0.0",
@@ -74,6 +74,17 @@ export function makeLineSpec(): NormalizedChartSpec {
74
74
  hiddenSeries: [],
75
75
  seriesStyles: {},
76
76
  watermark: true,
77
+ display: 'full',
78
+ userExplicit: {
79
+ chrome: false,
80
+ legend: false,
81
+ xAxis: false,
82
+ yAxis: false,
83
+ labels: false,
84
+ animation: false,
85
+ watermark: false,
86
+ crosshair: false,
87
+ },
77
88
  };
78
89
  }
79
90
 
@@ -104,6 +115,17 @@ export function makeBarSpec(): NormalizedChartSpec {
104
115
  hiddenSeries: [],
105
116
  seriesStyles: {},
106
117
  watermark: true,
118
+ display: 'full',
119
+ userExplicit: {
120
+ chrome: false,
121
+ legend: false,
122
+ xAxis: false,
123
+ yAxis: false,
124
+ labels: false,
125
+ animation: false,
126
+ watermark: false,
127
+ crosshair: false,
128
+ },
107
129
  };
108
130
  }
109
131
 
@@ -136,5 +158,16 @@ export function makeScatterSpec(): NormalizedChartSpec {
136
158
  hiddenSeries: [],
137
159
  seriesStyles: {},
138
160
  watermark: true,
161
+ display: 'full',
162
+ userExplicit: {
163
+ chrome: false,
164
+ legend: false,
165
+ xAxis: false,
166
+ yAxis: false,
167
+ labels: false,
168
+ animation: false,
169
+ watermark: false,
170
+ crosshair: false,
171
+ },
139
172
  };
140
173
  }
@@ -181,10 +181,12 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
181
181
  },
182
182
  "topHeight": 36.6,
183
183
  },
184
+ "crosshair": false,
184
185
  "dimensions": {
185
186
  "height": 400,
186
187
  "width": 600,
187
188
  },
189
+ "display": "full",
188
190
  "legend": {
189
191
  "bounds": {
190
192
  "height": 0,
@@ -731,10 +733,12 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
731
733
  },
732
734
  "topHeight": 61.599999999999994,
733
735
  },
736
+ "crosshair": false,
734
737
  "dimensions": {
735
738
  "height": 500,
736
739
  "width": 800,
737
740
  },
741
+ "display": "full",
738
742
  "legend": {
739
743
  "bounds": {
740
744
  "height": 85.2,
@@ -1574,10 +1578,12 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
1574
1578
  },
1575
1579
  "topHeight": 36.6,
1576
1580
  },
1581
+ "crosshair": false,
1577
1582
  "dimensions": {
1578
1583
  "height": 400,
1579
1584
  "width": 600,
1580
1585
  },
1586
+ "display": "full",
1581
1587
  "legend": {
1582
1588
  "bounds": {
1583
1589
  "height": 0,
@@ -398,6 +398,307 @@ describe('compileChart', () => {
398
398
  const pointMarks = layout.marks.filter((m) => m.type === 'point');
399
399
  expect(pointMarks.length).toBe(3);
400
400
  });
401
+
402
+ // ---------------------------------------------------------------------------
403
+ // display field + breakpoint override
404
+ // ---------------------------------------------------------------------------
405
+
406
+ it('display defaults to "full" when not specified', () => {
407
+ const layout = compileChart(lineSpec, { width: 600, height: 400 });
408
+ expect(layout.display).toBe('full');
409
+ });
410
+
411
+ it('display: "sparkline" propagates through to ChartLayout', () => {
412
+ const layout = compileChart(
413
+ { ...lineSpec, display: 'sparkline' as const },
414
+ { width: 400, height: 80 },
415
+ );
416
+ expect(layout.display).toBe('sparkline');
417
+ });
418
+
419
+ it('breakpoint override flips display at compact width', () => {
420
+ const spec = {
421
+ ...lineSpec,
422
+ overrides: {
423
+ compact: { display: 'sparkline' as const },
424
+ },
425
+ };
426
+
427
+ const compactLayout = compileChart(spec, { width: 320, height: 200 });
428
+ expect(compactLayout.display).toBe('sparkline');
429
+
430
+ const desktopLayout = compileChart(spec, { width: 1200, height: 600 });
431
+ expect(desktopLayout.display).toBe('full');
432
+ });
433
+
434
+ it('sparkline mode forces watermark off when not user-explicit', () => {
435
+ const layout = compileChart(
436
+ { ...lineSpec, display: 'sparkline' as const },
437
+ { width: 400, height: 80 },
438
+ );
439
+ expect(layout.watermark).toBe(false);
440
+ });
441
+
442
+ it('sparkline mode respects explicit watermark: true', () => {
443
+ const layout = compileChart(
444
+ { ...lineSpec, display: 'sparkline' as const, watermark: true },
445
+ { width: 400, height: 80 },
446
+ );
447
+ expect(layout.watermark).toBe(true);
448
+ });
449
+
450
+ it('sparkline mode forces crosshair off when not user-explicit', () => {
451
+ const layout = compileChart(
452
+ { ...lineSpec, display: 'sparkline' as const },
453
+ { width: 400, height: 80 },
454
+ );
455
+ expect(layout.crosshair).toBe(false);
456
+ });
457
+
458
+ it('sparkline mode respects explicit crosshair: true', () => {
459
+ const layout = compileChart(
460
+ { ...lineSpec, display: 'sparkline' as const, crosshair: true },
461
+ { width: 400, height: 80 },
462
+ );
463
+ expect(layout.crosshair).toBe(true);
464
+ });
465
+
466
+ // ---------------------------------------------------------------------------
467
+ // Sparkline layout profile (dimensions, axes, legend)
468
+ // ---------------------------------------------------------------------------
469
+
470
+ it('sparkline produces near-edge-to-edge chart area (margins <= 4px per side)', () => {
471
+ const layout = compileChart(
472
+ { ...lineSpec, chrome: undefined, display: 'sparkline' as const },
473
+ { width: 400, height: 80 },
474
+ );
475
+
476
+ // No chrome, no axes, no legend by default. Mark area should be tight.
477
+ expect(layout.area.x).toBeLessThanOrEqual(4);
478
+ expect(layout.area.y).toBeLessThanOrEqual(4);
479
+ const rightMargin = 400 - (layout.area.x + layout.area.width);
480
+ const bottomMargin = 80 - (layout.area.y + layout.area.height);
481
+ expect(rightMargin).toBeLessThanOrEqual(4);
482
+ expect(bottomMargin).toBeLessThanOrEqual(4);
483
+ });
484
+
485
+ it('sparkline returns no axes by default', () => {
486
+ const layout = compileChart(
487
+ { ...lineSpec, chrome: undefined, display: 'sparkline' as const },
488
+ { width: 400, height: 80 },
489
+ );
490
+ expect(layout.axes.x).toBeUndefined();
491
+ expect(layout.axes.y).toBeUndefined();
492
+ });
493
+
494
+ it('sparkline + explicit encoding.x.axis still reserves x-axis', () => {
495
+ const layout = compileChart(
496
+ {
497
+ ...lineSpec,
498
+ chrome: undefined,
499
+ display: 'sparkline' as const,
500
+ encoding: {
501
+ ...lineSpec.encoding,
502
+ x: { ...lineSpec.encoding.x, axis: { title: 'date' } },
503
+ },
504
+ },
505
+ { width: 400, height: 200 },
506
+ );
507
+ expect(layout.axes.x).toBeDefined();
508
+ });
509
+
510
+ it('sparkline + explicit chrome.title still renders chrome', () => {
511
+ const layout = compileChart(
512
+ {
513
+ ...lineSpec,
514
+ chrome: { title: 'Q4 Revenue' },
515
+ display: 'sparkline' as const,
516
+ },
517
+ { width: 400, height: 200 },
518
+ );
519
+ expect(layout.chrome.title).toBeDefined();
520
+ expect(layout.chrome.title?.text).toBe('Q4 Revenue');
521
+ });
522
+
523
+ it('sparkline hides legend by default even with color encoding', () => {
524
+ const layout = compileChart(
525
+ {
526
+ ...lineSpec,
527
+ chrome: undefined,
528
+ display: 'sparkline' as const,
529
+ },
530
+ { width: 400, height: 80 },
531
+ );
532
+ expect('entries' in layout.legend && layout.legend.entries.length).toBe(0);
533
+ });
534
+
535
+ it('sparkline + explicit legend.show: true renders legend', () => {
536
+ const layout = compileChart(
537
+ {
538
+ ...lineSpec,
539
+ chrome: undefined,
540
+ display: 'sparkline' as const,
541
+ legend: { show: true },
542
+ },
543
+ { width: 400, height: 200 },
544
+ );
545
+ expect('entries' in layout.legend && layout.legend.entries.length).toBeGreaterThan(0);
546
+ });
547
+
548
+ it('sparkline works at heights as low as 30px', () => {
549
+ const layout = compileChart(
550
+ { ...lineSpec, chrome: undefined, display: 'sparkline' as const },
551
+ { width: 200, height: 30 },
552
+ );
553
+ expect(layout.area.height).toBeGreaterThan(0);
554
+ expect(layout.area.width).toBeGreaterThan(0);
555
+ });
556
+
557
+ it('chrome: {} does not count as user-explicit chrome (still stripped in sparkline)', () => {
558
+ // Empty chrome object is the idiom for "silence defaults" — should not
559
+ // opt-in to chrome rendering in sparkline mode.
560
+ const layout = compileChart(
561
+ { ...lineSpec, chrome: {}, display: 'sparkline' as const },
562
+ { width: 400, height: 80 },
563
+ );
564
+ expect(layout.chrome.title).toBeUndefined();
565
+ expect(layout.chrome.topHeight).toBe(0);
566
+ });
567
+
568
+ // ---------------------------------------------------------------------------
569
+ // Explicit-at-any-level wins (precedence matrix)
570
+ // ---------------------------------------------------------------------------
571
+
572
+ it('top-level animation: true wins even when breakpoint flips to sparkline', () => {
573
+ const spec = {
574
+ ...lineSpec,
575
+ animation: true as const,
576
+ overrides: { compact: { display: 'sparkline' as const } },
577
+ };
578
+ const layout = compileChart(spec, { width: 320, height: 200 });
579
+ expect(layout.display).toBe('sparkline');
580
+ expect(layout.animation?.enabled).toBe(true);
581
+ });
582
+
583
+ it('breakpoint chrome wins when top-level is sparkline', () => {
584
+ const spec = {
585
+ ...lineSpec,
586
+ chrome: undefined,
587
+ display: 'sparkline' as const,
588
+ overrides: { full: { chrome: { title: 'Q4 revenue' } } },
589
+ };
590
+ const layout = compileChart(spec, { width: 1200, height: 600 });
591
+ // At full breakpoint, chrome.title from override should render even
592
+ // though display is sparkline at top-level.
593
+ expect(layout.chrome.title?.text).toBe('Q4 revenue');
594
+ });
595
+
596
+ it('top-level display: sparkline + breakpoint display: full restores all defaults', () => {
597
+ const spec = {
598
+ ...lineSpec,
599
+ display: 'sparkline' as const,
600
+ overrides: { full: { display: 'full' as const } },
601
+ };
602
+ const layout = compileChart(spec, { width: 1200, height: 600 });
603
+ expect(layout.display).toBe('full');
604
+ // Watermark default is true in full mode.
605
+ expect(layout.watermark).toBe(true);
606
+ });
607
+
608
+ it('explicit watermark: true in sparkline mode actually paints the watermark', () => {
609
+ const layout = compileChart(
610
+ {
611
+ ...lineSpec,
612
+ chrome: undefined,
613
+ display: 'sparkline' as const,
614
+ watermark: true,
615
+ },
616
+ { width: 400, height: 200 },
617
+ );
618
+ expect(layout.watermark).toBe(true);
619
+ });
620
+
621
+ it('breakpoint encoding.x.axis opts back into x-axis at that breakpoint', () => {
622
+ const spec = {
623
+ ...lineSpec,
624
+ chrome: undefined,
625
+ display: 'sparkline' as const,
626
+ overrides: {
627
+ full: {
628
+ encoding: {
629
+ x: { field: 'date', type: 'temporal' as const, axis: { title: 'date' } },
630
+ },
631
+ },
632
+ },
633
+ };
634
+ const layoutFull = compileChart(spec, { width: 1200, height: 600 });
635
+ expect(layoutFull.axes.x).toBeDefined();
636
+ const layoutCompact = compileChart(spec, { width: 320, height: 200 });
637
+ expect(layoutCompact.axes.x).toBeUndefined();
638
+ });
639
+
640
+ // ---------------------------------------------------------------------------
641
+ // Animation duration: sparkline mode bumps the entrance to 1100ms when on
642
+ // ---------------------------------------------------------------------------
643
+
644
+ it('sparkline + animation: true bumps entrance duration to 1100ms', () => {
645
+ const layout = compileChart(
646
+ { ...lineSpec, display: 'sparkline' as const, animation: true },
647
+ { width: 400, height: 80 },
648
+ );
649
+ expect(layout.animation?.enabled).toBe(true);
650
+ expect(layout.animation?.duration).toBe(1100);
651
+ });
652
+
653
+ it('sparkline + animation: { enter: { duration: 500 } } respects user duration', () => {
654
+ const layout = compileChart(
655
+ {
656
+ ...lineSpec,
657
+ display: 'sparkline' as const,
658
+ animation: { enter: { duration: 500 } },
659
+ },
660
+ { width: 400, height: 80 },
661
+ );
662
+ expect(layout.animation?.duration).toBe(500);
663
+ });
664
+
665
+ it('full mode + animation: true uses default 500ms (sparkline bump does not leak)', () => {
666
+ const layout = compileChart({ ...lineSpec, animation: true }, { width: 600, height: 400 });
667
+ expect(layout.animation?.duration).toBe(500);
668
+ });
669
+
670
+ // ---------------------------------------------------------------------------
671
+ // Breakpoint encoding deep-merge: nested axis state survives an override that
672
+ // only touches one axis property.
673
+ // ---------------------------------------------------------------------------
674
+
675
+ it('breakpoint encoding.x.axis deep-merges with base axis config', () => {
676
+ const spec = {
677
+ ...lineSpec,
678
+ encoding: {
679
+ ...lineSpec.encoding,
680
+ x: {
681
+ field: 'date',
682
+ type: 'temporal' as const,
683
+ axis: { title: 'date', tickCount: 8, format: '%b' },
684
+ },
685
+ },
686
+ overrides: {
687
+ compact: {
688
+ encoding: {
689
+ x: { axis: { title: 'd' } },
690
+ },
691
+ },
692
+ },
693
+ };
694
+ const layout = compileChart(spec, { width: 320, height: 200 });
695
+ // The compact override only touched axis.title; tickCount and format
696
+ // should survive the deep merge.
697
+ expect(layout.axes.x).toBeDefined();
698
+ expect(layout.axes.x?.label).toBe('d');
699
+ // Tick count flows from base spec — if shallow-merged, tickCount would be lost.
700
+ expect(layout.axes.x?.ticks.length).toBeGreaterThan(0);
701
+ });
401
702
  });
402
703
 
403
704
  describe('compileTable', () => {
@@ -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
  }