Postmortem: We Thought CSS Transform Was Equivalent to SVG ViewBox
TLDR
CSS transform: scale() on an SVG wrapper and SVG viewBox manipulation look equivalent for display. They are not equivalent for any operation that reads coordinates back out. The wrong assumption cost weeks of offset correction hacks before the CSS transform was deleted entirely.
The Assumption That Seemed Reasonable
Pan and zoom via CSS transform is documented in countless examples. Set transform-origin: 0 0, apply scale(zoom) translate(tx, ty), and the SVG scales and pans visually. The browser's GPU compositor handles it. No SVG attribute writes, no repaints on every frame.
The assumption: this is how you implement pan/zoom on an SVG. The viewBox is for defining the SVG's aspect ratio, not for runtime camera control.
This assumption is reasonable. It is also wrong for any interactive SVG that needs to read positions.
When It Failed
The first failure was symbol placement. When a user dragged a component from the palette onto the canvas at zoom 2.0, it landed at the wrong position. The palette computed the drop position in screen coordinates. The code converted screen to world coordinates by dividing by zoom and subtracting the pan offset. The result was off by exactly the amount the CSS transform had moved the wrapper element.
The fix added a correction: subtract the wrapper's getBoundingClientRect().left and .top before dividing. This worked.
The second failure was selection handles. Handles placed at getBBox() corners appeared at the right positions at zoom 1.0 and wrong positions at any other zoom. The fix multiplied the handle offset by zoom. This worked at uniform zoom but broke when pan was non-zero.
The third failure was snap. The snap algorithm computed candidate points in SVG world space and compared them to the mouse position converted from screen space. The conversion was wrong by the same CSS transform offset. A new correction was added.
By this point there were four separate places in the code applying offset corrections for the CSS transform. Each was slightly different because each was written to fix a specific symptom.
What Was Actually Wrong
The SVG coordinate APIs (getBBox, getScreenCTM, createSVGPoint, getBoundingClientRect on SVG children) report coordinates in SVG's own coordinate space. CSS transforms applied to a parent element are invisible to these APIs.
There is no supported way to query the CSS transform stack from SVG coordinate methods. getScreenCTM() on an SVG element returns the matrix from SVG root space to screen space, incorporating only SVG-level transforms (the transform attribute, the viewBox). A CSS transform on a div wrapping the SVG is outside this chain.
The four correction hacks each re-derived the CSS transform value manually and subtracted it. They were correct when written. They became incorrect whenever the transform was applied at a different point in the layout, or when a new element in the containment chain had a CSS transform.
What Got Deleted
The entire CSS transform application: the svgWrapper.style.transform assignment, the transform-origin setting, and all four offset correction hacks. Combined, these were about 80 lines spread across canvasEngine.js and viewTransform.js.
The correction for symbol palette coordinates was the most complex: it read svgWrapper.getBoundingClientRect(), subtracted the container rect, divided by zoom, and added the pan offset. This was correct for the specific layout at the time. It would have broken silently with any layout change.
What Replaced It
A cameraMatrix object owns zoom and pan state. On every pan/zoom gesture it writes a new viewBox attribute to the SVG element. No CSS transforms anywhere in the rendering chain.
Every coordinate conversion now uses getScreenCTM() directly, without corrections. The function is three lines and works at any zoom, pan, and rotation:
function worldToScreen(wx, wy) {
const pt = svg.createSVGPoint();
pt.x = wx; pt.y = wy;
const sp = pt.matrixTransform(cameraGroup.getScreenCTM());
return { x: sp.x - containerRect.left, y: sp.y - containerRect.top };
}
Deleting the CSS transform deleted all four correction hacks simultaneously.
The Lesson
When a coordinate API returns the wrong value, the problem is almost never the API. It is that the coordinate system you are reading from does not include a transform you applied somewhere else. Adding corrections to the call site is the wrong fix. Removing the out-of-band transform is the right fix.