@cdc/chart 4.26.4 → 4.26.6

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 (141) hide show
  1. package/CONFIG.md +353 -29
  2. package/LICENSE +201 -0
  3. package/dist/{cdcchart-DQ00cQCm.es.js → cdcchart-BWVIUcAF.es.js} +5 -55
  4. package/dist/cdcchart.js +37885 -35117
  5. package/examples/feature/heatmap/average-age-categorical.json +313 -0
  6. package/examples/feature/heatmap/calendar-heatmap.json +134 -0
  7. package/examples/feature/heatmap/cell-values-bottom-axis.json +181 -0
  8. package/examples/feature/heatmap/sparse-aggregation.json +158 -0
  9. package/examples/private/bad-y-axis.json +543 -0
  10. package/examples/private/bar-chart-negative-value.json +455 -0
  11. package/examples/private/bump-chart.json +1116 -0
  12. package/examples/private/test-123.json +624 -0
  13. package/package.json +4 -3
  14. package/src/CdcChart.tsx +5 -4
  15. package/src/CdcChartComponent.tsx +166 -62
  16. package/src/_stories/Chart.AltText.stories.tsx +136 -0
  17. package/src/_stories/Chart.HeatMap.stories.tsx +187 -0
  18. package/src/_stories/Chart.Sankey.smoke.stories.tsx +62 -0
  19. package/src/_stories/Chart.SeriesColorAssignments.stories.tsx +183 -0
  20. package/src/_stories/Chart.SmallMultiples.smoke.stories.tsx +29 -0
  21. package/src/_stories/Chart.YAxisAutoMaxStrategy.stories.tsx +52 -0
  22. package/src/_stories/Chart.YAxisFilterDomain.stories.tsx +90 -0
  23. package/src/_stories/ChartAnnotation.smoke.stories.tsx +10 -0
  24. package/src/_stories/ChartAxisTitles.smoke.stories.tsx +13 -0
  25. package/src/_stories/ChartBar.Editor.stories.tsx +63 -0
  26. package/src/_stories/ChartBrush.Interaction.stories.tsx +112 -0
  27. package/src/_stories/ChartBrush.smoke.stories.tsx +13 -4
  28. package/src/_stories/_mock/alt_text_metadata.json +38 -0
  29. package/src/_stories/_mock/annotation_event-line_mock.json +1064 -0
  30. package/src/_stories/_mock/heatmap-average-age-categorical.json +315 -0
  31. package/src/_stories/_mock/heatmap.json +156 -0
  32. package/src/_stories/_mock/reported_pertussis_cases.json +1643 -0
  33. package/src/_stories/_mock/sankey_mock.json +78 -0
  34. package/src/_stories/_mock/small_multiples/small_multiples_lines.json +1 -1
  35. package/src/components/Annotations/components/AnnotationDraggable.styles.css +22 -1
  36. package/src/components/Annotations/components/AnnotationDraggable.tsx +220 -120
  37. package/src/components/Annotations/components/AnnotationDropdown.tsx +5 -2
  38. package/src/components/Annotations/tests/AnnotationDraggable.test.tsx +198 -0
  39. package/src/components/Axis/LeftAxis.tsx +17 -11
  40. package/src/components/Axis/LeftAxisGridlines.tsx +22 -12
  41. package/src/components/Axis/RightAxis.tsx +3 -0
  42. package/src/components/Axis/tests/AxisTickValues.test.tsx +86 -0
  43. package/src/components/Brush/BrushSelector.tsx +88 -187
  44. package/src/components/Brush/brushInteraction.ts +22 -0
  45. package/src/components/Brush/tests/brushInteraction.test.ts +38 -0
  46. package/src/components/EditorPanel/EditorPanel.tsx +677 -185
  47. package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +114 -52
  48. package/src/components/EditorPanel/components/Panels/Panel.General.tsx +105 -13
  49. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +1 -0
  50. package/src/components/EditorPanel/components/Panels/Panel.Regions.tsx +5 -0
  51. package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +2 -2
  52. package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.test.tsx +98 -0
  53. package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.tsx +10 -0
  54. package/src/components/EditorPanel/components/Panels/Panel.Visual.test.tsx +484 -0
  55. package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +4 -1
  56. package/src/components/EditorPanel/components/Panels/SeriesColorAssignments.tsx +262 -0
  57. package/src/components/EditorPanel/components/Panels/panelVisual.styles.css +32 -0
  58. package/src/components/EditorPanel/components/panels.scss +10 -0
  59. package/src/components/EditorPanel/editor-panel.scss +47 -0
  60. package/src/components/EditorPanel/helpers/getVisualizationTypeConfigUpdate.ts +61 -0
  61. package/src/components/EditorPanel/helpers/tests/getVisualizationTypeConfigUpdate.test.ts +74 -0
  62. package/src/components/EditorPanel/useEditorPermissions.test.tsx +315 -0
  63. package/src/components/EditorPanel/useEditorPermissions.ts +77 -21
  64. package/src/components/HeatMap/components/HeatMap.tsx +767 -0
  65. package/src/components/HeatMap/components/HeatMapGradientLegend.tsx +128 -0
  66. package/src/components/HeatMap/heatmap.constants.ts +13 -0
  67. package/src/components/HeatMap/heatmap.css +85 -0
  68. package/src/components/HeatMap/helpers/index.ts +261 -0
  69. package/src/components/HeatMap/index.tsx +5 -0
  70. package/src/components/HeatMap/tests/HeatMap.test.tsx +1072 -0
  71. package/src/components/HeatMap/tests/helpers.test.ts +167 -0
  72. package/src/components/Legend/Legend.Component.tsx +2 -2
  73. package/src/components/Legend/helpers/createFormatLabels.test.ts +88 -0
  74. package/src/components/Legend/helpers/createFormatLabels.tsx +6 -1
  75. package/src/components/LineChart/components/LineChart.BumpCircle.tsx +19 -22
  76. package/src/components/LineChart/helpers.test.ts +574 -0
  77. package/src/components/LineChart/helpers.ts +39 -16
  78. package/src/components/LineChart/index.tsx +28 -5
  79. package/src/components/LinearChart/tests/LinearChart.test.tsx +137 -33
  80. package/src/components/LinearChart/tests/mockConfigContext.ts +1 -0
  81. package/src/components/LinearChart.tsx +103 -47
  82. package/src/components/PieChart/PieChart.tsx +4 -1
  83. package/src/components/RadarChart/RadarChart.tsx +3 -1
  84. package/src/components/Regions/components/Regions.tsx +195 -41
  85. package/src/components/Sankey/components/Sankey.tsx +7 -3
  86. package/src/components/Sankey/helpers/getSankeyTooltip.tsx +7 -6
  87. package/src/components/ScatterPlot/ScatterPlot.jsx +3 -3
  88. package/src/components/SmallMultiples/SmallMultipleTile.tsx +33 -3
  89. package/src/components/SmallMultiples/SmallMultiples.css +12 -0
  90. package/src/components/SmallMultiples/SmallMultiples.tsx +50 -15
  91. package/src/components/SmallMultiples/tests/SmallMultipleTile.test.tsx +152 -0
  92. package/src/components/SmallMultiples/tests/SmallMultiples.test.tsx +170 -0
  93. package/src/components/Sparkline/components/SparkLine.tsx +4 -9
  94. package/src/components/WarmingStripes/WarmingStripesGradientLegend.css +1 -1
  95. package/src/components/WarmingStripes/WarmingStripesGradientLegend.tsx +2 -2
  96. package/src/data/initial-state.js +12 -0
  97. package/src/helpers/axisLabelFontSize.ts +8 -0
  98. package/src/helpers/bumpChartTooltip.ts +54 -0
  99. package/src/helpers/calculateLeftYAxisWidth.ts +13 -3
  100. package/src/helpers/colorAssignmentHelpers.ts +102 -0
  101. package/src/helpers/dataHelpers.ts +11 -1
  102. package/src/helpers/ensureSpecialChartAxisTypes.ts +11 -0
  103. package/src/helpers/getAxisMaxOverride.ts +16 -0
  104. package/src/helpers/getCleanTopTickMax.ts +64 -0
  105. package/src/helpers/getColorScale.ts +18 -9
  106. package/src/helpers/getHasBoundarySuppression.ts +26 -0
  107. package/src/helpers/getMinMax.ts +26 -25
  108. package/src/helpers/getYAxisDomainData.ts +38 -0
  109. package/src/helpers/getYAxisFinalizationEligibility.ts +40 -0
  110. package/src/helpers/handleChartAriaLabels.ts +23 -10
  111. package/src/helpers/hasSpacedInlineLabel.ts +10 -0
  112. package/src/helpers/seriesColumnSettings.ts +5 -4
  113. package/src/helpers/seriesHighlight.ts +26 -0
  114. package/src/helpers/smallMultiplesHelpers.ts +5 -0
  115. package/src/helpers/tests/axisLabelFontSize.test.ts +15 -0
  116. package/src/helpers/tests/bumpChartTooltip.test.ts +75 -0
  117. package/src/helpers/tests/calculateLeftYAxisWidth.test.ts +38 -0
  118. package/src/helpers/tests/dataHelpers.test.ts +135 -0
  119. package/src/helpers/tests/ensureSpecialChartAxisTypes.test.ts +30 -0
  120. package/src/helpers/tests/getAxisMaxOverride.test.ts +36 -0
  121. package/src/helpers/tests/getCleanTopTickMax.test.ts +165 -0
  122. package/src/helpers/tests/getColorScale.test.ts +197 -0
  123. package/src/helpers/tests/getMinMax.test.ts +211 -0
  124. package/src/helpers/tests/getYAxisDomainData.test.ts +119 -0
  125. package/src/helpers/tests/getYAxisFinalizationEligibility.test.ts +185 -0
  126. package/src/helpers/tests/handleChartAriaLabels.test.ts +71 -0
  127. package/src/helpers/tests/hasSpacedInlineLabel.test.ts +75 -0
  128. package/src/helpers/tests/seriesColumnSettings.test.ts +16 -2
  129. package/src/helpers/tests/seriesHighlight.test.ts +61 -0
  130. package/src/helpers/tests/smallMultiplesHelpers.test.ts +99 -0
  131. package/src/hooks/tests/useRightAxis.test.tsx +127 -0
  132. package/src/hooks/tests/useScales.test.tsx +383 -0
  133. package/src/hooks/useRightAxis.ts +30 -13
  134. package/src/hooks/useScales.ts +48 -21
  135. package/src/hooks/useTooltip.tsx +3 -1
  136. package/src/index.jsx +5 -2
  137. package/src/scss/main.scss +32 -49
  138. package/src/test/CdcChart.dataTable.test.tsx +122 -0
  139. package/src/types/ChartConfig.ts +24 -1
  140. package/src/types/ChartContext.ts +5 -0
  141. package/src/helpers/getYAxisAutoPadding.ts +0 -53
@@ -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} />)
@@ -6,7 +6,7 @@ import { publishAnalyticsEvent } from '@cdc/core/helpers/metrics/helpers'
6
6
  import { getVizTitle, getVizSubType } from '@cdc/core/helpers/metrics/utils'
7
7
  import { buildSeriesTooltipListHtml } from '../../helpers/tooltipHelpers'
8
8
 
9
- const ScatterPlot = ({ xScale, yScale, yAxisWidth }) => {
9
+ const ScatterPlot = ({ xScale, yScale, yAxisWidth, getXAxisData }) => {
10
10
  const {
11
11
  transformedData: data,
12
12
  config,
@@ -61,7 +61,7 @@ const ScatterPlot = ({ xScale, yScale, yAxisWidth }) => {
61
61
  return config.runtime.seriesKeys.map((s, index) => {
62
62
  const transparentArea = config.legend.behavior === 'highlight' && seriesHighlight.length > 0 && seriesHighlight.indexOf(s) === -1
63
63
  const displayArea = config.legend.behavior === 'highlight' || seriesHighlight.length === 0 || seriesHighlight.indexOf(s) !== -1
64
- const seriesColor = config?.general?.palette?.customColors ? config.general.palette.customColors[index] : colorScale(config.runtime.seriesLabels?.[s] || s)
64
+ const seriesColor = colorScale(config.runtime.seriesLabels?.[s] || s)
65
65
 
66
66
  let pointStyles = {
67
67
  filter: 'unset',
@@ -78,7 +78,7 @@ const ScatterPlot = ({ xScale, yScale, yAxisWidth }) => {
78
78
  <circle
79
79
  key={`${dataIndex}-${index}`}
80
80
  r={circleRadii}
81
- cx={xScale(item[config.xAxis.dataKey])}
81
+ cx={xScale(getXAxisData(item))}
82
82
  cy={yScale(item[s])}
83
83
  fill={displayArea ? seriesColor : 'transparent'}
84
84
  fillOpacity={transparentArea ? 0.25 : 1}
@@ -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'
@@ -22,6 +23,7 @@ interface SmallMultipleTileProps {
22
23
  parentHeight?: number
23
24
  globalYAxisMax?: number
24
25
  globalYAxisMin?: number
26
+ globalYAxisTickValues?: number[]
25
27
  isFirstInRow?: boolean
26
28
  onHeightChange?: (tileKey: string, height: number) => void
27
29
  onChartRef?: (ref: any) => void
@@ -42,6 +44,7 @@ const SmallMultipleTile: React.FC<SmallMultipleTileProps> = ({
42
44
  parentWidth,
43
45
  globalYAxisMax,
44
46
  globalYAxisMin,
47
+ globalYAxisTickValues,
45
48
  isFirstInRow,
46
49
  onHeightChange,
47
50
  onChartRef,
@@ -107,11 +110,13 @@ const SmallMultipleTile: React.FC<SmallMultipleTileProps> = ({
107
110
  }
108
111
  }
109
112
 
113
+ const titlePlacementIsTop = tileConfig.yAxis?.titlePlacement === 'top'
114
+
110
115
  // Small multiples-specific modifications
111
116
  tileConfig = {
112
117
  ...tileConfig,
113
118
  hideXAxisLabel: !isFirstInRow,
114
- hideYAxisLabel: !isFirstInRow,
119
+ hideYAxisLabel: config.hideYAxisLabel || (!isFirstInRow && !titlePlacementIsTop),
115
120
  legend: {
116
121
  ...tileConfig.legend,
117
122
  tooltipLegendVisible: !config.legend?.hide,
@@ -125,6 +130,8 @@ const SmallMultipleTile: React.FC<SmallMultipleTileProps> = ({
125
130
  }
126
131
 
127
132
  const displayTitle = getTileDisplayTitle(mode, seriesKey, tileValue, tileKey, config)
133
+ const showTopYAxisTitle =
134
+ titlePlacementIsTop && isFirstInRow && !config.hideYAxisLabel && Boolean(tileConfig.runtime?.yAxis?.label)
128
135
 
129
136
  // Get the original context values to merge with our filtered config
130
137
  const originalContextValues = useContext(ConfigContext)
@@ -134,6 +141,7 @@ const SmallMultipleTile: React.FC<SmallMultipleTileProps> = ({
134
141
 
135
142
  // Create a ref for the entire tile (including header) for height measurement
136
143
  const fullTileRef = useRef<HTMLDivElement>(null)
144
+ const [tileChartWidth, setTileChartWidth] = useState(0)
137
145
 
138
146
  // Create a ref for the LinearChart instance for tooltip coordination
139
147
  const linearChartRef = useRef<any>(null)
@@ -146,6 +154,7 @@ const SmallMultipleTile: React.FC<SmallMultipleTileProps> = ({
146
154
  tableData: tileData, // Override with tile-specific filtered data (important for tooltip data lookup)
147
155
  parentRef: tileParentRef, // Override with tile-specific parentRef
148
156
  updateConfig: () => {}, // Prevent tile hooks from modifying global config
157
+ yAxisTickValues: globalYAxisTickValues,
149
158
  ...(customColorScale && { colorScale: customColorScale }) // Override colorScale if provided
150
159
  }
151
160
 
@@ -164,6 +173,22 @@ const SmallMultipleTile: React.FC<SmallMultipleTileProps> = ({
164
173
  return () => resizeObserver.disconnect()
165
174
  }, [tileKey, onHeightChange])
166
175
 
176
+ useEffect(() => {
177
+ if (!tileParentRef.current) return
178
+
179
+ const resizeObserver = new ResizeObserver(entries => {
180
+ entries.forEach(entry => {
181
+ setTileChartWidth(entry.contentRect.width)
182
+ })
183
+ })
184
+
185
+ resizeObserver.observe(tileParentRef.current)
186
+ return () => resizeObserver.disconnect()
187
+ }, [])
188
+
189
+ const tileVizViewport = tileChartWidth > 0 ? getViewport(tileChartWidth) : originalContextValues.vizViewport || 'lg'
190
+ const topYAxisTitleFontSize = getAxisLabelFontSize(tileVizViewport)
191
+
167
192
  // Pass chart ref to parent SmallMultiples component for tooltip coordination
168
193
  useEffect(() => {
169
194
  if (onChartRef && linearChartRef.current) {
@@ -173,8 +198,13 @@ const SmallMultipleTile: React.FC<SmallMultipleTileProps> = ({
173
198
 
174
199
  return (
175
200
  <div ref={fullTileRef} className='small-multiple-tile'>
176
- <div ref={onHeaderRef} className='tile-header'>
201
+ <div ref={onHeaderRef} className={`tile-header${showTopYAxisTitle ? ' tile-header--with-top-y-axis-title' : ''}`}>
177
202
  <div className='tile-title'>{displayTitle}</div>
203
+ {showTopYAxisTitle && (
204
+ <div className='y-axis-top-title' style={{ fontSize: `${topYAxisTitleFontSize}px` }}>
205
+ {tileConfig.runtime.yAxis.label}
206
+ </div>
207
+ )}
178
208
  </div>
179
209
  <div ref={tileParentRef} className='tile-chart'>
180
210
  <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;