@cdc/chart 4.25.10 → 4.26.1

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