@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.
- package/LICENSE +201 -0
- package/dist/cdcmap-vr9HZwRt.es.js +6 -0
- package/dist/cdcmap.js +26781 -24615
- package/examples/private/annotation-bug.json +642 -0
- package/package.json +3 -3
- package/src/CdcMap.tsx +3 -14
- package/src/CdcMapComponent.tsx +214 -159
- package/src/_stories/CdcMap.Defaults.stories.tsx +76 -0
- package/src/_stories/CdcMap.Editor.stories.tsx +187 -14
- package/src/_stories/CdcMap.stories.tsx +11 -1
- package/src/_stories/Map.HTMLInDataTable.stories.tsx +385 -0
- package/src/_stories/_mock/multi-state-show-unselected.json +82 -0
- package/src/cdcMapComponent.styles.css +2 -2
- package/src/components/Annotation/Annotation.Draggable.styles.css +4 -4
- package/src/components/Annotation/AnnotationDropdown.styles.css +1 -1
- package/src/components/Annotation/AnnotationList.styles.css +13 -13
- package/src/components/EditorPanel/components/EditorPanel.tsx +426 -58
- package/src/components/EditorPanel/components/Panels/Panel.PatternSettings-style.css +1 -1
- package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +5 -2
- package/src/components/EditorPanel/components/editorPanel.styles.css +34 -24
- package/src/components/Legend/components/Legend.tsx +9 -4
- package/src/components/Legend/components/LegendGroup/legend.group.css +5 -5
- package/src/components/Legend/components/index.scss +2 -3
- package/src/components/NavigationMenu.tsx +2 -1
- package/src/components/SmallMultiples/SmallMultiples.css +5 -5
- package/src/components/UsaMap/components/SingleState/SingleState.StateOutput.tsx +32 -17
- package/src/components/UsaMap/components/TerritoriesSection.tsx +3 -2
- package/src/components/UsaMap/components/Territory/Territory.Rectangle.tsx +13 -8
- package/src/components/UsaMap/components/UsaMap.County.tsx +410 -183
- package/src/components/UsaMap/components/UsaMap.Region.styles.css +1 -1
- package/src/components/UsaMap/components/UsaMap.SingleState.styles.css +2 -2
- package/src/components/UsaMap/components/UsaMap.State.tsx +13 -8
- package/src/components/WorldMap/WorldMap.tsx +10 -13
- package/src/components/WorldMap/data/world-topo-updated.json +1 -0
- package/src/components/WorldMap/data/world-topo.json +1 -1
- package/src/components/WorldMap/worldMap.styles.css +1 -1
- package/src/components/ZoomControls.tsx +49 -18
- package/src/components/zoomControls.styles.css +27 -11
- package/src/data/initial-state.js +14 -5
- package/src/data/legacy-defaults.ts +8 -0
- package/src/data/supported-geos.js +19 -0
- package/src/helpers/colors.ts +2 -1
- package/src/helpers/dataTableHelpers.ts +56 -0
- package/src/helpers/displayGeoName.ts +19 -11
- package/src/helpers/getMapContainerClasses.ts +8 -2
- package/src/helpers/getMatchingPatternForRow.ts +67 -0
- package/src/helpers/getPatternForRow.ts +11 -18
- package/src/helpers/tests/dataTableHelpers.test.ts +78 -0
- package/src/helpers/tests/displayGeoName.test.ts +17 -0
- package/src/helpers/tests/getMatchingPatternForRow.test.ts +150 -0
- package/src/helpers/tests/getPatternForRow.test.ts +140 -2
- package/src/helpers/urlDataHelpers.ts +7 -1
- package/src/hooks/useResizeObserver.ts +36 -22
- package/src/hooks/useTooltip.test.tsx +64 -0
- package/src/hooks/useTooltip.ts +28 -8
- package/src/scss/editor-panel.scss +1 -1
- package/src/scss/main.scss +140 -6
- package/src/scss/map.scss +9 -4
- package/src/store/map.actions.ts +2 -0
- package/src/store/map.reducer.ts +4 -0
- package/src/test/CdcMap.test.jsx +2 -2
- package/src/types/MapConfig.ts +22 -4
- package/src/types/MapContext.ts +3 -1
- package/dist/cdcmap-Cf9_fbQf.es.js +0 -6
- 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 {
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
223
|
-
|
|
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
|
|
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.
|
|
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
|
|
251
|
-
const cacheKey = `${pattern}-${patternColor}-${patternSize}-${
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
302
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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] -
|
|
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
|
|
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([
|
|
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
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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')
|
|
581
|
+
if (legendValues[0] === '#000000') {
|
|
582
|
+
context.restore()
|
|
583
|
+
return
|
|
584
|
+
}
|
|
470
585
|
context.globalAlpha = 1
|
|
471
|
-
|
|
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
|
|
478
|
-
const stateName = supportedStatesFipsCodes[
|
|
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 (
|
|
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] -
|
|
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] -
|
|
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
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
658
|
-
|
|
771
|
+
// Pre-compute Path2D objects with the current projection
|
|
772
|
+
buildPathCache()
|
|
659
773
|
|
|
660
|
-
//
|
|
661
|
-
|
|
774
|
+
// Render the map
|
|
775
|
+
renderFrame()
|
|
776
|
+
}
|
|
777
|
+
}
|
|
662
778
|
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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
|
-
|
|
673
|
-
|
|
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
|
-
|
|
676
|
-
|
|
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
|
-
|
|
680
|
-
if (
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
-
|
|
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
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
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
|
-
|
|
781
|
-
|
|
782
|
-
|
|
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
|
}
|