@cdc/map 4.26.2 → 4.26.3

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 (65) hide show
  1. package/LICENSE +201 -0
  2. package/dist/cdcmap-vr9HZwRt.es.js +6 -0
  3. package/dist/cdcmap.js +26781 -24615
  4. package/examples/private/annotation-bug.json +642 -0
  5. package/package.json +3 -3
  6. package/src/CdcMap.tsx +3 -14
  7. package/src/CdcMapComponent.tsx +214 -159
  8. package/src/_stories/CdcMap.Defaults.stories.tsx +76 -0
  9. package/src/_stories/CdcMap.Editor.stories.tsx +187 -14
  10. package/src/_stories/CdcMap.stories.tsx +11 -1
  11. package/src/_stories/Map.HTMLInDataTable.stories.tsx +385 -0
  12. package/src/_stories/_mock/multi-state-show-unselected.json +82 -0
  13. package/src/cdcMapComponent.styles.css +2 -2
  14. package/src/components/Annotation/Annotation.Draggable.styles.css +4 -4
  15. package/src/components/Annotation/AnnotationDropdown.styles.css +1 -1
  16. package/src/components/Annotation/AnnotationList.styles.css +13 -13
  17. package/src/components/EditorPanel/components/EditorPanel.tsx +426 -58
  18. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings-style.css +1 -1
  19. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +5 -2
  20. package/src/components/EditorPanel/components/editorPanel.styles.css +34 -24
  21. package/src/components/Legend/components/Legend.tsx +9 -4
  22. package/src/components/Legend/components/LegendGroup/legend.group.css +5 -5
  23. package/src/components/Legend/components/index.scss +2 -3
  24. package/src/components/NavigationMenu.tsx +2 -1
  25. package/src/components/SmallMultiples/SmallMultiples.css +5 -5
  26. package/src/components/UsaMap/components/SingleState/SingleState.StateOutput.tsx +32 -17
  27. package/src/components/UsaMap/components/TerritoriesSection.tsx +3 -2
  28. package/src/components/UsaMap/components/Territory/Territory.Rectangle.tsx +13 -8
  29. package/src/components/UsaMap/components/UsaMap.County.tsx +410 -183
  30. package/src/components/UsaMap/components/UsaMap.Region.styles.css +1 -1
  31. package/src/components/UsaMap/components/UsaMap.SingleState.styles.css +2 -2
  32. package/src/components/UsaMap/components/UsaMap.State.tsx +13 -8
  33. package/src/components/WorldMap/WorldMap.tsx +10 -13
  34. package/src/components/WorldMap/data/world-topo-updated.json +1 -0
  35. package/src/components/WorldMap/data/world-topo.json +1 -1
  36. package/src/components/WorldMap/worldMap.styles.css +1 -1
  37. package/src/components/ZoomControls.tsx +49 -18
  38. package/src/components/zoomControls.styles.css +27 -11
  39. package/src/data/initial-state.js +14 -5
  40. package/src/data/legacy-defaults.ts +8 -0
  41. package/src/data/supported-geos.js +19 -0
  42. package/src/helpers/colors.ts +2 -1
  43. package/src/helpers/dataTableHelpers.ts +56 -0
  44. package/src/helpers/displayGeoName.ts +19 -11
  45. package/src/helpers/getMapContainerClasses.ts +8 -2
  46. package/src/helpers/getMatchingPatternForRow.ts +67 -0
  47. package/src/helpers/getPatternForRow.ts +11 -18
  48. package/src/helpers/tests/dataTableHelpers.test.ts +78 -0
  49. package/src/helpers/tests/displayGeoName.test.ts +17 -0
  50. package/src/helpers/tests/getMatchingPatternForRow.test.ts +150 -0
  51. package/src/helpers/tests/getPatternForRow.test.ts +140 -2
  52. package/src/helpers/urlDataHelpers.ts +7 -1
  53. package/src/hooks/useResizeObserver.ts +36 -22
  54. package/src/hooks/useTooltip.test.tsx +64 -0
  55. package/src/hooks/useTooltip.ts +28 -8
  56. package/src/scss/editor-panel.scss +1 -1
  57. package/src/scss/main.scss +140 -6
  58. package/src/scss/map.scss +9 -4
  59. package/src/store/map.actions.ts +2 -0
  60. package/src/store/map.reducer.ts +4 -0
  61. package/src/test/CdcMap.test.jsx +2 -2
  62. package/src/types/MapConfig.ts +22 -4
  63. package/src/types/MapContext.ts +3 -1
  64. package/dist/cdcmap-Cf9_fbQf.es.js +0 -6
  65. package/src/helpers/componentHelpers.ts +0 -8
@@ -1,5 +1,7 @@
1
1
  import { useEffect, useState, useRef, useContext } from 'react'
2
2
  import { geoCentroid, geoPath, geoContains } from 'd3-geo'
3
+ import { zoom as d3Zoom, zoomIdentity as d3ZoomIdentity } from 'd3-zoom'
4
+ import { select as d3Select } from 'd3-selection'
3
5
  import { feature } from 'topojson-client'
4
6
  import { geoAlbersUsaTerritories } from 'd3-composite-projections'
5
7
  import debounce from 'lodash.debounce'
@@ -9,7 +11,13 @@ import useMapLayers from '../../../hooks/useMapLayers'
9
11
  import ConfigContext from '../../../context'
10
12
  import { useLegendMemoContext } from '../../../context/LegendMemoContext'
11
13
  import { drawShape, createShapeProperties } from '../helpers/shapes'
12
- import { getGeoStrokeColor, handleMapAriaLabels, displayGeoName, isLegendItemDisabled } from '../../../helpers'
14
+ import {
15
+ getGeoStrokeColor,
16
+ handleMapAriaLabels,
17
+ displayGeoName,
18
+ isLegendItemDisabled,
19
+ MAX_ZOOM_LEVEL
20
+ } from '../../../helpers'
13
21
  import { supportedStatesFipsCodes } from '../../../data/supported-geos'
14
22
  import useGeoClickHandler from '../../../hooks/useGeoClickHandler'
15
23
  import { applyLegendToRow } from '../../../helpers/applyLegendToRow'
@@ -140,6 +148,7 @@ const CountyMap = () => {
140
148
  runtimeFilters,
141
149
  runtimeLegend,
142
150
  setConfig,
151
+ setFilteredStateCode,
143
152
  config,
144
153
  tooltipId,
145
154
  tooltipRef,
@@ -154,6 +163,7 @@ const CountyMap = () => {
154
163
  const { applyTooltipsToGeo } = useApplyTooltipsToGeo()
155
164
  const [focus, setFocus] = useState({})
156
165
  const [topoData, setTopoData] = useState({})
166
+ const [hasMoved, setHasMoved] = useState(false)
157
167
 
158
168
  const pathGenerator = geoPath().projection(geoAlbersUsaTerritories())
159
169
 
@@ -201,28 +211,62 @@ const CountyMap = () => {
201
211
  return () => window.removeEventListener('resize', debounceOnResize)
202
212
  })
203
213
 
204
- const resetButton = useRef()
205
214
  const canvasRef = useRef()
206
215
  const patternCacheRef = useRef<Map<string, CanvasPattern | null>>(new Map())
216
+ const zoomTransformRef = useRef(d3ZoomIdentity)
217
+ const zoomBehaviorRef = useRef()
218
+ const zoomFrameRef = useRef<number | null>(null)
219
+ const geoPathCacheRef = useRef<Map<string, Path2D>>(new Map())
207
220
 
208
221
  // Clear pattern cache when pattern configuration changes
209
222
  useEffect(() => {
210
223
  patternCacheRef.current.clear()
211
224
  }, [config.map?.patterns])
212
225
 
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
- )
226
+ const runtimeKeys = runtimeData ? Object.keys(runtimeData) : []
227
+ const lineWidth = 1
228
+
229
+ // Pre-compute Path2D objects for all geo features — avoids expensive geoPath projection on every zoom frame
230
+ const buildPathCache = () => {
231
+ const pathGen = geoPath(topoData.projection)
232
+ const cache = new Map<string, Path2D>()
233
+ topoData.mapData.forEach(geo => {
234
+ if (!geo.id) return
235
+ const d = pathGen(geo)
236
+ if (d) cache.set(geo.id, new Path2D(d))
237
+ })
238
+ topoData.states.forEach(state => {
239
+ if (!state.id) return
240
+ const d = pathGen(state)
241
+ if (d) cache.set('state_border_' + state.id, new Path2D(d))
242
+ })
243
+ geoPathCacheRef.current = cache
220
244
  }
221
245
 
222
- const runtimeKeys = Object.keys(runtimeData)
223
- const lineWidth = 1
246
+ const resetZoomTransform = () => {
247
+ zoomTransformRef.current = d3ZoomIdentity
248
+ if (canvasRef.current && zoomBehaviorRef.current) {
249
+ d3Select(canvasRef.current).call(zoomBehaviorRef.current.transform, d3ZoomIdentity)
250
+ }
251
+ }
252
+
253
+ const getCanvasPoints = e => {
254
+ const canvas = e.target
255
+ const canvasBounds = canvas.getBoundingClientRect()
256
+ const x = e.clientX - canvasBounds.left
257
+ const y = e.clientY - canvasBounds.top
258
+ const [mapX, mapY] = zoomTransformRef.current.invert([x, y])
259
+ return { canvas, mapX, mapY }
260
+ }
224
261
 
225
- const paintCountyGeo = (context, path, geo, geoData, canvasWidth: number) => {
262
+ const applyZoomTransform = context => {
263
+ const { x, y, k } = zoomTransformRef.current || d3ZoomIdentity
264
+ context.setTransform(k, 0, 0, k, x, y)
265
+ }
266
+
267
+ const getZoomScale = () => zoomTransformRef.current?.k || 1
268
+
269
+ const paintCountyGeo = (context, path2d: Path2D, geoData, canvasWidth: number, strokeWidth?: number) => {
226
270
  const legendValues =
227
271
  geoData !== undefined
228
272
  ? applyLegendToRow(geoData, config, runtimeLegend, legendMemo, legendSpecialClassLastMemo)
@@ -236,9 +280,7 @@ const CountyMap = () => {
236
280
  : DEFAULT_MAP_BACKGROUND
237
281
 
238
282
  context.fillStyle = baseFill
239
- context.beginPath()
240
- path(geo)
241
- context.fill()
283
+ context.fill(path2d)
242
284
 
243
285
  if (config.map?.patterns?.length > 0 && geoData) {
244
286
  const patternInfo = getPatternForRow(geoData, config)
@@ -247,8 +289,8 @@ const CountyMap = () => {
247
289
  const { pattern, size, color } = patternInfo
248
290
  const patternColor = color || '#000000'
249
291
  const patternSize = size || 'medium'
250
- const strokeWidth = canvasWidth < 200 ? 1.75 : canvasWidth < 375 ? 1.25 : 0.75
251
- const cacheKey = `${pattern}-${patternColor}-${patternSize}-${strokeWidth}`
292
+ const patternStrokeWidth = canvasWidth < 200 ? 1.75 : canvasWidth < 375 ? 1.25 : 0.75
293
+ const cacheKey = `${pattern}-${patternColor}-${patternSize}-${patternStrokeWidth}`
252
294
 
253
295
  let canvasPattern = patternCacheRef.current.get(cacheKey)
254
296
  if (!canvasPattern) {
@@ -256,7 +298,7 @@ const CountyMap = () => {
256
298
  pattern as PatternType,
257
299
  patternColor,
258
300
  patternSize as 'small' | 'medium' | 'large',
259
- strokeWidth
301
+ patternStrokeWidth
260
302
  )
261
303
  if (canvasPattern) {
262
304
  patternCacheRef.current.set(cacheKey, canvasPattern)
@@ -265,18 +307,14 @@ const CountyMap = () => {
265
307
 
266
308
  if (canvasPattern) {
267
309
  context.fillStyle = canvasPattern
268
- context.beginPath()
269
- path(geo)
270
- context.fill()
310
+ context.fill(path2d)
271
311
  }
272
312
  }
273
313
  }
274
314
 
275
315
  context.strokeStyle = geoStrokeColor
276
- context.lineWidth = lineWidth
277
- context.beginPath()
278
- path(geo)
279
- context.stroke()
316
+ context.lineWidth = strokeWidth ?? lineWidth
317
+ context.stroke(path2d)
280
318
 
281
319
  return legendValues
282
320
  }
@@ -294,15 +332,83 @@ const CountyMap = () => {
294
332
  ...config,
295
333
  mapPosition: { coordinates: [0, 30], zoom: 1 }
296
334
  })
335
+ setFilteredStateCode('')
297
336
  setFocus({})
337
+ resetZoomTransform()
338
+ }
339
+
340
+ const handleZoomIn = () => {
341
+ if (!canvasRef.current || !zoomBehaviorRef.current) return
342
+ d3Select(canvasRef.current).call(zoomBehaviorRef.current.scaleBy, 1.2)
343
+ }
344
+
345
+ const handleZoomOut = () => {
346
+ if (!canvasRef.current || !zoomBehaviorRef.current) return
347
+ d3Select(canvasRef.current).call(zoomBehaviorRef.current.scaleBy, 1 / 1.2)
348
+ }
349
+
350
+ const handleZoomReset = () => {
351
+ const container = canvasRef.current?.closest('.geography-container') as HTMLElement | null
352
+ setHasMoved(false)
353
+ onReset()
354
+ container?.focus()
355
+ }
356
+
357
+ const PAN_STEP = 20
358
+
359
+ useEffect(() => {
360
+ if (!config.general.allowMapZoom) return
361
+
362
+ const container = canvasRef.current?.closest('.geography-container') as HTMLElement | null
363
+ if (!container) return
364
+
365
+ const handleKeyboardPan = (e: KeyboardEvent) => {
366
+ if (!canvasRef.current || !zoomBehaviorRef.current) return
367
+
368
+ const key = e.key.toLowerCase()
369
+ let dx = 0
370
+ let dy = 0
371
+
372
+ switch (key) {
373
+ case 'arrowleft':
374
+ case 'a':
375
+ dx = PAN_STEP
376
+ break
377
+ case 'arrowright':
378
+ case 'd':
379
+ dx = -PAN_STEP
380
+ break
381
+ case 'arrowup':
382
+ case 'w':
383
+ dy = PAN_STEP
384
+ break
385
+ case 'arrowdown':
386
+ case 's':
387
+ dy = -PAN_STEP
388
+ break
389
+ default:
390
+ return
391
+ }
392
+
393
+ e.preventDefault()
394
+ d3Select(canvasRef.current).call(zoomBehaviorRef.current.translateBy, dx, dy)
395
+ }
396
+
397
+ container.addEventListener('keydown', handleKeyboardPan)
398
+ return () => container.removeEventListener('keydown', handleKeyboardPan)
399
+ }, [config.general.allowMapZoom, topoData, runtimeLegend, focus])
400
+
401
+ const scheduleDraw = () => {
402
+ if (zoomFrameRef.current) return
403
+ zoomFrameRef.current = window.requestAnimationFrame(() => {
404
+ zoomFrameRef.current = null
405
+ renderFrame()
406
+ })
298
407
  }
299
408
 
300
409
  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])
410
+ const { mapX, mapY } = getCanvasPoints(e)
411
+ const pointCoordinates = topoData.projection.invert([mapX, mapY])
306
412
 
307
413
  // Use d3 geoContains method to find the state geo data that the user clicked inside
308
414
  let clickedState
@@ -315,12 +421,7 @@ const CountyMap = () => {
315
421
 
316
422
  // If the user clicked outside of all states, no behavior
317
423
  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
424
+ // If a county within the state was clicked and has data, call parent click handler
324
425
  if (topoData.countyIndecies[clickedState.id]) {
325
426
  let county
326
427
  for (
@@ -338,25 +439,33 @@ const CountyMap = () => {
338
439
  }
339
440
  }
340
441
 
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
442
+ // `us-geocode` maps still need state drilldown even when manual zoom controls are disabled.
443
+ if (config.general.allowMapZoom || config.general.type === 'us-geocode') {
444
+ setConfig({
445
+ ...config,
446
+ mapPosition: { coordinates: [0, 30], zoom: 3 }
447
+ })
448
+ setFilteredStateCode(clickedState.id)
449
+
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
+ }
346
456
  }
347
- }
348
457
 
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
- })
458
+ setFocus({ id: clickedState.id, index: focusIndex, center: geoCentroid(clickedState), feature: clickedState })
459
+ publishAnalyticsEvent({
460
+ vizType: config.type,
461
+ vizSubType: getVizSubType(config),
462
+ eventType: `zoom_in`,
463
+ eventAction: 'click',
464
+ eventLabel: interactionLabel,
465
+ vizTitle: getVizTitle(config),
466
+ specifics: `zoom_level: 3, location: ${clickedState.properties.name}`
467
+ })
468
+ }
360
469
  }
361
470
  if (config.general.type === 'us-geocode') {
362
471
  const geoRadius = (config.visual.geoCodeCircleSize || 5) * (focus.id ? 2 : 1)
@@ -368,7 +477,7 @@ const CountyMap = () => {
368
477
  ])
369
478
  if (
370
479
  pixelCoords &&
371
- Math.sqrt(Math.pow(pixelCoords[0] - x, 2) + Math.pow(pixelCoords[1] - y, 2)) < geoRadius &&
480
+ Math.sqrt(Math.pow(pixelCoords[0] - mapX, 2) + Math.pow(pixelCoords[1] - mapY, 2)) < geoRadius &&
372
481
  !isLegendItemDisabled(
373
482
  runtimeData[runtimeKeys[i]],
374
483
  runtimeLegend,
@@ -396,20 +505,20 @@ const CountyMap = () => {
396
505
  )
397
506
  return
398
507
 
399
- const canvas = e.target
400
- const canvasBounds = canvas.getBoundingClientRect()
401
- const x = e.clientX - canvasBounds.left
402
- const y = e.clientY - canvasBounds.top
508
+ const { canvas, mapX, mapY } = getCanvasPoints(e)
403
509
  const containerBounds = container?.getBoundingClientRect()
404
510
  const tooltipX = e.clientX - (containerBounds?.left || 0)
405
511
  const tooltipY = e.clientY - (containerBounds?.top || 0)
406
- let pointCoordinates = topoData.projection.invert([x, y])
512
+ let pointCoordinates = topoData.projection.invert([mapX, mapY])
407
513
 
408
514
  const currentTooltipIndex = parseInt(tooltipRef.current.getAttribute('data-index'))
409
515
  const geoRadius = (config.visual.geoCodeCircleSize || 5) * (focus.id ? 2 : 1)
516
+ const zoomScale = getZoomScale()
517
+ const strokeScale = zoomScale ? 1 / zoomScale : 1
410
518
 
411
519
  const context = canvas.getContext('2d')
412
- const path = geoPath(topoData.projection, context)
520
+ context.save()
521
+ applyZoomTransform(context)
413
522
 
414
523
  // Handle standard county map hover
415
524
  if (config.general.type !== 'us-geocode') {
@@ -425,13 +534,16 @@ const CountyMap = () => {
425
534
  legendSpecialClassLastMemo
426
535
  )
427
536
  ) {
428
- paintCountyGeo(
429
- context,
430
- path,
431
- topoData.mapData[currentTooltipIndex],
432
- runtimeData[topoData.mapData[currentTooltipIndex].id],
433
- canvas.width
434
- )
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
+ }
435
547
  }
436
548
 
437
549
  let hoveredState
@@ -466,16 +578,22 @@ const CountyMap = () => {
466
578
  legendSpecialClassLastMemo
467
579
  )
468
580
  if (legendValues) {
469
- if (legendValues[0] === '#000000') return
581
+ if (legendValues[0] === '#000000') {
582
+ context.restore()
583
+ return
584
+ }
470
585
  context.globalAlpha = 1
471
- paintCountyGeo(context, path, topoData.mapData[countyIndex], runtimeData[county.id], canvas.width)
586
+ const hoverPath2d = geoPathCacheRef.current.get(county.id)
587
+ if (hoverPath2d) {
588
+ paintCountyGeo(context, hoverPath2d, runtimeData[county.id], canvas.width, lineWidth * strokeScale)
589
+ }
472
590
  }
473
591
 
474
592
  // Track hover analytics event if this is a new location
475
593
  if (isNaN(currentTooltipIndex) || currentTooltipIndex !== countyIndex) {
476
594
  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'
595
+ const stateCode = county.id.slice(0, 2)
596
+ const stateName = supportedStatesFipsCodes[stateCode]?.replace(/[^a-zA-Z0-9]/g, '_') || 'unknown'
479
597
  const locationName = `${countyName}, ${stateName}`
480
598
  publishAnalyticsEvent({
481
599
  vizType: config.type,
@@ -512,7 +630,11 @@ const CountyMap = () => {
512
630
  runtimeData[runtimeKeys[currentTooltipIndex]][config.columns.longitude.name],
513
631
  runtimeData[runtimeKeys[currentTooltipIndex]][config.columns.latitude.name]
514
632
  ])
515
- if (pixelCoords && Math.sqrt(Math.pow(pixelCoords[0] - x, 2) + Math.pow(pixelCoords[1] - y, 2)) < geoRadius) {
633
+ if (
634
+ pixelCoords &&
635
+ Math.sqrt(Math.pow(pixelCoords[0] - mapX, 2) + Math.pow(pixelCoords[1] - mapY, 2)) < geoRadius
636
+ ) {
637
+ context.restore()
516
638
  return // The user is still hovering over the previous geo point, don't redraw tooltip
517
639
  }
518
640
  }
@@ -531,7 +653,7 @@ const CountyMap = () => {
531
653
  if (
532
654
  includedShapes &&
533
655
  pixelCoords &&
534
- Math.sqrt(Math.pow(pixelCoords[0] - x, 2) + Math.pow(pixelCoords[1] - y, 2)) < geoRadius &&
656
+ Math.sqrt(Math.pow(pixelCoords[0] - mapX, 2) + Math.pow(pixelCoords[1] - mapY, 2)) < geoRadius &&
535
657
  applyLegendToRow(
536
658
  runtimeData[runtimeKeys[i]],
537
659
  config,
@@ -553,7 +675,7 @@ const CountyMap = () => {
553
675
  }
554
676
 
555
677
  if (config.visual.cityStyle === 'pin' && pixelCoords) {
556
- const distance = Math.hypot(pixelCoords[0] - x, pixelCoords[1] - y)
678
+ const distance = Math.hypot(pixelCoords[0] - mapX, pixelCoords[1] - mapY)
557
679
  if (
558
680
  distance < 15 &&
559
681
  applyLegendToRow(
@@ -615,38 +737,30 @@ const CountyMap = () => {
615
737
  }
616
738
 
617
739
  if (focus.index !== -1) {
618
- context.strokeStyle = geoStrokeColor
619
- context.lineWidth = 1
620
- context.beginPath()
621
- path(topoData.mapData[focus.index])
622
- context.stroke()
740
+ const focusPath2d = geoPathCacheRef.current.get(topoData.mapData[focus.index]?.id)
741
+ if (focusPath2d) {
742
+ context.strokeStyle = geoStrokeColor
743
+ context.lineWidth = lineWidth * strokeScale
744
+ context.stroke(focusPath2d)
745
+ }
623
746
  }
747
+ context.restore()
624
748
  }
625
749
 
626
- // Redraws canvas. Takes as parameters the fips id of a state to center on and the [lat,long] center of that state
750
+ // Sets up canvas dimensions, projection, and Path2D cache, then renders.
751
+ // Called on data change, resize, focus change — NOT during zoom/pan.
627
752
  const drawCanvas = () => {
628
753
  if (canvasRef.current && runtimeLegend.items.length > 0) {
629
754
  const canvas = canvasRef.current
630
- const context = canvas.getContext('2d')
631
- const path = geoPath(topoData.projection, context)
632
755
 
633
756
  canvas.width = canvas.clientWidth
634
757
  canvas.height = canvas.width * 0.6
635
758
 
636
759
  topoData.projection.scale(canvas.width * 1.25).translate([canvas.width / 2, canvas.height / 2])
637
760
 
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
761
+ // Centers the projection on the focused state
647
762
  if (focus.feature) {
648
763
  const PADDING = 10
649
- // Fit the feature within the canvas dimensions with padding
650
764
  const fitExtent = [
651
765
  [PADDING, PADDING],
652
766
  [canvas.width - 0, canvas.height - PADDING]
@@ -654,115 +768,203 @@ const CountyMap = () => {
654
768
  topoData.projection.fitExtent(fitExtent, focus.feature)
655
769
  }
656
770
 
657
- // Erases previous renderings before redrawing map
658
- context.clearRect(0, 0, canvas.width, canvas.height)
771
+ // Pre-compute Path2D objects with the current projection
772
+ buildPathCache()
659
773
 
660
- // Enforces stroke style of the county lines
661
- context.strokeStyle = geoStrokeColor
774
+ // Render the map
775
+ renderFrame()
776
+ }
777
+ }
662
778
 
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
779
+ // Fast render using cached Path2D objects — called during zoom/pan for smooth performance.
780
+ // Skips canvas resize and projection setup; only applies the current zoom transform and redraws.
781
+ const renderFrame = () => {
782
+ if (!canvasRef.current || !runtimeLegend.items.length) return
671
783
 
672
- // Gets numeric data associated with the topo data for this state/county
673
- const geoData = runtimeData[geo.id]
784
+ const canvas = canvasRef.current
785
+ const context = canvas.getContext('2d')
786
+ const cache = geoPathCacheRef.current
787
+
788
+ // Clear canvas
789
+ context.setTransform(1, 0, 0, 1, 0, 0)
790
+ context.clearRect(0, 0, canvas.width, canvas.height)
791
+ context.save()
792
+ applyZoomTransform(context)
793
+ const zoomScale = getZoomScale()
794
+ const strokeScale = zoomScale ? 1 / zoomScale : 1
795
+ const countyStrokeWidth = lineWidth * 0.8 * strokeScale
796
+
797
+ // Enforces stroke style of the county lines
798
+ context.strokeStyle = geoStrokeColor
799
+ context.lineWidth = countyStrokeWidth
674
800
 
675
- // Renders state/county
676
- paintCountyGeo(context, path, geo, geoData, canvas.width)
677
- })
801
+ // Iterates through each state/county topo and renders it using cached Path2D
802
+ topoData.mapData.forEach(geo => {
803
+ if (!geo.id) return
804
+ if (focus.id && geo.id.length > 2 && geo.id.indexOf(focus.id) !== 0) return
805
+ if (!focus.id && config.general.type === 'us-geocode' && geo.id.length > 2) return
678
806
 
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()
807
+ const path2d = cache.get(geo.id)
808
+ if (!path2d) return
809
+
810
+ const geoData = runtimeData[geo.id]
811
+ paintCountyGeo(context, path2d, geoData, canvas.width, countyStrokeWidth)
812
+ })
813
+
814
+ // State borders
815
+ context.strokeStyle = '#1c1d1f'
816
+ context.lineWidth = lineWidth * 1.25 * strokeScale
817
+ topoData.states.forEach(state => {
818
+ if (!state.id) return
819
+ const path2d = cache.get('state_border_' + state.id)
820
+ if (path2d) {
821
+ context.stroke(path2d)
686
822
  }
823
+ })
687
824
 
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
- })
825
+ // If the focused state is found in the geo data, render it with a thicker outline
826
+ if (focus.index !== -1) {
827
+ const focusGeoId = topoData.mapData[focus.index]?.id
828
+ const path2d = focusGeoId && cache.get(focusGeoId)
829
+ if (path2d) {
830
+ context.strokeStyle = geoStrokeColor
831
+ context.lineWidth = lineWidth * 2 * strokeScale
832
+ context.stroke(path2d)
699
833
  }
834
+ }
700
835
 
701
- if (config.general.type === 'us-geocode') {
836
+ // Custom map layers (not cached — these are external features)
837
+ if (featureArray.length > 0) {
838
+ const layerPath = geoPath(topoData.projection, context)
839
+ featureArray.map(layer => {
840
+ context.beginPath()
841
+ layerPath(layer)
842
+ context.fillStyle = layer.properties.fill
702
843
  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
- })
844
+ context.lineWidth = layer.properties['stroke-width']
845
+ context.fill()
846
+ context.stroke()
847
+ })
848
+ }
713
849
 
714
- let cityPixelCoords = []
715
- cityStyles.forEach(city => {
716
- cityPixelCoords = topoData.projection([
717
- city[config.columns.longitude.name],
718
- city[config.columns.latitude.name]
719
- ])
850
+ if (config.general.type === 'us-geocode') {
851
+ context.strokeStyle = geoStrokeColor
852
+ context.lineWidth = lineWidth * strokeScale
853
+ const geoRadius = (config.visual.geoCodeCircleSize || 5) * (focus.id ? 2 : 1)
854
+ const { additionalCityStyles } = config.visual || []
855
+ const cityStyles = Object.values(runtimeData)
856
+ .filter(d => additionalCityStyles.some(style => String(d[style.column]) === String(style.value)))
857
+ .map(d => {
858
+ const conditionsMatched = additionalCityStyles.find(style => String(d[style.column]) === String(style.value))
859
+ return { ...conditionsMatched, ...d }
860
+ })
720
861
 
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
- }
862
+ let cityPixelCoords = []
863
+ cityStyles.forEach(city => {
864
+ cityPixelCoords = topoData.projection([city[config.columns.longitude.name], city[config.columns.latitude.name]])
865
+
866
+ if (cityPixelCoords) {
867
+ const legendValues = applyLegendToRow(
868
+ runtimeData[city?.value],
869
+ config,
870
+ runtimeLegend,
871
+ legendMemo,
872
+ legendSpecialClassLastMemo
873
+ )
874
+ if (legendValues) {
875
+ if (legendValues?.[0] === '#000000') return
876
+ const shapeType = city?.shape?.toLowerCase()
877
+ const shapeProperties = createShapeProperties(shapeType, cityPixelCoords, legendValues, config, geoRadius)
878
+ if (shapeProperties) {
879
+ drawShape(shapeProperties, context, config, lineWidth * strokeScale)
736
880
  }
737
881
  }
738
- })
882
+ }
883
+ })
739
884
 
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
- }
885
+ runtimeKeys.forEach(key => {
886
+ const citiesList = new Set(cityStyles.map(item => item.value))
887
+
888
+ const pixelCoords = topoData.projection([
889
+ runtimeData[key][config.columns.longitude.name],
890
+ runtimeData[key][config.columns.latitude.name]
891
+ ])
892
+ if (pixelCoords && !citiesList.has(key)) {
893
+ const legendValues =
894
+ runtimeData[key] !== undefined
895
+ ? applyLegendToRow(runtimeData[key], config, runtimeLegend, legendMemo, legendSpecialClassLastMemo)
896
+ : false
897
+ if (legendValues) {
898
+ if (legendValues?.[0] === '#000000' || legendValues?.[0] === DISABLED_MAP_COLOR) return
899
+ const shapeType = config.visual.cityStyle.toLowerCase()
900
+ const shapeProperties = createShapeProperties(shapeType, pixelCoords, legendValues, config, geoRadius)
901
+ if (shapeProperties) {
902
+ drawShape(shapeProperties, context, config, lineWidth * strokeScale)
759
903
  }
760
904
  }
761
- })
905
+ }
906
+ })
907
+ }
908
+ context.restore()
909
+ }
910
+
911
+ useEffect(() => {
912
+ if (!config.general.allowMapZoom) {
913
+ setFilteredStateCode('')
914
+ setFocus({})
915
+ setHasMoved(false)
916
+ resetZoomTransform()
917
+ }
918
+ }, [config.general.allowMapZoom])
919
+
920
+ useEffect(() => {
921
+ if (!canvasRef.current || !config.general.allowMapZoom) {
922
+ return
923
+ }
924
+
925
+ const COUNTY_MAX_ZOOM = 10
926
+ const canvasSelection = d3Select(canvasRef.current)
927
+ const zoomBehavior = d3Zoom()
928
+ .filter(d3Event => (d3Event ? !d3Event.ctrlKey && !d3Event.button : false))
929
+ .scaleExtent([1, COUNTY_MAX_ZOOM])
930
+ .on('zoom', d3Event => {
931
+ zoomTransformRef.current = d3Event.transform
932
+ const { x, y, k } = d3Event.transform
933
+ const isAtIdentity = x === 0 && y === 0 && k === 1
934
+ setHasMoved(!isAtIdentity)
935
+ scheduleDraw()
936
+ })
937
+
938
+ zoomBehaviorRef.current = zoomBehavior
939
+ canvasSelection.call(zoomBehavior)
940
+
941
+ return () => {
942
+ if (zoomFrameRef.current) {
943
+ window.cancelAnimationFrame(zoomFrameRef.current)
944
+ zoomFrameRef.current = null
762
945
  }
946
+ canvasSelection.on('.zoom', null)
763
947
  }
948
+ }, [config.general.allowMapZoom, topoData, runtimeLegend, focus])
949
+
950
+ useEffect(() => {
951
+ resetZoomTransform()
952
+ }, [focus?.id])
953
+
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
+ )
764
961
  }
765
962
 
963
+ const showManualZoomControls = config.general.allowMapZoom
964
+ const showResetControl = (hasMoved || focus.id) && (showManualZoomControls || config.general.type === 'us-geocode')
965
+ const showTopRightResetControl = showResetControl && config.general.type === 'us-geocode'
966
+ const showBottomLeftResetControl = showResetControl && config.general.type !== 'us-geocode'
967
+
766
968
  return (
767
969
  <ErrorBoundary component='CountyMap'>
768
970
  <canvas
@@ -775,11 +977,36 @@ const CountyMap = () => {
775
977
  }}
776
978
  onClick={canvasClick}
777
979
  className='county-map-canvas'
980
+ style={config.general.allowMapZoom ? undefined : { cursor: 'default' }}
778
981
  ></canvas>
779
982
 
780
- <button className={`btn btn--reset btn-primary p-absolute`} onClick={onReset} ref={resetButton} tabIndex={0}>
781
- Reset Zoom
782
- </button>
983
+ {showManualZoomControls && (
984
+ <div className='zoom-controls' data-html2canvas-ignore='true'>
985
+ <button onClick={handleZoomIn} aria-label='Zoom In'>
986
+ <svg viewBox='0 0 24 24' stroke='currentColor' strokeWidth='3'>
987
+ <line x1='12' y1='5' x2='12' y2='19' />
988
+ <line x1='5' y1='12' x2='19' y2='12' />
989
+ </svg>
990
+ </button>
991
+ <button onClick={handleZoomOut} aria-label='Zoom Out'>
992
+ <svg viewBox='0 0 24 24' stroke='currentColor' strokeWidth='3'>
993
+ <line x1='5' y1='12' x2='19' y2='12' />
994
+ </svg>
995
+ </button>
996
+ {showBottomLeftResetControl && (
997
+ <button onClick={handleZoomReset} className='reset' aria-label='Reset Zoom'>
998
+ Reset Zoom
999
+ </button>
1000
+ )}
1001
+ </div>
1002
+ )}
1003
+ {showTopRightResetControl && (
1004
+ <div className='zoom-controls zoom-controls--top-right' data-html2canvas-ignore='true'>
1005
+ <button onClick={handleZoomReset} className='reset' aria-label='Reset Zoom'>
1006
+ Reset Zoom
1007
+ </button>
1008
+ </div>
1009
+ )}
783
1010
  </ErrorBoundary>
784
1011
  )
785
1012
  }