@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.
- package/dist/{cdcchart-1a1724a1.es.js → cdcchart-dgT_1dIT.es.js} +136 -151
- package/dist/cdcchart.js +44003 -43518
- package/examples/feature/__data__/planet-example-data.json +1 -1
- package/examples/feature/boxplot/valid-boxplot.csv +38 -17
- package/examples/feature/pie/planet-pie-example-config.json +48 -2
- package/examples/private/DEV-11825.json +573 -0
- package/examples/private/DEV-12100.json +1303 -0
- package/examples/private/cat-y.json +1235 -0
- package/examples/private/data-points.json +228 -0
- package/examples/private/height.json +3915 -0
- package/examples/private/links.json +569 -0
- package/examples/private/na.json +913 -0
- package/examples/private/quadrant.txt +30 -0
- package/examples/private/test-data.csv +28 -0
- package/examples/private/test-forecast.json +5510 -0
- package/examples/private/warming-stripe-test.json +2578 -0
- package/examples/private/warming-stripes.json +4763 -0
- package/examples/tech-adoption-with-links.json +560 -0
- package/index.html +16 -140
- package/package.json +6 -5
- package/preview.html +1616 -0
- package/src/CdcChart.tsx +8 -11
- package/src/CdcChartComponent.tsx +329 -124
- package/src/_stories/Chart.Combo.stories.tsx +18 -0
- package/src/_stories/Chart.Forecast.stories.tsx +36 -0
- package/src/_stories/Chart.HTMLInDataTable.stories.tsx +520 -0
- package/src/_stories/Chart.Patterns.stories.tsx +2 -1
- package/src/_stories/Chart.PreserveDecimals.stories.tsx +220 -0
- package/src/_stories/Chart.Regions.Categorical.stories.tsx +148 -0
- package/src/_stories/Chart.Regions.DateScale.stories.tsx +197 -0
- package/src/_stories/Chart.Regions.DateTimeScale.stories.tsx +297 -0
- package/src/_stories/Chart.SmallMultiples.stories.tsx +47 -0
- package/src/_stories/Chart.stories.tsx +8 -0
- package/src/_stories/ChartAnnotation.stories.tsx +6 -3
- package/src/_stories/ChartBar.Editor.stories.tsx +3585 -0
- package/src/_stories/ChartBrush.Editor.stories.tsx +295 -0
- package/src/_stories/ChartBrush.stories.tsx +50 -0
- package/src/_stories/ChartEditor.Editor.stories.tsx +656 -0
- package/src/_stories/ChartEditor.stories.tsx +1 -2
- package/src/_stories/TechAdoptionWithLinks.stories.tsx +27 -0
- package/src/_stories/_mock/brush_enabled.json +326 -0
- package/src/_stories/_mock/brush_mock.json +2 -69
- package/src/_stories/_mock/combo.json +451 -0
- package/src/_stories/_mock/editor-test-configs.json +376 -0
- package/src/_stories/_mock/editor-test-datasets.json +477 -0
- package/src/_stories/_mock/editor-tests/bar-chart-editor-test.json +255 -0
- package/src/_stories/_mock/editor-tests/bar-chart-general-test.json +267 -0
- package/src/_stories/_mock/editor-tests/bar-chart-test.json +237 -0
- package/src/_stories/_mock/forecast_combo_with_gaps.json +913 -0
- package/src/_stories/_mock/horizontal-bars-dynamic-y-axis.json +413 -0
- package/src/_stories/_mock/pie_config.json +257 -62
- package/src/_stories/_mock/small_multiples/small_multiples_bars.json +1944 -0
- package/src/_stories/_mock/small_multiples/small_multiples_big_data_bars.json +1114 -0
- package/src/_stories/_mock/small_multiples/small_multiples_lines.json +2646 -0
- package/src/_stories/_mock/small_multiples/small_multiples_lines_colors.json +1305 -0
- package/src/_stories/_mock/small_multiples/small_multiples_stacked_bars.json +1936 -0
- package/src/components/Annotations/components/findNearestDatum.ts +6 -41
- package/src/components/AreaChart/components/AreaChart.Stacked.jsx +10 -7
- package/src/components/AreaChart/index.tsx +1 -2
- package/src/components/Axis/Categorical.Axis.tsx +6 -7
- package/src/components/BarChart/components/BarChart.Horizontal.tsx +181 -27
- package/src/components/BarChart/components/BarChart.StackedHorizontal.tsx +3 -1
- package/src/components/BarChart/components/BarChart.StackedVertical.tsx +1 -0
- package/src/components/BarChart/components/BarChart.Vertical.tsx +8 -9
- package/src/components/BarChart/components/context.tsx +1 -0
- package/src/components/BarChart/helpers/useBarChart.ts +14 -2
- package/src/components/BoxPlot/helpers/index.ts +3 -3
- package/src/components/Brush/BrushSelector.tsx +1258 -0
- package/src/components/Brush/MiniChartPreview.tsx +283 -0
- package/src/components/DeviationBar.jsx +9 -7
- package/src/components/EditorPanel/EditorPanel.tsx +2720 -2586
- package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +96 -111
- package/src/components/EditorPanel/components/Panels/Panel.ForestPlotSettings.tsx +56 -34
- package/src/components/EditorPanel/components/Panels/Panel.General.tsx +76 -31
- package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +104 -55
- package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +54 -49
- package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.tsx +427 -0
- package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +96 -48
- package/src/components/EditorPanel/components/Panels/index.tsx +3 -1
- package/src/components/EditorPanel/editor-panel.scss +0 -20
- package/src/components/EditorPanel/useEditorPermissions.ts +36 -31
- package/src/components/Forecasting/Forecasting.tsx +139 -21
- package/src/components/Legend/Legend.Component.tsx +16 -9
- package/src/components/Legend/Legend.tsx +3 -2
- package/src/components/Legend/helpers/createFormatLabels.tsx +325 -176
- package/src/components/Legend/helpers/getLegendClasses.ts +0 -1
- package/src/components/Legend/helpers/index.ts +10 -6
- package/src/components/LineChart/LineChartProps.ts +0 -3
- package/src/components/LineChart/helpers.ts +1 -1
- package/src/components/LineChart/index.tsx +36 -13
- package/src/components/LinearChart.tsx +559 -499
- package/src/components/PairedBarChart.jsx +20 -3
- package/src/components/Regions/components/Regions.tsx +366 -144
- package/src/components/Sankey/types/index.ts +1 -1
- package/src/components/ScatterPlot/ScatterPlot.jsx +2 -2
- package/src/components/SmallMultiples/SmallMultipleTile.tsx +202 -0
- package/src/components/SmallMultiples/SmallMultiples.css +32 -0
- package/src/components/SmallMultiples/SmallMultiples.tsx +271 -0
- package/src/components/SmallMultiples/index.ts +2 -0
- package/src/components/WarmingStripes/WarmingStripes.tsx +160 -0
- package/src/components/WarmingStripes/WarmingStripesGradientLegend.css +35 -0
- package/src/components/WarmingStripes/WarmingStripesGradientLegend.tsx +104 -0
- package/src/components/WarmingStripes/index.tsx +3 -0
- package/src/data/initial-state.js +16 -2
- package/src/helpers/buildForecastPaletteOptions.ts +0 -38
- package/src/helpers/calculateHorizontalBarCategoryLabelWidth.ts +57 -0
- package/src/helpers/getColorScale.ts +10 -0
- package/src/{hooks/useMinMax.ts → helpers/getMinMax.ts} +26 -14
- package/src/helpers/getYAxisAutoPadding.ts +53 -0
- package/src/helpers/sizeHelpers.ts +0 -20
- package/src/helpers/smallMultiplesHelpers.ts +529 -0
- package/src/hooks/useChartHoverAnalytics.tsx +10 -9
- package/src/hooks/useProgrammaticTooltip.ts +96 -0
- package/src/hooks/useScales.ts +98 -34
- package/src/hooks/useSmallMultipleSynchronization.ts +59 -0
- package/src/hooks/useTooltip.tsx +91 -25
- package/src/scss/DataTable.scss +0 -4
- package/src/scss/main.scss +18 -83
- package/src/store/chart.actions.ts +2 -0
- package/src/store/chart.reducer.ts +4 -0
- package/src/test/CdcChart.test.jsx +1 -1
- package/src/types/ChartConfig.ts +27 -6
- package/src/types/ChartContext.ts +3 -0
- package/src/types/Label.ts +1 -0
- package/src/utils/analyticsTracking.ts +19 -0
- package/LICENSE +0 -201
- package/src/_stories/_mock/pie_data.json +0 -218
- package/src/components/AreaChart/components/AreaChart.jsx +0 -109
- package/src/components/Brush/BrushChart.tsx +0 -128
- package/src/components/Brush/BrushController.tsx +0 -71
- package/src/components/Brush/types.tsx +0 -8
- package/src/components/BrushChart.tsx +0 -223
- package/src/helpers/sort.ts +0 -7
- package/src/hooks/useActiveElement.js +0 -19
- 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
|
+
})
|