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.