@cdc/chart 4.26.3 → 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.
Files changed (192) hide show
  1. package/CONFIG.md +489 -0
  2. package/README.md +42 -17
  3. package/dist/cdcchart.js +39024 -36587
  4. package/examples/feature/bar/additional-column-tooltip.json +3 -3
  5. package/examples/feature/deviation/planet-deviation-config.json +2 -2
  6. package/examples/feature/heatmap/average-age-categorical.json +313 -0
  7. package/examples/feature/heatmap/calendar-heatmap.json +134 -0
  8. package/examples/feature/heatmap/cell-values-bottom-axis.json +181 -0
  9. package/examples/feature/heatmap/sparse-aggregation.json +158 -0
  10. package/examples/feature/paired-bar/paired-bar-example.json +2 -2
  11. package/examples/feature/tests-case-rate/case-rate-example-config.json +2 -2
  12. package/examples/feature/tests-covid/covid-confidence-example-config.json +2 -2
  13. package/examples/feature/tests-covid/covid-example-config.json +2 -2
  14. package/examples/feature/tests-cutoff/cutoff-example-config.json +2 -2
  15. package/examples/feature/tests-non-numerics/planet-pie-example-config-nonnumeric.json +2 -2
  16. package/examples/metadata-variables.json +1 -1
  17. package/examples/minimal-example.json +24 -0
  18. package/examples/private/bump-chart.json +1116 -0
  19. package/examples/private/test-123.json +624 -0
  20. package/examples/private/test2.json +35012 -0
  21. package/package.json +5 -4
  22. package/src/CdcChartComponent.tsx +144 -60
  23. package/src/_stories/Chart.AltText.stories.tsx +136 -0
  24. package/src/_stories/{Chart.HTMLInDataTable.stories.tsx → Chart.HTMLInDataTable.smoke.stories.tsx} +10 -0
  25. package/src/_stories/Chart.HeatMap.stories.tsx +187 -0
  26. package/src/_stories/Chart.Minimal.smoke.stories.tsx +25 -0
  27. package/src/_stories/Chart.Sankey.smoke.stories.tsx +62 -0
  28. package/src/_stories/Chart.SmallMultiples.smoke.stories.tsx +108 -0
  29. package/src/_stories/{Chart.stories.tsx → Chart.smoke.stories.tsx} +12 -0
  30. package/src/_stories/{Chart.tooltip.stories.tsx → Chart.tooltip.smoke.stories.tsx} +68 -0
  31. package/src/_stories/{ChartAxisTitles.stories.tsx → ChartAxisTitles.smoke.stories.tsx} +13 -0
  32. package/src/_stories/ChartBar.Editor.stories.tsx +151 -85
  33. package/src/_stories/ChartBrush.Interaction.stories.tsx +112 -0
  34. package/src/_stories/{ChartBrush.stories.tsx → ChartBrush.smoke.stories.tsx} +13 -4
  35. package/src/_stories/ChartEditor.Editor.stories.tsx +23 -0
  36. package/src/_stories/ChartLine.DashedDates.smoke.stories.tsx +23 -0
  37. package/src/_stories/_mock/alt_text_metadata.json +38 -0
  38. package/src/_stories/_mock/heatmap-average-age-categorical.json +315 -0
  39. package/src/_stories/_mock/heatmap.json +156 -0
  40. package/src/_stories/_mock/line_chart_dashed_dates.json +276 -0
  41. package/src/_stories/_mock/paired-bar-abbr.json +1 -1
  42. package/src/_stories/_mock/paired-bar.json +1 -1
  43. package/src/_stories/_mock/reported_pertussis_cases.json +1643 -0
  44. package/src/_stories/_mock/sankey_mock.json +78 -0
  45. package/src/_stories/_mock/small_multiples/small_multiples_lines.json +1 -1
  46. package/src/components/Annotations/components/AnnotationDraggable.tsx +2 -2
  47. package/src/components/Annotations/components/AnnotationDropdown.tsx +5 -2
  48. package/src/components/AreaChart/components/AreaChart.Stacked.jsx +10 -2
  49. package/src/components/Axis/BottomAxis.tsx +1 -1
  50. package/src/components/Axis/LeftAxis.tsx +15 -14
  51. package/src/components/Axis/LeftAxisGridlines.tsx +14 -13
  52. package/src/components/BarChart/components/BarChart.Horizontal.tsx +9 -5
  53. package/src/components/BarChart/components/BarChart.StackedHorizontal.tsx +9 -5
  54. package/src/components/BarChart/components/BarChart.StackedVertical.tsx +9 -5
  55. package/src/components/BarChart/components/BarChart.Vertical.tsx +9 -6
  56. package/src/components/BarChart/components/BarChart.tsx +2 -6
  57. package/src/components/BarChart/helpers/useBarChart.ts +1 -1
  58. package/src/components/BoxPlot/BoxPlot.Vertical.tsx +3 -3
  59. package/src/components/Brush/BrushSelector.tsx +88 -187
  60. package/src/components/Brush/brushInteraction.ts +22 -0
  61. package/src/components/Brush/tests/brushInteraction.test.ts +38 -0
  62. package/src/components/EditorPanel/EditorPanel.tsx +946 -960
  63. package/src/components/EditorPanel/EditorPanelContext.ts +1 -1
  64. package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +229 -165
  65. package/src/components/EditorPanel/components/Panels/Panel.General.tsx +105 -13
  66. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +169 -116
  67. package/src/components/EditorPanel/components/Panels/Panel.Regions.tsx +159 -109
  68. package/src/components/EditorPanel/components/Panels/Panel.Sankey.tsx +3 -3
  69. package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +14 -28
  70. package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +1 -1
  71. package/src/components/EditorPanel/helpers/getVisualizationTypeConfigUpdate.ts +57 -0
  72. package/src/components/EditorPanel/helpers/tests/getVisualizationTypeConfigUpdate.test.ts +56 -0
  73. package/src/components/EditorPanel/useEditorPermissions.test.tsx +70 -0
  74. package/src/components/EditorPanel/useEditorPermissions.ts +32 -21
  75. package/src/components/Forecasting/Forecasting.tsx +2 -2
  76. package/src/components/HeatMap/components/HeatMap.tsx +762 -0
  77. package/src/components/HeatMap/components/HeatMapGradientLegend.tsx +114 -0
  78. package/src/components/HeatMap/heatmap.constants.ts +13 -0
  79. package/src/components/HeatMap/heatmap.css +71 -0
  80. package/src/components/HeatMap/helpers/index.ts +261 -0
  81. package/src/components/HeatMap/index.tsx +5 -0
  82. package/src/components/HeatMap/tests/HeatMap.test.tsx +1040 -0
  83. package/src/components/HeatMap/tests/helpers.test.ts +167 -0
  84. package/src/components/HorizonChart/HorizonChart.tsx +10 -2
  85. package/src/components/HoverLine/HoverLine.tsx +6 -8
  86. package/src/components/Legend/Legend.Component.tsx +4 -4
  87. package/src/components/Legend/LegendGroup/LegendGroup.styles.css +5 -0
  88. package/src/components/Legend/LegendGroup/LegendGroup.tsx +3 -1
  89. package/src/components/Legend/helpers/index.ts +0 -2
  90. package/src/components/LineChart/LineChartProps.ts +1 -0
  91. package/src/components/LineChart/components/LineChart.BumpCircle.tsx +22 -24
  92. package/src/components/LineChart/helpers.test.ts +345 -0
  93. package/src/components/LineChart/helpers.ts +15 -5
  94. package/src/components/LineChart/index.tsx +18 -5
  95. package/src/components/LinearChart/VisualizationRenderer.tsx +11 -1
  96. package/src/components/LinearChart/tests/LinearChart.test.tsx +228 -7
  97. package/src/components/LinearChart/tests/mockConfigContext.ts +1 -0
  98. package/src/components/LinearChart.tsx +155 -77
  99. package/src/components/PairedBarChart.jsx +2 -2
  100. package/src/components/PieChart/PieChart.tsx +15 -10
  101. package/src/components/RadarChart/RadarChart.tsx +3 -1
  102. package/src/components/Regions/components/Regions.tsx +199 -35
  103. package/src/components/Sankey/components/Sankey.tsx +7 -3
  104. package/src/components/Sankey/helpers/getSankeyTooltip.tsx +7 -6
  105. package/src/components/ScatterPlot/ScatterPlot.jsx +19 -17
  106. package/src/components/SmallMultiples/SmallMultipleTile.tsx +31 -3
  107. package/src/components/SmallMultiples/SmallMultiples.css +12 -0
  108. package/src/components/SmallMultiples/SmallMultiples.tsx +15 -9
  109. package/src/components/SmallMultiples/tests/SmallMultipleTile.test.tsx +152 -0
  110. package/src/components/Sparkline/components/SparkLine.tsx +5 -10
  111. package/src/components/WarmingStripes/WarmingStripes.tsx +5 -3
  112. package/src/components/WarmingStripes/WarmingStripesGradientLegend.css +1 -1
  113. package/src/components/WarmingStripes/WarmingStripesGradientLegend.tsx +2 -2
  114. package/src/data/initial-state.js +12 -0
  115. package/src/helpers/axisLabelFontSize.ts +8 -0
  116. package/src/helpers/bumpChartTooltip.ts +54 -0
  117. package/src/helpers/calculateLeftYAxisWidth.ts +197 -0
  118. package/src/helpers/ensureSpecialChartAxisTypes.ts +11 -0
  119. package/src/helpers/getMinMax.ts +2 -3
  120. package/src/helpers/getYAxisAutoPadding.ts +8 -6
  121. package/src/helpers/getYAxisAutoPaddingMode.ts +17 -0
  122. package/src/helpers/handleChartAriaLabels.ts +23 -10
  123. package/src/helpers/seriesHighlight.ts +26 -0
  124. package/src/helpers/tests/axisLabelFontSize.test.ts +15 -0
  125. package/src/helpers/tests/bumpChartTooltip.test.ts +75 -0
  126. package/src/helpers/tests/ensureSpecialChartAxisTypes.test.ts +30 -0
  127. package/src/helpers/tests/getYAxisAutoPadding.test.ts +37 -0
  128. package/src/helpers/tests/getYAxisAutoPaddingMode.test.ts +90 -0
  129. package/src/helpers/tests/handleChartAriaLabels.test.ts +71 -0
  130. package/src/helpers/tests/seriesHighlight.test.ts +61 -0
  131. package/src/helpers/tests/tooltipHelpers.test.ts +151 -0
  132. package/src/helpers/tooltipHelpers.ts +176 -0
  133. package/src/hooks/tests/useScales.test.tsx +105 -0
  134. package/src/hooks/useHighlightedBars.ts +9 -16
  135. package/src/hooks/useProgrammaticTooltip.ts +16 -2
  136. package/src/hooks/useScales.ts +19 -9
  137. package/src/hooks/useSmallMultipleSynchronization.ts +3 -4
  138. package/src/hooks/useTooltip.tsx +167 -38
  139. package/src/index.jsx +5 -2
  140. package/src/scss/main.scss +32 -49
  141. package/src/test/CdcChart.test.jsx +24 -0
  142. package/src/types/ChartConfig.ts +19 -0
  143. package/src/types/ChartContext.ts +2 -0
  144. package/examples/chart-regression-2.json +0 -2360
  145. package/examples/feature/__data__/age-adjusted-rates.json +0 -1486
  146. package/examples/feature/__data__/city-temperature.json +0 -2198
  147. package/examples/feature/bar/Barchart_with_negative.json +0 -34
  148. package/examples/feature/boxplot/box-plot-data.json +0 -71
  149. package/examples/feature/boxplot/boxplot-data.json +0 -156
  150. package/examples/feature/combo/combochart-categories_are_numbers .json +0 -18
  151. package/examples/feature/forest-plot/log.json +0 -26
  152. package/examples/feature/tests-date-exclusions/date-exclusions-data.json +0 -162
  153. package/examples/region-issue.json +0 -2065
  154. package/examples/xaxis.json +0 -493
  155. package/src/_stories/Chart.SmallMultiples.stories.tsx +0 -63
  156. /package/examples/{data → __data__}/data-with-metadata.json +0 -0
  157. /package/examples/feature/bar/{tall-data.json → __data__/tall-data.json} +0 -0
  158. /package/examples/feature/deviation/{planet-deviation-data.json → __data__/planet-deviation-data.json} +0 -0
  159. /package/examples/feature/paired-bar/{paired-bar-data.json → __data__/paired-bar-data.json} +0 -0
  160. /package/examples/feature/paired-bar/{paired-bar-formatted.json → __data__/paired-bar-formatted.json} +0 -0
  161. /package/examples/feature/tests-case-rate/{case-rate-example-data.json → __data__/case-rate-example-data.json} +0 -0
  162. /package/examples/feature/tests-covid/{covid-example-data-confidence.json → __data__/covid-example-data-confidence.json} +0 -0
  163. /package/examples/feature/tests-covid/{covid-example-data.json → __data__/covid-example-data.json} +0 -0
  164. /package/examples/feature/tests-cutoff/{cutoff-example-data.json → __data__/cutoff-example-data.json} +0 -0
  165. /package/examples/feature/tests-non-numerics/{planet-example-data-nonnumeric.json → __data__/planet-example-data-nonnumeric.json} +0 -0
  166. /package/src/_stories/{Chart.Anchors.stories.tsx → Chart.Anchors.smoke.stories.tsx} +0 -0
  167. /package/src/_stories/{Chart.BoxPlot.stories.tsx → Chart.BoxPlot.smoke.stories.tsx} +0 -0
  168. /package/src/_stories/{Chart.CI.stories.tsx → Chart.CI.smoke.stories.tsx} +0 -0
  169. /package/src/_stories/{Chart.Combo.stories.tsx → Chart.Combo.smoke.stories.tsx} +0 -0
  170. /package/src/_stories/{Chart.CustomColors.stories.tsx → Chart.CustomColors.smoke.stories.tsx} +0 -0
  171. /package/src/_stories/{Chart.Defaults.stories.tsx → Chart.Defaults.smoke.stories.tsx} +0 -0
  172. /package/src/_stories/{Chart.DynamicSeries.stories.tsx → Chart.DynamicSeries.smoke.stories.tsx} +0 -0
  173. /package/src/_stories/{Chart.Filters.stories.tsx → Chart.Filters.smoke.stories.tsx} +0 -0
  174. /package/src/_stories/{Chart.Forecast.stories.tsx → Chart.Forecast.smoke.stories.tsx} +0 -0
  175. /package/src/_stories/{Chart.Legend.Gradient.stories.tsx → Chart.Legend.Gradient.smoke.stories.tsx} +0 -0
  176. /package/src/_stories/{Chart.Patterns.stories.tsx → Chart.Patterns.smoke.stories.tsx} +0 -0
  177. /package/src/_stories/{Chart.PreserveDecimals.stories.tsx → Chart.PreserveDecimals.smoke.stories.tsx} +0 -0
  178. /package/src/_stories/{Chart.Regions.Categorical.stories.tsx → Chart.Regions.Categorical.smoke.stories.tsx} +0 -0
  179. /package/src/_stories/{Chart.Regions.DateScale.stories.tsx → Chart.Regions.DateScale.smoke.stories.tsx} +0 -0
  180. /package/src/_stories/{Chart.Regions.DateTimeScale.stories.tsx → Chart.Regions.DateTimeScale.smoke.stories.tsx} +0 -0
  181. /package/src/_stories/{Chart.ScatterPlot.stories.tsx → Chart.ScatterPlot.smoke.stories.tsx} +0 -0
  182. /package/src/_stories/{Chart.SmallestLeftAxisMax.stories.tsx → Chart.SmallestLeftAxisMax.smoke.stories.tsx} +0 -0
  183. /package/src/_stories/{ChartAnnotation.stories.tsx → ChartAnnotation.smoke.stories.tsx} +0 -0
  184. /package/src/_stories/{ChartAxisLabels.stories.tsx → ChartAxisLabels.smoke.stories.tsx} +0 -0
  185. /package/src/_stories/{ChartBrush.Matrix.Continuous.stories.tsx → ChartBrush.Matrix.Continuous.smoke.stories.tsx} +0 -0
  186. /package/src/_stories/{ChartBrush.Matrix.Date.stories.tsx → ChartBrush.Matrix.Date.smoke.stories.tsx} +0 -0
  187. /package/src/_stories/{ChartBrush.Matrix.DateTime.stories.tsx → ChartBrush.Matrix.DateTime.smoke.stories.tsx} +0 -0
  188. /package/src/_stories/{ChartLine.QuadrantAngles.stories.tsx → ChartLine.QuadrantAngles.smoke.stories.tsx} +0 -0
  189. /package/src/_stories/{ChartLine.Suppression.stories.tsx → ChartLine.Suppression.smoke.stories.tsx} +0 -0
  190. /package/src/_stories/{ChartLine.Symbols.stories.tsx → ChartLine.Symbols.smoke.stories.tsx} +0 -0
  191. /package/src/_stories/{ChartPrefixSuffix.stories.tsx → ChartPrefixSuffix.smoke.stories.tsx} +0 -0
  192. /package/src/_stories/{TechAdoptionWithLinks.stories.tsx → TechAdoptionWithLinks.smoke.stories.tsx} +0 -0
@@ -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: 'selection' | 'left-handle' | 'right-handle' | 'new-selection' | null
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
- const brush = brushRef.current
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 = getDragType(x)
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 } // For new selection, start at touch point
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, startX } = touchState.current
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 = (start: any) => {
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
+ }
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { classifyBrushInteraction } from '../brushInteraction'
3
+
4
+ describe('classifyBrushInteraction', () => {
5
+ const extent = { x0: 100, x1: 300 }
6
+ const handleHitWidth = 24
7
+
8
+ it('allows interactions inside the selection', () => {
9
+ expect(classifyBrushInteraction(200, extent, handleHitWidth)).toBe('selection')
10
+ })
11
+
12
+ it('allows interactions within the left handle hit area', () => {
13
+ expect(classifyBrushInteraction(82, extent, handleHitWidth)).toBe('left-handle')
14
+ expect(classifyBrushInteraction(124, extent, handleHitWidth)).toBe('left-handle')
15
+ })
16
+
17
+ it('allows interactions within the right handle hit area', () => {
18
+ expect(classifyBrushInteraction(276, extent, handleHitWidth)).toBe('right-handle')
19
+ expect(classifyBrushInteraction(318, extent, handleHitWidth)).toBe('right-handle')
20
+ })
21
+
22
+ it('blocks interactions outside the selection and handle hit areas', () => {
23
+ expect(classifyBrushInteraction(50, extent, handleHitWidth)).toBe('outside')
24
+ expect(classifyBrushInteraction(350, extent, handleHitWidth)).toBe('outside')
25
+ })
26
+
27
+ it('handles reversed extents', () => {
28
+ expect(classifyBrushInteraction(200, { x0: 300, x1: 100 }, handleHitWidth)).toBe('selection')
29
+ expect(classifyBrushInteraction(82, { x0: 300, x1: 100 }, handleHitWidth)).toBe('left-handle')
30
+ expect(classifyBrushInteraction(318, { x0: 300, x1: 100 }, handleHitWidth)).toBe('right-handle')
31
+ })
32
+
33
+ it('blocks interactions for uninitialized or invalid extents', () => {
34
+ expect(classifyBrushInteraction(100, null, handleHitWidth)).toBe('outside')
35
+ expect(classifyBrushInteraction(100, { x0: -1, x1: -1 }, handleHitWidth)).toBe('outside')
36
+ expect(classifyBrushInteraction(100, { x0: Number.NaN, x1: 200 }, handleHitWidth)).toBe('outside')
37
+ })
38
+ })