@cdc/chart 4.26.7 → 4.26.10

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 (255) hide show
  1. package/CONFIG.md +502 -368
  2. package/dist/cdcchart-CYc-Kayd.es.js +22488 -0
  3. package/dist/cdcchart-DCKn8NmI.es.js +20695 -0
  4. package/dist/cdcchart-Dyui9nk_.es.js +45828 -0
  5. package/dist/cdcchart.js +4 -97406
  6. package/examples/__data__/vega-remote-data.json +6 -0
  7. package/examples/dendrogram.json +18 -0
  8. package/examples/feature/bar/bar-chart-race-annual-change.json +68 -0
  9. package/examples/feature/heatmap/average-age-categorical.json +2 -2
  10. package/examples/feature/heatmap/calendar-heatmap.json +2 -2
  11. package/examples/feature/heatmap/cell-values-bottom-axis.json +2 -2
  12. package/examples/feature/heatmap/sparse-aggregation.json +2 -2
  13. package/examples/feature/line/line-chart-race.json +50 -0
  14. package/examples/feature/sankey/sankey-example-data.json +40 -1441
  15. package/examples/feature/sankey/sankey_chart_data.csv +9 -20
  16. package/examples/network.json +31 -0
  17. package/examples/private/box-plot.json +573 -0
  18. package/examples/private/custom-color-issue.json +625 -0
  19. package/examples/private/custom-colors.json +522 -0
  20. package/examples/private/cutoff.json +10750 -0
  21. package/examples/private/overlay-legend.json +485 -0
  22. package/examples/private/pie-test.json +469 -0
  23. package/examples/vega-remote.json +22 -0
  24. package/package.json +11 -5
  25. package/src/CdcChartComponent.tsx +405 -208
  26. package/src/_stories/Chart.BarRace.stories.tsx +443 -0
  27. package/src/_stories/Chart.BoxPlotNumericSort.stories.tsx +189 -0
  28. package/src/_stories/Chart.BoxPlotStatsParity.stories.tsx +207 -0
  29. package/src/_stories/Chart.ColorDistribution.stories.tsx +299 -0
  30. package/src/_stories/Chart.DataCutoff.stories.tsx +147 -0
  31. package/src/_stories/Chart.DataTableDownloadFilename.stories.tsx +60 -0
  32. package/src/_stories/Chart.Dendrogram.stories.tsx +373 -0
  33. package/src/_stories/Chart.Forecast.CILegend.stories.tsx +236 -0
  34. package/src/_stories/Chart.HeatMap.stories.tsx +170 -4
  35. package/src/_stories/Chart.Legend.AxisAlign.stories.tsx +119 -0
  36. package/src/_stories/Chart.LineRace.stories.tsx +149 -0
  37. package/src/_stories/Chart.Network.stories.tsx +432 -0
  38. package/src/_stories/Chart.PaletteConversion.Editor.stories.tsx +208 -0
  39. package/src/_stories/Chart.Patterns.smoke.stories.tsx +31 -0
  40. package/src/_stories/Chart.PercentageValue.stories.tsx +311 -0
  41. package/src/_stories/Chart.PieLabels.stories.tsx +4 -2
  42. package/src/_stories/Chart.RankByValueMutation.stories.tsx +206 -0
  43. package/src/_stories/Chart.RequiredFields.stories.tsx +681 -0
  44. package/src/_stories/Chart.Sankey.stories.tsx +417 -0
  45. package/src/_stories/Chart.SeriesColorAssignments.stories.tsx +47 -0
  46. package/src/_stories/Chart.SmallMultiplesNumericTileOrder.stories.tsx +137 -0
  47. package/src/_stories/Chart.TooltipSeriesLabels.stories.tsx +43 -0
  48. package/src/_stories/Chart.VegaRemoteData.stories.tsx +31 -0
  49. package/src/_stories/ChartAnnotation.Editor.stories.tsx +78 -0
  50. package/src/_stories/ChartAxisTitles.smoke.stories.tsx +48 -0
  51. package/src/_stories/ChartBar.Editor.stories.tsx +499 -57
  52. package/src/_stories/ChartBrush.Editor.stories.tsx +46 -67
  53. package/src/_stories/ChartBrush.Regression.stories.tsx +135 -0
  54. package/src/_stories/ChartEditor.Editor.stories.tsx +33 -3
  55. package/src/_stories/ChartLine.EffectCompatibility.stories.tsx +120 -0
  56. package/src/_stories/_mock/annotation_event-line_mock.json +2 -2
  57. package/src/_stories/_mock/editor-test-configs.json +20 -10
  58. package/src/_stories/_mock/editor-test-datasets.json +38 -42
  59. package/src/_stories/_mock/heatmap-average-age-categorical.json +2 -2
  60. package/src/_stories/_mock/heatmap.json +2 -2
  61. package/src/_stories/_mock/legend-axis-align-stacked-bar.json +125 -0
  62. package/src/_stories/_mock/sankey_mock.json +23 -27
  63. package/src/components/Annotations/components/AnnotationDraggable.styles.css +52 -0
  64. package/src/components/Annotations/components/AnnotationDraggable.tsx +639 -139
  65. package/src/components/Annotations/components/AnnotationDropdown.styles.css +44 -4
  66. package/src/components/Annotations/components/AnnotationDropdown.tsx +10 -32
  67. package/src/components/Annotations/helpers/resolveAnnotationLayout.ts +212 -0
  68. package/src/components/Annotations/tests/AnnotationDraggable.test.tsx +612 -19
  69. package/src/components/Annotations/tests/AnnotationDropdown.test.tsx +50 -0
  70. package/src/components/Annotations/tests/resolveAnnotationLayout.test.ts +203 -0
  71. package/src/components/Axis/BottomAxis.tsx +1 -1
  72. package/src/components/Axis/Categorical.Axis.tsx +3 -3
  73. package/src/components/Axis/RightAxis.tsx +21 -13
  74. package/src/components/BarChart/components/BarChart.Horizontal.tsx +38 -6
  75. package/src/components/BarChart/components/BarChart.StackedHorizontal.tsx +12 -3
  76. package/src/components/BarChart/components/BarChart.StackedVertical.test.ts +28 -0
  77. package/src/components/BarChart/components/BarChart.StackedVertical.tsx +29 -25
  78. package/src/components/BarChart/components/BarChart.Vertical.tsx +37 -26
  79. package/src/components/BarChart/components/PortionPatternOverlay.tsx +61 -0
  80. package/src/components/BarChart/components/context.tsx +6 -0
  81. package/src/components/BarChart/helpers/getPatternUrl.ts +2 -5
  82. package/src/components/BarChart/helpers/index.ts +8 -0
  83. package/src/components/BarChart/helpers/portionPattern.ts +243 -0
  84. package/src/components/BarChart/helpers/tests/addMinimumBarHeights.test.ts +42 -0
  85. package/src/components/BarChart/helpers/tests/getPatternUrl.test.ts +13 -0
  86. package/src/components/BarChart/helpers/tests/portionPattern.test.ts +280 -0
  87. package/src/components/BarChart/helpers/tests/useBarChart.test.tsx +104 -0
  88. package/src/components/BarChart/helpers/useBarChart.ts +60 -59
  89. package/src/components/BarChartRace/BarChartRace.test.tsx +199 -0
  90. package/src/components/BarChartRace/BarChartRace.tsx +230 -0
  91. package/src/components/BarChartRace/BarChartRaceFallback.test.tsx +61 -0
  92. package/src/components/BarChartRace/BarChartRaceFallback.tsx +48 -0
  93. package/src/components/BarChartRace/bar-chart-race.css +160 -0
  94. package/src/components/BarChartRace/helpers.test.ts +114 -0
  95. package/src/components/BarChartRace/helpers.ts +185 -0
  96. package/src/components/BarChartRace/index.ts +3 -0
  97. package/src/components/BoxPlot/helpers/index.ts +11 -82
  98. package/src/components/Brush/BrushSelector.tsx +9 -23
  99. package/src/components/Brush/MiniChartPreview.tsx +33 -1
  100. package/src/components/Dendrogram/components/Dendrogram.tsx +374 -0
  101. package/src/components/Dendrogram/dendrogram.css +113 -0
  102. package/src/components/Dendrogram/helpers/layoutDendrogram.test.ts +95 -0
  103. package/src/components/Dendrogram/helpers/layoutDendrogram.ts +56 -0
  104. package/src/components/Dendrogram/helpers/prepareDendrogramData.test.ts +135 -0
  105. package/src/components/Dendrogram/helpers/prepareDendrogramData.ts +166 -0
  106. package/src/components/Dendrogram/helpers/resolveDendrogramLabels.test.ts +196 -0
  107. package/src/components/Dendrogram/helpers/resolveDendrogramLabels.ts +418 -0
  108. package/src/components/Dendrogram/index.tsx +1 -0
  109. package/src/components/Dendrogram/types/index.ts +66 -0
  110. package/src/components/EditorPanel/EditorPanel.tsx +1005 -760
  111. package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +50 -5
  112. package/src/components/EditorPanel/components/Panels/Panel.Dendrogram.css +10 -0
  113. package/src/components/EditorPanel/components/Panels/Panel.Dendrogram.tsx +211 -0
  114. package/src/components/EditorPanel/components/Panels/Panel.ForestPlotSettings.tsx +19 -8
  115. package/src/components/EditorPanel/components/Panels/Panel.General.tsx +88 -54
  116. package/src/components/EditorPanel/components/Panels/Panel.Network.css +9 -0
  117. package/src/components/EditorPanel/components/Panels/Panel.Network.tsx +202 -0
  118. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +233 -33
  119. package/src/components/EditorPanel/components/Panels/Panel.Radar.tsx +6 -1
  120. package/src/components/EditorPanel/components/Panels/Panel.Sankey.tsx +89 -105
  121. package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +2 -2
  122. package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.tsx +1 -1
  123. package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +8 -66
  124. package/src/components/EditorPanel/components/Panels/RacingControls.test.tsx +131 -0
  125. package/src/components/EditorPanel/components/Panels/RacingControls.tsx +185 -0
  126. package/src/components/EditorPanel/components/Panels/SeriesColorAssignments.tsx +2 -2
  127. package/src/components/EditorPanel/components/Panels/index.tsx +4 -0
  128. package/src/components/EditorPanel/helpers/getVisualizationTypeConfigUpdate.ts +20 -7
  129. package/src/components/EditorPanel/helpers/tests/getVisualizationTypeConfigUpdate.test.ts +25 -2
  130. package/src/components/EditorPanel/helpers/updateFieldRankByValue.ts +2 -1
  131. package/src/components/EditorPanel/useEditorPermissions.test.tsx +62 -0
  132. package/src/components/EditorPanel/useEditorPermissions.ts +70 -24
  133. package/src/components/Forecasting/Forecasting.tsx +15 -6
  134. package/src/components/HeatMap/components/HeatMap.tsx +511 -178
  135. package/src/components/HeatMap/heatmap.constants.ts +7 -3
  136. package/src/components/HeatMap/heatmap.css +101 -2
  137. package/src/components/HeatMap/helpers/layout.ts +80 -0
  138. package/src/components/HeatMap/tests/HeatMap.test.tsx +339 -16
  139. package/src/components/HeatMap/tests/helpers.test.ts +79 -0
  140. package/src/components/Legend/Legend.Component.tsx +110 -90
  141. package/src/components/Legend/LegendGroup/LegendGroup.styles.css +7 -0
  142. package/src/components/Legend/LegendGroup/LegendGroup.tsx +47 -34
  143. package/src/components/Legend/helpers/createFormatLabels.test.ts +226 -0
  144. package/src/components/Legend/helpers/createFormatLabels.tsx +45 -51
  145. package/src/components/LineChart/components/LineChart.Circle.tsx +3 -3
  146. package/src/components/LineChart/helpers.test.ts +126 -38
  147. package/src/components/LineChart/helpers.ts +9 -4
  148. package/src/components/LineChart/index.tsx +69 -24
  149. package/src/components/LineChart/tests/LineChart.test.tsx +630 -0
  150. package/src/components/LineChartRace/LineChartRace.test.tsx +275 -0
  151. package/src/components/LineChartRace/LineChartRace.tsx +134 -0
  152. package/src/components/LineChartRace/LineChartRaceFallback.tsx +28 -0
  153. package/src/components/LineChartRace/helpers.test.ts +88 -0
  154. package/src/components/LineChartRace/helpers.ts +157 -0
  155. package/src/components/LineChartRace/index.ts +3 -0
  156. package/src/components/LineChartRace/line-chart-race.css +16 -0
  157. package/src/components/LinearChart/ValueAxisAnchors.tsx +58 -0
  158. package/src/components/LinearChart/VisualizationRenderer.tsx +18 -23
  159. package/src/components/LinearChart/tests/LinearChart.test.tsx +412 -13
  160. package/src/components/LinearChart/tests/VisualizationRenderer.test.tsx +141 -0
  161. package/src/components/LinearChart/tests/mockConfigContext.ts +2 -1
  162. package/src/components/LinearChart.tsx +81 -51
  163. package/src/components/Network/components/Network.tsx +372 -0
  164. package/src/components/Network/helpers/layoutNetwork.test.ts +68 -0
  165. package/src/components/Network/helpers/layoutNetwork.ts +87 -0
  166. package/src/components/Network/helpers/prepareNetworkData.test.ts +116 -0
  167. package/src/components/Network/helpers/prepareNetworkData.ts +101 -0
  168. package/src/components/Network/helpers/resolveNetworkLabels.test.ts +112 -0
  169. package/src/components/Network/helpers/resolveNetworkLabels.ts +206 -0
  170. package/src/components/Network/index.tsx +3 -0
  171. package/src/components/Network/network.css +107 -0
  172. package/src/components/Network/types/index.ts +56 -0
  173. package/src/components/PairedBarChart.jsx +5 -10
  174. package/src/components/PairedBarChart.test.tsx +71 -0
  175. package/src/components/PieChart/PieChart.tsx +9 -8
  176. package/src/components/RacingChartRenderer.tsx +44 -0
  177. package/src/components/Regions/components/Regions.test.tsx +179 -0
  178. package/src/components/Regions/components/Regions.tsx +104 -69
  179. package/src/components/Regions/index.tsx +2 -3
  180. package/src/components/Sankey/components/Sankey.tsx +264 -437
  181. package/src/components/Sankey/helpers/getSankeyTooltip.ts +12 -0
  182. package/src/components/Sankey/helpers/prepareSankeyData.test.ts +71 -0
  183. package/src/components/Sankey/helpers/prepareSankeyData.ts +90 -0
  184. package/src/components/Sankey/sankey.scss +57 -116
  185. package/src/components/Sankey/types/index.ts +41 -4
  186. package/src/components/ScatterPlot/ScatterPlot.jsx +28 -4
  187. package/src/components/ScatterPlot/ScatterPlot.test.tsx +60 -0
  188. package/src/components/SmallMultiples/SmallMultiples.tsx +12 -12
  189. package/src/components/SmallMultiples/tests/SmallMultipleTile.test.tsx +7 -5
  190. package/src/components/raceTiming.ts +18 -0
  191. package/src/data/initial-state.js +77 -31
  192. package/src/helpers/applyChartTypeContract.ts +71 -0
  193. package/src/helpers/axisLabelFontSize.ts +2 -2
  194. package/src/helpers/boxPlotStats.ts +60 -0
  195. package/src/helpers/bumpChartTooltip.ts +2 -1
  196. package/src/helpers/calculateHorizontalBarCategoryLabelWidth.test.ts +37 -0
  197. package/src/helpers/calculateHorizontalBarCategoryLabelWidth.ts +3 -2
  198. package/src/helpers/calculateRightYAxisWidth.ts +60 -0
  199. package/src/helpers/colorAssignmentHelpers.ts +2 -2
  200. package/src/helpers/createNewChartConfig.ts +56 -0
  201. package/src/helpers/dataDrivenYAxisCategories.ts +18 -0
  202. package/src/helpers/forecastLegend.ts +88 -0
  203. package/src/helpers/getBoxPlotConfig.ts +18 -37
  204. package/src/helpers/getChartTypeDefaultPalette.ts +29 -0
  205. package/src/helpers/getColorCodeCategoryColorMap.ts +26 -0
  206. package/src/helpers/getColorScale.ts +31 -22
  207. package/src/helpers/getDynamicYAxisCategories.ts +69 -0
  208. package/src/helpers/getMinMax.ts +9 -7
  209. package/src/helpers/getNewRuntime.ts +5 -2
  210. package/src/helpers/getStackedSeriesMax.ts +51 -0
  211. package/src/helpers/getV21ChartDistributionColors.ts +57 -0
  212. package/src/helpers/handleRankByValue.ts +15 -15
  213. package/src/helpers/seriesColumnSettings.ts +10 -0
  214. package/src/helpers/sizeHelpers.ts +4 -3
  215. package/src/helpers/smallMultiplesHelpers.ts +10 -13
  216. package/src/helpers/tests/applyChartTypeContract.test.ts +37 -0
  217. package/src/helpers/tests/boxPlotStats.test.ts +31 -0
  218. package/src/helpers/tests/bumpChartTooltip.test.ts +29 -0
  219. package/src/helpers/tests/calculateRightYAxisWidth.test.ts +105 -0
  220. package/src/helpers/tests/countNumOfTicks.test.ts +11 -0
  221. package/src/helpers/tests/createNewChartConfig.test.ts +182 -0
  222. package/src/helpers/tests/getBoxPlotConfig.test.ts +70 -0
  223. package/src/helpers/tests/getColorCodeCategoryColorMap.test.ts +113 -0
  224. package/src/helpers/tests/getColorScale.test.ts +226 -1
  225. package/src/helpers/tests/getDynamicYAxisCategories.test.ts +61 -0
  226. package/src/helpers/tests/getMinMax.test.ts +41 -0
  227. package/src/helpers/tests/getStackedSeriesMax.test.ts +26 -0
  228. package/src/helpers/tests/handleRankByValue.test.ts +55 -37
  229. package/src/helpers/tests/seriesColumnSettings.test.ts +23 -0
  230. package/src/helpers/tests/smallMultiplesHelpers.test.ts +37 -1
  231. package/src/helpers/tests/tooltipHelpers.test.ts +80 -0
  232. package/src/helpers/tooltipHelpers.ts +16 -0
  233. package/src/hooks/tests/useTooltip.test.tsx +353 -1
  234. package/src/hooks/usePrefersReducedMotion.ts +22 -0
  235. package/src/hooks/useReduceData.ts +4 -36
  236. package/src/hooks/useScales.ts +15 -0
  237. package/src/hooks/useTooltip.tsx +95 -64
  238. package/src/scss/main.scss +74 -2
  239. package/src/store/chart.reducer.ts +4 -3
  240. package/src/test/CdcChart.dataTable.test.tsx +490 -16
  241. package/src/test/CdcChart.dendrogram.test.tsx +277 -0
  242. package/src/test/CdcChart.finalization.test.tsx +143 -0
  243. package/src/test/CdcChart.network.test.tsx +271 -0
  244. package/src/types/ChartConfig.ts +91 -54
  245. package/src/types/ChartContext.ts +5 -0
  246. package/src/types/Horizon.ts +1 -1
  247. package/LICENSE +0 -201
  248. package/dist/cdcchart-BWVIUcAF.es.js +0 -4802
  249. package/src/_stories/Chart.Sankey.smoke.stories.tsx +0 -62
  250. package/src/components/Sankey/components/ColumnList.tsx +0 -19
  251. package/src/components/Sankey/helpers/getSankeyTooltip.tsx +0 -34
  252. package/src/components/Sankey/useSankeyAlert.tsx +0 -60
  253. package/src/data/legacy-defaults.ts +0 -18
  254. package/src/helpers/ensureSpecialChartAxisTypes.ts +0 -11
  255. package/src/helpers/tests/ensureSpecialChartAxisTypes.test.ts +0 -30
@@ -0,0 +1,275 @@
1
+ import React, { useContext } from 'react'
2
+ import { act, fireEvent, render, screen } from '@testing-library/react'
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
+ import ConfigContext from '../../ConfigContext'
5
+ import { createMockChartContext, createMockConfig } from '../LinearChart/tests/mockConfigContext'
6
+ import LineChartRace from './LineChartRace'
7
+ import { getLineRaceEligibility } from './helpers'
8
+
9
+ vi.mock('../LinearChart', () => ({
10
+ default: React.forwardRef((_props: any, _ref) => {
11
+ const context = useContext(ConfigContext) as any
12
+ return (
13
+ <>
14
+ <svg
15
+ data-testid='line-chart'
16
+ data-tooltip-rows={context.tableData?.length}
17
+ data-rendered-rows={context.transformedData?.length}
18
+ data-progress={context.lineRaceProgress}
19
+ />
20
+ <button
21
+ type='button'
22
+ onClick={() =>
23
+ context.updateConfig(
24
+ {
25
+ ...context.config,
26
+ annotations: [{ ...context.config.annotations[0], dx: 24, dy: 36 }]
27
+ },
28
+ annotationDataOverride
29
+ )
30
+ }
31
+ >
32
+ Persist annotation
33
+ </button>
34
+ </>
35
+ )
36
+ })
37
+ }))
38
+
39
+ const data = [
40
+ { Year: '2020', North: 10, South: 20 },
41
+ { Year: '2021', North: 30, South: 15 },
42
+ { Year: '2022', North: 25, South: 40 }
43
+ ]
44
+ const annotationDataOverride = [{ Year: '2020', North: 12, South: 20 }]
45
+
46
+ const getContext = (secondsPerFrame = 0.5) => {
47
+ const base = createMockConfig()
48
+ const config = createMockConfig({
49
+ visualizationType: 'Line',
50
+ visualizationSubType: 'racing',
51
+ xAxis: { ...base.xAxis, type: 'categorical', dataKey: 'Year', label: 'Year' },
52
+ series: [
53
+ { dataKey: 'North', type: 'Line', axis: 'left' },
54
+ { dataKey: 'South', type: 'Line', axis: 'left' }
55
+ ] as any,
56
+ lineRace: { secondsPerFrame }
57
+ })
58
+ return createMockChartContext(config, { transformedData: data, tableData: data })
59
+ }
60
+
61
+ const installAnimationFrameController = () => {
62
+ let currentTime = 0
63
+ let nextId = 1
64
+ const callbacks = new Map<number, FrameRequestCallback>()
65
+ vi.spyOn(performance, 'now').mockImplementation(() => currentTime)
66
+ const requestAnimationFrame = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
67
+ const id = nextId++
68
+ callbacks.set(id, callback)
69
+ return id
70
+ })
71
+ const cancelAnimationFrame = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => {
72
+ callbacks.delete(id)
73
+ })
74
+
75
+ return {
76
+ requestAnimationFrame,
77
+ cancelAnimationFrame,
78
+ setTime: (time: number) => {
79
+ currentTime = time
80
+ },
81
+ takeNextCallback: () => {
82
+ const next = callbacks.entries().next().value as [number, FrameRequestCallback] | undefined
83
+ if (!next) throw new Error('Expected a queued animation frame')
84
+ callbacks.delete(next[0])
85
+ return next[1]
86
+ },
87
+ pendingCount: () => callbacks.size
88
+ }
89
+ }
90
+
91
+ const renderRace = (context = getContext()) => {
92
+ const race = getLineRaceEligibility(context.config, data)
93
+ return render(
94
+ <ConfigContext.Provider value={context}>
95
+ <LineChartRace parentWidth={700} parentHeight={400} race={race} />
96
+ </ConfigContext.Provider>
97
+ )
98
+ }
99
+
100
+ const getProgress = () => {
101
+ const progress = screen.getByTestId('line-chart').getAttribute('data-progress')
102
+ if (progress === null) throw new Error('Expected the Line race progress attribute')
103
+ return Number(progress)
104
+ }
105
+
106
+ describe('LineChartRace', () => {
107
+ beforeEach(() => {
108
+ vi.stubGlobal('matchMedia', () => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() }))
109
+ })
110
+ afterEach(() => {
111
+ vi.restoreAllMocks()
112
+ vi.unstubAllGlobals()
113
+ })
114
+
115
+ it('plays, pauses, resumes from paused progress, completes, and replays', () => {
116
+ const animationFrames = installAnimationFrameController()
117
+ const context = getContext()
118
+ const { container } = renderRace(context)
119
+
120
+ expect(screen.getByTestId('line-chart')).toHaveAttribute('data-tooltip-rows', '1')
121
+ expect(screen.getByTestId('line-chart')).toHaveAttribute('data-rendered-rows', '3')
122
+ expect(screen.getByTestId('line-chart')).toHaveAttribute('data-progress', '0')
123
+ const playButton = screen.getByRole('button', { name: 'Play' })
124
+ const frame = container.querySelector('.line-chart-race__frame')
125
+ expect(frame).not.toBeNull()
126
+ expect(container.querySelector('.line-chart-race__header')?.firstElementChild).toBe(playButton)
127
+ expect(playButton.nextElementSibling).toBe(frame)
128
+ expect(playButton.querySelector('[data-icon="play"]')).toBeInTheDocument()
129
+ fireEvent.click(playButton)
130
+ expect(screen.getByRole('button', { name: 'Pause' }).querySelector('[data-icon="pause"]')).toBeInTheDocument()
131
+
132
+ const firstFrame = animationFrames.takeNextCallback()
133
+ animationFrames.setTime(600)
134
+ act(() => firstFrame(600))
135
+ expect(screen.getByTestId('line-chart')).toHaveAttribute('data-tooltip-rows', '2')
136
+ expect(getProgress()).toBeCloseTo(0.6)
137
+
138
+ fireEvent.click(screen.getByRole('button', { name: 'Pause' }))
139
+ const pausedProgress = getProgress()
140
+ const pausedFrame = frame?.textContent
141
+ expect(screen.getByRole('button', { name: 'Play' })).toBeInTheDocument()
142
+
143
+ animationFrames.setTime(900)
144
+ expect(getProgress()).toBe(pausedProgress)
145
+ expect(frame).toHaveTextContent(pausedFrame || '')
146
+
147
+ fireEvent.click(screen.getByRole('button', { name: 'Play' }))
148
+ const resumedFrame = animationFrames.takeNextCallback()
149
+ animationFrames.setTime(1100)
150
+ act(() => resumedFrame(1100))
151
+ expect(getProgress()).toBeCloseTo(0.8)
152
+ expect(getProgress()).toBeGreaterThan(pausedProgress)
153
+
154
+ const finishingFrame = animationFrames.takeNextCallback()
155
+ animationFrames.setTime(1300)
156
+ act(() => finishingFrame(1300))
157
+ expect(screen.getByRole('button', { name: 'Replay' }).querySelector('[data-icon="replay"]')).toBeInTheDocument()
158
+ expect(getProgress()).toBe(1)
159
+ expect(animationFrames.pendingCount()).toBe(0)
160
+
161
+ fireEvent.click(screen.getByRole('button', { name: 'Replay' }))
162
+ expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument()
163
+ expect(frame).toHaveTextContent('2020')
164
+ expect(getProgress()).toBe(0)
165
+
166
+ const replayFrame = animationFrames.takeNextCallback()
167
+ animationFrames.setTime(1400)
168
+ act(() => replayFrame(1400))
169
+ expect(getProgress()).toBeCloseTo(0.1)
170
+ expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument()
171
+ })
172
+
173
+ it('invalidates an already-dispatched animation frame when paused', () => {
174
+ const animationFrames = installAnimationFrameController()
175
+ const { container } = renderRace()
176
+
177
+ fireEvent.click(screen.getByRole('button', { name: 'Play' }))
178
+ const firstFrame = animationFrames.takeNextCallback()
179
+ animationFrames.setTime(600)
180
+ act(() => firstFrame(600))
181
+
182
+ const dispatchedStaleFrame = animationFrames.takeNextCallback()
183
+ fireEvent.click(screen.getByRole('button', { name: 'Pause' }))
184
+ const pausedProgress = getProgress()
185
+ const pausedFrame = container.querySelector('.line-chart-race__frame')?.textContent
186
+ const requestCountAtPause = animationFrames.requestAnimationFrame.mock.calls.length
187
+
188
+ animationFrames.setTime(900)
189
+ act(() => dispatchedStaleFrame(900))
190
+
191
+ expect(getProgress()).toBe(pausedProgress)
192
+ expect(container.querySelector('.line-chart-race__frame')).toHaveTextContent(pausedFrame || '')
193
+ expect(animationFrames.requestAnimationFrame).toHaveBeenCalledTimes(requestCountAtPause)
194
+ expect(animationFrames.cancelAnimationFrame).toHaveBeenCalled()
195
+ })
196
+
197
+ it('uses continuous playback when reduced motion is preferred', () => {
198
+ vi.stubGlobal('matchMedia', () => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() }))
199
+ const animationFrames = installAnimationFrameController()
200
+ renderRace()
201
+
202
+ fireEvent.click(screen.getByRole('button', { name: 'Play' }))
203
+ expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument()
204
+ expect(getProgress()).toBe(0)
205
+
206
+ const firstFrame = animationFrames.takeNextCallback()
207
+ animationFrames.setTime(250)
208
+ act(() => firstFrame(250))
209
+
210
+ expect(getProgress()).toBeCloseTo(0.25)
211
+ expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument()
212
+ })
213
+
214
+ it('completes immediately without scheduling animation frames when timing is zero', () => {
215
+ const animationFrames = installAnimationFrameController()
216
+ renderRace(getContext(0))
217
+
218
+ fireEvent.click(screen.getByRole('button', { name: 'Play' }))
219
+
220
+ expect(screen.getByRole('button', { name: 'Replay' })).toBeInTheDocument()
221
+ expect(getProgress()).toBe(1)
222
+ expect(animationFrames.requestAnimationFrame).not.toHaveBeenCalled()
223
+ })
224
+
225
+ it('preserves authored race fields when the inner chart persists an annotation update', () => {
226
+ const baseContext = getContext()
227
+ const updateConfig = vi.fn()
228
+ const annotation = { text: 'Original annotation', dx: 0, dy: 0 }
229
+ const initialConfig = {
230
+ ...baseContext.config,
231
+ visualizationSubType: 'racing',
232
+ allowLineToBarGraph: undefined,
233
+ animate: true,
234
+ annotations: [annotation]
235
+ } as any
236
+
237
+ const StatefulRaceHarness = () => {
238
+ const [config, setConfig] = React.useState(initialConfig)
239
+ const race = getLineRaceEligibility(config, data)
240
+ const context = {
241
+ ...baseContext,
242
+ config,
243
+ updateConfig: (nextConfig: typeof initialConfig, dataOverride?: any[]) => {
244
+ updateConfig(nextConfig, dataOverride)
245
+ setConfig(nextConfig)
246
+ }
247
+ } as any
248
+
249
+ return (
250
+ <ConfigContext.Provider value={context}>
251
+ {config.visualizationSubType === 'racing' && race.eligible ? (
252
+ <LineChartRace parentWidth={700} parentHeight={400} race={race} />
253
+ ) : null}
254
+ </ConfigContext.Provider>
255
+ )
256
+ }
257
+
258
+ render(<StatefulRaceHarness />)
259
+
260
+ fireEvent.click(screen.getByRole('button', { name: 'Persist annotation' }))
261
+
262
+ expect(updateConfig).toHaveBeenCalledTimes(1)
263
+ const [updatedConfig, dataOverride] = updateConfig.mock.calls[0]
264
+ expect(updatedConfig).toEqual(
265
+ expect.objectContaining({
266
+ visualizationSubType: 'racing',
267
+ allowLineToBarGraph: undefined,
268
+ animate: true,
269
+ annotations: [{ ...annotation, dx: 24, dy: 36 }]
270
+ })
271
+ )
272
+ expect(dataOverride).toBe(annotationDataOverride)
273
+ expect(screen.getByRole('button', { name: 'Play' })).toBeInTheDocument()
274
+ })
275
+ })
@@ -0,0 +1,134 @@
1
+ import { forwardRef, useContext, useEffect, useRef, useState } from 'react'
2
+ import PlaybackButton from '@cdc/core/components/PlaybackButton'
3
+ import ConfigContext from '../../ConfigContext'
4
+ import LinearChart from '../LinearChart'
5
+ import { clampRaceSecondsPerFrame } from '../raceTiming'
6
+ import { type LineRaceEligibility } from './helpers'
7
+ import './line-chart-race.css'
8
+
9
+ type Props = { parentHeight: number; parentWidth: number; race: LineRaceEligibility }
10
+
11
+ const LineChartRace = forwardRef<SVGAElement, Props>(({ parentHeight, parentWidth, race }, ref) => {
12
+ const context = useContext(ConfigContext)
13
+ const chartParentRef = useRef<HTMLDivElement>(null)
14
+ const [progress, setProgress] = useState(0)
15
+ const [isPlaying, setIsPlaying] = useState(false)
16
+ const [hasStarted, setHasStarted] = useState(false)
17
+ const secondsPerFrame = clampRaceSecondsPerFrame(context.config.lineRace?.secondsPerFrame)
18
+ const totalSeconds = (race.frames.length - 1) * secondsPerFrame
19
+ const signature = JSON.stringify(
20
+ race.frames.map(frame => [frame.key, frame.currentRows.map(row => Object.values(row))])
21
+ )
22
+
23
+ useEffect(() => {
24
+ setProgress(0)
25
+ setIsPlaying(false)
26
+ setHasStarted(false)
27
+ context.setRaceTiming?.(null)
28
+ }, [signature])
29
+
30
+ useEffect(() => {
31
+ if (!isPlaying) return
32
+ if (totalSeconds === 0) {
33
+ setProgress(1)
34
+ setIsPlaying(false)
35
+ return
36
+ }
37
+ const totalDuration = totalSeconds * 1000
38
+ const startedAt = performance.now()
39
+ const startingProgress = progress
40
+ let animationFrame = 0
41
+ let cancelled = false
42
+ const animate = (now: number) => {
43
+ if (cancelled) return
44
+
45
+ const nextProgress = Math.min(1, startingProgress + (now - startedAt) / totalDuration)
46
+ setProgress(nextProgress)
47
+ if (nextProgress < 1 && !cancelled) {
48
+ animationFrame = window.requestAnimationFrame(animate)
49
+ } else if (!cancelled) {
50
+ setIsPlaying(false)
51
+ }
52
+ }
53
+ animationFrame = window.requestAnimationFrame(animate)
54
+ return () => {
55
+ cancelled = true
56
+ window.cancelAnimationFrame(animationFrame)
57
+ }
58
+ }, [isPlaying, race.frames.length, totalSeconds])
59
+
60
+ const frameIndex = Math.min(race.frames.length - 1, Math.floor(progress * (race.frames.length - 1) + 1e-6))
61
+ const frame = race.frames[frameIndex]
62
+ const isAtEnd = progress >= 1
63
+ const visibleRows = race.frames.slice(0, frameIndex + 1).flatMap(raceFrame => raceFrame.currentRows)
64
+ const frameKey = frame?.key || ''
65
+
66
+ useEffect(() => {
67
+ if (!hasStarted || !frameKey) return
68
+ context.setRaceTiming?.({
69
+ elapsedSeconds: progress * totalSeconds,
70
+ frameKey,
71
+ isPlaying,
72
+ totalSeconds
73
+ })
74
+ }, [frameKey, hasStarted, isPlaying, totalSeconds])
75
+
76
+ if (!race.eligible || !frame) return null
77
+
78
+ const updateConfig = (nextConfig: typeof context.config, dataOverride?: any[]) => {
79
+ context.updateConfig?.(
80
+ {
81
+ ...nextConfig,
82
+ visualizationSubType: context.config.visualizationSubType,
83
+ allowLineToBarGraph: context.config.allowLineToBarGraph,
84
+ animate: context.config.animate
85
+ },
86
+ dataOverride
87
+ )
88
+ }
89
+
90
+ const raceContext = {
91
+ ...context,
92
+ convertLineToBarGraph: false,
93
+ config: {
94
+ ...context.config,
95
+ visualizationSubType: 'regular',
96
+ allowLineToBarGraph: false,
97
+ animate: false
98
+ },
99
+ updateConfig,
100
+ lineRaceProgress: progress,
101
+ parentRef: chartParentRef,
102
+ tableData: visibleRows,
103
+ yAxisDomainData: context.transformedData
104
+ }
105
+
106
+ const handlePlayback = () => {
107
+ setHasStarted(true)
108
+ if (isAtEnd) {
109
+ setProgress(0)
110
+ setIsPlaying(true)
111
+ return
112
+ }
113
+ setIsPlaying(current => !current)
114
+ }
115
+
116
+ return (
117
+ <section className='line-chart-race' aria-label={`Line chart race through ${frame.key}`}>
118
+ <div className='line-chart-race__header'>
119
+ <PlaybackButton isAtEnd={isAtEnd} isPlaying={isPlaying} onClick={handlePlayback} />
120
+ <strong className='line-chart-race__frame' aria-live='polite'>
121
+ {frame.key}
122
+ </strong>
123
+ </div>
124
+ <ConfigContext.Provider value={raceContext}>
125
+ <div ref={chartParentRef}>
126
+ <LinearChart ref={ref} parentWidth={parentWidth} parentHeight={parentHeight} />
127
+ </div>
128
+ </ConfigContext.Provider>
129
+ </section>
130
+ )
131
+ })
132
+
133
+ LineChartRace.displayName = 'LineChartRace'
134
+ export default LineChartRace
@@ -0,0 +1,28 @@
1
+ import { forwardRef, useContext } from 'react'
2
+ import ConfigContext from '../../ConfigContext'
3
+ import LinearChart from '../LinearChart'
4
+
5
+ type Props = { parentHeight: number; parentWidth: number }
6
+
7
+ const LineChartRaceFallback = forwardRef<SVGAElement, Props>(({ parentHeight, parentWidth }, ref) => {
8
+ const context = useContext(ConfigContext)
9
+ return (
10
+ <ConfigContext.Provider
11
+ value={{
12
+ ...context,
13
+ convertLineToBarGraph: false,
14
+ config: {
15
+ ...context.config,
16
+ visualizationSubType: 'regular',
17
+ allowLineToBarGraph: false,
18
+ animate: false
19
+ }
20
+ }}
21
+ >
22
+ <LinearChart ref={ref} parentWidth={parentWidth} parentHeight={parentHeight} />
23
+ </ConfigContext.Provider>
24
+ )
25
+ })
26
+
27
+ LineChartRaceFallback.displayName = 'LineChartRaceFallback'
28
+ export default LineChartRaceFallback
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { clampRaceSecondsPerFrame } from '../raceTiming'
3
+ import { buildLineRaceFrames, getLineRaceEligibility } from './helpers'
4
+
5
+ const config = {
6
+ visualizationType: 'Line',
7
+ visualizationSubType: 'racing',
8
+ xAxis: { type: 'categorical', dataKey: 'Year' },
9
+ series: [{ dataKey: 'North' }, { dataKey: 'South' }]
10
+ } as any
11
+
12
+ const data = [
13
+ { Year: '2020', North: 10, South: 20 },
14
+ { Year: '2021', North: 30, South: 15 },
15
+ { Year: '2022', North: 25, South: 40 }
16
+ ]
17
+
18
+ describe('line chart race helpers', () => {
19
+ it('clamps authored timing to the supported range', () => {
20
+ expect(clampRaceSecondsPerFrame(undefined)).toBe(0.5)
21
+ expect(clampRaceSecondsPerFrame(null)).toBe(0.5)
22
+ expect(clampRaceSecondsPerFrame('')).toBe(0.5)
23
+ expect(clampRaceSecondsPerFrame(-1)).toBe(0)
24
+ expect(clampRaceSecondsPerFrame(0.25)).toBe(0.5)
25
+ expect(clampRaceSecondsPerFrame('1.2')).toBe(1)
26
+ expect(clampRaceSecondsPerFrame('1.3')).toBe(1.5)
27
+ expect(clampRaceSecondsPerFrame(100)).toBe(1.5)
28
+ })
29
+
30
+ it('builds cumulative wide-data frames in visible axis order', () => {
31
+ const frames = buildLineRaceFrames(config, data)
32
+ expect(frames.map(frame => frame.key)).toEqual(['2020', '2021', '2022'])
33
+ expect(frames.map(frame => frame.rows.length)).toEqual([1, 2, 3])
34
+ expect(frames.map(frame => frame.currentRows.length)).toEqual([1, 1, 1])
35
+ expect(getLineRaceEligibility(config, data).eligible).toBe(true)
36
+ })
37
+
38
+ it('accepts one dynamic-category series and accumulates each frame together', () => {
39
+ const dynamicConfig = { ...config, series: [{ dataKey: 'Value', dynamicCategory: 'Region' }] }
40
+ const dynamicData = [
41
+ { Year: '2020', Region: 'North', Value: 10 },
42
+ { Year: '2020', Region: 'South', Value: 20 },
43
+ { Year: '2021', Region: 'North', Value: 30 },
44
+ { Year: '2021', Region: 'South', Value: 15 }
45
+ ]
46
+ const result = getLineRaceEligibility(dynamicConfig, dynamicData)
47
+ expect(result.eligible).toBe(true)
48
+ expect(result.frames.map(frame => frame.rows.length)).toEqual([2, 4])
49
+ })
50
+
51
+ it('applies custom categorical order and chronological or recent-first date order', () => {
52
+ const customConfig = {
53
+ ...config,
54
+ xAxis: { ...config.xAxis, categoryOrderType: 'custom', categoryOrder: ['2022', '2020', '2021'] }
55
+ }
56
+ expect(buildLineRaceFrames(customConfig, data).map(frame => frame.key)).toEqual(['2022', '2020', '2021'])
57
+
58
+ const recentFirst = [
59
+ { Date: '2022-01-01', North: 25, South: 40 },
60
+ { Date: '2020-01-01', North: 10, South: 20 },
61
+ { Date: '2021-01-01', North: 30, South: 15 }
62
+ ]
63
+ const dateConfig = { ...config, xAxis: { type: 'date', dataKey: 'Date', sortByRecentDate: true } }
64
+ expect(buildLineRaceFrames(dateConfig, recentFirst).map(frame => frame.key)).toEqual([
65
+ '2022-01-01',
66
+ '2021-01-01',
67
+ '2020-01-01'
68
+ ])
69
+ expect(
70
+ buildLineRaceFrames({ ...dateConfig, xAxis: { ...dateConfig.xAxis, sortByRecentDate: false } }, recentFirst).map(
71
+ frame => frame.key
72
+ )
73
+ ).toEqual(['2020-01-01', '2021-01-01', '2022-01-01'])
74
+ })
75
+
76
+ it('rejects invalid dates, duplicates, brushes, and insufficient frames', () => {
77
+ const dateConfig = { ...config, xAxis: { type: 'date', dataKey: 'Year' } }
78
+ expect(getLineRaceEligibility(dateConfig, [{ ...data[0], Year: 'bad-date' }, data[1]]).reason).toContain(
79
+ 'valid date'
80
+ )
81
+ expect(getLineRaceEligibility(config, [...data, data[0]]).reason).toContain('one row per frame')
82
+ expect(getLineRaceEligibility({ ...config, xAxis: { ...config.xAxis, brushActive: true } }, data).reason).toContain(
83
+ 'date brush'
84
+ )
85
+ expect(getLineRaceEligibility(config, data.slice(0, 1)).reason).toContain('two valid frame')
86
+ expect(getLineRaceEligibility(config, [{ ...data[0], North: -1 }, data[1]]).reason).toContain('negative values')
87
+ })
88
+ })
@@ -0,0 +1,157 @@
1
+ import { type ChartConfig } from '../../types/ChartConfig'
2
+
3
+ type DataRow = Record<string, any>
4
+
5
+ export type LineRaceFrame = {
6
+ key: string
7
+ rows: DataRow[]
8
+ currentRows: DataRow[]
9
+ }
10
+
11
+ export type LineRaceEligibility = {
12
+ eligible: boolean
13
+ frames: LineRaceFrame[]
14
+ reason?: string
15
+ }
16
+
17
+ const hasFiniteValue = (value: unknown) => {
18
+ if (value === null || value === undefined || value === '') return false
19
+ return Number.isFinite(Number(String(value).replaceAll(',', '').replace(/%$/, '')))
20
+ }
21
+
22
+ const isNegativeValue = (value: unknown) => {
23
+ if (value === null || value === undefined || value === '') return false
24
+ const numericValue = Number(String(value).replaceAll(',', '').replace(/%$/, ''))
25
+ return Number.isFinite(numericValue) && numericValue < 0
26
+ }
27
+
28
+ export const buildLineRaceFrames = (config: Partial<ChartConfig>, data: DataRow[] = []): LineRaceFrame[] => {
29
+ const frameKey = config.xAxis?.dataKey
30
+ const series = Array.isArray(config.series) ? config.series : []
31
+ const dynamicSeries = series.find(item => item.dynamicCategory)
32
+ if (!frameKey || !series.length || !Array.isArray(data)) return []
33
+
34
+ const orderedKeys: string[] = []
35
+ const rowsByFrame = new Map<string, DataRow[]>()
36
+ data.forEach(row => {
37
+ const rawFrame = row?.[frameKey]
38
+ if (rawFrame === null || rawFrame === undefined || String(rawFrame).trim() === '') return
39
+ const hasValue = dynamicSeries
40
+ ? hasFiniteValue(row?.[dynamicSeries.dataKey])
41
+ : series.some(item => hasFiniteValue(row?.[item.dataKey]))
42
+ if (!hasValue) return
43
+
44
+ const key = String(rawFrame).trim()
45
+ if (!rowsByFrame.has(key)) {
46
+ orderedKeys.push(key)
47
+ rowsByFrame.set(key, [])
48
+ }
49
+ rowsByFrame.get(key)?.push(row)
50
+ })
51
+
52
+ const frameKeys = [...orderedKeys]
53
+ if (['date', 'date-time'].includes(config.xAxis?.type)) {
54
+ frameKeys.sort((a, b) => Date.parse(a) - Date.parse(b))
55
+ if (config.xAxis?.sortByRecentDate) frameKeys.reverse()
56
+ } else if (config.xAxis?.categoryOrderType === 'custom' && Array.isArray(config.xAxis.categoryOrder)) {
57
+ const customOrder = new Map(config.xAxis.categoryOrder.map((value, index) => [String(value), index]))
58
+ const sourceOrder = new Map(orderedKeys.map((value, index) => [value, index]))
59
+ frameKeys.sort(
60
+ (a, b) =>
61
+ (customOrder.get(a) ?? Number.MAX_SAFE_INTEGER) - (customOrder.get(b) ?? Number.MAX_SAFE_INTEGER) ||
62
+ (sourceOrder.get(a) ?? 0) - (sourceOrder.get(b) ?? 0)
63
+ )
64
+ }
65
+
66
+ let visibleRows: DataRow[] = []
67
+ return frameKeys.map(key => {
68
+ const currentRows = rowsByFrame.get(key) ?? []
69
+ visibleRows = [...visibleRows, ...currentRows]
70
+ return { key, rows: visibleRows, currentRows }
71
+ })
72
+ }
73
+
74
+ export const getLineRaceEligibility = (config: Partial<ChartConfig>, data: DataRow[] = []): LineRaceEligibility => {
75
+ const frames = buildLineRaceFrames(config, data)
76
+ const series = Array.isArray(config.series) ? config.series : []
77
+ const dynamicSeries = series.filter(item => item.dynamicCategory)
78
+ const frameKey = config.xAxis?.dataKey
79
+
80
+ if (config.visualizationType !== 'Line') {
81
+ return { eligible: false, frames, reason: 'Racing mode is available only for Line charts.' }
82
+ }
83
+ if (!['categorical', 'date', 'date-time'].includes(config.xAxis?.type)) {
84
+ return { eligible: false, frames, reason: 'Racing mode requires a categorical, date, or date-time axis.' }
85
+ }
86
+ if (!frameKey || !series.length || series.some(item => !item.dataKey)) {
87
+ return {
88
+ eligible: false,
89
+ frames,
90
+ reason: 'Racing mode requires a Date/Category Axis and at least one data series.'
91
+ }
92
+ }
93
+ if (dynamicSeries.length > 1 || (dynamicSeries.length === 1 && series.length !== 1)) {
94
+ return {
95
+ eligible: false,
96
+ frames,
97
+ reason: 'Dynamic Line racing requires exactly one series with a Dynamic Category column.'
98
+ }
99
+ }
100
+ if (config.smallMultiples?.mode) {
101
+ return { eligible: false, frames, reason: 'Racing mode does not support small multiples.' }
102
+ }
103
+ if (config.xAxis?.brushActive) {
104
+ return { eligible: false, frames, reason: 'Racing mode does not support the date brush.' }
105
+ }
106
+ if (config.allowLineToBarGraph) {
107
+ return { eligible: false, frames, reason: 'Racing mode does not support automatic Line-to-Bar conversion.' }
108
+ }
109
+ if (
110
+ config.showAreaUnderLine ||
111
+ Boolean(config.confidenceKeys?.lower || config.confidenceKeys?.upper) ||
112
+ Boolean(config.preliminaryData?.length)
113
+ ) {
114
+ return {
115
+ eligible: false,
116
+ frames,
117
+ reason: 'Racing mode supports standard Line paths without areas, confidence bands, or preliminary styling.'
118
+ }
119
+ }
120
+ const hasNegative = data.some(row => {
121
+ if (dynamicSeries[0]) return isNegativeValue(row?.[dynamicSeries[0].dataKey])
122
+ return series.some(item => isNegativeValue(row?.[item.dataKey]))
123
+ })
124
+ if (hasNegative) {
125
+ return { eligible: false, frames, reason: 'Racing mode does not support negative values.' }
126
+ }
127
+ if (['date', 'date-time'].includes(config.xAxis?.type)) {
128
+ const malformedFrame = data.some(row => {
129
+ const value = row?.[frameKey]
130
+ return (
131
+ value !== null && value !== undefined && String(value).trim() !== '' && Number.isNaN(Date.parse(String(value)))
132
+ )
133
+ })
134
+ if (malformedFrame) {
135
+ return { eligible: false, frames, reason: 'Racing mode requires valid date values on the Date/Category Axis.' }
136
+ }
137
+ }
138
+
139
+ const seen = new Set<string>()
140
+ let hasDuplicate = false
141
+ data.forEach(row => {
142
+ const frame = String(row?.[frameKey] ?? '').trim()
143
+ if (!frame) return
144
+ const category = dynamicSeries[0] ? String(row?.[dynamicSeries[0].dynamicCategory] ?? '').trim() : '__wide__'
145
+ const key = `${frame}\u0000${category}`
146
+ if (seen.has(key)) hasDuplicate = true
147
+ seen.add(key)
148
+ })
149
+ if (hasDuplicate) {
150
+ return { eligible: false, frames, reason: 'Racing mode requires one row per frame and series combination.' }
151
+ }
152
+ if (frames.length < 2) {
153
+ return { eligible: false, frames, reason: 'Racing mode requires at least two valid frame values.' }
154
+ }
155
+
156
+ return { eligible: true, frames }
157
+ }
@@ -0,0 +1,3 @@
1
+ export { default } from './LineChartRace'
2
+ export { default as LineChartRaceFallback } from './LineChartRaceFallback'
3
+ export * from './helpers'