@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
@@ -0,0 +1,767 @@
1
+ import React, { useContext, useMemo } from 'react'
2
+ import { AxisBottom, AxisLeft, AxisTop } from '@visx/axis'
3
+ import { Group } from '@visx/group'
4
+ import { HeatmapRect } from '@visx/heatmap'
5
+ import { scaleBand } from '@visx/scale'
6
+ import { Tooltip as ReactTooltip } from 'react-tooltip'
7
+ import ConfigContext from '../../../ConfigContext'
8
+ import { buildHeatMapData, getHeatMapColorScale, HeatMapCell, HeatMapColumn } from '../helpers'
9
+ import { formatNumber as formatColumnNumber } from '@cdc/core/helpers/cove/number'
10
+ import { getTextWidth } from '@cdc/core/helpers/getTextWidth'
11
+ import { type ChartConfig, type HeatMapXAxisPosition } from '../../../types/ChartConfig'
12
+ import { buildTooltipListHtml } from '../../../helpers/tooltipHelpers'
13
+ import { findColumnConfigByName, getSeriesColumnFormattingParams } from '../../../helpers/seriesColumnSettings'
14
+ import { HEATMAP_CONFIG_DEFAULTS } from '../heatmap.constants'
15
+ import './../heatmap.css'
16
+
17
+ /**
18
+ * HeatMap render contract
19
+ *
20
+ * The chart treats `xAxis.dataKey` as the column bucket and `config.series` as the row definitions.
21
+ * Each configured series dataKey becomes one rendered row, and each distinct x value becomes one
22
+ * rendered column. The pure shaping and aggregation work lives in `../helpers/buildHeatMapData`;
23
+ * this component is responsible for formatting, layout, axes, accessibility labels, and tooltips.
24
+ */
25
+ type HeatMapProps = {
26
+ parentWidth: number
27
+ parentHeight: number
28
+ }
29
+
30
+ type TooltipColumn = {
31
+ label: string
32
+ name: string
33
+ options: {
34
+ addColPrefix?: string
35
+ addColSuffix?: string
36
+ addColRoundTo?: number
37
+ addColCommas?: boolean
38
+ }
39
+ }
40
+
41
+ type ParseDateFn = (value: string, showError?: boolean) => Date
42
+ type FormatDateFn = (value: Date) => string
43
+ type FormatValueFn = (value: number, position?: string) => string
44
+ type FormatCellValueFn = (cell: HeatMapCell) => string
45
+
46
+ type HeatMapMargins = {
47
+ top: number
48
+ right: number
49
+ bottom: number
50
+ left: number
51
+ }
52
+
53
+ type HeatMapLayout = {
54
+ availableWidth: number
55
+ availableHeight: number
56
+ gridWidth: number
57
+ gridHeight: number
58
+ cellSize: number
59
+ xGroupOffset: number
60
+ xOffset: number
61
+ yOffset: number
62
+ }
63
+
64
+ const AXIS_TOP_WITH_TICKS = 36
65
+ const AXIS_TOP_WITHOUT_TICKS = 24
66
+ const AXIS_TOP_LABEL_SPACE = 28
67
+ const AXIS_TICK_FONT_SIZE = 16
68
+ const AXIS_TITLE_SPACE = 30
69
+ const AXIS_TOP_TITLE_BASELINE = 18
70
+ const X_AXIS_TITLE_LABEL_SPACE = 32
71
+ const X_AXIS_TITLE_LABEL_PADDING = 9
72
+ const TICK_ROTATION_VERTICAL_ANCHOR_THRESHOLD = -50
73
+ const AXIS_MARGIN_PADDING = 12
74
+ const MIN_LEFT_MARGIN = 64
75
+ const MAX_LEFT_MARGIN = 240
76
+ const MIN_X_AXIS_MARGIN = 40
77
+ const MAX_X_AXIS_MARGIN = 220
78
+ const AXIS_TICK_FONT = `normal ${AXIS_TICK_FONT_SIZE}px Nunito, sans-serif`
79
+ const DEFAULT_SERIES_LABEL = 'Series'
80
+ const DEFAULT_VALUE_LABEL = 'Value'
81
+ const EMPTY_VALUE_LABEL = 'No data'
82
+ const MIN_CELL_VALUE_WIDTH = 18
83
+ const MIN_CELL_VALUE_HEIGHT = 14
84
+ const SIDE_Y_AXIS_TITLE_GAP = 20
85
+ const AXIS_TITLE_FONT_WEIGHT = 'bold'
86
+
87
+ const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max)
88
+
89
+ const getNonNegativeConfigNumber = (value: unknown, fallback: number) => {
90
+ const numericValue = Number(value)
91
+ return Number.isFinite(numericValue) ? Math.max(numericValue, 0) : fallback
92
+ }
93
+
94
+ const getMeasuredTextWidth = (label: string) => getTextWidth(label, AXIS_TICK_FONT) || label.length * 7
95
+
96
+ const getWidestLabelWidth = (labels: string[]) =>
97
+ labels.reduce((widest, label) => Math.max(widest, getMeasuredTextWidth(String(label))), 0)
98
+
99
+ const getRotatedLabelHeight = (labels: string[], rotationDegrees: number) => {
100
+ if (labels.length === 0) return 0
101
+
102
+ const labelWidth = getWidestLabelWidth(labels)
103
+ const radians = (Math.abs(rotationDegrees) * Math.PI) / 180
104
+
105
+ return Math.ceil(Math.abs(Math.sin(radians)) * labelWidth + Math.abs(Math.cos(radians)) * AXIS_TICK_FONT_SIZE)
106
+ }
107
+
108
+ const getXAxisTitleDistance = (labels: string[], rotationDegrees: number, columnLabelGap: number) => {
109
+ const rotatedLabelHeight = getRotatedLabelHeight(labels, rotationDegrees)
110
+ return columnLabelGap + Math.max(rotatedLabelHeight + X_AXIS_TITLE_LABEL_PADDING, X_AXIS_TITLE_LABEL_SPACE)
111
+ }
112
+
113
+ const getAxisMarginMax = (parentWidth: number, fallbackMax: number) => {
114
+ if (parentWidth <= 0) return fallbackMax
115
+
116
+ return Math.max(MIN_LEFT_MARGIN, Math.min(fallbackMax, parentWidth * 0.45))
117
+ }
118
+
119
+ const getHeatMapXAxisPosition = (config: ChartConfig): HeatMapXAxisPosition =>
120
+ config.heatmap?.xAxisPosition === 'bottom' ? 'bottom' : HEATMAP_CONFIG_DEFAULTS.xAxisPosition
121
+
122
+ const getHeatMapYAxisLabel = (config: ChartConfig) =>
123
+ String(config.yAxis?.label || config.runtime?.yAxis?.label || '').trim()
124
+
125
+ const shouldRenderTopYAxisTitle = (config: ChartConfig) =>
126
+ config.yAxis?.titlePlacement === 'top' && !config.hideYAxisLabel && Boolean(getHeatMapYAxisLabel(config))
127
+
128
+ const getXAxisTickLabelProps = (xAxisPosition: HeatMapXAxisPosition, xTickRotation: number, columnLabelGap: number) => {
129
+ const isSteepRotation = xTickRotation < TICK_ROTATION_VERTICAL_ANCHOR_THRESHOLD
130
+
131
+ if (!xTickRotation) {
132
+ return {
133
+ fontSize: AXIS_TICK_FONT_SIZE,
134
+ textAnchor: 'middle',
135
+ angle: 0,
136
+ dx: 0,
137
+ y: xAxisPosition === 'top' ? -columnLabelGap : columnLabelGap
138
+ }
139
+ }
140
+
141
+ return {
142
+ fontSize: AXIS_TICK_FONT_SIZE,
143
+ textAnchor: xAxisPosition === 'top' ? 'start' : 'end',
144
+ angle: xTickRotation,
145
+ dx: isSteepRotation || xAxisPosition === 'top' ? 0 : '-0.5em',
146
+ y: xAxisPosition === 'top' ? -columnLabelGap : columnLabelGap,
147
+ ...(isSteepRotation ? { verticalAnchor: 'middle' as const } : {})
148
+ }
149
+ }
150
+
151
+ const isDateAxisType = (axisType?: string) => ['date', 'date-time'].includes(axisType || '')
152
+
153
+ const parseRgbColor = (color?: string): [number, number, number] | null => {
154
+ if (!color) return null
155
+
156
+ const hexMatch = color.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i)
157
+ if (hexMatch) {
158
+ const hex = hexMatch[1]
159
+ const normalized =
160
+ hex.length === 3
161
+ ? hex
162
+ .split('')
163
+ .map(character => `${character}${character}`)
164
+ .join('')
165
+ : hex
166
+
167
+ return [
168
+ parseInt(normalized.slice(0, 2), 16),
169
+ parseInt(normalized.slice(2, 4), 16),
170
+ parseInt(normalized.slice(4, 6), 16)
171
+ ]
172
+ }
173
+
174
+ const rgbMatch = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i)
175
+ if (!rgbMatch) return null
176
+
177
+ return [Number(rgbMatch[1]), Number(rgbMatch[2]), Number(rgbMatch[3])]
178
+ }
179
+
180
+ const getRelativeLuminance = ([red, green, blue]: [number, number, number]) => {
181
+ const [r, g, b] = [red, green, blue].map(channel => {
182
+ const normalized = channel / 255
183
+ return normalized <= 0.03928 ? normalized / 12.92 : Math.pow((normalized + 0.055) / 1.055, 2.4)
184
+ })
185
+
186
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
187
+ }
188
+
189
+ const getContrastRatio = (leftColor: [number, number, number], rightColor: [number, number, number]) => {
190
+ const left = getRelativeLuminance(leftColor)
191
+ const right = getRelativeLuminance(rightColor)
192
+ const lighter = Math.max(left, right)
193
+ const darker = Math.min(left, right)
194
+
195
+ return (lighter + 0.05) / (darker + 0.05)
196
+ }
197
+
198
+ const getReadableCellTextColor = (fillColor?: string) => {
199
+ const rgb = parseRgbColor(fillColor)
200
+ if (!rgb) return '#1c1d1f'
201
+
202
+ return getContrastRatio(rgb, [28, 29, 31]) >= getContrastRatio(rgb, [255, 255, 255]) ? '#1c1d1f' : '#fff'
203
+ }
204
+
205
+ /**
206
+ * Axis labels and tooltip labels intentionally use separate lookups.
207
+ * The x-axis should follow the standard axis date formatter, while tooltips may use
208
+ * `config.tooltips.dateDisplayFormat` through `formatTooltipsDate`.
209
+ */
210
+ const buildXLabelLookup = (
211
+ columns: HeatMapColumn[],
212
+ xAxisType: string | undefined,
213
+ formatDate: (value: Date) => string,
214
+ parseDate: (value: string, showError?: boolean) => Date
215
+ ): Record<string, string> =>
216
+ Object.fromEntries(
217
+ columns.map(column => [
218
+ column.key,
219
+ isDateAxisType(xAxisType) ? formatDate(parseDate(String(column.rawValue), false)) : column.label
220
+ ])
221
+ )
222
+
223
+ /**
224
+ * Reserve enough space for row labels and the top axis without letting user-provided axis
225
+ * sizes create extreme blank areas. HeatMap rows are series labels, so the left margin is
226
+ * based on the longest rendered row label plus any configured y-axis size.
227
+ */
228
+ const buildChartMargins = (
229
+ config: ChartConfig,
230
+ rowLabels: string[],
231
+ columnLabels: string[],
232
+ parentWidth: number
233
+ ): HeatMapMargins => {
234
+ const rowLabelWidth = config.yAxis?.hideLabel ? 0 : getWidestLabelWidth(rowLabels)
235
+ const yAxisSize = Number(config.yAxis?.size) || 0
236
+ const xAxisSize = Number(config.xAxis?.size) || 0
237
+ const columnLabelGap = getNonNegativeConfigNumber(
238
+ config.heatmap?.columnLabelGap,
239
+ HEATMAP_CONFIG_DEFAULTS.columnLabelGap
240
+ )
241
+ const xAxisPosition = getHeatMapXAxisPosition(config)
242
+ const xTickRotation = getNonNegativeConfigNumber(config.xAxis?.tickRotation ?? config.xAxis?.maxTickRotation, 0)
243
+ const xTickLabelSpace = config.xAxis?.hideLabel
244
+ ? 0
245
+ : columnLabelGap + getRotatedLabelHeight(columnLabels, xTickRotation)
246
+ const yAxisLabel = getHeatMapYAxisLabel(config)
247
+ const hasTopYAxisTitle = shouldRenderTopYAxisTitle(config)
248
+ const topYAxisTitleSpace = hasTopYAxisTitle ? AXIS_TOP_LABEL_SPACE : 0
249
+ const sideYAxisTitleSpace = !config.hideYAxisLabel && yAxisLabel && !hasTopYAxisTitle ? AXIS_TITLE_SPACE : 0
250
+ const left = clamp(
251
+ Math.max(yAxisSize, rowLabelWidth + sideYAxisTitleSpace + AXIS_MARGIN_PADDING, yAxisLabel ? 96 : 72),
252
+ MIN_LEFT_MARGIN,
253
+ getAxisMarginMax(parentWidth, MAX_LEFT_MARGIN)
254
+ )
255
+ const topBase = config.xAxis?.hideTicks ? AXIS_TOP_WITHOUT_TICKS : AXIS_TOP_WITH_TICKS
256
+ const topLabelSpace = !config.hideXAxisLabel && config.xAxis?.label ? AXIS_TOP_LABEL_SPACE : 0
257
+ const xAxisMargin = clamp(
258
+ Math.max(xAxisSize, xTickLabelSpace + topBase + topLabelSpace + AXIS_MARGIN_PADDING),
259
+ MIN_X_AXIS_MARGIN,
260
+ MAX_X_AXIS_MARGIN
261
+ )
262
+
263
+ return {
264
+ top: (xAxisPosition === 'top' ? xAxisMargin : 24) + topYAxisTitleSpace,
265
+ right: 16,
266
+ bottom: xAxisPosition === 'bottom' ? xAxisMargin : 20,
267
+ left
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Build a square-cell matrix that behaves like a heatmap, not a continuous x/y plot.
273
+ * The grid is centered as a complete block, while `xOffset` is the configured
274
+ * row-label-to-grid gap. Keeping that as an explicit HeatMap option makes visual
275
+ * tuning predictable when rows use long city, state, or category names.
276
+ */
277
+ const buildGridLayout = (
278
+ parentWidth: number,
279
+ parentHeight: number,
280
+ margins: HeatMapMargins,
281
+ columnCount: number,
282
+ rowCount: number,
283
+ rowLabelGap: number
284
+ ): HeatMapLayout => {
285
+ const availableWidth = Math.max(parentWidth - margins.left - margins.right, 0)
286
+ const availableHeight = Math.max(parentHeight - margins.top - margins.bottom, 0)
287
+ const columnBandWidth = availableWidth / Math.max(columnCount, 1)
288
+ const rowBandHeight = availableHeight / Math.max(rowCount, 1)
289
+ const cellSize = Math.min(columnBandWidth, rowBandHeight)
290
+ const gridWidth = cellSize * Math.max(columnCount, 1)
291
+ const gridHeight = cellSize * Math.max(rowCount, 1)
292
+ const xOffset = Math.min(rowLabelGap, Math.max(availableWidth - gridWidth, 0))
293
+ const xGroupOffset = Math.max((availableWidth - xOffset - gridWidth) / 2, 0)
294
+
295
+ return {
296
+ availableWidth,
297
+ availableHeight,
298
+ gridWidth,
299
+ gridHeight,
300
+ cellSize,
301
+ xGroupOffset,
302
+ xOffset,
303
+ yOffset: 0
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Additional tooltip rows come from column configs with `tooltips: true`.
309
+ * The x-axis column and heatmap series columns are skipped because they are already
310
+ * represented by the tooltip heading, row label, and cell value.
311
+ */
312
+ const getTooltipColumns = (
313
+ config: ChartConfig,
314
+ xDataKey: string | undefined,
315
+ heatMapSeries: { dataKey: string }[]
316
+ ): TooltipColumn[] =>
317
+ Object.entries(config.columns || {}).reduce<TooltipColumn[]>((tooltipColumns, [columnKey, value]) => {
318
+ const columnName = value.name || columnKey
319
+ if (!value.tooltips || !columnName) return tooltipColumns
320
+ if (columnName === xDataKey) return tooltipColumns
321
+ if (heatMapSeries.some(series => series.dataKey === columnName)) return tooltipColumns
322
+
323
+ tooltipColumns.push({
324
+ label: value.label || columnName,
325
+ name: columnName,
326
+ options: getSeriesColumnFormattingParams(value) || {}
327
+ })
328
+
329
+ return tooltipColumns
330
+ }, [])
331
+
332
+ const getFormattedValueText = (cell: HeatMapCell, formatCellValue: FormatCellValueFn) =>
333
+ cell.value === null ? EMPTY_VALUE_LABEL : formatCellValue(cell)
334
+
335
+ const getHeatMapSeriesLabel = (
336
+ config: ChartConfig,
337
+ seriesEntry: { dataKey: string; name?: string },
338
+ runtimeSeriesLabels?: Record<string, string>
339
+ ) => {
340
+ const columnEntry = findColumnConfigByName(config.columns || {}, seriesEntry.dataKey)
341
+ const configuredColumnLabel = columnEntry?.columnConfig?.label
342
+ const hasCustomColumnLabel = configuredColumnLabel && configuredColumnLabel !== seriesEntry.dataKey
343
+
344
+ return hasCustomColumnLabel
345
+ ? configuredColumnLabel
346
+ : runtimeSeriesLabels?.[seriesEntry.dataKey] || seriesEntry.name || seriesEntry.dataKey
347
+ }
348
+
349
+ const getHeatMapSeriesLabels = (config: ChartConfig, heatMapSeries: { dataKey: string; name?: string }[]) =>
350
+ heatMapSeries.reduce<Record<string, string>>((seriesLabels, seriesEntry) => {
351
+ seriesLabels[seriesEntry.dataKey] = getHeatMapSeriesLabel(config, seriesEntry, config.runtime?.seriesLabels)
352
+ return seriesLabels
353
+ }, {})
354
+
355
+ const getHeatMapSeriesTooltipEnabled = (config: ChartConfig, seriesKey: string) =>
356
+ config.series?.find(series => series.dataKey === seriesKey)?.tooltip !== false
357
+
358
+ /**
359
+ * Aggregated cells can represent multiple source rows. If all source rows share the same
360
+ * formatted additional-column value, show it once. If the source rows disagree, summarize
361
+ * that field as "Multiple values" instead of implying one row's value represents all rows.
362
+ */
363
+ const formatAdditionalColumnValue = (cell: HeatMapCell, column: TooltipColumn, config: ChartConfig) => {
364
+ const formattedValues = cell.sourceRows
365
+ .map(sourceRow => sourceRow[column.name])
366
+ .filter(value => value !== undefined && value !== null && value !== '')
367
+ .map(value => String(formatColumnNumber(value, 'left', false, config as any, column.options as any)))
368
+
369
+ const uniqueValues = Array.from(new Set(formattedValues))
370
+
371
+ if (uniqueValues.length === 0) return EMPTY_VALUE_LABEL
372
+ if (uniqueValues.length > 1) return 'Multiple values'
373
+ return uniqueValues[0]
374
+ }
375
+
376
+ /**
377
+ * Each cell is keyboard-focusable, so it needs a concise label that matches the visual
378
+ * encoding: x bucket, row/series label, and formatted numeric value.
379
+ */
380
+ const getHeatMapCellLabel = ({
381
+ cell,
382
+ config,
383
+ formatNumber,
384
+ xLabel,
385
+ xDataKey
386
+ }: {
387
+ cell: HeatMapCell
388
+ config: ChartConfig
389
+ formatNumber: FormatCellValueFn
390
+ xLabel: string
391
+ xDataKey?: string
392
+ }) => {
393
+ const xAxisLabel = config.xAxis?.label || xDataKey
394
+ const yAxisLabel = config.yAxis?.label || DEFAULT_SERIES_LABEL
395
+ const valueLabel = config.legend?.label || DEFAULT_VALUE_LABEL
396
+
397
+ return `${xAxisLabel}: ${xLabel}; ${yAxisLabel}: ${cell.rowLabel}; ${valueLabel}: ${getFormattedValueText(
398
+ cell,
399
+ formatNumber
400
+ )}`
401
+ }
402
+
403
+ /**
404
+ * Tooltip HTML uses the shared chart tooltip list helper to preserve consistent tooltip
405
+ * markup and styling across chart types. Empty cells still receive a tooltip so users can
406
+ * distinguish missing values from very low values.
407
+ */
408
+ const buildTooltipHtml = ({
409
+ cell,
410
+ additionalColumns,
411
+ config,
412
+ formatNumber,
413
+ xLabel,
414
+ xDataKey,
415
+ showSeriesRows
416
+ }: {
417
+ cell: HeatMapCell
418
+ additionalColumns: TooltipColumn[]
419
+ config: ChartConfig
420
+ formatNumber: FormatCellValueFn
421
+ xLabel: string
422
+ xDataKey?: string
423
+ showSeriesRows: boolean
424
+ }) => {
425
+ const sourceRow = cell.sourceRows[0]
426
+ const xAxisLabel = config.xAxis?.label || xDataKey
427
+ const yAxisLabel = config.yAxis?.label || DEFAULT_SERIES_LABEL
428
+ const valueLabel = config.legend?.label || DEFAULT_VALUE_LABEL
429
+
430
+ const extraRows = sourceRow
431
+ ? additionalColumns.map(column => ({
432
+ text: `${column.label}: ${formatAdditionalColumnValue(cell, column, config)}`
433
+ }))
434
+ : []
435
+
436
+ return buildTooltipListHtml({
437
+ heading: `${xAxisLabel}: ${xLabel}`,
438
+ bodyRows: [
439
+ ...(showSeriesRows
440
+ ? [
441
+ { text: `${yAxisLabel}: ${cell.rowLabel}` },
442
+ { text: `${valueLabel}: ${getFormattedValueText(cell, formatNumber)}` }
443
+ ]
444
+ : []),
445
+ ...(cell.sourceRows.length > 1 ? [{ text: `Aggregated Rows: ${String(cell.sourceRows.length)}` }] : []),
446
+ ...extraRows
447
+ ]
448
+ })
449
+ }
450
+
451
+ const HeatMap: React.FC<HeatMapProps> = ({ parentWidth, parentHeight }) => {
452
+ const {
453
+ config,
454
+ filteredData,
455
+ excludedData,
456
+ parseDate,
457
+ formatDate,
458
+ formatTooltipsDate,
459
+ formatNumber,
460
+ handleChartAriaLabels
461
+ } = useContext(ConfigContext)
462
+
463
+ const parseDateValue: ParseDateFn =
464
+ typeof parseDate === 'function'
465
+ ? (value, showError = true) => parseDate(value, showError)
466
+ : value => new Date(value)
467
+ const formatDateValue: FormatDateFn =
468
+ typeof formatDate === 'function' ? value => formatDate(value) : value => value.toISOString()
469
+ const formatTooltipDateValue: FormatDateFn =
470
+ typeof formatTooltipsDate === 'function' && config.tooltips?.dateDisplayFormat
471
+ ? value => formatTooltipsDate(value)
472
+ : formatDateValue
473
+ const formatNumericValue: FormatValueFn =
474
+ typeof formatNumber === 'function'
475
+ ? (value, position = 'left') => String(formatNumber(value, position))
476
+ : value => String(value)
477
+
478
+ const rows = filteredData?.length ? filteredData : excludedData || []
479
+ const xDataKey = config.xAxis?.dataKey
480
+ const heatMapSeries = config.series || []
481
+ const heatMapSeriesLabels = useMemo(() => getHeatMapSeriesLabels(config, heatMapSeries), [config, heatMapSeries])
482
+
483
+ // Transform raw filtered rows into the matrix shape expected by @visx/heatmap.
484
+ const { columns, rowLabels, minValue, maxValue } = useMemo(
485
+ () =>
486
+ buildHeatMapData({
487
+ data: rows as Record<string, any>[],
488
+ xDataKey,
489
+ series: heatMapSeries,
490
+ seriesLabels: heatMapSeriesLabels,
491
+ xAxisType: config.xAxis?.type,
492
+ parseDate: parseDateValue
493
+ }),
494
+ [rows, xDataKey, heatMapSeries, heatMapSeriesLabels, config.xAxis?.type, parseDateValue]
495
+ )
496
+
497
+ // Axis and tooltip date labels can intentionally differ, so keep the two maps separate.
498
+ const xLabelLookup = useMemo(
499
+ () => buildXLabelLookup(columns, config.xAxis?.type, formatDateValue, parseDateValue),
500
+ [columns, config.xAxis?.type, formatDateValue, parseDateValue]
501
+ )
502
+ const xAxisLabels = useMemo(
503
+ () => columns.map(column => xLabelLookup[String(column.rawValue)] || column.label),
504
+ [columns, xLabelLookup]
505
+ )
506
+
507
+ const tooltipXLabelLookup = useMemo(
508
+ () => buildXLabelLookup(columns, config.xAxis?.type, formatTooltipDateValue, parseDateValue),
509
+ [columns, config.xAxis?.type, formatTooltipDateValue, parseDateValue]
510
+ )
511
+
512
+ const margins = useMemo(
513
+ () => buildChartMargins(config, rowLabels, xAxisLabels, parentWidth),
514
+ [config, rowLabels, xAxisLabels, parentWidth]
515
+ )
516
+ const configuredRowLabelGap = getNonNegativeConfigNumber(
517
+ config.heatmap?.rowLabelGap,
518
+ HEATMAP_CONFIG_DEFAULTS.rowLabelGap
519
+ )
520
+ const rowLabelGap = config.yAxis?.hideLabel ? 0 : configuredRowLabelGap
521
+ const columnLabelGap = getNonNegativeConfigNumber(
522
+ config.heatmap?.columnLabelGap,
523
+ HEATMAP_CONFIG_DEFAULTS.columnLabelGap
524
+ )
525
+
526
+ const columnCount = Math.max(columns.length, 1)
527
+ const rowCount = Math.max(rowLabels.length, 1)
528
+ const { gridWidth, gridHeight, cellSize, xGroupOffset, xOffset, yOffset } = useMemo(
529
+ () => buildGridLayout(parentWidth, parentHeight, margins, columnCount, rowCount, rowLabelGap),
530
+ [parentWidth, parentHeight, margins, columnCount, rowCount, rowLabelGap]
531
+ )
532
+ const cellGap = getNonNegativeConfigNumber(config.heatmap?.cellPadding, HEATMAP_CONFIG_DEFAULTS.cellPadding)
533
+ // During responsive measurement, cellSize can briefly be 0. Clamp the effective gap so SVG rects never go negative.
534
+ const effectiveCellGap = Math.min(cellGap, Math.max(cellSize - 1, 0))
535
+
536
+ const xAxisScale = useMemo(
537
+ () =>
538
+ scaleBand<string>({
539
+ domain: columns.map(column => column.key),
540
+ range: [xOffset, xOffset + gridWidth],
541
+ padding: 0
542
+ }),
543
+ [columns, xOffset, gridWidth]
544
+ )
545
+
546
+ const yAxisScale = useMemo(
547
+ () =>
548
+ scaleBand<string>({
549
+ domain: rowLabels,
550
+ range: [yOffset, yOffset + gridHeight],
551
+ padding: 0
552
+ }),
553
+ [rowLabels, yOffset, gridHeight]
554
+ )
555
+
556
+ const heatMapColorScale = useMemo(
557
+ () => getHeatMapColorScale(config, minValue, maxValue),
558
+ [config, minValue, maxValue]
559
+ )
560
+
561
+ const additionalColumns = useMemo(
562
+ () => getTooltipColumns(config, xDataKey, heatMapSeries),
563
+ [config, xDataKey, heatMapSeries]
564
+ )
565
+ const formatHeatMapCellValue: FormatCellValueFn = cell => {
566
+ if (cell.value === null) return EMPTY_VALUE_LABEL
567
+
568
+ const columnConfig = findColumnConfigByName(config.columns || {}, cell.rowKey)?.columnConfig
569
+ const formattingParams = getSeriesColumnFormattingParams(columnConfig)
570
+
571
+ if (formattingParams) {
572
+ return String(formatColumnNumber(String(cell.value), 'left', false, config as any, formattingParams as any))
573
+ }
574
+
575
+ return formatNumericValue(cell.value, 'left')
576
+ }
577
+
578
+ const tooltipId = `cdc-open-viz-tooltip-${config.runtime.uniqueId}`
579
+ const xAxisLabel = config.xAxis?.label
580
+ const yAxisLabel = getHeatMapYAxisLabel(config)
581
+ const xAxisPosition = getHeatMapXAxisPosition(config)
582
+ const xTickRotation = -getNonNegativeConfigNumber(config.xAxis?.tickRotation ?? config.xAxis?.maxTickRotation, 0)
583
+ const yTickRotation = -getNonNegativeConfigNumber(config.yAxis?.tickRotation, 0)
584
+ const showCellValues = Boolean(config.heatmap?.showCellValues)
585
+ const showTopYAxisTitle = shouldRenderTopYAxisTitle(config)
586
+ const visibleXAxisLabels = config.xAxis?.hideLabel ? [] : xAxisLabels
587
+ const xAxisTitleDistance = getXAxisTitleDistance(visibleXAxisLabels, Math.abs(xTickRotation), columnLabelGap)
588
+ const rowLabelTitleX = config.yAxis?.hideLabel ? 0 : -getWidestLabelWidth(rowLabels)
589
+ const sideYAxisTitleX = -Math.max(Math.abs(rowLabelTitleX) + SIDE_Y_AXIS_TITLE_GAP, AXIS_TITLE_SPACE)
590
+ const hasXAxisTitle = !config.hideXAxisLabel && Boolean(xAxisLabel)
591
+ const topYAxisTitleY = xAxisPosition === 'top' && hasXAxisTitle ? -xAxisTitleDistance : -AXIS_TOP_TITLE_BASELINE
592
+ const renderXAxisTitle = () => {
593
+ if (config.hideXAxisLabel || !xAxisLabel) return null
594
+
595
+ return (
596
+ <text
597
+ className='cdc-heatmap__axis-title'
598
+ x={xOffset + gridWidth / 2}
599
+ y={xAxisPosition === 'top' ? -xAxisTitleDistance : gridHeight + xAxisTitleDistance}
600
+ textAnchor='middle'
601
+ fontWeight={AXIS_TITLE_FONT_WEIGHT}
602
+ >
603
+ {xAxisLabel}
604
+ </text>
605
+ )
606
+ }
607
+ const renderYAxisTitle = () => {
608
+ if (!yAxisLabel || config.hideYAxisLabel) return null
609
+
610
+ if (showTopYAxisTitle) {
611
+ return (
612
+ <text
613
+ className='cdc-heatmap__axis-title'
614
+ x={rowLabelTitleX}
615
+ y={topYAxisTitleY}
616
+ textAnchor='start'
617
+ fontWeight={AXIS_TITLE_FONT_WEIGHT}
618
+ >
619
+ {yAxisLabel}
620
+ </text>
621
+ )
622
+ }
623
+
624
+ return (
625
+ <text
626
+ className='cdc-heatmap__axis-title'
627
+ transform={`translate(${sideYAxisTitleX}, ${yOffset + gridHeight / 2}) rotate(-90)`}
628
+ textAnchor='middle'
629
+ fontWeight={AXIS_TITLE_FONT_WEIGHT}
630
+ >
631
+ {yAxisLabel}
632
+ </text>
633
+ )
634
+ }
635
+
636
+ return (
637
+ <div className='cdc-heatmap'>
638
+ <svg
639
+ width={parentWidth}
640
+ height={parentHeight}
641
+ aria-label={handleChartAriaLabels(config)}
642
+ className={`cdc-heatmap__svg${config.animate ? ' animated' : ''}`}
643
+ >
644
+ {/* Shift the complete heatmap block, not just the cells, so row labels stay close while the block stays centered. */}
645
+ <Group className='cdc-heatmap__plot' top={margins.top} left={margins.left + xGroupOffset}>
646
+ <HeatmapRect
647
+ data={columns}
648
+ xScale={columnIndex => columnIndex * cellSize + xOffset}
649
+ yScale={rowIndex => rowIndex * cellSize + yOffset}
650
+ binWidth={cellSize}
651
+ binHeight={cellSize}
652
+ gap={effectiveCellGap}
653
+ bins={column => column.bins}
654
+ count={bin => (typeof bin.value === 'number' ? bin.value : 0)}
655
+ >
656
+ {heatmap =>
657
+ heatmap.map(columnBins =>
658
+ columnBins.map(bin => {
659
+ const hasValue = typeof bin.bin.value === 'number'
660
+ const xLabel = tooltipXLabelLookup[String(bin.bin.xValue)] || bin.bin.xLabel
661
+ const cellWidth = Math.max(bin.width, 0)
662
+ const cellHeight = Math.max(bin.height, 0)
663
+ const fillColor = hasValue ? heatMapColorScale(bin.bin.value as number) : undefined
664
+ const shouldShowCellValue =
665
+ showCellValues &&
666
+ hasValue &&
667
+ cellWidth >= MIN_CELL_VALUE_WIDTH &&
668
+ cellHeight >= MIN_CELL_VALUE_HEIGHT
669
+ return (
670
+ <React.Fragment key={`heatmap-cell-${bin.column}-${bin.row}`}>
671
+ <rect
672
+ className={`visx-heatmap-rect cdc-heatmap__cell${hasValue ? '' : ' cdc-heatmap__cell--empty'}`}
673
+ role='img'
674
+ tabIndex={0}
675
+ aria-label={getHeatMapCellLabel({
676
+ cell: bin.bin,
677
+ config,
678
+ formatNumber: formatHeatMapCellValue,
679
+ xLabel,
680
+ xDataKey
681
+ })}
682
+ x={bin.x}
683
+ y={bin.y}
684
+ width={cellWidth}
685
+ height={cellHeight}
686
+ fill={fillColor}
687
+ data-tooltip-id={tooltipId}
688
+ data-tooltip-html={buildTooltipHtml({
689
+ cell: bin.bin,
690
+ additionalColumns,
691
+ config,
692
+ formatNumber: formatHeatMapCellValue,
693
+ xLabel,
694
+ xDataKey,
695
+ showSeriesRows: getHeatMapSeriesTooltipEnabled(config, bin.bin.rowKey)
696
+ })}
697
+ />
698
+ {shouldShowCellValue && (
699
+ <text
700
+ className='cdc-heatmap__cell-value'
701
+ x={bin.x + cellWidth / 2}
702
+ y={bin.y + cellHeight / 2}
703
+ fill={getReadableCellTextColor(fillColor)}
704
+ fontSize={Math.max(9, Math.min(12, cellHeight * 0.35))}
705
+ textAnchor='middle'
706
+ dominantBaseline='middle'
707
+ aria-hidden='true'
708
+ >
709
+ {getFormattedValueText(bin.bin, formatHeatMapCellValue)}
710
+ </text>
711
+ )}
712
+ </React.Fragment>
713
+ )
714
+ })
715
+ )
716
+ }
717
+ </HeatmapRect>
718
+
719
+ {xAxisPosition === 'top' ? (
720
+ <AxisTop
721
+ scale={xAxisScale}
722
+ tickFormat={value => (config.xAxis?.hideLabel ? '' : xLabelLookup[String(value)] || String(value))}
723
+ hideAxisLine={Boolean(config.xAxis?.hideAxis)}
724
+ hideTicks={Boolean(config.xAxis?.hideTicks)}
725
+ hideZero={true}
726
+ tickLabelProps={() => getXAxisTickLabelProps(xAxisPosition, xTickRotation, columnLabelGap)}
727
+ />
728
+ ) : (
729
+ <AxisBottom
730
+ top={gridHeight}
731
+ scale={xAxisScale}
732
+ tickFormat={value => (config.xAxis?.hideLabel ? '' : xLabelLookup[String(value)] || String(value))}
733
+ hideAxisLine={Boolean(config.xAxis?.hideAxis)}
734
+ hideTicks={Boolean(config.xAxis?.hideTicks)}
735
+ hideZero={true}
736
+ tickLabelProps={() => getXAxisTickLabelProps(xAxisPosition, xTickRotation, columnLabelGap)}
737
+ />
738
+ )}
739
+ <AxisLeft
740
+ scale={yAxisScale}
741
+ tickFormat={value => (config.yAxis?.hideLabel ? '' : value)}
742
+ hideAxisLine={Boolean(config.yAxis?.hideAxis)}
743
+ hideTicks={Boolean(config.yAxis?.hideTicks)}
744
+ tickLabelProps={() => ({
745
+ fontSize: AXIS_TICK_FONT_SIZE,
746
+ textAnchor: 'end',
747
+ angle: yTickRotation,
748
+ dx: yTickRotation ? '-0.25em' : 0,
749
+ dy: '0.33em'
750
+ })}
751
+ />
752
+
753
+ {renderXAxisTitle()}
754
+ {renderYAxisTitle()}
755
+ </Group>
756
+ </svg>
757
+ <ReactTooltip
758
+ id={tooltipId}
759
+ variant='light'
760
+ className='tooltip'
761
+ style={{ background: `rgba(255,255,255, ${config.tooltips.opacity / 100})`, color: 'black' }}
762
+ />
763
+ </div>
764
+ )
765
+ }
766
+
767
+ export default HeatMap