@cdc/map 4.26.2 → 4.26.4

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 (118) hide show
  1. package/CONFIG.md +235 -0
  2. package/README.md +70 -24
  3. package/dist/cdcmap-CY9IcPSi.es.js +6 -0
  4. package/dist/cdcmap-DlpiY3fQ.es.js +4 -0
  5. package/dist/cdcmap.js +31260 -27946
  6. package/examples/{testing-layer-2.json → __data__/testing-layer-2.json} +1 -1
  7. package/examples/{testing-layer.json → __data__/testing-layer.json} +1 -1
  8. package/examples/county-hsa-toggle.json +51993 -0
  9. package/examples/custom-map-layers.json +2 -2
  10. package/examples/default-county.json +3 -3
  11. package/examples/minimal-example.json +69 -0
  12. package/examples/private/annotation-bug.json +642 -0
  13. package/examples/private/css-issue.json +314 -0
  14. package/examples/private/region-breaking.json +1639 -0
  15. package/examples/private/test1.json +27247 -0
  16. package/package.json +4 -4
  17. package/src/CdcMap.tsx +3 -14
  18. package/src/CdcMapComponent.tsx +302 -164
  19. package/src/_stories/CdcMap.Defaults.smoke.stories.tsx +76 -0
  20. package/src/_stories/CdcMap.Editor.ColumnsSectionTests.stories.tsx +601 -0
  21. package/src/_stories/CdcMap.Editor.DataTableSectionTests.stories.tsx +404 -0
  22. package/src/_stories/CdcMap.Editor.FiltersSectionTests.stories.tsx +229 -0
  23. package/src/_stories/CdcMap.Editor.GeneralSectionTests.stories.tsx +262 -0
  24. package/src/_stories/CdcMap.Editor.LegendSectionTests.stories.tsx +541 -0
  25. package/src/_stories/CdcMap.Editor.MultiCountryWorldMapTests.stories.tsx +359 -0
  26. package/src/_stories/CdcMap.Editor.PatternSettingsSectionTests.stories.tsx +516 -0
  27. package/src/_stories/CdcMap.Editor.SmallMultiplesSectionTests.stories.tsx +165 -0
  28. package/src/_stories/CdcMap.Editor.TextAnnotationsSectionTests.stories.tsx +145 -0
  29. package/src/_stories/CdcMap.Editor.TypeSectionTests.stories.tsx +312 -0
  30. package/src/_stories/CdcMap.Editor.VisualSectionTests.stories.tsx +359 -0
  31. package/src/_stories/CdcMap.Editor.ZoomControlsTests.stories.tsx +88 -0
  32. package/src/_stories/{CdcMap.stories.tsx → CdcMap.smoke.stories.tsx} +23 -1
  33. package/src/_stories/Map.HTMLInDataTable.stories.tsx +385 -0
  34. package/src/_stories/_mock/legends/legend-tests.json +3 -3
  35. package/src/_stories/_mock/multi-state-show-unselected.json +82 -0
  36. package/src/cdcMapComponent.styles.css +2 -2
  37. package/src/components/Annotation/Annotation.Draggable.styles.css +4 -4
  38. package/src/components/Annotation/AnnotationDropdown.styles.css +1 -1
  39. package/src/components/Annotation/AnnotationList.styles.css +13 -13
  40. package/src/components/Annotation/AnnotationList.tsx +1 -1
  41. package/src/components/EditorPanel/components/EditorPanel.tsx +905 -416
  42. package/src/components/EditorPanel/components/HexShapeSettings.tsx +1 -1
  43. package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +112 -117
  44. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings-style.css +1 -1
  45. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +31 -15
  46. package/src/components/EditorPanel/components/editorPanel.styles.css +55 -25
  47. package/src/components/Legend/components/Legend.tsx +12 -7
  48. package/src/components/Legend/components/LegendGroup/legend.group.css +5 -5
  49. package/src/components/Legend/components/LegendItem.Hex.tsx +4 -2
  50. package/src/components/Legend/components/index.scss +2 -3
  51. package/src/components/NavigationMenu.tsx +2 -1
  52. package/src/components/SmallMultiples/SmallMultiples.css +5 -5
  53. package/src/components/SmallMultiples/SynchronizedTooltip.tsx +1 -1
  54. package/src/components/UsaMap/components/SingleState/SingleState.StateOutput.tsx +32 -17
  55. package/src/components/UsaMap/components/TerritoriesSection.tsx +3 -2
  56. package/src/components/UsaMap/components/Territory/Territory.Rectangle.tsx +13 -8
  57. package/src/components/UsaMap/components/UsaMap.County.tsx +629 -231
  58. package/src/components/UsaMap/components/UsaMap.Region.styles.css +1 -1
  59. package/src/components/UsaMap/components/UsaMap.SingleState.styles.css +2 -2
  60. package/src/components/UsaMap/components/UsaMap.State.tsx +14 -9
  61. package/src/components/UsaMap/data/cb_2019_us_county_20m.json +75817 -1
  62. package/src/components/UsaMap/data/hsa_fips_mapping.json +3144 -0
  63. package/src/components/WorldMap/WorldMap.tsx +10 -13
  64. package/src/components/WorldMap/data/world-topo-updated.json +1 -0
  65. package/src/components/WorldMap/data/world-topo.json +1 -1
  66. package/src/components/WorldMap/worldMap.styles.css +1 -1
  67. package/src/components/ZoomControls.tsx +49 -18
  68. package/src/components/zoomControls.styles.css +27 -11
  69. package/src/data/initial-state.js +15 -5
  70. package/src/data/legacy-defaults.ts +8 -0
  71. package/src/data/supported-counties.json +1 -1
  72. package/src/data/supported-geos.js +19 -0
  73. package/src/helpers/colors.ts +2 -1
  74. package/src/helpers/countyTerritories.ts +38 -0
  75. package/src/helpers/dataTableHelpers.ts +85 -0
  76. package/src/helpers/displayGeoName.ts +19 -11
  77. package/src/helpers/getMapContainerClasses.ts +8 -2
  78. package/src/helpers/getMatchingPatternForRow.ts +67 -0
  79. package/src/helpers/getPatternForRow.ts +11 -18
  80. package/src/helpers/tests/countyTerritories.test.ts +87 -0
  81. package/src/helpers/tests/dataTableHelpers.test.ts +78 -0
  82. package/src/helpers/tests/displayGeoName.test.ts +17 -0
  83. package/src/helpers/tests/getMatchingPatternForRow.test.ts +150 -0
  84. package/src/helpers/tests/getPatternForRow.test.ts +140 -2
  85. package/src/helpers/urlDataHelpers.ts +7 -1
  86. package/src/hooks/useApplyTooltipsToGeo.tsx +7 -4
  87. package/src/hooks/useMapLayers.tsx +1 -1
  88. package/src/hooks/useResizeObserver.ts +36 -22
  89. package/src/hooks/useTooltip.test.tsx +64 -0
  90. package/src/hooks/useTooltip.ts +46 -15
  91. package/src/scss/editor-panel.scss +1 -1
  92. package/src/scss/main.scss +140 -6
  93. package/src/scss/map.scss +9 -4
  94. package/src/store/map.actions.ts +5 -0
  95. package/src/store/map.reducer.ts +13 -0
  96. package/src/test/CdcMap.test.jsx +26 -2
  97. package/src/types/MapConfig.ts +28 -4
  98. package/src/types/MapContext.ts +5 -1
  99. package/topojson-updater/README.txt +1 -1
  100. package/dist/cdcmap-Cf9_fbQf.es.js +0 -6
  101. package/examples/__data__/city-state-data.json +0 -668
  102. package/examples/city-state.json +0 -434
  103. package/examples/default-world-data.json +0 -1450
  104. package/examples/new-cities.json +0 -656
  105. package/src/_stories/CdcMap.Editor.stories.tsx +0 -3475
  106. package/src/helpers/componentHelpers.ts +0 -8
  107. package/topojson-updater/package-lock.json +0 -223
  108. /package/src/_stories/{CdcMap.ColumnWrap.stories.tsx → CdcMap.ColumnWrap.smoke.stories.tsx} +0 -0
  109. /package/src/_stories/{CdcMap.DistrictOfColumbia.stories.tsx → CdcMap.DistrictOfColumbia.smoke.stories.tsx} +0 -0
  110. /package/src/_stories/{CdcMap.Filters.stories.tsx → CdcMap.Filters.smoke.stories.tsx} +0 -0
  111. /package/src/_stories/{CdcMap.Legend.Gradient.stories.tsx → CdcMap.Legend.Gradient.smoke.stories.tsx} +0 -0
  112. /package/src/_stories/{CdcMap.Legend.stories.tsx → CdcMap.Legend.smoke.stories.tsx} +0 -0
  113. /package/src/_stories/{CdcMap.Patterns.stories.tsx → CdcMap.Patterns.smoke.stories.tsx} +0 -0
  114. /package/src/_stories/{CdcMap.SmallMultiples.stories.tsx → CdcMap.SmallMultiples.smoke.stories.tsx} +0 -0
  115. /package/src/_stories/{CdcMap.Table.stories.tsx → CdcMap.Table.smoke.stories.tsx} +0 -0
  116. /package/src/_stories/{CdcMap.ZeroColor.stories.tsx → CdcMap.ZeroColor.smoke.stories.tsx} +0 -0
  117. /package/src/_stories/{GoogleMap.stories.tsx → GoogleMap.smoke.stories.tsx} +0 -0
  118. /package/src/_stories/{UsaMap.NoData.stories.tsx → UsaMap.NoData.smoke.stories.tsx} +0 -0
@@ -0,0 +1,76 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { expect } from 'storybook/test'
3
+ import CdcMap from '../CdcMap'
4
+ import EqualNumberMap from './_mock/equal-number.json'
5
+ import { editConfigKeys } from '@cdc/core/helpers/configHelpers'
6
+ import { assertVisualizationRendered, waitForPresence } from '@cdc/core/helpers/testing'
7
+
8
+ const meta: Meta<typeof CdcMap> = {
9
+ title: 'Components/Templates/Map/Defaults',
10
+ component: CdcMap
11
+ }
12
+
13
+ type Story = StoryObj<typeof CdcMap>
14
+
15
+ const oldConfig = editConfigKeys(EqualNumberMap, [
16
+ { path: ['legend', 'style'], value: 'circles' },
17
+ { path: ['legend', 'position'], value: 'side' },
18
+ { path: ['legend', 'numberOfItems'], value: 3 },
19
+ { path: ['legend', 'hideBorder'], value: false }
20
+ ])
21
+
22
+ const newConfig = editConfigKeys(EqualNumberMap, [
23
+ { path: ['legend', 'style'], value: 'gradient' },
24
+ { path: ['legend', 'position'], value: 'top' },
25
+ { path: ['legend', 'numberOfItems'], value: 5 },
26
+ { path: ['legend', 'hideBorder'], value: true }
27
+ ])
28
+
29
+ export const OldConfig_Preserves_Legacy_Defaults: Story = {
30
+ args: {
31
+ config: oldConfig,
32
+ isEditor: false
33
+ },
34
+ play: async ({ canvasElement }) => {
35
+ await assertVisualizationRendered(canvasElement)
36
+
37
+ await waitForPresence('aside[aria-label="Legend"]', canvasElement)
38
+ await waitForPresence('.legend-container__li', canvasElement)
39
+ const legend = canvasElement.querySelector('aside[aria-label="Legend"]')
40
+ expect(legend).toBeInTheDocument()
41
+ expect(legend?.classList.contains('side')).toBe(true)
42
+
43
+ expect(legend?.classList.contains('no-border')).toBe(false)
44
+
45
+ const legendContainer = canvasElement.querySelector('.legend-container')
46
+ const legendItems = legendContainer?.querySelectorAll('.legend-container__li')
47
+ expect(legendItems?.length).toBeGreaterThan(0)
48
+
49
+ const linearGradient = legendContainer?.querySelector('linearGradient')
50
+ expect(linearGradient).toBeNull()
51
+ }
52
+ }
53
+
54
+ export const NewConfig_Gets_New_Defaults: Story = {
55
+ args: {
56
+ config: newConfig,
57
+ isEditor: false
58
+ },
59
+ play: async ({ canvasElement }) => {
60
+ await assertVisualizationRendered(canvasElement)
61
+
62
+ await waitForPresence('aside[aria-label="Legend"]', canvasElement)
63
+ const legend = canvasElement.querySelector('aside[aria-label="Legend"]')
64
+ expect(legend).toBeInTheDocument()
65
+ expect(legend?.classList.contains('top')).toBe(true)
66
+
67
+ expect(legend?.classList.contains('no-border')).toBe(true)
68
+
69
+ await waitForPresence('linearGradient', canvasElement)
70
+ const legendContainer = canvasElement.querySelector('.legend-container')
71
+ const linearGradient = legendContainer?.querySelector('linearGradient')
72
+ expect(linearGradient).toBeInTheDocument()
73
+ }
74
+ }
75
+
76
+ export default meta
@@ -0,0 +1,601 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { within, userEvent, expect } from 'storybook/test'
3
+ import CdcMap from '../CdcMap'
4
+ import usaStateGradientConfig from './_mock/usa-state-gradient.json'
5
+ import multiCountryConfig from './_mock/multi-country.json'
6
+ import wastewaterMapSmallMultiplesConfig from './_mock/small_multiples/wastewater-map-small-multiples.json'
7
+ import { performAndAssert, waitForEditor, waitForPresence, openAccordion } from '@cdc/core/helpers/testing'
8
+
9
+ type Story = StoryObj<typeof CdcMap>
10
+
11
+ const mapMeta: Meta<typeof CdcMap> = {
12
+ title: 'Components/Templates/Map/Editor Tests',
13
+ component: CdcMap,
14
+ parameters: {
15
+ layout: 'fullscreen'
16
+ }
17
+ }
18
+
19
+ export default mapMeta
20
+
21
+ const DEFAULT_ARGS = {
22
+ isEditor: true,
23
+ config: usaStateGradientConfig
24
+ }
25
+
26
+ export const ColumnsSectionTests: Story = {
27
+ args: {
28
+ ...DEFAULT_ARGS
29
+ },
30
+ play: async ({ canvasElement }) => {
31
+ const setTextFieldValue = (input: HTMLInputElement | HTMLTextAreaElement, value: string) => {
32
+ const prototype =
33
+ input instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype
34
+ const valueSetter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
35
+ valueSetter?.call(input, value)
36
+ input.dispatchEvent(new Event('input', { bubbles: true }))
37
+ input.dispatchEvent(new Event('change', { bubbles: true }))
38
+ }
39
+ const canvas = within(canvasElement)
40
+
41
+ await waitForEditor(canvas)
42
+ await waitForPresence('.map-container', canvasElement)
43
+
44
+ await openAccordion(canvas, 'Columns')
45
+
46
+ // ==========================================================================
47
+ // TEST: Geography column select
48
+ // Verifies: Changing geo column to invalid data makes map gray and legend shows "No data"
49
+ // ==========================================================================
50
+ const columnsAccordionButton = canvas
51
+ .getAllByRole('button', { name: /^Columns$/i })
52
+ .find(button => button.closest('[data-accordion-component="AccordionItem"], .accordion__item'))
53
+ const columnsAccordion = columnsAccordionButton?.closest(
54
+ '[data-accordion-component="AccordionItem"], .accordion__item'
55
+ )
56
+ expect(columnsAccordion).toBeTruthy()
57
+ const geoColumnLabel = Array.from(columnsAccordion?.querySelectorAll('label') || []).find(label => {
58
+ const columnHeading = label.querySelector('.edit-label.column-heading')
59
+ return columnHeading?.textContent?.includes('Geography')
60
+ })
61
+ const geoSelect = geoColumnLabel?.querySelector('select') as HTMLSelectElement
62
+ expect(geoSelect).toBeTruthy()
63
+
64
+ const getMapAndLegendState = () => {
65
+ const legendContainer = canvasElement.querySelector('.legend-container')
66
+ const legendSvg = legendContainer?.querySelector('svg')
67
+ const gradientStops = legendSvg?.querySelectorAll('linearGradient stop')
68
+
69
+ // Gradient legend has color stops when data is valid
70
+ const hasGradientStops = (gradientStops?.length || 0) > 0
71
+
72
+ return {
73
+ legendHTML: legendContainer?.innerHTML || '',
74
+ hasGradientStops: hasGradientStops,
75
+ gradientStopCount: gradientStops?.length || 0
76
+ }
77
+ }
78
+
79
+ // Change to a column that's NOT valid geography data (Rate instead of STATE)
80
+ await performAndAssert(
81
+ 'Geography Column → Change to invalid column',
82
+ getMapAndLegendState,
83
+ async () => {
84
+ await userEvent.selectOptions(geoSelect, 'Rate')
85
+ },
86
+ (before, after) => {
87
+ // Legend gradient should lose color stops when geo column is invalid
88
+ return before.hasGradientStops && !after.hasGradientStops
89
+ }
90
+ )
91
+
92
+ // Change back to valid geography column
93
+ await performAndAssert(
94
+ 'Geography Column → Change back to valid column',
95
+ getMapAndLegendState,
96
+ async () => {
97
+ await userEvent.selectOptions(geoSelect, 'STATE')
98
+ },
99
+ (before, after) => {
100
+ // Legend gradient should regain color stops when geo column is valid
101
+ return !before.hasGradientStops && after.hasGradientStops
102
+ }
103
+ )
104
+
105
+ // ==========================================================================
106
+ // TEST: Hide Geography Column Name in Tooltip checkbox
107
+ // Verifies: Tooltip content changes when geography label is hidden/shown
108
+ // ==========================================================================
109
+ const hideGeoLabel = Array.from(columnsAccordion?.querySelectorAll('label') || []).find(label =>
110
+ label.textContent?.includes('Hide Geography Column Name in Tooltip')
111
+ )
112
+ const hideGeoCheckbox = hideGeoLabel?.querySelector('input[type="checkbox"]') as HTMLInputElement
113
+ expect(hideGeoCheckbox).toBeTruthy()
114
+
115
+ const getTooltipContent = () => {
116
+ const geoGroups = Array.from(canvasElement.querySelectorAll('g.geo-group')) as SVGGElement[]
117
+ const tooltipHtml =
118
+ geoGroups.map(group => group.getAttribute('data-tooltip-html') || '').find(html => html.trim().length > 0) || ''
119
+
120
+ return {
121
+ tooltipContent: tooltipHtml,
122
+ hasTooltipHeading: tooltipHtml.includes('tooltip-heading'),
123
+ hasStatePrefix: tooltipHtml.includes('State:'),
124
+ usesStrongGeoName: tooltipHtml.startsWith('<strong>')
125
+ }
126
+ }
127
+
128
+ await performAndAssert(
129
+ 'Wait for geography tooltip to populate',
130
+ getTooltipContent,
131
+ async () => {},
132
+ (_before, after) => after.tooltipContent.length > 0
133
+ )
134
+
135
+ await performAndAssert(
136
+ 'Hide Geography Column Name → Enable',
137
+ getTooltipContent,
138
+ async () => {
139
+ await userEvent.click(hideGeoCheckbox)
140
+ },
141
+ (before, after) => {
142
+ // Hidden geography labels now render as a standalone <strong> name instead of a tooltip heading.
143
+ return before.tooltipContent.length > 0 && after.usesStrongGeoName && !after.hasTooltipHeading
144
+ }
145
+ )
146
+
147
+ await performAndAssert(
148
+ 'Hide Geography Column Name → Disable',
149
+ getTooltipContent,
150
+ async () => {
151
+ await userEvent.click(hideGeoCheckbox)
152
+ },
153
+ (before, after) => {
154
+ // Re-enabling restores the heading-based tooltip format.
155
+ return (
156
+ before.usesStrongGeoName &&
157
+ after.hasTooltipHeading &&
158
+ (after.hasStatePrefix || after.tooltipContent.length > 0)
159
+ )
160
+ }
161
+ )
162
+
163
+ // ==========================================================================
164
+ // TEST: Geography Label field
165
+ // Verifies: Custom geography label appears in tooltip instead of default "State:"
166
+ // ==========================================================================
167
+ const geoLabelInput = Array.from(columnsAccordion?.querySelectorAll('input[type="text"]') || []).find(input => {
168
+ const label = input.closest('label')
169
+ return label?.textContent?.includes('Geography Label')
170
+ }) as HTMLInputElement
171
+ expect(geoLabelInput).toBeTruthy()
172
+
173
+ await performAndAssert(
174
+ 'Geography Label → Add custom label',
175
+ getTooltipContent,
176
+ async () => {
177
+ geoLabelInput.scrollIntoView({ block: 'center' })
178
+ setTextFieldValue(geoLabelInput, 'Region')
179
+ },
180
+ (before, after) => {
181
+ // Custom label "Region:" should appear in tooltip
182
+ return !before.tooltipContent.includes('Region:') && after.tooltipContent.includes('Region:')
183
+ }
184
+ )
185
+
186
+ // ==========================================================================
187
+ // TEST: Data Column select
188
+ // Verifies: Changing data column changes tooltip data values
189
+ // ==========================================================================
190
+ const dataColumnLabel = Array.from(columnsAccordion?.querySelectorAll('label') || []).find(label =>
191
+ label.textContent?.includes('Data Column')
192
+ )
193
+ const dataColumnSelect = dataColumnLabel?.querySelector('select') as HTMLSelectElement
194
+ expect(dataColumnSelect).toBeTruthy()
195
+
196
+ const getDataTooltipContent = () => {
197
+ const geoGroup = canvasElement.querySelector('g.geo-group') as SVGGElement
198
+ const tooltipHtml = geoGroup?.getAttribute('data-tooltip-html') || ''
199
+ return {
200
+ tooltipContent: tooltipHtml
201
+ }
202
+ }
203
+
204
+ await performAndAssert(
205
+ 'Data Column → Change to STATE column',
206
+ getDataTooltipContent,
207
+ async () => {
208
+ await userEvent.selectOptions(dataColumnSelect, 'STATE')
209
+ },
210
+ (before, after) => {
211
+ // Tooltip should show STATE values (e.g., state names) instead of Rate values (numbers)
212
+ const beforeHasNumbers = /\d+/.test(before.tooltipContent)
213
+ const afterHasStateNames = /Alabama|Alaska|Arizona|Arkansas|California/.test(after.tooltipContent)
214
+ return beforeHasNumbers && afterHasStateNames
215
+ }
216
+ )
217
+
218
+ // Change back to Rate
219
+ await performAndAssert(
220
+ 'Data Column → Change back to Rate',
221
+ getDataTooltipContent,
222
+ async () => {
223
+ await userEvent.selectOptions(dataColumnSelect, 'Rate')
224
+ },
225
+ (before, after) => {
226
+ // Tooltip should show numeric Rate values again
227
+ const beforeHasStateNames = /Alabama|Alaska|Arizona|Arkansas|California/.test(before.tooltipContent)
228
+ const afterHasNumbers = /\d+/.test(after.tooltipContent)
229
+ return beforeHasStateNames && afterHasNumbers
230
+ }
231
+ )
232
+
233
+ // ==========================================================================
234
+ // TEST: Hide Data Column Name in Tooltip
235
+ // Verifies: Data column label disappears from tooltip when hidden
236
+ // ==========================================================================
237
+ const hideDataLabel = Array.from(columnsAccordion?.querySelectorAll('label') || []).find(label =>
238
+ label.textContent?.includes('Hide Data Column Name in Tooltip')
239
+ )
240
+ const hideDataCheckbox = hideDataLabel?.querySelector('input[type="checkbox"]') as HTMLInputElement
241
+ expect(hideDataCheckbox).toBeTruthy()
242
+
243
+ await performAndAssert(
244
+ 'Hide Data Column Name → Enable',
245
+ getDataTooltipContent,
246
+ async () => {
247
+ await userEvent.click(hideDataCheckbox)
248
+ },
249
+ (before, after) => {
250
+ // The label prefix (e.g., "Rate:" or similar) should be removed
251
+ // Before should have a colon pattern indicating a label, after should have less structure
252
+ const beforeHasLabelStructure = before.tooltipContent.includes(':</li>') || before.tooltipContent.includes(': ')
253
+ const afterHasLessStructure = after.tooltipContent.length < before.tooltipContent.length
254
+ return beforeHasLabelStructure && afterHasLessStructure
255
+ }
256
+ )
257
+
258
+ await performAndAssert(
259
+ 'Hide Data Column Name → Disable',
260
+ getDataTooltipContent,
261
+ async () => {
262
+ await userEvent.click(hideDataCheckbox)
263
+ },
264
+ (before, after) => {
265
+ // The label prefix should reappear
266
+ const beforeHasLessStructure = before.tooltipContent.length < after.tooltipContent.length
267
+ const afterHasLabelStructure = after.tooltipContent.includes(':</li>') || after.tooltipContent.includes(': ')
268
+ return beforeHasLessStructure && afterHasLabelStructure
269
+ }
270
+ )
271
+
272
+ // ==========================================================================
273
+ // TEST: Data Label field
274
+ // Verifies: Custom data label appears in tooltip
275
+ // ==========================================================================
276
+ const dataLabelInputs = Array.from(columnsAccordion?.querySelectorAll('input') || [])
277
+ const dataLabelInput = dataLabelInputs.find(input => {
278
+ const label = input.closest('label')
279
+ return label?.textContent?.includes('Data Label')
280
+ }) as HTMLInputElement
281
+ expect(dataLabelInput).toBeTruthy()
282
+
283
+ await performAndAssert(
284
+ 'Data Label → Add custom label',
285
+ getDataTooltipContent,
286
+ async () => {
287
+ dataLabelInput.scrollIntoView({ block: 'center' })
288
+ setTextFieldValue(dataLabelInput, 'Value')
289
+ },
290
+ (before, after) => {
291
+ // Custom label "Value:" should appear in tooltip
292
+ return !before.tooltipContent.includes('Value:') && after.tooltipContent.includes('Value:')
293
+ }
294
+ )
295
+
296
+ // ==========================================================================
297
+ // TEST: Prefix field
298
+ // Verifies: Prefix appears before data values in both tooltip and legend
299
+ // ==========================================================================
300
+ const prefixInputs = Array.from(columnsAccordion?.querySelectorAll('input') || [])
301
+ const prefixInput = prefixInputs.find(input => {
302
+ const label = input.closest('label')
303
+ return label?.textContent?.includes('Prefix')
304
+ }) as HTMLInputElement
305
+ expect(prefixInput).toBeTruthy()
306
+
307
+ const getPrefixVisualization = () => {
308
+ const geoGroup = canvasElement.querySelector('g.geo-group') as SVGGElement
309
+ const tooltipHtml = geoGroup?.getAttribute('data-tooltip-html') || ''
310
+ const legendContainer = canvasElement.querySelector('.legend-container')
311
+ const legendSvg = legendContainer?.querySelector('svg')
312
+ const legendText = legendSvg?.textContent || ''
313
+
314
+ return {
315
+ tooltipHasPrefix: tooltipHtml.includes('$'),
316
+ legendHasPrefix: legendText.includes('$'),
317
+ tooltipContent: tooltipHtml,
318
+ legendContent: legendText
319
+ }
320
+ }
321
+
322
+ await performAndAssert(
323
+ 'Prefix → Add dollar sign',
324
+ getPrefixVisualization,
325
+ async () => {
326
+ prefixInput.scrollIntoView({ block: 'center' })
327
+ setTextFieldValue(prefixInput, '$')
328
+ },
329
+ (before, after) => {
330
+ // Dollar sign should appear in both tooltip and legend
331
+ return !before.tooltipHasPrefix && after.tooltipHasPrefix && !before.legendHasPrefix && after.legendHasPrefix
332
+ }
333
+ )
334
+
335
+ // ==========================================================================
336
+ // TEST: Suffix field
337
+ // Verifies: Suffix appears after data values in both tooltip and legend
338
+ // ==========================================================================
339
+ const suffixInputs = Array.from(columnsAccordion?.querySelectorAll('input') || [])
340
+ const suffixInput = suffixInputs.find(input => {
341
+ const label = input.closest('label')
342
+ return label?.textContent?.includes('Suffix')
343
+ }) as HTMLInputElement
344
+ expect(suffixInput).toBeTruthy()
345
+
346
+ const getSuffixVisualization = () => {
347
+ const geoGroup = canvasElement.querySelector('g.geo-group') as SVGGElement
348
+ const tooltipHtml = geoGroup?.getAttribute('data-tooltip-html') || ''
349
+ const legendContainer = canvasElement.querySelector('.legend-container')
350
+ const legendSvg = legendContainer?.querySelector('svg')
351
+ const legendText = legendSvg?.textContent || ''
352
+
353
+ return {
354
+ tooltipHasSuffix: tooltipHtml.includes('%'),
355
+ legendHasSuffix: legendText.includes('%'),
356
+ tooltipContent: tooltipHtml,
357
+ legendContent: legendText
358
+ }
359
+ }
360
+
361
+ await performAndAssert(
362
+ 'Suffix → Add percent sign',
363
+ getSuffixVisualization,
364
+ async () => {
365
+ suffixInput.scrollIntoView({ block: 'center' })
366
+ setTextFieldValue(suffixInput, '%')
367
+ },
368
+ (before, after) => {
369
+ // Percent sign should appear in both tooltip and legend
370
+ return !before.tooltipHasSuffix && after.tooltipHasSuffix && !before.legendHasSuffix && after.legendHasSuffix
371
+ }
372
+ )
373
+
374
+ // ==========================================================================
375
+ // TEST: Round field
376
+ // Verifies: Changing rounding adds decimal places in both tooltip and legend
377
+ // ==========================================================================
378
+ const roundInputs = Array.from(columnsAccordion?.querySelectorAll('input') || [])
379
+ const roundInput = roundInputs.find(input => {
380
+ const label = input.closest('label')
381
+ return label?.textContent?.includes('Round')
382
+ }) as HTMLInputElement
383
+ expect(roundInput).toBeTruthy()
384
+
385
+ const getRoundingVisualization = () => {
386
+ const geoGroup = canvasElement.querySelector('g.geo-group') as SVGGElement
387
+ const tooltipHtml = geoGroup?.getAttribute('data-tooltip-html') || ''
388
+ const legendContainer = canvasElement.querySelector('.legend-container')
389
+ const legendSvg = legendContainer?.querySelector('svg')
390
+ const legendText = legendSvg?.textContent || ''
391
+
392
+ // Check for 2 decimal places pattern (e.g., "12.34", "0.50", etc.)
393
+ const twoDecimalPattern = /\d+\.\d{2}/
394
+
395
+ return {
396
+ tooltipHasTwoDecimals: twoDecimalPattern.test(tooltipHtml),
397
+ legendHasTwoDecimals: twoDecimalPattern.test(legendText),
398
+ tooltipContent: tooltipHtml,
399
+ legendContent: legendText
400
+ }
401
+ }
402
+
403
+ await performAndAssert(
404
+ 'Round → Set to 2 decimal places',
405
+ getRoundingVisualization,
406
+ async () => {
407
+ roundInput.scrollIntoView({ block: 'center' })
408
+ setTextFieldValue(roundInput, '2')
409
+ },
410
+ (before, after) => {
411
+ // Numbers should now show 2 decimal places in both tooltip and legend
412
+ return (
413
+ !before.tooltipHasTwoDecimals &&
414
+ after.tooltipHasTwoDecimals &&
415
+ !before.legendHasTwoDecimals &&
416
+ after.legendHasTwoDecimals
417
+ )
418
+ }
419
+ )
420
+
421
+ // ==========================================================================
422
+ // TEST: Show in Data Table checkbox
423
+ // Verifies: Primary column appears/disappears in the data table
424
+ // ==========================================================================
425
+ const showInTableLabel = Array.from(columnsAccordion?.querySelectorAll('label') || []).find(label =>
426
+ label.textContent?.includes('Show in Data Table')
427
+ )
428
+ const showInTableCheckbox = showInTableLabel?.querySelector('input[type="checkbox"]') as HTMLInputElement
429
+ expect(showInTableCheckbox).toBeTruthy()
430
+
431
+ const getDataTableState = () => {
432
+ const dataTable = canvasElement.querySelector('.data-table-container, table')
433
+ const tableHeaders = Array.from(dataTable?.querySelectorAll('th') || [])
434
+ const tableHeaderText = tableHeaders.map(th => th.textContent?.trim() || '')
435
+
436
+ // Check if "Rate" column header appears in data table
437
+ const hasRateColumn = tableHeaderText.some(text => text.includes('Rate') || text.includes('Value'))
438
+
439
+ return {
440
+ hasDataTable: Boolean(dataTable),
441
+ tableHeaders: tableHeaderText,
442
+ hasRateColumn: hasRateColumn
443
+ }
444
+ }
445
+
446
+ // Test config has dataTable: true for primary column
447
+ await performAndAssert(
448
+ 'Show in Data Table → Disable',
449
+ getDataTableState,
450
+ async () => {
451
+ await userEvent.click(showInTableCheckbox)
452
+ },
453
+ (before, after) => {
454
+ // Rate column should disappear from data table headers
455
+ return before.hasRateColumn && !after.hasRateColumn
456
+ }
457
+ )
458
+
459
+ await performAndAssert(
460
+ 'Show in Data Table → Enable',
461
+ getDataTableState,
462
+ async () => {
463
+ await userEvent.click(showInTableCheckbox)
464
+ },
465
+ (before, after) => {
466
+ // Rate column should reappear in data table headers
467
+ return !before.hasRateColumn && after.hasRateColumn
468
+ }
469
+ )
470
+
471
+ // ==========================================================================
472
+ // TEST: Show in Tooltips checkbox
473
+ // Verifies: Primary column data appears/disappears in tooltips
474
+ // ==========================================================================
475
+ const showInTooltipsLabel = Array.from(columnsAccordion?.querySelectorAll('label') || []).find(label =>
476
+ label.textContent?.includes('Show in Tooltips')
477
+ )
478
+ const showInTooltipsCheckbox = showInTooltipsLabel?.querySelector('input[type="checkbox"]') as HTMLInputElement
479
+ expect(showInTooltipsCheckbox).toBeTruthy()
480
+
481
+ const getTooltipDataState = () => {
482
+ const geoGroup = canvasElement.querySelector('g.geo-group') as SVGGElement
483
+ const tooltipHtml = geoGroup?.getAttribute('data-tooltip-html') || ''
484
+
485
+ // Check if tooltip contains data values (numbers with potential $ and % from earlier tests)
486
+ // Look for patterns like list items with numeric values
487
+ const hasDataValues = tooltipHtml.includes('<li') && /\$?\d+\.\d{2}%?/.test(tooltipHtml)
488
+
489
+ return {
490
+ tooltipContent: tooltipHtml,
491
+ hasDataValues: hasDataValues,
492
+ tooltipLength: tooltipHtml.length
493
+ }
494
+ }
495
+
496
+ // Test config has tooltip: true for primary column
497
+ await performAndAssert(
498
+ 'Show in Tooltips → Disable',
499
+ getTooltipDataState,
500
+ async () => {
501
+ await userEvent.click(showInTooltipsCheckbox)
502
+ },
503
+ (before, after) => {
504
+ // Tooltip should no longer contain data values, should be shorter
505
+ return before.hasDataValues && !after.hasDataValues && after.tooltipLength < before.tooltipLength
506
+ }
507
+ )
508
+
509
+ await performAndAssert(
510
+ 'Show in Tooltips → Enable',
511
+ getTooltipDataState,
512
+ async () => {
513
+ await userEvent.click(showInTooltipsCheckbox)
514
+ },
515
+ (before, after) => {
516
+ // Tooltip should contain data values again, should be longer
517
+ return !before.hasDataValues && after.hasDataValues && after.tooltipLength > before.tooltipLength
518
+ }
519
+ )
520
+
521
+ // ==========================================================================
522
+ // TEST: Add Special Class button
523
+ // Verifies: New special class item appears in legend
524
+ // ==========================================================================
525
+ const addSpecialClassButton = Array.from(columnsAccordion?.querySelectorAll('button') || []).find(button =>
526
+ button.textContent?.includes('Add Special Class')
527
+ ) as HTMLButtonElement
528
+ expect(addSpecialClassButton).toBeTruthy()
529
+
530
+ const getSpecialClassVisualization = () => {
531
+ const legendContainer = canvasElement.querySelector('.legend-container')
532
+ const legendSvg = legendContainer?.querySelector('svg')
533
+ const gradientStops = legendSvg?.querySelectorAll('linearGradient stop')
534
+ const gradientRects = legendSvg?.querySelectorAll('g.visx-group rect')
535
+
536
+ // Get all stop colors to detect changes
537
+ const stopColors = Array.from(gradientStops || []).map(stop => (stop as SVGStopElement).getAttribute('style'))
538
+
539
+ // Get all rect fills to detect new colors
540
+ const rectFills = Array.from(gradientRects || []).map(rect => (rect as SVGRectElement).getAttribute('fill'))
541
+
542
+ return {
543
+ gradientStopCount: gradientStops?.length || 0,
544
+ gradientRectCount: gradientRects?.length || 0,
545
+ stopColors: stopColors,
546
+ rectFills: rectFills,
547
+ legendHTML: legendContainer?.innerHTML || ''
548
+ }
549
+ }
550
+
551
+ await performAndAssert(
552
+ 'Add Special Class → Configure with STATE=Alabama',
553
+ getSpecialClassVisualization,
554
+ async () => {
555
+ // Click the Add Special Class button
556
+ await userEvent.click(addSpecialClassButton)
557
+
558
+ // Find the newly added special class fields
559
+ const specialClassSection = Array.from(columnsAccordion?.querySelectorAll('.edit-block') || []).find(block =>
560
+ block.textContent?.includes('Special Class 1')
561
+ )
562
+
563
+ // Get all selects in the special class section
564
+ // First select is Data Key, second is Value
565
+ const selects = Array.from(specialClassSection?.querySelectorAll('select') || [])
566
+ const dataKeySelect = selects[0] as HTMLSelectElement
567
+ const valueSelect = selects[1] as HTMLSelectElement
568
+
569
+ // Set Data Key to STATE
570
+ await userEvent.selectOptions(dataKeySelect, 'STATE')
571
+
572
+ // Wait for the Value select to populate with options based on STATE column
573
+ await new Promise(resolve => setTimeout(resolve, 200))
574
+
575
+ // Select "Alabama" from the Value select
576
+ await userEvent.selectOptions(valueSelect, 'Alabama')
577
+
578
+ // Find and fill in the Label field
579
+ const labelInput = Array.from(specialClassSection?.querySelectorAll('input') || []).find(input => {
580
+ const label = input.closest('label')
581
+ return label?.textContent?.includes('Label')
582
+ }) as HTMLInputElement
583
+
584
+ labelInput.scrollIntoView({ block: 'center' })
585
+ setTextFieldValue(labelInput, 'Alabama')
586
+ },
587
+ (before, after) => {
588
+ // Check that "Alabama" text appears in the legend
589
+ const alabamaInLegend = after.legendHTML.includes('Alabama')
590
+
591
+ // Check that the special class gray color appears
592
+ const hasGrayColor = after.rectFills.includes('#b4b4b4')
593
+
594
+ // Legend should change
595
+ const legendChanged = after.legendHTML !== before.legendHTML
596
+
597
+ return alabamaInLegend && hasGrayColor && legendChanged
598
+ }
599
+ )
600
+ }
601
+ }