@cdc/chart 4.26.4 → 4.26.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/CONFIG.md +353 -29
  2. package/LICENSE +201 -0
  3. package/dist/{cdcchart-DQ00cQCm.es.js → cdcchart-BWVIUcAF.es.js} +5 -55
  4. package/dist/cdcchart.js +37885 -35117
  5. package/examples/feature/heatmap/average-age-categorical.json +313 -0
  6. package/examples/feature/heatmap/calendar-heatmap.json +134 -0
  7. package/examples/feature/heatmap/cell-values-bottom-axis.json +181 -0
  8. package/examples/feature/heatmap/sparse-aggregation.json +158 -0
  9. package/examples/private/bad-y-axis.json +543 -0
  10. package/examples/private/bar-chart-negative-value.json +455 -0
  11. package/examples/private/bump-chart.json +1116 -0
  12. package/examples/private/test-123.json +624 -0
  13. package/package.json +4 -3
  14. package/src/CdcChart.tsx +5 -4
  15. package/src/CdcChartComponent.tsx +166 -62
  16. package/src/_stories/Chart.AltText.stories.tsx +136 -0
  17. package/src/_stories/Chart.HeatMap.stories.tsx +187 -0
  18. package/src/_stories/Chart.Sankey.smoke.stories.tsx +62 -0
  19. package/src/_stories/Chart.SeriesColorAssignments.stories.tsx +183 -0
  20. package/src/_stories/Chart.SmallMultiples.smoke.stories.tsx +29 -0
  21. package/src/_stories/Chart.YAxisAutoMaxStrategy.stories.tsx +52 -0
  22. package/src/_stories/Chart.YAxisFilterDomain.stories.tsx +90 -0
  23. package/src/_stories/ChartAnnotation.smoke.stories.tsx +10 -0
  24. package/src/_stories/ChartAxisTitles.smoke.stories.tsx +13 -0
  25. package/src/_stories/ChartBar.Editor.stories.tsx +63 -0
  26. package/src/_stories/ChartBrush.Interaction.stories.tsx +112 -0
  27. package/src/_stories/ChartBrush.smoke.stories.tsx +13 -4
  28. package/src/_stories/_mock/alt_text_metadata.json +38 -0
  29. package/src/_stories/_mock/annotation_event-line_mock.json +1064 -0
  30. package/src/_stories/_mock/heatmap-average-age-categorical.json +315 -0
  31. package/src/_stories/_mock/heatmap.json +156 -0
  32. package/src/_stories/_mock/reported_pertussis_cases.json +1643 -0
  33. package/src/_stories/_mock/sankey_mock.json +78 -0
  34. package/src/_stories/_mock/small_multiples/small_multiples_lines.json +1 -1
  35. package/src/components/Annotations/components/AnnotationDraggable.styles.css +22 -1
  36. package/src/components/Annotations/components/AnnotationDraggable.tsx +220 -120
  37. package/src/components/Annotations/components/AnnotationDropdown.tsx +5 -2
  38. package/src/components/Annotations/tests/AnnotationDraggable.test.tsx +198 -0
  39. package/src/components/Axis/LeftAxis.tsx +17 -11
  40. package/src/components/Axis/LeftAxisGridlines.tsx +22 -12
  41. package/src/components/Axis/RightAxis.tsx +3 -0
  42. package/src/components/Axis/tests/AxisTickValues.test.tsx +86 -0
  43. package/src/components/Brush/BrushSelector.tsx +88 -187
  44. package/src/components/Brush/brushInteraction.ts +22 -0
  45. package/src/components/Brush/tests/brushInteraction.test.ts +38 -0
  46. package/src/components/EditorPanel/EditorPanel.tsx +677 -185
  47. package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +114 -52
  48. package/src/components/EditorPanel/components/Panels/Panel.General.tsx +105 -13
  49. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +1 -0
  50. package/src/components/EditorPanel/components/Panels/Panel.Regions.tsx +5 -0
  51. package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +2 -2
  52. package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.test.tsx +98 -0
  53. package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.tsx +10 -0
  54. package/src/components/EditorPanel/components/Panels/Panel.Visual.test.tsx +484 -0
  55. package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +4 -1
  56. package/src/components/EditorPanel/components/Panels/SeriesColorAssignments.tsx +262 -0
  57. package/src/components/EditorPanel/components/Panels/panelVisual.styles.css +32 -0
  58. package/src/components/EditorPanel/components/panels.scss +10 -0
  59. package/src/components/EditorPanel/editor-panel.scss +47 -0
  60. package/src/components/EditorPanel/helpers/getVisualizationTypeConfigUpdate.ts +61 -0
  61. package/src/components/EditorPanel/helpers/tests/getVisualizationTypeConfigUpdate.test.ts +74 -0
  62. package/src/components/EditorPanel/useEditorPermissions.test.tsx +315 -0
  63. package/src/components/EditorPanel/useEditorPermissions.ts +77 -21
  64. package/src/components/HeatMap/components/HeatMap.tsx +767 -0
  65. package/src/components/HeatMap/components/HeatMapGradientLegend.tsx +128 -0
  66. package/src/components/HeatMap/heatmap.constants.ts +13 -0
  67. package/src/components/HeatMap/heatmap.css +85 -0
  68. package/src/components/HeatMap/helpers/index.ts +261 -0
  69. package/src/components/HeatMap/index.tsx +5 -0
  70. package/src/components/HeatMap/tests/HeatMap.test.tsx +1072 -0
  71. package/src/components/HeatMap/tests/helpers.test.ts +167 -0
  72. package/src/components/Legend/Legend.Component.tsx +2 -2
  73. package/src/components/Legend/helpers/createFormatLabels.test.ts +88 -0
  74. package/src/components/Legend/helpers/createFormatLabels.tsx +6 -1
  75. package/src/components/LineChart/components/LineChart.BumpCircle.tsx +19 -22
  76. package/src/components/LineChart/helpers.test.ts +574 -0
  77. package/src/components/LineChart/helpers.ts +39 -16
  78. package/src/components/LineChart/index.tsx +28 -5
  79. package/src/components/LinearChart/tests/LinearChart.test.tsx +137 -33
  80. package/src/components/LinearChart/tests/mockConfigContext.ts +1 -0
  81. package/src/components/LinearChart.tsx +103 -47
  82. package/src/components/PieChart/PieChart.tsx +4 -1
  83. package/src/components/RadarChart/RadarChart.tsx +3 -1
  84. package/src/components/Regions/components/Regions.tsx +195 -41
  85. package/src/components/Sankey/components/Sankey.tsx +7 -3
  86. package/src/components/Sankey/helpers/getSankeyTooltip.tsx +7 -6
  87. package/src/components/ScatterPlot/ScatterPlot.jsx +3 -3
  88. package/src/components/SmallMultiples/SmallMultipleTile.tsx +33 -3
  89. package/src/components/SmallMultiples/SmallMultiples.css +12 -0
  90. package/src/components/SmallMultiples/SmallMultiples.tsx +50 -15
  91. package/src/components/SmallMultiples/tests/SmallMultipleTile.test.tsx +152 -0
  92. package/src/components/SmallMultiples/tests/SmallMultiples.test.tsx +170 -0
  93. package/src/components/Sparkline/components/SparkLine.tsx +4 -9
  94. package/src/components/WarmingStripes/WarmingStripesGradientLegend.css +1 -1
  95. package/src/components/WarmingStripes/WarmingStripesGradientLegend.tsx +2 -2
  96. package/src/data/initial-state.js +12 -0
  97. package/src/helpers/axisLabelFontSize.ts +8 -0
  98. package/src/helpers/bumpChartTooltip.ts +54 -0
  99. package/src/helpers/calculateLeftYAxisWidth.ts +13 -3
  100. package/src/helpers/colorAssignmentHelpers.ts +102 -0
  101. package/src/helpers/dataHelpers.ts +11 -1
  102. package/src/helpers/ensureSpecialChartAxisTypes.ts +11 -0
  103. package/src/helpers/getAxisMaxOverride.ts +16 -0
  104. package/src/helpers/getCleanTopTickMax.ts +64 -0
  105. package/src/helpers/getColorScale.ts +18 -9
  106. package/src/helpers/getHasBoundarySuppression.ts +26 -0
  107. package/src/helpers/getMinMax.ts +26 -25
  108. package/src/helpers/getYAxisDomainData.ts +38 -0
  109. package/src/helpers/getYAxisFinalizationEligibility.ts +40 -0
  110. package/src/helpers/handleChartAriaLabels.ts +23 -10
  111. package/src/helpers/hasSpacedInlineLabel.ts +10 -0
  112. package/src/helpers/seriesColumnSettings.ts +5 -4
  113. package/src/helpers/seriesHighlight.ts +26 -0
  114. package/src/helpers/smallMultiplesHelpers.ts +5 -0
  115. package/src/helpers/tests/axisLabelFontSize.test.ts +15 -0
  116. package/src/helpers/tests/bumpChartTooltip.test.ts +75 -0
  117. package/src/helpers/tests/calculateLeftYAxisWidth.test.ts +38 -0
  118. package/src/helpers/tests/dataHelpers.test.ts +135 -0
  119. package/src/helpers/tests/ensureSpecialChartAxisTypes.test.ts +30 -0
  120. package/src/helpers/tests/getAxisMaxOverride.test.ts +36 -0
  121. package/src/helpers/tests/getCleanTopTickMax.test.ts +165 -0
  122. package/src/helpers/tests/getColorScale.test.ts +197 -0
  123. package/src/helpers/tests/getMinMax.test.ts +211 -0
  124. package/src/helpers/tests/getYAxisDomainData.test.ts +119 -0
  125. package/src/helpers/tests/getYAxisFinalizationEligibility.test.ts +185 -0
  126. package/src/helpers/tests/handleChartAriaLabels.test.ts +71 -0
  127. package/src/helpers/tests/hasSpacedInlineLabel.test.ts +75 -0
  128. package/src/helpers/tests/seriesColumnSettings.test.ts +16 -2
  129. package/src/helpers/tests/seriesHighlight.test.ts +61 -0
  130. package/src/helpers/tests/smallMultiplesHelpers.test.ts +99 -0
  131. package/src/hooks/tests/useRightAxis.test.tsx +127 -0
  132. package/src/hooks/tests/useScales.test.tsx +383 -0
  133. package/src/hooks/useRightAxis.ts +30 -13
  134. package/src/hooks/useScales.ts +48 -21
  135. package/src/hooks/useTooltip.tsx +3 -1
  136. package/src/index.jsx +5 -2
  137. package/src/scss/main.scss +32 -49
  138. package/src/test/CdcChart.dataTable.test.tsx +122 -0
  139. package/src/types/ChartConfig.ts +24 -1
  140. package/src/types/ChartContext.ts +5 -0
  141. package/src/helpers/getYAxisAutoPadding.ts +0 -53
@@ -0,0 +1,1072 @@
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: 'linear blocks',
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: 'linear blocks',
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 rows = [
457
+ { month: '2024-02-01', North: 5, South: 7, notes: 'Severe', population: 2500 },
458
+ { month: '2024-01-01', North: 2, South: 4, notes: 'Low', population: 1000 }
459
+ ]
460
+
461
+ context.config.data = rows
462
+ context.config.filteredData = rows
463
+ context.config.excludedData = rows
464
+ context.filteredData = rows
465
+ context.excludedData = rows
466
+ context.rawData = rows
467
+ context.tableData = rows
468
+ ;(context.config as any).columns.population = {
469
+ label: 'Population',
470
+ tooltips: true,
471
+ dataTable: true,
472
+ prefix: '$',
473
+ suffix: ' residents',
474
+ roundToPlace: 0,
475
+ commas: true
476
+ }
477
+
478
+ const { container } = render(
479
+ <ConfigContext.Provider value={context}>
480
+ <HeatMap parentWidth={800} parentHeight={320} />
481
+ </ConfigContext.Provider>
482
+ )
483
+
484
+ const cells = container.querySelectorAll('.visx-heatmap-rect')
485
+ const tooltipHtml = cells[0]?.getAttribute('data-tooltip-html') || ''
486
+
487
+ expect(cells.length).toBeGreaterThan(0)
488
+ expect(tooltipHtml).toContain('tooltip-heading')
489
+ expect(tooltipHtml).toContain('tooltip-body')
490
+ expect(tooltipHtml).toContain('Month: Tooltip 2024-01-01')
491
+ expect(tooltipHtml).toContain('Region:')
492
+ expect(tooltipHtml).toContain('Value:')
493
+ expect(tooltipHtml).toContain('Notes')
494
+ expect(tooltipHtml).toContain('Population: $1,000 residents')
495
+ expect(tooltipHtml).not.toContain('<br/>')
496
+ expect(cells[0]?.getAttribute('tabindex')).toBe('0')
497
+ expect(cells[0]?.getAttribute('aria-label')).toContain('Month: Tooltip 2024-01-01')
498
+ expect(cells[0]?.getAttribute('aria-label')).toContain('Region:')
499
+ })
500
+
501
+ it('summarizes aggregated cell tooltip columns when duplicate cells have different source values', () => {
502
+ const context = buildHeatMapContext()
503
+ const duplicateRows = [
504
+ { month: '2024-01-01', North: 2, South: 4, notes: 'Low' },
505
+ { month: '2024-01-01', North: 3, South: 1, notes: 'Severe' }
506
+ ]
507
+
508
+ context.config.data = duplicateRows
509
+ context.config.filteredData = duplicateRows
510
+ context.config.excludedData = duplicateRows
511
+ context.filteredData = duplicateRows
512
+ context.excludedData = duplicateRows
513
+ context.rawData = duplicateRows
514
+ context.tableData = duplicateRows
515
+
516
+ const { container } = render(
517
+ <ConfigContext.Provider value={context}>
518
+ <HeatMap parentWidth={800} parentHeight={320} />
519
+ </ConfigContext.Provider>
520
+ )
521
+
522
+ const firstCellTooltipHtml = container.querySelector('.visx-heatmap-rect')?.getAttribute('data-tooltip-html') || ''
523
+
524
+ expect(firstCellTooltipHtml).toContain('Value: 5')
525
+ expect(firstCellTooltipHtml).toContain('Aggregated Rows: 2')
526
+ expect(firstCellTooltipHtml).toContain('Notes: Multiple values')
527
+ })
528
+
529
+ it('uses HeatMap stylesheet classes for cell paint hooks', () => {
530
+ const context = buildHeatMapContext()
531
+ const sparseRows = [{ month: '2024-01-01', North: 2, notes: 'Low' }]
532
+
533
+ context.config.data = sparseRows
534
+ context.config.filteredData = sparseRows
535
+ context.config.excludedData = sparseRows
536
+ context.filteredData = sparseRows
537
+ context.excludedData = sparseRows
538
+ context.rawData = sparseRows
539
+ context.tableData = sparseRows
540
+
541
+ const { container } = render(
542
+ <ConfigContext.Provider value={context}>
543
+ <HeatMap parentWidth={800} parentHeight={320} />
544
+ </ConfigContext.Provider>
545
+ )
546
+
547
+ const cells = Array.from(container.querySelectorAll('.visx-heatmap-rect'))
548
+ const emptyCell = cells.find(cell => cell.getAttribute('aria-label')?.includes('South'))
549
+
550
+ expect(cells[0]).toHaveClass('cdc-heatmap__cell')
551
+ expect(emptyCell).toHaveClass('cdc-heatmap__cell--empty')
552
+ expect(emptyCell?.getAttribute('fill')).toBeNull()
553
+ })
554
+
555
+ it('keeps cell dimensions non-negative while the responsive layout is measuring', () => {
556
+ const context = buildHeatMapContext()
557
+ const { container } = render(
558
+ <ConfigContext.Provider value={context}>
559
+ <HeatMap parentWidth={0} parentHeight={0} />
560
+ </ConfigContext.Provider>
561
+ )
562
+
563
+ const cells = Array.from(container.querySelectorAll('.visx-heatmap-rect'))
564
+
565
+ expect(cells.length).toBeGreaterThan(0)
566
+ cells.forEach(cell => {
567
+ expect(Number(cell.getAttribute('width'))).toBeGreaterThanOrEqual(0)
568
+ expect(Number(cell.getAttribute('height'))).toBeGreaterThanOrEqual(0)
569
+ })
570
+ })
571
+
572
+ it('renders a gradient legend for the mapped value column', () => {
573
+ const context = buildHeatMapContext()
574
+ ;(context.config as any).heatmap.colorBucketCount = 5
575
+
576
+ const { container } = render(
577
+ <ConfigContext.Provider value={context}>
578
+ <HeatMapGradientLegend />
579
+ </ConfigContext.Provider>
580
+ )
581
+
582
+ const rangeLabels = Array.from(container.querySelectorAll('.cdc-heatmap__legend-range-label')).map(label =>
583
+ label.textContent?.trim()
584
+ )
585
+
586
+ expect(screen.getByText('Value')).toBeTruthy()
587
+ expect(rangeLabels).toEqual(['2\u20133', '3\u20134', '4\u20135', '5\u20136', '6\u20137'])
588
+ })
589
+
590
+ it('renders HeatMap gradient legends as linear blocks', () => {
591
+ const context = buildHeatMapContext()
592
+
593
+ const { container } = render(
594
+ <ConfigContext.Provider value={context}>
595
+ <HeatMapGradientLegend />
596
+ </ConfigContext.Provider>
597
+ )
598
+
599
+ expect(container.querySelector('linearGradient')).toBeNull()
600
+ expect(container.querySelectorAll('.cdc-heatmap__legend-svg rect').length).toBeGreaterThan(2)
601
+ })
602
+
603
+ it('renders non-gradient HeatMap legends with range items', () => {
604
+ const context = buildHeatMapContext()
605
+ ;(context.config as any).legend.style = 'boxes'
606
+
607
+ const { container } = render(
608
+ <ConfigContext.Provider value={context}>
609
+ <HeatMapGradientLegend />
610
+ </ConfigContext.Provider>
611
+ )
612
+
613
+ expect(container.querySelector('.legend-container.cdc-heatmap__legend')).toBeTruthy()
614
+ expect(container.querySelectorAll('.legend-item.not-clickable').length).toBeGreaterThan(0)
615
+ expect(container.querySelector('.cdc-heatmap__legend-svg')).toBeNull()
616
+ })
617
+
618
+ it('uses the configured HeatMap color bucket count for cells and legend ranges', () => {
619
+ const context = buildHeatMapContext()
620
+ ;(context.config as any).heatmap.colorBucketCount = 5
621
+ ;(context.config as any).legend.style = 'boxes'
622
+
623
+ const { container } = render(
624
+ <ConfigContext.Provider value={context}>
625
+ <>
626
+ <HeatMap parentWidth={800} parentHeight={320} />
627
+ <HeatMapGradientLegend />
628
+ </>
629
+ </ConfigContext.Provider>
630
+ )
631
+
632
+ const cellFills = Array.from(container.querySelectorAll('.visx-heatmap-rect'))
633
+ .map(cell => cell.getAttribute('fill'))
634
+ .filter(Boolean)
635
+ const legendFills = Array.from(
636
+ container.querySelectorAll<HTMLElement>('.cdc-heatmap__legend .legend-item.not-clickable > .legend-item')
637
+ )
638
+ .map(shape => shape.style.backgroundColor)
639
+ .filter(Boolean)
640
+
641
+ expect(new Set(cellFills).size).toBeLessThanOrEqual(5)
642
+ expect(new Set(legendFills).size).toBe(5)
643
+ expect(container.querySelectorAll('.legend-item.not-clickable')).toHaveLength(5)
644
+ })
645
+
646
+ it('renders series-driven rows when heatmap data series are configured', () => {
647
+ const context = buildSeriesModeHeatMapContext()
648
+ const { container } = render(
649
+ <ConfigContext.Provider value={context}>
650
+ <HeatMap parentWidth={800} parentHeight={320} />
651
+ </ConfigContext.Provider>
652
+ )
653
+
654
+ const cells = container.querySelectorAll('.visx-heatmap-rect')
655
+ expect(cells.length).toBe(4)
656
+ expect(cells[0]?.getAttribute('data-tooltip-html')).toContain('Austin')
657
+ expect(cells[1]?.getAttribute('data-tooltip-html')).toContain('Boston')
658
+ })
659
+
660
+ it('renders non-calendar categories for an average age by city heatmap', () => {
661
+ const context = buildCategoricalAverageAgeHeatMapContext()
662
+ const { container } = render(
663
+ <ConfigContext.Provider value={context}>
664
+ <HeatMap parentWidth={800} parentHeight={320} />
665
+ </ConfigContext.Provider>
666
+ )
667
+
668
+ const cells = container.querySelectorAll('.visx-heatmap-rect')
669
+ const firstCellTooltipHtml = cells[0]?.getAttribute('data-tooltip-html') || ''
670
+ const firstCellAriaLabel = cells[0]?.getAttribute('aria-label') || ''
671
+ const firstCellX = Number(cells[0]?.getAttribute('x'))
672
+ const xAxisTitle = Array.from(container.querySelectorAll('text')).find(
673
+ text => text.textContent === 'Community Type'
674
+ )
675
+ const xAxisTitleX = Number(xAxisTitle?.getAttribute('x'))
676
+
677
+ expect(cells).toHaveLength(9)
678
+ expect(firstCellX).toBeGreaterThan(20)
679
+ expect(firstCellX).toBeLessThan(90)
680
+ expect(xAxisTitleX).toBeGreaterThan(firstCellX)
681
+ expect(xAxisTitleX).toBeLessThan(180)
682
+ expect(firstCellTooltipHtml).toContain('Community Type: Urban Core')
683
+ expect(firstCellTooltipHtml).toContain('City: Atlanta')
684
+ expect(firstCellTooltipHtml).toContain('Average age: 34')
685
+ expect(firstCellTooltipHtml).toContain('Notes: Dense city center')
686
+ expect(firstCellTooltipHtml).not.toContain('Tooltip')
687
+ expect(firstCellAriaLabel).toContain('Community Type: Urban Core')
688
+ expect(firstCellAriaLabel).toContain('City: Atlanta')
689
+ })
690
+
691
+ it('uses customized series column labels and formatting in heatmap cells and tooltips', () => {
692
+ const context = buildCategoricalAverageAgeHeatMapContext()
693
+ ;(context.config as any).columns.Atlanta.label = 'ATL'
694
+ ;(context.config as any).columns.Atlanta.prefix = '$'
695
+ ;(context.config as any).columns.Atlanta.suffix = ' yrs'
696
+ ;(context.config as any).heatmap.showCellValues = true
697
+
698
+ const { container } = render(
699
+ <ConfigContext.Provider value={context}>
700
+ <HeatMap parentWidth={800} parentHeight={320} />
701
+ </ConfigContext.Provider>
702
+ )
703
+
704
+ const firstCell = container.querySelector('.visx-heatmap-rect')
705
+ const firstCellTooltipHtml = firstCell?.getAttribute('data-tooltip-html') || ''
706
+ const firstCellAriaLabel = firstCell?.getAttribute('aria-label') || ''
707
+
708
+ expect(firstCellTooltipHtml).toContain('City: ATL')
709
+ expect(firstCellTooltipHtml).toContain('Average age: $34 yrs')
710
+ expect(firstCellAriaLabel).toContain('City: ATL')
711
+ expect(firstCellAriaLabel).toContain('Average age: $34 yrs')
712
+ expect(screen.getByText('$34 yrs')).toBeTruthy()
713
+ })
714
+
715
+ it('honors heatmap series tooltip visibility', () => {
716
+ const context = buildCategoricalAverageAgeHeatMapContext()
717
+ ;(context.config as any).series[0].tooltip = false
718
+
719
+ const { container } = render(
720
+ <ConfigContext.Provider value={context}>
721
+ <HeatMap parentWidth={800} parentHeight={320} />
722
+ </ConfigContext.Provider>
723
+ )
724
+
725
+ const cells = container.querySelectorAll('.visx-heatmap-rect')
726
+ const hiddenSeriesTooltipHtml = cells[0]?.getAttribute('data-tooltip-html') || ''
727
+ const visibleSeriesTooltipHtml = cells[1]?.getAttribute('data-tooltip-html') || ''
728
+
729
+ expect(hiddenSeriesTooltipHtml).toContain('Community Type: Urban Core')
730
+ expect(hiddenSeriesTooltipHtml).not.toContain('City: Atlanta')
731
+ expect(hiddenSeriesTooltipHtml).not.toContain('Average age: 34')
732
+ expect(visibleSeriesTooltipHtml).toContain('City: Chicago')
733
+ expect(visibleSeriesTooltipHtml).toContain('Average age: 36')
734
+ })
735
+
736
+ it('applies y-axis tick rotation to row labels when configured', () => {
737
+ const context = buildCategoricalAverageAgeHeatMapContext()
738
+ ;(context.config as any).yAxis.tickRotation = 30
739
+
740
+ const { container } = render(
741
+ <ConfigContext.Provider value={context}>
742
+ <HeatMap parentWidth={800} parentHeight={320} />
743
+ </ConfigContext.Provider>
744
+ )
745
+
746
+ const rowLabel = Array.from(container.querySelectorAll('text')).find(text => text.textContent === 'Atlanta')
747
+
748
+ expect(rowLabel?.getAttribute('transform')).toContain('rotate(-30')
749
+ })
750
+
751
+ it('renders top y-axis titles above the heatmap and reserves layout space', () => {
752
+ const sideContext = buildCategoricalAverageAgeHeatMapContext()
753
+ const topContext = buildCategoricalAverageAgeHeatMapContext()
754
+ ;(topContext.config as any).yAxis.titlePlacement = 'top'
755
+
756
+ const { container: sideContainer } = render(
757
+ <ConfigContext.Provider value={sideContext}>
758
+ <HeatMap parentWidth={800} parentHeight={320} />
759
+ </ConfigContext.Provider>
760
+ )
761
+
762
+ const { container } = render(
763
+ <ConfigContext.Provider value={topContext}>
764
+ <HeatMap parentWidth={800} parentHeight={320} />
765
+ </ConfigContext.Provider>
766
+ )
767
+
768
+ const yAxisTitle = Array.from(container.querySelectorAll('.cdc-heatmap__axis-title')).find(
769
+ text => text.textContent === 'City'
770
+ )
771
+ const xAxisTitle = Array.from(container.querySelectorAll('.cdc-heatmap__axis-title')).find(
772
+ text => text.textContent === 'Community Type'
773
+ )
774
+ const rotatedYAxisTitles = Array.from(container.querySelectorAll('.cdc-heatmap__axis-title')).filter(
775
+ text => text.textContent === 'City' && text.getAttribute('transform')?.includes('rotate(-90)')
776
+ )
777
+
778
+ expect(yAxisTitle?.getAttribute('transform')).toBeNull()
779
+ expect(yAxisTitle?.getAttribute('text-anchor')).toBe('start')
780
+ expect(yAxisTitle?.getAttribute('y')).toBe(xAxisTitle?.getAttribute('y'))
781
+ expect(getHeatMapPlotTop(container) - getHeatMapPlotTop(sideContainer)).toBeCloseTo(28, 1)
782
+ expect(rotatedYAxisTitles).toHaveLength(0)
783
+ })
784
+
785
+ it('anchors top y-axis titles to the row labels instead of the outer margin', () => {
786
+ const context = buildCategoricalAverageAgeHeatMapContext()
787
+ ;(context.config as any).yAxis.titlePlacement = 'top'
788
+ ;(context.config as any).yAxis.size = 180
789
+
790
+ const { container } = render(
791
+ <ConfigContext.Provider value={context}>
792
+ <HeatMap parentWidth={800} parentHeight={320} />
793
+ </ConfigContext.Provider>
794
+ )
795
+
796
+ const plot = container.querySelector('.cdc-heatmap__plot')
797
+ const yAxisTitle = Array.from(container.querySelectorAll('.cdc-heatmap__axis-title')).find(
798
+ text => text.textContent === 'City'
799
+ )
800
+ const titleX = Number(yAxisTitle?.getAttribute('x'))
801
+
802
+ expect(titleX).toBeLessThanOrEqual(0)
803
+ expect(titleX).toBeGreaterThan(-100)
804
+ expect(getTranslateX(plot) + titleX).toBeGreaterThan(getTranslateX(plot) - 100)
805
+ })
806
+
807
+ it('keeps side y-axis titles near row labels when extra y-axis size is reserved', () => {
808
+ const context = buildCategoricalAverageAgeHeatMapContext()
809
+ ;(context.config as any).yAxis.titlePlacement = 'side'
810
+ ;(context.config as any).yAxis.size = 180
811
+
812
+ const { container } = render(
813
+ <ConfigContext.Provider value={context}>
814
+ <HeatMap parentWidth={800} parentHeight={320} />
815
+ </ConfigContext.Provider>
816
+ )
817
+
818
+ const yAxisTitle = Array.from(container.querySelectorAll('.cdc-heatmap__axis-title')).find(
819
+ text => text.textContent === 'City'
820
+ )
821
+ const titleX = getTranslateX(yAxisTitle)
822
+
823
+ expect(titleX).toBeLessThan(0)
824
+ expect(titleX).toBeGreaterThan(-100)
825
+ })
826
+
827
+ it('centers side y-axis titles on short wide heatmap grids', () => {
828
+ const context = buildCategoricalAverageAgeHeatMapContext()
829
+ const months = [
830
+ 'January',
831
+ 'February',
832
+ 'March',
833
+ 'April',
834
+ 'May',
835
+ 'June',
836
+ 'July',
837
+ 'August',
838
+ 'September',
839
+ 'October',
840
+ 'November',
841
+ 'December'
842
+ ]
843
+ const calendarRows = Array.from({ length: 31 }, (_, dayIndex) =>
844
+ months.reduce<Record<string, number>>(
845
+ (row, month, monthIndex) => {
846
+ row[month] = dayIndex + monthIndex + 1
847
+ return row
848
+ },
849
+ { day: dayIndex + 1 }
850
+ )
851
+ )
852
+ const calendarSeries = months.map(month => ({
853
+ dataKey: month,
854
+ name: month,
855
+ type: 'HeatMap',
856
+ axis: 'Left',
857
+ tooltip: true
858
+ }))
859
+ const config = context.config as any
860
+
861
+ config.data = calendarRows
862
+ config.filteredData = calendarRows
863
+ config.excludedData = calendarRows
864
+ config.xAxis = {
865
+ ...config.xAxis,
866
+ type: 'categorical',
867
+ dataKey: 'day',
868
+ label: 'Day of Month',
869
+ tickRotation: 0,
870
+ maxTickRotation: 0
871
+ }
872
+ config.yAxis = {
873
+ ...config.yAxis,
874
+ label: 'Month',
875
+ size: 120,
876
+ titlePlacement: 'side'
877
+ }
878
+ config.series = calendarSeries
879
+ config.runtime = {
880
+ ...config.runtime,
881
+ xAxis: { type: 'categorical', dataKey: 'day', label: 'Day of Month' },
882
+ yAxis: { type: 'categorical', label: 'Month' },
883
+ originalXAxis: { dataKey: 'day' },
884
+ seriesKeys: months,
885
+ seriesLabels: Object.fromEntries(months.map(month => [month, month])),
886
+ seriesLabelsAll: months
887
+ }
888
+
889
+ context.filteredData = calendarRows
890
+ context.excludedData = calendarRows
891
+ context.rawData = calendarRows
892
+ context.tableData = calendarRows
893
+ context.transformedData = calendarRows
894
+
895
+ const { container } = render(
896
+ <ConfigContext.Provider value={context}>
897
+ <HeatMap parentWidth={800} parentHeight={840} />
898
+ </ConfigContext.Provider>
899
+ )
900
+
901
+ const yAxisTitle = Array.from(container.querySelectorAll('.cdc-heatmap__axis-title')).find(
902
+ text => text.textContent === 'Month'
903
+ )
904
+ const cells = Array.from(container.querySelectorAll('.visx-heatmap-rect'))
905
+ const gridTop = Math.min(...cells.map(cell => Number(cell.getAttribute('y'))))
906
+ const gridBottom = Math.max(
907
+ ...cells.map(cell => Number(cell.getAttribute('y')) + Number(cell.getAttribute('height')))
908
+ )
909
+ const gridCenter = (gridTop + gridBottom) / 2
910
+
911
+ expect(Math.abs(getTranslateY(yAxisTitle) - gridCenter)).toBeLessThanOrEqual(1)
912
+ })
913
+
914
+ it('applies x-axis tick rotation to column labels when configured', () => {
915
+ const context = buildCategoricalAverageAgeHeatMapContext()
916
+ ;(context.config as any).xAxis.tickRotation = 30
917
+
918
+ const { container } = render(
919
+ <ConfigContext.Provider value={context}>
920
+ <HeatMap parentWidth={800} parentHeight={320} />
921
+ </ConfigContext.Provider>
922
+ )
923
+
924
+ const columnLabel = Array.from(container.querySelectorAll('text')).find(text => text.textContent === 'Urban Core')
925
+
926
+ expect(columnLabel?.getAttribute('transform')).toContain('rotate(-30')
927
+ expect(columnLabel?.getAttribute('text-anchor')).toBe('start')
928
+ })
929
+
930
+ it('places the x-axis below the heatmap when configured', () => {
931
+ const context = buildCategoricalAverageAgeHeatMapContext()
932
+ ;(context.config as any).heatmap.xAxisPosition = 'bottom'
933
+ ;(context.config as any).heatmap.columnLabelGap = 44
934
+ ;(context.config as any).xAxis.tickRotation = 135
935
+
936
+ const { container } = render(
937
+ <ConfigContext.Provider value={context}>
938
+ <HeatMap parentWidth={800} parentHeight={320} />
939
+ </ConfigContext.Provider>
940
+ )
941
+
942
+ const columnLabel = Array.from(container.querySelectorAll('text')).find(text => text.textContent === 'Urban Core')
943
+ const xAxisTitle = Array.from(container.querySelectorAll('.cdc-heatmap__axis-title')).find(
944
+ text => text.textContent === 'Community Type'
945
+ )
946
+ const axisBottomTop = getTranslateY(container.querySelector('.visx-axis-bottom'))
947
+ const columnLabelAbsoluteY = axisBottomTop + Number(columnLabel?.getAttribute('y'))
948
+ const xAxisTitleY = Number(xAxisTitle?.getAttribute('y'))
949
+
950
+ expect(container.querySelector('.visx-axis-bottom')).toBeTruthy()
951
+ expect(axisBottomTop).toBeGreaterThan(0)
952
+ expect(Number(columnLabel?.getAttribute('y'))).toBe(44)
953
+ expect(columnLabel?.getAttribute('text-anchor')).toBe('end')
954
+ expect(xAxisTitleY - columnLabelAbsoluteY).toBeGreaterThan(50)
955
+ })
956
+
957
+ it('centers 90-degree x-axis labels on their tick markers', () => {
958
+ const context = buildCategoricalAverageAgeHeatMapContext()
959
+ ;(context.config as any).heatmap.xAxisPosition = 'bottom'
960
+ ;(context.config as any).xAxis.tickRotation = 90
961
+
962
+ const { container } = render(
963
+ <ConfigContext.Provider value={context}>
964
+ <HeatMap parentWidth={800} parentHeight={320} />
965
+ </ConfigContext.Provider>
966
+ )
967
+
968
+ const columnLabel = Array.from(container.querySelectorAll('text')).find(text => text.textContent === 'Urban Core')
969
+
970
+ expect(columnLabel?.getAttribute('transform')).toContain('rotate(-90')
971
+ expect(columnLabel?.parentElement?.getAttribute('x')).toBe('0')
972
+ expect(columnLabel?.querySelector('tspan')?.getAttribute('dy')).toBe('0.355em')
973
+ })
974
+
975
+ it('renders readable cell values when HeatMap cell labels are enabled', () => {
976
+ const context = buildCategoricalAverageAgeHeatMapContext()
977
+ ;(context.config as any).heatmap.showCellValues = true
978
+
979
+ const { container } = render(
980
+ <ConfigContext.Provider value={context}>
981
+ <HeatMap parentWidth={800} parentHeight={320} />
982
+ </ConfigContext.Provider>
983
+ )
984
+
985
+ const cellValues = Array.from(container.querySelectorAll('.cdc-heatmap__cell-value'))
986
+
987
+ expect(cellValues.some(label => label.textContent === '34')).toBe(true)
988
+ expect(cellValues.every(label => ['#1c1d1f', '#fff'].includes(label.getAttribute('fill') || ''))).toBe(true)
989
+ })
990
+
991
+ it('uses the HeatMap row label gap setting to tune spacing between row labels and cells', () => {
992
+ const context = buildCategoricalAverageAgeHeatMapContext()
993
+ ;(context.config as any).heatmap.rowLabelGap = 18
994
+
995
+ const { container } = render(
996
+ <ConfigContext.Provider value={context}>
997
+ <HeatMap parentWidth={800} parentHeight={320} />
998
+ </ConfigContext.Provider>
999
+ )
1000
+
1001
+ const firstCell = container.querySelector('.visx-heatmap-rect')
1002
+
1003
+ expect(Number(firstCell?.getAttribute('x'))).toBeCloseTo(18, 1)
1004
+ })
1005
+
1006
+ it('uses the HeatMap column label gap setting to tune spacing above the first row', () => {
1007
+ const context = buildCategoricalAverageAgeHeatMapContext()
1008
+ ;(context.config as any).heatmap.columnLabelGap = 44
1009
+
1010
+ const { container } = render(
1011
+ <ConfigContext.Provider value={context}>
1012
+ <HeatMap parentWidth={800} parentHeight={320} />
1013
+ </ConfigContext.Provider>
1014
+ )
1015
+
1016
+ const columnLabel = Array.from(container.querySelectorAll('text')).find(text => text.textContent === 'Urban Core')
1017
+
1018
+ expect(columnLabel?.getAttribute('y')).toBe('-44')
1019
+ })
1020
+
1021
+ it('shows HeatMap editor controls in their owning accordion sections', () => {
1022
+ const context = buildHeatMapContext()
1023
+
1024
+ render(
1025
+ <ConfigContext.Provider value={context}>
1026
+ <EditorPanel />
1027
+ </ConfigContext.Provider>
1028
+ )
1029
+
1030
+ const yAxisHeading = screen.getByText('Left Value Axis')
1031
+ const dateCategoryHeading = screen.getByText('Date/Category Axis')
1032
+ const settingsHeading = screen.getByText('HeatMap Settings')
1033
+ const legendHeading = screen.getByText('Legend')
1034
+ expect(yAxisHeading).toBeTruthy()
1035
+ expect(settingsHeading).toBeTruthy()
1036
+ expect(
1037
+ Boolean(dateCategoryHeading.compareDocumentPosition(settingsHeading) & Node.DOCUMENT_POSITION_FOLLOWING)
1038
+ ).toBe(true)
1039
+ const dataSeriesHeading = screen.getByText('Data Series')
1040
+ expect(dataSeriesHeading).toBeTruthy()
1041
+
1042
+ fireEvent.click(yAxisHeading)
1043
+ fireEvent.click(dateCategoryHeading)
1044
+ fireEvent.click(settingsHeading)
1045
+ fireEvent.click(dataSeriesHeading)
1046
+ fireEvent.click(legendHeading)
1047
+
1048
+ const xAxisPositionLabel = screen.getByText('X-Axis Position')
1049
+ const gradientStyleSelect = screen.getByLabelText('Gradient Style') as HTMLSelectElement
1050
+
1051
+ expect(screen.queryByText('Value Column')).toBeNull()
1052
+ expect(screen.getAllByText('Hide Axis')).toHaveLength(2)
1053
+ expect(screen.getAllByText('Hide Ticks')).toHaveLength(2)
1054
+ expect(screen.getAllByText('Tick rotation (Degrees)').length).toBeGreaterThan(0)
1055
+ expect(screen.getByLabelText('Label Placement')).toBeTruthy()
1056
+ expect(screen.getByText('Add Data Series')).toBeTruthy()
1057
+ expect(
1058
+ Boolean(dateCategoryHeading.compareDocumentPosition(xAxisPositionLabel) & Node.DOCUMENT_POSITION_FOLLOWING)
1059
+ ).toBe(true)
1060
+ expect(
1061
+ Boolean(xAxisPositionLabel.compareDocumentPosition(settingsHeading) & Node.DOCUMENT_POSITION_FOLLOWING)
1062
+ ).toBe(true)
1063
+ expect(screen.getByText('Show Cell Values')).toBeTruthy()
1064
+ expect(screen.getByText('Cell Padding')).toBeTruthy()
1065
+ expect(screen.getByText('Row Label Gap')).toBeTruthy()
1066
+ expect(screen.getByText('Column Label Gap')).toBeTruthy()
1067
+ expect(screen.getByLabelText(/Data Grouping/i)).toBeTruthy()
1068
+ expect(screen.getByText('Displaying Rows')).toBeTruthy()
1069
+ expect(Array.from(gradientStyleSelect.options).map(option => option.value)).toEqual(['linear blocks'])
1070
+ expect(screen.queryByLabelText('Tick Rotation (Degrees)')).toBeNull()
1071
+ })
1072
+ })