@cdc/chart 4.25.11 → 4.26.2

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 (181) hide show
  1. package/CLAUDE.local.md +79 -0
  2. package/dist/{cdcchart-dgT_1dIT.es.js → cdcchart-DQ00cQCm.es.js} +1 -20
  3. package/dist/cdcchart.js +51401 -50814
  4. package/examples/default.json +378 -0
  5. package/examples/feature/__data__/horizon-chart-data.json +373 -0
  6. package/examples/feature/annotations/index.json +3 -6
  7. package/examples/feature/horizon/horizon-chart.json +395 -0
  8. package/examples/feature/pie/planet-pie-example-config.json +48 -2
  9. package/examples/line-chart-states.json +1085 -0
  10. package/examples/private/123.json +694 -0
  11. package/examples/private/DEV-12100.json +1303 -0
  12. package/examples/private/anchor-issue.json +4094 -0
  13. package/examples/private/backwards-slider.json +10430 -0
  14. package/examples/private/cat-y.json +1235 -0
  15. package/examples/private/data-points.json +228 -0
  16. package/examples/private/georgia.csv +160 -0
  17. package/examples/private/height.json +3915 -0
  18. package/examples/private/links.json +569 -0
  19. package/examples/private/quadrant.txt +30 -0
  20. package/examples/private/test-forecast.json +5510 -0
  21. package/examples/private/timeline-data.json +1 -0
  22. package/examples/private/timeline.json +389 -0
  23. package/examples/private/warming-stripe-test.json +2578 -0
  24. package/examples/private/warming-stripes.json +4763 -0
  25. package/examples/radar-chart-simple.json +133 -0
  26. package/examples/radar-chart.json +148 -0
  27. package/examples/tech-adoption-with-links.json +560 -0
  28. package/index.html +1 -36
  29. package/package.json +59 -60
  30. package/src/CdcChartComponent.tsx +206 -89
  31. package/src/_stories/Chart.Anchors.stories.tsx +10 -0
  32. package/src/_stories/Chart.BoxPlot.stories.tsx +7 -0
  33. package/src/_stories/Chart.CI.stories.tsx +13 -0
  34. package/src/_stories/Chart.Combo.stories.tsx +17 -0
  35. package/src/_stories/Chart.CustomColors.stories.tsx +4 -0
  36. package/src/_stories/Chart.DynamicSeries.stories.tsx +19 -0
  37. package/src/_stories/Chart.Filters.stories.tsx +4 -0
  38. package/src/_stories/Chart.Forecast.stories.tsx +4 -0
  39. package/src/_stories/Chart.HTMLInDataTable.stories.tsx +22 -0
  40. package/src/_stories/Chart.Legend.Gradient.stories.tsx +28 -0
  41. package/src/_stories/Chart.Patterns.stories.tsx +4 -0
  42. package/src/_stories/Chart.PreserveDecimals.stories.tsx +25 -0
  43. package/src/_stories/Chart.Regions.Categorical.stories.tsx +161 -0
  44. package/src/_stories/Chart.Regions.DateScale.stories.tsx +216 -0
  45. package/src/_stories/Chart.Regions.DateTimeScale.stories.tsx +312 -0
  46. package/src/_stories/Chart.ScatterPlot.stories.tsx +4 -0
  47. package/src/_stories/Chart.SmallMultiples.stories.tsx +16 -0
  48. package/src/_stories/Chart.stories.tsx +45 -0
  49. package/src/_stories/Chart.tooltip.stories.tsx +7 -0
  50. package/src/_stories/ChartAnnotation.stories.tsx +10 -0
  51. package/src/_stories/ChartAxisLabels.stories.tsx +4 -0
  52. package/src/_stories/ChartAxisTitles.stories.tsx +10 -0
  53. package/src/_stories/ChartBar.Editor.stories.tsx +11 -6
  54. package/src/_stories/ChartBrush.Editor.stories.tsx +295 -0
  55. package/src/_stories/ChartBrush.Matrix.Continuous.stories.tsx +41 -0
  56. package/src/_stories/ChartBrush.Matrix.Date.stories.tsx +114 -0
  57. package/src/_stories/ChartBrush.Matrix.DateTime.stories.tsx +78 -0
  58. package/src/_stories/ChartBrush.stories.tsx +57 -0
  59. package/src/_stories/ChartEditor.Editor.stories.tsx +3 -5
  60. package/src/_stories/ChartEditor.stories.tsx +7 -0
  61. package/src/_stories/ChartLine.QuadrantAngles.stories.tsx +89 -0
  62. package/src/_stories/ChartLine.Suppression.stories.tsx +7 -0
  63. package/src/_stories/ChartLine.Symbols.stories.tsx +4 -0
  64. package/src/_stories/ChartPrefixSuffix.stories.tsx +46 -1
  65. package/src/_stories/TechAdoptionWithLinks.stories.tsx +34 -0
  66. package/src/_stories/_mock/brush_continuous.json +86 -0
  67. package/src/_stories/_mock/brush_date_large.json +176 -0
  68. package/src/_stories/_mock/brush_enabled.json +326 -0
  69. package/src/_stories/_mock/brush_mock.json +2 -69
  70. package/src/_stories/_mock/horizontal-bars-dynamic-y-axis.json +413 -0
  71. package/src/_stories/_mock/line_chart_angle_near_zero_fall.json +195 -0
  72. package/src/_stories/_mock/line_chart_angle_near_zero_rise.json +195 -0
  73. package/src/_stories/_mock/line_chart_angle_q1_steep_upward.json +195 -0
  74. package/src/_stories/_mock/line_chart_angle_q2_gentle_downward.json +195 -0
  75. package/src/_stories/_mock/line_chart_angle_q3_steep_downward.json +195 -0
  76. package/src/_stories/_mock/line_chart_angle_q4_gentle_upward.json +195 -0
  77. package/src/_stories/_mock/line_chart_quadrant_angles.json +264 -0
  78. package/src/components/Annotations/components/AnnotationDraggable.styles.css +11 -17
  79. package/src/components/Annotations/components/AnnotationDraggable.tsx +240 -116
  80. package/src/components/Annotations/components/AnnotationDropdown.styles.css +1 -2
  81. package/src/components/Annotations/components/AnnotationDropdown.tsx +8 -12
  82. package/src/components/Annotations/components/AnnotationList.styles.css +4 -10
  83. package/src/components/Annotations/components/AnnotationList.tsx +5 -4
  84. package/src/components/Annotations/components/findNearestDatum.ts +75 -85
  85. package/src/components/Annotations/helpers/getVisibleAnnotations.ts +38 -0
  86. package/src/components/AreaChart/components/AreaChart.Stacked.jsx +1 -2
  87. package/src/components/Axis/BottomAxis.tsx +270 -0
  88. package/src/components/Axis/Categorical.Axis.tsx +6 -7
  89. package/src/components/Axis/LeftAxis.tsx +404 -0
  90. package/src/components/Axis/LeftAxisGridlines.tsx +77 -0
  91. package/src/components/Axis/PairedBarAxis.tsx +186 -0
  92. package/src/components/Axis/README.md +94 -0
  93. package/src/components/Axis/RightAxis.tsx +108 -0
  94. package/src/components/Axis/axis.constants.ts +21 -0
  95. package/src/components/Axis/index.ts +7 -0
  96. package/src/components/BarChart/components/BarChart.Horizontal.tsx +178 -24
  97. package/src/components/BarChart/components/BarChart.StackedHorizontal.tsx +3 -1
  98. package/src/components/BarChart/components/BarChart.StackedVertical.tsx +1 -0
  99. package/src/components/BarChart/components/BarChart.Vertical.tsx +6 -8
  100. package/src/components/BarChart/components/BarChart.tsx +7 -1
  101. package/src/components/BarChart/components/context.tsx +1 -0
  102. package/src/components/BarChart/helpers/useBarChart.ts +14 -2
  103. package/src/components/Brush/BrushSelector.tsx +1390 -0
  104. package/src/components/Brush/MiniChartPreview.tsx +400 -0
  105. package/src/components/DeviationBar.jsx +9 -7
  106. package/src/components/EditorPanel/EditorPanel.tsx +2734 -2595
  107. package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +60 -22
  108. package/src/components/EditorPanel/components/Panels/Panel.ForestPlotSettings.tsx +56 -34
  109. package/src/components/EditorPanel/components/Panels/Panel.General.tsx +137 -30
  110. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +2 -0
  111. package/src/components/EditorPanel/components/Panels/Panel.Radar.tsx +353 -0
  112. package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +0 -1
  113. package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.tsx +30 -25
  114. package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +42 -28
  115. package/src/components/EditorPanel/components/Panels/index.tsx +2 -0
  116. package/src/components/EditorPanel/useEditorPermissions.ts +81 -39
  117. package/src/components/HorizonChart/HorizonChart.tsx +131 -0
  118. package/src/components/HorizonChart/components/HorizonBand.tsx +160 -0
  119. package/src/components/HorizonChart/helpers/calculateHorizonBands.ts +27 -0
  120. package/src/components/HorizonChart/helpers/getHorizonLayerColors.ts +40 -0
  121. package/src/components/HorizonChart/index.tsx +3 -0
  122. package/src/components/Legend/Legend.Component.tsx +52 -4
  123. package/src/components/Legend/Legend.tsx +4 -3
  124. package/src/components/Legend/LegendValueRange.tsx +77 -0
  125. package/src/components/Legend/helpers/createFormatLabels.tsx +164 -2
  126. package/src/components/Legend/helpers/generateValueRanges.ts +92 -0
  127. package/src/components/Legend/helpers/index.ts +10 -6
  128. package/src/components/LineChart/helpers/README.md +292 -0
  129. package/src/components/LineChart/helpers/labelPositioning.test.ts +245 -0
  130. package/src/components/LineChart/helpers/labelPositioning.ts +304 -0
  131. package/src/components/LineChart/index.tsx +44 -8
  132. package/src/components/LinearChart/README.md +109 -0
  133. package/src/components/LinearChart/VisualizationRenderer.tsx +267 -0
  134. package/src/components/LinearChart/linearChart.constants.ts +84 -0
  135. package/src/components/LinearChart/tests/LinearChart.test.tsx +201 -0
  136. package/src/components/LinearChart/tests/mockConfigContext.ts +129 -0
  137. package/src/components/LinearChart/utils/tickFormatting.ts +146 -0
  138. package/src/components/LinearChart.tsx +338 -1082
  139. package/src/components/PairedBarChart.jsx +20 -3
  140. package/src/components/PieChart/PieChart.tsx +1 -1
  141. package/src/components/RadarChart/RadarAxis.tsx +78 -0
  142. package/src/components/RadarChart/RadarChart.tsx +298 -0
  143. package/src/components/RadarChart/RadarGrid.tsx +64 -0
  144. package/src/components/RadarChart/RadarPolygon.tsx +91 -0
  145. package/src/components/RadarChart/helpers.ts +83 -0
  146. package/src/components/RadarChart/index.tsx +3 -0
  147. package/src/components/Regions/components/Regions.tsx +365 -122
  148. package/src/components/ScatterPlot/ScatterPlot.jsx +2 -2
  149. package/src/components/SmallMultiples/SmallMultipleTile.tsx +5 -1
  150. package/src/components/WarmingStripes/WarmingStripes.tsx +230 -0
  151. package/src/components/WarmingStripes/WarmingStripesGradientLegend.css +35 -0
  152. package/src/components/WarmingStripes/WarmingStripesGradientLegend.tsx +104 -0
  153. package/src/components/WarmingStripes/index.tsx +3 -0
  154. package/src/data/initial-state.js +17 -2
  155. package/src/helpers/calculateHorizontalBarCategoryLabelWidth.ts +57 -0
  156. package/src/helpers/getExcludedData.ts +4 -0
  157. package/src/helpers/getMinMax.ts +12 -7
  158. package/src/helpers/handleChartAriaLabels.ts +19 -19
  159. package/src/helpers/handleLineType.ts +22 -18
  160. package/src/helpers/sizeHelpers.ts +0 -20
  161. package/src/helpers/smallMultiplesHelpers.ts +1 -1
  162. package/src/hooks/useChartHoverAnalytics.tsx +10 -9
  163. package/src/hooks/useProgrammaticTooltip.ts +23 -2
  164. package/src/hooks/useScales.ts +18 -1
  165. package/src/hooks/useTooltip.tsx +34 -10
  166. package/src/scss/DataTable.scss +0 -4
  167. package/src/scss/main.scss +22 -3
  168. package/src/selectors/README.md +68 -0
  169. package/src/store/chart.reducer.ts +2 -0
  170. package/src/test/CdcChart.test.jsx +1 -1
  171. package/src/types/ChartConfig.ts +21 -0
  172. package/src/types/ChartContext.ts +1 -0
  173. package/src/types/Horizon.ts +64 -0
  174. package/src/types/Label.ts +1 -0
  175. package/src/utils/analyticsTracking.ts +19 -0
  176. package/LICENSE +0 -201
  177. package/src/components/Annotations/components/helpers/index.tsx +0 -46
  178. package/src/components/Brush/BrushChart.tsx +0 -128
  179. package/src/components/Brush/BrushController.tsx +0 -71
  180. package/src/components/Brush/types.tsx +0 -8
  181. package/src/components/BrushChart.tsx +0 -223
@@ -0,0 +1,1390 @@
1
+ import React, { FC, useContext, useMemo, memo, useRef, useEffect, useState, useCallback } from 'react'
2
+ import { Brush } from '@visx/brush'
3
+ import BaseBrush from '@visx/brush/lib/BaseBrush'
4
+ import { Group } from '@visx/group'
5
+ import { PatternLines, PatternCircles, PatternWaves } from '@visx/pattern'
6
+ import { scaleBand, scaleLinear } from '@visx/scale'
7
+ import { Bounds } from '@visx/brush/lib/types'
8
+ import type { BrushHandleRenderProps } from '@visx/brush/lib/BrushHandle'
9
+ import { isDateScale } from '@cdc/core/helpers/cove/date'
10
+ import ConfigContext, { ChartDispatchContext } from '../../ConfigContext'
11
+ import MiniChartPreview from './MiniChartPreview'
12
+
13
+ interface BrushSelectorProps {
14
+ xMax: number
15
+ yMax: number
16
+ }
17
+
18
+ const BRUSH_HEIGHT = 50
19
+ const BRUSH_PADDING = 10
20
+ const KEYBOARD_STEP = 10 // pixels to move per arrow key press
21
+ const KEYBOARD_STEP_LARGE = 50 // pixels to move with Shift+arrow
22
+
23
+ type FocusedElement = 'left-handle' | 'selection' | 'right-handle' | null
24
+
25
+ // Simple brush handle component with wider grab area
26
+ const BrushHandle = memo<BrushHandleRenderProps>(({ x, height, isBrushActive, className }) => {
27
+ if (!isBrushActive) return null
28
+
29
+ const pathWidth = 14
30
+ const pathHeight = 28
31
+ const grabAreaWidth = 28 // Wider invisible grab area
32
+
33
+ // Determine if this is the left or right handle from className
34
+ const isLeftHandle = className?.includes('left')
35
+
36
+ // Arrow path: "<" for left handle, ">" for right handle
37
+ const arrowPath = isLeftHandle
38
+ ? 'M 2 7 L -3 14 L 2 21' // "<" chevron (points left)
39
+ : 'M -2 7 L 3 14 L -2 21' // ">" chevron (points right)
40
+
41
+ return (
42
+ <Group left={x + pathWidth / 2 - 7} top={(height - pathHeight) / 2}>
43
+ {/* Invisible wider grab area that extends full height */}
44
+ <rect
45
+ x={-grabAreaWidth / 2}
46
+ y={-(height - pathHeight) / 2}
47
+ width={grabAreaWidth}
48
+ height={height}
49
+ fill='transparent'
50
+ style={{ cursor: 'ew-resize' }}
51
+ pointerEvents='all'
52
+ />
53
+ {/* Visible handle background */}
54
+ <rect
55
+ x={-pathWidth / 2}
56
+ y={0}
57
+ width={pathWidth}
58
+ height={pathHeight}
59
+ fill='white'
60
+ stroke='rgb(51, 51, 51)'
61
+ strokeWidth='1'
62
+ style={{ cursor: 'ew-resize' }}
63
+ pointerEvents='none'
64
+ />
65
+ {/* Arrow icon */}
66
+ <path
67
+ d={arrowPath}
68
+ fill='none'
69
+ stroke='rgb(51, 51, 51)'
70
+ strokeWidth='2'
71
+ strokeLinecap='round'
72
+ strokeLinejoin='round'
73
+ style={{ cursor: 'ew-resize' }}
74
+ pointerEvents='none'
75
+ />
76
+ </Group>
77
+ )
78
+ })
79
+
80
+ BrushHandle.displayName = 'BrushHandle'
81
+
82
+ const BrushSelector: FC<BrushSelectorProps> = ({ xMax, yMax }) => {
83
+ const brushRef = useRef<BaseBrush>(null)
84
+ const containerRef = useRef<HTMLDivElement>(null)
85
+ const hasInitialized = useRef(false)
86
+ const mouseDownPos = useRef<{ x: number; y: number } | null>(null)
87
+ const isMouseDown = useRef<boolean>(false)
88
+ const previousExtent = useRef<{ x0: number; x1: number } | null>(null)
89
+ const previousDefaultCount = useRef<number | undefined>(undefined)
90
+ const isProgrammaticUpdate = useRef<boolean>(false)
91
+ const isUserInteracting = useRef<boolean>(false)
92
+
93
+ // Custom touch handling state
94
+ const touchState = useRef<{
95
+ active: boolean
96
+ startX: number
97
+ currentX: number
98
+ startExtent: { x0: number; x1: number }
99
+ dragType: 'selection' | 'left-handle' | 'right-handle' | 'new-selection' | null
100
+ }>({
101
+ active: false,
102
+ startX: 0,
103
+ currentX: 0,
104
+ startExtent: { x0: 0, x1: 0 },
105
+ dragType: null
106
+ })
107
+
108
+ // Keyboard accessibility state
109
+ const [focusedElement, setFocusedElement] = useState<FocusedElement>(null)
110
+ const [accessibleExtent, setAccessibleExtent] = useState<{ x0: number; x1: number } | null>(null)
111
+ const leftHandleRef = useRef<HTMLButtonElement>(null)
112
+ const selectionRef = useRef<HTMLButtonElement>(null)
113
+ const rightHandleRef = useRef<HTMLButtonElement>(null)
114
+
115
+ const { tableData, config, colorScale, parseDate } = useContext(ConfigContext)
116
+ const dispatch = useContext(ChartDispatchContext)
117
+ const dataKey = config.xAxis.dataKey
118
+ const series = config.series || []
119
+
120
+ const renderPatternDefs = () => {
121
+ if (!config.legend?.patterns || Object.keys(config.legend.patterns).length === 0) {
122
+ return null
123
+ }
124
+
125
+ return (
126
+ <>
127
+ {Object.entries(config.legend.patterns).map(([key, pattern]) => {
128
+ const patternId = `chart-pattern-${key}`
129
+ const size = pattern.patternSize || 8
130
+
131
+ switch (pattern.shape) {
132
+ case 'circles':
133
+ return (
134
+ <PatternCircles
135
+ key={patternId}
136
+ id={patternId}
137
+ height={size}
138
+ width={size}
139
+ fill={pattern.color}
140
+ radius={1.25}
141
+ />
142
+ )
143
+ case 'lines':
144
+ return (
145
+ <PatternLines
146
+ key={patternId}
147
+ id={patternId}
148
+ height={size}
149
+ width={size}
150
+ stroke={pattern.color}
151
+ strokeWidth={0.75}
152
+ orientation={['horizontal']}
153
+ />
154
+ )
155
+ case 'diagonalLines':
156
+ return (
157
+ <PatternLines
158
+ key={patternId}
159
+ id={patternId}
160
+ height={size}
161
+ width={size}
162
+ stroke={pattern.color}
163
+ strokeWidth={0.75}
164
+ orientation={['diagonalRightToLeft']}
165
+ />
166
+ )
167
+ case 'waves':
168
+ return (
169
+ <PatternWaves
170
+ key={patternId}
171
+ id={patternId}
172
+ height={size}
173
+ width={size}
174
+ fill={pattern.color}
175
+ strokeWidth={0.25}
176
+ />
177
+ )
178
+ default:
179
+ return null
180
+ }
181
+ })}
182
+ </>
183
+ )
184
+ }
185
+
186
+ // Capture initial brush extent after mount and sync accessible extent
187
+ useEffect(() => {
188
+ if (brushRef.current && brushRef.current.state.extent.x0 !== -1) {
189
+ const { x0, x1 } = brushRef.current.state.extent
190
+ if (!previousExtent.current) {
191
+ previousExtent.current = { x0, x1 }
192
+ }
193
+ // Also sync accessible extent for keyboard navigation
194
+ setAccessibleExtent({ x0, x1 })
195
+ }
196
+ }, [brushRef.current?.state])
197
+
198
+ // X scale for brush data - ensure positive dimensions
199
+ const xScale = useMemo(() => {
200
+ if (!tableData.length || xMax <= 0) {
201
+ return scaleBand<string>({ domain: [], range: [0, Math.max(xMax, 0)] })
202
+ }
203
+
204
+ const mappedValues = tableData.map(row => row[dataKey])
205
+
206
+ // Sort domain chronologically for date types, matching the main chart's sort behavior.
207
+ // Without this, data arriving in reverse chronological order renders the brush backwards.
208
+ const xAxisType = config?.xAxis?.type
209
+ let domain: string[]
210
+ if (xAxisType === 'date' || xAxisType === 'date-time') {
211
+ const sorted = [...mappedValues].sort((a, b) => {
212
+ const dateA = parseDate ? parseDate(a) : new Date(a)
213
+ const dateB = parseDate ? parseDate(b) : new Date(b)
214
+ return dateA - dateB
215
+ })
216
+ domain = config?.xAxis?.sortByRecentDate ? sorted.reverse() : sorted
217
+ } else {
218
+ domain = config?.xAxis?.sortByRecentDate ? [...mappedValues].reverse() : mappedValues
219
+ }
220
+
221
+ return scaleBand<string>({
222
+ domain,
223
+ range: [0, Math.max(xMax, 0)],
224
+ paddingInner: 0.1,
225
+ paddingOuter: 0.1
226
+ })
227
+ }, [tableData, dataKey, config?.xAxis?.sortByRecentDate, config?.xAxis?.type, parseDate, xMax])
228
+
229
+ // Simple Y scale for brush (identity mapping)
230
+ const yScale = useMemo(() => scaleLinear<number>({ domain: [0, BRUSH_HEIGHT], range: [BRUSH_HEIGHT, 0] }), [])
231
+
232
+ // Helper to build a mini Y scale from a subset of series
233
+ const buildMiniYScale = useCallback(
234
+ (seriesSubset: typeof series, includeZero: boolean) => {
235
+ const defaultScale = scaleLinear({ domain: [0, 100], range: [BRUSH_HEIGHT - 4, 2] })
236
+ if (!seriesSubset.length || !tableData.length) return defaultScale
237
+
238
+ let minValue = Number.POSITIVE_INFINITY
239
+ let maxValue = Number.NEGATIVE_INFINITY
240
+ let hasValidValues = false
241
+
242
+ for (const s of seriesSubset) {
243
+ if (!s.dataKey) continue
244
+ for (const row of tableData) {
245
+ const value = parseFloat(row[s.dataKey])
246
+ if (!isNaN(value) && isFinite(value)) {
247
+ hasValidValues = true
248
+ minValue = Math.min(minValue, value)
249
+ maxValue = Math.max(maxValue, value)
250
+ }
251
+ }
252
+ }
253
+
254
+ if (!hasValidValues) return defaultScale
255
+
256
+ if (includeZero) minValue = Math.min(0, minValue)
257
+
258
+ if (minValue === maxValue) {
259
+ const padding = Math.abs(minValue) * 0.1 || 10
260
+ minValue = minValue - padding
261
+ maxValue = maxValue + padding
262
+ if (minValue > 0) minValue = 0
263
+ }
264
+
265
+ const domain = [minValue, maxValue]
266
+ return scaleLinear({ domain, range: [BRUSH_HEIGHT - 4, 2], nice: true })
267
+ },
268
+ [tableData]
269
+ )
270
+
271
+ // Determine if we have a right-axis series (dual-axis combo)
272
+ const hasRightAxis = useMemo(
273
+ () => config.visualizationType === 'Combo' && series.some(s => s.axis === 'Right'),
274
+ [series, config.visualizationType]
275
+ )
276
+
277
+ // Mini chart Y scale — left-axis (or all series when there's no right axis)
278
+ const miniYScale = useMemo(() => {
279
+ if (!series.length || !tableData.length) {
280
+ return scaleLinear({ domain: [0, 100], range: [BRUSH_HEIGHT - 4, 2] })
281
+ }
282
+
283
+ const barSeriesTypes = new Set(['Bar', 'Paired Bar', 'Deviation Bar', 'Combo'])
284
+ const hasBarSeries =
285
+ config.visualizationType === 'Bar' ||
286
+ (config.visualizationType === 'Combo' && series.some(s => barSeriesTypes.has(s.type)))
287
+ const isStacked =
288
+ config.visualizationSubType === 'stacked' &&
289
+ (config.visualizationType === 'Bar' || config.visualizationType === 'Area Chart')
290
+
291
+ // When dual-axis, only use left-axis series for this scale
292
+ const leftSeries = hasRightAxis ? series.filter(s => s.axis !== 'Right') : series
293
+
294
+ let minValue = Number.POSITIVE_INFINITY
295
+ let maxValue = Number.NEGATIVE_INFINITY
296
+ let hasValidValues = false
297
+
298
+ if (isStacked) {
299
+ for (const row of tableData) {
300
+ let rowSum = 0
301
+ let hasRowValue = false
302
+ for (const s of leftSeries) {
303
+ if (!s.dataKey) continue
304
+ const value = parseFloat(row[s.dataKey])
305
+ if (!isNaN(value) && isFinite(value)) {
306
+ rowSum += value
307
+ hasRowValue = true
308
+ }
309
+ }
310
+ if (hasRowValue) {
311
+ hasValidValues = true
312
+ minValue = Math.min(minValue, rowSum)
313
+ maxValue = Math.max(maxValue, rowSum)
314
+ }
315
+ }
316
+ minValue = Math.min(0, minValue)
317
+ } else {
318
+ for (const s of leftSeries) {
319
+ if (!s.dataKey) continue
320
+ for (const row of tableData) {
321
+ const value = parseFloat(row[s.dataKey])
322
+ if (!isNaN(value) && isFinite(value)) {
323
+ hasValidValues = true
324
+ minValue = Math.min(minValue, value)
325
+ maxValue = Math.max(maxValue, value)
326
+ }
327
+ }
328
+ }
329
+ if (hasBarSeries) minValue = Math.min(0, minValue)
330
+ }
331
+
332
+ if (hasValidValues && minValue === maxValue) {
333
+ const padding = Math.abs(minValue) * 0.1 || 10
334
+ minValue = minValue - padding
335
+ maxValue = maxValue + padding
336
+ if (minValue > 0) minValue = 0
337
+ }
338
+
339
+ if (!hasValidValues) {
340
+ return scaleLinear({ domain: [0, 100], range: [BRUSH_HEIGHT - 4, 2] })
341
+ }
342
+
343
+ if (hasBarSeries) minValue = Math.min(0, minValue)
344
+
345
+ const domain = minValue === maxValue ? [minValue - 1, maxValue + 1] : [minValue, maxValue]
346
+ return scaleLinear({ domain, range: [BRUSH_HEIGHT - 4, 2], nice: true })
347
+ }, [series, tableData, config.visualizationSubType, config.visualizationType, hasRightAxis])
348
+
349
+ // Mini chart Y scale for right-axis series (dual-axis combo charts)
350
+ const miniRightYScale = useMemo(() => {
351
+ if (!hasRightAxis) return undefined
352
+ const rightSeries = series.filter(s => s.axis === 'Right')
353
+ return buildMiniYScale(rightSeries, false)
354
+ }, [hasRightAxis, series, buildMiniYScale])
355
+
356
+ // Fallback: Window mouseup listener to prevent stuck drag states
357
+ useEffect(() => {
358
+ const handleWindowMouseUp = () => {
359
+ // Check if brush is stuck in dragging state
360
+ if (brushRef.current?.state?.isBrushing) {
361
+ const brush = brushRef.current
362
+
363
+ // Use reset method if available to clear the brush.
364
+ // If the visx Brush API is insufficient, consider filing an issue upstream.
365
+ if (typeof brush.reset === 'function') {
366
+ brush.reset()
367
+ return
368
+ }
369
+ // No reliable fallback: avoid DOM manipulation. If issues persist, document and address upstream.
370
+ }
371
+ }
372
+
373
+ // Add window-level event listeners as fallback safety net
374
+ window.addEventListener('mouseup', handleWindowMouseUp, { passive: true })
375
+ window.addEventListener('touchend', handleWindowMouseUp, { passive: true })
376
+ // Also listen for mouse leave events on the window (e.g., mouse goes outside browser)
377
+ window.addEventListener('mouseleave', handleWindowMouseUp, { passive: true })
378
+
379
+ return () => {
380
+ window.removeEventListener('mouseup', handleWindowMouseUp)
381
+ window.removeEventListener('touchend', handleWindowMouseUp)
382
+ window.removeEventListener('mouseleave', handleWindowMouseUp)
383
+ }
384
+ }, [])
385
+
386
+ // Custom touch handling for mobile - bypasses broken @visx/brush touch support
387
+ useEffect(() => {
388
+ const container = containerRef.current
389
+ if (!container) return
390
+
391
+ const HANDLE_WIDTH = 24 // Width of the drag handles
392
+
393
+ const getRelativeX = (touch: Touch): number => {
394
+ const rect = container.getBoundingClientRect()
395
+ return touch.clientX - rect.left
396
+ }
397
+
398
+ const getDragType = (x: number): 'selection' | 'left-handle' | 'right-handle' | 'new-selection' => {
399
+ const brush = brushRef.current
400
+ if (!brush || brush.state.extent.x0 === -1) return 'new-selection'
401
+
402
+ const { x0, x1 } = brush.state.extent
403
+
404
+ // Check if touch is on handles (with some tolerance)
405
+ if (Math.abs(x - x0) < HANDLE_WIDTH) return 'left-handle'
406
+ if (Math.abs(x - x1) < HANDLE_WIDTH) return 'right-handle'
407
+
408
+ // Check if touch is on selection
409
+ if (x >= x0 && x <= x1) return 'selection'
410
+
411
+ // Touch is outside selection - create new selection
412
+ return 'new-selection'
413
+ }
414
+
415
+ const handleTouchStart = (e: TouchEvent) => {
416
+ if (e.touches.length !== 1) return
417
+
418
+ const touch = e.touches[0]
419
+ const x = getRelativeX(touch)
420
+ const dragType = getDragType(x)
421
+
422
+ e.preventDefault()
423
+ e.stopPropagation()
424
+
425
+ isUserInteracting.current = true
426
+
427
+ const brush = brushRef.current
428
+ const startExtent =
429
+ brush && brush.state.extent.x0 !== -1
430
+ ? { x0: brush.state.extent.x0, x1: brush.state.extent.x1 }
431
+ : { x0: x, x1: x } // For new selection, start at touch point
432
+
433
+ touchState.current = {
434
+ active: true,
435
+ startX: x,
436
+ currentX: x,
437
+ startExtent,
438
+ dragType
439
+ }
440
+ }
441
+
442
+ const handleTouchMove = (e: TouchEvent) => {
443
+ if (!touchState.current.active || e.touches.length !== 1) return
444
+
445
+ e.preventDefault()
446
+ e.stopPropagation()
447
+
448
+ const touch = e.touches[0]
449
+ const x = getRelativeX(touch)
450
+ touchState.current.currentX = x
451
+ const delta = x - touchState.current.startX
452
+ const { startExtent, dragType, startX } = touchState.current
453
+ const safeXMax = Math.max(xMax, 100)
454
+ const safeYMax = Math.max(yMax, BRUSH_HEIGHT + BRUSH_PADDING * 2)
455
+ const minSelectionWidth = 20
456
+
457
+ let newX0 = startExtent.x0
458
+ let newX1 = startExtent.x1
459
+
460
+ if (dragType === 'selection') {
461
+ // Move entire selection
462
+ const width = startExtent.x1 - startExtent.x0
463
+ newX0 = Math.max(0, Math.min(safeXMax - width, startExtent.x0 + delta))
464
+ newX1 = newX0 + width
465
+ } else if (dragType === 'left-handle') {
466
+ // Resize from left
467
+ newX0 = Math.max(0, Math.min(startExtent.x1 - minSelectionWidth, startExtent.x0 + delta))
468
+ } else if (dragType === 'right-handle') {
469
+ // Resize from right
470
+ newX1 = Math.max(startExtent.x0 + minSelectionWidth, Math.min(safeXMax, startExtent.x1 + delta))
471
+ } else if (dragType === 'new-selection') {
472
+ // Create new selection from start point to current point
473
+ newX0 = Math.max(0, Math.min(startX, x))
474
+ newX1 = Math.min(safeXMax, Math.max(startX, x))
475
+ // Ensure minimum width
476
+ if (newX1 - newX0 < minSelectionWidth) {
477
+ if (x > startX) {
478
+ newX1 = Math.min(safeXMax, newX0 + minSelectionWidth)
479
+ } else {
480
+ newX0 = Math.max(0, newX1 - minSelectionWidth)
481
+ }
482
+ }
483
+ }
484
+
485
+ // Update brush position
486
+ const brush = brushRef.current
487
+ if (brush?.updateBrush) {
488
+ brush.updateBrush(() => ({
489
+ ...brush.state,
490
+ extent: { x0: newX0, x1: newX1, y0: 0, y1: safeYMax },
491
+ start: { x: newX0, y: 0 },
492
+ end: { x: newX1, y: safeYMax }
493
+ }))
494
+ }
495
+ }
496
+
497
+ const handleTouchEnd = (e: TouchEvent) => {
498
+ if (!touchState.current.active) return
499
+
500
+ e.preventDefault()
501
+ e.stopPropagation()
502
+
503
+ // Get final brush state and update data
504
+ const brush = brushRef.current
505
+ if (brush && brush.state.extent && brush.state.extent.x1 > brush.state.extent.x0) {
506
+ const { x0, x1 } = brush.state.extent
507
+ const domain = xScale.domain()
508
+ const xValues: string[] = []
509
+
510
+ for (const value of domain) {
511
+ const xPos = xScale(value)
512
+ if (xPos !== undefined && xPos >= x0 && xPos < x1) {
513
+ xValues.push(value)
514
+ }
515
+ }
516
+
517
+ if (xValues.length > 0) {
518
+ const selectedSet = new Set(xValues)
519
+ const filteredData = tableData.filter(row => selectedSet.has(row[dataKey]))
520
+ dispatch({ type: 'SET_BRUSH_DATA', payload: filteredData })
521
+ previousExtent.current = { x0, x1 }
522
+ setAccessibleExtent({ x0, x1 })
523
+ }
524
+ }
525
+
526
+ // Reset touch state
527
+ touchState.current = {
528
+ active: false,
529
+ startX: 0,
530
+ currentX: 0,
531
+ startExtent: { x0: 0, x1: 0 },
532
+ dragType: null
533
+ }
534
+
535
+ // Reset isUserInteracting - handleBrushChange will check brush state if needed
536
+ // Reuse the brush variable already declared above
537
+ if (brush && !brush.state?.isBrushing) {
538
+ isUserInteracting.current = false
539
+ }
540
+ }
541
+
542
+ // Attach native event listeners with passive: false to allow preventDefault
543
+ container.addEventListener('touchstart', handleTouchStart, { passive: false })
544
+ container.addEventListener('touchmove', handleTouchMove, { passive: false })
545
+ container.addEventListener('touchend', handleTouchEnd, { passive: false })
546
+
547
+ return () => {
548
+ container.removeEventListener('touchstart', handleTouchStart)
549
+ container.removeEventListener('touchmove', handleTouchMove)
550
+ container.removeEventListener('touchend', handleTouchEnd)
551
+ }
552
+ }, [xMax, yMax, xScale, tableData, dataKey, dispatch])
553
+
554
+ // Handle brush changes
555
+ const handleBrushChange = useCallback(
556
+ (bounds: Bounds | null) => {
557
+ if (!bounds) {
558
+ dispatch({ type: 'SET_BRUSH_DATA', payload: [] })
559
+ return
560
+ }
561
+
562
+ // Validate bounds have valid numeric values
563
+ if (!isFinite(bounds.x0) || !isFinite(bounds.x1) || !isFinite(bounds.y0) || !isFinite(bounds.y1)) {
564
+ // Invalid bounds - likely an intermediate state during drag, skip silently
565
+ return
566
+ }
567
+
568
+ // Only ignore calls where x0=0, x1=0 AND xValues matches the FULL dataset
569
+ // This indicates an invalid initialization/reset. But if xValues is a subset,
570
+ // the user is legitimately dragging and we should accept it even with x0=0, x1=0
571
+ const domain = xScale.domain()
572
+
573
+ // Guard: ignore only if it's clearly an invalid full-dataset reset
574
+ // (x0=0, x1=0 with all domain values) - this happens on visx internal resets
575
+ const isFullDatasetReset =
576
+ bounds.x0 === 0 && bounds.x1 === 0 && bounds.xValues && bounds.xValues.length === domain.length
577
+ if (isFullDatasetReset) {
578
+ return
579
+ }
580
+
581
+ // Get xValues from bounds or compute from the scale if not provided
582
+ let xValues = bounds.xValues?.filter(val => val !== undefined) || []
583
+ const brushDefaultRecentDateCount = config.xAxis?.brushDefaultRecentDateCount
584
+
585
+ // If xValues is empty but we have valid bounds, compute xValues from the scale
586
+ // This handles cases where visx doesn't properly compute xValues during drag
587
+ if (xValues.length === 0 && bounds.x1 > bounds.x0) {
588
+ // During programmatic updates with brushDefaultRecentDateCount, use exact count
589
+ // During user interactions, use pixel-based calculation to allow free brush movement
590
+ if (isProgrammaticUpdate.current && brushDefaultRecentDateCount && brushDefaultRecentDateCount > 0) {
591
+ // When using brushDefaultRecentDateCount during programmatic updates, use exact count
592
+ // to avoid including extra dates that fall within the pixel bounds
593
+ const countToSelect = Math.min(brushDefaultRecentDateCount, domain.length)
594
+ xValues = domain.slice(-countToSelect)
595
+ } else {
596
+ // For manual brush selection, use pixel-based calculation
597
+ for (const value of domain) {
598
+ const xPos = xScale(value)
599
+ if (xPos !== undefined) {
600
+ const bandCenter = xPos + (xScale.bandwidth?.() || 0) / 2
601
+ if (bandCenter >= bounds.x0 && bandCenter <= bounds.x1) {
602
+ xValues.push(value)
603
+ }
604
+ }
605
+ }
606
+ }
607
+ } else if (xValues.length > 0 && brushDefaultRecentDateCount && brushDefaultRecentDateCount > 0) {
608
+ // CRITICAL: Always check if xValues matches expected count when brushDefaultRecentDateCount is set
609
+ // This prevents the tick/dates bug where visx recalculates and includes extra dates
610
+ // The bug fix from bb9d533 always enforced exact count, but that breaks user interactions
611
+ // Solution: Only enforce exact count during programmatic updates OR when user is not actively interacting
612
+ const expectedCount = Math.min(brushDefaultRecentDateCount, domain.length)
613
+ if (xValues.length !== expectedCount) {
614
+ if (isProgrammaticUpdate.current || !isUserInteracting.current) {
615
+ // Enforce exact count during programmatic updates or when user is not actively dragging
616
+ // This preserves the bug fix while allowing free brush movement during active user interactions
617
+ xValues = domain.slice(-expectedCount)
618
+ }
619
+ // During active user interactions (dragging), allow xValues to be any count - user can select freely
620
+ }
621
+ }
622
+
623
+ if (xValues.length === 0) {
624
+ dispatch({ type: 'SET_BRUSH_DATA', payload: [] })
625
+ return
626
+ }
627
+
628
+ // Create filtered dataset
629
+ const selectedSet = new Set(xValues)
630
+ const filteredData = tableData.filter(row => selectedSet.has(row[dataKey]))
631
+
632
+ dispatch({ type: 'SET_BRUSH_DATA', payload: filteredData })
633
+
634
+ // Update accessible extent for keyboard controls positioning
635
+ setAccessibleExtent({ x0: bounds.x0, x1: bounds.x1 })
636
+
637
+ // Reset programmatic update flag after handling the change
638
+ isProgrammaticUpdate.current = false
639
+
640
+ // Reset user interaction flag if brush is no longer being dragged
641
+ // This ensures we don't use timeouts and check the actual brush state
642
+ const brush = brushRef.current
643
+ if (isUserInteracting.current && brush && !brush.state?.isBrushing) {
644
+ isUserInteracting.current = false
645
+ }
646
+ },
647
+ [dispatch, tableData, dataKey, xScale, config.xAxis?.brushDefaultRecentDateCount]
648
+ )
649
+
650
+ // Set default selection on initial load
651
+ // Uses either the last X data points (if defaultRecentDateCount is set) or 35% of width
652
+ useEffect(() => {
653
+ if (hasInitialized.current || !tableData.length || !xScale || xMax <= 0) {
654
+ return
655
+ }
656
+
657
+ const safeXMax = Math.max(xMax, 100)
658
+ const safeYMax = Math.max(yMax, BRUSH_HEIGHT + BRUSH_PADDING * 2)
659
+ const domain = xScale.domain()
660
+ const brushDefaultRecentDateCount = config.xAxis.brushDefaultRecentDateCount
661
+
662
+ let x0: number
663
+ let x1: number
664
+ let xValues: string[] = []
665
+
666
+ if (brushDefaultRecentDateCount && brushDefaultRecentDateCount > 0 && domain.length > 0) {
667
+ // Use exact count of most recent data points
668
+ const countToSelect = Math.min(brushDefaultRecentDateCount, domain.length)
669
+ const selectedValues = domain.slice(-countToSelect)
670
+ xValues = selectedValues
671
+
672
+ const firstSelectedPos = xScale(selectedValues[0])
673
+ const lastSelectedPos = xScale(selectedValues[selectedValues.length - 1])
674
+ const bandwidth = xScale.bandwidth() || 0
675
+
676
+ x0 = firstSelectedPos ?? safeXMax * 0.65
677
+ x1 = lastSelectedPos !== undefined ? lastSelectedPos + bandwidth : safeXMax
678
+ } else {
679
+ // Default: 35% of the width, starting from most recent dates
680
+ const initialWidth = safeXMax * 0.35
681
+ x0 = safeXMax - initialWidth
682
+ x1 = safeXMax
683
+
684
+ for (const value of domain) {
685
+ const xPos = xScale(value)
686
+ if (xPos !== undefined && xPos >= x0 && xPos < x1) {
687
+ xValues.push(value)
688
+ }
689
+ }
690
+ }
691
+
692
+ // If we have values in the initial range, set the brush data
693
+ if (xValues.length > 0) {
694
+ const initialBounds: Bounds = {
695
+ x0,
696
+ x1,
697
+ y0: 0,
698
+ y1: safeYMax,
699
+ xValues
700
+ }
701
+
702
+ // Mark as programmatic update for initial load
703
+ isProgrammaticUpdate.current = true
704
+ // Just set the brush data - let the Brush component handle the visual selection
705
+ handleBrushChange(initialBounds)
706
+ hasInitialized.current = true
707
+ }
708
+ // eslint-disable-next-line react-hooks/exhaustive-deps
709
+ }, [tableData, xScale, xMax, dataKey, config.xAxis.brushDefaultRecentDateCount])
710
+
711
+ // Update brush selection when brushDefaultRecentDateCount changes in the editor
712
+ useEffect(() => {
713
+ const currentCount = config.xAxis.brushDefaultRecentDateCount
714
+
715
+ // Skip if not initialized yet (let the initial effect handle it)
716
+ if (!hasInitialized.current) {
717
+ previousDefaultCount.current = currentCount
718
+ return
719
+ }
720
+
721
+ // Skip if the value hasn't actually changed
722
+ if (currentCount === previousDefaultCount.current) {
723
+ return
724
+ }
725
+
726
+ // Update the previous value
727
+ previousDefaultCount.current = currentCount
728
+
729
+ // Skip if we don't have the necessary data
730
+ if (!tableData.length || !xScale || xMax <= 0) {
731
+ return
732
+ }
733
+
734
+ const safeXMax = Math.max(xMax, 100)
735
+ const safeYMax = Math.max(yMax, BRUSH_HEIGHT + BRUSH_PADDING * 2)
736
+ const domain = xScale.domain()
737
+
738
+ let x0: number
739
+ let x1: number
740
+ let xValues: string[] = []
741
+
742
+ if (currentCount && currentCount > 0 && domain.length > 0) {
743
+ // Use exact count of most recent data points
744
+ const countToSelect = Math.min(currentCount, domain.length)
745
+ const selectedValues = domain.slice(-countToSelect)
746
+ xValues = selectedValues
747
+
748
+ const firstSelectedPos = xScale(selectedValues[0])
749
+ const lastSelectedPos = xScale(selectedValues[selectedValues.length - 1])
750
+ const bandwidth = xScale.bandwidth() || 0
751
+
752
+ x0 = firstSelectedPos ?? safeXMax * 0.65
753
+ x1 = lastSelectedPos !== undefined ? lastSelectedPos + bandwidth : safeXMax
754
+ } else {
755
+ // Default: 35% of the width, starting from most recent dates
756
+ const initialWidth = safeXMax * 0.35
757
+ x0 = safeXMax - initialWidth
758
+ x1 = safeXMax
759
+
760
+ for (const value of domain) {
761
+ const xPos = xScale(value)
762
+ if (xPos !== undefined && xPos >= x0 && xPos < x1) {
763
+ xValues.push(value)
764
+ }
765
+ }
766
+ }
767
+
768
+ if (xValues.length > 0) {
769
+ const newBounds: Bounds = {
770
+ x0,
771
+ x1,
772
+ y0: 0,
773
+ y1: safeYMax,
774
+ xValues
775
+ }
776
+
777
+ // Mark as programmatic update when editor value changes
778
+ isProgrammaticUpdate.current = true
779
+ // Update the brush data
780
+ handleBrushChange(newBounds)
781
+
782
+ // Update the visual brush position
783
+ const brush = brushRef.current
784
+ if (brush && brush.updateBrush) {
785
+ brush.updateBrush(() => ({
786
+ ...brush.state,
787
+ extent: {
788
+ x0,
789
+ x1,
790
+ y0: 0,
791
+ y1: safeYMax
792
+ },
793
+ start: { x: x0, y: 0 },
794
+ end: { x: x1, y: safeYMax }
795
+ }))
796
+
797
+ // Store the new extent
798
+ previousExtent.current = { x0, x1 }
799
+ }
800
+ }
801
+ // eslint-disable-next-line react-hooks/exhaustive-deps
802
+ }, [config.xAxis.brushDefaultRecentDateCount])
803
+
804
+ // Selected box style with solid border and pattern fill
805
+ const selectedBoxStyle = useMemo(
806
+ () => ({
807
+ fill: 'url(#brush_pattern)',
808
+ stroke: '#333',
809
+ strokeWidth: 1
810
+ }),
811
+ []
812
+ )
813
+
814
+ // Helper to update brush position programmatically
815
+ const updateBrushPosition = useCallback(
816
+ (newX0: number, newX1: number) => {
817
+ const brush = brushRef.current
818
+ if (!brush || !tableData.length) return
819
+
820
+ const safeXMax = Math.max(xMax, 100)
821
+ const safeYMax = Math.max(yMax, BRUSH_HEIGHT + BRUSH_PADDING * 2)
822
+
823
+ // Clamp values to valid range
824
+ newX0 = Math.max(0, Math.min(safeXMax, newX0))
825
+ newX1 = Math.max(0, Math.min(safeXMax, newX1))
826
+
827
+ // Ensure x0 < x1
828
+ if (newX0 > newX1) {
829
+ ;[newX0, newX1] = [newX1, newX0]
830
+ }
831
+
832
+ // Ensure minimum width
833
+ if (newX1 - newX0 < 1) return
834
+
835
+ // Get the domain values for the new range
836
+ const domain = xScale.domain()
837
+ const xValues: string[] = []
838
+
839
+ for (const value of domain) {
840
+ const xPos = xScale(value)
841
+ if (xPos !== undefined && xPos >= newX0 && xPos < newX1) {
842
+ xValues.push(value)
843
+ }
844
+ }
845
+
846
+ if (xValues.length > 0) {
847
+ const newBounds: Bounds = {
848
+ x0: newX0,
849
+ x1: newX1,
850
+ y0: 0,
851
+ y1: safeYMax,
852
+ xValues
853
+ }
854
+
855
+ handleBrushChange(newBounds)
856
+
857
+ if (brush.updateBrush) {
858
+ brush.updateBrush(() => ({
859
+ ...brush.state,
860
+ extent: {
861
+ x0: newX0,
862
+ x1: newX1,
863
+ y0: 0,
864
+ y1: safeYMax
865
+ },
866
+ start: { x: newX0, y: 0 },
867
+ end: { x: newX1, y: safeYMax }
868
+ }))
869
+
870
+ previousExtent.current = { x0: newX0, x1: newX1 }
871
+ // For keyboard navigation, update state immediately so buttons move
872
+ setAccessibleExtent({ x0: newX0, x1: newX1 })
873
+ }
874
+ }
875
+ },
876
+ [xMax, yMax, tableData, xScale, handleBrushChange]
877
+ )
878
+
879
+ // Keyboard event handler for brush navigation
880
+ const handleKeyDown = useCallback(
881
+ (event: React.KeyboardEvent, element: FocusedElement) => {
882
+ if (!element) return
883
+
884
+ const brush = brushRef.current
885
+ if (!brush || brush.state.extent.x0 === -1) return
886
+
887
+ const { x0, x1 } = brush.state.extent
888
+ const step = event.shiftKey ? KEYBOARD_STEP_LARGE : KEYBOARD_STEP
889
+ const safeXMax = Math.max(xMax, 100)
890
+ const minSelectionWidth = 20 // Minimum selection width in pixels
891
+
892
+ let newX0 = x0
893
+ let newX1 = x1
894
+ let handled = false
895
+
896
+ switch (event.key) {
897
+ case 'ArrowLeft':
898
+ if (element === 'left-handle') {
899
+ // Move left handle left (expand selection)
900
+ newX0 = Math.max(0, x0 - step)
901
+ } else if (element === 'right-handle') {
902
+ // Move right handle left (shrink selection)
903
+ newX1 = Math.max(x0 + minSelectionWidth, x1 - step)
904
+ } else if (element === 'selection') {
905
+ // Move entire selection left
906
+ const width = x1 - x0
907
+ newX0 = Math.max(0, x0 - step)
908
+ newX1 = newX0 + width
909
+ }
910
+ handled = true
911
+ break
912
+
913
+ case 'ArrowRight':
914
+ if (element === 'left-handle') {
915
+ // Move left handle right (shrink selection)
916
+ newX0 = Math.min(x1 - minSelectionWidth, x0 + step)
917
+ } else if (element === 'right-handle') {
918
+ // Move right handle right (expand selection)
919
+ newX1 = Math.min(safeXMax, x1 + step)
920
+ } else if (element === 'selection') {
921
+ // Move entire selection right
922
+ const width = x1 - x0
923
+ newX1 = Math.min(safeXMax, x1 + step)
924
+ newX0 = newX1 - width
925
+ }
926
+ handled = true
927
+ break
928
+
929
+ case 'Home':
930
+ if (element === 'selection') {
931
+ // Move selection to start
932
+ const width = x1 - x0
933
+ newX0 = 0
934
+ newX1 = width
935
+ } else if (element === 'left-handle') {
936
+ newX0 = 0
937
+ }
938
+ handled = true
939
+ break
940
+
941
+ case 'End':
942
+ if (element === 'selection') {
943
+ // Move selection to end
944
+ const width = x1 - x0
945
+ newX1 = safeXMax
946
+ newX0 = safeXMax - width
947
+ } else if (element === 'right-handle') {
948
+ newX1 = safeXMax
949
+ }
950
+ handled = true
951
+ break
952
+ }
953
+
954
+ if (handled) {
955
+ event.preventDefault()
956
+ event.stopPropagation()
957
+ updateBrushPosition(newX0, newX1)
958
+ }
959
+ },
960
+ [xMax, updateBrushPosition]
961
+ )
962
+
963
+ // Get current brush extent for positioning accessible controls
964
+ const getCurrentExtent = useCallback(() => {
965
+ const brush = brushRef.current
966
+ if (brush && brush.state.extent.x0 !== -1) {
967
+ return {
968
+ x0: brush.state.extent.x0,
969
+ x1: brush.state.extent.x1
970
+ }
971
+ }
972
+ // Fallback to previousExtent or default
973
+ if (previousExtent.current) {
974
+ return previousExtent.current
975
+ }
976
+ const safeXMax = Math.max(xMax, 100)
977
+ return {
978
+ x0: safeXMax * 0.65,
979
+ x1: safeXMax
980
+ }
981
+ }, [xMax])
982
+
983
+ // Track when brush interaction starts (mouse down) with position
984
+ const handleBrushStart = (start: any) => {
985
+ isMouseDown.current = true
986
+ isUserInteracting.current = true
987
+
988
+ // Store the starting mouse position (from the brush start object)
989
+ if (start && start.x !== undefined) {
990
+ mouseDownPos.current = { x: start.x, y: start.y || 0 }
991
+ }
992
+
993
+ // Store the current extent before any changes
994
+ const brush = brushRef.current
995
+ if (brush && brush.state.extent.x0 !== -1) {
996
+ previousExtent.current = {
997
+ x0: brush.state.extent.x0,
998
+ x1: brush.state.extent.x1
999
+ }
1000
+ }
1001
+ }
1002
+
1003
+ // Track when brush interaction ends
1004
+ const handleBrushEnd = () => {
1005
+ isMouseDown.current = false
1006
+ // Reset isUserInteracting - handleBrushChange will be called after this
1007
+ // and will check brush state to ensure it's truly done
1008
+ const brush = brushRef.current
1009
+ if (brush && !brush.state?.isBrushing) {
1010
+ isUserInteracting.current = false
1011
+ }
1012
+ }
1013
+
1014
+ const handleMouseLeave = (event: React.PointerEvent<SVGRectElement>) => {
1015
+ // Don't cancel brush on touch devices - touch interactions trigger pointer leave
1016
+ // when finger moves, which would cancel the brush prematurely
1017
+ if (event.pointerType === 'touch') {
1018
+ return
1019
+ }
1020
+ isMouseDown.current = false
1021
+ mouseDownPos.current = null
1022
+ // Reset isUserInteracting - handleBrushChange will check brush state if needed
1023
+ const brush = brushRef.current
1024
+ if (brush && !brush.state?.isBrushing) {
1025
+ isUserInteracting.current = false
1026
+ }
1027
+ }
1028
+
1029
+ // Handle click to move brush to clicked location
1030
+ const handleClick = (event: React.PointerEvent<SVGRectElement>) => {
1031
+ const DRAG_THRESHOLD = 5 // pixels - if mouse moved less than this, it's a click
1032
+ const HANDLE_WIDTH = 8 // Width of the brush handles
1033
+
1034
+ // Calculate click position relative to the brush area (accounting for BRUSH_PADDING)
1035
+ const svgRect = event.currentTarget.ownerSVGElement?.getBoundingClientRect()
1036
+ if (!svgRect) {
1037
+ return
1038
+ }
1039
+
1040
+ const clickX = event.clientX - svgRect.left - BRUSH_PADDING
1041
+
1042
+ // Check if we have a mouse down position to compare against
1043
+ if (!mouseDownPos.current) {
1044
+ mouseDownPos.current = null
1045
+ return
1046
+ }
1047
+
1048
+ // Calculate distance moved since mouse down
1049
+ const dragDistance = Math.abs(clickX - mouseDownPos.current.x)
1050
+
1051
+ // Reset mouse down position for next interaction
1052
+ mouseDownPos.current = null
1053
+
1054
+ // Only proceed if this was a click (minimal movement), not a drag
1055
+ if (dragDistance > DRAG_THRESHOLD) {
1056
+ return
1057
+ }
1058
+
1059
+ // Use the previous extent (before visx potentially changed it)
1060
+ if (!previousExtent.current) {
1061
+ return
1062
+ }
1063
+
1064
+ const brush = brushRef.current
1065
+ if (!brush || !tableData.length) {
1066
+ return
1067
+ }
1068
+
1069
+ const prevExtent = previousExtent.current
1070
+
1071
+ // Check if click was on a handle (ignore clicks on handles)
1072
+ const currentWidth = Math.abs(prevExtent.x1 - prevExtent.x0)
1073
+ const leftHandleStart = Math.min(prevExtent.x0, prevExtent.x1) - HANDLE_WIDTH / 2
1074
+ const leftHandleEnd = Math.min(prevExtent.x0, prevExtent.x1) + HANDLE_WIDTH / 2
1075
+ const rightHandleStart = Math.max(prevExtent.x0, prevExtent.x1) - HANDLE_WIDTH / 2
1076
+ const rightHandleEnd = Math.max(prevExtent.x0, prevExtent.x1) + HANDLE_WIDTH / 2
1077
+
1078
+ // If click was on a handle, do nothing
1079
+ if (
1080
+ (clickX >= leftHandleStart && clickX <= leftHandleEnd) ||
1081
+ (clickX >= rightHandleStart && clickX <= rightHandleEnd)
1082
+ ) {
1083
+ return
1084
+ }
1085
+
1086
+ // Check if click was inside the current selection (ignore clicks inside selection to allow dragging)
1087
+ const selectionStart = Math.min(prevExtent.x0, prevExtent.x1)
1088
+ const selectionEnd = Math.max(prevExtent.x0, prevExtent.x1)
1089
+
1090
+ if (clickX > selectionStart + HANDLE_WIDTH && clickX < selectionEnd - HANDLE_WIDTH) {
1091
+ return
1092
+ }
1093
+
1094
+ // Calculate the new brush position centered on click
1095
+ const halfWidth = currentWidth / 2
1096
+ let newX0 = clickX - halfWidth
1097
+ let newX1 = clickX + halfWidth
1098
+
1099
+ // Ensure the new position stays within bounds
1100
+ const safeXMax = Math.max(xMax, 100)
1101
+ if (newX0 < 0) {
1102
+ newX0 = 0
1103
+ newX1 = currentWidth
1104
+ } else if (newX1 > safeXMax) {
1105
+ newX1 = safeXMax
1106
+ newX0 = safeXMax - currentWidth
1107
+ }
1108
+
1109
+ // Get the domain values for the new range
1110
+ const domain = xScale.domain()
1111
+ const xValues: string[] = []
1112
+
1113
+ for (const value of domain) {
1114
+ const xPos = xScale(value)
1115
+ if (xPos !== undefined && xPos >= newX0 && xPos < newX1) {
1116
+ xValues.push(value)
1117
+ }
1118
+ }
1119
+
1120
+ // Update the brush position
1121
+ if (xValues.length > 0) {
1122
+ const safeYMax = Math.max(yMax, BRUSH_HEIGHT + BRUSH_PADDING * 2)
1123
+ const newBounds: Bounds = {
1124
+ x0: newX0,
1125
+ x1: newX1,
1126
+ y0: 0,
1127
+ y1: safeYMax,
1128
+ xValues
1129
+ }
1130
+
1131
+ handleBrushChange(newBounds)
1132
+
1133
+ // Update the brush ref state directly to move the visual selection
1134
+ if (brush.updateBrush) {
1135
+ brush.updateBrush(() => ({
1136
+ ...brush.state,
1137
+ extent: {
1138
+ x0: newX0,
1139
+ x1: newX1,
1140
+ y0: 0,
1141
+ y1: safeYMax
1142
+ },
1143
+ start: { x: newX0, y: 0 },
1144
+ end: { x: newX1, y: safeYMax }
1145
+ }))
1146
+
1147
+ // Store the new extent as the previous extent for the next click
1148
+ previousExtent.current = {
1149
+ x0: newX0,
1150
+ x1: newX1
1151
+ }
1152
+ }
1153
+ }
1154
+ }
1155
+
1156
+ // Ensure positive dimensions to avoid SVG errors
1157
+ const safeXMax = Math.max(xMax, 100) // Minimum width of 100px
1158
+ const safeYMax = Math.max(yMax, BRUSH_HEIGHT + BRUSH_PADDING * 2) // Minimum height with padding
1159
+
1160
+ // Calculate initial brush position
1161
+ // Uses either the last X data points (if brushDefaultRecentDateCount is set) or 35% of width
1162
+ const initialBrushPosition = useMemo(() => {
1163
+ if (safeXMax > 0 && tableData.length > 0 && xScale) {
1164
+ const domain = xScale.domain()
1165
+ const brushDefaultRecentDateCount = config.xAxis.brushDefaultRecentDateCount
1166
+
1167
+ if (brushDefaultRecentDateCount && brushDefaultRecentDateCount > 0 && domain.length > 0) {
1168
+ // Use exact count of most recent data points
1169
+ const countToSelect = Math.min(brushDefaultRecentDateCount, domain.length)
1170
+ const selectedValues = domain.slice(-countToSelect)
1171
+
1172
+ const firstSelectedPos = xScale(selectedValues[0])
1173
+ const lastSelectedPos = xScale(selectedValues[selectedValues.length - 1])
1174
+ const bandwidth = xScale.bandwidth()
1175
+
1176
+ const x0 = firstSelectedPos !== undefined ? firstSelectedPos : safeXMax * 0.65
1177
+ const x1 = lastSelectedPos !== undefined ? lastSelectedPos + bandwidth : safeXMax
1178
+
1179
+ return {
1180
+ start: { x: x0, y: 0 },
1181
+ end: { x: x1, y: safeYMax }
1182
+ }
1183
+ }
1184
+
1185
+ // Default: 35% of the width
1186
+ const initialWidth = safeXMax * 0.35
1187
+ return {
1188
+ start: { x: safeXMax - initialWidth, y: 0 },
1189
+ end: { x: safeXMax, y: safeYMax }
1190
+ }
1191
+ }
1192
+ return undefined
1193
+ }, [safeXMax, safeYMax, tableData.length, xScale, config.xAxis.brushDefaultRecentDateCount])
1194
+
1195
+ // Calculate positions for accessible controls
1196
+ const extent = accessibleExtent || getCurrentExtent()
1197
+ const handleWidth = 24
1198
+ // Selection spans from right edge of left handle to left edge of right handle
1199
+ const selectionWidth = Math.max(extent.x1 - extent.x0 - handleWidth, 20)
1200
+
1201
+ return (
1202
+ <div
1203
+ ref={containerRef}
1204
+ style={{
1205
+ position: 'relative',
1206
+ width: safeXMax,
1207
+ height: safeYMax,
1208
+ touchAction: 'none',
1209
+ WebkitTouchCallout: 'none',
1210
+ WebkitUserSelect: 'none',
1211
+ userSelect: 'none'
1212
+ }}
1213
+ role='group'
1214
+ aria-label='Date range selector'
1215
+ >
1216
+ <svg
1217
+ width={safeXMax}
1218
+ height={safeYMax}
1219
+ style={{
1220
+ display: 'block',
1221
+ overflow: 'visible', // Ensure handles don't get clipped
1222
+ touchAction: 'none', // Enable touch interactions for brush
1223
+ WebkitTouchCallout: 'none',
1224
+ WebkitUserSelect: 'none',
1225
+ userSelect: 'none'
1226
+ }}
1227
+ aria-hidden='true'
1228
+ >
1229
+ {/* Pattern definition for brush selection */}
1230
+ <defs>
1231
+ <pattern id='brush_pattern' width='8' height='8' patternUnits='userSpaceOnUse'>
1232
+ <path
1233
+ d='M 0,8 l 8,-8 M -2,2 l 4,-4 M 6,10 l 4,-4'
1234
+ stroke='#bdbdbd'
1235
+ strokeWidth='1'
1236
+ strokeLinecap='square'
1237
+ shapeRendering='auto'
1238
+ />
1239
+ </pattern>
1240
+ {renderPatternDefs()}
1241
+ </defs>
1242
+
1243
+ {/* Mini chart preview */}
1244
+ <Group top={BRUSH_PADDING}>
1245
+ {safeXMax > 0 && tableData.length > 0 && (
1246
+ <MiniChartPreview
1247
+ series={series}
1248
+ tableData={tableData}
1249
+ dataKey={dataKey}
1250
+ xScale={xScale}
1251
+ miniYScale={miniYScale}
1252
+ miniRightYScale={miniRightYScale}
1253
+ colorScale={colorScale}
1254
+ config={config}
1255
+ xMax={safeXMax}
1256
+ />
1257
+ )}
1258
+ </Group>
1259
+
1260
+ {/* Brush component - positioned at the very top, no padding */}
1261
+ {safeXMax > 0 && (
1262
+ <Brush
1263
+ xScale={xScale}
1264
+ yScale={yScale}
1265
+ width={safeXMax}
1266
+ height={safeYMax}
1267
+ brushDirection='horizontal'
1268
+ onChange={handleBrushChange}
1269
+ selectedBoxStyle={selectedBoxStyle}
1270
+ useWindowMoveEvents={true} // Track mouse movements outside the brush area
1271
+ resizeTriggerAreas={['left', 'right']} // Enable resize handles on both sides
1272
+ innerRef={brushRef}
1273
+ renderBrushHandle={props => <BrushHandle {...props} />}
1274
+ initialBrushPosition={initialBrushPosition}
1275
+ onClick={handleClick}
1276
+ onBrushStart={handleBrushStart}
1277
+ onBrushEnd={handleBrushEnd}
1278
+ onMouseLeave={handleMouseLeave}
1279
+ />
1280
+ )}
1281
+ </svg>
1282
+
1283
+ {/* Accessible keyboard controls - visually hidden but focusable, appear when focused */}
1284
+ {safeXMax > 0 && hasInitialized.current && (
1285
+ <div
1286
+ style={{
1287
+ position: 'absolute',
1288
+ top: 0,
1289
+ left: 0,
1290
+ width: 0,
1291
+ height: 0,
1292
+ overflow: 'visible'
1293
+ }}
1294
+ >
1295
+ {/* Left handle control - visually hidden until focused */}
1296
+ <button
1297
+ ref={leftHandleRef}
1298
+ type='button'
1299
+ aria-label='Left handle: Use arrow keys to adjust start of selection'
1300
+ style={{
1301
+ position: 'absolute',
1302
+ left: `${extent.x0 - handleWidth / 2}px`,
1303
+ top: 0,
1304
+ width: focusedElement === 'left-handle' ? `${handleWidth}px` : '1px',
1305
+ height: focusedElement === 'left-handle' ? `${safeYMax}px` : '1px',
1306
+ background: focusedElement === 'left-handle' ? 'rgba(0, 102, 204, 0.3)' : 'transparent',
1307
+ border: focusedElement === 'left-handle' ? '2px solid #0066cc' : 'none',
1308
+ borderRadius: '4px',
1309
+ pointerEvents: 'none',
1310
+ outline: 'none',
1311
+ padding: 0,
1312
+ opacity: focusedElement === 'left-handle' ? 1 : 0,
1313
+ clip: focusedElement === 'left-handle' ? 'auto' : 'rect(0, 0, 0, 0)'
1314
+ }}
1315
+ onFocus={() => setFocusedElement('left-handle')}
1316
+ onBlur={() => setFocusedElement(null)}
1317
+ onKeyDown={e => handleKeyDown(e, 'left-handle')}
1318
+ tabIndex={0}
1319
+ />
1320
+
1321
+ {/* Selection/center control - visually hidden until focused */}
1322
+ <button
1323
+ ref={selectionRef}
1324
+ type='button'
1325
+ aria-label='Selection: Use arrow keys to move entire selection'
1326
+ style={{
1327
+ position: 'absolute',
1328
+ left: `${extent.x0 + handleWidth / 2}px`,
1329
+ top: 0,
1330
+ width: focusedElement === 'selection' ? `${selectionWidth}px` : '1px',
1331
+ height: focusedElement === 'selection' ? `${safeYMax}px` : '1px',
1332
+ background: focusedElement === 'selection' ? 'rgba(0, 102, 204, 0.2)' : 'transparent',
1333
+ border: focusedElement === 'selection' ? '2px solid #0066cc' : 'none',
1334
+ borderRadius: '4px',
1335
+ pointerEvents: 'none',
1336
+ outline: 'none',
1337
+ padding: 0,
1338
+ opacity: focusedElement === 'selection' ? 1 : 0,
1339
+ clip: focusedElement === 'selection' ? 'auto' : 'rect(0, 0, 0, 0)'
1340
+ }}
1341
+ onFocus={() => setFocusedElement('selection')}
1342
+ onBlur={() => setFocusedElement(null)}
1343
+ onKeyDown={e => handleKeyDown(e, 'selection')}
1344
+ tabIndex={0}
1345
+ />
1346
+
1347
+ {/* Right handle control - visually hidden until focused */}
1348
+ <button
1349
+ ref={rightHandleRef}
1350
+ type='button'
1351
+ aria-label='Right handle: Use arrow keys to adjust end of selection'
1352
+ style={{
1353
+ position: 'absolute',
1354
+ left: `${extent.x1 - handleWidth / 2}px`,
1355
+ top: 0,
1356
+ width: focusedElement === 'right-handle' ? `${handleWidth}px` : '1px',
1357
+ height: focusedElement === 'right-handle' ? `${safeYMax}px` : '1px',
1358
+ background: focusedElement === 'right-handle' ? 'rgba(0, 102, 204, 0.3)' : 'transparent',
1359
+ border: focusedElement === 'right-handle' ? '2px solid #0066cc' : 'none',
1360
+ borderRadius: '4px',
1361
+ pointerEvents: 'none',
1362
+ outline: 'none',
1363
+ padding: 0,
1364
+ opacity: focusedElement === 'right-handle' ? 1 : 0,
1365
+ clip: focusedElement === 'right-handle' ? 'auto' : 'rect(0, 0, 0, 0)'
1366
+ }}
1367
+ onFocus={() => setFocusedElement('right-handle')}
1368
+ onBlur={() => setFocusedElement(null)}
1369
+ onKeyDown={e => handleKeyDown(e, 'right-handle')}
1370
+ tabIndex={0}
1371
+ />
1372
+ </div>
1373
+ )}
1374
+
1375
+ {/* Screen reader instructions */}
1376
+ <div className='sr-only' style={{ position: 'absolute', left: '-9999px' }}>
1377
+ Use Tab to navigate between left handle, selection, and right handle. Use Arrow keys to adjust. Hold Shift for
1378
+ larger steps. Press Home to move to start, End to move to end.
1379
+ </div>
1380
+ </div>
1381
+ )
1382
+ }
1383
+
1384
+ export default memo(BrushSelector, (prev, next) => {
1385
+ // Only re-render if dimensions change significantly (avoid micro-changes)
1386
+ const xMaxChanged = Math.abs(prev.xMax - next.xMax) > 1
1387
+ const yMaxChanged = Math.abs(prev.yMax - next.yMax) > 1
1388
+
1389
+ return !xMaxChanged && !yMaxChanged
1390
+ })