@cdc/map 4.25.10 → 4.26.1

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 (107) hide show
  1. package/.claude/agents/typescript-organizer.md +118 -0
  2. package/dist/{cdcmap-fce76882.es.js → cdcmap-BnB1QM5d.es.js} +6 -13
  3. package/dist/{cdcmap-c55ac1ea.es.js → cdcmap-D6CG2-Hb.es.js} +5 -12
  4. package/dist/{cdcmap-31a33da1.es.js → cdcmap-MXgURbdZ.es.js} +6 -13
  5. package/dist/{cdcmap-1a1724a1.es.js → cdcmap-dgT_1dIT.es.js} +136 -151
  6. package/dist/cdcmap.js +58397 -55987
  7. package/examples/example-city-state.json +9 -1
  8. package/examples/multi-country-centering.json +45 -0
  9. package/examples/private/city_styles_variable.json +877 -0
  10. package/examples/private/colors-2.json +221 -0
  11. package/examples/private/colors.json +221 -0
  12. package/examples/private/map-filter-issue.json +2260 -0
  13. package/examples/private/map-legend.json +5303 -0
  14. package/index.html +27 -36
  15. package/package.json +6 -5
  16. package/src/CdcMapComponent.tsx +86 -26
  17. package/src/_stories/CdcMap.ColumnWrap.stories.tsx +31 -0
  18. package/src/_stories/CdcMap.DistrictOfColumbia.stories.tsx +320 -0
  19. package/src/_stories/CdcMap.Editor.stories.tsx +3426 -0
  20. package/src/_stories/CdcMap.SmallMultiples.stories.tsx +35 -0
  21. package/src/_stories/CdcMap.stories.tsx +116 -4
  22. package/src/_stories/_mock/column-wrap-test.json +265 -0
  23. package/src/_stories/_mock/multi-country-hide.json +78 -0
  24. package/src/_stories/_mock/multi-country.json +95 -0
  25. package/src/_stories/_mock/multi-state.json +887 -20403
  26. package/src/_stories/_mock/small_multiples/multi-state-small-multiples.json +8399 -0
  27. package/src/_stories/_mock/small_multiples/region-small-multiples.json +657 -0
  28. package/src/_stories/_mock/small_multiples/wastewater-map-small-multiples.json +221 -0
  29. package/src/_stories/_mock/usa-state-gradient.json +3 -4
  30. package/src/components/BubbleList.tsx +1 -1
  31. package/src/components/CityList.tsx +24 -18
  32. package/src/components/EditorPanel/components/EditorPanel.tsx +2380 -2206
  33. package/src/components/EditorPanel/components/HexShapeSettings.tsx +55 -93
  34. package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +0 -19
  35. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +27 -37
  36. package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.tsx +351 -0
  37. package/src/components/EditorPanel/components/Panels/index.tsx +3 -1
  38. package/src/components/Geo.tsx +20 -3
  39. package/src/components/Legend/components/Legend.tsx +58 -75
  40. package/src/components/Legend/components/LegendGroup/Legend.Group.tsx +1 -1
  41. package/src/components/Legend/components/index.scss +23 -6
  42. package/src/components/NavigationMenu.tsx +16 -13
  43. package/src/components/SmallMultiples/SmallMultipleTile.tsx +163 -0
  44. package/src/components/SmallMultiples/SmallMultiples.css +32 -0
  45. package/src/components/SmallMultiples/SmallMultiples.tsx +150 -0
  46. package/src/components/SmallMultiples/SynchronizedTooltip.tsx +105 -0
  47. package/src/components/SmallMultiples/index.tsx +3 -0
  48. package/src/components/UsaMap/components/SingleState/SingleState.CountyOutput.tsx +18 -3
  49. package/src/components/UsaMap/components/TerritoriesSection.tsx +26 -12
  50. package/src/components/UsaMap/components/Territory/Territory.Hexagon.tsx +30 -4
  51. package/src/components/UsaMap/components/Territory/Territory.Rectangle.tsx +29 -9
  52. package/src/components/UsaMap/components/Territory/TerritoryShape.ts +7 -0
  53. package/src/components/UsaMap/components/UsaMap.County.tsx +16 -4
  54. package/src/components/UsaMap/components/UsaMap.Region.tsx +14 -1
  55. package/src/components/UsaMap/components/UsaMap.SingleState.tsx +29 -12
  56. package/src/components/UsaMap/components/UsaMap.State.tsx +30 -5
  57. package/src/components/UsaMap/helpers/map.ts +2 -2
  58. package/src/components/UsaMap/helpers/shapes.ts +9 -6
  59. package/src/components/WorldMap/WorldMap.tsx +81 -11
  60. package/src/data/initial-state.js +11 -0
  61. package/src/data/supported-geos.js +8 -76
  62. package/src/helpers/addUIDs.ts +13 -2
  63. package/src/helpers/applyColorToLegend.ts +25 -1
  64. package/src/helpers/applyLegendToRow.ts +5 -3
  65. package/src/helpers/constants.ts +3 -15
  66. package/src/helpers/displayGeoName.ts +22 -4
  67. package/src/helpers/generateRuntimeFilters.ts +1 -1
  68. package/src/helpers/generateRuntimeLegend.ts +1 -3
  69. package/src/helpers/generateRuntimeLegendHash.ts +1 -1
  70. package/src/helpers/getCountriesPicked.ts +103 -0
  71. package/src/helpers/getMapContainerClasses.ts +7 -0
  72. package/src/helpers/getPatternForRow.ts +2 -5
  73. package/src/helpers/index.ts +2 -4
  74. package/src/helpers/isLegendItemDisabled.ts +2 -2
  75. package/src/helpers/resetLegendToggles.ts +1 -0
  76. package/src/helpers/smallMultiplesHelpers.ts +359 -0
  77. package/src/helpers/tests/hashObj.test.ts +1 -1
  78. package/src/helpers/tests/titleCase.test.ts +76 -0
  79. package/src/helpers/titleCase.ts +13 -13
  80. package/src/helpers/toggleLegendActive.ts +76 -8
  81. package/src/helpers/urlDataHelpers.ts +1 -1
  82. package/src/hooks/useCountryZoom.tsx +241 -0
  83. package/src/hooks/useGeoClickHandler.ts +1 -1
  84. package/src/hooks/useProgrammaticMapTooltip.ts +110 -0
  85. package/src/hooks/useResizeObserver.ts +8 -2
  86. package/src/hooks/useStateZoom.tsx +7 -4
  87. package/src/hooks/useSynchronizedGeographies.ts +56 -0
  88. package/src/index.jsx +1 -0
  89. package/src/scss/editor-panel.scss +4 -440
  90. package/src/scss/main.scss +1 -1
  91. package/src/scss/map.scss +12 -15
  92. package/src/store/map.actions.ts +7 -7
  93. package/src/test/CdcMap.test.jsx +1 -1
  94. package/src/types/MapConfig.ts +32 -11
  95. package/src/types/MapContext.ts +6 -0
  96. package/src/types/runtimeLegend.ts +2 -1
  97. package/LICENSE +0 -201
  98. package/src/components/DataTable.tsx +0 -413
  99. package/src/components/EditorPanel/components/Inputs.tsx +0 -59
  100. package/src/components/MapControls.tsx +0 -44
  101. package/src/helpers/getUniqueValues.ts +0 -19
  102. package/src/helpers/hashObj.ts +0 -25
  103. package/src/hooks/useActiveElement.ts +0 -19
  104. package/src/hooks/useLegendSeparators.ts +0 -26
  105. package/src/scss/mixins.scss +0 -47
  106. package/src/types/Annotations.ts +0 -24
  107. /package/dist/{cdcmap-548642e6.es.js → cdcmap-Ct2SB0vL.es.js} +0 -0
@@ -0,0 +1,76 @@
1
+ import { titleCase } from '../titleCase'
2
+
3
+ describe('titleCase', () => {
4
+ it('should return undefined for falsy input', () => {
5
+ expect(titleCase(undefined)).toBeUndefined()
6
+ expect(titleCase(null)).toBeUndefined()
7
+ expect(titleCase('')).toBeUndefined()
8
+ })
9
+
10
+ it('should convert simple strings to title case', () => {
11
+ expect(titleCase('hello world')).toBe('Hello World')
12
+ expect(titleCase('HELLO WORLD')).toBe('Hello World')
13
+ expect(titleCase('HeLLo WoRLd')).toBe('Hello World')
14
+ })
15
+
16
+ it('should keep "of", "the", and "and" lowercase in title case', () => {
17
+ expect(titleCase('DISTRICT OF COLUMBIA')).toBe('District of Columbia')
18
+ expect(titleCase('District Of Columbia')).toBe('District of Columbia')
19
+ expect(titleCase('district of columbia')).toBe('District of Columbia')
20
+ })
21
+
22
+ it('should handle "Federated States of Micronesia" correctly', () => {
23
+ expect(titleCase('FEDERATED STATES OF MICRONESIA')).toBe('Federated States of Micronesia')
24
+ expect(titleCase('Federated States Of Micronesia')).toBe('Federated States of Micronesia')
25
+ })
26
+
27
+ it('should handle "Republic of the Congo" correctly', () => {
28
+ expect(titleCase('REPUBLIC OF THE CONGO')).toBe('Republic of the Congo')
29
+ expect(titleCase('Republic Of The Congo')).toBe('Republic of the Congo')
30
+ })
31
+
32
+ it('should handle "and" in country names correctly', () => {
33
+ expect(titleCase('ANTIGUA AND BARBUDA')).toBe('Antigua and Barbuda')
34
+ expect(titleCase('TRINIDAD AND TOBAGO')).toBe('Trinidad and Tobago')
35
+ expect(titleCase('BOSNIA AND HERZEGOVINA')).toBe('Bosnia and Herzegovina')
36
+ })
37
+
38
+ it('should handle hyphenated strings correctly', () => {
39
+ expect(titleCase('INTER-TRIBAL INDIAN RESERVATION')).toBe('Inter-Tribal Indian Reservation')
40
+ expect(titleCase('inter-tribal indian reservation')).toBe('Inter-Tribal Indian Reservation')
41
+ })
42
+
43
+ it('should handle en dash strings correctly', () => {
44
+ expect(titleCase('PUERTO RICO–VIRGIN ISLANDS')).toBe('Puerto Rico–Virgin Islands')
45
+ expect(titleCase('puerto rico–virgin islands')).toBe('Puerto Rico–Virgin Islands')
46
+ })
47
+
48
+ it('should handle mixed case strings with "of"', () => {
49
+ expect(titleCase('UNIVERSITY OF WASHINGTON')).toBe('University of Washington')
50
+ expect(titleCase('STATE OF ALASKA')).toBe('State of Alaska')
51
+ })
52
+
53
+ it('should handle single words', () => {
54
+ expect(titleCase('CALIFORNIA')).toBe('California')
55
+ expect(titleCase('california')).toBe('California')
56
+ })
57
+
58
+ it('should handle strings with multiple "of" and "the" occurrences', () => {
59
+ expect(titleCase('OFFICE OF THE STATE OF CALIFORNIA')).toBe('Office of the State of California')
60
+ expect(titleCase('DEMOCRATIC REPUBLIC OF THE CONGO')).toBe('Democratic Republic of the Congo')
61
+ })
62
+
63
+ it('should handle hyphenated strings with "of", "the", and "and"', () => {
64
+ expect(titleCase('KINGDOM OF THE NETHERLANDS-ARUBA')).toBe('Kingdom of the Netherlands-Aruba')
65
+ expect(titleCase('SAINT VINCENT AND THE GRENADINES-ISLAND')).toBe('Saint Vincent and the Grenadines-Island')
66
+ })
67
+
68
+ it('should handle "Commonwealth of the Northern Mariana Islands"', () => {
69
+ expect(titleCase('COMMONWEALTH OF THE NORTHERN MARIANA ISLANDS')).toBe(
70
+ 'Commonwealth of the Northern Mariana Islands'
71
+ )
72
+ expect(titleCase('commonwealth of the northern mariana islands')).toBe(
73
+ 'Commonwealth of the Northern Mariana Islands'
74
+ )
75
+ })
76
+ })
@@ -5,25 +5,25 @@ export const titleCase = string => {
5
5
  // guard clause else error in editor
6
6
  if (!string) return
7
7
  if (string !== undefined) {
8
+ // Words that should remain lowercase in geographic names
9
+ const lowercaseWords = ['of', 'the', 'and']
10
+
11
+ const titleCaseWord = (word: string): string => {
12
+ const lowerWord = word.toLowerCase()
13
+ return lowercaseWords.includes(lowerWord)
14
+ ? lowerWord
15
+ : word.charAt(0).toUpperCase() + word.substring(1).toLowerCase()
16
+ }
17
+
8
18
  // if hyphen found, then split, uppercase each word, and put back together
9
19
  if (string.includes('–') || string.includes('-')) {
10
20
  let dashSplit = string.includes('–') ? string.split('–') : string.split('-') // determine hyphen or en dash to split on
11
21
  let splitCharacter = string.includes('–') ? '–' : '-' // print hyphen or en dash later on.
12
- let frontSplit = dashSplit[0]
13
- .split(' ')
14
- .map(word => word.charAt(0).toUpperCase() + word.substring(1).toLowerCase())
15
- .join(' ')
16
- let backSplit = dashSplit[1]
17
- .split(' ')
18
- .map(word => word.charAt(0).toUpperCase() + word.substring(1).toLowerCase())
19
- .join(' ')
22
+ let frontSplit = dashSplit[0].split(' ').map(titleCaseWord).join(' ')
23
+ let backSplit = dashSplit[1].split(' ').map(titleCaseWord).join(' ')
20
24
  return frontSplit + splitCharacter + backSplit
21
25
  } else {
22
- // just return with each word uppercase
23
- return string
24
- .split(' ')
25
- .map(word => (word === 'of' ? word : word.charAt(0).toUpperCase() + word.substring(1).toLowerCase()))
26
- .join(' ')
26
+ return string.split(' ').map(titleCaseWord).join(' ')
27
27
  }
28
28
  }
29
29
  }
@@ -1,20 +1,88 @@
1
1
  import _ from 'lodash'
2
2
 
3
- export const toggleLegendActive = (i: number, legendLabel: string, runtimeLegend, dispatch) => {
4
- const runtimeLegendCopy = _.cloneDeep(runtimeLegend)
3
+ export const toggleLegendActive = (i: number, legendLabel: string, runtimeLegend, dispatch, legendBehavior) => {
4
+ let runtimeLegendCopy = _.cloneDeep(runtimeLegend)
5
+ let items = runtimeLegendCopy.items || []
6
+ let disabledAmt = runtimeLegendCopy.disabledAmt || 0
7
+ const behavior = (legendBehavior || 'highlight').toLowerCase()
5
8
 
6
- // Create and toggle the new value
7
- const newValue = !runtimeLegendCopy.items?.[i].disabled
8
- runtimeLegendCopy.items[i].disabled = newValue
9
+ // "Isolate" behavior that exclusively shows only the clicked item
9
10
 
10
- const disabledAmt = runtimeLegend.disabledAmt ?? 0
11
+ // TODO: DEV-7271 Follow-up to implement option to isolate on legend click. For now, always highlight.
12
+ // if (behavior === 'isolate') {
13
+ if (false) {
14
+ // With an existing hidden item, just toggle the item clicked on
15
+ const hasExistingHidden = items.some(item => item.hidden === true)
16
+ if (hasExistingHidden) {
17
+ items = items.map((item, index) => {
18
+ if (index === i) {
19
+ return { ...item, hidden: !item.hidden }
20
+ }
21
+ return item
22
+ })
11
23
 
12
- runtimeLegendCopy['disabledAmt'] = newValue ? disabledAmt + 1 : disabledAmt - 1
24
+ // When no existing hidden items, isolate to only the clicked item
25
+ } else {
26
+ items = items.map((item, index) => {
27
+ if (index !== i) {
28
+ return { ...item, hidden: true }
29
+ }
30
+ return { ...item, hidden: false }
31
+ })
32
+ }
33
+
34
+ // In any case, prevent all items from being hidden and have them all enabled instead
35
+ if (items.every(item => item.hidden === true)) {
36
+ items = items.map(item => ({ ...item, hidden: false }))
37
+ }
38
+
39
+ disabledAmt = items.filter(item => item.hidden).length
40
+
41
+ // Default "Highlight" behavior where other items are dimmed or grayed out
42
+ } else {
43
+ if (behavior !== 'highlight') {
44
+ console.warn(`Unknown legend behavior: ${legendBehavior}. Defaulting to 'highlight'.`)
45
+ }
46
+
47
+ // With an existing disabled item, just toggle the item clicked on
48
+ const hasExistingDisabled = items.some(item => item.disabled === true)
49
+ if (hasExistingDisabled) {
50
+ items = items.map((item, index) => {
51
+ if (index === i) {
52
+ return { ...item, disabled: !item.disabled }
53
+ }
54
+ return item
55
+ })
56
+
57
+ // When no existing disabled items, enable only (highlight) the clicked item
58
+ } else {
59
+ items = items.map((item, index) => {
60
+ if (index !== i) {
61
+ return { ...item, disabled: true }
62
+ }
63
+ return { ...item, disabled: false }
64
+ })
65
+ }
66
+
67
+ // In any case, prevent all items from being disabled and have them all enabled instead
68
+ if (items.every(item => item.disabled === true)) {
69
+ items = items.map(item => ({ ...item, disabled: false }))
70
+ }
71
+
72
+ disabledAmt = items.filter(item => item.disabled).length
73
+ }
74
+
75
+ // Set results
76
+
77
+ runtimeLegendCopy.items = items
78
+ runtimeLegendCopy['disabledAmt'] = disabledAmt
13
79
 
14
80
  dispatch({ type: 'SET_RUNTIME_LEGEND', payload: runtimeLegendCopy })
15
81
 
16
82
  dispatch({
17
83
  type: 'SET_ACCESSIBLE_STATUS',
18
- payload: `Disabled legend item ${legendLabel ?? ''}. Please reference the data table to see updated values.`
84
+ payload: `${behavior === 'highlight' ? 'Highlighted' : 'Isolated'} legend item ${
85
+ legendLabel ?? ''
86
+ }. Please reference the data table to see updated values.`
19
87
  })
20
88
  }
@@ -6,7 +6,7 @@ import { MapConfig } from '../types/MapConfig'
6
6
  import { CSV_PARSE_CONFIG } from './constants'
7
7
  import { cloneConfig } from '@cdc/core/helpers/cloneConfig'
8
8
 
9
- export const buildQueryString = (params: Record<string, string>): string =>
9
+ const buildQueryString = (params: Record<string, string>): string =>
10
10
  Object.keys(params)
11
11
  .map((param, i) => {
12
12
  let qs = i === 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
@@ -31,7 +31,7 @@ const useGeoClickHandler = () => {
31
31
  }
32
32
 
33
33
  // If modals are set, or we are on a mobile viewport, display modal
34
- if (window.matchMedia('(any-hover: none)').matches || 'click' === state.tooltips.appearanceType) {
34
+ if ('click' === state.tooltips.appearanceType) {
35
35
  const modalData = {
36
36
  geoName: geoDisplayName,
37
37
  keyedData: geoData
@@ -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,23 @@ 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
20
+
21
+ // Account for 1rem padding in editor mode
22
+ width = width - (isEditor ? 36 : 0)
18
23
 
19
24
  const newViewport = getViewport(width)
20
25
 
21
26
  setCurrentViewport(newViewport)
27
+ setVizViewport(newViewport)
22
28
 
23
29
  setDimensions([width, height])
24
30
  }
@@ -35,7 +41,7 @@ export const useResizeObserver = (isEditor: boolean) => {
35
41
  }
36
42
  }, [])
37
43
 
38
- return { resizeObserver, dimensions, currentViewport, outerContainerRef, container }
44
+ return { resizeObserver, dimensions, currentViewport, vizViewport, outerContainerRef, container }
39
45
  }
40
46
 
41
47
  export default useResizeObserver
@@ -67,12 +67,14 @@ const useSetScaleAndTranslate = (topoData: { states: StateData[] }) => {
67
67
  const featureCenter = combinedData ? path.centroid(combinedData as any) : [0, 0]
68
68
  const stateCenter = newProjection.invert(featureCenter)
69
69
 
70
- return { projection, newProjection, stateCenter }
70
+ const bounds = combinedData ? path.bounds(combinedData as any) : null
71
+
72
+ return { projection, newProjection, stateCenter, bounds }
71
73
  }, [topoData, statesPicked])
72
74
 
73
75
  const setScaleAndTranslate = useCallback(
74
76
  (zoomFunction: string = '') => {
75
- const _prevPosition = config.mapPosition
77
+ const _prevPosition = position
76
78
  let newZoom = _prevPosition.zoom
77
79
  let newCoordinates = _prevPosition.coordinates
78
80
  if (zoomFunction === 'zoomIn' && _prevPosition.zoom < 4) {
@@ -116,7 +118,7 @@ const useSetScaleAndTranslate = (topoData: { states: StateData[] }) => {
116
118
  })
117
119
  }
118
120
  },
119
- [config.mapPosition, projectionData.stateCenter, interactionLabel, dispatch]
121
+ [position, projectionData.stateCenter, interactionLabel, dispatch]
120
122
  )
121
123
 
122
124
  // Essential fix: Remove config from dependencies to prevent infinite loops
@@ -176,7 +178,8 @@ const useSetScaleAndTranslate = (topoData: { states: StateData[] }) => {
176
178
  handleZoomOut,
177
179
  handleMoveEnd,
178
180
  handleZoomReset,
179
- projection: projectionData.projection
181
+ projection: projectionData.projection,
182
+ bounds: projectionData.bounds
180
183
  }
181
184
  }
182
185