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,