From c7aaf56ab59ef759ca49956f37b0941c4f344106 Mon Sep 17 00:00:00 2001 From: Ivan Cheung Date: Mon, 22 Jun 2026 23:22:18 +0000 Subject: [PATCH] fix(sgcr): relax SOURCE-side port spacing so fork in-line children stay straight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arrowheads sit at an edge's TARGET, so only target-side ports need the full PORT_STEP arrowhead-clearance spacing. Source-side ports (no arrowhead) were also spaced 16px apart, which shoved a fork parent's in-line child off-centre (its narrow child face then couldn't reach the port → diagonal connector even though parent and child were centre-aligned). Now source ports pack at a smaller step (8px, still distinct), so the in-line child keeps the centre/ aligned port and its connector runs straight; off-axis children turn as before. A port is 'target' (keeps PORT_STEP) iff it's the edge's arrowhead end here (higher-rank end of a forward edge / lower-rank end of a reversed one; flat edges conservatively keep the full step) — so P5 arrowhead clearance is untouched. Verified: stress fuzz 10,060 layouts zero violations, vitest 26/26, the arch demo's edge→api→svc→cache spine renders as one straight line. --- .../src/client/renderers/sgcr/layout.ts | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) 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