@opendata-ai/openchart-engine 6.28.5 → 7.0.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/README.md +1 -0
- package/dist/index.d.ts +8 -11
- package/dist/index.js +12297 -11338
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__test-fixtures__/specs.ts +3 -0
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +452 -377
- package/src/__tests__/axes.test.ts +75 -0
- package/src/__tests__/compile-chart.test.ts +304 -0
- package/src/__tests__/dimensions.test.ts +224 -0
- package/src/__tests__/legend.test.ts +44 -3
- package/src/annotations/__tests__/compute.test.ts +111 -0
- package/src/annotations/__tests__/resolve-text.test.ts +288 -0
- package/src/annotations/constants.ts +20 -0
- package/src/annotations/resolve-text.ts +161 -7
- package/src/charts/bar/compute.ts +24 -0
- package/src/charts/bar/labels.ts +1 -0
- package/src/charts/column/compute.ts +33 -1
- package/src/charts/column/labels.ts +1 -0
- package/src/charts/dot/labels.ts +1 -0
- package/src/charts/line/__tests__/compute.test.ts +153 -3
- package/src/charts/line/area.ts +111 -23
- package/src/charts/line/compute.ts +40 -10
- package/src/charts/line/index.ts +34 -7
- package/src/charts/line/labels.ts +29 -0
- package/src/charts/pie/labels.ts +1 -0
- package/src/compile/layer.ts +497 -0
- package/src/compile.ts +211 -586
- package/src/compiler/__tests__/sparkline-defaults.test.ts +153 -0
- package/src/compiler/normalize.ts +6 -1
- package/src/compiler/sparkline-defaults.ts +138 -0
- package/src/compiler/types.ts +8 -0
- package/src/endpoint-labels/__tests__/compute.test.ts +438 -0
- package/src/endpoint-labels/compute.ts +417 -0
- package/src/endpoint-labels/constants.ts +54 -0
- package/src/endpoint-labels/format.ts +30 -0
- package/src/endpoint-labels/predict.ts +108 -0
- package/src/graphs/compile-graph.ts +1 -0
- package/src/layout/axes.ts +27 -2
- package/src/layout/dimensions.ts +270 -33
- package/src/layout/metrics.ts +118 -0
- package/src/layout/scales.ts +41 -4
- package/src/legend/__tests__/suppression.test.ts +294 -0
- package/src/legend/compute.ts +50 -40
- package/src/legend/suppression.ts +204 -0
- package/src/sankey/compile-sankey.ts +2 -0
- package/src/tables/__tests__/heatmap.test.ts +4 -27
- package/src/tables/heatmap.ts +6 -2
|
@@ -982,3 +982,78 @@ describe('buildContinuousTicks — log scale power filtering', () => {
|
|
|
982
982
|
expect(nonPowerOf10.length).toBeGreaterThan(0);
|
|
983
983
|
});
|
|
984
984
|
});
|
|
985
|
+
|
|
986
|
+
describe('axis tickPosition', () => {
|
|
987
|
+
it('defaults y-axis to inline for line charts', () => {
|
|
988
|
+
const scales = computeScales(lineSpec, chartArea, lineSpec.data);
|
|
989
|
+
const axes = computeAxes(scales, chartArea, fullStrategy, theme, undefined, {
|
|
990
|
+
data: lineSpec.data,
|
|
991
|
+
encoding: lineSpec.encoding,
|
|
992
|
+
markType: 'line',
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
expect(axes.y!.tickPosition).toBe('inline');
|
|
996
|
+
expect(axes.y!.domainLine).toBe(false);
|
|
997
|
+
expect(axes.y!.tickMarks).toBe(false);
|
|
998
|
+
expect(axes.x!.tickPosition).toBe('gutter');
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
it('defaults y-axis to inline for area charts', () => {
|
|
1002
|
+
const areaSpec: NormalizedChartSpec = {
|
|
1003
|
+
...lineSpec,
|
|
1004
|
+
markType: 'area',
|
|
1005
|
+
markDef: { type: 'area' },
|
|
1006
|
+
};
|
|
1007
|
+
const scales = computeScales(areaSpec, chartArea, areaSpec.data);
|
|
1008
|
+
const axes = computeAxes(scales, chartArea, fullStrategy, theme, undefined, {
|
|
1009
|
+
data: areaSpec.data,
|
|
1010
|
+
encoding: areaSpec.encoding,
|
|
1011
|
+
markType: 'area',
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
expect(axes.y!.tickPosition).toBe('inline');
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
it('defaults y-axis to gutter when markType is unknown', () => {
|
|
1018
|
+
const scales = computeScales(lineSpec, chartArea, lineSpec.data);
|
|
1019
|
+
const axes = computeAxes(scales, chartArea, fullStrategy, theme);
|
|
1020
|
+
|
|
1021
|
+
expect(axes.y!.tickPosition).toBe('gutter');
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
it('respects explicit user override on y-axis', () => {
|
|
1025
|
+
const overrideSpec: NormalizedChartSpec = {
|
|
1026
|
+
...lineSpec,
|
|
1027
|
+
encoding: {
|
|
1028
|
+
...lineSpec.encoding,
|
|
1029
|
+
y: { ...lineSpec.encoding.y!, axis: { tickPosition: 'gutter' } },
|
|
1030
|
+
},
|
|
1031
|
+
};
|
|
1032
|
+
const scales = computeScales(overrideSpec, chartArea, overrideSpec.data);
|
|
1033
|
+
const axes = computeAxes(scales, chartArea, fullStrategy, theme, undefined, {
|
|
1034
|
+
data: overrideSpec.data,
|
|
1035
|
+
encoding: overrideSpec.encoding,
|
|
1036
|
+
markType: 'line',
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
expect(axes.y!.tickPosition).toBe('gutter');
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
it('right-side y-axis stays gutter even on line charts', () => {
|
|
1043
|
+
const dualSpec: NormalizedChartSpec = {
|
|
1044
|
+
...lineSpec,
|
|
1045
|
+
encoding: {
|
|
1046
|
+
...lineSpec.encoding,
|
|
1047
|
+
y: { ...lineSpec.encoding.y!, axis: { orient: 'right' } },
|
|
1048
|
+
},
|
|
1049
|
+
};
|
|
1050
|
+
const scales = computeScales(dualSpec, chartArea, dualSpec.data);
|
|
1051
|
+
const axes = computeAxes(scales, chartArea, fullStrategy, theme, undefined, {
|
|
1052
|
+
data: dualSpec.data,
|
|
1053
|
+
encoding: dualSpec.encoding,
|
|
1054
|
+
markType: 'line',
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
expect(axes.y!.tickPosition).toBe('gutter');
|
|
1058
|
+
});
|
|
1059
|
+
});
|
|
@@ -321,6 +321,75 @@ describe('compileChart', () => {
|
|
|
321
321
|
expect(layout.marks.length).toBeGreaterThan(0);
|
|
322
322
|
});
|
|
323
323
|
|
|
324
|
+
it('hiddenSeries keeps remaining series on their original palette colors', () => {
|
|
325
|
+
// Regression: filtering renderData by hiddenSeries used to shrink the
|
|
326
|
+
// ordinal color scale's domain, which shifted every visible series down
|
|
327
|
+
// one palette index.
|
|
328
|
+
type LineLike = { type: string; seriesKey?: string; stroke?: string };
|
|
329
|
+
const findStroke = (marks: { type: string }[], series: string) =>
|
|
330
|
+
(marks.find((m) => m.type === 'line' && (m as LineLike).seriesKey === series) as LineLike)
|
|
331
|
+
?.stroke;
|
|
332
|
+
|
|
333
|
+
const baseline = compileChart({ ...lineSpec, hiddenSeries: [] }, { width: 600, height: 400 });
|
|
334
|
+
const baselineUS = findStroke(baseline.marks, 'US');
|
|
335
|
+
const baselineUK = findStroke(baseline.marks, 'UK');
|
|
336
|
+
expect(baselineUS).toBeTruthy();
|
|
337
|
+
expect(baselineUK).toBeTruthy();
|
|
338
|
+
|
|
339
|
+
// Hide UK → US should keep its color.
|
|
340
|
+
const ukHidden = compileChart(
|
|
341
|
+
{ ...lineSpec, hiddenSeries: ['UK'] },
|
|
342
|
+
{ width: 600, height: 400 },
|
|
343
|
+
);
|
|
344
|
+
expect(findStroke(ukHidden.marks, 'US')).toBe(baselineUS);
|
|
345
|
+
|
|
346
|
+
// Hide US → UK should keep its color (reverse direction).
|
|
347
|
+
const usHidden = compileChart(
|
|
348
|
+
{ ...lineSpec, hiddenSeries: ['US'] },
|
|
349
|
+
{ width: 600, height: 400 },
|
|
350
|
+
);
|
|
351
|
+
expect(findStroke(usHidden.marks, 'UK')).toBe(baselineUK);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('hiddenSeries keeps middle-of-N series colors stable in 3-series charts', () => {
|
|
355
|
+
// Pre-fix bug shifted EVERY series after the hidden one — a 2-series
|
|
356
|
+
// case only proves shift-by-one. A 3-series case where the middle is
|
|
357
|
+
// hidden proves the late series doesn't drift either.
|
|
358
|
+
type LineLike = { type: string; seriesKey?: string; stroke?: string };
|
|
359
|
+
const findStroke = (marks: { type: string }[], series: string) =>
|
|
360
|
+
(marks.find((m) => m.type === 'line' && (m as LineLike).seriesKey === series) as LineLike)
|
|
361
|
+
?.stroke;
|
|
362
|
+
|
|
363
|
+
const threeSeriesSpec = {
|
|
364
|
+
mark: 'line' as const,
|
|
365
|
+
data: [
|
|
366
|
+
{ date: '2020-01-01', value: 10, country: 'US' },
|
|
367
|
+
{ date: '2021-01-01', value: 40, country: 'US' },
|
|
368
|
+
{ date: '2020-01-01', value: 15, country: 'UK' },
|
|
369
|
+
{ date: '2021-01-01', value: 35, country: 'UK' },
|
|
370
|
+
{ date: '2020-01-01', value: 12, country: 'JP' },
|
|
371
|
+
{ date: '2021-01-01', value: 38, country: 'JP' },
|
|
372
|
+
],
|
|
373
|
+
encoding: {
|
|
374
|
+
x: { field: 'date', type: 'temporal' as const },
|
|
375
|
+
y: { field: 'value', type: 'quantitative' as const },
|
|
376
|
+
color: { field: 'country', type: 'nominal' as const },
|
|
377
|
+
},
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
const baseline = compileChart(threeSeriesSpec, { width: 600, height: 400 });
|
|
381
|
+
const baselineUS = findStroke(baseline.marks, 'US');
|
|
382
|
+
const baselineJP = findStroke(baseline.marks, 'JP');
|
|
383
|
+
|
|
384
|
+
// Hide the middle series.
|
|
385
|
+
const ukHidden = compileChart(
|
|
386
|
+
{ ...threeSeriesSpec, hiddenSeries: ['UK'] },
|
|
387
|
+
{ width: 600, height: 400 },
|
|
388
|
+
);
|
|
389
|
+
expect(findStroke(ukHidden.marks, 'US')).toBe(baselineUS); // first stays
|
|
390
|
+
expect(findStroke(ukHidden.marks, 'JP')).toBe(baselineJP); // last doesn't shift up
|
|
391
|
+
});
|
|
392
|
+
|
|
324
393
|
// ---------------------------------------------------------------------------
|
|
325
394
|
// scale.clip
|
|
326
395
|
// ---------------------------------------------------------------------------
|
|
@@ -463,6 +532,16 @@ describe('compileChart', () => {
|
|
|
463
532
|
expect(layout.crosshair).toBe(true);
|
|
464
533
|
});
|
|
465
534
|
|
|
535
|
+
it('crosshair defaults on for line marks in full mode', () => {
|
|
536
|
+
const layout = compileChart(lineSpec, { width: 400, height: 300 });
|
|
537
|
+
expect(layout.crosshair).toBe(true);
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it('crosshair respects explicit crosshair: false on a line chart', () => {
|
|
541
|
+
const layout = compileChart({ ...lineSpec, crosshair: false }, { width: 400, height: 300 });
|
|
542
|
+
expect(layout.crosshair).toBe(false);
|
|
543
|
+
});
|
|
544
|
+
|
|
466
545
|
// ---------------------------------------------------------------------------
|
|
467
546
|
// Sparkline layout profile (dimensions, axes, legend)
|
|
468
547
|
// ---------------------------------------------------------------------------
|
|
@@ -565,6 +644,231 @@ describe('compileChart', () => {
|
|
|
565
644
|
expect(layout.chrome.topHeight).toBe(0);
|
|
566
645
|
});
|
|
567
646
|
|
|
647
|
+
// -------------------------------------------------------------------------
|
|
648
|
+
// Sparkline visual defaults (trend color, endpoint dot, gradient, bar pill)
|
|
649
|
+
// -------------------------------------------------------------------------
|
|
650
|
+
|
|
651
|
+
const upTrendData = [
|
|
652
|
+
{ date: '2026-01-01', value: 10 },
|
|
653
|
+
{ date: '2026-01-02', value: 12 },
|
|
654
|
+
{ date: '2026-01-03', value: 14 },
|
|
655
|
+
{ date: '2026-01-04', value: 18 },
|
|
656
|
+
{ date: '2026-01-05', value: 22 },
|
|
657
|
+
];
|
|
658
|
+
|
|
659
|
+
const downTrendData = [
|
|
660
|
+
{ date: '2026-01-01', value: 22 },
|
|
661
|
+
{ date: '2026-01-02', value: 18 },
|
|
662
|
+
{ date: '2026-01-03', value: 14 },
|
|
663
|
+
{ date: '2026-01-04', value: 12 },
|
|
664
|
+
{ date: '2026-01-05', value: 10 },
|
|
665
|
+
];
|
|
666
|
+
|
|
667
|
+
const sparkLineSpec = {
|
|
668
|
+
mark: 'line' as const,
|
|
669
|
+
data: upTrendData,
|
|
670
|
+
encoding: {
|
|
671
|
+
x: { field: 'date', type: 'temporal' as const },
|
|
672
|
+
y: { field: 'value', type: 'quantitative' as const },
|
|
673
|
+
},
|
|
674
|
+
display: 'sparkline' as const,
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
it('sparkline line emits a single decorative endpoint dot at the last point', () => {
|
|
678
|
+
const layout = compileChart(sparkLineSpec, { width: 200, height: 40 });
|
|
679
|
+
const points = layout.marks.filter((m) => m.type === 'point');
|
|
680
|
+
expect(points.length).toBe(1);
|
|
681
|
+
expect(points[0].aria?.decorative).toBe(true);
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
it('sparkline line uses the positive theme token for an up-trending series', () => {
|
|
685
|
+
const layout = compileChart(sparkLineSpec, { width: 200, height: 40 });
|
|
686
|
+
const lineMark = layout.marks.find((m) => m.type === 'line');
|
|
687
|
+
expect(lineMark?.stroke).toBe('#16a34a');
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
it('sparkline line uses the negative theme token for a down-trending series', () => {
|
|
691
|
+
const layout = compileChart(
|
|
692
|
+
{ ...sparkLineSpec, data: downTrendData },
|
|
693
|
+
{ width: 200, height: 40 },
|
|
694
|
+
);
|
|
695
|
+
const lineMark = layout.marks.find((m) => m.type === 'line');
|
|
696
|
+
expect(lineMark?.stroke).toBe('#dc2626');
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
it('explicit markDef.stroke wins over the trend default in sparkline mode', () => {
|
|
700
|
+
const layout = compileChart(
|
|
701
|
+
{ ...sparkLineSpec, mark: { type: 'line' as const, stroke: '#ff00ff' } },
|
|
702
|
+
{ width: 200, height: 40 },
|
|
703
|
+
);
|
|
704
|
+
const lineMark = layout.marks.find((m) => m.type === 'line');
|
|
705
|
+
expect(lineMark?.stroke).toBe('#ff00ff');
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
it('explicit markDef.point: false suppresses the auto-injected endpoint dot', () => {
|
|
709
|
+
const layout = compileChart(
|
|
710
|
+
{ ...sparkLineSpec, mark: { type: 'line' as const, point: false } },
|
|
711
|
+
{ width: 200, height: 40 },
|
|
712
|
+
);
|
|
713
|
+
const points = layout.marks.filter((m) => m.type === 'point');
|
|
714
|
+
expect(points.length).toBe(0);
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
it('sparkline area injects a trend-colored gradient fill by default', () => {
|
|
718
|
+
const layout = compileChart(
|
|
719
|
+
{ ...sparkLineSpec, mark: 'area' as const },
|
|
720
|
+
{ width: 200, height: 40 },
|
|
721
|
+
);
|
|
722
|
+
const areaMark = layout.marks.find((m) => m.type === 'area');
|
|
723
|
+
// fill should be a gradient object (not a flat string)
|
|
724
|
+
expect(typeof areaMark?.fill).toBe('object');
|
|
725
|
+
expect((areaMark?.fill as { gradient?: string }).gradient).toBe('linear');
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
it('sparkline single-series vertical bars use a [min, max] domain', () => {
|
|
729
|
+
// With values 100, 105, 110, 120 and zero: false, the y-domain should
|
|
730
|
+
// start near 100 — not 0 — so the shortest bar is still visible.
|
|
731
|
+
const layout = compileChart(
|
|
732
|
+
{
|
|
733
|
+
mark: 'bar' as const,
|
|
734
|
+
data: [
|
|
735
|
+
{ cat: 'a', value: 100 },
|
|
736
|
+
{ cat: 'b', value: 105 },
|
|
737
|
+
{ cat: 'c', value: 110 },
|
|
738
|
+
{ cat: 'd', value: 120 },
|
|
739
|
+
],
|
|
740
|
+
encoding: {
|
|
741
|
+
x: { field: 'cat', type: 'nominal' as const },
|
|
742
|
+
y: { field: 'value', type: 'quantitative' as const },
|
|
743
|
+
},
|
|
744
|
+
display: 'sparkline' as const,
|
|
745
|
+
},
|
|
746
|
+
{ width: 200, height: 40 },
|
|
747
|
+
);
|
|
748
|
+
// The shortest bar (value 100) should NOT have a height that fills the
|
|
749
|
+
// chart down from y=0; with zero:false it sits near the bottom.
|
|
750
|
+
const rects = layout.marks.filter((m) => m.type === 'rect');
|
|
751
|
+
expect(rects.length).toBe(4);
|
|
752
|
+
// Heights should differ meaningfully across the four bars.
|
|
753
|
+
const heights = rects.map((r) => (r as { height: number }).height);
|
|
754
|
+
const maxH = Math.max(...heights);
|
|
755
|
+
const minH = Math.min(...heights);
|
|
756
|
+
expect(maxH - minH).toBeGreaterThan(maxH * 0.5);
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
it('sparkline stacked vertical bars retain the [0, max] baseline', () => {
|
|
760
|
+
// Stacked bars MUST baseline at zero — non-zero baselines break stack
|
|
761
|
+
// arithmetic. The total bar should equal the category sum.
|
|
762
|
+
const layout = compileChart(
|
|
763
|
+
{
|
|
764
|
+
mark: 'bar' as const,
|
|
765
|
+
data: [
|
|
766
|
+
{ cat: 'a', series: 's1', value: 50 },
|
|
767
|
+
{ cat: 'a', series: 's2', value: 50 },
|
|
768
|
+
{ cat: 'b', series: 's1', value: 30 },
|
|
769
|
+
{ cat: 'b', series: 's2', value: 70 },
|
|
770
|
+
],
|
|
771
|
+
encoding: {
|
|
772
|
+
x: { field: 'cat', type: 'nominal' as const },
|
|
773
|
+
y: { field: 'value', type: 'quantitative' as const, stack: 'zero' as const },
|
|
774
|
+
color: { field: 'series', type: 'nominal' as const },
|
|
775
|
+
},
|
|
776
|
+
display: 'sparkline' as const,
|
|
777
|
+
},
|
|
778
|
+
{ width: 200, height: 80 },
|
|
779
|
+
);
|
|
780
|
+
const rects = layout.marks.filter((m) => m.type === 'rect');
|
|
781
|
+
expect(rects.length).toBe(4);
|
|
782
|
+
// For stacked bars, both categories sum to the same total (100), so the
|
|
783
|
+
// tallest stacked column should fill close to the full chart area height.
|
|
784
|
+
// Verify by checking that at least one bar starts at the chart-area top
|
|
785
|
+
// (its y matches chartArea.y within a rounding tolerance).
|
|
786
|
+
const minY = Math.min(...rects.map((r) => (r as { y: number }).y));
|
|
787
|
+
expect(minY).toBeCloseTo(layout.area.y, -1);
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
it('sparkline bar gets pill cornerRadius by default', () => {
|
|
791
|
+
const layout = compileChart(
|
|
792
|
+
{
|
|
793
|
+
mark: 'bar' as const,
|
|
794
|
+
data: [
|
|
795
|
+
{ cat: 'a', value: 5 },
|
|
796
|
+
{ cat: 'b', value: 8 },
|
|
797
|
+
{ cat: 'c', value: 6 },
|
|
798
|
+
],
|
|
799
|
+
encoding: {
|
|
800
|
+
x: { field: 'cat', type: 'nominal' as const },
|
|
801
|
+
y: { field: 'value', type: 'quantitative' as const },
|
|
802
|
+
},
|
|
803
|
+
display: 'sparkline' as const,
|
|
804
|
+
},
|
|
805
|
+
{ width: 200, height: 40 },
|
|
806
|
+
);
|
|
807
|
+
const rects = layout.marks.filter((m) => m.type === 'rect');
|
|
808
|
+
expect(rects.length).toBeGreaterThan(0);
|
|
809
|
+
// 'pill' translates to rx = barWidth / 2 in column compute. Each rect's
|
|
810
|
+
// cornerRadius should equal half the bar's width (or thickness).
|
|
811
|
+
const r = rects[0] as { width: number; cornerRadius: number };
|
|
812
|
+
expect(r.cornerRadius).toBeCloseTo(r.width / 2, 1);
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
it('sparkline stacked bars: only top segment per stack gets pill rounding (top corners)', () => {
|
|
816
|
+
// Stacked segments below the top stay square so the seams between
|
|
817
|
+
// segments are flush. Each topmost segment receives the pill radius
|
|
818
|
+
// with `cornerRadiusSides` constrained to the top corners — the
|
|
819
|
+
// renderer emits a path with rounded top + square bottom.
|
|
820
|
+
const layout = compileChart(
|
|
821
|
+
{
|
|
822
|
+
mark: 'bar' as const,
|
|
823
|
+
data: [
|
|
824
|
+
{ cat: 'a', series: 's1', value: 50 },
|
|
825
|
+
{ cat: 'a', series: 's2', value: 50 },
|
|
826
|
+
{ cat: 'b', series: 's1', value: 30 },
|
|
827
|
+
{ cat: 'b', series: 's2', value: 70 },
|
|
828
|
+
],
|
|
829
|
+
encoding: {
|
|
830
|
+
x: { field: 'cat', type: 'nominal' as const },
|
|
831
|
+
y: { field: 'value', type: 'quantitative' as const, stack: 'zero' as const },
|
|
832
|
+
color: { field: 'series', type: 'nominal' as const },
|
|
833
|
+
},
|
|
834
|
+
display: 'sparkline' as const,
|
|
835
|
+
},
|
|
836
|
+
{ width: 200, height: 80 },
|
|
837
|
+
);
|
|
838
|
+
type Rect = {
|
|
839
|
+
type: 'rect';
|
|
840
|
+
cornerRadius?: number;
|
|
841
|
+
cornerRadiusSides?: { tl?: boolean; tr?: boolean; br?: boolean; bl?: boolean };
|
|
842
|
+
stackGroup?: string;
|
|
843
|
+
y: number;
|
|
844
|
+
};
|
|
845
|
+
const rects = layout.marks.filter((m): m is Rect => m.type === 'rect') as Rect[];
|
|
846
|
+
|
|
847
|
+
// Group by stackGroup, find topmost (smallest y) per group.
|
|
848
|
+
const tops = new Map<string, Rect>();
|
|
849
|
+
for (const r of rects) {
|
|
850
|
+
if (!r.stackGroup) continue;
|
|
851
|
+
const cur = tops.get(r.stackGroup);
|
|
852
|
+
if (!cur || r.y < cur.y) tops.set(r.stackGroup, r);
|
|
853
|
+
}
|
|
854
|
+
expect(tops.size).toBe(2);
|
|
855
|
+
|
|
856
|
+
for (const r of rects) {
|
|
857
|
+
const isTop = tops.get(r.stackGroup ?? '') === r;
|
|
858
|
+
if (isTop) {
|
|
859
|
+
expect(r.cornerRadius).toBeGreaterThan(0);
|
|
860
|
+
expect(r.cornerRadiusSides).toEqual({
|
|
861
|
+
tl: true,
|
|
862
|
+
tr: true,
|
|
863
|
+
br: false,
|
|
864
|
+
bl: false,
|
|
865
|
+
});
|
|
866
|
+
} else {
|
|
867
|
+
expect(r.cornerRadius ?? 0).toBe(0);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
});
|
|
871
|
+
|
|
568
872
|
// ---------------------------------------------------------------------------
|
|
569
873
|
// Explicit-at-any-level wins (precedence matrix)
|
|
570
874
|
// ---------------------------------------------------------------------------
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { LayoutStrategy, LegendLayout } from '@opendata-ai/openchart-core';
|
|
2
2
|
import { adaptTheme, resolveTheme } from '@opendata-ai/openchart-core';
|
|
3
3
|
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { compileChart } from '../compile';
|
|
4
5
|
import type { NormalizedChartSpec } from '../compiler/types';
|
|
5
6
|
import { computeDimensions } from '../layout/dimensions';
|
|
7
|
+
import { legendGap } from '../legend/wrap';
|
|
6
8
|
|
|
7
9
|
const baseSpec: NormalizedChartSpec = {
|
|
8
10
|
markType: 'line',
|
|
@@ -296,6 +298,85 @@ describe('computeDimensions', () => {
|
|
|
296
298
|
expect(narrowDims.chartArea.width).toBeGreaterThanOrEqual(350 * 0.4);
|
|
297
299
|
});
|
|
298
300
|
|
|
301
|
+
describe('metrics bar', () => {
|
|
302
|
+
const fourMetrics = [
|
|
303
|
+
{ label: 'CLOSE', value: '$186.10', delta: '+1.4%', deltaTone: 'up' as const },
|
|
304
|
+
{ label: 'ALL-TIME HIGH', value: '$202.00' },
|
|
305
|
+
{ label: '3-YR RETURN', value: '+1228%', secondary: '10.3x' },
|
|
306
|
+
{ label: 'AVG MONTHLY', value: '$104.95' },
|
|
307
|
+
];
|
|
308
|
+
|
|
309
|
+
it('reserves space and emits cells when metrics fit', () => {
|
|
310
|
+
const spec: NormalizedChartSpec = { ...baseSpec, metrics: fourMetrics };
|
|
311
|
+
const dims = computeDimensions(spec, { width: 800, height: 500 }, emptyLegend, lightTheme);
|
|
312
|
+
|
|
313
|
+
expect(dims.metrics).toBeDefined();
|
|
314
|
+
expect(dims.metrics?.cells).toHaveLength(4);
|
|
315
|
+
// First cell sits at the container left padding (aligned with title /
|
|
316
|
+
// eyebrow), not indented to the chart area's left gutter.
|
|
317
|
+
expect(dims.metrics?.cells[0].x).toBeLessThanOrEqual(dims.chartArea.x);
|
|
318
|
+
expect(dims.metrics?.cells[0].x).toBeGreaterThan(0);
|
|
319
|
+
// Cells span the metrics area evenly
|
|
320
|
+
const totalSpan =
|
|
321
|
+
(dims.metrics?.cells[3].x ?? 0) +
|
|
322
|
+
(dims.metrics?.cells[3].cellWidth ?? 0) -
|
|
323
|
+
(dims.metrics?.cells[0].x ?? 0);
|
|
324
|
+
const cellWidth = totalSpan / 4;
|
|
325
|
+
expect(dims.metrics?.cells[1].x).toBeCloseTo((dims.metrics?.cells[0].x ?? 0) + cellWidth, 1);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('shrinks chart-area top to accommodate metric bar', () => {
|
|
329
|
+
const noMetrics = computeDimensions(
|
|
330
|
+
baseSpec,
|
|
331
|
+
{ width: 800, height: 500 },
|
|
332
|
+
emptyLegend,
|
|
333
|
+
lightTheme,
|
|
334
|
+
);
|
|
335
|
+
const withMetrics = computeDimensions(
|
|
336
|
+
{ ...baseSpec, metrics: fourMetrics },
|
|
337
|
+
{ width: 800, height: 500 },
|
|
338
|
+
emptyLegend,
|
|
339
|
+
lightTheme,
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
expect(withMetrics.chartArea.height).toBeLessThan(noMetrics.chartArea.height);
|
|
343
|
+
expect(withMetrics.margins.top).toBeGreaterThan(noMetrics.margins.top);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('strips metric bar on narrow widths', () => {
|
|
347
|
+
const spec: NormalizedChartSpec = { ...baseSpec, metrics: fourMetrics };
|
|
348
|
+
const dims = computeDimensions(spec, { width: 400, height: 400 }, emptyLegend, lightTheme);
|
|
349
|
+
expect(dims.metrics).toBeUndefined();
|
|
350
|
+
|
|
351
|
+
// Top margin should match the no-metrics case (no leftover reservation)
|
|
352
|
+
const noMetrics = computeDimensions(
|
|
353
|
+
baseSpec,
|
|
354
|
+
{ width: 400, height: 400 },
|
|
355
|
+
emptyLegend,
|
|
356
|
+
lightTheme,
|
|
357
|
+
);
|
|
358
|
+
expect(dims.margins.top).toBe(noMetrics.margins.top);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('strips metric bar when value text would overflow', () => {
|
|
362
|
+
const oversizeMetrics = [
|
|
363
|
+
{ label: 'A', value: 'this is an extraordinarily long monetary value that cannot fit' },
|
|
364
|
+
{ label: 'B', value: 'another impossibly long string to force overflow detection' },
|
|
365
|
+
{ label: 'C', value: 'still more text that pushes well past the cell width' },
|
|
366
|
+
{ label: 'D', value: 'and one more lengthy figure for good measure here too' },
|
|
367
|
+
];
|
|
368
|
+
const spec: NormalizedChartSpec = { ...baseSpec, metrics: oversizeMetrics };
|
|
369
|
+
const dims = computeDimensions(spec, { width: 600, height: 400 }, emptyLegend, lightTheme);
|
|
370
|
+
expect(dims.metrics).toBeUndefined();
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('returns undefined when metrics array is empty', () => {
|
|
374
|
+
const spec: NormalizedChartSpec = { ...baseSpec, metrics: [] };
|
|
375
|
+
const dims = computeDimensions(spec, { width: 800, height: 500 }, emptyLegend, lightTheme);
|
|
376
|
+
expect(dims.metrics).toBeUndefined();
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
299
380
|
it('tightens legend gap on narrow viewports', () => {
|
|
300
381
|
const wideDims = computeDimensions(
|
|
301
382
|
baseSpec,
|
|
@@ -313,4 +394,147 @@ describe('computeDimensions', () => {
|
|
|
313
394
|
// Narrow viewport should have more chart height available (smaller legend gap)
|
|
314
395
|
expect(narrowDims.chartArea.height).toBeGreaterThanOrEqual(wideDims.chartArea.height - 10);
|
|
315
396
|
});
|
|
397
|
+
|
|
398
|
+
it('exposes xAxisHeight on the layout dimensions', () => {
|
|
399
|
+
const dims = computeDimensions(baseSpec, { width: 600, height: 400 }, emptyLegend, lightTheme);
|
|
400
|
+
// Default x-axis (no rotation, no title): 26px reservation.
|
|
401
|
+
expect(dims.xAxisHeight).toBeGreaterThan(0);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it('reserves extra bottom space for a bottom legend so it sits below the x-axis', () => {
|
|
405
|
+
// Defect-3 regression: bottom-positioned legends used to render in the
|
|
406
|
+
// same band as the x-axis tick row. The reservation now flows through
|
|
407
|
+
// chrome.bottomHeight (via bottomLegendReservation) so chrome stacks
|
|
408
|
+
// below the legend band rather than colliding with it. The base bottom
|
|
409
|
+
// margin already includes xAxisHeight, so the additional reservation
|
|
410
|
+
// collapses to legendHeight + gap.
|
|
411
|
+
const bottomLegend: LegendLayout = {
|
|
412
|
+
...emptyLegend,
|
|
413
|
+
position: 'bottom',
|
|
414
|
+
entries: [{ label: 'US', color: '#1b7fa3', shape: 'line' }],
|
|
415
|
+
bounds: { x: 0, y: 0, width: 400, height: 28 },
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
const dimsNoLegend = computeDimensions(
|
|
419
|
+
baseSpec,
|
|
420
|
+
{ width: 600, height: 400 },
|
|
421
|
+
emptyLegend,
|
|
422
|
+
lightTheme,
|
|
423
|
+
);
|
|
424
|
+
const dimsBottom = computeDimensions(
|
|
425
|
+
baseSpec,
|
|
426
|
+
{ width: 600, height: 400 },
|
|
427
|
+
bottomLegend,
|
|
428
|
+
lightTheme,
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
// Reservation is legendHeight + gap, threaded through chrome.bottomHeight.
|
|
432
|
+
const gap = legendGap(600);
|
|
433
|
+
const expectedExtra = bottomLegend.bounds.height + gap;
|
|
434
|
+
expect(dimsBottom.margins.bottom - dimsNoLegend.margins.bottom).toBeCloseTo(expectedExtra, 5);
|
|
435
|
+
// The reservation lives on chrome.bottomHeight, not as a separate margin
|
|
436
|
+
// delta, so renderers reading chrome.source.y get the legend offset baked
|
|
437
|
+
// in for free.
|
|
438
|
+
expect(dimsBottom.chrome.bottomHeight - dimsNoLegend.chrome.bottomHeight).toBeCloseTo(
|
|
439
|
+
expectedExtra,
|
|
440
|
+
5,
|
|
441
|
+
);
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
describe('bottom legend placement (defect-3 regression)', () => {
|
|
446
|
+
it('places the bottom legend below the x-axis tick row, not over it', () => {
|
|
447
|
+
// Multi-series area with explicit bottom legend should render the legend
|
|
448
|
+
// beneath the x-axis ticks. Asserts:
|
|
449
|
+
// legend.bounds.y >= chartArea.y + chartArea.height + xAxisHeight + gap
|
|
450
|
+
const spec = {
|
|
451
|
+
mark: 'area' as const,
|
|
452
|
+
data: [
|
|
453
|
+
{ year: '2020', value: 10, series: 'A' },
|
|
454
|
+
{ year: '2021', value: 20, series: 'A' },
|
|
455
|
+
{ year: '2022', value: 15, series: 'A' },
|
|
456
|
+
{ year: '2020', value: 8, series: 'B' },
|
|
457
|
+
{ year: '2021', value: 18, series: 'B' },
|
|
458
|
+
{ year: '2022', value: 12, series: 'B' },
|
|
459
|
+
{ year: '2020', value: 5, series: 'C' },
|
|
460
|
+
{ year: '2021', value: 12, series: 'C' },
|
|
461
|
+
{ year: '2022', value: 9, series: 'C' },
|
|
462
|
+
],
|
|
463
|
+
encoding: {
|
|
464
|
+
x: { field: 'year', type: 'temporal' as const },
|
|
465
|
+
y: { field: 'value', type: 'quantitative' as const },
|
|
466
|
+
color: { field: 'series', type: 'nominal' as const },
|
|
467
|
+
},
|
|
468
|
+
legend: { position: 'bottom' as const, show: true },
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
const layout = compileChart(spec, { width: 800, height: 500 });
|
|
472
|
+
|
|
473
|
+
expect(layout.legend.entries.length).toBeGreaterThan(0);
|
|
474
|
+
expect(layout.legend.position).toBe('bottom');
|
|
475
|
+
|
|
476
|
+
// Recompute the gap the engine uses internally to make a tight assertion.
|
|
477
|
+
const gap = legendGap(800);
|
|
478
|
+
|
|
479
|
+
// Use the same axis-height fallback dimensions.ts uses for an unrotated
|
|
480
|
+
// x-axis without an axis title (26px). Asserting `>=` means the legend
|
|
481
|
+
// top is at or below the bottom of the x-axis tick row.
|
|
482
|
+
const xAxisHeight = 26;
|
|
483
|
+
const chartBottom = layout.area.y + layout.area.height;
|
|
484
|
+
const minLegendY = chartBottom + xAxisHeight + gap;
|
|
485
|
+
|
|
486
|
+
expect(layout.legend.bounds.y).toBeGreaterThanOrEqual(minLegendY - 0.5);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it('positions bottom chrome (source) below the bottom legend band', () => {
|
|
490
|
+
// Follow-up regression: a bottom legend reserved space below the x-axis
|
|
491
|
+
// tick row, but source/byline/footer chrome was still positioned via
|
|
492
|
+
// chartBottom + xAxisExtent + chartToFooter, landing in the same band as
|
|
493
|
+
// the legend swatches. Threading bottomLegendReservation into computeChrome
|
|
494
|
+
// now shifts chrome.source.y so it stacks below the legend.
|
|
495
|
+
const spec = {
|
|
496
|
+
mark: 'area' as const,
|
|
497
|
+
data: [
|
|
498
|
+
{ year: '2020', value: 10, series: 'A' },
|
|
499
|
+
{ year: '2021', value: 20, series: 'A' },
|
|
500
|
+
{ year: '2022', value: 15, series: 'A' },
|
|
501
|
+
{ year: '2020', value: 8, series: 'B' },
|
|
502
|
+
{ year: '2021', value: 18, series: 'B' },
|
|
503
|
+
{ year: '2022', value: 12, series: 'B' },
|
|
504
|
+
],
|
|
505
|
+
encoding: {
|
|
506
|
+
x: { field: 'year', type: 'temporal' as const },
|
|
507
|
+
y: { field: 'value', type: 'quantitative' as const },
|
|
508
|
+
color: { field: 'series', type: 'nominal' as const },
|
|
509
|
+
},
|
|
510
|
+
legend: { position: 'bottom' as const, show: true },
|
|
511
|
+
chrome: {
|
|
512
|
+
source: 'World Bank, 2024',
|
|
513
|
+
byline: 'OpenChart Newsroom',
|
|
514
|
+
},
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
const layout = compileChart(spec, { width: 800, height: 500 });
|
|
518
|
+
|
|
519
|
+
// Sanity: legend and chrome both rendered.
|
|
520
|
+
expect(layout.legend.position).toBe('bottom');
|
|
521
|
+
expect(layout.legend.entries.length).toBeGreaterThan(0);
|
|
522
|
+
expect(layout.chrome.source).toBeDefined();
|
|
523
|
+
expect(layout.chrome.byline).toBeDefined();
|
|
524
|
+
|
|
525
|
+
// Compute chrome's absolute y the same way the renderer does:
|
|
526
|
+
// bottomOffset = chartBottom + xAxisExtent (= 26 for default unrotated x-axis).
|
|
527
|
+
const xAxisExtent = 26;
|
|
528
|
+
const bottomOffset = layout.area.y + layout.area.height + xAxisExtent;
|
|
529
|
+
const sourceAbsoluteY = bottomOffset + layout.chrome.source!.y;
|
|
530
|
+
const bylineAbsoluteY = bottomOffset + layout.chrome.byline!.y;
|
|
531
|
+
|
|
532
|
+
// Legend's bottom edge.
|
|
533
|
+
const legendBottom = layout.legend.bounds.y + layout.legend.bounds.height;
|
|
534
|
+
|
|
535
|
+
// Source must land below the legend band, with at least a small gap
|
|
536
|
+
// (chartToFooter ≈ 12px) for breathing room.
|
|
537
|
+
expect(sourceAbsoluteY).toBeGreaterThan(legendBottom);
|
|
538
|
+
expect(bylineAbsoluteY).toBeGreaterThan(sourceAbsoluteY);
|
|
539
|
+
});
|
|
316
540
|
});
|