@cdc/chart 4.26.4 → 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 (141) hide show
  1. package/CONFIG.md +353 -29
  2. package/LICENSE +201 -0
  3. package/dist/{cdcchart-DQ00cQCm.es.js → cdcchart-BWVIUcAF.es.js} +5 -55
  4. package/dist/cdcchart.js +37885 -35117
  5. package/examples/feature/heatmap/average-age-categorical.json +313 -0
  6. package/examples/feature/heatmap/calendar-heatmap.json +134 -0
  7. package/examples/feature/heatmap/cell-values-bottom-axis.json +181 -0
  8. package/examples/feature/heatmap/sparse-aggregation.json +158 -0
  9. package/examples/private/bad-y-axis.json +543 -0
  10. package/examples/private/bar-chart-negative-value.json +455 -0
  11. package/examples/private/bump-chart.json +1116 -0
  12. package/examples/private/test-123.json +624 -0
  13. package/package.json +4 -3
  14. package/src/CdcChart.tsx +5 -4
  15. package/src/CdcChartComponent.tsx +166 -62
  16. package/src/_stories/Chart.AltText.stories.tsx +136 -0
  17. package/src/_stories/Chart.HeatMap.stories.tsx +187 -0
  18. package/src/_stories/Chart.Sankey.smoke.stories.tsx +62 -0
  19. package/src/_stories/Chart.SeriesColorAssignments.stories.tsx +183 -0
  20. package/src/_stories/Chart.SmallMultiples.smoke.stories.tsx +29 -0
  21. package/src/_stories/Chart.YAxisAutoMaxStrategy.stories.tsx +52 -0
  22. package/src/_stories/Chart.YAxisFilterDomain.stories.tsx +90 -0
  23. package/src/_stories/ChartAnnotation.smoke.stories.tsx +10 -0
  24. package/src/_stories/ChartAxisTitles.smoke.stories.tsx +13 -0
  25. package/src/_stories/ChartBar.Editor.stories.tsx +63 -0
  26. package/src/_stories/ChartBrush.Interaction.stories.tsx +112 -0
  27. package/src/_stories/ChartBrush.smoke.stories.tsx +13 -4
  28. package/src/_stories/_mock/alt_text_metadata.json +38 -0
  29. package/src/_stories/_mock/annotation_event-line_mock.json +1064 -0
  30. package/src/_stories/_mock/heatmap-average-age-categorical.json +315 -0
  31. package/src/_stories/_mock/heatmap.json +156 -0
  32. package/src/_stories/_mock/reported_pertussis_cases.json +1643 -0
  33. package/src/_stories/_mock/sankey_mock.json +78 -0
  34. package/src/_stories/_mock/small_multiples/small_multiples_lines.json +1 -1
  35. package/src/components/Annotations/components/AnnotationDraggable.styles.css +22 -1
  36. package/src/components/Annotations/components/AnnotationDraggable.tsx +220 -120
  37. package/src/components/Annotations/components/AnnotationDropdown.tsx +5 -2
  38. package/src/components/Annotations/tests/AnnotationDraggable.test.tsx +198 -0
  39. package/src/components/Axis/LeftAxis.tsx +17 -11
  40. package/src/components/Axis/LeftAxisGridlines.tsx +22 -12
  41. package/src/components/Axis/RightAxis.tsx +3 -0
  42. package/src/components/Axis/tests/AxisTickValues.test.tsx +86 -0
  43. package/src/components/Brush/BrushSelector.tsx +88 -187
  44. package/src/components/Brush/brushInteraction.ts +22 -0
  45. package/src/components/Brush/tests/brushInteraction.test.ts +38 -0
  46. package/src/components/EditorPanel/EditorPanel.tsx +677 -185
  47. package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +114 -52
  48. package/src/components/EditorPanel/components/Panels/Panel.General.tsx +105 -13
  49. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +1 -0
  50. package/src/components/EditorPanel/components/Panels/Panel.Regions.tsx +5 -0
  51. package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +2 -2
  52. package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.test.tsx +98 -0
  53. package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.tsx +10 -0
  54. package/src/components/EditorPanel/components/Panels/Panel.Visual.test.tsx +484 -0
  55. package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +4 -1
  56. package/src/components/EditorPanel/components/Panels/SeriesColorAssignments.tsx +262 -0
  57. package/src/components/EditorPanel/components/Panels/panelVisual.styles.css +32 -0
  58. package/src/components/EditorPanel/components/panels.scss +10 -0
  59. package/src/components/EditorPanel/editor-panel.scss +47 -0
  60. package/src/components/EditorPanel/helpers/getVisualizationTypeConfigUpdate.ts +61 -0
  61. package/src/components/EditorPanel/helpers/tests/getVisualizationTypeConfigUpdate.test.ts +74 -0
  62. package/src/components/EditorPanel/useEditorPermissions.test.tsx +315 -0
  63. package/src/components/EditorPanel/useEditorPermissions.ts +77 -21
  64. package/src/components/HeatMap/components/HeatMap.tsx +767 -0
  65. package/src/components/HeatMap/components/HeatMapGradientLegend.tsx +128 -0
  66. package/src/components/HeatMap/heatmap.constants.ts +13 -0
  67. package/src/components/HeatMap/heatmap.css +85 -0
  68. package/src/components/HeatMap/helpers/index.ts +261 -0
  69. package/src/components/HeatMap/index.tsx +5 -0
  70. package/src/components/HeatMap/tests/HeatMap.test.tsx +1072 -0
  71. package/src/components/HeatMap/tests/helpers.test.ts +167 -0
  72. package/src/components/Legend/Legend.Component.tsx +2 -2
  73. package/src/components/Legend/helpers/createFormatLabels.test.ts +88 -0
  74. package/src/components/Legend/helpers/createFormatLabels.tsx +6 -1
  75. package/src/components/LineChart/components/LineChart.BumpCircle.tsx +19 -22
  76. package/src/components/LineChart/helpers.test.ts +574 -0
  77. package/src/components/LineChart/helpers.ts +39 -16
  78. package/src/components/LineChart/index.tsx +28 -5
  79. package/src/components/LinearChart/tests/LinearChart.test.tsx +137 -33
  80. package/src/components/LinearChart/tests/mockConfigContext.ts +1 -0
  81. package/src/components/LinearChart.tsx +103 -47
  82. package/src/components/PieChart/PieChart.tsx +4 -1
  83. package/src/components/RadarChart/RadarChart.tsx +3 -1
  84. package/src/components/Regions/components/Regions.tsx +195 -41
  85. package/src/components/Sankey/components/Sankey.tsx +7 -3
  86. package/src/components/Sankey/helpers/getSankeyTooltip.tsx +7 -6
  87. package/src/components/ScatterPlot/ScatterPlot.jsx +3 -3
  88. package/src/components/SmallMultiples/SmallMultipleTile.tsx +33 -3
  89. package/src/components/SmallMultiples/SmallMultiples.css +12 -0
  90. package/src/components/SmallMultiples/SmallMultiples.tsx +50 -15
  91. package/src/components/SmallMultiples/tests/SmallMultipleTile.test.tsx +152 -0
  92. package/src/components/SmallMultiples/tests/SmallMultiples.test.tsx +170 -0
  93. package/src/components/Sparkline/components/SparkLine.tsx +4 -9
  94. package/src/components/WarmingStripes/WarmingStripesGradientLegend.css +1 -1
  95. package/src/components/WarmingStripes/WarmingStripesGradientLegend.tsx +2 -2
  96. package/src/data/initial-state.js +12 -0
  97. package/src/helpers/axisLabelFontSize.ts +8 -0
  98. package/src/helpers/bumpChartTooltip.ts +54 -0
  99. package/src/helpers/calculateLeftYAxisWidth.ts +13 -3
  100. package/src/helpers/colorAssignmentHelpers.ts +102 -0
  101. package/src/helpers/dataHelpers.ts +11 -1
  102. package/src/helpers/ensureSpecialChartAxisTypes.ts +11 -0
  103. package/src/helpers/getAxisMaxOverride.ts +16 -0
  104. package/src/helpers/getCleanTopTickMax.ts +64 -0
  105. package/src/helpers/getColorScale.ts +18 -9
  106. package/src/helpers/getHasBoundarySuppression.ts +26 -0
  107. package/src/helpers/getMinMax.ts +26 -25
  108. package/src/helpers/getYAxisDomainData.ts +38 -0
  109. package/src/helpers/getYAxisFinalizationEligibility.ts +40 -0
  110. package/src/helpers/handleChartAriaLabels.ts +23 -10
  111. package/src/helpers/hasSpacedInlineLabel.ts +10 -0
  112. package/src/helpers/seriesColumnSettings.ts +5 -4
  113. package/src/helpers/seriesHighlight.ts +26 -0
  114. package/src/helpers/smallMultiplesHelpers.ts +5 -0
  115. package/src/helpers/tests/axisLabelFontSize.test.ts +15 -0
  116. package/src/helpers/tests/bumpChartTooltip.test.ts +75 -0
  117. package/src/helpers/tests/calculateLeftYAxisWidth.test.ts +38 -0
  118. package/src/helpers/tests/dataHelpers.test.ts +135 -0
  119. package/src/helpers/tests/ensureSpecialChartAxisTypes.test.ts +30 -0
  120. package/src/helpers/tests/getAxisMaxOverride.test.ts +36 -0
  121. package/src/helpers/tests/getCleanTopTickMax.test.ts +165 -0
  122. package/src/helpers/tests/getColorScale.test.ts +197 -0
  123. package/src/helpers/tests/getMinMax.test.ts +211 -0
  124. package/src/helpers/tests/getYAxisDomainData.test.ts +119 -0
  125. package/src/helpers/tests/getYAxisFinalizationEligibility.test.ts +185 -0
  126. package/src/helpers/tests/handleChartAriaLabels.test.ts +71 -0
  127. package/src/helpers/tests/hasSpacedInlineLabel.test.ts +75 -0
  128. package/src/helpers/tests/seriesColumnSettings.test.ts +16 -2
  129. package/src/helpers/tests/seriesHighlight.test.ts +61 -0
  130. package/src/helpers/tests/smallMultiplesHelpers.test.ts +99 -0
  131. package/src/hooks/tests/useRightAxis.test.tsx +127 -0
  132. package/src/hooks/tests/useScales.test.tsx +383 -0
  133. package/src/hooks/useRightAxis.ts +30 -13
  134. package/src/hooks/useScales.ts +48 -21
  135. package/src/hooks/useTooltip.tsx +3 -1
  136. package/src/index.jsx +5 -2
  137. package/src/scss/main.scss +32 -49
  138. package/src/test/CdcChart.dataTable.test.tsx +122 -0
  139. package/src/types/ChartConfig.ts +24 -1
  140. package/src/types/ChartContext.ts +5 -0
  141. package/src/helpers/getYAxisAutoPadding.ts +0 -53
@@ -11,6 +11,7 @@ type CalculateLeftYAxisWidthProps = {
11
11
  data: any[]
12
12
  yScale: any
13
13
  numTicks: number
14
+ tickValues?: number[]
14
15
  parentWidth: number
15
16
  tickLabelFont: string
16
17
  axisLabelFontSize: number
@@ -28,7 +29,11 @@ type TickValueProps = Omit<
28
29
  'parentWidth' | 'tickLabelFont' | 'axisLabelFontSize' | 'handleLeftTickFormatting'
29
30
  >
30
31
 
31
- const getTickValues = ({ config, data, yScale, numTicks }: TickValueProps) => {
32
+ const getTickValues = ({ config, data, yScale, numTicks, tickValues }: TickValueProps) => {
33
+ if (tickValues) {
34
+ return tickValues
35
+ }
36
+
32
37
  if (config.visualizationType === 'Forest Plot') {
33
38
  return data.map((_, index) => index)
34
39
  }
@@ -45,6 +50,10 @@ const getTickValues = ({ config, data, yScale, numTicks }: TickValueProps) => {
45
50
  }
46
51
 
47
52
  const getAxisLabelSpace = (config: any, axisLabelFontSize: number) => {
53
+ if (config.yAxis?.titlePlacement === 'top') {
54
+ return 0
55
+ }
56
+
48
57
  return !config.hideYAxisLabel && config.runtime.yAxis.label ? axisLabelFontSize + AXIS_LABEL_PADDING : 0
49
58
  }
50
59
 
@@ -138,6 +147,7 @@ export const calculateLeftYAxisWidth = ({
138
147
  data,
139
148
  yScale,
140
149
  numTicks,
150
+ tickValues,
141
151
  parentWidth,
142
152
  tickLabelFont,
143
153
  axisLabelFontSize,
@@ -149,10 +159,10 @@ export const calculateLeftYAxisWidth = ({
149
159
  return getHorizonChartWidth({ config, parentWidth, tickLabelFont, axisLabelFontSize })
150
160
  }
151
161
 
152
- const tickValues = getTickValues({ config, data, yScale, numTicks })
162
+ const measuredTickValues = getTickValues({ config, data, yScale, numTicks, tickValues })
153
163
  const { hideAxis, inlineLabel, labelsAboveGridlines } = config.yAxis
154
164
  const formattedTicks = formatMeasuredTickLabels({
155
- tickValues,
165
+ tickValues: measuredTickValues,
156
166
  handleLeftTickFormatting,
157
167
  inlineLabel,
158
168
  labelsAboveGridlines
@@ -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,11 @@
1
+ import { type ChartConfig } from '../types/ChartConfig'
2
+
3
+ export const ensureSpecialChartAxisTypes = <T extends Pick<ChartConfig, 'visualizationType' | 'xAxis'>>(
4
+ config: T
5
+ ): T => {
6
+ if (config.visualizationType === 'Bump Chart' && config.xAxis) {
7
+ config.xAxis.type = 'date-time'
8
+ }
9
+
10
+ return config
11
+ }
@@ -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,19 +49,22 @@ 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
- // When brush is active, use tableData (full dataset) for min/max calculations
59
- // so the y-axis shows the full range, but still use filtered data for rendering
60
- const dataForMinMax = config.xAxis.brushActive && tableData && tableData.length > 0 ? tableData : data
66
+ const useBrushFullRange = config.xAxis.brushActive && !config.xAxis.brushDynamicYAxis
67
+ const dataForMinMax = useBrushFullRange && tableData && tableData.length > 0 ? tableData : data
61
68
 
62
69
  if (lower && upper && config.visualizationType === 'Bar') {
63
70
  const buffer = min < 0 ? 1.1 : 0
@@ -136,8 +143,8 @@ const getMinMax = ({
136
143
  leftMax = findMaxFromSeriesKeys(dataForMinMax, leftAxisSeriesItems, leftMax, 'left')
137
144
  rightMax = findMaxFromSeriesKeys(dataForMinMax, rightAxisSeriesItems, rightMax, 'right')
138
145
 
139
- if (leftMax < Number(enteredMaxValue)) {
140
- leftMax = Number(enteredMaxValue)
146
+ if (hasValidExplicitLeftMax && leftMax < enteredMaxNumber) {
147
+ leftMax = enteredMaxNumber
141
148
  }
142
149
  } catch (e) {
143
150
  console.error(e.message)
@@ -181,23 +188,9 @@ const getMinMax = ({
181
188
  const numEnteredMin = Number(enteredMinValue)
182
189
  const isMinValid = isLogarithmicAxis ? numEnteredMin >= 0 && numEnteredMin < minValue : numEnteredMin < minValue
183
190
 
184
- const suppressedMinValue = tableData?.some((item, i, arr) =>
185
- config.preliminaryData?.some(({ type, style, column, value }) => {
186
- if (type !== 'suppression' || !style) return false
187
-
188
- const values = _.values(_.pick(item, config.runtime?.seriesKeys))
189
- const dynamicCategory = config.series[0].dynamicCategory
190
-
191
- const match = column ? item[column] === value : values.includes(value)
192
- const dynamic = dynamicCategory && (item[dynamicCategory] === column || !column)
193
-
194
- return (match || dynamic) && (i === 0 || i === arr.length - 1)
195
- })
196
- )
197
-
198
191
  const isCategorical = config.yAxis.type === 'categorical'
199
192
 
200
- min = enteredMinValue !== '' && isMinValid ? numEnteredMin : suppressedMinValue ? 0 : isCategorical ? 0 : minValue
193
+ min = enteredMinValue !== '' && isMinValid ? numEnteredMin : hasBoundarySuppression ? 0 : isCategorical ? 0 : minValue
201
194
  }
202
195
 
203
196
  //If data value max wasn't provided, calculate it
@@ -206,6 +199,14 @@ const getMinMax = ({
206
199
  max = existPositiveValue ? maxValue : 0
207
200
  }
208
201
 
202
+ if (shouldCleanAutoLeftMax) {
203
+ if (config.visualizationType === 'Combo') {
204
+ leftMax = getCleanTopTickMax(leftMax)
205
+ } else {
206
+ max = getCleanTopTickMax(max)
207
+ }
208
+ }
209
+
209
210
  //Adds Y Axis data padding if applicable
210
211
  if (config.runtime.yAxis.paddingPercent) {
211
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
+ }
@@ -1,17 +1,30 @@
1
- export const handleChartAriaLabels = state => {
1
+ import type { AltTextConfig } from '@cdc/core/types/AltText'
2
+ import { resolveAltTextDescription } from '@cdc/core/helpers/resolveAltTextDescription'
3
+
4
+ const getAutoLabel = (state: { visualizationType?: string; title?: string }): string => {
5
+ let ariaLabel = ''
6
+ if (state.visualizationType) {
7
+ ariaLabel += `${state.visualizationType} chart`
8
+ }
9
+ if (state.title && state.visualizationType) {
10
+ ariaLabel += ` with the title: ${state.title}`
11
+ }
12
+ return ariaLabel
13
+ }
14
+
15
+ export const handleChartAriaLabels = (state: {
16
+ visualizationType?: string
17
+ title?: string
18
+ altText?: AltTextConfig
19
+ dataMetadata?: Record<string, string>
20
+ }): string => {
2
21
  try {
3
22
  if (!state.visualizationType) throw Error('handleChartAriaLabels: no visualization type found in state')
4
- let ariaLabel = ''
5
-
6
- if (state.visualizationType) {
7
- ariaLabel += `${state.visualizationType} chart`
8
- }
9
23
 
10
- if (state.title && state.visualizationType) {
11
- ariaLabel += ` with the title: ${state.title}`
12
- }
24
+ const title = getAutoLabel(state)
25
+ const description = resolveAltTextDescription(state.altText, state.dataMetadata)
13
26
 
14
- return ariaLabel
27
+ return description ? `${title}. ${description}` : title
15
28
  } catch (e) {
16
29
  console.error('COVE: ', e.message) // eslint-disable-line
17
30
  return 'Data visualization container'
@@ -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')) {
@@ -0,0 +1,26 @@
1
+ import type { Label } from '../types/Label'
2
+
3
+ type LegendHighlightSource = Pick<Label, 'datum'> | string
4
+
5
+ export const getLegendHighlightKey = (
6
+ seriesLabels: Record<string, string> = {},
7
+ legendSource: LegendHighlightSource
8
+ ): string => {
9
+ const labelDatum = typeof legendSource === 'string' ? legendSource : legendSource.datum
10
+
11
+ return Object.entries(seriesLabels).find(([, label]) => label === labelDatum)?.[0] || labelDatum
12
+ }
13
+
14
+ export const shouldResetSeriesHighlight = (
15
+ seriesHighlight: string[],
16
+ seriesKeys: string[] = [],
17
+ highlightKey: string,
18
+ visualizationType?: string
19
+ ): boolean => {
20
+ return (
21
+ seriesHighlight.length + 1 === seriesKeys.length &&
22
+ visualizationType !== 'Forecasting' &&
23
+ seriesKeys.includes(highlightKey) &&
24
+ !seriesHighlight.includes(highlightKey)
25
+ )
26
+ }