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