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", "")], + }, + [], ), ], )