From 6c3180252243b66cfc5b0db6bb83b513b22d4dfd Mon Sep 17 00:00:00 2001 From: mAi Date: Sat, 16 May 2026 12:05:24 +0200 Subject: [PATCH] feat(ui): canvas zoom + pan via SVG viewBox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit m: wheel to zoom around the cursor, drag with middle-mouse / Space-held to pan, `0` or `Home` to reset, Fit button to frame all content. Implementation: - state.view = { x, y, zoom } drives the SVG viewBox via applyViewBox(). Base canvas is 2000×1500; viewBox = (view.x, view.y, 2000/zoom, 1500/zoom). - Zoom clamped to 0.2x..5x. wheelZoom captures the cursor's world coord before + after the zoom-step and shifts view.x/y so it stays under the cursor (Excalidraw-style cursor-anchored zoom). - startPan captures screen→world scale from getScreenCTM at pointerdown and converts pointer-move deltas into view.x/y updates — robust across zoom levels. Triggered by middle-mouse OR Space+drag. Releases pointer capture + persists the view on pointerup. - resetView (0 / Home) restores zoom=1, x=0, y=0. - fitToContent walks frames + devices + IO markers, computes their bbox with 40px padding, picks zoom = min(BASE_W/bw, BASE_H/bh), and centres the bbox inside the viewBox (compensating for aspect-ratio meet). - Header gets a "100%" zoom indicator + Fit button. URL persists view as ?z=1.200&px=…&py=… so reload returns to the same view. Because everything goes through viewBox (not CSS transform), svgPoint still maps screen pixels to world coords via getScreenCTM. Existing hit-tests, drag, port/cable placement all keep working unchanged. --- web/static/index.html | 4 + web/static/main.js | 168 +++++++++++++++++++++++++++++++++++++++++- web/static/style.css | 21 ++++++ 3 files changed, 192 insertions(+), 1 deletion(-) diff --git a/web/static/index.html b/web/static/index.html index 3b00c0e..6d86c62 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -24,6 +24,10 @@ + + 100% + + diff --git a/web/static/main.js b/web/static/main.js index 6a2c250..4e118c3 100644 --- a/web/static/main.js +++ b/web/static/main.js @@ -58,6 +58,10 @@ const state = { activeTypeId: /** @type {number|null} */ (null), /** "frame" | "device" | "io" | "req" | "cable" | null */ tool: /** @type {string|null} */ (null), + /** Canvas viewport — drives the SVG viewBox. */ + view: { x: 0, y: 0, zoom: 1 }, + /** Space-key held → next pointerdown anywhere on canvas starts a pan. */ + spaceHeld: false, /** Slice-7: when the user clicked a source port, this is its id. */ cableDrawFromPortID: /** @type {number|null} */ (null), /** @type {({kind: "frame"|"device"|"io"|"cable_type"|"requirement"|"cable"|"bundle"|"port", id: number} | {kind: "port_new", device_id: number}) | null} */ selection: null, @@ -164,6 +168,137 @@ function setActiveInURL(id) { history.replaceState(null, "", url.toString()); } +// ---------- canvas view (zoom + pan) ---------- // + +const BASE_W = 2000, BASE_H = 1500; +const ZOOM_MIN = 0.2, ZOOM_MAX = 5; + +function clampZoom(z) { return Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, z)); } + +function applyViewBox() { + const z = state.view.zoom; + const vw = BASE_W / z; + const vh = BASE_H / z; + $("#canvas").setAttribute("viewBox", `${state.view.x} ${state.view.y} ${vw} ${vh}`); +} + +function updateZoomUI() { + const el = $("#zoom-pct"); + if (el) el.textContent = `${Math.round(state.view.zoom * 100)}%`; +} + +function viewFromURL() { + const p = new URLSearchParams(location.search); + const z = parseFloat(p.get("z")); + const px = parseFloat(p.get("px")); + const py = parseFloat(p.get("py")); + if (Number.isFinite(z) && z > 0) state.view.zoom = clampZoom(z); + if (Number.isFinite(px)) state.view.x = px; + if (Number.isFinite(py)) state.view.y = py; +} + +function setViewInURL() { + const url = new URL(location.href); + const isDefault = state.view.zoom === 1 && state.view.x === 0 && state.view.y === 0; + if (isDefault) { + url.searchParams.delete("z"); + url.searchParams.delete("px"); + url.searchParams.delete("py"); + } else { + url.searchParams.set("z", state.view.zoom.toFixed(3)); + url.searchParams.set("px", state.view.x.toFixed(1)); + url.searchParams.set("py", state.view.y.toFixed(1)); + } + history.replaceState(null, "", url.toString()); +} + +function wheelZoom(e) { + e.preventDefault(); + const before = svgPoint(e); + const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1; + const newZoom = clampZoom(state.view.zoom * factor); + if (newZoom === state.view.zoom) return; + state.view.zoom = newZoom; + applyViewBox(); + const after = svgPoint(e); // recomputed against the new viewBox + state.view.x += before.x - after.x; + state.view.y += before.y - after.y; + applyViewBox(); + updateZoomUI(); + setViewInURL(); +} + +function startPan(e) { + const svg = /** @type {SVGSVGElement} */ ($("#canvas")); + const ctm = svg.getScreenCTM(); + if (!ctm) return; + e.preventDefault(); + e.stopPropagation(); + $(".canvas-wrap").classList.add("panning"); + // ctm.a / ctm.d are the world→screen scales. world delta = screen delta / scale. + const scaleX = ctm.a, scaleY = ctm.d; + const startClientX = e.clientX, startClientY = e.clientY; + const startViewX = state.view.x, startViewY = state.view.y; + try { svg.setPointerCapture(e.pointerId); } catch {} + const onMove = (ev) => { + state.view.x = startViewX - (ev.clientX - startClientX) / scaleX; + state.view.y = startViewY - (ev.clientY - startClientY) / scaleY; + applyViewBox(); + }; + const onUp = (ev) => { + svg.removeEventListener("pointermove", onMove); + svg.removeEventListener("pointerup", onUp); + svg.removeEventListener("pointercancel", onUp); + try { svg.releasePointerCapture(ev.pointerId); } catch {} + $(".canvas-wrap").classList.remove("panning"); + setViewInURL(); + }; + svg.addEventListener("pointermove", onMove); + svg.addEventListener("pointerup", onUp); + svg.addEventListener("pointercancel", onUp); +} + +function resetView() { + state.view.zoom = 1; + state.view.x = 0; + state.view.y = 0; + applyViewBox(); + updateZoomUI(); + setViewInURL(); +} + +// Compute the bbox of every frame + device + IO marker in the current +// project and frame it into the view with a small padding. Falls back +// to reset when the project is empty. +function fitToContent() { + if (!state.active) return resetView(); + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + let any = false; + const cover = (x, y, w, h) => { + any = true; + if (x < minX) minX = x; + if (y < minY) minY = y; + if (x + w > maxX) maxX = x + w; + if (y + h > maxY) maxY = y + h; + }; + for (const f of state.frames) cover(f.x, f.y, f.width, f.height); + for (const d of state.devices) cover(d.x, d.y, d.width, d.height); + for (const m of state.ioMarkers) cover(m.x, m.y, IO_SIZE, IO_SIZE); + if (!any) return resetView(); + const pad = 40; + minX -= pad; minY -= pad; maxX += pad; maxY += pad; + const bw = maxX - minX, bh = maxY - minY; + const zoom = clampZoom(Math.min(BASE_W / bw, BASE_H / bh)); + const vw = BASE_W / zoom, vh = BASE_H / zoom; + // Centre the bbox inside the (potentially larger) viewBox. + state.view.zoom = zoom; + state.view.x = minX - (vw - bw) / 2; + state.view.y = minY - (vh - bh) / 2; + applyViewBox(); + updateZoomUI(); + setViewInURL(); +} + // ---------- geometry ---------- // /** Returns the smallest frame whose bbox contains (x, y), or null. */ @@ -1455,22 +1590,49 @@ function bindTools() { // Avoid stealing keys while user is typing into an input. const tag = (e.target instanceof HTMLElement) ? e.target.tagName : ""; if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return; + if (e.key === " " && !state.spaceHeld) { + // Hold Space to enable click-and-drag pan. Don't preventDefault here + // so pressing Space in unrelated focusable elements still works; the + // canvas pointerdown handler reads state.spaceHeld to gate the pan. + state.spaceHeld = true; + $(".canvas-wrap").classList.add("space-pan-ready"); + e.preventDefault(); + return; + } if (e.key === "Escape") { armTool(null); cancelInlineNamer(); state.cableDrawFromPortID = null; state.selection = null; render(); } + else if (e.key === "0" || e.key === "Home") resetView(); else if (e.key === "f" || e.key === "F") armTool("frame"); else if (e.key === "d" || e.key === "D") armTool("device"); else if (e.key === "i" || e.key === "I") armTool("io"); else if (e.key === "r" || e.key === "R") armTool("req"); else if (e.key === "s" || e.key === "S") openSolveModal(); }); + document.addEventListener("keyup", (e) => { + if (e.key === " ") { + state.spaceHeld = false; + $(".canvas-wrap").classList.remove("space-pan-ready"); + } + }); // Canvas-level pointerdown handles tool activation + selection clearing. - $("#canvas").addEventListener("pointerdown", onCanvasPointerDown); + const svg = $("#canvas"); + svg.addEventListener("pointerdown", onCanvasPointerDown); + // Wheel zooms around the cursor — `passive: false` so we can + // preventDefault and stop the page from scrolling. + svg.addEventListener("wheel", wheelZoom, { passive: false }); } let rubberBand = /** @type {SVGRectElement|null} */ (null); let rubberStart = /** @type {{x:number,y:number}|null} */ (null); function onCanvasPointerDown(e) { + // Pan gestures win over every tool. Middle-click and Space+drag both + // route here regardless of project state — m can pan an empty canvas + // without selecting a project first. + if (e.button === 1 || state.spaceHeld) { + startPan(e); + return; + } if (!state.active) return; const p = svgPoint(e); @@ -2866,6 +3028,7 @@ async function boot() { $("#btn-solve").addEventListener("click", openSolveModal); $("#btn-apply-template").addEventListener("click", openApplyTemplateModal); $("#btn-export").addEventListener("click", exportCurrentProject); + $("#btn-fit").addEventListener("click", fitToContent); $("#project-select").addEventListener("change", (e) => { const v = /** @type {HTMLSelectElement} */ (e.target).value; @@ -2873,6 +3036,9 @@ async function boot() { }); bindTools(); + viewFromURL(); + applyViewBox(); + updateZoomUI(); try { [state.projects, state.cableTypes] = await Promise.all([ diff --git a/web/static/style.css b/web/static/style.css index 4009863..6c0c547 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -235,6 +235,27 @@ body { filter: drop-shadow(0 0 4px var(--accent)); } +/* Zoom cluster — % + Fit button next to Admin. */ +.zoom-cluster { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: 8px; + padding-left: 12px; + border-left: 1px solid var(--border); +} +#zoom-pct { + font-size: 12px; + color: var(--text-muted); + min-width: 38px; + text-align: right; + font-variant-numeric: tabular-nums; +} +.canvas-wrap.panning #canvas, +.canvas-wrap.panning #canvas * { cursor: grabbing !important; } +.canvas-wrap.space-pan-ready #canvas, +.canvas-wrap.space-pan-ready #canvas * { cursor: grab !important; } + /* Header toast — slice 8 export feedback */ .toast { display: inline-block;