@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.
- package/dist/{cdcchart-1a1724a1.es.js → cdcchart-dgT_1dIT.es.js} +136 -151
- package/dist/cdcchart.js +44003 -43518
- package/examples/feature/__data__/planet-example-data.json +1 -1
- package/examples/feature/boxplot/valid-boxplot.csv +38 -17
- package/examples/feature/pie/planet-pie-example-config.json +48 -2
- package/examples/private/DEV-11825.json +573 -0
- package/examples/private/DEV-12100.json +1303 -0
- package/examples/private/cat-y.json +1235 -0
- package/examples/private/data-points.json +228 -0
- package/examples/private/height.json +3915 -0
- package/examples/private/links.json +569 -0
- package/examples/private/na.json +913 -0
- package/examples/private/quadrant.txt +30 -0
- package/examples/private/test-data.csv +28 -0
- package/examples/private/test-forecast.json +5510 -0
- package/examples/private/warming-stripe-test.json +2578 -0
- package/examples/private/warming-stripes.json +4763 -0
- package/examples/tech-adoption-with-links.json +560 -0
- package/index.html +16 -140
- package/package.json +6 -5
- package/preview.html +1616 -0
- package/src/CdcChart.tsx +8 -11
- package/src/CdcChartComponent.tsx +329 -124
- package/src/_stories/Chart.Combo.stories.tsx +18 -0
- package/src/_stories/Chart.Forecast.stories.tsx +36 -0
- package/src/_stories/Chart.HTMLInDataTable.stories.tsx +520 -0
- package/src/_stories/Chart.Patterns.stories.tsx +2 -1
- package/src/_stories/Chart.PreserveDecimals.stories.tsx +220 -0
- package/src/_stories/Chart.Regions.Categorical.stories.tsx +148 -0
- package/src/_stories/Chart.Regions.DateScale.stories.tsx +197 -0
- package/src/_stories/Chart.Regions.DateTimeScale.stories.tsx +297 -0
- package/src/_stories/Chart.SmallMultiples.stories.tsx +47 -0
- package/src/_stories/Chart.stories.tsx +8 -0
- package/src/_stories/ChartAnnotation.stories.tsx +6 -3
- package/src/_stories/ChartBar.Editor.stories.tsx +3585 -0
- package/src/_stories/ChartBrush.Editor.stories.tsx +295 -0
- package/src/_stories/ChartBrush.stories.tsx +50 -0
- package/src/_stories/ChartEditor.Editor.stories.tsx +656 -0
- package/src/_stories/ChartEditor.stories.tsx +1 -2
- package/src/_stories/TechAdoptionWithLinks.stories.tsx +27 -0
- package/src/_stories/_mock/brush_enabled.json +326 -0
- package/src/_stories/_mock/brush_mock.json +2 -69
- package/src/_stories/_mock/combo.json +451 -0
- package/src/_stories/_mock/editor-test-configs.json +376 -0
- package/src/_stories/_mock/editor-test-datasets.json +477 -0
- package/src/_stories/_mock/editor-tests/bar-chart-editor-test.json +255 -0
- package/src/_stories/_mock/editor-tests/bar-chart-general-test.json +267 -0
- package/src/_stories/_mock/editor-tests/bar-chart-test.json +237 -0
- package/src/_stories/_mock/forecast_combo_with_gaps.json +913 -0
- package/src/_stories/_mock/horizontal-bars-dynamic-y-axis.json +413 -0
- package/src/_stories/_mock/pie_config.json +257 -62
- package/src/_stories/_mock/small_multiples/small_multiples_bars.json +1944 -0
- package/src/_stories/_mock/small_multiples/small_multiples_big_data_bars.json +1114 -0
- package/src/_stories/_mock/small_multiples/small_multiples_lines.json +2646 -0
- package/src/_stories/_mock/small_multiples/small_multiples_lines_colors.json +1305 -0
- package/src/_stories/_mock/small_multiples/small_multiples_stacked_bars.json +1936 -0
- package/src/components/Annotations/components/findNearestDatum.ts +6 -41
- package/src/components/AreaChart/components/AreaChart.Stacked.jsx +10 -7
- package/src/components/AreaChart/index.tsx +1 -2
- package/src/components/Axis/Categorical.Axis.tsx +6 -7
- package/src/components/BarChart/components/BarChart.Horizontal.tsx +181 -27
- package/src/components/BarChart/components/BarChart.StackedHorizontal.tsx +3 -1
- package/src/components/BarChart/components/BarChart.StackedVertical.tsx +1 -0
- package/src/components/BarChart/components/BarChart.Vertical.tsx +8 -9
- package/src/components/BarChart/components/context.tsx +1 -0
- package/src/components/BarChart/helpers/useBarChart.ts +14 -2
- package/src/components/BoxPlot/helpers/index.ts +3 -3
- package/src/components/Brush/BrushSelector.tsx +1258 -0
- package/src/components/Brush/MiniChartPreview.tsx +283 -0
- package/src/components/DeviationBar.jsx +9 -7
- package/src/components/EditorPanel/EditorPanel.tsx +2720 -2586
- package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +96 -111
- package/src/components/EditorPanel/components/Panels/Panel.ForestPlotSettings.tsx +56 -34
- package/src/components/EditorPanel/components/Panels/Panel.General.tsx +76 -31
- package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +104 -55
- package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +54 -49
- package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.tsx +427 -0
- package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +96 -48
- package/src/components/EditorPanel/components/Panels/index.tsx +3 -1
- package/src/components/EditorPanel/editor-panel.scss +0 -20
- package/src/components/EditorPanel/useEditorPermissions.ts +36 -31
- package/src/components/Forecasting/Forecasting.tsx +139 -21
- package/src/components/Legend/Legend.Component.tsx +16 -9
- package/src/components/Legend/Legend.tsx +3 -2
- package/src/components/Legend/helpers/createFormatLabels.tsx +325 -176
- package/src/components/Legend/helpers/getLegendClasses.ts +0 -1
- package/src/components/Legend/helpers/index.ts +10 -6
- package/src/components/LineChart/LineChartProps.ts +0 -3
- package/src/components/LineChart/helpers.ts +1 -1
- package/src/components/LineChart/index.tsx +36 -13
- package/src/components/LinearChart.tsx +559 -499
- package/src/components/PairedBarChart.jsx +20 -3
- package/src/components/Regions/components/Regions.tsx +366 -144
- package/src/components/Sankey/types/index.ts +1 -1
- package/src/components/ScatterPlot/ScatterPlot.jsx +2 -2
- package/src/components/SmallMultiples/SmallMultipleTile.tsx +202 -0
- package/src/components/SmallMultiples/SmallMultiples.css +32 -0
- package/src/components/SmallMultiples/SmallMultiples.tsx +271 -0
- package/src/components/SmallMultiples/index.ts +2 -0
- package/src/components/WarmingStripes/WarmingStripes.tsx +160 -0
- package/src/components/WarmingStripes/WarmingStripesGradientLegend.css +35 -0
- package/src/components/WarmingStripes/WarmingStripesGradientLegend.tsx +104 -0
- package/src/components/WarmingStripes/index.tsx +3 -0
- package/src/data/initial-state.js +16 -2
- package/src/helpers/buildForecastPaletteOptions.ts +0 -38
- package/src/helpers/calculateHorizontalBarCategoryLabelWidth.ts +57 -0
- package/src/helpers/getColorScale.ts +10 -0
- package/src/{hooks/useMinMax.ts → helpers/getMinMax.ts} +26 -14
- package/src/helpers/getYAxisAutoPadding.ts +53 -0
- package/src/helpers/sizeHelpers.ts +0 -20
- package/src/helpers/smallMultiplesHelpers.ts +529 -0
- package/src/hooks/useChartHoverAnalytics.tsx +10 -9
- package/src/hooks/useProgrammaticTooltip.ts +96 -0
- package/src/hooks/useScales.ts +98 -34
- package/src/hooks/useSmallMultipleSynchronization.ts +59 -0
- package/src/hooks/useTooltip.tsx +91 -25
- package/src/scss/DataTable.scss +0 -4
- package/src/scss/main.scss +18 -83
- package/src/store/chart.actions.ts +2 -0
- package/src/store/chart.reducer.ts +4 -0
- package/src/test/CdcChart.test.jsx +1 -1
- package/src/types/ChartConfig.ts +27 -6
- package/src/types/ChartContext.ts +3 -0
- package/src/types/Label.ts +1 -0
- package/src/utils/analyticsTracking.ts +19 -0
- package/LICENSE +0 -201
- package/src/_stories/_mock/pie_data.json +0 -218
- package/src/components/AreaChart/components/AreaChart.jsx +0 -109
- package/src/components/Brush/BrushChart.tsx +0 -128
- package/src/components/Brush/BrushController.tsx +0 -71
- package/src/components/Brush/types.tsx +0 -8
- package/src/components/BrushChart.tsx +0 -223
- package/src/helpers/sort.ts +0 -7
- package/src/hooks/useActiveElement.js +0 -19
- 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,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
|