From 73cd72c9f1330bd71e2ff29f3ba80acc124bcf5e Mon Sep 17 00:00:00 2001 From: Anders Kaseorg Date: Thu, 8 Oct 2026 21:54:00 +0000 Subject: [PATCH] fix(interaction): keep brushX selection span while dragging Dragging a brushX selection that snaps to explicit values snapped each edge to its nearest value separately. When D3 stopped the selection at the plot edge, half a step past the first or last value, the opposite edge landed exactly between two values and the range lost a value. Instead, snap the edge leading the drag, judged from where the drag started, and snap the other edge at the selection's width from it. On evenly spaced values that keeps the number of selected values; on unevenly spaced values it keeps the selection's width on screen. Co-Authored-By: Claude Opus 5.5 --- .changeset/brush-x-selection-span.md | 5 + .../charts-core/src/interaction-brush.test.ts | 140 ++++++++++++++++++ packages/charts-core/src/interaction-brush.ts | 42 +++++- 3 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 .changeset/brush-x-selection-span.md diff --git a/.changeset/brush-x-selection-span.md b/.changeset/brush-x-selection-span.md new file mode 100644 index 00000000..402cab41 --- /dev/null +++ b/.changeset/brush-x-selection-span.md @@ -0,0 +1,5 @@ +--- +'@tanstack/charts': patch +--- + +Keep the number of selected values when dragging a `brushX` selection with evenly spaced explicit `values`, including when the drag stops at the edge of the plot. diff --git a/packages/charts-core/src/interaction-brush.test.ts b/packages/charts-core/src/interaction-brush.test.ts index 759c9aea..1c3f9b52 100644 --- a/packages/charts-core/src/interaction-brush.test.ts +++ b/packages/charts-core/src/interaction-brush.test.ts @@ -268,6 +268,59 @@ describe('brushX', () => { container.remove() }) + it('keeps the number of values when dragging the selection to an edge', () => { + const brush = mountDailyBrush(range(days[1]!, days[3]!)) + + dragMouse(brush.selection(), brush.center(), brush.center() - 1000) + expect(brush.commits.at(-1)).toEqual(range(days[0]!, days[2]!)) + + dragMouse(brush.selection(), brush.center(), brush.center() + 1000) + expect(brush.commits.at(-1)).toEqual(range(days[2]!, days[4]!)) + + brush.destroy() + }) + + it('snaps the edge leading a drag on an unevenly spaced axis', () => { + const values = [0, 1, 100, 101, 102].map(day) + const brush = mountDailyBrush(range(values[1]!, values[2]!), values) + const dayWidth = Number(brush.selection().getAttribute('width')) / 99 + const from = brush.center() + + mouse(brush.selection(), 'mousedown', from) + mouse(window, 'mousemove', from + 0.7 * dayWidth) + expect(brush.value()).toEqual(range(values[1]!, values[3]!)) + // The leading edge depends on the drag's direction from where it + // started, not on the last movement. + mouse(window, 'mousemove', from + 0.6 * dayWidth) + expect(brush.value()).toEqual(range(values[1]!, values[3]!)) + mouse(window, 'mouseup', from + 0.6 * dayWidth) + expect(brush.value()).toEqual(range(values[1]!, values[3]!)) + + dragMouse(brush.selection(), brush.center(), brush.center() + 1000) + expect(brush.value()).toEqual(range(values[1]!, values[4]!)) + + dragMouse(brush.selection(), brush.center(), brush.center() - 1000) + expect(brush.value()).toEqual(range(values[0]!, values[3]!)) + + brush.destroy() + }) + + it('keeps the dragged range when the values change during the drag', () => { + const values = days.slice(0, 4) + const brush = mountDailyBrush(range(values[1]!, values[2]!), values) + const step = Number(brush.selection().getAttribute('width')) + const from = brush.center() + + mouse(brush.selection(), 'mousedown', from) + mouse(window, 'mousemove', from + step) + expect(brush.value()).toEqual(range(values[2]!, values[3]!)) + brush.setValues([values[0]!, values[2]!, values[3]!]) + mouse(window, 'mouseup', from + step) + expect(brush.value()).toEqual(range(values[2]!, values[3]!)) + + brush.destroy() + }) + it('rejects duplicate and nonmonotone authored values at scene resolution', () => { const value = range(dates[0], dates[2]) expect(() => @@ -340,6 +393,76 @@ function definition( }) } +function day(index: number) { + return new Date(Date.UTC(2024, 0, 1 + index)) +} + +const days = Array.from({ length: 5 }, (_, index) => day(index)) + +function mountDailyBrush(initial: BrushRange, values = days) { + const halfDay = 43_200_000 + const container = document.createElement('div') + document.body.append(container) + const domain = [ + new Date(values[0]!.getTime() - halfDay), + new Date(values.at(-1)!.getTime() + halfDay), + ] + let value = initial + const commits: BrushRange[] = [] + const options = () => ({ + definition: defineChart({ + marks: [ + dot( + values.map((date, index) => ({ date, value: index })), + { x: 'date', y: 'value' }, + ), + ], + scales: { + x: { scale: scaleUtc().domain(domain) }, + y: { scale: scaleLinear }, + }, + controls: [ + brushX({ + id: 'window', + range: controlledSignal, BrushXChange>( + value, + (next, { reason }) => { + value = next + if (reason.type === 'commit') commits.push(next) + host.update(options()) + }, + ), + values, + }), + ], + }), + width: 480, + height: 240, + ariaLabel: 'Daily range', + }) + const host = mountChart(container, options()) + const selection = () => + container.querySelector( + '[data-chart-brush="window"] .selection', + )! + return { + commits, + value: () => value, + setValues(next: Date[]) { + values = next + host.update(options()) + }, + selection, + center: () => + Number(selection().getAttribute('x')) + + Number(selection().getAttribute('width')) / 2, + destroy() { + host.destroy() + container.remove() + }, + } +} + function range(start: Date, end: Date): BrushRange { return { start, end } } @@ -357,6 +480,23 @@ function beginMouseBrush(container: HTMLElement) { overlay.dispatchEvent(event) } +function dragMouse(target: Element, fromX: number, toX: number) { + mouse(target, 'mousedown', fromX) + mouse(window, 'mousemove', toX) + mouse(window, 'mouseup', toX) +} + +function mouse(target: EventTarget, type: string, clientX: number) { + const event = new MouseEvent(type, { + bubbles: true, + cancelable: true, + clientX, + clientY: 100, + }) + Object.defineProperty(event, 'view', { value: window }) + target.dispatchEvent(event) +} + interface TestTouch { readonly identifier: number readonly clientX: number diff --git a/packages/charts-core/src/interaction-brush.ts b/packages/charts-core/src/interaction-brush.ts index 72aea517..5d146fec 100644 --- a/packages/charts-core/src/interaction-brush.ts +++ b/packages/charts-core/src/interaction-brush.ts @@ -265,6 +265,7 @@ function createBrushXControl({ let active = false let origin: BrushRange | undefined let gestureRange: BrushRange | undefined + let originLeft: number | undefined let target: BrushXTarget = 'selection' let activeInput: 'mouse' | 'touch' | undefined let activeView: (Window & typeof globalThis) | undefined @@ -354,6 +355,8 @@ function createBrushXControl({ active = true origin = cloneRange(control.range) gestureRange = cloneRange(control.range) + originLeft = + typeof event.selection?.[0] === 'number' ? event.selection[0] : undefined target = eventTarget(event.sourceEvent, control) activeInput = isTouchSource(event.sourceEvent) ? 'touch' : 'mouse' activeView = @@ -364,7 +367,7 @@ function createBrushXControl({ function handleBrush(event: D3BrushEvent) { if (moving || !active || !event.sourceEvent || !control || !origin) return - const next = selectionRange(event.selection, control.axis) + const next = gestureSelectionRange(event.selection) if ( !next || (gestureRange && sameRange(control.axis, next, gestureRange)) @@ -395,7 +398,7 @@ function createBrushXControl({ } if (!origin) return active = false - let next = selectionRange(event.selection, control.axis) + let next = gestureSelectionRange(event.selection) if (!next) { const position = sourceSceneX(event.sourceEvent) if (position == null) return finishGesture() @@ -417,6 +420,13 @@ function createBrushXControl({ finishGesture() } + function gestureSelectionRange(selection: BrushSelection | null) { + if (!control) return null + return target === 'selection' && originLeft !== undefined + ? translatedSelectionRange(selection, control.axis, originLeft) + : selectionRange(selection, control.axis) + } + function cancelPointer(event: Event) { cancel('pointer', event.type !== 'touchcancel') } @@ -453,6 +463,7 @@ function createBrushXControl({ function finishGesture() { origin = undefined gestureRange = undefined + originLeft = undefined target = 'selection' activeInput = undefined activeView = undefined @@ -644,6 +655,33 @@ function selectionRange( }) } +function translatedSelectionRange( + selection: BrushSelection | null, + axis: InteractionAxis, + originLeft: number, +): BrushRange | null { + const snapped = selectionRange(selection, axis) + if (!snapped || !axis.values) return snapped + // Snapping each edge separately can change the number of selected + // values, as when D3 stops the selection at the plot edge between + // two values. Instead, snap the edge leading the drag, and place the + // other edge the selection's width away from it. + const [left, right] = selection as [number, number] + const width = right - left + if (left > originLeft) { + const end = axis.valueAt(right) + return normalizeRange(axis, { + start: axis.valueAt(axis.position(end) - width), + end, + }) + } + const start = axis.valueAt(left) + return normalizeRange(axis, { + start, + end: axis.valueAt(axis.position(start) + width), + }) +} + function eventTarget( source: unknown, control: BrushXControl,