Postmortem: We Thought CSS Transform Was Equivalent to SVG ViewBox
TLDR
Early in our schema editor's development, we implemented camera pan and zoom via CSStransform: scale() on a parent wrapper <div>. Over several months, this produced recurring bugs across palette drops, handle rendering, snapping, and marquee selection because native SVG coordinate methods (getScreenCTM()) ignore outer CSS transforms. Deleting 80+ lines of manual offset math and migrating camera control to native SVG viewBox resolved these issues across the entire codebase.
| Architecture Stage | Camera Mechanism | Coordinate Resolution Strategy | Code Complexity |
|---|---|---|---|
| Initial Implementation | Outer CSS scale() on <div> | Manual offset patches per feature | High (80+ lines of compensation math) |
| Postmortem Resolution | Native SVG viewBox | Direct native getScreenCTM() | Low (3-line unified helper) |
Problem statement: the escalation of coordinate patch hacks
We initially selected CSS wrapper scaling for SVG pan and zoom:
// DEPRECATED: Initial CSS wrapper transform implementation
svgWrapper.style.transform = scale(${zoom}) translate(${tx}px, ${ty}px);
This produced a sequence of recurring coordinate bugs across features:
- Palette Symbol Drop: Dropping a component at
2.0xzoom placed it far from the cursor. We patched it with manual wrapper rect offsets. - Selection Handles: Bounding box handles drifted at non-1.0 zoom levels. We added zoom multipliers to the handle positions.
- Snap-to-Grid: Pin snapping misaligned while the canvas was panning. We subtracted the pan offset from the snap coordinates.
Technical failure mode: compounding coordinate systems
Native SVG coordinate APIs (getBBox(), getScreenCTM(), createSVGPoint()) operate exclusively within the SVG element's transform tree.
Outer CSS wrapper transforms sit outside this tree.
Attempts to manually re-derive outer CSS transforms at call sites failed whenever parent DOM container padding, page scroll, or wrapper margins shifted.
The fix & architecture: unified native ViewBox migration
We removed all CSS wrapper transforms and deleted every manual offset patch across canvasEngine.js and viewTransform.js.
Camera management was consolidated into an SVG viewBox controller:
// REFACTORED: Native ViewBox Camera Controller
export const cameraController = {
zoom: 1,
tx: 0,
ty: 0,
apply(svgElement, containerWidth, containerHeight) { const vbW = containerWidth / this.zoom; const vbH = containerHeight / this.zoom; const vbX = -this.tx / this.zoom; const vbY = -this.ty / this.zoom;
svgElement.setAttribute('viewBox', ${vbX} ${vbY} ${vbW} ${vbH}); } };
// Unified Screen Coordinate Conversion (Zero Patch Math!) export function worldToScreen(svgElement, worldX, worldY, containerRect) { const point = svgElement.createSVGPoint(); point.x = worldX; point.y = worldY;
const screenPoint = point.matrixTransform(svgElement.getScreenCTM());
return { x: screenPoint.x - containerRect.left, y: screenPoint.y - containerRect.top }; }
Rule of thumb: If coordinate API calls return offset errors, eliminate outer CSS container transforms rather than adding call-site compensation math.