diff --git a/packages/views/src/charts/linked-cursor.dom.test.ts b/packages/views/src/charts/linked-cursor.dom.test.ts
new file mode 100644
index 0000000000..520f325723
--- /dev/null
+++ b/packages/views/src/charts/linked-cursor.dom.test.ts
@@ -0,0 +1,161 @@
+// @vitest-environment happy-dom
+import { Effect, Fiber, Stream } from "effect"
+import { describe, expect, test } from "vitest"
+import { type ChartRange, linkedCursorStream } from "./linked-cursor"
+
+const HOUR = 3_600_000
+/** The plot's box on screen: 500px wide from x=100, over a ten-hour axis. */
+const PLOT_BOX = {
+ left: 100,
+ right: 600,
+ top: 0,
+ bottom: 200,
+ width: 500,
+ height: 200,
+ x: 100,
+ y: 0,
+ toJSON: () => ({}),
+}
+
+/** One chart of a linked group as the views render it: plot anchor, page overlay, cursor overlay. */
+const setUp = () => {
+ const group = document.createElement("div")
+ group.innerHTML = `
+
`
+ document.body.append(group)
+ const find = (selector: string) =>
+ group.querySelector(selector) ?? document.createElement("div")
+ const plot = Object.assign(find("[data-chart-plot]"), { getBoundingClientRect: () => PLOT_BOX })
+ return { group, plot, marker: find("[data-marker]"), label: find("[data-linked-zoom-label]") }
+}
+type Parts = ReturnType
+
+/** A pointer event at `x`; a move carries the held primary button unless `buttons` says otherwise. */
+const pointer = (type: string, x: number, pointerType = "mouse", buttons = type === "pointermove" ? 1 : 0) =>
+ new PointerEvent(type, { bubbles: true, clientX: x, clientY: 100, button: 0, buttons, pointerType })
+
+/** The spans the zoom reports while `gesture` plays on a mounted group. */
+const rangesFor = (gesture: (parts: Parts) => void) =>
+ Effect.runPromise(
+ Effect.gen(function* () {
+ const parts = setUp()
+ const seen: Array = []
+ const fiber = yield* Effect.forkChild(
+ Stream.runForEach(
+ linkedCursorStream(parts.group, null, {
+ timeZone: "UTC",
+ selected: (range) => range,
+ }),
+ (emitted) =>
+ Effect.sync(() => {
+ if (emitted !== null) seen.push(emitted)
+ }),
+ ),
+ )
+ yield* Effect.sleep(10)
+ yield* Effect.sync(() => gesture(parts))
+ yield* Effect.sleep(10)
+ yield* Fiber.interrupt(fiber)
+ parts.group.remove()
+ return seen
+ }),
+ )
+
+describe("linked zoom gesture", () => {
+ test("a drag across the plot labels the span while it moves and reports it on release", async () => {
+ const labels: Array = []
+ const ranges = await rangesFor(({ plot, label }) => {
+ plot.dispatchEvent(pointer("pointerdown", 300))
+ plot.dispatchEvent(pointer("pointermove", 450))
+ labels.push(label.textContent)
+ plot.dispatchEvent(pointer("pointerup", 450))
+ labels.push(label.textContent)
+ })
+ expect(ranges).toEqual([{ startMs: 4 * HOUR, endMs: 7 * HOUR }])
+ expect(labels[0]).toContain("ยท 3h")
+ expect(labels[1]).toBe("")
+ })
+
+ test("a press that barely moves is a click, not a zoom", async () => {
+ const ranges = await rangesFor(({ plot }) => {
+ plot.dispatchEvent(pointer("pointerdown", 300))
+ plot.dispatchEvent(pointer("pointermove", 303))
+ plot.dispatchEvent(pointer("pointerup", 303))
+ })
+ expect(ranges).toEqual([])
+ })
+
+ test("Escape drops the drag in flight", async () => {
+ const ranges = await rangesFor(({ plot }) => {
+ plot.dispatchEvent(pointer("pointerdown", 300))
+ plot.dispatchEvent(pointer("pointermove", 450))
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }))
+ plot.dispatchEvent(pointer("pointerup", 450))
+ })
+ expect(ranges).toEqual([])
+ })
+
+ test("a drag whose release never arrived ends at the next move with no button down", async () => {
+ const labels: Array = []
+ const ranges = await rangesFor(({ plot, label }) => {
+ plot.dispatchEvent(pointer("pointerdown", 300))
+ plot.dispatchEvent(pointer("pointermove", 450))
+ plot.dispatchEvent(pointer("pointermove", 470, "mouse", 0))
+ labels.push(label.textContent)
+ plot.dispatchEvent(pointer("pointerup", 470))
+ })
+ expect(ranges).toEqual([])
+ expect(labels).toEqual([""])
+ })
+
+ test("a press on what the page lays over the plot, or a touch, starts no drag", async () => {
+ const ranges = await rangesFor(({ plot, marker }) => {
+ marker.dispatchEvent(pointer("pointerdown", 300))
+ plot.dispatchEvent(pointer("pointermove", 450))
+ plot.dispatchEvent(pointer("pointerup", 450))
+ plot.dispatchEvent(pointer("pointerdown", 300, "touch"))
+ plot.dispatchEvent(pointer("pointermove", 450, "touch"))
+ plot.dispatchEvent(pointer("pointerup", 450, "touch"))
+ })
+ expect(ranges).toEqual([])
+ })
+
+ test("a drag cancelled with Escape still owns its release: that click never reaches the chart", async () => {
+ const clicks: Array = []
+ const ranges = await rangesFor(({ plot }) => {
+ plot.addEventListener("click", () => clicks.push("after Escape"))
+ plot.dispatchEvent(pointer("pointerdown", 300))
+ plot.dispatchEvent(pointer("pointermove", 450))
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }))
+ plot.dispatchEvent(pointer("pointermove", 470))
+ plot.dispatchEvent(pointer("pointerup", 470))
+ plot.dispatchEvent(new MouseEvent("click", { bubbles: true }))
+ })
+ expect(ranges).toEqual([])
+ expect(clicks).toEqual([])
+ })
+
+ test("the click that ends a drag never reaches the chart; a plain click does", async () => {
+ const clicks: Array = []
+ await rangesFor(({ plot }) => {
+ plot.addEventListener("click", () => clicks.push("plain"))
+ plot.dispatchEvent(new MouseEvent("click", { bubbles: true }))
+ })
+ await rangesFor(({ plot }) => {
+ plot.addEventListener("click", () => clicks.push("after drag"))
+ plot.dispatchEvent(pointer("pointerdown", 300))
+ plot.dispatchEvent(pointer("pointermove", 450))
+ plot.dispatchEvent(pointer("pointerup", 450))
+ plot.dispatchEvent(new MouseEvent("click", { bubbles: true }))
+ })
+ expect(clicks).toEqual(["plain"])
+ })
+})
diff --git a/packages/views/src/charts/linked-cursor.test.ts b/packages/views/src/charts/linked-cursor.test.ts
index e21cd1f734..8ad464a267 100644
--- a/packages/views/src/charts/linked-cursor.test.ts
+++ b/packages/views/src/charts/linked-cursor.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest"
-import { draggedRange, zoomLabel } from "./linked-cursor"
+import { draggedRange, labelLeft, zoomLabel } from "./linked-cursor"
const HOUR = 3_600_000
const axis = { domainStartMs: 0, domainEndMs: 10 * HOUR, startPx: 0, endPx: 500 }
@@ -16,6 +16,15 @@ describe("linked zoom", () => {
expect(draggedRange(axis, 200, 600, 2_000)).toEqual({ startMs: 8 * HOUR, endMs: 10 * HOUR })
})
+ test("the label sits centred on the band, and stops at the plot's edges", () => {
+ expect([
+ labelLeft(250, 100, 500),
+ labelLeft(10, 100, 500),
+ labelLeft(495, 100, 500),
+ labelLeft(40, 100, 60),
+ ]).toEqual([200, 0, 400, 0])
+ })
+
test("the label gives both ends and the length, with the day once the span is half a day", () => {
expect([
zoomLabel({ startMs: 2 * HOUR, endMs: 5 * HOUR }, clock),
diff --git a/packages/views/src/charts/linked-cursor.ts b/packages/views/src/charts/linked-cursor.ts
index bb91c772cd..42a6444043 100644
--- a/packages/views/src/charts/linked-cursor.ts
+++ b/packages/views/src/charts/linked-cursor.ts
@@ -65,6 +65,8 @@ interface Drag {
readonly axis: PlotTimeAxis
readonly fromX: number
moved: boolean
+ /** Escape was pressed: no band and no zoom, but the release is still this drag's. */
+ cancelled: boolean
}
/** The hovered chart, the last tooltip-forwarding move, the overlays placed on their plots (null until aligned), whether they show, and the drag in flight. */
@@ -90,6 +92,12 @@ export const draggedRange = (
return { startMs: Math.min(from, to), endMs: Math.max(from, to) }
}
+/** Where the label's left edge goes to centre it on the band without leaving the plot (all px). */
+export const labelLeft = (bandCentre: number, labelWidth: number, plotWidth: number): number => {
+ const half = labelWidth / 2
+ return Math.min(Math.max(bandCentre, half), Math.max(half, plotWidth - half)) - half
+}
+
/** The drag's label: both ends on the clock, then how long the span is. */
export const zoomLabel = (range: ChartRange, clock: (ms: number, withDay: boolean) => string): string => {
const span = range.endMs - range.startMs
@@ -280,8 +288,10 @@ const listenForDrags = (
overlay.style.setProperty("--linked-zoom-visible", "1")
})
const label = drag.chart.querySelector(LABEL_SELECTOR)
- if (label !== null)
- label.textContent = zoomLabel(draggedRange(drag.axis, drag.left, drag.fromX, toX), clock)
+ if (label === null) return
+ label.textContent = zoomLabel(draggedRange(drag.axis, drag.left, drag.fromX, toX), clock)
+ const centre = ((from + to) / 2) * drag.width
+ label.style.left = `${labelLeft(centre, label.offsetWidth, drag.width)}px`
}
const clearBand = (drag: Drag) => {
Arr.forEach(state.overlays ?? [], (overlay) =>
@@ -290,10 +300,22 @@ const listenForDrags = (
const label = drag.chart.querySelector(LABEL_SELECTOR)
if (label !== null) label.textContent = ""
}
- const cancel = () => {
+ /** The drag's release will never be seen (a missed pointerup, a pointercancel): forget it. */
+ const drop = () => {
const drag = state.drag
state.drag = null
- if (drag?.moved === true) clearBand(drag)
+ if (drag?.moved === true && !drag.cancelled) clearBand(drag)
+ }
+ /** Escape: the band goes now; the drag stays until its release, so that click is still swallowed. */
+ const cancel = () => {
+ const drag = state.drag
+ if (drag === null || drag.cancelled) return
+ if (!drag.moved) {
+ state.drag = null
+ return
+ }
+ drag.cancelled = true
+ clearBand(drag)
}
// The click that follows a drag's release would land on whatever is under the pointer.
const swallowClick = (event: MouseEvent) => {
@@ -316,11 +338,18 @@ const listenForDrags = (
axis: axis.value,
fromX: event.clientX,
moved: false,
+ cancelled: false,
}
}
const onDragMove = (event: PointerEvent) => {
const drag = state.drag
if (drag === null) return
+ // A release the page never saw (the window lost focus mid-drag): no button is down any more.
+ if ((event.buttons & 1) === 0) {
+ drop()
+ return
+ }
+ if (drag.cancelled) return
if (!drag.moved) {
if (Math.abs(event.clientX - drag.fromX) < DRAG_THRESHOLD_PX) return
drag.moved = true
@@ -335,8 +364,9 @@ const listenForDrags = (
const drag = state.drag
state.drag = null
if (drag === null || !drag.moved) return
- clearBand(drag)
state.dragEndedAt = event.timeStamp
+ if (drag.cancelled) return
+ clearBand(drag)
if (Math.abs(event.clientX - drag.fromX) < DRAG_THRESHOLD_PX) return
const range = draggedRange(drag.axis, drag.left, drag.fromX, event.clientX)
if (range.endMs > range.startMs) selected(range)
@@ -358,7 +388,7 @@ const listenForDrags = (
// Capture on the document: these run before `holdChart` stops the event.
document.addEventListener("pointermove", onDragMove, true)
document.addEventListener("pointerup", onDragEnd)
- document.addEventListener("pointercancel", cancel)
+ document.addEventListener("pointercancel", drop)
document.addEventListener("keydown", onKeyDown)
return () => {
container.removeEventListener("pointerdown", onPointerDown)
@@ -367,7 +397,7 @@ const listenForDrags = (
container.removeEventListener("pointermove", holdChart, true)
document.removeEventListener("pointermove", onDragMove, true)
document.removeEventListener("pointerup", onDragEnd)
- document.removeEventListener("pointercancel", cancel)
+ document.removeEventListener("pointercancel", drop)
document.removeEventListener("keydown", onKeyDown)
}
}
@@ -417,17 +447,16 @@ export const linkedCursorOverlay = (h: HtmlBuilder, chartId: string): Html
opacity: "var(--linked-zoom-visible, 0)",
}),
],
- [
- badge(
- h,
- {
- // `max-w-none`: the label may be wider than a narrow band.
- className: "absolute top-1 left-1/2 max-w-none -translate-x-1/2 empty:hidden",
- attributes: [h.DataAttribute("linked-zoom-label", "")],
- },
- [],
- ),
- ],
+ [],
+ ),
+ // Beside the band, not inside it: the Mount centres it on the band and keeps it in the plot.
+ badge(
+ h,
+ {
+ className: "absolute top-1 left-0 max-w-none empty:hidden",
+ attributes: [h.DataAttribute("linked-zoom-label", "")],
+ },
+ [],
),
],
)