@cdc/chart 4.26.4 → 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.
- package/CONFIG.md +349 -29
- package/LICENSE +201 -0
- package/dist/cdcchart.js +34247 -32805
- package/examples/feature/heatmap/average-age-categorical.json +313 -0
- package/examples/feature/heatmap/calendar-heatmap.json +134 -0
- package/examples/feature/heatmap/cell-values-bottom-axis.json +181 -0
- package/examples/feature/heatmap/sparse-aggregation.json +158 -0
- package/examples/private/bump-chart.json +1116 -0
- package/examples/private/test-123.json +624 -0
- package/package.json +4 -3
- package/src/CdcChartComponent.tsx +135 -54
- package/src/_stories/Chart.AltText.stories.tsx +136 -0
- package/src/_stories/Chart.HeatMap.stories.tsx +187 -0
- package/src/_stories/Chart.Sankey.smoke.stories.tsx +62 -0
- package/src/_stories/Chart.SmallMultiples.smoke.stories.tsx +29 -0
- package/src/_stories/ChartAxisTitles.smoke.stories.tsx +13 -0
- package/src/_stories/ChartBar.Editor.stories.tsx +63 -0
- package/src/_stories/ChartBrush.Interaction.stories.tsx +112 -0
- package/src/_stories/ChartBrush.smoke.stories.tsx +13 -4
- package/src/_stories/_mock/alt_text_metadata.json +38 -0
- package/src/_stories/_mock/heatmap-average-age-categorical.json +315 -0
- package/src/_stories/_mock/heatmap.json +156 -0
- package/src/_stories/_mock/reported_pertussis_cases.json +1643 -0
- package/src/_stories/_mock/sankey_mock.json +78 -0
- package/src/_stories/_mock/small_multiples/small_multiples_lines.json +1 -1
- package/src/components/Annotations/components/AnnotationDraggable.tsx +1 -1
- package/src/components/Annotations/components/AnnotationDropdown.tsx +5 -2
- package/src/components/Axis/LeftAxis.tsx +14 -11
- package/src/components/Axis/LeftAxisGridlines.tsx +14 -11
- package/src/components/Brush/BrushSelector.tsx +88 -187
- package/src/components/Brush/brushInteraction.ts +22 -0
- package/src/components/Brush/tests/brushInteraction.test.ts +38 -0
- package/src/components/EditorPanel/EditorPanel.tsx +310 -49
- package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +13 -0
- package/src/components/EditorPanel/components/Panels/Panel.General.tsx +105 -13
- package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +1 -0
- package/src/components/EditorPanel/components/Panels/Panel.Regions.tsx +5 -0
- package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +2 -2
- package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +1 -1
- package/src/components/EditorPanel/helpers/getVisualizationTypeConfigUpdate.ts +57 -0
- package/src/components/EditorPanel/helpers/tests/getVisualizationTypeConfigUpdate.test.ts +56 -0
- package/src/components/EditorPanel/useEditorPermissions.test.tsx +70 -0
- package/src/components/EditorPanel/useEditorPermissions.ts +42 -22
- package/src/components/HeatMap/components/HeatMap.tsx +762 -0
- package/src/components/HeatMap/components/HeatMapGradientLegend.tsx +114 -0
- package/src/components/HeatMap/heatmap.constants.ts +13 -0
- package/src/components/HeatMap/heatmap.css +71 -0
- package/src/components/HeatMap/helpers/index.ts +261 -0
- package/src/components/HeatMap/index.tsx +5 -0
- package/src/components/HeatMap/tests/HeatMap.test.tsx +1040 -0
- package/src/components/HeatMap/tests/helpers.test.ts +167 -0
- package/src/components/LineChart/components/LineChart.BumpCircle.tsx +19 -22
- package/src/components/LineChart/helpers.test.ts +345 -0
- package/src/components/LineChart/helpers.ts +15 -5
- package/src/components/LineChart/index.tsx +7 -1
- package/src/components/LinearChart/tests/LinearChart.test.tsx +107 -33
- package/src/components/LinearChart/tests/mockConfigContext.ts +1 -0
- package/src/components/LinearChart.tsx +58 -37
- package/src/components/PieChart/PieChart.tsx +4 -1
- package/src/components/RadarChart/RadarChart.tsx +3 -1
- package/src/components/Regions/components/Regions.tsx +195 -41
- package/src/components/Sankey/components/Sankey.tsx +7 -3
- package/src/components/Sankey/helpers/getSankeyTooltip.tsx +7 -6
- package/src/components/SmallMultiples/SmallMultipleTile.tsx +30 -3
- package/src/components/SmallMultiples/SmallMultiples.css +12 -0
- package/src/components/SmallMultiples/SmallMultiples.tsx +15 -9
- package/src/components/SmallMultiples/tests/SmallMultipleTile.test.tsx +152 -0
- package/src/components/Sparkline/components/SparkLine.tsx +4 -9
- package/src/components/WarmingStripes/WarmingStripesGradientLegend.css +1 -1
- package/src/components/WarmingStripes/WarmingStripesGradientLegend.tsx +2 -2
- package/src/data/initial-state.js +11 -0
- package/src/helpers/axisLabelFontSize.ts +8 -0
- package/src/helpers/bumpChartTooltip.ts +54 -0
- package/src/helpers/calculateLeftYAxisWidth.ts +4 -0
- package/src/helpers/ensureSpecialChartAxisTypes.ts +11 -0
- package/src/helpers/getMinMax.ts +2 -3
- package/src/helpers/getYAxisAutoPadding.ts +8 -6
- package/src/helpers/getYAxisAutoPaddingMode.ts +17 -0
- package/src/helpers/handleChartAriaLabels.ts +23 -10
- package/src/helpers/seriesHighlight.ts +26 -0
- package/src/helpers/tests/axisLabelFontSize.test.ts +15 -0
- package/src/helpers/tests/bumpChartTooltip.test.ts +75 -0
- package/src/helpers/tests/ensureSpecialChartAxisTypes.test.ts +30 -0
- package/src/helpers/tests/getYAxisAutoPadding.test.ts +37 -0
- package/src/helpers/tests/getYAxisAutoPaddingMode.test.ts +90 -0
- package/src/helpers/tests/handleChartAriaLabels.test.ts +71 -0
- package/src/helpers/tests/seriesHighlight.test.ts +61 -0
- package/src/hooks/tests/useScales.test.tsx +105 -0
- package/src/hooks/useScales.ts +19 -9
- package/src/index.jsx +5 -2
- package/src/scss/main.scss +32 -49
- package/src/types/ChartConfig.ts +18 -0
- package/src/types/ChartContext.ts +2 -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
|
+
})
|