feat(ui): canvas zoom + pan via SVG viewBox
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.
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user