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;