@cdc/map 4.23.2 → 4.23.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/dist/cdcmap.js +21645 -21544
- package/examples/default-county.json +1 -1
- package/examples/default-geocode.json +744 -744
- package/examples/default-hex.json +3 -5
- package/examples/example-city-state-no-territories.json +703 -0
- package/examples/example-city-state.json +4 -7
- package/examples/example-city-stateBAD.json +744 -0
- package/examples/gallery/city-state.json +478 -478
- package/examples/world-geocode-data.json +18 -0
- package/examples/world-geocode.json +108 -0
- package/index.html +34 -29
- package/package.json +6 -3
- package/src/CdcMap.jsx +127 -64
- package/src/components/CityList.jsx +35 -35
- package/src/components/CountyMap.jsx +285 -447
- package/src/components/DataTable.jsx +7 -31
- package/src/components/EditorPanel.jsx +227 -102
- package/src/components/Sidebar.jsx +2 -0
- package/src/components/UsaMap.jsx +24 -19
- package/src/components/WorldMap.jsx +40 -8
- package/src/data/feature-test.json +73 -0
- package/src/data/initial-state.js +3 -0
- package/src/data/supported-geos.js +7 -7
- package/src/scss/map.scss +12 -0
- package/src/test/CdcMap.test.jsx +19 -0
- package/vite.config.js +3 -3
- package/src/hooks/useColorPalette.ts +0 -88
|
@@ -1,70 +1,67 @@
|
|
|
1
|
-
import React, {
|
|
2
|
-
import Loading from '@cdc/core/components/Loading'
|
|
1
|
+
import React, { useEffect, useState, memo, useRef } from 'react'
|
|
3
2
|
|
|
4
|
-
import {
|
|
5
|
-
import
|
|
6
|
-
import { geoCentroid, geoPath } from 'd3-geo'
|
|
7
|
-
import { feature, mesh } from 'topojson-client'
|
|
8
|
-
import { CustomProjection } from '@visx/geo'
|
|
9
|
-
import colorPalettes from '../../../core/data/colorPalettes'
|
|
3
|
+
import { geoCentroid, geoPath, geoContains } from 'd3-geo'
|
|
4
|
+
import { feature } from 'topojson-client'
|
|
10
5
|
import { geoAlbersUsaTerritories } from 'd3-composite-projections'
|
|
11
|
-
import
|
|
12
|
-
|
|
13
|
-
import
|
|
14
|
-
|
|
15
|
-
// Label lines for smaller states/geo labels
|
|
16
|
-
const offsets = {
|
|
17
|
-
Vermont: [ 50, -8 ],
|
|
18
|
-
'New Hampshire': [ 34, 5 ],
|
|
19
|
-
Massachusetts: [ 30, -5 ],
|
|
20
|
-
'Rhode Island': [ 28, 4 ],
|
|
21
|
-
Connecticut: [ 35, 16 ],
|
|
22
|
-
'New Jersey': [ 42, 0 ],
|
|
23
|
-
Delaware: [ 33, 0 ],
|
|
24
|
-
Maryland: [ 47, 10 ],
|
|
25
|
-
'District of Columbia': [ 30, 20 ],
|
|
26
|
-
'Puerto Rico': [ 10, -20 ],
|
|
27
|
-
'Virgin Islands': [ 10, -10 ],
|
|
28
|
-
Guam: [ 10, -5 ],
|
|
29
|
-
'American Samoa': [ 10, 0 ]
|
|
30
|
-
}
|
|
6
|
+
import debounce from 'lodash.debounce'
|
|
7
|
+
|
|
8
|
+
import Loading from '@cdc/core/components/Loading'
|
|
9
|
+
import ErrorBoundary from '@cdc/core/components/ErrorBoundary'
|
|
31
10
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
11
|
+
import topoJSON from '../data/county-map.json'
|
|
12
|
+
import { formatPrefix } from 'd3'
|
|
13
|
+
|
|
14
|
+
const sortById = (a, b) => {
|
|
15
|
+
if (a.id < b.id) return -1
|
|
16
|
+
if (b.id < a.id) return 1
|
|
17
|
+
return 0
|
|
18
|
+
}
|
|
36
19
|
|
|
37
20
|
// DATA
|
|
38
|
-
|
|
39
|
-
|
|
21
|
+
const { features: counties } = feature(topoJSON, topoJSON.objects.counties)
|
|
22
|
+
const { features: states } = feature(topoJSON, topoJSON.objects.states)
|
|
23
|
+
|
|
24
|
+
// Sort states/counties data to ensure that countyIndecies logic below works
|
|
25
|
+
states.sort(sortById)
|
|
26
|
+
counties.sort(sortById)
|
|
27
|
+
const mapData = states.concat(counties).filter(geo => geo.id !== '51620') //Not sure why, but Franklin City, VA is very broken and messes up the rendering
|
|
28
|
+
|
|
29
|
+
// For each state, find the beginning index and end index of the counties in that state in the mapData array
|
|
30
|
+
// For use in hover logic (only check counties for hover in the state that is being hovered, instead of checking all counties)
|
|
31
|
+
const countyIndecies = {}
|
|
32
|
+
states.forEach(state => {
|
|
33
|
+
let minIndex = mapData.length - 1
|
|
34
|
+
let maxIndex = 0
|
|
35
|
+
|
|
36
|
+
for (let i = 0; i < mapData.length; i++) {
|
|
37
|
+
if (mapData[i].id.length > 2 && mapData[i].id.indexOf(state.id) === 0) {
|
|
38
|
+
if (i < minIndex) minIndex = i
|
|
39
|
+
if (i > maxIndex) maxIndex = i
|
|
40
|
+
}
|
|
41
|
+
}
|
|
40
42
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const STATE_INACTIVE_FILL = '#F4F7FA'
|
|
43
|
+
countyIndecies[state.id] = [minIndex, maxIndex]
|
|
44
|
+
})
|
|
44
45
|
|
|
45
46
|
// CREATE STATE LINES
|
|
46
|
-
const projection = geoAlbersUsaTerritories()
|
|
47
|
-
const path = geoPath().projection(projection)
|
|
48
|
-
const stateLines = path(mesh(testJSON, testJSON.objects.states))
|
|
49
|
-
const countyLines = path(mesh(testJSON, testJSON.objects.counties))
|
|
47
|
+
const projection = geoAlbersUsaTerritories()
|
|
50
48
|
|
|
49
|
+
// Ensures county map is only rerendered when it needs to (when one of the variables below is updated)
|
|
51
50
|
function CountyMapChecks(prevState, nextState) {
|
|
52
51
|
const equalNumberOptIn = prevState.state.general.equalNumberOptIn && nextState.state.general.equalNumberOptIn
|
|
53
52
|
const equalColumnName = prevState.state.general.type && nextState.state.general.type
|
|
54
53
|
const equalNavColumn = prevState.state.columns.navigate && nextState.state.columns.navigate
|
|
55
54
|
const equalLegend = prevState.runtimeLegend === nextState.runtimeLegend
|
|
56
55
|
const equalBorderColors = prevState.state.general.geoBorderColor === nextState.state.general.geoBorderColor // update when geoborder color changes
|
|
57
|
-
const equalMapColors = prevState.state.color === nextState.state.color // update when map colors change
|
|
58
56
|
const equalData = prevState.data === nextState.data // update when data changes
|
|
59
|
-
|
|
57
|
+
const equalTooltipBehavior = prevState.state.tooltips.appearanceType === nextState.state.tooltips.appearanceType
|
|
58
|
+
return equalData && equalBorderColors && equalLegend && equalColumnName && equalNavColumn && equalNumberOptIn && equalTooltipBehavior ? true : false
|
|
60
59
|
}
|
|
61
60
|
|
|
62
61
|
const CountyMap = props => {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const { state, applyTooltipsToGeo, data, geoClickHandler, applyLegendToRow, displayGeoName, containerEl, handleMapAriaLabels, titleCase, setSharedFilterValue, isFilterValueSupported } = props
|
|
62
|
+
const { state, runtimeLegend, applyTooltipsToGeo, data, geoClickHandler, applyLegendToRow, displayGeoName, containerEl, handleMapAriaLabels } = props
|
|
66
63
|
|
|
67
|
-
|
|
64
|
+
const [focus, setFocus] = useState({})
|
|
68
65
|
|
|
69
66
|
useEffect(() => {
|
|
70
67
|
if (containerEl) {
|
|
@@ -74,448 +71,289 @@ const CountyMap = props => {
|
|
|
74
71
|
}
|
|
75
72
|
})
|
|
76
73
|
|
|
77
|
-
// Use State
|
|
78
|
-
const [ scale, setScale ] = useState(0.85)
|
|
79
|
-
const [ startingLineWidth, setStartingLineWidth ] = useState(1.3)
|
|
80
|
-
const [ translate, setTranslate ] = useState([ 0, 0 ])
|
|
81
|
-
const [ mapColorPalette, setMapColorPalette ] = useState(colorPalettes[state.color] || '#fff')
|
|
82
|
-
const [ focusedState, setFocusedState ] = useState(null)
|
|
83
|
-
const [ showLabel, setShowLabels ] = useState(true)
|
|
84
|
-
|
|
85
74
|
const resetButton = useRef()
|
|
86
|
-
const
|
|
87
|
-
const
|
|
88
|
-
const mapGroup = useRef()
|
|
89
|
-
|
|
90
|
-
let focusedBorderColor = mapColorPalette[3]
|
|
91
|
-
let geoStrokeColor = state.general.geoBorderColor === 'darkGray' ? 'rgba(0, 0, 0, 0.2)' : 'rgba(255,255,255,0.7)'
|
|
92
|
-
|
|
93
|
-
const geoLabel = (geo, projection) => {
|
|
94
|
-
let [ x, y ] = projection(geoCentroid(geo))
|
|
95
|
-
let abbr = abbrs[geo.properties.name]
|
|
96
|
-
if (abbr === 'NJ') x += 3
|
|
97
|
-
if (undefined === abbr) return null
|
|
98
|
-
let [ dx, dy ] = offsets[geo.properties.name]
|
|
99
|
-
|
|
100
|
-
return (
|
|
101
|
-
<>
|
|
102
|
-
<line className="abbrLine" x1={x} y1={y} x2={x + dx} y2={y + dy} stroke="black" strokeWidth={0.85}/>
|
|
103
|
-
<text className="abbrText" x={4} strokeWidth="0" fontSize={13} style={{ fill: '#202020' }} alignmentBaseline="middle" transform={`translate(${x + dx}, ${y + dy})`}>
|
|
104
|
-
{abbr}
|
|
105
|
-
</text>
|
|
106
|
-
</>
|
|
107
|
-
)
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
const focusGeo = (geoKey, geo) => {
|
|
111
|
-
if (!geoKey) {
|
|
112
|
-
console.log('County Map: no geoKey provided to focusGeo')
|
|
113
|
-
return
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// 1) Get the state the county is in.
|
|
117
|
-
let myState = states.find(s => s.id === geoKey)
|
|
118
|
-
|
|
119
|
-
// 2) Set projections translation & scale to the geographic center of the passed geo.
|
|
120
|
-
const projection = geoAlbersUsaTerritories().translate([ WIDTH / 2, HEIGHT / 2 ])
|
|
121
|
-
const newProjection = projection.fitExtent(
|
|
122
|
-
[
|
|
123
|
-
[ PADDING, PADDING ],
|
|
124
|
-
[ WIDTH - PADDING, HEIGHT - PADDING ]
|
|
125
|
-
],
|
|
126
|
-
myState
|
|
127
|
-
)
|
|
128
|
-
|
|
129
|
-
// 3) Gets the new scale
|
|
130
|
-
const newScale = newProjection.scale()
|
|
131
|
-
const hypot = Math.hypot(880, 500)
|
|
132
|
-
const newScaleWithHypot = newScale / 1070
|
|
133
|
-
|
|
134
|
-
// 4) Pull the x & y out, divide by half the viewport for some reason
|
|
135
|
-
let [ x, y ] = newProjection.translate()
|
|
136
|
-
x = x - WIDTH / 2
|
|
137
|
-
y = y - HEIGHT / 2
|
|
138
|
-
|
|
139
|
-
// 5) Debug if needed
|
|
140
|
-
const debug = {
|
|
141
|
-
width: WIDTH,
|
|
142
|
-
height: HEIGHT,
|
|
143
|
-
beginX: 0,
|
|
144
|
-
beginY: 0,
|
|
145
|
-
hypot: hypot,
|
|
146
|
-
x: x,
|
|
147
|
-
y: y,
|
|
148
|
-
newScale: newScale,
|
|
149
|
-
newScaleWithHypot: newScaleWithHypot,
|
|
150
|
-
geoKey: geoKey,
|
|
151
|
-
geo: geo
|
|
152
|
-
}
|
|
153
|
-
//console.table(debug)
|
|
154
|
-
|
|
155
|
-
mapGroup.current.setAttribute('transform', `translate(${[ x, y ]}) scale(${newScaleWithHypot})`)
|
|
156
|
-
resetButton.current.style.display = 'block'
|
|
75
|
+
const canvasRef = useRef()
|
|
76
|
+
const tooltipRef = useRef()
|
|
157
77
|
|
|
158
|
-
|
|
159
|
-
let allStates = document.querySelectorAll('.state path')
|
|
160
|
-
let allCounties = document.querySelectorAll('.county path')
|
|
161
|
-
let currentState = document.querySelector(`.state--${myState.id}`)
|
|
162
|
-
let otherStates = document.querySelectorAll(`.state:not(.state--${myState.id})`)
|
|
163
|
-
let svgContainer = document.querySelector('.svg-container')
|
|
164
|
-
svgContainer.setAttribute('data-scaleZoom', newScaleWithHypot)
|
|
78
|
+
const runtimeKeys = Object.keys(data)
|
|
165
79
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
})
|
|
80
|
+
const geoStrokeColor = state.general.geoBorderColor === 'darkGray' ? 'rgb(90, 90, 90)' : 'rgb(255, 255, 255)'
|
|
81
|
+
const lineWidth = 0.3
|
|
169
82
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
currentState.style.display = 'none'
|
|
173
|
-
|
|
174
|
-
allStates.forEach(state => (state.style.strokeWidth = 0.75 / newScaleWithHypot))
|
|
175
|
-
allCounties.forEach(county => (county.style.strokeWidth = 0.75 / newScaleWithHypot))
|
|
176
|
-
otherStates.forEach(el => (el.style.display = 'block'))
|
|
177
|
-
|
|
178
|
-
// State Line Updates
|
|
179
|
-
stateLinesPath.current.setAttribute('stroke-width', 0.75 / newScaleWithHypot)
|
|
180
|
-
stateLinesPath.current.setAttribute('stroke', geoStrokeColor)
|
|
181
|
-
|
|
182
|
-
// Set Focus Border
|
|
183
|
-
focusedBorderPath.current.style.display = 'block'
|
|
184
|
-
focusedBorderPath.current.setAttribute('d', focusedStateLine)
|
|
185
|
-
//focusedBorderPath.current.setAttribute('stroke-width', 0.75 / newScaleWithHypot);
|
|
186
|
-
//focusedBorderPath.current.setAttribute('stroke', focusedBorderColor)
|
|
83
|
+
const onReset = () => {
|
|
84
|
+
setFocus({})
|
|
187
85
|
}
|
|
188
86
|
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
otherStates.forEach(el => (el.style.display = 'none'))
|
|
204
|
-
allCounties.forEach(el => (el.style.strokeWidth = 0.85))
|
|
205
|
-
allStates.forEach(state => state.setAttribute('stroke-width', 0.75 / 0.85))
|
|
206
|
-
|
|
207
|
-
mapGroup.current.setAttribute('transform', `translate(${[ 0, 0 ]}) scale(${0.85})`)
|
|
208
|
-
|
|
209
|
-
// reset button
|
|
210
|
-
resetButton.current.style.display = 'none'
|
|
211
|
-
} else {
|
|
212
|
-
const svg = document.querySelector('.svg-container')
|
|
213
|
-
const allStates = document.querySelectorAll('.state')
|
|
214
|
-
document.querySelector('#focusedBorder path').style.stroke = 'none'
|
|
215
|
-
allStates.forEach(item => item.classList.remove('state--inactive'))
|
|
216
|
-
//document.querySelectorAll('.state path').forEach(item => item.style.fill = 'rgb(244, 247, 250)')
|
|
217
|
-
document.querySelectorAll('.state').forEach(item => (item.style.display = 'block'))
|
|
218
|
-
stateLinesPath.current.setAttribute('stroke', geoStrokeColor)
|
|
219
|
-
stateLinesPath.current.setAttribute('stroke-width', startingLineWidth)
|
|
220
|
-
svg.setAttribute('data-scaleZoom', 0)
|
|
221
|
-
mapGroup.current.setAttribute('transform', `translate(${[ 0, 0 ]}) scale(${0.85})`)
|
|
222
|
-
resetButton.current.style.display = 'none'
|
|
87
|
+
const canvasClick = e => {
|
|
88
|
+
const canvas = e.target
|
|
89
|
+
const canvasBounds = canvas.getBoundingClientRect()
|
|
90
|
+
const x = e.clientX - canvasBounds.left
|
|
91
|
+
const y = e.clientY - canvasBounds.top
|
|
92
|
+
const pointCoordinates = projection.invert([x, y])
|
|
93
|
+
|
|
94
|
+
// Use d3 geoContains method to find the state geo data that the user clicked inside
|
|
95
|
+
let clickedState
|
|
96
|
+
for (let i = 0; i < states.length; i++) {
|
|
97
|
+
if (geoContains(states[i], pointCoordinates)) {
|
|
98
|
+
clickedState = states[i]
|
|
99
|
+
break
|
|
100
|
+
}
|
|
223
101
|
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
function setStateLeave() {
|
|
227
|
-
focusedBorderPath.current.setAttribute('d', '')
|
|
228
|
-
focusedBorderPath.current.setAttribute('stroke', '')
|
|
229
|
-
focusedBorderPath.current.setAttribute('stroke-width', 0.75 / scale)
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
function setStateEnter(id) {
|
|
233
|
-
const svg = document.querySelector('.svg-container')
|
|
234
|
-
const scale = svg.getAttribute('data-scaleZoom')
|
|
235
102
|
|
|
236
|
-
|
|
237
|
-
|
|
103
|
+
// If the user clicked outside of all states, no behavior
|
|
104
|
+
if (clickedState) {
|
|
105
|
+
// If a county within the state was also clicked and has data, call parent click handler
|
|
106
|
+
if (countyIndecies[clickedState.id]) {
|
|
107
|
+
let county
|
|
108
|
+
for (let i = countyIndecies[clickedState.id][0]; i <= countyIndecies[clickedState.id][1]; i++) {
|
|
109
|
+
if (geoContains(mapData[i], pointCoordinates)) {
|
|
110
|
+
county = mapData[i]
|
|
111
|
+
break
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (county && data[county.id]) {
|
|
115
|
+
geoClickHandler(displayGeoName(county.id), data[county.id])
|
|
116
|
+
}
|
|
117
|
+
}
|
|
238
118
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
}
|
|
119
|
+
// Redraw with focus on state
|
|
120
|
+
setFocus({ id: clickedState.id, center: geoCentroid(clickedState) })
|
|
121
|
+
}
|
|
242
122
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
123
|
+
if (state.general.type === 'us-geocode') {
|
|
124
|
+
const geoRadius = (state.visual.geoCodeCircleSize || 5) * (focus.id ? 2 : 1)
|
|
125
|
+
let clickedGeo
|
|
126
|
+
for (let i = 0; i < runtimeKeys.length; i++) {
|
|
127
|
+
const pixelCoords = projection([data[runtimeKeys[i]][state.columns.longitude.name], data[runtimeKeys[i]][state.columns.latitude.name]])
|
|
128
|
+
if (pixelCoords && Math.sqrt(Math.pow(pixelCoords[0] - x, 2) + Math.pow(pixelCoords[1] - y, 2)) < geoRadius) {
|
|
129
|
+
clickedGeo = data[runtimeKeys[i]]
|
|
130
|
+
break
|
|
131
|
+
}
|
|
132
|
+
}
|
|
247
133
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
134
|
+
if (clickedGeo) {
|
|
135
|
+
geoClickHandler(displayGeoName(clickedGeo[state.columns.geo.name]), clickedGeo)
|
|
136
|
+
}
|
|
251
137
|
}
|
|
252
138
|
}
|
|
253
139
|
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
<g className="stateLines" key="state-line">
|
|
257
|
-
<path id="stateLinesPath" ref={stateLinesPath} d={stateLines} strokeWidth={lineWidth} stroke={geoStrokeColor} fill="none" fillOpacity="1"/>
|
|
258
|
-
</g>
|
|
259
|
-
)
|
|
260
|
-
})
|
|
140
|
+
const canvasHover = e => {
|
|
141
|
+
if (!tooltipRef.current || state.tooltips.appearanceType !== 'hover' || window.matchMedia('(any-hover: none)').matches) return
|
|
261
142
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
143
|
+
const canvas = e.target
|
|
144
|
+
const canvasBounds = canvas.getBoundingClientRect()
|
|
145
|
+
const x = e.clientX - canvasBounds.left
|
|
146
|
+
const y = e.clientY - canvasBounds.top
|
|
147
|
+
let pointCoordinates = projection.invert([x, y])
|
|
148
|
+
|
|
149
|
+
const currentTooltipIndex = parseInt(tooltipRef.current.getAttribute('data-index'))
|
|
150
|
+
const geoRadius = (state.visual.geoCodeCircleSize || 5) * (focus.id ? 2 : 1)
|
|
269
151
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
152
|
+
// Handle standard county map hover
|
|
153
|
+
if (state.general.type !== 'us-geocode') {
|
|
154
|
+
//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
|
|
155
|
+
if (isNaN(currentTooltipIndex) || !geoContains(mapData[currentTooltipIndex], pointCoordinates)) {
|
|
156
|
+
const context = canvas.getContext('2d')
|
|
157
|
+
const path = geoPath(projection, context)
|
|
158
|
+
if (!isNaN(currentTooltipIndex)) {
|
|
159
|
+
context.fillStyle = applyLegendToRow(data[mapData[currentTooltipIndex].id])[0]
|
|
160
|
+
context.strokeStyle = geoStrokeColor
|
|
161
|
+
context.lineWidth = lineWidth
|
|
162
|
+
context.beginPath()
|
|
163
|
+
path(mapData[currentTooltipIndex])
|
|
164
|
+
context.fill()
|
|
165
|
+
context.stroke()
|
|
281
166
|
}
|
|
282
167
|
|
|
283
|
-
|
|
284
|
-
let
|
|
168
|
+
let hoveredState
|
|
169
|
+
let county
|
|
170
|
+
let countyIndex
|
|
171
|
+
// First find the state that is hovered
|
|
172
|
+
for (let i = 0; i < states.length; i++) {
|
|
173
|
+
if (geoContains(states[i], pointCoordinates)) {
|
|
174
|
+
hoveredState = states[i].id
|
|
175
|
+
break
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// If a state is hovered and it is not an unfocused state, search only the counties within that state for the county hovered
|
|
179
|
+
if (hoveredState && (!focus.id || focus.id === hoveredState) && countyIndecies[hoveredState]) {
|
|
180
|
+
for (let i = countyIndecies[hoveredState][0]; i <= countyIndecies[hoveredState][1]; i++) {
|
|
181
|
+
if (geoContains(mapData[i], pointCoordinates)) {
|
|
182
|
+
county = mapData[i]
|
|
183
|
+
countyIndex = i
|
|
184
|
+
break
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
285
188
|
|
|
286
|
-
|
|
189
|
+
// If the hovered county is found, show the tooltip for that county, otherwise hide the tooltip
|
|
190
|
+
if (county && data[county.id]) {
|
|
191
|
+
context.fillStyle = applyLegendToRow(data[county.id])[1]
|
|
192
|
+
context.strokeStyle = geoStrokeColor
|
|
193
|
+
context.lineWidth = lineWidth
|
|
194
|
+
context.beginPath()
|
|
195
|
+
path(mapData[countyIndex])
|
|
196
|
+
context.fill()
|
|
197
|
+
context.stroke()
|
|
198
|
+
|
|
199
|
+
tooltipRef.current.style.display = 'block'
|
|
200
|
+
tooltipRef.current.style.top = e.clientY + 'px'
|
|
201
|
+
tooltipRef.current.style.left = e.clientX + 'px'
|
|
202
|
+
tooltipRef.current.innerHTML = applyTooltipsToGeo(displayGeoName(county.id), data[county.id])
|
|
203
|
+
tooltipRef.current.setAttribute('data-index', countyIndex)
|
|
204
|
+
} else {
|
|
205
|
+
tooltipRef.current.style.display = 'none'
|
|
206
|
+
tooltipRef.current.setAttribute('data-index', null)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
} else {
|
|
210
|
+
// Handle geo map hover
|
|
211
|
+
if (!isNaN(currentTooltipIndex)) {
|
|
212
|
+
const pixelCoords = projection([data[runtimeKeys[currentTooltipIndex]][state.columns.longitude.name], data[runtimeKeys[currentTooltipIndex]][state.columns.latitude.name]])
|
|
213
|
+
if (pixelCoords && Math.sqrt(Math.pow(pixelCoords[0] - x, 2) + Math.pow(pixelCoords[1] - y, 2)) < geoRadius) {
|
|
214
|
+
// Who knew pythagorean theorum was useful
|
|
215
|
+
return // The user is still hovering over the previous geo point, don't redraw tooltip
|
|
216
|
+
}
|
|
217
|
+
}
|
|
287
218
|
|
|
288
|
-
|
|
219
|
+
let hoveredGeo
|
|
220
|
+
let hoveredGeoIndex
|
|
221
|
+
for (let i = 0; i < runtimeKeys.length; i++) {
|
|
222
|
+
const pixelCoords = projection([data[runtimeKeys[i]][state.columns.longitude.name], data[runtimeKeys[i]][state.columns.latitude.name]])
|
|
223
|
+
if (pixelCoords && Math.sqrt(Math.pow(pixelCoords[0] - x, 2) + Math.pow(pixelCoords[1] - y, 2)) < geoRadius) {
|
|
224
|
+
hoveredGeo = data[runtimeKeys[i]]
|
|
225
|
+
hoveredGeoIndex = i
|
|
226
|
+
break
|
|
227
|
+
}
|
|
228
|
+
}
|
|
289
229
|
|
|
290
|
-
|
|
230
|
+
if (hoveredGeo) {
|
|
231
|
+
tooltipRef.current.style.display = 'block'
|
|
232
|
+
tooltipRef.current.style.top = e.clientY + 'px'
|
|
233
|
+
tooltipRef.current.style.left = e.clientX + 'px'
|
|
234
|
+
tooltipRef.current.innerHTML = applyTooltipsToGeo(displayGeoName(hoveredGeo[state.columns.geo.name]), hoveredGeo)
|
|
235
|
+
tooltipRef.current.setAttribute('data-index', hoveredGeoIndex)
|
|
236
|
+
} else {
|
|
237
|
+
tooltipRef.current.style.display = 'none'
|
|
238
|
+
tooltipRef.current.setAttribute('data-index', null)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
291
242
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
243
|
+
// Redraws canvas. Takes as parameters the fips id of a state to center on and the [lat,long] center of that state
|
|
244
|
+
const drawCanvas = () => {
|
|
245
|
+
if (canvasRef.current && runtimeLegend.length > 0) {
|
|
246
|
+
const canvas = canvasRef.current
|
|
247
|
+
const context = canvas.getContext('2d')
|
|
248
|
+
const path = geoPath(projection, context)
|
|
296
249
|
|
|
297
|
-
|
|
250
|
+
canvas.width = canvas.clientWidth
|
|
251
|
+
canvas.height = canvas.width * 0.6
|
|
298
252
|
|
|
299
|
-
|
|
300
|
-
if (geoDisplayName === 'Franklin City' || geoDisplayName === 'Waynesboro') return null
|
|
253
|
+
projection.scale(canvas.width * 1.25).translate([canvas.width / 2, canvas.height / 2])
|
|
301
254
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
255
|
+
// If we are rendering the map without a zoom on a state, hide the reset button
|
|
256
|
+
if (!focus.id) {
|
|
257
|
+
if (resetButton.current) resetButton.current.style.display = 'none'
|
|
258
|
+
} else {
|
|
259
|
+
if (resetButton.current) resetButton.current.style.display = 'block'
|
|
260
|
+
}
|
|
305
261
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
'&:active': {
|
|
313
|
-
fill: legendColors[2]
|
|
314
|
-
}
|
|
315
|
-
}
|
|
262
|
+
// Centers the projection on the paramter passed
|
|
263
|
+
if (focus.center) {
|
|
264
|
+
projection.scale(canvas.width * 2.5)
|
|
265
|
+
let offset = projection(focus.center)
|
|
266
|
+
projection.translate([-offset[0] + canvas.width, -offset[1] + canvas.height])
|
|
267
|
+
}
|
|
316
268
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
geoClickHandler(geoDisplayName, geoData)
|
|
341
|
-
focusGeo(stateFipsCode, geo)
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
data-tooltip-id="tooltip"
|
|
345
|
-
data-tooltip-html={toolTip}
|
|
346
|
-
>
|
|
347
|
-
<path tabIndex={-1} className={`county county--${geoDisplayName}`} stroke={geoStrokeColor} d={path} strokeWidth=".5"/>
|
|
348
|
-
</g>
|
|
349
|
-
)
|
|
269
|
+
// Erases previous renderings before redrawing map
|
|
270
|
+
context.clearRect(0, 0, canvas.width, canvas.height)
|
|
271
|
+
|
|
272
|
+
// Enforces stroke style of the county lines
|
|
273
|
+
context.strokeStyle = geoStrokeColor
|
|
274
|
+
context.lineWidth = lineWidth
|
|
275
|
+
|
|
276
|
+
let focusIndex = -1
|
|
277
|
+
// Iterates through each state/county topo and renders it
|
|
278
|
+
mapData.forEach((geo, i) => {
|
|
279
|
+
// If invalid geo item, don't render
|
|
280
|
+
if (!geo.id) return
|
|
281
|
+
// If the map is focused on one state, don't render counties that are not in that state
|
|
282
|
+
if (focus.id && geo.id.length > 2 && geo.id.indexOf(focus.id) !== 0) return
|
|
283
|
+
// If rendering a geocode map without a focus, don't render counties
|
|
284
|
+
if (!focus.id && state.general.type === 'us-geocode' && geo.id.length > 2) return
|
|
285
|
+
|
|
286
|
+
// Gets numeric data associated with the topo data for this state/county
|
|
287
|
+
const geoData = data[geo.id]
|
|
288
|
+
|
|
289
|
+
// Marks that the focused state was found for the logic below
|
|
290
|
+
if (geo.id === focus.id) {
|
|
291
|
+
focusIndex = i
|
|
350
292
|
}
|
|
351
293
|
|
|
352
|
-
//
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
onMouseEnter={() => {
|
|
360
|
-
setStateEnter(geo.id)
|
|
361
|
-
}}
|
|
362
|
-
onMouseLeave={() => {
|
|
363
|
-
setStateLeave()
|
|
364
|
-
}}
|
|
365
|
-
onClick={
|
|
366
|
-
// default
|
|
367
|
-
e => {
|
|
368
|
-
e.stopPropagation()
|
|
369
|
-
e.nativeEvent.stopImmediatePropagation()
|
|
370
|
-
let countyFipsCode = geo.id
|
|
371
|
-
let stateFipsCode = countyFipsCode.substring(0, 2)
|
|
372
|
-
focusGeo(stateFipsCode, geo)
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
>
|
|
376
|
-
<path tabIndex={-1} className="single-geo" stroke={geoStrokeColor} d={path} strokeWidth=".85"/>
|
|
377
|
-
</g>
|
|
378
|
-
)
|
|
294
|
+
// Renders state/county
|
|
295
|
+
const legendValues = geoData !== undefined ? applyLegendToRow(geoData) : false
|
|
296
|
+
context.fillStyle = legendValues ? legendValues[0] : '#EEE'
|
|
297
|
+
context.beginPath()
|
|
298
|
+
path(geo)
|
|
299
|
+
context.fill()
|
|
300
|
+
context.stroke()
|
|
379
301
|
})
|
|
380
|
-
)
|
|
381
|
-
return output
|
|
382
|
-
})
|
|
383
|
-
|
|
384
|
-
const GeoCodeCountyLines = memo(() => {
|
|
385
|
-
return <path d={countyLines} className="county-borders" style={{ stroke: geoStrokeColor }}/>
|
|
386
|
-
})
|
|
387
|
-
|
|
388
|
-
const StateOutput = memo(({ geographies, states }) => {
|
|
389
|
-
let output = []
|
|
390
|
-
output.push(
|
|
391
|
-
states.map(({ feature: geo, path = '' }) => {
|
|
392
|
-
const key = geo.id + '-group'
|
|
393
|
-
|
|
394
|
-
// Map the name from the geo data with the appropriate key for the processed data
|
|
395
|
-
let geoKey = geo.id
|
|
396
302
|
|
|
397
|
-
|
|
303
|
+
// If the focused state is found in the geo data, render it with a thicker outline
|
|
304
|
+
if (focusIndex !== -1) {
|
|
305
|
+
context.strokeStyle = 'black'
|
|
306
|
+
context.lineWidth = 2
|
|
307
|
+
context.beginPath()
|
|
308
|
+
path(mapData[focusIndex])
|
|
309
|
+
context.stroke()
|
|
310
|
+
}
|
|
398
311
|
|
|
399
|
-
|
|
312
|
+
if (state.general.type === 'us-geocode') {
|
|
313
|
+
context.strokeStyle = 'black'
|
|
314
|
+
const geoRadius = (state.visual.geoCodeCircleSize || 5) * (focus.id ? 2 : 1)
|
|
400
315
|
|
|
401
|
-
|
|
316
|
+
runtimeKeys.forEach(key => {
|
|
317
|
+
const pixelCoords = projection([data[key][state.columns.longitude.name], data[key][state.columns.latitude.name]])
|
|
402
318
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
319
|
+
if (pixelCoords) {
|
|
320
|
+
context.fillStyle = data[key] !== undefined ? applyLegendToRow(data[key])[0] : '#EEE'
|
|
321
|
+
context.beginPath()
|
|
322
|
+
context.arc(pixelCoords[0], pixelCoords[1], geoRadius, 0, 2 * Math.PI)
|
|
323
|
+
context.fill()
|
|
324
|
+
context.stroke()
|
|
325
|
+
}
|
|
326
|
+
})
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
407
330
|
|
|
408
|
-
|
|
331
|
+
// Whenever the memo at the top is triggered and the map is called to re-render, call drawCanvas and update
|
|
332
|
+
// The resize function so it includes the latest state variables
|
|
333
|
+
useEffect(() => {
|
|
334
|
+
drawCanvas()
|
|
409
335
|
|
|
410
|
-
|
|
336
|
+
const onResize = () => {
|
|
337
|
+
if (canvasRef.current) {
|
|
338
|
+
drawCanvas()
|
|
339
|
+
}
|
|
340
|
+
}
|
|
411
341
|
|
|
412
|
-
|
|
413
|
-
stateStyles = {
|
|
414
|
-
cursor: 'default',
|
|
415
|
-
stroke: STATE_BORDER,
|
|
416
|
-
strokeWidth: 0.75 / scale,
|
|
417
|
-
display: !focusedState ? 'none' : focusedState && focusedState !== geo.id ? 'block' : 'none',
|
|
418
|
-
fill: focusedState && focusedState !== geo.id ? STATE_INACTIVE_FILL : 'none'
|
|
419
|
-
}
|
|
420
|
-
} else {
|
|
421
|
-
stateStyles = {
|
|
422
|
-
cursor: 'default',
|
|
423
|
-
stroke: STATE_BORDER,
|
|
424
|
-
strokeWidth: 0.75 / scale,
|
|
425
|
-
display: 'block',
|
|
426
|
-
fill: '#f4f7fa'
|
|
427
|
-
}
|
|
428
|
-
}
|
|
342
|
+
const debounceOnResize = debounce(onResize, 300)
|
|
429
343
|
|
|
430
|
-
|
|
431
|
-
fillOpacity: 1,
|
|
432
|
-
cursor: 'default'
|
|
433
|
-
}
|
|
344
|
+
window.addEventListener('resize', debounceOnResize)
|
|
434
345
|
|
|
435
|
-
|
|
436
|
-
focusedState === geo.id ? stateClasses.push('state--focused') : stateClasses.push('state--inactive')
|
|
437
|
-
|
|
438
|
-
return (
|
|
439
|
-
<React.Fragment key={`state--${key}`}>
|
|
440
|
-
<g key={`state--${key}`} className={stateClasses.join(' ')} style={stateStyles} tabIndex="-1">
|
|
441
|
-
<>
|
|
442
|
-
<path
|
|
443
|
-
tabIndex={-1}
|
|
444
|
-
className="state-path"
|
|
445
|
-
d={path}
|
|
446
|
-
fillOpacity={`${focusedState !== geo.id ? '1' : '0'}`}
|
|
447
|
-
fill={STATE_INACTIVE_FILL}
|
|
448
|
-
css={stateSelectedStyles}
|
|
449
|
-
onClick={e => {
|
|
450
|
-
e.stopPropagation()
|
|
451
|
-
e.nativeEvent.stopImmediatePropagation()
|
|
452
|
-
focusGeo(geo.id, geo)
|
|
453
|
-
}}
|
|
454
|
-
onMouseEnter={e => {
|
|
455
|
-
e.target.attributes.fill.value = colorPalettes[state.color][3]
|
|
456
|
-
}}
|
|
457
|
-
onMouseLeave={e => {
|
|
458
|
-
e.target.attributes.fill.value = STATE_INACTIVE_FILL
|
|
459
|
-
}}
|
|
460
|
-
/>
|
|
461
|
-
</>
|
|
462
|
-
</g>
|
|
463
|
-
<g key={`label--${key}`}>{offsets[geo.properties.name] && geoLabel(geo, geoAlbersUsaTerritories().translate([ WIDTH / 2, HEIGHT / 2 ]))}</g>
|
|
464
|
-
</React.Fragment>
|
|
465
|
-
)
|
|
466
|
-
})
|
|
467
|
-
)
|
|
468
|
-
return output
|
|
346
|
+
return () => window.removeEventListener('resize', debounceOnResize)
|
|
469
347
|
})
|
|
470
348
|
|
|
471
|
-
//
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
const counties = geographies.slice(56)
|
|
475
|
-
let geosJsx = []
|
|
476
|
-
|
|
477
|
-
'us-geocode' !== state.general.type && geosJsx.push(<CountyOutput geographies={geographies} counties={counties} key="county-key"/>)
|
|
478
|
-
'us-geocode' === state.general.type && geosJsx.push(<GeoCodeCountyLines/>)
|
|
479
|
-
|
|
480
|
-
geosJsx.push(<StateOutput geographies={geographies} states={states} key="state-key"/>)
|
|
481
|
-
geosJsx.push(<StateLines key="stateLines" lineWidth={startingLineWidth} geoStrokeColor={geoStrokeColor} stateLines={stateLines}/>)
|
|
482
|
-
geosJsx.push(<FocusedStateBorder key="focused-border-key"/>)
|
|
483
|
-
geosJsx.push(
|
|
484
|
-
<CityList
|
|
485
|
-
projection={projection}
|
|
486
|
-
key="cities"
|
|
487
|
-
data={data}
|
|
488
|
-
state={state}
|
|
489
|
-
geoClickHandler={geoClickHandler}
|
|
490
|
-
applyTooltipsToGeo={applyTooltipsToGeo}
|
|
491
|
-
displayGeoName={displayGeoName}
|
|
492
|
-
applyLegendToRow={applyLegendToRow}
|
|
493
|
-
titleCase={titleCase}
|
|
494
|
-
setSharedFilterValue={setSharedFilterValue}
|
|
495
|
-
isFilterValueSupported={isFilterValueSupported}
|
|
496
|
-
isGeoCodeMap={state.general.type === 'us-geocode'}
|
|
497
|
-
/>
|
|
498
|
-
)
|
|
499
|
-
return geosJsx
|
|
500
|
-
}
|
|
501
|
-
if (!data) <Loading/>
|
|
349
|
+
// If runtimeData is not defined, show loader
|
|
350
|
+
if (!data) <Loading />
|
|
351
|
+
|
|
502
352
|
return (
|
|
503
|
-
<ErrorBoundary component=
|
|
504
|
-
<
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
{({ features, projection }) => {
|
|
508
|
-
return (
|
|
509
|
-
<g ref={mapGroup} className="countyMapGroup" transform={`translate(${translate}) scale(${scale})`} key="countyMapGroup">
|
|
510
|
-
{constructGeoJsx(features, projection)}
|
|
511
|
-
</g>
|
|
512
|
-
)
|
|
513
|
-
}}
|
|
514
|
-
</CustomProjection>
|
|
515
|
-
</svg>
|
|
516
|
-
|
|
517
|
-
{/* TODO: Refactor to COVE button */}
|
|
518
|
-
<button className={`btn btn--reset`} onClick={onReset} ref={resetButton} style={{ display: 'none' }} tabIndex="0">
|
|
353
|
+
<ErrorBoundary component='CountyMap'>
|
|
354
|
+
<canvas ref={canvasRef} aria-label={handleMapAriaLabels(state)} onMouseMove={canvasHover} onClick={canvasClick}></canvas>
|
|
355
|
+
<div ref={tooltipRef} id='canvas-tooltip' className='tooltip'></div>
|
|
356
|
+
<button className={`btn btn--reset`} onClick={onReset} ref={resetButton} tabIndex='0'>
|
|
519
357
|
Reset Zoom
|
|
520
358
|
</button>
|
|
521
359
|
</ErrorBoundary>
|