@cdc/chart 4.26.3 → 4.26.5

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