@cdc/map 4.25.7 → 4.25.8

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 (44) hide show
  1. package/CLAUDE.local.md +0 -0
  2. package/dist/cdcmap.js +22037 -22074
  3. package/examples/private/filter-map.json +909 -0
  4. package/examples/private/rsv-data.json +532 -0
  5. package/examples/private/test.json +222 -640
  6. package/index.html +34 -35
  7. package/package.json +3 -3
  8. package/src/CdcMap.tsx +7 -2
  9. package/src/CdcMapComponent.tsx +26 -8
  10. package/src/_stories/CdcMap.stories.tsx +8 -11
  11. package/src/_stories/_mock/multi-state.json +21389 -0
  12. package/src/components/CityList.tsx +4 -4
  13. package/src/components/DataTable.tsx +8 -4
  14. package/src/components/EditorPanel/components/EditorPanel.tsx +24 -38
  15. package/src/components/Legend/components/Legend.tsx +23 -35
  16. package/src/components/Modal.tsx +2 -8
  17. package/src/components/NavigationMenu.tsx +4 -1
  18. package/src/components/UsaMap/components/SingleState/SingleState.StateOutput.tsx +21 -15
  19. package/src/components/UsaMap/components/TerritoriesSection.tsx +2 -2
  20. package/src/components/UsaMap/components/UsaMap.County.tsx +6 -1
  21. package/src/components/UsaMap/components/UsaMap.SingleState.tsx +36 -24
  22. package/src/components/UsaMap/helpers/map.ts +16 -8
  23. package/src/components/WorldMap/WorldMap.tsx +17 -0
  24. package/src/context.ts +1 -0
  25. package/src/data/initial-state.js +8 -6
  26. package/src/data/supported-geos.js +185 -2
  27. package/src/helpers/addUIDs.ts +8 -8
  28. package/src/helpers/applyColorToLegend.ts +24 -43
  29. package/src/helpers/applyLegendToRow.ts +5 -7
  30. package/src/helpers/displayGeoName.ts +11 -6
  31. package/src/helpers/formatLegendLocation.ts +1 -3
  32. package/src/helpers/generateRuntimeLegend.ts +149 -333
  33. package/src/helpers/getStatesPicked.ts +11 -0
  34. package/src/helpers/handleMapAriaLabels.ts +2 -2
  35. package/src/hooks/useStateZoom.tsx +116 -86
  36. package/src/index.jsx +6 -1
  37. package/src/scss/main.scss +23 -12
  38. package/src/store/map.actions.ts +2 -2
  39. package/src/store/map.reducer.ts +4 -4
  40. package/src/types/MapConfig.ts +2 -3
  41. package/src/types/MapContext.ts +2 -1
  42. package/src/types/runtimeLegend.ts +1 -15
  43. package/src/_stories/_mock/floating-point.json +0 -427
  44. package/src/helpers/getStatePicked.ts +0 -8
@@ -3,7 +3,7 @@ export const handleMapAriaLabels = (state: MapConfig = '', testing = false) => {
3
3
  try {
4
4
  if (!state.general.geoType) throw Error('handleMapAriaLabels: no geoType found in state')
5
5
  const {
6
- general: { title, geoType, statePicked }
6
+ general: { title, geoType, statesPicked }
7
7
  } = state
8
8
  let ariaLabel = ''
9
9
  switch (geoType) {
@@ -17,7 +17,7 @@ export const handleMapAriaLabels = (state: MapConfig = '', testing = false) => {
17
17
  ariaLabel += `United States county map`
18
18
  break
19
19
  case 'single-state':
20
- ariaLabel += `${statePicked.stateName} county map`
20
+ ariaLabel += `${statesPicked.map(sp => sp.stateName).join(', ')} county map`
21
21
  break
22
22
  case 'us-region':
23
23
  ariaLabel += `United States HHS Region map`
@@ -1,12 +1,12 @@
1
- import { useContext, useEffect } from 'react'
1
+ import { useContext, useEffect, useMemo, useCallback } from 'react'
2
2
  import ConfigContext, { MapDispatchContext } from '../context'
3
3
  import { geoAlbersUsaTerritories } from 'd3-composite-projections'
4
4
  import { MapContext } from '../types/MapContext'
5
5
  import { geoPath, GeoPath } from 'd3-geo'
6
- import { getFilterControllingStatePicked } from '../components/UsaMap/helpers/map'
6
+ import { getFilterControllingStatesPicked } from '../components/UsaMap/helpers/map'
7
7
  import { supportedStatesFipsCodes } from '../data/supported-geos'
8
8
  import { SVG_HEIGHT, SVG_WIDTH, SVG_PADDING } from '../helpers'
9
- import _ from 'lodash'
9
+ import { publishAnalyticsEvent } from '@cdc/core/helpers/metrics/helpers'
10
10
 
11
11
  interface StateData {
12
12
  geometry: { type: 'MultiPolygon'; coordinates: number[][][][] }
@@ -18,116 +18,146 @@ interface StateData {
18
18
  }
19
19
 
20
20
  const useSetScaleAndTranslate = (topoData: { states: StateData[] }) => {
21
- const { config, runtimeData, position } = useContext<MapContext>(ConfigContext)
22
- const statePicked = getFilterControllingStatePicked(config, runtimeData)
21
+ const { config, runtimeData, position, interactionLabel } = useContext<MapContext>(ConfigContext)
23
22
  const dispatch = useContext(MapDispatchContext)
24
23
 
24
+ // Get statesPicked with memoization
25
+ const statesPicked = useMemo(() => {
26
+ const result = getFilterControllingStatesPicked(config, runtimeData)
27
+ if (!result) return []
28
+ if (!Array.isArray(result)) return [result]
29
+ return result
30
+ }, [config.general.statesPicked, runtimeData])
31
+
32
+ // Memoize expensive computations
33
+ const statesData = useMemo(() => {
34
+ return statesPicked.map(state => ({
35
+ fipsCode: Object.keys(supportedStatesFipsCodes).find(key => supportedStatesFipsCodes[key] === state),
36
+ stateName: state
37
+ }))
38
+ }, [statesPicked])
39
+
40
+ // Memoize projection calculations
41
+ const projectionData = useMemo(() => {
42
+ const projection = geoAlbersUsaTerritories()
43
+ .translate([SVG_WIDTH / 2, SVG_HEIGHT / 2])
44
+ .scale(1)
45
+
46
+ const _statesPickedData = topoData?.states?.filter(s => statesPicked.includes(s.properties.name))
47
+
48
+ const combinedData = _statesPickedData?.length
49
+ ? {
50
+ type: 'FeatureCollection',
51
+ features: _statesPickedData
52
+ }
53
+ : null
54
+
55
+ const newProjection = combinedData
56
+ ? projection.fitExtent(
57
+ [
58
+ [SVG_PADDING, SVG_PADDING],
59
+ [SVG_WIDTH - SVG_PADDING, SVG_HEIGHT - SVG_PADDING]
60
+ ],
61
+ combinedData
62
+ )
63
+ : projection
64
+
65
+ const path: GeoPath = geoPath().projection(projection)
66
+ const featureCenter = combinedData ? path.centroid(combinedData as any) : [0, 0]
67
+ const stateCenter = newProjection.invert(featureCenter)
68
+
69
+ return { projection, newProjection, stateCenter }
70
+ }, [topoData, statesPicked])
71
+
72
+ const setScaleAndTranslate = useCallback(
73
+ (zoomFunction: string = '') => {
74
+ const _prevPosition = config.mapPosition
75
+ let newZoom = _prevPosition.zoom
76
+ let newCoordinates = _prevPosition.coordinates
77
+ if (zoomFunction === 'zoomIn' && _prevPosition.zoom < 4) {
78
+ newZoom = _prevPosition.zoom * 1.5
79
+ newCoordinates =
80
+ _prevPosition.coordinates[0] !== 0 && _prevPosition.coordinates[1] !== 0
81
+ ? _prevPosition.coordinates
82
+ : projectionData.stateCenter
83
+ publishAnalyticsEvent('map_zoomed_in', 'click', `${interactionLabel}|${newZoom}|${newCoordinates}`, 'map')
84
+ } else if (zoomFunction === 'zoomOut' && _prevPosition.zoom > 1) {
85
+ newZoom = _prevPosition.zoom / 1.5
86
+ newCoordinates =
87
+ _prevPosition.coordinates[0] !== 0 && _prevPosition.coordinates[1] !== 0
88
+ ? _prevPosition.coordinates
89
+ : projectionData.stateCenter
90
+ } else if (zoomFunction === 'reset') {
91
+ newZoom = 1
92
+ newCoordinates = projectionData.stateCenter
93
+ }
94
+
95
+ dispatch({ type: 'SET_POSITION', payload: { coordinates: newCoordinates, zoom: newZoom } })
96
+
97
+ if (zoomFunction === 'reset') {
98
+ dispatch({ type: 'SET_TRANSLATE', payload: [0, 0] }) // needed for state switcher
99
+ dispatch({ type: 'SET_SCALE', payload: 1 }) // needed for state switcher
100
+ publishAnalyticsEvent('map_reset_zoom_level', 'click', interactionLabel, 'map')
101
+ }
102
+ },
103
+ [config.mapPosition, projectionData.stateCenter, interactionLabel, dispatch]
104
+ )
105
+
106
+ // Essential fix: Remove config from dependencies to prevent infinite loops
25
107
  useEffect(() => {
26
- const fipsCode = Object.keys(supportedStatesFipsCodes).find(key => supportedStatesFipsCodes[key] === statePicked)
27
- const stateName = statePicked
28
- const stateData = { fipsCode, stateName }
29
- const newConfig = _.cloneDeep(config)
30
- newConfig.general.statePicked = stateData
31
- const stateToShow = topoData?.states?.find(s => s.properties.name === statePicked)
108
+ if (!topoData) return
32
109
 
33
110
  dispatch({ type: 'SET_SCALE', payload: 1 })
34
111
  dispatch({ type: 'SET_TRANSLATE', payload: [0, 0] })
35
- dispatch({ type: 'SET_CONFIG', payload: newConfig })
36
- dispatch({ type: 'SET_STATE_TO_SHOW', payload: stateToShow })
37
- }, [topoData])
112
+ dispatch({ type: 'SET_STATES_TO_SHOW', payload: statesPicked })
113
+ }, [topoData, statesPicked, dispatch])
38
114
 
39
115
  useEffect(() => {
40
- const fipsCode = Object.keys(supportedStatesFipsCodes).find(key => supportedStatesFipsCodes[key] === statePicked)
41
- const stateName = statePicked
42
- const stateData = { fipsCode, stateName }
43
- const newConfig = _.cloneDeep(config)
44
- newConfig.general.statePicked = stateData
45
- dispatch({ type: 'SET_CONFIG', payload: newConfig })
46
- setScaleAndTranslate('reset')
47
- }, [statePicked])
48
-
49
- // TODO: same as city list projection?
50
- const projection = geoAlbersUsaTerritories()
51
- .translate([SVG_WIDTH / 2, SVG_HEIGHT / 2])
52
- .scale(1)
53
-
54
- const _statePickedData = topoData?.states?.find(s => s.properties.name === statePicked)
55
- const newProjection = projection.fitExtent(
56
- [
57
- [SVG_PADDING, SVG_PADDING],
58
- [SVG_WIDTH - SVG_PADDING, SVG_HEIGHT - SVG_PADDING]
59
- ],
60
- _statePickedData
61
- )
116
+ const currentStatesPicked = config.general.statesPicked?.map(state => state.stateName) || []
62
117
 
63
- // Work for centering the state.
64
- let [x, y] = newProjection.translate()
65
- x = x - SVG_WIDTH / 2
66
- y = y - SVG_HEIGHT / 2
118
+ const alreadySet =
119
+ currentStatesPicked.length === statesPicked.length &&
120
+ currentStatesPicked.every((s: string) => statesPicked.includes(s))
67
121
 
68
- const path: GeoPath = geoPath().projection(projection)
69
- const featureCenter = path.centroid(_statePickedData)
70
- const stateCenter = newProjection.invert(featureCenter)
122
+ if (alreadySet) return
71
123
 
72
- const switchState = () => {
73
- dispatch({ type: 'SET_STATE_TO_SHOW', payload: _statePickedData })
74
- setScaleAndTranslate('reset')
75
- }
124
+ const newConfig = { ...config }
125
+ newConfig.general = { ...config.general, statesPicked: statesData }
126
+ dispatch({ type: 'SET_CONFIG', payload: newConfig })
127
+ }, [statesPicked, statesData, dispatch])
76
128
 
77
- const setScaleAndTranslate = (zoomFunction: string = '') => {
78
- const _prevPosition = position
79
- let newZoom = _prevPosition.zoom
80
- let newCoordinates = _prevPosition.coordinates
81
- if (zoomFunction === 'zoomIn' && _prevPosition.zoom < 4) {
82
- newZoom = _prevPosition.zoom * 1.5
83
- newCoordinates =
84
- _prevPosition.coordinates[0] !== 0 && _prevPosition.coordinates[1] !== 0
85
- ? _prevPosition.coordinates
86
- : stateCenter
87
- } else if (zoomFunction === 'zoomOut' && _prevPosition.zoom > 1) {
88
- newZoom = _prevPosition.zoom / 1.5
89
- newCoordinates =
90
- _prevPosition.coordinates[0] !== 0 && _prevPosition.coordinates[1] !== 0
91
- ? _prevPosition.coordinates
92
- : stateCenter
93
- } else if (zoomFunction === 'reset') {
94
- newZoom = 1
95
- newCoordinates = stateCenter
96
- }
97
-
98
- dispatch({ type: 'SET_POSITION', payload: { coordinates: newCoordinates, zoom: newZoom } })
99
-
100
- if (zoomFunction === 'reset') {
101
- dispatch({ type: 'SET_TRANSLATE', payload: [0, 0] }) // needed for state switcher
102
- dispatch({ type: 'SET_SCALE', payload: 1 }) // needed for state switcher
103
- }
104
- }
129
+ const switchState = useCallback(() => {
130
+ dispatch({ type: 'SET_STATES_TO_SHOW', payload: statesPicked })
131
+ }, [statesPicked, setScaleAndTranslate, dispatch])
105
132
 
106
- const handleZoomIn = () => {
133
+ const handleZoomIn = useCallback(() => {
107
134
  setScaleAndTranslate('zoomIn')
108
- }
135
+ }, [setScaleAndTranslate])
109
136
 
110
- const handleZoomOut = () => {
137
+ const handleZoomOut = useCallback(() => {
111
138
  setScaleAndTranslate('zoomOut')
112
- }
139
+ }, [setScaleAndTranslate])
113
140
 
114
- const handleMoveEnd = position => {
115
- dispatch({ type: 'SET_POSITION', payload: position })
116
- }
141
+ const handleMoveEnd = useCallback(
142
+ position => {
143
+ dispatch({ type: 'SET_POSITION', payload: position })
144
+ },
145
+ [dispatch]
146
+ )
117
147
 
118
- const handleReset = () => {
148
+ const handleReset = useCallback(() => {
119
149
  setScaleAndTranslate('reset')
120
- }
150
+ }, [setScaleAndTranslate])
121
151
 
122
152
  return {
123
- statePicked,
153
+ statesPicked,
124
154
  setScaleAndTranslate,
125
155
  switchState,
126
156
  handleZoomIn,
127
157
  handleZoomOut,
128
158
  handleMoveEnd,
129
159
  handleReset,
130
- projection
160
+ projection: projectionData.projection
131
161
  }
132
162
  }
133
163
 
package/src/index.jsx CHANGED
@@ -12,6 +12,11 @@ let domContainer = document.getElementsByClassName('react-container')[0]
12
12
 
13
13
  ReactDOM.createRoot(domContainer).render(
14
14
  <React.StrictMode>
15
- <CdcMap isEditor={isEditor} configUrl={domContainer.attributes['data-config'].value} containerEl={domContainer} />
15
+ <CdcMap
16
+ isEditor={isEditor}
17
+ configUrl={domContainer.attributes['data-config'].value}
18
+ interactionLabel={domContainer.attributes['data-config'].value}
19
+ containerEl={domContainer}
20
+ />
16
21
  </React.StrictMode>
17
22
  )
@@ -15,7 +15,8 @@
15
15
  .cdc-map-outer-container {
16
16
  position: relative;
17
17
  display: flex; // Needed for the main content
18
- .loading > div.la-ball-beat {
18
+
19
+ .loading>div.la-ball-beat {
19
20
  margin-top: 20%;
20
21
  }
21
22
 
@@ -52,12 +53,14 @@
52
53
  &.bottom {
53
54
  flex-direction: column;
54
55
  }
56
+
55
57
  &.top {
56
58
  flex-direction: column-reverse;
57
59
  }
58
60
 
59
61
  &.modal-background {
60
62
  position: relative;
63
+
61
64
  &::before {
62
65
  content: ' ';
63
66
  position: absolute;
@@ -65,6 +68,7 @@
65
68
  bottom: 0;
66
69
  z-index: 7;
67
70
  }
71
+
68
72
  .modal-content {
69
73
  background: #fff;
70
74
  position: absolute;
@@ -80,8 +84,10 @@
80
84
  padding: 16px 40px;
81
85
  min-width: 250px;
82
86
  width: auto;
83
- max-height: 90vh; /* Constrain the modal's height to 90% of the viewport */
84
- overflow-y: auto; /* Enable vertical scrolling if content overflows */
87
+ max-height: 90vh;
88
+ /* Constrain the modal's height to 90% of the viewport */
89
+ overflow-y: auto;
90
+ /* Enable vertical scrolling if content overflows */
85
91
  font-size: 1rem;
86
92
  line-height: 1.4em;
87
93
  }
@@ -122,16 +128,15 @@
122
128
  margin-left: 0.5em;
123
129
  }
124
130
 
125
- .modal-content.capitalize p {
126
- text-transform: capitalize;
127
- }
128
-
129
131
  /* Responsive adjustments for smaller screens */
130
132
  @media (max-width: 1048px) {
131
133
  .modal-content {
132
- width: 90%; /* Adjust width to fit smaller screens */
133
- top: 10%; /* Offset from the top for better usability */
134
- transform: translate(-50%, 0); /* Remove vertical centering */
134
+ width: 90%;
135
+ /* Adjust width to fit smaller screens */
136
+ top: 10%;
137
+ /* Offset from the top for better usability */
138
+ transform: translate(-50%, 0);
139
+ /* Remove vertical centering */
135
140
  }
136
141
  }
137
142
  }
@@ -141,6 +146,7 @@
141
146
  em {
142
147
  font-style: italic;
143
148
  }
149
+
144
150
  strong {
145
151
  font-weight: bold;
146
152
  }
@@ -167,25 +173,30 @@
167
173
  z-index: 6;
168
174
  width: 100%;
169
175
  border-top: var(--lightGray) 1px solid;
176
+
170
177
  label {
171
178
  flex-grow: 1;
172
- > div.select-heading {
179
+
180
+ >div.select-heading {
173
181
  font-size: 1.1em;
174
182
  font-weight: 600;
175
183
  margin-bottom: 0.75em;
176
184
  }
177
185
  }
186
+
178
187
  form {
179
188
  max-width: 400px;
180
189
  display: flex;
181
190
  align-items: flex-end;
182
191
  }
192
+
183
193
  select {
184
194
  font-size: 1.2em;
185
195
  display: inline-block;
186
196
  vertical-align: top;
187
197
  width: 100%;
188
198
  }
199
+
189
200
  input {
190
201
  color: #fff;
191
202
  font-weight: 700;
@@ -202,4 +213,4 @@
202
213
  [tabIndex]:focus {
203
214
  outline-color: rgb(0, 95, 204);
204
215
  }
205
- }
216
+ }
@@ -20,7 +20,7 @@ type SET_RUNTIME_DATA = Action<'SET_RUNTIME_DATA', RuntimeData>
20
20
  type SET_RUNTIME_FILTERS = Action<'SET_RUNTIME_FILTERS', VizFilter[]>
21
21
  type SET_RUNTIME_LEGEND = Action<'SET_RUNTIME_LEGEND', GeneratedLegend | []>
22
22
  type SET_SCALE = Action<'SET_SCALE', number>
23
- type SET_STATE_TO_SHOW = Action<'SET_STATE_TO_SHOW', string>
23
+ type SET_STATES_TO_SHOW = Action<'SET_STATES_TO_SHOW', string[]>
24
24
  type SET_TOPO_DATA = Action<'SET_TOPO_DATA', any>
25
25
  type SET_TRANSLATE = Action<'SET_TRANSLATE', [number, number]>
26
26
 
@@ -39,7 +39,7 @@ export type MapActions =
39
39
  | SET_RUNTIME_FILTERS
40
40
  | SET_RUNTIME_LEGEND
41
41
  | SET_SCALE
42
- | SET_STATE_TO_SHOW
42
+ | SET_STATES_TO_SHOW
43
43
  | SET_TOPO_DATA
44
44
  | SET_TRANSLATE
45
45
 
@@ -24,7 +24,7 @@ export const getInitialState = (configObj = {}): MapState => {
24
24
  runtimeData: { init: true },
25
25
  runtimeFilters: [],
26
26
  runtimeLegend: [],
27
- stateToShow: ''
27
+ statesToShow: []
28
28
  }
29
29
  }
30
30
 
@@ -46,7 +46,7 @@ export type MapState = {
46
46
  runtimeData: object
47
47
  runtimeFilters: object[]
48
48
  runtimeLegend: object[]
49
- stateToShow: string
49
+ statesToShow: string[]
50
50
  dataUrl: string
51
51
  }
52
52
 
@@ -84,8 +84,8 @@ const reducer = (state: MapState, action: MapActions): MapState => {
84
84
  return { ...state, runtimeFilters: action.payload }
85
85
  case 'SET_RUNTIME_LEGEND':
86
86
  return { ...state, runtimeLegend: action.payload }
87
- case 'SET_STATE_TO_SHOW':
88
- return { ...state, stateToShow: action.payload }
87
+ case 'SET_STATES_TO_SHOW':
88
+ return { ...state, statesToShow: action.payload }
89
89
  default:
90
90
  return state
91
91
  }
@@ -127,10 +127,10 @@ export type MapConfig = Visualization & {
127
127
  showDownloadPdfButton: boolean
128
128
  showSidebar: boolean
129
129
  showTitle: boolean
130
- statePicked: {
130
+ statesPicked: {
131
131
  fipsCode: string
132
132
  stateName: string
133
- }
133
+ }[]
134
134
  territoriesAlwaysShow: boolean
135
135
  territoriesLabel: string
136
136
  title: string
@@ -176,7 +176,6 @@ export type MapConfig = Visualization & {
176
176
  tooltips: {
177
177
  appearanceType: 'hover' | 'click'
178
178
  linkLabel: string
179
- capitalizeLabels: boolean
180
179
  opacity: number
181
180
  }
182
181
  runtime: {
@@ -43,10 +43,11 @@ export type MapContext = {
43
43
  setConfig: (newState: MapConfig) => MapConfig
44
44
  config: MapConfig
45
45
  viewport: ViewPort
46
- stateToShow: string
46
+ statesToShow: string[]
47
47
  scale: number
48
48
  translate: [number, number]
49
49
  topoData: object
50
50
  runtimeData: Object[]
51
51
  tooltipId: string
52
+ interactionLabel?: string
52
53
  }
@@ -1,15 +1 @@
1
- export type RuntimeLegend = {
2
- items: {
3
- disabled?: boolean
4
- bin?: number
5
- color?: string
6
- special?: boolean
7
- value?: any
8
- label?: string
9
- min?: number
10
- max?: number
11
- }[]
12
- disabledAmt?: number
13
- fromHash?: number
14
- runtimeDataHash?: number
15
- }
1
+ export type RuntimeLegend = { disabled; bin; color; special }[]