From 19d68df5c890cf3d8572b66d6a9bda62a175790b Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 19:23:40 +0100 Subject: [PATCH 1/3] test(charts): cover the zoom gesture in a DOM: threshold, Escape, overlay presses, touch, the trailing click --- .../src/charts/linked-cursor.dom.test.ts | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 packages/views/src/charts/linked-cursor.dom.test.ts 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..e3c738fa26 --- /dev/null +++ b/packages/views/src/charts/linked-cursor.dom.test.ts @@ -0,0 +1,131 @@ +// @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 + +const pointer = (type: string, x: number, pointerType = "mouse") => + new PointerEvent(type, { bubbles: true, clientX: x, clientY: 100, button: 0, 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 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("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"]) + }) +}) From c2e4aa685eb5d77ffe9b5ce60157f16793ed8b5e Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 19:27:44 +0100 Subject: [PATCH 2/3] fix(charts): end a drag whose release never arrived A pointerup lost to a window switch left the drag set: later moves kept painting the band and the chart stopped getting pointer events. A move with no button down now cancels it. --- .../views/src/charts/linked-cursor.dom.test.ts | 18 ++++++++++++++++-- packages/views/src/charts/linked-cursor.ts | 5 +++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/views/src/charts/linked-cursor.dom.test.ts b/packages/views/src/charts/linked-cursor.dom.test.ts index e3c738fa26..23b8ba2908 100644 --- a/packages/views/src/charts/linked-cursor.dom.test.ts +++ b/packages/views/src/charts/linked-cursor.dom.test.ts @@ -37,8 +37,9 @@ const setUp = () => { } type Parts = ReturnType -const pointer = (type: string, x: number, pointerType = "mouse") => - new PointerEvent(type, { bubbles: true, clientX: x, clientY: 100, button: 0, pointerType }) +/** 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) => @@ -101,6 +102,19 @@ describe("linked zoom gesture", () => { 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)) diff --git a/packages/views/src/charts/linked-cursor.ts b/packages/views/src/charts/linked-cursor.ts index bb91c772cd..12688711b3 100644 --- a/packages/views/src/charts/linked-cursor.ts +++ b/packages/views/src/charts/linked-cursor.ts @@ -321,6 +321,11 @@ const listenForDrags = ( 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) { + cancel() + return + } if (!drag.moved) { if (Math.abs(event.clientX - drag.fromX) < DRAG_THRESHOLD_PX) return drag.moved = true From bece67a5a224bfd3a2b18caca71d2865f8fba9bf Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 19:42:42 +0100 Subject: [PATCH 3/3] fix(charts): a cancelled drag keeps its release, and its label stays in the plot Escape dropped the drag at once, so the click after the release reached the chart and pinned its tooltip. A cancelled drag now clears its band and waits for its release, which is swallowed like any other drag's. The label was centred on the band and clipped at the plot's edge. It is a sibling of the band now, centred on it and held inside the plot. --- .../src/charts/linked-cursor.dom.test.ts | 18 +++++- .../views/src/charts/linked-cursor.test.ts | 11 +++- packages/views/src/charts/linked-cursor.ts | 62 +++++++++++++------ 3 files changed, 70 insertions(+), 21 deletions(-) diff --git a/packages/views/src/charts/linked-cursor.dom.test.ts b/packages/views/src/charts/linked-cursor.dom.test.ts index 23b8ba2908..520f325723 100644 --- a/packages/views/src/charts/linked-cursor.dom.test.ts +++ b/packages/views/src/charts/linked-cursor.dom.test.ts @@ -26,7 +26,8 @@ const setUp = () => {
-
+
+
` document.body.append(group) @@ -127,6 +128,21 @@ describe("linked zoom gesture", () => { 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 }) => { 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 12688711b3..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,6 +338,7 @@ const listenForDrags = ( axis: axis.value, fromX: event.clientX, moved: false, + cancelled: false, } } const onDragMove = (event: PointerEvent) => { @@ -323,9 +346,10 @@ const listenForDrags = ( 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) { - cancel() + drop() return } + if (drag.cancelled) return if (!drag.moved) { if (Math.abs(event.clientX - drag.fromX) < DRAG_THRESHOLD_PX) return drag.moved = true @@ -340,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) @@ -363,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) @@ -372,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) } } @@ -422,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", "")], + }, + [], ), ], )