@automattic/charts 0.56.7 → 0.57.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 (176) hide show
  1. package/AGENTS.md +28 -98
  2. package/CHANGELOG.md +16 -0
  3. package/dist/charts/bar-chart/index.cjs +5 -6
  4. package/dist/charts/bar-chart/index.cjs.map +1 -1
  5. package/dist/charts/bar-chart/index.d.cts +3 -3
  6. package/dist/charts/bar-chart/index.d.ts +3 -3
  7. package/dist/charts/bar-chart/index.js +4 -5
  8. package/dist/charts/bar-list-chart/index.cjs +6 -7
  9. package/dist/charts/bar-list-chart/index.cjs.map +1 -1
  10. package/dist/charts/bar-list-chart/index.d.cts +3 -3
  11. package/dist/charts/bar-list-chart/index.d.ts +3 -3
  12. package/dist/charts/bar-list-chart/index.js +5 -6
  13. package/dist/charts/conversion-funnel-chart/index.cjs +5 -6
  14. package/dist/charts/conversion-funnel-chart/index.cjs.map +1 -1
  15. package/dist/charts/conversion-funnel-chart/index.d.cts +1 -1
  16. package/dist/charts/conversion-funnel-chart/index.d.ts +1 -1
  17. package/dist/charts/conversion-funnel-chart/index.js +4 -5
  18. package/dist/charts/geo-chart/index.cjs +4 -4
  19. package/dist/charts/geo-chart/index.d.cts +1 -1
  20. package/dist/charts/geo-chart/index.d.ts +1 -1
  21. package/dist/charts/geo-chart/index.js +3 -3
  22. package/dist/charts/leaderboard-chart/index.cjs +5 -5
  23. package/dist/charts/leaderboard-chart/index.css +8 -9
  24. package/dist/charts/leaderboard-chart/index.css.map +1 -1
  25. package/dist/charts/leaderboard-chart/index.d.cts +3 -3
  26. package/dist/charts/leaderboard-chart/index.d.ts +3 -3
  27. package/dist/charts/leaderboard-chart/index.js +4 -4
  28. package/dist/charts/line-chart/index.cjs +5 -6
  29. package/dist/charts/line-chart/index.cjs.map +1 -1
  30. package/dist/charts/line-chart/index.d.cts +3 -3
  31. package/dist/charts/line-chart/index.d.ts +3 -3
  32. package/dist/charts/line-chart/index.js +4 -5
  33. package/dist/charts/pie-chart/index.cjs +5 -6
  34. package/dist/charts/pie-chart/index.cjs.map +1 -1
  35. package/dist/charts/pie-chart/index.d.cts +4 -4
  36. package/dist/charts/pie-chart/index.d.ts +4 -4
  37. package/dist/charts/pie-chart/index.js +4 -5
  38. package/dist/charts/pie-semi-circle-chart/index.cjs +5 -6
  39. package/dist/charts/pie-semi-circle-chart/index.cjs.map +1 -1
  40. package/dist/charts/pie-semi-circle-chart/index.d.cts +4 -4
  41. package/dist/charts/pie-semi-circle-chart/index.d.ts +4 -4
  42. package/dist/charts/pie-semi-circle-chart/index.js +4 -5
  43. package/dist/charts/sparkline/index.cjs +6 -7
  44. package/dist/charts/sparkline/index.cjs.map +1 -1
  45. package/dist/charts/sparkline/index.js +5 -6
  46. package/dist/{chunk-XD2HV7M5.js → chunk-2NCY7R4G.js} +127 -762
  47. package/dist/chunk-2NCY7R4G.js.map +1 -0
  48. package/dist/{chunk-RFSHE3HL.js → chunk-32DH6JDF.js} +64 -43
  49. package/dist/chunk-32DH6JDF.js.map +1 -0
  50. package/dist/{chunk-SSFFCBCF.js → chunk-4OPFE4RM.js} +11 -8
  51. package/dist/chunk-4OPFE4RM.js.map +1 -0
  52. package/dist/{chunk-CAFJRZPZ.cjs → chunk-77OKCVQN.cjs} +17 -17
  53. package/dist/{chunk-CAFJRZPZ.cjs.map → chunk-77OKCVQN.cjs.map} +1 -1
  54. package/dist/{chunk-K6TGILHX.cjs → chunk-7FQX4ALL.cjs} +6 -6
  55. package/dist/{chunk-K6TGILHX.cjs.map → chunk-7FQX4ALL.cjs.map} +1 -1
  56. package/dist/{chunk-7FDQGBY7.js → chunk-BCX5THDQ.js} +9 -7
  57. package/dist/chunk-BCX5THDQ.js.map +1 -0
  58. package/dist/{chunk-KHQPN77E.js → chunk-CZGYJKG6.js} +4 -4
  59. package/dist/{chunk-3EXJP67N.cjs → chunk-D2UH4CFE.cjs} +9 -9
  60. package/dist/{chunk-3EXJP67N.cjs.map → chunk-D2UH4CFE.cjs.map} +1 -1
  61. package/dist/{chunk-TE63Y5PX.js → chunk-DAU3HNEG.js} +2 -2
  62. package/dist/chunk-DAU3HNEG.js.map +1 -0
  63. package/dist/{chunk-MDRCAGKZ.js → chunk-H2V4JMSA.js} +3 -3
  64. package/dist/{chunk-UFRBUT2D.cjs → chunk-I35UYJJR.cjs} +49 -6
  65. package/dist/chunk-I35UYJJR.cjs.map +1 -0
  66. package/dist/{chunk-GWBS65VC.js → chunk-IU4DYUAV.js} +3 -3
  67. package/dist/{chunk-E62LCBGD.js → chunk-PXLEMUGJ.js} +3 -3
  68. package/dist/{chunk-YDVHT7GS.cjs → chunk-RHHVEJHJ.cjs} +83 -62
  69. package/dist/chunk-RHHVEJHJ.cjs.map +1 -0
  70. package/dist/{chunk-YAXY5L7I.cjs → chunk-TO3OQBXG.cjs} +5 -5
  71. package/dist/{chunk-YAXY5L7I.cjs.map → chunk-TO3OQBXG.cjs.map} +1 -1
  72. package/dist/{chunk-VPAEBI2F.js → chunk-V36ERY7Y.js} +9 -7
  73. package/dist/chunk-V36ERY7Y.js.map +1 -0
  74. package/dist/{chunk-X7JL2NYJ.cjs → chunk-VJM5XCB4.cjs} +33 -30
  75. package/dist/chunk-VJM5XCB4.cjs.map +1 -0
  76. package/dist/{chunk-ZVGEDXDP.cjs → chunk-VTS3PNMS.cjs} +2 -2
  77. package/dist/{chunk-ZVGEDXDP.cjs.map → chunk-VTS3PNMS.cjs.map} +1 -1
  78. package/dist/{chunk-OMS5QIJN.js → chunk-WLODYNLB.js} +9 -7
  79. package/dist/chunk-WLODYNLB.js.map +1 -0
  80. package/dist/{chunk-NQJE2CC7.cjs → chunk-XKRJL2QT.cjs} +25 -23
  81. package/dist/chunk-XKRJL2QT.cjs.map +1 -0
  82. package/dist/{chunk-O2JIANHK.cjs → chunk-YE2T52VZ.cjs} +33 -31
  83. package/dist/chunk-YE2T52VZ.cjs.map +1 -0
  84. package/dist/{chunk-IS5YYLTV.js → chunk-Z26M4V2M.js} +46 -3
  85. package/dist/chunk-Z26M4V2M.js.map +1 -0
  86. package/dist/{chunk-55ZCOYDF.cjs → chunk-Z45KX47P.cjs} +153 -788
  87. package/dist/chunk-Z45KX47P.cjs.map +1 -0
  88. package/dist/{chunk-BXFD7JIG.cjs → chunk-ZH4F5RMG.cjs} +26 -24
  89. package/dist/chunk-ZH4F5RMG.cjs.map +1 -0
  90. package/dist/components/legend/index.cjs +3 -3
  91. package/dist/components/legend/index.d.cts +4 -4
  92. package/dist/components/legend/index.d.ts +4 -4
  93. package/dist/components/legend/index.js +2 -2
  94. package/dist/components/tooltip/index.d.cts +1 -1
  95. package/dist/components/tooltip/index.d.ts +1 -1
  96. package/dist/hooks/index.cjs +3 -3
  97. package/dist/hooks/index.d.cts +7 -3
  98. package/dist/hooks/index.d.ts +7 -3
  99. package/dist/hooks/index.js +2 -2
  100. package/dist/index.cjs +13 -14
  101. package/dist/index.cjs.map +1 -1
  102. package/dist/index.css +8 -9
  103. package/dist/index.css.map +1 -1
  104. package/dist/index.d.cts +7 -7
  105. package/dist/index.d.ts +7 -7
  106. package/dist/index.js +12 -13
  107. package/dist/{leaderboard-chart-BSgEw_Um.d.ts → leaderboard-chart-BKYYXcg2.d.ts} +5 -9
  108. package/dist/{leaderboard-chart-COtgamhe.d.cts → leaderboard-chart-DR7CGb0L.d.cts} +5 -9
  109. package/dist/{legend-C9ahiwOt.d.cts → legend-C2grwnWk.d.cts} +1 -1
  110. package/dist/{legend-jjMmhSg3.d.ts → legend-Cj0xM5dU.d.ts} +1 -1
  111. package/dist/providers/index.cjs +3 -3
  112. package/dist/providers/index.d.cts +3 -3
  113. package/dist/providers/index.d.ts +3 -3
  114. package/dist/providers/index.js +2 -2
  115. package/dist/{themes-DQzmaSze.d.ts → themes-BmVGrYnF.d.ts} +2 -2
  116. package/dist/{themes-CVR5rmIs.d.cts → themes-CyjKm-P_.d.cts} +2 -2
  117. package/dist/{types-DQNnq5Fr.d.ts → types-CuUEszrM.d.ts} +1 -1
  118. package/dist/{types-CzdN7rUe.d.cts → types-DZordNiO.d.cts} +11 -7
  119. package/dist/{types-CzdN7rUe.d.ts → types-DZordNiO.d.ts} +11 -7
  120. package/dist/types-I67mddpr.d.cts +78 -0
  121. package/dist/types-I67mddpr.d.ts +78 -0
  122. package/dist/{types-BBwg4Evw.d.cts → types-KtOPPzPX.d.cts} +1 -1
  123. package/dist/utils/index.cjs +2 -2
  124. package/dist/utils/index.d.cts +1 -1
  125. package/dist/utils/index.d.ts +1 -1
  126. package/dist/utils/index.js +1 -1
  127. package/package.json +6 -4
  128. package/src/charts/bar-chart/bar-chart.tsx +4 -3
  129. package/src/charts/bar-chart/test/bar-chart.test.tsx +30 -0
  130. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +2 -2
  131. package/src/charts/leaderboard-chart/hooks/use-leaderboard-legend-items.ts +0 -2
  132. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +9 -10
  133. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +95 -70
  134. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +58 -29
  135. package/src/charts/leaderboard-chart/test/use-leaderboard-legend-items.test.tsx +2 -5
  136. package/src/charts/leaderboard-chart/types.ts +4 -7
  137. package/src/charts/line-chart/line-chart.tsx +2 -3
  138. package/src/charts/pie-chart/pie-chart.tsx +2 -3
  139. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +2 -3
  140. package/src/components/legend/index.ts +8 -1
  141. package/src/components/legend/private/base-legend.tsx +32 -22
  142. package/src/components/legend/test/legend.test.tsx +148 -52
  143. package/src/components/legend/types.ts +42 -16
  144. package/src/hooks/test/use-zero-value-display.test.tsx +206 -0
  145. package/src/hooks/use-zero-value-display.ts +52 -23
  146. package/src/index.ts +7 -1
  147. package/src/providers/chart-context/test/chart-context.test.tsx +12 -6
  148. package/src/providers/chart-context/themes.ts +6 -4
  149. package/src/types.ts +11 -7
  150. package/src/utils/get-styles.ts +1 -1
  151. package/src/utils/test/get-styles.test.ts +12 -10
  152. package/dist/chunk-55ZCOYDF.cjs.map +0 -1
  153. package/dist/chunk-7FDQGBY7.js.map +0 -1
  154. package/dist/chunk-BXFD7JIG.cjs.map +0 -1
  155. package/dist/chunk-IS5YYLTV.js.map +0 -1
  156. package/dist/chunk-KNIMXN6Z.js +0 -51
  157. package/dist/chunk-KNIMXN6Z.js.map +0 -1
  158. package/dist/chunk-NQJE2CC7.cjs.map +0 -1
  159. package/dist/chunk-O2JIANHK.cjs.map +0 -1
  160. package/dist/chunk-OMS5QIJN.js.map +0 -1
  161. package/dist/chunk-RFSHE3HL.js.map +0 -1
  162. package/dist/chunk-SSFFCBCF.js.map +0 -1
  163. package/dist/chunk-SUDERBUA.cjs +0 -51
  164. package/dist/chunk-SUDERBUA.cjs.map +0 -1
  165. package/dist/chunk-TE63Y5PX.js.map +0 -1
  166. package/dist/chunk-UFRBUT2D.cjs.map +0 -1
  167. package/dist/chunk-VPAEBI2F.js.map +0 -1
  168. package/dist/chunk-X7JL2NYJ.cjs.map +0 -1
  169. package/dist/chunk-XD2HV7M5.js.map +0 -1
  170. package/dist/chunk-YDVHT7GS.cjs.map +0 -1
  171. package/dist/types-C05PdDJa.d.cts +0 -57
  172. package/dist/types-C05PdDJa.d.ts +0 -57
  173. /package/dist/{chunk-KHQPN77E.js.map → chunk-CZGYJKG6.js.map} +0 -0
  174. /package/dist/{chunk-MDRCAGKZ.js.map → chunk-H2V4JMSA.js.map} +0 -0
  175. /package/dist/{chunk-GWBS65VC.js.map → chunk-IU4DYUAV.js.map} +0 -0
  176. /package/dist/{chunk-E62LCBGD.js.map → chunk-PXLEMUGJ.js.map} +0 -0
@@ -0,0 +1,206 @@
1
+ import { renderHook } from '@testing-library/react';
2
+ import { useZeroValueDisplay } from '../use-zero-value-display';
3
+ import type { SeriesData } from '../../types';
4
+
5
+ describe( 'useZeroValueDisplay', () => {
6
+ const mockData: SeriesData[] = [
7
+ {
8
+ label: 'Series 1',
9
+ data: [
10
+ { label: 'A', value: 0 },
11
+ { label: 'B', value: 100 },
12
+ { label: 'C', value: 200 },
13
+ ],
14
+ },
15
+ ];
16
+
17
+ test( 'returns original data when disabled', () => {
18
+ const { result } = renderHook( () =>
19
+ useZeroValueDisplay( mockData, { enabled: false, valueAxisLength: 100 } )
20
+ );
21
+ expect( result.current ).toBe( mockData );
22
+ } );
23
+
24
+ test( 'returns original data when valueAxisLength is not provided', () => {
25
+ const { result } = renderHook( () => useZeroValueDisplay( mockData, { enabled: true } ) );
26
+ expect( result.current ).toBe( mockData );
27
+ } );
28
+
29
+ test( 'adds visualValue for zero values', () => {
30
+ const { result } = renderHook( () =>
31
+ useZeroValueDisplay( mockData, { enabled: true, valueAxisLength: 100 } )
32
+ );
33
+
34
+ const enhancedData = result.current;
35
+ expect( enhancedData[ 0 ].data[ 0 ] ).toHaveProperty( 'visualValue' );
36
+ expect(
37
+ ( enhancedData[ 0 ].data[ 0 ] as { visualValue?: number } ).visualValue
38
+ ).toBeGreaterThan( 0 );
39
+ } );
40
+
41
+ test( 'adds visualValue for near-zero values that would render below minimum', () => {
42
+ const data: SeriesData[] = [
43
+ {
44
+ label: 'Series 1',
45
+ data: [
46
+ { label: 'A', value: 1 }, // Would render as 1px (below 3px minimum)
47
+ { label: 'B', value: 100 },
48
+ ],
49
+ },
50
+ ];
51
+
52
+ // With axis=100 and max=100, 3px threshold = 3
53
+ // Value of 1 < 3, so it gets boosted to 3
54
+ const { result } = renderHook( () =>
55
+ useZeroValueDisplay( data, { enabled: true, valueAxisLength: 100 } )
56
+ );
57
+
58
+ const enhancedData = result.current;
59
+ expect( enhancedData[ 0 ].data[ 0 ] ).toHaveProperty( 'visualValue' );
60
+ expect( ( enhancedData[ 0 ].data[ 0 ] as { visualValue?: number } ).visualValue ).toBe( 3 );
61
+ } );
62
+
63
+ test( 'does not add visualValue for values above minimum threshold', () => {
64
+ const data: SeriesData[] = [
65
+ {
66
+ label: 'Series 1',
67
+ data: [
68
+ { label: 'A', value: 10 }, // Would render as 10px (above 3px minimum)
69
+ { label: 'B', value: 100 },
70
+ ],
71
+ },
72
+ ];
73
+
74
+ const { result } = renderHook( () =>
75
+ useZeroValueDisplay( data, { enabled: true, valueAxisLength: 100 } )
76
+ );
77
+
78
+ const enhancedData = result.current;
79
+ expect( enhancedData[ 0 ].data[ 0 ] ).not.toHaveProperty( 'visualValue' );
80
+ expect( enhancedData[ 0 ].data[ 1 ] ).not.toHaveProperty( 'visualValue' );
81
+ } );
82
+
83
+ test( 'zero values get 2px equivalent (1px less than near-zero)', () => {
84
+ // mockData has values [0, 100, 200], max = 200
85
+ // zeroVisualValue = (2 / 100) * 200 = 4
86
+ const { result } = renderHook( () =>
87
+ useZeroValueDisplay( mockData, { enabled: true, valueAxisLength: 100 } )
88
+ );
89
+
90
+ const visualValue = ( result.current[ 0 ].data[ 0 ] as { visualValue?: number } ).visualValue;
91
+ expect( visualValue ).toBe( 4 ); // 2px equivalent
92
+ } );
93
+
94
+ test( 'near-zero values get 3px equivalent', () => {
95
+ const data: SeriesData[] = [
96
+ {
97
+ label: 'Series 1',
98
+ data: [
99
+ { label: 'A', value: 1 }, // Would render as 1px
100
+ { label: 'B', value: 100 },
101
+ ],
102
+ },
103
+ ];
104
+
105
+ // minNonZeroValue = (3 / 100) * 100 = 3
106
+ const { result } = renderHook( () =>
107
+ useZeroValueDisplay( data, { enabled: true, valueAxisLength: 100 } )
108
+ );
109
+
110
+ const visualValue = ( result.current[ 0 ].data[ 0 ] as { visualValue?: number } ).visualValue;
111
+ expect( visualValue ).toBe( 3 ); // 3px equivalent
112
+ } );
113
+
114
+ test( 'zeros and near-zeros have 1px visual difference', () => {
115
+ const data: SeriesData[] = [
116
+ {
117
+ label: 'Series 1',
118
+ data: [
119
+ { label: 'Zero', value: 0 },
120
+ { label: 'NearZero', value: 1 },
121
+ { label: 'Max', value: 100 },
122
+ ],
123
+ },
124
+ ];
125
+
126
+ const { result } = renderHook( () =>
127
+ useZeroValueDisplay( data, { enabled: true, valueAxisLength: 100 } )
128
+ );
129
+
130
+ const zeroVisual = ( result.current[ 0 ].data[ 0 ] as { visualValue?: number } ).visualValue;
131
+ const nearZeroVisual = ( result.current[ 0 ].data[ 1 ] as { visualValue?: number } )
132
+ .visualValue;
133
+
134
+ // Zero = 2px equivalent (2), near-zero = 3px equivalent (3)
135
+ expect( zeroVisual ).toBe( 2 );
136
+ expect( nearZeroVisual ).toBe( 3 );
137
+ expect( nearZeroVisual! - zeroVisual! ).toBe( 1 ); // 1px difference
138
+ } );
139
+
140
+ test( 'handles data with only zero values', () => {
141
+ const zeroOnlyData: SeriesData[] = [
142
+ {
143
+ label: 'Series 1',
144
+ data: [
145
+ { label: 'A', value: 0 },
146
+ { label: 'B', value: 0 },
147
+ ],
148
+ },
149
+ ];
150
+
151
+ const { result } = renderHook( () =>
152
+ useZeroValueDisplay( zeroOnlyData, { enabled: true, valueAxisLength: 100 } )
153
+ );
154
+
155
+ // Should return original data since there are no non-zero values to calculate from
156
+ expect( result.current ).toBe( zeroOnlyData );
157
+ } );
158
+
159
+ test( 'negative near-zero values preserve sign when boosted', () => {
160
+ const data: SeriesData[] = [
161
+ {
162
+ label: 'Series 1',
163
+ data: [
164
+ { label: 'NegativeNearZero', value: -1 }, // Would render as 1px (below 3px minimum)
165
+ { label: 'NegativeLarge', value: -100 },
166
+ { label: 'PositiveMax', value: 100 },
167
+ ],
168
+ },
169
+ ];
170
+
171
+ const { result } = renderHook( () =>
172
+ useZeroValueDisplay( data, { enabled: true, valueAxisLength: 100 } )
173
+ );
174
+
175
+ const negativeNearZero = result.current[ 0 ].data[ 0 ] as { visualValue?: number };
176
+
177
+ // Should have a boosted magnitude but keep the negative sign
178
+ expect( negativeNearZero.visualValue ).toBeLessThan( 0 );
179
+ expect( Math.abs( negativeNearZero.visualValue! ) ).toBeGreaterThan( 1 );
180
+ } );
181
+
182
+ test( 'null values remain untouched without visualValue', () => {
183
+ const data: SeriesData[] = [
184
+ {
185
+ label: 'Series 1',
186
+ data: [
187
+ { label: 'NullValue', value: null },
188
+ { label: 'NonZero', value: 50 },
189
+ ],
190
+ },
191
+ ];
192
+
193
+ const { result } = renderHook( () =>
194
+ useZeroValueDisplay( data, { enabled: true, valueAxisLength: 100 } )
195
+ );
196
+
197
+ const nullPoint = result.current[ 0 ].data[ 0 ] as {
198
+ value: number | null;
199
+ visualValue?: number;
200
+ };
201
+
202
+ // The original null value should be preserved and no visualValue should be added
203
+ expect( nullPoint.value ).toBeNull();
204
+ expect( nullPoint ).not.toHaveProperty( 'visualValue' );
205
+ } );
206
+ } );
@@ -11,57 +11,86 @@ export interface EnhancedSeriesData extends Omit< SeriesData, 'data' > {
11
11
 
12
12
  export interface UseZeroValueDisplayOptions {
13
13
  enabled: boolean;
14
- minValueRatio?: number;
15
- maxValueRatio?: number;
14
+ /**
15
+ * The pixel length of the value axis (height for vertical charts, width for
16
+ * horizontal charts). Used to calculate a minimum visible value that ensures
17
+ * zero-value bars are at least MIN_PIXEL_HEIGHT pixels tall along that axis.
18
+ */
19
+ valueAxisLength?: number;
16
20
  }
17
21
 
22
+ /**
23
+ * Minimum pixel size for near-zero bars (non-zero values that would render too small).
24
+ * Using 3px to be visible but not misleading - larger values might look like actual data.
25
+ */
26
+ const MIN_PIXEL_SIZE = 3;
27
+
28
+ /**
29
+ * Pixel size for zero-value bars (1px less than near-zero to be visually distinguishable).
30
+ */
31
+ const ZERO_PIXEL_SIZE = MIN_PIXEL_SIZE - 1;
32
+
18
33
  export const useZeroValueDisplay = (
19
34
  data: SeriesData[],
20
35
  options: UseZeroValueDisplayOptions = { enabled: false }
21
36
  ): SeriesData[] | EnhancedSeriesData[] => {
22
- const { enabled, minValueRatio = 0.6, maxValueRatio = 0.008 } = options;
37
+ const { enabled, valueAxisLength } = options;
23
38
 
24
39
  return useMemo( () => {
25
- if ( ! enabled ) return data;
26
-
27
- // Collect all non-zero, non-null values (both positive and negative)
28
- const nonZeroValues: number[] = [];
40
+ if ( ! enabled || ! valueAxisLength || valueAxisLength <= 0 ) return data;
29
41
 
42
+ // Find max absolute value
43
+ let maxAbsoluteValue = 0;
30
44
  for ( const series of data ) {
31
45
  for ( const point of series.data ) {
32
46
  if ( point.value !== null && point.value !== 0 ) {
33
- nonZeroValues.push( point.value );
47
+ maxAbsoluteValue = Math.max( maxAbsoluteValue, Math.abs( point.value ) );
34
48
  }
35
49
  }
36
50
  }
37
51
 
38
- if ( nonZeroValues.length === 0 ) return data;
39
-
40
- // Convert to absolute values to find the range
41
- const absoluteValues = nonZeroValues.map( Math.abs );
52
+ if ( maxAbsoluteValue === 0 ) return data;
42
53
 
43
- // Calculate min and max based on absolute values
44
- const minAbsoluteValue = Math.min( ...absoluteValues );
45
- const maxAbsoluteValue = Math.max( ...absoluteValues );
46
-
47
- // Calculate minimum visible value using absolute range
48
- const minVisibleValue = Math.min(
49
- minAbsoluteValue * minValueRatio,
50
- maxAbsoluteValue * maxValueRatio
54
+ // Calculate values that render as specific pixel sizes, clamped to maxAbsoluteValue
55
+ // to prevent visual distortion when valueAxisLength is very small
56
+ const minNonZeroValue = Math.min(
57
+ ( MIN_PIXEL_SIZE / valueAxisLength ) * maxAbsoluteValue,
58
+ maxAbsoluteValue
59
+ );
60
+ const zeroVisualValue = Math.min(
61
+ ( ZERO_PIXEL_SIZE / valueAxisLength ) * maxAbsoluteValue,
62
+ maxAbsoluteValue
51
63
  );
52
64
 
53
65
  return data.map( series => ( {
54
66
  ...series,
55
- data: series.data.map( ( point ): EnhancedDataPoint => {
67
+ data: series.data.map( ( point: DataPointDate ): EnhancedDataPoint => {
68
+ // Zero values get a smaller visual representation (2px)
56
69
  if ( point.value === 0 ) {
57
70
  return {
58
71
  ...point,
59
- visualValue: minVisibleValue,
72
+ visualValue: zeroVisualValue,
73
+ };
74
+ }
75
+
76
+ // Null values should remain untouched
77
+ if ( point.value === null ) {
78
+ return point;
79
+ }
80
+
81
+ const absValue = Math.abs( point.value );
82
+
83
+ // Near-zero values that would render below MIN_PIXEL_SIZE get boosted to 3px
84
+ // Preserve the sign for negative values
85
+ if ( absValue < minNonZeroValue ) {
86
+ return {
87
+ ...point,
88
+ visualValue: Math.sign( point.value ) * minNonZeroValue,
60
89
  };
61
90
  }
62
91
 
63
92
  return point;
64
93
  } ),
65
94
  } ) );
66
- }, [ data, enabled, minValueRatio, maxValueRatio ] );
95
+ }, [ data, enabled, valueAxisLength ] );
67
96
  };
package/src/index.ts CHANGED
@@ -37,7 +37,13 @@ export type {
37
37
  PieSemiCircleChartRenderTooltipParams,
38
38
  } from './charts/pie-semi-circle-chart';
39
39
  export type { GeoChartProps, GeoRegion, GeoResolution } from './charts/geo-chart';
40
- export type { LegendValueDisplay, BaseLegendItem } from './components/legend';
40
+ export type {
41
+ LegendValueDisplay,
42
+ BaseLegendItem,
43
+ LegendItemStyles,
44
+ LegendLabelStyles,
45
+ LegendShapeStyles,
46
+ } from './components/legend';
41
47
  export type { TrendIndicatorProps, TrendDirection } from './components/trend-indicator';
42
48
  export type { LineStyles, GridStyles, EventHandlerParams } from '@visx/xychart';
43
49
  export type {
@@ -987,10 +987,12 @@ describe( 'ChartContext', () => {
987
987
 
988
988
  const extendedTheme = {
989
989
  ...mockTheme,
990
- legendShapeStyles: [
991
- { fill: '#LEGEND1', stroke: '#BORDER1' },
992
- { fill: '#LEGEND2', strokeWidth: 3 },
993
- ],
990
+ legend: {
991
+ shapeStyles: [
992
+ { fill: '#LEGEND1', stroke: '#BORDER1' },
993
+ { fill: '#LEGEND2', strokeWidth: 3 },
994
+ ],
995
+ },
994
996
  };
995
997
 
996
998
  render(
@@ -1087,7 +1089,9 @@ describe( 'ChartContext', () => {
1087
1089
  },
1088
1090
  },
1089
1091
  },
1090
- legendShapeStyles: [ { fill: '#LEGEND1', stroke: '#BORDER1' } ],
1092
+ legend: {
1093
+ shapeStyles: [ { fill: '#LEGEND1', stroke: '#BORDER1' } ],
1094
+ },
1091
1095
  };
1092
1096
 
1093
1097
  render(
@@ -1234,7 +1238,9 @@ describe( 'ChartContext', () => {
1234
1238
  ...mockTheme,
1235
1239
  glyphs: [ mockGlyph ],
1236
1240
  seriesLineStyles: [ { strokeWidth: 2 } ],
1237
- legendShapeStyles: [ { fill: '#SHAPE1' } ],
1241
+ legend: {
1242
+ shapeStyles: [ { fill: '#SHAPE1' } ],
1243
+ },
1238
1244
  };
1239
1245
 
1240
1246
  render(
@@ -17,12 +17,14 @@ const defaultTheme: CompleteChartTheme = {
17
17
  gridColorDark: '',
18
18
  xTickLineStyles: { stroke: 'black' },
19
19
  xAxisLineStyles: { stroke: '#DCDCDE', strokeWidth: 1 },
20
- legendLabelStyles: {
21
- color: 'var(--jp-gray-80, #2c3338)',
20
+ legend: {
21
+ labelStyles: {
22
+ color: 'var(--jp-gray-80, #2c3338)',
23
+ },
24
+ containerStyles: {},
25
+ shapeStyles: [],
22
26
  },
23
- legendContainerStyles: {},
24
27
  seriesLineStyles: [],
25
- legendShapeStyles: [],
26
28
  glyphs: [],
27
29
  svgLabelSmall: { fill: 'var(--jp-gray-80, #2c3338)' },
28
30
  annotationStyles: {
package/src/types.ts CHANGED
@@ -210,14 +210,17 @@ export type ChartTheme = {
210
210
  xAxisLineStyles?: LineStyles;
211
211
  /** Styles for series lines */
212
212
  seriesLineStyles?: LineStyles[];
213
- /** Styles for legend shapes */
214
- legendShapeStyles?: Record< string, unknown >[];
215
213
  /** Array of render functions for glyphs */
216
214
  glyphs?: Array< < Datum extends object >( props: GlyphProps< Datum > ) => ReactNode >;
217
- /** Styles for legend labels */
218
- legendLabelStyles?: CSSProperties;
219
- /** Styles for legend container */
220
- legendContainerStyles?: CSSProperties;
215
+ /** Legend specific settings */
216
+ legend?: {
217
+ /** Styles for legend shapes */
218
+ shapeStyles?: Record< string, unknown >[];
219
+ /** Styles for legend labels */
220
+ labelStyles?: CSSProperties;
221
+ /** Styles for legend container */
222
+ containerStyles?: CSSProperties;
223
+ };
221
224
  /** Styles for small SVG text (eg. axis tick labels), passed through to the XYChart theme. */
222
225
  svgLabelSmall?: TextProps;
223
226
  annotationStyles?: AnnotationStyles;
@@ -287,6 +290,7 @@ export type CompleteChartTheme = Required< ChartTheme > & {
287
290
  lineChart: {
288
291
  lineStyles: Record< NonNullable< SeriesDataOptions[ 'type' ] >, LineStyles >;
289
292
  };
293
+ legend: Required< NonNullable< ChartTheme[ 'legend' ] > >;
290
294
  sparkline: Required< NonNullable< ChartTheme[ 'sparkline' ] > > & {
291
295
  margin: Required< NonNullable< ChartTheme[ 'sparkline' ] >[ 'margin' ] >;
292
296
  };
@@ -446,7 +450,7 @@ export type BaseChartProps< T = DataPoint | DataPointDate | LeaderboardEntry > =
446
450
  legendItemClassName?: string;
447
451
  /**
448
452
  * Enable interactive legend items that can toggle series visibility.
449
- * Supported for LineChart, PieChart, and PieSemiCircleChart.
453
+ * Supported for all chart types that render series.
450
454
  * Requires chartId and GlobalChartsProvider.
451
455
  * For pie charts, percentages are recalculated so visible segments total 100%.
452
456
  */
@@ -62,7 +62,7 @@ export function getItemShapeStyles(
62
62
  ): Record< string, unknown > {
63
63
  const seriesShapeStyles = series.options?.legendShapeStyle ?? {};
64
64
  const lineStyles = legendShape === 'line' ? getSeriesLineStyles( series, index, theme ) : {};
65
- const themeShapeStyles = theme.legendShapeStyles?.[ index ];
65
+ const themeShapeStyles = theme.legend?.shapeStyles?.[ index ];
66
66
 
67
67
  const itemShapeStyles = {
68
68
  ...seriesShapeStyles,
@@ -20,10 +20,12 @@ describe( 'Series styling utility functions', () => {
20
20
  },
21
21
  },
22
22
  seriesLineStyles: [ { strokeWidth: 2 }, { strokeWidth: 3, strokeDasharray: '2 2' } ],
23
- legendShapeStyles: [
24
- { fill: '#LEGEND1', stroke: '#BORDER1' },
25
- { fill: '#LEGEND2', strokeWidth: 3 },
26
- ],
23
+ legend: {
24
+ shapeStyles: [
25
+ { fill: '#LEGEND1', stroke: '#BORDER1' },
26
+ { fill: '#LEGEND2', strokeWidth: 3 },
27
+ ],
28
+ },
27
29
  };
28
30
 
29
31
  describe( 'getSeriesStroke', () => {
@@ -152,7 +154,7 @@ describe( 'Series styling utility functions', () => {
152
154
  };
153
155
 
154
156
  const result = getItemShapeStyles( comparisonSeries, 0, mockTheme as ChartTheme, 'rect' );
155
- expect( result ).toEqual( mockTheme.legendShapeStyles[ 0 ] );
157
+ expect( result ).toEqual( mockTheme.legend.shapeStyles[ 0 ] );
156
158
  } );
157
159
 
158
160
  it( 'merges custom shape styles with line styles for line shape', () => {
@@ -186,13 +188,13 @@ describe( 'Series styling utility functions', () => {
186
188
  mockTheme as ChartTheme,
187
189
  'rect'
188
190
  );
189
- expect( result ).toEqual( mockTheme.legendShapeStyles[ 1 ] );
191
+ expect( result ).toEqual( mockTheme.legend.shapeStyles[ 1 ] );
190
192
  } );
191
193
 
192
194
  it( 'returns empty object when no theme shape styles and no meaningful custom styles', () => {
193
195
  const themeWithoutShapeStyles = {
194
196
  ...mockTheme,
195
- legendShapeStyles: undefined,
197
+ legend: { shapeStyles: undefined },
196
198
  } as ChartTheme;
197
199
 
198
200
  const result = getItemShapeStyles( mockSeriesData, 0, themeWithoutShapeStyles, 'rect' );
@@ -254,7 +256,7 @@ describe( 'Series styling utility functions', () => {
254
256
 
255
257
  it( 'works without legendShape parameter', () => {
256
258
  const result = getItemShapeStyles( mockSeriesData, 0, mockTheme as ChartTheme );
257
- expect( result ).toEqual( mockTheme.legendShapeStyles[ 0 ] );
259
+ expect( result ).toEqual( mockTheme.legend.shapeStyles[ 0 ] );
258
260
  } );
259
261
 
260
262
  it( 'handles other legendShape string values like "circle"', () => {
@@ -265,7 +267,7 @@ describe( 'Series styling utility functions', () => {
265
267
 
266
268
  const result = getItemShapeStyles( comparisonSeries, 0, mockTheme as ChartTheme, 'circle' );
267
269
  // Should not include line styles for circle shape
268
- expect( result ).toEqual( mockTheme.legendShapeStyles[ 0 ] );
270
+ expect( result ).toEqual( mockTheme.legend.shapeStyles[ 0 ] );
269
271
  } );
270
272
 
271
273
  it( 'handles React component as legendShape parameter', () => {
@@ -282,7 +284,7 @@ describe( 'Series styling utility functions', () => {
282
284
  CustomShape
283
285
  );
284
286
  // Should not include line styles for component shape
285
- expect( result ).toEqual( mockTheme.legendShapeStyles[ 0 ] );
287
+ expect( result ).toEqual( mockTheme.legend.shapeStyles[ 0 ] );
286
288
  } );
287
289
 
288
290
  it( 'prioritizes series line styles over theme line styles when legendShape is line', () => {