@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.
- package/.claude/agents/typescript-organizer.md +118 -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 +58397 -55987
- package/examples/example-city-state.json +9 -1
- package/examples/multi-country-centering.json +45 -0
- package/examples/private/city_styles_variable.json +877 -0
- package/examples/private/colors-2.json +221 -0
- package/examples/private/colors.json +221 -0
- package/examples/private/map-filter-issue.json +2260 -0
- package/examples/private/map-legend.json +5303 -0
- package/index.html +27 -36
- package/package.json +6 -5
- package/src/CdcMapComponent.tsx +86 -26
- 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 +3426 -0
- package/src/_stories/CdcMap.SmallMultiples.stories.tsx +35 -0
- package/src/_stories/CdcMap.stories.tsx +116 -4
- package/src/_stories/_mock/column-wrap-test.json +265 -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/usa-state-gradient.json +3 -4
- package/src/components/BubbleList.tsx +1 -1
- package/src/components/CityList.tsx +24 -18
- package/src/components/EditorPanel/components/EditorPanel.tsx +2380 -2206
- package/src/components/EditorPanel/components/HexShapeSettings.tsx +55 -93
- package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +0 -19
- package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +27 -37
- package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.tsx +351 -0
- package/src/components/EditorPanel/components/Panels/index.tsx +3 -1
- package/src/components/Geo.tsx +20 -3
- package/src/components/Legend/components/Legend.tsx +58 -75
- package/src/components/Legend/components/LegendGroup/Legend.Group.tsx +1 -1
- package/src/components/Legend/components/index.scss +23 -6
- package/src/components/NavigationMenu.tsx +16 -13
- 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 +18 -3
- 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 +29 -9
- package/src/components/UsaMap/components/Territory/TerritoryShape.ts +7 -0
- package/src/components/UsaMap/components/UsaMap.County.tsx +16 -4
- package/src/components/UsaMap/components/UsaMap.Region.tsx +14 -1
- package/src/components/UsaMap/components/UsaMap.SingleState.tsx +29 -12
- package/src/components/UsaMap/components/UsaMap.State.tsx +30 -5
- package/src/components/UsaMap/helpers/map.ts +2 -2
- package/src/components/UsaMap/helpers/shapes.ts +9 -6
- package/src/components/WorldMap/WorldMap.tsx +81 -11
- package/src/data/initial-state.js +11 -0
- package/src/data/supported-geos.js +8 -76
- package/src/helpers/addUIDs.ts +13 -2
- package/src/helpers/applyColorToLegend.ts +25 -1
- package/src/helpers/applyLegendToRow.ts +5 -3
- package/src/helpers/constants.ts +3 -15
- package/src/helpers/displayGeoName.ts +22 -4
- package/src/helpers/generateRuntimeFilters.ts +1 -1
- package/src/helpers/generateRuntimeLegend.ts +1 -3
- package/src/helpers/generateRuntimeLegendHash.ts +1 -1
- package/src/helpers/getCountriesPicked.ts +103 -0
- package/src/helpers/getMapContainerClasses.ts +7 -0
- package/src/helpers/getPatternForRow.ts +2 -5
- package/src/helpers/index.ts +2 -4
- package/src/helpers/isLegendItemDisabled.ts +2 -2
- package/src/helpers/resetLegendToggles.ts +1 -0
- package/src/helpers/smallMultiplesHelpers.ts +359 -0
- package/src/helpers/tests/hashObj.test.ts +1 -1
- package/src/helpers/tests/titleCase.test.ts +76 -0
- package/src/helpers/titleCase.ts +13 -13
- package/src/helpers/toggleLegendActive.ts +76 -8
- package/src/helpers/urlDataHelpers.ts +1 -1
- package/src/hooks/useCountryZoom.tsx +241 -0
- package/src/hooks/useGeoClickHandler.ts +1 -1
- package/src/hooks/useProgrammaticMapTooltip.ts +110 -0
- package/src/hooks/useResizeObserver.ts +8 -2
- package/src/hooks/useStateZoom.tsx +7 -4
- package/src/hooks/useSynchronizedGeographies.ts +56 -0
- package/src/index.jsx +1 -0
- 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/test/CdcMap.test.jsx +1 -1
- package/src/types/MapConfig.ts +32 -11
- package/src/types/MapContext.ts +6 -0
- package/src/types/runtimeLegend.ts +2 -1
- package/LICENSE +0 -201
- package/src/components/DataTable.tsx +0 -413
- package/src/components/EditorPanel/components/Inputs.tsx +0 -59
- package/src/components/MapControls.tsx +0 -44
- package/src/helpers/getUniqueValues.ts +0 -19
- package/src/helpers/hashObj.ts +0 -25
- package/src/hooks/useActiveElement.ts +0 -19
- package/src/hooks/useLegendSeparators.ts +0 -26
- 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,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
|
+
})
|
package/src/helpers/titleCase.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
[
|
|
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
|
|