Engineering Journal
Schema Editor
Schema Editor

Wire Endpoint Drag and Re-Anchor Logic in an SVG Schematic Editor

2026-05-22

TLDR: Dragging a wire endpoint runs _wireSnapToPort on every move to find and highlight the nearest pin. On mouseUp: if a pin is found, set data-from-sym/data-to-sym; if not, purge the stale anchor attribute. Reset _lastSnappedPin to null at drag start to prevent leakage across drag sessions.

Repo: tools/schema-editor

The Problem

In a schematic editor, wires connect component pins. When you drag a wire endpoint, two things should happen:

  1. The endpoint should snap to nearby pins and show a visual indicator when it is close enough to connect
  2. On drop, the wire should either anchor to a pin or float freely (unanchored)
Getting this wrong in either direction causes bugs. If a wire keeps its anchor attribute after being dropped on empty canvas, subsequent component moves drag the wire with the component it was previously connected to.

The Data Model

Each wire <path> element carries anchor attributes:

<path
    class="wire"
    data-from-sym="R1"
    data-to-sym="R2"
    d="M 100 100 L 100 200 L 200 200"
/>

data-from-sym and data-to-sym store the data-symbol attribute of the component at each endpoint. When a component moves, _updateAttachedWire finds wires whose anchor attributes match the component's symbol and redraws them to follow the component's new pin positions.

If data-from-sym is set but the endpoint is not actually on that component's pin (because the user dragged it away), _updateAttachedWire will teleport the endpoint back to the original pin on every component move. This is the stale anchor bug.


_wireSnapToPort

During endpoint drag, on every mousemove:

function _wireSnapToPort(worldPt) {
    const PIN_SNAP_RADIUS = 12; // world units
    let closest = null;
    let closestDist = PIN_SNAP_RADIUS;

document.querySelectorAll('.pin-point').forEach(pin => { const pinPos = _pinWorldPos(pin); const dist = Math.hypot(worldPt.x - pinPos.x, worldPt.y - pinPos.y); if (dist < closestDist) { closestDist = dist; closest = pin; } });

// update visual highlight document.querySelectorAll('.pin-point.snap-target').forEach(p => p.classList.remove('snap-target') ); if (closest) { closest.classList.add('snap-target'); }

_lastSnappedPin = closest; return closest ? _pinWorldPos(closest) : worldPt; }

_lastSnappedPin is a module-level variable that tracks the most recently snapped-to pin. It is the communication channel between mousemove (which finds the pin) and mouseup (which commits the anchor).


The Stale State Bug

The original implementation did not reset _lastSnappedPin at drag start. This caused a specific failure:

  1. User drags endpoint A near pin P1, snaps to it
  2. User releases without confirming (e.g., presses Escape mid-drag)
  3. _lastSnappedPin still holds P1
  4. User starts a new drag of a different endpoint
  5. mouseUp fires before any mousemove runs
  6. _lastSnappedPin is still P1 from session 1
  7. The new endpoint gets incorrectly anchored to P1
Fix: reset at the start of every drag session:
function _startWirePointDrag(wireEl, pointIndex, e) {
    _lastSnappedPin = null;  // clear stale state from any previous drag
    dragState = { wire: wireEl, pointIndex, ...};
}

mouseUp: Commit or Purge

function _endWirePointDrag(e) {
    const sym = _lastSnappedPin
        ? _lastSnappedPin.closest('[data-symbol]')?.getAttribute('data-symbol')
        : null;

if (sym) { if (dragState.pointIndex === 0) { wireEl.setAttribute('data-from-sym', sym); } else { wireEl.setAttribute('data-to-sym', sym); } } else { // no pin snap: purge the anchor attribute to prevent stale re-anchoring if (dragState.pointIndex === 0) { wireEl.removeAttribute('data-from-sym'); } else { wireEl.removeAttribute('data-to-sym'); } }

// clean up visual state document.querySelectorAll('.pin-point.snap-target').forEach(p => p.classList.remove('snap-target') ); _lastSnappedPin = null; dragState = null; }

The critical path is the else branch. When the user drops the endpoint on empty canvas (no snap target), removeAttribute purges the stale anchor. Without this, the old anchor survives and the wire will snap back to the previous component on the next component move.


_updateAttachedWire Endpoint Preservation

When a component moves, only the anchored endpoint should move with it. The other endpoint (and any intermediate waypoints) should stay where they are.

function _updateAttachedWire(wireEl, movedSymbol, newPinPos, endpointIndex) {
    const pts = getWirePoints(wireEl);

if (endpointIndex === 0) { // move first point, preserve rest pts[0] = newPinPos; } else { // move last point, preserve rest pts[pts.length - 1] = newPinPos; }

setWirePoints(wireEl, pts); }

Preserving intermediate waypoints was a separate bug: the original code was calling _manhattanRoute(fromPt, toPt) and replacing the entire path with a fresh two-knee route every time the component moved. Multi-segment wires (manually routed) collapsed to a simple two-knee elbow on every component nudge.

The fix: only replace the first or last point. Leave everything between them untouched. Full re-route only happens when the wire is created or when the user explicitly re-routes it.


Tradeoffs

_lastSnappedPin is a module-level variable. Two simultaneous wire drags (not currently possible in single-pointer UI) would share the same variable. The current implementation is single-pointer safe.

Pin snap radius is fixed at 12 world units. At very high zoom, 12 world units is a small screen distance. At very low zoom, it is a large screen distance, making accidental snaps more likely. A screen-pixel-based radius (12 / currentZoom) would be more consistent across zoom levels.

Stale anchor purge is unconditional on empty-canvas drop. If a user wants to temporarily unanchor one endpoint of a wire (to extend its path without losing the other anchor), they must drag to empty canvas. The anchor is immediately purged. There is no "tentative unanchor" state.

Read this post in the full Engineering Journal →