@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.
Files changed (48) hide show
  1. package/README.md +1 -0
  2. package/dist/index.d.ts +8 -11
  3. package/dist/index.js +12297 -11338
  4. package/dist/index.js.map +1 -1
  5. package/package.json +2 -2
  6. package/src/__test-fixtures__/specs.ts +3 -0
  7. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +452 -377
  8. package/src/__tests__/axes.test.ts +75 -0
  9. package/src/__tests__/compile-chart.test.ts +304 -0
  10. package/src/__tests__/dimensions.test.ts +224 -0
  11. package/src/__tests__/legend.test.ts +44 -3
  12. package/src/annotations/__tests__/compute.test.ts +111 -0
  13. package/src/annotations/__tests__/resolve-text.test.ts +288 -0
  14. package/src/annotations/constants.ts +20 -0
  15. package/src/annotations/resolve-text.ts +161 -7
  16. package/src/charts/bar/compute.ts +24 -0
  17. package/src/charts/bar/labels.ts +1 -0
  18. package/src/charts/column/compute.ts +33 -1
  19. package/src/charts/column/labels.ts +1 -0
  20. package/src/charts/dot/labels.ts +1 -0
  21. package/src/charts/line/__tests__/compute.test.ts +153 -3
  22. package/src/charts/line/area.ts +111 -23
  23. package/src/charts/line/compute.ts +40 -10
  24. package/src/charts/line/index.ts +34 -7
  25. package/src/charts/line/labels.ts +29 -0
  26. package/src/charts/pie/labels.ts +1 -0
  27. package/src/compile/layer.ts +497 -0
  28. package/src/compile.ts +211 -586
  29. package/src/compiler/__tests__/sparkline-defaults.test.ts +153 -0
  30. package/src/compiler/normalize.ts +6 -1
  31. package/src/compiler/sparkline-defaults.ts +138 -0
  32. package/src/compiler/types.ts +8 -0
  33. package/src/endpoint-labels/__tests__/compute.test.ts +438 -0
  34. package/src/endpoint-labels/compute.ts +417 -0
  35. package/src/endpoint-labels/constants.ts +54 -0
  36. package/src/endpoint-labels/format.ts +30 -0
  37. package/src/endpoint-labels/predict.ts +108 -0
  38. package/src/graphs/compile-graph.ts +1 -0
  39. package/src/layout/axes.ts +27 -2
  40. package/src/layout/dimensions.ts +270 -33
  41. package/src/layout/metrics.ts +118 -0
  42. package/src/layout/scales.ts +41 -4
  43. package/src/legend/__tests__/suppression.test.ts +294 -0
  44. package/src/legend/compute.ts +50 -40
  45. package/src/legend/suppression.ts +204 -0
  46. package/src/sankey/compile-sankey.ts +2 -0
  47. package/src/tables/__tests__/heatmap.test.ts +4 -27
  48. 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
  });