Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/brush-x-selection-span.md
Original file line number Diff line number Diff line change
@@ -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.
140 changes: 140 additions & 0 deletions packages/charts-core/src/interaction-brush.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() =>
Expand Down Expand Up @@ -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<Date>, 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<Date>[] = []
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<BrushRange<Date>, BrushXChange<Date>>(
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<SVGRectElement>(
'[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<Date> {
return { start, end }
}
Expand All @@ -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
Expand Down
42 changes: 40 additions & 2 deletions packages/charts-core/src/interaction-brush.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ function createBrushXControl({
let active = false
let origin: BrushRange<ChartValue> | undefined
let gestureRange: BrushRange<ChartValue> | undefined
let originLeft: number | undefined
let target: BrushXTarget = 'selection'
let activeInput: 'mouse' | 'touch' | undefined
let activeView: (Window & typeof globalThis) | undefined
Expand Down Expand Up @@ -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 =
Expand All @@ -364,7 +367,7 @@ function createBrushXControl({

function handleBrush(event: D3BrushEvent<unknown>) {
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))
Expand Down Expand Up @@ -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()
Expand All @@ -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')
}
Expand Down Expand Up @@ -453,6 +463,7 @@ function createBrushXControl({
function finishGesture() {
origin = undefined
gestureRange = undefined
originLeft = undefined
target = 'selection'
activeInput = undefined
activeView = undefined
Expand Down Expand Up @@ -644,6 +655,33 @@ function selectionRange<TValue extends ChartValue>(
})
}

function translatedSelectionRange<TValue extends ChartValue>(
selection: BrushSelection | null,
axis: InteractionAxis<TValue>,
originLeft: number,
): BrushRange<TValue> | 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),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
end,
})
}
const start = axis.valueAt(left)
return normalizeRange(axis, {
start,
end: axis.valueAt(axis.position(start) + width),
})
}

function eventTarget<TValue extends ChartValue>(
source: unknown,
control: BrushXControl<TValue>,
Expand Down