@opendata-ai/openchart-engine 7.0.4 → 7.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "7.0.4",
3
+ "version": "7.1.1",
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": "7.0.4",
51
+ "@opendata-ai/openchart-core": "7.1.1",
52
52
  "d3-array": "^3.2.0",
53
53
  "d3-format": "^3.1.2",
54
54
  "d3-interpolate": "^3.0.0",
@@ -57,7 +57,7 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
57
57
  "fill": "#09090b",
58
58
  "fontFamily": ""Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif",
59
59
  "fontSize": 13,
60
- "fontWeight": 510,
60
+ "fontWeight": 550,
61
61
  "lineHeight": 1.3,
62
62
  },
63
63
  "offset": undefined,
@@ -72,7 +72,7 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
72
72
  "fontFamily": ""Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif",
73
73
  "fontSize": 11,
74
74
  "fontVariant": "tabular-nums",
75
- "fontWeight": 400,
75
+ "fontWeight": 450,
76
76
  "lineHeight": 1.2,
77
77
  },
78
78
  "tickMarks": undefined,
@@ -124,7 +124,7 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
124
124
  "fill": "#09090b",
125
125
  "fontFamily": ""Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif",
126
126
  "fontSize": 13,
127
- "fontWeight": 510,
127
+ "fontWeight": 550,
128
128
  "lineHeight": 1.3,
129
129
  },
130
130
  "offset": undefined,
@@ -139,7 +139,7 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
139
139
  "fontFamily": ""Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif",
140
140
  "fontSize": 11,
141
141
  "fontVariant": "tabular-nums",
142
- "fontWeight": 400,
142
+ "fontWeight": 450,
143
143
  "lineHeight": 1.2,
144
144
  },
145
145
  "tickMarks": undefined,
@@ -202,7 +202,7 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
202
202
  "fill": "#09090b",
203
203
  "fontFamily": ""Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif",
204
204
  "fontSize": 11,
205
- "fontWeight": 400,
205
+ "fontWeight": 450,
206
206
  "lineHeight": 1.3,
207
207
  },
208
208
  "position": "top",
@@ -357,7 +357,7 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
357
357
  "annotationFill": "rgba(0,0,0,0.04)",
358
358
  "annotationText": "#71717a",
359
359
  "axis": "#71717a",
360
- "background": "#ffffff",
360
+ "background": "transparent",
361
361
  "categorical": [
362
362
  "#06b6d4",
363
363
  "#eb7289",
@@ -449,8 +449,8 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
449
449
  },
450
450
  "weights": {
451
451
  "bold": 700,
452
- "medium": 510,
453
- "normal": 400,
452
+ "medium": 550,
453
+ "normal": 450,
454
454
  "semibold": 590,
455
455
  },
456
456
  },
@@ -636,7 +636,7 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
636
636
  "fill": "#09090b",
637
637
  "fontFamily": ""Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif",
638
638
  "fontSize": 13,
639
- "fontWeight": 510,
639
+ "fontWeight": 550,
640
640
  "lineHeight": 1.3,
641
641
  },
642
642
  "offset": undefined,
@@ -651,7 +651,7 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
651
651
  "fontFamily": ""Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif",
652
652
  "fontSize": 11,
653
653
  "fontVariant": "tabular-nums",
654
- "fontWeight": 400,
654
+ "fontWeight": 450,
655
655
  "lineHeight": 1.2,
656
656
  },
657
657
  "tickMarks": false,
@@ -777,11 +777,12 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
777
777
  ],
778
778
  "labelY": 105.2,
779
779
  "marker": {
780
- "fill": "#ffffff",
780
+ "dataX": 657.899,
781
+ "fill": "transparent",
781
782
  "radius": 4,
782
783
  "stroke": "#06b6d4",
783
784
  "strokeWidth": 2,
784
- "x": 657.899,
785
+ "x": 661.899,
785
786
  "y": 105.2,
786
787
  },
787
788
  "seriesKey": "US",
@@ -796,11 +797,12 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
796
797
  ],
797
798
  "labelY": 139.95,
798
799
  "marker": {
799
- "fill": "#ffffff",
800
+ "dataX": 657.899,
801
+ "fill": "transparent",
800
802
  "radius": 4,
801
803
  "stroke": "#eb7289",
802
804
  "strokeWidth": 2,
803
- "x": 657.899,
805
+ "x": 661.899,
804
806
  "y": 143.375,
805
807
  },
806
808
  "seriesKey": "UK",
@@ -815,11 +817,12 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
815
817
  ],
816
818
  "labelY": 236.12999999999997,
817
819
  "marker": {
818
- "fill": "#ffffff",
820
+ "dataX": 657.899,
821
+ "fill": "transparent",
819
822
  "radius": 4,
820
823
  "stroke": "#3bb974",
821
824
  "strokeWidth": 2,
822
- "x": 657.899,
825
+ "x": 661.899,
823
826
  "y": 242.62999999999997,
824
827
  },
825
828
  "seriesKey": "FR",
@@ -834,11 +837,12 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
834
837
  ],
835
838
  "labelY": 190.32,
836
839
  "marker": {
837
- "fill": "#ffffff",
840
+ "dataX": 657.899,
841
+ "fill": "transparent",
838
842
  "radius": 4,
839
843
  "stroke": "#ad87ed",
840
844
  "strokeWidth": 2,
841
- "x": 657.899,
845
+ "x": 661.899,
842
846
  "y": 196.82,
843
847
  },
844
848
  "seriesKey": "DE",
@@ -903,7 +907,7 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
903
907
  "fill": "#09090b",
904
908
  "fontFamily": ""Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif",
905
909
  "fontSize": 11,
906
- "fontWeight": 400,
910
+ "fontWeight": 450,
907
911
  "lineHeight": 1.3,
908
912
  },
909
913
  "position": "right",
@@ -1327,7 +1331,7 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
1327
1331
  "annotationFill": "rgba(0,0,0,0.04)",
1328
1332
  "annotationText": "#71717a",
1329
1333
  "axis": "#71717a",
1330
- "background": "#ffffff",
1334
+ "background": "transparent",
1331
1335
  "categorical": [
1332
1336
  "#06b6d4",
1333
1337
  "#eb7289",
@@ -1419,8 +1423,8 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
1419
1423
  },
1420
1424
  "weights": {
1421
1425
  "bold": 700,
1422
- "medium": 510,
1423
- "normal": 400,
1426
+ "medium": 550,
1427
+ "normal": 450,
1424
1428
  "semibold": 590,
1425
1429
  },
1426
1430
  },
@@ -1491,7 +1495,7 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
1491
1495
  "fill": "#09090b",
1492
1496
  "fontFamily": ""Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif",
1493
1497
  "fontSize": 13,
1494
- "fontWeight": 510,
1498
+ "fontWeight": 550,
1495
1499
  "lineHeight": 1.3,
1496
1500
  },
1497
1501
  "offset": undefined,
@@ -1506,7 +1510,7 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
1506
1510
  "fontFamily": ""Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif",
1507
1511
  "fontSize": 11,
1508
1512
  "fontVariant": "tabular-nums",
1509
- "fontWeight": 400,
1513
+ "fontWeight": 450,
1510
1514
  "lineHeight": 1.2,
1511
1515
  },
1512
1516
  "tickMarks": undefined,
@@ -1567,7 +1571,7 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
1567
1571
  "fill": "#09090b",
1568
1572
  "fontFamily": ""Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif",
1569
1573
  "fontSize": 13,
1570
- "fontWeight": 510,
1574
+ "fontWeight": 550,
1571
1575
  "lineHeight": 1.3,
1572
1576
  },
1573
1577
  "offset": undefined,
@@ -1582,7 +1586,7 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
1582
1586
  "fontFamily": ""Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif",
1583
1587
  "fontSize": 11,
1584
1588
  "fontVariant": "tabular-nums",
1585
- "fontWeight": 400,
1589
+ "fontWeight": 450,
1586
1590
  "lineHeight": 1.2,
1587
1591
  },
1588
1592
  "tickMarks": undefined,
@@ -1680,7 +1684,7 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
1680
1684
  "fill": "#09090b",
1681
1685
  "fontFamily": ""Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif",
1682
1686
  "fontSize": 11,
1683
- "fontWeight": 400,
1687
+ "fontWeight": 450,
1684
1688
  "lineHeight": 1.3,
1685
1689
  },
1686
1690
  "position": "top",
@@ -1716,7 +1720,7 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
1716
1720
  "label": {
1717
1721
  "connector": undefined,
1718
1722
  "style": {
1719
- "dominantBaseline": "auto",
1723
+ "dominantBaseline": "hanging",
1720
1724
  "fill": "#0000ff",
1721
1725
  "fontFamily": "system-ui, -apple-system, sans-serif",
1722
1726
  "fontSize": 10,
@@ -1727,7 +1731,7 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
1727
1731
  "text": "30",
1728
1732
  "visible": true,
1729
1733
  "x": 124.62862068965518,
1730
- "y": 177.92857142857142,
1734
+ "y": 175.92857142857142,
1731
1735
  },
1732
1736
  "orient": "vertical",
1733
1737
  "type": "rect",
@@ -1761,7 +1765,7 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
1761
1765
  "label": {
1762
1766
  "connector": undefined,
1763
1767
  "style": {
1764
- "dominantBaseline": "auto",
1768
+ "dominantBaseline": "hanging",
1765
1769
  "fill": "#0000ff",
1766
1770
  "fontFamily": "system-ui, -apple-system, sans-serif",
1767
1771
  "fontSize": 10,
@@ -1772,7 +1776,7 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
1772
1776
  "text": "55",
1773
1777
  "visible": true,
1774
1778
  "x": 247.7228735632184,
1775
- "y": 93.53571428571429,
1779
+ "y": 91.53571428571429,
1776
1780
  },
1777
1781
  "orient": "vertical",
1778
1782
  "type": "rect",
@@ -1806,7 +1810,7 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
1806
1810
  "label": {
1807
1811
  "connector": undefined,
1808
1812
  "style": {
1809
- "dominantBaseline": "auto",
1813
+ "dominantBaseline": "hanging",
1810
1814
  "fill": "#0000ff",
1811
1815
  "fontFamily": "system-ui, -apple-system, sans-serif",
1812
1816
  "fontSize": 10,
@@ -1817,7 +1821,7 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
1817
1821
  "text": "70",
1818
1822
  "visible": true,
1819
1823
  "x": 370.8171264367816,
1820
- "y": 42.9,
1824
+ "y": 40.9,
1821
1825
  },
1822
1826
  "orient": "vertical",
1823
1827
  "type": "rect",
@@ -1851,7 +1855,7 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
1851
1855
  "label": {
1852
1856
  "connector": undefined,
1853
1857
  "style": {
1854
- "dominantBaseline": "auto",
1858
+ "dominantBaseline": "hanging",
1855
1859
  "fill": "#0000ff",
1856
1860
  "fontFamily": "system-ui, -apple-system, sans-serif",
1857
1861
  "fontSize": 10,
@@ -1862,7 +1866,7 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
1862
1866
  "text": "45",
1863
1867
  "visible": true,
1864
1868
  "x": 493.9113793103449,
1865
- "y": 127.29285714285712,
1869
+ "y": 125.29285714285712,
1866
1870
  },
1867
1871
  "orient": "vertical",
1868
1872
  "type": "rect",
@@ -1916,7 +1920,7 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
1916
1920
  "annotationFill": "rgba(0,0,0,0.04)",
1917
1921
  "annotationText": "#71717a",
1918
1922
  "axis": "#71717a",
1919
- "background": "#ffffff",
1923
+ "background": "transparent",
1920
1924
  "categorical": [
1921
1925
  "#06b6d4",
1922
1926
  "#eb7289",
@@ -2008,8 +2012,8 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
2008
2012
  },
2009
2013
  "weights": {
2010
2014
  "bold": 700,
2011
- "medium": 510,
2012
- "normal": 400,
2015
+ "medium": 550,
2016
+ "normal": 450,
2013
2017
  "semibold": 590,
2014
2018
  },
2015
2019
  },
@@ -230,7 +230,10 @@ describe('compileChart', () => {
230
230
 
231
231
  expect(light.theme.isDark).toBe(false);
232
232
  expect(dark.theme.isDark).toBe(true);
233
- expect(dark.theme.colors.background).not.toBe(light.theme.colors.background);
233
+ // Both modes preserve transparent background — dark mode swaps text/axis/gridline
234
+ // colors but keeps transparency so the host surface shows through.
235
+ expect(dark.theme.colors.background).toBe('transparent');
236
+ expect(light.theme.colors.background).toBe('transparent');
234
237
  // Dark mode text should be light, light mode text should be dark
235
238
  expect(dark.theme.colors.text).not.toBe(light.theme.colors.text);
236
239
  });
@@ -142,7 +142,12 @@ describe('computeDimensions', () => {
142
142
 
143
143
  expect(lightDims.theme.isDark).toBe(false);
144
144
  expect(darkDims.theme.isDark).toBe(true);
145
- expect(darkDims.theme.colors.background).not.toBe(lightDims.theme.colors.background);
145
+ // Both modes use transparent background — the dark adaptation changes text/axis
146
+ // colors while keeping transparency so the host surface shows through.
147
+ expect(darkDims.theme.colors.background).toBe('transparent');
148
+ expect(lightDims.theme.colors.background).toBe('transparent');
149
+ // Dark mode uses a different text color
150
+ expect(darkDims.theme.colors.text).not.toBe(lightDims.theme.colors.text);
146
151
  });
147
152
 
148
153
  it('prevents negative chart area dimensions', () => {
@@ -32,7 +32,7 @@ import { formatLabelValue } from '../_shared/format-label-value';
32
32
 
33
33
  const LABEL_FONT_SIZE = 10;
34
34
  const LABEL_FONT_WEIGHT = 600;
35
- const LABEL_OFFSET_Y = 6;
35
+ const LABEL_OFFSET_Y = 8;
36
36
 
37
37
  // ---------------------------------------------------------------------------
38
38
  // Public API
@@ -91,8 +91,11 @@ export function computeColumnLabels(
91
91
  const textWidth = estimateTextWidth(valuePart, LABEL_FONT_SIZE, LABEL_FONT_WEIGHT);
92
92
  const textHeight = LABEL_FONT_SIZE * 1.2;
93
93
 
94
- // For positive values, place label above the column top.
95
- // For negative values, place label below the column bottom.
94
+ // anchorY is the TOP of the label bounding box so the collision system's
95
+ // AABB check (rect = { y: anchorY, height: textHeight }) is geometrically
96
+ // correct. dominantBaseline 'hanging' anchors the glyph top at anchorY.
97
+ // Positive bar: top = barTop - LABEL_OFFSET_Y - textHeight, text floats above
98
+ // Negative bar: top = barBottom + LABEL_OFFSET_Y, text hangs below
96
99
  const anchorX = mark.x + mark.width / 2;
97
100
  const anchorY = isNegative
98
101
  ? mark.y + mark.height + LABEL_OFFSET_Y
@@ -112,7 +115,7 @@ export function computeColumnLabels(
112
115
  fill: labelColor ?? getRepresentativeColor(mark.fill),
113
116
  lineHeight: 1.2,
114
117
  textAnchor: 'middle',
115
- dominantBaseline: isNegative ? 'hanging' : 'auto',
118
+ dominantBaseline: 'hanging',
116
119
  },
117
120
  });
118
121
  }
@@ -620,12 +620,31 @@ describe('computeAreaMarks', () => {
620
620
  expect(fill.gradient).toBe('linear');
621
621
  expect(fill.stops).toHaveLength(2);
622
622
  expect(fill.stops[0].opacity).toBe(0.65);
623
- expect(fill.stops[1].opacity).toBe(0.35);
623
+ // Light mode: bottom fades to 0 so the colored wash at the base is avoided
624
+ expect(fill.stops[1].opacity).toBe(0);
624
625
  // fillOpacity should be 1 so gradient stop-opacity controls the fade
625
626
  expect(mark.fillOpacity).toBe(1);
626
627
  }
627
628
  });
628
629
 
630
+ it('stacked areas use higher bottom opacity in dark mode', () => {
631
+ const spec = makeMultiSeriesSpec();
632
+ spec.encoding.y!.stack = 'zero';
633
+ const scales = computeScales(spec, chartArea, spec.data);
634
+ const marks = computeAreaMarks(spec, scales, chartArea, true /* darkMode */);
635
+
636
+ expect(marks.length).toBeGreaterThan(0);
637
+ for (const mark of marks) {
638
+ const fill = mark.fill as { gradient: string; stops: { opacity?: number }[] };
639
+ expect(fill.gradient).toBe('linear');
640
+ expect(fill.stops).toHaveLength(2);
641
+ expect(fill.stops[0].opacity).toBe(0.65);
642
+ // Dark mode: bottom stop is 0.35 so bands remain visible on dark surfaces
643
+ expect(fill.stops[1].opacity).toBe(0.35);
644
+ expect(mark.fillOpacity).toBe(1);
645
+ }
646
+ });
647
+
629
648
  it('stacked: markDef.fill string still overrides per-layer gradient', () => {
630
649
  const spec = makeMultiSeriesSpec();
631
650
  spec.encoding.y!.stack = 'zero';
@@ -80,6 +80,13 @@ const STACKED_GRADIENT_STOPS = [
80
80
  { offset: 1, opacity: 0.35 },
81
81
  ];
82
82
 
83
+ // Light-mode stacked areas: bottom out at opacity 0 rather than 0.35 to avoid
84
+ // the muddy color wash at the base of each band on white/light backgrounds.
85
+ const STACKED_GRADIENT_STOPS_LIGHT = [
86
+ { offset: 0, opacity: 0.65 },
87
+ { offset: 1, opacity: 0 },
88
+ ];
89
+
83
90
  function buildGradientFill(
84
91
  colorStr: string,
85
92
  stops: ReadonlyArray<{ offset: number; opacity: number }>,
@@ -257,6 +264,7 @@ function computeStackedArea(
257
264
  spec: NormalizedChartSpec,
258
265
  scales: ResolvedScales,
259
266
  chartArea: Rect,
267
+ darkMode?: boolean,
260
268
  ): AreaMark[] {
261
269
  const encoding = spec.encoding as Encoding;
262
270
  const xChannel = encoding.x;
@@ -389,7 +397,8 @@ function computeStackedArea(
389
397
  fillOpacity = isGradientDef(markFill) ? 1 : (spec.markDef.opacity ?? 0.7);
390
398
  } else {
391
399
  const colorStr = getRepresentativeColor(color);
392
- fillValue = buildGradientFill(colorStr, STACKED_GRADIENT_STOPS);
400
+ const stackedStops = darkMode ? STACKED_GRADIENT_STOPS : STACKED_GRADIENT_STOPS_LIGHT;
401
+ fillValue = buildGradientFill(colorStr, stackedStops);
393
402
  fillOpacity = 1;
394
403
  }
395
404
 
@@ -443,12 +452,13 @@ export function computeAreaMarks(
443
452
  spec: NormalizedChartSpec,
444
453
  scales: ResolvedScales,
445
454
  chartArea: Rect,
455
+ darkMode?: boolean,
446
456
  ): AreaMark[] {
447
457
  const encoding = spec.encoding as Encoding;
448
458
  const yChannel = encoding.y;
449
459
 
450
460
  if (yChannel && isStacked(yChannel.stack)) {
451
- return computeStackedArea(spec, scales, chartArea);
461
+ return computeStackedArea(spec, scales, chartArea, darkMode);
452
462
  }
453
463
 
454
464
  return computeSingleArea(spec, scales, chartArea);
@@ -71,8 +71,8 @@ export const lineRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _
71
71
  * of whether the layout is stacked (cumulative tops) or overlap (per-series
72
72
  * raw values).
73
73
  */
74
- export const areaRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _theme) => {
75
- const areas = computeAreaMarks(spec, scales, chartArea);
74
+ export const areaRenderer: ChartRenderer = (spec, scales, chartArea, strategy, theme) => {
75
+ const areas = computeAreaMarks(spec, scales, chartArea, theme.isDark);
76
76
 
77
77
  const encoding = spec.encoding;
78
78
  const hasColor = !!(encoding.color && 'field' in encoding.color);
@@ -19,6 +19,7 @@ import { describe, expect, it } from 'vitest';
19
19
 
20
20
  import type { NormalizedChartSpec } from '../../compiler/types';
21
21
  import { bidirectionalSweep, computeEndpointLabels } from '../compute';
22
+ import { ENDPOINT_MARKER_RADIUS } from '../constants';
22
23
 
23
24
  // ---------------------------------------------------------------------------
24
25
  // Fixtures
@@ -281,7 +282,10 @@ describe('computeEndpointLabels', () => {
281
282
 
282
283
  for (const entry of layout.entries) {
283
284
  expect(entry.marker).toBeDefined();
284
- expect(entry.marker!.x).toBe(lastX);
285
+ // dataX is the original line endpoint; x is offset right by radius so the
286
+ // line terminates at the circle edge rather than its center.
287
+ expect(entry.marker!.dataX).toBe(lastX);
288
+ expect(entry.marker!.x).toBe(lastX + ENDPOINT_MARKER_RADIUS);
285
289
  // Marker y is at the actual data point (not displaced labelY).
286
290
  expect(entry.marker!.y).toBe(entry.dataY);
287
291
  // Open-circle convention: fill = background, stroke = series color.
@@ -386,9 +386,12 @@ export function computeEndpointLabels(
386
386
  showLeader: showLeader && displaced,
387
387
  };
388
388
  if (showMarker) {
389
+ // Offset cx right by markerRadius so the line terminates at the circle's
390
+ // left edge rather than its center — prevents the line from piercing the ring.
389
391
  entry.marker = {
390
- x: p.dataX,
392
+ x: p.dataX + markerRadius,
391
393
  y: p.dataY,
394
+ dataX: p.dataX,
392
395
  fill: markerFill,
393
396
  stroke: config?.markerStyle?.stroke ?? p.color,
394
397
  strokeWidth: markerStrokeWidth,
@@ -12,9 +12,11 @@ import { estimateTextWidth } from '@opendata-ai/openchart-core';
12
12
 
13
13
  /**
14
14
  * Minimum gap between adjacent tick labels as a multiple of font size.
15
- * At the default 12px axis font, this yields ~12px of breathing room.
15
+ * At the default 11px axis font, this yields ~5-6px of breathing room.
16
+ * Reduced from 1.0 to 0.5 to prevent over-aggressive thinning on charts
17
+ * with a small number of categories that clearly have room for all labels.
16
18
  */
17
- const MIN_TICK_GAP_FACTOR = 1.0;
19
+ const MIN_TICK_GAP_FACTOR = 0.5;
18
20
 
19
21
  /** Always show at least this many ticks, even if they overlap. */
20
22
  const MIN_TICK_COUNT = 2;
@@ -37,6 +37,7 @@ import {
37
37
  MAX_LEFT_LABEL_FRACTION_MEDIUM,
38
38
  MAX_LEFT_LABEL_FRACTION_MEDIUM_MAX,
39
39
  NARROW_VIEWPORT_MAX,
40
+ TICK_LABEL_OFFSET,
40
41
  TOP_PAD_EXTRA_NARROW,
41
42
  } from '@opendata-ai/openchart-core';
42
43
  import { format as d3Format } from 'd3-format';
@@ -93,6 +94,16 @@ function chromeToInput(chrome: NormalizedChrome): import('@opendata-ai/openchart
93
94
  };
94
95
  }
95
96
 
97
+ /**
98
+ * Compute the bottom margin contribution from chrome.
99
+ * Padding always applies (gap between x-axis ticks and the chrome below).
100
+ * bottomHeight is additive on top — it covers source/watermark/legend space
101
+ * including its own internal padding when content is present.
102
+ */
103
+ function bottomMargin(bottomHeight: number, padding: number, xAxisHeight: number): number {
104
+ return padding + bottomHeight + xAxisHeight;
105
+ }
106
+
96
107
  /**
97
108
  * Scale padding based on the smaller container dimension.
98
109
  * At >= 500px, padding is unchanged. At <= 200px, padding is halved (min 4px).
@@ -370,7 +381,7 @@ export function computeDimensions(
370
381
  const margins: Margins = {
371
382
  top: topPad + chrome.topHeight + tentativeMetricsHeight,
372
383
  right: hPad + (isRadial ? hPad : axisMargin),
373
- bottom: padding + chrome.bottomHeight + xAxisHeight,
384
+ bottom: bottomMargin(chrome.bottomHeight, padding, xAxisHeight),
374
385
  left: hPad + (isRadial ? hPad : axisMargin),
375
386
  };
376
387
 
@@ -580,10 +591,53 @@ export function computeDimensions(
580
591
  }
581
592
 
582
593
  // Rotated y-axis label needs extra left margin (rendered at area.x - offset in SVG).
583
- // Tighter on compact viewports where horizontal space is scarce.
594
+ // The renderer computes a dynamic offset that accounts for wide tick labels (e.g.
595
+ // "$100,000" is ~62px wide and would overlap a fixed 45px offset). We replicate
596
+ // the same formula here so the reserved space matches what the renderer places.
584
597
  const yAxis = encoding.y?.axis as Record<string, unknown> | undefined;
585
598
  if (yAxis && (yAxis.title || yAxis.label) && !isRadial) {
586
- const axisTitleOffset = getAxisTitleOffset(width);
599
+ // Estimate the widest y-axis tick label width to mirror the renderer's dynamic offset.
600
+ const yFieldForTitle = encoding.y?.field;
601
+ const yAxisFormatForTitle = yAxis?.format as string | undefined;
602
+ let estTickLabelWidth = 0;
603
+ if (
604
+ yFieldForTitle &&
605
+ (encoding.y?.type === 'quantitative' || encoding.y?.type === 'temporal')
606
+ ) {
607
+ let maxAbsValForTitle = 0;
608
+ for (const row of spec.data) {
609
+ const v = Number(row[yFieldForTitle]);
610
+ if (Number.isFinite(v) && Math.abs(v) > maxAbsValForTitle) maxAbsValForTitle = Math.abs(v);
611
+ }
612
+ let sampleLabelForTitle: string;
613
+ if (yAxisFormatForTitle) {
614
+ try {
615
+ const fmt = d3Format(yAxisFormatForTitle);
616
+ sampleLabelForTitle = fmt(maxAbsValForTitle);
617
+ } catch {
618
+ sampleLabelForTitle = String(maxAbsValForTitle);
619
+ }
620
+ } else {
621
+ if (maxAbsValForTitle >= 1_000_000_000) sampleLabelForTitle = '1.5B';
622
+ else if (maxAbsValForTitle >= 1_000_000) sampleLabelForTitle = '1.5M';
623
+ else if (maxAbsValForTitle >= 1_000) sampleLabelForTitle = '1.5K';
624
+ else if (maxAbsValForTitle >= 100) sampleLabelForTitle = '100';
625
+ else if (maxAbsValForTitle >= 10) sampleLabelForTitle = '10';
626
+ else sampleLabelForTitle = '0.0';
627
+ }
628
+ const negPrefixForTitle = spec.data.some((r) => Number(r[yFieldForTitle]) < 0) ? '-' : '';
629
+ estTickLabelWidth = estimateTextWidth(
630
+ negPrefixForTitle + sampleLabelForTitle,
631
+ theme.fonts.sizes.axisTick,
632
+ theme.fonts.weights.normal,
633
+ );
634
+ }
635
+ // Mirror the renderer's dynamic offset formula:
636
+ // dynamicOffset = TICK_LABEL_OFFSET(6) + maxTickLabelWidth + 8px gap
637
+ // titleOffset = max(dynamicOffset, AXIS_TITLE_OFFSET_COMPACT)
638
+ const AXIS_TITLE_GAP = 8;
639
+ const dynamicTitleOffset = TICK_LABEL_OFFSET + estTickLabelWidth + AXIS_TITLE_GAP;
640
+ const axisTitleOffset = Math.max(dynamicTitleOffset, getAxisTitleOffset(width));
587
641
  const halfGlyph = Math.ceil(theme.fonts.sizes.body / 2);
588
642
  const rotatedLabelMargin =
589
643
  axisTitleOffset + halfGlyph + (width < BREAKPOINT_COMPACT_MAX ? 0 : AXIS_TITLE_TRAILING_PAD);
@@ -655,7 +709,7 @@ export function computeDimensions(
655
709
  isRadial && fallbackChrome.topHeight === 0 ? 0 : axisMargin + inlineTickOverhang;
656
710
  const newTop = topPad + fallbackChrome.topHeight + tentativeMetricsHeight;
657
711
  const topDelta = margins.top - newTop;
658
- const newBottom = padding + fallbackChrome.bottomHeight + xAxisHeight;
712
+ const newBottom = bottomMargin(fallbackChrome.bottomHeight, padding, xAxisHeight);
659
713
  const bottomDelta = margins.bottom - newBottom;
660
714
 
661
715
  if (topDelta > 0 || bottomDelta > 0) {