@cdc/chart 4.26.4 → 4.26.5
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/CONFIG.md +349 -29
- package/LICENSE +201 -0
- package/dist/cdcchart.js +34247 -32805
- package/examples/feature/heatmap/average-age-categorical.json +313 -0
- package/examples/feature/heatmap/calendar-heatmap.json +134 -0
- package/examples/feature/heatmap/cell-values-bottom-axis.json +181 -0
- package/examples/feature/heatmap/sparse-aggregation.json +158 -0
- package/examples/private/bump-chart.json +1116 -0
- package/examples/private/test-123.json +624 -0
- package/package.json +4 -3
- package/src/CdcChartComponent.tsx +135 -54
- package/src/_stories/Chart.AltText.stories.tsx +136 -0
- package/src/_stories/Chart.HeatMap.stories.tsx +187 -0
- package/src/_stories/Chart.Sankey.smoke.stories.tsx +62 -0
- package/src/_stories/Chart.SmallMultiples.smoke.stories.tsx +29 -0
- package/src/_stories/ChartAxisTitles.smoke.stories.tsx +13 -0
- package/src/_stories/ChartBar.Editor.stories.tsx +63 -0
- package/src/_stories/ChartBrush.Interaction.stories.tsx +112 -0
- package/src/_stories/ChartBrush.smoke.stories.tsx +13 -4
- package/src/_stories/_mock/alt_text_metadata.json +38 -0
- package/src/_stories/_mock/heatmap-average-age-categorical.json +315 -0
- package/src/_stories/_mock/heatmap.json +156 -0
- package/src/_stories/_mock/reported_pertussis_cases.json +1643 -0
- package/src/_stories/_mock/sankey_mock.json +78 -0
- package/src/_stories/_mock/small_multiples/small_multiples_lines.json +1 -1
- package/src/components/Annotations/components/AnnotationDraggable.tsx +1 -1
- package/src/components/Annotations/components/AnnotationDropdown.tsx +5 -2
- package/src/components/Axis/LeftAxis.tsx +14 -11
- package/src/components/Axis/LeftAxisGridlines.tsx +14 -11
- package/src/components/Brush/BrushSelector.tsx +88 -187
- package/src/components/Brush/brushInteraction.ts +22 -0
- package/src/components/Brush/tests/brushInteraction.test.ts +38 -0
- package/src/components/EditorPanel/EditorPanel.tsx +310 -49
- package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +13 -0
- package/src/components/EditorPanel/components/Panels/Panel.General.tsx +105 -13
- package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +1 -0
- package/src/components/EditorPanel/components/Panels/Panel.Regions.tsx +5 -0
- package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +2 -2
- package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +1 -1
- package/src/components/EditorPanel/helpers/getVisualizationTypeConfigUpdate.ts +57 -0
- package/src/components/EditorPanel/helpers/tests/getVisualizationTypeConfigUpdate.test.ts +56 -0
- package/src/components/EditorPanel/useEditorPermissions.test.tsx +70 -0
- package/src/components/EditorPanel/useEditorPermissions.ts +42 -22
- package/src/components/HeatMap/components/HeatMap.tsx +762 -0
- package/src/components/HeatMap/components/HeatMapGradientLegend.tsx +114 -0
- package/src/components/HeatMap/heatmap.constants.ts +13 -0
- package/src/components/HeatMap/heatmap.css +71 -0
- package/src/components/HeatMap/helpers/index.ts +261 -0
- package/src/components/HeatMap/index.tsx +5 -0
- package/src/components/HeatMap/tests/HeatMap.test.tsx +1040 -0
- package/src/components/HeatMap/tests/helpers.test.ts +167 -0
- package/src/components/LineChart/components/LineChart.BumpCircle.tsx +19 -22
- package/src/components/LineChart/helpers.test.ts +345 -0
- package/src/components/LineChart/helpers.ts +15 -5
- package/src/components/LineChart/index.tsx +7 -1
- package/src/components/LinearChart/tests/LinearChart.test.tsx +107 -33
- package/src/components/LinearChart/tests/mockConfigContext.ts +1 -0
- package/src/components/LinearChart.tsx +58 -37
- package/src/components/PieChart/PieChart.tsx +4 -1
- package/src/components/RadarChart/RadarChart.tsx +3 -1
- package/src/components/Regions/components/Regions.tsx +195 -41
- package/src/components/Sankey/components/Sankey.tsx +7 -3
- package/src/components/Sankey/helpers/getSankeyTooltip.tsx +7 -6
- package/src/components/SmallMultiples/SmallMultipleTile.tsx +30 -3
- package/src/components/SmallMultiples/SmallMultiples.css +12 -0
- package/src/components/SmallMultiples/SmallMultiples.tsx +15 -9
- package/src/components/SmallMultiples/tests/SmallMultipleTile.test.tsx +152 -0
- package/src/components/Sparkline/components/SparkLine.tsx +4 -9
- package/src/components/WarmingStripes/WarmingStripesGradientLegend.css +1 -1
- package/src/components/WarmingStripes/WarmingStripesGradientLegend.tsx +2 -2
- package/src/data/initial-state.js +11 -0
- package/src/helpers/axisLabelFontSize.ts +8 -0
- package/src/helpers/bumpChartTooltip.ts +54 -0
- package/src/helpers/calculateLeftYAxisWidth.ts +4 -0
- package/src/helpers/ensureSpecialChartAxisTypes.ts +11 -0
- package/src/helpers/getMinMax.ts +2 -3
- package/src/helpers/getYAxisAutoPadding.ts +8 -6
- package/src/helpers/getYAxisAutoPaddingMode.ts +17 -0
- package/src/helpers/handleChartAriaLabels.ts +23 -10
- package/src/helpers/seriesHighlight.ts +26 -0
- package/src/helpers/tests/axisLabelFontSize.test.ts +15 -0
- package/src/helpers/tests/bumpChartTooltip.test.ts +75 -0
- package/src/helpers/tests/ensureSpecialChartAxisTypes.test.ts +30 -0
- package/src/helpers/tests/getYAxisAutoPadding.test.ts +37 -0
- package/src/helpers/tests/getYAxisAutoPaddingMode.test.ts +90 -0
- package/src/helpers/tests/handleChartAriaLabels.test.ts +71 -0
- package/src/helpers/tests/seriesHighlight.test.ts +61 -0
- package/src/hooks/tests/useScales.test.tsx +105 -0
- package/src/hooks/useScales.ts +19 -9
- package/src/index.jsx +5 -2
- package/src/scss/main.scss +32 -49
- package/src/types/ChartConfig.ts +18 -0
- package/src/types/ChartContext.ts +2 -0
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import React, { useContext } from 'react'
|
|
2
2
|
import ConfigContext from '../../../ConfigContext'
|
|
3
3
|
import { ChartContext } from '../../../types/ChartContext'
|
|
4
|
-
import { Text } from '@visx/text'
|
|
4
|
+
import { Text, useText } from '@visx/text'
|
|
5
5
|
import { Group } from '@visx/group'
|
|
6
|
-
import { formatDate
|
|
6
|
+
import { formatDate } from '@cdc/core/helpers/cove/date.js'
|
|
7
|
+
import { APP_FONT_COLOR } from '@cdc/core/helpers/constants'
|
|
8
|
+
import { isMobileFontViewport } from '@cdc/core/helpers/viewports'
|
|
7
9
|
|
|
8
10
|
// Constants for visualization types
|
|
9
11
|
const VIZ_TYPES = {
|
|
@@ -14,6 +16,16 @@ const VIZ_TYPES = {
|
|
|
14
16
|
HORIZON: 'Horizon Chart'
|
|
15
17
|
} as const
|
|
16
18
|
|
|
19
|
+
const DEFAULT_REGION_BACKGROUND = 'var(--cool-gray-50, #71767a)'
|
|
20
|
+
const TICK_LABEL_FONT_SIZE = 16
|
|
21
|
+
const TICK_LABEL_FONT_SIZE_SMALL = 13
|
|
22
|
+
const REGION_LABEL_HORIZONTAL_PADDING = 8
|
|
23
|
+
const REGION_LABEL_TOP_PADDING = 6
|
|
24
|
+
const REGION_LABEL_MAX_PLOT_WIDTH_RATIO = 1 / 3
|
|
25
|
+
const REGION_LABEL_COMFORTABLE_MIN_WIDTH = 120
|
|
26
|
+
const REGION_LABEL_COMFORTABLE_MIN_WIDTH_SMALL = 96
|
|
27
|
+
const REGION_LABEL_FONT_FAMILY = 'Nunito, sans-serif'
|
|
28
|
+
|
|
17
29
|
type Region = {
|
|
18
30
|
from: string
|
|
19
31
|
to: string
|
|
@@ -36,7 +48,6 @@ type RegionsProps = {
|
|
|
36
48
|
barWidth?: number
|
|
37
49
|
totalBarsInGroup?: number
|
|
38
50
|
xMax?: number
|
|
39
|
-
yAxisWidth?: number
|
|
40
51
|
}
|
|
41
52
|
|
|
42
53
|
type HighlightedAreaProps = {
|
|
@@ -46,10 +57,111 @@ type HighlightedAreaProps = {
|
|
|
46
57
|
background: string
|
|
47
58
|
}
|
|
48
59
|
|
|
60
|
+
type RegionLabelLayout = {
|
|
61
|
+
x: number
|
|
62
|
+
width: number
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
type RegionLabelProps = {
|
|
66
|
+
label: string
|
|
67
|
+
color: string
|
|
68
|
+
clippedFrom: number
|
|
69
|
+
regionWidth: number
|
|
70
|
+
plotWidth: number
|
|
71
|
+
fontSize: number
|
|
72
|
+
isMobileViewport: boolean
|
|
73
|
+
}
|
|
74
|
+
|
|
49
75
|
const HighlightedArea: React.FC<HighlightedAreaProps> = ({ x, width, yMax, background }) => (
|
|
50
|
-
<rect x={x} y={0} width={width} height={yMax} fill={background} opacity={0.3} />
|
|
76
|
+
<rect x={x} y={0} width={width} height={yMax} fill={background || DEFAULT_REGION_BACKGROUND} opacity={0.3} />
|
|
51
77
|
)
|
|
52
78
|
|
|
79
|
+
const clamp = (value: number, min: number, max: number): number => Math.min(Math.max(value, min), max)
|
|
80
|
+
|
|
81
|
+
const getRegionLabelY = (fontSize: number): number => REGION_LABEL_TOP_PADDING + fontSize * 0.25
|
|
82
|
+
|
|
83
|
+
const getComfortableRegionLabelWidth = (plotWidth: number, isMobileViewport: boolean): number => {
|
|
84
|
+
const maxPlotLabelWidth = Math.max(1, plotWidth * REGION_LABEL_MAX_PLOT_WIDTH_RATIO)
|
|
85
|
+
const comfortableMinWidth = isMobileViewport
|
|
86
|
+
? REGION_LABEL_COMFORTABLE_MIN_WIDTH_SMALL
|
|
87
|
+
: REGION_LABEL_COMFORTABLE_MIN_WIDTH
|
|
88
|
+
return Math.min(comfortableMinWidth, maxPlotLabelWidth, plotWidth)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Region labels prefer to sit centered inside their highlighted region when the measured text
|
|
92
|
+
// fits with padding. If the visible region is too narrow, keep a readable fixed wrapping width
|
|
93
|
+
// and pin the measured text near the region's right edge, clamped so the text stays inside the plot.
|
|
94
|
+
const getRegionLabelLayout = (
|
|
95
|
+
clippedFrom: number,
|
|
96
|
+
regionWidth: number,
|
|
97
|
+
plotWidth: number,
|
|
98
|
+
comfortableLabelWidth: number,
|
|
99
|
+
estimatedWrappedWidth: number
|
|
100
|
+
): RegionLabelLayout => {
|
|
101
|
+
const maxPlotLabelWidth = Math.max(1, plotWidth * REGION_LABEL_MAX_PLOT_WIDTH_RATIO)
|
|
102
|
+
const regionInnerWidth = Math.max(0, regionWidth - REGION_LABEL_HORIZONTAL_PADDING * 2)
|
|
103
|
+
const insideThreshold = Math.min(comfortableLabelWidth, estimatedWrappedWidth || comfortableLabelWidth)
|
|
104
|
+
|
|
105
|
+
const isInsideRegion = regionInnerWidth >= insideThreshold
|
|
106
|
+
const labelWidth = isInsideRegion ? Math.min(regionInnerWidth, maxPlotLabelWidth) : comfortableLabelWidth
|
|
107
|
+
|
|
108
|
+
const regionCenter = clippedFrom + regionWidth / 2
|
|
109
|
+
const halfLabelWidth = labelWidth / 2
|
|
110
|
+
const measuredTextWidth = Math.min(estimatedWrappedWidth || labelWidth, labelWidth)
|
|
111
|
+
const halfMeasuredTextWidth = measuredTextWidth / 2
|
|
112
|
+
const overflowLabelX = clamp(
|
|
113
|
+
clippedFrom + regionWidth - REGION_LABEL_HORIZONTAL_PADDING - halfMeasuredTextWidth,
|
|
114
|
+
halfMeasuredTextWidth,
|
|
115
|
+
plotWidth - halfMeasuredTextWidth
|
|
116
|
+
)
|
|
117
|
+
const insideLabelX =
|
|
118
|
+
labelWidth >= plotWidth ? plotWidth / 2 : clamp(regionCenter, halfLabelWidth, plotWidth - halfLabelWidth)
|
|
119
|
+
|
|
120
|
+
return { x: isInsideRegion ? insideLabelX : overflowLabelX, width: labelWidth }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const RegionLabel: React.FC<RegionLabelProps> = ({
|
|
124
|
+
label,
|
|
125
|
+
color,
|
|
126
|
+
clippedFrom,
|
|
127
|
+
regionWidth,
|
|
128
|
+
plotWidth,
|
|
129
|
+
fontSize,
|
|
130
|
+
isMobileViewport
|
|
131
|
+
}) => {
|
|
132
|
+
const style = { fontFamily: REGION_LABEL_FONT_FAMILY, fontSize }
|
|
133
|
+
const comfortableLabelWidth = getComfortableRegionLabelWidth(plotWidth, isMobileViewport)
|
|
134
|
+
const { wordsByLines } = useText({
|
|
135
|
+
children: label,
|
|
136
|
+
width: comfortableLabelWidth,
|
|
137
|
+
style
|
|
138
|
+
})
|
|
139
|
+
const estimatedWrappedWidth = Math.max(...wordsByLines.map(line => line.width || 0), 0)
|
|
140
|
+
const labelLayout = getRegionLabelLayout(
|
|
141
|
+
clippedFrom,
|
|
142
|
+
regionWidth,
|
|
143
|
+
plotWidth,
|
|
144
|
+
comfortableLabelWidth,
|
|
145
|
+
estimatedWrappedWidth
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<Text
|
|
150
|
+
x={labelLayout.x}
|
|
151
|
+
y={getRegionLabelY(fontSize)}
|
|
152
|
+
width={labelLayout.width}
|
|
153
|
+
fill={color || APP_FONT_COLOR}
|
|
154
|
+
fontSize={fontSize}
|
|
155
|
+
style={style}
|
|
156
|
+
lineHeight='1.1em'
|
|
157
|
+
verticalAnchor='start'
|
|
158
|
+
textAnchor='middle'
|
|
159
|
+
>
|
|
160
|
+
{label}
|
|
161
|
+
</Text>
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
53
165
|
/** Find the closest date in domain to a target date */
|
|
54
166
|
const findClosestDate = <T,>(targetTime: number, domain: T[], getTime: (d: T) => number): T => {
|
|
55
167
|
let closest = domain[0]
|
|
@@ -73,17 +185,12 @@ const isLineLike = (type: string): boolean =>
|
|
|
73
185
|
const isBarLike = (type: string): boolean => type === VIZ_TYPES.BAR || type === VIZ_TYPES.COMBO
|
|
74
186
|
|
|
75
187
|
// TODO: should regions be removed on categorical axis?
|
|
76
|
-
const Regions: React.FC<RegionsProps> = ({
|
|
77
|
-
|
|
78
|
-
barWidth = 0,
|
|
79
|
-
totalBarsInGroup = 1,
|
|
80
|
-
yMax,
|
|
81
|
-
xMax,
|
|
82
|
-
yAxisWidth = 0
|
|
83
|
-
}) => {
|
|
84
|
-
const { parseDate, config } = useContext<ChartContext>(ConfigContext)
|
|
188
|
+
const Regions: React.FC<RegionsProps> = ({ xScale, barWidth = 0, totalBarsInGroup = 1, yMax, xMax }) => {
|
|
189
|
+
const { parseDate, config, tableData, vizViewport } = useContext<ChartContext>(ConfigContext)
|
|
85
190
|
|
|
86
191
|
const { regions, visualizationType, orientation, xAxis } = config
|
|
192
|
+
const isMobileViewport = isMobileFontViewport(vizViewport || 'lg')
|
|
193
|
+
const regionLabelFontSize = isMobileViewport ? TICK_LABEL_FONT_SIZE_SMALL : TICK_LABEL_FONT_SIZE
|
|
87
194
|
|
|
88
195
|
const getBarOffset = (): number => (barWidth * totalBarsInGroup) / 2
|
|
89
196
|
|
|
@@ -91,15 +198,32 @@ const Regions: React.FC<RegionsProps> = ({
|
|
|
91
198
|
// HELPER FUNCTIONS FOR PREVIOUS DAYS
|
|
92
199
|
// ============================================
|
|
93
200
|
|
|
201
|
+
const getDataLastTime = (): number => {
|
|
202
|
+
const dataKey = config.xAxis.dataKey
|
|
203
|
+
if (!Array.isArray(tableData) || tableData.length === 0 || !dataKey) return NaN
|
|
204
|
+
let last = NaN
|
|
205
|
+
for (const row of tableData) {
|
|
206
|
+
const value = (row as Record<string, unknown>)[dataKey]
|
|
207
|
+
const t = parseDate(String(value))?.getTime()
|
|
208
|
+
if (typeof t === 'number' && !isNaN(t) && (isNaN(last) || t > last)) {
|
|
209
|
+
last = t
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return last
|
|
213
|
+
}
|
|
214
|
+
|
|
94
215
|
const calculatePreviousDaysFrom = (region: Region, axisType: string): number => {
|
|
95
216
|
const previousDays = Number(region.from) || 0
|
|
96
217
|
const domain = xScale.domain()
|
|
97
218
|
|
|
98
219
|
// Determine the "to" reference date
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
220
|
+
let toRefDate: number | Date
|
|
221
|
+
if (region.toType === 'Last Date') {
|
|
222
|
+
const dataLastTime = getDataLastTime()
|
|
223
|
+
toRefDate = !isNaN(dataLastTime) ? dataLastTime : new Date(domain[domain.length - 1] as string | number).getTime()
|
|
224
|
+
} else {
|
|
225
|
+
toRefDate = new Date(region.to)
|
|
226
|
+
}
|
|
103
227
|
|
|
104
228
|
const toFormatted = formatDate(config.xAxis.dateParseFormat, toRefDate, config.locale)
|
|
105
229
|
const toDate = new Date(toFormatted)
|
|
@@ -132,8 +256,8 @@ const Regions: React.FC<RegionsProps> = ({
|
|
|
132
256
|
} else {
|
|
133
257
|
from = xScale(region.from)
|
|
134
258
|
}
|
|
135
|
-
// Add
|
|
136
|
-
let scalePadding =
|
|
259
|
+
// Add half bandwidth to center on the category
|
|
260
|
+
let scalePadding = 0
|
|
137
261
|
if (xScale.bandwidth) {
|
|
138
262
|
scalePadding += xScale.bandwidth() / 2
|
|
139
263
|
}
|
|
@@ -145,8 +269,8 @@ const Regions: React.FC<RegionsProps> = ({
|
|
|
145
269
|
return calculateLineLastDatePosition_Categorical()
|
|
146
270
|
}
|
|
147
271
|
let to = xScale(region.to)
|
|
148
|
-
// Add
|
|
149
|
-
let scalePadding =
|
|
272
|
+
// Add half bandwidth to center on the category
|
|
273
|
+
let scalePadding = 0
|
|
150
274
|
if (xScale.bandwidth) {
|
|
151
275
|
scalePadding += xScale.bandwidth() / 2
|
|
152
276
|
}
|
|
@@ -168,8 +292,8 @@ const Regions: React.FC<RegionsProps> = ({
|
|
|
168
292
|
const closestDate = findClosestDate(parsedDate, domain, d => d)
|
|
169
293
|
from = xScale(closestDate)
|
|
170
294
|
}
|
|
171
|
-
// Add
|
|
172
|
-
let scalePadding =
|
|
295
|
+
// Add half bandwidth to center on the date band
|
|
296
|
+
let scalePadding = 0
|
|
173
297
|
if (xScale.bandwidth) {
|
|
174
298
|
scalePadding += xScale.bandwidth() / 2
|
|
175
299
|
}
|
|
@@ -189,8 +313,8 @@ const Regions: React.FC<RegionsProps> = ({
|
|
|
189
313
|
const closestDate = findClosestDate(parsedDate, domain, d => d)
|
|
190
314
|
let to = xScale(closestDate)
|
|
191
315
|
|
|
192
|
-
// Add
|
|
193
|
-
let scalePadding =
|
|
316
|
+
// Add half bandwidth to center on the date band
|
|
317
|
+
let scalePadding = 0
|
|
194
318
|
if (xScale.bandwidth) {
|
|
195
319
|
scalePadding += xScale.bandwidth() / 2
|
|
196
320
|
}
|
|
@@ -200,13 +324,13 @@ const Regions: React.FC<RegionsProps> = ({
|
|
|
200
324
|
const getLineFromValue_DateTime = (region: Region): number => {
|
|
201
325
|
if (region.fromType === 'Previous Days') {
|
|
202
326
|
const from = calculatePreviousDaysFrom(region, 'date-time')
|
|
203
|
-
return from
|
|
327
|
+
return from
|
|
204
328
|
}
|
|
205
329
|
const date = new Date(region.from)
|
|
206
330
|
const parsedDate = parseDate(formatDate(config.xAxis.dateParseFormat, date, config.locale)).getTime()
|
|
207
331
|
let from = xScale(parsedDate)
|
|
208
|
-
// For date-time, xScale returns
|
|
209
|
-
return from
|
|
332
|
+
// For date-time, xScale returns the plot-local position (no bandwidth)
|
|
333
|
+
return from
|
|
210
334
|
}
|
|
211
335
|
|
|
212
336
|
const getLineToValue_DateTime = (region: Region): number => {
|
|
@@ -214,27 +338,24 @@ const Regions: React.FC<RegionsProps> = ({
|
|
|
214
338
|
return calculateLineLastDatePosition_DateTime()
|
|
215
339
|
}
|
|
216
340
|
let to = xScale(parseDate(region.to).getTime())
|
|
217
|
-
return to
|
|
341
|
+
return to
|
|
218
342
|
}
|
|
219
343
|
|
|
220
344
|
const calculateLineLastDatePosition_Categorical = (): number => {
|
|
221
|
-
const chartStart = yAxisWidth
|
|
222
345
|
// Extend to the right edge of the chart
|
|
223
|
-
return
|
|
346
|
+
return xMax || 0
|
|
224
347
|
}
|
|
225
348
|
|
|
226
349
|
const calculateLineLastDatePosition_Date = (): number => {
|
|
227
|
-
const chartStart = yAxisWidth
|
|
228
350
|
// For date scale line charts with Last Date, extend to the right edge of the chart
|
|
229
|
-
return
|
|
351
|
+
return xMax || 0
|
|
230
352
|
}
|
|
231
353
|
|
|
232
354
|
const calculateLineLastDatePosition_DateTime = (): number => {
|
|
233
355
|
const domain = xScale.domain()
|
|
234
356
|
const lastDate = domain[domain.length - 1]
|
|
235
357
|
const lastDatePosition = xScale(lastDate)
|
|
236
|
-
|
|
237
|
-
return Number(lastDatePosition + yAxisWidth)
|
|
358
|
+
return Number(lastDatePosition)
|
|
238
359
|
}
|
|
239
360
|
|
|
240
361
|
// ============================================
|
|
@@ -333,6 +454,31 @@ const Regions: React.FC<RegionsProps> = ({
|
|
|
333
454
|
return Number(lastDatePosition)
|
|
334
455
|
}
|
|
335
456
|
|
|
457
|
+
const isRegionInVisibleDomain = (region: Region): boolean => {
|
|
458
|
+
if (region.toType !== 'Last Date') return true
|
|
459
|
+
if (xAxis.type !== 'date' && xAxis.type !== 'date-time') return true
|
|
460
|
+
|
|
461
|
+
const domain = xScale.domain()
|
|
462
|
+
if (!domain || domain.length === 0) return true
|
|
463
|
+
const visEnd = Number(domain[domain.length - 1])
|
|
464
|
+
if (isNaN(visEnd)) return true
|
|
465
|
+
|
|
466
|
+
let fromTime: number | undefined
|
|
467
|
+
if (region.fromType === 'Previous Days') {
|
|
468
|
+
const dataLastTime = getDataLastTime()
|
|
469
|
+
if (isNaN(dataLastTime)) return true
|
|
470
|
+
const previousDays = Number(region.from) || 0
|
|
471
|
+
const fromDate = new Date(dataLastTime)
|
|
472
|
+
fromDate.setDate(fromDate.getDate() - previousDays)
|
|
473
|
+
fromTime = fromDate.getTime()
|
|
474
|
+
} else {
|
|
475
|
+
fromTime = parseDate(formatDate(config.xAxis.dateParseFormat, new Date(region.from), config.locale))?.getTime()
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (fromTime === undefined || isNaN(fromTime)) return true
|
|
479
|
+
return fromTime < visEnd
|
|
480
|
+
}
|
|
481
|
+
|
|
336
482
|
// ============================================
|
|
337
483
|
// MAIN ROUTING FUNCTIONS
|
|
338
484
|
// ============================================
|
|
@@ -399,10 +545,12 @@ const Regions: React.FC<RegionsProps> = ({
|
|
|
399
545
|
|
|
400
546
|
if (!regions || orientation !== 'vertical') return null
|
|
401
547
|
|
|
402
|
-
const chartStart =
|
|
403
|
-
const chartEnd = xMax !== undefined ?
|
|
548
|
+
const chartStart = 0
|
|
549
|
+
const chartEnd = xMax !== undefined ? xMax : 1000
|
|
404
550
|
|
|
405
551
|
return regions.map((region: Region) => {
|
|
552
|
+
if (!isRegionInVisibleDomain(region)) return null
|
|
553
|
+
|
|
406
554
|
const from = getFromValue(region)
|
|
407
555
|
const to = getToValue(region)
|
|
408
556
|
|
|
@@ -419,11 +567,17 @@ const Regions: React.FC<RegionsProps> = ({
|
|
|
419
567
|
if (width <= 0) return null
|
|
420
568
|
|
|
421
569
|
return (
|
|
422
|
-
<Group height={100}
|
|
570
|
+
<Group height={100} className='regions regions-group--line' key={region.label} pointerEvents='none'>
|
|
423
571
|
<HighlightedArea x={clippedFrom} width={width} yMax={yMax} background={region.background} />
|
|
424
|
-
<
|
|
425
|
-
{region.label}
|
|
426
|
-
|
|
572
|
+
<RegionLabel
|
|
573
|
+
label={region.label}
|
|
574
|
+
color={region.color}
|
|
575
|
+
clippedFrom={clippedFrom}
|
|
576
|
+
regionWidth={width}
|
|
577
|
+
plotWidth={chartEnd}
|
|
578
|
+
fontSize={regionLabelFontSize}
|
|
579
|
+
isMobileViewport={isMobileViewport}
|
|
580
|
+
/>
|
|
427
581
|
</Group>
|
|
428
582
|
)
|
|
429
583
|
})
|
|
@@ -16,8 +16,10 @@ import useSankeyAlert from '../useSankeyAlert'
|
|
|
16
16
|
import { getSankeyTooltip } from '../helpers/getSankeyTooltip'
|
|
17
17
|
|
|
18
18
|
const Sankey = ({ width, height, runtime }: SankeyProps) => {
|
|
19
|
-
const { config } = useContext<ChartContext>(ConfigContext)
|
|
19
|
+
const { config, handleChartAriaLabels } = useContext<ChartContext>(ConfigContext)
|
|
20
20
|
const { sankey: sankeyConfig } = config
|
|
21
|
+
|
|
22
|
+
const a11y = handleChartAriaLabels(config)
|
|
21
23
|
const [largestGroupWidth, setLargestGroupWidth] = useState(0)
|
|
22
24
|
const [tooltipID, setTooltipID] = useState<string>('')
|
|
23
25
|
const { showAlert, alert } = useSankeyAlert()
|
|
@@ -43,7 +45,7 @@ const Sankey = ({ width, height, runtime }: SankeyProps) => {
|
|
|
43
45
|
useEffect(() => {
|
|
44
46
|
let largest = 0
|
|
45
47
|
groupRefs?.current?.map(g => {
|
|
46
|
-
const groupWidth = g?.getBoundingClientRect().width
|
|
48
|
+
const groupWidth = g?.getBBox?.().width || g?.getBoundingClientRect?.().width || 0
|
|
47
49
|
if (groupWidth > largest) {
|
|
48
50
|
largest = groupWidth
|
|
49
51
|
}
|
|
@@ -455,10 +457,12 @@ const Sankey = ({ width, height, runtime }: SankeyProps) => {
|
|
|
455
457
|
width={width}
|
|
456
458
|
height={Number(config.heights.vertical)}
|
|
457
459
|
style={{ overflow: 'visible' }}
|
|
460
|
+
role='img'
|
|
461
|
+
aria-label={a11y}
|
|
458
462
|
>
|
|
459
463
|
<Group className='links'>{allLinks}</Group>
|
|
460
464
|
<Group className='nodes'>{allNodes}</Group>
|
|
461
|
-
<Group className='finalNodes' style={{
|
|
465
|
+
<Group className='finalNodes' style={{ visibility: 'hidden', pointerEvents: 'none' }} aria-hidden='true'>
|
|
462
466
|
{finalNodes}
|
|
463
467
|
</Group>
|
|
464
468
|
</svg>
|
|
@@ -2,12 +2,13 @@ import ReactDOMServer from 'react-dom/server'
|
|
|
2
2
|
import ColumnList from '../components/ColumnList'
|
|
3
3
|
|
|
4
4
|
export const getSankeyTooltip = (data: Object, tooltipID: string) => {
|
|
5
|
-
const
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
const
|
|
5
|
+
const tooltips = Array.isArray(data?.tooltips) ? data.tooltips : []
|
|
6
|
+
const tooltipVal = `${(tooltips.find(item => item.node === tooltipID) || {}).value}`
|
|
7
|
+
const tooltipSummary = `${(tooltips.find(item => item.node === tooltipID) || {}).summary}`
|
|
8
|
+
const tooltipColumn1Label = (tooltips.find(item => item.node === tooltipID) || {}).column1Label
|
|
9
|
+
const tooltipColumn2Label = (tooltips.find(item => item.node === tooltipID) || {}).column2Label
|
|
10
|
+
const tooltipColumn1 = (tooltips.find(item => item.node === tooltipID) || {}).column1
|
|
11
|
+
const tooltipColumn2 = (tooltips.find(item => item.node === tooltipID) || {}).column2
|
|
11
12
|
|
|
12
13
|
const tooltipColumn1Data = ReactDOMServer.renderToString(<ColumnList columnData={tooltipColumn1} />)
|
|
13
14
|
const tooltipColumn2Data = ReactDOMServer.renderToString(<ColumnList columnData={tooltipColumn2} />)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useContext, useRef, useEffect } from 'react'
|
|
1
|
+
import React, { useContext, useRef, useEffect, useState } from 'react'
|
|
2
2
|
import LinearChart from '../LinearChart'
|
|
3
3
|
import ParentSize from '@visx/responsive/lib/components/ParentSize'
|
|
4
4
|
import ConfigContext from '../../ConfigContext'
|
|
@@ -7,6 +7,7 @@ import cloneConfig from '@cdc/core/helpers/cloneConfig'
|
|
|
7
7
|
import { getTileDisplayTitle } from '../../helpers/smallMultiplesHelpers'
|
|
8
8
|
import getViewport from '@cdc/core/helpers/getViewport'
|
|
9
9
|
import { ChartConfig } from '../../types/ChartConfig'
|
|
10
|
+
import { getAxisLabelFontSize } from '../../helpers/axisLabelFontSize'
|
|
10
11
|
|
|
11
12
|
interface SmallMultipleTileProps {
|
|
12
13
|
mode: 'by-series' | 'by-column'
|
|
@@ -107,11 +108,13 @@ const SmallMultipleTile: React.FC<SmallMultipleTileProps> = ({
|
|
|
107
108
|
}
|
|
108
109
|
}
|
|
109
110
|
|
|
111
|
+
const titlePlacementIsTop = tileConfig.yAxis?.titlePlacement === 'top'
|
|
112
|
+
|
|
110
113
|
// Small multiples-specific modifications
|
|
111
114
|
tileConfig = {
|
|
112
115
|
...tileConfig,
|
|
113
116
|
hideXAxisLabel: !isFirstInRow,
|
|
114
|
-
hideYAxisLabel: !isFirstInRow,
|
|
117
|
+
hideYAxisLabel: config.hideYAxisLabel || (!isFirstInRow && !titlePlacementIsTop),
|
|
115
118
|
legend: {
|
|
116
119
|
...tileConfig.legend,
|
|
117
120
|
tooltipLegendVisible: !config.legend?.hide,
|
|
@@ -125,6 +128,8 @@ const SmallMultipleTile: React.FC<SmallMultipleTileProps> = ({
|
|
|
125
128
|
}
|
|
126
129
|
|
|
127
130
|
const displayTitle = getTileDisplayTitle(mode, seriesKey, tileValue, tileKey, config)
|
|
131
|
+
const showTopYAxisTitle =
|
|
132
|
+
titlePlacementIsTop && isFirstInRow && !config.hideYAxisLabel && Boolean(tileConfig.runtime?.yAxis?.label)
|
|
128
133
|
|
|
129
134
|
// Get the original context values to merge with our filtered config
|
|
130
135
|
const originalContextValues = useContext(ConfigContext)
|
|
@@ -134,6 +139,7 @@ const SmallMultipleTile: React.FC<SmallMultipleTileProps> = ({
|
|
|
134
139
|
|
|
135
140
|
// Create a ref for the entire tile (including header) for height measurement
|
|
136
141
|
const fullTileRef = useRef<HTMLDivElement>(null)
|
|
142
|
+
const [tileChartWidth, setTileChartWidth] = useState(0)
|
|
137
143
|
|
|
138
144
|
// Create a ref for the LinearChart instance for tooltip coordination
|
|
139
145
|
const linearChartRef = useRef<any>(null)
|
|
@@ -164,6 +170,22 @@ const SmallMultipleTile: React.FC<SmallMultipleTileProps> = ({
|
|
|
164
170
|
return () => resizeObserver.disconnect()
|
|
165
171
|
}, [tileKey, onHeightChange])
|
|
166
172
|
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
if (!tileParentRef.current) return
|
|
175
|
+
|
|
176
|
+
const resizeObserver = new ResizeObserver(entries => {
|
|
177
|
+
entries.forEach(entry => {
|
|
178
|
+
setTileChartWidth(entry.contentRect.width)
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
resizeObserver.observe(tileParentRef.current)
|
|
183
|
+
return () => resizeObserver.disconnect()
|
|
184
|
+
}, [])
|
|
185
|
+
|
|
186
|
+
const tileVizViewport = tileChartWidth > 0 ? getViewport(tileChartWidth) : originalContextValues.vizViewport || 'lg'
|
|
187
|
+
const topYAxisTitleFontSize = getAxisLabelFontSize(tileVizViewport)
|
|
188
|
+
|
|
167
189
|
// Pass chart ref to parent SmallMultiples component for tooltip coordination
|
|
168
190
|
useEffect(() => {
|
|
169
191
|
if (onChartRef && linearChartRef.current) {
|
|
@@ -173,8 +195,13 @@ const SmallMultipleTile: React.FC<SmallMultipleTileProps> = ({
|
|
|
173
195
|
|
|
174
196
|
return (
|
|
175
197
|
<div ref={fullTileRef} className='small-multiple-tile'>
|
|
176
|
-
<div ref={onHeaderRef} className='tile-header'>
|
|
198
|
+
<div ref={onHeaderRef} className={`tile-header${showTopYAxisTitle ? ' tile-header--with-top-y-axis-title' : ''}`}>
|
|
177
199
|
<div className='tile-title'>{displayTitle}</div>
|
|
200
|
+
{showTopYAxisTitle && (
|
|
201
|
+
<div className='y-axis-top-title' style={{ fontSize: `${topYAxisTitleFontSize}px` }}>
|
|
202
|
+
{tileConfig.runtime.yAxis.label}
|
|
203
|
+
</div>
|
|
204
|
+
)}
|
|
178
205
|
</div>
|
|
179
206
|
<div ref={tileParentRef} className='tile-chart'>
|
|
180
207
|
<ParentSize
|
|
@@ -19,6 +19,18 @@
|
|
|
19
19
|
margin-bottom: 0.5rem;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
.small-multiples-container--with-top-y-axis-title .tile-header {
|
|
23
|
+
margin-bottom: 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.tile-header--with-top-y-axis-title {
|
|
27
|
+
margin-bottom: 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.tile-header--with-top-y-axis-title .tile-title {
|
|
31
|
+
margin-bottom: 0.5rem;
|
|
32
|
+
}
|
|
33
|
+
|
|
22
34
|
.tile-title {
|
|
23
35
|
font-weight: 700;
|
|
24
36
|
line-height: 1.3;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import React, { useContext,
|
|
1
|
+
import React, { useContext, useRef, useState, useMemo, useCallback, useLayoutEffect } from 'react'
|
|
2
2
|
import SmallMultipleTile from './SmallMultipleTile'
|
|
3
3
|
import ConfigContext from '../../ConfigContext'
|
|
4
4
|
import useReduceData from '../../hooks/useReduceData'
|
|
5
5
|
import useScales from '../../hooks/useScales'
|
|
6
|
+
import { getYAxisAutoPaddingMode } from '../../helpers/getYAxisAutoPaddingMode'
|
|
6
7
|
import { createCombinedDataForYAxis, applyTileOrder, createTileColorScale } from '../../helpers/smallMultiplesHelpers'
|
|
7
8
|
import { isMobileSmallMultiplesViewport } from '@cdc/core/helpers/viewports'
|
|
8
9
|
import './SmallMultiples.css'
|
|
@@ -105,9 +106,7 @@ const SmallMultiples: React.FC<SmallMultiplesProps> = ({ config, data, svgRef, p
|
|
|
105
106
|
combinedDataForYAxis.data
|
|
106
107
|
)
|
|
107
108
|
|
|
108
|
-
const
|
|
109
|
-
const inlineLabelHasNoSpace = !inlineLabel?.includes(' ')
|
|
110
|
-
const needsYAxisAutoPadding = inlineLabel && !inlineLabelHasNoSpace
|
|
109
|
+
const yAxisAutoPaddingMode = getYAxisAutoPaddingMode(config)
|
|
111
110
|
|
|
112
111
|
const { min, max } = useScales({
|
|
113
112
|
config: combinedDataForYAxis.config,
|
|
@@ -120,7 +119,7 @@ const SmallMultiples: React.FC<SmallMultiplesProps> = ({ config, data, svgRef, p
|
|
|
120
119
|
xAxisDataMapped: [],
|
|
121
120
|
xMax: parentWidth,
|
|
122
121
|
yMax: parentHeight,
|
|
123
|
-
|
|
122
|
+
yAxisAutoPaddingMode,
|
|
124
123
|
currentViewport
|
|
125
124
|
})
|
|
126
125
|
|
|
@@ -168,7 +167,7 @@ const SmallMultiples: React.FC<SmallMultiplesProps> = ({ config, data, svgRef, p
|
|
|
168
167
|
)
|
|
169
168
|
|
|
170
169
|
// Align tile header heights per row
|
|
171
|
-
|
|
170
|
+
useLayoutEffect(() => {
|
|
172
171
|
const headerEntries = Object.entries(headerRefs.current).filter(([_, ref]) => ref) as TileHeaderEntries
|
|
173
172
|
if (headerEntries.length === 0) return
|
|
174
173
|
|
|
@@ -209,7 +208,7 @@ const SmallMultiples: React.FC<SmallMultiplesProps> = ({ config, data, svgRef, p
|
|
|
209
208
|
}, [tileItems, tilesPerRow])
|
|
210
209
|
|
|
211
210
|
// Calculate container height from measured tile heights
|
|
212
|
-
|
|
211
|
+
useLayoutEffect(() => {
|
|
213
212
|
if (!parentRef.current) return
|
|
214
213
|
|
|
215
214
|
const measuredHeights = Object.values(tileHeights)
|
|
@@ -223,14 +222,21 @@ const SmallMultiples: React.FC<SmallMultiplesProps> = ({ config, data, svgRef, p
|
|
|
223
222
|
const totalHeight = numberOfRows * maxTileHeight + totalGapsHeight
|
|
224
223
|
|
|
225
224
|
parentRef.current.style.height = `${totalHeight}px`
|
|
226
|
-
}, [tileHeights, numberOfRows, isMobile
|
|
225
|
+
}, [tileHeights, numberOfRows, isMobile])
|
|
227
226
|
|
|
228
227
|
if (tileItems.length === 0) {
|
|
229
228
|
return null
|
|
230
229
|
}
|
|
231
230
|
|
|
231
|
+
const hasTopYAxisTitle =
|
|
232
|
+
config.yAxis?.titlePlacement === 'top' && !config.hideYAxisLabel && Boolean(config.runtime?.yAxis?.label)
|
|
233
|
+
|
|
232
234
|
return (
|
|
233
|
-
<div
|
|
235
|
+
<div
|
|
236
|
+
className={`small-multiples-container${
|
|
237
|
+
hasTopYAxisTitle ? ' small-multiples-container--with-top-y-axis-title' : ''
|
|
238
|
+
}`}
|
|
239
|
+
>
|
|
234
240
|
<div className='small-multiples-grid' style={gridStyle}>
|
|
235
241
|
{tileItems.map((item, index) => {
|
|
236
242
|
const customColorScale = createTileColorScale(item, config, colorScale, index, tileItems.length)
|