@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.
Files changed (137) hide show
  1. package/.claude/agents/typescript-organizer.md +118 -0
  2. package/.claude/settings.local.json +30 -0
  3. package/dist/{cdcmap-fce76882.es.js → cdcmap-BnB1QM5d.es.js} +6 -13
  4. package/dist/{cdcmap-c55ac1ea.es.js → cdcmap-D6CG2-Hb.es.js} +5 -12
  5. package/dist/{cdcmap-31a33da1.es.js → cdcmap-MXgURbdZ.es.js} +6 -13
  6. package/dist/{cdcmap-1a1724a1.es.js → cdcmap-dgT_1dIT.es.js} +136 -151
  7. package/dist/cdcmap.js +56991 -53706
  8. package/examples/example-city-state.json +9 -1
  9. package/examples/multi-country-centering.json +45 -0
  10. package/examples/private/c.json +290 -0
  11. package/examples/private/canvas-city-hover.json +787 -0
  12. package/examples/private/colors-2.json +221 -0
  13. package/examples/private/colors.json +221 -0
  14. package/examples/private/d.json +345 -0
  15. package/examples/private/g.json +1 -0
  16. package/examples/private/h.json +105911 -0
  17. package/examples/private/measles-data.json +378 -0
  18. package/examples/private/measles.json +211 -0
  19. package/examples/private/north-dakota.json +1132 -0
  20. package/examples/private/state-with-pattern.json +883 -0
  21. package/index.html +36 -34
  22. package/package.json +26 -5
  23. package/src/CdcMap.tsx +23 -8
  24. package/src/CdcMapComponent.tsx +238 -308
  25. package/src/_stories/CdcMap.ColumnWrap.stories.tsx +31 -0
  26. package/src/_stories/CdcMap.DistrictOfColumbia.stories.tsx +320 -0
  27. package/src/_stories/CdcMap.Editor.stories.tsx +3371 -0
  28. package/src/_stories/CdcMap.Filters.stories.tsx +2 -2
  29. package/src/_stories/CdcMap.Legend.Gradient.stories.tsx +3 -3
  30. package/src/_stories/CdcMap.Legend.stories.tsx +7 -4
  31. package/src/_stories/CdcMap.Patterns.stories.tsx +2 -2
  32. package/src/_stories/CdcMap.SmallMultiples.stories.tsx +35 -0
  33. package/src/_stories/CdcMap.Table.stories.tsx +2 -2
  34. package/src/_stories/CdcMap.stories.tsx +37 -9
  35. package/src/_stories/GoogleMap.stories.tsx +2 -2
  36. package/src/_stories/UsaMap.NoData.stories.tsx +2 -2
  37. package/src/_stories/_mock/column-wrap-test.json +265 -0
  38. package/src/_stories/_mock/equal-number.json +1109 -0
  39. package/src/_stories/_mock/multi-country-hide.json +78 -0
  40. package/src/_stories/_mock/multi-country.json +95 -0
  41. package/src/_stories/_mock/multi-state.json +887 -20403
  42. package/src/_stories/_mock/small_multiples/multi-state-small-multiples.json +8399 -0
  43. package/src/_stories/_mock/small_multiples/region-small-multiples.json +657 -0
  44. package/src/_stories/_mock/small_multiples/wastewater-map-small-multiples.json +221 -0
  45. package/src/_stories/_mock/us-bubble-cities.json +306 -0
  46. package/src/_stories/_mock/usa-state-gradient.json +2 -4
  47. package/src/components/BubbleList.tsx +17 -13
  48. package/src/components/CityList.tsx +85 -107
  49. package/src/components/EditorPanel/components/EditorPanel.tsx +787 -709
  50. package/src/components/EditorPanel/components/HexShapeSettings.tsx +58 -95
  51. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +34 -42
  52. package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.tsx +354 -0
  53. package/src/components/EditorPanel/components/Panels/index.tsx +3 -1
  54. package/src/components/Geo.tsx +22 -3
  55. package/src/components/Legend/components/Legend.tsx +76 -40
  56. package/src/components/Legend/components/LegendGroup/Legend.Group.tsx +10 -7
  57. package/src/components/Legend/components/index.scss +1 -1
  58. package/src/components/MapContainer.tsx +52 -0
  59. package/src/components/MapControls.tsx +44 -0
  60. package/src/components/NavigationMenu.tsx +27 -15
  61. package/src/components/SmallMultiples/SmallMultipleTile.tsx +163 -0
  62. package/src/components/SmallMultiples/SmallMultiples.css +32 -0
  63. package/src/components/SmallMultiples/SmallMultiples.tsx +150 -0
  64. package/src/components/SmallMultiples/SynchronizedTooltip.tsx +105 -0
  65. package/src/components/SmallMultiples/index.tsx +3 -0
  66. package/src/components/UsaMap/components/SingleState/SingleState.CountyOutput.tsx +36 -4
  67. package/src/components/UsaMap/components/TerritoriesSection.tsx +26 -12
  68. package/src/components/UsaMap/components/Territory/Territory.Hexagon.tsx +30 -4
  69. package/src/components/UsaMap/components/Territory/Territory.Rectangle.tsx +23 -4
  70. package/src/components/UsaMap/components/Territory/TerritoryShape.ts +6 -0
  71. package/src/components/UsaMap/components/UsaMap.County.tsx +123 -37
  72. package/src/components/UsaMap/components/UsaMap.Region.tsx +36 -5
  73. package/src/components/UsaMap/components/UsaMap.SingleState.tsx +30 -10
  74. package/src/components/UsaMap/components/UsaMap.State.tsx +53 -12
  75. package/src/components/UsaMap/helpers/map.ts +4 -4
  76. package/src/components/UsaMap/helpers/shapes.ts +9 -6
  77. package/src/components/WorldMap/WorldMap.tsx +193 -35
  78. package/src/components/ZoomControls.tsx +6 -9
  79. package/src/context/LegendMemoContext.tsx +30 -0
  80. package/src/context.ts +1 -40
  81. package/src/data/initial-state.js +153 -130
  82. package/src/data/supported-geos.js +25 -78
  83. package/src/helpers/addUIDs.ts +13 -2
  84. package/src/helpers/applyColorToLegend.ts +140 -20
  85. package/src/helpers/applyLegendToRow.ts +10 -6
  86. package/src/helpers/componentHelpers.ts +8 -0
  87. package/src/helpers/constants.ts +12 -14
  88. package/src/helpers/dataTableHelpers.ts +6 -0
  89. package/src/helpers/displayGeoName.ts +18 -3
  90. package/src/helpers/generateRuntimeLegend.ts +44 -10
  91. package/src/helpers/generateRuntimeLegendHash.ts +4 -2
  92. package/src/helpers/getColumnNames.ts +1 -1
  93. package/src/helpers/getCountriesPicked.ts +103 -0
  94. package/src/helpers/getMapContainerClasses.ts +7 -0
  95. package/src/helpers/getPatternForRow.ts +33 -0
  96. package/src/helpers/getStatesPicked.ts +8 -5
  97. package/src/helpers/index.ts +3 -3
  98. package/src/helpers/isLegendItemDisabled.ts +16 -0
  99. package/src/helpers/mapObserverHelpers.ts +40 -0
  100. package/src/helpers/resetLegendToggles.ts +3 -2
  101. package/src/helpers/smallMultiplesHelpers.ts +359 -0
  102. package/src/helpers/tests/titleCase.test.ts +76 -0
  103. package/src/helpers/titleCase.ts +13 -13
  104. package/src/helpers/toggleLegendActive.ts +6 -11
  105. package/src/helpers/urlDataHelpers.ts +70 -0
  106. package/src/hooks/useCountryZoom.tsx +241 -0
  107. package/src/hooks/useGeoClickHandler.ts +36 -2
  108. package/src/hooks/useLegendMemo.ts +17 -0
  109. package/src/hooks/useMapLayers.tsx +5 -4
  110. package/src/hooks/useProgrammaticMapTooltip.ts +110 -0
  111. package/src/hooks/useResizeObserver.ts +5 -2
  112. package/src/hooks/useStateZoom.tsx +30 -8
  113. package/src/hooks/useSynchronizedGeographies.ts +56 -0
  114. package/src/hooks/useTooltip.ts +1 -2
  115. package/src/index.jsx +1 -2
  116. package/src/scss/editor-panel.scss +4 -440
  117. package/src/scss/main.scss +1 -1
  118. package/src/scss/map.scss +12 -15
  119. package/src/store/map.actions.ts +7 -7
  120. package/src/store/map.reducer.ts +17 -6
  121. package/src/test/CdcMap.test.jsx +11 -0
  122. package/src/types/MapConfig.ts +46 -18
  123. package/src/types/MapContext.ts +6 -7
  124. package/src/types/runtimeLegend.ts +17 -1
  125. package/vite.config.js +2 -7
  126. package/vitest.config.ts +16 -0
  127. package/src/components/DataTable.tsx +0 -385
  128. package/src/components/EditorPanel/components/Inputs.tsx +0 -59
  129. package/src/coreStyles_map.scss +0 -3
  130. package/src/helpers/colorDistributions.ts +0 -12
  131. package/src/helpers/generateColorsArray.ts +0 -14
  132. package/src/helpers/tests/generateColorsArray.test.ts +0 -18
  133. package/src/helpers/tests/generateRuntimeLegendHash.test.ts +0 -11
  134. package/src/hooks/useActiveElement.ts +0 -19
  135. package/src/scss/mixins.scss +0 -47
  136. package/src/types/Annotations.ts +0 -24
  137. /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('map_navigation_menu', 'submit', `${interactionLabel}|${urlString}`)
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
- <label htmlFor={mapTabbingID.replace('#', '')}>
60
- <div className='select-heading'>{navSelect}</div>
61
- <div className='d-flex'>
62
- <select value={activeGeo} id={mapTabbingID.replace('#', '')} onChange={e => setActiveGeo(e.target.value)}>
63
- {Object.keys(dropdownItems).map(key => (
64
- <option key={key} value={key}>
65
- {key}
66
- </option>
67
- ))}
68
- </select>
69
- <input type='submit' value={navGo} className={`${options.headerColor} btn`} id='cdcnavmap-dropdown-go' />
70
- </div>
71
- </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>
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
@@ -0,0 +1,3 @@
1
+ export { default } from './SmallMultiples'
2
+ export { default as SmallMultiples } from './SmallMultiples'
3
+ export { default as SmallMultipleTile } from './SmallMultipleTile'