@cdc/map 4.25.8 → 4.25.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/agents/typescript-organizer.md +118 -0
- package/.claude/settings.local.json +30 -0
- package/dist/{cdcmap-fce76882.es.js → cdcmap-BnB1QM5d.es.js} +6 -13
- package/dist/{cdcmap-c55ac1ea.es.js → cdcmap-D6CG2-Hb.es.js} +5 -12
- package/dist/{cdcmap-31a33da1.es.js → cdcmap-MXgURbdZ.es.js} +6 -13
- package/dist/{cdcmap-1a1724a1.es.js → cdcmap-dgT_1dIT.es.js} +136 -151
- package/dist/cdcmap.js +56991 -53706
- package/examples/example-city-state.json +9 -1
- package/examples/multi-country-centering.json +45 -0
- package/examples/private/c.json +290 -0
- package/examples/private/canvas-city-hover.json +787 -0
- package/examples/private/colors-2.json +221 -0
- package/examples/private/colors.json +221 -0
- package/examples/private/d.json +345 -0
- package/examples/private/g.json +1 -0
- package/examples/private/h.json +105911 -0
- package/examples/private/measles-data.json +378 -0
- package/examples/private/measles.json +211 -0
- package/examples/private/north-dakota.json +1132 -0
- package/examples/private/state-with-pattern.json +883 -0
- package/index.html +36 -34
- package/package.json +26 -5
- package/src/CdcMap.tsx +23 -8
- package/src/CdcMapComponent.tsx +238 -308
- package/src/_stories/CdcMap.ColumnWrap.stories.tsx +31 -0
- package/src/_stories/CdcMap.DistrictOfColumbia.stories.tsx +320 -0
- package/src/_stories/CdcMap.Editor.stories.tsx +3371 -0
- package/src/_stories/CdcMap.Filters.stories.tsx +2 -2
- package/src/_stories/CdcMap.Legend.Gradient.stories.tsx +3 -3
- package/src/_stories/CdcMap.Legend.stories.tsx +7 -4
- package/src/_stories/CdcMap.Patterns.stories.tsx +2 -2
- package/src/_stories/CdcMap.SmallMultiples.stories.tsx +35 -0
- package/src/_stories/CdcMap.Table.stories.tsx +2 -2
- package/src/_stories/CdcMap.stories.tsx +37 -9
- package/src/_stories/GoogleMap.stories.tsx +2 -2
- package/src/_stories/UsaMap.NoData.stories.tsx +2 -2
- package/src/_stories/_mock/column-wrap-test.json +265 -0
- package/src/_stories/_mock/equal-number.json +1109 -0
- package/src/_stories/_mock/multi-country-hide.json +78 -0
- package/src/_stories/_mock/multi-country.json +95 -0
- package/src/_stories/_mock/multi-state.json +887 -20403
- package/src/_stories/_mock/small_multiples/multi-state-small-multiples.json +8399 -0
- package/src/_stories/_mock/small_multiples/region-small-multiples.json +657 -0
- package/src/_stories/_mock/small_multiples/wastewater-map-small-multiples.json +221 -0
- package/src/_stories/_mock/us-bubble-cities.json +306 -0
- package/src/_stories/_mock/usa-state-gradient.json +2 -4
- package/src/components/BubbleList.tsx +17 -13
- package/src/components/CityList.tsx +85 -107
- package/src/components/EditorPanel/components/EditorPanel.tsx +787 -709
- package/src/components/EditorPanel/components/HexShapeSettings.tsx +58 -95
- package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +34 -42
- package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.tsx +354 -0
- package/src/components/EditorPanel/components/Panels/index.tsx +3 -1
- package/src/components/Geo.tsx +22 -3
- package/src/components/Legend/components/Legend.tsx +76 -40
- package/src/components/Legend/components/LegendGroup/Legend.Group.tsx +10 -7
- package/src/components/Legend/components/index.scss +1 -1
- package/src/components/MapContainer.tsx +52 -0
- package/src/components/MapControls.tsx +44 -0
- package/src/components/NavigationMenu.tsx +27 -15
- package/src/components/SmallMultiples/SmallMultipleTile.tsx +163 -0
- package/src/components/SmallMultiples/SmallMultiples.css +32 -0
- package/src/components/SmallMultiples/SmallMultiples.tsx +150 -0
- package/src/components/SmallMultiples/SynchronizedTooltip.tsx +105 -0
- package/src/components/SmallMultiples/index.tsx +3 -0
- package/src/components/UsaMap/components/SingleState/SingleState.CountyOutput.tsx +36 -4
- package/src/components/UsaMap/components/TerritoriesSection.tsx +26 -12
- package/src/components/UsaMap/components/Territory/Territory.Hexagon.tsx +30 -4
- package/src/components/UsaMap/components/Territory/Territory.Rectangle.tsx +23 -4
- package/src/components/UsaMap/components/Territory/TerritoryShape.ts +6 -0
- package/src/components/UsaMap/components/UsaMap.County.tsx +123 -37
- package/src/components/UsaMap/components/UsaMap.Region.tsx +36 -5
- package/src/components/UsaMap/components/UsaMap.SingleState.tsx +30 -10
- package/src/components/UsaMap/components/UsaMap.State.tsx +53 -12
- package/src/components/UsaMap/helpers/map.ts +4 -4
- package/src/components/UsaMap/helpers/shapes.ts +9 -6
- package/src/components/WorldMap/WorldMap.tsx +193 -35
- package/src/components/ZoomControls.tsx +6 -9
- package/src/context/LegendMemoContext.tsx +30 -0
- package/src/context.ts +1 -40
- package/src/data/initial-state.js +153 -130
- package/src/data/supported-geos.js +25 -78
- package/src/helpers/addUIDs.ts +13 -2
- package/src/helpers/applyColorToLegend.ts +140 -20
- package/src/helpers/applyLegendToRow.ts +10 -6
- package/src/helpers/componentHelpers.ts +8 -0
- package/src/helpers/constants.ts +12 -14
- package/src/helpers/dataTableHelpers.ts +6 -0
- package/src/helpers/displayGeoName.ts +18 -3
- package/src/helpers/generateRuntimeLegend.ts +44 -10
- package/src/helpers/generateRuntimeLegendHash.ts +4 -2
- package/src/helpers/getColumnNames.ts +1 -1
- package/src/helpers/getCountriesPicked.ts +103 -0
- package/src/helpers/getMapContainerClasses.ts +7 -0
- package/src/helpers/getPatternForRow.ts +33 -0
- package/src/helpers/getStatesPicked.ts +8 -5
- package/src/helpers/index.ts +3 -3
- package/src/helpers/isLegendItemDisabled.ts +16 -0
- package/src/helpers/mapObserverHelpers.ts +40 -0
- package/src/helpers/resetLegendToggles.ts +3 -2
- package/src/helpers/smallMultiplesHelpers.ts +359 -0
- package/src/helpers/tests/titleCase.test.ts +76 -0
- package/src/helpers/titleCase.ts +13 -13
- package/src/helpers/toggleLegendActive.ts +6 -11
- package/src/helpers/urlDataHelpers.ts +70 -0
- package/src/hooks/useCountryZoom.tsx +241 -0
- package/src/hooks/useGeoClickHandler.ts +36 -2
- package/src/hooks/useLegendMemo.ts +17 -0
- package/src/hooks/useMapLayers.tsx +5 -4
- package/src/hooks/useProgrammaticMapTooltip.ts +110 -0
- package/src/hooks/useResizeObserver.ts +5 -2
- package/src/hooks/useStateZoom.tsx +30 -8
- package/src/hooks/useSynchronizedGeographies.ts +56 -0
- package/src/hooks/useTooltip.ts +1 -2
- package/src/index.jsx +1 -2
- package/src/scss/editor-panel.scss +4 -440
- package/src/scss/main.scss +1 -1
- package/src/scss/map.scss +12 -15
- package/src/store/map.actions.ts +7 -7
- package/src/store/map.reducer.ts +17 -6
- package/src/test/CdcMap.test.jsx +11 -0
- package/src/types/MapConfig.ts +46 -18
- package/src/types/MapContext.ts +6 -7
- package/src/types/runtimeLegend.ts +17 -1
- package/vite.config.js +2 -7
- package/vitest.config.ts +16 -0
- package/src/components/DataTable.tsx +0 -385
- package/src/components/EditorPanel/components/Inputs.tsx +0 -59
- package/src/coreStyles_map.scss +0 -3
- package/src/helpers/colorDistributions.ts +0 -12
- package/src/helpers/generateColorsArray.ts +0 -14
- package/src/helpers/tests/generateColorsArray.test.ts +0 -18
- package/src/helpers/tests/generateRuntimeLegendHash.test.ts +0 -11
- package/src/hooks/useActiveElement.ts +0 -19
- package/src/scss/mixins.scss +0 -47
- package/src/types/Annotations.ts +0 -24
- /package/dist/{cdcmap-548642e6.es.js → cdcmap-Ct2SB0vL.es.js} +0 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import Modal from './Modal'
|
|
3
|
+
import UsaMap from './UsaMap'
|
|
4
|
+
import WorldMap from './WorldMap'
|
|
5
|
+
import GoogleMap from './GoogleMap'
|
|
6
|
+
import { MapConfig } from '../types/MapConfig'
|
|
7
|
+
import { LOGO_MAX_WIDTH } from '../helpers/constants'
|
|
8
|
+
|
|
9
|
+
interface MapContainerProps {
|
|
10
|
+
config: MapConfig
|
|
11
|
+
modal: any
|
|
12
|
+
currentViewport: string
|
|
13
|
+
geoType: string
|
|
14
|
+
general: any
|
|
15
|
+
logo: string
|
|
16
|
+
mapSvgRef: React.RefObject<HTMLElement>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const MapContainer: React.FC<MapContainerProps> = ({
|
|
20
|
+
config,
|
|
21
|
+
modal,
|
|
22
|
+
currentViewport,
|
|
23
|
+
geoType,
|
|
24
|
+
general,
|
|
25
|
+
logo,
|
|
26
|
+
mapSvgRef
|
|
27
|
+
}) => {
|
|
28
|
+
return (
|
|
29
|
+
<section className='outline-none geography-container w-100 position-relative' ref={mapSvgRef} tabIndex={0}>
|
|
30
|
+
{currentViewport && (
|
|
31
|
+
<>
|
|
32
|
+
{modal && <Modal />}
|
|
33
|
+
{'single-state' === geoType && <UsaMap.SingleState />}
|
|
34
|
+
{'us' === geoType && 'us-geocode' !== config.general.type && <UsaMap.State />}
|
|
35
|
+
{'us-region' === geoType && <UsaMap.Region />}
|
|
36
|
+
{'us-county' === geoType && <UsaMap.County />}
|
|
37
|
+
{'world' === geoType && <WorldMap />}
|
|
38
|
+
{'google-map' === geoType && <GoogleMap />}
|
|
39
|
+
{
|
|
40
|
+
/* logo is handled in UsaMap.State when applicable */
|
|
41
|
+
// prettier-ignore
|
|
42
|
+
'data' === general.type && logo && ('us' !== geoType || 'us-geocode' === general.type) && (
|
|
43
|
+
<img src={logo} alt='' className='map-logo' style={{ maxWidth: LOGO_MAX_WIDTH }} />
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
</>
|
|
47
|
+
)}
|
|
48
|
+
</section>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export default MapContainer
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import MediaControls from '@cdc/core/components/MediaControls'
|
|
3
|
+
import { MapConfig } from '../types/MapConfig'
|
|
4
|
+
|
|
5
|
+
interface MapControlsProps {
|
|
6
|
+
config: MapConfig
|
|
7
|
+
imageId: string
|
|
8
|
+
interactionLabel: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const MapControls: React.FC<MapControlsProps> = ({ config, imageId, interactionLabel }) => {
|
|
12
|
+
const { showDownloadImgButton, showDownloadPdfButton } = config.general
|
|
13
|
+
|
|
14
|
+
if (!showDownloadImgButton && !showDownloadPdfButton) {
|
|
15
|
+
return null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<MediaControls.Section classes={['download-buttons']}>
|
|
20
|
+
{showDownloadImgButton && (
|
|
21
|
+
<MediaControls.Button
|
|
22
|
+
text='Download Image'
|
|
23
|
+
title='Download Chart as Image'
|
|
24
|
+
type='image'
|
|
25
|
+
state={config}
|
|
26
|
+
elementToCapture={imageId}
|
|
27
|
+
interactionLabel={interactionLabel}
|
|
28
|
+
/>
|
|
29
|
+
)}
|
|
30
|
+
{showDownloadPdfButton && (
|
|
31
|
+
<MediaControls.Button
|
|
32
|
+
text='Download PDF'
|
|
33
|
+
title='Download Chart as PDF'
|
|
34
|
+
type='pdf'
|
|
35
|
+
state={config}
|
|
36
|
+
interactionLabel={interactionLabel}
|
|
37
|
+
elementToCapture={imageId}
|
|
38
|
+
/>
|
|
39
|
+
)}
|
|
40
|
+
</MediaControls.Section>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export default MapControls
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import React, { useContext, useEffect, useState } from 'react'
|
|
2
2
|
import ConfigContext from '../context'
|
|
3
3
|
import { publishAnalyticsEvent } from '@cdc/core/helpers/metrics/helpers'
|
|
4
|
+
import { getVizTitle, getVizSubType } from '@cdc/core/helpers/metrics/utils'
|
|
5
|
+
import { Select } from '@cdc/core/components/EditorPanel/Inputs'
|
|
4
6
|
|
|
5
7
|
const NavigationMenu = ({ data, navigationHandler, options, columns, displayGeoName, mapTabbingID }) => {
|
|
6
|
-
const { interactionLabel } = useContext(ConfigContext)
|
|
8
|
+
const { interactionLabel, config } = useContext(ConfigContext)
|
|
7
9
|
const [activeGeo, setActiveGeo] = useState('')
|
|
8
10
|
const [dropdownItems, setDropdownItems] = useState({})
|
|
9
11
|
|
|
@@ -12,7 +14,15 @@ const NavigationMenu = ({ data, navigationHandler, options, columns, displayGeoN
|
|
|
12
14
|
if (activeGeo !== '') {
|
|
13
15
|
const urlString = data[dropdownItems[activeGeo]][columns.navigate.name]
|
|
14
16
|
|
|
15
|
-
publishAnalyticsEvent(
|
|
17
|
+
publishAnalyticsEvent({
|
|
18
|
+
vizType: config.type,
|
|
19
|
+
vizSubType: getVizSubType(config),
|
|
20
|
+
eventType: `map_navigation_menu`,
|
|
21
|
+
eventAction: 'submit',
|
|
22
|
+
eventLabel: `${interactionLabel}`,
|
|
23
|
+
vizTitle: getVizTitle(config),
|
|
24
|
+
specifics: `url: ${urlString}, activeGeo: ${activeGeo}`
|
|
25
|
+
})
|
|
16
26
|
|
|
17
27
|
navigationHandler(urlString)
|
|
18
28
|
}
|
|
@@ -56,19 +66,21 @@ const NavigationMenu = ({ data, navigationHandler, options, columns, displayGeoN
|
|
|
56
66
|
return (
|
|
57
67
|
<section className='navigation-menu'>
|
|
58
68
|
<form onSubmit={handleSubmit} type='get'>
|
|
59
|
-
<
|
|
60
|
-
<
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
<div className='d-flex' style={{ alignItems: 'flex-end' }}>
|
|
70
|
+
<Select
|
|
71
|
+
label={navSelect}
|
|
72
|
+
value={activeGeo}
|
|
73
|
+
options={Object.keys(dropdownItems)}
|
|
74
|
+
onChange={e => setActiveGeo(e.target.value)}
|
|
75
|
+
/>
|
|
76
|
+
<input
|
|
77
|
+
type='submit'
|
|
78
|
+
value={navGo}
|
|
79
|
+
className={`${options.headerColor} btn`}
|
|
80
|
+
id='cdcnavmap-dropdown-go'
|
|
81
|
+
style={{ height: '50px', width: '35%' }}
|
|
82
|
+
/>
|
|
83
|
+
</div>
|
|
72
84
|
</form>
|
|
73
85
|
</section>
|
|
74
86
|
)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import React, { useContext, useMemo, useRef, useState, useEffect } from 'react'
|
|
2
|
+
import { MapConfig, DataRow } from '../../types/MapConfig'
|
|
3
|
+
import { getTileData, getTileDisplayTitle } from '../../helpers/smallMultiplesHelpers'
|
|
4
|
+
import { cloneConfig } from '@cdc/core/helpers/cloneConfig'
|
|
5
|
+
import ConfigContext from '../../context'
|
|
6
|
+
import { MapContext } from '../../types/MapContext'
|
|
7
|
+
import { DimensionsType } from '@cdc/core/types/Dimensions'
|
|
8
|
+
import generateRuntimeData from '../../helpers/generateRuntimeData'
|
|
9
|
+
import UsaMap from '../UsaMap'
|
|
10
|
+
import ResizeObserver from 'resize-observer-polyfill'
|
|
11
|
+
import getViewport from '@cdc/core/helpers/getViewport'
|
|
12
|
+
import { MapRefInterface } from '../../hooks/useProgrammaticMapTooltip'
|
|
13
|
+
import SynchronizedTooltip from './SynchronizedTooltip'
|
|
14
|
+
|
|
15
|
+
interface SmallMultipleTileProps {
|
|
16
|
+
tileValue: string | number
|
|
17
|
+
tileColumn: string
|
|
18
|
+
config: MapConfig
|
|
19
|
+
data: DataRow[]
|
|
20
|
+
isFirstInRow?: boolean
|
|
21
|
+
tilesPerRow: number
|
|
22
|
+
onHeaderRef?: (ref: HTMLDivElement | null) => void
|
|
23
|
+
onMapRef?: (ref: MapRefInterface | null) => void
|
|
24
|
+
onMapHover?: (geoId: string | null, yCoordinate?: number) => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const SmallMultipleTile: React.FC<SmallMultipleTileProps> = ({
|
|
28
|
+
tileValue,
|
|
29
|
+
tileColumn,
|
|
30
|
+
config,
|
|
31
|
+
data,
|
|
32
|
+
isFirstInRow,
|
|
33
|
+
tilesPerRow,
|
|
34
|
+
onHeaderRef,
|
|
35
|
+
onMapRef,
|
|
36
|
+
onMapHover
|
|
37
|
+
}) => {
|
|
38
|
+
const parentContext = useContext<MapContext>(ConfigContext)
|
|
39
|
+
const tileMapRef = useRef<HTMLDivElement>(null)
|
|
40
|
+
const [tileDimensions, setTileDimensions] = useState<DimensionsType>([0, 0])
|
|
41
|
+
const mapRefForSync = useRef<MapRefInterface | null>(null)
|
|
42
|
+
|
|
43
|
+
// Generate unique tooltip ID for this tile to ensure each tile has its own ReactTooltip instance
|
|
44
|
+
const tileTooltipId = useMemo(() => {
|
|
45
|
+
return `${parentContext.tooltipId}-tile-${String(tileValue).replace(/[^a-zA-Z0-9]/g, '_')}`
|
|
46
|
+
}, [parentContext.tooltipId, tileValue])
|
|
47
|
+
|
|
48
|
+
// Measure this tile's actual dimensions for pattern stroke calculation
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!tileMapRef.current) return
|
|
51
|
+
|
|
52
|
+
const resizeObserver = new ResizeObserver(entries => {
|
|
53
|
+
for (let entry of entries) {
|
|
54
|
+
const { width, height } = entry.contentRect
|
|
55
|
+
setTileDimensions([width, height])
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
resizeObserver.observe(tileMapRef.current)
|
|
60
|
+
return () => resizeObserver.disconnect()
|
|
61
|
+
}, [])
|
|
62
|
+
|
|
63
|
+
const tileData = useMemo(() => getTileData(data, tileColumn, tileValue), [data, tileColumn, tileValue])
|
|
64
|
+
|
|
65
|
+
const tileTitle = useMemo(
|
|
66
|
+
() => getTileDisplayTitle(tileValue, config.smallMultiples?.tileTitles),
|
|
67
|
+
[tileValue, config.smallMultiples?.tileTitles]
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
// Clone config and modify for this tile
|
|
71
|
+
const tileConfig = useMemo(() => {
|
|
72
|
+
let clonedConfig = cloneConfig(config) as MapConfig
|
|
73
|
+
|
|
74
|
+
// Remove smallMultiples config to prevent infinite loop
|
|
75
|
+
clonedConfig.smallMultiples = undefined
|
|
76
|
+
|
|
77
|
+
// Hide the main title on individual tiles
|
|
78
|
+
if (clonedConfig.general) {
|
|
79
|
+
clonedConfig.general.showTitle = false
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// CRITICAL: Force unified legend for small multiples
|
|
83
|
+
// This ensures the legend is generated from ALL data (all pathogens), not just this tile's data
|
|
84
|
+
if (clonedConfig.legend) {
|
|
85
|
+
clonedConfig.legend.unified = true
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Replace data with filtered tile data
|
|
89
|
+
clonedConfig.data = tileData
|
|
90
|
+
|
|
91
|
+
return clonedConfig
|
|
92
|
+
}, [config, tileData])
|
|
93
|
+
|
|
94
|
+
// Generate tile-specific runtimeData from filtered data
|
|
95
|
+
const tileRuntimeData = useMemo(() => {
|
|
96
|
+
if (!tileData || tileData.length === 0) return {}
|
|
97
|
+
|
|
98
|
+
const isCategoryLegend = tileConfig?.legend?.type === 'category'
|
|
99
|
+
const hash = Math.random()
|
|
100
|
+
|
|
101
|
+
return generateRuntimeData(tileConfig, tileConfig.filters || [], hash, isCategoryLegend, false)
|
|
102
|
+
}, [tileConfig, tileData])
|
|
103
|
+
|
|
104
|
+
const useDynamicViewbox = config.general.geoType === 'single-state' && tilesPerRow > 1
|
|
105
|
+
|
|
106
|
+
// Notify parent when map ref is ready
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
if (onMapRef && mapRefForSync.current) {
|
|
109
|
+
onMapRef(mapRefForSync.current)
|
|
110
|
+
}
|
|
111
|
+
return () => {
|
|
112
|
+
if (onMapRef) {
|
|
113
|
+
onMapRef(null)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}, [onMapRef, tileValue])
|
|
117
|
+
|
|
118
|
+
// Create tile-specific context with filtered config, filtered runtimeData, and tile dimensions
|
|
119
|
+
// Parent's runtimeLegend is already unified (forced in CdcMapComponent for small multiples)
|
|
120
|
+
const tileContext: MapContext = useMemo(
|
|
121
|
+
() => ({
|
|
122
|
+
...parentContext,
|
|
123
|
+
config: tileConfig,
|
|
124
|
+
runtimeData: tileRuntimeData as any,
|
|
125
|
+
dimensions: tileDimensions,
|
|
126
|
+
vizViewport: getViewport(tileDimensions[0]),
|
|
127
|
+
useDynamicViewbox,
|
|
128
|
+
// Override tooltipId with unique tile-specific ID
|
|
129
|
+
tooltipId: tileTooltipId,
|
|
130
|
+
// Small multiples synchronization: pass wrapped callback
|
|
131
|
+
handleSmallMultipleHover: onMapHover,
|
|
132
|
+
// Internal: ref for programmatic tooltip control
|
|
133
|
+
mapRefForSync
|
|
134
|
+
}),
|
|
135
|
+
[parentContext, tileConfig, tileRuntimeData, tileDimensions, useDynamicViewbox, tileTooltipId, onMapHover]
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<div className='small-multiple-tile'>
|
|
140
|
+
<div ref={onHeaderRef} className='tile-header'>
|
|
141
|
+
<div className='tile-title'>{tileTitle}</div>
|
|
142
|
+
</div>
|
|
143
|
+
<div className='tile-map' ref={tileMapRef}>
|
|
144
|
+
<ConfigContext.Provider value={tileContext}>
|
|
145
|
+
{config.general.geoType === 'us' && <UsaMap.State />}
|
|
146
|
+
{config.general.geoType === 'single-state' && <UsaMap.SingleState />}
|
|
147
|
+
{config.general.geoType === 'us-region' && <UsaMap.Region />}
|
|
148
|
+
</ConfigContext.Provider>
|
|
149
|
+
|
|
150
|
+
{/* Custom tooltip component that responds to both natural and synthetic events */}
|
|
151
|
+
{!window.matchMedia('(any-hover: none)').matches && config.tooltips.appearanceType === 'hover' && (
|
|
152
|
+
<SynchronizedTooltip
|
|
153
|
+
tileTooltipId={tileTooltipId}
|
|
154
|
+
opacity={config.tooltips.opacity}
|
|
155
|
+
containerRef={tileMapRef}
|
|
156
|
+
/>
|
|
157
|
+
)}
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export default SmallMultipleTile
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
.small-multiples-container {
|
|
2
|
+
width: 100%;
|
|
3
|
+
display: flex;
|
|
4
|
+
flex-direction: column;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.small-multiples-grid {
|
|
8
|
+
display: grid;
|
|
9
|
+
width: 100%;
|
|
10
|
+
flex: 1;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.small-multiple-tile {
|
|
14
|
+
display: flex;
|
|
15
|
+
flex-direction: column;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.tile-header {
|
|
19
|
+
margin-bottom: 0.5rem;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.tile-title {
|
|
23
|
+
margin: 0;
|
|
24
|
+
font-weight: 700;
|
|
25
|
+
text-align: left;
|
|
26
|
+
line-height: 1.3;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.tile-map {
|
|
30
|
+
width: 100%;
|
|
31
|
+
flex-shrink: 0;
|
|
32
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import React, { useContext, useMemo, useRef, useEffect, useCallback } from 'react'
|
|
2
|
+
import SmallMultipleTile from './SmallMultipleTile'
|
|
3
|
+
import ConfigContext from '../../context'
|
|
4
|
+
import { MapContext } from '../../types/MapContext'
|
|
5
|
+
import { getTileValues, applyTileOrder } from '../../helpers/smallMultiplesHelpers'
|
|
6
|
+
import { isMobileSmallMultiplesViewport } from '@cdc/core/helpers/viewports'
|
|
7
|
+
import { MapRefInterface } from '../../hooks/useProgrammaticMapTooltip'
|
|
8
|
+
import './SmallMultiples.css'
|
|
9
|
+
|
|
10
|
+
type TileHeaderRows = Array<Array<HTMLDivElement>>
|
|
11
|
+
|
|
12
|
+
type TileHeaderEntries = Array<[string, HTMLDivElement]>
|
|
13
|
+
|
|
14
|
+
interface SmallMultiplesProps {}
|
|
15
|
+
|
|
16
|
+
const SmallMultiples: React.FC<SmallMultiplesProps> = () => {
|
|
17
|
+
const { config, currentViewport } = useContext<MapContext>(ConfigContext)
|
|
18
|
+
|
|
19
|
+
const { mode, tileColumn, tilesPerRowDesktop, tilesPerRowMobile, tileOrderType, tileOrder, tileTitles } =
|
|
20
|
+
config.smallMultiples || {}
|
|
21
|
+
|
|
22
|
+
const data = config.data || []
|
|
23
|
+
|
|
24
|
+
const isMobile = isMobileSmallMultiplesViewport(currentViewport)
|
|
25
|
+
const tilesPerRow = isMobile ? tilesPerRowMobile || 1 : tilesPerRowDesktop || 3
|
|
26
|
+
|
|
27
|
+
const rawTileValues = useMemo(() => {
|
|
28
|
+
return getTileValues(data, tileColumn)
|
|
29
|
+
}, [data, tileColumn])
|
|
30
|
+
|
|
31
|
+
const orderedTileValues = useMemo(() => {
|
|
32
|
+
return applyTileOrder(rawTileValues, tileOrderType, tileOrder, tileTitles)
|
|
33
|
+
}, [rawTileValues, tileOrderType, tileOrder, tileTitles])
|
|
34
|
+
|
|
35
|
+
// Refs to all tile header elements for height alignment
|
|
36
|
+
const headerRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
|
37
|
+
|
|
38
|
+
// Refs to all tile map components for tooltip synchronization
|
|
39
|
+
const tileMapRefs = useRef<Record<string, MapRefInterface | null>>({})
|
|
40
|
+
|
|
41
|
+
// Handle tooltip synchronization across small multiple tiles
|
|
42
|
+
// This follows the chart package pattern where we manage the source tile key here
|
|
43
|
+
const handleMapHover = useCallback(
|
|
44
|
+
(sourceTileKey: string, geoId: string | null, yCoordinate?: number) => {
|
|
45
|
+
if (!config.smallMultiples?.synchronizedTooltips) {
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// If geoId is null, mouse left the geography - hide all tooltips
|
|
50
|
+
if (geoId === null) {
|
|
51
|
+
Object.entries(tileMapRefs.current).forEach(([tileKey, mapRef]) => {
|
|
52
|
+
if (tileKey !== sourceTileKey && mapRef?.hideTooltip) {
|
|
53
|
+
mapRef.hideTooltip()
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Show tooltip for same geography on all other tiles
|
|
60
|
+
Object.entries(tileMapRefs.current).forEach(([tileKey, mapRef]) => {
|
|
61
|
+
if (tileKey === sourceTileKey || !mapRef) {
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (mapRef.triggerTooltipAtGeo && yCoordinate !== undefined) {
|
|
66
|
+
mapRef.triggerTooltipAtGeo(geoId, yCoordinate)
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
},
|
|
70
|
+
[config.smallMultiples?.synchronizedTooltips]
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
// Align tile header heights per row
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
const headerEntries = Object.entries(headerRefs.current).filter(([_, ref]) => ref) as TileHeaderEntries
|
|
76
|
+
if (headerEntries.length === 0) return
|
|
77
|
+
|
|
78
|
+
// Group headers by row based on their index in orderedTileValues
|
|
79
|
+
const headersByRow: TileHeaderRows = []
|
|
80
|
+
|
|
81
|
+
orderedTileValues.forEach((tileValue, index) => {
|
|
82
|
+
const rowIndex = Math.floor(index / tilesPerRow)
|
|
83
|
+
const header = headerRefs.current[String(tileValue)]
|
|
84
|
+
|
|
85
|
+
headersByRow[rowIndex] ||= []
|
|
86
|
+
headersByRow[rowIndex].push(header)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
// For each row, find the header with longest text and align others to it
|
|
90
|
+
headersByRow.forEach(rowHeaders => {
|
|
91
|
+
let longestHeader: HTMLDivElement | null = null
|
|
92
|
+
let maxTextLength = 0
|
|
93
|
+
|
|
94
|
+
rowHeaders.forEach(header => {
|
|
95
|
+
const textLength = header.textContent?.length || 0
|
|
96
|
+
if (textLength > maxTextLength) {
|
|
97
|
+
maxTextLength = textLength
|
|
98
|
+
longestHeader = header
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
if (!longestHeader) return
|
|
103
|
+
|
|
104
|
+
// Get the height of the longest header in this row
|
|
105
|
+
const targetHeight = longestHeader.offsetHeight
|
|
106
|
+
|
|
107
|
+
// Apply that height to all other headers in this row
|
|
108
|
+
rowHeaders.forEach(header => {
|
|
109
|
+
header.style.minHeight = header !== longestHeader ? `${targetHeight}px` : 'auto'
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
}, [orderedTileValues, tilesPerRow])
|
|
113
|
+
|
|
114
|
+
// Calculate grid styling
|
|
115
|
+
const gridGap = isMobile ? '1rem' : '2rem'
|
|
116
|
+
const gridStyle = {
|
|
117
|
+
gridTemplateColumns: `repeat(${tilesPerRow}, 1fr)`,
|
|
118
|
+
gap: gridGap
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<div className='small-multiples-container mt-4'>
|
|
123
|
+
<div className='small-multiples-grid' style={gridStyle}>
|
|
124
|
+
{orderedTileValues.map((tileValue, index) => {
|
|
125
|
+
const tileKey = String(tileValue)
|
|
126
|
+
return (
|
|
127
|
+
<SmallMultipleTile
|
|
128
|
+
key={tileKey}
|
|
129
|
+
tileValue={tileValue}
|
|
130
|
+
tileColumn={tileColumn}
|
|
131
|
+
config={config}
|
|
132
|
+
data={data}
|
|
133
|
+
isFirstInRow={index % tilesPerRow === 0}
|
|
134
|
+
tilesPerRow={tilesPerRow}
|
|
135
|
+
onHeaderRef={ref => {
|
|
136
|
+
headerRefs.current[tileKey] = ref
|
|
137
|
+
}}
|
|
138
|
+
onMapRef={ref => {
|
|
139
|
+
tileMapRefs.current[tileKey] = ref
|
|
140
|
+
}}
|
|
141
|
+
onMapHover={(geoId, yCoordinate) => handleMapHover(tileKey, geoId, yCoordinate)}
|
|
142
|
+
/>
|
|
143
|
+
)
|
|
144
|
+
})}
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export default SmallMultiples
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import React, { useState, useEffect, useRef, RefObject } from 'react'
|
|
2
|
+
|
|
3
|
+
interface SynchronizedTooltipProps {
|
|
4
|
+
tileTooltipId: string
|
|
5
|
+
opacity: number
|
|
6
|
+
containerRef: RefObject<HTMLElement>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Custom tooltip component for small multiples that responds to synthetic events
|
|
11
|
+
* This bypasses react-tooltip's lazy initialization issues
|
|
12
|
+
*/
|
|
13
|
+
const SynchronizedTooltip: React.FC<SynchronizedTooltipProps> = ({ tileTooltipId, opacity, containerRef }) => {
|
|
14
|
+
const [tooltipState, setTooltipState] = useState<{
|
|
15
|
+
visible: boolean
|
|
16
|
+
html: string
|
|
17
|
+
x: number
|
|
18
|
+
y: number
|
|
19
|
+
}>({
|
|
20
|
+
visible: false,
|
|
21
|
+
html: '',
|
|
22
|
+
x: 0,
|
|
23
|
+
y: 0
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const tooltipRef = useRef<HTMLDivElement>(null)
|
|
27
|
+
const currentGeoRef = useRef<string | null>(null)
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
const handleMouseEnter = (e: MouseEvent) => {
|
|
31
|
+
const target = e.target as HTMLElement
|
|
32
|
+
const tooltipId = target.getAttribute('data-tooltip-id')
|
|
33
|
+
|
|
34
|
+
if (tooltipId === `tooltip__${tileTooltipId}`) {
|
|
35
|
+
const tooltipHtml = target.getAttribute('data-tooltip-html')
|
|
36
|
+
const geoId = target.getAttribute('data-geo-id') || ''
|
|
37
|
+
|
|
38
|
+
// Don't show tooltip if there's no content
|
|
39
|
+
if (!tooltipHtml || tooltipHtml === 'undefined' || tooltipHtml.trim() === '') {
|
|
40
|
+
setTooltipState(prev => ({ ...prev, visible: false }))
|
|
41
|
+
currentGeoRef.current = null
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Only update if we're entering a different geography or showing for first time
|
|
46
|
+
if (geoId !== currentGeoRef.current) {
|
|
47
|
+
currentGeoRef.current = geoId
|
|
48
|
+
|
|
49
|
+
// Position tooltip relative to the geography element's bounding box
|
|
50
|
+
const rect = target.getBoundingClientRect()
|
|
51
|
+
const x = rect.left + rect.width / 2
|
|
52
|
+
const y = rect.top + rect.height / 2
|
|
53
|
+
|
|
54
|
+
setTooltipState({
|
|
55
|
+
visible: true,
|
|
56
|
+
html: tooltipHtml,
|
|
57
|
+
x,
|
|
58
|
+
y
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const handleMouseLeave = (e: MouseEvent) => {
|
|
65
|
+
const target = e.target as HTMLElement
|
|
66
|
+
const tooltipId = target.getAttribute('data-tooltip-id')
|
|
67
|
+
|
|
68
|
+
if (tooltipId === `tooltip__${tileTooltipId}`) {
|
|
69
|
+
setTooltipState(prev => ({ ...prev, visible: false }))
|
|
70
|
+
currentGeoRef.current = null
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const container = containerRef.current
|
|
75
|
+
if (!container) return
|
|
76
|
+
|
|
77
|
+
container.addEventListener('mouseenter', handleMouseEnter, true)
|
|
78
|
+
container.addEventListener('mouseleave', handleMouseLeave, true)
|
|
79
|
+
|
|
80
|
+
return () => {
|
|
81
|
+
container.removeEventListener('mouseenter', handleMouseEnter, true)
|
|
82
|
+
container.removeEventListener('mouseleave', handleMouseLeave, true)
|
|
83
|
+
}
|
|
84
|
+
}, [tileTooltipId, containerRef])
|
|
85
|
+
|
|
86
|
+
if (!tooltipState.visible) return null
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div
|
|
90
|
+
ref={tooltipRef}
|
|
91
|
+
className='tooltip tooltip-test'
|
|
92
|
+
style={{
|
|
93
|
+
position: 'fixed',
|
|
94
|
+
left: `${tooltipState.x + 10}px`,
|
|
95
|
+
top: `${tooltipState.y + 10}px`,
|
|
96
|
+
background: `rgba(255,255,255, ${opacity / 100})`,
|
|
97
|
+
pointerEvents: 'none',
|
|
98
|
+
zIndex: 9999
|
|
99
|
+
}}
|
|
100
|
+
dangerouslySetInnerHTML={{ __html: tooltipState.html }}
|
|
101
|
+
/>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export default SynchronizedTooltip
|