@cdc/chart 4.26.7 → 4.26.10

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 (255) hide show
  1. package/CONFIG.md +502 -368
  2. package/dist/cdcchart-CYc-Kayd.es.js +22488 -0
  3. package/dist/cdcchart-DCKn8NmI.es.js +20695 -0
  4. package/dist/cdcchart-Dyui9nk_.es.js +45828 -0
  5. package/dist/cdcchart.js +4 -97406
  6. package/examples/__data__/vega-remote-data.json +6 -0
  7. package/examples/dendrogram.json +18 -0
  8. package/examples/feature/bar/bar-chart-race-annual-change.json +68 -0
  9. package/examples/feature/heatmap/average-age-categorical.json +2 -2
  10. package/examples/feature/heatmap/calendar-heatmap.json +2 -2
  11. package/examples/feature/heatmap/cell-values-bottom-axis.json +2 -2
  12. package/examples/feature/heatmap/sparse-aggregation.json +2 -2
  13. package/examples/feature/line/line-chart-race.json +50 -0
  14. package/examples/feature/sankey/sankey-example-data.json +40 -1441
  15. package/examples/feature/sankey/sankey_chart_data.csv +9 -20
  16. package/examples/network.json +31 -0
  17. package/examples/private/box-plot.json +573 -0
  18. package/examples/private/custom-color-issue.json +625 -0
  19. package/examples/private/custom-colors.json +522 -0
  20. package/examples/private/cutoff.json +10750 -0
  21. package/examples/private/overlay-legend.json +485 -0
  22. package/examples/private/pie-test.json +469 -0
  23. package/examples/vega-remote.json +22 -0
  24. package/package.json +11 -5
  25. package/src/CdcChartComponent.tsx +405 -208
  26. package/src/_stories/Chart.BarRace.stories.tsx +443 -0
  27. package/src/_stories/Chart.BoxPlotNumericSort.stories.tsx +189 -0
  28. package/src/_stories/Chart.BoxPlotStatsParity.stories.tsx +207 -0
  29. package/src/_stories/Chart.ColorDistribution.stories.tsx +299 -0
  30. package/src/_stories/Chart.DataCutoff.stories.tsx +147 -0
  31. package/src/_stories/Chart.DataTableDownloadFilename.stories.tsx +60 -0
  32. package/src/_stories/Chart.Dendrogram.stories.tsx +373 -0
  33. package/src/_stories/Chart.Forecast.CILegend.stories.tsx +236 -0
  34. package/src/_stories/Chart.HeatMap.stories.tsx +170 -4
  35. package/src/_stories/Chart.Legend.AxisAlign.stories.tsx +119 -0
  36. package/src/_stories/Chart.LineRace.stories.tsx +149 -0
  37. package/src/_stories/Chart.Network.stories.tsx +432 -0
  38. package/src/_stories/Chart.PaletteConversion.Editor.stories.tsx +208 -0
  39. package/src/_stories/Chart.Patterns.smoke.stories.tsx +31 -0
  40. package/src/_stories/Chart.PercentageValue.stories.tsx +311 -0
  41. package/src/_stories/Chart.PieLabels.stories.tsx +4 -2
  42. package/src/_stories/Chart.RankByValueMutation.stories.tsx +206 -0
  43. package/src/_stories/Chart.RequiredFields.stories.tsx +681 -0
  44. package/src/_stories/Chart.Sankey.stories.tsx +417 -0
  45. package/src/_stories/Chart.SeriesColorAssignments.stories.tsx +47 -0
  46. package/src/_stories/Chart.SmallMultiplesNumericTileOrder.stories.tsx +137 -0
  47. package/src/_stories/Chart.TooltipSeriesLabels.stories.tsx +43 -0
  48. package/src/_stories/Chart.VegaRemoteData.stories.tsx +31 -0
  49. package/src/_stories/ChartAnnotation.Editor.stories.tsx +78 -0
  50. package/src/_stories/ChartAxisTitles.smoke.stories.tsx +48 -0
  51. package/src/_stories/ChartBar.Editor.stories.tsx +499 -57
  52. package/src/_stories/ChartBrush.Editor.stories.tsx +46 -67
  53. package/src/_stories/ChartBrush.Regression.stories.tsx +135 -0
  54. package/src/_stories/ChartEditor.Editor.stories.tsx +33 -3
  55. package/src/_stories/ChartLine.EffectCompatibility.stories.tsx +120 -0
  56. package/src/_stories/_mock/annotation_event-line_mock.json +2 -2
  57. package/src/_stories/_mock/editor-test-configs.json +20 -10
  58. package/src/_stories/_mock/editor-test-datasets.json +38 -42
  59. package/src/_stories/_mock/heatmap-average-age-categorical.json +2 -2
  60. package/src/_stories/_mock/heatmap.json +2 -2
  61. package/src/_stories/_mock/legend-axis-align-stacked-bar.json +125 -0
  62. package/src/_stories/_mock/sankey_mock.json +23 -27
  63. package/src/components/Annotations/components/AnnotationDraggable.styles.css +52 -0
  64. package/src/components/Annotations/components/AnnotationDraggable.tsx +639 -139
  65. package/src/components/Annotations/components/AnnotationDropdown.styles.css +44 -4
  66. package/src/components/Annotations/components/AnnotationDropdown.tsx +10 -32
  67. package/src/components/Annotations/helpers/resolveAnnotationLayout.ts +212 -0
  68. package/src/components/Annotations/tests/AnnotationDraggable.test.tsx +612 -19
  69. package/src/components/Annotations/tests/AnnotationDropdown.test.tsx +50 -0
  70. package/src/components/Annotations/tests/resolveAnnotationLayout.test.ts +203 -0
  71. package/src/components/Axis/BottomAxis.tsx +1 -1
  72. package/src/components/Axis/Categorical.Axis.tsx +3 -3
  73. package/src/components/Axis/RightAxis.tsx +21 -13
  74. package/src/components/BarChart/components/BarChart.Horizontal.tsx +38 -6
  75. package/src/components/BarChart/components/BarChart.StackedHorizontal.tsx +12 -3
  76. package/src/components/BarChart/components/BarChart.StackedVertical.test.ts +28 -0
  77. package/src/components/BarChart/components/BarChart.StackedVertical.tsx +29 -25
  78. package/src/components/BarChart/components/BarChart.Vertical.tsx +37 -26
  79. package/src/components/BarChart/components/PortionPatternOverlay.tsx +61 -0
  80. package/src/components/BarChart/components/context.tsx +6 -0
  81. package/src/components/BarChart/helpers/getPatternUrl.ts +2 -5
  82. package/src/components/BarChart/helpers/index.ts +8 -0
  83. package/src/components/BarChart/helpers/portionPattern.ts +243 -0
  84. package/src/components/BarChart/helpers/tests/addMinimumBarHeights.test.ts +42 -0
  85. package/src/components/BarChart/helpers/tests/getPatternUrl.test.ts +13 -0
  86. package/src/components/BarChart/helpers/tests/portionPattern.test.ts +280 -0
  87. package/src/components/BarChart/helpers/tests/useBarChart.test.tsx +104 -0
  88. package/src/components/BarChart/helpers/useBarChart.ts +60 -59
  89. package/src/components/BarChartRace/BarChartRace.test.tsx +199 -0
  90. package/src/components/BarChartRace/BarChartRace.tsx +230 -0
  91. package/src/components/BarChartRace/BarChartRaceFallback.test.tsx +61 -0
  92. package/src/components/BarChartRace/BarChartRaceFallback.tsx +48 -0
  93. package/src/components/BarChartRace/bar-chart-race.css +160 -0
  94. package/src/components/BarChartRace/helpers.test.ts +114 -0
  95. package/src/components/BarChartRace/helpers.ts +185 -0
  96. package/src/components/BarChartRace/index.ts +3 -0
  97. package/src/components/BoxPlot/helpers/index.ts +11 -82
  98. package/src/components/Brush/BrushSelector.tsx +9 -23
  99. package/src/components/Brush/MiniChartPreview.tsx +33 -1
  100. package/src/components/Dendrogram/components/Dendrogram.tsx +374 -0
  101. package/src/components/Dendrogram/dendrogram.css +113 -0
  102. package/src/components/Dendrogram/helpers/layoutDendrogram.test.ts +95 -0
  103. package/src/components/Dendrogram/helpers/layoutDendrogram.ts +56 -0
  104. package/src/components/Dendrogram/helpers/prepareDendrogramData.test.ts +135 -0
  105. package/src/components/Dendrogram/helpers/prepareDendrogramData.ts +166 -0
  106. package/src/components/Dendrogram/helpers/resolveDendrogramLabels.test.ts +196 -0
  107. package/src/components/Dendrogram/helpers/resolveDendrogramLabels.ts +418 -0
  108. package/src/components/Dendrogram/index.tsx +1 -0
  109. package/src/components/Dendrogram/types/index.ts +66 -0
  110. package/src/components/EditorPanel/EditorPanel.tsx +1005 -760
  111. package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +50 -5
  112. package/src/components/EditorPanel/components/Panels/Panel.Dendrogram.css +10 -0
  113. package/src/components/EditorPanel/components/Panels/Panel.Dendrogram.tsx +211 -0
  114. package/src/components/EditorPanel/components/Panels/Panel.ForestPlotSettings.tsx +19 -8
  115. package/src/components/EditorPanel/components/Panels/Panel.General.tsx +88 -54
  116. package/src/components/EditorPanel/components/Panels/Panel.Network.css +9 -0
  117. package/src/components/EditorPanel/components/Panels/Panel.Network.tsx +202 -0
  118. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +233 -33
  119. package/src/components/EditorPanel/components/Panels/Panel.Radar.tsx +6 -1
  120. package/src/components/EditorPanel/components/Panels/Panel.Sankey.tsx +89 -105
  121. package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +2 -2
  122. package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.tsx +1 -1
  123. package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +8 -66
  124. package/src/components/EditorPanel/components/Panels/RacingControls.test.tsx +131 -0
  125. package/src/components/EditorPanel/components/Panels/RacingControls.tsx +185 -0
  126. package/src/components/EditorPanel/components/Panels/SeriesColorAssignments.tsx +2 -2
  127. package/src/components/EditorPanel/components/Panels/index.tsx +4 -0
  128. package/src/components/EditorPanel/helpers/getVisualizationTypeConfigUpdate.ts +20 -7
  129. package/src/components/EditorPanel/helpers/tests/getVisualizationTypeConfigUpdate.test.ts +25 -2
  130. package/src/components/EditorPanel/helpers/updateFieldRankByValue.ts +2 -1
  131. package/src/components/EditorPanel/useEditorPermissions.test.tsx +62 -0
  132. package/src/components/EditorPanel/useEditorPermissions.ts +70 -24
  133. package/src/components/Forecasting/Forecasting.tsx +15 -6
  134. package/src/components/HeatMap/components/HeatMap.tsx +511 -178
  135. package/src/components/HeatMap/heatmap.constants.ts +7 -3
  136. package/src/components/HeatMap/heatmap.css +101 -2
  137. package/src/components/HeatMap/helpers/layout.ts +80 -0
  138. package/src/components/HeatMap/tests/HeatMap.test.tsx +339 -16
  139. package/src/components/HeatMap/tests/helpers.test.ts +79 -0
  140. package/src/components/Legend/Legend.Component.tsx +110 -90
  141. package/src/components/Legend/LegendGroup/LegendGroup.styles.css +7 -0
  142. package/src/components/Legend/LegendGroup/LegendGroup.tsx +47 -34
  143. package/src/components/Legend/helpers/createFormatLabels.test.ts +226 -0
  144. package/src/components/Legend/helpers/createFormatLabels.tsx +45 -51
  145. package/src/components/LineChart/components/LineChart.Circle.tsx +3 -3
  146. package/src/components/LineChart/helpers.test.ts +126 -38
  147. package/src/components/LineChart/helpers.ts +9 -4
  148. package/src/components/LineChart/index.tsx +69 -24
  149. package/src/components/LineChart/tests/LineChart.test.tsx +630 -0
  150. package/src/components/LineChartRace/LineChartRace.test.tsx +275 -0
  151. package/src/components/LineChartRace/LineChartRace.tsx +134 -0
  152. package/src/components/LineChartRace/LineChartRaceFallback.tsx +28 -0
  153. package/src/components/LineChartRace/helpers.test.ts +88 -0
  154. package/src/components/LineChartRace/helpers.ts +157 -0
  155. package/src/components/LineChartRace/index.ts +3 -0
  156. package/src/components/LineChartRace/line-chart-race.css +16 -0
  157. package/src/components/LinearChart/ValueAxisAnchors.tsx +58 -0
  158. package/src/components/LinearChart/VisualizationRenderer.tsx +18 -23
  159. package/src/components/LinearChart/tests/LinearChart.test.tsx +412 -13
  160. package/src/components/LinearChart/tests/VisualizationRenderer.test.tsx +141 -0
  161. package/src/components/LinearChart/tests/mockConfigContext.ts +2 -1
  162. package/src/components/LinearChart.tsx +81 -51
  163. package/src/components/Network/components/Network.tsx +372 -0
  164. package/src/components/Network/helpers/layoutNetwork.test.ts +68 -0
  165. package/src/components/Network/helpers/layoutNetwork.ts +87 -0
  166. package/src/components/Network/helpers/prepareNetworkData.test.ts +116 -0
  167. package/src/components/Network/helpers/prepareNetworkData.ts +101 -0
  168. package/src/components/Network/helpers/resolveNetworkLabels.test.ts +112 -0
  169. package/src/components/Network/helpers/resolveNetworkLabels.ts +206 -0
  170. package/src/components/Network/index.tsx +3 -0
  171. package/src/components/Network/network.css +107 -0
  172. package/src/components/Network/types/index.ts +56 -0
  173. package/src/components/PairedBarChart.jsx +5 -10
  174. package/src/components/PairedBarChart.test.tsx +71 -0
  175. package/src/components/PieChart/PieChart.tsx +9 -8
  176. package/src/components/RacingChartRenderer.tsx +44 -0
  177. package/src/components/Regions/components/Regions.test.tsx +179 -0
  178. package/src/components/Regions/components/Regions.tsx +104 -69
  179. package/src/components/Regions/index.tsx +2 -3
  180. package/src/components/Sankey/components/Sankey.tsx +264 -437
  181. package/src/components/Sankey/helpers/getSankeyTooltip.ts +12 -0
  182. package/src/components/Sankey/helpers/prepareSankeyData.test.ts +71 -0
  183. package/src/components/Sankey/helpers/prepareSankeyData.ts +90 -0
  184. package/src/components/Sankey/sankey.scss +57 -116
  185. package/src/components/Sankey/types/index.ts +41 -4
  186. package/src/components/ScatterPlot/ScatterPlot.jsx +28 -4
  187. package/src/components/ScatterPlot/ScatterPlot.test.tsx +60 -0
  188. package/src/components/SmallMultiples/SmallMultiples.tsx +12 -12
  189. package/src/components/SmallMultiples/tests/SmallMultipleTile.test.tsx +7 -5
  190. package/src/components/raceTiming.ts +18 -0
  191. package/src/data/initial-state.js +77 -31
  192. package/src/helpers/applyChartTypeContract.ts +71 -0
  193. package/src/helpers/axisLabelFontSize.ts +2 -2
  194. package/src/helpers/boxPlotStats.ts +60 -0
  195. package/src/helpers/bumpChartTooltip.ts +2 -1
  196. package/src/helpers/calculateHorizontalBarCategoryLabelWidth.test.ts +37 -0
  197. package/src/helpers/calculateHorizontalBarCategoryLabelWidth.ts +3 -2
  198. package/src/helpers/calculateRightYAxisWidth.ts +60 -0
  199. package/src/helpers/colorAssignmentHelpers.ts +2 -2
  200. package/src/helpers/createNewChartConfig.ts +56 -0
  201. package/src/helpers/dataDrivenYAxisCategories.ts +18 -0
  202. package/src/helpers/forecastLegend.ts +88 -0
  203. package/src/helpers/getBoxPlotConfig.ts +18 -37
  204. package/src/helpers/getChartTypeDefaultPalette.ts +29 -0
  205. package/src/helpers/getColorCodeCategoryColorMap.ts +26 -0
  206. package/src/helpers/getColorScale.ts +31 -22
  207. package/src/helpers/getDynamicYAxisCategories.ts +69 -0
  208. package/src/helpers/getMinMax.ts +9 -7
  209. package/src/helpers/getNewRuntime.ts +5 -2
  210. package/src/helpers/getStackedSeriesMax.ts +51 -0
  211. package/src/helpers/getV21ChartDistributionColors.ts +57 -0
  212. package/src/helpers/handleRankByValue.ts +15 -15
  213. package/src/helpers/seriesColumnSettings.ts +10 -0
  214. package/src/helpers/sizeHelpers.ts +4 -3
  215. package/src/helpers/smallMultiplesHelpers.ts +10 -13
  216. package/src/helpers/tests/applyChartTypeContract.test.ts +37 -0
  217. package/src/helpers/tests/boxPlotStats.test.ts +31 -0
  218. package/src/helpers/tests/bumpChartTooltip.test.ts +29 -0
  219. package/src/helpers/tests/calculateRightYAxisWidth.test.ts +105 -0
  220. package/src/helpers/tests/countNumOfTicks.test.ts +11 -0
  221. package/src/helpers/tests/createNewChartConfig.test.ts +182 -0
  222. package/src/helpers/tests/getBoxPlotConfig.test.ts +70 -0
  223. package/src/helpers/tests/getColorCodeCategoryColorMap.test.ts +113 -0
  224. package/src/helpers/tests/getColorScale.test.ts +226 -1
  225. package/src/helpers/tests/getDynamicYAxisCategories.test.ts +61 -0
  226. package/src/helpers/tests/getMinMax.test.ts +41 -0
  227. package/src/helpers/tests/getStackedSeriesMax.test.ts +26 -0
  228. package/src/helpers/tests/handleRankByValue.test.ts +55 -37
  229. package/src/helpers/tests/seriesColumnSettings.test.ts +23 -0
  230. package/src/helpers/tests/smallMultiplesHelpers.test.ts +37 -1
  231. package/src/helpers/tests/tooltipHelpers.test.ts +80 -0
  232. package/src/helpers/tooltipHelpers.ts +16 -0
  233. package/src/hooks/tests/useTooltip.test.tsx +353 -1
  234. package/src/hooks/usePrefersReducedMotion.ts +22 -0
  235. package/src/hooks/useReduceData.ts +4 -36
  236. package/src/hooks/useScales.ts +15 -0
  237. package/src/hooks/useTooltip.tsx +95 -64
  238. package/src/scss/main.scss +74 -2
  239. package/src/store/chart.reducer.ts +4 -3
  240. package/src/test/CdcChart.dataTable.test.tsx +490 -16
  241. package/src/test/CdcChart.dendrogram.test.tsx +277 -0
  242. package/src/test/CdcChart.finalization.test.tsx +143 -0
  243. package/src/test/CdcChart.network.test.tsx +271 -0
  244. package/src/types/ChartConfig.ts +91 -54
  245. package/src/types/ChartContext.ts +5 -0
  246. package/src/types/Horizon.ts +1 -1
  247. package/LICENSE +0 -201
  248. package/dist/cdcchart-BWVIUcAF.es.js +0 -4802
  249. package/src/_stories/Chart.Sankey.smoke.stories.tsx +0 -62
  250. package/src/components/Sankey/components/ColumnList.tsx +0 -19
  251. package/src/components/Sankey/helpers/getSankeyTooltip.tsx +0 -34
  252. package/src/components/Sankey/useSankeyAlert.tsx +0 -60
  253. package/src/data/legacy-defaults.ts +0 -18
  254. package/src/helpers/ensureSpecialChartAxisTypes.ts +0 -11
  255. package/src/helpers/tests/ensureSpecialChartAxisTypes.test.ts +0 -30
@@ -1,4 +1,4 @@
1
- import React, { useContext, useMemo } from 'react'
1
+ import React, { useContext, useLayoutEffect, useMemo, useRef, useState } from 'react'
2
2
  import { AxisBottom, AxisLeft, AxisTop } from '@visx/axis'
3
3
  import { Group } from '@visx/group'
4
4
  import { HeatmapRect } from '@visx/heatmap'
@@ -10,8 +10,13 @@ import { formatNumber as formatColumnNumber } from '@cdc/core/helpers/cove/numbe
10
10
  import { getTextWidth } from '@cdc/core/helpers/getTextWidth'
11
11
  import { type ChartConfig, type HeatMapXAxisPosition } from '../../../types/ChartConfig'
12
12
  import { buildTooltipListHtml } from '../../../helpers/tooltipHelpers'
13
- import { findColumnConfigByName, getSeriesColumnFormattingParams } from '../../../helpers/seriesColumnSettings'
14
- import { HEATMAP_CONFIG_DEFAULTS } from '../heatmap.constants'
13
+ import {
14
+ findColumnConfigByName,
15
+ getAdditionalColumnFormattingParams,
16
+ getSeriesColumnFormattingParams
17
+ } from '../../../helpers/seriesColumnSettings'
18
+ import { HEATMAP_CONFIG_DEFAULTS, MAX_HEATMAP_COLUMN_WIDTH, MIN_HEATMAP_COLUMN_WIDTH } from '../heatmap.constants'
19
+ import { getHeatMapXAxisTickValues } from '../helpers/layout'
15
20
  import './../heatmap.css'
16
21
 
17
22
  /**
@@ -53,28 +58,46 @@ type HeatMapMargins = {
53
58
  type HeatMapLayout = {
54
59
  availableWidth: number
55
60
  availableHeight: number
61
+ responsiveGridWidth: number
56
62
  gridWidth: number
57
63
  gridHeight: number
58
- cellSize: number
59
- xGroupOffset: number
64
+ cellWidth: number
65
+ cellHeight: number
60
66
  xOffset: number
61
67
  yOffset: number
62
68
  }
63
69
 
64
- const AXIS_TOP_WITH_TICKS = 36
65
- const AXIS_TOP_WITHOUT_TICKS = 24
70
+ type HeatMapScrollState = {
71
+ scrollbarSpace: number
72
+ canScrollLeft: boolean
73
+ canScrollRight: boolean
74
+ }
75
+
66
76
  const AXIS_TOP_LABEL_SPACE = 28
67
77
  const AXIS_TICK_FONT_SIZE = 16
78
+ const AXIS_TICK_LENGTH = 8
68
79
  const AXIS_TITLE_SPACE = 30
80
+ const AXIS_TITLE_FONT_SIZE = 18
69
81
  const AXIS_TOP_TITLE_BASELINE = 18
70
82
  const X_AXIS_TITLE_LABEL_SPACE = 32
71
83
  const X_AXIS_TITLE_LABEL_PADDING = 9
72
84
  const TICK_ROTATION_VERTICAL_ANCHOR_THRESHOLD = -50
73
85
  const AXIS_MARGIN_PADDING = 12
86
+ const AXIS_OUTER_PADDING = 8
74
87
  const MIN_LEFT_MARGIN = 64
75
88
  const MAX_LEFT_MARGIN = 240
76
- const MIN_X_AXIS_MARGIN = 40
89
+ const MIN_X_AXIS_MARGIN = 16
77
90
  const MAX_X_AXIS_MARGIN = 220
91
+ const MIN_GRID_WIDTH = 24
92
+ const MIN_GRID_HEIGHT = 24
93
+ const MAX_HEATMAP_GRID_WIDTH = 1_000_000
94
+ const MAX_X_AXIS_SCROLL_INSET = MAX_LEFT_MARGIN
95
+ const SCROLL_EDGE_TOLERANCE = 1
96
+ const DEFAULT_SCROLL_STATE: HeatMapScrollState = {
97
+ scrollbarSpace: 0,
98
+ canScrollLeft: false,
99
+ canScrollRight: false
100
+ }
78
101
  const AXIS_TICK_FONT = `normal ${AXIS_TICK_FONT_SIZE}px Nunito, sans-serif`
79
102
  const DEFAULT_SERIES_LABEL = 'Series'
80
103
  const DEFAULT_VALUE_LABEL = 'Value'
@@ -105,6 +128,30 @@ const getRotatedLabelHeight = (labels: string[], rotationDegrees: number) => {
105
128
  return Math.ceil(Math.abs(Math.sin(radians)) * labelWidth + Math.abs(Math.cos(radians)) * AXIS_TICK_FONT_SIZE)
106
129
  }
107
130
 
131
+ const getProjectedLabelWidth = (label: string, rotationDegrees: number) => {
132
+ const radians = (Math.abs(rotationDegrees) * Math.PI) / 180
133
+
134
+ return Math.abs(Math.cos(radians)) * getMeasuredTextWidth(label) + Math.abs(Math.sin(radians)) * AXIS_TICK_FONT_SIZE
135
+ }
136
+
137
+ const getXAxisEndpointMargins = (labels: string[], rotationDegrees: number, xAxisPosition: HeatMapXAxisPosition) => {
138
+ if (!labels.length) return { left: 0, right: 0 }
139
+
140
+ const firstLabelWidth = getProjectedLabelWidth(labels[0], rotationDegrees)
141
+ const lastLabelWidth = getProjectedLabelWidth(labels[labels.length - 1], rotationDegrees)
142
+
143
+ if (!rotationDegrees) {
144
+ return {
145
+ left: firstLabelWidth / 2 + AXIS_OUTER_PADDING,
146
+ right: lastLabelWidth / 2 + AXIS_OUTER_PADDING
147
+ }
148
+ }
149
+
150
+ return xAxisPosition === 'top'
151
+ ? { left: 0, right: lastLabelWidth + AXIS_OUTER_PADDING }
152
+ : { left: firstLabelWidth + AXIS_OUTER_PADDING, right: 0 }
153
+ }
154
+
108
155
  const getXAxisTitleDistance = (labels: string[], rotationDegrees: number, columnLabelGap: number) => {
109
156
  const rotatedLabelHeight = getRotatedLabelHeight(labels, rotationDegrees)
110
157
  return columnLabelGap + Math.max(rotatedLabelHeight + X_AXIS_TITLE_LABEL_PADDING, X_AXIS_TITLE_LABEL_SPACE)
@@ -131,7 +178,7 @@ const getXAxisTickLabelProps = (xAxisPosition: HeatMapXAxisPosition, xTickRotati
131
178
  if (!xTickRotation) {
132
179
  return {
133
180
  fontSize: AXIS_TICK_FONT_SIZE,
134
- textAnchor: 'middle',
181
+ textAnchor: 'middle' as const,
135
182
  angle: 0,
136
183
  dx: 0,
137
184
  y: xAxisPosition === 'top' ? -columnLabelGap : columnLabelGap
@@ -140,9 +187,9 @@ const getXAxisTickLabelProps = (xAxisPosition: HeatMapXAxisPosition, xTickRotati
140
187
 
141
188
  return {
142
189
  fontSize: AXIS_TICK_FONT_SIZE,
143
- textAnchor: xAxisPosition === 'top' ? 'start' : 'end',
190
+ textAnchor: xAxisPosition === 'top' ? ('start' as const) : ('end' as const),
144
191
  angle: xTickRotation,
145
- dx: isSteepRotation || xAxisPosition === 'top' ? 0 : '-0.5em',
192
+ dx: 0,
146
193
  y: xAxisPosition === 'top' ? -columnLabelGap : columnLabelGap,
147
194
  ...(isSteepRotation ? { verticalAnchor: 'middle' as const } : {})
148
195
  }
@@ -240,39 +287,54 @@ const buildChartMargins = (
240
287
  )
241
288
  const xAxisPosition = getHeatMapXAxisPosition(config)
242
289
  const xTickRotation = getNonNegativeConfigNumber(config.xAxis?.tickRotation ?? config.xAxis?.maxTickRotation, 0)
290
+ const endpointMargins = config.xAxis?.hideLabel
291
+ ? { left: 0, right: 0 }
292
+ : getXAxisEndpointMargins(columnLabels, xTickRotation, xAxisPosition)
243
293
  const xTickLabelSpace = config.xAxis?.hideLabel
244
294
  ? 0
245
295
  : columnLabelGap + getRotatedLabelHeight(columnLabels, xTickRotation)
296
+ const xAxisTitleSpace =
297
+ !config.hideXAxisLabel && config.xAxis?.label
298
+ ? getXAxisTitleDistance(config.xAxis?.hideLabel ? [] : columnLabels, xTickRotation, columnLabelGap) +
299
+ (xAxisPosition === 'top' ? AXIS_TITLE_FONT_SIZE : AXIS_TITLE_FONT_SIZE * 0.25)
300
+ : 0
246
301
  const yAxisLabel = getHeatMapYAxisLabel(config)
247
302
  const hasTopYAxisTitle = shouldRenderTopYAxisTitle(config)
248
303
  const topYAxisTitleSpace = hasTopYAxisTitle ? AXIS_TOP_LABEL_SPACE : 0
249
304
  const sideYAxisTitleSpace = !config.hideYAxisLabel && yAxisLabel && !hasTopYAxisTitle ? AXIS_TITLE_SPACE : 0
250
305
  const left = clamp(
251
- Math.max(yAxisSize, rowLabelWidth + sideYAxisTitleSpace + AXIS_MARGIN_PADDING, yAxisLabel ? 96 : 72),
306
+ Math.max(
307
+ yAxisSize,
308
+ rowLabelWidth +
309
+ sideYAxisTitleSpace +
310
+ AXIS_MARGIN_PADDING +
311
+ (config.yAxis?.hideLabel
312
+ ? 0
313
+ : getNonNegativeConfigNumber(config.heatmap?.rowLabelGap, HEATMAP_CONFIG_DEFAULTS.rowLabelGap)),
314
+ endpointMargins.left,
315
+ yAxisLabel ? 96 : 72
316
+ ),
252
317
  MIN_LEFT_MARGIN,
253
318
  getAxisMarginMax(parentWidth, MAX_LEFT_MARGIN)
254
319
  )
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
320
+ const xTickSpace = config.xAxis?.hideTicks ? 0 : AXIS_TICK_LENGTH
257
321
  const xAxisMargin = clamp(
258
- Math.max(xAxisSize, xTickLabelSpace + topBase + topLabelSpace + AXIS_MARGIN_PADDING),
322
+ Math.max(xAxisSize, Math.max(xTickLabelSpace, xAxisTitleSpace, xTickSpace) + AXIS_OUTER_PADDING),
259
323
  MIN_X_AXIS_MARGIN,
260
324
  MAX_X_AXIS_MARGIN
261
325
  )
262
326
 
263
327
  return {
264
328
  top: (xAxisPosition === 'top' ? xAxisMargin : 24) + topYAxisTitleSpace,
265
- right: 16,
266
- bottom: xAxisPosition === 'bottom' ? xAxisMargin : 20,
329
+ right: clamp(endpointMargins.right, 16, getAxisMarginMax(parentWidth, MAX_LEFT_MARGIN)),
330
+ bottom: xAxisPosition === 'bottom' ? xAxisMargin : AXIS_OUTER_PADDING,
267
331
  left
268
332
  }
269
333
  }
270
334
 
271
335
  /**
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.
336
+ * Build a rectangular-cell matrix that fills the area left after reserving axis margins.
337
+ * Row-label spacing belongs in the left margin, so the grid stays against the row axis.
276
338
  */
277
339
  const buildGridLayout = (
278
340
  parentWidth: number,
@@ -280,25 +342,26 @@ const buildGridLayout = (
280
342
  margins: HeatMapMargins,
281
343
  columnCount: number,
282
344
  rowCount: number,
283
- rowLabelGap: number
345
+ minimumColumnWidth = 0
284
346
  ): HeatMapLayout => {
285
347
  const availableWidth = Math.max(parentWidth - margins.left - margins.right, 0)
286
348
  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)
349
+ const xOffset = 0
350
+ const responsiveGridWidth = Math.max(availableWidth - xOffset, 0)
351
+ const boundedMinimumGridWidth = Math.min(minimumColumnWidth * columnCount, MAX_HEATMAP_GRID_WIDTH)
352
+ const gridWidth = Math.max(responsiveGridWidth, boundedMinimumGridWidth)
353
+ const gridHeight = availableHeight
354
+ const cellWidth = gridWidth / Math.max(columnCount, 1)
355
+ const cellHeight = gridHeight / Math.max(rowCount, 1)
294
356
 
295
357
  return {
296
358
  availableWidth,
297
359
  availableHeight,
360
+ responsiveGridWidth,
298
361
  gridWidth,
299
362
  gridHeight,
300
- cellSize,
301
- xGroupOffset,
363
+ cellWidth,
364
+ cellHeight,
302
365
  xOffset,
303
366
  yOffset: 0
304
367
  }
@@ -323,7 +386,7 @@ const getTooltipColumns = (
323
386
  tooltipColumns.push({
324
387
  label: value.label || columnName,
325
388
  name: columnName,
326
- options: getSeriesColumnFormattingParams(value) || {}
389
+ options: getAdditionalColumnFormattingParams(value)
327
390
  })
328
391
 
329
392
  return tooltipColumns
@@ -457,8 +520,11 @@ const HeatMap: React.FC<HeatMapProps> = ({ parentWidth, parentHeight }) => {
457
520
  formatDate,
458
521
  formatTooltipsDate,
459
522
  formatNumber,
460
- handleChartAriaLabels
523
+ handleChartAriaLabels,
524
+ currentViewport
461
525
  } = useContext(ConfigContext)
526
+ const scrollAreaRef = useRef<HTMLDivElement>(null)
527
+ const [scrollState, setScrollState] = useState(DEFAULT_SCROLL_STATE)
462
528
 
463
529
  const parseDateValue: ParseDateFn =
464
530
  typeof parseDate === 'function'
@@ -499,9 +565,31 @@ const HeatMap: React.FC<HeatMapProps> = ({ parentWidth, parentHeight }) => {
499
565
  () => buildXLabelLookup(columns, config.xAxis?.type, formatDateValue, parseDateValue),
500
566
  [columns, config.xAxis?.type, formatDateValue, parseDateValue]
501
567
  )
502
- const xAxisLabels = useMemo(
503
- () => columns.map(column => xLabelLookup[String(column.rawValue)] || column.label),
504
- [columns, xLabelLookup]
568
+ const columnCount = Math.max(columns.length, 1)
569
+ const rowCount = Math.max(rowLabels.length, 1)
570
+ const xAxisPosition = getHeatMapXAxisPosition(config)
571
+ const xTickRotation = -getNonNegativeConfigNumber(config.xAxis?.tickRotation ?? config.xAxis?.maxTickRotation, 0)
572
+ const yTickRotation = -getNonNegativeConfigNumber(config.yAxis?.tickRotation, 0)
573
+ const xAxisDomain = useMemo(() => columns.map(column => column.key), [columns])
574
+ const viewportNumTicks = config.xAxis?.viewportNumTicks
575
+ const xAxisTickColor = config.xAxis?.tickColor || '#333'
576
+ const requestedXAxisTickCount =
577
+ (currentViewport && viewportNumTicks?.[String(currentViewport)]) || config.xAxis?.numTicks
578
+ const marginXAxisTickValues = useMemo(
579
+ () =>
580
+ getHeatMapXAxisTickValues({
581
+ domain: xAxisDomain,
582
+ formattedLabels: xLabelLookup,
583
+ rotationDegrees: xTickRotation,
584
+ requestedCount: requestedXAxisTickCount,
585
+ measureLabel: getMeasuredTextWidth,
586
+ fontSize: AXIS_TICK_FONT_SIZE
587
+ }),
588
+ [requestedXAxisTickCount, xAxisDomain, xLabelLookup, xTickRotation]
589
+ )
590
+ const marginXAxisLabels = useMemo(
591
+ () => marginXAxisTickValues.map(value => xLabelLookup[value] || value),
592
+ [marginXAxisTickValues, xLabelLookup]
505
593
  )
506
594
 
507
595
  const tooltipXLabelLookup = useMemo(
@@ -509,38 +597,159 @@ const HeatMap: React.FC<HeatMapProps> = ({ parentWidth, parentHeight }) => {
509
597
  [columns, config.xAxis?.type, formatTooltipDateValue, parseDateValue]
510
598
  )
511
599
 
512
- const margins = useMemo(
513
- () => buildChartMargins(config, rowLabels, xAxisLabels, parentWidth),
514
- [config, rowLabels, xAxisLabels, parentWidth]
600
+ const baseMargins = useMemo(
601
+ () => buildChartMargins(config, rowLabels, marginXAxisLabels, parentWidth),
602
+ [config, marginXAxisLabels, parentWidth, rowLabels]
515
603
  )
516
604
  const configuredRowLabelGap = getNonNegativeConfigNumber(
517
605
  config.heatmap?.rowLabelGap,
518
606
  HEATMAP_CONFIG_DEFAULTS.rowLabelGap
519
607
  )
520
- const rowLabelGap = config.yAxis?.hideLabel ? 0 : configuredRowLabelGap
608
+ // Bound extreme gaps to the reserved label area so labels and the grid remain visible.
609
+ const rowLabelGap = config.yAxis?.hideLabel
610
+ ? 0
611
+ : Math.min(
612
+ configuredRowLabelGap,
613
+ Math.max(
614
+ baseMargins.left -
615
+ getWidestLabelWidth(rowLabels) -
616
+ (!config.hideYAxisLabel && getHeatMapYAxisLabel(config) && !shouldRenderTopYAxisTitle(config)
617
+ ? AXIS_TITLE_SPACE
618
+ : 0) -
619
+ AXIS_MARGIN_PADDING,
620
+ 0
621
+ )
622
+ )
521
623
  const columnLabelGap = getNonNegativeConfigNumber(
522
624
  config.heatmap?.columnLabelGap,
523
625
  HEATMAP_CONFIG_DEFAULTS.columnLabelGap
524
626
  )
627
+ const horizontalScrollEnabled = Boolean(config.heatmap?.horizontalScroll ?? HEATMAP_CONFIG_DEFAULTS.horizontalScroll)
628
+ const minimumColumnWidth = horizontalScrollEnabled
629
+ ? clamp(
630
+ getNonNegativeConfigNumber(config.heatmap?.minColumnWidth, HEATMAP_CONFIG_DEFAULTS.minColumnWidth),
631
+ MIN_HEATMAP_COLUMN_WIDTH,
632
+ MAX_HEATMAP_COLUMN_WIDTH
633
+ )
634
+ : 0
525
635
 
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]
636
+ const initialLayout = useMemo(
637
+ () => buildGridLayout(parentWidth, parentHeight, baseMargins, columnCount, rowCount, minimumColumnWidth),
638
+ [parentWidth, parentHeight, baseMargins, columnCount, rowCount, minimumColumnWidth]
531
639
  )
640
+ const isHorizontallyScrollable =
641
+ horizontalScrollEnabled && initialLayout.gridWidth > initialLayout.responsiveGridWidth
642
+ useLayoutEffect(() => {
643
+ if (!isHorizontallyScrollable) {
644
+ setScrollState(currentState =>
645
+ currentState.scrollbarSpace === 0 && !currentState.canScrollLeft && !currentState.canScrollRight
646
+ ? currentState
647
+ : DEFAULT_SCROLL_STATE
648
+ )
649
+ return
650
+ }
651
+
652
+ const scrollArea = scrollAreaRef.current
653
+ if (!scrollArea) return
654
+
655
+ const updateScrollState = () => {
656
+ const measuredSpace = Math.max(scrollArea.offsetHeight - scrollArea.clientHeight, 0)
657
+ const maximumScrollLeft = Math.max(scrollArea.scrollWidth - scrollArea.clientWidth, 0)
658
+ const normalizedScrollLeft = Math.max(scrollArea.scrollLeft, 0)
659
+ const canScrollLeft = normalizedScrollLeft > SCROLL_EDGE_TOLERANCE
660
+ const canScrollRight = normalizedScrollLeft < maximumScrollLeft - SCROLL_EDGE_TOLERANCE
661
+
662
+ setScrollState(currentState =>
663
+ currentState.scrollbarSpace === measuredSpace &&
664
+ currentState.canScrollLeft === canScrollLeft &&
665
+ currentState.canScrollRight === canScrollRight
666
+ ? currentState
667
+ : { scrollbarSpace: measuredSpace, canScrollLeft, canScrollRight }
668
+ )
669
+ }
670
+
671
+ updateScrollState()
672
+ scrollArea.addEventListener('scroll', updateScrollState, { passive: true })
673
+
674
+ if (typeof ResizeObserver === 'undefined') {
675
+ return () => scrollArea.removeEventListener('scroll', updateScrollState)
676
+ }
677
+
678
+ const resizeObserver = new ResizeObserver(updateScrollState)
679
+ resizeObserver.observe(scrollArea)
680
+ return () => {
681
+ scrollArea.removeEventListener('scroll', updateScrollState)
682
+ resizeObserver.disconnect()
683
+ }
684
+ }, [columnCount, isHorizontallyScrollable, marginXAxisLabels, minimumColumnWidth, parentHeight, parentWidth])
685
+ const chartHeight = Math.max(parentHeight - (isHorizontallyScrollable ? scrollState.scrollbarSpace : 0), 0)
686
+ const margins = useMemo(() => {
687
+ if (!yTickRotation || config.yAxis?.hideLabel) return baseMargins
688
+
689
+ const rotatedRowLabelHeight = getRotatedLabelHeight(rowLabels, Math.abs(yTickRotation))
690
+ // The last row label starts at its cell center. Its overflow and the x-axis
691
+ // occupy the same bottom margin; adding both reservations wastes plot height.
692
+ const halfRowFraction = 1 / (2 * Math.max(rowCount, 1))
693
+ const desiredBottomMargin = Math.max(
694
+ baseMargins.bottom,
695
+ Math.ceil(
696
+ (rotatedRowLabelHeight + AXIS_OUTER_PADDING - (chartHeight - baseMargins.top) * halfRowFraction) /
697
+ (1 - halfRowFraction)
698
+ )
699
+ )
700
+ const availableBottomMargin = Math.max(chartHeight - baseMargins.top - MIN_GRID_HEIGHT, 0)
701
+ const maximumBottomMargin = Math.max(baseMargins.bottom, availableBottomMargin)
702
+
703
+ return {
704
+ ...baseMargins,
705
+ bottom: Math.min(desiredBottomMargin, maximumBottomMargin)
706
+ }
707
+ }, [baseMargins, chartHeight, config.yAxis?.hideLabel, rowLabels, rowCount, yTickRotation])
708
+ const { gridWidth, gridHeight, cellWidth, cellHeight, xOffset, yOffset } = useMemo(
709
+ () => buildGridLayout(parentWidth, chartHeight, margins, columnCount, rowCount, minimumColumnWidth),
710
+ [parentWidth, chartHeight, margins, columnCount, rowCount, minimumColumnWidth]
711
+ )
712
+ const xAxisEndpointMargins = useMemo(
713
+ () =>
714
+ config.xAxis?.hideLabel
715
+ ? { left: 0, right: 0 }
716
+ : getXAxisEndpointMargins(marginXAxisLabels, xTickRotation, xAxisPosition),
717
+ [config.xAxis?.hideLabel, marginXAxisLabels, xAxisPosition, xTickRotation]
718
+ )
719
+ const scrollViewportWidth = Math.max(parentWidth - margins.left, 0)
720
+ const minimumVisibleCellWidth = Math.min(cellWidth, MIN_GRID_WIDTH)
721
+ const maximumViewportInset = Math.max(scrollViewportWidth - xOffset - minimumVisibleCellWidth, 0)
722
+ const scrollXAxisInset = isHorizontallyScrollable
723
+ ? Math.min(Math.max(xAxisEndpointMargins.left - cellWidth / 2, 0), MAX_X_AXIS_SCROLL_INSET, maximumViewportInset)
724
+ : 0
725
+ const plotXOffset = xOffset + scrollXAxisInset
726
+ const scrollContentWidth = plotXOffset + gridWidth + margins.right
532
727
  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))
728
+ // During responsive measurement, cell dimensions can briefly be 0. Clamp the effective gap so SVG rects never go negative.
729
+ const effectiveCellGap = Math.min(cellGap, Math.max(Math.min(cellWidth, cellHeight) - 1, 0))
730
+
731
+ const xAxisTickValues = useMemo(
732
+ () =>
733
+ getHeatMapXAxisTickValues({
734
+ domain: xAxisDomain,
735
+ formattedLabels: xLabelLookup,
736
+ gridWidth,
737
+ rotationDegrees: xTickRotation,
738
+ requestedCount: requestedXAxisTickCount,
739
+ measureLabel: getMeasuredTextWidth,
740
+ fontSize: AXIS_TICK_FONT_SIZE
741
+ }),
742
+ [gridWidth, requestedXAxisTickCount, xAxisDomain, xLabelLookup, xTickRotation]
743
+ )
535
744
 
536
745
  const xAxisScale = useMemo(
537
746
  () =>
538
747
  scaleBand<string>({
539
- domain: columns.map(column => column.key),
540
- range: [xOffset, xOffset + gridWidth],
748
+ domain: xAxisDomain,
749
+ range: [plotXOffset, plotXOffset + gridWidth],
541
750
  padding: 0
542
751
  }),
543
- [columns, xOffset, gridWidth]
752
+ [gridWidth, plotXOffset, xAxisDomain]
544
753
  )
545
754
 
546
755
  const yAxisScale = useMemo(
@@ -576,30 +785,28 @@ const HeatMap: React.FC<HeatMapProps> = ({ parentWidth, parentHeight }) => {
576
785
  }
577
786
 
578
787
  const tooltipId = `cdc-open-viz-tooltip-${config.runtime.uniqueId}`
788
+ const scrollInstructionsId = `${tooltipId}-scroll-instructions`
579
789
  const xAxisLabel = config.xAxis?.label
580
790
  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)
791
+ const chartAriaLabel = String(handleChartAriaLabels(config))
792
+ const scrollRegionLabel = `${chartAriaLabel}; ${xAxisLabel || xDataKey || 'HeatMap'}; scrollable columns`
793
+ const fixedYAxisAriaLabel = `${chartAriaLabel}; ${yAxisLabel || DEFAULT_SERIES_LABEL} row axis`
584
794
  const showCellValues = Boolean(config.heatmap?.showCellValues)
585
795
  const showTopYAxisTitle = shouldRenderTopYAxisTitle(config)
586
- const visibleXAxisLabels = config.xAxis?.hideLabel ? [] : xAxisLabels
796
+ const visibleXAxisLabels = config.xAxis?.hideLabel ? [] : xAxisTickValues.map(value => xLabelLookup[value] || value)
587
797
  const xAxisTitleDistance = getXAxisTitleDistance(visibleXAxisLabels, Math.abs(xTickRotation), columnLabelGap)
588
- const rowLabelTitleX = config.yAxis?.hideLabel ? 0 : -getWidestLabelWidth(rowLabels)
798
+ const rowLabelTitleX = config.yAxis?.hideLabel ? 0 : -getWidestLabelWidth(rowLabels) - rowLabelGap
589
799
  const sideYAxisTitleX = -Math.max(Math.abs(rowLabelTitleX) + SIDE_Y_AXIS_TITLE_GAP, AXIS_TITLE_SPACE)
590
800
  const hasXAxisTitle = !config.hideXAxisLabel && Boolean(xAxisLabel)
591
801
  const topYAxisTitleY = xAxisPosition === 'top' && hasXAxisTitle ? -xAxisTitleDistance : -AXIS_TOP_TITLE_BASELINE
592
- const renderXAxisTitle = () => {
802
+ const renderXAxisTitle = (
803
+ x = plotXOffset + gridWidth / 2,
804
+ y = xAxisPosition === 'top' ? -xAxisTitleDistance : gridHeight + xAxisTitleDistance
805
+ ) => {
593
806
  if (config.hideXAxisLabel || !xAxisLabel) return null
594
807
 
595
808
  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
- >
809
+ <text className='cdc-heatmap__axis-title' x={x} y={y} textAnchor='middle' fontWeight={AXIS_TITLE_FONT_WEIGHT}>
603
810
  {xAxisLabel}
604
811
  </text>
605
812
  )
@@ -632,134 +839,260 @@ const HeatMap: React.FC<HeatMapProps> = ({ parentWidth, parentHeight }) => {
632
839
  </text>
633
840
  )
634
841
  }
842
+ const cellTextWidths = new Map<string, number>()
843
+ const renderCells = () => (
844
+ <HeatmapRect
845
+ data={columns}
846
+ xScale={columnIndex => columnIndex * cellWidth + plotXOffset}
847
+ yScale={rowIndex => rowIndex * cellHeight + yOffset}
848
+ binWidth={cellWidth}
849
+ binHeight={cellHeight}
850
+ gap={effectiveCellGap}
851
+ bins={column => column.bins}
852
+ count={bin => (typeof bin.value === 'number' ? bin.value : 0)}
853
+ >
854
+ {heatmap =>
855
+ heatmap.map(columnBins =>
856
+ columnBins.map(bin => {
857
+ const hasValue = typeof bin.bin.value === 'number'
858
+ const xLabel = tooltipXLabelLookup[String(bin.bin.xValue)] || bin.bin.xLabel
859
+ const renderedCellWidth = Math.max(bin.width, 0)
860
+ const renderedCellHeight = Math.max(bin.height, 0)
861
+ const fillColor = hasValue ? heatMapColorScale(bin.bin.value as number) : undefined
862
+ const cellFontSize = Math.max(9, Math.min(16, renderedCellHeight * 0.5))
863
+ const cellValueText =
864
+ showCellValues && hasValue ? getFormattedValueText(bin.bin, formatHeatMapCellValue) : ''
865
+ const textWidthKey = `${cellFontSize}:${cellValueText}`
866
+ let cellTextWidth = cellTextWidths.get(textWidthKey)
867
+ if (
868
+ cellValueText &&
869
+ renderedCellWidth >= MIN_CELL_VALUE_WIDTH &&
870
+ renderedCellHeight >= MIN_CELL_VALUE_HEIGHT &&
871
+ cellTextWidth === undefined
872
+ ) {
873
+ cellTextWidth =
874
+ getTextWidth(cellValueText, `bold ${cellFontSize}px Nunito, sans-serif`) ??
875
+ cellValueText.length * cellFontSize * 0.65
876
+ cellTextWidths.set(textWidthKey, cellTextWidth)
877
+ }
878
+ const shouldShowCellValue =
879
+ showCellValues &&
880
+ hasValue &&
881
+ renderedCellWidth >= MIN_CELL_VALUE_WIDTH &&
882
+ renderedCellHeight >= MIN_CELL_VALUE_HEIGHT &&
883
+ (cellTextWidth ?? 0) <= renderedCellWidth - 4
884
+
885
+ return (
886
+ <React.Fragment key={`heatmap-cell-${bin.column}-${bin.row}`}>
887
+ <rect
888
+ className={`visx-heatmap-rect cdc-heatmap__cell${hasValue ? '' : ' cdc-heatmap__cell--empty'}`}
889
+ role='img'
890
+ tabIndex={0}
891
+ aria-label={getHeatMapCellLabel({
892
+ cell: bin.bin,
893
+ config,
894
+ formatNumber: formatHeatMapCellValue,
895
+ xLabel,
896
+ xDataKey
897
+ })}
898
+ x={bin.x}
899
+ y={bin.y}
900
+ width={renderedCellWidth}
901
+ height={renderedCellHeight}
902
+ fill={fillColor}
903
+ data-tooltip-id={tooltipId}
904
+ data-tooltip-html={buildTooltipHtml({
905
+ cell: bin.bin,
906
+ additionalColumns,
907
+ config,
908
+ formatNumber: formatHeatMapCellValue,
909
+ xLabel,
910
+ xDataKey,
911
+ showSeriesRows: getHeatMapSeriesTooltipEnabled(config, bin.bin.rowKey)
912
+ })}
913
+ />
914
+ {shouldShowCellValue && (
915
+ <text
916
+ className='cdc-heatmap__cell-value'
917
+ x={bin.x + renderedCellWidth / 2}
918
+ y={bin.y + renderedCellHeight / 2}
919
+ fill={getReadableCellTextColor(fillColor)}
920
+ fontSize={cellFontSize}
921
+ textAnchor='middle'
922
+ dominantBaseline='middle'
923
+ aria-hidden='true'
924
+ >
925
+ {cellValueText}
926
+ </text>
927
+ )}
928
+ </React.Fragment>
929
+ )
930
+ })
931
+ )
932
+ }
933
+ </HeatmapRect>
934
+ )
935
+ const renderXAxis = () => (
936
+ <>
937
+ {!config.xAxis?.hideAxis && (
938
+ <line
939
+ className='cdc-heatmap__x-axis-line'
940
+ x1={0}
941
+ x2={plotXOffset + gridWidth}
942
+ y1={xAxisPosition === 'top' ? 0 : gridHeight}
943
+ y2={xAxisPosition === 'top' ? 0 : gridHeight}
944
+ stroke={xAxisTickColor}
945
+ />
946
+ )}
947
+
948
+ {xAxisPosition === 'top' ? (
949
+ <AxisTop
950
+ scale={xAxisScale}
951
+ tickValues={xAxisTickValues}
952
+ tickFormat={value => (config.xAxis?.hideLabel ? '' : xLabelLookup[String(value)] || String(value))}
953
+ hideAxisLine={true}
954
+ hideTicks={Boolean(config.xAxis?.hideTicks)}
955
+ hideZero={true}
956
+ tickStroke={xAxisTickColor}
957
+ tickLabelProps={() => getXAxisTickLabelProps(xAxisPosition, xTickRotation, columnLabelGap)}
958
+ />
959
+ ) : (
960
+ <AxisBottom
961
+ top={gridHeight}
962
+ scale={xAxisScale}
963
+ tickValues={xAxisTickValues}
964
+ tickFormat={value => (config.xAxis?.hideLabel ? '' : xLabelLookup[String(value)] || String(value))}
965
+ hideAxisLine={true}
966
+ hideTicks={Boolean(config.xAxis?.hideTicks)}
967
+ hideZero={true}
968
+ tickStroke={xAxisTickColor}
969
+ tickLabelProps={() => getXAxisTickLabelProps(xAxisPosition, xTickRotation, columnLabelGap)}
970
+ />
971
+ )}
972
+ </>
973
+ )
974
+ const renderYAxis = () => (
975
+ <AxisLeft
976
+ scale={yAxisScale}
977
+ tickFormat={value => (config.yAxis?.hideLabel ? '' : value)}
978
+ hideAxisLine={Boolean(config.yAxis?.hideAxis)}
979
+ hideTicks={Boolean(config.yAxis?.hideTicks)}
980
+ tickLabelProps={() => ({
981
+ fontSize: AXIS_TICK_FONT_SIZE,
982
+ textAnchor: 'end',
983
+ angle: yTickRotation,
984
+ x: -AXIS_TICK_LENGTH - rowLabelGap,
985
+ dx: yTickRotation ? '-0.25em' : 0,
986
+ dy: '0.33em'
987
+ })}
988
+ />
989
+ )
990
+
991
+ const tooltip = (
992
+ <ReactTooltip
993
+ id={tooltipId}
994
+ variant='light'
995
+ className='tooltip'
996
+ style={{ background: `rgba(255,255,255, ${config.tooltips.opacity / 100})`, color: 'black' }}
997
+ />
998
+ )
999
+
1000
+ if (isHorizontallyScrollable) {
1001
+ const fixedXAxisTitleY =
1002
+ margins.top + (xAxisPosition === 'top' ? -xAxisTitleDistance : gridHeight + xAxisTitleDistance)
1003
+ const fixedXAxisTitleX = scrollViewportWidth / 2
1004
+
1005
+ return (
1006
+ <div className='cdc-heatmap cdc-heatmap--scrollable'>
1007
+ <div
1008
+ className='cdc-heatmap__scroll-layout'
1009
+ style={{ gridTemplateColumns: `${margins.left}px minmax(0, 1fr)`, height: parentHeight }}
1010
+ >
1011
+ <svg
1012
+ width={margins.left}
1013
+ height={chartHeight}
1014
+ role='img'
1015
+ aria-label={fixedYAxisAriaLabel}
1016
+ className='cdc-heatmap__fixed-axis-svg'
1017
+ >
1018
+ <Group className='cdc-heatmap__fixed-y-axis' top={margins.top} left={margins.left}>
1019
+ {renderYAxis()}
1020
+ {renderYAxisTitle()}
1021
+ </Group>
1022
+ </svg>
1023
+ <div className='cdc-heatmap__scroll-plot'>
1024
+ <div
1025
+ ref={scrollAreaRef}
1026
+ className='cdc-heatmap__scroll-area'
1027
+ role='region'
1028
+ tabIndex={0}
1029
+ aria-label={scrollRegionLabel}
1030
+ aria-describedby={scrollInstructionsId}
1031
+ >
1032
+ <svg
1033
+ width={scrollContentWidth}
1034
+ height={chartHeight}
1035
+ aria-label={chartAriaLabel}
1036
+ className={`cdc-heatmap__svg cdc-heatmap__scroll-content${config.animate ? ' animated' : ''}`}
1037
+ >
1038
+ <Group className='cdc-heatmap__plot' top={margins.top} left={0}>
1039
+ {renderCells()}
1040
+ {renderXAxis()}
1041
+ </Group>
1042
+ </svg>
1043
+ </div>
1044
+ <span id={scrollInstructionsId} className='cdcdataviz-sr-only'>
1045
+ Scroll horizontally to view additional columns.
1046
+ </span>
1047
+ <div
1048
+ className={`cdc-heatmap__scroll-cue cdc-heatmap__scroll-cue--left${
1049
+ scrollState.canScrollLeft ? ' is-visible' : ''
1050
+ }`}
1051
+ style={{ bottom: scrollState.scrollbarSpace }}
1052
+ aria-hidden='true'
1053
+ />
1054
+ <div
1055
+ className={`cdc-heatmap__scroll-cue cdc-heatmap__scroll-cue--right${
1056
+ scrollState.canScrollRight ? ' is-visible' : ''
1057
+ }`}
1058
+ style={{ bottom: scrollState.scrollbarSpace }}
1059
+ aria-hidden='true'
1060
+ />
1061
+ {hasXAxisTitle && (
1062
+ <svg
1063
+ width={scrollViewportWidth}
1064
+ height={chartHeight}
1065
+ aria-hidden='true'
1066
+ className='cdc-heatmap__fixed-x-title'
1067
+ >
1068
+ {renderXAxisTitle(fixedXAxisTitleX, fixedXAxisTitleY)}
1069
+ </svg>
1070
+ )}
1071
+ </div>
1072
+ </div>
1073
+ {tooltip}
1074
+ </div>
1075
+ )
1076
+ }
635
1077
 
636
1078
  return (
637
1079
  <div className='cdc-heatmap'>
638
1080
  <svg
639
1081
  width={parentWidth}
640
1082
  height={parentHeight}
641
- aria-label={handleChartAriaLabels(config)}
1083
+ aria-label={chartAriaLabel}
642
1084
  className={`cdc-heatmap__svg${config.animate ? ' animated' : ''}`}
643
1085
  >
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
- />
1086
+ <Group className='cdc-heatmap__plot' top={margins.top} left={margins.left}>
1087
+ {renderCells()}
1088
+ {renderXAxis()}
1089
+ {renderYAxis()}
752
1090
 
753
1091
  {renderXAxisTitle()}
754
1092
  {renderYAxisTitle()}
755
1093
  </Group>
756
1094
  </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
- />
1095
+ {tooltip}
763
1096
  </div>
764
1097
  )
765
1098
  }