@cdc/chart 4.26.5 → 4.26.6

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 (91) hide show
  1. package/CONFIG.md +12 -8
  2. package/dist/{cdcchart-DQ00cQCm.es.js → cdcchart-BWVIUcAF.es.js} +5 -55
  3. package/dist/cdcchart.js +34093 -32767
  4. package/examples/feature/heatmap/average-age-categorical.json +1 -1
  5. package/examples/feature/heatmap/calendar-heatmap.json +1 -1
  6. package/examples/feature/heatmap/sparse-aggregation.json +1 -1
  7. package/examples/private/bad-y-axis.json +543 -0
  8. package/examples/private/bar-chart-negative-value.json +455 -0
  9. package/package.json +3 -3
  10. package/src/CdcChart.tsx +5 -4
  11. package/src/CdcChartComponent.tsx +32 -9
  12. package/src/_stories/Chart.SeriesColorAssignments.stories.tsx +183 -0
  13. package/src/_stories/Chart.YAxisAutoMaxStrategy.stories.tsx +52 -0
  14. package/src/_stories/Chart.YAxisFilterDomain.stories.tsx +90 -0
  15. package/src/_stories/ChartAnnotation.smoke.stories.tsx +10 -0
  16. package/src/_stories/_mock/annotation_event-line_mock.json +1064 -0
  17. package/src/_stories/_mock/heatmap-average-age-categorical.json +1 -1
  18. package/src/_stories/_mock/heatmap.json +1 -1
  19. package/src/components/Annotations/components/AnnotationDraggable.styles.css +22 -1
  20. package/src/components/Annotations/components/AnnotationDraggable.tsx +219 -119
  21. package/src/components/Annotations/tests/AnnotationDraggable.test.tsx +198 -0
  22. package/src/components/Axis/LeftAxis.tsx +3 -0
  23. package/src/components/Axis/LeftAxisGridlines.tsx +8 -1
  24. package/src/components/Axis/RightAxis.tsx +3 -0
  25. package/src/components/Axis/tests/AxisTickValues.test.tsx +86 -0
  26. package/src/components/EditorPanel/EditorPanel.tsx +367 -136
  27. package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +101 -52
  28. package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.test.tsx +98 -0
  29. package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.tsx +10 -0
  30. package/src/components/EditorPanel/components/Panels/Panel.Visual.test.tsx +484 -0
  31. package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +3 -0
  32. package/src/components/EditorPanel/components/Panels/SeriesColorAssignments.tsx +262 -0
  33. package/src/components/EditorPanel/components/Panels/panelVisual.styles.css +32 -0
  34. package/src/components/EditorPanel/components/panels.scss +10 -0
  35. package/src/components/EditorPanel/editor-panel.scss +47 -0
  36. package/src/components/EditorPanel/helpers/getVisualizationTypeConfigUpdate.ts +6 -2
  37. package/src/components/EditorPanel/helpers/tests/getVisualizationTypeConfigUpdate.test.ts +20 -2
  38. package/src/components/EditorPanel/useEditorPermissions.test.tsx +256 -11
  39. package/src/components/EditorPanel/useEditorPermissions.ts +41 -5
  40. package/src/components/HeatMap/components/HeatMap.tsx +22 -17
  41. package/src/components/HeatMap/components/HeatMapGradientLegend.tsx +29 -15
  42. package/src/components/HeatMap/heatmap.css +15 -1
  43. package/src/components/HeatMap/tests/HeatMap.test.tsx +39 -7
  44. package/src/components/Legend/Legend.Component.tsx +2 -2
  45. package/src/components/Legend/helpers/createFormatLabels.test.ts +88 -0
  46. package/src/components/Legend/helpers/createFormatLabels.tsx +6 -1
  47. package/src/components/LineChart/helpers.test.ts +230 -1
  48. package/src/components/LineChart/helpers.ts +26 -13
  49. package/src/components/LineChart/index.tsx +21 -4
  50. package/src/components/LinearChart/tests/LinearChart.test.tsx +30 -0
  51. package/src/components/LinearChart.tsx +50 -15
  52. package/src/components/ScatterPlot/ScatterPlot.jsx +3 -3
  53. package/src/components/SmallMultiples/SmallMultipleTile.tsx +3 -0
  54. package/src/components/SmallMultiples/SmallMultiples.tsx +38 -9
  55. package/src/components/SmallMultiples/tests/SmallMultiples.test.tsx +170 -0
  56. package/src/data/initial-state.js +1 -0
  57. package/src/helpers/calculateLeftYAxisWidth.ts +9 -3
  58. package/src/helpers/colorAssignmentHelpers.ts +102 -0
  59. package/src/helpers/dataHelpers.ts +11 -1
  60. package/src/helpers/getAxisMaxOverride.ts +16 -0
  61. package/src/helpers/getCleanTopTickMax.ts +64 -0
  62. package/src/helpers/getColorScale.ts +18 -9
  63. package/src/helpers/getHasBoundarySuppression.ts +26 -0
  64. package/src/helpers/getMinMax.ts +24 -22
  65. package/src/helpers/getYAxisDomainData.ts +38 -0
  66. package/src/helpers/getYAxisFinalizationEligibility.ts +40 -0
  67. package/src/helpers/hasSpacedInlineLabel.ts +10 -0
  68. package/src/helpers/seriesColumnSettings.ts +5 -4
  69. package/src/helpers/smallMultiplesHelpers.ts +5 -0
  70. package/src/helpers/tests/calculateLeftYAxisWidth.test.ts +38 -0
  71. package/src/helpers/tests/dataHelpers.test.ts +135 -0
  72. package/src/helpers/tests/getAxisMaxOverride.test.ts +36 -0
  73. package/src/helpers/tests/getCleanTopTickMax.test.ts +165 -0
  74. package/src/helpers/tests/getColorScale.test.ts +197 -0
  75. package/src/helpers/tests/getMinMax.test.ts +211 -0
  76. package/src/helpers/tests/getYAxisDomainData.test.ts +119 -0
  77. package/src/helpers/tests/getYAxisFinalizationEligibility.test.ts +185 -0
  78. package/src/helpers/tests/{getYAxisAutoPaddingMode.test.ts → hasSpacedInlineLabel.test.ts} +17 -32
  79. package/src/helpers/tests/seriesColumnSettings.test.ts +16 -2
  80. package/src/helpers/tests/smallMultiplesHelpers.test.ts +99 -0
  81. package/src/hooks/tests/useRightAxis.test.tsx +127 -0
  82. package/src/hooks/tests/useScales.test.tsx +307 -29
  83. package/src/hooks/useRightAxis.ts +30 -13
  84. package/src/hooks/useScales.ts +45 -28
  85. package/src/hooks/useTooltip.tsx +3 -1
  86. package/src/test/CdcChart.dataTable.test.tsx +122 -0
  87. package/src/types/ChartConfig.ts +6 -1
  88. package/src/types/ChartContext.ts +3 -0
  89. package/src/helpers/getYAxisAutoPadding.ts +0 -55
  90. package/src/helpers/getYAxisAutoPaddingMode.ts +0 -17
  91. package/src/helpers/tests/getYAxisAutoPadding.test.ts +0 -37
@@ -0,0 +1,99 @@
1
+ import { scaleOrdinal } from '@visx/scale'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { createTileColorScale } from '../smallMultiplesHelpers'
4
+ import { ChartConfig } from '../../types/ChartConfig'
5
+
6
+ const buildConfig = (overrides: Partial<ChartConfig> = {}): ChartConfig =>
7
+ ({
8
+ visualizationType: 'Line',
9
+ visualizationSubType: 'regular',
10
+ general: {
11
+ palette: {
12
+ customColors: ['#111111', '#222222'],
13
+ colorAssignmentMode: 'ordered'
14
+ }
15
+ },
16
+ legend: {},
17
+ smallMultiples: {
18
+ mode: 'by-series',
19
+ colorMode: 'same'
20
+ },
21
+ series: [
22
+ { dataKey: 'series_a', name: 'Series A', type: 'Line' },
23
+ { dataKey: 'series_b', name: 'Series B', type: 'Line' }
24
+ ],
25
+ runtime: {
26
+ seriesKeys: ['series_a', 'series_b'],
27
+ seriesLabels: {
28
+ series_a: 'Series A',
29
+ series_b: 'Series B'
30
+ },
31
+ seriesLabelsAll: ['Series A', 'Series B']
32
+ },
33
+ ...overrides
34
+ } as ChartConfig)
35
+
36
+ describe('smallMultiplesHelpers createTileColorScale', () => {
37
+ it('uses the same first color for by-series tiles in same color mode by default', () => {
38
+ const colorScale = scaleOrdinal({
39
+ domain: ['Series A', 'Series B'],
40
+ range: ['#111111', '#222222'],
41
+ unknown: null
42
+ })
43
+
44
+ const tileScale = createTileColorScale(
45
+ { mode: 'by-series', seriesKey: 'series_b' },
46
+ buildConfig(),
47
+ colorScale,
48
+ 1,
49
+ 2
50
+ )
51
+
52
+ expect(tileScale('Series A')).toBe('#111111')
53
+ expect(tileScale('Series B')).toBe('#111111')
54
+ })
55
+
56
+ it('lets by-value assignments override same color mode for by-series tiles', () => {
57
+ const colorScale = scaleOrdinal({
58
+ domain: ['Series A', 'Series B'],
59
+ range: ['#aa0000', '#00bb00'],
60
+ unknown: null
61
+ })
62
+ const config = buildConfig({
63
+ general: {
64
+ palette: {
65
+ customColors: ['#111111', '#222222'],
66
+ colorAssignmentMode: 'by-value',
67
+ colorAssignments: [{ key: 'series_b', color: '#00bb00' }]
68
+ }
69
+ } as any
70
+ })
71
+
72
+ const tileScale = createTileColorScale({ mode: 'by-series', seriesKey: 'series_b' }, config, colorScale, 1, 2)
73
+
74
+ expect(tileScale('Series A')).toBe('#aa0000')
75
+ expect(tileScale('Series B')).toBe('#00bb00')
76
+ })
77
+
78
+ it('ignores stale by-value assignments when deciding whether to override same color mode', () => {
79
+ const colorScale = scaleOrdinal({
80
+ domain: ['Series A', 'Series B'],
81
+ range: ['#aa0000', '#00bb00'],
82
+ unknown: null
83
+ })
84
+ const config = buildConfig({
85
+ general: {
86
+ palette: {
87
+ customColors: ['#111111', '#222222'],
88
+ colorAssignmentMode: 'by-value',
89
+ colorAssignments: [{ key: 'removed_series', color: '#00bb00' }]
90
+ }
91
+ } as any
92
+ })
93
+
94
+ const tileScale = createTileColorScale({ mode: 'by-series', seriesKey: 'series_b' }, config, colorScale, 1, 2)
95
+
96
+ expect(tileScale('Series A')).toBe('#aa0000')
97
+ expect(tileScale('Series B')).toBe('#aa0000')
98
+ })
99
+ })
@@ -0,0 +1,127 @@
1
+ import { renderHook } from '@testing-library/react'
2
+ import { describe, expect, it } from 'vitest'
3
+ import useRightAxis from '../useRightAxis'
4
+ import { createMockConfig } from '../../components/LinearChart/tests/mockConfigContext'
5
+
6
+ const leftSeries = { dataKey: 'Cases', type: 'Bar', axis: 'Left', tooltip: true }
7
+ const rightSeries = { dataKey: 'Rate', type: 'Line', axis: 'Right', tooltip: true }
8
+
9
+ const createComboConfig = () =>
10
+ createMockConfig({
11
+ visualizationType: 'Combo',
12
+ orientation: 'vertical',
13
+ series: [leftSeries, rightSeries],
14
+ yAxis: {
15
+ ...createMockConfig().yAxis,
16
+ rightMax: undefined,
17
+ rightMin: undefined,
18
+ smallestRightAxisMax: undefined
19
+ },
20
+ runtime: {
21
+ ...createMockConfig().runtime,
22
+ series: [leftSeries, rightSeries],
23
+ seriesKeys: ['Cases', 'Rate'],
24
+ barSeriesKeys: ['Cases'],
25
+ lineSeriesKeys: ['Rate']
26
+ } as any
27
+ })
28
+
29
+ describe('useRightAxis', () => {
30
+ it('builds the right-axis scale from the supplied domain rows', () => {
31
+ const config = createComboConfig()
32
+ const renderedRows = [{ Cases: 5, Rate: 12 }]
33
+ const stableDomainRows = [
34
+ { Cases: 5, Rate: 12 },
35
+ { Cases: 10, Rate: 90 }
36
+ ]
37
+
38
+ const renderedResult = renderHook(() => useRightAxis({ config, yMax: 100, data: renderedRows }))
39
+ const stableResult = renderHook(() => useRightAxis({ config, yMax: 100, data: stableDomainRows }))
40
+
41
+ expect(renderedResult.result.current.yScaleRight.domain()).toEqual([0, 12])
42
+ expect(stableResult.result.current.yScaleRight.domain()).toEqual([0, 90])
43
+ })
44
+
45
+ it('uses clean-top-tick for the automatic right-axis max when shared auto max strategy is enabled', () => {
46
+ const config = {
47
+ ...createComboConfig(),
48
+ yAxis: {
49
+ ...createComboConfig().yAxis,
50
+ autoMaxStrategy: 'clean-top-tick' as const
51
+ }
52
+ }
53
+
54
+ const result = renderHook(() => useRightAxis({ config, yMax: 100, data: [{ Cases: 5, Rate: 101 }] }))
55
+
56
+ expect(result.result.current.yScaleRight.domain()).toEqual([0, 120])
57
+ expect(result.result.current.rightTickValues).toBeUndefined()
58
+ })
59
+
60
+ it('does not finalize right-axis ticks from the inherited mixed Combo min', () => {
61
+ const config = {
62
+ ...createComboConfig(),
63
+ yAxis: {
64
+ ...createComboConfig().yAxis,
65
+ autoMaxStrategy: 'clean-top-tick' as const
66
+ }
67
+ }
68
+
69
+ const result = renderHook(() => useRightAxis({ config, yMax: 100, data: [{ Cases: -100, Rate: 6 }] }))
70
+
71
+ expect(result.result.current.yScaleRight.domain()).toEqual([-100, 6])
72
+ expect(result.result.current.rightTickValues).toBeUndefined()
73
+ })
74
+
75
+ it('does not round the right-axis max when rightMax is explicit', () => {
76
+ const config = {
77
+ ...createComboConfig(),
78
+ yAxis: {
79
+ ...createComboConfig().yAxis,
80
+ autoMaxStrategy: 'clean-top-tick' as const,
81
+ rightMax: '100'
82
+ }
83
+ }
84
+
85
+ const result = renderHook(() => useRightAxis({ config, yMax: 100, data: [{ Cases: 5, Rate: 25 }] }))
86
+
87
+ expect(result.result.current.yScaleRight.domain()).toEqual([0, 100])
88
+ expect(result.result.current.rightTickValues).toBeUndefined()
89
+ })
90
+
91
+ it('uses clean-top-tick when rightMax is invalid', () => {
92
+ const createConfigWithRightMax = (rightMax: string) => ({
93
+ ...createComboConfig(),
94
+ yAxis: {
95
+ ...createComboConfig().yAxis,
96
+ autoMaxStrategy: 'clean-top-tick' as const,
97
+ rightMax
98
+ }
99
+ })
100
+
101
+ const lowerThanData = renderHook(() =>
102
+ useRightAxis({ config: createConfigWithRightMax('50'), yMax: 100, data: [{ Cases: 5, Rate: 101 }] })
103
+ )
104
+ const nonNumeric = renderHook(() =>
105
+ useRightAxis({ config: createConfigWithRightMax('not-a-number'), yMax: 100, data: [{ Cases: 5, Rate: 101 }] })
106
+ )
107
+
108
+ expect(lowerThanData.result.current.yScaleRight.domain()).toEqual([0, 120])
109
+ expect(lowerThanData.result.current.rightTickValues).toBeUndefined()
110
+ expect(nonNumeric.result.current.yScaleRight.domain()).toEqual([0, 120])
111
+ expect(nonNumeric.result.current.rightTickValues).toBeUndefined()
112
+ })
113
+
114
+ it('ignores a non-numeric rightMin value', () => {
115
+ const config = {
116
+ ...createComboConfig(),
117
+ yAxis: {
118
+ ...createComboConfig().yAxis,
119
+ rightMin: 'not-a-number'
120
+ }
121
+ }
122
+
123
+ const result = renderHook(() => useRightAxis({ config, yMax: 100, data: [{ Cases: 5, Rate: 25 }] }))
124
+
125
+ expect(result.result.current.yScaleRight.domain()).toEqual([0, 25])
126
+ })
127
+ })
@@ -12,16 +12,10 @@ const mockGetMinMax = vi.fn(() => ({
12
12
  rightMax: 0
13
13
  }))
14
14
 
15
- const mockGetYAxisAutoPadding = vi.fn(() => 10)
16
-
17
15
  vi.mock('../../helpers/getMinMax', () => ({
18
16
  default: (...args: any[]) => mockGetMinMax(...args)
19
17
  }))
20
18
 
21
- vi.mock('../../helpers/getYAxisAutoPadding', () => ({
22
- getYAxisAutoPadding: (...args: any[]) => mockGetYAxisAutoPadding(...args)
23
- }))
24
-
25
19
  const config = createMockConfig({
26
20
  runtime: {
27
21
  ...createMockConfig().runtime,
@@ -56,27 +50,54 @@ const baseProps = {
56
50
 
57
51
  describe('useScales', () => {
58
52
  beforeEach(() => {
59
- mockGetMinMax.mockClear()
60
- mockGetYAxisAutoPadding.mockClear()
61
- })
62
-
63
- it('allows repeated padding passes for inline-label mode', () => {
64
- renderHook(() => useScales({ ...baseProps, yAxisAutoPaddingMode: 'inline-label' }), {
65
- wrapper: createWrapper()
53
+ mockGetMinMax.mockReset()
54
+ mockGetMinMax.mockReturnValue({
55
+ min: 0,
56
+ max: 100,
57
+ leftMax: 100,
58
+ rightMax: 0
66
59
  })
67
-
68
- expect(mockGetYAxisAutoPadding).toHaveBeenCalledTimes(3)
69
60
  })
70
61
 
71
- it('runs a single padding pass for top-title mode', () => {
72
- renderHook(() => useScales({ ...baseProps, yAxisAutoPaddingMode: 'top-title' }), {
73
- wrapper: createWrapper()
62
+ it('uses spaced inline labels as an effective clean-top-tick trigger', () => {
63
+ mockGetMinMax.mockReturnValue({
64
+ min: 0,
65
+ max: 11.25,
66
+ leftMax: 0,
67
+ rightMax: 0
74
68
  })
75
69
 
76
- expect(mockGetYAxisAutoPadding).toHaveBeenCalledTimes(1)
70
+ const result = renderHook(
71
+ () =>
72
+ useScales({
73
+ ...baseProps,
74
+ maxValue: 11.25,
75
+ config: createMockConfig({
76
+ yAxis: {
77
+ ...createMockConfig().yAxis,
78
+ autoMaxStrategy: 'default',
79
+ inlineLabel: ' percent'
80
+ },
81
+ runtime: {
82
+ ...createMockConfig().runtime,
83
+ yAxis: {
84
+ ...createMockConfig().runtime.yAxis,
85
+ numTicks: 3
86
+ }
87
+ } as any
88
+ }),
89
+ hasSpacedInlineLabel: true
90
+ }),
91
+ {
92
+ wrapper: createWrapper()
93
+ }
94
+ )
95
+
96
+ expect(result.result.current.max).toBe(15)
97
+ expect(result.result.current.yTickValues).toEqual([0, 5, 10, 15])
77
98
  })
78
99
 
79
- it('ignores manual y-axis padding while calculating auto-padding', () => {
100
+ it('ignores manual y-axis padding while calculating inline-label headroom', () => {
80
101
  const configWithManualPadding = createMockConfig({
81
102
  yAxis: {
82
103
  ...createMockConfig().yAxis,
@@ -85,21 +106,278 @@ describe('useScales', () => {
85
106
  }
86
107
  })
87
108
 
88
- renderHook(() => useScales({ ...baseProps, config: configWithManualPadding, yAxisAutoPaddingMode: 'top-title' }), {
89
- wrapper: createWrapper()
90
- })
109
+ renderHook(
110
+ () => useScales({ ...baseProps, config: configWithManualPadding, hasSpacedInlineLabel: true }),
111
+ {
112
+ wrapper: createWrapper()
113
+ }
114
+ )
91
115
 
92
116
  expect(mockGetMinMax.mock.calls[0][0].config.yAxis).toMatchObject({
93
117
  enablePadding: false,
94
118
  scalePadding: 0
95
119
  })
96
- expect(mockGetYAxisAutoPadding.mock.calls[0][4].yAxis).toMatchObject({
97
- enablePadding: false,
98
- scalePadding: 0
120
+ expect(mockGetMinMax).toHaveBeenCalledTimes(1)
121
+ })
122
+
123
+ it('does not finalize default strategy domains for single-word inline labels', () => {
124
+ mockGetMinMax.mockReturnValue({
125
+ min: 0,
126
+ max: 11.25,
127
+ leftMax: 0,
128
+ rightMax: 0
129
+ })
130
+
131
+ const result = renderHook(
132
+ () =>
133
+ useScales({
134
+ ...baseProps,
135
+ config: createMockConfig({
136
+ yAxis: {
137
+ ...createMockConfig().yAxis,
138
+ autoMaxStrategy: 'default',
139
+ inlineLabel: '%'
140
+ },
141
+ runtime: {
142
+ ...createMockConfig().runtime,
143
+ yAxis: {
144
+ ...createMockConfig().runtime.yAxis,
145
+ numTicks: 3
146
+ }
147
+ } as any
148
+ }),
149
+ hasSpacedInlineLabel: false
150
+ }),
151
+ {
152
+ wrapper: createWrapper()
153
+ }
154
+ )
155
+
156
+ expect(result.result.current.max).toBe(11.25)
157
+ expect(result.result.current.yTickValues).toBeUndefined()
158
+ })
159
+
160
+ it('keeps shared small-multiple Y domains on the finalized top tick instead of percent padding', () => {
161
+ mockGetMinMax.mockReturnValue({
162
+ min: 0,
163
+ max: 11.25,
164
+ leftMax: 0,
165
+ rightMax: 0
166
+ })
167
+
168
+ const result = renderHook(
169
+ () =>
170
+ useScales({
171
+ ...baseProps,
172
+ config: createMockConfig({
173
+ smallMultiples: {
174
+ mode: 'by-column',
175
+ tileColumn: 'group',
176
+ independentYAxis: false
177
+ },
178
+ yAxis: {
179
+ ...createMockConfig().yAxis,
180
+ autoMaxStrategy: 'default',
181
+ inlineLabel: ' percent'
182
+ },
183
+ runtime: {
184
+ ...createMockConfig().runtime,
185
+ yAxis: {
186
+ ...createMockConfig().runtime.yAxis,
187
+ numTicks: 3
188
+ }
189
+ } as any
190
+ }),
191
+ maxValue: 11.25,
192
+ hasSpacedInlineLabel: true
193
+ }),
194
+ {
195
+ wrapper: createWrapper()
196
+ }
197
+ )
198
+
199
+ expect(result.result.current.max).toBe(15)
200
+ expect(result.result.current.yTickValues).toEqual([0, 5, 10, 15])
201
+ expect(result.result.current.yScale.ticks(3).at(-1)).toBe(15)
202
+ })
203
+
204
+ it('does not return explicit tick values when the primary Y-axis max is explicit', () => {
205
+ mockGetMinMax.mockReturnValue({
206
+ min: 0,
207
+ max: 20,
208
+ leftMax: 0,
209
+ rightMax: 0
210
+ })
211
+
212
+ const result = renderHook(
213
+ () =>
214
+ useScales({
215
+ ...baseProps,
216
+ maxValue: 11.25,
217
+ config: createMockConfig({
218
+ yAxis: {
219
+ ...createMockConfig().yAxis,
220
+ autoMaxStrategy: 'clean-top-tick',
221
+ inlineLabel: ' percent'
222
+ },
223
+ runtime: {
224
+ ...createMockConfig().runtime,
225
+ yAxis: {
226
+ ...createMockConfig().runtime.yAxis,
227
+ max: '20',
228
+ numTicks: 3
229
+ }
230
+ } as any
231
+ }),
232
+ hasSpacedInlineLabel: true
233
+ }),
234
+ {
235
+ wrapper: createWrapper()
236
+ }
237
+ )
238
+
239
+ expect(result.result.current.max).toBe(20)
240
+ expect(result.result.current.yTickValues).toBeUndefined()
241
+ })
242
+
243
+ it('does not use spaced inline labels as an effective clean-top-tick trigger for Combo charts', () => {
244
+ mockGetMinMax.mockReturnValue({
245
+ min: 0,
246
+ max: 100,
247
+ leftMax: 11.25,
248
+ rightMax: 0
99
249
  })
100
- expect(mockGetMinMax.mock.calls[1][0].config.yAxis).toMatchObject({
101
- enablePadding: true,
102
- scalePadding: 10
250
+
251
+ const result = renderHook(
252
+ () =>
253
+ useScales({
254
+ ...baseProps,
255
+ config: createMockConfig({
256
+ visualizationType: 'Combo',
257
+ yAxis: {
258
+ ...createMockConfig().yAxis,
259
+ autoMaxStrategy: 'default',
260
+ inlineLabel: ' percent'
261
+ },
262
+ runtime: {
263
+ ...createMockConfig().runtime,
264
+ yAxis: {
265
+ ...createMockConfig().runtime.yAxis,
266
+ numTicks: 3
267
+ }
268
+ } as any
269
+ }),
270
+ maxValue: 101,
271
+ hasSpacedInlineLabel: true
272
+ }),
273
+ {
274
+ wrapper: createWrapper()
275
+ }
276
+ )
277
+
278
+ expect(result.result.current.leftMax).toBe(11.25)
279
+ expect(result.result.current.yTickValues).toBeUndefined()
280
+ })
281
+
282
+ it('finalizes Combo left-axis ticks when clean-top-tick is explicitly enabled', () => {
283
+ mockGetMinMax.mockReturnValue({
284
+ min: 0,
285
+ max: 100,
286
+ leftMax: 12,
287
+ rightMax: 0
103
288
  })
289
+
290
+ const result = renderHook(
291
+ () =>
292
+ useScales({
293
+ ...baseProps,
294
+ config: createMockConfig({
295
+ visualizationType: 'Combo',
296
+ yAxis: {
297
+ ...createMockConfig().yAxis,
298
+ autoMaxStrategy: 'clean-top-tick'
299
+ },
300
+ runtime: {
301
+ ...createMockConfig().runtime,
302
+ yAxis: {
303
+ ...createMockConfig().runtime.yAxis,
304
+ numTicks: 3
305
+ }
306
+ } as any
307
+ }),
308
+ maxValue: 101,
309
+ hasSpacedInlineLabel: false
310
+ }),
311
+ {
312
+ wrapper: createWrapper()
313
+ }
314
+ )
315
+
316
+ expect(result.result.current.leftMax).toBe(15)
317
+ expect(result.result.current.yTickValues).toEqual([0, 5, 10, 15])
318
+ })
319
+
320
+ it('uses the selected Y-axis domain rows for min/max calculation', () => {
321
+ const renderedRows = [{ Date: '2024-01-01', value: 3 }]
322
+ const domainRows = [
323
+ { Date: '2024-01-01', value: 3 },
324
+ { Date: '2024-01-02', value: 12 }
325
+ ]
326
+
327
+ renderHook(
328
+ () =>
329
+ useScales({
330
+ ...baseProps,
331
+ data: renderedRows,
332
+ tableData: domainRows,
333
+ yAxisDomainData: domainRows
334
+ }),
335
+ {
336
+ wrapper: createWrapper()
337
+ }
338
+ )
339
+
340
+ expect(mockGetMinMax.mock.calls[0][0].data).toBe(domainRows)
341
+ expect(mockGetMinMax.mock.calls[0][0].tableData).toBe(domainRows)
342
+ })
343
+
344
+ it('passes hasBoundarySuppression through to getMinMax', () => {
345
+ renderHook(
346
+ () =>
347
+ useScales({
348
+ ...baseProps,
349
+ config: createMockConfig({ visualizationType: 'Line' }),
350
+ data: [{ Cases: 10 }],
351
+ tableData: [{ Cases: 10 }],
352
+ hasBoundarySuppression: true
353
+ }),
354
+ {
355
+ wrapper: createWrapper()
356
+ }
357
+ )
358
+
359
+ expect(mockGetMinMax.mock.calls[0][0].hasBoundarySuppression).toBe(true)
360
+ })
361
+
362
+ it('passes hasBoundarySuppression=false when not set', () => {
363
+ const renderedRows = [{ Cases: 50 }]
364
+ const stableDomainRows = [{ Cases: 50 }, { Cases: 60 }]
365
+
366
+ renderHook(
367
+ () =>
368
+ useScales({
369
+ ...baseProps,
370
+ config: createMockConfig({ visualizationType: 'Line' }),
371
+ data: renderedRows,
372
+ tableData: renderedRows,
373
+ yAxisDomainData: stableDomainRows
374
+ }),
375
+ {
376
+ wrapper: createWrapper()
377
+ }
378
+ )
379
+
380
+ expect(mockGetMinMax.mock.calls[0][0].data).toBe(stableDomainRows)
381
+ expect(mockGetMinMax.mock.calls[0][0].hasBoundarySuppression).toBe(false)
104
382
  })
105
383
  })
@@ -1,6 +1,8 @@
1
1
  import { scaleLinear } from '@visx/scale'
2
2
  import useReduceData from './useReduceData'
3
3
  import { TOP_PADDING } from './useScales'
4
+ import { getCleanTopTickMax } from '../helpers/getCleanTopTickMax'
5
+ import { getAxisMaxOverride } from '../helpers/getAxisMaxOverride'
4
6
 
5
7
  export default function useRightAxis({ config, yMax = 0, data = [] }) {
6
8
  const hasRightAxis = config.visualizationType === 'Combo' && config.orientation === 'vertical'
@@ -17,14 +19,36 @@ export default function useRightAxis({ config, yMax = 0, data = [] }) {
17
19
  return rightAxisData
18
20
  }
19
21
 
20
- let max = Math.max.apply(null, allRightAxisData(rightSeriesKeys))
22
+ const rightAxisValues = allRightAxisData(rightSeriesKeys).map(Number).filter(Number.isFinite)
23
+ const rawRightMax = rightAxisValues.length ? Math.max(...rightAxisValues) : 0
24
+ let max = rawRightMax
25
+ const { hasValidMax: hasValidExplicitRightMax, maxNumber: rightMaxNumber } = getAxisMaxOverride({
26
+ value: config.yAxis.rightMax,
27
+ minimumValidMax: max
28
+ })
29
+ const rightMinRaw = config.yAxis.rightMin
30
+ const rightMinNumber = Number(rightMinRaw)
31
+ const hasExplicitRightMin =
32
+ rightMinRaw !== undefined && rightMinRaw !== null && rightMinRaw !== '' && Number.isFinite(rightMinNumber)
33
+
34
+ if (hasValidExplicitRightMax && rightMaxNumber > max) {
35
+ max = rightMaxNumber
36
+ }
37
+
38
+ if (hasExplicitRightMin && rightMinNumber < minValue) {
39
+ minValue = rightMinNumber
40
+ }
41
+
42
+ // if there is a bar series & the right axis doesn't include a negative number, default to zero
43
+ const hasBarSeries = config.runtime?.barSeriesKeys?.length > 0
44
+ const hasLineSeries = config.runtime?.lineSeriesKeys?.length > 0
21
45
 
22
- if (config.yAxis.rightMax > max) {
23
- max = config.yAxis.rightMax
46
+ if ((hasBarSeries || hasLineSeries) && minValue > 0) {
47
+ minValue = 0
24
48
  }
25
49
 
26
- if (config.yAxis.rightMin < minValue) {
27
- minValue = config.yAxis.rightMin
50
+ if (config.yAxis.autoMaxStrategy === 'clean-top-tick' && !hasValidExplicitRightMax) {
51
+ max = getCleanTopTickMax(max)
28
52
  }
29
53
 
30
54
  // Enforce smallest right axis max so small-data charts don't show misleading decimal ticks
@@ -41,18 +65,11 @@ export default function useRightAxis({ config, yMax = 0, data = [] }) {
41
65
  if (smallestRightAxisMax !== null && max < smallestRightAxisMax) {
42
66
  max = smallestRightAxisMax
43
67
  }
44
- // if there is a bar series & the right axis doesn't include a negative number, default to zero
45
- const hasBarSeries = config.runtime?.barSeriesKeys?.length > 0
46
- const hasLineSeries = config.runtime?.lineSeriesKeys?.length > 0
47
-
48
- if ((hasBarSeries || hasLineSeries) && minValue > 0) {
49
- minValue = 0
50
- }
51
68
 
52
69
  const yScaleRight = scaleLinear({
53
70
  domain: [minValue, max],
54
71
  range: [yMax, TOP_PADDING]
55
72
  })
56
73
 
57
- return { yScaleRight, hasRightAxis }
74
+ return { yScaleRight, hasRightAxis, rightTickValues: undefined }
58
75
  }