@cdc/map 4.26.3 → 4.26.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/CONFIG.md +268 -0
  2. package/README.md +74 -24
  3. package/dist/cdcmap-CY9IcPSi.es.js +6 -0
  4. package/dist/cdcmap-DlpiY3fQ.es.js +4 -0
  5. package/dist/cdcmap.js +29168 -27482
  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 +6 -3
  11. package/examples/minimal-example.json +73 -0
  12. package/examples/private/annotation-bug.json +2 -2
  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/CdcMapComponent.tsx +107 -14
  18. package/src/_stories/CdcMap.AltText.stories.tsx +122 -0
  19. package/src/_stories/CdcMap.Editor.ColumnsSectionTests.stories.tsx +600 -0
  20. package/src/_stories/CdcMap.Editor.DataTableSectionTests.stories.tsx +404 -0
  21. package/src/_stories/CdcMap.Editor.FiltersSectionTests.stories.tsx +229 -0
  22. package/src/_stories/CdcMap.Editor.GeneralSectionTests.stories.tsx +262 -0
  23. package/src/_stories/CdcMap.Editor.LegendSectionTests.stories.tsx +541 -0
  24. package/src/_stories/CdcMap.Editor.MultiCountryWorldMapTests.stories.tsx +359 -0
  25. package/src/_stories/CdcMap.Editor.PatternSettingsSectionTests.stories.tsx +516 -0
  26. package/src/_stories/CdcMap.Editor.SmallMultiplesSectionTests.stories.tsx +165 -0
  27. package/src/_stories/CdcMap.Editor.TextAnnotationsSectionTests.stories.tsx +145 -0
  28. package/src/_stories/CdcMap.Editor.TypeSectionTests.stories.tsx +312 -0
  29. package/src/_stories/CdcMap.Editor.VisualSectionTests.stories.tsx +359 -0
  30. package/src/_stories/CdcMap.Editor.ZoomControlsTests.stories.tsx +88 -0
  31. package/src/_stories/CdcMap.FocusVisibility.stories.tsx +87 -0
  32. package/src/_stories/CdcMap.HiddenMount.stories.tsx +69 -0
  33. package/src/_stories/CdcMap.ResetBehavior.stories.tsx +32 -0
  34. package/src/_stories/CdcMap.Zoom.stories.tsx +111 -0
  35. package/src/_stories/{CdcMap.stories.tsx → CdcMap.smoke.stories.tsx} +60 -0
  36. package/src/_stories/_mock/alt_text_metadata.json +65 -0
  37. package/src/_stories/_mock/legends/legend-tests.json +3 -3
  38. package/src/_stories/_mock/world-bubble-reset.json +138 -0
  39. package/src/_stories/_mock/world-data-zoom-filters.json +166 -0
  40. package/src/components/Annotation/AnnotationList.tsx +1 -1
  41. package/src/components/BubbleList.tsx +13 -0
  42. package/src/components/EditorPanel/components/EditorPanel.tsx +637 -382
  43. package/src/components/EditorPanel/components/HexShapeSettings.tsx +1 -1
  44. package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +112 -117
  45. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +26 -13
  46. package/src/components/EditorPanel/components/editorPanel.styles.css +22 -2
  47. package/src/components/FilterControls.tsx +21 -0
  48. package/src/components/Legend/components/Legend.tsx +3 -3
  49. package/src/components/Legend/components/LegendItem.Hex.tsx +4 -2
  50. package/src/components/SmallMultiples/SmallMultiples.tsx +2 -2
  51. package/src/components/SmallMultiples/SynchronizedTooltip.tsx +1 -1
  52. package/src/components/UsaMap/components/UsaMap.County.tsx +309 -108
  53. package/src/components/UsaMap/components/UsaMap.Region.tsx +5 -2
  54. package/src/components/UsaMap/components/UsaMap.SingleState.tsx +33 -10
  55. package/src/components/UsaMap/components/UsaMap.State.tsx +10 -3
  56. package/src/components/UsaMap/data/cb_2019_us_county_20m.json +75817 -1
  57. package/src/components/UsaMap/data/hsa_fips_mapping.json +3144 -0
  58. package/src/components/WorldMap/WorldMap.tsx +37 -4
  59. package/src/components/WorldMap/data/world-topo.json +1 -1
  60. package/src/components/ZoomableGroup.tsx +23 -3
  61. package/src/components/filterControls.styles.css +6 -0
  62. package/src/data/initial-state.js +3 -0
  63. package/src/data/supported-counties.json +1 -1
  64. package/src/helpers/countyTerritories.ts +38 -0
  65. package/src/helpers/dataTableHelpers.ts +35 -6
  66. package/src/helpers/generateRuntimeFilters.ts +2 -1
  67. package/src/helpers/handleMapAriaLabels.ts +45 -30
  68. package/src/helpers/shouldAutoResetSingleStateZoom.ts +22 -0
  69. package/src/helpers/tests/countyTerritories.test.ts +87 -0
  70. package/src/helpers/tests/handleMapAriaLabels.test.ts +71 -0
  71. package/src/helpers/tests/shouldAutoResetSingleStateZoom.test.ts +71 -0
  72. package/src/hooks/useApplyTooltipsToGeo.tsx +7 -4
  73. package/src/hooks/useGeoClickHandler.ts +13 -1
  74. package/src/hooks/useMapLayers.tsx +1 -1
  75. package/src/hooks/useStateZoom.tsx +39 -20
  76. package/src/hooks/useTooltip.test.tsx +2 -16
  77. package/src/hooks/useTooltip.ts +18 -7
  78. package/src/index.jsx +5 -2
  79. package/src/scss/main.scss +6 -21
  80. package/src/scss/map.scss +20 -0
  81. package/src/store/map.actions.ts +5 -2
  82. package/src/store/map.reducer.ts +12 -3
  83. package/src/test/CdcMap.test.jsx +24 -0
  84. package/src/types/MapConfig.ts +11 -0
  85. package/src/types/MapContext.ts +6 -1
  86. package/topojson-updater/README.txt +1 -1
  87. package/dist/cdcmap-vr9HZwRt.es.js +0 -6
  88. package/examples/__data__/city-state-data.json +0 -668
  89. package/examples/city-state.json +0 -434
  90. package/examples/default-world-data.json +0 -1450
  91. package/examples/new-cities.json +0 -656
  92. package/src/_stories/CdcMap.Editor.stories.tsx +0 -3648
  93. package/topojson-updater/package-lock.json +0 -223
  94. /package/src/_stories/{CdcMap.ColumnWrap.stories.tsx → CdcMap.ColumnWrap.smoke.stories.tsx} +0 -0
  95. /package/src/_stories/{CdcMap.Defaults.stories.tsx → CdcMap.Defaults.smoke.stories.tsx} +0 -0
  96. /package/src/_stories/{CdcMap.DistrictOfColumbia.stories.tsx → CdcMap.DistrictOfColumbia.smoke.stories.tsx} +0 -0
  97. /package/src/_stories/{CdcMap.Filters.stories.tsx → CdcMap.Filters.smoke.stories.tsx} +0 -0
  98. /package/src/_stories/{CdcMap.Legend.Gradient.stories.tsx → CdcMap.Legend.Gradient.smoke.stories.tsx} +0 -0
  99. /package/src/_stories/{CdcMap.Legend.stories.tsx → CdcMap.Legend.smoke.stories.tsx} +0 -0
  100. /package/src/_stories/{CdcMap.Patterns.stories.tsx → CdcMap.Patterns.smoke.stories.tsx} +0 -0
  101. /package/src/_stories/{CdcMap.SmallMultiples.stories.tsx → CdcMap.SmallMultiples.smoke.stories.tsx} +0 -0
  102. /package/src/_stories/{CdcMap.Table.stories.tsx → CdcMap.Table.smoke.stories.tsx} +0 -0
  103. /package/src/_stories/{CdcMap.ZeroColor.stories.tsx → CdcMap.ZeroColor.smoke.stories.tsx} +0 -0
  104. /package/src/_stories/{GoogleMap.stories.tsx → GoogleMap.smoke.stories.tsx} +0 -0
  105. /package/src/_stories/{UsaMap.NoData.stories.tsx → UsaMap.NoData.smoke.stories.tsx} +0 -0
@@ -1,23 +1,19 @@
1
- import { useEffect, useState, useRef, useContext } from 'react'
1
+ import { useEffect, useState, useRef, useContext, useMemo } from 'react'
2
2
  import { geoCentroid, geoPath, geoContains } from 'd3-geo'
3
+ import type { GeoGeometryObjects } from 'd3-geo'
3
4
  import { zoom as d3Zoom, zoomIdentity as d3ZoomIdentity } from 'd3-zoom'
4
5
  import { select as d3Select } from 'd3-selection'
5
- import { feature } from 'topojson-client'
6
+ import { feature, merge } from 'topojson-client'
6
7
  import { geoAlbersUsaTerritories } from 'd3-composite-projections'
7
8
  import debounce from 'lodash.debounce'
8
9
  import Loading from '@cdc/core/components/Loading'
9
10
  import ErrorBoundary from '@cdc/core/components/ErrorBoundary'
11
+ import usExtendedGeography from '../data/us-extended-geography.json'
10
12
  import useMapLayers from '../../../hooks/useMapLayers'
11
13
  import ConfigContext from '../../../context'
12
14
  import { useLegendMemoContext } from '../../../context/LegendMemoContext'
13
15
  import { drawShape, createShapeProperties } from '../helpers/shapes'
14
- import {
15
- getGeoStrokeColor,
16
- handleMapAriaLabels,
17
- displayGeoName,
18
- isLegendItemDisabled,
19
- MAX_ZOOM_LEVEL
20
- } from '../../../helpers'
16
+ import { getGeoStrokeColor, handleMapAriaLabels, displayGeoName, isLegendItemDisabled } from '../../../helpers'
21
17
  import { supportedStatesFipsCodes } from '../../../data/supported-geos'
22
18
  import useGeoClickHandler from '../../../hooks/useGeoClickHandler'
23
19
  import { applyLegendToRow } from '../../../helpers/applyLegendToRow'
@@ -28,6 +24,40 @@ import { publishAnalyticsEvent } from '@cdc/core/helpers/metrics/helpers'
28
24
  import { getVizTitle, getVizSubType } from '@cdc/core/helpers/metrics/utils'
29
25
  import { createCanvasPattern, PatternType } from '../../../helpers/createCanvasPattern'
30
26
  import { getPatternForRow } from '../../../helpers/getPatternForRow'
27
+ import {
28
+ getCountyTerritoryVisibility,
29
+ type CountyTerritoryVisibility,
30
+ US_TERRITORY_STATE_FIPS_PREFIXES
31
+ } from '../../../helpers/countyTerritories'
32
+
33
+ type Geometry = GeoGeometryObjects & { id?: string; properties: { name: string } }
34
+ type Focus = {
35
+ id: string
36
+ index: number
37
+ center: [number, number]
38
+ feature: Geometry
39
+ }
40
+ type TopoData = {
41
+ year?: string
42
+ counties: Geometry[]
43
+ states: Geometry[]
44
+ hsas: { groupId: string; feature: Geometry }[]
45
+ mapData: Geometry[]
46
+ countyIndecies: Record<string, [number, number]>
47
+ projection: any
48
+ hsaMapping: Record<string, string>
49
+ }
50
+
51
+ const dedupeFeaturesById = <T extends { id?: string }>(features: T[]): T[] => {
52
+ const seenIds = new Set<string>()
53
+
54
+ return features.filter(feature => {
55
+ if (!feature.id) return true
56
+ if (seenIds.has(feature.id)) return false
57
+ seenIds.add(feature.id)
58
+ return true
59
+ })
60
+ }
31
61
 
32
62
  const getCountyTopoURL = year => {
33
63
  return `https://www.cdc.gov/TemplatePackage/contrib/data/county-topography/cb_${year}_us_county_20m.json`
@@ -39,7 +69,7 @@ const sortById = (a, b) => {
39
69
  return 0
40
70
  }
41
71
 
42
- const getTopoData = year => {
72
+ const getTopoData = (year, showHSABoundaries, territoryVisibility: CountyTerritoryVisibility, showPuertoRico) => {
43
73
  return new Promise(resolve => {
44
74
  const resolveWithTopo = async response => {
45
75
  if (response.status !== 200) {
@@ -48,28 +78,91 @@ const getTopoData = year => {
48
78
  response = await response.json()
49
79
  }
50
80
 
51
- let topoData = {
81
+ let topoData: TopoData = {
52
82
  year: undefined,
53
- counties: undefined,
54
- states: undefined,
55
- mapData: undefined,
56
- countyIndecies: undefined,
57
- projection: undefined
83
+ counties: [],
84
+ states: [],
85
+ hsas: [],
86
+ mapData: [],
87
+ countyIndecies: {},
88
+ projection: undefined,
89
+ hsaMapping: {}
58
90
  }
59
91
 
60
92
  topoData.year = year || 'default'
61
- topoData.counties = feature(response, response.objects.counties).features
62
- topoData.states = feature(response, response.objects.states).features
93
+ const topoSources = territoryVisibility.showTerritories ? [response, usExtendedGeography] : [response]
94
+ topoData.counties = dedupeFeaturesById(
95
+ topoSources
96
+ .flatMap(topo => feature(topo, topo.objects.counties).features)
97
+ .filter(county => typeof county.id === 'string' && county.id.length > 2)
98
+ )
99
+ topoData.states = dedupeFeaturesById(topoSources.flatMap(topo => feature(topo, topo.objects.states).features))
100
+ // Additional filtering removes territory features that may still be present in the topology data when territories are hidden.
101
+ if (!territoryVisibility.showTerritories) {
102
+ topoData.states = topoData.states.filter(state => {
103
+ const statePrefix = state.id?.substring(0, 2)
104
+ if (statePrefix === '72') return showPuertoRico
105
+ return !statePrefix || !US_TERRITORY_STATE_FIPS_PREFIXES.has(statePrefix)
106
+ })
107
+ topoData.counties = topoData.counties.filter(county => {
108
+ const countyPrefix = county.id?.substring(0, 2)
109
+ if (countyPrefix === '72') return showPuertoRico
110
+ if (showPuertoRico) {
111
+ return !countyPrefix || !US_TERRITORY_STATE_FIPS_PREFIXES.has(countyPrefix)
112
+ }
113
+
114
+ return !countyPrefix || !US_TERRITORY_STATE_FIPS_PREFIXES.has(countyPrefix)
115
+ })
116
+ } else {
117
+ topoData.states = topoData.states.filter(state => {
118
+ const statePrefix = state.id?.substring(0, 2)
119
+ return (
120
+ !statePrefix ||
121
+ !US_TERRITORY_STATE_FIPS_PREFIXES.has(statePrefix) ||
122
+ territoryVisibility.statePrefixes.has(statePrefix)
123
+ )
124
+ })
125
+ topoData.counties = topoData.counties.filter(county => {
126
+ const countyPrefix = county.id?.substring(0, 2)
127
+ return (
128
+ !countyPrefix ||
129
+ !US_TERRITORY_STATE_FIPS_PREFIXES.has(countyPrefix) ||
130
+ territoryVisibility.countyIds.has(county.id)
131
+ )
132
+ })
133
+ }
134
+ if (showHSABoundaries) {
135
+ const mappingResponse = await import(
136
+ /* webpackChunkName: "hsa_fips_mapping" */ './../data/hsa_fips_mapping.json'
137
+ )
138
+ const hsaMapping = mappingResponse.default.reduce((acc, curr) => {
139
+ acc[curr.county_fips] = curr.hsa_no
140
+ return acc
141
+ }, {} as Record<string, string>)
142
+ const countyGeometries = response.objects.counties.geometries
143
+ const geometriesByGroup = countyGeometries.reduce((acc, geometry) => {
144
+ const groupId = hsaMapping[geometry.id]
145
+ if (!groupId) return acc
146
+
147
+ if (!acc[groupId]) acc[groupId] = []
148
+ acc[groupId].push(geometry)
149
+ return acc
150
+ }, {} as Record<string, any[]>)
151
+ topoData.hsas = Object.entries(geometriesByGroup).map(([groupId, geometries]) => ({
152
+ groupId,
153
+ feature: merge(response as any, geometries)
154
+ }))
155
+ topoData.hsaMapping = hsaMapping
156
+ }
63
157
  topoData.states.sort(sortById)
64
158
  topoData.counties.sort(sortById)
65
159
  topoData.mapData = topoData.states.concat(topoData.counties).filter(geo => geo.id !== '51620') //Not sure why, but Franklin City, VA is very broken and messes up the rendering
66
- topoData.countyIndecies = {}
67
160
  topoData.states.forEach(state => {
68
161
  let minIndex = topoData.mapData.length - 1
69
162
  let maxIndex = 0
70
163
 
71
164
  for (let i = 0; i < topoData.mapData.length; i++) {
72
- if (topoData.mapData[i].id.length > 2 && topoData.mapData[i].id.indexOf(state.id) === 0) {
165
+ if (topoData.mapData[i].id?.length > 2 && topoData.mapData[i].id?.indexOf(state.id) === 0) {
73
166
  if (i < minIndex) minIndex = i
74
167
  if (i > maxIndex) maxIndex = i
75
168
  }
@@ -78,7 +171,6 @@ const getTopoData = year => {
78
171
  topoData.countyIndecies[state.id] = [minIndex, maxIndex]
79
172
  })
80
173
  topoData.projection = geoAlbersUsaTerritories()
81
-
82
174
  resolve(topoData)
83
175
  }
84
176
 
@@ -135,8 +227,7 @@ const getCurrentTopoYear = (config: MapConfig, runtimeFilters) => {
135
227
  }
136
228
 
137
229
  const isTopoReady = (topoData, config: MapConfig, runtimeFilters) => {
138
- let currentYear = getCurrentTopoYear(config, runtimeFilters)
139
-
230
+ const currentYear = getCurrentTopoYear(config, runtimeFilters)
140
231
  return topoData.year && (!currentYear || currentYear === topoData.year)
141
232
  }
142
233
 
@@ -148,7 +239,9 @@ const CountyMap = () => {
148
239
  runtimeFilters,
149
240
  runtimeLegend,
150
241
  setConfig,
151
- setFilteredStateCode,
242
+ filteredStateCode,
243
+ setFilteredStateCountyCode,
244
+ filteredCountyCode,
152
245
  config,
153
246
  tooltipId,
154
247
  tooltipRef,
@@ -161,13 +254,27 @@ const CountyMap = () => {
161
254
  const geoStrokeColor = getGeoStrokeColor(config)
162
255
  const { geoClickHandler } = useGeoClickHandler()
163
256
  const { applyTooltipsToGeo } = useApplyTooltipsToGeo()
164
- const [focus, setFocus] = useState({})
165
- const [topoData, setTopoData] = useState({})
257
+ const [topoData, setTopoData] = useState<TopoData>({} as TopoData)
258
+ const focus = useMemo(() => {
259
+ if (!isTopoReady(topoData, config, runtimeFilters) || !filteredStateCode) return {} as Focus
260
+ const stateGeo = topoData.states.find(state => state.id === filteredStateCode)
261
+ if (!stateGeo) return {} as Focus
262
+ return {
263
+ id: stateGeo.id,
264
+ index: topoData.mapData.findIndex(geo => geo.id === stateGeo.id),
265
+ center: geoCentroid(stateGeo),
266
+ feature: stateGeo
267
+ } as Focus
268
+ }, [filteredStateCode, topoData, config, runtimeFilters])
166
269
  const [hasMoved, setHasMoved] = useState(false)
167
270
 
168
271
  const pathGenerator = geoPath().projection(geoAlbersUsaTerritories())
169
272
 
170
273
  const { featureArray } = useMapLayers(config, setConfig, pathGenerator, tooltipId)
274
+ const territoryVisibility = useMemo(
275
+ () => getCountyTerritoryVisibility(config.general.territoriesAlwaysShow, runtimeData),
276
+ [config.general.territoriesAlwaysShow, runtimeData]
277
+ )
171
278
 
172
279
  useEffect(() => {
173
280
  if (containerEl) {
@@ -177,27 +284,55 @@ const CountyMap = () => {
177
284
  }
178
285
  })
179
286
 
287
+ const getAndSetTopoData = currentYear => {
288
+ getTopoData(
289
+ currentYear,
290
+ config.general.showHSABoundaries,
291
+ territoryVisibility,
292
+ config.migrations.showPuertoRico
293
+ ).then(response => {
294
+ if (canvasRef.current) {
295
+ const context = canvasRef.current.getContext('2d') as CanvasRenderingContext2D
296
+ context.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height)
297
+ }
298
+ geoPathCacheRef.current.clear()
299
+ geoPathCacheKeyRef.current = ''
300
+ setTopoData(response)
301
+ })
302
+ }
303
+
180
304
  useEffect(() => {
181
- let currentYear = getCurrentTopoYear(config, runtimeFilters)
305
+ const currentYear = getCurrentTopoYear(config, runtimeFilters)
182
306
 
183
307
  if (currentYear !== topoData.year) {
184
- getTopoData(currentYear).then(response => {
185
- if (canvasRef.current) {
186
- const context = canvasRef.current.getContext('2d')
187
- context.clearRect(canvasRef.current.width, canvasRef.current.height)
188
- }
189
- setTopoData(response)
190
- })
308
+ getAndSetTopoData(currentYear)
191
309
  }
192
- }, [config.general.countyCensusYear, config.general.filterControlsCountyYear, JSON.stringify(runtimeFilters)])
310
+ }, [
311
+ config.general.countyCensusYear,
312
+ config.general.filterControlsCountyYear,
313
+ JSON.stringify(runtimeFilters),
314
+ territoryVisibility.key
315
+ ])
316
+
317
+ const prevShowHSABoundariesRef = useRef(config.general.showHSABoundaries)
318
+ useEffect(() => {
319
+ if (prevShowHSABoundariesRef.current === config.general.showHSABoundaries) return
320
+ const currentYear = getCurrentTopoYear(config, runtimeFilters)
321
+ getAndSetTopoData(currentYear)
322
+ prevShowHSABoundariesRef.current = config.general.showHSABoundaries
323
+ }, [config.general.showHSABoundaries])
324
+
325
+ const prevTerritoryVisibilityRef = useRef(territoryVisibility.key)
326
+ useEffect(() => {
327
+ if (prevTerritoryVisibilityRef.current === territoryVisibility.key) return
328
+ const currentYear = getCurrentTopoYear(config, runtimeFilters)
329
+ getAndSetTopoData(currentYear)
330
+ prevTerritoryVisibilityRef.current = territoryVisibility.key
331
+ }, [territoryVisibility.key])
193
332
 
194
333
  // Whenever the memo at the top is triggered and the map is called to re-render, call drawCanvas and update
195
334
  // The resize function so it includes the latest state variables
196
335
  useEffect(() => {
197
- if (isTopoReady(topoData, config, runtimeFilters)) {
198
- drawCanvas()
199
- }
200
-
201
336
  const onResize = () => {
202
337
  if (canvasRef.current && isTopoReady(topoData, config, runtimeFilters)) {
203
338
  drawCanvas()
@@ -217,6 +352,7 @@ const CountyMap = () => {
217
352
  const zoomBehaviorRef = useRef()
218
353
  const zoomFrameRef = useRef<number | null>(null)
219
354
  const geoPathCacheRef = useRef<Map<string, Path2D>>(new Map())
355
+ const geoPathCacheKeyRef = useRef('')
220
356
 
221
357
  // Clear pattern cache when pattern configuration changes
222
358
  useEffect(() => {
@@ -226,8 +362,20 @@ const CountyMap = () => {
226
362
  const runtimeKeys = runtimeData ? Object.keys(runtimeData) : []
227
363
  const lineWidth = 1
228
364
 
365
+ const getPathCacheKey = (canvas: HTMLCanvasElement) =>
366
+ [
367
+ topoData.year,
368
+ topoData.mapData?.length || 0,
369
+ topoData.states?.length || 0,
370
+ topoData.hsas?.length || 0,
371
+ focus.id || '',
372
+ canvas.clientWidth,
373
+ config.general.showHSABoundaries ? 'hsa' : 'county',
374
+ territoryVisibility.key
375
+ ].join('|')
376
+
229
377
  // Pre-compute Path2D objects for all geo features — avoids expensive geoPath projection on every zoom frame
230
- const buildPathCache = () => {
378
+ const buildPathCache = (cacheKey: string) => {
231
379
  const pathGen = geoPath(topoData.projection)
232
380
  const cache = new Map<string, Path2D>()
233
381
  topoData.mapData.forEach(geo => {
@@ -240,7 +388,14 @@ const CountyMap = () => {
240
388
  const d = pathGen(state)
241
389
  if (d) cache.set('state_border_' + state.id, new Path2D(d))
242
390
  })
391
+ topoData.hsas.forEach(hsa => {
392
+ if (!hsa?.groupId || !hsa?.feature) return
393
+ const d = pathGen(hsa.feature as any)
394
+ if (d) cache.set('hsa_border_' + hsa.groupId, new Path2D(d))
395
+ })
396
+ geoPathCacheRef.current.clear()
243
397
  geoPathCacheRef.current = cache
398
+ geoPathCacheKeyRef.current = cacheKey
244
399
  }
245
400
 
246
401
  const resetZoomTransform = () => {
@@ -253,8 +408,10 @@ const CountyMap = () => {
253
408
  const getCanvasPoints = e => {
254
409
  const canvas = e.target
255
410
  const canvasBounds = canvas.getBoundingClientRect()
256
- const x = e.clientX - canvasBounds.left
257
- const y = e.clientY - canvasBounds.top
411
+ const scaleX = canvasBounds.width ? canvas.width / canvasBounds.width : 1
412
+ const scaleY = canvasBounds.height ? canvas.height / canvasBounds.height : 1
413
+ const x = (e.clientX - canvasBounds.left) * scaleX
414
+ const y = (e.clientY - canvasBounds.top) * scaleY
258
415
  const [mapX, mapY] = zoomTransformRef.current.invert([x, y])
259
416
  return { canvas, mapX, mapY }
260
417
  }
@@ -265,8 +422,19 @@ const CountyMap = () => {
265
422
  }
266
423
 
267
424
  const getZoomScale = () => zoomTransformRef.current?.k || 1
425
+ const getZoomTransformString = () => {
426
+ const { x, y, k } = zoomTransformRef.current || d3ZoomIdentity
427
+ return `translate(${x} ${y}) scale(${k})`
428
+ }
268
429
 
269
- const paintCountyGeo = (context, path2d: Path2D, geoData, canvasWidth: number, strokeWidth?: number) => {
430
+ const paintCountyGeo = (
431
+ context,
432
+ path2d: Path2D,
433
+ geoData,
434
+ canvasWidth: number,
435
+ strokeWidth?: number,
436
+ strokeColor?: string
437
+ ) => {
270
438
  const legendValues =
271
439
  geoData !== undefined
272
440
  ? applyLegendToRow(geoData, config, runtimeLegend, legendMemo, legendSpecialClassLastMemo)
@@ -312,7 +480,7 @@ const CountyMap = () => {
312
480
  }
313
481
  }
314
482
 
315
- context.strokeStyle = geoStrokeColor
483
+ context.strokeStyle = strokeColor ?? geoStrokeColor
316
484
  context.lineWidth = strokeWidth ?? lineWidth
317
485
  context.stroke(path2d)
318
486
 
@@ -332,8 +500,7 @@ const CountyMap = () => {
332
500
  ...config,
333
501
  mapPosition: { coordinates: [0, 30], zoom: 1 }
334
502
  })
335
- setFilteredStateCode('')
336
- setFocus({})
503
+ setFilteredStateCountyCode('')
337
504
  resetZoomTransform()
338
505
  }
339
506
 
@@ -411,7 +578,7 @@ const CountyMap = () => {
411
578
  const pointCoordinates = topoData.projection.invert([mapX, mapY])
412
579
 
413
580
  // Use d3 geoContains method to find the state geo data that the user clicked inside
414
- let clickedState
581
+ let clickedState: Geometry
415
582
  for (let i = 0; i < topoData.states.length; i++) {
416
583
  if (geoContains(topoData.states[i], pointCoordinates)) {
417
584
  clickedState = topoData.states[i]
@@ -420,6 +587,7 @@ const CountyMap = () => {
420
587
  }
421
588
 
422
589
  // If the user clicked outside of all states, no behavior
590
+ let clickedCounty = ''
423
591
  if (clickedState) {
424
592
  // If a county within the state was clicked and has data, call parent click handler
425
593
  if (topoData.countyIndecies[clickedState.id]) {
@@ -436,6 +604,11 @@ const CountyMap = () => {
436
604
  }
437
605
  if (county && runtimeData[county.id]) {
438
606
  geoClickHandler(displayGeoName(county.id), runtimeData[county.id])
607
+ if (filteredStateCode) {
608
+ if (filteredStateCode === clickedState.id) {
609
+ clickedCounty = county.id || ''
610
+ }
611
+ }
439
612
  }
440
613
  }
441
614
 
@@ -445,17 +618,8 @@ const CountyMap = () => {
445
618
  ...config,
446
619
  mapPosition: { coordinates: [0, 30], zoom: 3 }
447
620
  })
448
- setFilteredStateCode(clickedState.id)
621
+ setFilteredStateCountyCode(clickedState.id, clickedCounty)
449
622
 
450
- let focusIndex = -1
451
- for (let i = 0; i < topoData.mapData.length; i++) {
452
- if (topoData.mapData[i].id === clickedState.id) {
453
- focusIndex = i
454
- break
455
- }
456
- }
457
-
458
- setFocus({ id: clickedState.id, index: focusIndex, center: geoCentroid(clickedState), feature: clickedState })
459
623
  publishAnalyticsEvent({
460
624
  vizType: config.type,
461
625
  vizSubType: getVizSubType(config),
@@ -524,28 +688,6 @@ const CountyMap = () => {
524
688
  if (config.general.type !== 'us-geocode') {
525
689
  //If no tooltip is shown, or if the current geo associated with the tooltip shown is no longer containing the mouse, then rerender the tooltip
526
690
  if (isNaN(currentTooltipIndex) || !geoContains(topoData.mapData[currentTooltipIndex], pointCoordinates)) {
527
- if (
528
- !isNaN(currentTooltipIndex) &&
529
- applyLegendToRow(
530
- runtimeData[topoData.mapData[currentTooltipIndex].id],
531
- config,
532
- runtimeLegend,
533
- legendMemo,
534
- legendSpecialClassLastMemo
535
- )
536
- ) {
537
- const prevPath2d = geoPathCacheRef.current.get(topoData.mapData[currentTooltipIndex].id)
538
- if (prevPath2d) {
539
- paintCountyGeo(
540
- context,
541
- prevPath2d,
542
- runtimeData[topoData.mapData[currentTooltipIndex].id],
543
- canvas.width,
544
- lineWidth * strokeScale
545
- )
546
- }
547
- }
548
-
549
691
  let hoveredState
550
692
  let county
551
693
  let countyIndex
@@ -583,10 +725,6 @@ const CountyMap = () => {
583
725
  return
584
726
  }
585
727
  context.globalAlpha = 1
586
- const hoverPath2d = geoPathCacheRef.current.get(county.id)
587
- if (hoverPath2d) {
588
- paintCountyGeo(context, hoverPath2d, runtimeData[county.id], canvas.width, lineWidth * strokeScale)
589
- }
590
728
  }
591
729
 
592
730
  // Track hover analytics event if this is a new location
@@ -736,7 +874,7 @@ const CountyMap = () => {
736
874
  }
737
875
  }
738
876
 
739
- if (focus.index !== -1) {
877
+ if (focus.index !== -1 && !config.general.showHSABoundaries) {
740
878
  const focusPath2d = geoPathCacheRef.current.get(topoData.mapData[focus.index]?.id)
741
879
  if (focusPath2d) {
742
880
  context.strokeStyle = geoStrokeColor
@@ -747,14 +885,17 @@ const CountyMap = () => {
747
885
  context.restore()
748
886
  }
749
887
 
750
- // Sets up canvas dimensions, projection, and Path2D cache, then renders.
888
+ // Sets up canvas dimensions and projection, rebuilds the Path2D cache only when geometry changes, then renders.
751
889
  // Called on data change, resize, focus change — NOT during zoom/pan.
752
890
  const drawCanvas = () => {
753
891
  if (canvasRef.current && runtimeLegend.items.length > 0) {
754
892
  const canvas = canvasRef.current
893
+ const canvasWidth = canvas.clientWidth
894
+ if (canvasWidth <= 0) return
895
+ const canvasHeight = canvasWidth * 0.6
755
896
 
756
- canvas.width = canvas.clientWidth
757
- canvas.height = canvas.width * 0.6
897
+ if (canvas.width !== canvasWidth) canvas.width = canvasWidth
898
+ if (canvas.height !== canvasHeight) canvas.height = canvasHeight
758
899
 
759
900
  topoData.projection.scale(canvas.width * 1.25).translate([canvas.width / 2, canvas.height / 2])
760
901
 
@@ -768,8 +909,10 @@ const CountyMap = () => {
768
909
  topoData.projection.fitExtent(fitExtent, focus.feature)
769
910
  }
770
911
 
771
- // Pre-compute Path2D objects with the current projection
772
- buildPathCache()
912
+ const pathCacheKey = getPathCacheKey(canvas)
913
+ if (geoPathCacheKeyRef.current !== pathCacheKey || geoPathCacheRef.current.size === 0) {
914
+ buildPathCache(pathCacheKey)
915
+ }
773
916
 
774
917
  // Render the map
775
918
  renderFrame()
@@ -779,7 +922,7 @@ const CountyMap = () => {
779
922
  // Fast render using cached Path2D objects — called during zoom/pan for smooth performance.
780
923
  // Skips canvas resize and projection setup; only applies the current zoom transform and redraws.
781
924
  const renderFrame = () => {
782
- if (!canvasRef.current || !runtimeLegend.items.length) return
925
+ if (!canvasRef.current || !runtimeLegend.items.length || !topoData.mapData) return
783
926
 
784
927
  const canvas = canvasRef.current
785
928
  const context = canvas.getContext('2d')
@@ -792,27 +935,65 @@ const CountyMap = () => {
792
935
  applyZoomTransform(context)
793
936
  const zoomScale = getZoomScale()
794
937
  const strokeScale = zoomScale ? 1 / zoomScale : 1
795
- const countyStrokeWidth = lineWidth * 0.8 * strokeScale
938
+ const useHsaStrokeStyling = config.general.showHSABoundaries
939
+ const countyStrokeWidth = lineWidth * (useHsaStrokeStyling ? 0.45 : 0.8) * strokeScale
940
+ const hsaStrokeWidth = lineWidth * 0.7 * strokeScale
941
+ const countyStrokeColor = useHsaStrokeStyling ? '#a9aeb1' : geoStrokeColor
942
+ const isZoomedIntoState = focus.id
943
+ const hsaStrokeColor = isZoomedIntoState ? '#000' : '#303030'
944
+ const stateStrokeColor = useHsaStrokeStyling ? '#000000' : '#1c1d1f'
796
945
 
797
946
  // Enforces stroke style of the county lines
798
- context.strokeStyle = geoStrokeColor
947
+ context.strokeStyle = countyStrokeColor
799
948
  context.lineWidth = countyStrokeWidth
800
949
 
801
950
  // Iterates through each state/county topo and renders it using cached Path2D
951
+ let countyHighlight = null
802
952
  topoData.mapData.forEach(geo => {
803
953
  if (!geo.id) return
804
- if (focus.id && geo.id.length > 2 && geo.id.indexOf(focus.id) !== 0) return
954
+ const hideCounty =
955
+ !config.general.showNeighboringStates && focus.id && geo.id.length > 2 && geo.id.indexOf(focus.id) !== 0
956
+ if (hideCounty) return
805
957
  if (!focus.id && config.general.type === 'us-geocode' && geo.id.length > 2) return
806
958
 
807
959
  const path2d = cache.get(geo.id)
808
960
  if (!path2d) return
809
961
 
810
962
  const geoData = runtimeData[geo.id]
811
- paintCountyGeo(context, path2d, geoData, canvas.width, countyStrokeWidth)
963
+ if (!config.general.showHSABoundaries && filteredCountyCode && geo.id === filteredCountyCode) {
964
+ countyHighlight = {
965
+ context,
966
+ path2d,
967
+ geoData,
968
+ canvasWidth: canvas.width,
969
+ strokeWidth: 2,
970
+ strokeColor: '#000000'
971
+ }
972
+ }
973
+ paintCountyGeo(context, path2d, geoData, canvas.width, countyStrokeWidth, countyStrokeColor)
812
974
  })
813
975
 
976
+ let hsaHighlight = null
977
+ if (config.general.showHSABoundaries) {
978
+ context.strokeStyle = hsaStrokeColor
979
+ context.lineWidth = hsaStrokeWidth
980
+
981
+ topoData.hsas.forEach(hsa => {
982
+ if (!hsa?.groupId) return
983
+ const cacheKey = 'hsa_border_' + hsa.groupId
984
+ const path2d = cache.get(cacheKey)
985
+ if (path2d) {
986
+ if (filteredCountyCode && topoData.hsaMapping[filteredCountyCode] === hsa.groupId) {
987
+ hsaHighlight = path2d
988
+ } else {
989
+ context.stroke(path2d)
990
+ }
991
+ }
992
+ })
993
+ }
994
+
814
995
  // State borders
815
- context.strokeStyle = '#1c1d1f'
996
+ context.strokeStyle = stateStrokeColor
816
997
  context.lineWidth = lineWidth * 1.25 * strokeScale
817
998
  topoData.states.forEach(state => {
818
999
  if (!state.id) return
@@ -827,7 +1008,7 @@ const CountyMap = () => {
827
1008
  const focusGeoId = topoData.mapData[focus.index]?.id
828
1009
  const path2d = focusGeoId && cache.get(focusGeoId)
829
1010
  if (path2d) {
830
- context.strokeStyle = geoStrokeColor
1011
+ context.strokeStyle = config.general.showNeighboringStates ? '#000000' : geoStrokeColor
831
1012
  context.lineWidth = lineWidth * 2 * strokeScale
832
1013
  context.stroke(path2d)
833
1014
  }
@@ -905,13 +1086,24 @@ const CountyMap = () => {
905
1086
  }
906
1087
  })
907
1088
  }
1089
+
1090
+ // Highlight county last so it is visible on top of all other layers
1091
+ if (countyHighlight) {
1092
+ const { context, path2d, geoData, canvasWidth, strokeWidth, strokeColor } = countyHighlight
1093
+ paintCountyGeo(context, path2d, geoData, canvasWidth, strokeWidth, strokeColor)
1094
+ }
1095
+ // Highlight HSA boundary if applicable
1096
+ if (hsaHighlight) {
1097
+ context.lineWidth = 2.5 * strokeScale
1098
+ context.strokeStyle = hsaStrokeColor
1099
+ context.stroke(hsaHighlight)
1100
+ }
908
1101
  context.restore()
909
1102
  }
910
1103
 
911
1104
  useEffect(() => {
912
1105
  if (!config.general.allowMapZoom) {
913
- setFilteredStateCode('')
914
- setFocus({})
1106
+ setFilteredStateCountyCode('')
915
1107
  setHasMoved(false)
916
1108
  resetZoomTransform()
917
1109
  }
@@ -951,14 +1143,15 @@ const CountyMap = () => {
951
1143
  resetZoomTransform()
952
1144
  }, [focus?.id])
953
1145
 
954
- // If runtimeData is not defined, show loader
955
- if (!runtimeData || !isTopoReady(topoData, config, runtimeFilters)) {
956
- return (
957
- <div style={{ height: 300 }}>
958
- <Loading />
959
- </div>
960
- )
961
- }
1146
+ const isLoading = !runtimeData || !isTopoReady(topoData, config, runtimeFilters) || !canvasRef.current
1147
+
1148
+ useEffect(() => {
1149
+ if (isLoading) {
1150
+ return
1151
+ }
1152
+
1153
+ drawCanvas()
1154
+ }, [isLoading, topoData, focus, runtimeLegend, runtimeData, featureArray, config, filteredCountyCode])
962
1155
 
963
1156
  const showManualZoomControls = config.general.allowMapZoom
964
1157
  const showResetControl = (hasMoved || focus.id) && (showManualZoomControls || config.general.type === 'us-geocode')
@@ -967,21 +1160,29 @@ const CountyMap = () => {
967
1160
 
968
1161
  return (
969
1162
  <ErrorBoundary component='CountyMap'>
1163
+ {isLoading && (
1164
+ <div style={{ height: 300 }}>
1165
+ <Loading />
1166
+ </div>
1167
+ )}
970
1168
  <canvas
971
1169
  ref={canvasRef}
1170
+ role='img'
972
1171
  aria-label={handleMapAriaLabels(config)}
1172
+ data-zoom-transform={getZoomTransformString()}
1173
+ data-zoom-scale={getZoomScale()}
973
1174
  onMouseMove={canvasHover}
974
1175
  onMouseOut={() => {
975
1176
  tooltipRef.current.style.display = 'none'
976
1177
  tooltipRef.current.setAttribute('data-index', null)
977
1178
  }}
978
1179
  onClick={canvasClick}
979
- className='county-map-canvas'
1180
+ className={'county-map-canvas' + (isLoading ? ' d-none' : '')}
980
1181
  style={config.general.allowMapZoom ? undefined : { cursor: 'default' }}
981
1182
  ></canvas>
982
1183
 
983
1184
  {showManualZoomControls && (
984
- <div className='zoom-controls' data-html2canvas-ignore='true'>
1185
+ <div className={'zoom-controls' + (isLoading ? ' d-none' : '')} data-html2canvas-ignore='true'>
985
1186
  <button onClick={handleZoomIn} aria-label='Zoom In'>
986
1187
  <svg viewBox='0 0 24 24' stroke='currentColor' strokeWidth='3'>
987
1188
  <line x1='12' y1='5' x2='12' y2='19' />