@cdc/map 4.25.8 → 4.25.11
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/.claude/agents/typescript-organizer.md +118 -0
- package/.claude/settings.local.json +30 -0
- package/dist/{cdcmap-fce76882.es.js → cdcmap-BnB1QM5d.es.js} +6 -13
- package/dist/{cdcmap-c55ac1ea.es.js → cdcmap-D6CG2-Hb.es.js} +5 -12
- package/dist/{cdcmap-31a33da1.es.js → cdcmap-MXgURbdZ.es.js} +6 -13
- package/dist/{cdcmap-1a1724a1.es.js → cdcmap-dgT_1dIT.es.js} +136 -151
- package/dist/cdcmap.js +56991 -53706
- package/examples/example-city-state.json +9 -1
- package/examples/multi-country-centering.json +45 -0
- package/examples/private/c.json +290 -0
- package/examples/private/canvas-city-hover.json +787 -0
- package/examples/private/colors-2.json +221 -0
- package/examples/private/colors.json +221 -0
- package/examples/private/d.json +345 -0
- package/examples/private/g.json +1 -0
- package/examples/private/h.json +105911 -0
- package/examples/private/measles-data.json +378 -0
- package/examples/private/measles.json +211 -0
- package/examples/private/north-dakota.json +1132 -0
- package/examples/private/state-with-pattern.json +883 -0
- package/index.html +36 -34
- package/package.json +26 -5
- package/src/CdcMap.tsx +23 -8
- package/src/CdcMapComponent.tsx +238 -308
- package/src/_stories/CdcMap.ColumnWrap.stories.tsx +31 -0
- package/src/_stories/CdcMap.DistrictOfColumbia.stories.tsx +320 -0
- package/src/_stories/CdcMap.Editor.stories.tsx +3371 -0
- package/src/_stories/CdcMap.Filters.stories.tsx +2 -2
- package/src/_stories/CdcMap.Legend.Gradient.stories.tsx +3 -3
- package/src/_stories/CdcMap.Legend.stories.tsx +7 -4
- package/src/_stories/CdcMap.Patterns.stories.tsx +2 -2
- package/src/_stories/CdcMap.SmallMultiples.stories.tsx +35 -0
- package/src/_stories/CdcMap.Table.stories.tsx +2 -2
- package/src/_stories/CdcMap.stories.tsx +37 -9
- package/src/_stories/GoogleMap.stories.tsx +2 -2
- package/src/_stories/UsaMap.NoData.stories.tsx +2 -2
- package/src/_stories/_mock/column-wrap-test.json +265 -0
- package/src/_stories/_mock/equal-number.json +1109 -0
- package/src/_stories/_mock/multi-country-hide.json +78 -0
- package/src/_stories/_mock/multi-country.json +95 -0
- package/src/_stories/_mock/multi-state.json +887 -20403
- package/src/_stories/_mock/small_multiples/multi-state-small-multiples.json +8399 -0
- package/src/_stories/_mock/small_multiples/region-small-multiples.json +657 -0
- package/src/_stories/_mock/small_multiples/wastewater-map-small-multiples.json +221 -0
- package/src/_stories/_mock/us-bubble-cities.json +306 -0
- package/src/_stories/_mock/usa-state-gradient.json +2 -4
- package/src/components/BubbleList.tsx +17 -13
- package/src/components/CityList.tsx +85 -107
- package/src/components/EditorPanel/components/EditorPanel.tsx +787 -709
- package/src/components/EditorPanel/components/HexShapeSettings.tsx +58 -95
- package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +34 -42
- package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.tsx +354 -0
- package/src/components/EditorPanel/components/Panels/index.tsx +3 -1
- package/src/components/Geo.tsx +22 -3
- package/src/components/Legend/components/Legend.tsx +76 -40
- package/src/components/Legend/components/LegendGroup/Legend.Group.tsx +10 -7
- package/src/components/Legend/components/index.scss +1 -1
- package/src/components/MapContainer.tsx +52 -0
- package/src/components/MapControls.tsx +44 -0
- package/src/components/NavigationMenu.tsx +27 -15
- package/src/components/SmallMultiples/SmallMultipleTile.tsx +163 -0
- package/src/components/SmallMultiples/SmallMultiples.css +32 -0
- package/src/components/SmallMultiples/SmallMultiples.tsx +150 -0
- package/src/components/SmallMultiples/SynchronizedTooltip.tsx +105 -0
- package/src/components/SmallMultiples/index.tsx +3 -0
- package/src/components/UsaMap/components/SingleState/SingleState.CountyOutput.tsx +36 -4
- package/src/components/UsaMap/components/TerritoriesSection.tsx +26 -12
- package/src/components/UsaMap/components/Territory/Territory.Hexagon.tsx +30 -4
- package/src/components/UsaMap/components/Territory/Territory.Rectangle.tsx +23 -4
- package/src/components/UsaMap/components/Territory/TerritoryShape.ts +6 -0
- package/src/components/UsaMap/components/UsaMap.County.tsx +123 -37
- package/src/components/UsaMap/components/UsaMap.Region.tsx +36 -5
- package/src/components/UsaMap/components/UsaMap.SingleState.tsx +30 -10
- package/src/components/UsaMap/components/UsaMap.State.tsx +53 -12
- package/src/components/UsaMap/helpers/map.ts +4 -4
- package/src/components/UsaMap/helpers/shapes.ts +9 -6
- package/src/components/WorldMap/WorldMap.tsx +193 -35
- package/src/components/ZoomControls.tsx +6 -9
- package/src/context/LegendMemoContext.tsx +30 -0
- package/src/context.ts +1 -40
- package/src/data/initial-state.js +153 -130
- package/src/data/supported-geos.js +25 -78
- package/src/helpers/addUIDs.ts +13 -2
- package/src/helpers/applyColorToLegend.ts +140 -20
- package/src/helpers/applyLegendToRow.ts +10 -6
- package/src/helpers/componentHelpers.ts +8 -0
- package/src/helpers/constants.ts +12 -14
- package/src/helpers/dataTableHelpers.ts +6 -0
- package/src/helpers/displayGeoName.ts +18 -3
- package/src/helpers/generateRuntimeLegend.ts +44 -10
- package/src/helpers/generateRuntimeLegendHash.ts +4 -2
- package/src/helpers/getColumnNames.ts +1 -1
- package/src/helpers/getCountriesPicked.ts +103 -0
- package/src/helpers/getMapContainerClasses.ts +7 -0
- package/src/helpers/getPatternForRow.ts +33 -0
- package/src/helpers/getStatesPicked.ts +8 -5
- package/src/helpers/index.ts +3 -3
- package/src/helpers/isLegendItemDisabled.ts +16 -0
- package/src/helpers/mapObserverHelpers.ts +40 -0
- package/src/helpers/resetLegendToggles.ts +3 -2
- package/src/helpers/smallMultiplesHelpers.ts +359 -0
- package/src/helpers/tests/titleCase.test.ts +76 -0
- package/src/helpers/titleCase.ts +13 -13
- package/src/helpers/toggleLegendActive.ts +6 -11
- package/src/helpers/urlDataHelpers.ts +70 -0
- package/src/hooks/useCountryZoom.tsx +241 -0
- package/src/hooks/useGeoClickHandler.ts +36 -2
- package/src/hooks/useLegendMemo.ts +17 -0
- package/src/hooks/useMapLayers.tsx +5 -4
- package/src/hooks/useProgrammaticMapTooltip.ts +110 -0
- package/src/hooks/useResizeObserver.ts +5 -2
- package/src/hooks/useStateZoom.tsx +30 -8
- package/src/hooks/useSynchronizedGeographies.ts +56 -0
- package/src/hooks/useTooltip.ts +1 -2
- package/src/index.jsx +1 -2
- package/src/scss/editor-panel.scss +4 -440
- package/src/scss/main.scss +1 -1
- package/src/scss/map.scss +12 -15
- package/src/store/map.actions.ts +7 -7
- package/src/store/map.reducer.ts +17 -6
- package/src/test/CdcMap.test.jsx +11 -0
- package/src/types/MapConfig.ts +46 -18
- package/src/types/MapContext.ts +6 -7
- package/src/types/runtimeLegend.ts +17 -1
- package/vite.config.js +2 -7
- package/vitest.config.ts +16 -0
- package/src/components/DataTable.tsx +0 -385
- package/src/components/EditorPanel/components/Inputs.tsx +0 -59
- package/src/coreStyles_map.scss +0 -3
- package/src/helpers/colorDistributions.ts +0 -12
- package/src/helpers/generateColorsArray.ts +0 -14
- package/src/helpers/tests/generateColorsArray.test.ts +0 -18
- package/src/helpers/tests/generateRuntimeLegendHash.test.ts +0 -11
- package/src/hooks/useActiveElement.ts +0 -19
- package/src/scss/mixins.scss +0 -47
- package/src/types/Annotations.ts +0 -24
- /package/dist/{cdcmap-548642e6.es.js → cdcmap-Ct2SB0vL.es.js} +0 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { useContext, useEffect, useMemo, useCallback, useRef } from 'react'
|
|
2
|
+
import ConfigContext, { MapDispatchContext } from '../context'
|
|
3
|
+
import { geoMercator } from 'd3-geo'
|
|
4
|
+
import { MapContext } from '../types/MapContext'
|
|
5
|
+
import { geoPath, GeoPath } from 'd3-geo'
|
|
6
|
+
import { getCountriesPicked } from '../helpers/getCountriesPicked'
|
|
7
|
+
import { SVG_HEIGHT, SVG_WIDTH, SVG_PADDING } from '../helpers'
|
|
8
|
+
import { publishAnalyticsEvent } from '@cdc/core/helpers/metrics/helpers'
|
|
9
|
+
import { getVizTitle, getVizSubType } from '@cdc/core/helpers/metrics/utils'
|
|
10
|
+
|
|
11
|
+
interface CountryData {
|
|
12
|
+
geometry: { type: 'Polygon' | 'MultiPolygon'; coordinates: number[][][][] | number[][][] }
|
|
13
|
+
// ISO code of country
|
|
14
|
+
id: string
|
|
15
|
+
// Country properties
|
|
16
|
+
properties: { name: string; iso: string }
|
|
17
|
+
type: 'Feature'
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const useCountryZoom = (worldData: CountryData[]) => {
|
|
21
|
+
const { config, interactionLabel } = useContext<MapContext>(ConfigContext)
|
|
22
|
+
const dispatch = useContext(MapDispatchContext)
|
|
23
|
+
|
|
24
|
+
// Ref to track last centered countries to prevent unnecessary re-centering
|
|
25
|
+
const lastCenteredCountries = useRef<string>('')
|
|
26
|
+
|
|
27
|
+
// Get countriesPicked with memoization
|
|
28
|
+
const countriesPicked = useMemo(() => {
|
|
29
|
+
const result = getCountriesPicked(config)
|
|
30
|
+
|
|
31
|
+
if (!result) return []
|
|
32
|
+
if (!Array.isArray(result)) return [result]
|
|
33
|
+
const isoList = result.map(country => country.iso)
|
|
34
|
+
return isoList
|
|
35
|
+
}, [config.general.countriesPicked])
|
|
36
|
+
|
|
37
|
+
// Memoize expensive computations for country centering
|
|
38
|
+
const projectionData = useMemo(() => {
|
|
39
|
+
if (!worldData || !countriesPicked.length) {
|
|
40
|
+
return {
|
|
41
|
+
projection: geoMercator(),
|
|
42
|
+
countryCenter: [0, 30] as [number, number], // Default world center
|
|
43
|
+
countryZoom: 1
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const baseProjection = geoMercator()
|
|
48
|
+
.translate([SVG_WIDTH / 2, SVG_HEIGHT / 2])
|
|
49
|
+
.scale(1)
|
|
50
|
+
|
|
51
|
+
// List of ISO codes for small/uninhabited territories that should be excluded from zoom calculations
|
|
52
|
+
// These tiny islands can distort the bounding box and cause excessive zoom out
|
|
53
|
+
const excludedTerritories = [
|
|
54
|
+
'ATF', // French Southern and Antarctic Lands (Kerguelen, Crozet, etc.)
|
|
55
|
+
'HMD', // Heard Island and McDonald Islands
|
|
56
|
+
'SGS', // South Georgia and South Sandwich Islands
|
|
57
|
+
'BVT' // Bouvet Island
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
// List of specific territory names that should be excluded even if they share an ISO with a main country
|
|
61
|
+
const excludedTerritoryNames = [
|
|
62
|
+
'Saint Paul and Amsterdam Islands', // French territory in Indian Ocean
|
|
63
|
+
'Kerguelen Islands',
|
|
64
|
+
'Crozet Islands'
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
// Filter world data to selected countries, excluding small territories from zoom calculation
|
|
68
|
+
const selectedCountriesData = worldData.filter(country => {
|
|
69
|
+
// Match by ISO code OR by name (to handle cases where config uses name instead of ISO)
|
|
70
|
+
const matchesSelectedCountry = countriesPicked.some(
|
|
71
|
+
iso => iso === country.properties.iso || iso === country.properties.name
|
|
72
|
+
)
|
|
73
|
+
const isExcludedByISO = excludedTerritories.includes(country.properties.iso)
|
|
74
|
+
const isExcludedByName = excludedTerritoryNames.some(
|
|
75
|
+
name => country.properties.name && country.properties.name.includes(name)
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
return matchesSelectedCountry && !isExcludedByISO && !isExcludedByName
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
if (!selectedCountriesData.length) {
|
|
82
|
+
return {
|
|
83
|
+
projection: baseProjection,
|
|
84
|
+
countryCenter: [0, 30] as [number, number], // Default world center
|
|
85
|
+
countryZoom: 1
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Create a feature collection for selected countries
|
|
90
|
+
const combinedData = {
|
|
91
|
+
type: 'FeatureCollection',
|
|
92
|
+
features: selectedCountriesData
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Fit projection to selected countries - this calculates the optimal scale and translate
|
|
96
|
+
const fittedProjection = baseProjection.fitExtent(
|
|
97
|
+
[
|
|
98
|
+
[SVG_PADDING, SVG_PADDING],
|
|
99
|
+
[SVG_WIDTH - SVG_PADDING, SVG_HEIGHT - SVG_PADDING]
|
|
100
|
+
],
|
|
101
|
+
combinedData as any
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
// Calculate zoom level from the fitted projection
|
|
105
|
+
// The fitExtent method automatically calculates the scale needed to fit the features
|
|
106
|
+
const fittedScale = fittedProjection.scale()
|
|
107
|
+
|
|
108
|
+
// Use the default geoMercator scale (around 153) as base reference
|
|
109
|
+
const defaultMercatorScale = 153 // Standard geoMercator scale for world view
|
|
110
|
+
let calculatedZoom = fittedScale / defaultMercatorScale
|
|
111
|
+
|
|
112
|
+
// Clamp zoom to reasonable bounds
|
|
113
|
+
// Allow zoom out to 0.5x for large multi-country spans (e.g., Argentina + Canada)
|
|
114
|
+
// Max zoom of 10x for small countries
|
|
115
|
+
const clampedZoom = Math.max(0.5, Math.min(10, calculatedZoom))
|
|
116
|
+
|
|
117
|
+
// Calculate the center point using BOUNDING BOX CENTER (not area-weighted centroid)
|
|
118
|
+
// This ensures equal geographic treatment regardless of country size
|
|
119
|
+
const path: GeoPath = geoPath().projection(fittedProjection)
|
|
120
|
+
const bounds = path.bounds(combinedData as any)
|
|
121
|
+
|
|
122
|
+
// Calculate geographic bounds
|
|
123
|
+
const geoBounds = {
|
|
124
|
+
topLeft: fittedProjection.invert([bounds[0][0], bounds[0][1]]),
|
|
125
|
+
bottomRight: fittedProjection.invert([bounds[1][0], bounds[1][1]])
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Use bounding box midpoint as center (NOT area-weighted centroid which favors larger countries)
|
|
129
|
+
const countryCenter: [number, number] = [
|
|
130
|
+
(geoBounds.topLeft[0] + geoBounds.bottomRight[0]) / 2,
|
|
131
|
+
(geoBounds.topLeft[1] + geoBounds.bottomRight[1]) / 2
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
const result = {
|
|
135
|
+
projection: fittedProjection,
|
|
136
|
+
countryCenter,
|
|
137
|
+
countryZoom: clampedZoom
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return result
|
|
141
|
+
}, [worldData, countriesPicked.join(',')]) // Use string key for more stable memoization
|
|
142
|
+
|
|
143
|
+
const centerOnCountries = useCallback(
|
|
144
|
+
(zoomFunction: string = '') => {
|
|
145
|
+
if (!countriesPicked.length) {
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const _prevPosition = config.mapPosition || { coordinates: [0, 30], zoom: 1 }
|
|
150
|
+
let newZoom = projectionData.countryZoom
|
|
151
|
+
let newCoordinates = projectionData.countryCenter
|
|
152
|
+
|
|
153
|
+
if (zoomFunction === 'zoomIn' && projectionData.countryZoom < 4) {
|
|
154
|
+
newZoom = Math.min(4, projectionData.countryZoom * 1.5)
|
|
155
|
+
newCoordinates = projectionData.countryCenter
|
|
156
|
+
publishAnalyticsEvent({
|
|
157
|
+
vizType: 'map',
|
|
158
|
+
vizSubType: getVizSubType(config),
|
|
159
|
+
eventType: 'zoom_in',
|
|
160
|
+
eventAction: 'click',
|
|
161
|
+
eventLabel: interactionLabel,
|
|
162
|
+
vizTitle: getVizTitle(config),
|
|
163
|
+
specifics: `zoom_level: ${Math.floor(newZoom)}`
|
|
164
|
+
})
|
|
165
|
+
} else if (zoomFunction === 'zoomOut' && projectionData.countryZoom > 1) {
|
|
166
|
+
newZoom = Math.max(1, projectionData.countryZoom / 1.5)
|
|
167
|
+
newCoordinates = projectionData.countryCenter
|
|
168
|
+
} else if (zoomFunction === 'reset') {
|
|
169
|
+
newZoom = projectionData.countryZoom
|
|
170
|
+
newCoordinates = projectionData.countryCenter
|
|
171
|
+
} else if (zoomFunction === 'center' || zoomFunction === '') {
|
|
172
|
+
// Auto-center with calculated zoom - this is the main centering logic
|
|
173
|
+
newZoom = projectionData.countryZoom
|
|
174
|
+
newCoordinates = projectionData.countryCenter
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const payload = { coordinates: newCoordinates, zoom: newZoom }
|
|
178
|
+
dispatch({ type: 'SET_POSITION', payload })
|
|
179
|
+
|
|
180
|
+
if (zoomFunction === 'reset') {
|
|
181
|
+
publishAnalyticsEvent({
|
|
182
|
+
vizType: 'map',
|
|
183
|
+
vizSubType: getVizSubType(config),
|
|
184
|
+
eventType: 'map_reset_zoom_level',
|
|
185
|
+
eventAction: 'click',
|
|
186
|
+
eventLabel: interactionLabel,
|
|
187
|
+
vizTitle: getVizTitle(config)
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
[
|
|
192
|
+
config.mapPosition,
|
|
193
|
+
projectionData.countryCenter,
|
|
194
|
+
projectionData.countryZoom,
|
|
195
|
+
interactionLabel,
|
|
196
|
+
dispatch,
|
|
197
|
+
countriesPicked
|
|
198
|
+
]
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
// Auto-center when countries are selected/changed
|
|
202
|
+
useEffect(() => {
|
|
203
|
+
const countriesKey = countriesPicked.sort().join(',')
|
|
204
|
+
|
|
205
|
+
if (!worldData || !countriesPicked.length) {
|
|
206
|
+
lastCenteredCountries.current = ''
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Only re-center if countries have actually changed
|
|
211
|
+
if (lastCenteredCountries.current === countriesKey) {
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
lastCenteredCountries.current = countriesKey
|
|
216
|
+
|
|
217
|
+
// Immediately center on the selected countries with calculated zoom
|
|
218
|
+
const payload = {
|
|
219
|
+
coordinates: projectionData.countryCenter,
|
|
220
|
+
zoom: projectionData.countryZoom
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Additional validation before dispatch
|
|
224
|
+
const [lng, lat] = payload.coordinates
|
|
225
|
+
const coordsValid = lng >= -180 && lng <= 180 && lat >= -90 && lat <= 90
|
|
226
|
+
const zoomValid = payload.zoom >= 0.1 && payload.zoom <= 10
|
|
227
|
+
|
|
228
|
+
if (coordsValid && zoomValid) {
|
|
229
|
+
dispatch({ type: 'SET_POSITION', payload })
|
|
230
|
+
}
|
|
231
|
+
}, [worldData, countriesPicked, projectionData.countryCenter, projectionData.countryZoom, dispatch])
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
centerOnCountries,
|
|
235
|
+
countriesPicked,
|
|
236
|
+
countryCenter: projectionData.countryCenter,
|
|
237
|
+
countryZoom: projectionData.countryZoom
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export default useCountryZoom
|
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import ConfigContext, { MapDispatchContext } from '../context'
|
|
2
2
|
import { navigationHandler } from '../helpers'
|
|
3
3
|
import { useContext } from 'react'
|
|
4
|
+
import { publishAnalyticsEvent } from '@cdc/core/helpers/metrics/helpers'
|
|
5
|
+
import { getVizTitle } from '@cdc/core/helpers/metrics/utils'
|
|
4
6
|
|
|
5
7
|
const useGeoClickHandler = () => {
|
|
6
|
-
const {
|
|
8
|
+
const {
|
|
9
|
+
config: state,
|
|
10
|
+
setConfig,
|
|
11
|
+
setSharedFilter,
|
|
12
|
+
customNavigationHandler,
|
|
13
|
+
interactionLabel
|
|
14
|
+
} = useContext(ConfigContext)
|
|
7
15
|
const dispatch = useContext(MapDispatchContext)
|
|
8
16
|
|
|
9
17
|
const geoClickHandler = (geoDisplayName: string, geoData: object): void => {
|
|
@@ -23,18 +31,44 @@ const useGeoClickHandler = () => {
|
|
|
23
31
|
}
|
|
24
32
|
|
|
25
33
|
// If modals are set, or we are on a mobile viewport, display modal
|
|
26
|
-
if (
|
|
34
|
+
if ('click' === state.tooltips.appearanceType) {
|
|
27
35
|
const modalData = {
|
|
28
36
|
geoName: geoDisplayName,
|
|
29
37
|
keyedData: geoData
|
|
30
38
|
}
|
|
31
39
|
dispatch({ type: 'SET_MODAL', payload: modalData })
|
|
32
40
|
|
|
41
|
+
// Track modal click analytics event
|
|
42
|
+
if (interactionLabel) {
|
|
43
|
+
const locationName = geoDisplayName.replace(/[^a-zA-Z0-9]/g, '_')
|
|
44
|
+
publishAnalyticsEvent({
|
|
45
|
+
vizType: 'map',
|
|
46
|
+
eventType: `modal_trigger` as any,
|
|
47
|
+
eventAction: 'click',
|
|
48
|
+
eventLabel: interactionLabel,
|
|
49
|
+
vizTitle: getVizTitle(state),
|
|
50
|
+
specifics: `clicked on: ${String(locationName).toLowerCase()}`
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
33
54
|
return
|
|
34
55
|
}
|
|
35
56
|
|
|
36
57
|
// Otherwise if this item has a link specified for it, do regular navigation.
|
|
37
58
|
if (state.columns.navigate && geoData[state.columns.navigate.name]) {
|
|
59
|
+
// Track navigation click analytics event
|
|
60
|
+
if (interactionLabel) {
|
|
61
|
+
const locationName = geoDisplayName.replace(/[^a-zA-Z0-9]/g, '_')
|
|
62
|
+
publishAnalyticsEvent({
|
|
63
|
+
vizType: 'map',
|
|
64
|
+
eventType: `map_trigger` as any,
|
|
65
|
+
eventAction: 'click',
|
|
66
|
+
eventLabel: interactionLabel,
|
|
67
|
+
vizTitle: getVizTitle(state),
|
|
68
|
+
specifics: `clicked on: ${String(locationName).toLowerCase()}`
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
38
72
|
navigationHandler(state.general.navigationTarget, geoData[state.columns.navigate.name], customNavigationHandler)
|
|
39
73
|
}
|
|
40
74
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { useRef } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Custom hook to manage legend memoization refs
|
|
5
|
+
* Extracted from context to reduce context props and improve performance
|
|
6
|
+
*/
|
|
7
|
+
export const useLegendMemo = () => {
|
|
8
|
+
const legendMemo = useRef(new Map())
|
|
9
|
+
const legendSpecialClassLastMemo = useRef(new Map())
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
legendMemo,
|
|
13
|
+
legendSpecialClassLastMemo
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default useLegendMemo
|
|
@@ -3,6 +3,7 @@ import { feature } from 'topojson-client'
|
|
|
3
3
|
import { Group } from '@visx/group'
|
|
4
4
|
import { MapConfig } from '../types/MapConfig'
|
|
5
5
|
import _ from 'lodash'
|
|
6
|
+
import { cloneConfig } from '@cdc/core/helpers/cloneConfig'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* This is the starting structure for adding custom geoJSON shape layers to a projection.
|
|
@@ -46,10 +47,10 @@ export default function useMapLayers(config: MapConfig, setConfig, pathGenerator
|
|
|
46
47
|
|
|
47
48
|
const handleRemoveLayer = (e: MouseEvent<HTMLButtonElement>, index: number) => {
|
|
48
49
|
e.preventDefault()
|
|
49
|
-
const newConfig =
|
|
50
|
+
const newConfig = cloneConfig(config)
|
|
50
51
|
const layers = newConfig.map.layers.filter((_layer, i) => i !== index)
|
|
51
52
|
newConfig.map.layers = layers
|
|
52
|
-
setConfig(newConfig
|
|
53
|
+
setConfig(newConfig)
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
const handleAddLayer = (e: Event) => {
|
|
@@ -58,9 +59,9 @@ export default function useMapLayers(config: MapConfig, setConfig, pathGenerator
|
|
|
58
59
|
name: 'New Custom Layer',
|
|
59
60
|
url: ''
|
|
60
61
|
}
|
|
61
|
-
const newConfig =
|
|
62
|
+
const newConfig = cloneConfig(config)
|
|
62
63
|
newConfig.map.layers.unshift(placeHolderLayer)
|
|
63
|
-
setConfig(
|
|
64
|
+
setConfig(newConfig)
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
const handleMapLayer = (e: ChangeEvent<HTMLInputElement>, index: number, layerKey: string) => {
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { useRef, useImperativeHandle, ForwardedRef } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Interface for geography element refs exposed to parent components
|
|
5
|
+
* Follows chart package pattern for consistency
|
|
6
|
+
*/
|
|
7
|
+
export interface MapRefInterface {
|
|
8
|
+
/**
|
|
9
|
+
* Programmatically trigger tooltip at a specific geography (data-centric approach)
|
|
10
|
+
* @param geoId - Geography identifier (FIPS code, name, ISO code, etc.)
|
|
11
|
+
* @param yCoordinate - Y coordinate for tooltip positioning (from source event)
|
|
12
|
+
*/
|
|
13
|
+
triggerTooltipAtGeo: (geoId: string, yCoordinate: number) => void
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Hide all tooltips for this map tile
|
|
17
|
+
*/
|
|
18
|
+
hideTooltip: () => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface UseProgrammaticMapTooltipProps {
|
|
22
|
+
mapRef: ForwardedRef<MapRefInterface>
|
|
23
|
+
tooltipId: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Custom hook to provide programmatic tooltip control for small multiples synchronization
|
|
28
|
+
*
|
|
29
|
+
* This hook enables tooltips to be triggered programmatically on geography elements
|
|
30
|
+
* using synthetic mouse events. It's designed to work with react-tooltip v5.
|
|
31
|
+
*
|
|
32
|
+
*/
|
|
33
|
+
export const useProgrammaticMapTooltip = ({ mapRef, tooltipId }: UseProgrammaticMapTooltipProps) => {
|
|
34
|
+
// Store refs to all geography elements in this map
|
|
35
|
+
const geoElementRefs = useRef<Record<string, SVGElement>>({})
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Register a geography element so it can be programmatically controlled
|
|
39
|
+
* Call this in the ref callback of each geography element
|
|
40
|
+
*/
|
|
41
|
+
const registerGeoElement = (geoId: string, element: SVGElement | null) => {
|
|
42
|
+
geoElementRefs.current[geoId] = element
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const unregisterGeoElement = (geoId: string) => {
|
|
46
|
+
delete geoElementRefs.current[geoId]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Expose programmatic tooltip methods via ref
|
|
50
|
+
useImperativeHandle(
|
|
51
|
+
mapRef,
|
|
52
|
+
() => ({
|
|
53
|
+
/**
|
|
54
|
+
* Trigger tooltip at specific geography
|
|
55
|
+
*/
|
|
56
|
+
triggerTooltipAtGeo: (geoId: string, yCoordinate: number) => {
|
|
57
|
+
const geoElement = geoElementRefs.current[geoId]
|
|
58
|
+
if (!geoElement) {
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Get the horizontal center of the geography element
|
|
63
|
+
const rect = geoElement.getBoundingClientRect()
|
|
64
|
+
const centerX = rect.left + rect.width / 2
|
|
65
|
+
|
|
66
|
+
// Create synthetic mouseenter event with coordinates
|
|
67
|
+
const syntheticEvent = new MouseEvent('mouseenter', {
|
|
68
|
+
bubbles: true,
|
|
69
|
+
cancelable: true,
|
|
70
|
+
clientX: centerX,
|
|
71
|
+
clientY: yCoordinate,
|
|
72
|
+
screenX: window.screenX + centerX,
|
|
73
|
+
screenY: window.screenY + yCoordinate,
|
|
74
|
+
view: window
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
Object.defineProperty(syntheticEvent, 'currentTarget', {
|
|
78
|
+
value: geoElement,
|
|
79
|
+
writable: false
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
Object.defineProperty(syntheticEvent, 'target', {
|
|
83
|
+
value: geoElement,
|
|
84
|
+
writable: false
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
geoElement.dispatchEvent(syntheticEvent)
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
hideTooltip: () => {
|
|
91
|
+
Object.values(geoElementRefs.current).forEach(element => {
|
|
92
|
+
const syntheticEvent = new MouseEvent('mouseleave', {
|
|
93
|
+
bubbles: true,
|
|
94
|
+
cancelable: true,
|
|
95
|
+
view: window
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
element.dispatchEvent(syntheticEvent)
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
}),
|
|
102
|
+
[tooltipId]
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
registerGeoElement,
|
|
107
|
+
unregisterGeoElement,
|
|
108
|
+
geoElementRefs
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -8,17 +8,20 @@ import ResizeObserver from 'resize-observer-polyfill'
|
|
|
8
8
|
export const useResizeObserver = (isEditor: boolean) => {
|
|
9
9
|
const [dimensions, setDimensions] = useState<DimensionsType>([0, 0])
|
|
10
10
|
const [currentViewport, setCurrentViewport] = useState<ViewPort>(null)
|
|
11
|
+
const [vizViewport, setVizViewport] = useState<ViewPort>(null)
|
|
11
12
|
const [container, setContainer] = useState<HTMLElement | null>(null)
|
|
12
13
|
|
|
13
14
|
const resizeObserver = new ResizeObserver(entries => {
|
|
14
15
|
for (let entry of entries) {
|
|
15
16
|
let { width, height } = entry.contentRect
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
const editorIsOpen = isEditor && !!document.querySelector('.editor-panel:not(.hidden)')
|
|
19
|
+
width = editorIsOpen ? width - EDITOR_WIDTH : width
|
|
18
20
|
|
|
19
21
|
const newViewport = getViewport(width)
|
|
20
22
|
|
|
21
23
|
setCurrentViewport(newViewport)
|
|
24
|
+
setVizViewport(newViewport)
|
|
22
25
|
|
|
23
26
|
setDimensions([width, height])
|
|
24
27
|
}
|
|
@@ -35,7 +38,7 @@ export const useResizeObserver = (isEditor: boolean) => {
|
|
|
35
38
|
}
|
|
36
39
|
}, [])
|
|
37
40
|
|
|
38
|
-
return { resizeObserver, dimensions, currentViewport, outerContainerRef, container }
|
|
41
|
+
return { resizeObserver, dimensions, currentViewport, vizViewport, outerContainerRef, container }
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
export default useResizeObserver
|
|
@@ -7,6 +7,7 @@ import { getFilterControllingStatesPicked } from '../components/UsaMap/helpers/m
|
|
|
7
7
|
import { supportedStatesFipsCodes } from '../data/supported-geos'
|
|
8
8
|
import { SVG_HEIGHT, SVG_WIDTH, SVG_PADDING } from '../helpers'
|
|
9
9
|
import { publishAnalyticsEvent } from '@cdc/core/helpers/metrics/helpers'
|
|
10
|
+
import { getVizTitle, getVizSubType } from '@cdc/core/helpers/metrics/utils'
|
|
10
11
|
|
|
11
12
|
interface StateData {
|
|
12
13
|
geometry: { type: 'MultiPolygon'; coordinates: number[][][][] }
|
|
@@ -66,7 +67,9 @@ const useSetScaleAndTranslate = (topoData: { states: StateData[] }) => {
|
|
|
66
67
|
const featureCenter = combinedData ? path.centroid(combinedData as any) : [0, 0]
|
|
67
68
|
const stateCenter = newProjection.invert(featureCenter)
|
|
68
69
|
|
|
69
|
-
|
|
70
|
+
const bounds = combinedData ? path.bounds(combinedData as any) : null
|
|
71
|
+
|
|
72
|
+
return { projection, newProjection, stateCenter, bounds }
|
|
70
73
|
}, [topoData, statesPicked])
|
|
71
74
|
|
|
72
75
|
const setScaleAndTranslate = useCallback(
|
|
@@ -80,7 +83,15 @@ const useSetScaleAndTranslate = (topoData: { states: StateData[] }) => {
|
|
|
80
83
|
_prevPosition.coordinates[0] !== 0 && _prevPosition.coordinates[1] !== 0
|
|
81
84
|
? _prevPosition.coordinates
|
|
82
85
|
: projectionData.stateCenter
|
|
83
|
-
publishAnalyticsEvent(
|
|
86
|
+
publishAnalyticsEvent({
|
|
87
|
+
vizType: 'map',
|
|
88
|
+
vizSubType: getVizSubType(config),
|
|
89
|
+
eventType: 'zoom_in',
|
|
90
|
+
eventAction: 'click',
|
|
91
|
+
eventLabel: interactionLabel,
|
|
92
|
+
vizTitle: getVizTitle(config),
|
|
93
|
+
specifics: `zoom: ${newZoom}, coordinates:${newCoordinates}`
|
|
94
|
+
})
|
|
84
95
|
} else if (zoomFunction === 'zoomOut' && _prevPosition.zoom > 1) {
|
|
85
96
|
newZoom = _prevPosition.zoom / 1.5
|
|
86
97
|
newCoordinates =
|
|
@@ -97,7 +108,14 @@ const useSetScaleAndTranslate = (topoData: { states: StateData[] }) => {
|
|
|
97
108
|
if (zoomFunction === 'reset') {
|
|
98
109
|
dispatch({ type: 'SET_TRANSLATE', payload: [0, 0] }) // needed for state switcher
|
|
99
110
|
dispatch({ type: 'SET_SCALE', payload: 1 }) // needed for state switcher
|
|
100
|
-
publishAnalyticsEvent(
|
|
111
|
+
publishAnalyticsEvent({
|
|
112
|
+
vizType: 'map',
|
|
113
|
+
vizSubType: getVizSubType(config),
|
|
114
|
+
eventType: 'map_reset_zoom_level',
|
|
115
|
+
eventAction: 'click',
|
|
116
|
+
eventLabel: interactionLabel,
|
|
117
|
+
vizTitle: getVizTitle(config)
|
|
118
|
+
})
|
|
101
119
|
}
|
|
102
120
|
},
|
|
103
121
|
[config.mapPosition, projectionData.stateCenter, interactionLabel, dispatch]
|
|
@@ -145,9 +163,12 @@ const useSetScaleAndTranslate = (topoData: { states: StateData[] }) => {
|
|
|
145
163
|
[dispatch]
|
|
146
164
|
)
|
|
147
165
|
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
166
|
+
const handleZoomReset = useCallback(
|
|
167
|
+
_setRuntimeData => {
|
|
168
|
+
setScaleAndTranslate('reset')
|
|
169
|
+
},
|
|
170
|
+
[setScaleAndTranslate]
|
|
171
|
+
)
|
|
151
172
|
|
|
152
173
|
return {
|
|
153
174
|
statesPicked,
|
|
@@ -156,8 +177,9 @@ const useSetScaleAndTranslate = (topoData: { states: StateData[] }) => {
|
|
|
156
177
|
handleZoomIn,
|
|
157
178
|
handleZoomOut,
|
|
158
179
|
handleMoveEnd,
|
|
159
|
-
|
|
160
|
-
projection: projectionData.projection
|
|
180
|
+
handleZoomReset,
|
|
181
|
+
projection: projectionData.projection,
|
|
182
|
+
bounds: projectionData.bounds
|
|
161
183
|
}
|
|
162
184
|
}
|
|
163
185
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { useContext } from 'react'
|
|
2
|
+
import ConfigContext from '../context'
|
|
3
|
+
import { useProgrammaticMapTooltip } from './useProgrammaticMapTooltip'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Hook to enable synchronized tooltips for map geographies in small multiples
|
|
7
|
+
*
|
|
8
|
+
* This abstracts all the small multiples synchronization logic so it can be
|
|
9
|
+
* easily added to any map type (US, Region, SingleState, World, etc.)
|
|
10
|
+
*
|
|
11
|
+
*/
|
|
12
|
+
export const useSynchronizedGeographies = () => {
|
|
13
|
+
const { handleSmallMultipleHover, mapRefForSync, tooltipId } = useContext(ConfigContext)
|
|
14
|
+
|
|
15
|
+
// Set up programmatic tooltip control for this map
|
|
16
|
+
const { registerGeoElement, unregisterGeoElement } = useProgrammaticMapTooltip({
|
|
17
|
+
mapRef: mapRefForSync || { current: null },
|
|
18
|
+
tooltipId
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Returns props to spread onto geography elements for synchronized tooltip support
|
|
23
|
+
* Includes ref for programmatic control and data-geo-id for tooltip tracking
|
|
24
|
+
*
|
|
25
|
+
* @param geoKey - Geography identifier (FIPS code, state abbreviation, etc.)
|
|
26
|
+
*/
|
|
27
|
+
const getSyncProps = (geoKey: string) => ({
|
|
28
|
+
ref: (el: SVGElement | null) => {
|
|
29
|
+
if (el) {
|
|
30
|
+
registerGeoElement(geoKey, el)
|
|
31
|
+
} else {
|
|
32
|
+
unregisterGeoElement(geoKey)
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
'data-geo-id': geoKey
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Event handler helpers for synchronized tooltips
|
|
40
|
+
* Call these inside your existing event handlers
|
|
41
|
+
*/
|
|
42
|
+
const syncHandlers = {
|
|
43
|
+
onMouseEnter: (geoKey: string, clientY: number) => {
|
|
44
|
+
handleSmallMultipleHover?.(geoKey, clientY)
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
onMouseLeave: () => {
|
|
48
|
+
handleSmallMultipleHover?.(null)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
getSyncProps,
|
|
54
|
+
syncHandlers
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/hooks/useTooltip.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import { displayDataAsText } from '
|
|
1
|
+
import { displayDataAsText } from '@cdc/core/helpers/displayDataAsText'
|
|
2
2
|
import { displayGeoName } from '../helpers/displayGeoName'
|
|
3
3
|
|
|
4
4
|
const useTooltip = props => {
|
|
5
5
|
const { config, supportedStatesFipsCodes } = props
|
|
6
6
|
|
|
7
|
-
|
|
8
7
|
/**
|
|
9
8
|
* On county maps there's a need to append the state name
|
|
10
9
|
* @param {String} toolTipText - previous tooltip text to build upon
|
package/src/index.jsx
CHANGED
|
@@ -2,8 +2,7 @@ import React from 'react'
|
|
|
2
2
|
import ReactDOM from 'react-dom/client'
|
|
3
3
|
|
|
4
4
|
import '@cdc/core/styles/cove-main.scss'
|
|
5
|
-
import '
|
|
6
|
-
import './coreStyles_map.scss'
|
|
5
|
+
import '@cdc/core/components/EditorPanel/EditorPanel.styles.css'
|
|
7
6
|
|
|
8
7
|
import CdcMap from './CdcMap'
|
|
9
8
|
|