@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/dist/index.d.ts +37 -1
- package/dist/index.js +166 -11
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__test-fixtures__/specs.ts +33 -0
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +6 -0
- package/src/__tests__/compile-chart.test.ts +301 -0
- package/src/charts/line/area.ts +1 -1
- package/src/charts/line/compute.ts +7 -1
- package/src/compile.ts +175 -4
- package/src/compiler/normalize.ts +26 -0
- package/src/compiler/types.ts +38 -0
- package/src/layout/axes.ts +9 -2
- package/src/layout/dimensions.ts +77 -2
- package/src/legend/compute.ts +6 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "6.
|
|
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.
|
|
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', () => {
|
package/src/charts/line/area.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
}
|