Postmortem: Our Snap Algorithm Could Only Align to One Element at a Time
TLDR
Our original canvas snapping engine used Euclidean distance (Math.hypot) to find the single closest candidate point across the canvas. While this worked for single-element alignment, it prevented users from aligning to two different reference components simultaneously. We refactored the snapping core to perform independent scalar searches for X and Y axes, reducing a 4-step manual alignment workaround down to a single drag gesture.
| Incident Aspect | Initial Implementation | Engineering Root Cause | Production Remediation |
|---|---|---|---|
| Snapping Behavior | Locked onto 1 element only | Single bestDist Euclidean calculation | Split into independent X & Y searches |
| User Workaround | Manual coordinate entry | Single winner took both axes | Independent Math.abs per axis |
| User Friction | High (4 steps per alignment) | Coupled spatial distance | Low (1 drag gesture) |
Problem statement: the 4-step layout alignment workaround
Users reported that aligning a new schematic symbol to existing canvas components was taking twice as long as expected:
- Drag component near Component A to align left edges.
- Note the resulting X coordinate.
- Drag component near Component B to align top edges (which broke the X alignment to Component A).
- Open the Property Panel and manually type the saved X coordinate back in.
Technical failure mode: coupled Euclidean distance search
The original implementation tracked a single bestDist variable across 2D Euclidean space:
// DEPRECATED: Euclidean single-winner calculation (~15 lines deleted)
let bestDist = threshold;
candidates.forEach(bb => {
const points = [ { x: bb.x, y: bb.y }, / ... / ];
points.forEach(pt => {
const d = Math.hypot(x - pt.x, y - pt.y); // Coupled distance!
if (d < bestDist) { bestDist = d; best = pt; }
});
});
Because bestDist was a single scalar value, the algorithm picked one reference point to satisfy both axes, completely ignoring the second candidate element.
The fix & architecture: decoupled per-axis search
We replaced the Euclidean search with two independent candidate sweeps:
// REFACTORED: Decoupled per-axis search
let snapX = x, snapY = y;
let minDistX = threshold, minDistY = threshold;
// 1. Independent Horizontal Search [bb.x, bb.x + bb.width / 2, bb.x + bb.width].forEach(cx => { const d = Math.abs(x - cx); if (d < minDistX) { minDistX = d; snapX = cx; } });
// 2. Independent Vertical Search [bb.y, bb.y + bb.height / 2, bb.y + bb.height].forEach(cy => { const d = Math.abs(y - cy); if (d < minDistY) { minDistY = d; snapY = cy; } });
return { x: snapX, y: snapY };
Rule of thumb: If users report that a basic canvas operation requires manual property panel tweaks, check whether your underlying algorithm is trying to solve two independent constraints with a single coupled calculation.