Axis-Independent Snap: Why Snapping to Two Elements at Once Requires Splitting X and Y
TLDR
Traditional 2D snapping algorithms compute a single Euclidean distance (Math.hypot) to find the nearest snap target. This forces a single "winner" and prevents an element from snapping to two different reference targets at once (e.g., aligning to element A's left edge while aligning to element B's top edge). Running independent candidate searches for X and Y using absolute scalar distances (Math.abs) allows two simultaneous alignments during a single drag operation.
| Snapping Model | Distance Formula | Winner Determination | Dual Alignment Capability |
|---|---|---|---|
| Euclidean Nearest-Point | Math.hypot(dx, dy) | Single 2D point winner | Impossible (Ignores second target) |
| Axis-Independent Snap | Math.abs(dx) & Math.abs(dy) | Independent X & Y winners | Supported (Aligns to both A and B) |
Problem statement: the limits of Euclidean distance snapping
Visual layout tools (such as Figma, Illustrator, or circuit diagram editors) rely on smart alignment guides.
When a developer drags a component between two existing components, they frequently want to align to both: matching element A's left boundary horizontally while matching element B's top boundary vertically.
Under standard Euclidean snapping algorithms, the engine computes a single hypotenuse distance to every reference point. Whichever element happens to be 1 pixel closer claims both axes, ignoring the second element completely and forcing the developer to perform manual two-step adjustments.
Technical failure mode: single-winner Euclidean math
Consider three bounding boxes: candidate A (left), candidate B (top), and dragged element C.
// NAIVE IMPLEMENTATION: Single 2D winner via Math.hypot
function snapToNearest(x, y, candidates, threshold) {
let best = { x, y };
let bestDist = threshold;
candidates.forEach(bb => { const points = [ { x: bb.x, y: bb.y }, { x: bb.x + bb.width / 2, y: bb.y + bb.height / 2 }, { x: bb.x + bb.width, y: bb.y + bb.height } ]; points.forEach(pt => { const d = Math.hypot(x - pt.x, y - pt.y); // Fails for 2-axis independent targets! if (d < bestDist) { bestDist = d; best = pt; } }); });
return best; }
Because Math.hypot collapses X and Y offsets into a single scalar distance, the algorithm forces a single candidate box to win both axes. Alignment to the second candidate box is completely lost.
The fix & architecture: independent X and Y alignment searches
We decoupled the search algorithm into separate X and Y sweeps, allowing each axis to resolve its nearest candidate independently:
function snapAxisIndependent(x, y, candidates, threshold) {
let snapX = x, snapY = y;
let distX = threshold, distY = threshold;
candidates.forEach(bb => { // 1. Independent Horizontal Search (Left, Center, Right) [bb.x, bb.x + bb.width * 0.5, bb.x + bb.width].forEach(cx => { const d = Math.abs(x - cx); if (d < distX) { distX = d; snapX = cx; } });
// 2. Independent Vertical Search (Top, Center, Bottom) [bb.y, bb.y + bb.height * 0.5, bb.y + bb.height].forEach(cy => { const d = Math.abs(y - cy); if (d < distY) { distY = d; snapY = cy; } }); });
return { x: snapX, y: snapY }; }
Handling multi-element selection deltas
For dragging groups of elements, project the selection's union bounding box and scale the snap threshold by the canvas zoom level (threshold = screenPixels / currentZoom):
function computeAlignSnap(origBBoxes, delta, candidates, zoomThreshold) {
let uL = Infinity, uT = Infinity, uR = -Infinity, uB = -Infinity;
origBBoxes.forEach(bb => {
uL = Math.min(uL, bb.x + delta.x);
uT = Math.min(uT, bb.y + delta.y);
uR = Math.max(uR, bb.x + bb.width + delta.x);
uB = Math.max(uB, bb.y + bb.height + delta.y);
});
const selEdgesX = [uL, (uL + uR) / 2, uR]; const selEdgesY = [uT, (uT + uB) / 2, uB];
let bestAdjX = 0, bestAdjY = 0; let minDistX = zoomThreshold, minDistY = zoomThreshold;
candidates.forEach(bb => { const refEdgesX = [bb.x, bb.x + bb.width / 2, bb.x + bb.width]; const refEdgesY = [bb.y, bb.y + bb.height / 2, bb.y + bb.height];
selEdgesX.forEach(sx => { refEdgesX.forEach(rx => { const d = Math.abs(sx - rx); if (d < minDistX) { minDistX = d; bestAdjX = rx - sx; } }); });
selEdgesY.forEach(sy => { refEdgesY.forEach(ry => { const d = Math.abs(sy - ry); if (d < minDistY) { minDistY = d; bestAdjY = ry - sy; } }); }); });
return { x: delta.x + bestAdjX, y: delta.y + bestAdjY }; }
Rule of thumb: Never use EuclideanMath.hypotdistance for 2D visual layout alignment. Run separateMath.abscandidate searches for X and Y axes to enable dual-element alignment during single drag operations.