feat(diagram-engine): label avoidance, paired opposite edges, semantic group colours

A git-workflow flowchart rendered with no overlaps but read poorly. Three
distinct causes, each fixed and measured:

1. Edge labels sat on boxes and on each other (4 collisions on the
   reported diagram; 280 across 250 generated flowcharts). The router
   keeps LINES off the boxes but a label renders at its edge's midpoint,
   which on a long edge is beside exactly the things the line was routed
   around. placeLabels slides each label along its own edge to a clear
   spot — longest edges first, midpoint-outward tries — written as the
   geometry's relative x, which draw.io natively supports. Corpus: 280
   label collisions -> 8.

2. A->B and B->A were routed independently, so "git add" ran straight
   while "git reset" wandered through a different corridor with a kink.
   Opposite edges that agree on axis now get two absolute parallel tracks
   in the strip where the two boxes overlap, a constant 24px apart,
   converted back to port fractions. Zero crossing regressions.

3. All boxes rendered the same white, because the render layer's
   fill/stroke support was never reachable: neither add_box's schema nor
   draw_graph's nodes exposed it. Rather than exposing raw hex (the model
   picks mismatched saturations, differently every time), nodes take a
   semantic group name and the engine maps groups to a fixed palette of
   six paired fill/strokes in order of first appearance. The model names
   the zones - remote vs local vs temp - and never touches a colour.

532 unit tests pass; the 5 diagram e2e tests pass in a real browser.
This commit is contained in:
dayuan.jiang
2026-08-09 18:59:34 +09:00
parent c919a2d0ec
commit 6b5fd613f2
7 changed files with 503 additions and 5 deletions

View File

@@ -436,6 +436,60 @@ export function routeEdges(
}
}
// --- stage 1c: pair opposite edges
//
// A→B and B→A are one relationship drawn as two arrows — "git add" out, "git reset"
// back. Left to the general de-collide they land on port positions chosen for entirely
// separate reasons at each end, so one line runs straight while its partner wanders off
// through a different corridor. A reader expects a matched pair: two parallel lines a
// constant gap apart, one clearly out and one clearly back.
//
// The two tracks are ABSOLUTE positions in the strip where the boxes overlap — the
// corridor's centre ± half a track gap — converted back to a fraction of each box.
// Assigning the same fraction to both boxes instead only works when they happen to be
// the same size and aligned; on real diagrams it put the two lines 79px apart with a
// kink in one of them.
const seen = new Map<string, number>()
edges.forEach((e, i) => {
seen.set(`${e.source}|${e.target}`, i)
})
const PAIR_GAP = 24
const pairedDone = new Set<number>()
edges.forEach((e, i) => {
if (pairedDone.has(i)) return
const j = seen.get(`${e.target}|${e.source}`)
if (j === undefined || j === i || pairedDone.has(j)) return
const fi = faces[i]
const fj = faces[j]
const a = rects.get(e.source)
const b = rects.get(e.target)
if (!fi || !fj || !a || !b) return
// Only pair edges that agree on the axis; when they disagree the geometry wants
// them apart, and forcing them together would fight the search.
if (fi.horiz !== fj.horiz) return
// The strip both boxes span, on the axis ACROSS the arrows. Two straight parallel
// tracks need the corridor to hold them both.
const lo = fi.horiz ? Math.max(a.y, b.y) : Math.max(a.x, b.x)
const hi = fi.horiz
? Math.min(a.y + a.h, b.y + b.h)
: Math.min(a.x + a.w, b.x + b.w)
if (hi - lo < PAIR_GAP + 12) return
const mid = (lo + hi) / 2
const t1 = mid - PAIR_GAP / 2
const t2 = mid + PAIR_GAP / 2
const fracOf = (r: Rect, track: number) =>
fi.horiz ? (track - r.y) / r.h : (track - r.x) / r.w
pairedDone.add(i)
pairedDone.add(j)
frac[i].s = fracOf(a, t1)
frac[i].t = fracOf(b, t1)
frac[j].s = fracOf(b, t2)
frac[j].t = fracOf(a, t2)
})
// --- stage 2: try shapes in order of directness, keep the first that is clear
//
// Segments already claimed by a routed edge, so later edges can avoid sharing a lane