@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.
- package/CONFIG.md +70 -37
- package/LICENSE +201 -0
- package/README.md +6 -2
- package/dist/cdcmap.js +23502 -22964
- package/examples/default-county.json +3 -0
- package/examples/minimal-example.json +6 -2
- package/package.json +3 -3
- package/src/CdcMapComponent.tsx +13 -3
- package/src/_stories/CdcMap.AltText.stories.tsx +122 -0
- package/src/_stories/CdcMap.Editor.ColumnsSectionTests.stories.tsx +15 -16
- package/src/_stories/CdcMap.FocusVisibility.stories.tsx +87 -0
- package/src/_stories/CdcMap.HiddenMount.stories.tsx +69 -0
- package/src/_stories/CdcMap.ResetBehavior.stories.tsx +32 -0
- package/src/_stories/CdcMap.Zoom.stories.tsx +111 -0
- package/src/_stories/CdcMap.smoke.stories.tsx +48 -0
- package/src/_stories/_mock/alt_text_metadata.json +65 -0
- package/src/_stories/_mock/world-bubble-reset.json +138 -0
- package/src/_stories/_mock/world-data-zoom-filters.json +166 -0
- package/src/components/BubbleList.tsx +13 -0
- package/src/components/EditorPanel/components/EditorPanel.tsx +134 -0
- package/src/components/FilterControls.tsx +21 -0
- package/src/components/SmallMultiples/SmallMultiples.tsx +2 -2
- package/src/components/UsaMap/components/UsaMap.County.tsx +39 -9
- package/src/components/UsaMap/components/UsaMap.Region.tsx +5 -2
- package/src/components/UsaMap/components/UsaMap.SingleState.tsx +33 -10
- package/src/components/UsaMap/components/UsaMap.State.tsx +9 -2
- package/src/components/WorldMap/WorldMap.tsx +37 -4
- package/src/components/ZoomableGroup.tsx +23 -3
- package/src/components/filterControls.styles.css +6 -0
- package/src/data/initial-state.js +2 -0
- package/src/helpers/countyTerritories.ts +1 -1
- package/src/helpers/generateRuntimeFilters.ts +2 -1
- package/src/helpers/handleMapAriaLabels.ts +45 -30
- package/src/helpers/shouldAutoResetSingleStateZoom.ts +22 -0
- package/src/helpers/tests/handleMapAriaLabels.test.ts +71 -0
- package/src/helpers/tests/shouldAutoResetSingleStateZoom.test.ts +71 -0
- package/src/hooks/useGeoClickHandler.ts +13 -1
- package/src/hooks/useStateZoom.tsx +39 -20
- package/src/hooks/useTooltip.test.tsx +2 -16
- package/src/index.jsx +5 -2
- package/src/scss/main.scss +6 -21
- package/src/scss/map.scss +20 -0
- package/src/types/MapConfig.ts +5 -0
- 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
|
|
395
|
-
const
|
|
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
|
|
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
|
|
872
|
-
canvas.height
|
|
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
|
-
|
|
887
|
-
|
|
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='
|
|
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={
|
|
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 } =
|
|
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={
|
|
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={
|
|
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={
|
|
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={
|
|
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={
|
|
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 = ({
|
|
5
|
-
|
|
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
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
45
|
+
const title = getAutoLabel(state)
|
|
46
|
+
const description = resolveAltTextDescription(state.altText, state.dataMetadata)
|
|
32
47
|
|
|
33
|
-
return
|
|
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
|
+
})
|