@cdc/chart 4.25.10 → 4.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/dist/{cdcchart-1a1724a1.es.js → cdcchart-dgT_1dIT.es.js} +136 -151
  2. package/dist/cdcchart.js +44003 -43518
  3. package/examples/feature/__data__/planet-example-data.json +1 -1
  4. package/examples/feature/boxplot/valid-boxplot.csv +38 -17
  5. package/examples/feature/pie/planet-pie-example-config.json +48 -2
  6. package/examples/private/DEV-11825.json +573 -0
  7. package/examples/private/DEV-12100.json +1303 -0
  8. package/examples/private/cat-y.json +1235 -0
  9. package/examples/private/data-points.json +228 -0
  10. package/examples/private/height.json +3915 -0
  11. package/examples/private/links.json +569 -0
  12. package/examples/private/na.json +913 -0
  13. package/examples/private/quadrant.txt +30 -0
  14. package/examples/private/test-data.csv +28 -0
  15. package/examples/private/test-forecast.json +5510 -0
  16. package/examples/private/warming-stripe-test.json +2578 -0
  17. package/examples/private/warming-stripes.json +4763 -0
  18. package/examples/tech-adoption-with-links.json +560 -0
  19. package/index.html +16 -140
  20. package/package.json +6 -5
  21. package/preview.html +1616 -0
  22. package/src/CdcChart.tsx +8 -11
  23. package/src/CdcChartComponent.tsx +329 -124
  24. package/src/_stories/Chart.Combo.stories.tsx +18 -0
  25. package/src/_stories/Chart.Forecast.stories.tsx +36 -0
  26. package/src/_stories/Chart.HTMLInDataTable.stories.tsx +520 -0
  27. package/src/_stories/Chart.Patterns.stories.tsx +2 -1
  28. package/src/_stories/Chart.PreserveDecimals.stories.tsx +220 -0
  29. package/src/_stories/Chart.Regions.Categorical.stories.tsx +148 -0
  30. package/src/_stories/Chart.Regions.DateScale.stories.tsx +197 -0
  31. package/src/_stories/Chart.Regions.DateTimeScale.stories.tsx +297 -0
  32. package/src/_stories/Chart.SmallMultiples.stories.tsx +47 -0
  33. package/src/_stories/Chart.stories.tsx +8 -0
  34. package/src/_stories/ChartAnnotation.stories.tsx +6 -3
  35. package/src/_stories/ChartBar.Editor.stories.tsx +3585 -0
  36. package/src/_stories/ChartBrush.Editor.stories.tsx +295 -0
  37. package/src/_stories/ChartBrush.stories.tsx +50 -0
  38. package/src/_stories/ChartEditor.Editor.stories.tsx +656 -0
  39. package/src/_stories/ChartEditor.stories.tsx +1 -2
  40. package/src/_stories/TechAdoptionWithLinks.stories.tsx +27 -0
  41. package/src/_stories/_mock/brush_enabled.json +326 -0
  42. package/src/_stories/_mock/brush_mock.json +2 -69
  43. package/src/_stories/_mock/combo.json +451 -0
  44. package/src/_stories/_mock/editor-test-configs.json +376 -0
  45. package/src/_stories/_mock/editor-test-datasets.json +477 -0
  46. package/src/_stories/_mock/editor-tests/bar-chart-editor-test.json +255 -0
  47. package/src/_stories/_mock/editor-tests/bar-chart-general-test.json +267 -0
  48. package/src/_stories/_mock/editor-tests/bar-chart-test.json +237 -0
  49. package/src/_stories/_mock/forecast_combo_with_gaps.json +913 -0
  50. package/src/_stories/_mock/horizontal-bars-dynamic-y-axis.json +413 -0
  51. package/src/_stories/_mock/pie_config.json +257 -62
  52. package/src/_stories/_mock/small_multiples/small_multiples_bars.json +1944 -0
  53. package/src/_stories/_mock/small_multiples/small_multiples_big_data_bars.json +1114 -0
  54. package/src/_stories/_mock/small_multiples/small_multiples_lines.json +2646 -0
  55. package/src/_stories/_mock/small_multiples/small_multiples_lines_colors.json +1305 -0
  56. package/src/_stories/_mock/small_multiples/small_multiples_stacked_bars.json +1936 -0
  57. package/src/components/Annotations/components/findNearestDatum.ts +6 -41
  58. package/src/components/AreaChart/components/AreaChart.Stacked.jsx +10 -7
  59. package/src/components/AreaChart/index.tsx +1 -2
  60. package/src/components/Axis/Categorical.Axis.tsx +6 -7
  61. package/src/components/BarChart/components/BarChart.Horizontal.tsx +181 -27
  62. package/src/components/BarChart/components/BarChart.StackedHorizontal.tsx +3 -1
  63. package/src/components/BarChart/components/BarChart.StackedVertical.tsx +1 -0
  64. package/src/components/BarChart/components/BarChart.Vertical.tsx +8 -9
  65. package/src/components/BarChart/components/context.tsx +1 -0
  66. package/src/components/BarChart/helpers/useBarChart.ts +14 -2
  67. package/src/components/BoxPlot/helpers/index.ts +3 -3
  68. package/src/components/Brush/BrushSelector.tsx +1258 -0
  69. package/src/components/Brush/MiniChartPreview.tsx +283 -0
  70. package/src/components/DeviationBar.jsx +9 -7
  71. package/src/components/EditorPanel/EditorPanel.tsx +2720 -2586
  72. package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +96 -111
  73. package/src/components/EditorPanel/components/Panels/Panel.ForestPlotSettings.tsx +56 -34
  74. package/src/components/EditorPanel/components/Panels/Panel.General.tsx +76 -31
  75. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +104 -55
  76. package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +54 -49
  77. package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.tsx +427 -0
  78. package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +96 -48
  79. package/src/components/EditorPanel/components/Panels/index.tsx +3 -1
  80. package/src/components/EditorPanel/editor-panel.scss +0 -20
  81. package/src/components/EditorPanel/useEditorPermissions.ts +36 -31
  82. package/src/components/Forecasting/Forecasting.tsx +139 -21
  83. package/src/components/Legend/Legend.Component.tsx +16 -9
  84. package/src/components/Legend/Legend.tsx +3 -2
  85. package/src/components/Legend/helpers/createFormatLabels.tsx +325 -176
  86. package/src/components/Legend/helpers/getLegendClasses.ts +0 -1
  87. package/src/components/Legend/helpers/index.ts +10 -6
  88. package/src/components/LineChart/LineChartProps.ts +0 -3
  89. package/src/components/LineChart/helpers.ts +1 -1
  90. package/src/components/LineChart/index.tsx +36 -13
  91. package/src/components/LinearChart.tsx +559 -499
  92. package/src/components/PairedBarChart.jsx +20 -3
  93. package/src/components/Regions/components/Regions.tsx +366 -144
  94. package/src/components/Sankey/types/index.ts +1 -1
  95. package/src/components/ScatterPlot/ScatterPlot.jsx +2 -2
  96. package/src/components/SmallMultiples/SmallMultipleTile.tsx +202 -0
  97. package/src/components/SmallMultiples/SmallMultiples.css +32 -0
  98. package/src/components/SmallMultiples/SmallMultiples.tsx +271 -0
  99. package/src/components/SmallMultiples/index.ts +2 -0
  100. package/src/components/WarmingStripes/WarmingStripes.tsx +160 -0
  101. package/src/components/WarmingStripes/WarmingStripesGradientLegend.css +35 -0
  102. package/src/components/WarmingStripes/WarmingStripesGradientLegend.tsx +104 -0
  103. package/src/components/WarmingStripes/index.tsx +3 -0
  104. package/src/data/initial-state.js +16 -2
  105. package/src/helpers/buildForecastPaletteOptions.ts +0 -38
  106. package/src/helpers/calculateHorizontalBarCategoryLabelWidth.ts +57 -0
  107. package/src/helpers/getColorScale.ts +10 -0
  108. package/src/{hooks/useMinMax.ts → helpers/getMinMax.ts} +26 -14
  109. package/src/helpers/getYAxisAutoPadding.ts +53 -0
  110. package/src/helpers/sizeHelpers.ts +0 -20
  111. package/src/helpers/smallMultiplesHelpers.ts +529 -0
  112. package/src/hooks/useChartHoverAnalytics.tsx +10 -9
  113. package/src/hooks/useProgrammaticTooltip.ts +96 -0
  114. package/src/hooks/useScales.ts +98 -34
  115. package/src/hooks/useSmallMultipleSynchronization.ts +59 -0
  116. package/src/hooks/useTooltip.tsx +91 -25
  117. package/src/scss/DataTable.scss +0 -4
  118. package/src/scss/main.scss +18 -83
  119. package/src/store/chart.actions.ts +2 -0
  120. package/src/store/chart.reducer.ts +4 -0
  121. package/src/test/CdcChart.test.jsx +1 -1
  122. package/src/types/ChartConfig.ts +27 -6
  123. package/src/types/ChartContext.ts +3 -0
  124. package/src/types/Label.ts +1 -0
  125. package/src/utils/analyticsTracking.ts +19 -0
  126. package/LICENSE +0 -201
  127. package/src/_stories/_mock/pie_data.json +0 -218
  128. package/src/components/AreaChart/components/AreaChart.jsx +0 -109
  129. package/src/components/Brush/BrushChart.tsx +0 -128
  130. package/src/components/Brush/BrushController.tsx +0 -71
  131. package/src/components/Brush/types.tsx +0 -8
  132. package/src/components/BrushChart.tsx +0 -223
  133. package/src/helpers/sort.ts +0 -7
  134. package/src/hooks/useActiveElement.js +0 -19
  135. package/src/hooks/useChartClasses.js +0 -41
@@ -0,0 +1,529 @@
1
+ import { scaleOrdinal } from '@visx/scale'
2
+ import { getColorScale } from './getColorScale'
3
+ import { ColorScale } from '../types/ChartContext'
4
+
5
+ /**
6
+ * Get filtered data for a specific tile based on its mode
7
+ */
8
+ export const getTileData = (tileItem, data) => {
9
+ if (tileItem.mode === 'by-series') {
10
+ // BY-SERIES: All data, but will be filtered to single series by config
11
+ return data
12
+ } else if (tileItem.mode === 'by-column') {
13
+ // BY-COLUMN: Filter data by tile column value
14
+ return data.filter(row => row[tileItem.tileColumn] === tileItem.tileValue)
15
+ }
16
+ return data
17
+ }
18
+
19
+ /**
20
+ * Get tile-specific config based on its mode
21
+ */
22
+ export const getTileConfig = (tileItem, config) => {
23
+ if (tileItem.mode === 'by-series') {
24
+ // BY-SERIES: Single series config
25
+ const singleSeries = config.series.find(s => s.dataKey === tileItem.seriesKey)
26
+ return {
27
+ ...config,
28
+ series: [singleSeries],
29
+ runtime: {
30
+ ...config.runtime,
31
+ series: config.runtime.series.filter(s => s.dataKey === tileItem.seriesKey),
32
+ seriesKeys: [tileItem.seriesKey],
33
+ seriesLabels: {
34
+ [tileItem.seriesKey]: config.runtime.seriesLabels?.[tileItem.seriesKey] || tileItem.seriesKey
35
+ },
36
+ seriesLabelsAll: [config.runtime.seriesLabels?.[tileItem.seriesKey] || tileItem.seriesKey],
37
+ // Filter area chart specific series keys for proper rendering
38
+ ...(config.runtime.areaSeriesKeys && {
39
+ areaSeriesKeys: config.runtime.areaSeriesKeys.filter(s => s.dataKey === tileItem.seriesKey)
40
+ }),
41
+ // Filter line chart specific series keys for proper rendering
42
+ ...(config.runtime.lineSeriesKeys && {
43
+ lineSeriesKeys: config.runtime.lineSeriesKeys.filter(key => key === tileItem.seriesKey)
44
+ })
45
+ }
46
+ }
47
+ } else if (tileItem.mode === 'by-column') {
48
+ // BY-COLUMN: Same config, data will be filtered
49
+ return config
50
+ }
51
+ return config
52
+ }
53
+
54
+ /**
55
+ * Create combined data and config for consistent Y-axis calculation across all tiles
56
+ * This combines all tile data into one dataset and creates a unified config
57
+ */
58
+ export const createCombinedDataForYAxis = (config, data, tileItems) => {
59
+ if (config.smallMultiples?.independentYAxis) {
60
+ return { data: [], config: config }
61
+ }
62
+
63
+ // Combine all tile data into one dataset for Y-axis calculation
64
+ let allTileData = []
65
+ let allSeriesKeys = new Set()
66
+
67
+ tileItems.forEach(item => {
68
+ const tileData = getTileData(item, data)
69
+ const tileConfig = getTileConfig(item, config)
70
+
71
+ if (tileData.length === 0) return
72
+
73
+ // Add this tile's data to the combined dataset
74
+ allTileData = allTileData.concat(tileData)
75
+
76
+ // Collect all series keys
77
+ if (tileConfig.runtime?.seriesKeys) {
78
+ tileConfig.runtime.seriesKeys.forEach(key => allSeriesKeys.add(key))
79
+ }
80
+ })
81
+
82
+ // Create combined config with all series for Y-axis calculation
83
+ const combinedConfig = {
84
+ ...config,
85
+ runtime: {
86
+ ...config.runtime,
87
+ seriesKeys: Array.from(allSeriesKeys)
88
+ }
89
+ }
90
+
91
+ return { data: allTileData, config: combinedConfig }
92
+ }
93
+
94
+ /**
95
+ * Apply tile ordering to tile items array
96
+ * Sorts tiles according to the user's order preference (ascending, descending, or custom)
97
+ */
98
+ export const applyTileOrder = (tileItems, orderType, customOrder, config) => {
99
+ if (!orderType) {
100
+ return tileItems
101
+ }
102
+
103
+ const getTileKey = item => (item.mode === 'by-series' ? item.seriesKey : item.tileValue)
104
+
105
+ switch (orderType) {
106
+ case 'asc':
107
+ return [...tileItems].sort((a, b) => {
108
+ const titleA = String(getTileDisplayTitle(a.mode, a.seriesKey, a.tileValue, a.key, config)).toLowerCase()
109
+ const titleB = String(getTileDisplayTitle(b.mode, b.seriesKey, b.tileValue, b.key, config)).toLowerCase()
110
+ return titleA.localeCompare(titleB)
111
+ })
112
+
113
+ case 'desc':
114
+ return [...tileItems].sort((a, b) => {
115
+ const titleA = String(getTileDisplayTitle(a.mode, a.seriesKey, a.tileValue, a.key, config)).toLowerCase()
116
+ const titleB = String(getTileDisplayTitle(b.mode, b.seriesKey, b.tileValue, b.key, config)).toLowerCase()
117
+ return titleB.localeCompare(titleA)
118
+ })
119
+
120
+ case 'custom':
121
+ if (!customOrder || customOrder.length === 0) {
122
+ return tileItems
123
+ }
124
+
125
+ // Sort tiles based on custom order, with unordered items at the end
126
+ return [...tileItems].sort((a, b) => {
127
+ const keyA = getTileKey(a)
128
+ const keyB = getTileKey(b)
129
+
130
+ const orderA = customOrder.indexOf(keyA)
131
+ const orderB = customOrder.indexOf(keyB)
132
+
133
+ // Items not in customOrder go to the end
134
+ const finalOrderA = orderA === -1 ? 999999 : orderA
135
+ const finalOrderB = orderB === -1 ? 999999 : orderB
136
+
137
+ return finalOrderA - finalOrderB
138
+ })
139
+
140
+ default:
141
+ return tileItems
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Get the available tile keys that can be reordered
147
+ * Returns series keys for by-series mode, unique column values for by-column mode
148
+ */
149
+ export const getTileKeys = (config, data) => {
150
+ if (config.smallMultiples?.mode === 'by-series') {
151
+ return config.series.map(series => series.dataKey)
152
+ } else if (config.smallMultiples?.mode === 'by-column') {
153
+ const tileColumn = config.smallMultiples.tileColumn
154
+ if (!tileColumn) return []
155
+
156
+ return Array.from(new Set(data.map(row => row[tileColumn])))
157
+ .filter(val => val != null)
158
+ .sort()
159
+ }
160
+ return []
161
+ }
162
+
163
+ /**
164
+ * Get the display title for a tile based on its mode
165
+ * For by-series: uses series.name or seriesKey
166
+ * For by-column: uses custom title or tileValue
167
+ */
168
+ export const getTileDisplayTitle = (mode, seriesKey, tileValue, tileKey, config) => {
169
+ if (mode === 'by-series') {
170
+ // For by-series mode: use configured series name, fall back to seriesKey
171
+ const series = config.series?.find(s => s.dataKey === seriesKey)
172
+ return series?.name || seriesKey
173
+ } else if (mode === 'by-column') {
174
+ // For by-column mode: use custom title from editor, fall back to column value
175
+ return config.smallMultiples?.tileTitles?.[tileValue] || tileValue
176
+ }
177
+ return tileKey
178
+ }
179
+
180
+ /**
181
+ * Get the full color palette from config with exactly the number of colors needed
182
+ * This creates a temporary colorScale with the right number of series to get the needed colors
183
+ */
184
+ export const getFullColorPalette = (config, numberOfTiles) => {
185
+ // Create fake series keys for exactly the number of tiles needed
186
+ const tempSeriesKeys = Array(numberOfTiles)
187
+ .fill(null)
188
+ .map((_, i) => `temp${i + 1}`)
189
+ const tempConfig = {
190
+ ...config,
191
+ runtime: {
192
+ ...config.runtime,
193
+ seriesKeys: tempSeriesKeys,
194
+ seriesLabelsAll: tempSeriesKeys
195
+ }
196
+ }
197
+
198
+ const tempColorScale = getColorScale(tempConfig) as ColorScale
199
+ return tempColorScale.range()
200
+ }
201
+
202
+ /**
203
+ * Create a custom colorScale for a tile based on the color mode
204
+ * This reuses the existing colorScale and extracts/manipulates its palette as needed
205
+ */
206
+ export const createTileColorScale = (tileItem, config, originalColorScale, tileIndex, numberOfTiles) => {
207
+ const colorMode = config.smallMultiples?.colorMode || 'same'
208
+
209
+ if (colorMode === 'same') {
210
+ if (tileItem.mode === 'by-series') {
211
+ // Same mode + by-series: All tiles use the same color
212
+ const palette = originalColorScale.range() // Extract palette from existing colorScale
213
+ const baseColor = palette[0]
214
+ // Create a ScaleOrdinal that always returns the same color
215
+ return scaleOrdinal({
216
+ domain: originalColorScale.domain(), // Reuse existing domain
217
+ range: [baseColor],
218
+ unknown: baseColor
219
+ })
220
+ } else {
221
+ // Same mode + by-column: Use original colorScale
222
+ return originalColorScale
223
+ }
224
+ } else {
225
+ if (tileItem.mode === 'by-series') {
226
+ // Different mode + by-series: Each series gets different color
227
+ return originalColorScale
228
+ } else {
229
+ // Different mode + by-column: Each tile gets a different base color
230
+ // Get exactly the right number of colors for the tiles
231
+ const fullPalette = getFullColorPalette(config, numberOfTiles)
232
+ const baseColor = fullPalette[tileIndex]
233
+ // Create a ScaleOrdinal that returns the tile-specific color for all series
234
+ return scaleOrdinal({
235
+ domain: originalColorScale.domain(), // Reuse existing domain
236
+ range: [baseColor],
237
+ unknown: baseColor
238
+ })
239
+ }
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Pivot data from long format to wide format for DataTable display
245
+ * Transforms data so each unique tileColumn value becomes its own column
246
+ * Works for both regular data and runtimeData
247
+ *
248
+ * Example:
249
+ * From: [{index: "1", value: 100, smell: "sweet"}, {index: "1", value: 50, smell: "bitter"}]
250
+ * To: [{index: "1", sweet: 100, bitter: 50}]
251
+ *
252
+ * @param data - Original data in long format
253
+ * @param tileColumn - Column to pivot on (e.g., "smell")
254
+ * @param valueColumn - Column containing values to pivot (e.g., "value")
255
+ * @param xAxisColumn - X-axis column name (e.g., "index")
256
+ * @param tileValues - Ordered array of tile values (determines column order)
257
+ * @returns Pivoted data in wide format
258
+ */
259
+ export const pivotDataForDataTable = (data, tileColumn, valueColumn, xAxisColumn, tileValues) => {
260
+ if (!data || !tileColumn || !valueColumn || !xAxisColumn) return []
261
+
262
+ // Group data by x-axis value
263
+ const xAxisGroups = new Map()
264
+
265
+ data.forEach(row => {
266
+ const xAxisKey = String(row[xAxisColumn])
267
+ if (!xAxisGroups.has(xAxisKey)) {
268
+ xAxisGroups.set(xAxisKey, [])
269
+ }
270
+ xAxisGroups.get(xAxisKey).push(row)
271
+ })
272
+
273
+ // Create pivoted rows
274
+ const pivotedData = []
275
+
276
+ xAxisGroups.forEach((rows, xAxisKey) => {
277
+ const pivotedRow = {
278
+ [xAxisColumn]: xAxisKey
279
+ }
280
+
281
+ // Add tile value columns in the correct order
282
+ tileValues.forEach(tileValue => {
283
+ const columnKey = String(tileValue)
284
+ const matchingRow = rows.find(row => String(row[tileColumn]) === columnKey)
285
+ if (matchingRow) {
286
+ pivotedRow[columnKey] = matchingRow[valueColumn]
287
+ }
288
+ })
289
+
290
+ // Copy non-value, non-tile columns from first row
291
+ const firstRow = rows[0]
292
+ Object.keys(firstRow).forEach(key => {
293
+ if (key !== tileColumn && key !== valueColumn && key !== xAxisColumn) {
294
+ pivotedRow[key] = firstRow[key]
295
+ }
296
+ })
297
+
298
+ pivotedData.push(pivotedRow)
299
+ })
300
+
301
+ return pivotedData
302
+ }
303
+
304
+ /**
305
+ * Create a single column config for a tile value
306
+ * Helper function to avoid duplication
307
+ */
308
+ const createTileColumnConfig = (tileValue, valueColumnConfig, tileTitles) => {
309
+ const columnKey = String(tileValue)
310
+ return {
311
+ ...valueColumnConfig,
312
+ name: columnKey,
313
+ label: tileTitles?.[tileValue] || String(tileValue),
314
+ dataTable: true
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Create column configurations for pivoted data table
320
+ * Generates one column config for each tile value, copying formatting from the original value column
321
+ * Preserves column order by inserting new columns where the value column was
322
+ *
323
+ * @param originalColumns - Original columns configuration
324
+ * @param valueColumnName - Name of the value column to clone config from
325
+ * @param tileColumnName - Name of the tile column to remove
326
+ * @param tileValues - Array of tile values (becomes new column names)
327
+ * @param tileTitles - Custom titles for columns
328
+ * @returns New columns configuration with xAxis column + one column per tile value
329
+ */
330
+ export const createPivotedColumns = (originalColumns, valueColumnName, tileColumnName, tileValues, tileTitles) => {
331
+ const newColumns = {}
332
+ let valueColumnConfig = {}
333
+ let addedPivotedColumns = false
334
+
335
+ const hasOriginalColumns = originalColumns && Object.keys(originalColumns).length > 0
336
+
337
+ const addPivotedColumns = () => {
338
+ if (addedPivotedColumns) return
339
+ tileValues.forEach(tileValue => {
340
+ const columnKey = String(tileValue)
341
+ newColumns[columnKey] = createTileColumnConfig(tileValue, valueColumnConfig, tileTitles)
342
+ })
343
+ addedPivotedColumns = true
344
+ }
345
+
346
+ if (hasOriginalColumns) {
347
+ Object.keys(originalColumns).forEach(key => {
348
+ const column = originalColumns[key]
349
+
350
+ if (column.name === valueColumnName) {
351
+ // Found value column - save its config and replace with pivoted columns
352
+ valueColumnConfig = column
353
+ addPivotedColumns()
354
+ } else if (column.name !== tileColumnName) {
355
+ newColumns[key] = column
356
+ }
357
+ })
358
+ }
359
+
360
+ // Only added if not previously added
361
+ addPivotedColumns()
362
+
363
+ return newColumns
364
+ }
365
+
366
+ /**
367
+ * Get ordered tile values/keys for data table display
368
+ * Works for both by-series and by-column modes
369
+ */
370
+ const getOrderedTileValues = (config, rawValues, mode) => {
371
+ const { tileOrderType, tileOrder } = config.smallMultiples || {}
372
+
373
+ if (!tileOrderType) {
374
+ return rawValues
375
+ }
376
+
377
+ // Create minimal tile items for ordering
378
+ const tileItems = rawValues.map(value => ({
379
+ mode,
380
+ ...(mode === 'by-series' ? { seriesKey: value } : { tileValue: value }),
381
+ key: value
382
+ }))
383
+
384
+ // Apply ordering
385
+ const orderedItems = applyTileOrder(tileItems, tileOrderType, tileOrder, config)
386
+
387
+ // Extract values back
388
+ return orderedItems.map(item => (mode === 'by-series' ? item.seriesKey : item.tileValue))
389
+ }
390
+
391
+ /**
392
+ * Create runtime series objects for pivoted data
393
+ * Each tile value becomes a series in the data table
394
+ */
395
+ const createPivotedRuntimeSeries = (tileValues, originalSeries) => {
396
+ return tileValues.map(tileValue => ({
397
+ dataKey: String(tileValue),
398
+ type: originalSeries[0]?.type || 'Bar',
399
+ axis: originalSeries[0]?.axis || 'Left',
400
+ tooltip: true
401
+ }))
402
+ }
403
+
404
+ /**
405
+ * Handle by-series mode for data table
406
+ * Reorders series based on tile ordering settings
407
+ */
408
+ const handleBySeriesMode = (config, columns, runtimeData) => {
409
+ const rawSeriesKeys = config.series?.map(s => s.dataKey) || []
410
+ const orderedSeriesKeys = getOrderedTileValues(config, rawSeriesKeys, 'by-series')
411
+
412
+ if (!config.runtime?.series) {
413
+ return { config, columns, runtimeData }
414
+ }
415
+
416
+ // Reorder runtime series
417
+ const reorderedRuntimeSeries = orderedSeriesKeys
418
+ .map(seriesKey => config.runtime.series.find(s => s.dataKey === seriesKey))
419
+ .filter(Boolean)
420
+
421
+ return {
422
+ config: {
423
+ ...config,
424
+ runtime: {
425
+ ...config.runtime,
426
+ series: reorderedRuntimeSeries
427
+ }
428
+ },
429
+ columns,
430
+ runtimeData
431
+ }
432
+ }
433
+
434
+ /**
435
+ * Handle by-column mode for data table
436
+ * Two scenarios:
437
+ * 1. Single-series: Pivot data and create columns per tile value
438
+ * 2. Multi-series (stacked/grouped): Add tileColumn to data table, no pivoting
439
+ */
440
+ const handleByColumnMode = (config, columns, runtimeData) => {
441
+ const xAxisColumn = config.xAxis?.dataKey
442
+ const { tileColumn, tileTitles } = config.smallMultiples
443
+
444
+ // Validate required columns
445
+ if (!xAxisColumn || !tileColumn) {
446
+ return { config, columns, runtimeData }
447
+ }
448
+
449
+ if (config.series && config.series.length > 1) {
450
+ const updatedColumns = {
451
+ ...columns
452
+ }
453
+
454
+ const tileColumnKey = tileColumn
455
+ if (!updatedColumns[tileColumnKey]) {
456
+ updatedColumns[tileColumnKey] = {
457
+ name: tileColumn,
458
+ label: tileColumn,
459
+ dataTable: true,
460
+ order: 2
461
+ }
462
+ }
463
+
464
+ return {
465
+ config: {
466
+ ...config,
467
+ columns: updatedColumns
468
+ },
469
+ columns: updatedColumns,
470
+ runtimeData
471
+ }
472
+ }
473
+
474
+ const valueColumn = config.series?.[0]?.dataKey
475
+
476
+ if (!valueColumn) {
477
+ return { config, columns, runtimeData }
478
+ }
479
+
480
+ // Get ordered tile values
481
+ const rawTileValues = getTileKeys(config, config.data)
482
+ const orderedTileValues = getOrderedTileValues(config, rawTileValues, 'by-column')
483
+
484
+ // Pivot data and runtimeData
485
+ const pivotedData = pivotDataForDataTable(config.data, tileColumn, valueColumn, xAxisColumn, orderedTileValues)
486
+ const pivotedRuntimeData = Array.isArray(runtimeData)
487
+ ? pivotDataForDataTable(runtimeData, tileColumn, valueColumn, xAxisColumn, orderedTileValues)
488
+ : runtimeData
489
+
490
+ // Create pivoted columns and runtime series
491
+ const pivotedColumns = createPivotedColumns(columns, valueColumn, tileColumn, orderedTileValues, tileTitles)
492
+ const pivotedRuntimeSeries = createPivotedRuntimeSeries(orderedTileValues, config.series)
493
+
494
+ return {
495
+ config: {
496
+ ...config,
497
+ data: pivotedData,
498
+ columns: pivotedColumns,
499
+ runtime: {
500
+ ...config.runtime,
501
+ series: pivotedRuntimeSeries,
502
+ seriesKeys: orderedTileValues.map(String)
503
+ }
504
+ },
505
+ columns: pivotedColumns,
506
+ runtimeData: pivotedRuntimeData
507
+ }
508
+ }
509
+
510
+ /**
511
+ * Prepare data table props for small multiples display
512
+ * Handles both by-column and by-series modes
513
+ *
514
+ * @param config - Chart configuration
515
+ * @param columns - Original columns configuration
516
+ * @param runtimeData - Original runtime data
517
+ * @returns Object with modified config, columns, and runtimeData (or originals if not applicable)
518
+ */
519
+ export const prepareSmallMultiplesDataTable = (config, columns, runtimeData) => {
520
+ const mode = config.smallMultiples?.mode
521
+
522
+ if (!mode) {
523
+ return { config, columns, runtimeData }
524
+ }
525
+
526
+ return mode === 'by-series'
527
+ ? handleBySeriesMode(config, columns, runtimeData)
528
+ : handleByColumnMode(config, columns, runtimeData)
529
+ }
@@ -1,6 +1,6 @@
1
- import { useRef } from 'react'
2
1
  import { publishAnalyticsEvent } from '@cdc/core/helpers/metrics/helpers'
3
2
  import { getVizTitle, getVizSubType } from '@cdc/core/helpers/metrics/utils'
3
+ import { hasTrackedHover, markHoverTracked } from '../utils/analyticsTracking'
4
4
 
5
5
  type UseChartHoverAnalyticsParams = {
6
6
  config: any
@@ -9,14 +9,16 @@ type UseChartHoverAnalyticsParams = {
9
9
 
10
10
  /**
11
11
  * Hook to track analytics when user enters the chart area
12
- * Fires once per chart entry, not on every hover interaction
12
+ * Fires once per visualization, persists across component remounts
13
13
  */
14
14
  export const useChartHoverAnalytics = ({ config, interactionLabel = '' }: UseChartHoverAnalyticsParams) => {
15
- const hasTrackedRef = useRef(false)
16
-
17
15
  const handleChartMouseEnter = () => {
18
- // Only track if we have an interaction label and haven't tracked yet
19
- if (!interactionLabel || hasTrackedRef.current) return
16
+ // Only track if we have an interaction label
17
+ if (!interactionLabel) return
18
+
19
+ // Use unique ID to track per visualization
20
+ const vizId = String(config.runtime.uniqueId)
21
+ if (hasTrackedHover(vizId)) return
20
22
 
21
23
  // Publish the analytics event
22
24
  publishAnalyticsEvent({
@@ -29,12 +31,11 @@ export const useChartHoverAnalytics = ({ config, interactionLabel = '' }: UseCha
29
31
  })
30
32
 
31
33
  // Mark as tracked so we don't fire again
32
- hasTrackedRef.current = true
34
+ markHoverTracked(vizId)
33
35
  }
34
36
 
35
37
  const handleChartMouseLeave = () => {
36
- // Reset tracking when mouse leaves so next entry will track
37
- hasTrackedRef.current = false
38
+ // No-op: We no longer reset tracking on mouse leave
38
39
  }
39
40
 
40
41
  return {
@@ -0,0 +1,96 @@
1
+ import { useRef, useImperativeHandle, ForwardedRef } from 'react'
2
+
3
+ interface UseProgrammaticTooltipProps {
4
+ svgRef: ForwardedRef<SVGAElement>
5
+ getCoordinateFromXValue: (xAxisValue: any) => number
6
+ config: any
7
+ setPoint: (point: { x: number; y: number }) => void
8
+ setShowHoverLine: (show: boolean) => void
9
+ handleTooltipMouseOver: (event: MouseEvent, additionalChartData?: any) => void
10
+ hideTooltip: () => void
11
+ }
12
+
13
+ /**
14
+ * Custom hook to provide programmatic tooltip control for small multiples synchronization
15
+ * Handles SVG ref management and exposes methods to trigger tooltips from external sources
16
+ */
17
+ export const useProgrammaticTooltip = ({
18
+ svgRef,
19
+ getCoordinateFromXValue,
20
+ config,
21
+ setPoint,
22
+ setShowHoverLine,
23
+ handleTooltipMouseOver,
24
+ hideTooltip
25
+ }: UseProgrammaticTooltipProps) => {
26
+ // Internal SVG ref for DOM manipulation
27
+ const internalSvgRef = useRef<SVGSVGElement>(null)
28
+
29
+ // Expose programmatic tooltip methods via ref
30
+ useImperativeHandle(
31
+ svgRef,
32
+ () => {
33
+ // Return a proxy that combines SVG element access with our custom methods
34
+ const svgElement = internalSvgRef.current
35
+
36
+ // If no SVG element yet, return minimal interface
37
+ if (!svgElement) {
38
+ return {
39
+ triggerTooltipAtDataValue: () => {},
40
+ hideTooltip: () => {}
41
+ }
42
+ }
43
+
44
+ // Create object that has both SVG methods and our custom methods
45
+ return Object.assign(svgElement, {
46
+ /**
47
+ * Trigger tooltip at specific data value and Y coordinate (data-centric approach)
48
+ * This ensures pixel-perfect alignment across small multiple tiles
49
+ * @param {any} xAxisValue - X-axis data value (date, number, or category)
50
+ * @param {number} yCoordinate - Exact Y coordinate to use
51
+ */
52
+ triggerTooltipAtDataValue: (xAxisValue: any, yCoordinate: number) => {
53
+ const pixelX = getCoordinateFromXValue(xAxisValue)
54
+ const adjustedX = pixelX + Number(config.yAxis.size || 0)
55
+
56
+ const svgRect = internalSvgRef.current!.getBoundingClientRect()
57
+
58
+ const syntheticEvent = new MouseEvent('mousemove', {
59
+ bubbles: true,
60
+ cancelable: true,
61
+ clientX: svgRect.left + adjustedX,
62
+ clientY: svgRect.top + yCoordinate,
63
+ screenX: window.screenX + svgRect.left + adjustedX,
64
+ screenY: window.screenY + svgRect.top + yCoordinate,
65
+ view: window
66
+ })
67
+
68
+ Object.defineProperty(syntheticEvent, 'currentTarget', {
69
+ value: internalSvgRef.current,
70
+ writable: false
71
+ })
72
+
73
+ Object.defineProperty(syntheticEvent, 'target', {
74
+ value: internalSvgRef.current,
75
+ writable: false
76
+ })
77
+
78
+ setPoint({ x: adjustedX, y: yCoordinate })
79
+ setShowHoverLine(true)
80
+ handleTooltipMouseOver(syntheticEvent, null)
81
+ },
82
+
83
+ /**
84
+ * Hide any currently displayed tooltip and hover line
85
+ */
86
+ hideTooltip: () => {
87
+ hideTooltip()
88
+ setShowHoverLine(false)
89
+ }
90
+ })
91
+ },
92
+ [getCoordinateFromXValue, config.yAxis.size, setPoint, setShowHoverLine, handleTooltipMouseOver, hideTooltip]
93
+ )
94
+
95
+ return internalSvgRef
96
+ }