@cdc/chart 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 (135) hide show
  1. package/dist/{cdcchart-1a1724a1.es.js → cdcchart-dgT_1dIT.es.js} +136 -151
  2. package/dist/cdcchart.js +44003 -43518
  3. package/examples/feature/__data__/planet-example-data.json +1 -1
  4. package/examples/feature/boxplot/valid-boxplot.csv +38 -17
  5. package/examples/feature/pie/planet-pie-example-config.json +48 -2
  6. package/examples/private/DEV-11825.json +573 -0
  7. package/examples/private/DEV-12100.json +1303 -0
  8. package/examples/private/cat-y.json +1235 -0
  9. package/examples/private/data-points.json +228 -0
  10. package/examples/private/height.json +3915 -0
  11. package/examples/private/links.json +569 -0
  12. package/examples/private/na.json +913 -0
  13. package/examples/private/quadrant.txt +30 -0
  14. package/examples/private/test-data.csv +28 -0
  15. package/examples/private/test-forecast.json +5510 -0
  16. package/examples/private/warming-stripe-test.json +2578 -0
  17. package/examples/private/warming-stripes.json +4763 -0
  18. package/examples/tech-adoption-with-links.json +560 -0
  19. package/index.html +16 -140
  20. package/package.json +6 -5
  21. package/preview.html +1616 -0
  22. package/src/CdcChart.tsx +8 -11
  23. package/src/CdcChartComponent.tsx +329 -124
  24. package/src/_stories/Chart.Combo.stories.tsx +18 -0
  25. package/src/_stories/Chart.Forecast.stories.tsx +36 -0
  26. package/src/_stories/Chart.HTMLInDataTable.stories.tsx +520 -0
  27. package/src/_stories/Chart.Patterns.stories.tsx +2 -1
  28. package/src/_stories/Chart.PreserveDecimals.stories.tsx +220 -0
  29. package/src/_stories/Chart.Regions.Categorical.stories.tsx +148 -0
  30. package/src/_stories/Chart.Regions.DateScale.stories.tsx +197 -0
  31. package/src/_stories/Chart.Regions.DateTimeScale.stories.tsx +297 -0
  32. package/src/_stories/Chart.SmallMultiples.stories.tsx +47 -0
  33. package/src/_stories/Chart.stories.tsx +8 -0
  34. package/src/_stories/ChartAnnotation.stories.tsx +6 -3
  35. package/src/_stories/ChartBar.Editor.stories.tsx +3585 -0
  36. package/src/_stories/ChartBrush.Editor.stories.tsx +295 -0
  37. package/src/_stories/ChartBrush.stories.tsx +50 -0
  38. package/src/_stories/ChartEditor.Editor.stories.tsx +656 -0
  39. package/src/_stories/ChartEditor.stories.tsx +1 -2
  40. package/src/_stories/TechAdoptionWithLinks.stories.tsx +27 -0
  41. package/src/_stories/_mock/brush_enabled.json +326 -0
  42. package/src/_stories/_mock/brush_mock.json +2 -69
  43. package/src/_stories/_mock/combo.json +451 -0
  44. package/src/_stories/_mock/editor-test-configs.json +376 -0
  45. package/src/_stories/_mock/editor-test-datasets.json +477 -0
  46. package/src/_stories/_mock/editor-tests/bar-chart-editor-test.json +255 -0
  47. package/src/_stories/_mock/editor-tests/bar-chart-general-test.json +267 -0
  48. package/src/_stories/_mock/editor-tests/bar-chart-test.json +237 -0
  49. package/src/_stories/_mock/forecast_combo_with_gaps.json +913 -0
  50. package/src/_stories/_mock/horizontal-bars-dynamic-y-axis.json +413 -0
  51. package/src/_stories/_mock/pie_config.json +257 -62
  52. package/src/_stories/_mock/small_multiples/small_multiples_bars.json +1944 -0
  53. package/src/_stories/_mock/small_multiples/small_multiples_big_data_bars.json +1114 -0
  54. package/src/_stories/_mock/small_multiples/small_multiples_lines.json +2646 -0
  55. package/src/_stories/_mock/small_multiples/small_multiples_lines_colors.json +1305 -0
  56. package/src/_stories/_mock/small_multiples/small_multiples_stacked_bars.json +1936 -0
  57. package/src/components/Annotations/components/findNearestDatum.ts +6 -41
  58. package/src/components/AreaChart/components/AreaChart.Stacked.jsx +10 -7
  59. package/src/components/AreaChart/index.tsx +1 -2
  60. package/src/components/Axis/Categorical.Axis.tsx +6 -7
  61. package/src/components/BarChart/components/BarChart.Horizontal.tsx +181 -27
  62. package/src/components/BarChart/components/BarChart.StackedHorizontal.tsx +3 -1
  63. package/src/components/BarChart/components/BarChart.StackedVertical.tsx +1 -0
  64. package/src/components/BarChart/components/BarChart.Vertical.tsx +8 -9
  65. package/src/components/BarChart/components/context.tsx +1 -0
  66. package/src/components/BarChart/helpers/useBarChart.ts +14 -2
  67. package/src/components/BoxPlot/helpers/index.ts +3 -3
  68. package/src/components/Brush/BrushSelector.tsx +1258 -0
  69. package/src/components/Brush/MiniChartPreview.tsx +283 -0
  70. package/src/components/DeviationBar.jsx +9 -7
  71. package/src/components/EditorPanel/EditorPanel.tsx +2720 -2586
  72. package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +96 -111
  73. package/src/components/EditorPanel/components/Panels/Panel.ForestPlotSettings.tsx +56 -34
  74. package/src/components/EditorPanel/components/Panels/Panel.General.tsx +76 -31
  75. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +104 -55
  76. package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +54 -49
  77. package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.tsx +427 -0
  78. package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +96 -48
  79. package/src/components/EditorPanel/components/Panels/index.tsx +3 -1
  80. package/src/components/EditorPanel/editor-panel.scss +0 -20
  81. package/src/components/EditorPanel/useEditorPermissions.ts +36 -31
  82. package/src/components/Forecasting/Forecasting.tsx +139 -21
  83. package/src/components/Legend/Legend.Component.tsx +16 -9
  84. package/src/components/Legend/Legend.tsx +3 -2
  85. package/src/components/Legend/helpers/createFormatLabels.tsx +325 -176
  86. package/src/components/Legend/helpers/getLegendClasses.ts +0 -1
  87. package/src/components/Legend/helpers/index.ts +10 -6
  88. package/src/components/LineChart/LineChartProps.ts +0 -3
  89. package/src/components/LineChart/helpers.ts +1 -1
  90. package/src/components/LineChart/index.tsx +36 -13
  91. package/src/components/LinearChart.tsx +559 -499
  92. package/src/components/PairedBarChart.jsx +20 -3
  93. package/src/components/Regions/components/Regions.tsx +366 -144
  94. package/src/components/Sankey/types/index.ts +1 -1
  95. package/src/components/ScatterPlot/ScatterPlot.jsx +2 -2
  96. package/src/components/SmallMultiples/SmallMultipleTile.tsx +202 -0
  97. package/src/components/SmallMultiples/SmallMultiples.css +32 -0
  98. package/src/components/SmallMultiples/SmallMultiples.tsx +271 -0
  99. package/src/components/SmallMultiples/index.ts +2 -0
  100. package/src/components/WarmingStripes/WarmingStripes.tsx +160 -0
  101. package/src/components/WarmingStripes/WarmingStripesGradientLegend.css +35 -0
  102. package/src/components/WarmingStripes/WarmingStripesGradientLegend.tsx +104 -0
  103. package/src/components/WarmingStripes/index.tsx +3 -0
  104. package/src/data/initial-state.js +16 -2
  105. package/src/helpers/buildForecastPaletteOptions.ts +0 -38
  106. package/src/helpers/calculateHorizontalBarCategoryLabelWidth.ts +57 -0
  107. package/src/helpers/getColorScale.ts +10 -0
  108. package/src/{hooks/useMinMax.ts → helpers/getMinMax.ts} +26 -14
  109. package/src/helpers/getYAxisAutoPadding.ts +53 -0
  110. package/src/helpers/sizeHelpers.ts +0 -20
  111. package/src/helpers/smallMultiplesHelpers.ts +529 -0
  112. package/src/hooks/useChartHoverAnalytics.tsx +10 -9
  113. package/src/hooks/useProgrammaticTooltip.ts +96 -0
  114. package/src/hooks/useScales.ts +98 -34
  115. package/src/hooks/useSmallMultipleSynchronization.ts +59 -0
  116. package/src/hooks/useTooltip.tsx +91 -25
  117. package/src/scss/DataTable.scss +0 -4
  118. package/src/scss/main.scss +18 -83
  119. package/src/store/chart.actions.ts +2 -0
  120. package/src/store/chart.reducer.ts +4 -0
  121. package/src/test/CdcChart.test.jsx +1 -1
  122. package/src/types/ChartConfig.ts +27 -6
  123. package/src/types/ChartContext.ts +3 -0
  124. package/src/types/Label.ts +1 -0
  125. package/src/utils/analyticsTracking.ts +19 -0
  126. package/LICENSE +0 -201
  127. package/src/_stories/_mock/pie_data.json +0 -218
  128. package/src/components/AreaChart/components/AreaChart.jsx +0 -109
  129. package/src/components/Brush/BrushChart.tsx +0 -128
  130. package/src/components/Brush/BrushController.tsx +0 -71
  131. package/src/components/Brush/types.tsx +0 -8
  132. package/src/components/BrushChart.tsx +0 -223
  133. package/src/helpers/sort.ts +0 -7
  134. package/src/hooks/useActiveElement.js +0 -19
  135. package/src/hooks/useChartClasses.js +0 -41
@@ -0,0 +1,202 @@
1
+ import React, { useContext, useRef, useEffect } from 'react'
2
+ import LinearChart from '../LinearChart'
3
+ import ParentSize from '@visx/responsive/lib/components/ParentSize'
4
+ import ConfigContext from '../../ConfigContext'
5
+ import { ColorScale } from '../../types/ChartContext'
6
+ import cloneConfig from '@cdc/core/helpers/cloneConfig'
7
+ import { getTileDisplayTitle } from '../../helpers/smallMultiplesHelpers'
8
+ import getViewport from '@cdc/core/helpers/getViewport'
9
+ import { ChartConfig } from '../../types/ChartConfig'
10
+
11
+ interface SmallMultipleTileProps {
12
+ mode: 'by-series' | 'by-column'
13
+ config: ChartConfig
14
+ data: object[]
15
+ tileKey: string
16
+ seriesKey?: string
17
+ tileValue?: string | number
18
+ tileColumn?: string
19
+ customColorScale?: ColorScale
20
+ svgRef?: React.RefObject<SVGAElement>
21
+ parentWidth?: number
22
+ parentHeight?: number
23
+ globalYAxisMax?: number
24
+ globalYAxisMin?: number
25
+ isFirstInRow?: boolean
26
+ onHeightChange?: (tileKey: string, height: number) => void
27
+ onChartRef?: (ref: any) => void
28
+ onHeaderRef?: (ref: HTMLDivElement | null) => void
29
+ onChartHover?: (xAxisValue: any, yCoordinate: number) => void
30
+ }
31
+
32
+ const SmallMultipleTile: React.FC<SmallMultipleTileProps> = ({
33
+ mode,
34
+ config,
35
+ data,
36
+ tileKey,
37
+ seriesKey,
38
+ tileValue,
39
+ tileColumn,
40
+ customColorScale,
41
+ svgRef,
42
+ parentWidth,
43
+ globalYAxisMax,
44
+ globalYAxisMin,
45
+ isFirstInRow,
46
+ onHeightChange,
47
+ onChartRef,
48
+ onHeaderRef,
49
+ onChartHover
50
+ }) => {
51
+ let tileConfig = cloneConfig(config)
52
+ let tileData = data
53
+
54
+ if (mode === 'by-series') {
55
+ // BY-SERIES: One series per tile, all data
56
+ const singleSeries = tileConfig.series.find(s => s.dataKey === seriesKey)
57
+ tileConfig = {
58
+ ...tileConfig,
59
+ series: [singleSeries], // Single series
60
+ runtime: {
61
+ ...tileConfig.runtime,
62
+ series: tileConfig.runtime.series.filter(s => s.dataKey === seriesKey),
63
+ seriesKeys: [seriesKey],
64
+ seriesLabels: { [seriesKey]: tileConfig.runtime.seriesLabels?.[seriesKey] || seriesKey },
65
+ seriesLabelsAll: [tileConfig.runtime.seriesLabels?.[seriesKey] || seriesKey],
66
+ // Filter area chart specific series keys for proper rendering
67
+ ...(tileConfig.runtime.areaSeriesKeys && {
68
+ areaSeriesKeys: tileConfig.runtime.areaSeriesKeys.filter(s => s.dataKey === seriesKey)
69
+ }),
70
+ // Filter line chart specific series keys for proper rendering
71
+ ...(tileConfig.runtime.lineSeriesKeys && {
72
+ lineSeriesKeys: tileConfig.runtime.lineSeriesKeys.filter(key => key === seriesKey)
73
+ })
74
+ },
75
+ showTitle: false, // Individual tiles don't need the main title
76
+ smallMultiples: undefined // Remove smallMultiples to prevent infinite loop
77
+ }
78
+ tileData = data // All data, but only one series will render
79
+ } else if (mode === 'by-column') {
80
+ // BY-COLUMN: All series, filtered data by tile column value
81
+ tileConfig = {
82
+ ...tileConfig,
83
+ showTitle: false, // Individual tiles don't need the main title
84
+ smallMultiples: undefined // Remove smallMultiples to prevent infinite loop
85
+ }
86
+ tileData = data.filter(row => row[tileColumn] === tileValue) // Filtered data
87
+ }
88
+
89
+ // Apply global Y-axis values for consistent scaling if provided
90
+ if (globalYAxisMax !== undefined) {
91
+ tileConfig = {
92
+ ...tileConfig,
93
+ yAxis: {
94
+ ...tileConfig.yAxis,
95
+ max: globalYAxisMax,
96
+ min: globalYAxisMin
97
+ },
98
+ // Also update runtime properties since LinearChart checks runtime.yAxis.max
99
+ runtime: {
100
+ ...tileConfig.runtime,
101
+ yAxis: {
102
+ ...tileConfig.runtime?.yAxis,
103
+ max: globalYAxisMax,
104
+ min: globalYAxisMin
105
+ }
106
+ }
107
+ }
108
+ }
109
+
110
+ // Small multiples-specific modifications
111
+ tileConfig = {
112
+ ...tileConfig,
113
+ hideXAxisLabel: !isFirstInRow,
114
+ hideYAxisLabel: !isFirstInRow,
115
+ legend: {
116
+ ...tileConfig.legend,
117
+ hide: true // Hide legends for all small multiple tiles
118
+ },
119
+ xAxis: {
120
+ ...tileConfig.xAxis,
121
+ brushActive: false // Hide brush slider for all small multiple tiles
122
+ },
123
+ showAreaUnderLine: config.smallMultiples?.showAreaUnderLine || false
124
+ }
125
+
126
+ const displayTitle = getTileDisplayTitle(mode, seriesKey, tileValue, tileKey, config)
127
+
128
+ // Get the original context values to merge with our filtered config
129
+ const originalContextValues = useContext(ConfigContext)
130
+
131
+ // Create a tile-specific parentRef for this tile's chart
132
+ const tileParentRef = useRef<HTMLDivElement>(null)
133
+
134
+ // Create a ref for the entire tile (including header) for height measurement
135
+ const fullTileRef = useRef<HTMLDivElement>(null)
136
+
137
+ // Create a ref for the LinearChart instance for tooltip coordination
138
+ const linearChartRef = useRef<any>(null)
139
+
140
+ // Create new context values with our filtered config
141
+ const tileContextValues = {
142
+ ...originalContextValues,
143
+ config: tileConfig,
144
+ transformedData: tileData,
145
+ tableData: tileData, // Override with tile-specific filtered data (important for tooltip data lookup)
146
+ parentRef: tileParentRef, // Override with tile-specific parentRef
147
+ updateConfig: () => {}, // Prevent tile hooks from modifying global config
148
+ ...(customColorScale && { colorScale: customColorScale }) // Override colorScale if provided
149
+ }
150
+
151
+ // Use ResizeObserver to capture actual full tile height changes (including header)
152
+ useEffect(() => {
153
+ if (!fullTileRef.current || !onHeightChange) return
154
+
155
+ const resizeObserver = new ResizeObserver(entries => {
156
+ entries.forEach(entry => {
157
+ const { height } = entry.contentRect
158
+ onHeightChange(tileKey, height)
159
+ })
160
+ })
161
+
162
+ resizeObserver.observe(fullTileRef.current)
163
+ return () => resizeObserver.disconnect()
164
+ }, [tileKey, onHeightChange])
165
+
166
+ // Pass chart ref to parent SmallMultiples component for tooltip coordination
167
+ useEffect(() => {
168
+ if (onChartRef && linearChartRef.current) {
169
+ onChartRef(linearChartRef.current)
170
+ }
171
+ }, [onChartRef])
172
+
173
+ return (
174
+ <div ref={fullTileRef} className='small-multiple-tile'>
175
+ <div ref={onHeaderRef} className='tile-header'>
176
+ <div className='tile-title'>{displayTitle}</div>
177
+ </div>
178
+ <div ref={tileParentRef} className='tile-chart'>
179
+ <ParentSize
180
+ key={`${mode}-${seriesKey || tileValue}-${config.smallMultiples?.tilesPerRowDesktop}-${
181
+ config.smallMultiples?.tilesPerRowMobile
182
+ }-${parentWidth}`}
183
+ >
184
+ {parent => (
185
+ <ConfigContext.Provider
186
+ value={{
187
+ ...tileContextValues,
188
+ dimensions: [parent.width, parent.height], // Override with tile-specific dimensions
189
+ vizViewport: getViewport(parent.width), // Override with tile-specific viewport
190
+ handleSmallMultipleHover: onChartHover
191
+ }}
192
+ >
193
+ <LinearChart ref={linearChartRef} parentWidth={parent.width} parentHeight={parent.height} />
194
+ </ConfigContext.Provider>
195
+ )}
196
+ </ParentSize>
197
+ </div>
198
+ </div>
199
+ )
200
+ }
201
+
202
+ 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-chart {
30
+ width: 100%;
31
+ flex-shrink: 0;
32
+ }
@@ -0,0 +1,271 @@
1
+ import React, { useContext, useEffect, useRef, useState, useMemo, useCallback } from 'react'
2
+ import SmallMultipleTile from './SmallMultipleTile'
3
+ import ConfigContext from '../../ConfigContext'
4
+ import useReduceData from '../../hooks/useReduceData'
5
+ import useScales from '../../hooks/useScales'
6
+ import { createCombinedDataForYAxis, applyTileOrder, createTileColorScale } from '../../helpers/smallMultiplesHelpers'
7
+ import { isMobileSmallMultiplesViewport } from '@cdc/core/helpers/viewports'
8
+ import './SmallMultiples.css'
9
+ import { ChartConfig } from '../../types/ChartConfig'
10
+
11
+ interface SmallMultiplesProps {
12
+ config: ChartConfig
13
+ data: object[]
14
+ svgRef?: React.RefObject<SVGAElement>
15
+ parentWidth?: number
16
+ parentHeight?: number
17
+ }
18
+
19
+ type TileItem = {
20
+ key: string | number
21
+ mode: 'by-series' | 'by-column'
22
+ seriesKey?: string
23
+ tileValue?: any
24
+ tileColumn?: string
25
+ }
26
+
27
+ type ChartRefWithTooltipMethods = {
28
+ triggerTooltipAtDataValue?: (xAxisValue: any, yCoordinate: number) => void
29
+ hideTooltip?: () => void
30
+ }
31
+
32
+ type TileHeaderRows = Array<Array<HTMLDivElement>>
33
+
34
+ type TileHeaderEntries = Array<[string, HTMLDivElement]>
35
+
36
+ const SmallMultiples: React.FC<SmallMultiplesProps> = ({ config, data, svgRef, parentWidth, parentHeight }) => {
37
+ const { currentViewport, colorScale, parentRef } = useContext(ConfigContext)
38
+ const { mode, tileColumn, tilesPerRowDesktop, tilesPerRowMobile } = config.smallMultiples || {}
39
+
40
+ const isMobile = isMobileSmallMultiplesViewport(currentViewport)
41
+ const tilesPerRow = isMobile ? tilesPerRowMobile || 1 : tilesPerRowDesktop || 3
42
+
43
+ // Figure out what objects to iterate over based on mode - memoized to prevent recalculation
44
+ const tileItems = useMemo<Array<TileItem>>(() => {
45
+ let items: Array<TileItem> = []
46
+
47
+ if (mode === 'by-series') {
48
+ items = config.series.map(series => ({
49
+ key: series.dataKey,
50
+ mode: 'by-series' as const,
51
+ seriesKey: series.dataKey
52
+ }))
53
+ } else if (mode === 'by-column') {
54
+ const uniqueValues = Array.from(new Set(data.map(row => row[tileColumn])))
55
+ .filter(val => val != null)
56
+ .sort()
57
+ items = uniqueValues.map(value => ({
58
+ key: value,
59
+ mode: 'by-column' as const,
60
+ tileValue: value,
61
+ tileColumn: tileColumn
62
+ }))
63
+ }
64
+
65
+ // Apply tile ordering based on user preference
66
+ return applyTileOrder(
67
+ items,
68
+ config.smallMultiples?.tileOrderType || 'asc',
69
+ config.smallMultiples?.tileOrder,
70
+ config
71
+ )
72
+ }, [
73
+ mode,
74
+ config.series,
75
+ data,
76
+ tileColumn,
77
+ config.smallMultiples?.tileOrderType,
78
+ config.smallMultiples?.tileOrder,
79
+ config.smallMultiples?.tileTitles
80
+ ])
81
+
82
+ // Calculate the grid styling based on tiles per row
83
+ const gridGap = isMobile ? '1rem' : '2rem'
84
+ const gridStyle = {
85
+ gridTemplateColumns: `repeat(${tilesPerRow}, 1fr)`,
86
+ gap: gridGap
87
+ }
88
+
89
+ const [tileHeights, setTileHeights] = useState<Record<string, number>>({})
90
+
91
+ // Refs to all LinearChart instances for tooltip coordination
92
+ const chartRefs = useRef<Record<string, ChartRefWithTooltipMethods>>({})
93
+
94
+ // Refs to all tile header elements for height alignment
95
+ const headerRefs = useRef<Record<string, HTMLDivElement | null>>({})
96
+
97
+ // Create combined data and config for consistent Y-axis calculation
98
+ const combinedDataForYAxis = useMemo(
99
+ () => createCombinedDataForYAxis(config, data, tileItems),
100
+ [config, data, tileItems]
101
+ )
102
+
103
+ const { minValue, maxValue, existPositiveValue, isAllLine } = useReduceData(
104
+ combinedDataForYAxis.config,
105
+ combinedDataForYAxis.data
106
+ )
107
+
108
+ const inlineLabel = config.yAxis?.inlineLabel
109
+ const inlineLabelHasNoSpace = !inlineLabel?.includes(' ')
110
+ const needsYAxisAutoPadding = inlineLabel && !inlineLabelHasNoSpace
111
+
112
+ const { min, max } = useScales({
113
+ config: combinedDataForYAxis.config,
114
+ data: combinedDataForYAxis.data,
115
+ tableData: combinedDataForYAxis.data,
116
+ minValue,
117
+ maxValue,
118
+ existPositiveValue,
119
+ isAllLine,
120
+ xAxisDataMapped: [],
121
+ xMax: parentWidth,
122
+ yMax: parentHeight,
123
+ needsYAxisAutoPadding,
124
+ currentViewport
125
+ })
126
+
127
+ // Use consistent Y-axis if the feature is enabled and we have valid values
128
+ const globalYAxisValues = useMemo(() => {
129
+ if (config.smallMultiples?.independentYAxis) return null
130
+ if (typeof min !== 'number' || typeof max !== 'number') return null
131
+ if (combinedDataForYAxis.data.length === 0) return null
132
+
133
+ return { min, max }
134
+ }, [config.smallMultiples?.independentYAxis, min, max, combinedDataForYAxis.data.length])
135
+
136
+ const numberOfRows = useMemo(() => Math.ceil(tileItems.length / tilesPerRow), [tileItems.length, tilesPerRow])
137
+
138
+ // Handle tile height changes from ResizeObserver
139
+ const handleTileHeightChange = useCallback((tileKey: string, height: number) => {
140
+ setTileHeights(prev => ({ ...prev, [tileKey]: height }))
141
+ }, [])
142
+
143
+ // Handle tooltip synchronization across small multiple tiles
144
+ const handleChartHover = useCallback(
145
+ (sourceTileKey: string, xAxisValue: any, yCoordinate: number) => {
146
+ if (!config.smallMultiples?.synchronizedTooltips) return
147
+
148
+ // If xAxisValue is null, it means mouse left the chart - hide all tooltips
149
+ if (xAxisValue === null) {
150
+ Object.entries(chartRefs.current).forEach(([tileKey, chartRef]) => {
151
+ if (tileKey !== sourceTileKey && chartRef?.hideTooltip) {
152
+ chartRef.hideTooltip()
153
+ }
154
+ })
155
+ return
156
+ }
157
+
158
+ // For each OTHER chart in the grid, trigger tooltip at same X-axis data value and exact Y coordinate
159
+ Object.entries(chartRefs.current).forEach(([tileKey, chartRef]) => {
160
+ if (tileKey === sourceTileKey || !chartRef) return
161
+
162
+ if (chartRef.triggerTooltipAtDataValue) {
163
+ chartRef.triggerTooltipAtDataValue(xAxisValue, yCoordinate)
164
+ }
165
+ })
166
+ },
167
+ [config.smallMultiples?.synchronizedTooltips]
168
+ )
169
+
170
+ // Align tile header heights per row
171
+ useEffect(() => {
172
+ const headerEntries = Object.entries(headerRefs.current).filter(([_, ref]) => ref) as TileHeaderEntries
173
+ if (headerEntries.length === 0) return
174
+
175
+ // Group headers by row based on their index in tileItems
176
+ const headersByRow: TileHeaderRows = []
177
+
178
+ tileItems.forEach((item, index) => {
179
+ const rowIndex = Math.floor(index / tilesPerRow)
180
+ const header = headerRefs.current[String(item.key)]
181
+
182
+ headersByRow[rowIndex] ||= []
183
+ headersByRow[rowIndex].push(header)
184
+ })
185
+
186
+ // For each row, find the header with longest text and align others to it
187
+ headersByRow.forEach(rowHeaders => {
188
+ let longestHeader: HTMLDivElement | null = null
189
+ let maxTextLength = 0
190
+
191
+ rowHeaders.forEach(header => {
192
+ const textLength = header.textContent?.length || 0
193
+ if (textLength > maxTextLength) {
194
+ maxTextLength = textLength
195
+ longestHeader = header
196
+ }
197
+ })
198
+
199
+ if (!longestHeader) return
200
+
201
+ // Get the height of the longest header in this row
202
+ const targetHeight = longestHeader.offsetHeight
203
+
204
+ // Apply that height to all other headers in this row
205
+ rowHeaders.forEach(header => {
206
+ header.style.minHeight = header !== longestHeader ? `${targetHeight}px` : 'auto'
207
+ })
208
+ })
209
+ }, [tileItems, tilesPerRow])
210
+
211
+ // Calculate container height from measured tile heights
212
+ useEffect(() => {
213
+ if (!parentRef.current) return
214
+
215
+ const measuredHeights = Object.values(tileHeights)
216
+ if (measuredHeights.length === 0) {
217
+ return
218
+ }
219
+
220
+ const maxTileHeight = Math.max(...measuredHeights)
221
+ const gapSize = isMobile ? 18 : 36
222
+ const totalGapsHeight = (numberOfRows - 1) * gapSize
223
+ const totalHeight = numberOfRows * maxTileHeight + totalGapsHeight
224
+
225
+ parentRef.current.style.height = `${totalHeight}px`
226
+ }, [tileHeights, numberOfRows, isMobile, parentRef, tilesPerRow])
227
+
228
+ if (tileItems.length === 0) {
229
+ return null
230
+ }
231
+
232
+ return (
233
+ <div className='small-multiples-container'>
234
+ <div className='small-multiples-grid' style={gridStyle}>
235
+ {tileItems.map((item, index) => {
236
+ const customColorScale = createTileColorScale(item, config, colorScale, index, tileItems.length)
237
+
238
+ return (
239
+ <SmallMultipleTile
240
+ key={item.key}
241
+ tileKey={String(item.key)}
242
+ mode={item.mode}
243
+ seriesKey={item.seriesKey}
244
+ tileValue={item.tileValue}
245
+ tileColumn={item.tileColumn}
246
+ customColorScale={customColorScale}
247
+ config={config}
248
+ data={data}
249
+ svgRef={svgRef}
250
+ parentWidth={parentWidth}
251
+ parentHeight={parentHeight}
252
+ globalYAxisMax={globalYAxisValues?.max}
253
+ globalYAxisMin={globalYAxisValues?.min}
254
+ isFirstInRow={index % tilesPerRow === 0}
255
+ onHeightChange={handleTileHeightChange}
256
+ onChartRef={ref => {
257
+ chartRefs.current[String(item.key)] = ref
258
+ }}
259
+ onHeaderRef={ref => {
260
+ headerRefs.current[String(item.key)] = ref
261
+ }}
262
+ onChartHover={(xAxisValue, yCoordinate) => handleChartHover(String(item.key), xAxisValue, yCoordinate)}
263
+ />
264
+ )
265
+ })}
266
+ </div>
267
+ </div>
268
+ )
269
+ }
270
+
271
+ export default SmallMultiples
@@ -0,0 +1,2 @@
1
+ export { default as SmallMultiples } from './SmallMultiples'
2
+ export { default as SmallMultipleTile } from './SmallMultipleTile'
@@ -0,0 +1,160 @@
1
+ import { useContext, useState } from 'react'
2
+ import ConfigContext from '../../ConfigContext'
3
+ import { Group } from '@visx/group'
4
+ import { scaleSequential } from 'd3-scale'
5
+ import { interpolateRgbBasis } from 'd3-interpolate'
6
+ import { publishAnalyticsEvent } from '@cdc/core/helpers/metrics/helpers'
7
+ import { getVizTitle, getVizSubType } from '@cdc/core/helpers/metrics/utils'
8
+ import { filterChartColorPalettes } from '@cdc/core/helpers/filterColorPalettes'
9
+ import { getColorPaletteVersion } from '@cdc/core/helpers/getColorPaletteVersion'
10
+ import { getFallbackColorPalette, migratePaletteWithMap } from '@cdc/core/helpers/palettes/utils'
11
+ import { paletteMigrationMap } from '@cdc/core/helpers/palettes/migratePaletteName'
12
+ import { hasTrackedHover, markHoverTracked } from '../../utils/analyticsTracking'
13
+
14
+ type WarmingStripesProps = {
15
+ xScale: any
16
+ yScale: any
17
+ xMax: number
18
+ yMax: number
19
+ }
20
+
21
+ const WarmingStripes = ({ xMax, yMax }: WarmingStripesProps) => {
22
+ const { transformedData: data, config, formatNumber, interactionLabel, currentViewport } = useContext(ConfigContext)
23
+
24
+ const [currentHover, setCurrentHover] = useState<number | null>(null)
25
+
26
+ // Get the data key for the temperature/anomaly values
27
+ // Use the first series key as the value column
28
+ const valueKey = config.runtime.seriesKeys?.[0]
29
+ const xAxisDataKey = config.runtime.originalXAxis?.dataKey || config.xAxis?.dataKey
30
+
31
+ if (!valueKey || !xAxisDataKey || !data || data.length === 0) {
32
+ return null
33
+ }
34
+
35
+ // Determine max stripes based on viewport
36
+ const isMobile = ['xxs', 'xs', 'sm', 'md'].includes(currentViewport)
37
+ const maxStripes = isMobile ? 60 : 200
38
+
39
+ // Sample data if we have more than the max allowed stripes
40
+ let displayData = data
41
+ if (data.length > maxStripes) {
42
+ const step = data.length / maxStripes
43
+ displayData = []
44
+ for (let i = 0; i < maxStripes; i++) {
45
+ const index = Math.floor(i * step)
46
+ displayData.push(data[index])
47
+ }
48
+ }
49
+
50
+ // Calculate the min and max values for the color scale
51
+ const values = data.map(d => Number(d[valueKey])).filter(v => !isNaN(v))
52
+ const minValue = Math.min(...values)
53
+ const maxValue = Math.max(...values)
54
+
55
+ // Get the color palette from config
56
+ const colorPalettes = filterChartColorPalettes(config)
57
+ const configPalette = config.general?.palette?.name
58
+ const migratedPaletteName = configPalette ? configPalette : getFallbackColorPalette(config)
59
+
60
+ // Check if the palette name ends with 'reverse' and get the base palette
61
+ const isReversedPalette = migratedPaletteName?.endsWith('reverse')
62
+ const basePaletteName = isReversedPalette ? migratedPaletteName.slice(0, -7) : migratedPaletteName
63
+
64
+ let palette =
65
+ colorPalettes[migratePaletteWithMap(basePaletteName, paletteMigrationMap, false)] ||
66
+ colorPalettes[basePaletteName] ||
67
+ colorPalettes[configPalette]
68
+
69
+ // Fallback to a default diverging palette if none found
70
+ if (!palette || palette.length < 2) {
71
+ console.warn(`Palette "${configPalette}" not found or invalid, falling back to default`)
72
+ // Use a default blue to red palette
73
+ palette = [
74
+ '#053061',
75
+ '#2166ac',
76
+ '#4393c3',
77
+ '#92c5de',
78
+ '#d1e5f0',
79
+ '#f7f7f7',
80
+ '#fddbc7',
81
+ '#f4a582',
82
+ '#d6604d',
83
+ '#b2182b',
84
+ '#67001f'
85
+ ]
86
+ }
87
+
88
+ // Create a sequential color scale using the palette colors
89
+ // Apply reverse if configured (either via isReversed flag or 'reverse' suffix in name)
90
+ const shouldReverse = config.general?.palette?.isReversed || isReversedPalette
91
+ const finalPalette = shouldReverse ? [...palette].reverse() : palette
92
+ const colorScale = scaleSequential(interpolateRgbBasis(finalPalette)).domain([minValue, maxValue])
93
+
94
+ // Calculate stripe width based on available space and display data
95
+ const stripeWidth = xMax / displayData.length
96
+
97
+ const handleTooltip = (item: any) => {
98
+ const xValue = item[xAxisDataKey]
99
+ const yValue = item[valueKey]
100
+ const formattedValue = formatNumber(yValue, 'left')
101
+
102
+ return `<div>
103
+ <strong>${config.xAxis.label || xAxisDataKey}:</strong> ${xValue}<br/>
104
+ <strong>${config.runtime.seriesLabels?.[valueKey] || valueKey}:</strong> ${formattedValue}
105
+ </div>`
106
+ }
107
+
108
+ return (
109
+ <Group className='warming-stripes' left={config.yAxis.size}>
110
+ {displayData.map((item, index) => {
111
+ const value = Number(item[valueKey])
112
+ if (isNaN(value)) return null
113
+
114
+ const xPosition = index * stripeWidth
115
+ const fillColor = colorScale(value) as unknown as string
116
+ const isHovered = currentHover === index
117
+ const isMuted = currentHover !== null && !isHovered
118
+
119
+ return (
120
+ <rect
121
+ key={`stripe-${index}`}
122
+ x={xPosition}
123
+ y={0}
124
+ width={stripeWidth}
125
+ height={yMax}
126
+ fill={fillColor}
127
+ fillOpacity={isMuted ? 0.5 : 1}
128
+ stroke='none'
129
+ data-tooltip-html={handleTooltip(item)}
130
+ data-tooltip-id={`cdc-open-viz-tooltip-${config.runtime.uniqueId}`}
131
+ tabIndex={-1}
132
+ style={{ cursor: 'pointer', transition: 'fill-opacity 0.2s ease' }}
133
+ onMouseEnter={() => {
134
+ if (currentHover !== index) {
135
+ // Only publish analytics event once per visualization (shared tracking)
136
+ const vizId = String(config.runtime.uniqueId)
137
+ if (!hasTrackedHover(vizId)) {
138
+ publishAnalyticsEvent({
139
+ vizType: config?.type,
140
+ vizSubType: getVizSubType(config),
141
+ eventType: 'chart_hover',
142
+ eventAction: 'hover',
143
+ eventLabel: interactionLabel || 'unknown',
144
+ vizTitle: getVizTitle(config),
145
+ series: valueKey
146
+ })
147
+ markHoverTracked(vizId)
148
+ }
149
+ setCurrentHover(index)
150
+ }
151
+ }}
152
+ onMouseLeave={() => setCurrentHover(null)}
153
+ />
154
+ )
155
+ })}
156
+ </Group>
157
+ )
158
+ }
159
+
160
+ export default WarmingStripes