@cdc/chart 4.26.4 → 4.26.5
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/CONFIG.md +349 -29
- package/LICENSE +201 -0
- package/dist/cdcchart.js +34247 -32805
- package/examples/feature/heatmap/average-age-categorical.json +313 -0
- package/examples/feature/heatmap/calendar-heatmap.json +134 -0
- package/examples/feature/heatmap/cell-values-bottom-axis.json +181 -0
- package/examples/feature/heatmap/sparse-aggregation.json +158 -0
- package/examples/private/bump-chart.json +1116 -0
- package/examples/private/test-123.json +624 -0
- package/package.json +4 -3
- package/src/CdcChartComponent.tsx +135 -54
- package/src/_stories/Chart.AltText.stories.tsx +136 -0
- package/src/_stories/Chart.HeatMap.stories.tsx +187 -0
- package/src/_stories/Chart.Sankey.smoke.stories.tsx +62 -0
- package/src/_stories/Chart.SmallMultiples.smoke.stories.tsx +29 -0
- package/src/_stories/ChartAxisTitles.smoke.stories.tsx +13 -0
- package/src/_stories/ChartBar.Editor.stories.tsx +63 -0
- package/src/_stories/ChartBrush.Interaction.stories.tsx +112 -0
- package/src/_stories/ChartBrush.smoke.stories.tsx +13 -4
- package/src/_stories/_mock/alt_text_metadata.json +38 -0
- package/src/_stories/_mock/heatmap-average-age-categorical.json +315 -0
- package/src/_stories/_mock/heatmap.json +156 -0
- package/src/_stories/_mock/reported_pertussis_cases.json +1643 -0
- package/src/_stories/_mock/sankey_mock.json +78 -0
- package/src/_stories/_mock/small_multiples/small_multiples_lines.json +1 -1
- package/src/components/Annotations/components/AnnotationDraggable.tsx +1 -1
- package/src/components/Annotations/components/AnnotationDropdown.tsx +5 -2
- package/src/components/Axis/LeftAxis.tsx +14 -11
- package/src/components/Axis/LeftAxisGridlines.tsx +14 -11
- package/src/components/Brush/BrushSelector.tsx +88 -187
- package/src/components/Brush/brushInteraction.ts +22 -0
- package/src/components/Brush/tests/brushInteraction.test.ts +38 -0
- package/src/components/EditorPanel/EditorPanel.tsx +310 -49
- package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +13 -0
- package/src/components/EditorPanel/components/Panels/Panel.General.tsx +105 -13
- package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +1 -0
- package/src/components/EditorPanel/components/Panels/Panel.Regions.tsx +5 -0
- package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +2 -2
- package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +1 -1
- package/src/components/EditorPanel/helpers/getVisualizationTypeConfigUpdate.ts +57 -0
- package/src/components/EditorPanel/helpers/tests/getVisualizationTypeConfigUpdate.test.ts +56 -0
- package/src/components/EditorPanel/useEditorPermissions.test.tsx +70 -0
- package/src/components/EditorPanel/useEditorPermissions.ts +42 -22
- package/src/components/HeatMap/components/HeatMap.tsx +762 -0
- package/src/components/HeatMap/components/HeatMapGradientLegend.tsx +114 -0
- package/src/components/HeatMap/heatmap.constants.ts +13 -0
- package/src/components/HeatMap/heatmap.css +71 -0
- package/src/components/HeatMap/helpers/index.ts +261 -0
- package/src/components/HeatMap/index.tsx +5 -0
- package/src/components/HeatMap/tests/HeatMap.test.tsx +1040 -0
- package/src/components/HeatMap/tests/helpers.test.ts +167 -0
- package/src/components/LineChart/components/LineChart.BumpCircle.tsx +19 -22
- package/src/components/LineChart/helpers.test.ts +345 -0
- package/src/components/LineChart/helpers.ts +15 -5
- package/src/components/LineChart/index.tsx +7 -1
- package/src/components/LinearChart/tests/LinearChart.test.tsx +107 -33
- package/src/components/LinearChart/tests/mockConfigContext.ts +1 -0
- package/src/components/LinearChart.tsx +58 -37
- package/src/components/PieChart/PieChart.tsx +4 -1
- package/src/components/RadarChart/RadarChart.tsx +3 -1
- package/src/components/Regions/components/Regions.tsx +195 -41
- package/src/components/Sankey/components/Sankey.tsx +7 -3
- package/src/components/Sankey/helpers/getSankeyTooltip.tsx +7 -6
- package/src/components/SmallMultiples/SmallMultipleTile.tsx +30 -3
- package/src/components/SmallMultiples/SmallMultiples.css +12 -0
- package/src/components/SmallMultiples/SmallMultiples.tsx +15 -9
- package/src/components/SmallMultiples/tests/SmallMultipleTile.test.tsx +152 -0
- package/src/components/Sparkline/components/SparkLine.tsx +4 -9
- package/src/components/WarmingStripes/WarmingStripesGradientLegend.css +1 -1
- package/src/components/WarmingStripes/WarmingStripesGradientLegend.tsx +2 -2
- package/src/data/initial-state.js +11 -0
- package/src/helpers/axisLabelFontSize.ts +8 -0
- package/src/helpers/bumpChartTooltip.ts +54 -0
- package/src/helpers/calculateLeftYAxisWidth.ts +4 -0
- package/src/helpers/ensureSpecialChartAxisTypes.ts +11 -0
- package/src/helpers/getMinMax.ts +2 -3
- package/src/helpers/getYAxisAutoPadding.ts +8 -6
- package/src/helpers/getYAxisAutoPaddingMode.ts +17 -0
- package/src/helpers/handleChartAriaLabels.ts +23 -10
- package/src/helpers/seriesHighlight.ts +26 -0
- package/src/helpers/tests/axisLabelFontSize.test.ts +15 -0
- package/src/helpers/tests/bumpChartTooltip.test.ts +75 -0
- package/src/helpers/tests/ensureSpecialChartAxisTypes.test.ts +30 -0
- package/src/helpers/tests/getYAxisAutoPadding.test.ts +37 -0
- package/src/helpers/tests/getYAxisAutoPaddingMode.test.ts +90 -0
- package/src/helpers/tests/handleChartAriaLabels.test.ts +71 -0
- package/src/helpers/tests/seriesHighlight.test.ts +61 -0
- package/src/hooks/tests/useScales.test.tsx +105 -0
- package/src/hooks/useScales.ts +19 -9
- package/src/index.jsx +5 -2
- package/src/scss/main.scss +32 -49
- package/src/types/ChartConfig.ts +18 -0
- package/src/types/ChartContext.ts +2 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "chart",
|
|
3
|
+
"visualizationType": "Sankey",
|
|
4
|
+
"title": "Patient Care Pathway",
|
|
5
|
+
"showTitle": true,
|
|
6
|
+
"enableTooltips": true,
|
|
7
|
+
"heights": {
|
|
8
|
+
"vertical": 500,
|
|
9
|
+
"horizontal": 750
|
|
10
|
+
},
|
|
11
|
+
"data": [
|
|
12
|
+
{
|
|
13
|
+
"links": [
|
|
14
|
+
{ "source": "Screened", "target": "Eligible", "value": 850 },
|
|
15
|
+
{ "source": "Screened", "target": "Ineligible", "value": 150 },
|
|
16
|
+
{ "source": "Eligible", "target": "Enrolled", "value": 700 },
|
|
17
|
+
{ "source": "Eligible", "target": "Declined", "value": 150 },
|
|
18
|
+
{ "source": "Enrolled", "target": "Completed Treatment", "value": 580 },
|
|
19
|
+
{ "source": "Enrolled", "target": "Dropped Out", "value": 120 },
|
|
20
|
+
{ "source": "Completed Treatment", "target": "Recovered", "value": 460 },
|
|
21
|
+
{ "source": "Completed Treatment", "target": "Ongoing Care", "value": 120 }
|
|
22
|
+
],
|
|
23
|
+
"tooltips": true,
|
|
24
|
+
"storyNodeText": []
|
|
25
|
+
}
|
|
26
|
+
],
|
|
27
|
+
"sankey": {
|
|
28
|
+
"title": {
|
|
29
|
+
"defaultColor": "black"
|
|
30
|
+
},
|
|
31
|
+
"iterations": 1,
|
|
32
|
+
"rxValue": 0.9,
|
|
33
|
+
"overallSize": {
|
|
34
|
+
"width": 900,
|
|
35
|
+
"height": 700
|
|
36
|
+
},
|
|
37
|
+
"margin": {
|
|
38
|
+
"margin_y": 25,
|
|
39
|
+
"margin_x": 0
|
|
40
|
+
},
|
|
41
|
+
"nodeSize": {
|
|
42
|
+
"nodeWidth": 26,
|
|
43
|
+
"nodeHeight": 40
|
|
44
|
+
},
|
|
45
|
+
"nodePadding": 55,
|
|
46
|
+
"nodeFontColor": "black",
|
|
47
|
+
"nodeColor": {
|
|
48
|
+
"default": "#005eaa",
|
|
49
|
+
"inactive": "#808080"
|
|
50
|
+
},
|
|
51
|
+
"linkColor": {
|
|
52
|
+
"default": "#7bafd4",
|
|
53
|
+
"inactive": "#D3D3D3"
|
|
54
|
+
},
|
|
55
|
+
"opacity": {
|
|
56
|
+
"nodeOpacityDefault": 1.0,
|
|
57
|
+
"nodeOpacityInactive": 0.1,
|
|
58
|
+
"LinkOpacityDefault": 0.6,
|
|
59
|
+
"LinkOpacityInactive": 0.1
|
|
60
|
+
},
|
|
61
|
+
"storyNodeFontColor": "#006778",
|
|
62
|
+
"storyNodeText": [],
|
|
63
|
+
"nodeValueStyle": {
|
|
64
|
+
"textBefore": "(",
|
|
65
|
+
"textAfter": ")"
|
|
66
|
+
},
|
|
67
|
+
"data": []
|
|
68
|
+
},
|
|
69
|
+
"table": {
|
|
70
|
+
"show": false,
|
|
71
|
+
"expanded": false,
|
|
72
|
+
"label": "Data Table",
|
|
73
|
+
"caption": ""
|
|
74
|
+
},
|
|
75
|
+
"legend": {
|
|
76
|
+
"hide": true
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -34,7 +34,7 @@ const Annotations = ({
|
|
|
34
34
|
const [height] = dimensions
|
|
35
35
|
|
|
36
36
|
const AnnotationComponent = isEditor ? EditableAnnotation : VisxAnnotation
|
|
37
|
-
const isMobile = isMobileAnnotationViewport(currentViewport)
|
|
37
|
+
const isMobile = isMobileAnnotationViewport(currentViewport) && config?.general?.mobileAnnotationDisplay !== 'text'
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* Scale dx/dy offsets based on savedDimensions vs current dimensions.
|
|
@@ -27,8 +27,11 @@ const AnnotationDropdown = () => {
|
|
|
27
27
|
const handleSectionClasses = () => {
|
|
28
28
|
const classes = [`data-table-container`, viewport, `w-100`, 'mt-4']
|
|
29
29
|
|
|
30
|
-
//
|
|
31
|
-
|
|
30
|
+
// When mobile annotations show full text on the chart, the auto mobile dropdown is suppressed.
|
|
31
|
+
// The dropdown then follows showAnnotationDropdown only (matching desktop behavior).
|
|
32
|
+
const mobileShowsText = config.general?.mobileAnnotationDisplay === 'text'
|
|
33
|
+
|
|
34
|
+
if ((isMobile && !mobileShowsText) || config.general.showAnnotationDropdown) {
|
|
32
35
|
classes.push('d-block')
|
|
33
36
|
} else {
|
|
34
37
|
classes.push('d-none')
|
|
@@ -60,6 +60,7 @@ const LeftAxis: React.FC<LeftAxisProps> = ({
|
|
|
60
60
|
const isLogarithmicAxis = config.yAxis.type === 'logarithmic'
|
|
61
61
|
const { labelsAboveGridlines, hideAxis, inlineLabel } = config.yAxis
|
|
62
62
|
const inlineLabelHasNoSpace = !inlineLabel?.includes(' ')
|
|
63
|
+
const showSideTitle = config.yAxis.titlePlacement !== 'top'
|
|
63
64
|
|
|
64
65
|
return (
|
|
65
66
|
<VisxAxisLeft
|
|
@@ -381,17 +382,19 @@ const LeftAxis: React.FC<LeftAxisProps> = ({
|
|
|
381
382
|
})
|
|
382
383
|
})()}
|
|
383
384
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
385
|
+
{showSideTitle && (
|
|
386
|
+
<Text
|
|
387
|
+
className='y-label'
|
|
388
|
+
textAnchor='middle'
|
|
389
|
+
verticalAnchor='start'
|
|
390
|
+
transform={`translate(${-1 * yAxisWidth}, ${axisCenter}) rotate(-90)`}
|
|
391
|
+
fontWeight='bold'
|
|
392
|
+
fill={config.yAxis.labelColor}
|
|
393
|
+
fontSize={axisLabelFontSize}
|
|
394
|
+
>
|
|
395
|
+
{!config.hideYAxisLabel ? props.label : null}
|
|
396
|
+
</Text>
|
|
397
|
+
)}
|
|
395
398
|
</Group>
|
|
396
399
|
)
|
|
397
400
|
}}
|
|
@@ -24,6 +24,7 @@ const LeftAxisGridlines: React.FC<LeftAxisGridlinesProps> = ({
|
|
|
24
24
|
const { runtime } = config
|
|
25
25
|
|
|
26
26
|
const isLogarithmicAxis = config.yAxis.type === 'logarithmic'
|
|
27
|
+
const showSideTitle = config.yAxis.titlePlacement !== 'top'
|
|
27
28
|
|
|
28
29
|
return (
|
|
29
30
|
<AxisLeft scale={yScale} left={yAxisWidth - config.yAxis.axisPadding} numTicks={numTicks}>
|
|
@@ -54,17 +55,19 @@ const LeftAxisGridlines: React.FC<LeftAxisGridlinesProps> = ({
|
|
|
54
55
|
</Group>
|
|
55
56
|
)
|
|
56
57
|
})}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
58
|
+
{showSideTitle && (
|
|
59
|
+
<Text
|
|
60
|
+
className='y-label'
|
|
61
|
+
textAnchor='middle'
|
|
62
|
+
verticalAnchor='start'
|
|
63
|
+
transform={`translate(${-1 * yAxisWidth}, ${axisCenter}) rotate(-90)`}
|
|
64
|
+
fontWeight='bold'
|
|
65
|
+
fill={config.yAxis.labelColor}
|
|
66
|
+
fontSize={axisLabelFontSize}
|
|
67
|
+
>
|
|
68
|
+
{!config.hideYAxisLabel ? props.label : null}
|
|
69
|
+
</Text>
|
|
70
|
+
)}
|
|
68
71
|
</Group>
|
|
69
72
|
)
|
|
70
73
|
}}
|
|
@@ -10,6 +10,8 @@ import { isDateScale } from '@cdc/core/helpers/cove/date'
|
|
|
10
10
|
import ConfigContext, { ChartDispatchContext } from '../../ConfigContext'
|
|
11
11
|
import { getChartPatternId } from '../../helpers/getChartPatternId'
|
|
12
12
|
import MiniChartPreview from './MiniChartPreview'
|
|
13
|
+
import { classifyBrushInteraction } from './brushInteraction'
|
|
14
|
+
import type { BrushInteractionTarget } from './brushInteraction'
|
|
13
15
|
|
|
14
16
|
interface BrushSelectorProps {
|
|
15
17
|
xMax: number
|
|
@@ -20,6 +22,7 @@ const BRUSH_HEIGHT = 50
|
|
|
20
22
|
const BRUSH_PADDING = 10
|
|
21
23
|
const KEYBOARD_STEP = 10 // pixels to move per arrow key press
|
|
22
24
|
const KEYBOARD_STEP_LARGE = 50 // pixels to move with Shift+arrow
|
|
25
|
+
const HANDLE_HIT_WIDTH = 24
|
|
23
26
|
|
|
24
27
|
type FocusedElement = 'left-handle' | 'selection' | 'right-handle' | null
|
|
25
28
|
|
|
@@ -40,7 +43,7 @@ const BrushHandle = memo<BrushHandleRenderProps>(({ x, height, isBrushActive, cl
|
|
|
40
43
|
: 'M -2 7 L 3 14 L -2 21' // ">" chevron (points right)
|
|
41
44
|
|
|
42
45
|
return (
|
|
43
|
-
<Group left={x + pathWidth / 2 - 7} top={(height - pathHeight) / 2}>
|
|
46
|
+
<Group className={className} left={x + pathWidth / 2 - 7} top={(height - pathHeight) / 2}>
|
|
44
47
|
{/* Invisible wider grab area that extends full height */}
|
|
45
48
|
<rect
|
|
46
49
|
x={-grabAreaWidth / 2}
|
|
@@ -84,8 +87,6 @@ const BrushSelector: FC<BrushSelectorProps> = ({ xMax, yMax }) => {
|
|
|
84
87
|
const brushRef = useRef<BaseBrush>(null)
|
|
85
88
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
86
89
|
const hasInitialized = useRef(false)
|
|
87
|
-
const mouseDownPos = useRef<{ x: number; y: number } | null>(null)
|
|
88
|
-
const isMouseDown = useRef<boolean>(false)
|
|
89
90
|
const previousExtent = useRef<{ x0: number; x1: number } | null>(null)
|
|
90
91
|
const previousDefaultCount = useRef<number | undefined>(undefined)
|
|
91
92
|
const isProgrammaticUpdate = useRef<boolean>(false)
|
|
@@ -97,7 +98,7 @@ const BrushSelector: FC<BrushSelectorProps> = ({ xMax, yMax }) => {
|
|
|
97
98
|
startX: number
|
|
98
99
|
currentX: number
|
|
99
100
|
startExtent: { x0: number; x1: number }
|
|
100
|
-
dragType:
|
|
101
|
+
dragType: BrushInteractionTarget | null
|
|
101
102
|
}>({
|
|
102
103
|
active: false,
|
|
103
104
|
startX: 0,
|
|
@@ -354,20 +355,66 @@ const BrushSelector: FC<BrushSelectorProps> = ({ xMax, yMax }) => {
|
|
|
354
355
|
return buildMiniYScale(rightSeries, false)
|
|
355
356
|
}, [hasRightAxis, series, buildMiniYScale])
|
|
356
357
|
|
|
358
|
+
const finishBrushInteraction = useCallback(() => {
|
|
359
|
+
const brush = brushRef.current
|
|
360
|
+
if (!brush?.updateBrush) return
|
|
361
|
+
|
|
362
|
+
brush.updateBrush(prevBrush => {
|
|
363
|
+
return {
|
|
364
|
+
...prevBrush,
|
|
365
|
+
activeHandle: null,
|
|
366
|
+
isBrushing: false,
|
|
367
|
+
brushingType: undefined,
|
|
368
|
+
brushPageOffset: undefined
|
|
369
|
+
}
|
|
370
|
+
})
|
|
371
|
+
}, [])
|
|
372
|
+
|
|
373
|
+
const getBrushInteractionTarget = useCallback((x: number) => {
|
|
374
|
+
const brush = brushRef.current
|
|
375
|
+
const extent =
|
|
376
|
+
brush && brush.state.extent.x0 !== -1
|
|
377
|
+
? { x0: brush.state.extent.x0, x1: brush.state.extent.x1 }
|
|
378
|
+
: previousExtent.current
|
|
379
|
+
|
|
380
|
+
return classifyBrushInteraction(x, extent, HANDLE_HIT_WIDTH)
|
|
381
|
+
}, [])
|
|
382
|
+
|
|
383
|
+
const getRelativeXFromClientX = useCallback((clientX: number) => {
|
|
384
|
+
const rect = containerRef.current?.getBoundingClientRect()
|
|
385
|
+
return rect ? clientX - rect.left : null
|
|
386
|
+
}, [])
|
|
387
|
+
|
|
388
|
+
const blockEvent = useCallback((event: React.SyntheticEvent | Event) => {
|
|
389
|
+
event.preventDefault()
|
|
390
|
+
event.stopPropagation()
|
|
391
|
+
|
|
392
|
+
const nativeEvent =
|
|
393
|
+
'nativeEvent' in event ? (event.nativeEvent as Event & { stopImmediatePropagation?: () => void }) : event
|
|
394
|
+
nativeEvent.stopImmediatePropagation?.()
|
|
395
|
+
}, [])
|
|
396
|
+
|
|
397
|
+
const blockOutsideInteraction = useCallback(
|
|
398
|
+
(event: React.PointerEvent | React.MouseEvent | React.TouchEvent) => {
|
|
399
|
+
const clientX = 'touches' in event ? event.touches[0]?.clientX : event.clientX
|
|
400
|
+
if (clientX === undefined) return
|
|
401
|
+
|
|
402
|
+
const x = getRelativeXFromClientX(clientX)
|
|
403
|
+
if (x === null) return
|
|
404
|
+
|
|
405
|
+
if (getBrushInteractionTarget(x) === 'outside') {
|
|
406
|
+
blockEvent(event)
|
|
407
|
+
}
|
|
408
|
+
},
|
|
409
|
+
[blockEvent, getBrushInteractionTarget, getRelativeXFromClientX]
|
|
410
|
+
)
|
|
411
|
+
|
|
357
412
|
// Fallback: Window mouseup listener to prevent stuck drag states
|
|
358
413
|
useEffect(() => {
|
|
359
414
|
const handleWindowMouseUp = () => {
|
|
360
415
|
// Check if brush is stuck in dragging state
|
|
361
416
|
if (brushRef.current?.state?.isBrushing) {
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
// Use reset method if available to clear the brush.
|
|
365
|
-
// If the visx Brush API is insufficient, consider filing an issue upstream.
|
|
366
|
-
if (typeof brush.reset === 'function') {
|
|
367
|
-
brush.reset()
|
|
368
|
-
return
|
|
369
|
-
}
|
|
370
|
-
// No reliable fallback: avoid DOM manipulation. If issues persist, document and address upstream.
|
|
417
|
+
finishBrushInteraction()
|
|
371
418
|
}
|
|
372
419
|
}
|
|
373
420
|
|
|
@@ -382,54 +429,46 @@ const BrushSelector: FC<BrushSelectorProps> = ({ xMax, yMax }) => {
|
|
|
382
429
|
window.removeEventListener('touchend', handleWindowMouseUp)
|
|
383
430
|
window.removeEventListener('mouseleave', handleWindowMouseUp)
|
|
384
431
|
}
|
|
385
|
-
}, [])
|
|
432
|
+
}, [finishBrushInteraction])
|
|
386
433
|
|
|
387
434
|
// Custom touch handling for mobile - bypasses broken @visx/brush touch support
|
|
388
435
|
useEffect(() => {
|
|
389
436
|
const container = containerRef.current
|
|
390
437
|
if (!container) return
|
|
391
438
|
|
|
392
|
-
const HANDLE_WIDTH = 24 // Width of the drag handles
|
|
393
|
-
|
|
394
439
|
const getRelativeX = (touch: Touch): number => {
|
|
395
440
|
const rect = container.getBoundingClientRect()
|
|
396
441
|
return touch.clientX - rect.left
|
|
397
442
|
}
|
|
398
443
|
|
|
399
|
-
const getDragType = (x: number): 'selection' | 'left-handle' | 'right-handle' | 'new-selection' => {
|
|
400
|
-
const brush = brushRef.current
|
|
401
|
-
if (!brush || brush.state.extent.x0 === -1) return 'new-selection'
|
|
402
|
-
|
|
403
|
-
const { x0, x1 } = brush.state.extent
|
|
404
|
-
|
|
405
|
-
// Check if touch is on handles (with some tolerance)
|
|
406
|
-
if (Math.abs(x - x0) < HANDLE_WIDTH) return 'left-handle'
|
|
407
|
-
if (Math.abs(x - x1) < HANDLE_WIDTH) return 'right-handle'
|
|
408
|
-
|
|
409
|
-
// Check if touch is on selection
|
|
410
|
-
if (x >= x0 && x <= x1) return 'selection'
|
|
411
|
-
|
|
412
|
-
// Touch is outside selection - create new selection
|
|
413
|
-
return 'new-selection'
|
|
414
|
-
}
|
|
415
|
-
|
|
416
444
|
const handleTouchStart = (e: TouchEvent) => {
|
|
417
445
|
if (e.touches.length !== 1) return
|
|
418
446
|
|
|
419
447
|
const touch = e.touches[0]
|
|
420
448
|
const x = getRelativeX(touch)
|
|
421
|
-
const dragType =
|
|
449
|
+
const dragType = getBrushInteractionTarget(x)
|
|
422
450
|
|
|
423
451
|
e.preventDefault()
|
|
424
452
|
e.stopPropagation()
|
|
425
453
|
|
|
454
|
+
if (dragType === 'outside') {
|
|
455
|
+
touchState.current = {
|
|
456
|
+
active: false,
|
|
457
|
+
startX: 0,
|
|
458
|
+
currentX: 0,
|
|
459
|
+
startExtent: { x0: 0, x1: 0 },
|
|
460
|
+
dragType: null
|
|
461
|
+
}
|
|
462
|
+
return
|
|
463
|
+
}
|
|
464
|
+
|
|
426
465
|
isUserInteracting.current = true
|
|
427
466
|
|
|
428
467
|
const brush = brushRef.current
|
|
429
468
|
const startExtent =
|
|
430
469
|
brush && brush.state.extent.x0 !== -1
|
|
431
470
|
? { x0: brush.state.extent.x0, x1: brush.state.extent.x1 }
|
|
432
|
-
: { x0: x, x1: x }
|
|
471
|
+
: previousExtent.current || { x0: x, x1: x }
|
|
433
472
|
|
|
434
473
|
touchState.current = {
|
|
435
474
|
active: true,
|
|
@@ -450,7 +489,7 @@ const BrushSelector: FC<BrushSelectorProps> = ({ xMax, yMax }) => {
|
|
|
450
489
|
const x = getRelativeX(touch)
|
|
451
490
|
touchState.current.currentX = x
|
|
452
491
|
const delta = x - touchState.current.startX
|
|
453
|
-
const { startExtent, dragType
|
|
492
|
+
const { startExtent, dragType } = touchState.current
|
|
454
493
|
const safeXMax = Math.max(xMax, 100)
|
|
455
494
|
const safeYMax = Math.max(yMax, BRUSH_HEIGHT + BRUSH_PADDING * 2)
|
|
456
495
|
const minSelectionWidth = 20
|
|
@@ -469,18 +508,6 @@ const BrushSelector: FC<BrushSelectorProps> = ({ xMax, yMax }) => {
|
|
|
469
508
|
} else if (dragType === 'right-handle') {
|
|
470
509
|
// Resize from right
|
|
471
510
|
newX1 = Math.max(startExtent.x0 + minSelectionWidth, Math.min(safeXMax, startExtent.x1 + delta))
|
|
472
|
-
} else if (dragType === 'new-selection') {
|
|
473
|
-
// Create new selection from start point to current point
|
|
474
|
-
newX0 = Math.max(0, Math.min(startX, x))
|
|
475
|
-
newX1 = Math.min(safeXMax, Math.max(startX, x))
|
|
476
|
-
// Ensure minimum width
|
|
477
|
-
if (newX1 - newX0 < minSelectionWidth) {
|
|
478
|
-
if (x > startX) {
|
|
479
|
-
newX1 = Math.min(safeXMax, newX0 + minSelectionWidth)
|
|
480
|
-
} else {
|
|
481
|
-
newX0 = Math.max(0, newX1 - minSelectionWidth)
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
511
|
}
|
|
485
512
|
|
|
486
513
|
// Update brush position
|
|
@@ -538,6 +565,8 @@ const BrushSelector: FC<BrushSelectorProps> = ({ xMax, yMax }) => {
|
|
|
538
565
|
if (brush && !brush.state?.isBrushing) {
|
|
539
566
|
isUserInteracting.current = false
|
|
540
567
|
}
|
|
568
|
+
|
|
569
|
+
finishBrushInteraction()
|
|
541
570
|
}
|
|
542
571
|
|
|
543
572
|
// Attach native event listeners with passive: false to allow preventDefault
|
|
@@ -550,7 +579,7 @@ const BrushSelector: FC<BrushSelectorProps> = ({ xMax, yMax }) => {
|
|
|
550
579
|
container.removeEventListener('touchmove', handleTouchMove)
|
|
551
580
|
container.removeEventListener('touchend', handleTouchEnd)
|
|
552
581
|
}
|
|
553
|
-
}, [xMax, yMax, xScale, tableData, dataKey, dispatch])
|
|
582
|
+
}, [xMax, yMax, xScale, tableData, dataKey, dispatch, finishBrushInteraction, getBrushInteractionTarget])
|
|
554
583
|
|
|
555
584
|
// Handle brush changes
|
|
556
585
|
const handleBrushChange = useCallback(
|
|
@@ -982,15 +1011,9 @@ const BrushSelector: FC<BrushSelectorProps> = ({ xMax, yMax }) => {
|
|
|
982
1011
|
}, [xMax])
|
|
983
1012
|
|
|
984
1013
|
// Track when brush interaction starts (mouse down) with position
|
|
985
|
-
const handleBrushStart = (
|
|
986
|
-
isMouseDown.current = true
|
|
1014
|
+
const handleBrushStart = () => {
|
|
987
1015
|
isUserInteracting.current = true
|
|
988
1016
|
|
|
989
|
-
// Store the starting mouse position (from the brush start object)
|
|
990
|
-
if (start && start.x !== undefined) {
|
|
991
|
-
mouseDownPos.current = { x: start.x, y: start.y || 0 }
|
|
992
|
-
}
|
|
993
|
-
|
|
994
1017
|
// Store the current extent before any changes
|
|
995
1018
|
const brush = brushRef.current
|
|
996
1019
|
if (brush && brush.state.extent.x0 !== -1) {
|
|
@@ -1003,7 +1026,6 @@ const BrushSelector: FC<BrushSelectorProps> = ({ xMax, yMax }) => {
|
|
|
1003
1026
|
|
|
1004
1027
|
// Track when brush interaction ends
|
|
1005
1028
|
const handleBrushEnd = () => {
|
|
1006
|
-
isMouseDown.current = false
|
|
1007
1029
|
// Reset isUserInteracting - handleBrushChange will be called after this
|
|
1008
1030
|
// and will check brush state to ensure it's truly done
|
|
1009
1031
|
const brush = brushRef.current
|
|
@@ -1018,8 +1040,6 @@ const BrushSelector: FC<BrushSelectorProps> = ({ xMax, yMax }) => {
|
|
|
1018
1040
|
if (event.pointerType === 'touch') {
|
|
1019
1041
|
return
|
|
1020
1042
|
}
|
|
1021
|
-
isMouseDown.current = false
|
|
1022
|
-
mouseDownPos.current = null
|
|
1023
1043
|
// Reset isUserInteracting - handleBrushChange will check brush state if needed
|
|
1024
1044
|
const brush = brushRef.current
|
|
1025
1045
|
if (brush && !brush.state?.isBrushing) {
|
|
@@ -1027,133 +1047,6 @@ const BrushSelector: FC<BrushSelectorProps> = ({ xMax, yMax }) => {
|
|
|
1027
1047
|
}
|
|
1028
1048
|
}
|
|
1029
1049
|
|
|
1030
|
-
// Handle click to move brush to clicked location
|
|
1031
|
-
const handleClick = (event: React.PointerEvent<SVGRectElement>) => {
|
|
1032
|
-
const DRAG_THRESHOLD = 5 // pixels - if mouse moved less than this, it's a click
|
|
1033
|
-
const HANDLE_WIDTH = 8 // Width of the brush handles
|
|
1034
|
-
|
|
1035
|
-
// Calculate click position relative to the brush area (accounting for BRUSH_PADDING)
|
|
1036
|
-
const svgRect = event.currentTarget.ownerSVGElement?.getBoundingClientRect()
|
|
1037
|
-
if (!svgRect) {
|
|
1038
|
-
return
|
|
1039
|
-
}
|
|
1040
|
-
|
|
1041
|
-
const clickX = event.clientX - svgRect.left - BRUSH_PADDING
|
|
1042
|
-
|
|
1043
|
-
// Check if we have a mouse down position to compare against
|
|
1044
|
-
if (!mouseDownPos.current) {
|
|
1045
|
-
mouseDownPos.current = null
|
|
1046
|
-
return
|
|
1047
|
-
}
|
|
1048
|
-
|
|
1049
|
-
// Calculate distance moved since mouse down
|
|
1050
|
-
const dragDistance = Math.abs(clickX - mouseDownPos.current.x)
|
|
1051
|
-
|
|
1052
|
-
// Reset mouse down position for next interaction
|
|
1053
|
-
mouseDownPos.current = null
|
|
1054
|
-
|
|
1055
|
-
// Only proceed if this was a click (minimal movement), not a drag
|
|
1056
|
-
if (dragDistance > DRAG_THRESHOLD) {
|
|
1057
|
-
return
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
// Use the previous extent (before visx potentially changed it)
|
|
1061
|
-
if (!previousExtent.current) {
|
|
1062
|
-
return
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
const brush = brushRef.current
|
|
1066
|
-
if (!brush || !tableData.length) {
|
|
1067
|
-
return
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1070
|
-
const prevExtent = previousExtent.current
|
|
1071
|
-
|
|
1072
|
-
// Check if click was on a handle (ignore clicks on handles)
|
|
1073
|
-
const currentWidth = Math.abs(prevExtent.x1 - prevExtent.x0)
|
|
1074
|
-
const leftHandleStart = Math.min(prevExtent.x0, prevExtent.x1) - HANDLE_WIDTH / 2
|
|
1075
|
-
const leftHandleEnd = Math.min(prevExtent.x0, prevExtent.x1) + HANDLE_WIDTH / 2
|
|
1076
|
-
const rightHandleStart = Math.max(prevExtent.x0, prevExtent.x1) - HANDLE_WIDTH / 2
|
|
1077
|
-
const rightHandleEnd = Math.max(prevExtent.x0, prevExtent.x1) + HANDLE_WIDTH / 2
|
|
1078
|
-
|
|
1079
|
-
// If click was on a handle, do nothing
|
|
1080
|
-
if (
|
|
1081
|
-
(clickX >= leftHandleStart && clickX <= leftHandleEnd) ||
|
|
1082
|
-
(clickX >= rightHandleStart && clickX <= rightHandleEnd)
|
|
1083
|
-
) {
|
|
1084
|
-
return
|
|
1085
|
-
}
|
|
1086
|
-
|
|
1087
|
-
// Check if click was inside the current selection (ignore clicks inside selection to allow dragging)
|
|
1088
|
-
const selectionStart = Math.min(prevExtent.x0, prevExtent.x1)
|
|
1089
|
-
const selectionEnd = Math.max(prevExtent.x0, prevExtent.x1)
|
|
1090
|
-
|
|
1091
|
-
if (clickX > selectionStart + HANDLE_WIDTH && clickX < selectionEnd - HANDLE_WIDTH) {
|
|
1092
|
-
return
|
|
1093
|
-
}
|
|
1094
|
-
|
|
1095
|
-
// Calculate the new brush position centered on click
|
|
1096
|
-
const halfWidth = currentWidth / 2
|
|
1097
|
-
let newX0 = clickX - halfWidth
|
|
1098
|
-
let newX1 = clickX + halfWidth
|
|
1099
|
-
|
|
1100
|
-
// Ensure the new position stays within bounds
|
|
1101
|
-
const safeXMax = Math.max(xMax, 100)
|
|
1102
|
-
if (newX0 < 0) {
|
|
1103
|
-
newX0 = 0
|
|
1104
|
-
newX1 = currentWidth
|
|
1105
|
-
} else if (newX1 > safeXMax) {
|
|
1106
|
-
newX1 = safeXMax
|
|
1107
|
-
newX0 = safeXMax - currentWidth
|
|
1108
|
-
}
|
|
1109
|
-
|
|
1110
|
-
// Get the domain values for the new range
|
|
1111
|
-
const domain = xScale.domain()
|
|
1112
|
-
const xValues: string[] = []
|
|
1113
|
-
|
|
1114
|
-
for (const value of domain) {
|
|
1115
|
-
const xPos = xScale(value)
|
|
1116
|
-
if (xPos !== undefined && xPos >= newX0 && xPos < newX1) {
|
|
1117
|
-
xValues.push(value)
|
|
1118
|
-
}
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
|
-
// Update the brush position
|
|
1122
|
-
if (xValues.length > 0) {
|
|
1123
|
-
const safeYMax = Math.max(yMax, BRUSH_HEIGHT + BRUSH_PADDING * 2)
|
|
1124
|
-
const newBounds: Bounds = {
|
|
1125
|
-
x0: newX0,
|
|
1126
|
-
x1: newX1,
|
|
1127
|
-
y0: 0,
|
|
1128
|
-
y1: safeYMax,
|
|
1129
|
-
xValues
|
|
1130
|
-
}
|
|
1131
|
-
|
|
1132
|
-
handleBrushChange(newBounds)
|
|
1133
|
-
|
|
1134
|
-
// Update the brush ref state directly to move the visual selection
|
|
1135
|
-
if (brush.updateBrush) {
|
|
1136
|
-
brush.updateBrush(() => ({
|
|
1137
|
-
...brush.state,
|
|
1138
|
-
extent: {
|
|
1139
|
-
x0: newX0,
|
|
1140
|
-
x1: newX1,
|
|
1141
|
-
y0: 0,
|
|
1142
|
-
y1: safeYMax
|
|
1143
|
-
},
|
|
1144
|
-
start: { x: newX0, y: 0 },
|
|
1145
|
-
end: { x: newX1, y: safeYMax }
|
|
1146
|
-
}))
|
|
1147
|
-
|
|
1148
|
-
// Store the new extent as the previous extent for the next click
|
|
1149
|
-
previousExtent.current = {
|
|
1150
|
-
x0: newX0,
|
|
1151
|
-
x1: newX1
|
|
1152
|
-
}
|
|
1153
|
-
}
|
|
1154
|
-
}
|
|
1155
|
-
}
|
|
1156
|
-
|
|
1157
1050
|
// Ensure positive dimensions to avoid SVG errors
|
|
1158
1051
|
const safeXMax = Math.max(xMax, 100) // Minimum width of 100px
|
|
1159
1052
|
const safeYMax = Math.max(yMax, BRUSH_HEIGHT + BRUSH_PADDING * 2) // Minimum height with padding
|
|
@@ -1213,6 +1106,10 @@ const BrushSelector: FC<BrushSelectorProps> = ({ xMax, yMax }) => {
|
|
|
1213
1106
|
}}
|
|
1214
1107
|
role='group'
|
|
1215
1108
|
aria-label='Date range selector'
|
|
1109
|
+
onPointerDownCapture={blockOutsideInteraction}
|
|
1110
|
+
onMouseDownCapture={blockOutsideInteraction}
|
|
1111
|
+
onTouchStartCapture={blockOutsideInteraction}
|
|
1112
|
+
onClickCapture={blockOutsideInteraction}
|
|
1216
1113
|
>
|
|
1217
1114
|
<svg
|
|
1218
1115
|
width={safeXMax}
|
|
@@ -1226,6 +1123,10 @@ const BrushSelector: FC<BrushSelectorProps> = ({ xMax, yMax }) => {
|
|
|
1226
1123
|
userSelect: 'none'
|
|
1227
1124
|
}}
|
|
1228
1125
|
aria-hidden='true'
|
|
1126
|
+
onPointerDownCapture={blockOutsideInteraction}
|
|
1127
|
+
onMouseDownCapture={blockOutsideInteraction}
|
|
1128
|
+
onTouchStartCapture={blockOutsideInteraction}
|
|
1129
|
+
onClickCapture={blockOutsideInteraction}
|
|
1229
1130
|
>
|
|
1230
1131
|
{/* Pattern definition for brush selection */}
|
|
1231
1132
|
<defs>
|
|
@@ -1269,11 +1170,11 @@ const BrushSelector: FC<BrushSelectorProps> = ({ xMax, yMax }) => {
|
|
|
1269
1170
|
onChange={handleBrushChange}
|
|
1270
1171
|
selectedBoxStyle={selectedBoxStyle}
|
|
1271
1172
|
useWindowMoveEvents={true} // Track mouse movements outside the brush area
|
|
1173
|
+
disableDraggingOverlay={true}
|
|
1272
1174
|
resizeTriggerAreas={['left', 'right']} // Enable resize handles on both sides
|
|
1273
1175
|
innerRef={brushRef}
|
|
1274
1176
|
renderBrushHandle={props => <BrushHandle {...props} />}
|
|
1275
1177
|
initialBrushPosition={initialBrushPosition}
|
|
1276
|
-
onClick={handleClick}
|
|
1277
1178
|
onBrushStart={handleBrushStart}
|
|
1278
1179
|
onBrushEnd={handleBrushEnd}
|
|
1279
1180
|
onMouseLeave={handleMouseLeave}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type BrushExtent = { x0: number; x1: number } | null | undefined
|
|
2
|
+
|
|
3
|
+
export type BrushInteractionTarget = 'left-handle' | 'right-handle' | 'selection' | 'outside'
|
|
4
|
+
|
|
5
|
+
export const classifyBrushInteraction = (
|
|
6
|
+
x: number,
|
|
7
|
+
extent: BrushExtent,
|
|
8
|
+
handleHitWidth: number
|
|
9
|
+
): BrushInteractionTarget => {
|
|
10
|
+
if (!extent || !isFinite(extent.x0) || !isFinite(extent.x1) || extent.x0 === -1 || extent.x1 === -1) {
|
|
11
|
+
return 'outside'
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const selectionStart = Math.min(extent.x0, extent.x1)
|
|
15
|
+
const selectionEnd = Math.max(extent.x0, extent.x1)
|
|
16
|
+
|
|
17
|
+
if (Math.abs(x - selectionStart) <= handleHitWidth) return 'left-handle'
|
|
18
|
+
if (Math.abs(x - selectionEnd) <= handleHitWidth) return 'right-handle'
|
|
19
|
+
if (x >= selectionStart && x <= selectionEnd) return 'selection'
|
|
20
|
+
|
|
21
|
+
return 'outside'
|
|
22
|
+
}
|