@cdc/map 4.25.10 → 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/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 +27405 -25783
- package/examples/example-city-state.json +9 -1
- package/examples/multi-country-centering.json +45 -0
- package/examples/private/colors-2.json +221 -0
- package/examples/private/colors.json +221 -0
- package/index.html +2 -1
- package/package.json +4 -4
- package/src/CdcMapComponent.tsx +44 -20
- 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.SmallMultiples.stories.tsx +35 -0
- package/src/_stories/CdcMap.stories.tsx +22 -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 +2 -4
- package/src/components/BubbleList.tsx +1 -1
- package/src/components/EditorPanel/components/EditorPanel.tsx +630 -564
- package/src/components/EditorPanel/components/HexShapeSettings.tsx +55 -93
- package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +27 -37
- 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 +20 -3
- package/src/components/Legend/components/Legend.tsx +34 -34
- package/src/components/Legend/components/index.scss +1 -1
- 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 +23 -4
- package/src/components/UsaMap/components/Territory/TerritoryShape.ts +6 -0
- package/src/components/UsaMap/components/UsaMap.County.tsx +14 -2
- package/src/components/UsaMap/components/UsaMap.Region.tsx +14 -1
- package/src/components/UsaMap/components/UsaMap.SingleState.tsx +25 -5
- package/src/components/UsaMap/components/UsaMap.State.tsx +26 -3
- 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 +10 -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/constants.ts +1 -15
- package/src/helpers/displayGeoName.ts +19 -4
- package/src/helpers/generateRuntimeLegend.ts +0 -2
- 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 +1 -9
- 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/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 +5 -2
- package/src/hooks/useStateZoom.tsx +5 -2
- 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/types/MapConfig.ts +30 -11
- package/src/types/MapContext.ts +6 -0
- package/src/types/runtimeLegend.ts +1 -1
- package/src/components/DataTable.tsx +0 -413
- package/src/components/EditorPanel/components/Inputs.tsx +0 -59
- 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
|
@@ -367,41 +367,41 @@ const Legend = forwardRef<HTMLDivElement, LegendProps>((props, ref) => {
|
|
|
367
367
|
|
|
368
368
|
{((config.visual.additionalCityStyles && config.visual.additionalCityStyles.some(c => c.label)) ||
|
|
369
369
|
config.visual.cityStyleLabel) && (
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
370
|
+
<>
|
|
371
|
+
<hr />
|
|
372
|
+
<div className={legendClasses.div.join(' ') || ''}>
|
|
373
|
+
{config.visual.cityStyleLabel && (
|
|
374
|
+
<div>
|
|
375
|
+
<svg>
|
|
376
|
+
<Group
|
|
377
|
+
top={
|
|
378
|
+
config.visual.cityStyle === 'pin' ? 19 : config.visual.cityStyle === 'triangle' ? 13 : 11
|
|
379
|
+
}
|
|
380
|
+
left={10}
|
|
381
|
+
>
|
|
382
|
+
{cityStyleShapes[config.visual.cityStyle.toLowerCase()]}
|
|
383
|
+
</Group>
|
|
384
|
+
</svg>
|
|
385
|
+
<p>{config.visual.cityStyleLabel}</p>
|
|
386
|
+
</div>
|
|
387
|
+
)}
|
|
388
388
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
389
|
+
{config.visual.additionalCityStyles.map(
|
|
390
|
+
({ shape, label }, index) =>
|
|
391
|
+
label && (
|
|
392
|
+
<div key={`additional-city-style-${index}-${shape}`}>
|
|
393
|
+
<svg>
|
|
394
|
+
<Group top={shape === 'Pin' ? 19 : shape === 'Triangle' ? 13 : 11} left={10}>
|
|
395
|
+
{cityStyleShapes[shape.toLowerCase()]}
|
|
396
|
+
</Group>
|
|
397
|
+
</svg>
|
|
398
|
+
<p>{label}</p>
|
|
399
|
+
</div>
|
|
400
|
+
)
|
|
401
|
+
)}
|
|
402
|
+
</div>
|
|
403
|
+
</>
|
|
404
|
+
)}
|
|
405
405
|
{runtimeLegend.disabledAmt > 0 && (
|
|
406
406
|
<Button className={legendClasses.showAllButton.join(' ')} onClick={handleReset}>
|
|
407
407
|
Show All
|
|
@@ -2,6 +2,7 @@ import React, { useContext, useEffect, useState } from 'react'
|
|
|
2
2
|
import ConfigContext from '../context'
|
|
3
3
|
import { publishAnalyticsEvent } from '@cdc/core/helpers/metrics/helpers'
|
|
4
4
|
import { getVizTitle, getVizSubType } from '@cdc/core/helpers/metrics/utils'
|
|
5
|
+
import { Select } from '@cdc/core/components/EditorPanel/Inputs'
|
|
5
6
|
|
|
6
7
|
const NavigationMenu = ({ data, navigationHandler, options, columns, displayGeoName, mapTabbingID }) => {
|
|
7
8
|
const { interactionLabel, config } = useContext(ConfigContext)
|
|
@@ -65,19 +66,21 @@ const NavigationMenu = ({ data, navigationHandler, options, columns, displayGeoN
|
|
|
65
66
|
return (
|
|
66
67
|
<section className='navigation-menu'>
|
|
67
68
|
<form onSubmit={handleSubmit} type='get'>
|
|
68
|
-
<
|
|
69
|
-
<
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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>
|
|
81
84
|
</form>
|
|
82
85
|
</section>
|
|
83
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
|
|
@@ -8,6 +8,7 @@ import { applyLegendToRow } from '../../../../helpers/applyLegendToRow'
|
|
|
8
8
|
import useGeoClickHandler, { geoClickHandler } from '././../../../../hooks/useGeoClickHandler'
|
|
9
9
|
import { publishAnalyticsEvent } from '@cdc/core/helpers/metrics/helpers'
|
|
10
10
|
import { getVizTitle, getVizSubType } from '@cdc/core/helpers/metrics/utils'
|
|
11
|
+
import { useSynchronizedGeographies } from '../../../../hooks/useSynchronizedGeographies'
|
|
11
12
|
|
|
12
13
|
interface CountyOutputProps {
|
|
13
14
|
counties: any[]
|
|
@@ -23,6 +24,7 @@ const CountyOutput: React.FC<CountyOutputProps> = ({ path, counties, scale, geoS
|
|
|
23
24
|
const { applyTooltipsToGeo } = useApplyTooltipsToGeo()
|
|
24
25
|
const geoFillColor = getGeoFillColor(config)
|
|
25
26
|
const { geoClickHandler } = useGeoClickHandler()
|
|
27
|
+
const { getSyncProps, syncHandlers } = useSynchronizedGeographies()
|
|
26
28
|
|
|
27
29
|
return (
|
|
28
30
|
<>
|
|
@@ -71,14 +73,16 @@ const CountyOutput: React.FC<CountyOutputProps> = ({ path, counties, scale, geoS
|
|
|
71
73
|
|
|
72
74
|
return (
|
|
73
75
|
<g
|
|
76
|
+
{...getSyncProps(geoKey)}
|
|
74
77
|
key={`key--${county.id}`}
|
|
75
|
-
className={`county county--${geoDisplayName.split(' ').join('')} county--${
|
|
76
|
-
|
|
78
|
+
className={`county county--${geoDisplayName.split(' ').join('')} county--${
|
|
79
|
+
geoData[config.columns.geo.name]
|
|
80
|
+
}`}
|
|
77
81
|
style={styles}
|
|
78
82
|
onClick={() => geoClickHandler(geoDisplayName, geoData)}
|
|
79
83
|
data-tooltip-id={`tooltip__${tooltipId}`}
|
|
80
84
|
data-tooltip-html={toolTip}
|
|
81
|
-
onMouseEnter={
|
|
85
|
+
onMouseEnter={e => {
|
|
82
86
|
// Track hover analytics event if this is a new location
|
|
83
87
|
const locationName = geoDisplayName.replace(/[^a-zA-Z0-9]/g, '_')
|
|
84
88
|
publishAnalyticsEvent({
|
|
@@ -91,6 +95,10 @@ const CountyOutput: React.FC<CountyOutputProps> = ({ path, counties, scale, geoS
|
|
|
91
95
|
location: geoDisplayName,
|
|
92
96
|
specifics: `location: ${locationName?.toLowerCase()}`
|
|
93
97
|
})
|
|
98
|
+
syncHandlers.onMouseEnter(geoKey, e.clientY)
|
|
99
|
+
}}
|
|
100
|
+
onMouseLeave={() => {
|
|
101
|
+
syncHandlers.onMouseLeave()
|
|
94
102
|
}}
|
|
95
103
|
>
|
|
96
104
|
<path
|
|
@@ -105,11 +113,18 @@ const CountyOutput: React.FC<CountyOutputProps> = ({ path, counties, scale, geoS
|
|
|
105
113
|
} else {
|
|
106
114
|
return (
|
|
107
115
|
<g
|
|
116
|
+
{...getSyncProps(geoKey)}
|
|
108
117
|
key={`key--${county.id}`}
|
|
109
118
|
className={`county county--${geoDisplayName.split(' ').join('')}`}
|
|
110
119
|
style={{ fill: geoFillColor }}
|
|
111
120
|
data-tooltip-id={`tooltip__${tooltipId}`}
|
|
112
121
|
data-tooltip-html={toolTip}
|
|
122
|
+
onMouseEnter={e => {
|
|
123
|
+
syncHandlers.onMouseEnter(geoKey, e.clientY)
|
|
124
|
+
}}
|
|
125
|
+
onMouseLeave={() => {
|
|
126
|
+
syncHandlers.onMouseLeave()
|
|
127
|
+
}}
|
|
113
128
|
>
|
|
114
129
|
<path
|
|
115
130
|
tabIndex={-1}
|