@cdc/map 4.25.10 → 4.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/.claude/agents/typescript-organizer.md +118 -0
  2. package/dist/{cdcmap-fce76882.es.js → cdcmap-BnB1QM5d.es.js} +6 -13
  3. package/dist/{cdcmap-c55ac1ea.es.js → cdcmap-D6CG2-Hb.es.js} +5 -12
  4. package/dist/{cdcmap-31a33da1.es.js → cdcmap-MXgURbdZ.es.js} +6 -13
  5. package/dist/{cdcmap-1a1724a1.es.js → cdcmap-dgT_1dIT.es.js} +136 -151
  6. package/dist/cdcmap.js +58397 -55987
  7. package/examples/example-city-state.json +9 -1
  8. package/examples/multi-country-centering.json +45 -0
  9. package/examples/private/city_styles_variable.json +877 -0
  10. package/examples/private/colors-2.json +221 -0
  11. package/examples/private/colors.json +221 -0
  12. package/examples/private/map-filter-issue.json +2260 -0
  13. package/examples/private/map-legend.json +5303 -0
  14. package/index.html +27 -36
  15. package/package.json +6 -5
  16. package/src/CdcMapComponent.tsx +86 -26
  17. package/src/_stories/CdcMap.ColumnWrap.stories.tsx +31 -0
  18. package/src/_stories/CdcMap.DistrictOfColumbia.stories.tsx +320 -0
  19. package/src/_stories/CdcMap.Editor.stories.tsx +3426 -0
  20. package/src/_stories/CdcMap.SmallMultiples.stories.tsx +35 -0
  21. package/src/_stories/CdcMap.stories.tsx +116 -4
  22. package/src/_stories/_mock/column-wrap-test.json +265 -0
  23. package/src/_stories/_mock/multi-country-hide.json +78 -0
  24. package/src/_stories/_mock/multi-country.json +95 -0
  25. package/src/_stories/_mock/multi-state.json +887 -20403
  26. package/src/_stories/_mock/small_multiples/multi-state-small-multiples.json +8399 -0
  27. package/src/_stories/_mock/small_multiples/region-small-multiples.json +657 -0
  28. package/src/_stories/_mock/small_multiples/wastewater-map-small-multiples.json +221 -0
  29. package/src/_stories/_mock/usa-state-gradient.json +3 -4
  30. package/src/components/BubbleList.tsx +1 -1
  31. package/src/components/CityList.tsx +24 -18
  32. package/src/components/EditorPanel/components/EditorPanel.tsx +2380 -2206
  33. package/src/components/EditorPanel/components/HexShapeSettings.tsx +55 -93
  34. package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +0 -19
  35. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +27 -37
  36. package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.tsx +351 -0
  37. package/src/components/EditorPanel/components/Panels/index.tsx +3 -1
  38. package/src/components/Geo.tsx +20 -3
  39. package/src/components/Legend/components/Legend.tsx +58 -75
  40. package/src/components/Legend/components/LegendGroup/Legend.Group.tsx +1 -1
  41. package/src/components/Legend/components/index.scss +23 -6
  42. package/src/components/NavigationMenu.tsx +16 -13
  43. package/src/components/SmallMultiples/SmallMultipleTile.tsx +163 -0
  44. package/src/components/SmallMultiples/SmallMultiples.css +32 -0
  45. package/src/components/SmallMultiples/SmallMultiples.tsx +150 -0
  46. package/src/components/SmallMultiples/SynchronizedTooltip.tsx +105 -0
  47. package/src/components/SmallMultiples/index.tsx +3 -0
  48. package/src/components/UsaMap/components/SingleState/SingleState.CountyOutput.tsx +18 -3
  49. package/src/components/UsaMap/components/TerritoriesSection.tsx +26 -12
  50. package/src/components/UsaMap/components/Territory/Territory.Hexagon.tsx +30 -4
  51. package/src/components/UsaMap/components/Territory/Territory.Rectangle.tsx +29 -9
  52. package/src/components/UsaMap/components/Territory/TerritoryShape.ts +7 -0
  53. package/src/components/UsaMap/components/UsaMap.County.tsx +16 -4
  54. package/src/components/UsaMap/components/UsaMap.Region.tsx +14 -1
  55. package/src/components/UsaMap/components/UsaMap.SingleState.tsx +29 -12
  56. package/src/components/UsaMap/components/UsaMap.State.tsx +30 -5
  57. package/src/components/UsaMap/helpers/map.ts +2 -2
  58. package/src/components/UsaMap/helpers/shapes.ts +9 -6
  59. package/src/components/WorldMap/WorldMap.tsx +81 -11
  60. package/src/data/initial-state.js +11 -0
  61. package/src/data/supported-geos.js +8 -76
  62. package/src/helpers/addUIDs.ts +13 -2
  63. package/src/helpers/applyColorToLegend.ts +25 -1
  64. package/src/helpers/applyLegendToRow.ts +5 -3
  65. package/src/helpers/constants.ts +3 -15
  66. package/src/helpers/displayGeoName.ts +22 -4
  67. package/src/helpers/generateRuntimeFilters.ts +1 -1
  68. package/src/helpers/generateRuntimeLegend.ts +1 -3
  69. package/src/helpers/generateRuntimeLegendHash.ts +1 -1
  70. package/src/helpers/getCountriesPicked.ts +103 -0
  71. package/src/helpers/getMapContainerClasses.ts +7 -0
  72. package/src/helpers/getPatternForRow.ts +2 -5
  73. package/src/helpers/index.ts +2 -4
  74. package/src/helpers/isLegendItemDisabled.ts +2 -2
  75. package/src/helpers/resetLegendToggles.ts +1 -0
  76. package/src/helpers/smallMultiplesHelpers.ts +359 -0
  77. package/src/helpers/tests/hashObj.test.ts +1 -1
  78. package/src/helpers/tests/titleCase.test.ts +76 -0
  79. package/src/helpers/titleCase.ts +13 -13
  80. package/src/helpers/toggleLegendActive.ts +76 -8
  81. package/src/helpers/urlDataHelpers.ts +1 -1
  82. package/src/hooks/useCountryZoom.tsx +241 -0
  83. package/src/hooks/useGeoClickHandler.ts +1 -1
  84. package/src/hooks/useProgrammaticMapTooltip.ts +110 -0
  85. package/src/hooks/useResizeObserver.ts +8 -2
  86. package/src/hooks/useStateZoom.tsx +7 -4
  87. package/src/hooks/useSynchronizedGeographies.ts +56 -0
  88. package/src/index.jsx +1 -0
  89. package/src/scss/editor-panel.scss +4 -440
  90. package/src/scss/main.scss +1 -1
  91. package/src/scss/map.scss +12 -15
  92. package/src/store/map.actions.ts +7 -7
  93. package/src/test/CdcMap.test.jsx +1 -1
  94. package/src/types/MapConfig.ts +32 -11
  95. package/src/types/MapContext.ts +6 -0
  96. package/src/types/runtimeLegend.ts +2 -1
  97. package/LICENSE +0 -201
  98. package/src/components/DataTable.tsx +0 -413
  99. package/src/components/EditorPanel/components/Inputs.tsx +0 -59
  100. package/src/components/MapControls.tsx +0 -44
  101. package/src/helpers/getUniqueValues.ts +0 -19
  102. package/src/helpers/hashObj.ts +0 -25
  103. package/src/hooks/useActiveElement.ts +0 -19
  104. package/src/hooks/useLegendSeparators.ts +0 -26
  105. package/src/scss/mixins.scss +0 -47
  106. package/src/types/Annotations.ts +0 -24
  107. /package/dist/{cdcmap-548642e6.es.js → cdcmap-Ct2SB0vL.es.js} +0 -0
@@ -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()?.replaceAll(' ', '') || 'country'
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 tabIndex={-1} className={`single-geo ${geoClassName}`} stroke={stroke} strokeWidth={strokeWidth} d={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 patternsOnlyFont = isMobileFontViewport(viewport) ? '12px' : '14px'
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
- className={handleListItemClass()}
130
- key={idx}
131
- title={`Legend item ${item.label} - Click to disable`}
132
- onClick={() => {
133
- toggleLegendActive(idx, item.label, runtimeLegend, dispatch)
134
- publishAnalyticsEvent({
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: 'keydown',
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
- tabIndex={0}
160
- role='button'
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
- className={`legend-container__li legend-container__li--geo-pattern`}
185
- aria-label='You are on a pattern button. We dont support toggling patterns on this legend at the moment, but provide the area as being focusable for congruity.'
186
- tabIndex={0}
187
- >
188
- <span className='legend-item' style={{ border: 'unset' }}>
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
- </span>
229
- <p style={{ lineHeight: '22.4px', fontSize: patternsOnly ? patternsOnlyFont : '16px' }}>
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
- <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
- )}
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
- {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
- )}
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 '../../../scss/mixins';
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
- <label htmlFor={mapTabbingID.replace('#', '')}>
69
- <div className='select-heading'>{navSelect}</div>
70
- <div className='d-flex'>
71
- <select value={activeGeo} id={mapTabbingID.replace('#', '')} onChange={e => setActiveGeo(e.target.value)}>
72
- {Object.keys(dropdownItems).map(key => (
73
- <option key={key} value={key}>
74
- {key}
75
- </option>
76
- ))}
77
- </select>
78
- <input type='submit' value={navGo} className={`${options.headerColor} btn`} id='cdcnavmap-dropdown-go' />
79
- </div>
80
- </label>
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