@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/dist/index.js +57 -13
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +42 -38
- package/src/__tests__/compile-chart.test.ts +4 -1
- package/src/__tests__/dimensions.test.ts +6 -1
- package/src/charts/column/labels.ts +7 -4
- package/src/charts/line/__tests__/compute.test.ts +20 -1
- package/src/charts/line/area.ts +12 -2
- package/src/charts/line/index.ts +2 -2
- package/src/endpoint-labels/__tests__/compute.test.ts +5 -1
- package/src/endpoint-labels/compute.ts +4 -1
- package/src/layout/axes/thinning.ts +4 -2
- package/src/layout/dimensions.ts +58 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "7.
|
|
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.
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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": "
|
|
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":
|
|
453
|
-
"normal":
|
|
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":
|
|
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":
|
|
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
|
-
"
|
|
780
|
+
"dataX": 657.899,
|
|
781
|
+
"fill": "transparent",
|
|
781
782
|
"radius": 4,
|
|
782
783
|
"stroke": "#06b6d4",
|
|
783
784
|
"strokeWidth": 2,
|
|
784
|
-
"x":
|
|
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
|
-
"
|
|
800
|
+
"dataX": 657.899,
|
|
801
|
+
"fill": "transparent",
|
|
800
802
|
"radius": 4,
|
|
801
803
|
"stroke": "#eb7289",
|
|
802
804
|
"strokeWidth": 2,
|
|
803
|
-
"x":
|
|
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
|
-
"
|
|
820
|
+
"dataX": 657.899,
|
|
821
|
+
"fill": "transparent",
|
|
819
822
|
"radius": 4,
|
|
820
823
|
"stroke": "#3bb974",
|
|
821
824
|
"strokeWidth": 2,
|
|
822
|
-
"x":
|
|
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
|
-
"
|
|
840
|
+
"dataX": 657.899,
|
|
841
|
+
"fill": "transparent",
|
|
838
842
|
"radius": 4,
|
|
839
843
|
"stroke": "#ad87ed",
|
|
840
844
|
"strokeWidth": 2,
|
|
841
|
-
"x":
|
|
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":
|
|
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": "
|
|
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":
|
|
1423
|
-
"normal":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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": "
|
|
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":
|
|
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": "
|
|
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":
|
|
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": "
|
|
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":
|
|
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": "
|
|
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":
|
|
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": "
|
|
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":
|
|
2012
|
-
"normal":
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
//
|
|
95
|
-
//
|
|
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:
|
|
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
|
-
|
|
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';
|
package/src/charts/line/area.ts
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/src/charts/line/index.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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;
|
package/src/layout/dimensions.ts
CHANGED
|
@@ -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:
|
|
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
|
-
//
|
|
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
|
-
|
|
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 =
|
|
712
|
+
const newBottom = bottomMargin(fallbackChrome.bottomHeight, padding, xAxisHeight);
|
|
659
713
|
const bottomDelta = margins.bottom - newBottom;
|
|
660
714
|
|
|
661
715
|
if (topDelta > 0 || bottomDelta > 0) {
|