The Graph Bug Where Nodes Exist But Nothing Connects to Them
TLDR
A two-pass graph builder that creates nodes in pass 1 and links edges in pass 2 will produce orphan nodes if edge-resolution logic is narrower than node-creation logic. We experienced this exact defect: junction dots were detected and stored as graph nodes, but wire-edge resolution only scanned component pins. Any connection through a junction silently vanished. We fixed this by implementing a unified fallback resolution chain and integrating a Union-Find (Disjoint-Set) data structure to manage transitive net connectivity.| Builder Phase | Initial Defect | Refactored Architecture | Graph Result |
|---|---|---|---|
| Node Discovery | Created junctions (nodes.set(jid, ...)) | Created junctions (nodes.set(jid, ...)) | Junction nodes present |
| Edge Resolution | Checked Component Pins ONLY | Fallback Chain (Pin $\rightarrow$ Junction) | Edges correctly linked |
| Transitive Query | Traversed empty adjacency lists | Union-Find Disjoint Set (wire.net) | Instant 60fps Net Tracing |
Problem statement: the invisible orphan node defect
In electrical schematics, 3-way wire junctions connect multiple conductors into a single electrical net.
During interactive trace mode, hovering a wire should highlight all connected wires and components across the entire signal path.
The bug
Hovering a wire highlighted only the two directly attached components. Highlighting stopped dead at every T-junction dot. In the debugger, the junction node existed in memory, but its adjacency list was completely empty.Technical failure mode: asymmetric two-pass construction
The root cause was an architectural mismatch between Pass 1 (Node Creation) and Pass 2 (Edge Resolution):
// PASS 1: Node Creation (Discovers junctions correctly)
epMap.forEach((wireIds, key) => {
if (wireIds.length >= 3) {
nodes.set(jid, { kind: 'junction', x, y });
adjacency.set(jid, []); // Initialized... but never populated!
}
});
// DEFECTIVE PASS 2: Edge Resolution (Checked COMPONENT PINS ONLY!) const fromNode = portMatches.find(nearEndpoint)?.compId || null; // Wire endpoints landing on junctions resolved to null, dropping the edge!
Because Pass 2 only searched component pins, wire endpoints terminating at junction dots returned null. The edges were dropped, leaving junction nodes isolated in memory.
The fix & architecture: fallback chains & union-find sets
Step 1: fallback endpoint resolution chain
Unify endpoint resolution into a single function that checks component pins first, then falls back to junction nodes:function resolveEndpointNode(ep, portMatches, junctionMap) {
// 1. Check Component Pins
const pinMatch = portMatches.find(p => isNear(p, ep));
if (pinMatch) return pinMatch.compId;
// 2. Fallback to Junction Nodes const junctionId = junctionMap.get(coordKey(ep)); if (junctionId) return junctionId;
return null; // Unconnected endpoint }
Step 2: transitive net resolution via union-find
Instead of traversing adjacency graphs on every hover query, use a Union-Find (Disjoint-Set) data structure during graph assembly to assign net IDs:// Union-Find Disjoint Set Assembly
const parent = new Map();
function find(i) {
if (!parent.has(i)) parent.set(i, i);
if (parent.get(i) === i) return i;
parent.set(i, find(parent.get(i))); // Path compression
return parent.get(i);
}
function union(i, j) {
const rootI = find(i);
const rootJ = find(j);
if (rootI !== rootJ) parent.set(rootI, rootJ);
}
// Merge wire endpoints into transitive nets wires.forEach(w => { union(coordKey(w.endpoints[0]), coordKey(w.endpoints[1])); });
// Assign computed net ID to each wire for O(1) trace queries wires.forEach(w => { w.netId = find(coordKey(w.endpoints[0])); });
Rule of thumb: Never separate node creation from edge resolution without a shared endpoint resolver. Use Union-Find (Disjoint-Set) structures to evaluate transitive net connectivity in $O(1)$ time.