Skip to content
Merged
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
161 changes: 161 additions & 0 deletions packages/views/src/charts/linked-cursor.dom.test.ts
Original file line number Diff line number Diff line change
@@ -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 = `
<div data-linked-cursor-chart="latency">
<div data-chart-plot data-chart-time-axis="0,${10 * HOUR},0,500"></div>
<div data-chart-overlay><button data-marker></button></div>
<div data-linked-cursor-overlay="latency">
<div data-linked-cursor-line></div>
<div data-linked-zoom-band></div>
<span data-linked-zoom-label></span>
</div>
</div>`
document.body.append(group)
const find = (selector: string) =>
group.querySelector<HTMLElement>(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<typeof setUp>

/** 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<ChartRange> = []
const fiber = yield* Effect.forkChild(
Stream.runForEach(
linkedCursorStream<ChartRange | null>(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<string> = []
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<string> = []
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<string> = []
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<string> = []
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"])
})
})
11 changes: 10 additions & 1 deletion packages/views/src/charts/linked-cursor.test.ts
Original file line number Diff line number Diff line change
@@ -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 }
Expand All @@ -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),
Expand Down
65 changes: 47 additions & 18 deletions packages/views/src/charts/linked-cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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
Expand Down Expand Up @@ -280,8 +288,10 @@ const listenForDrags = <A>(
overlay.style.setProperty("--linked-zoom-visible", "1")
})
const label = drag.chart.querySelector<HTMLElement>(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) =>
Expand All @@ -290,10 +300,22 @@ const listenForDrags = <A>(
const label = drag.chart.querySelector<HTMLElement>(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) => {
Expand All @@ -316,11 +338,18 @@ const listenForDrags = <A>(
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
Expand All @@ -335,8 +364,9 @@ const listenForDrags = <A>(
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)
Expand All @@ -358,7 +388,7 @@ const listenForDrags = <A>(
// 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)
Expand All @@ -367,7 +397,7 @@ const listenForDrags = <A>(
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)
}
}
Expand Down Expand Up @@ -417,17 +447,16 @@ export const linkedCursorOverlay = <M>(h: HtmlBuilder<M>, 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", "")],
},
[],
),
],
)
Loading