@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.
Files changed (137) hide show
  1. package/.claude/agents/typescript-organizer.md +118 -0
  2. package/.claude/settings.local.json +30 -0
  3. package/dist/{cdcmap-fce76882.es.js → cdcmap-BnB1QM5d.es.js} +6 -13
  4. package/dist/{cdcmap-c55ac1ea.es.js → cdcmap-D6CG2-Hb.es.js} +5 -12
  5. package/dist/{cdcmap-31a33da1.es.js → cdcmap-MXgURbdZ.es.js} +6 -13
  6. package/dist/{cdcmap-1a1724a1.es.js → cdcmap-dgT_1dIT.es.js} +136 -151
  7. package/dist/cdcmap.js +56991 -53706
  8. package/examples/example-city-state.json +9 -1
  9. package/examples/multi-country-centering.json +45 -0
  10. package/examples/private/c.json +290 -0
  11. package/examples/private/canvas-city-hover.json +787 -0
  12. package/examples/private/colors-2.json +221 -0
  13. package/examples/private/colors.json +221 -0
  14. package/examples/private/d.json +345 -0
  15. package/examples/private/g.json +1 -0
  16. package/examples/private/h.json +105911 -0
  17. package/examples/private/measles-data.json +378 -0
  18. package/examples/private/measles.json +211 -0
  19. package/examples/private/north-dakota.json +1132 -0
  20. package/examples/private/state-with-pattern.json +883 -0
  21. package/index.html +36 -34
  22. package/package.json +26 -5
  23. package/src/CdcMap.tsx +23 -8
  24. package/src/CdcMapComponent.tsx +238 -308
  25. package/src/_stories/CdcMap.ColumnWrap.stories.tsx +31 -0
  26. package/src/_stories/CdcMap.DistrictOfColumbia.stories.tsx +320 -0
  27. package/src/_stories/CdcMap.Editor.stories.tsx +3371 -0
  28. package/src/_stories/CdcMap.Filters.stories.tsx +2 -2
  29. package/src/_stories/CdcMap.Legend.Gradient.stories.tsx +3 -3
  30. package/src/_stories/CdcMap.Legend.stories.tsx +7 -4
  31. package/src/_stories/CdcMap.Patterns.stories.tsx +2 -2
  32. package/src/_stories/CdcMap.SmallMultiples.stories.tsx +35 -0
  33. package/src/_stories/CdcMap.Table.stories.tsx +2 -2
  34. package/src/_stories/CdcMap.stories.tsx +37 -9
  35. package/src/_stories/GoogleMap.stories.tsx +2 -2
  36. package/src/_stories/UsaMap.NoData.stories.tsx +2 -2
  37. package/src/_stories/_mock/column-wrap-test.json +265 -0
  38. package/src/_stories/_mock/equal-number.json +1109 -0
  39. package/src/_stories/_mock/multi-country-hide.json +78 -0
  40. package/src/_stories/_mock/multi-country.json +95 -0
  41. package/src/_stories/_mock/multi-state.json +887 -20403
  42. package/src/_stories/_mock/small_multiples/multi-state-small-multiples.json +8399 -0
  43. package/src/_stories/_mock/small_multiples/region-small-multiples.json +657 -0
  44. package/src/_stories/_mock/small_multiples/wastewater-map-small-multiples.json +221 -0
  45. package/src/_stories/_mock/us-bubble-cities.json +306 -0
  46. package/src/_stories/_mock/usa-state-gradient.json +2 -4
  47. package/src/components/BubbleList.tsx +17 -13
  48. package/src/components/CityList.tsx +85 -107
  49. package/src/components/EditorPanel/components/EditorPanel.tsx +787 -709
  50. package/src/components/EditorPanel/components/HexShapeSettings.tsx +58 -95
  51. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +34 -42
  52. package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.tsx +354 -0
  53. package/src/components/EditorPanel/components/Panels/index.tsx +3 -1
  54. package/src/components/Geo.tsx +22 -3
  55. package/src/components/Legend/components/Legend.tsx +76 -40
  56. package/src/components/Legend/components/LegendGroup/Legend.Group.tsx +10 -7
  57. package/src/components/Legend/components/index.scss +1 -1
  58. package/src/components/MapContainer.tsx +52 -0
  59. package/src/components/MapControls.tsx +44 -0
  60. package/src/components/NavigationMenu.tsx +27 -15
  61. package/src/components/SmallMultiples/SmallMultipleTile.tsx +163 -0
  62. package/src/components/SmallMultiples/SmallMultiples.css +32 -0
  63. package/src/components/SmallMultiples/SmallMultiples.tsx +150 -0
  64. package/src/components/SmallMultiples/SynchronizedTooltip.tsx +105 -0
  65. package/src/components/SmallMultiples/index.tsx +3 -0
  66. package/src/components/UsaMap/components/SingleState/SingleState.CountyOutput.tsx +36 -4
  67. package/src/components/UsaMap/components/TerritoriesSection.tsx +26 -12
  68. package/src/components/UsaMap/components/Territory/Territory.Hexagon.tsx +30 -4
  69. package/src/components/UsaMap/components/Territory/Territory.Rectangle.tsx +23 -4
  70. package/src/components/UsaMap/components/Territory/TerritoryShape.ts +6 -0
  71. package/src/components/UsaMap/components/UsaMap.County.tsx +123 -37
  72. package/src/components/UsaMap/components/UsaMap.Region.tsx +36 -5
  73. package/src/components/UsaMap/components/UsaMap.SingleState.tsx +30 -10
  74. package/src/components/UsaMap/components/UsaMap.State.tsx +53 -12
  75. package/src/components/UsaMap/helpers/map.ts +4 -4
  76. package/src/components/UsaMap/helpers/shapes.ts +9 -6
  77. package/src/components/WorldMap/WorldMap.tsx +193 -35
  78. package/src/components/ZoomControls.tsx +6 -9
  79. package/src/context/LegendMemoContext.tsx +30 -0
  80. package/src/context.ts +1 -40
  81. package/src/data/initial-state.js +153 -130
  82. package/src/data/supported-geos.js +25 -78
  83. package/src/helpers/addUIDs.ts +13 -2
  84. package/src/helpers/applyColorToLegend.ts +140 -20
  85. package/src/helpers/applyLegendToRow.ts +10 -6
  86. package/src/helpers/componentHelpers.ts +8 -0
  87. package/src/helpers/constants.ts +12 -14
  88. package/src/helpers/dataTableHelpers.ts +6 -0
  89. package/src/helpers/displayGeoName.ts +18 -3
  90. package/src/helpers/generateRuntimeLegend.ts +44 -10
  91. package/src/helpers/generateRuntimeLegendHash.ts +4 -2
  92. package/src/helpers/getColumnNames.ts +1 -1
  93. package/src/helpers/getCountriesPicked.ts +103 -0
  94. package/src/helpers/getMapContainerClasses.ts +7 -0
  95. package/src/helpers/getPatternForRow.ts +33 -0
  96. package/src/helpers/getStatesPicked.ts +8 -5
  97. package/src/helpers/index.ts +3 -3
  98. package/src/helpers/isLegendItemDisabled.ts +16 -0
  99. package/src/helpers/mapObserverHelpers.ts +40 -0
  100. package/src/helpers/resetLegendToggles.ts +3 -2
  101. package/src/helpers/smallMultiplesHelpers.ts +359 -0
  102. package/src/helpers/tests/titleCase.test.ts +76 -0
  103. package/src/helpers/titleCase.ts +13 -13
  104. package/src/helpers/toggleLegendActive.ts +6 -11
  105. package/src/helpers/urlDataHelpers.ts +70 -0
  106. package/src/hooks/useCountryZoom.tsx +241 -0
  107. package/src/hooks/useGeoClickHandler.ts +36 -2
  108. package/src/hooks/useLegendMemo.ts +17 -0
  109. package/src/hooks/useMapLayers.tsx +5 -4
  110. package/src/hooks/useProgrammaticMapTooltip.ts +110 -0
  111. package/src/hooks/useResizeObserver.ts +5 -2
  112. package/src/hooks/useStateZoom.tsx +30 -8
  113. package/src/hooks/useSynchronizedGeographies.ts +56 -0
  114. package/src/hooks/useTooltip.ts +1 -2
  115. package/src/index.jsx +1 -2
  116. package/src/scss/editor-panel.scss +4 -440
  117. package/src/scss/main.scss +1 -1
  118. package/src/scss/map.scss +12 -15
  119. package/src/store/map.actions.ts +7 -7
  120. package/src/store/map.reducer.ts +17 -6
  121. package/src/test/CdcMap.test.jsx +11 -0
  122. package/src/types/MapConfig.ts +46 -18
  123. package/src/types/MapContext.ts +6 -7
  124. package/src/types/runtimeLegend.ts +17 -1
  125. package/vite.config.js +2 -7
  126. package/vitest.config.ts +16 -0
  127. package/src/components/DataTable.tsx +0 -385
  128. package/src/components/EditorPanel/components/Inputs.tsx +0 -59
  129. package/src/coreStyles_map.scss +0 -3
  130. package/src/helpers/colorDistributions.ts +0 -12
  131. package/src/helpers/generateColorsArray.ts +0 -14
  132. package/src/helpers/tests/generateColorsArray.test.ts +0 -18
  133. package/src/helpers/tests/generateRuntimeLegendHash.test.ts +0 -11
  134. package/src/hooks/useActiveElement.ts +0 -19
  135. package/src/scss/mixins.scss +0 -47
  136. package/src/types/Annotations.ts +0 -24
  137. /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 { config: state, setConfig, setSharedFilter, customNavigationHandler } = useContext(ConfigContext)
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 (window.matchMedia('(any-hover: none)').matches || 'click' === state.tooltips.appearanceType) {
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 = _.cloneDeep(config)
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 = _.cloneDeep(config)
62
+ const newConfig = cloneConfig(config)
62
63
  newConfig.map.layers.unshift(placeHolderLayer)
63
- setConfig( newConfig )
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
- width = isEditor ? width - EDITOR_WIDTH : width
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
- return { projection, newProjection, stateCenter }
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('map_zoomed_in', 'click', `${interactionLabel}|${newZoom}|${newCoordinates}`, 'map')
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('map_reset_zoom_level', 'click', interactionLabel, 'map')
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 handleReset = useCallback(() => {
149
- setScaleAndTranslate('reset')
150
- }, [setScaleAndTranslate])
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
- handleReset,
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
+ }
@@ -1,10 +1,9 @@
1
- import { displayDataAsText } from '../../../core/helpers/displayDataAsText'
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 'react-tooltip/dist/react-tooltip.css'
6
- import './coreStyles_map.scss'
5
+ import '@cdc/core/components/EditorPanel/EditorPanel.styles.css'
7
6
 
8
7
  import CdcMap from './CdcMap'
9
8