@cdc/map 2.6.0 → 2.6.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/convert-topojson.js +70 -0
- package/dist/cdcmap.js +190 -0
- package/examples/default-county.json +105 -0
- package/examples/default-single-state.json +109 -0
- package/examples/default-usa.json +968 -0
- package/examples/default-world.json +1495 -0
- package/examples/example-city-state.json +474 -0
- package/examples/example-world-map.json +1596 -0
- package/examples/gender-rate-map.json +1 -0
- package/package.json +50 -50
- package/src/CdcMap.js +1384 -0
- package/src/components/CityList.js +93 -0
- package/src/components/CountyMap.js +556 -0
- package/src/components/DataTable.js +357 -0
- package/src/components/EditorPanel.js +2111 -0
- package/src/components/Geo.js +21 -0
- package/src/components/Modal.js +31 -0
- package/src/components/NavigationMenu.js +66 -0
- package/src/components/Sidebar.js +167 -0
- package/src/components/SingleStateMap.js +326 -0
- package/src/components/UsaMap.js +342 -0
- package/src/components/WorldMap.js +175 -0
- package/src/components/ZoomableGroup.js +47 -0
- package/src/data/abbreviations.js +57 -0
- package/src/data/color-palettes.js +200 -0
- package/src/data/county-map-halfquality.json +58453 -0
- package/src/data/county-map-quarterquality.json +1 -0
- package/src/data/county-topo.json +1 -0
- package/src/data/dfc-map.json +1 -0
- package/src/data/initial-state.js +60 -0
- package/src/data/newtest.json +1 -0
- package/src/data/state-abbreviations.js +60 -0
- package/src/data/supported-geos.js +3775 -0
- package/src/data/test.json +1 -0
- package/src/data/us-hex-topo.json +1 -0
- package/src/data/us-topo.json +1 -0
- package/src/data/world-topo.json +1 -0
- package/src/hooks/useActiveElement.js +19 -0
- package/src/hooks/useZoomPan.js +110 -0
- package/src/images/active-checkmark.svg +1 -0
- package/src/images/asc.svg +1 -0
- package/src/images/close.svg +1 -0
- package/src/images/desc.svg +1 -0
- package/src/images/external-link.svg +1 -0
- package/src/images/icon-download-img.svg +1 -0
- package/src/images/icon-download-pdf.svg +1 -0
- package/src/images/inactive-checkmark.svg +1 -0
- package/src/images/map-folded.svg +1 -0
- package/src/index.html +29 -0
- package/src/index.js +20 -0
- package/src/scss/btn.scss +69 -0
- package/src/scss/datatable.scss +7 -0
- package/src/scss/editor-panel.scss +654 -0
- package/src/scss/main.scss +224 -0
- package/src/scss/map.scss +188 -0
- package/src/scss/sidebar.scss +146 -0
- package/src/scss/tooltips.scss +30 -0
- package/src/scss/variables.scss +1 -0
- package/uploads/upload-example-city-state.json +392 -0
- package/uploads/upload-example-world-data.json +1490 -0
- package/LICENSE +0 -201
package/src/CdcMap.js
ADDED
|
@@ -0,0 +1,1384 @@
|
|
|
1
|
+
import React, { useState, useEffect, useRef, memo, useCallback } from 'react';
|
|
2
|
+
|
|
3
|
+
// IE11
|
|
4
|
+
import 'core-js/stable'
|
|
5
|
+
import 'whatwg-fetch'
|
|
6
|
+
import ResizeObserver from 'resize-observer-polyfill';
|
|
7
|
+
|
|
8
|
+
// Third party
|
|
9
|
+
import ReactTooltip from 'react-tooltip';
|
|
10
|
+
import chroma from 'chroma-js';
|
|
11
|
+
import Papa from 'papaparse';
|
|
12
|
+
import parse from 'html-react-parser';
|
|
13
|
+
import html2pdf from 'html2pdf.js'
|
|
14
|
+
import html2canvas from 'html2canvas';
|
|
15
|
+
import Canvg from 'canvg';
|
|
16
|
+
|
|
17
|
+
// Data
|
|
18
|
+
import ExternalIcon from './images/external-link.svg';
|
|
19
|
+
import { supportedStates, supportedTerritories, supportedCountries, supportedCounties, supportedCities, supportedStatesFipsCodes } from './data/supported-geos';
|
|
20
|
+
import colorPalettes from './data/color-palettes';
|
|
21
|
+
import initialState from './data/initial-state';
|
|
22
|
+
|
|
23
|
+
// Sass
|
|
24
|
+
import './scss/main.scss';
|
|
25
|
+
import './scss/btn.scss'
|
|
26
|
+
|
|
27
|
+
// Images
|
|
28
|
+
import DownloadImg from './images/icon-download-img.svg'
|
|
29
|
+
import DownloadPdf from './images/icon-download-pdf.svg'
|
|
30
|
+
|
|
31
|
+
// Core
|
|
32
|
+
import Loading from '@cdc/core/components/Loading';
|
|
33
|
+
import DataTransform from '@cdc/core/components/DataTransform';
|
|
34
|
+
import getViewport from '@cdc/core/helpers/getViewport';
|
|
35
|
+
import numberFromString from '@cdc/core/helpers/numberFromString'
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
// Child Components
|
|
39
|
+
import Sidebar from './components/Sidebar';
|
|
40
|
+
import Modal from './components/Modal';
|
|
41
|
+
import EditorPanel from './components/EditorPanel'; // Future: Lazy
|
|
42
|
+
import UsaMap from './components/UsaMap'; // Future: Lazy
|
|
43
|
+
import CountyMap from './components/CountyMap'; // Future: Lazy
|
|
44
|
+
import DataTable from './components/DataTable'; // Future: Lazy
|
|
45
|
+
import NavigationMenu from './components/NavigationMenu'; // Future: Lazy
|
|
46
|
+
import WorldMap from './components/WorldMap'; // Future: Lazy
|
|
47
|
+
import SingleStateMap from './components/SingleStateMap'; // Future: Lazy
|
|
48
|
+
|
|
49
|
+
// Data props
|
|
50
|
+
const stateKeys = Object.keys(supportedStates)
|
|
51
|
+
const territoryKeys = Object.keys(supportedTerritories)
|
|
52
|
+
const countryKeys = Object.keys(supportedCountries)
|
|
53
|
+
const countyKeys = Object.keys(supportedCounties)
|
|
54
|
+
const cityKeys = Object.keys(supportedCities)
|
|
55
|
+
|
|
56
|
+
const generateColorsArray = (color = '#000000', special = false) => {
|
|
57
|
+
let colorObj = chroma(color)
|
|
58
|
+
|
|
59
|
+
let hoverColor = special ? colorObj.brighten(0.5).hex() : colorObj.saturate(1.3).hex()
|
|
60
|
+
|
|
61
|
+
return [
|
|
62
|
+
color,
|
|
63
|
+
hoverColor,
|
|
64
|
+
colorObj.darken(0.3).hex()
|
|
65
|
+
]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const hashObj = (row) => {
|
|
69
|
+
let str = JSON.stringify(row)
|
|
70
|
+
|
|
71
|
+
let hash = 0;
|
|
72
|
+
if (str.length === 0) return hash;
|
|
73
|
+
for (let i = 0; i < str.length; i++) {
|
|
74
|
+
let char = str.charCodeAt(i);
|
|
75
|
+
hash = ((hash<<5)-hash) + char;
|
|
76
|
+
hash = hash & hash;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return hash;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// returns string[]
|
|
83
|
+
const getUniqueValues = (data, columnName) => {
|
|
84
|
+
let result = {};
|
|
85
|
+
|
|
86
|
+
for(let i = 0; i < data.length; i++) {
|
|
87
|
+
let val = data[i][columnName]
|
|
88
|
+
|
|
89
|
+
if(undefined === val) continue
|
|
90
|
+
|
|
91
|
+
if(undefined === result[val]) {
|
|
92
|
+
result[val] = true
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return Object.keys(result)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const CdcMap = ({className, config, navigationHandler: customNavigationHandler, isDashboard = false, isEditor = false, configUrl, logo = null, setConfig, hostname}) => {
|
|
100
|
+
|
|
101
|
+
const [showLoadingMessage, setShowLoadingMessage] = useState(false)
|
|
102
|
+
const transform = new DataTransform()
|
|
103
|
+
const [state, setState] = useState( {...initialState} )
|
|
104
|
+
const [loading, setLoading] = useState(true)
|
|
105
|
+
const [currentViewport, setCurrentViewport] = useState()
|
|
106
|
+
const [runtimeFilters, setRuntimeFilters] = useState([])
|
|
107
|
+
const [runtimeLegend, setRuntimeLegend] = useState([])
|
|
108
|
+
const [runtimeData, setRuntimeData] = useState({init: true})
|
|
109
|
+
const [modal, setModal] = useState(null)
|
|
110
|
+
const [accessibleStatus, setAccessibleStatus] = useState('')
|
|
111
|
+
let legendMemo = useRef(new Map())
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
const resizeObserver = new ResizeObserver(entries => {
|
|
116
|
+
for (let entry of entries) {
|
|
117
|
+
let newViewport = getViewport(entry.contentRect.width)
|
|
118
|
+
|
|
119
|
+
setCurrentViewport(newViewport)
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// *******START SCREEN READER DEBUG*******
|
|
124
|
+
// const focusedElement = useActiveElement();
|
|
125
|
+
|
|
126
|
+
// useEffect(() => {
|
|
127
|
+
// if (focusedElement) {
|
|
128
|
+
// focusedElement.value && console.log(focusedElement.value);
|
|
129
|
+
// }
|
|
130
|
+
// console.log(focusedElement);
|
|
131
|
+
// }, [focusedElement])
|
|
132
|
+
// *******END SCREEN READER DEBUG*******
|
|
133
|
+
|
|
134
|
+
// Tag each row with a UID. Helps with filtering/placing geos. Not enumerable so doesn't show up in loops/console logs except when directly addressed ex row.uid
|
|
135
|
+
// We are mutating state in place here (depending on where called) - but it's okay, this isn't used for rerender
|
|
136
|
+
const addUIDs = useCallback((obj, fromColumn) => {
|
|
137
|
+
|
|
138
|
+
obj.data.forEach(row => {
|
|
139
|
+
let uid = null
|
|
140
|
+
|
|
141
|
+
if(row.uid) row.uid = null // Wipe existing UIDs
|
|
142
|
+
|
|
143
|
+
// United States check
|
|
144
|
+
if("us" === obj.general.geoType) {
|
|
145
|
+
const geoName = row[obj.columns.geo.name] ? row[obj.columns.geo.name].toUpperCase() : '';
|
|
146
|
+
|
|
147
|
+
// States
|
|
148
|
+
uid = stateKeys.find( (key) => supportedStates[key].includes(geoName) )
|
|
149
|
+
|
|
150
|
+
// Territories
|
|
151
|
+
if(!uid) {
|
|
152
|
+
uid = territoryKeys.find( (key) => supportedTerritories[key].includes(geoName) )
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Cities
|
|
156
|
+
if(!uid) {
|
|
157
|
+
uid = cityKeys.find( (key) => key === geoName)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// World Check
|
|
162
|
+
if("world" === obj.general.geoType) {
|
|
163
|
+
const geoName = row[obj.columns.geo.name]
|
|
164
|
+
|
|
165
|
+
uid = countryKeys.find( (key) => supportedCountries[key].includes(geoName) )
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// County Check
|
|
169
|
+
if("us-county" === obj.general.geoType || "single-state" === obj.general.geoType) {
|
|
170
|
+
const fips = row[obj.columns.geo.name]
|
|
171
|
+
uid = countyKeys.find( (key) => key === fips )
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// TODO: Points
|
|
175
|
+
if(uid) {
|
|
176
|
+
Object.defineProperty(row, 'uid', {
|
|
177
|
+
value: uid,
|
|
178
|
+
writable: true
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
obj.data.fromColumn = fromColumn
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
const generateRuntimeLegend = useCallback((obj, runtimeData, hash) => {
|
|
187
|
+
|
|
188
|
+
const newLegendMemo = new Map(); // Reset memoization
|
|
189
|
+
|
|
190
|
+
const
|
|
191
|
+
primaryCol = obj.columns.primary.name,
|
|
192
|
+
type = obj.legend.type,
|
|
193
|
+
number = obj.legend.numberOfItems,
|
|
194
|
+
result = [];
|
|
195
|
+
|
|
196
|
+
// Add a hash for what we're working from if passed
|
|
197
|
+
if(hash) {
|
|
198
|
+
result.fromHash = hash
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Unified will based the legend off ALL of the data maps received. Otherwise, it will use
|
|
202
|
+
let dataSet = obj.legend.unified ? obj.data : Object.values(runtimeData);
|
|
203
|
+
|
|
204
|
+
const colorDistributions = {
|
|
205
|
+
1: [ 1 ],
|
|
206
|
+
2: [ 1, 3 ],
|
|
207
|
+
3: [ 1, 3, 5 ],
|
|
208
|
+
4: [ 0, 2, 4, 6 ],
|
|
209
|
+
5: [ 0, 2, 4, 6, 7 ],
|
|
210
|
+
6: [ 0, 2, 3, 4, 5, 7 ],
|
|
211
|
+
7: [ 0, 2, 3, 4, 5, 6, 7 ],
|
|
212
|
+
8: [ 0, 2, 3, 4, 5, 6, 7, 8 ],
|
|
213
|
+
9: [ 0, 1, 2, 3, 4, 5, 6, 7, 8 ]
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const applyColorToLegend = (legendIdx) => {
|
|
217
|
+
// Default to "bluegreen" color scheme if the passed color isn't valid
|
|
218
|
+
let mapColorPalette = obj.customColors || colorPalettes[obj.color] || colorPalettes['bluegreen']
|
|
219
|
+
|
|
220
|
+
let colorIdx = legendIdx - specialClasses
|
|
221
|
+
|
|
222
|
+
// Special Classes (No Data)
|
|
223
|
+
if (result[legendIdx].special) {
|
|
224
|
+
const specialClassColors = chroma.scale(['#D4D4D4', '#939393']).colors(specialClasses)
|
|
225
|
+
|
|
226
|
+
return specialClassColors[ legendIdx ]
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if ( obj.color.includes( 'qualitative' ) ) return mapColorPalette[colorIdx]
|
|
230
|
+
|
|
231
|
+
let amt = Math.max( result.length - specialClasses, 1 )
|
|
232
|
+
let distributionArray = colorDistributions[ amt ]
|
|
233
|
+
|
|
234
|
+
const specificColor = distributionArray[ colorIdx ]
|
|
235
|
+
|
|
236
|
+
return mapColorPalette[specificColor]
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let specialClasses = 0
|
|
240
|
+
let specialClassesHash = {}
|
|
241
|
+
|
|
242
|
+
// Special classes
|
|
243
|
+
if (obj.legend.specialClasses.length) {
|
|
244
|
+
if(typeof obj.legend.specialClasses[0] === 'object'){
|
|
245
|
+
obj.legend.specialClasses.forEach(specialClass => {
|
|
246
|
+
dataSet = dataSet.filter(row => {
|
|
247
|
+
const val = String(row[specialClass.key]);
|
|
248
|
+
|
|
249
|
+
if(specialClass.value === val){
|
|
250
|
+
if(undefined === specialClassesHash[val]) {
|
|
251
|
+
specialClassesHash[val] = true;
|
|
252
|
+
|
|
253
|
+
result.push({
|
|
254
|
+
special: true,
|
|
255
|
+
value: val,
|
|
256
|
+
label: specialClass.label
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
result[result.length - 1].color = applyColorToLegend(result.length - 1);
|
|
260
|
+
|
|
261
|
+
specialClasses += 1;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
let specialColor = '';
|
|
265
|
+
|
|
266
|
+
// color the state if val is in row
|
|
267
|
+
specialColor = result.findIndex(p => p.value === val)
|
|
268
|
+
|
|
269
|
+
newLegendMemo.set( hashObj(row), specialColor)
|
|
270
|
+
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return true;
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
} else {
|
|
278
|
+
dataSet = dataSet.filter(row => {
|
|
279
|
+
const val = row[primaryCol]
|
|
280
|
+
|
|
281
|
+
if( obj.legend.specialClasses.includes(val) ) {
|
|
282
|
+
|
|
283
|
+
// apply the special color to the legend
|
|
284
|
+
if(undefined === specialClassesHash[val]) {
|
|
285
|
+
specialClassesHash[val] = true;
|
|
286
|
+
|
|
287
|
+
result.push({
|
|
288
|
+
special: true,
|
|
289
|
+
value: val
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
result[result.length - 1].color = applyColorToLegend(result.length - 1);
|
|
293
|
+
|
|
294
|
+
specialClasses += 1;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
let specialColor = '';
|
|
298
|
+
|
|
299
|
+
// color the state if val is in row
|
|
300
|
+
if ( Object.values(row).includes(val) ) {
|
|
301
|
+
specialColor = result.findIndex(p => p.value === val)
|
|
302
|
+
}
|
|
303
|
+
newLegendMemo.set( hashObj(row), specialColor)
|
|
304
|
+
|
|
305
|
+
return false
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return true
|
|
309
|
+
})
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Category
|
|
314
|
+
if('category' === type) {
|
|
315
|
+
let uniqueValues = new Map()
|
|
316
|
+
let count = 0
|
|
317
|
+
|
|
318
|
+
for(let i = 0; i < dataSet.length; i++) {
|
|
319
|
+
let row = dataSet[i]
|
|
320
|
+
let value = row[primaryCol]
|
|
321
|
+
|
|
322
|
+
if(undefined === value) continue
|
|
323
|
+
|
|
324
|
+
if(false === uniqueValues.has(value)) {
|
|
325
|
+
uniqueValues.set(value, [hashObj(row)]);
|
|
326
|
+
count++
|
|
327
|
+
} else {
|
|
328
|
+
uniqueValues.get(value).push(hashObj(row))
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if(count === 9) break // Can only have 9 categorical items for now
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
let sorted = [...uniqueValues.keys()]
|
|
335
|
+
|
|
336
|
+
// Apply custom sorting or regular sorting
|
|
337
|
+
let configuredOrder = obj.legend.categoryValuesOrder ?? []
|
|
338
|
+
|
|
339
|
+
// Coerce strings to numbers inside configuredOrder property
|
|
340
|
+
for(let i = 0; i < configuredOrder.length; i++) {
|
|
341
|
+
configuredOrder[i] = numberFromString(configuredOrder[i])
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if(configuredOrder.length) {
|
|
345
|
+
sorted.sort( (a, b) => {
|
|
346
|
+
return configuredOrder.indexOf(a) - configuredOrder.indexOf(b);
|
|
347
|
+
})
|
|
348
|
+
} else {
|
|
349
|
+
sorted.sort((a, b) => a - b)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Add legend item for each
|
|
353
|
+
sorted.forEach((val) => {
|
|
354
|
+
result.push({
|
|
355
|
+
value: val,
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
let lastIdx = result.length - 1
|
|
359
|
+
let arr = uniqueValues.get(val)
|
|
360
|
+
|
|
361
|
+
if(arr) {
|
|
362
|
+
arr.forEach(hashedRow => newLegendMemo.set(hashedRow, lastIdx))
|
|
363
|
+
}
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
// Add color to new legend item
|
|
368
|
+
for(let i = 0; i < result.length; i++) {
|
|
369
|
+
result[i].color = applyColorToLegend(i)
|
|
370
|
+
}
|
|
371
|
+
legendMemo.current = newLegendMemo
|
|
372
|
+
return result
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
let uniqueValues = {};
|
|
376
|
+
dataSet.forEach(datum => {
|
|
377
|
+
uniqueValues[datum[primaryCol]] = true;
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
let legendNumber = Math.min(number, Object.keys(uniqueValues).length);
|
|
381
|
+
|
|
382
|
+
// Separate zero
|
|
383
|
+
if(true === obj.legend.separateZero) {
|
|
384
|
+
let addLegendItem = false;
|
|
385
|
+
|
|
386
|
+
for(let i = 0; i < dataSet.length; i++) {
|
|
387
|
+
if (dataSet[i][primaryCol] === 0) {
|
|
388
|
+
addLegendItem = true
|
|
389
|
+
|
|
390
|
+
let row = dataSet.splice(i, 1)[0]
|
|
391
|
+
|
|
392
|
+
newLegendMemo.set( hashObj(row), result.length)
|
|
393
|
+
i--
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if(addLegendItem) {
|
|
398
|
+
legendNumber -= 1 // This zero takes up one legend item
|
|
399
|
+
|
|
400
|
+
// Add new legend item
|
|
401
|
+
result.push({
|
|
402
|
+
min: 0,
|
|
403
|
+
max: 0
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
let lastIdx = result.length - 1
|
|
407
|
+
|
|
408
|
+
// Add color to new legend item
|
|
409
|
+
result[lastIdx].color = applyColorToLegend(lastIdx)
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Sort data for use in equalnumber or equalinterval
|
|
414
|
+
dataSet = dataSet.filter(row => typeof row[primaryCol] === 'number').sort((a, b) => {
|
|
415
|
+
let aNum = a[primaryCol]
|
|
416
|
+
let bNum = b[primaryCol]
|
|
417
|
+
|
|
418
|
+
return aNum - bNum
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
// Equal Number
|
|
422
|
+
if(type === 'equalnumber') {
|
|
423
|
+
let numberOfRows = dataSet.length
|
|
424
|
+
|
|
425
|
+
let remainder
|
|
426
|
+
let changingNumber = legendNumber
|
|
427
|
+
|
|
428
|
+
let chunkAmt
|
|
429
|
+
|
|
430
|
+
// Loop through the array until it has been split into equal subarrays
|
|
431
|
+
while ( numberOfRows > 0 ) {
|
|
432
|
+
remainder = numberOfRows % changingNumber
|
|
433
|
+
|
|
434
|
+
chunkAmt = Math.floor(numberOfRows / changingNumber)
|
|
435
|
+
|
|
436
|
+
if (remainder > 0) {
|
|
437
|
+
chunkAmt += 1
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
let removedRows = dataSet.splice(0, chunkAmt);
|
|
441
|
+
|
|
442
|
+
let min = removedRows[0][primaryCol],
|
|
443
|
+
max = removedRows[removedRows.length - 1][primaryCol]
|
|
444
|
+
|
|
445
|
+
removedRows.forEach(row => {
|
|
446
|
+
newLegendMemo.set( hashObj(row), result.length )
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
result.push({
|
|
450
|
+
min,
|
|
451
|
+
max
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
result[result.length - 1].color = applyColorToLegend(result.length - 1)
|
|
455
|
+
|
|
456
|
+
changingNumber -= 1
|
|
457
|
+
numberOfRows -= chunkAmt
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Equal Interval
|
|
462
|
+
if(type === 'equalinterval') {
|
|
463
|
+
dataSet = dataSet.filter(row => row[primaryCol] !== undefined)
|
|
464
|
+
let dataMin = dataSet[0][primaryCol]
|
|
465
|
+
let dataMax = dataSet[dataSet.length - 1][primaryCol]
|
|
466
|
+
|
|
467
|
+
let pointer = 0 // Start at beginning of dataSet
|
|
468
|
+
|
|
469
|
+
for (let i = 0; i < legendNumber; i++) {
|
|
470
|
+
let interval = Math.abs(dataMax - dataMin) / legendNumber
|
|
471
|
+
|
|
472
|
+
let min = dataMin + (interval * i)
|
|
473
|
+
let max = min + interval
|
|
474
|
+
|
|
475
|
+
// If this is the last loop, assign actual max of data as the end point
|
|
476
|
+
if (i === legendNumber - 1) max = dataMax
|
|
477
|
+
|
|
478
|
+
// Add rows in dataSet that belong to this new legend item since we've got the data sorted
|
|
479
|
+
while(pointer < dataSet.length && dataSet[pointer][primaryCol] <= max) {
|
|
480
|
+
newLegendMemo.set(hashObj(dataSet[pointer]), result.length )
|
|
481
|
+
pointer += 1
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
let range = {
|
|
485
|
+
min: Math.round(min * 100) / 100,
|
|
486
|
+
max: Math.round(max * 100) / 100,
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
result.push(range)
|
|
490
|
+
|
|
491
|
+
result[result.length - 1].color = applyColorToLegend(result.length - 1)
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
result.forEach((legendItem, idx) => {
|
|
496
|
+
legendItem.color = applyColorToLegend(idx, specialClasses, result)
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
legendMemo.current = newLegendMemo
|
|
500
|
+
return result
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
const generateRuntimeFilters = useCallback((obj, hash, runtimeFilters) => {
|
|
504
|
+
if(undefined === obj.filters || obj.filters.length === 0) return []
|
|
505
|
+
|
|
506
|
+
let filters = []
|
|
507
|
+
|
|
508
|
+
if(hash) filters.fromHash = hash
|
|
509
|
+
|
|
510
|
+
obj?.filters.forEach(({columnName, label, active, values}, idx) => {
|
|
511
|
+
if(undefined === columnName) return
|
|
512
|
+
|
|
513
|
+
let newFilter = runtimeFilters[idx]
|
|
514
|
+
|
|
515
|
+
const sortAsc = (a, b) => {
|
|
516
|
+
return a.toString().localeCompare(b.toString(), 'en', { numeric: true })
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
const sortDesc = (a, b) => {
|
|
520
|
+
return b.toString().localeCompare(a.toString(), 'en', { numeric: true })
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
values = getUniqueValues(state.data, columnName)
|
|
524
|
+
|
|
525
|
+
if(obj.filters[idx].order === 'asc') {
|
|
526
|
+
values = values.sort(sortAsc)
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if(obj.filters[idx].order === 'desc') {
|
|
530
|
+
values = values.sort(sortDesc)
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if(obj.filters[idx].order === 'cust') {
|
|
534
|
+
if(obj.filters[idx]?.values.length > 0) {
|
|
535
|
+
values = obj.filters[idx].values
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if(undefined === newFilter) {
|
|
540
|
+
newFilter = {}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
newFilter.label = label ?? ''
|
|
544
|
+
newFilter.columnName = columnName
|
|
545
|
+
newFilter.values = values
|
|
546
|
+
newFilter.active = active || values[0] // Default to first found value
|
|
547
|
+
|
|
548
|
+
filters.push(newFilter)
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
return filters
|
|
552
|
+
})
|
|
553
|
+
|
|
554
|
+
// Calculates what's going to be displayed on the map and data table at render.
|
|
555
|
+
const generateRuntimeData = useCallback((obj, filters, hash) => {
|
|
556
|
+
const result = {}
|
|
557
|
+
|
|
558
|
+
if(hash) {
|
|
559
|
+
// Adding property this way prevents it from being enumerated
|
|
560
|
+
Object.defineProperty(result, 'fromHash', {
|
|
561
|
+
value : hash
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
obj.data.forEach(row => {
|
|
567
|
+
if(undefined === row.uid) return false // No UID for this row, we can't use for mapping
|
|
568
|
+
|
|
569
|
+
// When on a single state map filter runtime data by state
|
|
570
|
+
if (
|
|
571
|
+
!(row[obj.columns.geo.name].substring(0, 2) === obj.general?.statePicked?.fipsCode) &&
|
|
572
|
+
obj.general.geoType === 'single-state'
|
|
573
|
+
) {
|
|
574
|
+
return false;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
if(row[obj.columns.primary.name]) {
|
|
579
|
+
row[obj.columns.primary.name] = numberFromString(row[obj.columns.primary.name])
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// If this is a navigation only map, skip if it doesn't have a URL
|
|
583
|
+
if("navigation" === obj.general.type ) {
|
|
584
|
+
let navigateUrl = row[obj.columns.navigate.name] || "";
|
|
585
|
+
|
|
586
|
+
if ( undefined !== navigateUrl && typeof navigateUrl === "string" ) {
|
|
587
|
+
// Strip hidden characters before we check length
|
|
588
|
+
navigateUrl = navigateUrl.replace( /(\r\n|\n|\r)/gm, '' );
|
|
589
|
+
}
|
|
590
|
+
if ( 0 === navigateUrl.length ) {
|
|
591
|
+
return false
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Filters
|
|
596
|
+
if(filters?.length) {
|
|
597
|
+
for(let i = 0; i < filters.length; i++) {
|
|
598
|
+
const {columnName, active} = filters[i]
|
|
599
|
+
|
|
600
|
+
if (String(row[columnName]) !== String(active)) return false // Bail out, not part of filter
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Don't add additional rows with same UID
|
|
605
|
+
if(undefined === result[row.uid]) {
|
|
606
|
+
result[row.uid] = row
|
|
607
|
+
}
|
|
608
|
+
})
|
|
609
|
+
|
|
610
|
+
return result
|
|
611
|
+
})
|
|
612
|
+
|
|
613
|
+
const outerContainerRef = useCallback(node => {
|
|
614
|
+
if (node !== null) {
|
|
615
|
+
resizeObserver.observe(node);
|
|
616
|
+
}
|
|
617
|
+
},[]);
|
|
618
|
+
|
|
619
|
+
const mapSvg = useRef(null);
|
|
620
|
+
|
|
621
|
+
const closeModal = ({target}) => {
|
|
622
|
+
if('string' === typeof target.className && (target.className.includes('modal-close') || target.className.includes('modal-background') ) && null !== modal) {
|
|
623
|
+
setModal(null)
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const saveImageAs = (uri, filename) => {
|
|
628
|
+
const ie = navigator.userAgent.match(/MSIE\s([\d.]+)/)
|
|
629
|
+
const ie11 = navigator.userAgent.match(/Trident\/7.0/) && navigator.userAgent.match(/rv:11/)
|
|
630
|
+
const ieEdge = navigator.userAgent.match(/Edge/g)
|
|
631
|
+
const ieVer=(ie ? ie[1] : (ie11 ? 11 : (ieEdge ? 12 : -1)));
|
|
632
|
+
|
|
633
|
+
if (ieVer>-1) {
|
|
634
|
+
const fileAsBlob = new Blob([uri], {
|
|
635
|
+
type: 'image/png'
|
|
636
|
+
});
|
|
637
|
+
window.navigator.msSaveBlob(fileAsBlob, filename);
|
|
638
|
+
} else {
|
|
639
|
+
const link = document.createElement('a')
|
|
640
|
+
if (typeof link.download === 'string') {
|
|
641
|
+
link.href = uri
|
|
642
|
+
link.download = filename
|
|
643
|
+
link.onclick = (e) => document.body.removeChild(e.target);
|
|
644
|
+
document.body.appendChild(link)
|
|
645
|
+
link.click()
|
|
646
|
+
} else {
|
|
647
|
+
window.open(uri)
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const generateMedia = (target, type) => {
|
|
653
|
+
// Convert SVG to canvas
|
|
654
|
+
const baseSvg = mapSvg.current.querySelector('.rsm-svg')
|
|
655
|
+
|
|
656
|
+
const ratio = baseSvg.getBoundingClientRect().height / baseSvg.getBoundingClientRect().width
|
|
657
|
+
const calcHeight = ratio * 1440
|
|
658
|
+
const xmlSerializer = new XMLSerializer()
|
|
659
|
+
const svgStr = xmlSerializer.serializeToString(baseSvg)
|
|
660
|
+
const options = { log: false, ignoreMouse: true }
|
|
661
|
+
const canvas = document.createElement('canvas')
|
|
662
|
+
const ctx = canvas.getContext('2d')
|
|
663
|
+
ctx.canvas.width = 1440
|
|
664
|
+
ctx.canvas.height = calcHeight
|
|
665
|
+
const canvg = Canvg.fromString(ctx, svgStr, options)
|
|
666
|
+
canvg.start()
|
|
667
|
+
|
|
668
|
+
// Generate DOM <img> from svg data
|
|
669
|
+
const generatedImage = document.createElement('img')
|
|
670
|
+
generatedImage.src = canvas.toDataURL('image/png')
|
|
671
|
+
generatedImage.style.width = '100%'
|
|
672
|
+
generatedImage.style.height = 'auto'
|
|
673
|
+
|
|
674
|
+
baseSvg.style.display = 'none' // Hide default SVG during media generation
|
|
675
|
+
baseSvg.parentNode.insertBefore(generatedImage, baseSvg.nextSibling) // Insert png generated from canvas of svg
|
|
676
|
+
|
|
677
|
+
// Construct filename with timestamp
|
|
678
|
+
const date = new Date()
|
|
679
|
+
const filename = state.general.title.replace(/\s+/g, '-').toLowerCase() + '-' + date.getDate() + date.getMonth() + date.getFullYear()
|
|
680
|
+
|
|
681
|
+
switch (type) {
|
|
682
|
+
case 'image':
|
|
683
|
+
return html2canvas(target, {
|
|
684
|
+
allowTaint: true,
|
|
685
|
+
backgroundColor: '#ffffff',
|
|
686
|
+
width: 1440,
|
|
687
|
+
windowWidth: 1440,
|
|
688
|
+
scale: 1,
|
|
689
|
+
logging: false
|
|
690
|
+
}).then(canvas => {
|
|
691
|
+
saveImageAs(canvas.toDataURL(), filename + '.png')
|
|
692
|
+
}).then(() => {
|
|
693
|
+
generatedImage.remove() // Remove generated png
|
|
694
|
+
baseSvg.style.display = null // Re-display initial svg map
|
|
695
|
+
})
|
|
696
|
+
case 'pdf':
|
|
697
|
+
let opt = {
|
|
698
|
+
margin: 0.2,
|
|
699
|
+
filename: filename + '.pdf',
|
|
700
|
+
image: { type: 'png' },
|
|
701
|
+
html2canvas: { scale: 2, logging: false },
|
|
702
|
+
jsPDF: { unit: 'in', format: 'letter', orientation: 'portrait' }
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
html2pdf().set(opt).from(target).save().then(() => {
|
|
706
|
+
generatedImage.remove() // Remove generated png
|
|
707
|
+
baseSvg.style.display = null // Re-display initial svg map
|
|
708
|
+
})
|
|
709
|
+
break
|
|
710
|
+
default:
|
|
711
|
+
console.warn('generateMedia param 2 type must be \'image\' or \'pdf\'')
|
|
712
|
+
break
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const changeFilterActive = async (idx, activeValue) => {
|
|
717
|
+
// Reset active legend toggles
|
|
718
|
+
resetLegendToggles()
|
|
719
|
+
|
|
720
|
+
try {
|
|
721
|
+
|
|
722
|
+
const isEmpty = (obj) => {
|
|
723
|
+
return Object.keys(obj).length === 0;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
let filters = [...runtimeFilters]
|
|
727
|
+
|
|
728
|
+
filters[idx] = { ...filters[idx] }
|
|
729
|
+
|
|
730
|
+
filters[idx].active = activeValue
|
|
731
|
+
const newData = generateRuntimeData(state, filters)
|
|
732
|
+
|
|
733
|
+
// throw an error if newData is empty
|
|
734
|
+
if (isEmpty(newData)) throw new Error('Cove Filter Error: No runtime data to set for this filter')
|
|
735
|
+
|
|
736
|
+
// set the runtime filters and data
|
|
737
|
+
setRuntimeData(newData)
|
|
738
|
+
setRuntimeFilters(filters)
|
|
739
|
+
} catch(e) {
|
|
740
|
+
console.error(e.message)
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const displayDataAsText = (value, columnName) => {
|
|
746
|
+
if(value === null) {
|
|
747
|
+
return ""
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
let formattedValue = value
|
|
751
|
+
|
|
752
|
+
let columnObj = state.columns[columnName]
|
|
753
|
+
|
|
754
|
+
if (columnObj) {
|
|
755
|
+
// If value is a number, apply specific formattings
|
|
756
|
+
if (Number(value)) {
|
|
757
|
+
// Rounding
|
|
758
|
+
if(columnObj.hasOwnProperty('roundToPlace') && columnObj.roundToPlace !== "None") {
|
|
759
|
+
|
|
760
|
+
const decimalPoint = columnObj.roundToPlace
|
|
761
|
+
|
|
762
|
+
formattedValue = Number(value).toFixed(decimalPoint)
|
|
763
|
+
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
if(columnObj.hasOwnProperty('useCommas') && columnObj.useCommas === true) {
|
|
767
|
+
|
|
768
|
+
formattedValue = Number(value).toLocaleString('en-US', { style: 'decimal'})
|
|
769
|
+
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// Check if it's a special value. If it is not, apply the designated prefix and suffix
|
|
774
|
+
if (false === state.legend.specialClasses.includes(String(value))) {
|
|
775
|
+
formattedValue = columnObj.prefix + formattedValue + columnObj.suffix
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
return formattedValue
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const applyLegendToRow = (rowObj) => {
|
|
783
|
+
// Navigation map
|
|
784
|
+
if("navigation" === state.general.type) {
|
|
785
|
+
let mapColorPalette = colorPalettes[ state.color ] || colorPalettes[ 'bluegreenreverse' ]
|
|
786
|
+
|
|
787
|
+
return generateColorsArray( mapColorPalette[ 3 ] )
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
let hash = hashObj(rowObj)
|
|
791
|
+
|
|
792
|
+
if( legendMemo.current.has(hash) ) {
|
|
793
|
+
let idx = legendMemo.current.get(hash)
|
|
794
|
+
|
|
795
|
+
if(runtimeLegend[idx]?.disabled) return false
|
|
796
|
+
|
|
797
|
+
return generateColorsArray(runtimeLegend[idx]?.color, runtimeLegend[idx]?.special)
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// Fail state
|
|
801
|
+
return generateColorsArray()
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const applyTooltipsToGeo = (geoName, row, returnType = 'string') => {
|
|
805
|
+
let toolTipText = '';
|
|
806
|
+
let stateOrCounty =
|
|
807
|
+
state.general.geoType === 'us' ? 'State: ' :
|
|
808
|
+
(state.general.geoType === 'us-county' || state.general.geoType === 'single-state') ? 'County: ':
|
|
809
|
+
'';
|
|
810
|
+
if (state.general.geoType === 'us-county') {
|
|
811
|
+
let stateFipsCode = row[state.columns.geo.name].substring(0,2)
|
|
812
|
+
const stateName = supportedStatesFipsCodes[stateFipsCode];
|
|
813
|
+
|
|
814
|
+
//supportedStatesFipsCodes[]
|
|
815
|
+
toolTipText += `<strong>State: ${stateName}</strong><br/>`;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
toolTipText += `<strong>${stateOrCounty}${displayGeoName(geoName)}</strong>`
|
|
819
|
+
|
|
820
|
+
if('data' === state.general.type && undefined !== row) {
|
|
821
|
+
toolTipText += `<dl>`
|
|
822
|
+
|
|
823
|
+
Object.keys(state.columns).forEach((columnKey) => {
|
|
824
|
+
const column = state.columns[columnKey]
|
|
825
|
+
|
|
826
|
+
if (true === column.tooltip) {
|
|
827
|
+
|
|
828
|
+
let label = column.label.length > 0 ? column.label : '';
|
|
829
|
+
|
|
830
|
+
let value;
|
|
831
|
+
|
|
832
|
+
if(state.legend.specialClasses && state.legend.specialClasses.length && typeof state.legend.specialClasses[0] === 'object'){
|
|
833
|
+
for(let i = 0; i < state.legend.specialClasses.length; i++){
|
|
834
|
+
if(String(row[state.legend.specialClasses[i].key]) === state.legend.specialClasses[i].value){
|
|
835
|
+
value = displayDataAsText(state.legend.specialClasses[i].label, columnKey);
|
|
836
|
+
break;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
if(!value){
|
|
842
|
+
value = displayDataAsText(row[column.name], columnKey);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
if(0 < value.length) { // Only spit out the tooltip if there's a value there
|
|
846
|
+
toolTipText += `<div><dt>${label}</dt><dd>${value}</dd></div>`
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
}
|
|
850
|
+
})
|
|
851
|
+
|
|
852
|
+
toolTipText += `</dl>`
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// We convert the markup into JSX and add a navigation link if it's going into a modal.
|
|
856
|
+
if('jsx' === returnType) {
|
|
857
|
+
toolTipText = [(<div key="modal-content">{parse(toolTipText)}</div>)]
|
|
858
|
+
|
|
859
|
+
if(state.columns.hasOwnProperty('navigate') && row[state.columns.navigate.name]) {
|
|
860
|
+
toolTipText.push( (<span className="navigation-link" key="modal-navigation-link" onClick={() => navigationHandler(row[state.columns.navigate.name])}>{state.tooltips.linkLabel}<ExternalIcon className="inline-icon ml-1" /></span>) )
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
return toolTipText
|
|
865
|
+
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const titleCase = (string) => {
|
|
869
|
+
return string.split(' ').map(word => word.charAt(0).toUpperCase() + word.substring(1).toLowerCase()).join(' ');
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// This resets all active legend toggles.
|
|
873
|
+
const resetLegendToggles = async () => {
|
|
874
|
+
let newLegend = [...runtimeLegend]
|
|
875
|
+
|
|
876
|
+
newLegend.forEach(legendItem => {
|
|
877
|
+
delete legendItem.disabled
|
|
878
|
+
})
|
|
879
|
+
|
|
880
|
+
setRuntimeLegend(newLegend)
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// Supports JSON or CSV
|
|
884
|
+
const fetchRemoteData = async (url) => {
|
|
885
|
+
try {
|
|
886
|
+
const urlObj = new URL(url);
|
|
887
|
+
const regex = /(?:\.([^.]+))?$/
|
|
888
|
+
|
|
889
|
+
let data = []
|
|
890
|
+
|
|
891
|
+
const ext = (regex.exec(urlObj.pathname)[1])
|
|
892
|
+
if ('csv' === ext) {
|
|
893
|
+
data = await fetch(url)
|
|
894
|
+
.then(response => response.text())
|
|
895
|
+
.then(responseText => {
|
|
896
|
+
const parsedCsv = Papa.parse(responseText, {
|
|
897
|
+
header: true,
|
|
898
|
+
dynamicTyping: true,
|
|
899
|
+
skipEmptyLines: true
|
|
900
|
+
})
|
|
901
|
+
return parsedCsv.data
|
|
902
|
+
})
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
if ('json' === ext) {
|
|
906
|
+
data = await fetch(url)
|
|
907
|
+
.then(response => response.json())
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
return data;
|
|
911
|
+
} catch {
|
|
912
|
+
// If we can't parse it, still attempt to fetch it
|
|
913
|
+
try {
|
|
914
|
+
let response = await (await fetch(configUrl)).json()
|
|
915
|
+
return response
|
|
916
|
+
} catch {
|
|
917
|
+
console.error(`Cannot parse URL: ${url}`);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Attempts to find the corresponding value
|
|
923
|
+
const displayGeoName = (key) => {
|
|
924
|
+
let value = key
|
|
925
|
+
|
|
926
|
+
// Map to first item in values array which is the preferred label
|
|
927
|
+
if(stateKeys.includes(value)) {
|
|
928
|
+
value = titleCase(supportedStates[key][0])
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
if(territoryKeys.includes(value)) {
|
|
932
|
+
value = titleCase(supportedTerritories[key][0])
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
if(countryKeys.includes(value)) {
|
|
936
|
+
value = titleCase(supportedCountries[key][0])
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
if(countyKeys.includes(value)) {
|
|
940
|
+
value = titleCase(supportedCounties[key])
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
const dict = {
|
|
944
|
+
"District of Columbia" : "Washington D.C."
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
if(true === Object.keys(dict).includes(value)) {
|
|
948
|
+
value = dict[value]
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
return titleCase(value);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const navigationHandler = (urlString) => {
|
|
955
|
+
// Call custom navigation method if passed
|
|
956
|
+
if(customNavigationHandler) {
|
|
957
|
+
customNavigationHandler(urlString);
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// Abort if value is blank
|
|
962
|
+
if(0 === urlString.length) {
|
|
963
|
+
throw Error("Blank string passed as URL. Navigation aborted.");
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
const urlObj = new URL(urlString);
|
|
967
|
+
|
|
968
|
+
// Open constructed link in new tab/window
|
|
969
|
+
window.open(urlObj.toString(), '_blank');
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
const geoClickHandler = (key, value) => {
|
|
973
|
+
// If modals are set or we are on a mobile viewport, display modal
|
|
974
|
+
if ('xs' === currentViewport || 'xxs' === currentViewport || 'click' === state.tooltips.appearanceType) {
|
|
975
|
+
setModal({
|
|
976
|
+
geoName: key,
|
|
977
|
+
keyedData: value
|
|
978
|
+
})
|
|
979
|
+
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// Otherwise if this item has a link specified for it, do regular navigation.
|
|
984
|
+
if (state.columns.navigate && value[state.columns.navigate.name]) {
|
|
985
|
+
navigationHandler(value[state.columns.navigate.name])
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
const validateFipsCodeLength = (newState) => {
|
|
990
|
+
if(newState.general.geoType === 'us-county' || newState.general.geoType === 'single-state' || newState.general.geoType === 'us' && newState?.data) {
|
|
991
|
+
|
|
992
|
+
newState?.data.forEach(dataPiece => {
|
|
993
|
+
if(dataPiece[newState.columns.geo.name]) {
|
|
994
|
+
if(!isNaN(parseInt(dataPiece[newState.columns.geo.name])) && dataPiece[newState.columns.geo.name].length === 4) {
|
|
995
|
+
dataPiece[newState.columns.geo.name] = 0 + dataPiece[newState.columns.geo.name]
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
})
|
|
999
|
+
}
|
|
1000
|
+
return newState;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
const loadConfig = async (configObj) => {
|
|
1004
|
+
// Set loading flag
|
|
1005
|
+
if(!loading) setLoading(true)
|
|
1006
|
+
|
|
1007
|
+
// Create new config object the same way each time no matter when this method is called.
|
|
1008
|
+
let newState = {
|
|
1009
|
+
...initialState,
|
|
1010
|
+
...configObj
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// If a dataUrl property exists, always pull from that.
|
|
1014
|
+
if (newState.dataUrl) {
|
|
1015
|
+
if(newState.dataUrl[0] === '/') {
|
|
1016
|
+
newState.dataUrl = 'https://' + hostname + newState.dataUrl
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
let newData = await fetchRemoteData(newState.dataUrl)
|
|
1020
|
+
|
|
1021
|
+
if(newData && newState.dataDescription) {
|
|
1022
|
+
newData = transform.autoStandardize(newData);
|
|
1023
|
+
newData = transform.developerStandardize(newData, newState.dataDescription);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
if(newData) {
|
|
1027
|
+
newState.data = newData
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// This code goes through and adds the defaults for every property declaring in the initial state at the top.
|
|
1032
|
+
// This allows you to easily add new properties to the config without having to worry about accounting for backwards compatibility.
|
|
1033
|
+
// Right now this does not work recursively -- only on first and second level properties. So state -> prop1 -> childprop1
|
|
1034
|
+
Object.keys(newState).forEach( (key) => {
|
|
1035
|
+
if("object" === typeof newState[key] && false === Array.isArray(newState[key])) {
|
|
1036
|
+
if(initialState[key] ) {
|
|
1037
|
+
Object.keys(initialState[key]).forEach( (property) => {
|
|
1038
|
+
if(undefined === newState[key][property]) {
|
|
1039
|
+
newState[key][property] = initialState[key][property]
|
|
1040
|
+
}
|
|
1041
|
+
})
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
})
|
|
1045
|
+
|
|
1046
|
+
// If there's a name for the geo, add UIDs
|
|
1047
|
+
if(newState.columns.geo.name || newState.columns.geo.fips) {
|
|
1048
|
+
addUIDs(newState, newState.columns.geo.name || newState.columns.geo.fips)
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
if(newState.dataTable.forceDisplay === undefined){
|
|
1052
|
+
newState.dataTable.forceDisplay = !isDashboard;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
validateFipsCodeLength(newState);
|
|
1057
|
+
setState(newState)
|
|
1058
|
+
|
|
1059
|
+
// Done loading
|
|
1060
|
+
setLoading(false)
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
const init = async () => {
|
|
1064
|
+
let configData = null
|
|
1065
|
+
|
|
1066
|
+
// Load the configuration data passed to this component if it exists
|
|
1067
|
+
if(config) {
|
|
1068
|
+
configData = config
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// If the config passed is a string, try to load it as an ajax
|
|
1072
|
+
if(configUrl) {
|
|
1073
|
+
configData = await fetchRemoteData(configUrl)
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
// Once we have a config verify that it is an object and load it
|
|
1077
|
+
if('object' === typeof configData) {
|
|
1078
|
+
loadConfig(configData)
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// Initial load
|
|
1083
|
+
useEffect(() => {
|
|
1084
|
+
init()
|
|
1085
|
+
}, [])
|
|
1086
|
+
|
|
1087
|
+
useEffect(() => {
|
|
1088
|
+
if (state.data) {
|
|
1089
|
+
let newData = generateRuntimeData(state);
|
|
1090
|
+
setRuntimeData(newData);
|
|
1091
|
+
}
|
|
1092
|
+
}, [state.general.statePicked]);
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
// When geotype changes
|
|
1097
|
+
useEffect(() => {
|
|
1098
|
+
|
|
1099
|
+
// UID
|
|
1100
|
+
if(state.data && state.columns.geo.name) {
|
|
1101
|
+
addUIDs(state, state.columns.geo.name)
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
}, [state.general.geoType]);
|
|
1105
|
+
|
|
1106
|
+
useEffect(() => {
|
|
1107
|
+
|
|
1108
|
+
// UID
|
|
1109
|
+
if(state.data && state.columns.geo.name && state.columns.geo.name !== state.data.fromColumn) {
|
|
1110
|
+
addUIDs(state, state.columns.geo.name)
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// Filters
|
|
1114
|
+
const hashFilters = hashObj(state.filters)
|
|
1115
|
+
let filters;
|
|
1116
|
+
|
|
1117
|
+
if(state.filters && hashFilters !== runtimeFilters.fromHash) {
|
|
1118
|
+
filters = generateRuntimeFilters(state, hashFilters, runtimeFilters)
|
|
1119
|
+
|
|
1120
|
+
if(filters) {
|
|
1121
|
+
setRuntimeFilters(filters)
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
const hashLegend = hashObj({
|
|
1126
|
+
color: state.color,
|
|
1127
|
+
customColors: state.customColors,
|
|
1128
|
+
numberOfItems: state.legend.numberOfItems,
|
|
1129
|
+
type: state.legend.type,
|
|
1130
|
+
separateZero: state.legend.separateZero ?? false,
|
|
1131
|
+
categoryValuesOrder: state.legend.categoryValuesOrder,
|
|
1132
|
+
specialClasses: state.legend.specialClasses,
|
|
1133
|
+
geoType: state.general.geoType,
|
|
1134
|
+
data: state.data
|
|
1135
|
+
})
|
|
1136
|
+
|
|
1137
|
+
const hashData = hashObj({
|
|
1138
|
+
columns: state.columns,
|
|
1139
|
+
geoType: state.general.geoType,
|
|
1140
|
+
type: state.general.type,
|
|
1141
|
+
geo: state.columns.geo.name,
|
|
1142
|
+
primary: state.columns.primary.name,
|
|
1143
|
+
data: state.data,
|
|
1144
|
+
...runtimeFilters
|
|
1145
|
+
})
|
|
1146
|
+
|
|
1147
|
+
// Data
|
|
1148
|
+
let newRuntimeData;
|
|
1149
|
+
if(hashData !== runtimeData.fromHash && state.data?.fromColumn) {
|
|
1150
|
+
newRuntimeData = generateRuntimeData(state, filters || runtimeFilters, hashData)
|
|
1151
|
+
setRuntimeData(newRuntimeData)
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// Legend
|
|
1155
|
+
if (hashLegend !== runtimeLegend.fromHash && (undefined === runtimeData.init || newRuntimeData)) {
|
|
1156
|
+
const legend = generateRuntimeLegend(state, newRuntimeData || runtimeData, hashLegend)
|
|
1157
|
+
setRuntimeLegend(legend)
|
|
1158
|
+
}
|
|
1159
|
+
}, [state])
|
|
1160
|
+
|
|
1161
|
+
useEffect(() => {
|
|
1162
|
+
const hashLegend = hashObj({
|
|
1163
|
+
color: state.color,
|
|
1164
|
+
customColors: state.customColors,
|
|
1165
|
+
numberOfItems: state.legend.numberOfItems,
|
|
1166
|
+
type: state.legend.type,
|
|
1167
|
+
separateZero: state.legend.separateZero ?? false,
|
|
1168
|
+
categoryValuesOrder: state.legend.categoryValuesOrder,
|
|
1169
|
+
specialClasses: state.legend.specialClasses,
|
|
1170
|
+
geoType: state.general.geoType,
|
|
1171
|
+
data: state.data
|
|
1172
|
+
})
|
|
1173
|
+
|
|
1174
|
+
// Legend - Update when runtimeData does
|
|
1175
|
+
if(hashLegend !== runtimeLegend.fromHash && undefined === runtimeData.init) {
|
|
1176
|
+
const legend = generateRuntimeLegend(state, runtimeData)
|
|
1177
|
+
setRuntimeLegend(legend)
|
|
1178
|
+
}
|
|
1179
|
+
}, [runtimeData])
|
|
1180
|
+
|
|
1181
|
+
if(config) {
|
|
1182
|
+
useEffect(() => {
|
|
1183
|
+
loadConfig(config)
|
|
1184
|
+
}, [config.data])
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
// Destructuring for more readable JSX
|
|
1188
|
+
const { general, tooltips, dataTable } = state
|
|
1189
|
+
const { title = '', subtext = ''} = general
|
|
1190
|
+
|
|
1191
|
+
// Outer container classes
|
|
1192
|
+
let outerContainerClasses = [
|
|
1193
|
+
'cdc-open-viz-module',
|
|
1194
|
+
'cdc-map-outer-container',
|
|
1195
|
+
currentViewport
|
|
1196
|
+
]
|
|
1197
|
+
|
|
1198
|
+
if(className) {
|
|
1199
|
+
outerContainerClasses.push(className)
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// Map container classes
|
|
1203
|
+
let mapContainerClasses = [
|
|
1204
|
+
'map-container',
|
|
1205
|
+
state.legend.position,
|
|
1206
|
+
state.general.type,
|
|
1207
|
+
state.general.geoType
|
|
1208
|
+
]
|
|
1209
|
+
|
|
1210
|
+
if(modal) {
|
|
1211
|
+
mapContainerClasses.push('modal-background')
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
if(general.type === 'navigation' && true === general.fullBorder) {
|
|
1215
|
+
mapContainerClasses.push('full-border')
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
// Props passed to all map types
|
|
1219
|
+
const mapProps = {
|
|
1220
|
+
state,
|
|
1221
|
+
data: runtimeData,
|
|
1222
|
+
rebuildTooltips : ReactTooltip.rebuild,
|
|
1223
|
+
applyTooltipsToGeo,
|
|
1224
|
+
closeModal,
|
|
1225
|
+
navigationHandler,
|
|
1226
|
+
geoClickHandler,
|
|
1227
|
+
applyLegendToRow,
|
|
1228
|
+
displayGeoName,
|
|
1229
|
+
runtimeLegend,
|
|
1230
|
+
generateColorsArray,
|
|
1231
|
+
titleCase
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
if (!mapProps.data || !state.data) return <Loading />;
|
|
1235
|
+
|
|
1236
|
+
const handleMapTabbing = general.showSidebar ? `#legend` : state.general.title ? `#dataTableSection__${state.general.title.replace(/\s/g, '')}` : `#dataTableSection`
|
|
1237
|
+
|
|
1238
|
+
|
|
1239
|
+
return (
|
|
1240
|
+
<div className={outerContainerClasses.join(' ')} ref={outerContainerRef}>
|
|
1241
|
+
{isEditor && (
|
|
1242
|
+
<EditorPanel
|
|
1243
|
+
isDashboard={isDashboard}
|
|
1244
|
+
state={state}
|
|
1245
|
+
setState={setState}
|
|
1246
|
+
loadConfig={loadConfig}
|
|
1247
|
+
setParentConfig={setConfig}
|
|
1248
|
+
setRuntimeFilters={setRuntimeFilters}
|
|
1249
|
+
runtimeFilters={runtimeFilters}
|
|
1250
|
+
runtimeLegend={runtimeLegend}
|
|
1251
|
+
columnsInData={Object.keys(state.data[0])}
|
|
1252
|
+
/>
|
|
1253
|
+
)}
|
|
1254
|
+
{!runtimeData.init && (general.type === 'navigation' || runtimeLegend.length !== 0) && <section className={`cdc-map-inner-container ${currentViewport}`} aria-label={'Map: ' + title}>
|
|
1255
|
+
{['lg', 'md'].includes(currentViewport) && 'hover' === tooltips.appearanceType && (
|
|
1256
|
+
<ReactTooltip
|
|
1257
|
+
id='tooltip'
|
|
1258
|
+
place='right'
|
|
1259
|
+
type='light'
|
|
1260
|
+
html={true}
|
|
1261
|
+
className={tooltips.capitalizeLabels ? 'capitalize tooltip' : 'tooltip'}
|
|
1262
|
+
/>
|
|
1263
|
+
)}
|
|
1264
|
+
<header className={general.showTitle === true ? '' : 'hidden'} aria-hidden='true'>
|
|
1265
|
+
<div role='heading' className={'map-title ' + general.headerColor} tabIndex="0">
|
|
1266
|
+
{parse(title)}
|
|
1267
|
+
</div>
|
|
1268
|
+
</header>
|
|
1269
|
+
<section className={mapContainerClasses.join(' ')} onClick={(e) => closeModal(e)}>
|
|
1270
|
+
{general.showDownloadMediaButton === true && (
|
|
1271
|
+
<div className='map-downloads' data-html2canvas-ignore>
|
|
1272
|
+
<div className='map-downloads__ui btn-group'>
|
|
1273
|
+
<button
|
|
1274
|
+
className='btn'
|
|
1275
|
+
title='Download Map as Image'
|
|
1276
|
+
onClick={() => generateMedia(outerContainerRef.current, 'image')}
|
|
1277
|
+
>
|
|
1278
|
+
<DownloadImg className='btn__icon' title='Download Map as Image' />
|
|
1279
|
+
</button>
|
|
1280
|
+
<button
|
|
1281
|
+
className='btn'
|
|
1282
|
+
title='Download Map as PDF'
|
|
1283
|
+
onClick={() => generateMedia(outerContainerRef.current, 'pdf')}
|
|
1284
|
+
>
|
|
1285
|
+
<DownloadPdf className='btn__icon' title='Download Map as PDF' />
|
|
1286
|
+
</button>
|
|
1287
|
+
</div>
|
|
1288
|
+
</div>
|
|
1289
|
+
)}
|
|
1290
|
+
|
|
1291
|
+
<a id='skip-geo-container' className='cdcdataviz-sr-only-focusable' href={handleMapTabbing}>
|
|
1292
|
+
Skip Over Map Container
|
|
1293
|
+
</a>
|
|
1294
|
+
<section className='geography-container' aria-hidden='true' ref={mapSvg}>
|
|
1295
|
+
{currentViewport && (
|
|
1296
|
+
<section className='geography-container' aria-hidden='true' ref={mapSvg}>
|
|
1297
|
+
{modal && (
|
|
1298
|
+
<Modal
|
|
1299
|
+
type={general.type}
|
|
1300
|
+
viewport={currentViewport}
|
|
1301
|
+
applyTooltipsToGeo={applyTooltipsToGeo}
|
|
1302
|
+
applyLegendToRow={applyLegendToRow}
|
|
1303
|
+
capitalize={state.tooltips.capitalizeLabels}
|
|
1304
|
+
content={modal}
|
|
1305
|
+
/>
|
|
1306
|
+
)}
|
|
1307
|
+
{'single-state' === general.geoType && (
|
|
1308
|
+
<SingleStateMap supportedTerritories={supportedTerritories} {...mapProps} />
|
|
1309
|
+
)}
|
|
1310
|
+
{'us' === general.geoType && (
|
|
1311
|
+
<UsaMap supportedTerritories={supportedTerritories} {...mapProps} />
|
|
1312
|
+
)}
|
|
1313
|
+
{'world' === general.geoType && (
|
|
1314
|
+
<WorldMap supportedCountries={supportedCountries} {...mapProps} />
|
|
1315
|
+
)}
|
|
1316
|
+
{'us-county' === general.geoType && (
|
|
1317
|
+
<CountyMap
|
|
1318
|
+
supportedCountries={supportedCountries}
|
|
1319
|
+
{...mapProps}
|
|
1320
|
+
/>
|
|
1321
|
+
)}
|
|
1322
|
+
{'data' === general.type && logo && <img src={logo} alt='' className='map-logo' />}
|
|
1323
|
+
</section>
|
|
1324
|
+
|
|
1325
|
+
)}
|
|
1326
|
+
</section>
|
|
1327
|
+
|
|
1328
|
+
{general.showSidebar && 'navigation' !== general.type && (
|
|
1329
|
+
<Sidebar
|
|
1330
|
+
viewport={currentViewport}
|
|
1331
|
+
legend={state.legend}
|
|
1332
|
+
runtimeLegend={runtimeLegend}
|
|
1333
|
+
setRuntimeLegend={setRuntimeLegend}
|
|
1334
|
+
runtimeFilters={runtimeFilters}
|
|
1335
|
+
columns={state.columns}
|
|
1336
|
+
sharing={state.sharing}
|
|
1337
|
+
prefix={state.columns.primary.prefix}
|
|
1338
|
+
suffix={state.columns.primary.suffix}
|
|
1339
|
+
setState={setState}
|
|
1340
|
+
resetLegendToggles={resetLegendToggles}
|
|
1341
|
+
changeFilterActive={changeFilterActive}
|
|
1342
|
+
setAccessibleStatus={setAccessibleStatus}
|
|
1343
|
+
/>
|
|
1344
|
+
)}
|
|
1345
|
+
</section>
|
|
1346
|
+
{'navigation' === general.type && (
|
|
1347
|
+
<NavigationMenu
|
|
1348
|
+
displayGeoName={displayGeoName}
|
|
1349
|
+
data={runtimeData}
|
|
1350
|
+
options={general}
|
|
1351
|
+
columns={state.columns}
|
|
1352
|
+
navigationHandler={(val) => navigationHandler(val)}
|
|
1353
|
+
/>
|
|
1354
|
+
)}
|
|
1355
|
+
{true === dataTable.forceDisplay && general.type !== 'navigation' && false === loading && (
|
|
1356
|
+
<DataTable
|
|
1357
|
+
state={state}
|
|
1358
|
+
rawData={state.data}
|
|
1359
|
+
navigationHandler={navigationHandler}
|
|
1360
|
+
expandDataTable={general.expandDataTable}
|
|
1361
|
+
headerColor={general.headerColor}
|
|
1362
|
+
columns={state.columns}
|
|
1363
|
+
showDownloadButton={general.showDownloadButton}
|
|
1364
|
+
runtimeLegend={runtimeLegend}
|
|
1365
|
+
runtimeData={runtimeData}
|
|
1366
|
+
displayDataAsText={displayDataAsText}
|
|
1367
|
+
displayGeoName={displayGeoName}
|
|
1368
|
+
applyLegendToRow={applyLegendToRow}
|
|
1369
|
+
tableTitle={dataTable.title}
|
|
1370
|
+
indexTitle={dataTable.indexTitle}
|
|
1371
|
+
mapTitle={general.title}
|
|
1372
|
+
viewport={currentViewport}
|
|
1373
|
+
/>
|
|
1374
|
+
)}
|
|
1375
|
+
{subtext.length > 0 && <p className='subtext'>{parse(subtext)}</p>}
|
|
1376
|
+
</section>}
|
|
1377
|
+
<div aria-live='assertive' className='cdcdataviz-sr-only'>
|
|
1378
|
+
{accessibleStatus}
|
|
1379
|
+
</div>
|
|
1380
|
+
</div>
|
|
1381
|
+
);
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
export default memo(CdcMap)
|