From f0878da69471cbe46bda7efaebac8026d9bcfb1c Mon Sep 17 00:00:00 2001 From: Dan Barua Date: Thu, 15 Oct 2026 15:20:54 +0100 Subject: [PATCH 1/2] The graph view computes each frame's rotation, neighbours and edges once - One rotation (`rotationOf`, `onScreen`) serves the node projection, the compass axes, the framing's candidate yaws and `distanceToFit`. The cosines and sines of the yaw and pitch are computed once per frame for the nodes, and once per candidate yaw for the framing, instead of once per point. - `neighboursOf` replaces three loops over the edges. In the temporal colour overlay, a node's colour no longer scans every edge: the frame looks the node up in the open node's neighbours. - `sceneOf` finds the edges whose two nodes are in front of the camera, and whether each joins the open or hovered node, once per frame for both the edge drawing and the label candidates. - The palette looks up each record type's colour in the stylesheet once per frame, not once per node. - The starting pivot's depth is the newest node's depth, without a pass over every node: the canvas never removes a node. - `edgeKey` is the one spelling of an edge's identity. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GVjyjkaADPAqLvtnf3xUNs --- packages/app-web/src/ui/GraphView.tsx | 314 +++++++++++++--------- packages/app-web/tests/graph-view.test.ts | 3 +- 2 files changed, 189 insertions(+), 128 deletions(-) diff --git a/packages/app-web/src/ui/GraphView.tsx b/packages/app-web/src/ui/GraphView.tsx index 75e35d47..c933ddff 100644 --- a/packages/app-web/src/ui/GraphView.tsx +++ b/packages/app-web/src/ui/GraphView.tsx @@ -117,8 +117,17 @@ const FOCAL = 640; */ const ANCHOR = { x: 0.35, y: 0.45 }; -/** The colours the canvas draws in, read from the page's stylesheet on each frame so a theme change shows. */ -type Palette = { css: CSSStyleDeclaration; text: string; dim: string; accent: string }; +/** + * The colours the canvas draws in, read from the page's stylesheet on each frame so a theme change + * shows. `kinds` holds each record type's colour once the frame has looked it up. + */ +type Palette = { + css: CSSStyleDeclaration; + text: string; + dim: string; + accent: string; + kinds: Map; +}; function paletteOf(canvas: HTMLCanvasElement): Palette { const css = getComputedStyle(canvas); @@ -128,12 +137,12 @@ function paletteOf(canvas: HTMLCanvasElement): Palette { text: read("--text", "#ffffff"), dim: read("--text-dim", "#c8cedb"), accent: read("--accent", "#5ad1c9"), + kinds: new Map(), }; } -function colorFor(kind: string, palette: Palette): string { - const styled = palette.css.getPropertyValue(`--c-${kind.toLowerCase()}`).trim(); - if (styled) return styled; +/** The colour KIND_COLOR hands a record type the stylesheet gives none. */ +function assignedColor(kind: string): string { const existing = KIND_COLOR[kind]; if (existing) return existing; const n = Object.keys(KIND_COLOR).length; @@ -143,22 +152,44 @@ function colorFor(kind: string, palette: Palette): string { return color; } -function colorForNode(sim: Sim, node: SimNode, palette: Palette): string { +function colorFor(kind: string, palette: Palette): string { + const known = palette.kinds.get(kind); + if (known !== undefined) return known; + const styled = palette.css.getPropertyValue(`--c-${kind.toLowerCase()}`).trim(); + const color = styled || assignedColor(kind); + palette.kinds.set(kind, color); + return color; +} + +function colorForNode( + sim: Sim, + node: SimNode, + palette: Palette, + neighbours: ReadonlySet, +): string { if (sim.overlay === "temporal") { if (node.id === sim.selectedId) return TEMPORAL_CREATED; - for (const edge of sim.edges) { - if ( - (edge.from === sim.selectedId && edge.to === node.id) || - (edge.to === sim.selectedId && edge.from === node.id) - ) { - return TEMPORAL_TOUCHED; - } - } - return TEMPORAL_HISTORICAL; + return neighbours.has(node.id) ? TEMPORAL_TOUCHED : TEMPORAL_HISTORICAL; } return colorFor(node.type, palette); } +/** The ids of the nodes an edge joins to the node `id`; none when `id` is null. */ +function neighboursOf(edges: readonly GraphEdgeSeed[], id: string | null): Set { + const near = new Set(); + if (id === null) return near; + for (const edge of edges) { + if (edge.from === id) near.add(edge.to); + else if (edge.to === id) near.add(edge.from); + } + return near; +} + +/** An edge's identity on the canvas: its two ends and its label. */ +function edgeKey(edge: GraphEdgeSeed): string { + return `${edge.from}\0${edge.label}\0${edge.to}`; +} + function springiness(n: number): number { return 1 / Math.log(Math.max(n, 3)); } @@ -187,7 +218,6 @@ export function mergeSeeds( ? seeds : [...seeds.filter((s) => s.id === selectedId), ...seeds.filter((s) => s.id !== selectedId)]; - const parent = selectedId ? sim.nodes.get(selectedId) : undefined; const [cx, cy] = centroid(sim); for (const seed of ordered) { @@ -196,11 +226,8 @@ export function mergeSeeds( existing.type = seed.type; continue; } - const near = - parent ?? - (sim.nodes.size === 0 - ? { x: cx, y: cy } - : (sim.nodes.get(selectedId ?? "") ?? { x: cx, y: cy })); + // Beside the open resource's node, which `ordered` adds first; else at the centroid before. + const near = (selectedId === null ? undefined : sim.nodes.get(selectedId)) ?? { x: cx, y: cy }; sim.nodes.set(seed.id, { ...seed, x: near.x + (Math.random() - 0.5) * 40, @@ -214,7 +241,7 @@ export function mergeSeeds( } for (const edge of edges) { - const key = `${edge.from}\0${edge.label}\0${edge.to}`; + const key = edgeKey(edge); if (sim.edgeKeys.has(key)) continue; sim.edgeKeys.add(key); sim.edges.push({ ...edge, born: sim.labels.frame }); @@ -277,23 +304,22 @@ export function tickPhysics(sim: Sim): boolean { return true; } -function zOf(sim: Sim, node: SimNode): number { - const total = Math.max(1, sim.nextStep - 1); - return (node.createdStep / total) * TOTAL_DEPTH; +/** The depth of the node created at `step`: the first node at 0, the newest at TOTAL_DEPTH. */ +function depthAt(sim: Sim, step: number): number { + return (step / Math.max(1, sim.nextStep - 1)) * TOTAL_DEPTH; } -function maxCreatedZ(sim: Sim): number { - let max = 0; - for (const n of sim.nodes.values()) max = Math.max(max, zOf(sim, n)); - return max; +function zOf(sim: Sim, node: SimNode): number { + return depthAt(sim, node.createdStep); } /** * Where the camera faces before it has faced any node: the middle of the graph's depth at the - * plane's origin, which the layout is pulled towards. + * plane's origin, which the layout is pulled towards. The canvas never removes a node, so the + * newest node, created at step `nextStep - 1`, is the deepest. */ function startingPivot(sim: Sim): Point3 { - return { x: 0, y: 0, z: maxCreatedZ(sim) / 2 }; + return { x: 0, y: 0, z: depthAt(sim, Math.max(0, sim.nextStep - 1)) / 2 }; } /** @@ -344,19 +370,28 @@ const TURN_MAX = (0.75 * Math.PI) / 180; */ const FRAME_DISTANCE = { min: 300, max: 900 }; -/** Where `v`, a point relative to the pivot, is drawn relative to the pivot, ignoring perspective. */ -function onScreen(v: Point3, camera: Pick) { - const x1 = v.x * Math.cos(camera.yaw) - v.z * Math.sin(camera.yaw); - const z1 = v.x * Math.sin(camera.yaw) + v.z * Math.cos(camera.yaw); - const y1 = v.y * Math.cos(camera.pitch) - z1 * Math.sin(camera.pitch); - const z2 = v.y * Math.sin(camera.pitch) + z1 * Math.cos(camera.pitch); - return { x1, y1, z2 }; +/** A camera's turn and tilt as the cosines and sines of its yaw and pitch. */ +type Rotation = { cosY: number; sinY: number; cosX: number; sinX: number }; + +function rotationOf(camera: Pick): Rotation { + return { + cosY: Math.cos(camera.yaw), + sinY: Math.sin(camera.yaw), + cosX: Math.cos(camera.pitch), + sinX: Math.sin(camera.pitch), + }; } -/** How far toward the canvas's bottom-right corner `v` is drawn, at the given yaw. */ -function towardCorner(v: Point3, yaw: number, pitch: number): number { - const { x1, y1 } = onScreen(v, { yaw, pitch }); - return (x1 + y1) / Math.SQRT2; +/** + * Where `v`, a point relative to the pivot, is drawn relative to the pivot, ignoring perspective: + * `x1` right, `y1` down, and `z2` its depth away from the viewer. + */ +function onScreen(v: Point3, r: Rotation) { + const x1 = v.x * r.cosY - v.z * r.sinY; + const z1 = v.x * r.sinY + v.z * r.cosY; + const y1 = v.y * r.cosX - z1 * r.sinX; + const z2 = v.y * r.sinX + z1 * r.cosX; + return { x1, y1, z2 }; } /** The signed turn, between -π and π, from angle `from` to angle `to`. */ @@ -369,13 +404,13 @@ function turnBetween(from: number, to: number): number { * points of each one's drawn distance divided by its distance in the graph's space. 1 when every * point is drawn at its full distance; lower when the camera looks along their directions. */ -function spreadOf(points: Point3[], camera: Pick): number { +function spreadOf(points: Point3[], rotation: Rotation): number { let sum = 0; let count = 0; for (const v of points) { const length = Math.hypot(v.x, v.y, v.z); if (length === 0) continue; - const { x1, y1 } = onScreen(v, camera); + const { x1, y1 } = onScreen(v, rotation); sum += Math.hypot(x1, y1) / length; count++; } @@ -392,20 +427,28 @@ function spreadOf(points: Point3[], camera: Pick): numb */ export function yawToward(view: { rest: Point3; near: Point3[]; pitch: number; yaw: number }) { const { rest, near, pitch, yaw } = view; - const candidates = [yaw, ...Array.from({ length: 72 }, (_, i) => -Math.PI + (i * Math.PI) / 36)]; - const spread = candidates.map((c) => spreadOf(near, { yaw: c, pitch })); - const most = Math.max(...spread); - const shows = candidates.filter((_, i) => (spread[i] ?? 0) >= SPREAD_KEEP * most); - const behind = shows.filter((c) => onScreen(rest, { yaw: c, pitch }).z2 >= 0); + const yaws = [yaw, ...Array.from({ length: 72 }, (_, i) => -Math.PI + (i * Math.PI) / 36)]; + const candidates = yaws.map((c) => { + const rotation = rotationOf({ yaw: c, pitch }); + const drawn = onScreen(rest, rotation); + return { + yaw: c, + spread: spreadOf(near, rotation), + behind: drawn.z2 >= 0, + // How far toward the canvas's bottom-right corner `rest` is drawn. + corner: (drawn.x1 + drawn.y1) / Math.SQRT2, + turn: Math.abs(turnBetween(yaw, c)), + }; + }); + const most = Math.max(...candidates.map((c) => c.spread)); + const shows = candidates.filter((c) => c.spread >= SPREAD_KEEP * most); + const behind = shows.filter((c) => c.behind); // `shows` always holds the yaw with the best spread, so the pool is never empty. const pool = behind.length > 0 ? behind : shows; - const score = (c: number) => towardCorner(rest, c, pitch); - const best = Math.max(...pool.map(score)); + const best = Math.max(...pool.map((c) => c.corner)); const margin = 0.1 * Math.hypot(rest.x, rest.y, rest.z); - const turn = (c: number) => Math.abs(turnBetween(yaw, c)); - return pool - .filter((c) => score(c) >= best - margin) - .reduce((a, c) => (turn(c) < turn(a) ? c : a)); + const qualify = pool.filter((c) => c.corner >= best - margin); + return qualify.reduce((a, c) => (c.turn < a.turn ? c : a)).yaw; } /** @@ -417,9 +460,10 @@ export function distanceToFit( camera: Pick, radius: number, ): number | undefined { + const rotation = rotationOf(camera); let distance: number | undefined; for (const point of points) { - const { x1, y1, z2 } = onScreen(point, camera); + const { x1, y1, z2 } = onScreen(point, rotation); const offset = Math.hypot(x1, y1); if (offset === 0) continue; const needed = (offset * FOCAL) / radius - z2; @@ -443,11 +487,7 @@ export function frameCamera(sim: Sim, width: number, height: number): void { camera.pitch += turnBetween(camera.pitch, pitch) * FRAME_EASE; const at = zOf(sim, open); const rel = (n: SimNode): Point3 => ({ x: n.x - open.x, y: n.y - open.y, z: zOf(sim, n) - at }); - const near = new Set(); - for (const edge of sim.edges) { - if (edge.from === open.id) near.add(edge.to); - else if (edge.to === open.id) near.add(edge.from); - } + const near = neighboursOf(sim.edges, open.id); const rest = { x: 0, y: 0, z: 0 }; let count = 0; for (const n of sim.nodes.values()) { @@ -481,46 +521,48 @@ export function frameCamera(sim: Sim, width: number, height: number): void { } } -export function project(sim: Sim, node: SimNode, width: number, height: number): Projected { - const { yaw, pitch, distance } = sim.camera; +/** + * Where each node is drawn on a canvas `width` by `height` pixels, in perspective from the camera. + * A node at or behind the camera has scale 0 and is drawn nowhere. + */ +function projectNodes(sim: Sim, width: number, height: number): Map { + const rotation = rotationOf(sim.camera); const pivot = sim.pivot ?? startingPivot(sim); - const x0 = node.x - pivot.x; - const y0 = node.y - pivot.y; - const zc = zOf(sim, node) - pivot.z; - const cosY = Math.cos(yaw); - const sinY = Math.sin(yaw); - const x1 = x0 * cosY - zc * sinY; - const z1 = x0 * sinY + zc * cosY; - const cosX = Math.cos(pitch); - const sinX = Math.sin(pitch); - const y1 = y0 * cosX - z1 * sinX; - const z2 = y0 * sinX + z1 * cosX; - const viewZ = z2 + distance; - if (viewZ <= 1) return { sx: -9999, sy: -9999, scale: 0, depth: viewZ }; - const scale = FOCAL / viewZ; - return { - sx: width * ANCHOR.x + x1 * scale, - sy: height * ANCHOR.y + y1 * scale, - scale, - depth: viewZ, - }; + const projected = new Map(); + for (const node of sim.nodes.values()) { + const v = { x: node.x - pivot.x, y: node.y - pivot.y, z: zOf(sim, node) - pivot.z }; + const { x1, y1, z2 } = onScreen(v, rotation); + const depth = z2 + sim.camera.distance; + if (depth <= 1) { + projected.set(node.id, { sx: -9999, sy: -9999, scale: 0, depth }); + continue; + } + const scale = FOCAL / depth; + projected.set(node.id, { + sx: width * ANCHOR.x + x1 * scale, + sy: height * ANCHOR.y + y1 * scale, + scale, + depth, + }); + } + return projected; } +/** The compass's arms: the graph's unit axes. The third is the time axis. */ +const AXES: { unit: Point3; color: string; label: string | null }[] = [ + { unit: { x: 1, y: 0, z: 0 }, color: "#e0687a", label: null }, + { unit: { x: 0, y: 1, z: 0 }, color: "#5ad1c9", label: null }, + { unit: { x: 0, y: 0, z: 1 }, color: "#e0b25a", label: "time" }, +]; + /** * The graph's three axes as the camera sees them: each unit axis's screen direction (`x1` right, - * `y1` down) and its depth away from the viewer (`z2`). The third is the time axis: a node seen - * later lies further along it. + * `y1` down) and its depth away from the viewer (`z2`). A node seen later lies further along the + * time axis. */ export function screenAxes(camera: Pick) { - const cosY = Math.cos(camera.yaw); - const sinY = Math.sin(camera.yaw); - const cosX = Math.cos(camera.pitch); - const sinX = Math.sin(camera.pitch); - return [ - { x1: cosY, y1: -sinY * sinX, z2: sinY * cosX, color: "#e0687a", label: null as string | null }, - { x1: 0, y1: cosX, z2: sinX, color: "#5ad1c9", label: null as string | null }, - { x1: -sinY, y1: -cosY * sinX, z2: cosY * cosX, color: "#e0b25a", label: "time" }, - ]; + const rotation = rotationOf(camera); + return AXES.map(({ unit, color, label }) => ({ ...onScreen(unit, rotation), color, label })); } // Seen from here, the time axis points up, to the right and away from the viewer. @@ -724,6 +766,37 @@ type EdgeLabel = { width: number; } & ReturnType; +/** An edge whose two nodes are both in front of the camera, and where its nodes are drawn. */ +type SceneEdge = { + edge: SimEdge; + a: Projected; + b: Projected; + /** Whether the edge joins the open node or the node under the pointer. */ + hot: boolean; +}; + +/** + * What one frame draws: where each node is drawn, the open node's neighbours, and the edges whose + * two nodes are both in front of the camera. + */ +export type Scene = { + projected: Map; + neighbours: Set; + edges: SceneEdge[]; +}; + +export function sceneOf(sim: Sim, projected: Map): Scene { + const focus = [sim.selectedId, sim.hoverId].filter((id) => id !== null); + const edges: SceneEdge[] = []; + for (const edge of sim.edges) { + const a = projected.get(edge.from); + const b = projected.get(edge.to); + if (!a || !b || a.scale === 0 || b.scale === 0) continue; + edges.push({ edge, a, b, hot: focus.some((id) => edge.from === id || edge.to === id) }); + } + return { projected, neighbours: neighboursOf(sim.edges, sim.selectedId), edges }; +} + /** * The labels the canvas may draw in this frame, before overlaps are resolved, and their boxes. * `measure` returns a text's width in pixels in a font. An edge's label is not a candidate until @@ -732,16 +805,12 @@ type EdgeLabel = { */ export function labelCandidates( sim: Sim, - projected: Map, + scene: Scene, measure: (text: string, font: string) => number, ): { boxes: LabelBox[]; edgeLabels: EdgeLabel[] } { const boxes: LabelBox[] = []; const edgeLabels: EdgeLabel[] = []; - const neighbours = new Set(); - for (const edge of sim.edges) { - if (edge.from === sim.selectedId) neighbours.add(edge.to); - else if (edge.to === sim.selectedId) neighbours.add(edge.from); - } + const { projected, neighbours } = scene; // A label drawn in the last frame stays a candidate a little past the size at which it appears, // so one at that size does not flicker as the camera moves. const big = (scale: number, key: string) => scale > (sim.labels.shown.has(key) ? 0.5 : 0.55); @@ -761,15 +830,9 @@ export function labelCandidates( angle: 0, }); } - for (const edge of sim.edges) { - const a = projected.get(edge.from); - const b = projected.get(edge.to); - if (!a || !b || a.scale === 0 || b.scale === 0) continue; - const hot = [sim.selectedId, sim.hoverId].some( - (id) => id !== null && (edge.from === id || edge.to === id), - ); - const key = `e\0${edge.from}\0${edge.label}\0${edge.to}`; + for (const { edge, a, b, hot } of scene.edges) { if (sim.labels.frame - edge.born < EDGE_LABEL_DELAY) continue; + const key = `e\0${edgeKey(edge)}`; if (!hot && !big(Math.min(a.scale, b.scale), key)) continue; const font = hot ? "600 10px ui-monospace, monospace" : "9px ui-monospace, monospace"; const width = measure(edge.label, font) + LABEL_MARKER; @@ -806,13 +869,18 @@ export function labelCandidates( function renderFrame(ctx: CanvasRenderingContext2D, sim: Sim, width: number, height: number): void { ctx.clearRect(0, 0, width, height); const palette = paletteOf(ctx.canvas); - const nodes = [...sim.nodes.values()]; - const projected = new Map(); - for (const n of nodes) projected.set(n.id, project(sim, n, width, height)); + const projected = projectNodes(sim, width, height); sim.screenPos = projected; - - const order = [...projected.entries()].sort((a, b) => b[1].depth - a[1].depth); - const visible = order.filter(([, p]) => p.scale > 0).map(([, p]) => p.depth); + const scene = sceneOf(sim, projected); + + // The nodes in front of the camera, farthest first, so nearer nodes are drawn over them. + const order = [...sim.nodes.values()] + .flatMap((node) => { + const p = projected.get(node.id); + return p === undefined || p.scale === 0 ? [] : [{ node, p }]; + }) + .sort((m, n) => n.p.depth - m.p.depth); + const visible = order.map(({ p }) => p.depth); const near = Math.min(...visible); const far = Math.max(...visible); const opacity = (depth: number) => depthOpacity(depth, near, far); @@ -822,15 +890,9 @@ function renderFrame(ctx: CanvasRenderingContext2D, sim: Sim, width: number, hei // colour and twice as wide, unfaded, so they stand out from the rest. Each edge is drawn from // the rim of one node to the rim of the other, so a node drawn see-through has no line inside it. const hotEdges: Segment[] = []; - for (const edge of sim.edges) { - const a = projected.get(edge.from); - const b = projected.get(edge.to); - if (!a || !b || a.scale === 0 || b.scale === 0) continue; + for (const { a, b, hot } of scene.edges) { const segment = rimToRim(a, nodeRadius(a), b, nodeRadius(b)); if (segment === undefined) continue; - const hot = [sim.selectedId, sim.hoverId].some( - (id) => id !== null && (edge.from === id || edge.to === id), - ); if (hot) hotEdges.push(segment); else { ctx.globalAlpha = opacity((a.depth + b.depth) / 2); @@ -852,7 +914,7 @@ function renderFrame(ctx: CanvasRenderingContext2D, sim: Sim, width: number, hei } ctx.lineWidth = 1; - const { boxes, edgeLabels } = labelCandidates(sim, projected, (text, font) => { + const { boxes, edgeLabels } = labelCandidates(sim, scene, (text, font) => { ctx.font = font; return ctx.measureText(text).width; }); @@ -860,17 +922,15 @@ function renderFrame(ctx: CanvasRenderingContext2D, sim: Sim, width: number, hei // The share of its opacity a label is drawn at as it fades in or out; 0 for one not drawn. const fade = (key: string) => (sim.labels.faded.get(key) ?? 0) / LABEL_FADE; - for (const [id] of order) { - const node = sim.nodes.get(id); - const p = projected.get(id); - if (!node || !p || p.scale === 0) continue; + for (const { node, p } of order) { + const id = node.id; const r = nodeRadius(p); const isHover = id === sim.hoverId; const isSelected = id === sim.selectedId; const alpha = node.alpha * (isHover || isSelected ? 1 : opacity(p.depth)); ctx.globalAlpha = alpha; - ctx.fillStyle = colorForNode(sim, node, palette); + ctx.fillStyle = colorForNode(sim, node, palette, scene.neighbours); ctx.beginPath(); ctx.arc(p.sx, p.sy, r, 0, Math.PI * 2); ctx.fill(); diff --git a/packages/app-web/tests/graph-view.test.ts b/packages/app-web/tests/graph-view.test.ts index 950f8868..28a16236 100644 --- a/packages/app-web/tests/graph-view.test.ts +++ b/packages/app-web/tests/graph-view.test.ts @@ -14,6 +14,7 @@ import { tickPhysics, yawToward, rimToRim, + sceneOf, } from "../src/ui/GraphView"; describe("the graph's depth fade", () => { @@ -377,7 +378,7 @@ describe("label placement", () => { sim: ReturnType, projected: Map>, ) => - labelCandidates(sim, projected, measure) + labelCandidates(sim, sceneOf(sim, projected), measure) .boxes.map((b) => b.key.replace(/\0/g, " ")) .sort(); From 4c8661936d228e226e88261861bddf142ac34e3e Mon Sep 17 00:00:00 2001 From: Dan Barua Date: Thu, 15 Oct 2026 15:24:44 +0100 Subject: [PATCH 2/2] The graph view zooms on a two-finger pinch, and a cancelled pointer ends its drag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The canvas already orbited under one finger, through pointer events and `touch-action: none`. Two fingers did not zoom: pressing the second finger restarted the drag from that finger, and each finger's move then set the camera's turn from its own offset, so the camera jumped between them. A touch the browser cancelled left the drag running. - `pressPointer`, `movePointer` and `releasePointer` record the pointers pressed on the canvas and what they do to the camera. One pointer orbits, as before. Two pinch: the camera's distance scales with how much nearer or further apart they are than when the second was pressed, within the same 120 to 2200 as a scroll. Pressing or lifting a pointer restarts the gesture from where the pointers are, so the camera does not jump. - A press that had two pointers opens no node when it ends. - `pointercancel` releases a pointer as `pointerup` does. - On a device whose main pointer cannot hover, the hint reads "drag to orbit · pinch to zoom". - An e2e test sends a two-finger pinch through Chrome's touch input and checks the open node is drawn larger. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GVjyjkaADPAqLvtnf3xUNs --- packages/app-web/e2e/app.e2e.ts | 51 +++++++ packages/app-web/src/ui/GraphView.tsx | 160 ++++++++++++++++++---- packages/app-web/tests/graph-view.test.ts | 96 +++++++++++++ 3 files changed, 280 insertions(+), 27 deletions(-) diff --git a/packages/app-web/e2e/app.e2e.ts b/packages/app-web/e2e/app.e2e.ts index adafa05e..3631dc05 100644 --- a/packages/app-web/e2e/app.e2e.ts +++ b/packages/app-web/e2e/app.e2e.ts @@ -1080,6 +1080,57 @@ test.describe("the graph", () => { await expect.poll(anchorFilled).toBe(true); }); + test.describe("on a touch screen", () => { + test.use({ hasTouch: true }); + + test("two fingers moving apart zoom in on the open resource's node", async ({ page }) => { + await page.goto("/app/workspace/alpha/LOE_1"); + await expect(header(page)).toHaveText("LOE_1"); + await expect(page.locator(".hint")).toHaveText("drag to orbit · pinch to zoom"); + // Whether every pixel of a square `side` pixels across, centred where the open resource's + // node is drawn, is opaque. While the camera frames the node, the node is at most 30 pixels + // across; with the camera as near as a pinch can bring it, about 75. + const anchorFilled = (side: number) => + page.locator("#stage").evaluate((el, side) => { + const c = el as HTMLCanvasElement; + const half = Math.floor(side / 2); + const data = c + .getContext("2d") + ?.getImageData( + Math.floor(c.width * 0.35) - half, + Math.floor(c.height * 0.45) - half, + side, + side, + ).data; + if (!data) return false; + for (let i = 3; i < data.length; i += 4) if ((data[i] ?? 0) < 250) return false; + return true; + }, side); + await expect.poll(() => anchorFilled(5)).toBe(true); + expect(await anchorFilled(41)).toBe(false); + + // Touch input as the browser receives it from a screen, not events made up in the page. + const stage = await page.locator("#stage").boundingBox(); + if (stage === null) throw new Error("the canvas has no box"); + const cdp = await page.context().newCDPSession(page); + const x = stage.x + stage.width / 2; + const y = stage.y + stage.height / 2; + const fingers = (apart: number) => [ + { x: x - apart / 2, y, id: 0 }, + { x: x + apart / 2, y, id: 1 }, + ]; + await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: fingers(40) }); + for (const apart of [100, 200, 300, 400]) { + await cdp.send("Input.dispatchTouchEvent", { + type: "touchMove", + touchPoints: fingers(apart), + }); + } + await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }); + await expect.poll(() => anchorFilled(41)).toBe(true); + }); + }); + test("the colour toggle switches, and there is no 2D view or standing overlay", async ({ page, }) => { diff --git a/packages/app-web/src/ui/GraphView.tsx b/packages/app-web/src/ui/GraphView.tsx index c933ddff..db5dc568 100644 --- a/packages/app-web/src/ui/GraphView.tsx +++ b/packages/app-web/src/ui/GraphView.tsx @@ -55,7 +55,18 @@ type Camera = { yaw: number; pitch: number; distance: number }; /** A point in the graph's space: the plane of the force layout, and depth by order first seen. */ type Point3 = { x: number; y: number; z: number }; -type Drag = { x: number; y: number; yaw: number; pitch: number }; +/** A position in the window, in CSS pixels. */ +type Point2 = { x: number; y: number }; + +/** + * What the pointers pressed on the canvas do to the camera, measured from where they were when a + * pointer was last pressed or released. One pointer orbits: it turns the camera from `yaw` and + * `pitch` as it moves from `from`. Two pinch: the camera's distance scales with how much nearer or + * further apart they are than `gap`, from `distance`. + */ +type Gesture = + | { kind: "orbit"; from: Point2; yaw: number; pitch: number } + | { kind: "pinch"; gap: number; distance: number }; type Sim = { nodes: Map; @@ -72,7 +83,7 @@ type Sim = { pivot: Point3 | null; /** Scales every force, and falls each tick; below HEAT_MIN the layout is not computed. */ heat: number; - /** Whether the camera frames the open node by itself: set when a node opens, cleared by a drag or a scroll. */ + /** Whether the camera frames the open node by itself: set when a node opens, cleared by a drag, a pinch or a scroll. */ follow: boolean; /** The yaw the framing chose, which the sway swings about; null until it chooses after following starts. */ aim: number | null; @@ -82,7 +93,14 @@ type Sim = { pointerOn: boolean; /** Whether the reader's system asks for reduced motion. The camera does not sway when it does. */ reducedMotion: boolean; - drag: Drag | null; + /** The pointers pressed on the canvas and not yet released, by pointer id, where each is now. */ + pointers: Map; + /** What the pressed pointers do to the camera; null while none is pressed. */ + gesture: Gesture | null; + /** + * Whether the pointers pressed since the canvas last had none have moved the camera or pinched. + * The click that ends such a press opens no node. + */ dragged: boolean; }; @@ -1071,11 +1089,106 @@ export function createSim(): Sim { swayPhase: 0, pointerOn: false, reducedMotion: false, - drag: null, + pointers: new Map(), + gesture: null, dragged: false, }; } +/** How far the camera can be from the pivot, as a scroll or a pinch zooms it. */ +const ZOOM = { min: 120, max: 2200 }; +/** + * How far, in pixels, a pressed pointer must move, or a pinch's two pointers must close or part, + * before the gesture takes the camera from the framing. + */ +const DRAG_SLOP = 4; +/** How far the camera turns, in radians, for each pixel an orbiting pointer moves. */ +const ORBIT_RATE = 0.006; + +/** Stops the camera framing the open node, as a drag, a pinch or a scroll takes it. */ +function takeCamera(sim: Sim): void { + sim.follow = false; + sim.aim = null; +} + +function zoomTo(sim: Sim, distance: number): void { + sim.camera.distance = Math.max(ZOOM.min, Math.min(ZOOM.max, distance)); +} + +/** + * The gesture the pressed pointers make from where they are now: none, an orbit by the one + * pointer, or a pinch by the first two pressed. A third pointer moves nothing. + */ +function gestureOf(sim: Sim): Gesture | null { + const [first, second] = sim.pointers.values(); + if (first === undefined) return null; + if (second === undefined) + return { kind: "orbit", from: first, yaw: sim.camera.yaw, pitch: sim.camera.pitch }; + return { kind: "pinch", gap: gapBetween(first, second), distance: sim.camera.distance }; +} + +/** The distance between two pointers, at least 1 pixel, so a pinch never divides by 0. */ +function gapBetween(a: Point2, b: Point2): number { + return Math.max(1, Math.hypot(a.x - b.x, a.y - b.y)); +} + +/** + * Records a pointer pressed on the canvas at `at`. The gesture starts again from the pointers' + * positions, so the camera does not jump when a second pointer joins or one is lifted. A second + * pointer makes the press a pinch, whose click opens no node. + */ +export function pressPointer(sim: Sim, id: number, at: Point2): void { + const stale = sim.pointers.get(id); + if (stale !== undefined) { + console.warn( + `graph view: pointer ${id}, pressed at (${stale.x}, ${stale.y}), is pressed again at (${at.x}, ${at.y}) with no release between; the earlier press is dropped`, + ); + sim.pointers.delete(id); + } + if (sim.pointers.size === 0) sim.dragged = false; + sim.pointers.set(id, at); + if (sim.pointers.size > 1) sim.dragged = true; + sim.gesture = gestureOf(sim); +} + +/** Records a pointer lifted or cancelled. One pressed elsewhere changes nothing. */ +export function releasePointer(sim: Sim, id: number): void { + if (!sim.pointers.delete(id)) return; + sim.gesture = gestureOf(sim); +} + +/** + * Moves a pressed pointer to `at`, and turns or zooms the camera. Returns false for a pointer not + * pressed on the canvas, such as a mouse passing over it. + */ +export function movePointer(sim: Sim, id: number, at: Point2): boolean { + const gesture = sim.gesture; + if (gesture === null || !sim.pointers.has(id)) return false; + sim.pointers.set(id, at); + switch (gesture.kind) { + case "orbit": { + const dx = at.x - gesture.from.x; + const dy = at.y - gesture.from.y; + if (Math.hypot(dx, dy) > DRAG_SLOP) { + sim.dragged = true; + takeCamera(sim); + } + sim.camera.yaw = gesture.yaw + dx * ORBIT_RATE; + sim.camera.pitch = gesture.pitch - dy * ORBIT_RATE; + return true; + } + case "pinch": { + const [first, second] = sim.pointers.values(); + if (first === undefined || second === undefined) return true; + const gap = gapBetween(first, second); + if (Math.abs(gap - gesture.gap) > DRAG_SLOP) takeCamera(sim); + // Pointers moving apart bring the camera nearer. + zoomTo(sim, (gesture.distance * gesture.gap) / gap); + return true; + } + } +} + function resizeCanvas( canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D, @@ -1111,6 +1224,8 @@ export function GraphView({ const ctxRef = useRef(null); const simRef = useRef(createSim()); const reducedMotion = useMediaQuery(REDUCED_MOTION); + // A device whose main pointer cannot hover, such as a phone or a tablet, zooms by pinching. + const touch = useMediaQuery("(hover: none)"); const sizeRef = useRef({ width: 0, height: 0 }); const navigateRef = useRef(onNavigate); navigateRef.current = onNavigate; @@ -1144,27 +1259,15 @@ export function GraphView({ const wrap = canvas.parentElement; if (wrap) ro.observe(wrap); + const at = (event: PointerEvent): Point2 => ({ x: event.clientX, y: event.clientY }); const onPointerDown = (event: PointerEvent) => { - const s = simRef.current; - s.dragged = false; - s.drag = { x: event.clientX, y: event.clientY, yaw: s.camera.yaw, pitch: s.camera.pitch }; + pressPointer(simRef.current, event.pointerId, at(event)); canvas.classList.add("dragging"); }; const onPointerMove = (event: PointerEvent) => { const s = simRef.current; + if (movePointer(s, event.pointerId, at(event))) return; const rect = canvas.getBoundingClientRect(); - if (s.drag) { - const dx = event.clientX - s.drag.x; - const dy = event.clientY - s.drag.y; - if (Math.hypot(dx, dy) > 4) { - s.dragged = true; - s.follow = false; - s.aim = null; - } - s.camera.yaw = s.drag.yaw + dx * 0.006; - s.camera.pitch = s.drag.pitch - dy * 0.006; - return; - } const mx = event.clientX - rect.left; const my = event.clientY - rect.top; const inside = mx >= 0 && my >= 0 && mx <= rect.width && my <= rect.height; @@ -1174,10 +1277,12 @@ export function GraphView({ setHoverId(s.hoverId); setPointerOn(inside); }; - const onPointerUp = () => { + // The browser fires pointercancel instead of pointerup when it stops reporting a pointer, for + // example when the operating system uses a touch for its own gesture. Either releases it. + const onPointerUp = (event: PointerEvent) => { const s = simRef.current; - s.drag = null; - canvas.classList.remove("dragging"); + releasePointer(s, event.pointerId); + if (s.pointers.size === 0) canvas.classList.remove("dragging"); }; const onClick = (event: MouseEvent) => { const s = simRef.current; @@ -1189,10 +1294,9 @@ export function GraphView({ }; const onWheel = (event: WheelEvent) => { event.preventDefault(); - simRef.current.follow = false; - simRef.current.aim = null; - const cam = simRef.current.camera; - cam.distance = Math.max(120, Math.min(2200, cam.distance + event.deltaY * 0.6)); + const s = simRef.current; + takeCamera(s); + zoomTo(s, s.camera.distance + event.deltaY * 0.6); }; const onLeave = () => { simRef.current.hoverId = null; @@ -1204,6 +1308,7 @@ export function GraphView({ canvas.addEventListener("pointerdown", onPointerDown); window.addEventListener("pointermove", onPointerMove); window.addEventListener("pointerup", onPointerUp); + window.addEventListener("pointercancel", onPointerUp); canvas.addEventListener("click", onClick); canvas.addEventListener("wheel", onWheel, { passive: false }); canvas.addEventListener("pointerleave", onLeave); @@ -1213,6 +1318,7 @@ export function GraphView({ canvas.removeEventListener("pointerdown", onPointerDown); window.removeEventListener("pointermove", onPointerMove); window.removeEventListener("pointerup", onPointerUp); + window.removeEventListener("pointercancel", onPointerUp); canvas.removeEventListener("click", onClick); canvas.removeEventListener("wheel", onWheel); canvas.removeEventListener("pointerleave", onLeave); @@ -1238,7 +1344,7 @@ export function GraphView({ return (
-
drag to orbit · scroll to zoom
+
drag to orbit · {touch ? "pinch" : "scroll"} to zoom
{carded === null ? null : summary(carded)}
diff --git a/packages/app-web/tests/graph-view.test.ts b/packages/app-web/tests/graph-view.test.ts index 28a16236..c5c33af5 100644 --- a/packages/app-web/tests/graph-view.test.ts +++ b/packages/app-web/tests/graph-view.test.ts @@ -8,8 +8,11 @@ import { INITIAL_CAMERA, labelAngle, mergeSeeds, + movePointer, labelCandidates, placeLabels, + pressPointer, + releasePointer, screenAxes, tickPhysics, yawToward, @@ -443,6 +446,99 @@ describe("the camera's pivot", () => { }); }); +describe("pointers on the canvas", () => { + // A followed camera at distance 600. + const followed = () => { + const sim = createSim(); + sim.camera.distance = 600; + return sim; + }; + + test("one pointer orbits the camera and takes it from the framing; its click opens nothing", () => { + const sim = followed(); + pressPointer(sim, 1, { x: 100, y: 100 }); + expect(movePointer(sim, 1, { x: 200, y: 150 })).toBe(true); + expect(sim.camera.yaw).toBeCloseTo(INITIAL_CAMERA.yaw + 0.6); + expect(sim.camera.pitch).toBeCloseTo(INITIAL_CAMERA.pitch - 0.3); + expect(sim.follow).toBe(false); + expect(sim.dragged).toBe(true); + }); + + test("a press that moves 4 pixels or less leaves the framing, and its click opens the node", () => { + const sim = followed(); + pressPointer(sim, 1, { x: 100, y: 100 }); + movePointer(sim, 1, { x: 103, y: 102 }); + releasePointer(sim, 1); + expect(sim.follow).toBe(true); + expect(sim.dragged).toBe(false); + }); + + test("two pointers moving apart bring the camera nearer, and together take it further", () => { + const sim = followed(); + pressPointer(sim, 1, { x: 100, y: 100 }); + pressPointer(sim, 2, { x: 140, y: 100 }); + movePointer(sim, 2, { x: 180, y: 100 }); + expect(sim.camera.distance).toBeCloseTo(300); + expect(sim.follow).toBe(false); + movePointer(sim, 2, { x: 120, y: 100 }); + expect(sim.camera.distance).toBeCloseTo(1200); + }); + + test("a pinch zooms no nearer than 120 and no further than 2200", () => { + const sim = followed(); + pressPointer(sim, 1, { x: 100, y: 100 }); + pressPointer(sim, 2, { x: 140, y: 100 }); + movePointer(sim, 2, { x: 900, y: 100 }); + expect(sim.camera.distance).toBe(120); + movePointer(sim, 2, { x: 101, y: 100 }); + expect(sim.camera.distance).toBe(2200); + }); + + test("a pinch does not turn the camera, and its click opens nothing even when it did not zoom", () => { + const sim = followed(); + pressPointer(sim, 1, { x: 100, y: 100 }); + pressPointer(sim, 2, { x: 140, y: 100 }); + movePointer(sim, 1, { x: 90, y: 100 }); + expect(sim.camera.yaw).toBe(INITIAL_CAMERA.yaw); + expect(sim.camera.pitch).toBe(INITIAL_CAMERA.pitch); + releasePointer(sim, 1); + releasePointer(sim, 2); + expect(sim.dragged).toBe(true); + }); + + test("when one of two pointers is lifted, the other orbits from where it is, so the camera does not jump", () => { + const sim = followed(); + pressPointer(sim, 1, { x: 100, y: 100 }); + pressPointer(sim, 2, { x: 140, y: 100 }); + movePointer(sim, 1, { x: 60, y: 100 }); + releasePointer(sim, 2); + const yaw = sim.camera.yaw; + movePointer(sim, 1, { x: 60, y: 100 }); + expect(sim.camera.yaw).toBe(yaw); + movePointer(sim, 1, { x: 110, y: 100 }); + expect(sim.camera.yaw).toBeCloseTo(yaw + 0.3); + }); + + test("a pointer not pressed on the canvas moves nothing; a cancelled one ends its gesture", () => { + const sim = followed(); + expect(movePointer(sim, 7, { x: 500, y: 500 })).toBe(false); + pressPointer(sim, 1, { x: 100, y: 100 }); + releasePointer(sim, 1); + expect(sim.gesture).toBeNull(); + expect(movePointer(sim, 1, { x: 300, y: 100 })).toBe(false); + expect(sim.camera.yaw).toBe(INITIAL_CAMERA.yaw); + }); + + test("the next press after a drag starts with nothing dragged, so its click opens the node", () => { + const sim = followed(); + pressPointer(sim, 1, { x: 100, y: 100 }); + movePointer(sim, 1, { x: 200, y: 100 }); + releasePointer(sim, 1); + pressPointer(sim, 1, { x: 300, y: 100 }); + expect(sim.dragged).toBe(false); + }); +}); + describe("an edge between two nodes", () => { test("runs from the rim of one node to the rim of the other", () => { expect(rimToRim({ sx: 0, sy: 0 }, 5, { sx: 30, sy: 40 }, 10)).toEqual({