@opendata-ai/openchart-engine 6.25.4 → 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.25.4",
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.25.4",
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', () => {
@@ -0,0 +1,147 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { compileChart } from '../compile';
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Test data
6
+ // ---------------------------------------------------------------------------
7
+
8
+ const albumData = [
9
+ { album: 'Abbey Road', artist: 'The Beatles', sales: 31 },
10
+ { album: 'Thriller', artist: 'Michael Jackson', sales: 66 },
11
+ { album: 'Back in Black', artist: 'AC/DC', sales: 50 },
12
+ { album: 'The Dark Side of the Moon', artist: 'Pink Floyd', sales: 45 },
13
+ { album: 'Rumours', artist: 'Fleetwood Mac', sales: 40 },
14
+ ];
15
+
16
+ function makeBarSpec(axisConfig?: Record<string, unknown>) {
17
+ return {
18
+ mark: 'bar' as const,
19
+ data: albumData,
20
+ encoding: {
21
+ x: { field: 'sales', type: 'quantitative' as const },
22
+ y: {
23
+ field: 'album',
24
+ type: 'nominal' as const,
25
+ axis: axisConfig,
26
+ },
27
+ },
28
+ };
29
+ }
30
+
31
+ const compileOpts = { width: 600, height: 400 };
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Tests
35
+ // ---------------------------------------------------------------------------
36
+
37
+ describe('compound axis labels (labelField)', () => {
38
+ it('populates subtitle on ticks when labelField is set', () => {
39
+ const spec = makeBarSpec({ labelField: 'artist' });
40
+ const layout = compileChart(spec, compileOpts);
41
+
42
+ const yTicks = layout.axes.y?.ticks ?? [];
43
+ expect(yTicks.length).toBeGreaterThan(0);
44
+
45
+ // Every tick should have a subtitle matching the artist for that album
46
+ for (const tick of yTicks) {
47
+ const row = albumData.find((r) => r.album === tick.label);
48
+ expect(row).toBeDefined();
49
+ expect(tick.subtitle).toBe(row!.artist);
50
+ }
51
+ });
52
+
53
+ it('does not add subtitle when labelField is omitted', () => {
54
+ const spec = makeBarSpec();
55
+ const layout = compileChart(spec, compileOpts);
56
+
57
+ const yTicks = layout.axes.y?.ticks ?? [];
58
+ expect(yTicks.length).toBeGreaterThan(0);
59
+
60
+ for (const tick of yTicks) {
61
+ expect(tick.subtitle).toBeUndefined();
62
+ }
63
+ });
64
+
65
+ it('handles missing labelField value gracefully', () => {
66
+ const dataWithMissing = [
67
+ { album: 'Abbey Road', artist: 'The Beatles', sales: 31 },
68
+ { album: 'Unknown Album', sales: 20 }, // no artist field
69
+ ];
70
+ const spec = {
71
+ mark: 'bar' as const,
72
+ data: dataWithMissing,
73
+ encoding: {
74
+ x: { field: 'sales', type: 'quantitative' as const },
75
+ y: {
76
+ field: 'album',
77
+ type: 'nominal' as const,
78
+ axis: { labelField: 'artist' },
79
+ },
80
+ },
81
+ };
82
+
83
+ const layout = compileChart(spec, compileOpts);
84
+ const yTicks = layout.axes.y?.ticks ?? [];
85
+
86
+ // Abbey Road should have a subtitle
87
+ const abbeyRoad = yTicks.find((t) => t.label === 'Abbey Road');
88
+ expect(abbeyRoad?.subtitle).toBe('The Beatles');
89
+
90
+ // Unknown Album has no artist field, so subtitle should be undefined
91
+ const unknown = yTicks.find((t) => t.label === 'Unknown Album');
92
+ expect(unknown?.subtitle).toBeUndefined();
93
+ });
94
+
95
+ it('maps subtitle correctly across multiple ticks', () => {
96
+ const spec = makeBarSpec({ labelField: 'artist' });
97
+ const layout = compileChart(spec, compileOpts);
98
+
99
+ const yTicks = layout.axes.y?.ticks ?? [];
100
+ expect(yTicks.length).toBe(5);
101
+
102
+ // Verify specific mappings
103
+ const thrillerTick = yTicks.find((t) => t.label === 'Thriller');
104
+ expect(thrillerTick?.subtitle).toBe('Michael Jackson');
105
+
106
+ const rumoursTick = yTicks.find((t) => t.label === 'Rumours');
107
+ expect(rumoursTick?.subtitle).toBe('Fleetwood Mac');
108
+ });
109
+
110
+ it('preserves subtitle mapping with sort: descending', () => {
111
+ const spec = {
112
+ mark: 'bar' as const,
113
+ data: albumData,
114
+ encoding: {
115
+ x: { field: 'sales', type: 'quantitative' as const },
116
+ y: {
117
+ field: 'album',
118
+ type: 'nominal' as const,
119
+ sort: 'descending' as const,
120
+ axis: { labelField: 'artist' },
121
+ },
122
+ },
123
+ };
124
+
125
+ const layout = compileChart(spec, compileOpts);
126
+ const yTicks = layout.axes.y?.ticks ?? [];
127
+
128
+ // Regardless of sort order, each tick should still map to the right artist
129
+ for (const tick of yTicks) {
130
+ const row = albumData.find((r) => r.album === tick.label);
131
+ expect(row).toBeDefined();
132
+ expect(tick.subtitle).toBe(row!.artist);
133
+ }
134
+ });
135
+
136
+ it('reserves wider dimension with labelField than without', () => {
137
+ const specWith = makeBarSpec({ labelField: 'artist' });
138
+ const specWithout = makeBarSpec();
139
+
140
+ const layoutWith = compileChart(specWith, compileOpts);
141
+ const layoutWithout = compileChart(specWithout, compileOpts);
142
+
143
+ // Chart area x (left edge) should be larger with labelField because more
144
+ // left margin is reserved for the wider compound labels
145
+ expect(layoutWith.area.x).toBeGreaterThanOrEqual(layoutWithout.area.x);
146
+ });
147
+ });
@@ -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,