merge: canvas zoom + pan (last of 6 polish tasks)

state.view = {x,y,zoom} drives SVG viewBox. Zoom clamped 0.2-5x.
- Wheel = zoom around cursor (Excalidraw-style)
- Middle-drag or Space+drag = pan
- 0 or Home = reset
- Header: zoom % indicator + Fit button (bbox + 40px padding)
- URL persists ?z=&px=&py= (cleaned when at default)
- All inputs/hit-tests stay in world coords — no changes needed to
  port/cable/drag handlers
This commit is contained in:
mAi
2026-05-16 12:10:28 +02:00
3 changed files with 192 additions and 1 deletions

View File

@@ -24,6 +24,10 @@
<button type="button" id="btn-solve" class="btn btn-primary">Solve</button>
<button type="button" id="btn-export" class="btn">Export</button>
<button type="button" id="btn-admin" class="btn" title="Admin: projects, cable types, device types, setup templates">⚙ Admin</button>
<span class="zoom-cluster">
<span id="zoom-pct" title="Zoom — scroll on canvas, or 0/Home to reset">100%</span>
<button type="button" id="btn-fit" class="btn btn-tiny" title="Fit content to view">Fit</button>
</span>
<span id="toast" class="toast" hidden></span>
</header>

View File

@@ -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([

View File

@@ -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;