@cdc/chart 4.26.5 → 4.26.7

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 (128) hide show
  1. package/CONFIG.md +20 -14
  2. package/dist/{cdcchart-DQ00cQCm.es.js → cdcchart-BWVIUcAF.es.js} +5 -55
  3. package/dist/cdcchart.js +36554 -34320
  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/examples/private/flueview.json +22051 -0
  10. package/package.json +3 -11
  11. package/src/CdcChart.tsx +7 -6
  12. package/src/CdcChartComponent.tsx +71 -18
  13. package/src/_stories/Chart.PieLabels.stories.tsx +47 -0
  14. package/src/_stories/Chart.SeriesColorAssignments.stories.tsx +183 -0
  15. package/src/_stories/Chart.YAxisAutoMaxStrategy.stories.tsx +52 -0
  16. package/src/_stories/Chart.YAxisFilterDomain.stories.tsx +90 -0
  17. package/src/_stories/Chart.smoke.stories.tsx +14 -1
  18. package/src/_stories/ChartAnnotation.smoke.stories.tsx +10 -0
  19. package/src/_stories/ChartPrefixSuffix.smoke.stories.tsx +28 -1
  20. package/src/_stories/_mock/annotation_event-line_mock.json +1064 -0
  21. package/src/_stories/_mock/heatmap-average-age-categorical.json +1 -1
  22. package/src/_stories/_mock/heatmap.json +1 -1
  23. package/src/components/Annotations/components/AnnotationDraggable.styles.css +22 -1
  24. package/src/components/Annotations/components/AnnotationDraggable.tsx +219 -119
  25. package/src/components/Annotations/tests/AnnotationDraggable.test.tsx +198 -0
  26. package/src/components/Axis/LeftAxis.tsx +3 -0
  27. package/src/components/Axis/LeftAxisGridlines.tsx +8 -1
  28. package/src/components/Axis/RightAxis.tsx +3 -0
  29. package/src/components/Axis/tests/AxisTickValues.test.tsx +86 -0
  30. package/src/components/BarChart/components/BarChart.Horizontal.tsx +1 -2
  31. package/src/components/BarChart/components/BarChart.StackedHorizontal.tsx +1 -1
  32. package/src/components/BarChart/components/BarChart.StackedVertical.tsx +1 -1
  33. package/src/components/BarChart/components/BarChart.Vertical.tsx +1 -2
  34. package/src/components/BarChart/helpers/getBarData.ts +28 -28
  35. package/src/components/BarChart/helpers/getBarHeights.ts +2 -2
  36. package/src/components/BarChart/helpers/tests/useBarChart.test.tsx +98 -0
  37. package/src/components/BarChart/helpers/useBarChart.ts +4 -5
  38. package/src/components/BoxPlot/BoxPlot.Horizontal.tsx +7 -6
  39. package/src/components/BoxPlot/BoxPlot.Vertical.tsx +9 -7
  40. package/src/components/BoxPlot/helpers/index.ts +4 -3
  41. package/src/components/Brush/BrushSelector.tsx +1 -1
  42. package/src/components/EditorPanel/EditorPanel.tsx +457 -165
  43. package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +101 -52
  44. package/src/components/EditorPanel/components/Panels/Panel.General.tsx +16 -7
  45. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +3 -3
  46. package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +19 -4
  47. package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.test.tsx +98 -0
  48. package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.tsx +10 -0
  49. package/src/components/EditorPanel/components/Panels/Panel.Visual.test.tsx +484 -0
  50. package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +3 -0
  51. package/src/components/EditorPanel/components/Panels/SeriesColorAssignments.tsx +262 -0
  52. package/src/components/EditorPanel/components/Panels/panelVisual.styles.css +32 -0
  53. package/src/components/EditorPanel/components/panels.scss +10 -0
  54. package/src/components/EditorPanel/editor-panel.scss +47 -0
  55. package/src/components/EditorPanel/helpers/getVisualizationTypeConfigUpdate.ts +6 -2
  56. package/src/components/EditorPanel/helpers/tests/getVisualizationTypeConfigUpdate.test.ts +20 -2
  57. package/src/components/EditorPanel/helpers/updateFieldRankByValue.ts +3 -3
  58. package/src/components/EditorPanel/useEditorPermissions.test.tsx +288 -11
  59. package/src/components/EditorPanel/useEditorPermissions.ts +46 -6
  60. package/src/components/Forecasting/Forecasting.tsx +1 -1
  61. package/src/components/HeatMap/components/HeatMap.tsx +22 -17
  62. package/src/components/HeatMap/components/HeatMapGradientLegend.tsx +29 -15
  63. package/src/components/HeatMap/heatmap.css +15 -1
  64. package/src/components/HeatMap/tests/HeatMap.test.tsx +39 -7
  65. package/src/components/Legend/Legend.Component.tsx +56 -61
  66. package/src/components/Legend/Legend.Suppression.tsx +3 -3
  67. package/src/components/Legend/LegendGroup/LegendGroup.tsx +0 -1
  68. package/src/components/Legend/helpers/createFormatLabels.test.ts +88 -0
  69. package/src/components/Legend/helpers/createFormatLabels.tsx +6 -1
  70. package/src/components/LineChart/helpers.test.ts +230 -1
  71. package/src/components/LineChart/helpers.ts +25 -14
  72. package/src/components/LineChart/index.tsx +23 -7
  73. package/src/components/LinearChart/tests/LinearChart.test.tsx +30 -0
  74. package/src/components/LinearChart.tsx +51 -15
  75. package/src/components/PieChart/PieChart.tsx +117 -10
  76. package/src/components/PieChart/helpers/labelPlacement.test.ts +88 -0
  77. package/src/components/PieChart/helpers/labelPlacement.ts +95 -0
  78. package/src/components/ScatterPlot/ScatterPlot.jsx +5 -13
  79. package/src/components/SmallMultiples/SmallMultipleTile.tsx +3 -0
  80. package/src/components/SmallMultiples/SmallMultiples.tsx +38 -9
  81. package/src/components/SmallMultiples/tests/SmallMultiples.test.tsx +170 -0
  82. package/src/data/initial-state.js +3 -0
  83. package/src/helpers/calculateLeftYAxisWidth.ts +9 -3
  84. package/src/helpers/categoryOrder.ts +60 -0
  85. package/src/helpers/colorAssignmentHelpers.ts +102 -0
  86. package/src/helpers/dataHelpers.ts +11 -1
  87. package/src/helpers/getAxisMaxOverride.ts +16 -0
  88. package/src/helpers/getBoxPlotConfig.ts +19 -12
  89. package/src/helpers/getCleanTopTickMax.ts +64 -0
  90. package/src/helpers/getColorScale.ts +18 -9
  91. package/src/helpers/getComboChartConfig.ts +0 -1
  92. package/src/helpers/getExcludedData.ts +0 -1
  93. package/src/helpers/getHasBoundarySuppression.ts +27 -0
  94. package/src/helpers/getMinMax.ts +25 -22
  95. package/src/helpers/getNewRuntime.ts +0 -2
  96. package/src/helpers/getPiePercent.ts +3 -3
  97. package/src/helpers/getYAxisDomainData.ts +38 -0
  98. package/src/helpers/getYAxisFinalizationEligibility.ts +40 -0
  99. package/src/helpers/hasSpacedInlineLabel.ts +10 -0
  100. package/src/helpers/isConvertLineToBarGraph.ts +2 -2
  101. package/src/helpers/seriesColumnSettings.ts +26 -7
  102. package/src/helpers/sizeHelpers.ts +0 -2
  103. package/src/helpers/smallMultiplesHelpers.ts +5 -0
  104. package/src/helpers/tests/calculateLeftYAxisWidth.test.ts +38 -0
  105. package/src/helpers/tests/categoryOrder.test.ts +57 -0
  106. package/src/helpers/tests/dataHelpers.test.ts +135 -0
  107. package/src/helpers/tests/getAxisMaxOverride.test.ts +36 -0
  108. package/src/helpers/tests/getCleanTopTickMax.test.ts +165 -0
  109. package/src/helpers/tests/getColorScale.test.ts +197 -0
  110. package/src/helpers/tests/getMinMax.test.ts +211 -0
  111. package/src/helpers/tests/getYAxisDomainData.test.ts +119 -0
  112. package/src/helpers/tests/getYAxisFinalizationEligibility.test.ts +185 -0
  113. package/src/helpers/tests/{getYAxisAutoPaddingMode.test.ts → hasSpacedInlineLabel.test.ts} +17 -32
  114. package/src/helpers/tests/seriesColumnSettings.test.ts +76 -2
  115. package/src/helpers/tests/smallMultiplesHelpers.test.ts +99 -0
  116. package/src/hooks/tests/useRightAxis.test.tsx +268 -0
  117. package/src/hooks/tests/useScales.test.tsx +302 -27
  118. package/src/hooks/tests/useTooltip.test.tsx +132 -0
  119. package/src/hooks/useRightAxis.ts +35 -25
  120. package/src/hooks/useScales.ts +45 -28
  121. package/src/hooks/useTooltip.tsx +17 -11
  122. package/src/store/chart.reducer.ts +0 -1
  123. package/src/test/CdcChart.dataTable.test.tsx +216 -0
  124. package/src/types/ChartConfig.ts +6 -1
  125. package/src/types/ChartContext.ts +3 -0
  126. package/src/helpers/getYAxisAutoPadding.ts +0 -55
  127. package/src/helpers/getYAxisAutoPaddingMode.ts +0 -17
  128. package/src/helpers/tests/getYAxisAutoPadding.test.ts +0 -37
@@ -11,13 +11,23 @@ type ColumnFormattingParams = {
11
11
  }
12
12
 
13
13
  const hasOwn = (object: object, key: keyof Column) => Object.prototype.hasOwnProperty.call(object, key)
14
+ const isNonEmptyString = (value: unknown) => typeof value === 'string' && value !== ''
15
+ const getNumericRoundToPlace = (value: Column['roundToPlace']): number | undefined => {
16
+ if (value === undefined || value === null || (typeof value === 'string' && value.trim() === '')) {
17
+ return undefined
18
+ }
19
+
20
+ const numericValue = Number(value)
21
+ if (!Number.isFinite(numericValue)) return undefined
22
+
23
+ return Math.max(0, Math.min(20, Math.round(numericValue)))
24
+ }
14
25
 
15
26
  export const createDefaultSeriesColumnConfig = (columnName: string): Column => ({
16
27
  name: columnName,
17
28
  label: columnName,
18
29
  prefix: '',
19
30
  suffix: '',
20
- roundToPlace: 0,
21
31
  commas: false,
22
32
  dataTable: true,
23
33
  order: undefined,
@@ -69,12 +79,18 @@ export const upsertSeriesColumnConfig = (
69
79
  ): ChartColumns => {
70
80
  const existingEntry = findColumnConfigByName(columns, seriesKey)
71
81
  const columnKey = existingEntry?.columnKey || seriesKey
72
- const nextColumnConfig = {
82
+ const nextColumnConfig: Partial<Column> = {
73
83
  ...(existingEntry?.columnConfig || {}),
74
84
  ...updates,
75
85
  name: seriesKey
76
86
  }
77
87
 
88
+ Object.entries(updates).forEach(([key, value]) => {
89
+ if (value === undefined) {
90
+ delete nextColumnConfig[key as keyof Column]
91
+ }
92
+ })
93
+
78
94
  if (
79
95
  nextColumnConfig.label === undefined &&
80
96
  !hasOwn(existingEntry?.columnConfig || {}, 'label') &&
@@ -94,16 +110,19 @@ export const getSeriesColumnFormattingParams = (columnConfig?: Partial<Column>):
94
110
 
95
111
  const formattingParams: ColumnFormattingParams = {}
96
112
 
97
- if (hasOwn(columnConfig, 'prefix')) {
98
- formattingParams.addColPrefix = columnConfig.prefix ?? ''
113
+ if (hasOwn(columnConfig, 'prefix') && isNonEmptyString(columnConfig.prefix)) {
114
+ formattingParams.addColPrefix = columnConfig.prefix
99
115
  }
100
116
 
101
- if (hasOwn(columnConfig, 'suffix')) {
102
- formattingParams.addColSuffix = columnConfig.suffix ?? ''
117
+ if (hasOwn(columnConfig, 'suffix') && isNonEmptyString(columnConfig.suffix)) {
118
+ formattingParams.addColSuffix = columnConfig.suffix
103
119
  }
104
120
 
105
121
  if (hasOwn(columnConfig, 'roundToPlace')) {
106
- formattingParams.addColRoundTo = columnConfig.roundToPlace ?? 0
122
+ const numericRoundToPlace = getNumericRoundToPlace(columnConfig.roundToPlace)
123
+ if (numericRoundToPlace !== undefined) {
124
+ formattingParams.addColRoundTo = numericRoundToPlace
125
+ }
107
126
  }
108
127
 
109
128
  if (hasOwn(columnConfig, 'commas')) {
@@ -1,5 +1,3 @@
1
- import { clamp } from 'lodash'
2
-
3
1
  import { isMobileHeightViewport } from '@cdc/core/helpers/viewports'
4
2
  import { ChartConfig, ViewportSize } from '../types/ChartConfig'
5
3
  import { EDITOR_WIDTH } from '@cdc/core/helpers/constants'
@@ -1,6 +1,7 @@
1
1
  import { scaleOrdinal } from '@visx/scale'
2
2
  import { getColorScale } from './getColorScale'
3
3
  import { ColorScale } from '../types/ChartContext'
4
+ import { hasSeriesColorAssignmentOverrides } from './colorAssignmentHelpers'
4
5
 
5
6
  /**
6
7
  * Get filtered data for a specific tile based on its mode
@@ -206,6 +207,10 @@ export const getFullColorPalette = (config, numberOfTiles) => {
206
207
  export const createTileColorScale = (tileItem, config, originalColorScale, tileIndex, numberOfTiles) => {
207
208
  const colorMode = config.smallMultiples?.colorMode || 'same'
208
209
 
210
+ if (tileItem.mode === 'by-series' && hasSeriesColorAssignmentOverrides(config)) {
211
+ return originalColorScale
212
+ }
213
+
209
214
  if (colorMode === 'same') {
210
215
  if (tileItem.mode === 'by-series') {
211
216
  // Same mode + by-series: All tiles use the same color
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { createMockConfig } from '../../components/LinearChart/tests/mockConfigContext'
3
+
4
+ vi.mock('@cdc/core/helpers/getTextWidth', () => ({
5
+ getTextWidth: (text: string) => text.length
6
+ }))
7
+
8
+ import { calculateLeftYAxisWidth } from '../calculateLeftYAxisWidth'
9
+
10
+ describe('calculateLeftYAxisWidth', () => {
11
+ it('prefers supplied tick values when measuring labels', () => {
12
+ const handleLeftTickFormatting = vi.fn(tick => String(tick))
13
+ const yScale = {
14
+ ticks: vi.fn(() => [0, 5000, 10000]),
15
+ domain: vi.fn(() => [0, 10000])
16
+ }
17
+
18
+ calculateLeftYAxisWidth({
19
+ config: createMockConfig({
20
+ yAxis: {
21
+ ...createMockConfig().yAxis,
22
+ titlePlacement: 'side'
23
+ }
24
+ }),
25
+ data: [],
26
+ yScale,
27
+ numTicks: 3,
28
+ tickValues: [0, 5, 10],
29
+ parentWidth: 400,
30
+ tickLabelFont: 'normal 16px sans-serif',
31
+ axisLabelFontSize: 16,
32
+ handleLeftTickFormatting
33
+ })
34
+
35
+ expect(yScale.ticks).not.toHaveBeenCalled()
36
+ expect(handleLeftTickFormatting.mock.calls.map(call => call[0])).toEqual([0, 5, 10])
37
+ })
38
+ })
@@ -0,0 +1,57 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { getOrderedCategoryValues, isCustomCategoryOrderActive, sortByCategoryOrder } from '../categoryOrder'
3
+
4
+ describe('categoryOrder helpers', () => {
5
+ const data = [
6
+ { category: 'A', value: 1 },
7
+ { category: 'D', value: 4 },
8
+ { category: 'C', value: 3 },
9
+ { category: 'D', value: 5 },
10
+ { category: 'L', value: 12 }
11
+ ]
12
+
13
+ it('detects custom category order only for categorical axes with configured values', () => {
14
+ expect(
15
+ isCustomCategoryOrderActive({
16
+ xAxis: { type: 'categorical', dataKey: 'category', categoryOrderType: 'custom', categoryOrder: ['C'] } as any
17
+ })
18
+ ).toBe(true)
19
+
20
+ expect(
21
+ isCustomCategoryOrderActive({
22
+ xAxis: { type: 'date', dataKey: 'category', categoryOrderType: 'custom', categoryOrder: ['C'] } as any
23
+ })
24
+ ).toBe(false)
25
+ })
26
+
27
+ it('sorts rows by configured category order and keeps duplicate rows stable', () => {
28
+ const sorted = sortByCategoryOrder(data, {
29
+ xAxis: {
30
+ type: 'categorical',
31
+ dataKey: 'category',
32
+ categoryOrderType: 'custom',
33
+ categoryOrder: ['A', 'C', 'D', 'L']
34
+ } as any
35
+ })
36
+
37
+ expect(sorted.map(row => row.category)).toEqual(['A', 'C', 'D', 'D', 'L'])
38
+ expect(sorted.map(row => row.value)).toEqual([1, 3, 4, 5, 12])
39
+ })
40
+
41
+ it('appends values missing from custom order in source order', () => {
42
+ const sorted = sortByCategoryOrder(data, {
43
+ xAxis: {
44
+ type: 'categorical',
45
+ dataKey: 'category',
46
+ categoryOrderType: 'custom',
47
+ categoryOrder: ['C']
48
+ } as any
49
+ })
50
+
51
+ expect(sorted.map(row => row.category)).toEqual(['C', 'A', 'D', 'D', 'L'])
52
+ })
53
+
54
+ it('merges configured category order with newly discovered values', () => {
55
+ expect(getOrderedCategoryValues(data, 'category', ['C', 'A'])).toEqual(['C', 'A', 'D', 'L'])
56
+ })
57
+ })
@@ -0,0 +1,135 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { getSeriesWithData } from '../dataHelpers'
3
+ import { ChartConfig } from '../../types/ChartConfig'
4
+
5
+ type SeriesInput = {
6
+ dataKey: string
7
+ name?: string
8
+ type?: string
9
+ dynamicCategory?: string
10
+ originalDataKey?: string
11
+ }
12
+
13
+ const buildConfig = (
14
+ data: Record<string, unknown>[],
15
+ series: SeriesInput[],
16
+ overrides: Partial<ChartConfig> = {}
17
+ ): ChartConfig =>
18
+ ({
19
+ data,
20
+ filters: [],
21
+ legend: { colorCode: '' },
22
+ runtime: { series },
23
+ ...overrides
24
+ } as unknown as ChartConfig)
25
+
26
+ describe('getSeriesWithData', () => {
27
+ it('includes a series that has at least one numeric non-zero value', () => {
28
+ const config = buildConfig(
29
+ [{ percent_mild: '3.8' }, { percent_mild: '0.0' }],
30
+ [{ dataKey: 'percent_mild', name: 'Mild', type: 'Bar' }]
31
+ )
32
+ expect(getSeriesWithData(config)).toEqual(['Mild'])
33
+ })
34
+
35
+ it('excludes a series whose values are all zero (0 and "0.0")', () => {
36
+ const config = buildConfig(
37
+ [{ percent_fever_notemp: '0.0' }, { percent_fever_notemp: 0 }],
38
+ [{ dataKey: 'percent_fever_notemp', name: 'Reported fever', type: 'Bar' }]
39
+ )
40
+ expect(getSeriesWithData(config)).toEqual([])
41
+ })
42
+
43
+ it('keeps a series that has a single non-zero value among zeros', () => {
44
+ const config = buildConfig(
45
+ [{ percent_mild: '0.0' }, { percent_mild: '0.0' }, { percent_mild: '1.2' }],
46
+ [{ dataKey: 'percent_mild', name: 'Mild', type: 'Bar' }]
47
+ )
48
+ expect(getSeriesWithData(config)).toEqual(['Mild'])
49
+ })
50
+
51
+ it('treats non-numeric placeholder strings as no data', () => {
52
+ const config = buildConfig(
53
+ [{ percent_fever_notemp: 'Not applicable' }, { percent_fever_notemp: '<1.0' }, { percent_fever_notemp: '' }],
54
+ [{ dataKey: 'percent_fever_notemp', name: 'Reported fever', type: 'Bar' }]
55
+ )
56
+ expect(getSeriesWithData(config)).toEqual([])
57
+ })
58
+
59
+ it('treats null and undefined values as no data', () => {
60
+ const config = buildConfig(
61
+ [{ percent_fever_notemp: null }, { percent_fever_notemp: undefined }],
62
+ [{ dataKey: 'percent_fever_notemp', name: 'Reported fever', type: 'Bar' }]
63
+ )
64
+ expect(getSeriesWithData(config)).toEqual([])
65
+ })
66
+
67
+ it('counts comma- and dollar-formatted numeric strings as data', () => {
68
+ const config = buildConfig(
69
+ [{ revenue: '$1,000' }, { revenue: '0.0' }],
70
+ [{ dataKey: 'revenue', name: 'Revenue', type: 'Bar' }]
71
+ )
72
+ expect(getSeriesWithData(config)).toEqual(['Revenue'])
73
+ })
74
+
75
+ it('counts negative non-zero values as data', () => {
76
+ const config = buildConfig([{ delta: '-5' }, { delta: '0' }], [{ dataKey: 'delta', name: 'Delta', type: 'Bar' }])
77
+ expect(getSeriesWithData(config)).toEqual(['Delta'])
78
+ })
79
+
80
+ it('applies active filters before evaluating series data', () => {
81
+ const config = buildConfig(
82
+ [
83
+ { reaction: 'Headache', percent_fever_notemp: '0.0', percent_mild: '3.8' },
84
+ { reaction: 'Fever or feverish', percent_fever_notemp: '0.9', percent_mild: '0.0' }
85
+ ],
86
+ [
87
+ { dataKey: 'percent_mild', name: 'Mild', type: 'Bar' },
88
+ { dataKey: 'percent_fever_notemp', name: 'Reported fever', type: 'Bar' }
89
+ ],
90
+ { filters: [{ columnName: 'reaction', active: 'Headache' }] as ChartConfig['filters'] }
91
+ )
92
+ // Under "Headache" the fever column is all zeros, so it drops out of the legend.
93
+ expect(getSeriesWithData(config)).toEqual(['Mild'])
94
+ })
95
+
96
+ it('uses originalDataKey for dynamicCategory series', () => {
97
+ const config = buildConfig(
98
+ [
99
+ { category: 'a', value: '4.2', label: 'a-value' },
100
+ { category: 'b', value: '0.0', label: 'b-value' }
101
+ ],
102
+ [
103
+ {
104
+ dataKey: 'a',
105
+ name: 'A',
106
+ type: 'Bar',
107
+ dynamicCategory: 'category',
108
+ originalDataKey: 'value'
109
+ },
110
+ {
111
+ dataKey: 'b',
112
+ name: 'B',
113
+ type: 'Bar',
114
+ dynamicCategory: 'category',
115
+ originalDataKey: 'value'
116
+ }
117
+ ]
118
+ )
119
+ // Series "a" has a non-zero value; series "b" is zero only.
120
+ expect(getSeriesWithData(config)).toEqual(['A'])
121
+ })
122
+
123
+ it('splits color-coded Bar series by colorCode values', () => {
124
+ const config = buildConfig(
125
+ [
126
+ { group: 'North', amount: '5' },
127
+ { group: 'South', amount: '0.0' }
128
+ ],
129
+ [{ dataKey: 'amount', name: 'Amount', type: 'Bar' }],
130
+ { legend: { colorCode: 'group' } as ChartConfig['legend'] }
131
+ )
132
+ // Only the "North" rows carry a non-zero amount.
133
+ expect(getSeriesWithData(config)).toEqual(['North'])
134
+ })
135
+ })
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { getAxisMaxOverride } from '../getAxisMaxOverride'
3
+
4
+ describe('getAxisMaxOverride', () => {
5
+ it('accepts finite entered values that meet the required max', () => {
6
+ expect(getAxisMaxOverride({ value: '25', minimumValidMax: 20 })).toEqual({
7
+ hasEnteredMax: true,
8
+ hasValidMax: true,
9
+ maxNumber: 25
10
+ })
11
+ })
12
+
13
+ it('rejects missing or empty entered values', () => {
14
+ expect(getAxisMaxOverride({ value: undefined, minimumValidMax: 0 }).hasValidMax).toBe(false)
15
+ expect(getAxisMaxOverride({ value: null, minimumValidMax: 0 }).hasValidMax).toBe(false)
16
+ expect(getAxisMaxOverride({ value: '', minimumValidMax: 0 }).hasValidMax).toBe(false)
17
+ })
18
+
19
+ it('rejects non-finite entered values', () => {
20
+ expect(getAxisMaxOverride({ value: 'not-a-number', minimumValidMax: 0 }).hasValidMax).toBe(false)
21
+ expect(getAxisMaxOverride({ value: Infinity, minimumValidMax: 0 }).hasValidMax).toBe(false)
22
+ })
23
+
24
+ it('rejects entered values below the required max', () => {
25
+ expect(getAxisMaxOverride({ value: '19', minimumValidMax: 20 })).toMatchObject({
26
+ hasEnteredMax: true,
27
+ hasValidMax: false,
28
+ maxNumber: 19
29
+ })
30
+ })
31
+
32
+ it('supports the non-positive primary-axis rule by passing zero as the required max', () => {
33
+ expect(getAxisMaxOverride({ value: '0', minimumValidMax: 0 }).hasValidMax).toBe(true)
34
+ expect(getAxisMaxOverride({ value: '-1', minimumValidMax: 0 }).hasValidMax).toBe(false)
35
+ })
36
+ })
@@ -0,0 +1,165 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { getCleanTopTickMax, getCleanTopTickMaxCandidates, getFinalTopTickMax } from '../getCleanTopTickMax'
3
+
4
+ describe('getCleanTopTickMax', () => {
5
+ it('rounds automatic max values with the clean-top-tick mantissa ladder', () => {
6
+ expect(getCleanTopTickMaxCandidates()).toEqual([1, 1.2, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10])
7
+
8
+ expect([
9
+ [0.0049, getCleanTopTickMax(0.0049)],
10
+ [0.011, getCleanTopTickMax(0.011)],
11
+ [0.012, getCleanTopTickMax(0.012)],
12
+ [1.1, getCleanTopTickMax(1.1)],
13
+ [1.2, getCleanTopTickMax(1.2)],
14
+ [3, getCleanTopTickMax(3)],
15
+ [5, getCleanTopTickMax(5)],
16
+ [5.1, getCleanTopTickMax(5.1)],
17
+ [7, getCleanTopTickMax(7)],
18
+ [7.2, getCleanTopTickMax(7.2)],
19
+ [8, getCleanTopTickMax(8)],
20
+ [9, getCleanTopTickMax(9)],
21
+ [25, getCleanTopTickMax(25)],
22
+ [89, getCleanTopTickMax(89)],
23
+ [101, getCleanTopTickMax(101)],
24
+ [1434, getCleanTopTickMax(1434)],
25
+ [1470, getCleanTopTickMax(1470)],
26
+ [1490, getCleanTopTickMax(1490)],
27
+ [1675, getCleanTopTickMax(1675)],
28
+ [2340, getCleanTopTickMax(2340)],
29
+ [5678, getCleanTopTickMax(5678)],
30
+ [8999, getCleanTopTickMax(8999)]
31
+ ]).toEqual([
32
+ [0.0049, 0.005],
33
+ [0.011, 0.012],
34
+ [0.012, 0.012],
35
+ [1.1, 1.2],
36
+ [1.2, 1.2],
37
+ [3, 3],
38
+ [5, 5],
39
+ [5.1, 6],
40
+ [7, 8],
41
+ [7.2, 8],
42
+ [8, 8],
43
+ [9, 10],
44
+ [25, 25],
45
+ [89, 100],
46
+ [101, 120],
47
+ [1434, 1500],
48
+ [1470, 1500],
49
+ [1490, 1500],
50
+ [1675, 2000],
51
+ [2340, 2500],
52
+ [5678, 6000],
53
+ [8999, 10000]
54
+ ])
55
+ })
56
+
57
+ it('leaves non-finite values unchanged', () => {
58
+ expect(getCleanTopTickMax(Infinity)).toBe(Infinity)
59
+ expect(Number.isNaN(getCleanTopTickMax(NaN))).toBe(true)
60
+ })
61
+
62
+ it('leaves non-positive values unchanged', () => {
63
+ expect(getCleanTopTickMax(0)).toBe(0)
64
+ expect(getCleanTopTickMax(-1)).toBe(-1)
65
+ })
66
+ })
67
+
68
+ describe('getFinalTopTickMax', () => {
69
+ it('advances clean max values to the next generated tick interval when the top tick is below the max', () => {
70
+ expect(
71
+ getFinalTopTickMax({
72
+ currentMax: 12,
73
+ rawMax: 12,
74
+ ticks: [0, 5, 10],
75
+ shouldUseCleanTopTick: true
76
+ })
77
+ ).toEqual({ max: 15, tickValues: [0, 5, 10, 15] })
78
+
79
+ expect(
80
+ getFinalTopTickMax({
81
+ currentMax: 25,
82
+ rawMax: 25,
83
+ ticks: [0, 10, 20],
84
+ shouldUseCleanTopTick: true
85
+ })
86
+ ).toEqual({ max: 30, tickValues: [0, 10, 20, 30] })
87
+ })
88
+
89
+ it('keeps a clean max when the generated top tick already reaches it', () => {
90
+ expect(
91
+ getFinalTopTickMax({
92
+ currentMax: 12,
93
+ rawMax: 12,
94
+ ticks: [0, 2, 4, 6, 8, 10, 12],
95
+ shouldUseCleanTopTick: true
96
+ })
97
+ ).toEqual({ max: 12, tickValues: [0, 2, 4, 6, 8, 10, 12] })
98
+ })
99
+
100
+ it('does not advance for floating-point dust above the generated top tick', () => {
101
+ expect(
102
+ getFinalTopTickMax({
103
+ currentMax: 0.6000000000000001,
104
+ rawMax: 0.6,
105
+ ticks: [0, 0.2, 0.4, 0.6],
106
+ shouldUseCleanTopTick: true
107
+ })
108
+ ).toEqual({ max: 0.6, tickValues: [0, 0.2, 0.4, 0.6] })
109
+ })
110
+
111
+ it('adds one more interval for spaced inline labels when the raw max is too close to the top tick', () => {
112
+ expect(
113
+ getFinalTopTickMax({
114
+ currentMax: 14.4,
115
+ rawMax: 14.4,
116
+ ticks: [0, 5, 10, 15],
117
+ shouldUseCleanTopTick: true,
118
+ headroomMode: 'inline-label'
119
+ })
120
+ ).toEqual({ max: 20, tickValues: [0, 5, 10, 15, 20] })
121
+
122
+ expect(
123
+ getFinalTopTickMax({
124
+ currentMax: 11.25,
125
+ rawMax: 11.25,
126
+ ticks: [0, 5, 10, 15],
127
+ shouldUseCleanTopTick: true,
128
+ headroomMode: 'inline-label'
129
+ })
130
+ ).toEqual({ max: 15, tickValues: [0, 5, 10, 15] })
131
+ })
132
+
133
+ it('finalizes tiny spaced inline-label domains and includes the top tick', () => {
134
+ expect(
135
+ getFinalTopTickMax({
136
+ currentMax: 0.015,
137
+ rawMax: 0.015,
138
+ ticks: [0, 0.005, 0.01, 0.015],
139
+ shouldUseCleanTopTick: true,
140
+ headroomMode: 'inline-label'
141
+ })
142
+ ).toEqual({ max: 0.02, tickValues: [0, 0.005, 0.01, 0.015, 0.02] })
143
+
144
+ expect(
145
+ getFinalTopTickMax({
146
+ currentMax: 0.019,
147
+ rawMax: 0.019,
148
+ ticks: [0, 0.005, 0.01, 0.015],
149
+ shouldUseCleanTopTick: true,
150
+ headroomMode: 'inline-label'
151
+ })
152
+ ).toEqual({ max: 0.025, tickValues: [0, 0.005, 0.01, 0.015, 0.02, 0.025] })
153
+ })
154
+
155
+ it('does not change the max when effective clean-top-tick is disabled', () => {
156
+ expect(
157
+ getFinalTopTickMax({
158
+ currentMax: 12,
159
+ rawMax: 12,
160
+ ticks: [0, 5, 10],
161
+ shouldUseCleanTopTick: false
162
+ })
163
+ ).toEqual({ max: 12 })
164
+ })
165
+ })