@cdc/map 4.26.4 → 4.26.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CONFIG.md +70 -37
  2. package/LICENSE +201 -0
  3. package/README.md +6 -2
  4. package/dist/cdcmap.js +23502 -22964
  5. package/examples/default-county.json +3 -0
  6. package/examples/minimal-example.json +6 -2
  7. package/package.json +3 -3
  8. package/src/CdcMapComponent.tsx +13 -3
  9. package/src/_stories/CdcMap.AltText.stories.tsx +122 -0
  10. package/src/_stories/CdcMap.Editor.ColumnsSectionTests.stories.tsx +15 -16
  11. package/src/_stories/CdcMap.FocusVisibility.stories.tsx +87 -0
  12. package/src/_stories/CdcMap.HiddenMount.stories.tsx +69 -0
  13. package/src/_stories/CdcMap.ResetBehavior.stories.tsx +32 -0
  14. package/src/_stories/CdcMap.Zoom.stories.tsx +111 -0
  15. package/src/_stories/CdcMap.smoke.stories.tsx +48 -0
  16. package/src/_stories/_mock/alt_text_metadata.json +65 -0
  17. package/src/_stories/_mock/world-bubble-reset.json +138 -0
  18. package/src/_stories/_mock/world-data-zoom-filters.json +166 -0
  19. package/src/components/BubbleList.tsx +13 -0
  20. package/src/components/EditorPanel/components/EditorPanel.tsx +134 -0
  21. package/src/components/FilterControls.tsx +21 -0
  22. package/src/components/SmallMultiples/SmallMultiples.tsx +2 -2
  23. package/src/components/UsaMap/components/UsaMap.County.tsx +39 -9
  24. package/src/components/UsaMap/components/UsaMap.Region.tsx +5 -2
  25. package/src/components/UsaMap/components/UsaMap.SingleState.tsx +33 -10
  26. package/src/components/UsaMap/components/UsaMap.State.tsx +9 -2
  27. package/src/components/WorldMap/WorldMap.tsx +37 -4
  28. package/src/components/ZoomableGroup.tsx +23 -3
  29. package/src/components/filterControls.styles.css +6 -0
  30. package/src/data/initial-state.js +2 -0
  31. package/src/helpers/countyTerritories.ts +1 -1
  32. package/src/helpers/generateRuntimeFilters.ts +2 -1
  33. package/src/helpers/handleMapAriaLabels.ts +45 -30
  34. package/src/helpers/shouldAutoResetSingleStateZoom.ts +22 -0
  35. package/src/helpers/tests/handleMapAriaLabels.test.ts +71 -0
  36. package/src/helpers/tests/shouldAutoResetSingleStateZoom.test.ts +71 -0
  37. package/src/hooks/useGeoClickHandler.ts +13 -1
  38. package/src/hooks/useStateZoom.tsx +39 -20
  39. package/src/hooks/useTooltip.test.tsx +2 -16
  40. package/src/index.jsx +5 -2
  41. package/src/scss/main.scss +6 -21
  42. package/src/scss/map.scss +20 -0
  43. package/src/types/MapConfig.ts +5 -0
  44. package/src/types/MapContext.ts +3 -0
@@ -295,6 +295,8 @@ const CountyMap = () => {
295
295
  const context = canvasRef.current.getContext('2d') as CanvasRenderingContext2D
296
296
  context.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height)
297
297
  }
298
+ geoPathCacheRef.current.clear()
299
+ geoPathCacheKeyRef.current = ''
298
300
  setTopoData(response)
299
301
  })
300
302
  }
@@ -350,6 +352,7 @@ const CountyMap = () => {
350
352
  const zoomBehaviorRef = useRef()
351
353
  const zoomFrameRef = useRef<number | null>(null)
352
354
  const geoPathCacheRef = useRef<Map<string, Path2D>>(new Map())
355
+ const geoPathCacheKeyRef = useRef('')
353
356
 
354
357
  // Clear pattern cache when pattern configuration changes
355
358
  useEffect(() => {
@@ -359,8 +362,20 @@ const CountyMap = () => {
359
362
  const runtimeKeys = runtimeData ? Object.keys(runtimeData) : []
360
363
  const lineWidth = 1
361
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
+
362
377
  // Pre-compute Path2D objects for all geo features — avoids expensive geoPath projection on every zoom frame
363
- const buildPathCache = () => {
378
+ const buildPathCache = (cacheKey: string) => {
364
379
  const pathGen = geoPath(topoData.projection)
365
380
  const cache = new Map<string, Path2D>()
366
381
  topoData.mapData.forEach(geo => {
@@ -378,7 +393,9 @@ const CountyMap = () => {
378
393
  const d = pathGen(hsa.feature as any)
379
394
  if (d) cache.set('hsa_border_' + hsa.groupId, new Path2D(d))
380
395
  })
396
+ geoPathCacheRef.current.clear()
381
397
  geoPathCacheRef.current = cache
398
+ geoPathCacheKeyRef.current = cacheKey
382
399
  }
383
400
 
384
401
  const resetZoomTransform = () => {
@@ -391,8 +408,10 @@ const CountyMap = () => {
391
408
  const getCanvasPoints = e => {
392
409
  const canvas = e.target
393
410
  const canvasBounds = canvas.getBoundingClientRect()
394
- const x = e.clientX - canvasBounds.left
395
- 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
396
415
  const [mapX, mapY] = zoomTransformRef.current.invert([x, y])
397
416
  return { canvas, mapX, mapY }
398
417
  }
@@ -403,6 +422,10 @@ const CountyMap = () => {
403
422
  }
404
423
 
405
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
+ }
406
429
 
407
430
  const paintCountyGeo = (
408
431
  context,
@@ -862,14 +885,17 @@ const CountyMap = () => {
862
885
  context.restore()
863
886
  }
864
887
 
865
- // 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.
866
889
  // Called on data change, resize, focus change — NOT during zoom/pan.
867
890
  const drawCanvas = () => {
868
891
  if (canvasRef.current && runtimeLegend.items.length > 0) {
869
892
  const canvas = canvasRef.current
893
+ const canvasWidth = canvas.clientWidth
894
+ if (canvasWidth <= 0) return
895
+ const canvasHeight = canvasWidth * 0.6
870
896
 
871
- canvas.width = canvas.clientWidth
872
- canvas.height = canvas.width * 0.6
897
+ if (canvas.width !== canvasWidth) canvas.width = canvasWidth
898
+ if (canvas.height !== canvasHeight) canvas.height = canvasHeight
873
899
 
874
900
  topoData.projection.scale(canvas.width * 1.25).translate([canvas.width / 2, canvas.height / 2])
875
901
 
@@ -883,8 +909,10 @@ const CountyMap = () => {
883
909
  topoData.projection.fitExtent(fitExtent, focus.feature)
884
910
  }
885
911
 
886
- // Pre-compute Path2D objects with the current projection
887
- buildPathCache()
912
+ const pathCacheKey = getPathCacheKey(canvas)
913
+ if (geoPathCacheKeyRef.current !== pathCacheKey || geoPathCacheRef.current.size === 0) {
914
+ buildPathCache(pathCacheKey)
915
+ }
888
916
 
889
917
  // Render the map
890
918
  renderFrame()
@@ -968,7 +996,6 @@ const CountyMap = () => {
968
996
  context.strokeStyle = stateStrokeColor
969
997
  context.lineWidth = lineWidth * 1.25 * strokeScale
970
998
  topoData.states.forEach(state => {
971
- if (config.migrations.showPuertoRico == false) return
972
999
  if (!state.id) return
973
1000
  const path2d = cache.get('state_border_' + state.id)
974
1001
  if (path2d) {
@@ -1140,7 +1167,10 @@ const CountyMap = () => {
1140
1167
  )}
1141
1168
  <canvas
1142
1169
  ref={canvasRef}
1170
+ role='img'
1143
1171
  aria-label={handleMapAriaLabels(config)}
1172
+ data-zoom-transform={getZoomTransformString()}
1173
+ data-zoom-scale={getZoomScale()}
1144
1174
  onMouseMove={canvasHover}
1145
1175
  onMouseOut={() => {
1146
1176
  tooltipRef.current.style.display = 'none'
@@ -57,6 +57,9 @@ const Rect: React.FC<RectProps> = ({ label, text, stroke, strokeWidth, ...props
57
57
 
58
58
  const UsaRegionMap = () => {
59
59
  const { runtimeData, config, tooltipId, runtimeLegend, interactionLabel } = useContext(ConfigContext)
60
+
61
+ const a11y = handleMapAriaLabels(config)
62
+
60
63
  const { legendMemo, legendSpecialClassLastMemo } = useLegendMemoContext()
61
64
  const [focusedStates, setFocusedStates] = useState(null)
62
65
  const { geoClickHandler } = useGeoClickHandler()
@@ -250,7 +253,7 @@ const UsaRegionMap = () => {
250
253
  <path tabIndex={-1} className='single-geo' stroke={geoStrokeColor} strokeWidth={1} d={path} />
251
254
  <g id={`region-${index + 1}-label`}>
252
255
  <circle fill='#fff' stroke='#999' cx={CIRCLE_RADIUS} cy={CIRCLE_RADIUS} r={CIRCLE_RADIUS} />
253
- <text fill='#333' x='15px' y='20px' textAnchor='middle'>
256
+ <text fill='var(--baseColor)' x='15px' y='20px' textAnchor='middle'>
254
257
  {index + 1}
255
258
  </text>
256
259
  </g>
@@ -290,7 +293,7 @@ const UsaRegionMap = () => {
290
293
 
291
294
  return (
292
295
  <ErrorBoundary component='UsaRegionMap'>
293
- <svg viewBox={SVG_VIEWBOX} role='img' aria-label={handleMapAriaLabels(config)}>
296
+ <svg viewBox={SVG_VIEWBOX} role='img' aria-label={a11y}>
294
297
  <Mercator data={focusedStates} scale={620} translate={[1500, 735]}>
295
298
  {({ features, projection }) => constructGeoJsx(features, projection)}
296
299
  </Mercator>
@@ -1,4 +1,4 @@
1
- import { useEffect, memo, useContext, useMemo } from 'react'
1
+ import { useEffect, memo, useContext, useMemo, useRef } from 'react'
2
2
  import ErrorBoundary from '@cdc/core/components/ErrorBoundary'
3
3
  import { geoPath } from 'd3-geo'
4
4
  import { CustomProjection } from '@visx/geo'
@@ -26,10 +26,12 @@ import useGeoClickHandler from '../../../hooks/useGeoClickHandler'
26
26
  import { SVG_WIDTH, SVG_HEIGHT, SVG_PADDING, SVG_VIEWBOX } from '../../../helpers'
27
27
  import _ from 'lodash'
28
28
  import { getStatesPicked } from '../../../helpers/getStatesPicked'
29
+ import { shouldAutoResetSingleStateZoom } from '../../../helpers/shouldAutoResetSingleStateZoom'
29
30
 
30
31
  const SingleStateMap: React.FC = () => {
31
32
  const {
32
33
  config,
34
+ isDashboard,
33
35
  setSharedFilterValue,
34
36
  isFilterValueSupported,
35
37
  runtimeFilters,
@@ -42,17 +44,17 @@ const SingleStateMap: React.FC = () => {
42
44
  useDynamicViewbox
43
45
  } = useContext<MapContext>(ConfigContext)
44
46
 
47
+ const a11y = handleMapAriaLabels(config)
48
+
45
49
  const dispatch = useContext(MapDispatchContext)
46
- const { handleMoveEnd, handleZoomIn, handleZoomOut, handleZoomReset, projection, bounds } = useStateZoom(topoData)
50
+ const { handleMoveEnd, handleZoomIn, handleZoomOut, handleZoomReset, resetZoomState, projection, bounds } =
51
+ useStateZoom(topoData)
52
+ const previousRuntimeDataHashRef = useRef<number | null>(null)
47
53
 
48
54
  // Memoize statesPicked to prevent creating new arrays on every render
49
55
  const statesPicked = useMemo(() => {
50
56
  return getStatesPicked(config, runtimeData)
51
- }, [
52
- config.general.statesPicked?.length,
53
- config.general.statesPicked?.[0]?.stateName
54
- // Don't include runtimeData as it causes excessive re-renders
55
- ])
57
+ }, [config.general.statesPicked?.length, config.general.statesPicked?.[0]?.stateName, (runtimeData as any)?.fromHash])
56
58
 
57
59
  const statesToShow = topoData?.states?.find(s => statesPicked.map(sp => sp.stateName).includes(s.properties.name))
58
60
 
@@ -84,6 +86,27 @@ const SingleStateMap: React.FC = () => {
84
86
  }
85
87
  }, [config.general.countyCensusYear, config.general.filterControlsCountyYear, JSON.stringify(runtimeFilters)])
86
88
 
89
+ useEffect(() => {
90
+ const runtimeDataHash = (runtimeData as any)?.fromHash as number | undefined
91
+ const hasDashboardFilters = Boolean(config.dashboardFilters?.length)
92
+
93
+ if (
94
+ shouldAutoResetSingleStateZoom({
95
+ isDashboard,
96
+ previousRuntimeDataHash: previousRuntimeDataHashRef.current,
97
+ nextRuntimeDataHash: runtimeDataHash,
98
+ hasDashboardFilters,
99
+ allowMapZoom: config.general.allowMapZoom
100
+ })
101
+ ) {
102
+ resetZoomState({ publishEvent: false })
103
+ }
104
+
105
+ if (runtimeDataHash !== undefined) {
106
+ previousRuntimeDataHashRef.current = runtimeDataHash
107
+ }
108
+ }, [config.dashboardFilters, config.general.allowMapZoom, isDashboard, resetZoomState, runtimeData])
109
+
87
110
  if (!isTopoReady(topoData, config, runtimeFilters)) {
88
111
  return (
89
112
  <div style={{ height: `${SVG_HEIGHT}px` }}>
@@ -152,7 +175,7 @@ const SingleStateMap: React.FC = () => {
152
175
  preserveAspectRatio='xMinYMin'
153
176
  className='svg-container'
154
177
  role='img'
155
- aria-label={handleMapAriaLabels(config)}
178
+ aria-label={a11y}
156
179
  >
157
180
  <ZoomableGroup
158
181
  center={position.coordinates}
@@ -215,7 +238,7 @@ const SingleStateMap: React.FC = () => {
215
238
  preserveAspectRatio='xMinYMin'
216
239
  className='svg-container'
217
240
  role='img'
218
- aria-label={handleMapAriaLabels(config)}
241
+ aria-label={a11y}
219
242
  >
220
243
  <rect
221
244
  className='background center-container ocean'
@@ -268,7 +291,7 @@ const SingleStateMap: React.FC = () => {
268
291
  preserveAspectRatio='xMinYMin'
269
292
  className='svg-container'
270
293
  role='img'
271
- aria-label={handleMapAriaLabels(config)}
294
+ aria-label={a11y}
272
295
  >
273
296
  <Text
274
297
  verticalAnchor='start'
@@ -20,6 +20,7 @@ import HexIcon from './HexIcon'
20
20
  import { patternSizes } from '../helpers/patternSizes'
21
21
  import Annotation from '../../Annotation'
22
22
  import Territory from './Territory'
23
+ import FilterControls from '../../FilterControls'
23
24
 
24
25
  import ConfigContext, { MapDispatchContext } from '../../../context'
25
26
  import { useLegendMemoContext } from '../../../context/LegendMemoContext'
@@ -88,9 +89,13 @@ const UsaMap = () => {
88
89
  dimensions,
89
90
  translate,
90
91
  runtimeLegend,
91
- interactionLabel
92
+ interactionLabel,
93
+ clearSharedFilter,
94
+ hasActiveSharedFilter
92
95
  } = useContext<MapContext>(ConfigContext)
93
96
 
97
+ const a11y = handleMapAriaLabels(config)
98
+
94
99
  const { legendMemo, legendSpecialClassLastMemo } = useLegendMemoContext()
95
100
 
96
101
  const { getSyncProps, syncHandlers } = useSynchronizedGeographies()
@@ -668,7 +673,7 @@ const UsaMap = () => {
668
673
 
669
674
  return (
670
675
  <ErrorBoundary component='UsaMap'>
671
- <svg viewBox={SVG_VIEWBOX} role='img' aria-label={handleMapAriaLabels(config)}>
676
+ <svg viewBox={SVG_VIEWBOX} role='img' aria-label={a11y}>
672
677
  {general.displayAsHex ? (
673
678
  <Mercator data={unitedStatesHex} scale={650} translate={[1600, 775]}>
674
679
  {({ features, projection }) => constructGeoJsx(features, projection)}
@@ -681,6 +686,8 @@ const UsaMap = () => {
681
686
  {annotations?.length > 0 && <Annotation.Draggable onDragStateChange={handleDragStateChange} />}
682
687
  </svg>
683
688
 
689
+ <FilterControls />
690
+
684
691
  <TerritoriesSection territories={territories} logo={logo} config={config} territoriesData={territoriesData} />
685
692
  </ErrorBoundary>
686
693
  )
@@ -1,4 +1,4 @@
1
- import { memo, useContext, useState, useEffect } from 'react'
1
+ import { memo, useContext, useState, useEffect, useRef } from 'react'
2
2
  import { geoMercator } from 'd3-geo'
3
3
  import { Mercator } from '@visx/geo'
4
4
  import { feature } from 'topojson-client'
@@ -27,6 +27,7 @@ import useGeoClickHandler from '../../hooks/useGeoClickHandler'
27
27
  import useApplyTooltipsToGeo from '../../hooks/useApplyTooltipsToGeo'
28
28
  import useCountryZoom from '../../hooks/useCountryZoom'
29
29
  import generateRuntimeData from '../../helpers/generateRuntimeData'
30
+ import { generateRuntimeFilters } from '../../helpers/generateRuntimeFilters'
30
31
  import { applyLegendToRow } from '../../helpers/applyLegendToRow'
31
32
  import { normalizeTopoJsonProperties } from '../../helpers/normalizeTopoJsonProperties'
32
33
 
@@ -42,6 +43,8 @@ const WorldMap = () => {
42
43
  // prettier-ignore
43
44
  const {
44
45
  runtimeData,
46
+ runtimeFilters,
47
+ filteredCountryCode,
45
48
  position: mapPosition,
46
49
  config,
47
50
  tooltipId,
@@ -49,6 +52,8 @@ const WorldMap = () => {
49
52
  interactionLabel
50
53
  } = useContext(ConfigContext)
51
54
 
55
+ const a11y = handleMapAriaLabels(config)
56
+
52
57
  // Type assertion: position from context is actually the map viewport position, not legend position
53
58
  const position = mapPosition as unknown as MapPosition
54
59
 
@@ -63,6 +68,9 @@ const WorldMap = () => {
63
68
  const { centerOnCountries } = useCountryZoom(world)
64
69
 
65
70
  const dispatch = useContext(MapDispatchContext)
71
+ const previousWorldBubbleRuntimeData = useRef(runtimeData)
72
+ const isWorldBubbleMap = config.general.geoType === 'world' && config.general.type === 'bubble'
73
+ const isDrilledBubbleView = isWorldBubbleMap && Boolean(filteredCountryCode)
66
74
 
67
75
  useEffect(() => {
68
76
  const fetchData = async () => {
@@ -98,6 +106,11 @@ const WorldMap = () => {
98
106
  fetchData()
99
107
  }, [])
100
108
 
109
+ useEffect(() => {
110
+ if (!isWorldBubbleMap || isDrilledBubbleView || runtimeData?.init) return
111
+ previousWorldBubbleRuntimeData.current = runtimeData
112
+ }, [isWorldBubbleMap, isDrilledBubbleView, runtimeData])
113
+
101
114
  if (!world) {
102
115
  return <></>
103
116
  }
@@ -115,8 +128,19 @@ const WorldMap = () => {
115
128
 
116
129
  const filteredWorld = getFilteredWorld()
117
130
 
131
+ const rebuildRuntimeDataFromActiveFilters = () => {
132
+ const activeFilters = runtimeFilters?.length ? runtimeFilters : generateRuntimeFilters(config, undefined, [])
133
+
134
+ return generateRuntimeData(
135
+ config,
136
+ activeFilters,
137
+ runtimeFilters?.fromHash ?? runtimeData?.fromHash,
138
+ config.legend?.type === 'category',
139
+ config.table?.showNonGeoData
140
+ )
141
+ }
142
+
118
143
  const handleFiltersReset = () => {
119
- const newRuntimeData = generateRuntimeData(config)
120
144
  publishAnalyticsEvent({
121
145
  vizType: config.type,
122
146
  vizSubType: getVizSubType(config),
@@ -125,6 +149,7 @@ const WorldMap = () => {
125
149
  eventLabel: interactionLabel,
126
150
  vizTitle: getVizTitle(config)
127
151
  })
152
+ const newRuntimeData = rebuildRuntimeDataFromActiveFilters()
128
153
  dispatch({ type: 'SET_FILTERED_COUNTRY_CODE', payload: '' })
129
154
  dispatch({ type: 'SET_RUNTIME_DATA', payload: newRuntimeData })
130
155
  }
@@ -139,6 +164,14 @@ const WorldMap = () => {
139
164
  vizTitle: getVizTitle(config)
140
165
  })
141
166
 
167
+ if (isWorldBubbleMap) {
168
+ const newRuntimeData = isDrilledBubbleView
169
+ ? previousWorldBubbleRuntimeData.current
170
+ : rebuildRuntimeDataFromActiveFilters()
171
+ dispatch({ type: 'SET_FILTERED_COUNTRY_CODE', payload: '' })
172
+ dispatch({ type: 'SET_RUNTIME_DATA', payload: newRuntimeData })
173
+ }
174
+
142
175
  // If countries are selected, center on them; otherwise, use default world position
143
176
  const countriesPicked = getCountriesPicked(config)
144
177
 
@@ -387,7 +420,7 @@ const WorldMap = () => {
387
420
  return (
388
421
  <ErrorBoundary component='WorldMap'>
389
422
  {allowMapZoom ? (
390
- <svg viewBox={SVG_VIEWBOX} role='img' aria-label={handleMapAriaLabels(config)}>
423
+ <svg viewBox={SVG_VIEWBOX} role='img' aria-label={a11y}>
391
424
  <rect height={SVG_HEIGHT} width={SVG_WIDTH} onClick={handleFiltersReset} fill='white' />
392
425
  <ZoomableGroup
393
426
  zoom={position.zoom}
@@ -402,7 +435,7 @@ const WorldMap = () => {
402
435
  </ZoomableGroup>
403
436
  </svg>
404
437
  ) : (
405
- <svg viewBox={SVG_VIEWBOX}>
438
+ <svg viewBox={SVG_VIEWBOX} role='img' aria-label={a11y}>
406
439
  <ZoomableGroup
407
440
  zoom={1}
408
441
  center={position.coordinates}
@@ -1,8 +1,23 @@
1
1
  import React from 'react'
2
2
  import useZoomPan from '../hooks/useZoomPan'
3
3
 
4
- const ZoomableGroup = ({ center = [0, 0], zoom = 1, minZoom = 1, maxZoom = 8, translateExtent, filterZoomEvent, onMoveStart, onMove, onMoveEnd, className, projection, width, height, ...restProps }) => {
5
- const { mapRef, transformString } = useZoomPan({
4
+ const ZoomableGroup = ({
5
+ center = [0, 0],
6
+ zoom = 1,
7
+ minZoom = 1,
8
+ maxZoom = 8,
9
+ translateExtent,
10
+ filterZoomEvent,
11
+ onMoveStart,
12
+ onMove,
13
+ onMoveEnd,
14
+ className,
15
+ projection,
16
+ width,
17
+ height,
18
+ ...restProps
19
+ }) => {
20
+ const { mapRef, transformString, position } = useZoomPan({
6
21
  center,
7
22
  filterZoomEvent,
8
23
  onMoveStart,
@@ -19,7 +34,12 @@ const ZoomableGroup = ({ center = [0, 0], zoom = 1, minZoom = 1, maxZoom = 8, tr
19
34
  return (
20
35
  <g ref={mapRef}>
21
36
  <rect width={width} height={height} fill='transparent' />
22
- <g transform={transformString} {...restProps} />
37
+ <g
38
+ transform={transformString}
39
+ data-zoom-transform={transformString}
40
+ data-zoom-scale={position.k}
41
+ {...restProps}
42
+ />
23
43
  </g>
24
44
  )
25
45
  }
@@ -0,0 +1,6 @@
1
+ .filter-controls {
2
+ bottom: 1em;
3
+ position: absolute;
4
+ right: 1em;
5
+ z-index: 4;
6
+ }
@@ -101,6 +101,8 @@ const createInitialState = () => {
101
101
  downloadUrlLabel: '',
102
102
  showDataTableLink: true,
103
103
  showDownloadLinkBelow: true,
104
+ search: false,
105
+ searchPlaceholder: '',
104
106
  showFullGeoNameInCSV: false,
105
107
  forceDisplay: true,
106
108
  download: false,
@@ -1,4 +1,4 @@
1
- export const US_TERRITORY_STATE_FIPS_PREFIXES = new Set(['60', '66', '69', '78'])
1
+ export const US_TERRITORY_STATE_FIPS_PREFIXES = new Set(['60', '66', '69', '72', '78'])
2
2
 
3
3
  export type CountyTerritoryVisibility = {
4
4
  showTerritories: boolean
@@ -13,6 +13,7 @@ export const generateRuntimeFilters = (state, hash, runtimeFilters) => {
13
13
  queryParameter,
14
14
  orderedValues,
15
15
  active,
16
+ defaultValue,
16
17
  values,
17
18
  type,
18
19
  showDropdown,
@@ -50,7 +51,7 @@ export const generateRuntimeFilters = (state, hash, runtimeFilters) => {
50
51
  newFilter.values = values
51
52
  newFilter.setByQueryParameter = setByQueryParameter
52
53
  handleSorting(newFilter)
53
- newFilter.active = active ?? values[0] // Default to first found value
54
+ newFilter.active = active ?? defaultValue ?? values[0] // Default to configured defaultValue, then first found value
54
55
  newFilter.filterStyle = state.filters[idx].filterStyle ? state.filters[idx].filterStyle : 'dropdown'
55
56
  newFilter.showDropdown = showDropdown
56
57
  newFilter.subGrouping = state.filters[idx].subGrouping
@@ -1,36 +1,51 @@
1
- export const handleMapAriaLabels = (state: MapConfig = '') => {
1
+ import type { AltTextConfig } from '@cdc/core/types/AltText'
2
+ import { resolveAltTextDescription } from '@cdc/core/helpers/resolveAltTextDescription'
3
+
4
+ const getAutoLabel = (state): string => {
5
+ const {
6
+ general: { title, geoType, statesPicked }
7
+ } = state
8
+ let ariaLabel = ''
9
+ switch (geoType) {
10
+ case 'world':
11
+ ariaLabel += 'World map'
12
+ break
13
+ case 'us':
14
+ ariaLabel += 'United States map'
15
+ break
16
+ case 'us-county':
17
+ ariaLabel += `United States county map`
18
+ break
19
+ case 'single-state':
20
+ ariaLabel += `${statesPicked.map(sp => sp.stateName).join(', ')} county map`
21
+ break
22
+ case 'us-region':
23
+ ariaLabel += `United States HHS Region map`
24
+ break
25
+ default:
26
+ ariaLabel = 'Data visualization container'
27
+ break
28
+ }
29
+
30
+ if (title) {
31
+ ariaLabel += ` with the title: ${title}`
32
+ }
33
+
34
+ return ariaLabel
35
+ }
36
+
37
+ export const handleMapAriaLabels = (state: {
38
+ general?: { geoType?: string; title?: string; statesPicked?: { stateName: string }[] }
39
+ altText?: AltTextConfig
40
+ dataMetadata?: Record<string, string>
41
+ }): string => {
2
42
  try {
3
- if (!state.general.geoType) throw Error('handleMapAriaLabels: no geoType found in state')
4
- const {
5
- general: { title, geoType, statesPicked }
6
- } = state
7
- let ariaLabel = ''
8
- switch (geoType) {
9
- case 'world':
10
- ariaLabel += 'World map'
11
- break
12
- case 'us':
13
- ariaLabel += 'United States map'
14
- break
15
- case 'us-county':
16
- ariaLabel += `United States county map`
17
- break
18
- case 'single-state':
19
- ariaLabel += `${statesPicked.map(sp => sp.stateName).join(', ')} county map`
20
- break
21
- case 'us-region':
22
- ariaLabel += `United States HHS Region map`
23
- break
24
- default:
25
- ariaLabel = 'Data visualization container'
26
- break
27
- }
43
+ if (!state.general?.geoType) throw Error('handleMapAriaLabels: no geoType found in state')
28
44
 
29
- if (title) {
30
- ariaLabel += ` with the title: ${title}`
31
- }
45
+ const title = getAutoLabel(state)
46
+ const description = resolveAltTextDescription(state.altText, state.dataMetadata)
32
47
 
33
- return ariaLabel
48
+ return description ? `${title}. ${description}` : title
34
49
  } catch (e) {
35
50
  console.error('COVE: ', e.message) // eslint-disable-line
36
51
  return 'Data visualization container'
@@ -0,0 +1,22 @@
1
+ type AutoResetArgs = {
2
+ isDashboard: boolean
3
+ previousRuntimeDataHash: number | null
4
+ nextRuntimeDataHash?: number
5
+ hasDashboardFilters?: boolean
6
+ allowMapZoom?: boolean
7
+ }
8
+
9
+ export const shouldAutoResetSingleStateZoom = ({
10
+ isDashboard,
11
+ previousRuntimeDataHash,
12
+ nextRuntimeDataHash,
13
+ hasDashboardFilters = false,
14
+ allowMapZoom = true
15
+ }: AutoResetArgs): boolean => {
16
+ if (!isDashboard || !allowMapZoom) return false
17
+ if (!hasDashboardFilters) return false
18
+ if (previousRuntimeDataHash === null) return false
19
+ if (nextRuntimeDataHash === undefined) return false
20
+
21
+ return previousRuntimeDataHash !== nextRuntimeDataHash
22
+ }
@@ -0,0 +1,71 @@
1
+ import { handleMapAriaLabels } from '../handleMapAriaLabels'
2
+
3
+ const baseState = {
4
+ general: {
5
+ geoType: 'us',
6
+ title: 'COVID-19 Cases by State',
7
+ statesPicked: []
8
+ }
9
+ }
10
+
11
+ describe('handleMapAriaLabels', () => {
12
+ it('returns auto-generated title for US map', () => {
13
+ const result = handleMapAriaLabels(baseState)
14
+ expect(result).toBe('United States map with the title: COVID-19 Cases by State')
15
+ })
16
+
17
+ it('returns auto-generated title for world map', () => {
18
+ const result = handleMapAriaLabels({
19
+ general: { geoType: 'world', title: 'Global Data', statesPicked: [] }
20
+ })
21
+ expect(result).toBe('World map with the title: Global Data')
22
+ })
23
+
24
+ it('returns fallback when geoType is missing', () => {
25
+ const result = handleMapAriaLabels({ general: {} })
26
+ expect(result).toBe('Data visualization container')
27
+ })
28
+
29
+ describe('description', () => {
30
+ it('concatenates static description after title', () => {
31
+ const result = handleMapAriaLabels({
32
+ ...baseState,
33
+ altText: { type: 'static', value: 'Rates highest in the Southeast.' }
34
+ })
35
+ expect(result).toBe('United States map with the title: COVID-19 Cases by State. Rates highest in the Southeast.')
36
+ })
37
+
38
+ it('concatenates description from metadata after title', () => {
39
+ const result = handleMapAriaLabels({
40
+ ...baseState,
41
+ altText: { type: 'metadata', metadataKey: 'altDescription' },
42
+ dataMetadata: { altDescription: 'Map shows elevated rates in southern states.' }
43
+ })
44
+ expect(result).toBe(
45
+ 'United States map with the title: COVID-19 Cases by State. Map shows elevated rates in southern states.'
46
+ )
47
+ })
48
+
49
+ it('returns title only when metadata key is missing', () => {
50
+ const result = handleMapAriaLabels({
51
+ ...baseState,
52
+ altText: { type: 'metadata', metadataKey: 'missing' },
53
+ dataMetadata: { other: 'value' }
54
+ })
55
+ expect(result).toBe('United States map with the title: COVID-19 Cases by State')
56
+ })
57
+
58
+ it('returns title only when not configured', () => {
59
+ const result = handleMapAriaLabels(baseState)
60
+ expect(result).toBe('United States map with the title: COVID-19 Cases by State')
61
+ })
62
+
63
+ it('returns title only when altText is empty object', () => {
64
+ const result = handleMapAriaLabels({
65
+ ...baseState,
66
+ altText: {}
67
+ })
68
+ expect(result).toBe('United States map with the title: COVID-19 Cases by State')
69
+ })
70
+ })
71
+ })