From 4e27ddbbecdc0f68290cbc32aac0c111ab03636e Mon Sep 17 00:00:00 2001 From: n30nex Date: Thu, 15 Oct 2026 09:58:57 -0400 Subject: [PATCH] Avoid repeated Topology scene and traffic work while preserving animation --- src/features/topology/topology-renderer.ts | 132 ++++++++++++++---- src/features/topology/topology.ts | 62 ++++++-- .../features/topology/TopologyCanvas.test.ts | 109 +++++++++++++++ tests/features/topology/topology.test.ts | 38 ++++- 4 files changed, 296 insertions(+), 45 deletions(-) diff --git a/src/features/topology/topology-renderer.ts b/src/features/topology/topology-renderer.ts index a307c8f..9847c48 100644 --- a/src/features/topology/topology-renderer.ts +++ b/src/features/topology/topology-renderer.ts @@ -1,6 +1,6 @@ import { CHAT_MS, type LiveChatter } from "./chatter"; import { nodeTypeColor, type ChartColors } from "../stats/chartTheme"; -import { DEFAULT_CAMERA, FLOW_MS, project, fitCamera, panCamera, pathControls, visibleLinks, flowColor, linkContext, type Camera, type PathDisplay, type LiveTraffic, type Point3, type Topology } from "./topology"; +import { DEFAULT_CAMERA, FLOW_MS, createProjector, fitCamera, panCamera, pathControls, visibleLinks, flowColor, linkContext, type Camera, type PathDisplay, type LiveTraffic, type Point3, type Topology } from "./topology"; export type CameraView = { camera: Camera; region: string; extent: number }; @@ -10,7 +10,13 @@ export function createRenderer(canvas: HTMLCanvasElement, graph: Topology, traff let camera = { ...(view?.camera ?? DEFAULT_CAMERA), target: { ...(view?.camera.target ?? DEFAULT_CAMERA.target) } }; let transition: { from: Camera; to: Camera; at: number } | null = null; const background = document.createElement("canvas"), backgroundCtx = background.getContext("2d"); - let width = 1, height = 1, frame = 0, previousFrame = 0; + const underlay = document.createElement("canvas"), underlayCtx = underlay.getContext("2d"); + const foreground = document.createElement("canvas"), foregroundCtx = foreground.getContext("2d"); + if (!backgroundCtx || !underlayCtx || !foregroundCtx) return null; + let trailsDirty = true, layersValid = false, movingUntil = 0; + const bubbleLayouts = new Map(); + const labelWidths = new Map(); + let width = 1, height = 1, ratio = 1, frame = 0, previousFrame = 0; let running = true, motion = true, selected = "", dirty = true, baseDirty = true, regionCode = view?.region ?? ""; let fitted = !!view; let display: PathDisplay = "all"; @@ -23,13 +29,17 @@ export function createRenderer(canvas: HTMLCanvasElement, graph: Topology, traff const regionByCode = new Map(graph.regions.map(r => [r.code, r])); const extent = Math.max(220, ...graph.regions.map(r => Math.hypot(r.x, r.z) + r.radius)); if (view) camera.zoom *= extent / view.extent; - const p = (point: Point3) => project(point, camera, width, height, extent); + let projector = createProjector(camera, width, height, extent); + const p = (point: Point3) => projector(point); const line = (a: Point3, b: Point3) => { const from = p(a), to = p(b); ctx.moveTo(from.x, from.y); ctx.lineTo(to.x, to.y); }; const curve = (a: Point3, b: Point3, cross: boolean) => pathControls(a, b, cross).map(p); const strokeCurve = (points: ReturnType) => { const [a, c, d, b] = points; ctx.beginPath(); ctx.moveTo(a!.x, a!.y); ctx.bezierCurveTo(c!.x, c!.y, d!.x, d!.y, b!.x, b!.y); ctx.stroke(); }; + // Rounded backing sizes can differ from CSS size * ratio by half a pixel. + // Copy backing pixels 1:1 so fractional-DPR phones do not blur each layer. + const composite = (layer: HTMLCanvasElement) => ctx.drawImage(layer, 0, 0, layer.width / ratio, layer.height / ratio); const wake = (changed = false) => { baseDirty ||= changed; dirty = true; if (!frame && running && !document.hidden) frame = requestAnimationFrame(draw); }; function fit(smooth = true) { const regions = regionByCode.has(regionCode) ? [regionByCode.get(regionCode)!] : graph.regions; @@ -47,11 +57,21 @@ export function createRenderer(canvas: HTMLCanvasElement, graph: Topology, traff wake(true); } + function drawTrails() { + for (const trail of traffic.trails.values()) { + const from = graph.byId.get(trail.from), to = graph.byId.get(trail.to); if (!from || !to) continue; + if (display !== "all" && selected !== from.id && selected !== to.id && !(display === "bundled" && regionCode && from.region === regionCode && to.region === regionCode)) continue; + ctx!.globalAlpha = 0.35; ctx!.strokeStyle = flowColor(trail.kind, colors); ctx!.lineWidth = 1; + strokeCurve(curve(from, to, from.region !== to.region)); + } + } + function draw(now: number) { frame = 0; if (!running || document.hidden) return; - const bubbles = chatter?.visible(Date.now()) ?? []; - const flows = motion ? traffic.flows.filter(r => Date.now() - r.at < FLOW_MS) : []; + const time = Date.now(); + const bubbles = chatter?.visible(time) ?? []; + const flows = motion ? traffic.flows.filter(r => time - r.at < FLOW_MS) : []; // A fixed 30 fps ceiling and capped pixel ratio bound work on phones and large meshes. if (!dirty && (flows.length || bubbles.length || transition) && now - previousFrame < 32) { frame = requestAnimationFrame(draw); return; } previousFrame = now; @@ -62,8 +82,14 @@ export function createRenderer(canvas: HTMLCanvasElement, graph: Topology, traff camera = { yaw: mix(from.yaw, to.yaw), pitch: mix(from.pitch, to.pitch), zoom: mix(from.zoom, to.zoom), target: { x: mix(from.target.x, to.target.x), y: mix(from.target.y, to.target.y), z: mix(from.target.z, to.target.z) } }; baseDirty = true; if (t === 1) transition = null; } - // Repaint static ink only for a changed camera, graph, style or selection. - if (baseDirty || !backgroundCtx) { + projector = createProjector(camera, width, height, extent); + // Keep the mesh below live trails and the nodes above them. Trail changes + // only recomposite these bounded, pixel-ratio-capped layers; they do not + // reproject, sort and stroke the entire graph. + const cacheLayers = !transition && now >= movingUntil; + let paintedScene = false; + if (baseDirty || (trailsDirty && !layersValid)) { + baseDirty = true; ctx!.clearRect(0, 0, width, height); ctx!.fillStyle = colors.bgBase; ctx!.fillRect(0, 0, width, height); @@ -98,12 +124,12 @@ export function createRenderer(canvas: HTMLCanvasElement, graph: Topology, traff strokeCurve(curve(graph.byId.get(a)!, graph.byId.get(b)!, context.crossRegion)); } ctx!.setLineDash([]); - for (const trail of traffic.trails.values()) { - const from = graph.byId.get(trail.from), to = graph.byId.get(trail.to); if (!from || !to) continue; - if (display !== "all" && selected !== from.id && selected !== to.id && !(display === "bundled" && regionCode && from.region === regionCode && to.region === regionCode)) continue; - ctx!.globalAlpha = 0.35; ctx!.strokeStyle = flowColor(trail.kind, colors); ctx!.lineWidth = 1; - strokeCurve(curve(from, to, from.region !== to.region)); - } + ctx!.globalAlpha = 1; + if (cacheLayers) { + underlayCtx!.clearRect(0, 0, underlay.width, underlay.height); + underlayCtx!.drawImage(canvas, 0, 0); + ctx!.clearRect(0, 0, width, height); + } else drawTrails(); if (display === "bundled") for (const bundle of graph.bundles) { const points = curve(regionByCode.get(bundle.from)!, regionByCode.get(bundle.to)!, true); ctx!.globalAlpha = 0.6; ctx!.strokeStyle = colors.primary; ctx!.lineWidth = Math.min(4, 1 + Math.log2(1 + bundle.count) * 0.3); strokeCurve(points); @@ -128,12 +154,36 @@ export function createRenderer(canvas: HTMLCanvasElement, graph: Topology, traff if (n.isObserver || n.id === selected) { ctx!.strokeStyle = n.id === selected ? colors.textBright : colors.primary; ctx!.lineWidth = 1.2; ctx!.beginPath(); ctx!.arc(q.x, q.y, radius + 3, 0, Math.PI * 2); ctx!.stroke(); } } ctx!.globalAlpha = 1; - backgroundCtx?.clearRect(0, 0, background.width, background.height); backgroundCtx?.drawImage(canvas, 0, 0); baseDirty = false; - } else ctx!.drawImage(background, 0, 0, width, height); + if (cacheLayers) { + foregroundCtx!.clearRect(0, 0, foreground.width, foreground.height); + foregroundCtx!.drawImage(canvas, 0, 0); + layersValid = true; + } else { + // While the camera moves every frame, render directly in the original + // order. Populate the separated layers once it settles, not every drag. + backgroundCtx!.clearRect(0, 0, background.width, background.height); + backgroundCtx!.drawImage(canvas, 0, 0); + layersValid = false; + baseDirty = trailsDirty = false; + paintedScene = true; + } + } + if (baseDirty || trailsDirty) { + ctx!.globalAlpha = 1; + ctx!.clearRect(0, 0, width, height); + composite(underlay); + ctx!.setLineDash([]); + drawTrails(); + ctx!.globalAlpha = 1; + composite(foreground); + backgroundCtx!.clearRect(0, 0, background.width, background.height); + backgroundCtx!.drawImage(canvas, 0, 0); + baseDirty = trailsDirty = false; + } else if (!paintedScene) composite(background); // All live flows remain visible in every link-display mode. Curves change // presentation only: their endpoints still come from exact observed segments. for (const report of flows) { - const progress = Math.min(1, (Date.now() - report.at) / FLOW_MS); + const progress = Math.min(1, (time - report.at) / FLOW_MS); const segmentIndex = Math.min(report.segments.length - 1, Math.floor(progress * report.segments.length)); const pair = report.segments[segmentIndex]; if (!pair) continue; @@ -158,7 +208,7 @@ export function createRenderer(canvas: HTMLCanvasElement, graph: Topology, traff regionHits = []; const recentByRegion = new Map(); if (motion) for (let i = traffic.reports.length - 1; i >= 0; i--) { - const report = traffic.reports[i]!; if (Date.now() - report.at >= 1500) break; + const report = traffic.reports[i]!; if (time - report.at >= 1500) break; if (!recentByRegion.has(report.iata)) recentByRegion.set(report.iata, report); } for (const region of [...graph.regions].sort((a, b) => Number(b.code === regionCode) - Number(a.code === regionCode) || b.count - a.count)) { @@ -171,7 +221,12 @@ export function createRenderer(canvas: HTMLCanvasElement, graph: Topology, traff q.y -= Math.min(35, region.radius * q.scale * 0.6); if (q.x < -100 || q.x > width + 100 || q.y < -30 || q.y > height + 30) continue; const label = `${region.code} · ${region.count}`; - const w = ctx!.measureText(label).width + 16; + let metrics = labelWidths.get(region.code); + if (!metrics || metrics.font !== ctx!.font || metrics.label !== label) { + metrics = { font: ctx!.font, label, width: ctx!.measureText(label).width + 16 }; + labelWidths.set(region.code, metrics); + } + const w = metrics.width; if (regionHits.some(r => Math.abs(r.x - q.x) < (r.width + w) / 2 + 5 && Math.abs(r.y - q.y) < 29)) continue; ctx!.fillStyle = colors.bgRaised; ctx!.fillRect(q.x - w / 2, q.y - 13, w, 26); ctx!.fillStyle = colors.textBright; ctx!.fillText(label, q.x, q.y + 4); @@ -188,6 +243,7 @@ export function createRenderer(canvas: HTMLCanvasElement, graph: Topology, traff ctx!.fillStyle = colors.bgSurface; ctx!.fillRect(selectedPoint.x - w / 2, selectedPoint.y - 34, w, 22); ctx!.fillStyle = colors.textBright; ctx!.fillText(label, selectedPoint.x, selectedPoint.y - 19); } + for (const hash of bubbleLayouts.keys()) if (!bubbles.some(b => b.hash === hash)) bubbleLayouts.delete(hash); const placed: {x:number;y:number;w:number;h:number}[] = []; for (const bubble of bubbles) { if (placed.length >= (width < 600 ? 1 : 2)) break; @@ -199,7 +255,7 @@ export function createRenderer(canvas: HTMLCanvasElement, graph: Topology, traff const positions=[{x:q.x-w/2,y:q.y-h-25},{x:q.x+20,y:q.y-h-15},{x:q.x-w-20,y:q.y-h-15},{x:q.x-w/2,y:q.y+25}]; const box=positions.map(pos=>({x:Math.max(8,Math.min(width-w-8,pos.x)),y:Math.max(8,Math.min(height-h-8,pos.y)),w,h})).find(pos=>(q.xpos.x+pos.w+8 || q.ypos.y+pos.h+8) && !placed.some(old=>pos.xold.x && pos.yold.y)); if (!box) continue; placed.push(box); - const alpha=motion ? Math.min(1,Math.max(0,(CHAT_MS-(Date.now()-bubble.at))/1600)) : 1; + const alpha=motion ? Math.min(1,Math.max(0,(CHAT_MS-(time-bubble.at))/1600)) : 1; ctx!.globalAlpha=alpha; ctx!.strokeStyle=colors.secondary;ctx!.lineWidth=1; const sx=Math.max(box.x,Math.min(box.x+w,q.x)),sy=Math.max(box.y,Math.min(box.y+h,q.y)); @@ -210,9 +266,15 @@ export function createRenderer(canvas: HTMLCanvasElement, graph: Topology, traff ctx!.textAlign="left";ctx!.font="600 12px Inter, sans-serif";ctx!.fillStyle=colors.secondary; ctx!.fillText(bubble.sender,box.x+9,box.y+18,w-18); ctx!.font="12px Inter, sans-serif";ctx!.fillStyle=colors.textBright; - const chars=Array.from(bubble.content);const lines:string[]=[];let line=""; - for (const char of chars) { if(ctx!.measureText(line+char).width>w-20) { const space=line.lastIndexOf(" "); if(space>0){lines.push(line.slice(0,space));line=line.slice(space+1);} else {lines.push(line);line="";} } line+=char; } - if(line)lines.push(line); + let layout = bubbleLayouts.get(bubble.hash); + if (!layout || layout.content !== bubble.content || layout.width !== w) { + const lines: string[] = []; let line = ""; + for (const char of Array.from(bubble.content)) { if(ctx!.measureText(line+char).width>w-20) { const space=line.lastIndexOf(" "); if(space>0){lines.push(line.slice(0,space));line=line.slice(space+1);} else {lines.push(line);line="";} } line+=char; } + if (line) lines.push(line); + layout = { content: bubble.content, width: w, lines }; + bubbleLayouts.set(bubble.hash, layout); + } + const { lines } = layout; for(let i=0;i2 ? -2 : undefined)+(i===1 && lines.length>2 ? "…" : ""),box.x+9,box.y+39+i*17,w-18); } ctx!.globalAlpha=1; @@ -221,13 +283,15 @@ export function createRenderer(canvas: HTMLCanvasElement, graph: Topology, traff function resize() { const box = canvas.getBoundingClientRect(); width = Math.max(1, box.width); height = Math.max(1, box.height); - const ratio = Math.min(window.devicePixelRatio || 1, width < 600 ? 1.25 : 1.5); - canvas.width = background.width = Math.round(width * ratio); canvas.height = background.height = Math.round(height * ratio); + ratio = Math.min(window.devicePixelRatio || 1, width < 600 ? 1.25 : 1.5); + for (const layer of [canvas, background, underlay, foreground]) { + layer.width = Math.round(width * ratio); layer.height = Math.round(height * ratio); + } ctx!.setTransform(ratio, 0, 0, ratio, 0, 0); if (!fitted) { fit(false); fitted = true; } else wake(true); } - function zoom(factor: number) { transition = null; camera = { ...camera, zoom: Math.max(0.2, Math.min(80, camera.zoom * factor)) }; wake(true); } - function pan(dx: number, dy: number) { transition = null; camera = panCamera(camera, dx, dy, width, height, extent); wake(true); } + function zoom(factor: number) { movingUntil = performance.now() + 120; transition = null; camera = { ...camera, zoom: Math.max(0.2, Math.min(80, camera.zoom * factor)) }; wake(true); } + function pan(dx: number, dy: number) { movingUntil = performance.now() + 120; transition = null; camera = panCamera(camera, dx, dy, width, height, extent); wake(true); } function down(e: PointerEvent) { if (e.button > 2) return; e.preventDefault(); canvas.focus({ preventScroll: true }); @@ -237,6 +301,7 @@ export function createRenderer(canvas: HTMLCanvasElement, graph: Topology, traff } function move(e: PointerEvent) { const old = pointers.get(e.pointerId); if (!old) return; + movingUntil = performance.now() + 120; const other = [...pointers].find(([id]) => id !== e.pointerId)?.[1]; const dx = e.clientX - old.x, dy = e.clientY - old.y; if (other) { const before = Math.hypot(old.x - other.x, old.y - other.y); const after = Math.hypot(e.clientX - other.x, e.clientY - other.y); if (before > 5) zoom(after / before); pan(dx / 2, dy / 2); moved = true; } @@ -259,7 +324,7 @@ export function createRenderer(canvas: HTMLCanvasElement, graph: Topology, traff function wheel(e: WheelEvent) { e.preventDefault(); zoom(Math.exp(-Math.max(-100, Math.min(100, e.deltaY)) * 0.002)); } function key(e: KeyboardEvent) { if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "+", "=", "-", "Home", "Escape"].includes(e.key)) return; - e.preventDefault(); transition = null; + e.preventDefault(); transition = null; movingUntil = performance.now() + 120; if (e.key === "Home") fit(); else if (e.key === "Escape") focus(""); else if (e.key === "+" || e.key === "=") zoom(1.2); @@ -272,6 +337,8 @@ export function createRenderer(canvas: HTMLCanvasElement, graph: Topology, traff wake(true); } function visibility() { pointers.clear(); if (document.hidden) { cancelAnimationFrame(frame); frame = 0; } else wake(); } + const fontsLoaded = () => { labelWidths.clear(); bubbleLayouts.clear(); wake(true); }; + document.fonts?.addEventListener("loadingdone", fontsLoaded); const observer = new ResizeObserver(resize); observer.observe(canvas); resize(); canvas.addEventListener("pointerdown", down); canvas.addEventListener("pointermove", move); canvas.addEventListener("pointerup", up); canvas.addEventListener("pointercancel", up); canvas.addEventListener("lostpointercapture", up); @@ -281,11 +348,14 @@ export function createRenderer(canvas: HTMLCanvasElement, graph: Topology, traff return { update(id: string, region: string, paths: PathDisplay, animate: boolean, active: boolean) { const changeRegion = region !== regionCode; - baseDirty ||= selected !== id || changeRegion || paths !== display || lastTrailVersion !== traffic.trailVersion; + baseDirty ||= selected !== id || changeRegion || paths !== display; + trailsDirty ||= lastTrailVersion !== traffic.trailVersion; lastTrailVersion = traffic.trailVersion; + if (selected !== id) { + related = new Set([id]); + if (id) for (const [a, b] of graph.links) { if (a === id) related.add(b); if (b === id) related.add(a); } + } selected = id; regionCode = region; display = paths; motion = animate; running = active; - related = new Set([id]); - for (const [a, b] of graph.links) { if (a === id) related.add(b); if (b === id) related.add(a); } if (!motion && transition) { camera = transition.to; transition = null; baseDirty = true; } if (changeRegion) fit(); if (!running) { cancelAnimationFrame(frame); frame = 0; } else wake(); @@ -299,6 +369,8 @@ export function createRenderer(canvas: HTMLCanvasElement, graph: Topology, traff canvas.removeEventListener("contextmenu", contextMenu); canvas.removeEventListener("wheel", wheel); canvas.removeEventListener("keydown", key); document.removeEventListener("visibilitychange", visibility); + document.fonts?.removeEventListener("loadingdone", fontsLoaded); + for (const layer of [background, underlay, foreground]) layer.width = layer.height = 0; }, }; } diff --git a/src/features/topology/topology.ts b/src/features/topology/topology.ts index 5ed21df..3b464f2 100644 --- a/src/features/topology/topology.ts +++ b/src/features/topology/topology.ts @@ -191,7 +191,11 @@ export class LiveTraffic { gapUntil = 0; markGap() { this.gapUntil = Date.now() + LIVE_WINDOW; } private seen = new Map(); + private ordered = true; + private lastAt = -Infinity; add(report: LiveReport) { + this.ordered &&= report.at >= this.lastAt; + this.lastAt = report.at; this.prune(report.at); if (this.seen.has(report.key)) return false; this.seen.set(report.key, report.at); @@ -206,31 +210,61 @@ export class LiveTraffic { if (this.reports.length > REPORT_CAP) { this.reports.shift(); this.cappedUntil = report.at + LIVE_WINDOW; } if (report.segments.length) { if (this.flows.length >= FLOW_CAP) this.cappedUntil = report.at + LIVE_WINDOW; - this.flows = [...this.flows.slice(-(FLOW_CAP - 1)), report]; + if (this.flows.length >= FLOW_CAP) this.flows.shift(); + this.flows.push(report); } return true; } clearFlows() { this.flows = []; } prune(now: number) { - this.reports = this.reports.filter(r => now - r.at < LIVE_WINDOW); - this.flows = this.flows.filter(r => now - r.at < FLOW_MS); - for (const [key, at] of this.seen) { if (now - at < LIVE_WINDOW) break; this.seen.delete(key); } - for (const [key, trail] of this.trails) { if (now - trail.at < LIVE_WINDOW) break; this.trails.delete(key); this.trailVersion++; } + // Receipt times normally arrive in order: inspect only the expired prefix, + // without copying the whole live window for every packet. Fall back after a + // clock adjustment so an older entry behind a newer one still expires. + for (const [items, lifetime] of [[this.reports, LIVE_WINDOW], [this.flows, FLOW_MS]] as const) { + if (this.ordered) { + let expired = 0; + while (expired < items.length && now - items[expired]!.at >= lifetime) expired++; + if (expired) items.splice(0, expired); + } else { + let kept = 0; + for (const item of items) if (now - item.at < lifetime) items[kept++] = item; + items.length = kept; + } + } + for (const [key, at] of this.seen) { + if (now - at < LIVE_WINDOW) { if (this.ordered) break; continue; } + this.seen.delete(key); + } + for (const [key, trail] of this.trails) { + if (now - trail.at < LIVE_WINDOW) { if (this.ordered) break; continue; } + this.trails.delete(key); this.trailVersion++; + } + if (!this.reports.length && !this.seen.size && !this.trails.size) this.ordered = true; } } export type Camera = { yaw: number; pitch: number; zoom: number; target: Point3 }; export const DEFAULT_CAMERA: Camera = { yaw: 0, pitch: 1.05, zoom: 1, target: { x: 0, y: 0, z: 0 } }; export function project(point: Point3, camera: Camera, width: number, height: number, extent: number) { - const local = { x: point.x - camera.target.x, y: point.y - camera.target.y, z: point.z - camera.target.z }; - const x = local.x * Math.cos(camera.yaw) - local.z * Math.sin(camera.yaw); - const z = local.x * Math.sin(camera.yaw) + local.z * Math.cos(camera.yaw); - // Positive pitch puts the camera above the ground: raised nodes are closer. - const y = local.y * Math.cos(camera.pitch) + z * Math.sin(camera.pitch); - const depth = -local.y * Math.sin(camera.pitch) + z * Math.cos(camera.pitch); - const perspective = 3 * extent / (3 * extent + depth); - const scale = Math.min(width, height) / (extent * 2.6) * camera.zoom * perspective; - return { x: width / 2 + x * scale, y: height / 2 - y * scale, depth, scale, visible: perspective > 0 }; + return createProjector(camera, width, height, extent)(point); +} + +// One set of camera coefficients per frame, shared by every vertex and particle. +export function createProjector(camera: Camera, width: number, height: number, extent: number) { + const cy = Math.cos(camera.yaw), sy = Math.sin(camera.yaw); + const cp = Math.cos(camera.pitch), sp = Math.sin(camera.pitch); + const baseScale = Math.min(width, height) / (extent * 2.6) * camera.zoom; + return (point: Point3) => { + const lx = point.x - camera.target.x, ly = point.y - camera.target.y, lz = point.z - camera.target.z; + const x = lx * cy - lz * sy; + const z = lx * sy + lz * cy; + // Positive pitch puts the camera above the ground: raised nodes are closer. + const y = ly * cp + z * sp; + const depth = -ly * sp + z * cp; + const perspective = 3 * extent / (3 * extent + depth); + const scale = baseScale * perspective; + return { x: width / 2 + x * scale, y: height / 2 - y * scale, depth, scale, visible: perspective > 0 }; + }; } export function fitCamera(points: Point3[], camera: Camera, width: number, height: number, extent: number): Camera { diff --git a/tests/features/topology/TopologyCanvas.test.ts b/tests/features/topology/TopologyCanvas.test.ts index e534711..c65b6e5 100644 --- a/tests/features/topology/TopologyCanvas.test.ts +++ b/tests/features/topology/TopologyCanvas.test.ts @@ -3,6 +3,8 @@ import { createRenderer } from "../../../src/features/topology/topology-renderer import { buildTopology, LiveTraffic } from "../../../src/features/topology/topology"; import type { NodeSummary } from "../../../src/features/nodes/types"; import type { ChartColors } from "../../../src/features/stats/chartTheme"; +import type { LiveChatter } from "../../../src/features/topology/chatter"; +import type { LiveReport } from "../../../src/features/topology/topology"; afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); @@ -34,3 +36,110 @@ describe("topology camera continuity", () => { refreshed.dispose(); }); }); + +function drawing(width = 1200, height = 700) { + const frames = new Map(); + let clock = 0, frameId = 0; + vi.spyOn(Date, "now").mockImplementation(() => 1000 + clock); + vi.spyOn(performance, "now").mockImplementation(() => clock); + vi.spyOn(document, "hidden", "get").mockReturnValue(false); + vi.stubGlobal("ResizeObserver", class { observe() {} disconnect() {} }); + vi.stubGlobal("requestAnimationFrame", (fn: FrameRequestCallback) => { frames.set(++frameId, fn); return frameId; }); + vi.stubGlobal("cancelAnimationFrame", (id: number) => frames.delete(id)); + const contexts = new Map>(); + const makeContext = () => ({ + setTransform: vi.fn(), clearRect: vi.fn(), fillRect: vi.fn(), strokeRect: vi.fn(), drawImage: vi.fn(), + beginPath: vi.fn(), closePath: vi.fn(), moveTo: vi.fn(), lineTo: vi.fn(), bezierCurveTo: vi.fn(), + stroke: vi.fn(), fill: vi.fn(), arc: vi.fn(), rect: vi.fn(), setLineDash: vi.fn(), fillText: vi.fn(), + measureText: vi.fn((text: string) => ({ width: text.length * 6 })), font: "", + }); + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(function(this: HTMLCanvasElement) { + const value = makeContext(); contexts.set(this, value); + return value as unknown as CanvasRenderingContext2D; + }); + const canvas = document.createElement("canvas"); + vi.spyOn(canvas, "getBoundingClientRect").mockReturnValue({ width, height } as DOMRect); + const nodes = ["a", "b"].map(id => ({id, publicKey:id.repeat(64), name:id, nodeType:2,nodeTypeName:"repeater",iatas:[{iata:"YKF",lastHeard:1}],knownNeighborCount:1,neighborIds:[id==="a"?"b":"a"]} as NodeSummary)); + const graph = buildTopology(nodes), traffic = new LiveTraffic(); + const colors = { series:["red","green","blue","orange","purple","gray"] } as unknown as ChartColors; + return { + canvas, graph, traffic, colors, contexts, frames, + frame(at: number) { clock = at; const pending = [...frames.values()]; frames.clear(); pending.forEach(fn => fn(at)); }, + }; +} + +describe("topology rendering work", () => { + it("copies rounded backing pixels without resampling on fractional-DPR phones", () => { + const previous = Object.getOwnPropertyDescriptor(window,"devicePixelRatio")!; + Object.defineProperty(window,"devicePixelRatio",{configurable:true,value:1.25}); + const s = drawing(390,640); + const renderer = createRenderer(s.canvas,s.graph,s.traffic,s.colors,vi.fn(),vi.fn())!; + try { + renderer.update("","","all",false,true); s.frame(0); + expect(s.canvas.width).toBe(488); + expect(s.contexts.get(s.canvas)!.drawImage).toHaveBeenCalledWith(expect.any(HTMLCanvasElement),0,0,390.4,640); + } finally { renderer.dispose(); Object.defineProperty(window,"devicePixelRatio",previous); } + }); + + it("updates and removes trails without repainting the graph, then invalidates on camera changes", () => { + const s = drawing(); + const renderer = createRenderer(s.canvas,s.graph,s.traffic,s.colors,vi.fn(),vi.fn())!; + renderer.update("","","all",false,true); s.frame(0); + const ctx = s.contexts.get(s.canvas)!; + expect(ctx.arc).toHaveBeenCalled(); + ctx.arc.mockClear(); ctx.bezierCurveTo.mockClear(); + s.traffic.add({key:"packet",at:1000,kind:"text",segments:[["a","b"]]} as LiveReport); + renderer.update("","","all",false,true); s.frame(34); + expect(ctx.arc).not.toHaveBeenCalled(); + expect(ctx.bezierCurveTo).toHaveBeenCalledTimes(1); + ctx.bezierCurveTo.mockClear(); + s.traffic.prune(61000); renderer.update("","","all",false,true); s.frame(61000); + expect(ctx.arc).not.toHaveBeenCalled(); + expect(ctx.bezierCurveTo).not.toHaveBeenCalled(); + renderer.zoom(1.2); s.frame(61034); + expect(ctx.arc).toHaveBeenCalled(); + expect(ctx.bezierCurveTo).toHaveBeenCalled(); + renderer.dispose(); + for (const canvas of s.contexts.keys()) if (canvas !== s.canvas) expect([canvas.width,canvas.height]).toEqual([0,0]); + }); + + it("keeps moving packet cores and glows in all display modes and stops when inactive", () => { + const s = drawing(); + s.traffic.add({key:"packet",at:1000,kind:"text",segments:[["a","b"]]} as LiveReport); + const renderer = createRenderer(s.canvas,s.graph,s.traffic,s.colors,vi.fn(),vi.fn())!; + const ctx = s.contexts.get(s.canvas)!; + for (const [i,display] of (["all","bundled","selected"] as const).entries()) { + ctx.arc.mockClear(); renderer.update("a","",display,true,true); s.frame(i*34); + expect(ctx.arc.mock.calls.some(call => call[2] === 8)).toBe(true); + expect(ctx.arc.mock.calls.some(call => call[2] === 2.8)).toBe(true); + } + renderer.update("a","","all",true,false); + expect(s.frames.size).toBe(0); + renderer.dispose(); + }); + + it("reuses chat text layout while fading, and remeasures edits and loaded fonts", () => { + const s = drawing(); + const bubble = {hash:"chat",sender:"Sender",content:"A Public message",nodeId:"a",at:1000,heardAt:1000}; + const chatter = {visible:() => [bubble]} as unknown as LiveChatter; + const oldFonts = Object.getOwnPropertyDescriptor(document,"fonts"); + const fonts = new EventTarget(); + Object.defineProperty(document,"fonts",{configurable:true,value:fonts}); + const renderer = createRenderer(s.canvas,s.graph,s.traffic,s.colors,vi.fn(),vi.fn(),undefined,chatter)!; + try { + renderer.update("","","all",true,true); s.frame(0); + const ctx = s.contexts.get(s.canvas)!; + expect(ctx.measureText).toHaveBeenCalled(); ctx.measureText.mockClear(); + s.frame(34); expect(ctx.measureText).not.toHaveBeenCalled(); + expect(ctx.fillText).toHaveBeenCalledWith("Sender",expect.any(Number),expect.any(Number),expect.any(Number)); + bubble.content = "A changed Public message"; + s.frame(68); expect(ctx.measureText).toHaveBeenCalled(); ctx.measureText.mockClear(); + fonts.dispatchEvent(new Event("loadingdone")); s.frame(102); + expect(ctx.measureText).toHaveBeenCalled(); + } finally { + renderer.dispose(); + if (oldFonts) Object.defineProperty(document,"fonts",oldFonts); + else delete (document as unknown as {fonts?: EventTarget}).fonts; + } + }); +}); diff --git a/tests/features/topology/topology.test.ts b/tests/features/topology/topology.test.ts index be1f84c..50cb8c1 100644 --- a/tests/features/topology/topology.test.ts +++ b/tests/features/topology/topology.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from "vitest"; -import { buildTopology, reportFromEvent, LiveTraffic, project, fitCamera, panCamera, pathControls, visibleLinks, DEFAULT_CAMERA, linkContext, loadTopology, loadTopologyRoutes, NODE_CAP, FLOW_CAP, REPORT_CAP } from "../../../src/features/topology/topology"; +import { buildTopology, reportFromEvent, LiveTraffic, project, createProjector, fitCamera, panCamera, pathControls, visibleLinks, DEFAULT_CAMERA, linkContext, loadTopology, loadTopologyRoutes, NODE_CAP, FLOW_CAP, REPORT_CAP } from "../../../src/features/topology/topology"; import { getTopologyLinks, getNodesPage } from "../../../src/api/client"; import type { NodeSummary } from "../../../src/features/nodes/types"; import type { WsPacketObservation } from "../../../src/types/ws"; @@ -13,6 +13,42 @@ const hop = (id: string): ResolvedHop => ({ confidence: "high", nodes: [{ id, pu const event = (path: ResolvedHop[] = [hop("a"), hop("b"), hop("c")]): WsPacketObservation["data"] => ({ packetHash: "hash", packet: { payloadType: 5, payloadTypeName: "GRP_TXT", routeType: 1, routeTypeName: "FLOOD", isFirstObservation: true, observationCount: 1, scope: "#on" }, observation: { observerId: "obs", observerName: "Observer", iata: "YOW", heardAt: 1, rssi: -90, snr: 5, sourceBroker: "fixture", resolvedPath: path } }); describe("3D topology evidence and bounds", () => { + it("expires out-of-order receipts and trails after a clock adjustment", () => { + const live = new LiveTraffic(); + const report = reportFromEvent(event(), graph, 2000)!; + live.add({ ...report, key: "newer", at: 2000, segments: [["a", "b"]] }); + live.add({ ...report, key: "older", at: 1000, segments: [["b", "c"]] }); + live.prune(61000); + expect(live.reports.map(r => r.key)).toEqual(["newer"]); + expect([...live.trails.keys()]).toEqual(["a|b"]); + expect(live.flows).toEqual([]); + expect(live.add({ ...report, key: "older", at: 61000 })).toBe(true); + }); + + it("does not replace retained history arrays when no observations have expired", () => { + const live = new LiveTraffic(); + const reports = live.reports, flows = live.flows; + for (let i = 0; i < 100; i++) live.add({ ...reportFromEvent(event(), graph, 1000 + i)!, key: String(i) }); + live.prune(1200); + expect(live.reports).toBe(reports); + expect(live.flows).toBe(flows); + expect(live.reports).toHaveLength(100); + expect(live.flows).toHaveLength(100); + }); + + it("projects all vertices with the same perspective as the original camera equations", () => { + for (const yaw of [-1.5, 0, 1.2]) for (const pitch of [0.35, 1.05, Math.PI / 2]) { + const camera = { yaw, pitch, zoom: 2.4, target: { x: 50, y: 12, z: -80 } }; + const render = createProjector(camera, 1280, 720, 800); + for (const point of [{x:0,y:0,z:0},{x:900,y:28,z:-400},{x:-300,y:400,z:-6000}]) { + const lx=point.x-50, ly=point.y-12, lz=point.z+80; + const x=lx*Math.cos(yaw)-lz*Math.sin(yaw), z=lx*Math.sin(yaw)+lz*Math.cos(yaw); + const y=ly*Math.cos(pitch)+z*Math.sin(pitch), depth=-ly*Math.sin(pitch)+z*Math.cos(pitch); + const perspective=2400/(2400+depth), scale=720/(800*2.6)*2.4*perspective; + expect(render(point)).toEqual({x:640+x*scale,y:360-y*scale,depth,scale,visible:perspective>0}); + } + } + }); it("groups nodes within the selected shared regions despite a newer outside reception", () => { const multi = { ...a, iatas: [{ iata: "YYZ", lastHeard: 30 }, { iata: "YOW", lastHeard: 20 }, { iata: "YKF", lastHeard: 10 }] }; expect(buildTopology([multi], ["YOW", "YKF"]).nodes[0]!.region).toBe("YOW");