@cdc/map 4.25.7 → 4.25.10

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.
Files changed (99) hide show
  1. package/.claude/settings.local.json +30 -0
  2. package/CLAUDE.local.md +0 -0
  3. package/dist/cdcmap.js +54785 -53159
  4. package/examples/private/c.json +290 -0
  5. package/examples/private/canvas-city-hover.json +787 -0
  6. package/examples/private/d.json +345 -0
  7. package/examples/private/filter-map.json +909 -0
  8. package/examples/private/g.json +1 -0
  9. package/examples/private/h.json +105911 -0
  10. package/examples/private/measles-data.json +378 -0
  11. package/examples/private/measles.json +211 -0
  12. package/examples/private/north-dakota.json +1132 -0
  13. package/examples/private/rsv-data.json +532 -0
  14. package/examples/private/state-with-pattern.json +883 -0
  15. package/examples/private/test.json +222 -640
  16. package/index.html +1 -1
  17. package/package.json +26 -5
  18. package/src/CdcMap.tsx +28 -8
  19. package/src/CdcMapComponent.tsx +230 -306
  20. package/src/_stories/CdcMap.Filters.stories.tsx +2 -2
  21. package/src/_stories/CdcMap.Legend.Gradient.stories.tsx +3 -3
  22. package/src/_stories/CdcMap.Legend.stories.tsx +7 -4
  23. package/src/_stories/CdcMap.Patterns.stories.tsx +2 -2
  24. package/src/_stories/CdcMap.Table.stories.tsx +2 -2
  25. package/src/_stories/CdcMap.stories.tsx +18 -11
  26. package/src/_stories/GoogleMap.stories.tsx +2 -2
  27. package/src/_stories/UsaMap.NoData.stories.tsx +2 -2
  28. package/src/_stories/_mock/equal-number.json +1109 -0
  29. package/src/_stories/_mock/multi-state.json +21389 -0
  30. package/src/_stories/_mock/us-bubble-cities.json +306 -0
  31. package/src/components/BubbleList.tsx +16 -12
  32. package/src/components/CityList.tsx +88 -110
  33. package/src/components/DataTable.tsx +44 -12
  34. package/src/components/EditorPanel/components/EditorPanel.tsx +201 -203
  35. package/src/components/EditorPanel/components/HexShapeSettings.tsx +3 -2
  36. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +7 -5
  37. package/src/components/Geo.tsx +2 -0
  38. package/src/components/Legend/components/Legend.tsx +117 -93
  39. package/src/components/Legend/components/LegendGroup/Legend.Group.tsx +10 -7
  40. package/src/components/MapContainer.tsx +52 -0
  41. package/src/components/MapControls.tsx +44 -0
  42. package/src/components/Modal.tsx +2 -8
  43. package/src/components/NavigationMenu.tsx +13 -1
  44. package/src/components/UsaMap/components/SingleState/SingleState.CountyOutput.tsx +24 -7
  45. package/src/components/UsaMap/components/SingleState/SingleState.StateOutput.tsx +21 -15
  46. package/src/components/UsaMap/components/TerritoriesSection.tsx +2 -2
  47. package/src/components/UsaMap/components/UsaMap.County.tsx +112 -33
  48. package/src/components/UsaMap/components/UsaMap.Region.tsx +23 -5
  49. package/src/components/UsaMap/components/UsaMap.SingleState.tsx +38 -26
  50. package/src/components/UsaMap/components/UsaMap.State.tsx +28 -10
  51. package/src/components/UsaMap/helpers/map.ts +16 -8
  52. package/src/components/WorldMap/WorldMap.tsx +116 -11
  53. package/src/components/ZoomControls.tsx +6 -9
  54. package/src/context/LegendMemoContext.tsx +30 -0
  55. package/src/context.ts +1 -39
  56. package/src/data/initial-state.js +143 -128
  57. package/src/data/supported-geos.js +202 -4
  58. package/src/helpers/addUIDs.ts +8 -8
  59. package/src/helpers/applyColorToLegend.ts +122 -45
  60. package/src/helpers/applyLegendToRow.ts +15 -13
  61. package/src/helpers/componentHelpers.ts +8 -0
  62. package/src/helpers/constants.ts +12 -0
  63. package/src/helpers/dataTableHelpers.ts +6 -0
  64. package/src/helpers/displayGeoName.ts +12 -7
  65. package/src/helpers/formatLegendLocation.ts +1 -3
  66. package/src/helpers/generateRuntimeLegend.ts +192 -340
  67. package/src/helpers/generateRuntimeLegendHash.ts +4 -2
  68. package/src/helpers/getColumnNames.ts +1 -1
  69. package/src/helpers/getPatternForRow.ts +36 -0
  70. package/src/helpers/getStatesPicked.ts +14 -0
  71. package/src/helpers/handleMapAriaLabels.ts +2 -2
  72. package/src/helpers/index.ts +11 -3
  73. package/src/helpers/isLegendItemDisabled.ts +16 -0
  74. package/src/helpers/mapObserverHelpers.ts +40 -0
  75. package/src/helpers/resetLegendToggles.ts +3 -2
  76. package/src/helpers/toggleLegendActive.ts +6 -11
  77. package/src/helpers/urlDataHelpers.ts +70 -0
  78. package/src/hooks/useGeoClickHandler.ts +35 -1
  79. package/src/hooks/useLegendMemo.ts +17 -0
  80. package/src/hooks/useMapLayers.tsx +5 -4
  81. package/src/hooks/useStateZoom.tsx +137 -88
  82. package/src/hooks/useTooltip.ts +1 -2
  83. package/src/index.jsx +6 -3
  84. package/src/scss/main.scss +23 -12
  85. package/src/store/map.actions.ts +2 -2
  86. package/src/store/map.reducer.ts +21 -10
  87. package/src/test/CdcMap.test.jsx +11 -0
  88. package/src/types/MapConfig.ts +25 -17
  89. package/src/types/MapContext.ts +2 -8
  90. package/src/types/runtimeLegend.ts +12 -10
  91. package/vite.config.js +2 -7
  92. package/vitest.config.ts +16 -0
  93. package/src/_stories/_mock/floating-point.json +0 -427
  94. package/src/coreStyles_map.scss +0 -3
  95. package/src/helpers/colorDistributions.ts +0 -12
  96. package/src/helpers/generateColorsArray.ts +0 -14
  97. package/src/helpers/getStatePicked.ts +0 -8
  98. package/src/helpers/tests/generateColorsArray.test.ts +0 -18
  99. package/src/helpers/tests/generateRuntimeLegendHash.test.ts +0 -11
@@ -1,10 +1,11 @@
1
- import { useContext, useEffect, useState } from 'react'
1
+ import { useContext, useMemo } from 'react'
2
2
  import { scaleLinear } from 'd3-scale'
3
3
  import { GlyphCircle, GlyphDiamond, GlyphSquare, GlyphStar, GlyphTriangle } from '@visx/glyph'
4
4
  import ConfigContext from '../context'
5
+ import { useLegendMemoContext } from '../context/LegendMemoContext'
5
6
  import { supportedCities } from '../data/supported-geos'
6
- import { getFilterControllingStatePicked } from './UsaMap/helpers/map'
7
- import { displayGeoName, getGeoStrokeColor, SVG_HEIGHT, SVG_PADDING, SVG_WIDTH, titleCase } from '../helpers'
7
+ import { getFilterControllingStatesPicked } from './UsaMap/helpers/map'
8
+ import { displayGeoName, getGeoStrokeColor, SVG_HEIGHT, SVG_PADDING, SVG_WIDTH, isLegendItemDisabled } from '../helpers'
8
9
  import useGeoClickHandler from '../hooks/useGeoClickHandler'
9
10
  import useApplyTooltipsToGeo from '../hooks/useApplyTooltipsToGeo'
10
11
  import { applyLegendToRow } from '../helpers/applyLegendToRow'
@@ -18,72 +19,86 @@ type CityListProps = {
18
19
  }
19
20
 
20
21
  const CityList: React.FC<CityListProps> = ({ setSharedFilterValue, isFilterValueSupported, tooltipId, projection }) => {
21
- const {
22
- config,
23
- topoData,
24
- data: runtimeData,
25
- position,
26
- legendMemo,
27
- legendSpecialClassLastMemo,
28
- runtimeLegend
29
- } = useContext(ConfigContext)
22
+ const { config, topoData, runtimeData, position, runtimeLegend } = useContext(ConfigContext)
23
+ const { legendMemo, legendSpecialClassLastMemo } = useLegendMemoContext()
30
24
  const { geoClickHandler } = useGeoClickHandler()
31
25
  const { applyTooltipsToGeo } = useApplyTooltipsToGeo()
32
26
 
33
- const { geoColumnName, latitudeColumnName, longitudeColumnName, primaryColumnName } = getColumnNames(config.columns)
34
- const { additionalCityStyles } = config.visual || []
27
+ const { geoColumnName, latitudeColumnName, longitudeColumnName, primaryColumnName } =
28
+ getColumnNames(config.columns) || {}
35
29
 
36
- if (!projection) return
30
+ // Memoize expensive city data creation
31
+ const citiesData = useMemo(() => {
32
+ if (!runtimeData) return {}
37
33
 
38
- const citiesData = runtimeData
39
- ? Object.keys(runtimeData).reduce((acc, key) => {
40
- const city = runtimeData[key]
34
+ return Object.keys(runtimeData).reduce((acc, key) => {
35
+ const city = runtimeData[key]
36
+ if (city && city[geoColumnName]) {
41
37
  acc[city[geoColumnName]] = city
42
- return acc
43
- }, {})
44
- : {}
38
+ }
39
+ return acc
40
+ }, {})
41
+ }, [runtimeData, geoColumnName])
42
+
43
+ // Memoize bubble size calculation
44
+ const size = useMemo(() => {
45
+ if (config.general.type !== 'bubble' || !runtimeData) {
46
+ return null
47
+ }
45
48
 
46
- if (config.general.type === 'bubble') {
47
- const maxDataValue = Math.max(
48
- ...(runtimeData ? Object.keys(runtimeData).map(key => runtimeData[key][config.columns.primary.name]) : [0])
49
- )
50
- const sortedRuntimeData = Object.values(runtimeData).sort((a, b) =>
51
- a[primaryColumnName] < b[primaryColumnName] ? 1 : -1
52
- )
53
- if (!sortedRuntimeData) return
49
+ const maxVal = Math.max(...Object.keys(runtimeData).map(key => runtimeData[key][config.columns.primary.name]))
50
+
51
+ if (maxVal <= 0) {
52
+ return null
53
+ }
54
+
55
+ return scaleLinear().domain([1, maxVal]).range([config.visual.minBubbleSize, config.visual.maxBubbleSize])
56
+ }, [
57
+ config.general.type,
58
+ config.columns.primary.name,
59
+ config.visual.minBubbleSize,
60
+ config.visual.maxBubbleSize,
61
+ runtimeData
62
+ ])
63
+
64
+ // Get the list of cities to render
65
+ const cityList = useMemo(() => {
66
+ return Object.keys(citiesData).filter(cityName => cityName && citiesData[cityName])
67
+ }, [citiesData])
68
+
69
+ // Early exit for map types that don't use city rendering
70
+ if (!projection) {
71
+ return null
72
+ }
54
73
 
55
- // Set bubble sizes
56
- var size = scaleLinear().domain([1, maxDataValue]).range([config.visual.minBubbleSize, config.visual.maxBubbleSize])
74
+ // Early exit if no cities to render
75
+ if (!cityList.length) {
76
+ return null
57
77
  }
58
- const cityList = Object.keys(citiesData).filter(c => undefined !== c || undefined !== runtimeData[c])
59
- if (!cityList) return true
60
78
 
61
79
  // Cities output
62
80
  return cityList.map((city, i) => {
63
- let geoData: Object
64
- if (runtimeData) {
65
- Object.keys(runtimeData).forEach(key => {
66
- if (city === runtimeData[key][config.columns.geo.name]) {
67
- geoData = runtimeData[key]
68
- }
69
- })
70
- }
81
+ // Get the city data directly from our memoized citiesData
82
+ const geoData = citiesData[city]
83
+
71
84
  if (!geoData) {
72
- geoData = runtimeData ? runtimeData[city] : undefined
85
+ return null
73
86
  }
74
- const cityDisplayName = titleCase(displayGeoName(city))
75
87
 
76
- const legendColors = geoData
77
- ? applyLegendToRow(geoData, config, runtimeLegend, legendMemo, legendSpecialClassLastMemo)
78
- : runtimeData[city]
79
- ? applyLegendToRow(runtimeData[city], config, runtimeLegend, legendMemo, legendSpecialClassLastMemo)
80
- : false
88
+ const cityDisplayName = displayGeoName(city)
81
89
 
82
- if (legendColors === false) {
83
- return true
90
+ const legendColors = applyLegendToRow(geoData, config, runtimeLegend, legendMemo, legendSpecialClassLastMemo)
91
+
92
+ if (!legendColors || legendColors.length === 0) {
93
+ return null
94
+ }
95
+
96
+ // Don't render if legend item is disabled
97
+ if (isLegendItemDisabled(geoData, runtimeLegend, legendMemo, legendSpecialClassLastMemo, config)) {
98
+ return null
84
99
  }
85
100
 
86
- const toolTip = applyTooltipsToGeo(cityDisplayName, geoData || runtimeData[city])
101
+ const toolTip = applyTooltipsToGeo(cityDisplayName, geoData)
87
102
 
88
103
  const radius = config.visual.geoCodeCircleSize || 8
89
104
 
@@ -94,19 +109,21 @@ const CityList: React.FC<CityListProps> = ({ setSharedFilterValue, isFilterValue
94
109
  const geoStrokeColor = getGeoStrokeColor(config)
95
110
 
96
111
  const pin = (
97
- <path
98
- className='marker'
99
- d='M0,0l-8.8-17.7C-12.1-24.3-7.4-32,0-32h0c7.4,0,12.1,7.7,8.8,14.3L0,0z'
100
- title='Select for more information'
101
- onClick={() => geoClickHandler(cityDisplayName, geoData)}
102
- data-tooltip-id={`tooltip__${tooltipId}`}
103
- data-tooltip-html={toolTip}
104
- transform={`scale(${radius / 7.5})`}
105
- stroke={geoStrokeColor}
106
- strokeWidth={'2px'}
107
- tabIndex='-1'
108
- {...additionalProps}
109
- />
112
+ <g>
113
+ <title>Select for more information</title>
114
+ <path
115
+ className='marker'
116
+ d='M0,0l-8.8-17.7C-12.1-24.3-7.4-32,0-32h0c7.4,0,12.1,7.7,8.8,14.3L0,0z'
117
+ onClick={() => geoClickHandler(cityDisplayName, geoData)}
118
+ data-tooltip-id={`tooltip__${tooltipId}`}
119
+ data-tooltip-html={toolTip}
120
+ transform={`scale(${radius / 7.5})`}
121
+ stroke={geoStrokeColor}
122
+ strokeWidth={'2px'}
123
+ tabIndex={-1}
124
+ {...additionalProps}
125
+ />
126
+ </g>
110
127
  )
111
128
 
112
129
  let transform = ''
@@ -129,19 +146,19 @@ const CityList: React.FC<CityListProps> = ({ setSharedFilterValue, isFilterValue
129
146
  }
130
147
 
131
148
  if (geoData?.[longitudeColumnName] && geoData?.[latitudeColumnName] && config.general.geoType === 'single-state') {
132
- const statePicked = getFilterControllingStatePicked(config, runtimeData)
133
- const _statePickedData = topoData?.states?.find(s => s.properties.name === statePicked)
149
+ const statesPicked = getFilterControllingStatesPicked(config, runtimeData)
150
+ const _statesPickedData = (topoData as any)?.states?.find(s => statesPicked.includes(s.properties.name))
134
151
 
135
152
  const newProjection = projection.fitExtent(
136
153
  [
137
154
  [SVG_PADDING, SVG_PADDING],
138
155
  [SVG_WIDTH - SVG_PADDING, SVG_HEIGHT - SVG_PADDING]
139
156
  ],
140
- _statePickedData
157
+ _statesPickedData
141
158
  )
142
159
  let coords = [Number(geoData?.[longitudeColumnName]), Number(geoData?.[latitudeColumnName])]
143
160
  transform = `translate(${newProjection(coords)}) scale(${
144
- config.visual.geoCodeCircleSize / (position.zoom > 1 ? position.zoom : 1)
161
+ config.visual.geoCodeCircleSize / ((position as any).zoom > 1 ? (position as any).zoom : 1)
145
162
  })`
146
163
  needsPointer = true
147
164
  }
@@ -181,8 +198,7 @@ const CityList: React.FC<CityListProps> = ({ setSharedFilterValue, isFilterValue
181
198
 
182
199
  const shapeProps = {
183
200
  onClick: () => geoClickHandler(cityDisplayName, geoData),
184
- size: config.general.type === 'bubble' ? size(geoData[primaryColumnName]) : radius * 30,
185
- title: 'Select for more information',
201
+ size: config.general.type === 'bubble' && size ? size(geoData[primaryColumnName]) : radius * 30,
186
202
  'data-tooltip-id': `tooltip__${tooltipId}`,
187
203
  'data-tooltip-html': toolTip,
188
204
  stroke: geoStrokeColor,
@@ -200,48 +216,10 @@ const CityList: React.FC<CityListProps> = ({ setSharedFilterValue, isFilterValue
200
216
  triangle: <GlyphTriangle {...shapeProps} />
201
217
  }
202
218
 
203
- const cityStyle = Object.values(runtimeData)
204
- .filter(d => additionalCityStyles?.some(style => String(d[style.column]) === String(style.value)))
205
- .map(d => {
206
- const conditionsMatched = additionalCityStyles.find(style => String(d[style.column]) === String(style.value))
207
- return { ...conditionsMatched, ...d }
208
- })
209
- .find(item => {
210
- return Object.keys(item).find(key => item[key] === city)
211
- })
212
-
213
- if (cityStyle !== undefined && cityStyle.shape) {
214
- if (
215
- !geoData?.[longitudeColumnName] &&
216
- !geoData?.[latitudeColumnName] &&
217
- city &&
218
- supportedCities[city.toUpperCase()]
219
- ) {
220
- let translate = `translate(${projection(supportedCities[city.toUpperCase()])})`
221
-
222
- return (
223
- <g key={i} transform={translate} style={styles} className='geo-point' tabIndex={-1}>
224
- {cityStyleShapes[cityStyle.shape.toLowerCase()]}
225
- </g>
226
- )
227
- }
228
-
229
- if (geoData?.[longitudeColumnName] && geoData?.[latitudeColumnName]) {
230
- const coords = [Number(geoData?.[longitudeColumnName]), Number(geoData?.[latitudeColumnName])]
231
- let translate = `translate(${projection(coords)})`
232
-
233
- return (
234
- <g key={i} transform={translate} style={styles} className='geo-point' tabIndex={-1}>
235
- {cityStyleShapes[cityStyle.shape.toLowerCase()]}
236
- </g>
237
- )
238
- }
239
- }
240
- if (legendColors?.[0] === '#000000') return
241
-
219
+ // Render the city marker
242
220
  return (
243
221
  <g key={i} transform={transform} style={styles} className='geo-point' tabIndex={-1}>
244
- {cityStyleShapes[config.visual.cityStyle.toLowerCase()]}
222
+ {cityStyleShapes[config.visual.cityStyle?.toLowerCase() || 'circle']}
245
223
  </g>
246
224
  )
247
225
  })
@@ -12,8 +12,10 @@ import SkipTo from '@cdc/core/components/elements/SkipTo'
12
12
  import Loading from '@cdc/core/components/Loading'
13
13
  import { navigationHandler } from '../helpers'
14
14
  import ConfigContext, { MapDispatchContext } from '../context'
15
+ import { publishAnalyticsEvent } from '@cdc/core/helpers/metrics/helpers'
16
+ import { getPatternForRow } from '../helpers/getPatternForRow'
17
+ import { getVizTitle, getVizSubType } from '@cdc/core/helpers/metrics/utils'
15
18
 
16
- /* eslint-disable jsx-a11y/no-noninteractive-tabindex, jsx-a11y/no-static-element-interactions */
17
19
  const DataTable = props => {
18
20
  const {
19
21
  state,
@@ -29,11 +31,12 @@ const DataTable = props => {
29
31
  applyLegendToRow,
30
32
  displayGeoName,
31
33
  formatLegendLocation,
32
- tabbingId
34
+ tabbingId,
35
+ interactionLabel
33
36
  } = props
34
37
 
35
38
  const dispatch = useContext(MapDispatchContext)
36
- const { currentViewport: viewport } = useContext(ConfigContext)
39
+ const { currentViewport: viewport, mapId } = useContext(ConfigContext)
37
40
  const [expanded, setExpanded] = useState(expandDataTable)
38
41
  const [sortBy, setSortBy] = useState({ column: 'geo', asc: false })
39
42
  const [accessibilityLabel, setAccessibilityLabel] = useState('')
@@ -123,7 +126,7 @@ const DataTable = props => {
123
126
  role='link'
124
127
  tabIndex='0'
125
128
  onKeyDown={e => {
126
- if (e.keyCode === 13) {
129
+ if (e.key === 'Enter') {
127
130
  navigationHandler(state.general.navigationTarget, row[columns.navigate.name])
128
131
  }
129
132
  }}
@@ -176,7 +179,17 @@ const DataTable = props => {
176
179
  <a
177
180
  download={fileName}
178
181
  type='button'
179
- onClick={saveBlob}
182
+ onClick={() => {
183
+ saveBlob
184
+ publishAnalyticsEvent({
185
+ vizType: state.type,
186
+ vizSubType: getVizSubType(state),
187
+ eventType: 'data_downloaded',
188
+ eventAction: 'click',
189
+ eventLabel: interactionLabel,
190
+ vizTitle: getVizTitle(state)
191
+ })
192
+ }}
180
193
  href={URL.createObjectURL(blob)}
181
194
  aria-label='Download this data in a CSV file format.'
182
195
  className={`${headerColor} no-border`}
@@ -192,8 +205,8 @@ const DataTable = props => {
192
205
  const TableMediaControls = ({ belowTable }) => {
193
206
  return (
194
207
  <MediaControls.Section classes={['download-links']}>
195
- <MediaControls.Link config={state} />
196
- {state.table.download && <DownloadButton />}
208
+ <MediaControls.Link config={state} interactionLabel={interactionLabel} />
209
+ {state.table.download && <DownloadButton config={state} />}
197
210
  </MediaControls.Section>
198
211
  )
199
212
  }
@@ -245,7 +258,7 @@ const DataTable = props => {
245
258
  }}
246
259
  tabIndex='0'
247
260
  onKeyDown={e => {
248
- if (e.keyCode === 13) {
261
+ if (e.key === 'Enter') {
249
262
  setExpanded(!expanded)
250
263
  }
251
264
  }}
@@ -293,7 +306,7 @@ const DataTable = props => {
293
306
  setSortBy({ column, asc: sortBy.column === column ? !sortBy.asc : false })
294
307
  }}
295
308
  onKeyDown={e => {
296
- if (e.keyCode === 13) {
309
+ if (e.key === 'Enter') {
297
310
  setSortBy({ column, asc: sortBy.column === column ? !sortBy.asc : false })
298
311
  }
299
312
  }}
@@ -337,9 +350,28 @@ const DataTable = props => {
337
350
 
338
351
  labelValue = getCellAnchor(labelValue, rowObj)
339
352
 
353
+ // Check for pattern information
354
+ const patternInfo = getPatternForRow(rowObj, state)
355
+
356
+ const legendShape = patternInfo ? (
357
+ <LegendShape
358
+ fill={legendColor[0]}
359
+ patternInfo={{
360
+ pattern: patternInfo.pattern,
361
+ patternId: `${mapId}--${String(patternInfo.dataKey).replace(' ', '-')}--${
362
+ patternInfo.patternIndex
363
+ }--table`,
364
+ size: patternInfo.size,
365
+ color: patternInfo.color
366
+ }}
367
+ />
368
+ ) : (
369
+ <LegendShape fill={legendColor[0]} />
370
+ )
371
+
340
372
  cellValue = (
341
373
  <>
342
- <LegendShape fill={legendColor[0]} />
374
+ {legendShape}
343
375
  {labelValue}
344
376
  </>
345
377
  )
@@ -349,9 +381,9 @@ const DataTable = props => {
349
381
 
350
382
  return (
351
383
  <td
352
- tabIndex='0'
384
+ tabIndex={0}
353
385
  role='gridcell'
354
- onClick={e =>
386
+ onClick={() =>
355
387
  state.general.type === 'bubble' &&
356
388
  state.general.allowMapZoom &&
357
389
  state.general.geoType === 'world'