@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
package/src/components/Geo.tsx
CHANGED
|
@@ -8,14 +8,31 @@ type GeoProps = {
|
|
|
8
8
|
className?: string
|
|
9
9
|
onMouseEnter?: () => void
|
|
10
10
|
onClick?: () => void
|
|
11
|
+
'data-country-code'?: string
|
|
12
|
+
'data-tooltip-id'?: string
|
|
13
|
+
'data-tooltip-html'?: string
|
|
14
|
+
additionalData?: any
|
|
15
|
+
geoData?: any
|
|
16
|
+
additionaldata?: string
|
|
17
|
+
geodata?: string
|
|
18
|
+
tabIndex?: number
|
|
11
19
|
}
|
|
12
20
|
|
|
13
21
|
const Geo: React.FC<GeoProps> = ({ path, styles, stroke, strokeWidth, ...props }) => {
|
|
14
|
-
const { className, ...restProps } = props
|
|
15
|
-
const geoClassName = String(props.additionalData?.name)?.toLowerCase()?.
|
|
22
|
+
const { className, 'data-country-code': dataCountryCode, ...restProps } = props
|
|
23
|
+
const geoClassName = String(props.additionalData?.name)?.toLowerCase()?.replace(/\s+/g, '') || 'country'
|
|
16
24
|
return (
|
|
17
25
|
<g className={`geo-group ${geoClassName}`} style={styles} {...restProps}>
|
|
18
|
-
<path
|
|
26
|
+
<path
|
|
27
|
+
tabIndex={-1}
|
|
28
|
+
className={`single-geo ${geoClassName} ${className || ''}`}
|
|
29
|
+
stroke={stroke}
|
|
30
|
+
strokeWidth={strokeWidth}
|
|
31
|
+
strokeLinejoin='round'
|
|
32
|
+
strokeLinecap='round'
|
|
33
|
+
d={path}
|
|
34
|
+
data-country-code={dataCountryCode}
|
|
35
|
+
/>
|
|
19
36
|
</g>
|
|
20
37
|
)
|
|
21
38
|
}
|
|
@@ -99,6 +99,7 @@ const Legend = forwardRef<HTMLDivElement, LegendProps>((props, ref) => {
|
|
|
99
99
|
color: entry.color,
|
|
100
100
|
label: parse(legendLabel),
|
|
101
101
|
disabled: entry.disabled,
|
|
102
|
+
hidden: entry.hidden,
|
|
102
103
|
special: entry.hasOwnProperty('special'),
|
|
103
104
|
value: [entry.min, entry.max]
|
|
104
105
|
}
|
|
@@ -111,56 +112,40 @@ const Legend = forwardRef<HTMLDivElement, LegendProps>((props, ref) => {
|
|
|
111
112
|
|
|
112
113
|
const legendList = (patternsOnly = false) => {
|
|
113
114
|
const formattedItems = patternsOnly ? [] : getFormattedLegendItems()
|
|
114
|
-
const
|
|
115
|
-
const hasDisabledItems = formattedItems.some(item => item.disabled)
|
|
115
|
+
const hasDisabledItems = runtimeLegend.disabledAmt > 0
|
|
116
116
|
let legendItems
|
|
117
117
|
|
|
118
118
|
legendItems = formattedItems.map((item, idx) => {
|
|
119
119
|
const handleListItemClass = () => {
|
|
120
120
|
let classes = ['legend-container__li', 'd-flex', 'align-items-center']
|
|
121
|
-
if (item.disabled) classes.push('legend-container__li--disabled')
|
|
121
|
+
if (item.disabled || item.hidden) classes.push('legend-container__li--disabled')
|
|
122
122
|
else if (hasDisabledItems) classes.push('legend-container__li--not-disabled')
|
|
123
123
|
if (item.special) classes.push('legend-container__li--special-class')
|
|
124
124
|
return classes.join(' ')
|
|
125
125
|
}
|
|
126
126
|
|
|
127
127
|
return (
|
|
128
|
-
<li
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
vizType: config.type,
|
|
136
|
-
vizSubType: getVizSubType(config),
|
|
137
|
-
eventType: `map_legend_item_toggled`,
|
|
138
|
-
eventAction: 'click',
|
|
139
|
-
eventLabel: `${interactionLabel}`,
|
|
140
|
-
vizTitle: getVizTitle(config),
|
|
141
|
-
specifics: `mode: isolate, label: ${item.label}`
|
|
142
|
-
})
|
|
143
|
-
}}
|
|
144
|
-
onKeyDown={e => {
|
|
145
|
-
if (e.key === 'Enter') {
|
|
146
|
-
e.preventDefault()
|
|
147
|
-
toggleLegendActive(idx, item.label, runtimeLegend, dispatch)
|
|
128
|
+
<li className={handleListItemClass()} key={idx}>
|
|
129
|
+
<button
|
|
130
|
+
type='button'
|
|
131
|
+
className='legend-container__li-btn'
|
|
132
|
+
title={`Legend item ${item.label} - Click to disable`}
|
|
133
|
+
onClick={() => {
|
|
134
|
+
toggleLegendActive(idx, item.label, runtimeLegend, dispatch, config.legend.behavior)
|
|
148
135
|
publishAnalyticsEvent({
|
|
149
136
|
vizType: config.type,
|
|
150
137
|
vizSubType: getVizSubType(config),
|
|
151
138
|
eventType: `map_legend_item_toggled`,
|
|
152
|
-
eventAction: '
|
|
139
|
+
eventAction: 'click',
|
|
153
140
|
eventLabel: `${interactionLabel}`,
|
|
154
141
|
vizTitle: getVizTitle(config),
|
|
155
142
|
specifics: `mode: isolate, label: ${item.label}`
|
|
156
143
|
})
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
<LegendShape shape={config.legend.style === 'boxes' ? 'square' : 'circle'} fill={item.color} />
|
|
163
|
-
<span>{item.label}</span>
|
|
144
|
+
}}
|
|
145
|
+
>
|
|
146
|
+
<LegendShape shape={config.legend.style === 'boxes' ? 'square' : 'circle'} fill={item.color} />
|
|
147
|
+
<span>{item.label}</span>
|
|
148
|
+
</button>
|
|
164
149
|
</li>
|
|
165
150
|
)
|
|
166
151
|
})
|
|
@@ -180,12 +165,12 @@ const Legend = forwardRef<HTMLDivElement, LegendProps>((props, ref) => {
|
|
|
180
165
|
|
|
181
166
|
legendItems.push(
|
|
182
167
|
<>
|
|
183
|
-
<li
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
168
|
+
<li className={`legend-container__li legend-container__li--geo-pattern`}>
|
|
169
|
+
<button
|
|
170
|
+
type='button'
|
|
171
|
+
className='legend-container__li-btn legend-container__li-btn--pattern'
|
|
172
|
+
aria-label='Pattern legend item. Toggling patterns is not currently supported.'
|
|
173
|
+
>
|
|
189
174
|
<svg width={legendSize} height={legendSize}>
|
|
190
175
|
{pattern === 'waves' && (
|
|
191
176
|
<PatternWaves
|
|
@@ -225,10 +210,8 @@ const Legend = forwardRef<HTMLDivElement, LegendProps>((props, ref) => {
|
|
|
225
210
|
strokeWidth={1}
|
|
226
211
|
/>
|
|
227
212
|
</svg>
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
{patternData.label || patternData.dataValue || ''}
|
|
231
|
-
</p>
|
|
213
|
+
<span>{patternData.label || patternData.dataValue || ''}</span>
|
|
214
|
+
</button>
|
|
232
215
|
</li>
|
|
233
216
|
</>
|
|
234
217
|
)
|
|
@@ -367,41 +350,41 @@ const Legend = forwardRef<HTMLDivElement, LegendProps>((props, ref) => {
|
|
|
367
350
|
|
|
368
351
|
{((config.visual.additionalCityStyles && config.visual.additionalCityStyles.some(c => c.label)) ||
|
|
369
352
|
config.visual.cityStyleLabel) && (
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
353
|
+
<>
|
|
354
|
+
<hr />
|
|
355
|
+
<div className={legendClasses.div.join(' ') || ''}>
|
|
356
|
+
{config.visual.cityStyleLabel && (
|
|
357
|
+
<div>
|
|
358
|
+
<svg>
|
|
359
|
+
<Group
|
|
360
|
+
top={
|
|
361
|
+
config.visual.cityStyle === 'pin' ? 19 : config.visual.cityStyle === 'triangle' ? 13 : 11
|
|
362
|
+
}
|
|
363
|
+
left={10}
|
|
364
|
+
>
|
|
365
|
+
{cityStyleShapes[config.visual.cityStyle.toLowerCase()]}
|
|
366
|
+
</Group>
|
|
367
|
+
</svg>
|
|
368
|
+
<p>{config.visual.cityStyleLabel}</p>
|
|
369
|
+
</div>
|
|
370
|
+
)}
|
|
388
371
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
372
|
+
{config.visual.additionalCityStyles.map(
|
|
373
|
+
({ shape, label }, index) =>
|
|
374
|
+
label && (
|
|
375
|
+
<div key={`additional-city-style-${index}-${shape}`}>
|
|
376
|
+
<svg>
|
|
377
|
+
<Group top={shape === 'Pin' ? 19 : shape === 'Triangle' ? 13 : 11} left={10}>
|
|
378
|
+
{cityStyleShapes[shape.toLowerCase()]}
|
|
379
|
+
</Group>
|
|
380
|
+
</svg>
|
|
381
|
+
<p>{label}</p>
|
|
382
|
+
</div>
|
|
383
|
+
)
|
|
384
|
+
)}
|
|
385
|
+
</div>
|
|
386
|
+
</>
|
|
387
|
+
)}
|
|
405
388
|
{runtimeLegend.disabledAmt > 0 && (
|
|
406
389
|
<Button className={legendClasses.showAllButton.join(' ')} onClick={handleReset}>
|
|
407
390
|
Show All
|
|
@@ -112,7 +112,7 @@ const LegendGroup = ({ legendItems }) => {
|
|
|
112
112
|
onKeyDown={e => {
|
|
113
113
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
114
114
|
e.preventDefault()
|
|
115
|
-
toggleLegendActive(index, item.label, runtimeLegend, dispatch)
|
|
115
|
+
toggleLegendActive(index, item.label, runtimeLegend, dispatch, config.legend.behavior)
|
|
116
116
|
}
|
|
117
117
|
}}
|
|
118
118
|
>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
@import '
|
|
1
|
+
@import '@cdc/core/styles/v2/utils/breakpoints';
|
|
2
2
|
|
|
3
3
|
.cdc-map-inner-container {
|
|
4
4
|
.map-container.world aside.side {
|
|
@@ -103,11 +103,33 @@
|
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
.legend-container__ul {
|
|
106
|
+
list-style: none;
|
|
106
107
|
line-height: 1;
|
|
107
108
|
row-gap: var(--space-between-legend-item-rows);
|
|
108
109
|
column-gap: var(--space-between-legend-item-columns);
|
|
109
110
|
}
|
|
110
111
|
|
|
112
|
+
.legend-container__li-btn {
|
|
113
|
+
display: flex;
|
|
114
|
+
align-items: center;
|
|
115
|
+
gap: 0.5em;
|
|
116
|
+
width: 100%;
|
|
117
|
+
background: none;
|
|
118
|
+
border: none;
|
|
119
|
+
padding: 0;
|
|
120
|
+
margin: 0;
|
|
121
|
+
font: inherit;
|
|
122
|
+
color: inherit;
|
|
123
|
+
cursor: pointer;
|
|
124
|
+
text-align: left;
|
|
125
|
+
|
|
126
|
+
&--pattern {
|
|
127
|
+
cursor: default;
|
|
128
|
+
}
|
|
129
|
+
&:focus {
|
|
130
|
+
outline: none;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
111
133
|
|
|
112
134
|
.legend-container__ul:not(.single-row, .legend-container__ul--single-column) {
|
|
113
135
|
list-style: none;
|
|
@@ -118,11 +140,6 @@
|
|
|
118
140
|
grid-template-columns: 1fr 1fr;
|
|
119
141
|
}
|
|
120
142
|
|
|
121
|
-
button {
|
|
122
|
-
font-size: unset;
|
|
123
|
-
background: transparent;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
143
|
&.vertical-sorted {
|
|
127
144
|
// Remove the grid overrides - let the existing column rules handle this
|
|
128
145
|
display: block !important; // Switch from grid to block to enable columns
|
|
@@ -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
|