diff --git a/packages/viewer/src/client/renderers/sgcr/layout.ts b/packages/viewer/src/client/renderers/sgcr/layout.ts index e018715..f388f75 100644 --- a/packages/viewer/src/client/renderers/sgcr/layout.ts +++ b/packages/viewer/src/client/renderers/sgcr/layout.ts @@ -304,7 +304,12 @@ function assignCoordinates( // at one node can never visually stack (an "other artifact" the vision pass caught). PORT_STEP ≥ // arrowhead width is the by-construction guarantee for arrowhead separation. const PORT_STEP = 16; + // Arrowheads sit at an edge's TARGET, so only TARGET-side ports need the full arrowhead-clearance + // spacing. SOURCE-side ports (no arrowhead) only need to be visually distinct — packing them tighter + // lets a fork keep its in-line child's port centred (straight connector) instead of shoving it off. + const SOURCE_STEP = 8; const LOOP_W = 26; // cell cross reserved on the right for a self-loop + const reversedOf = new Map(edgeRecs.map((er) => [er.id, er.reversed])); // Build wires (one per unit sub-edge) first so we know each channel's load + each node's port demand. const wires: Wire[] = []; @@ -468,7 +473,7 @@ function assignCoordinates( // + arrowheads can't stack) and within the node face; an endpoint outside the face clamps to the face // edge (that edge must turn — it's genuinely off to the side). Ordering by neighbour cross also keeps // the fan from self-crossing at the node. - const distribute = (it: Item, list: Wire[]) => { + const distribute = (it: Item, list: Wire[], isFar: boolean) => { if (it.isDummy) { for (const w of list) setPort(w, it.id, it.cc); return; } if (list.length === 0) return; const parity = it.rank % 2 === 1 ? 0.5 : 0; @@ -478,21 +483,25 @@ function assignCoordinates( it.portHi = hiI; const sorted = list.slice().sort((p, q) => otherCross(p, it.id) - otherCross(q, it.id) || (p.edgeId < q.edgeId ? -1 : p.edgeId > q.edgeId ? 1 : 0)); const n = sorted.length; - // Forward pass: each port at its desired (clamped) col, pushed right to keep PORT_STEP spacing. + // A port carries an arrowhead (→ needs full PORT_STEP) iff `it` is the edge's TARGET end here: the + // higher-rank end of a forward edge, or the lower-rank end of a reversed one. Flat edges keep the + // full step (conservative). The min gap before port i is PORT_STEP if either neighbour is a target. + const isTarget = sorted.map((w) => w.flat || (isFar ? !!reversedOf.get(w.edgeId) : !reversedOf.get(w.edgeId))); + const gap = (i: number) => (isTarget[i - 1] || isTarget[i] ? PORT_STEP : SOURCE_STEP); + // Forward pass: each port at its desired (clamped) col, pushed right to keep the per-gap spacing. const cols: number[] = []; - let prev = -Infinity; for (let i = 0; i < n; i++) { let col = Math.max(loI, Math.min(hiI, Math.round(otherCross(sorted[i], it.id)))); - if (col < prev + PORT_STEP) col = prev + PORT_STEP; - cols[i] = col; prev = col; + if (i > 0 && col < cols[i - 1] + gap(i)) col = cols[i - 1] + gap(i); + cols[i] = col; } // If the forward pass overflowed the face (cluster pushed past hiI), pull back from the right. - if (cols[n - 1] > hiI) { let next = hiI; for (let i = n - 1; i >= 0; i--) { if (cols[i] > next) cols[i] = next; next = cols[i] - PORT_STEP; } } + if (cols[n - 1] > hiI) { let next = hiI; for (let i = n - 1; i >= 0; i--) { if (cols[i] > next) cols[i] = next; next = cols[i] - (i > 0 ? gap(i) : 0); } } for (let i = 0; i < n; i++) setPort(sorted[i], it.id, cols[i] + parity); }; for (const it of items.values()) { - distribute(it, farWires.get(it.id)!); - distribute(it, nearWires.get(it.id)!); + distribute(it, farWires.get(it.id)!, true); + distribute(it, nearWires.get(it.id)!, false); } // STRAIGHTEN SPINE EDGES (Brandes-Köpf-style turn minimization): each node's PRIMARY wire on a face