@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,1040 @@
1
+ import React from 'react'
2
+ import { beforeAll, describe, expect, it, vi } from 'vitest'
3
+ import { fireEvent, render, screen } from '@testing-library/react'
4
+
5
+ vi.mock('@cdc/core/components/ui/Icon', () => ({
6
+ default: () => <span data-testid='icon' />
7
+ }))
8
+
9
+ vi.mock('@cdc/core/components/ui/Tooltip', () => {
10
+ const Tooltip = ({ children }) => <>{children}</>
11
+ Tooltip.Target = ({ children }) => <>{children}</>
12
+ Tooltip.Content = ({ children }) => <>{children}</>
13
+ return { default: Tooltip }
14
+ })
15
+
16
+ vi.mock('@cdc/core/components/AdvancedEditor', () => ({
17
+ default: () => <div>Advanced Editor</div>
18
+ }))
19
+
20
+ vi.mock('@cdc/core/components/EditorPanel/DataTableEditor', () => ({
21
+ default: () => <div>Data Table Editor</div>
22
+ }))
23
+
24
+ vi.mock('@cdc/core/assets/icon-check.svg', () => ({
25
+ default: () => <span data-testid='check-icon' />
26
+ }))
27
+
28
+ import ConfigContext from '../../../ConfigContext'
29
+ import HeatMap from '../components/HeatMap'
30
+ import HeatMapGradientLegend from '../components/HeatMapGradientLegend'
31
+ import EditorPanel from '../../EditorPanel/EditorPanel'
32
+ import { createMockChartContext } from '../../LinearChart/tests/mockConfigContext'
33
+
34
+ beforeAll(() => {
35
+ HTMLCanvasElement.prototype.getContext = vi.fn(() => ({
36
+ measureText: vi.fn(() => ({ width: 50 })),
37
+ fillText: vi.fn(),
38
+ fillRect: vi.fn(),
39
+ clearRect: vi.fn()
40
+ })) as any
41
+
42
+ // @ts-expect-error test shim
43
+ SVGElement.prototype.getBBox = vi.fn(() => ({ x: 0, y: 0, width: 100, height: 50 }))
44
+ })
45
+
46
+ const buildHeatMapContext = () => {
47
+ const config = {
48
+ type: 'chart',
49
+ visualizationType: 'HeatMap',
50
+ visualizationSubType: 'regular',
51
+ orientation: 'vertical',
52
+ title: 'HeatMap Title',
53
+ animate: false,
54
+ data: [
55
+ { month: '2024-02-01', North: 5, South: 7, notes: 'Severe' },
56
+ { month: '2024-01-01', North: 2, South: 4, notes: 'Low' }
57
+ ],
58
+ filteredData: [
59
+ { month: '2024-02-01', North: 5, South: 7, notes: 'Severe' },
60
+ { month: '2024-01-01', North: 2, South: 4, notes: 'Low' }
61
+ ],
62
+ excludedData: [
63
+ { month: '2024-02-01', North: 5, South: 7, notes: 'Severe' },
64
+ { month: '2024-01-01', North: 2, South: 4, notes: 'Low' }
65
+ ],
66
+ xAxis: {
67
+ type: 'date',
68
+ dataKey: 'month',
69
+ label: 'Month',
70
+ maxTickRotation: 45,
71
+ tickRotation: 0,
72
+ hideTicks: false,
73
+ hideAxis: false,
74
+ anchors: [],
75
+ axisPadding: 0,
76
+ labelOffset: 0
77
+ },
78
+ yAxis: {
79
+ type: 'categorical',
80
+ label: 'Region',
81
+ hideTicks: false,
82
+ hideAxis: false,
83
+ gridLines: false,
84
+ size: 50,
85
+ tickRotation: 0,
86
+ anchors: [],
87
+ axisPadding: 0
88
+ },
89
+ heatmap: {
90
+ cellPadding: 1,
91
+ rowLabelGap: 32,
92
+ columnLabelGap: 56,
93
+ colorBucketCount: 9,
94
+ xAxisPosition: 'top',
95
+ showCellValues: false
96
+ },
97
+ columns: {
98
+ notes: {
99
+ name: 'notes',
100
+ label: 'Notes',
101
+ tooltips: true,
102
+ dataTable: true
103
+ },
104
+ North: {
105
+ name: 'North',
106
+ label: 'North',
107
+ dataTable: true
108
+ },
109
+ South: {
110
+ name: 'South',
111
+ label: 'South',
112
+ dataTable: true
113
+ }
114
+ },
115
+ legend: {
116
+ hide: false,
117
+ position: 'bottom',
118
+ style: 'gradient',
119
+ subStyle: 'smooth',
120
+ hideBorder: {
121
+ side: false,
122
+ topBottom: true
123
+ }
124
+ },
125
+ series: [
126
+ { dataKey: 'North', name: 'North', type: 'HeatMap', axis: 'Left', tooltip: true },
127
+ { dataKey: 'South', name: 'South', type: 'HeatMap', axis: 'Left', tooltip: true }
128
+ ],
129
+ general: {
130
+ palette: {
131
+ name: 'sequential_blue',
132
+ version: '2.0',
133
+ isReversed: false
134
+ }
135
+ },
136
+ confidenceKeys: {},
137
+ exclusions: { active: false, keys: [] },
138
+ annotations: [],
139
+ preliminaryData: [],
140
+ runtime: {
141
+ uniqueId: 'heatmap-test',
142
+ xAxis: {
143
+ type: 'date',
144
+ dataKey: 'month',
145
+ label: 'Month',
146
+ dateParseFormat: '%Y-%m-%d',
147
+ dateDisplayFormat: '%Y-%m-%d'
148
+ },
149
+ yAxis: { type: 'categorical', label: 'Region' },
150
+ originalXAxis: { dataKey: 'month' },
151
+ series: [],
152
+ seriesKeys: ['North', 'South'],
153
+ seriesLabels: { North: 'North', South: 'South' },
154
+ seriesLabelsAll: ['North', 'South'],
155
+ editorErrorMessage: '',
156
+ horizontal: false
157
+ },
158
+ tooltips: {
159
+ opacity: 90,
160
+ singleSeries: false,
161
+ dateDisplayFormat: '%Y-%m-%d'
162
+ },
163
+ forestPlot: {
164
+ type: ''
165
+ },
166
+ heights: {
167
+ vertical: 300,
168
+ horizontal: 300,
169
+ mobileVertical: 200
170
+ },
171
+ chartMessage: { noData: 'No Data Available' },
172
+ dataFormat: { abbreviated: false, roundTo: 0, commas: true },
173
+ table: { showVertical: true, show: true },
174
+ filters: [],
175
+ visual: {}
176
+ }
177
+
178
+ return createMockChartContext(
179
+ config as any,
180
+ {
181
+ config: config as any,
182
+ filteredData: config.filteredData,
183
+ excludedData: config.excludedData,
184
+ transformedData: config.filteredData,
185
+ tableData: config.filteredData,
186
+ rawData: config.data,
187
+ unfilteredData: config.data,
188
+ updateConfig: vi.fn(),
189
+ missingRequiredSections: vi.fn(() => false),
190
+ setFilteredData: vi.fn(),
191
+ formatDate: (date: Date) => `Axis ${date.toISOString().slice(0, 10)}`,
192
+ formatTooltipsDate: (date: Date) => `Tooltip ${date.toISOString().slice(0, 10)}`,
193
+ lineOptions: [],
194
+ highlight: vi.fn(),
195
+ handleShowAll: vi.fn(),
196
+ isLoading: false,
197
+ dimensions: [800, 400],
198
+ handleChartAriaLabels: () => 'HeatMap chart'
199
+ } as any
200
+ )
201
+ }
202
+
203
+ const buildSeriesModeHeatMapContext = () => {
204
+ const config = {
205
+ type: 'chart',
206
+ visualizationType: 'HeatMap',
207
+ visualizationSubType: 'regular',
208
+ orientation: 'vertical',
209
+ title: 'HeatMap Title',
210
+ animate: false,
211
+ data: [
212
+ { month: '2024-01-01', Austin: 12, Boston: 30, notes: 'Cold' },
213
+ { month: '2024-02-01', Austin: 18, Boston: 40, notes: 'Warmer' }
214
+ ],
215
+ filteredData: [
216
+ { month: '2024-01-01', Austin: 12, Boston: 30, notes: 'Cold' },
217
+ { month: '2024-02-01', Austin: 18, Boston: 40, notes: 'Warmer' }
218
+ ],
219
+ excludedData: [
220
+ { month: '2024-01-01', Austin: 12, Boston: 30, notes: 'Cold' },
221
+ { month: '2024-02-01', Austin: 18, Boston: 40, notes: 'Warmer' }
222
+ ],
223
+ xAxis: {
224
+ type: 'date',
225
+ dataKey: 'month',
226
+ label: 'Month',
227
+ maxTickRotation: 45,
228
+ tickRotation: 0,
229
+ hideTicks: false,
230
+ hideAxis: false,
231
+ anchors: [],
232
+ axisPadding: 0,
233
+ labelOffset: 0,
234
+ size: 75
235
+ },
236
+ yAxis: {
237
+ type: 'categorical',
238
+ label: 'Location',
239
+ hideTicks: false,
240
+ hideAxis: false,
241
+ gridLines: false,
242
+ size: 80,
243
+ tickRotation: 0,
244
+ anchors: [],
245
+ axisPadding: 0
246
+ },
247
+ heatmap: {
248
+ cellPadding: 1,
249
+ rowLabelGap: 32,
250
+ columnLabelGap: 56,
251
+ colorBucketCount: 9,
252
+ xAxisPosition: 'top',
253
+ showCellValues: false
254
+ },
255
+ columns: {
256
+ notes: {
257
+ name: 'notes',
258
+ label: 'Notes',
259
+ tooltips: true,
260
+ dataTable: true
261
+ }
262
+ },
263
+ legend: {
264
+ hide: false,
265
+ position: 'top',
266
+ style: 'gradient',
267
+ subStyle: 'smooth',
268
+ hideBorder: {
269
+ side: false,
270
+ topBottom: true
271
+ },
272
+ label: 'Average value'
273
+ },
274
+ series: [
275
+ { dataKey: 'Austin', name: 'Austin', type: 'HeatMap', axis: 'Left', tooltip: true },
276
+ { dataKey: 'Boston', name: 'Boston', type: 'HeatMap', axis: 'Left', tooltip: true }
277
+ ],
278
+ general: {
279
+ palette: {
280
+ name: 'sequential_blue',
281
+ version: '2.0',
282
+ isReversed: false
283
+ }
284
+ },
285
+ confidenceKeys: {},
286
+ exclusions: { active: false, keys: [] },
287
+ annotations: [],
288
+ preliminaryData: [],
289
+ runtime: {
290
+ uniqueId: 'heatmap-series-test',
291
+ xAxis: {
292
+ type: 'date',
293
+ dataKey: 'month',
294
+ label: 'Month',
295
+ dateParseFormat: '%Y-%m-%d',
296
+ dateDisplayFormat: '%Y-%m-%d'
297
+ },
298
+ yAxis: { type: 'categorical', label: 'Location' },
299
+ originalXAxis: { dataKey: 'month' },
300
+ series: [],
301
+ seriesKeys: ['Austin', 'Boston'],
302
+ seriesLabels: { Austin: 'Austin', Boston: 'Boston' },
303
+ seriesLabelsAll: ['Austin', 'Boston'],
304
+ editorErrorMessage: '',
305
+ horizontal: false
306
+ },
307
+ tooltips: {
308
+ opacity: 90,
309
+ singleSeries: false,
310
+ dateDisplayFormat: '%Y-%m-%d'
311
+ },
312
+ forestPlot: {
313
+ type: ''
314
+ },
315
+ heights: {
316
+ vertical: 300,
317
+ horizontal: 300,
318
+ mobileVertical: 200
319
+ },
320
+ chartMessage: { noData: 'No Data Available' },
321
+ dataFormat: { abbreviated: false, roundTo: 0, commas: true },
322
+ table: { showVertical: true, show: true },
323
+ filters: [],
324
+ visual: {}
325
+ }
326
+
327
+ return createMockChartContext(
328
+ config as any,
329
+ {
330
+ config: config as any,
331
+ filteredData: config.filteredData,
332
+ excludedData: config.excludedData,
333
+ transformedData: config.filteredData,
334
+ tableData: config.filteredData,
335
+ rawData: config.data,
336
+ unfilteredData: config.data,
337
+ updateConfig: vi.fn(),
338
+ missingRequiredSections: vi.fn(() => false),
339
+ setFilteredData: vi.fn(),
340
+ lineOptions: [],
341
+ highlight: vi.fn(),
342
+ handleShowAll: vi.fn(),
343
+ isLoading: false,
344
+ dimensions: [800, 400],
345
+ handleChartAriaLabels: () => 'HeatMap chart'
346
+ } as any
347
+ )
348
+ }
349
+
350
+ const buildCategoricalAverageAgeHeatMapContext = () => {
351
+ const context = buildSeriesModeHeatMapContext()
352
+ const averageAgeData = [
353
+ { communityType: 'Urban Core', Atlanta: 34, Chicago: 36, Phoenix: 38, notes: 'Dense city center' },
354
+ { communityType: 'Suburban', Atlanta: 41, Chicago: 39, Phoenix: 42, notes: 'Commuter belt' },
355
+ { communityType: 'Rural', Atlanta: 46, Chicago: 44, Phoenix: 49, notes: 'Outer counties' }
356
+ ]
357
+ const averageAgeSeries = [
358
+ { dataKey: 'Atlanta', name: 'Atlanta', type: 'HeatMap', axis: 'Left', tooltip: true },
359
+ { dataKey: 'Chicago', name: 'Chicago', type: 'HeatMap', axis: 'Left', tooltip: true },
360
+ { dataKey: 'Phoenix', name: 'Phoenix', type: 'HeatMap', axis: 'Left', tooltip: true }
361
+ ]
362
+ const config = context.config as any
363
+
364
+ config.title = 'Average Age by City'
365
+ config.data = averageAgeData
366
+ config.filteredData = averageAgeData
367
+ config.excludedData = averageAgeData
368
+ config.xAxis = {
369
+ ...config.xAxis,
370
+ type: 'categorical',
371
+ dataKey: 'communityType',
372
+ label: 'Community Type'
373
+ }
374
+ config.yAxis = {
375
+ ...config.yAxis,
376
+ label: 'City'
377
+ }
378
+ config.columns = {
379
+ communityType: {
380
+ name: 'communityType',
381
+ label: 'Community Type',
382
+ dataTable: true
383
+ },
384
+ notes: {
385
+ name: 'notes',
386
+ label: 'Notes',
387
+ tooltips: true,
388
+ dataTable: true
389
+ },
390
+ Atlanta: {
391
+ name: 'Atlanta',
392
+ label: 'Atlanta',
393
+ dataTable: true
394
+ },
395
+ Chicago: {
396
+ name: 'Chicago',
397
+ label: 'Chicago',
398
+ dataTable: true
399
+ },
400
+ Phoenix: {
401
+ name: 'Phoenix',
402
+ label: 'Phoenix',
403
+ dataTable: true
404
+ }
405
+ }
406
+ config.legend = {
407
+ ...config.legend,
408
+ label: 'Average age'
409
+ }
410
+ config.series = averageAgeSeries
411
+ config.runtime = {
412
+ ...config.runtime,
413
+ uniqueId: 'heatmap-average-age-test',
414
+ xAxis: {
415
+ type: 'categorical',
416
+ dataKey: 'communityType',
417
+ label: 'Community Type'
418
+ },
419
+ yAxis: { type: 'categorical', label: 'City' },
420
+ originalXAxis: { dataKey: 'communityType' },
421
+ seriesKeys: ['Atlanta', 'Chicago', 'Phoenix'],
422
+ seriesLabels: { Atlanta: 'Atlanta', Chicago: 'Chicago', Phoenix: 'Phoenix' },
423
+ seriesLabelsAll: ['Atlanta', 'Chicago', 'Phoenix']
424
+ }
425
+ config.tooltips = {
426
+ opacity: 90,
427
+ singleSeries: false
428
+ }
429
+
430
+ context.filteredData = averageAgeData
431
+ context.excludedData = averageAgeData
432
+ context.rawData = averageAgeData
433
+ context.tableData = averageAgeData
434
+ context.transformedData = averageAgeData
435
+
436
+ return context
437
+ }
438
+
439
+ const getTranslateY = (element: Element | null) => {
440
+ const transform = element?.getAttribute('transform') || ''
441
+ const match = transform.match(/translate\([^,]+,\s*([^)]+)\)/)
442
+ return match ? Number(match[1]) : 0
443
+ }
444
+
445
+ const getTranslateX = (element: Element | null) => {
446
+ const transform = element?.getAttribute('transform') || ''
447
+ const match = transform.match(/translate\(([^,\s)]+)/)
448
+ return match ? Number(match[1]) : 0
449
+ }
450
+
451
+ const getHeatMapPlotTop = (container: HTMLElement) => getTranslateY(container.querySelector('.cdc-heatmap__plot'))
452
+
453
+ describe('HeatMap', () => {
454
+ it('renders cells with tooltip metadata from additional columns', () => {
455
+ const context = buildHeatMapContext()
456
+ const { container } = render(
457
+ <ConfigContext.Provider value={context}>
458
+ <HeatMap parentWidth={800} parentHeight={320} />
459
+ </ConfigContext.Provider>
460
+ )
461
+
462
+ const cells = container.querySelectorAll('.visx-heatmap-rect')
463
+ const tooltipHtml = cells[0]?.getAttribute('data-tooltip-html') || ''
464
+
465
+ expect(cells.length).toBeGreaterThan(0)
466
+ expect(tooltipHtml).toContain('tooltip-heading')
467
+ expect(tooltipHtml).toContain('tooltip-body')
468
+ expect(tooltipHtml).toContain('Month: Tooltip 2024-01-01')
469
+ expect(tooltipHtml).toContain('Region:')
470
+ expect(tooltipHtml).toContain('Value:')
471
+ expect(tooltipHtml).toContain('Notes')
472
+ expect(tooltipHtml).not.toContain('<br/>')
473
+ expect(cells[0]?.getAttribute('tabindex')).toBe('0')
474
+ expect(cells[0]?.getAttribute('aria-label')).toContain('Month: Tooltip 2024-01-01')
475
+ expect(cells[0]?.getAttribute('aria-label')).toContain('Region:')
476
+ })
477
+
478
+ it('summarizes aggregated cell tooltip columns when duplicate cells have different source values', () => {
479
+ const context = buildHeatMapContext()
480
+ const duplicateRows = [
481
+ { month: '2024-01-01', North: 2, South: 4, notes: 'Low' },
482
+ { month: '2024-01-01', North: 3, South: 1, notes: 'Severe' }
483
+ ]
484
+
485
+ context.config.data = duplicateRows
486
+ context.config.filteredData = duplicateRows
487
+ context.config.excludedData = duplicateRows
488
+ context.filteredData = duplicateRows
489
+ context.excludedData = duplicateRows
490
+ context.rawData = duplicateRows
491
+ context.tableData = duplicateRows
492
+
493
+ const { container } = render(
494
+ <ConfigContext.Provider value={context}>
495
+ <HeatMap parentWidth={800} parentHeight={320} />
496
+ </ConfigContext.Provider>
497
+ )
498
+
499
+ const firstCellTooltipHtml = container.querySelector('.visx-heatmap-rect')?.getAttribute('data-tooltip-html') || ''
500
+
501
+ expect(firstCellTooltipHtml).toContain('Value: 5')
502
+ expect(firstCellTooltipHtml).toContain('Aggregated Rows: 2')
503
+ expect(firstCellTooltipHtml).toContain('Notes: Multiple values')
504
+ })
505
+
506
+ it('uses HeatMap stylesheet classes for cell paint hooks', () => {
507
+ const context = buildHeatMapContext()
508
+ const sparseRows = [{ month: '2024-01-01', North: 2, notes: 'Low' }]
509
+
510
+ context.config.data = sparseRows
511
+ context.config.filteredData = sparseRows
512
+ context.config.excludedData = sparseRows
513
+ context.filteredData = sparseRows
514
+ context.excludedData = sparseRows
515
+ context.rawData = sparseRows
516
+ context.tableData = sparseRows
517
+
518
+ const { container } = render(
519
+ <ConfigContext.Provider value={context}>
520
+ <HeatMap parentWidth={800} parentHeight={320} />
521
+ </ConfigContext.Provider>
522
+ )
523
+
524
+ const cells = Array.from(container.querySelectorAll('.visx-heatmap-rect'))
525
+ const emptyCell = cells.find(cell => cell.getAttribute('aria-label')?.includes('South'))
526
+
527
+ expect(cells[0]).toHaveClass('cdc-heatmap__cell')
528
+ expect(emptyCell).toHaveClass('cdc-heatmap__cell--empty')
529
+ expect(emptyCell?.getAttribute('fill')).toBeNull()
530
+ })
531
+
532
+ it('keeps cell dimensions non-negative while the responsive layout is measuring', () => {
533
+ const context = buildHeatMapContext()
534
+ const { container } = render(
535
+ <ConfigContext.Provider value={context}>
536
+ <HeatMap parentWidth={0} parentHeight={0} />
537
+ </ConfigContext.Provider>
538
+ )
539
+
540
+ const cells = Array.from(container.querySelectorAll('.visx-heatmap-rect'))
541
+
542
+ expect(cells.length).toBeGreaterThan(0)
543
+ cells.forEach(cell => {
544
+ expect(Number(cell.getAttribute('width'))).toBeGreaterThanOrEqual(0)
545
+ expect(Number(cell.getAttribute('height'))).toBeGreaterThanOrEqual(0)
546
+ })
547
+ })
548
+
549
+ it('renders a gradient legend for the mapped value column', () => {
550
+ const context = buildHeatMapContext()
551
+
552
+ render(
553
+ <ConfigContext.Provider value={context}>
554
+ <HeatMapGradientLegend />
555
+ </ConfigContext.Provider>
556
+ )
557
+
558
+ expect(screen.getByText('Value')).toBeTruthy()
559
+ expect(screen.getByText('2')).toBeTruthy()
560
+ expect(screen.getByText('7')).toBeTruthy()
561
+ })
562
+
563
+ it('renders HeatMap gradient legends as linear blocks', () => {
564
+ const context = buildHeatMapContext()
565
+
566
+ const { container } = render(
567
+ <ConfigContext.Provider value={context}>
568
+ <HeatMapGradientLegend />
569
+ </ConfigContext.Provider>
570
+ )
571
+
572
+ expect(container.querySelector('linearGradient')).toBeNull()
573
+ expect(container.querySelectorAll('.cdc-heatmap__legend-svg rect').length).toBeGreaterThan(2)
574
+ })
575
+
576
+ it('renders non-gradient HeatMap legends with range items', () => {
577
+ const context = buildHeatMapContext()
578
+ ;(context.config as any).legend.style = 'boxes'
579
+
580
+ const { container } = render(
581
+ <ConfigContext.Provider value={context}>
582
+ <HeatMapGradientLegend />
583
+ </ConfigContext.Provider>
584
+ )
585
+
586
+ expect(container.querySelector('.legend-container.cdc-heatmap__legend')).toBeTruthy()
587
+ expect(container.querySelectorAll('.legend-item.not-clickable').length).toBeGreaterThan(0)
588
+ expect(container.querySelector('.cdc-heatmap__legend-svg')).toBeNull()
589
+ })
590
+
591
+ it('uses the configured HeatMap color bucket count for cells and legend ranges', () => {
592
+ const context = buildHeatMapContext()
593
+ ;(context.config as any).heatmap.colorBucketCount = 5
594
+ ;(context.config as any).legend.style = 'boxes'
595
+
596
+ const { container } = render(
597
+ <ConfigContext.Provider value={context}>
598
+ <>
599
+ <HeatMap parentWidth={800} parentHeight={320} />
600
+ <HeatMapGradientLegend />
601
+ </>
602
+ </ConfigContext.Provider>
603
+ )
604
+
605
+ const cellFills = Array.from(container.querySelectorAll('.visx-heatmap-rect'))
606
+ .map(cell => cell.getAttribute('fill'))
607
+ .filter(Boolean)
608
+ const legendFills = Array.from(
609
+ container.querySelectorAll<HTMLElement>('.cdc-heatmap__legend .legend-item.not-clickable > .legend-item')
610
+ )
611
+ .map(shape => shape.style.backgroundColor)
612
+ .filter(Boolean)
613
+
614
+ expect(new Set(cellFills).size).toBeLessThanOrEqual(5)
615
+ expect(new Set(legendFills).size).toBe(5)
616
+ expect(container.querySelectorAll('.legend-item.not-clickable')).toHaveLength(5)
617
+ })
618
+
619
+ it('renders series-driven rows when heatmap data series are configured', () => {
620
+ const context = buildSeriesModeHeatMapContext()
621
+ const { container } = render(
622
+ <ConfigContext.Provider value={context}>
623
+ <HeatMap parentWidth={800} parentHeight={320} />
624
+ </ConfigContext.Provider>
625
+ )
626
+
627
+ const cells = container.querySelectorAll('.visx-heatmap-rect')
628
+ expect(cells.length).toBe(4)
629
+ expect(cells[0]?.getAttribute('data-tooltip-html')).toContain('Austin')
630
+ expect(cells[1]?.getAttribute('data-tooltip-html')).toContain('Boston')
631
+ })
632
+
633
+ it('renders non-calendar categories for an average age by city heatmap', () => {
634
+ const context = buildCategoricalAverageAgeHeatMapContext()
635
+ const { container } = render(
636
+ <ConfigContext.Provider value={context}>
637
+ <HeatMap parentWidth={800} parentHeight={320} />
638
+ </ConfigContext.Provider>
639
+ )
640
+
641
+ const cells = container.querySelectorAll('.visx-heatmap-rect')
642
+ const firstCellTooltipHtml = cells[0]?.getAttribute('data-tooltip-html') || ''
643
+ const firstCellAriaLabel = cells[0]?.getAttribute('aria-label') || ''
644
+ const firstCellX = Number(cells[0]?.getAttribute('x'))
645
+ const xAxisTitle = Array.from(container.querySelectorAll('text')).find(
646
+ text => text.textContent === 'Community Type'
647
+ )
648
+ const xAxisTitleX = Number(xAxisTitle?.getAttribute('x'))
649
+
650
+ expect(cells).toHaveLength(9)
651
+ expect(firstCellX).toBeGreaterThan(20)
652
+ expect(firstCellX).toBeLessThan(90)
653
+ expect(xAxisTitleX).toBeGreaterThan(firstCellX)
654
+ expect(xAxisTitleX).toBeLessThan(180)
655
+ expect(firstCellTooltipHtml).toContain('Community Type: Urban Core')
656
+ expect(firstCellTooltipHtml).toContain('City: Atlanta')
657
+ expect(firstCellTooltipHtml).toContain('Average age: 34')
658
+ expect(firstCellTooltipHtml).toContain('Notes: Dense city center')
659
+ expect(firstCellTooltipHtml).not.toContain('Tooltip')
660
+ expect(firstCellAriaLabel).toContain('Community Type: Urban Core')
661
+ expect(firstCellAriaLabel).toContain('City: Atlanta')
662
+ })
663
+
664
+ it('uses customized series column labels and formatting in heatmap cells and tooltips', () => {
665
+ const context = buildCategoricalAverageAgeHeatMapContext()
666
+ ;(context.config as any).columns.Atlanta.label = 'ATL'
667
+ ;(context.config as any).columns.Atlanta.prefix = '$'
668
+ ;(context.config as any).columns.Atlanta.suffix = ' yrs'
669
+ ;(context.config as any).heatmap.showCellValues = true
670
+
671
+ const { container } = render(
672
+ <ConfigContext.Provider value={context}>
673
+ <HeatMap parentWidth={800} parentHeight={320} />
674
+ </ConfigContext.Provider>
675
+ )
676
+
677
+ const firstCell = container.querySelector('.visx-heatmap-rect')
678
+ const firstCellTooltipHtml = firstCell?.getAttribute('data-tooltip-html') || ''
679
+ const firstCellAriaLabel = firstCell?.getAttribute('aria-label') || ''
680
+
681
+ expect(firstCellTooltipHtml).toContain('City: ATL')
682
+ expect(firstCellTooltipHtml).toContain('Average age: $34 yrs')
683
+ expect(firstCellAriaLabel).toContain('City: ATL')
684
+ expect(firstCellAriaLabel).toContain('Average age: $34 yrs')
685
+ expect(screen.getByText('$34 yrs')).toBeTruthy()
686
+ })
687
+
688
+ it('honors heatmap series tooltip visibility', () => {
689
+ const context = buildCategoricalAverageAgeHeatMapContext()
690
+ ;(context.config as any).series[0].tooltip = false
691
+
692
+ const { container } = render(
693
+ <ConfigContext.Provider value={context}>
694
+ <HeatMap parentWidth={800} parentHeight={320} />
695
+ </ConfigContext.Provider>
696
+ )
697
+
698
+ const cells = container.querySelectorAll('.visx-heatmap-rect')
699
+ const hiddenSeriesTooltipHtml = cells[0]?.getAttribute('data-tooltip-html') || ''
700
+ const visibleSeriesTooltipHtml = cells[1]?.getAttribute('data-tooltip-html') || ''
701
+
702
+ expect(hiddenSeriesTooltipHtml).toContain('Community Type: Urban Core')
703
+ expect(hiddenSeriesTooltipHtml).not.toContain('City: Atlanta')
704
+ expect(hiddenSeriesTooltipHtml).not.toContain('Average age: 34')
705
+ expect(visibleSeriesTooltipHtml).toContain('City: Chicago')
706
+ expect(visibleSeriesTooltipHtml).toContain('Average age: 36')
707
+ })
708
+
709
+ it('applies y-axis tick rotation to row labels when configured', () => {
710
+ const context = buildCategoricalAverageAgeHeatMapContext()
711
+ ;(context.config as any).yAxis.tickRotation = 30
712
+
713
+ const { container } = render(
714
+ <ConfigContext.Provider value={context}>
715
+ <HeatMap parentWidth={800} parentHeight={320} />
716
+ </ConfigContext.Provider>
717
+ )
718
+
719
+ const rowLabel = Array.from(container.querySelectorAll('text')).find(text => text.textContent === 'Atlanta')
720
+
721
+ expect(rowLabel?.getAttribute('transform')).toContain('rotate(-30')
722
+ })
723
+
724
+ it('renders top y-axis titles above the heatmap and reserves layout space', () => {
725
+ const sideContext = buildCategoricalAverageAgeHeatMapContext()
726
+ const topContext = buildCategoricalAverageAgeHeatMapContext()
727
+ ;(topContext.config as any).yAxis.titlePlacement = 'top'
728
+
729
+ const { container: sideContainer } = render(
730
+ <ConfigContext.Provider value={sideContext}>
731
+ <HeatMap parentWidth={800} parentHeight={320} />
732
+ </ConfigContext.Provider>
733
+ )
734
+
735
+ const { container } = render(
736
+ <ConfigContext.Provider value={topContext}>
737
+ <HeatMap parentWidth={800} parentHeight={320} />
738
+ </ConfigContext.Provider>
739
+ )
740
+
741
+ const yAxisTitle = Array.from(container.querySelectorAll('.cdc-heatmap__axis-title')).find(
742
+ text => text.textContent === 'City'
743
+ )
744
+ const xAxisTitle = Array.from(container.querySelectorAll('.cdc-heatmap__axis-title')).find(
745
+ text => text.textContent === 'Community Type'
746
+ )
747
+ const rotatedYAxisTitles = Array.from(container.querySelectorAll('.cdc-heatmap__axis-title')).filter(
748
+ text => text.textContent === 'City' && text.getAttribute('transform')?.includes('rotate(-90)')
749
+ )
750
+
751
+ expect(yAxisTitle?.getAttribute('transform')).toBeNull()
752
+ expect(yAxisTitle?.getAttribute('text-anchor')).toBe('start')
753
+ expect(yAxisTitle?.getAttribute('y')).toBe(xAxisTitle?.getAttribute('y'))
754
+ expect(getHeatMapPlotTop(container) - getHeatMapPlotTop(sideContainer)).toBeCloseTo(28, 1)
755
+ expect(rotatedYAxisTitles).toHaveLength(0)
756
+ })
757
+
758
+ it('anchors top y-axis titles to the row labels instead of the outer margin', () => {
759
+ const context = buildCategoricalAverageAgeHeatMapContext()
760
+ ;(context.config as any).yAxis.titlePlacement = 'top'
761
+ ;(context.config as any).yAxis.size = 180
762
+
763
+ const { container } = render(
764
+ <ConfigContext.Provider value={context}>
765
+ <HeatMap parentWidth={800} parentHeight={320} />
766
+ </ConfigContext.Provider>
767
+ )
768
+
769
+ const plot = container.querySelector('.cdc-heatmap__plot')
770
+ const yAxisTitle = Array.from(container.querySelectorAll('.cdc-heatmap__axis-title')).find(
771
+ text => text.textContent === 'City'
772
+ )
773
+ const titleX = Number(yAxisTitle?.getAttribute('x'))
774
+
775
+ expect(titleX).toBeLessThanOrEqual(0)
776
+ expect(titleX).toBeGreaterThan(-100)
777
+ expect(getTranslateX(plot) + titleX).toBeGreaterThan(getTranslateX(plot) - 100)
778
+ })
779
+
780
+ it('keeps side y-axis titles near row labels when extra y-axis size is reserved', () => {
781
+ const context = buildCategoricalAverageAgeHeatMapContext()
782
+ ;(context.config as any).yAxis.titlePlacement = 'side'
783
+ ;(context.config as any).yAxis.size = 180
784
+
785
+ const { container } = render(
786
+ <ConfigContext.Provider value={context}>
787
+ <HeatMap parentWidth={800} parentHeight={320} />
788
+ </ConfigContext.Provider>
789
+ )
790
+
791
+ const yAxisTitle = Array.from(container.querySelectorAll('.cdc-heatmap__axis-title')).find(
792
+ text => text.textContent === 'City'
793
+ )
794
+ const titleX = getTranslateX(yAxisTitle)
795
+
796
+ expect(titleX).toBeLessThan(0)
797
+ expect(titleX).toBeGreaterThan(-100)
798
+ })
799
+
800
+ it('centers side y-axis titles on short wide heatmap grids', () => {
801
+ const context = buildCategoricalAverageAgeHeatMapContext()
802
+ const months = [
803
+ 'January',
804
+ 'February',
805
+ 'March',
806
+ 'April',
807
+ 'May',
808
+ 'June',
809
+ 'July',
810
+ 'August',
811
+ 'September',
812
+ 'October',
813
+ 'November',
814
+ 'December'
815
+ ]
816
+ const calendarRows = Array.from({ length: 31 }, (_, dayIndex) =>
817
+ months.reduce<Record<string, number>>(
818
+ (row, month, monthIndex) => {
819
+ row[month] = dayIndex + monthIndex + 1
820
+ return row
821
+ },
822
+ { day: dayIndex + 1 }
823
+ )
824
+ )
825
+ const calendarSeries = months.map(month => ({
826
+ dataKey: month,
827
+ name: month,
828
+ type: 'HeatMap',
829
+ axis: 'Left',
830
+ tooltip: true
831
+ }))
832
+ const config = context.config as any
833
+
834
+ config.data = calendarRows
835
+ config.filteredData = calendarRows
836
+ config.excludedData = calendarRows
837
+ config.xAxis = {
838
+ ...config.xAxis,
839
+ type: 'categorical',
840
+ dataKey: 'day',
841
+ label: 'Day of Month',
842
+ tickRotation: 0,
843
+ maxTickRotation: 0
844
+ }
845
+ config.yAxis = {
846
+ ...config.yAxis,
847
+ label: 'Month',
848
+ size: 120,
849
+ titlePlacement: 'side'
850
+ }
851
+ config.series = calendarSeries
852
+ config.runtime = {
853
+ ...config.runtime,
854
+ xAxis: { type: 'categorical', dataKey: 'day', label: 'Day of Month' },
855
+ yAxis: { type: 'categorical', label: 'Month' },
856
+ originalXAxis: { dataKey: 'day' },
857
+ seriesKeys: months,
858
+ seriesLabels: Object.fromEntries(months.map(month => [month, month])),
859
+ seriesLabelsAll: months
860
+ }
861
+
862
+ context.filteredData = calendarRows
863
+ context.excludedData = calendarRows
864
+ context.rawData = calendarRows
865
+ context.tableData = calendarRows
866
+ context.transformedData = calendarRows
867
+
868
+ const { container } = render(
869
+ <ConfigContext.Provider value={context}>
870
+ <HeatMap parentWidth={800} parentHeight={840} />
871
+ </ConfigContext.Provider>
872
+ )
873
+
874
+ const yAxisTitle = Array.from(container.querySelectorAll('.cdc-heatmap__axis-title')).find(
875
+ text => text.textContent === 'Month'
876
+ )
877
+ const cells = Array.from(container.querySelectorAll('.visx-heatmap-rect'))
878
+ const gridTop = Math.min(...cells.map(cell => Number(cell.getAttribute('y'))))
879
+ const gridBottom = Math.max(
880
+ ...cells.map(cell => Number(cell.getAttribute('y')) + Number(cell.getAttribute('height')))
881
+ )
882
+ const gridCenter = (gridTop + gridBottom) / 2
883
+
884
+ expect(Math.abs(getTranslateY(yAxisTitle) - gridCenter)).toBeLessThanOrEqual(1)
885
+ })
886
+
887
+ it('applies x-axis tick rotation to column labels when configured', () => {
888
+ const context = buildCategoricalAverageAgeHeatMapContext()
889
+ ;(context.config as any).xAxis.tickRotation = 30
890
+
891
+ const { container } = render(
892
+ <ConfigContext.Provider value={context}>
893
+ <HeatMap parentWidth={800} parentHeight={320} />
894
+ </ConfigContext.Provider>
895
+ )
896
+
897
+ const columnLabel = Array.from(container.querySelectorAll('text')).find(text => text.textContent === 'Urban Core')
898
+
899
+ expect(columnLabel?.getAttribute('transform')).toContain('rotate(-30')
900
+ expect(columnLabel?.getAttribute('text-anchor')).toBe('start')
901
+ })
902
+
903
+ it('places the x-axis below the heatmap when configured', () => {
904
+ const context = buildCategoricalAverageAgeHeatMapContext()
905
+ ;(context.config as any).heatmap.xAxisPosition = 'bottom'
906
+ ;(context.config as any).heatmap.columnLabelGap = 44
907
+ ;(context.config as any).xAxis.tickRotation = 135
908
+
909
+ const { container } = render(
910
+ <ConfigContext.Provider value={context}>
911
+ <HeatMap parentWidth={800} parentHeight={320} />
912
+ </ConfigContext.Provider>
913
+ )
914
+
915
+ const columnLabel = Array.from(container.querySelectorAll('text')).find(text => text.textContent === 'Urban Core')
916
+ const xAxisTitle = Array.from(container.querySelectorAll('.cdc-heatmap__axis-title')).find(
917
+ text => text.textContent === 'Community Type'
918
+ )
919
+ const axisBottomTop = getTranslateY(container.querySelector('.visx-axis-bottom'))
920
+ const columnLabelAbsoluteY = axisBottomTop + Number(columnLabel?.getAttribute('y'))
921
+ const xAxisTitleY = Number(xAxisTitle?.getAttribute('y'))
922
+
923
+ expect(container.querySelector('.visx-axis-bottom')).toBeTruthy()
924
+ expect(axisBottomTop).toBeGreaterThan(0)
925
+ expect(Number(columnLabel?.getAttribute('y'))).toBe(44)
926
+ expect(columnLabel?.getAttribute('text-anchor')).toBe('end')
927
+ expect(xAxisTitleY - columnLabelAbsoluteY).toBeGreaterThan(50)
928
+ })
929
+
930
+ it('centers 90-degree x-axis labels on their tick markers', () => {
931
+ const context = buildCategoricalAverageAgeHeatMapContext()
932
+ ;(context.config as any).heatmap.xAxisPosition = 'bottom'
933
+ ;(context.config as any).xAxis.tickRotation = 90
934
+
935
+ const { container } = render(
936
+ <ConfigContext.Provider value={context}>
937
+ <HeatMap parentWidth={800} parentHeight={320} />
938
+ </ConfigContext.Provider>
939
+ )
940
+
941
+ const columnLabel = Array.from(container.querySelectorAll('text')).find(text => text.textContent === 'Urban Core')
942
+
943
+ expect(columnLabel?.getAttribute('transform')).toContain('rotate(-90')
944
+ expect(columnLabel?.parentElement?.getAttribute('x')).toBe('0')
945
+ expect(columnLabel?.querySelector('tspan')?.getAttribute('dy')).toBe('0.355em')
946
+ })
947
+
948
+ it('renders readable cell values when HeatMap cell labels are enabled', () => {
949
+ const context = buildCategoricalAverageAgeHeatMapContext()
950
+ ;(context.config as any).heatmap.showCellValues = true
951
+
952
+ const { container } = render(
953
+ <ConfigContext.Provider value={context}>
954
+ <HeatMap parentWidth={800} parentHeight={320} />
955
+ </ConfigContext.Provider>
956
+ )
957
+
958
+ const cellValues = Array.from(container.querySelectorAll('.cdc-heatmap__cell-value'))
959
+
960
+ expect(cellValues.some(label => label.textContent === '34')).toBe(true)
961
+ expect(cellValues.every(label => ['#1c1d1f', '#fff'].includes(label.getAttribute('fill') || ''))).toBe(true)
962
+ })
963
+
964
+ it('uses the HeatMap row label gap setting to tune spacing between row labels and cells', () => {
965
+ const context = buildCategoricalAverageAgeHeatMapContext()
966
+ ;(context.config as any).heatmap.rowLabelGap = 18
967
+
968
+ const { container } = render(
969
+ <ConfigContext.Provider value={context}>
970
+ <HeatMap parentWidth={800} parentHeight={320} />
971
+ </ConfigContext.Provider>
972
+ )
973
+
974
+ const firstCell = container.querySelector('.visx-heatmap-rect')
975
+
976
+ expect(Number(firstCell?.getAttribute('x'))).toBeCloseTo(18, 1)
977
+ })
978
+
979
+ it('uses the HeatMap column label gap setting to tune spacing above the first row', () => {
980
+ const context = buildCategoricalAverageAgeHeatMapContext()
981
+ ;(context.config as any).heatmap.columnLabelGap = 44
982
+
983
+ const { container } = render(
984
+ <ConfigContext.Provider value={context}>
985
+ <HeatMap parentWidth={800} parentHeight={320} />
986
+ </ConfigContext.Provider>
987
+ )
988
+
989
+ const columnLabel = Array.from(container.querySelectorAll('text')).find(text => text.textContent === 'Urban Core')
990
+
991
+ expect(columnLabel?.getAttribute('y')).toBe('-44')
992
+ })
993
+
994
+ it('shows HeatMap editor controls in their owning accordion sections', () => {
995
+ const context = buildHeatMapContext()
996
+
997
+ render(
998
+ <ConfigContext.Provider value={context}>
999
+ <EditorPanel />
1000
+ </ConfigContext.Provider>
1001
+ )
1002
+
1003
+ const yAxisHeading = screen.getByText('Left Value Axis')
1004
+ const dateCategoryHeading = screen.getByText('Date/Category Axis')
1005
+ const settingsHeading = screen.getByText('HeatMap Settings')
1006
+ expect(yAxisHeading).toBeTruthy()
1007
+ expect(settingsHeading).toBeTruthy()
1008
+ expect(
1009
+ Boolean(dateCategoryHeading.compareDocumentPosition(settingsHeading) & Node.DOCUMENT_POSITION_FOLLOWING)
1010
+ ).toBe(true)
1011
+ const dataSeriesHeading = screen.getByText('Data Series')
1012
+ expect(dataSeriesHeading).toBeTruthy()
1013
+
1014
+ fireEvent.click(yAxisHeading)
1015
+ fireEvent.click(dateCategoryHeading)
1016
+ fireEvent.click(settingsHeading)
1017
+ fireEvent.click(dataSeriesHeading)
1018
+
1019
+ const xAxisPositionLabel = screen.getByText('X-Axis Position')
1020
+
1021
+ expect(screen.queryByText('Value Column')).toBeNull()
1022
+ expect(screen.getAllByText('Hide Axis')).toHaveLength(1)
1023
+ expect(screen.getAllByText('Hide Ticks')).toHaveLength(1)
1024
+ expect(screen.getAllByText('Tick rotation (Degrees)').length).toBeGreaterThan(0)
1025
+ expect(screen.getByLabelText('Label Placement')).toBeTruthy()
1026
+ expect(screen.getByText('Add Data Series')).toBeTruthy()
1027
+ expect(
1028
+ Boolean(dateCategoryHeading.compareDocumentPosition(xAxisPositionLabel) & Node.DOCUMENT_POSITION_FOLLOWING)
1029
+ ).toBe(true)
1030
+ expect(
1031
+ Boolean(xAxisPositionLabel.compareDocumentPosition(settingsHeading) & Node.DOCUMENT_POSITION_FOLLOWING)
1032
+ ).toBe(true)
1033
+ expect(screen.getByText('Show Cell Values')).toBeTruthy()
1034
+ expect(screen.getByText('Cell Padding')).toBeTruthy()
1035
+ expect(screen.getByText('Row Label Gap')).toBeTruthy()
1036
+ expect(screen.getByText('Column Label Gap')).toBeTruthy()
1037
+ expect(screen.getByLabelText(/Data Grouping/i)).toBeTruthy()
1038
+ expect(screen.getByText('Displaying Rows')).toBeTruthy()
1039
+ })
1040
+ })