@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
@@ -1,10 +1,14 @@
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 { feature } from 'topojson-client'
3
+ import type { GeoGeometryObjects } from 'd3-geo'
4
+ import { zoom as d3Zoom, zoomIdentity as d3ZoomIdentity } from 'd3-zoom'
5
+ import { select as d3Select } from 'd3-selection'
6
+ import { feature, merge } from 'topojson-client'
4
7
  import { geoAlbersUsaTerritories } from 'd3-composite-projections'
5
8
  import debounce from 'lodash.debounce'
6
9
  import Loading from '@cdc/core/components/Loading'
7
10
  import ErrorBoundary from '@cdc/core/components/ErrorBoundary'
11
+ import usExtendedGeography from '../data/us-extended-geography.json'
8
12
  import useMapLayers from '../../../hooks/useMapLayers'
9
13
  import ConfigContext from '../../../context'
10
14
  import { useLegendMemoContext } from '../../../context/LegendMemoContext'
@@ -20,6 +24,40 @@ import { publishAnalyticsEvent } from '@cdc/core/helpers/metrics/helpers'
20
24
  import { getVizTitle, getVizSubType } from '@cdc/core/helpers/metrics/utils'
21
25
  import { createCanvasPattern, PatternType } from '../../../helpers/createCanvasPattern'
22
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
+ }
23
61
 
24
62
  const getCountyTopoURL = year => {
25
63
  return `https://www.cdc.gov/TemplatePackage/contrib/data/county-topography/cb_${year}_us_county_20m.json`
@@ -31,7 +69,7 @@ const sortById = (a, b) => {
31
69
  return 0
32
70
  }
33
71
 
34
- const getTopoData = year => {
72
+ const getTopoData = (year, showHSABoundaries, territoryVisibility: CountyTerritoryVisibility, showPuertoRico) => {
35
73
  return new Promise(resolve => {
36
74
  const resolveWithTopo = async response => {
37
75
  if (response.status !== 200) {
@@ -40,28 +78,91 @@ const getTopoData = year => {
40
78
  response = await response.json()
41
79
  }
42
80
 
43
- let topoData = {
81
+ let topoData: TopoData = {
44
82
  year: undefined,
45
- counties: undefined,
46
- states: undefined,
47
- mapData: undefined,
48
- countyIndecies: undefined,
49
- projection: undefined
83
+ counties: [],
84
+ states: [],
85
+ hsas: [],
86
+ mapData: [],
87
+ countyIndecies: {},
88
+ projection: undefined,
89
+ hsaMapping: {}
50
90
  }
51
91
 
52
92
  topoData.year = year || 'default'
53
- topoData.counties = feature(response, response.objects.counties).features
54
- 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
+ }
55
157
  topoData.states.sort(sortById)
56
158
  topoData.counties.sort(sortById)
57
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
58
- topoData.countyIndecies = {}
59
160
  topoData.states.forEach(state => {
60
161
  let minIndex = topoData.mapData.length - 1
61
162
  let maxIndex = 0
62
163
 
63
164
  for (let i = 0; i < topoData.mapData.length; i++) {
64
- 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) {
65
166
  if (i < minIndex) minIndex = i
66
167
  if (i > maxIndex) maxIndex = i
67
168
  }
@@ -70,7 +171,6 @@ const getTopoData = year => {
70
171
  topoData.countyIndecies[state.id] = [minIndex, maxIndex]
71
172
  })
72
173
  topoData.projection = geoAlbersUsaTerritories()
73
-
74
174
  resolve(topoData)
75
175
  }
76
176
 
@@ -127,8 +227,7 @@ const getCurrentTopoYear = (config: MapConfig, runtimeFilters) => {
127
227
  }
128
228
 
129
229
  const isTopoReady = (topoData, config: MapConfig, runtimeFilters) => {
130
- let currentYear = getCurrentTopoYear(config, runtimeFilters)
131
-
230
+ const currentYear = getCurrentTopoYear(config, runtimeFilters)
132
231
  return topoData.year && (!currentYear || currentYear === topoData.year)
133
232
  }
134
233
 
@@ -140,6 +239,9 @@ const CountyMap = () => {
140
239
  runtimeFilters,
141
240
  runtimeLegend,
142
241
  setConfig,
242
+ filteredStateCode,
243
+ setFilteredStateCountyCode,
244
+ filteredCountyCode,
143
245
  config,
144
246
  tooltipId,
145
247
  tooltipRef,
@@ -152,12 +254,27 @@ const CountyMap = () => {
152
254
  const geoStrokeColor = getGeoStrokeColor(config)
153
255
  const { geoClickHandler } = useGeoClickHandler()
154
256
  const { applyTooltipsToGeo } = useApplyTooltipsToGeo()
155
- const [focus, setFocus] = useState({})
156
- 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])
269
+ const [hasMoved, setHasMoved] = useState(false)
157
270
 
158
271
  const pathGenerator = geoPath().projection(geoAlbersUsaTerritories())
159
272
 
160
273
  const { featureArray } = useMapLayers(config, setConfig, pathGenerator, tooltipId)
274
+ const territoryVisibility = useMemo(
275
+ () => getCountyTerritoryVisibility(config.general.territoriesAlwaysShow, runtimeData),
276
+ [config.general.territoriesAlwaysShow, runtimeData]
277
+ )
161
278
 
162
279
  useEffect(() => {
163
280
  if (containerEl) {
@@ -167,27 +284,53 @@ const CountyMap = () => {
167
284
  }
168
285
  })
169
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
+ setTopoData(response)
299
+ })
300
+ }
301
+
170
302
  useEffect(() => {
171
- let currentYear = getCurrentTopoYear(config, runtimeFilters)
303
+ const currentYear = getCurrentTopoYear(config, runtimeFilters)
172
304
 
173
305
  if (currentYear !== topoData.year) {
174
- getTopoData(currentYear).then(response => {
175
- if (canvasRef.current) {
176
- const context = canvasRef.current.getContext('2d')
177
- context.clearRect(canvasRef.current.width, canvasRef.current.height)
178
- }
179
- setTopoData(response)
180
- })
306
+ getAndSetTopoData(currentYear)
181
307
  }
182
- }, [config.general.countyCensusYear, config.general.filterControlsCountyYear, JSON.stringify(runtimeFilters)])
308
+ }, [
309
+ config.general.countyCensusYear,
310
+ config.general.filterControlsCountyYear,
311
+ JSON.stringify(runtimeFilters),
312
+ territoryVisibility.key
313
+ ])
314
+
315
+ const prevShowHSABoundariesRef = useRef(config.general.showHSABoundaries)
316
+ useEffect(() => {
317
+ if (prevShowHSABoundariesRef.current === config.general.showHSABoundaries) return
318
+ const currentYear = getCurrentTopoYear(config, runtimeFilters)
319
+ getAndSetTopoData(currentYear)
320
+ prevShowHSABoundariesRef.current = config.general.showHSABoundaries
321
+ }, [config.general.showHSABoundaries])
322
+
323
+ const prevTerritoryVisibilityRef = useRef(territoryVisibility.key)
324
+ useEffect(() => {
325
+ if (prevTerritoryVisibilityRef.current === territoryVisibility.key) return
326
+ const currentYear = getCurrentTopoYear(config, runtimeFilters)
327
+ getAndSetTopoData(currentYear)
328
+ prevTerritoryVisibilityRef.current = territoryVisibility.key
329
+ }, [territoryVisibility.key])
183
330
 
184
331
  // Whenever the memo at the top is triggered and the map is called to re-render, call drawCanvas and update
185
332
  // The resize function so it includes the latest state variables
186
333
  useEffect(() => {
187
- if (isTopoReady(topoData, config, runtimeFilters)) {
188
- drawCanvas()
189
- }
190
-
191
334
  const onResize = () => {
192
335
  if (canvasRef.current && isTopoReady(topoData, config, runtimeFilters)) {
193
336
  drawCanvas()
@@ -201,28 +344,74 @@ const CountyMap = () => {
201
344
  return () => window.removeEventListener('resize', debounceOnResize)
202
345
  })
203
346
 
204
- const resetButton = useRef()
205
347
  const canvasRef = useRef()
206
348
  const patternCacheRef = useRef<Map<string, CanvasPattern | null>>(new Map())
349
+ const zoomTransformRef = useRef(d3ZoomIdentity)
350
+ const zoomBehaviorRef = useRef()
351
+ const zoomFrameRef = useRef<number | null>(null)
352
+ const geoPathCacheRef = useRef<Map<string, Path2D>>(new Map())
207
353
 
208
354
  // Clear pattern cache when pattern configuration changes
209
355
  useEffect(() => {
210
356
  patternCacheRef.current.clear()
211
357
  }, [config.map?.patterns])
212
358
 
213
- // If runtimeData is not defined, show loader
214
- if (!runtimeData || !isTopoReady(topoData, config, runtimeFilters)) {
215
- return (
216
- <div style={{ height: 300 }}>
217
- <Loading />
218
- </div>
219
- )
359
+ const runtimeKeys = runtimeData ? Object.keys(runtimeData) : []
360
+ const lineWidth = 1
361
+
362
+ // Pre-compute Path2D objects for all geo features — avoids expensive geoPath projection on every zoom frame
363
+ const buildPathCache = () => {
364
+ const pathGen = geoPath(topoData.projection)
365
+ const cache = new Map<string, Path2D>()
366
+ topoData.mapData.forEach(geo => {
367
+ if (!geo.id) return
368
+ const d = pathGen(geo)
369
+ if (d) cache.set(geo.id, new Path2D(d))
370
+ })
371
+ topoData.states.forEach(state => {
372
+ if (!state.id) return
373
+ const d = pathGen(state)
374
+ if (d) cache.set('state_border_' + state.id, new Path2D(d))
375
+ })
376
+ topoData.hsas.forEach(hsa => {
377
+ if (!hsa?.groupId || !hsa?.feature) return
378
+ const d = pathGen(hsa.feature as any)
379
+ if (d) cache.set('hsa_border_' + hsa.groupId, new Path2D(d))
380
+ })
381
+ geoPathCacheRef.current = cache
220
382
  }
221
383
 
222
- const runtimeKeys = Object.keys(runtimeData)
223
- const lineWidth = 1
384
+ const resetZoomTransform = () => {
385
+ zoomTransformRef.current = d3ZoomIdentity
386
+ if (canvasRef.current && zoomBehaviorRef.current) {
387
+ d3Select(canvasRef.current).call(zoomBehaviorRef.current.transform, d3ZoomIdentity)
388
+ }
389
+ }
390
+
391
+ const getCanvasPoints = e => {
392
+ const canvas = e.target
393
+ const canvasBounds = canvas.getBoundingClientRect()
394
+ const x = e.clientX - canvasBounds.left
395
+ const y = e.clientY - canvasBounds.top
396
+ const [mapX, mapY] = zoomTransformRef.current.invert([x, y])
397
+ return { canvas, mapX, mapY }
398
+ }
399
+
400
+ const applyZoomTransform = context => {
401
+ const { x, y, k } = zoomTransformRef.current || d3ZoomIdentity
402
+ context.setTransform(k, 0, 0, k, x, y)
403
+ }
404
+
405
+ const getZoomScale = () => zoomTransformRef.current?.k || 1
224
406
 
225
- const paintCountyGeo = (context, path, geo, geoData, canvasWidth: number) => {
407
+ const paintCountyGeo = (
408
+ context,
409
+ path2d: Path2D,
410
+ geoData,
411
+ canvasWidth: number,
412
+ strokeWidth?: number,
413
+ strokeColor?: string
414
+ ) => {
226
415
  const legendValues =
227
416
  geoData !== undefined
228
417
  ? applyLegendToRow(geoData, config, runtimeLegend, legendMemo, legendSpecialClassLastMemo)
@@ -236,9 +425,7 @@ const CountyMap = () => {
236
425
  : DEFAULT_MAP_BACKGROUND
237
426
 
238
427
  context.fillStyle = baseFill
239
- context.beginPath()
240
- path(geo)
241
- context.fill()
428
+ context.fill(path2d)
242
429
 
243
430
  if (config.map?.patterns?.length > 0 && geoData) {
244
431
  const patternInfo = getPatternForRow(geoData, config)
@@ -247,8 +434,8 @@ const CountyMap = () => {
247
434
  const { pattern, size, color } = patternInfo
248
435
  const patternColor = color || '#000000'
249
436
  const patternSize = size || 'medium'
250
- const strokeWidth = canvasWidth < 200 ? 1.75 : canvasWidth < 375 ? 1.25 : 0.75
251
- const cacheKey = `${pattern}-${patternColor}-${patternSize}-${strokeWidth}`
437
+ const patternStrokeWidth = canvasWidth < 200 ? 1.75 : canvasWidth < 375 ? 1.25 : 0.75
438
+ const cacheKey = `${pattern}-${patternColor}-${patternSize}-${patternStrokeWidth}`
252
439
 
253
440
  let canvasPattern = patternCacheRef.current.get(cacheKey)
254
441
  if (!canvasPattern) {
@@ -256,7 +443,7 @@ const CountyMap = () => {
256
443
  pattern as PatternType,
257
444
  patternColor,
258
445
  patternSize as 'small' | 'medium' | 'large',
259
- strokeWidth
446
+ patternStrokeWidth
260
447
  )
261
448
  if (canvasPattern) {
262
449
  patternCacheRef.current.set(cacheKey, canvasPattern)
@@ -265,18 +452,14 @@ const CountyMap = () => {
265
452
 
266
453
  if (canvasPattern) {
267
454
  context.fillStyle = canvasPattern
268
- context.beginPath()
269
- path(geo)
270
- context.fill()
455
+ context.fill(path2d)
271
456
  }
272
457
  }
273
458
  }
274
459
 
275
- context.strokeStyle = geoStrokeColor
276
- context.lineWidth = lineWidth
277
- context.beginPath()
278
- path(geo)
279
- context.stroke()
460
+ context.strokeStyle = strokeColor ?? geoStrokeColor
461
+ context.lineWidth = strokeWidth ?? lineWidth
462
+ context.stroke(path2d)
280
463
 
281
464
  return legendValues
282
465
  }
@@ -294,18 +477,85 @@ const CountyMap = () => {
294
477
  ...config,
295
478
  mapPosition: { coordinates: [0, 30], zoom: 1 }
296
479
  })
297
- setFocus({})
480
+ setFilteredStateCountyCode('')
481
+ resetZoomTransform()
482
+ }
483
+
484
+ const handleZoomIn = () => {
485
+ if (!canvasRef.current || !zoomBehaviorRef.current) return
486
+ d3Select(canvasRef.current).call(zoomBehaviorRef.current.scaleBy, 1.2)
487
+ }
488
+
489
+ const handleZoomOut = () => {
490
+ if (!canvasRef.current || !zoomBehaviorRef.current) return
491
+ d3Select(canvasRef.current).call(zoomBehaviorRef.current.scaleBy, 1 / 1.2)
492
+ }
493
+
494
+ const handleZoomReset = () => {
495
+ const container = canvasRef.current?.closest('.geography-container') as HTMLElement | null
496
+ setHasMoved(false)
497
+ onReset()
498
+ container?.focus()
499
+ }
500
+
501
+ const PAN_STEP = 20
502
+
503
+ useEffect(() => {
504
+ if (!config.general.allowMapZoom) return
505
+
506
+ const container = canvasRef.current?.closest('.geography-container') as HTMLElement | null
507
+ if (!container) return
508
+
509
+ const handleKeyboardPan = (e: KeyboardEvent) => {
510
+ if (!canvasRef.current || !zoomBehaviorRef.current) return
511
+
512
+ const key = e.key.toLowerCase()
513
+ let dx = 0
514
+ let dy = 0
515
+
516
+ switch (key) {
517
+ case 'arrowleft':
518
+ case 'a':
519
+ dx = PAN_STEP
520
+ break
521
+ case 'arrowright':
522
+ case 'd':
523
+ dx = -PAN_STEP
524
+ break
525
+ case 'arrowup':
526
+ case 'w':
527
+ dy = PAN_STEP
528
+ break
529
+ case 'arrowdown':
530
+ case 's':
531
+ dy = -PAN_STEP
532
+ break
533
+ default:
534
+ return
535
+ }
536
+
537
+ e.preventDefault()
538
+ d3Select(canvasRef.current).call(zoomBehaviorRef.current.translateBy, dx, dy)
539
+ }
540
+
541
+ container.addEventListener('keydown', handleKeyboardPan)
542
+ return () => container.removeEventListener('keydown', handleKeyboardPan)
543
+ }, [config.general.allowMapZoom, topoData, runtimeLegend, focus])
544
+
545
+ const scheduleDraw = () => {
546
+ if (zoomFrameRef.current) return
547
+ zoomFrameRef.current = window.requestAnimationFrame(() => {
548
+ zoomFrameRef.current = null
549
+ renderFrame()
550
+ })
298
551
  }
299
552
 
300
553
  const canvasClick = e => {
301
- const canvas = e.target
302
- const canvasBounds = canvas.getBoundingClientRect()
303
- const x = e.clientX - canvasBounds.left
304
- const y = e.clientY - canvasBounds.top
305
- const pointCoordinates = topoData.projection.invert([x, y])
554
+ const { mapX, mapY } = getCanvasPoints(e)
555
+ const pointCoordinates = topoData.projection.invert([mapX, mapY])
306
556
 
307
557
  // Use d3 geoContains method to find the state geo data that the user clicked inside
308
- let clickedState
558
+ let clickedState: Geometry
309
559
  for (let i = 0; i < topoData.states.length; i++) {
310
560
  if (geoContains(topoData.states[i], pointCoordinates)) {
311
561
  clickedState = topoData.states[i]
@@ -314,13 +564,9 @@ const CountyMap = () => {
314
564
  }
315
565
 
316
566
  // If the user clicked outside of all states, no behavior
567
+ let clickedCounty = ''
317
568
  if (clickedState) {
318
- setConfig({
319
- ...config,
320
- mapPosition: { coordinates: [0, 30], zoom: 3 }
321
- })
322
-
323
- // If a county within the state was also clicked and has data, call parent click handler
569
+ // If a county within the state was clicked and has data, call parent click handler
324
570
  if (topoData.countyIndecies[clickedState.id]) {
325
571
  let county
326
572
  for (
@@ -335,28 +581,32 @@ const CountyMap = () => {
335
581
  }
336
582
  if (county && runtimeData[county.id]) {
337
583
  geoClickHandler(displayGeoName(county.id), runtimeData[county.id])
584
+ if (filteredStateCode) {
585
+ if (filteredStateCode === clickedState.id) {
586
+ clickedCounty = county.id || ''
587
+ }
588
+ }
338
589
  }
339
590
  }
340
591
 
341
- let focusIndex = -1
342
- for (let i = 0; i < topoData.mapData.length; i++) {
343
- if (topoData.mapData[i].id === clickedState.id) {
344
- focusIndex = i
345
- break
346
- }
592
+ // `us-geocode` maps still need state drilldown even when manual zoom controls are disabled.
593
+ if (config.general.allowMapZoom || config.general.type === 'us-geocode') {
594
+ setConfig({
595
+ ...config,
596
+ mapPosition: { coordinates: [0, 30], zoom: 3 }
597
+ })
598
+ setFilteredStateCountyCode(clickedState.id, clickedCounty)
599
+
600
+ publishAnalyticsEvent({
601
+ vizType: config.type,
602
+ vizSubType: getVizSubType(config),
603
+ eventType: `zoom_in`,
604
+ eventAction: 'click',
605
+ eventLabel: interactionLabel,
606
+ vizTitle: getVizTitle(config),
607
+ specifics: `zoom_level: 3, location: ${clickedState.properties.name}`
608
+ })
347
609
  }
348
-
349
- // Redraw with focus on state
350
- setFocus({ id: clickedState.id, index: focusIndex, center: geoCentroid(clickedState), feature: clickedState })
351
- publishAnalyticsEvent({
352
- vizType: config.type,
353
- vizSubType: getVizSubType(config),
354
- eventType: `zoom_in`,
355
- eventAction: 'click',
356
- eventLabel: interactionLabel,
357
- vizTitle: getVizTitle(config),
358
- specifics: `zoom_level: 3, location: ${clickedState.properties.name}`
359
- })
360
610
  }
361
611
  if (config.general.type === 'us-geocode') {
362
612
  const geoRadius = (config.visual.geoCodeCircleSize || 5) * (focus.id ? 2 : 1)
@@ -368,7 +618,7 @@ const CountyMap = () => {
368
618
  ])
369
619
  if (
370
620
  pixelCoords &&
371
- Math.sqrt(Math.pow(pixelCoords[0] - x, 2) + Math.pow(pixelCoords[1] - y, 2)) < geoRadius &&
621
+ Math.sqrt(Math.pow(pixelCoords[0] - mapX, 2) + Math.pow(pixelCoords[1] - mapY, 2)) < geoRadius &&
372
622
  !isLegendItemDisabled(
373
623
  runtimeData[runtimeKeys[i]],
374
624
  runtimeLegend,
@@ -396,44 +646,25 @@ const CountyMap = () => {
396
646
  )
397
647
  return
398
648
 
399
- const canvas = e.target
400
- const canvasBounds = canvas.getBoundingClientRect()
401
- const x = e.clientX - canvasBounds.left
402
- const y = e.clientY - canvasBounds.top
649
+ const { canvas, mapX, mapY } = getCanvasPoints(e)
403
650
  const containerBounds = container?.getBoundingClientRect()
404
651
  const tooltipX = e.clientX - (containerBounds?.left || 0)
405
652
  const tooltipY = e.clientY - (containerBounds?.top || 0)
406
- let pointCoordinates = topoData.projection.invert([x, y])
653
+ let pointCoordinates = topoData.projection.invert([mapX, mapY])
407
654
 
408
655
  const currentTooltipIndex = parseInt(tooltipRef.current.getAttribute('data-index'))
409
656
  const geoRadius = (config.visual.geoCodeCircleSize || 5) * (focus.id ? 2 : 1)
657
+ const zoomScale = getZoomScale()
658
+ const strokeScale = zoomScale ? 1 / zoomScale : 1
410
659
 
411
660
  const context = canvas.getContext('2d')
412
- const path = geoPath(topoData.projection, context)
661
+ context.save()
662
+ applyZoomTransform(context)
413
663
 
414
664
  // Handle standard county map hover
415
665
  if (config.general.type !== 'us-geocode') {
416
666
  //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
417
667
  if (isNaN(currentTooltipIndex) || !geoContains(topoData.mapData[currentTooltipIndex], pointCoordinates)) {
418
- if (
419
- !isNaN(currentTooltipIndex) &&
420
- applyLegendToRow(
421
- runtimeData[topoData.mapData[currentTooltipIndex].id],
422
- config,
423
- runtimeLegend,
424
- legendMemo,
425
- legendSpecialClassLastMemo
426
- )
427
- ) {
428
- paintCountyGeo(
429
- context,
430
- path,
431
- topoData.mapData[currentTooltipIndex],
432
- runtimeData[topoData.mapData[currentTooltipIndex].id],
433
- canvas.width
434
- )
435
- }
436
-
437
668
  let hoveredState
438
669
  let county
439
670
  let countyIndex
@@ -466,16 +697,18 @@ const CountyMap = () => {
466
697
  legendSpecialClassLastMemo
467
698
  )
468
699
  if (legendValues) {
469
- if (legendValues[0] === '#000000') return
700
+ if (legendValues[0] === '#000000') {
701
+ context.restore()
702
+ return
703
+ }
470
704
  context.globalAlpha = 1
471
- paintCountyGeo(context, path, topoData.mapData[countyIndex], runtimeData[county.id], canvas.width)
472
705
  }
473
706
 
474
707
  // Track hover analytics event if this is a new location
475
708
  if (isNaN(currentTooltipIndex) || currentTooltipIndex !== countyIndex) {
476
709
  const countyName = displayGeoName(county.id).replace(/[^a-zA-Z0-9]/g, ' ')
477
- const stateFips = county.id.slice(0, 2)
478
- const stateName = supportedStatesFipsCodes[stateFips]?.replace(/[^a-zA-Z0-9]/g, '_') || 'unknown'
710
+ const stateCode = county.id.slice(0, 2)
711
+ const stateName = supportedStatesFipsCodes[stateCode]?.replace(/[^a-zA-Z0-9]/g, '_') || 'unknown'
479
712
  const locationName = `${countyName}, ${stateName}`
480
713
  publishAnalyticsEvent({
481
714
  vizType: config.type,
@@ -512,7 +745,11 @@ const CountyMap = () => {
512
745
  runtimeData[runtimeKeys[currentTooltipIndex]][config.columns.longitude.name],
513
746
  runtimeData[runtimeKeys[currentTooltipIndex]][config.columns.latitude.name]
514
747
  ])
515
- if (pixelCoords && Math.sqrt(Math.pow(pixelCoords[0] - x, 2) + Math.pow(pixelCoords[1] - y, 2)) < geoRadius) {
748
+ if (
749
+ pixelCoords &&
750
+ Math.sqrt(Math.pow(pixelCoords[0] - mapX, 2) + Math.pow(pixelCoords[1] - mapY, 2)) < geoRadius
751
+ ) {
752
+ context.restore()
516
753
  return // The user is still hovering over the previous geo point, don't redraw tooltip
517
754
  }
518
755
  }
@@ -531,7 +768,7 @@ const CountyMap = () => {
531
768
  if (
532
769
  includedShapes &&
533
770
  pixelCoords &&
534
- Math.sqrt(Math.pow(pixelCoords[0] - x, 2) + Math.pow(pixelCoords[1] - y, 2)) < geoRadius &&
771
+ Math.sqrt(Math.pow(pixelCoords[0] - mapX, 2) + Math.pow(pixelCoords[1] - mapY, 2)) < geoRadius &&
535
772
  applyLegendToRow(
536
773
  runtimeData[runtimeKeys[i]],
537
774
  config,
@@ -553,7 +790,7 @@ const CountyMap = () => {
553
790
  }
554
791
 
555
792
  if (config.visual.cityStyle === 'pin' && pixelCoords) {
556
- const distance = Math.hypot(pixelCoords[0] - x, pixelCoords[1] - y)
793
+ const distance = Math.hypot(pixelCoords[0] - mapX, pixelCoords[1] - mapY)
557
794
  if (
558
795
  distance < 15 &&
559
796
  applyLegendToRow(
@@ -614,39 +851,31 @@ const CountyMap = () => {
614
851
  }
615
852
  }
616
853
 
617
- if (focus.index !== -1) {
618
- context.strokeStyle = geoStrokeColor
619
- context.lineWidth = 1
620
- context.beginPath()
621
- path(topoData.mapData[focus.index])
622
- context.stroke()
854
+ if (focus.index !== -1 && !config.general.showHSABoundaries) {
855
+ const focusPath2d = geoPathCacheRef.current.get(topoData.mapData[focus.index]?.id)
856
+ if (focusPath2d) {
857
+ context.strokeStyle = geoStrokeColor
858
+ context.lineWidth = lineWidth * strokeScale
859
+ context.stroke(focusPath2d)
860
+ }
623
861
  }
862
+ context.restore()
624
863
  }
625
864
 
626
- // Redraws canvas. Takes as parameters the fips id of a state to center on and the [lat,long] center of that state
865
+ // Sets up canvas dimensions, projection, and Path2D cache, then renders.
866
+ // Called on data change, resize, focus change — NOT during zoom/pan.
627
867
  const drawCanvas = () => {
628
868
  if (canvasRef.current && runtimeLegend.items.length > 0) {
629
869
  const canvas = canvasRef.current
630
- const context = canvas.getContext('2d')
631
- const path = geoPath(topoData.projection, context)
632
870
 
633
871
  canvas.width = canvas.clientWidth
634
872
  canvas.height = canvas.width * 0.6
635
873
 
636
874
  topoData.projection.scale(canvas.width * 1.25).translate([canvas.width / 2, canvas.height / 2])
637
875
 
638
- // If we are rendering the map without a zoom on a state, hide the reset button
639
- if (!focus.id) {
640
- if (resetButton.current) resetButton.current.style.display = 'none'
641
- } else {
642
- if (resetButton.current) resetButton.current.style.display = 'block'
643
- }
644
-
645
- // Centers the projection on the parameter passed
646
- // Centers the projection on the parameter passed
876
+ // Centers the projection on the focused state
647
877
  if (focus.feature) {
648
878
  const PADDING = 10
649
- // Fit the feature within the canvas dimensions with padding
650
879
  const fitExtent = [
651
880
  [PADDING, PADDING],
652
881
  [canvas.width - 0, canvas.height - PADDING]
@@ -654,117 +883,261 @@ const CountyMap = () => {
654
883
  topoData.projection.fitExtent(fitExtent, focus.feature)
655
884
  }
656
885
 
657
- // Erases previous renderings before redrawing map
658
- context.clearRect(0, 0, canvas.width, canvas.height)
886
+ // Pre-compute Path2D objects with the current projection
887
+ buildPathCache()
659
888
 
660
- // Enforces stroke style of the county lines
661
- context.strokeStyle = geoStrokeColor
889
+ // Render the map
890
+ renderFrame()
891
+ }
892
+ }
662
893
 
663
- // Iterates through each state/county topo and renders it
664
- topoData.mapData.forEach(geo => {
665
- // If invalid geo item, don't render
666
- if (!geo.id) return
667
- // If the map is focused on one state, don't render counties that are not in that state
668
- if (focus.id && geo.id.length > 2 && geo.id.indexOf(focus.id) !== 0) return
669
- // If rendering a geocode map without a focus, don't render counties
670
- if (!focus.id && config.general.type === 'us-geocode' && geo.id.length > 2) return
894
+ // Fast render using cached Path2D objects — called during zoom/pan for smooth performance.
895
+ // Skips canvas resize and projection setup; only applies the current zoom transform and redraws.
896
+ const renderFrame = () => {
897
+ if (!canvasRef.current || !runtimeLegend.items.length || !topoData.mapData) return
671
898
 
672
- // Gets numeric data associated with the topo data for this state/county
673
- const geoData = runtimeData[geo.id]
899
+ const canvas = canvasRef.current
900
+ const context = canvas.getContext('2d')
901
+ const cache = geoPathCacheRef.current
902
+
903
+ // Clear canvas
904
+ context.setTransform(1, 0, 0, 1, 0, 0)
905
+ context.clearRect(0, 0, canvas.width, canvas.height)
906
+ context.save()
907
+ applyZoomTransform(context)
908
+ const zoomScale = getZoomScale()
909
+ const strokeScale = zoomScale ? 1 / zoomScale : 1
910
+ const useHsaStrokeStyling = config.general.showHSABoundaries
911
+ const countyStrokeWidth = lineWidth * (useHsaStrokeStyling ? 0.45 : 0.8) * strokeScale
912
+ const hsaStrokeWidth = lineWidth * 0.7 * strokeScale
913
+ const countyStrokeColor = useHsaStrokeStyling ? '#a9aeb1' : geoStrokeColor
914
+ const isZoomedIntoState = focus.id
915
+ const hsaStrokeColor = isZoomedIntoState ? '#000' : '#303030'
916
+ const stateStrokeColor = useHsaStrokeStyling ? '#000000' : '#1c1d1f'
917
+
918
+ // Enforces stroke style of the county lines
919
+ context.strokeStyle = countyStrokeColor
920
+ context.lineWidth = countyStrokeWidth
921
+
922
+ // Iterates through each state/county topo and renders it using cached Path2D
923
+ let countyHighlight = null
924
+ topoData.mapData.forEach(geo => {
925
+ if (!geo.id) return
926
+ const hideCounty =
927
+ !config.general.showNeighboringStates && focus.id && geo.id.length > 2 && geo.id.indexOf(focus.id) !== 0
928
+ if (hideCounty) return
929
+ if (!focus.id && config.general.type === 'us-geocode' && geo.id.length > 2) return
930
+
931
+ const path2d = cache.get(geo.id)
932
+ if (!path2d) return
933
+
934
+ const geoData = runtimeData[geo.id]
935
+ if (!config.general.showHSABoundaries && filteredCountyCode && geo.id === filteredCountyCode) {
936
+ countyHighlight = {
937
+ context,
938
+ path2d,
939
+ geoData,
940
+ canvasWidth: canvas.width,
941
+ strokeWidth: 2,
942
+ strokeColor: '#000000'
943
+ }
944
+ }
945
+ paintCountyGeo(context, path2d, geoData, canvas.width, countyStrokeWidth, countyStrokeColor)
946
+ })
674
947
 
675
- // Renders state/county
676
- paintCountyGeo(context, path, geo, geoData, canvas.width)
948
+ let hsaHighlight = null
949
+ if (config.general.showHSABoundaries) {
950
+ context.strokeStyle = hsaStrokeColor
951
+ context.lineWidth = hsaStrokeWidth
952
+
953
+ topoData.hsas.forEach(hsa => {
954
+ if (!hsa?.groupId) return
955
+ const cacheKey = 'hsa_border_' + hsa.groupId
956
+ const path2d = cache.get(cacheKey)
957
+ if (path2d) {
958
+ if (filteredCountyCode && topoData.hsaMapping[filteredCountyCode] === hsa.groupId) {
959
+ hsaHighlight = path2d
960
+ } else {
961
+ context.stroke(path2d)
962
+ }
963
+ }
677
964
  })
965
+ }
678
966
 
679
- // If the focused state is found in the geo data, render it with a thicker outline
680
- if (focus.index !== -1) {
681
- context.strokeStyle = geoStrokeColor
682
- context.lineWidth = 2
683
- context.beginPath()
684
- path(topoData.mapData[focus.index])
685
- context.stroke()
967
+ // State borders
968
+ context.strokeStyle = stateStrokeColor
969
+ context.lineWidth = lineWidth * 1.25 * strokeScale
970
+ topoData.states.forEach(state => {
971
+ if (config.migrations.showPuertoRico == false) return
972
+ if (!state.id) return
973
+ const path2d = cache.get('state_border_' + state.id)
974
+ if (path2d) {
975
+ context.stroke(path2d)
686
976
  }
977
+ })
687
978
 
688
- // add in custom map layers
689
- if (featureArray.length > 0) {
690
- featureArray.map(layer => {
691
- context.beginPath()
692
- path(layer)
693
- context.fillStyle = layer.properties.fill
694
- context.strokeStyle = geoStrokeColor
695
- context.lineWidth = layer.properties['stroke-width']
696
- context.fill()
697
- context.stroke()
698
- })
979
+ // If the focused state is found in the geo data, render it with a thicker outline
980
+ if (focus.index !== -1) {
981
+ const focusGeoId = topoData.mapData[focus.index]?.id
982
+ const path2d = focusGeoId && cache.get(focusGeoId)
983
+ if (path2d) {
984
+ context.strokeStyle = config.general.showNeighboringStates ? '#000000' : geoStrokeColor
985
+ context.lineWidth = lineWidth * 2 * strokeScale
986
+ context.stroke(path2d)
699
987
  }
988
+ }
700
989
 
701
- if (config.general.type === 'us-geocode') {
990
+ // Custom map layers (not cached — these are external features)
991
+ if (featureArray.length > 0) {
992
+ const layerPath = geoPath(topoData.projection, context)
993
+ featureArray.map(layer => {
994
+ context.beginPath()
995
+ layerPath(layer)
996
+ context.fillStyle = layer.properties.fill
702
997
  context.strokeStyle = geoStrokeColor
703
- const geoRadius = (config.visual.geoCodeCircleSize || 5) * (focus.id ? 2 : 1)
704
- const { additionalCityStyles } = config.visual || []
705
- const cityStyles = Object.values(runtimeData)
706
- .filter(d => additionalCityStyles.some(style => String(d[style.column]) === String(style.value)))
707
- .map(d => {
708
- const conditionsMatched = additionalCityStyles.find(
709
- style => String(d[style.column]) === String(style.value)
710
- )
711
- return { ...conditionsMatched, ...d }
712
- })
998
+ context.lineWidth = layer.properties['stroke-width']
999
+ context.fill()
1000
+ context.stroke()
1001
+ })
1002
+ }
713
1003
 
714
- let cityPixelCoords = []
715
- cityStyles.forEach(city => {
716
- cityPixelCoords = topoData.projection([
717
- city[config.columns.longitude.name],
718
- city[config.columns.latitude.name]
719
- ])
1004
+ if (config.general.type === 'us-geocode') {
1005
+ context.strokeStyle = geoStrokeColor
1006
+ context.lineWidth = lineWidth * strokeScale
1007
+ const geoRadius = (config.visual.geoCodeCircleSize || 5) * (focus.id ? 2 : 1)
1008
+ const { additionalCityStyles } = config.visual || []
1009
+ const cityStyles = Object.values(runtimeData)
1010
+ .filter(d => additionalCityStyles.some(style => String(d[style.column]) === String(style.value)))
1011
+ .map(d => {
1012
+ const conditionsMatched = additionalCityStyles.find(style => String(d[style.column]) === String(style.value))
1013
+ return { ...conditionsMatched, ...d }
1014
+ })
720
1015
 
721
- if (cityPixelCoords) {
722
- const legendValues = applyLegendToRow(
723
- runtimeData[city?.value],
724
- config,
725
- runtimeLegend,
726
- legendMemo,
727
- legendSpecialClassLastMemo
728
- )
729
- if (legendValues) {
730
- if (legendValues?.[0] === '#000000') return
731
- const shapeType = city?.shape?.toLowerCase()
732
- const shapeProperties = createShapeProperties(shapeType, cityPixelCoords, legendValues, config, geoRadius)
733
- if (shapeProperties) {
734
- drawShape(shapeProperties, context, config, lineWidth)
735
- }
1016
+ let cityPixelCoords = []
1017
+ cityStyles.forEach(city => {
1018
+ cityPixelCoords = topoData.projection([city[config.columns.longitude.name], city[config.columns.latitude.name]])
1019
+
1020
+ if (cityPixelCoords) {
1021
+ const legendValues = applyLegendToRow(
1022
+ runtimeData[city?.value],
1023
+ config,
1024
+ runtimeLegend,
1025
+ legendMemo,
1026
+ legendSpecialClassLastMemo
1027
+ )
1028
+ if (legendValues) {
1029
+ if (legendValues?.[0] === '#000000') return
1030
+ const shapeType = city?.shape?.toLowerCase()
1031
+ const shapeProperties = createShapeProperties(shapeType, cityPixelCoords, legendValues, config, geoRadius)
1032
+ if (shapeProperties) {
1033
+ drawShape(shapeProperties, context, config, lineWidth * strokeScale)
736
1034
  }
737
1035
  }
738
- })
1036
+ }
1037
+ })
739
1038
 
740
- runtimeKeys.forEach(key => {
741
- const citiesList = new Set(cityStyles.map(item => item.value))
742
-
743
- const pixelCoords = topoData.projection([
744
- runtimeData[key][config.columns.longitude.name],
745
- runtimeData[key][config.columns.latitude.name]
746
- ])
747
- if (pixelCoords && !citiesList.has(key)) {
748
- const legendValues =
749
- runtimeData[key] !== undefined
750
- ? applyLegendToRow(runtimeData[key], config, runtimeLegend, legendMemo, legendSpecialClassLastMemo)
751
- : false
752
- if (legendValues) {
753
- if (legendValues?.[0] === '#000000' || legendValues?.[0] === DISABLED_MAP_COLOR) return
754
- const shapeType = config.visual.cityStyle.toLowerCase()
755
- const shapeProperties = createShapeProperties(shapeType, pixelCoords, legendValues, config, geoRadius)
756
- if (shapeProperties) {
757
- drawShape(shapeProperties, context, config, lineWidth)
758
- }
1039
+ runtimeKeys.forEach(key => {
1040
+ const citiesList = new Set(cityStyles.map(item => item.value))
1041
+
1042
+ const pixelCoords = topoData.projection([
1043
+ runtimeData[key][config.columns.longitude.name],
1044
+ runtimeData[key][config.columns.latitude.name]
1045
+ ])
1046
+ if (pixelCoords && !citiesList.has(key)) {
1047
+ const legendValues =
1048
+ runtimeData[key] !== undefined
1049
+ ? applyLegendToRow(runtimeData[key], config, runtimeLegend, legendMemo, legendSpecialClassLastMemo)
1050
+ : false
1051
+ if (legendValues) {
1052
+ if (legendValues?.[0] === '#000000' || legendValues?.[0] === DISABLED_MAP_COLOR) return
1053
+ const shapeType = config.visual.cityStyle.toLowerCase()
1054
+ const shapeProperties = createShapeProperties(shapeType, pixelCoords, legendValues, config, geoRadius)
1055
+ if (shapeProperties) {
1056
+ drawShape(shapeProperties, context, config, lineWidth * strokeScale)
759
1057
  }
760
1058
  }
761
- })
762
- }
1059
+ }
1060
+ })
1061
+ }
1062
+
1063
+ // Highlight county last so it is visible on top of all other layers
1064
+ if (countyHighlight) {
1065
+ const { context, path2d, geoData, canvasWidth, strokeWidth, strokeColor } = countyHighlight
1066
+ paintCountyGeo(context, path2d, geoData, canvasWidth, strokeWidth, strokeColor)
1067
+ }
1068
+ // Highlight HSA boundary if applicable
1069
+ if (hsaHighlight) {
1070
+ context.lineWidth = 2.5 * strokeScale
1071
+ context.strokeStyle = hsaStrokeColor
1072
+ context.stroke(hsaHighlight)
763
1073
  }
1074
+ context.restore()
764
1075
  }
765
1076
 
1077
+ useEffect(() => {
1078
+ if (!config.general.allowMapZoom) {
1079
+ setFilteredStateCountyCode('')
1080
+ setHasMoved(false)
1081
+ resetZoomTransform()
1082
+ }
1083
+ }, [config.general.allowMapZoom])
1084
+
1085
+ useEffect(() => {
1086
+ if (!canvasRef.current || !config.general.allowMapZoom) {
1087
+ return
1088
+ }
1089
+
1090
+ const COUNTY_MAX_ZOOM = 10
1091
+ const canvasSelection = d3Select(canvasRef.current)
1092
+ const zoomBehavior = d3Zoom()
1093
+ .filter(d3Event => (d3Event ? !d3Event.ctrlKey && !d3Event.button : false))
1094
+ .scaleExtent([1, COUNTY_MAX_ZOOM])
1095
+ .on('zoom', d3Event => {
1096
+ zoomTransformRef.current = d3Event.transform
1097
+ const { x, y, k } = d3Event.transform
1098
+ const isAtIdentity = x === 0 && y === 0 && k === 1
1099
+ setHasMoved(!isAtIdentity)
1100
+ scheduleDraw()
1101
+ })
1102
+
1103
+ zoomBehaviorRef.current = zoomBehavior
1104
+ canvasSelection.call(zoomBehavior)
1105
+
1106
+ return () => {
1107
+ if (zoomFrameRef.current) {
1108
+ window.cancelAnimationFrame(zoomFrameRef.current)
1109
+ zoomFrameRef.current = null
1110
+ }
1111
+ canvasSelection.on('.zoom', null)
1112
+ }
1113
+ }, [config.general.allowMapZoom, topoData, runtimeLegend, focus])
1114
+
1115
+ useEffect(() => {
1116
+ resetZoomTransform()
1117
+ }, [focus?.id])
1118
+
1119
+ const isLoading = !runtimeData || !isTopoReady(topoData, config, runtimeFilters) || !canvasRef.current
1120
+
1121
+ useEffect(() => {
1122
+ if (isLoading) {
1123
+ return
1124
+ }
1125
+
1126
+ drawCanvas()
1127
+ }, [isLoading, topoData, focus, runtimeLegend, runtimeData, featureArray, config, filteredCountyCode])
1128
+
1129
+ const showManualZoomControls = config.general.allowMapZoom
1130
+ const showResetControl = (hasMoved || focus.id) && (showManualZoomControls || config.general.type === 'us-geocode')
1131
+ const showTopRightResetControl = showResetControl && config.general.type === 'us-geocode'
1132
+ const showBottomLeftResetControl = showResetControl && config.general.type !== 'us-geocode'
1133
+
766
1134
  return (
767
1135
  <ErrorBoundary component='CountyMap'>
1136
+ {isLoading && (
1137
+ <div style={{ height: 300 }}>
1138
+ <Loading />
1139
+ </div>
1140
+ )}
768
1141
  <canvas
769
1142
  ref={canvasRef}
770
1143
  aria-label={handleMapAriaLabels(config)}
@@ -774,12 +1147,37 @@ const CountyMap = () => {
774
1147
  tooltipRef.current.setAttribute('data-index', null)
775
1148
  }}
776
1149
  onClick={canvasClick}
777
- className='county-map-canvas'
1150
+ className={'county-map-canvas' + (isLoading ? ' d-none' : '')}
1151
+ style={config.general.allowMapZoom ? undefined : { cursor: 'default' }}
778
1152
  ></canvas>
779
1153
 
780
- <button className={`btn btn--reset btn-primary p-absolute`} onClick={onReset} ref={resetButton} tabIndex={0}>
781
- Reset Zoom
782
- </button>
1154
+ {showManualZoomControls && (
1155
+ <div className={'zoom-controls' + (isLoading ? ' d-none' : '')} data-html2canvas-ignore='true'>
1156
+ <button onClick={handleZoomIn} aria-label='Zoom In'>
1157
+ <svg viewBox='0 0 24 24' stroke='currentColor' strokeWidth='3'>
1158
+ <line x1='12' y1='5' x2='12' y2='19' />
1159
+ <line x1='5' y1='12' x2='19' y2='12' />
1160
+ </svg>
1161
+ </button>
1162
+ <button onClick={handleZoomOut} aria-label='Zoom Out'>
1163
+ <svg viewBox='0 0 24 24' stroke='currentColor' strokeWidth='3'>
1164
+ <line x1='5' y1='12' x2='19' y2='12' />
1165
+ </svg>
1166
+ </button>
1167
+ {showBottomLeftResetControl && (
1168
+ <button onClick={handleZoomReset} className='reset' aria-label='Reset Zoom'>
1169
+ Reset Zoom
1170
+ </button>
1171
+ )}
1172
+ </div>
1173
+ )}
1174
+ {showTopRightResetControl && (
1175
+ <div className='zoom-controls zoom-controls--top-right' data-html2canvas-ignore='true'>
1176
+ <button onClick={handleZoomReset} className='reset' aria-label='Reset Zoom'>
1177
+ Reset Zoom
1178
+ </button>
1179
+ </div>
1180
+ )}
783
1181
  </ErrorBoundary>
784
1182
  )
785
1183
  }