Element Snap Only Aligns to One Reference at a Time: The Euclidean Distance Problem
TLDR
When dragging canvas elements near multiple reference items, usingMath.hypot causes the snap engine to lock onto whichever candidate element is 1 pixel closer in combined Euclidean space, completely ignoring alignment targets along the secondary axis. Replacing Euclidean calculations with independent Math.abs distance checks along X and Y axes restores dual-target snapping.
| Search Metric | Mathematical Expression | Behavior | Result |
|---|---|---|---|
| Coupled 2D Distance | Math.hypot(dx, dy) | Single target wins both axes | Ignored secondary alignment |
| Decoupled Axis Distance | Math.abs(dx) & Math.abs(dy) | Independent X/Y candidate winners | Simultaneous dual alignment |
Problem statement: the one-target snapping glitch
Users attempting to position a schematic component between two existing components experienced a frustrating defect: when trying to align component C's left edge to component A and its top edge to component B, the editor snapped to B but completely ignored A.
The user was forced to drag once to align vertically, release, drag again horizontally, and then manually adjust coordinates in the properties inspector when the second drag broke the first alignment.
Technical failure mode: scalar coupling in Math.hypot
The root cause was using Euclidean 2D distance to evaluate snap priority:
// DEFECTIVE: Combines X and Y into a single scalar distance
candidates.forEach(bb => {
const centerX = bb.x + bb.width / 2;
const centerY = bb.y + bb.height / 2;
const d = Math.hypot(dragX - centerX, dragY - centerY);
if (d < bestDist) {
bestDist = d;
snapX = centerX;
snapY = centerY;
}
});
Because Math.hypot evaluates 2D space as a single scalar, an element 0px away in Y but 4px away in X loses to an element 1px away in both X and Y. The algorithm couples X and Y constraints together when they should be evaluated independently.
The fix: independent Math.abs searches per axis
We decoupled the search into two separate scalar comparisons scaled by canvas zoom:
// REFACTORED: Independent per-axis candidate evaluation
const threshold = 8 / (currentZoom || 1); // Zoom-adjusted threshold in screen pixels
let snapX = dragX, snapY = dragY;
let minDistX = threshold, minDistY = threshold;
candidates.forEach(bb => { // 1. Horizontal Snap Candidates (Left, Center, Right) [bb.x, bb.x + bb.width * 0.5, bb.x + bb.width].forEach(cx => { const d = Math.abs(dragX - cx); if (d < minDistX) { minDistX = d; snapX = cx; } });
// 2. Vertical Snap Candidates (Top, Center, Bottom) [bb.y, bb.y + bb.height * 0.5, bb.y + bb.height].forEach(cy => { const d = Math.abs(dragY - cy); if (d < minDistY) { minDistY = d; snapY = cy; } }); });
return { x: snapX, y: snapY };
Rule of thumb: UseMath.hypotonly when snapping to exact point targets (such as component pins or vertex nodes). Use independentMath.abschecks per axis when aligning element bounding boxes across a canvas layout.