@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.
Files changed (93) hide show
  1. package/CONFIG.md +349 -29
  2. package/LICENSE +201 -0
  3. package/dist/cdcchart.js +34247 -32805
  4. package/examples/feature/heatmap/average-age-categorical.json +313 -0
  5. package/examples/feature/heatmap/calendar-heatmap.json +134 -0
  6. package/examples/feature/heatmap/cell-values-bottom-axis.json +181 -0
  7. package/examples/feature/heatmap/sparse-aggregation.json +158 -0
  8. package/examples/private/bump-chart.json +1116 -0
  9. package/examples/private/test-123.json +624 -0
  10. package/package.json +4 -3
  11. package/src/CdcChartComponent.tsx +135 -54
  12. package/src/_stories/Chart.AltText.stories.tsx +136 -0
  13. package/src/_stories/Chart.HeatMap.stories.tsx +187 -0
  14. package/src/_stories/Chart.Sankey.smoke.stories.tsx +62 -0
  15. package/src/_stories/Chart.SmallMultiples.smoke.stories.tsx +29 -0
  16. package/src/_stories/ChartAxisTitles.smoke.stories.tsx +13 -0
  17. package/src/_stories/ChartBar.Editor.stories.tsx +63 -0
  18. package/src/_stories/ChartBrush.Interaction.stories.tsx +112 -0
  19. package/src/_stories/ChartBrush.smoke.stories.tsx +13 -4
  20. package/src/_stories/_mock/alt_text_metadata.json +38 -0
  21. package/src/_stories/_mock/heatmap-average-age-categorical.json +315 -0
  22. package/src/_stories/_mock/heatmap.json +156 -0
  23. package/src/_stories/_mock/reported_pertussis_cases.json +1643 -0
  24. package/src/_stories/_mock/sankey_mock.json +78 -0
  25. package/src/_stories/_mock/small_multiples/small_multiples_lines.json +1 -1
  26. package/src/components/Annotations/components/AnnotationDraggable.tsx +1 -1
  27. package/src/components/Annotations/components/AnnotationDropdown.tsx +5 -2
  28. package/src/components/Axis/LeftAxis.tsx +14 -11
  29. package/src/components/Axis/LeftAxisGridlines.tsx +14 -11
  30. package/src/components/Brush/BrushSelector.tsx +88 -187
  31. package/src/components/Brush/brushInteraction.ts +22 -0
  32. package/src/components/Brush/tests/brushInteraction.test.ts +38 -0
  33. package/src/components/EditorPanel/EditorPanel.tsx +310 -49
  34. package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +13 -0
  35. package/src/components/EditorPanel/components/Panels/Panel.General.tsx +105 -13
  36. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +1 -0
  37. package/src/components/EditorPanel/components/Panels/Panel.Regions.tsx +5 -0
  38. package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +2 -2
  39. package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +1 -1
  40. package/src/components/EditorPanel/helpers/getVisualizationTypeConfigUpdate.ts +57 -0
  41. package/src/components/EditorPanel/helpers/tests/getVisualizationTypeConfigUpdate.test.ts +56 -0
  42. package/src/components/EditorPanel/useEditorPermissions.test.tsx +70 -0
  43. package/src/components/EditorPanel/useEditorPermissions.ts +42 -22
  44. package/src/components/HeatMap/components/HeatMap.tsx +762 -0
  45. package/src/components/HeatMap/components/HeatMapGradientLegend.tsx +114 -0
  46. package/src/components/HeatMap/heatmap.constants.ts +13 -0
  47. package/src/components/HeatMap/heatmap.css +71 -0
  48. package/src/components/HeatMap/helpers/index.ts +261 -0
  49. package/src/components/HeatMap/index.tsx +5 -0
  50. package/src/components/HeatMap/tests/HeatMap.test.tsx +1040 -0
  51. package/src/components/HeatMap/tests/helpers.test.ts +167 -0
  52. package/src/components/LineChart/components/LineChart.BumpCircle.tsx +19 -22
  53. package/src/components/LineChart/helpers.test.ts +345 -0
  54. package/src/components/LineChart/helpers.ts +15 -5
  55. package/src/components/LineChart/index.tsx +7 -1
  56. package/src/components/LinearChart/tests/LinearChart.test.tsx +107 -33
  57. package/src/components/LinearChart/tests/mockConfigContext.ts +1 -0
  58. package/src/components/LinearChart.tsx +58 -37
  59. package/src/components/PieChart/PieChart.tsx +4 -1
  60. package/src/components/RadarChart/RadarChart.tsx +3 -1
  61. package/src/components/Regions/components/Regions.tsx +195 -41
  62. package/src/components/Sankey/components/Sankey.tsx +7 -3
  63. package/src/components/Sankey/helpers/getSankeyTooltip.tsx +7 -6
  64. package/src/components/SmallMultiples/SmallMultipleTile.tsx +30 -3
  65. package/src/components/SmallMultiples/SmallMultiples.css +12 -0
  66. package/src/components/SmallMultiples/SmallMultiples.tsx +15 -9
  67. package/src/components/SmallMultiples/tests/SmallMultipleTile.test.tsx +152 -0
  68. package/src/components/Sparkline/components/SparkLine.tsx +4 -9
  69. package/src/components/WarmingStripes/WarmingStripesGradientLegend.css +1 -1
  70. package/src/components/WarmingStripes/WarmingStripesGradientLegend.tsx +2 -2
  71. package/src/data/initial-state.js +11 -0
  72. package/src/helpers/axisLabelFontSize.ts +8 -0
  73. package/src/helpers/bumpChartTooltip.ts +54 -0
  74. package/src/helpers/calculateLeftYAxisWidth.ts +4 -0
  75. package/src/helpers/ensureSpecialChartAxisTypes.ts +11 -0
  76. package/src/helpers/getMinMax.ts +2 -3
  77. package/src/helpers/getYAxisAutoPadding.ts +8 -6
  78. package/src/helpers/getYAxisAutoPaddingMode.ts +17 -0
  79. package/src/helpers/handleChartAriaLabels.ts +23 -10
  80. package/src/helpers/seriesHighlight.ts +26 -0
  81. package/src/helpers/tests/axisLabelFontSize.test.ts +15 -0
  82. package/src/helpers/tests/bumpChartTooltip.test.ts +75 -0
  83. package/src/helpers/tests/ensureSpecialChartAxisTypes.test.ts +30 -0
  84. package/src/helpers/tests/getYAxisAutoPadding.test.ts +37 -0
  85. package/src/helpers/tests/getYAxisAutoPaddingMode.test.ts +90 -0
  86. package/src/helpers/tests/handleChartAriaLabels.test.ts +71 -0
  87. package/src/helpers/tests/seriesHighlight.test.ts +61 -0
  88. package/src/hooks/tests/useScales.test.tsx +105 -0
  89. package/src/hooks/useScales.ts +19 -9
  90. package/src/index.jsx +5 -2
  91. package/src/scss/main.scss +32 -49
  92. package/src/types/ChartConfig.ts +18 -0
  93. 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, isDateScale } from '@cdc/core/helpers/cove/date.js'
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
- xScale,
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
- const toRefDate =
100
- region.toType === 'Last Date'
101
- ? new Date(domain[domain.length - 1] as string | number).getTime()
102
- : new Date(region.to)
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 left padding (yAxisWidth) + half bandwidth to center on the category
136
- let scalePadding = yAxisWidth
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 left padding (yAxisWidth) + half bandwidth
149
- let scalePadding = yAxisWidth
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 left padding (yAxisWidth) + half bandwidth
172
- let scalePadding = yAxisWidth
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 left padding (yAxisWidth) + half bandwidth
193
- let scalePadding = yAxisWidth
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 + yAxisWidth
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 correct position (no bandwidth), just add left padding
209
- return from + yAxisWidth
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 + yAxisWidth
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 chartStart + (xMax || 0)
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 chartStart + (xMax || 0)
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
- // Match the non-Last Date logic: just add yAxisWidth
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 = yAxisWidth
403
- const chartEnd = xMax !== undefined ? chartStart + xMax : chartStart + 1000
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} fill='red' className='regions regions-group--line' key={region.label} pointerEvents='none'>
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
- <Text x={clippedFrom + width / 2} y={5} fill={region.color} verticalAnchor='start' textAnchor='middle'>
425
- {region.label}
426
- </Text>
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={{ display: 'none' }}>
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 tooltipVal = `${(data?.tooltips?.find(item => item.node === tooltipID) || {}).value}`
6
- const tooltipSummary = `${(data?.tooltips?.find(item => item.node === tooltipID) || {}).summary}`
7
- const tooltipColumn1Label = (data?.tooltips?.find(item => item.node === tooltipID) || {}).column1Label
8
- const tooltipColumn2Label = (data?.tooltips?.find(item => item.node === tooltipID) || {}).column2Label
9
- const tooltipColumn1 = (data?.tooltips?.find(item => item.node === tooltipID) || {}).column1
10
- const tooltipColumn2 = (data?.tooltips?.find(item => item.node === tooltipID) || {}).column2
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, useEffect, useRef, useState, useMemo, useCallback } from 'react'
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 inlineLabel = config.yAxis?.inlineLabel
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
- needsYAxisAutoPadding,
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
- useEffect(() => {
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
- useEffect(() => {
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, parentRef, tilesPerRow])
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 className='small-multiples-container'>
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)