@cdc/chart 4.25.8 → 4.25.11

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 (145) hide show
  1. package/.claude/settings.local.json +9 -0
  2. package/dist/{cdcchart-1a1724a1.es.js → cdcchart-dgT_1dIT.es.js} +136 -151
  3. package/dist/cdcchart.js +44236 -40355
  4. package/examples/feature/__data__/planet-example-data.json +0 -30
  5. package/examples/feature/boxplot/valid-boxplot.csv +38 -17
  6. package/examples/grouped-bar-test.json +400 -0
  7. package/examples/private/DEV-11825.json +573 -0
  8. package/examples/private/d.json +382 -0
  9. package/examples/private/example-2.json +49784 -0
  10. package/examples/private/f2.json +1 -0
  11. package/examples/private/f4.json +1577 -0
  12. package/examples/private/forecast.json +1180 -0
  13. package/examples/private/lollipop.json +468 -0
  14. package/examples/private/na.json +913 -0
  15. package/examples/private/new.json +48756 -0
  16. package/examples/private/pie-chart-legend.json +904 -0
  17. package/examples/private/test-data.csv +28 -0
  18. package/examples/suppressed_tooltip.json +480 -0
  19. package/index.html +2 -133
  20. package/package.json +25 -7
  21. package/src/CdcChart.tsx +9 -13
  22. package/src/CdcChartComponent.tsx +403 -92
  23. package/src/_stories/Chart.Anchors.stories.tsx +2 -2
  24. package/src/_stories/Chart.BoxPlot.stories.tsx +1 -1
  25. package/src/_stories/Chart.CI.stories.tsx +1 -1
  26. package/src/_stories/Chart.Combo.stories.tsx +18 -0
  27. package/src/_stories/Chart.CustomColors.stories.tsx +1 -1
  28. package/src/_stories/Chart.DynamicSeries.stories.tsx +2 -2
  29. package/src/_stories/Chart.Filters.stories.tsx +2 -2
  30. package/src/_stories/Chart.Forecast.stories.tsx +36 -0
  31. package/src/_stories/Chart.HTMLInDataTable.stories.tsx +520 -0
  32. package/src/_stories/Chart.Legend.Gradient.stories.tsx +2 -2
  33. package/src/_stories/Chart.Patterns.stories.tsx +20 -0
  34. package/src/_stories/Chart.PreserveDecimals.stories.tsx +220 -0
  35. package/src/_stories/Chart.ScatterPlot.stories.tsx +1 -1
  36. package/src/_stories/Chart.SmallMultiples.stories.tsx +47 -0
  37. package/src/_stories/Chart.stories.tsx +8 -5
  38. package/src/_stories/Chart.tooltip.stories.tsx +1 -1
  39. package/src/_stories/ChartAnnotation.stories.tsx +7 -4
  40. package/src/_stories/ChartAxisLabels.stories.tsx +2 -2
  41. package/src/_stories/ChartAxisTitles.stories.tsx +2 -2
  42. package/src/_stories/ChartBar.Editor.stories.tsx +3580 -0
  43. package/src/_stories/ChartEditor.Editor.stories.tsx +658 -0
  44. package/src/_stories/ChartEditor.stories.tsx +59 -60
  45. package/src/_stories/ChartLine.Suppression.stories.tsx +1 -1
  46. package/src/_stories/ChartLine.Symbols.stories.tsx +1 -1
  47. package/src/_stories/ChartPrefixSuffix.stories.tsx +2 -2
  48. package/src/_stories/_mock/combo.json +451 -0
  49. package/src/_stories/_mock/editor-test-configs.json +376 -0
  50. package/src/_stories/_mock/editor-test-datasets.json +477 -0
  51. package/src/_stories/_mock/editor-tests/bar-chart-editor-test.json +255 -0
  52. package/src/_stories/_mock/editor-tests/bar-chart-general-test.json +267 -0
  53. package/src/_stories/_mock/editor-tests/bar-chart-test.json +237 -0
  54. package/src/_stories/_mock/forecast_combo_with_gaps.json +913 -0
  55. package/src/_stories/_mock/pie_config.json +257 -62
  56. package/src/_stories/_mock/small_multiples/small_multiples_bars.json +1944 -0
  57. package/src/_stories/_mock/small_multiples/small_multiples_big_data_bars.json +1114 -0
  58. package/src/_stories/_mock/small_multiples/small_multiples_lines.json +2646 -0
  59. package/src/_stories/_mock/small_multiples/small_multiples_lines_colors.json +1305 -0
  60. package/src/_stories/_mock/small_multiples/small_multiples_stacked_bars.json +1936 -0
  61. package/src/_stories/_mock/stacked-pattern-test.json +520 -0
  62. package/src/components/Annotations/components/AnnotationDraggable.tsx +1 -0
  63. package/src/components/Annotations/components/AnnotationDropdown.tsx +1 -1
  64. package/src/components/Annotations/components/findNearestDatum.ts +6 -41
  65. package/src/components/AreaChart/components/AreaChart.Stacked.jsx +10 -6
  66. package/src/components/AreaChart/index.tsx +1 -2
  67. package/src/components/BarChart/components/BarChart.Horizontal.tsx +161 -22
  68. package/src/components/BarChart/components/BarChart.StackedHorizontal.tsx +138 -5
  69. package/src/components/BarChart/components/BarChart.StackedVertical.tsx +215 -73
  70. package/src/components/BarChart/components/BarChart.Vertical.tsx +155 -22
  71. package/src/components/BarChart/helpers/index.ts +43 -4
  72. package/src/components/BarChart/helpers/lollipopColors.ts +27 -0
  73. package/src/components/BarChart/helpers/useBarChart.ts +25 -3
  74. package/src/components/BoxPlot/BoxPlot.Vertical.tsx +2 -1
  75. package/src/components/BoxPlot/helpers/index.ts +3 -3
  76. package/src/components/Brush/BrushChart.tsx +1 -1
  77. package/src/components/DeviationBar.jsx +9 -6
  78. package/src/components/EditorPanel/EditorPanel.tsx +563 -229
  79. package/src/components/EditorPanel/EditorPanelContext.ts +3 -0
  80. package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +96 -111
  81. package/src/components/EditorPanel/components/Panels/Panel.General.tsx +19 -1
  82. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +461 -0
  83. package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +80 -67
  84. package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.tsx +422 -0
  85. package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +188 -139
  86. package/src/components/EditorPanel/components/Panels/index.tsx +5 -1
  87. package/src/components/EditorPanel/components/Panels/panelVisual.styles.css +0 -8
  88. package/src/components/EditorPanel/editor-panel.scss +0 -20
  89. package/src/components/EditorPanel/helpers/updateFieldRankByValue.ts +49 -48
  90. package/src/components/EditorPanel/useEditorPermissions.ts +7 -15
  91. package/src/components/Forecasting/Forecasting.tsx +175 -27
  92. package/src/components/ForestPlot/ForestPlot.tsx +11 -7
  93. package/src/components/ForestPlot/ForestPlotProps.ts +1 -1
  94. package/src/components/Legend/Legend.Component.tsx +114 -14
  95. package/src/components/Legend/helpers/createFormatLabels.tsx +230 -171
  96. package/src/components/Legend/helpers/getLegendClasses.ts +0 -1
  97. package/src/components/LegendWrapper.tsx +1 -1
  98. package/src/components/LineChart/LineChartProps.ts +0 -3
  99. package/src/components/LineChart/components/LineChart.Circle.tsx +2 -2
  100. package/src/components/LineChart/helpers.ts +1 -1
  101. package/src/components/LineChart/index.tsx +38 -15
  102. package/src/components/LinearChart.tsx +96 -84
  103. package/src/components/PairedBarChart.jsx +6 -4
  104. package/src/components/PieChart/PieChart.tsx +170 -54
  105. package/src/components/Regions/components/Regions.tsx +3 -24
  106. package/src/components/Sankey/components/Sankey.tsx +7 -1
  107. package/src/components/Sankey/types/index.ts +1 -1
  108. package/src/components/ScatterPlot/ScatterPlot.jsx +32 -4
  109. package/src/components/SmallMultiples/SmallMultipleTile.tsx +198 -0
  110. package/src/components/SmallMultiples/SmallMultiples.css +32 -0
  111. package/src/components/SmallMultiples/SmallMultiples.tsx +271 -0
  112. package/src/components/SmallMultiples/index.ts +2 -0
  113. package/src/data/initial-state.js +327 -293
  114. package/src/helpers/buildForecastPaletteMappings.ts +112 -0
  115. package/src/helpers/buildForecastPaletteOptions.ts +71 -0
  116. package/src/helpers/getColorScale.ts +82 -8
  117. package/src/{hooks/useMinMax.ts → helpers/getMinMax.ts} +14 -7
  118. package/src/helpers/getNewRuntime.ts +1 -1
  119. package/src/helpers/getTransformedData.ts +1 -1
  120. package/src/helpers/getYAxisAutoPadding.ts +53 -0
  121. package/src/helpers/smallMultiplesHelpers.ts +529 -0
  122. package/src/hooks/useChartHoverAnalytics.tsx +44 -0
  123. package/src/hooks/useProgrammaticTooltip.ts +96 -0
  124. package/src/hooks/useReduceData.ts +105 -70
  125. package/src/hooks/useScales.ts +88 -34
  126. package/src/hooks/useSmallMultipleSynchronization.ts +59 -0
  127. package/src/hooks/useTooltip.tsx +116 -29
  128. package/src/index.jsx +0 -2
  129. package/src/scss/main.scss +13 -80
  130. package/src/store/chart.actions.ts +2 -0
  131. package/src/store/chart.reducer.ts +5 -1
  132. package/src/test/CdcChart.test.jsx +8 -3
  133. package/src/types/ChartConfig.ts +53 -11
  134. package/src/types/ChartContext.ts +4 -0
  135. package/vite.config.js +1 -1
  136. package/vitest.config.ts +16 -0
  137. package/src/_stories/_mock/pie_data.json +0 -218
  138. package/src/components/AreaChart/components/AreaChart.jsx +0 -109
  139. package/src/coreStyles_chart.scss +0 -3
  140. package/src/helpers/configHelpers.ts +0 -28
  141. package/src/helpers/generateColorsArray.ts +0 -8
  142. package/src/helpers/sort.ts +0 -7
  143. package/src/hooks/useActiveElement.js +0 -19
  144. package/src/hooks/useChartClasses.js +0 -41
  145. package/src/hooks/useColorPalette.js +0 -76
@@ -1,21 +1,145 @@
1
- import React, { useContext } from 'react'
1
+ import React, { useContext, useMemo } from 'react'
2
2
  import { replace } from 'lodash'
3
3
  // cdc
4
4
  import ConfigContext from '../../ConfigContext'
5
5
  import ErrorBoundary from '@cdc/core/components/ErrorBoundary'
6
- import { colorPalettesChart, sequentialPalettes } from '@cdc/core/data/colorPalettes'
6
+ import { colorPalettesChartV2, sequentialPalettes } from '@cdc/core/data/colorPalettes'
7
+ import { updatePaletteNames } from '@cdc/core/helpers/updatePaletteNames'
8
+ import { getColorPaletteVersion } from '@cdc/core/helpers/getColorPaletteVersion'
7
9
  import { getBridgedData } from '../../helpers/getBridgedData'
10
+ import { buildForecastPaletteMappings } from '../../helpers/buildForecastPaletteMappings'
8
11
 
9
12
  // visx & d3
10
13
  import { curveMonotoneX } from '@visx/curve'
11
14
  import { Bar, Area, LinePath } from '@visx/shape'
12
15
  import { Group } from '@visx/group'
13
16
 
17
+ // Helper function to check if a value is numeric/calculable
18
+ const isCalculable = (value: any): boolean => {
19
+ if (value === null || value === undefined || value === '' || value === 'NA') return false
20
+ const num = typeof value === 'string' ? parseFloat(value.replace(/,/g, '')) : Number(value)
21
+ return !isNaN(num) && isFinite(num)
22
+ }
23
+
24
+ // Helper function to filter and sort forecast data, splitting into segments at gaps
25
+ const prepareForecastData = (
26
+ data: Record<string, any>[],
27
+ xAxisKey: string,
28
+ highKey: string,
29
+ lowKey: string
30
+ ): Record<string, any>[][] => {
31
+ if (!data || data.length === 0) return []
32
+
33
+ // Filter out invalid data points (where confidence intervals are not calculable)
34
+ const validData = data.filter(d => {
35
+ const high = d[highKey]
36
+ const low = d[lowKey]
37
+ return isCalculable(high) && isCalculable(low) && d[xAxisKey]
38
+ })
39
+
40
+ if (validData.length === 0) return []
41
+
42
+ // Sort by date
43
+ const sortedData = [...validData].sort((a, b) => {
44
+ const dateA = Date.parse(a[xAxisKey])
45
+ const dateB = Date.parse(b[xAxisKey])
46
+ if (isNaN(dateA) || isNaN(dateB)) return 0
47
+ return dateA - dateB
48
+ })
49
+
50
+ // Split into segments when there are gaps
51
+ // Calculate intervals between consecutive points to detect gaps
52
+ const intervals: number[] = []
53
+ for (let i = 1; i < sortedData.length; i++) {
54
+ const currentDate = Date.parse(sortedData[i][xAxisKey])
55
+ const prevDate = Date.parse(sortedData[i - 1][xAxisKey])
56
+ if (!isNaN(currentDate) && !isNaN(prevDate)) {
57
+ intervals.push(currentDate - prevDate)
58
+ }
59
+ }
60
+
61
+ // Calculate median interval (more robust than average for detecting gaps)
62
+ const medianInterval =
63
+ intervals.length > 0
64
+ ? [...intervals].sort((a, b) => a - b)[Math.floor(intervals.length / 2)]
65
+ : 7 * 24 * 60 * 60 * 1000 // Default to 7 days if no intervals
66
+
67
+ // Threshold: gap is more than 2x the median interval, or more than 30 days
68
+ const gapThreshold = Math.max(medianInterval * 2, 30 * 24 * 60 * 60 * 1000)
69
+
70
+ const segments: Record<string, any>[][] = []
71
+ let currentSegment: Record<string, any>[] = []
72
+
73
+ for (let i = 0; i < sortedData.length; i++) {
74
+ const current = sortedData[i]
75
+ const prev = sortedData[i - 1]
76
+
77
+ if (i === 0) {
78
+ // First data point starts a new segment
79
+ currentSegment = [current]
80
+ } else {
81
+ const currentDate = Date.parse(current[xAxisKey])
82
+ const prevDate = Date.parse(prev[xAxisKey])
83
+
84
+ if (isNaN(currentDate) || isNaN(prevDate)) {
85
+ // If dates are invalid, continue current segment
86
+ currentSegment.push(current)
87
+ } else {
88
+ const interval = currentDate - prevDate
89
+ const hasGap = interval > gapThreshold
90
+
91
+ if (hasGap) {
92
+ // Save current segment and start a new one
93
+ if (currentSegment.length > 0) {
94
+ segments.push(currentSegment)
95
+ }
96
+ currentSegment = [current]
97
+ } else {
98
+ // Continue current segment
99
+ currentSegment.push(current)
100
+ }
101
+ }
102
+ }
103
+ }
104
+
105
+ // Add the last segment
106
+ if (currentSegment.length > 0) {
107
+ segments.push(currentSegment)
108
+ }
109
+
110
+ return segments.length > 0 ? segments : [sortedData]
111
+ }
112
+
14
113
  const Forecasting = ({ xScale, yScale, height, width, handleTooltipMouseOver, handleTooltipMouseOff }) => {
15
114
  const { transformedData: data, rawData, config, seriesHighlight, parseDate } = useContext(ConfigContext)
16
115
  const { xAxis, yAxis, legend, runtime } = config
17
116
  const DEBUG = false
18
117
 
118
+ // Memoize processed palettes - use version-specific palettes
119
+ const forecastingPalettes = useMemo(() => {
120
+ // Determine palette version from config
121
+ // Forecasting charts use sequentialPalettes for v1, sequential-only palettes for v2
122
+ const paletteVersion = getColorPaletteVersion(config)
123
+
124
+ let forecastPalettes
125
+ if (paletteVersion === 1) {
126
+ // V1: Use original sequential palettes
127
+ forecastPalettes = sequentialPalettes
128
+ } else {
129
+ // V2: Only use sequential palettes (filter out divergent and qualitative)
130
+ const allV2Palettes = colorPalettesChartV2
131
+ forecastPalettes = {}
132
+ Object.keys(allV2Palettes).forEach(key => {
133
+ if (key.startsWith('sequential')) {
134
+ forecastPalettes[key] = allV2Palettes[key]
135
+ }
136
+ })
137
+ }
138
+
139
+ const processedPalettes = updatePaletteNames(forecastPalettes)
140
+ return buildForecastPaletteMappings(processedPalettes, paletteVersion)
141
+ }, [config])
142
+
19
143
  return (
20
144
  data && (
21
145
  <ErrorBoundary component='ForecastingChart'>
@@ -33,50 +157,74 @@ const Forecasting = ({ xScale, yScale, height, width, handleTooltipMouseOver, ha
33
157
  return (
34
158
  <Group
35
159
  className={`forecasting-areas-combo-${index}`}
36
- key={`forecasting-areas--stage-${replace(stage.key, / /g, '—')}-${index}`}
160
+ key={`forecasting-areas--stage-${replace(stage.key, / /g, '—')}-${index}`}
37
161
  >
38
162
  {group.confidenceIntervals?.map((ciGroup, ciGroupIndex) => {
39
- const palette = sequentialPalettes[stage.color] || colorPalettesChart[stage.color] || false
163
+ const palette = forecastingPalettes[stage.color] || false
164
+ const isReversed = stage.color?.toLowerCase().includes('reverse')
40
165
 
41
166
  const getFill = () => {
42
- if (displayArea) return palette[2] ? palette[2] : 'transparent'
167
+ if (displayArea) return palette?.[2] || 'transparent'
43
168
  return 'transparent'
44
169
  }
45
170
 
46
171
  const getStroke = () => {
47
- if (displayArea) return palette[1] ? palette[1] : 'transparent'
48
- return 'transparent'
172
+ if (!displayArea) return 'transparent'
173
+ // Use darker colors: index 1 for reversed (dark at start), index 4 for non-reversed (dark at end)
174
+ return isReversed ? palette?.[1] || 'transparent' : palette?.[4] || 'transparent'
49
175
  }
50
176
 
51
- if (ciGroup.high === '' || ciGroup.low === '') return
177
+ if (ciGroup.high === '' || ciGroup.low === '') return null
178
+
179
+ // Prepare data: filter invalid values, sort by date, and split into segments at gaps
180
+ const dataSegments = prepareForecastData(bridgedData, xAxis.dataKey, ciGroup.high, ciGroup.low)
181
+
52
182
  return (
53
183
  <Group
54
184
  key={`forecasting-areas--stage-${replace(
55
185
  stage.key,
56
- / /g,
186
+ / /g,
57
187
  '—'
58
188
  )}--group-${stageIndex}-${ciGroupIndex}`}
59
189
  >
60
- {/* prettier-ignore */}
61
- <Area
62
- curve={curveMonotoneX}
63
- data={bridgedData}
64
- fill={getFill()}
65
- opacity={transparentArea? 0.1 : 0.5 }
66
- x={d => xScale(Date.parse(d[xAxis.dataKey]))}
67
- y0={d => yScale(d[ciGroup.low])}
68
- y1={d => yScale(d[ciGroup.high])}
69
- />
70
-
71
- {ciGroupIndex === 0 && (
72
- <>
190
+ {dataSegments.map((segment, segmentIndex) => (
191
+ <Group key={`segment-${segmentIndex}`}>
73
192
  {/* prettier-ignore */}
74
- <LinePath data={bridgedData} x={d => Number(xScale(Date.parse(d[xAxis.dataKey])))} y={d => Number(yScale(d[ciGroup.high]))} curve={curveMonotoneX} stroke={getStroke()} strokeWidth={1} strokeOpacity={1} />
193
+ <Area
194
+ curve={curveMonotoneX}
195
+ data={segment}
196
+ fill={getFill()}
197
+ opacity={transparentArea ? 0.1 : 0.5}
198
+ x={d => xScale(Date.parse(d[xAxis.dataKey]))}
199
+ y0={d => yScale(d[ciGroup.low])}
200
+ y1={d => yScale(d[ciGroup.high])}
201
+ />
75
202
 
76
- {/* prettier-ignore */}
77
- <LinePath data={bridgedData} x={d => Number(xScale(Date.parse(d[xAxis.dataKey])))} y={d => Number(yScale(d[ciGroup.low]))} curve={curveMonotoneX} stroke={getStroke()} strokeWidth={1} strokeOpacity={1} />
78
- </>
79
- )}
203
+ {ciGroupIndex === 0 && (
204
+ <>
205
+ <LinePath
206
+ data={segment}
207
+ x={d => Number(xScale(Date.parse(d[xAxis.dataKey])))}
208
+ y={d => Number(yScale(d[ciGroup.high]))}
209
+ curve={curveMonotoneX}
210
+ stroke={getStroke()}
211
+ strokeWidth={1}
212
+ strokeOpacity={1}
213
+ />
214
+
215
+ <LinePath
216
+ data={segment}
217
+ x={d => Number(xScale(Date.parse(d[xAxis.dataKey])))}
218
+ y={d => Number(yScale(d[ciGroup.low]))}
219
+ curve={curveMonotoneX}
220
+ stroke={getStroke()}
221
+ strokeWidth={1}
222
+ strokeOpacity={1}
223
+ />
224
+ </>
225
+ )}
226
+ </Group>
227
+ ))}
80
228
  </Group>
81
229
  )
82
230
  })}
@@ -8,9 +8,9 @@ import { scaleLinear } from '@visx/scale'
8
8
  import { curveLinearClosed } from '@visx/curve'
9
9
 
10
10
  // types
11
- import { type ForestPlotProps } from '@cdc/chart/src/components/ForestPlot/ForestPlotProps'
12
- import { type ChartConfig } from '@cdc/chart/src/types/ChartConfig'
13
- import { type ChartContext } from '@cdc/chart/src/types/ChartContext'
11
+ import { type ForestPlotProps } from './ForestPlotProps'
12
+ import { type ChartConfig } from '../../types/ChartConfig'
13
+ import { type ChartContext } from '../../types/ChartContext'
14
14
 
15
15
  // cdc
16
16
  import ConfigContext from '../../ConfigContext'
@@ -73,7 +73,7 @@ const ForestPlot = ({
73
73
 
74
74
  updateConfig(newConfig)
75
75
  } catch (e) {
76
- console.log(e.message)
76
+ console.error(e.message)
77
77
  }
78
78
  }, [])
79
79
 
@@ -184,7 +184,7 @@ const ForestPlot = ({
184
184
  const isTotalColumn = d[config.xAxis.dataKey] === forestPlot.pooledResult.column
185
185
 
186
186
  return !isTotalColumn ? (
187
- <Group>
187
+ <Group key={`forest-plot-row-${i}-${d[config.xAxis.dataKey]}`}>
188
188
  {/* Confidence Interval Paths */}
189
189
  <path
190
190
  stroke={lineColor}
@@ -252,6 +252,7 @@ const ForestPlot = ({
252
252
  </Group>
253
253
  ) : (
254
254
  <LinePath
255
+ key={`forest-plot-regression-${i}-${d[config.xAxis.dataKey]}`}
255
256
  data={regressionPoints}
256
257
  x={d => d.x}
257
258
  y={d => d.y - APP_FONT_SIZE / 2}
@@ -314,10 +315,11 @@ const ForestPlot = ({
314
315
  />
315
316
 
316
317
  {/* column data */}
317
- {columnsOnChart.map(column => {
318
+ {columnsOnChart.map((column, colIndex) => {
318
319
  return data.map((d, i) => {
319
320
  return (
320
321
  <Text
322
+ key={`forest-plot-column-${colIndex}-${i}-${d[config.xAxis.dataKey]}`}
321
323
  className={`${d[column.name]}`}
322
324
  x={column.forestPlotAlignRight ? width : column.forestPlotStartingPoint}
323
325
  y={yScale(i)}
@@ -336,6 +338,7 @@ const ForestPlot = ({
336
338
  data.map((d, i) => {
337
339
  return (
338
340
  <Text
341
+ key={`forest-plot-xaxis-${i}-${d[config.xAxis.dataKey]}`}
339
342
  className={`${d[config.xAxis.dataKey]}`}
340
343
  x={0}
341
344
  y={yScale(i)}
@@ -356,9 +359,10 @@ const ForestPlot = ({
356
359
  )}
357
360
 
358
361
  {/* column headers */}
359
- {columnsOnChart.map(column => {
362
+ {columnsOnChart.map((column, colIndex) => {
360
363
  return (
361
364
  <Text
365
+ key={`forest-plot-header-${colIndex}-${column.label}`}
362
366
  className={`${column.label}`}
363
367
  x={column.forestPlotAlignRight ? width : column.forestPlotStartingPoint}
364
368
  y={0}
@@ -1,4 +1,4 @@
1
- import { type ChartConfig } from '@cdc/chart/src/types/ChartConfig'
1
+ import { type ChartConfig } from '../../types/ChartConfig'
2
2
 
3
3
  export type ForestPlotProps = {
4
4
  /** xScale - scaling function for bottom axis */
@@ -1,6 +1,7 @@
1
1
  import parse from 'html-react-parser'
2
2
  import React from 'react'
3
3
  import { LegendOrdinal, LegendItem, LegendLabel } from '@visx/legend'
4
+ import { PatternLines, PatternCircles, PatternWaves } from '@visx/pattern'
4
5
  import LegendShape from '@cdc/core/components/LegendShape'
5
6
  import Button from '@cdc/core/components/elements/Button'
6
7
  import { getLegendClasses } from './helpers/getLegendClasses'
@@ -18,10 +19,11 @@ import LegendLineShape from './LegendLine.Shape'
18
19
  import LegendGroup from './LegendGroup'
19
20
  import { getSeriesWithData } from '../../helpers/dataHelpers'
20
21
  import { publishAnalyticsEvent } from '@cdc/core/helpers/metrics/helpers'
22
+ import { getVizTitle, getVizSubType } from '@cdc/core/helpers/metrics/utils'
21
23
 
22
24
  const LEGEND_PADDING = 36
23
25
 
24
- export interface LegendProps {
26
+ interface LegendProps {
25
27
  colorScale: ColorScale
26
28
  config: ChartConfig
27
29
  currentViewport: ViewportSize
@@ -36,7 +38,6 @@ export interface LegendProps {
36
38
  interactionLabel: string
37
39
  }
38
40
 
39
- /* eslint-disable jsx-a11y/no-noninteractive-tabindex, jsx-a11y/no-static-element-interactions */
40
41
  const Legend: React.FC<LegendProps> = forwardRef(
41
42
  (
42
43
  {
@@ -72,6 +73,7 @@ const Legend: React.FC<LegendProps> = forwardRef(
72
73
 
73
74
  const { HighLightedBarUtils } = useHighlightedBars(config)
74
75
  let highLightedLegendItems = HighLightedBarUtils.findDuplicates(config.highlightedBarValues)
76
+
75
77
  if (!legend) return null
76
78
  return (
77
79
  <aside
@@ -140,23 +142,40 @@ const Legend: React.FC<LegendProps> = forwardRef(
140
142
  onKeyDown={e => {
141
143
  if (e.key === 'Enter') {
142
144
  e.preventDefault()
143
- publishAnalyticsEvent(
144
- `chart_legend_item_toggled--${legend.behavior}-mode`,
145
- 'keydown',
146
- `${interactionLabel}|${label.text}`,
147
- 'chart'
148
- )
145
+ publishAnalyticsEvent({
146
+ vizType: config?.type,
147
+ vizSubType: getVizSubType(config),
148
+ vizTitle: getVizTitle(config),
149
+ eventType: `chart_legend_item_toggled` as any,
150
+ eventAction: 'keydown',
151
+ eventLabel: interactionLabel,
152
+ specifics:
153
+ config.visualizationType === 'Bar'
154
+ ? `label: ${label.text}, orientation: ${
155
+ config.orientation === 'horizontal' ? 'horizontal' : 'vertical'
156
+ }, mode: ${legend.behavior}`
157
+ : `label: ${label.text}, mode: ${legend.behavior}`
158
+ })
149
159
  highlight(label)
150
160
  }
151
161
  }}
152
162
  onClick={e => {
153
163
  e.preventDefault()
154
- publishAnalyticsEvent(
155
- `chart_legend_item_toggled--${legend.behavior}-mode`,
156
- 'click',
157
- `${interactionLabel}|${label.text}`,
158
- 'chart'
159
- )
164
+ publishAnalyticsEvent({
165
+ vizType: config?.type,
166
+ vizSubType: getVizSubType(config),
167
+ eventType: `chart_legend_item_toggled` as any,
168
+ eventAction: 'click',
169
+ eventLabel: interactionLabel,
170
+ specifics:
171
+ config.visualizationType === 'Bar'
172
+ ? `label: ${label.text}, orientation: ${
173
+ config.orientation === 'horizontal' ? 'horizontal' : 'vertical'
174
+ }, mode: ${legend.behavior}`
175
+ : `label: ${label.text}, mode: ${legend.behavior}`,
176
+
177
+ vizTitle: getVizTitle(config)
178
+ })
160
179
  highlight(label)
161
180
  }}
162
181
  role='button'
@@ -221,6 +240,87 @@ const Legend: React.FC<LegendProps> = forwardRef(
221
240
  </div>
222
241
 
223
242
  <LegendSuppression config={config} isLegendBottom={isLegendBottom} />
243
+
244
+ {/* Pattern Legend Items */}
245
+ {config.legend.patterns && Object.keys(config.legend.patterns).length > 0 && (
246
+ <div
247
+ className={`legend-patterns d-flex ${
248
+ ['top', 'bottom'].includes(config.legend.position) ? 'flex-row flex-wrap' : 'flex-column'
249
+ }`}
250
+ >
251
+ {Object.entries(config.legend.patterns).map(([key, pattern]) => {
252
+ const patternId = `legend-pattern-${key}`
253
+ const size = config.legend.patternSize || 8
254
+ const legendSize = 16
255
+ const pColor = (pattern as any)?.color || '#666666'
256
+
257
+ return (
258
+ <LegendItem
259
+ key={patternId}
260
+ className='legend-item legend-item--pattern d-flex align-items-center'
261
+ tabIndex={0}
262
+ role='button'
263
+ >
264
+ <span className='me-2'>
265
+ <svg width={legendSize} height={legendSize}>
266
+ <defs>
267
+ {pattern.shape === 'circles' && (
268
+ <PatternCircles
269
+ id={patternId}
270
+ height={size}
271
+ width={size}
272
+ fill={pColor}
273
+ radius={1.25}
274
+ />
275
+ )}
276
+ {pattern.shape === 'lines' && (
277
+ <PatternLines
278
+ id={patternId}
279
+ height={size}
280
+ width={size}
281
+ stroke={pColor}
282
+ strokeWidth={0.75}
283
+ orientation={['horizontal']}
284
+ />
285
+ )}
286
+ {pattern.shape === 'diagonalLines' && (
287
+ <PatternLines
288
+ id={patternId}
289
+ height={size}
290
+ width={size}
291
+ stroke={pColor}
292
+ strokeWidth={0.75}
293
+ orientation={['diagonalRightToLeft']}
294
+ />
295
+ )}
296
+ {pattern.shape === 'waves' && (
297
+ <PatternWaves
298
+ id={patternId}
299
+ height={size}
300
+ width={size}
301
+ fill={pColor}
302
+ strokeWidth={0.25}
303
+ />
304
+ )}
305
+ </defs>
306
+ <circle
307
+ fill={`url(#${patternId})`}
308
+ r={legendSize / 2}
309
+ cx={legendSize / 2}
310
+ cy={legendSize / 2}
311
+ stroke='#0000004d'
312
+ strokeWidth={1}
313
+ />
314
+ </svg>
315
+ </span>
316
+ <LegendLabel align='left' className='m-0'>
317
+ {parse(String((pattern as any)?.label || key))}
318
+ </LegendLabel>
319
+ </LegendItem>
320
+ )
321
+ })}
322
+ </div>
323
+ )}
224
324
  </>
225
325
  )
226
326
  }}