@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,102 @@
1
+ import { type ChartConfig, type VisualizationType } from '../types/ChartConfig'
2
+
3
+ export type SeriesColorAssignment = {
4
+ key: string
5
+ color: string
6
+ }
7
+
8
+ export type SeriesColorAssignmentItem = {
9
+ key: string
10
+ label: string
11
+ }
12
+
13
+ export const SERIES_COLOR_ASSIGNMENT_TYPES: VisualizationType[] = ['Line', 'Bar', 'Combo', 'Area Chart', 'Scatter Plot']
14
+
15
+ export const hasDynamicCategorySeries = (config: ChartConfig): boolean =>
16
+ Array.isArray(config.series) && config.series.some(series => Boolean(series?.dynamicCategory))
17
+
18
+ export const isColorByCategoryBarChart = (config: ChartConfig): boolean =>
19
+ config.visualizationType === 'Bar' &&
20
+ config.visualizationSubType === 'regular' &&
21
+ Boolean(config.legend?.colorCode) &&
22
+ (config.series?.length || 0) === 1
23
+
24
+ export const supportsSeriesColorAssignments = (config: ChartConfig): boolean =>
25
+ SERIES_COLOR_ASSIGNMENT_TYPES.includes(config.visualizationType) &&
26
+ !hasDynamicCategorySeries(config) &&
27
+ !isColorByCategoryBarChart(config)
28
+
29
+ export const isSeriesColorAssignmentModeByValue = (config: ChartConfig): boolean =>
30
+ config.general?.palette?.colorAssignmentMode === 'by-value'
31
+
32
+ export const getSeriesColorAssignments = (config: ChartConfig): SeriesColorAssignment[] =>
33
+ Array.isArray(config.general?.palette?.colorAssignments) ? config.general.palette.colorAssignments : []
34
+
35
+ export const hasSeriesColorAssignmentOverrides = (config: ChartConfig): boolean => {
36
+ if (!supportsSeriesColorAssignments(config) || !isSeriesColorAssignmentModeByValue(config)) return false
37
+
38
+ const currentSeriesKeys = new Set(getSeriesColorAssignmentItems(config).map(({ key }) => key))
39
+
40
+ return getSeriesColorAssignments(config).some(assignment => {
41
+ if (!assignment?.key || !assignment?.color) return false
42
+
43
+ return currentSeriesKeys.has(assignment.key)
44
+ })
45
+ }
46
+
47
+ export const getSeriesDisplayLabel = (config: ChartConfig, key: string): string => {
48
+ const series = config.series?.find(item => item?.dataKey === key)
49
+ return String(config.runtime?.seriesLabels?.[key] || series?.name || (series as any)?.label || key)
50
+ }
51
+
52
+ export const getSeriesColorAssignmentItems = (config: ChartConfig): SeriesColorAssignmentItem[] => {
53
+ if (!supportsSeriesColorAssignments(config) || !Array.isArray(config.series)) return []
54
+
55
+ return config.series
56
+ .filter(series => series?.dataKey && !series.dynamicCategory)
57
+ .map(series => ({
58
+ key: series.dataKey,
59
+ label: getSeriesDisplayLabel(config, series.dataKey)
60
+ }))
61
+ }
62
+
63
+ export const getSeriesKeyByColorScaleLabel = (config: ChartConfig): Map<string, string> => {
64
+ const labelToKey = new Map<string, string>()
65
+ const runtimeKeys = Array.isArray(config.runtime?.seriesKeys) ? config.runtime.seriesKeys : []
66
+
67
+ runtimeKeys.forEach(key => {
68
+ labelToKey.set(String(key), key)
69
+ labelToKey.set(getSeriesDisplayLabel(config, key), key)
70
+ })
71
+
72
+ getSeriesColorAssignmentItems(config).forEach(({ key, label }) => {
73
+ labelToKey.set(String(key), key)
74
+ labelToKey.set(label, key)
75
+ })
76
+
77
+ return labelToKey
78
+ }
79
+
80
+ export const applySeriesColorAssignmentsToRange = (
81
+ config: ChartConfig,
82
+ domain: string[] = [],
83
+ range: string[] = []
84
+ ): string[] => {
85
+ if (!supportsSeriesColorAssignments(config) || !isSeriesColorAssignmentModeByValue(config)) return range
86
+
87
+ const assignmentMap = new Map(
88
+ getSeriesColorAssignments(config)
89
+ .filter(assignment => assignment?.key && assignment?.color)
90
+ .map(assignment => [assignment.key, assignment.color])
91
+ )
92
+
93
+ if (!assignmentMap.size || !range.length) return range
94
+
95
+ const labelToKey = getSeriesKeyByColorScaleLabel(config)
96
+
97
+ return domain.map((label, index) => {
98
+ const key = labelToKey.get(String(label))
99
+ const assignedColor = key ? assignmentMap.get(key) : undefined
100
+ return assignedColor || range[index % range.length]
101
+ })
102
+ }
@@ -1,6 +1,16 @@
1
1
  import { Series } from '@cdc/core/types/Series'
2
+ import isNumber from '@cdc/core/helpers/isNumber'
2
3
  import { ChartConfig } from '../types/ChartConfig'
3
4
 
5
+ // For legend suppression (when `legend.unified` is false), treat a series as having
6
+ // data only if the active (filtered) dataset contains at least one numeric, non-zero
7
+ // value for that series. Placeholder strings (e.g. "Not applicable", "<1.0", ""),
8
+ // nullish values, and zeros are treated as "no visible data" for legend purposes.
9
+ const hasGraphableValue = (raw: unknown): boolean => {
10
+ const sanitized = typeof raw === 'string' ? raw.replace(/[,$]/g, '').trim() : raw
11
+ return isNumber(sanitized) && Number(sanitized) !== 0
12
+ }
13
+
4
14
  export const getSeriesWithData = (config: ChartConfig) => {
5
15
  const { filters, data, runtime, legend } = config
6
16
  const { colorCode } = legend
@@ -21,7 +31,7 @@ export const getSeriesWithData = (config: ChartConfig) => {
21
31
  .filter(d => !s.colorCodeSeries || d[colorCode] === s.colorCodeSeries)
22
32
  .filter(d => {
23
33
  const key = s.dynamicCategory ? s.originalDataKey : s.dataKey
24
- return d[key] || d[key] === 0
34
+ return hasGraphableValue(d[key])
25
35
  })
26
36
  }))
27
37
  .filter(s => s.data.length)
@@ -0,0 +1,16 @@
1
+ type AxisMaxOverrideProps = {
2
+ minimumValidMax: number
3
+ value: unknown
4
+ }
5
+
6
+ export const getAxisMaxOverride = ({ minimumValidMax, value }: AxisMaxOverrideProps) => {
7
+ const maxNumber = Number(value)
8
+ const hasEnteredMax = value !== undefined && value !== null && value !== ''
9
+ const hasValidMax = hasEnteredMax && Number.isFinite(maxNumber) && maxNumber >= minimumValidMax
10
+
11
+ return {
12
+ hasEnteredMax,
13
+ hasValidMax,
14
+ maxNumber
15
+ }
16
+ }
@@ -0,0 +1,64 @@
1
+ const NICE_MANTISSAS = [1, 1.2, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10] as const
2
+
3
+ type FinalizeTopTickMaxProps = {
4
+ currentMax: number
5
+ rawMax: number
6
+ ticks: number[]
7
+ shouldUseCleanTopTick: boolean
8
+ headroomMode?: 'none' | 'inline-label'
9
+ }
10
+
11
+ const getTickComparisonEpsilon = (tickGap: number) => Math.abs(tickGap) * 1e-10
12
+
13
+ export const getCleanTopTickMax = (value: number) => {
14
+ if (!Number.isFinite(value) || value <= 0) return value
15
+
16
+ const powerOfTenStep = Math.pow(10, Math.floor(Math.log10(value)))
17
+ const normalizedValue = value / powerOfTenStep
18
+ const mantissa = NICE_MANTISSAS.find(candidate => normalizedValue <= candidate)
19
+
20
+ return (mantissa ?? 10) * powerOfTenStep
21
+ }
22
+
23
+ export const getCleanTopTickMaxCandidates = () => [...NICE_MANTISSAS]
24
+
25
+ export const getFinalTopTickMax = ({
26
+ currentMax,
27
+ rawMax,
28
+ ticks,
29
+ shouldUseCleanTopTick,
30
+ headroomMode = 'none'
31
+ }: FinalizeTopTickMaxProps) => {
32
+ if (!shouldUseCleanTopTick || !Number.isFinite(currentMax)) return { max: currentMax }
33
+ if (!Array.isArray(ticks) || ticks.length === 0) return { max: currentMax }
34
+
35
+ const sortedTicks = Array.from(new Set(ticks.filter(Number.isFinite))).sort((a, b) => a - b)
36
+ if (sortedTicks.length === 0) return { max: currentMax }
37
+
38
+ const tickGap = sortedTicks.slice(1).reduce((smallestGap, tick, index) => {
39
+ const gap = tick - sortedTicks[index]
40
+ return gap > 0 ? Math.min(smallestGap, gap) : smallestGap
41
+ }, Infinity)
42
+
43
+ if (!Number.isFinite(tickGap) || tickGap <= 0) return { max: currentMax }
44
+
45
+ const targetMax = Math.max(currentMax, Number.isFinite(rawMax) ? rawMax : currentMax)
46
+ const tickComparisonEpsilon = getTickComparisonEpsilon(tickGap)
47
+ let finalMax = sortedTicks[sortedTicks.length - 1]
48
+
49
+ while (finalMax + tickComparisonEpsilon < targetMax) {
50
+ finalMax = Number((finalMax + tickGap).toPrecision(15))
51
+ sortedTicks.push(finalMax)
52
+ }
53
+
54
+ if (
55
+ headroomMode === 'inline-label' &&
56
+ Number.isFinite(rawMax) &&
57
+ finalMax - rawMax <= tickGap * 0.25 + tickComparisonEpsilon
58
+ ) {
59
+ finalMax = Number((finalMax + tickGap).toPrecision(15))
60
+ sortedTicks.push(finalMax)
61
+ }
62
+
63
+ return { max: finalMax, tickValues: sortedTicks }
64
+ }
@@ -10,6 +10,7 @@ import {
10
10
  divergentColorDistribution,
11
11
  colorblindColorDistribution
12
12
  } from '@cdc/core/helpers/palettes/colorDistributions'
13
+ import { applySeriesColorAssignmentsToRange } from './colorAssignmentHelpers'
13
14
 
14
15
  export const getColorScale = (config: ChartConfig): ((value: string) => string) => {
15
16
  const configPalette = ['Paired Bar', 'Deviation Bar'].includes(config.visualizationType)
@@ -32,18 +33,24 @@ export const getColorScale = (config: ChartConfig): ((value: string) => string)
32
33
  // Migrate old palette name if needed
33
34
  const migratedPaletteName = configPalette ? configPalette : getFallbackColorPalette(config)
34
35
 
36
+ const domain = config.runtime.seriesLabelsAll
37
+
38
+ const customColorsOrdered = config.general?.palette?.customColorsOrdered
39
+
35
40
  // Check for customColorsOrdered first (direct 1-to-1 mapping, no distribution needed)
36
- if (config.general?.palette?.customColorsOrdered && Array.isArray(config.general.palette.customColorsOrdered)) {
37
- const customColorsOrdered = config.general.palette.customColorsOrdered
41
+ if (Array.isArray(customColorsOrdered) && customColorsOrdered.length > 0) {
42
+ const range = applySeriesColorAssignmentsToRange(config, domain, customColorsOrdered)
38
43
  return scaleOrdinal({
39
- domain: config.runtime.seriesLabelsAll,
40
- range: customColorsOrdered,
44
+ domain,
45
+ range,
41
46
  unknown: null
42
47
  })
43
48
  }
44
49
 
50
+ const customColors = config.general?.palette?.customColors
51
+ const isUsingCustomColors = Array.isArray(customColors) && customColors.length > 0
45
52
  let palette =
46
- config.general?.palette?.customColors ||
53
+ (isUsingCustomColors ? customColors : undefined) ||
47
54
  palettesSource[migratePaletteWithMap(migratedPaletteName, paletteMigrationMap, false)] ||
48
55
  palettesSource[configPalette]
49
56
 
@@ -58,8 +65,8 @@ export const getColorScale = (config: ChartConfig): ((value: string) => string)
58
65
  // Apply enhanced color distribution (same logic as pie charts)
59
66
  const paletteVersion = getColorPaletteVersion(config)
60
67
 
61
- // Skip enhanced distribution if not v2, too many keys, or wrong palette length
62
- if (paletteVersion !== 2 || numberOfKeys > 9 || palette.length !== 9) {
68
+ // Skip enhanced distribution if using custom colors, not v2, too many keys, or wrong palette length
69
+ if (isUsingCustomColors || paletteVersion !== 2 || numberOfKeys > 9 || palette.length !== 9) {
63
70
  // Use existing logic for v1 palettes and other cases
64
71
  while (numberOfKeys > palette.length) {
65
72
  palette = palette.concat(palette)
@@ -90,9 +97,11 @@ export const getColorScale = (config: ChartConfig): ((value: string) => string)
90
97
  }
91
98
  }
92
99
 
100
+ const range = applySeriesColorAssignmentsToRange(config, domain, palette)
101
+
93
102
  return scaleOrdinal({
94
- domain: config.runtime.seriesLabelsAll,
95
- range: palette,
103
+ domain,
104
+ range,
96
105
  unknown: null
97
106
  })
98
107
  }
@@ -0,0 +1,26 @@
1
+ import _ from 'lodash'
2
+ import { ChartConfig } from '../types/ChartConfig'
3
+
4
+ type GetHasBoundarySuppressionProps = {
5
+ rows?: Object[]
6
+ config: ChartConfig
7
+ }
8
+
9
+ export const getHasBoundarySuppression = ({ rows, config }: GetHasBoundarySuppressionProps) => {
10
+ if (!Array.isArray(rows) || rows.length === 0) return false
11
+ const suppressionRules = config.preliminaryData?.filter(({ type, style }) => type === 'suppression' && style) || []
12
+ if (!suppressionRules.length) return false
13
+
14
+ const boundaryRows = [rows[0], rows[rows.length - 1]].filter(Boolean)
15
+
16
+ return boundaryRows.some(item =>
17
+ suppressionRules.some(({ column, value }) => {
18
+ const values = _.values(_.pick(item, config.runtime?.seriesKeys))
19
+ const dynamicCategory = config.series[0]?.dynamicCategory
20
+ const match = column ? item[column] === value : values.includes(value)
21
+ const dynamic = dynamicCategory && (item[dynamicCategory] === column || !column)
22
+
23
+ return Boolean(match || dynamic)
24
+ })
25
+ )
26
+ }
@@ -1,5 +1,6 @@
1
1
  import { ChartConfig } from '../types/ChartConfig'
2
- import _ from 'lodash'
2
+ import { getCleanTopTickMax } from './getCleanTopTickMax'
3
+ import { getAxisMaxOverride } from './getAxisMaxOverride'
3
4
 
4
5
  type GetMinMaxProps = {
5
6
  /** config - standard chart config */
@@ -18,6 +19,8 @@ type GetMinMaxProps = {
18
19
  isAllLine: boolean
19
20
  /** convertLineToBarGraph - whether line charts should be rendered as bar graphs */
20
21
  convertLineToBarGraph?: boolean
22
+ /** whether selected-domain boundary rows include suppression tokens */
23
+ hasBoundarySuppression?: boolean
21
24
  }
22
25
 
23
26
  const getMinMax = ({
@@ -28,7 +31,8 @@ const getMinMax = ({
28
31
  data,
29
32
  isAllLine,
30
33
  tableData,
31
- convertLineToBarGraph
34
+ convertLineToBarGraph,
35
+ hasBoundarySuppression = false
32
36
  }: GetMinMaxProps) => {
33
37
  let min = 0
34
38
  let max = 0
@@ -45,14 +49,18 @@ const getMinMax = ({
45
49
  const { max: enteredMaxValue, min: enteredMinValue } = config.runtime.yAxis
46
50
  const paddingAddedToAxis = config.yAxis.enablePadding ? 1 + config.yAxis.scalePadding / 100 : 1
47
51
  const isLogarithmicAxis = config.yAxis.type === 'logarithmic'
48
- // do validation bafore applying t0 charts
49
- const isMaxValid = existPositiveValue ? Number(enteredMaxValue) >= maxValue : Number(enteredMaxValue) >= 0
52
+ // Validate before applying chart-specific domain rules.
53
+ const { hasValidMax: hasValidExplicitLeftMax, maxNumber: enteredMaxNumber } = getAxisMaxOverride({
54
+ value: enteredMaxValue,
55
+ minimumValidMax: existPositiveValue ? maxValue : 0
56
+ })
57
+ const shouldCleanAutoLeftMax = config.yAxis.autoMaxStrategy === 'clean-top-tick' && !hasValidExplicitLeftMax
50
58
  const isMinValid = isLogarithmicAxis
51
59
  ? Number(enteredMinValue) >= 0
52
60
  : (Number(enteredMinValue) <= 0 && minValue >= 0) || (Number(enteredMinValue) <= minValue && minValue < 0)
53
61
 
54
62
  min = enteredMinValue && isMinValid ? Number(enteredMinValue) : minValue
55
- max = enteredMaxValue && isMaxValid ? Number(enteredMaxValue) : Number.MIN_VALUE
63
+ max = hasValidExplicitLeftMax ? enteredMaxNumber : Number.MIN_VALUE
56
64
  const { lower, upper } = config?.confidenceKeys || {}
57
65
 
58
66
  const useBrushFullRange = config.xAxis.brushActive && !config.xAxis.brushDynamicYAxis
@@ -135,8 +143,8 @@ const getMinMax = ({
135
143
  leftMax = findMaxFromSeriesKeys(dataForMinMax, leftAxisSeriesItems, leftMax, 'left')
136
144
  rightMax = findMaxFromSeriesKeys(dataForMinMax, rightAxisSeriesItems, rightMax, 'right')
137
145
 
138
- if (leftMax < Number(enteredMaxValue)) {
139
- leftMax = Number(enteredMaxValue)
146
+ if (hasValidExplicitLeftMax && leftMax < enteredMaxNumber) {
147
+ leftMax = enteredMaxNumber
140
148
  }
141
149
  } catch (e) {
142
150
  console.error(e.message)
@@ -180,23 +188,9 @@ const getMinMax = ({
180
188
  const numEnteredMin = Number(enteredMinValue)
181
189
  const isMinValid = isLogarithmicAxis ? numEnteredMin >= 0 && numEnteredMin < minValue : numEnteredMin < minValue
182
190
 
183
- const suppressedMinValue = tableData?.some((item, i, arr) =>
184
- config.preliminaryData?.some(({ type, style, column, value }) => {
185
- if (type !== 'suppression' || !style) return false
186
-
187
- const values = _.values(_.pick(item, config.runtime?.seriesKeys))
188
- const dynamicCategory = config.series[0].dynamicCategory
189
-
190
- const match = column ? item[column] === value : values.includes(value)
191
- const dynamic = dynamicCategory && (item[dynamicCategory] === column || !column)
192
-
193
- return (match || dynamic) && (i === 0 || i === arr.length - 1)
194
- })
195
- )
196
-
197
191
  const isCategorical = config.yAxis.type === 'categorical'
198
192
 
199
- min = enteredMinValue !== '' && isMinValid ? numEnteredMin : suppressedMinValue ? 0 : isCategorical ? 0 : minValue
193
+ min = enteredMinValue !== '' && isMinValid ? numEnteredMin : hasBoundarySuppression ? 0 : isCategorical ? 0 : minValue
200
194
  }
201
195
 
202
196
  //If data value max wasn't provided, calculate it
@@ -205,6 +199,14 @@ const getMinMax = ({
205
199
  max = existPositiveValue ? maxValue : 0
206
200
  }
207
201
 
202
+ if (shouldCleanAutoLeftMax) {
203
+ if (config.visualizationType === 'Combo') {
204
+ leftMax = getCleanTopTickMax(leftMax)
205
+ } else {
206
+ max = getCleanTopTickMax(max)
207
+ }
208
+ }
209
+
208
210
  //Adds Y Axis data padding if applicable
209
211
  if (config.runtime.yAxis.paddingPercent) {
210
212
  let paddingValue = (max - min) * config.runtime.yAxis.paddingPercent
@@ -0,0 +1,38 @@
1
+ import { ChartConfig } from '../types/ChartConfig'
2
+ import { AxisFilterDomainBehavior } from '@cdc/core/types/Axis'
3
+
4
+ const hasRows = (data?: Object[]) => Array.isArray(data) && data.length > 0
5
+
6
+ export const getYAxisFilterDomainBehavior = (config: ChartConfig): AxisFilterDomainBehavior => {
7
+ return config.yAxis?.filterDomainBehavior === 'stable' ? 'stable' : 'dynamic'
8
+ }
9
+
10
+ export const getYAxisDomainData = ({
11
+ config,
12
+ data,
13
+ tableData,
14
+ fullEligibleDomainData
15
+ }: {
16
+ config: ChartConfig
17
+ data: Object[]
18
+ tableData?: Object[]
19
+ fullEligibleDomainData?: Object[]
20
+ }): Object[] => {
21
+ const brushActive = Boolean(config.xAxis?.brushActive)
22
+ const brushDynamicYAxis = Boolean(config.xAxis?.brushDynamicYAxis)
23
+
24
+ if (brushActive && brushDynamicYAxis) {
25
+ return data
26
+ }
27
+
28
+ const behavior = getYAxisFilterDomainBehavior(config)
29
+ if (behavior === 'stable' && hasRows(fullEligibleDomainData)) {
30
+ return fullEligibleDomainData
31
+ }
32
+
33
+ if (brushActive && !brushDynamicYAxis && hasRows(tableData)) {
34
+ return tableData
35
+ }
36
+
37
+ return data
38
+ }
@@ -0,0 +1,40 @@
1
+ import { type ChartConfig } from '../types/ChartConfig'
2
+
3
+ type YAxisFinalizationEligibilityProps = {
4
+ config: ChartConfig
5
+ hasSpacedInlineLabel: boolean
6
+ hasValidExplicitLeftMax: boolean
7
+ isHorizontal: boolean
8
+ }
9
+
10
+ const chartUsesStandardPrimaryYScale = (config: ChartConfig) => {
11
+ return !['Box Plot', 'Bump Chart', 'Forest Plot'].includes(config.visualizationType)
12
+ }
13
+
14
+ const isLinearOrLegacyYAxis = (config: ChartConfig) => {
15
+ const yAxisType = config.yAxis.type
16
+
17
+ return yAxisType === undefined || yAxisType === '' || yAxisType === 'linear'
18
+ }
19
+
20
+ export const getYAxisFinalizationEligibility = ({
21
+ config,
22
+ hasSpacedInlineLabel,
23
+ hasValidExplicitLeftMax,
24
+ isHorizontal
25
+ }: YAxisFinalizationEligibilityProps) => {
26
+ const hasStandardLinearPrimaryYAxis =
27
+ !isHorizontal && isLinearOrLegacyYAxis(config) && chartUsesStandardPrimaryYScale(config)
28
+ const supportsInlineLabelHeadroom = hasStandardLinearPrimaryYAxis && config.visualizationType !== 'Combo'
29
+ const shouldUseInlineLabelHeadroom = hasSpacedInlineLabel && supportsInlineLabelHeadroom
30
+ const shouldUseCleanTopTick =
31
+ !hasValidExplicitLeftMax && (config.yAxis.autoMaxStrategy === 'clean-top-tick' || shouldUseInlineLabelHeadroom)
32
+
33
+ return {
34
+ hasStandardLinearPrimaryYAxis,
35
+ supportsInlineLabelHeadroom,
36
+ shouldUseCleanTopTick,
37
+ shouldUseInlineLabelHeadroom,
38
+ shouldFinalizePrimaryYAxis: shouldUseCleanTopTick && hasStandardLinearPrimaryYAxis
39
+ }
40
+ }
@@ -0,0 +1,10 @@
1
+ import { ChartConfig } from '../types/ChartConfig'
2
+
3
+ export const hasSpacedInlineLabel = (config: ChartConfig): boolean => {
4
+ const inlineLabel = config.yAxis?.inlineLabel
5
+ if (inlineLabel && inlineLabel.includes(' ')) {
6
+ return true
7
+ }
8
+
9
+ return false
10
+ }
@@ -11,6 +11,7 @@ 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 !== ''
14
15
 
15
16
  export const createDefaultSeriesColumnConfig = (columnName: string): Column => ({
16
17
  name: columnName,
@@ -94,12 +95,12 @@ export const getSeriesColumnFormattingParams = (columnConfig?: Partial<Column>):
94
95
 
95
96
  const formattingParams: ColumnFormattingParams = {}
96
97
 
97
- if (hasOwn(columnConfig, 'prefix')) {
98
- formattingParams.addColPrefix = columnConfig.prefix ?? ''
98
+ if (hasOwn(columnConfig, 'prefix') && isNonEmptyString(columnConfig.prefix)) {
99
+ formattingParams.addColPrefix = columnConfig.prefix
99
100
  }
100
101
 
101
- if (hasOwn(columnConfig, 'suffix')) {
102
- formattingParams.addColSuffix = columnConfig.suffix ?? ''
102
+ if (hasOwn(columnConfig, 'suffix') && isNonEmptyString(columnConfig.suffix)) {
103
+ formattingParams.addColSuffix = columnConfig.suffix
103
104
  }
104
105
 
105
106
  if (hasOwn(columnConfig, 'roundToPlace')) {
@@ -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
+ })