@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/dist/index.d.ts +82 -4
- package/dist/index.js +1027 -76
- 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/__tests__/compound-labels.test.ts +147 -0
- package/src/charts/line/area.ts +1 -1
- package/src/charts/line/compute.ts +7 -1
- package/src/compile.ts +222 -17
- package/src/compiler/normalize.ts +83 -1
- package/src/compiler/types.ts +41 -1
- package/src/compiler/validate.ts +124 -5
- package/src/index.ts +16 -1
- package/src/layout/axes/ticks.ts +34 -2
- package/src/layout/axes.ts +36 -3
- package/src/layout/dimensions.ts +98 -5
- package/src/legend/compute.ts +6 -1
- package/src/sankey/compile-sankey.ts +1 -1
- package/src/tilemap/__tests__/compile-tilemap.test.ts +322 -0
- package/src/tilemap/compile-tilemap.ts +383 -0
- package/src/tilemap/layout.ts +172 -0
- package/src/tilemap/types.ts +32 -0
- package/src/transforms/__tests__/filter-relative.test.ts +202 -0
- package/src/transforms/__tests__/window.test.ts +286 -0
- package/src/transforms/filter.ts +108 -3
- package/src/transforms/index.ts +5 -1
- package/src/transforms/predicates.ts +39 -9
- package/src/transforms/window.ts +185 -0
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', () => {
|
|
@@ -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
|
+
});
|
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,
|