Commit Graph

3 Commits

Author SHA1 Message Date
dayuan.jiang
6b5fd613f2 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.
2026-08-09 18:59:34 +09:00
dayuan.jiang
00ebf91b90 fix(diagram-engine): eliminate arrows drawn through boxes, complete the route search
A user's approval-workflow flowchart came out with the return arrow drawn
straight through two unrelated steps, and a second report showed arrows
leaving a box and bending straight back across it. Measured over 250
generated flowcharts (2722 edges): 347 arrows crossed an unrelated box and
215 waypoints landed inside a shape. Four defects, each measured in
isolation:

1. The invisible layer containers draw_graph emits were handed to the
   router as frames, so every clean return path was rejected for
   "trespassing" on a border that is not drawn, and the fallback cut
   through two boxes. Excluding invisible containers: 347 -> 218 crossing
   arrows, no diagram made worse.

2. The router chose the horizontal-vs-vertical axis BEFORE searching, so
   when the only clean corridor ran along the other axis it was never
   looked at. A complete two-bend candidate generator that tries both
   trunk axes, all four sides at each end, and the port fractions:
   218 -> 27. (An independent ablation measured the axis pre-choice alone
   at a 40% per-edge failure rate.)

3. Nothing stopped a route's first leg from turning back across its own
   source shape - the obstacle test exempts an edge's own endpoints, and
   must, since the line has to touch them. A terminal-leg rule refuses
   such routes outright: 215 -> 4 hooks.

4. A two-bend search cannot express the staircase needed when a box sits
   directly between two vertically aligned nodes (21 of the last 27
   crossings). Added the orthogonal visibility graph + A* from Wybrow,
   Marriott & Stuckey, "Orthogonal Connector Routing" (GD 2009) - the
   libavoid algorithm - as the backstop when the candidate search finds
   nothing. The interesting-points grid is provably sufficient: any valid
   route shrinks onto it without getting longer or gaining bends. The A*
   state is (point, incoming direction) with libavoid's bend cost of 10,
   and the admissible bends-remaining heuristic, so it returns a cheapest
   route, not merely a route. Implemented from the paper, not ported.

After all four: 0 crossing arrows and 0 hooks over the same 250 diagrams,
page area unchanged (494k px^2 mean), 260ms for the whole corpus, mean
0.82 bends per edge. The shape ladder still runs first, so routes that
were already clean are byte-identical.

Also post-nudge validation now checks the whole path (the nudge pass only
reverts the single segment it moved, judged in isolation) and restores the
search's route if nudging made it dirty.
2026-08-09 18:18:54 +09:00
dayuan.jiang
1e4c74d464 feat(diagram-engine): edge router — pinned ports, obstacle avoidance, cost search
Fixes the reported problem: arrows overlapped on top of icons and ran through shapes
they had nothing to do with.

The cause was a missing layer, not a bug. render.ts emitted only source and target, so
draw.io routed every edge itself — and its router sees the two terminals' bounds and
nothing else, not where the other icons are. It therefore ran lines straight through
whatever was in between and left several edges leaving one node at the same point.

Ported from drawio-ai-kit's router (MIT, see NOTICE):

  - Port de-collision. Edges leaving the same side of the same node spread along it,
    ordered by where their far end sits so they do not cross on the way out. An edge
    with a clean straight shot keeps the centre.
  - Obstacle avoidance. Candidate shapes in order of directness — straight, a Z through
    the gap, an L, a two-bend detour — each tested against every icon on the page.
  - Frame placement. A frame is passable (an edge into a VPC must cross its border) but
    not free: running alongside a border, or cutting through a frame that holds only one
    of the two endpoints, is penalised.
  - Port snapping. On a bent route, each port moves to the side its leg actually arrives
    from. Without this the terminal segment can pierce the icon to reach a far-side port.
  - Lane claiming. Each routed edge records the lanes it occupies so later edges avoid
    them, rather than colliding and being pulled apart afterwards.
  - Global nudge, three passes, reverting any move that makes a path worse.

Two things I got wrong on the way, both caught by looking at real geometry:

  - The Z corridor was computed as min/max of both nodes' edges, which spans the whole
    distance between them — including anything parked in between. So the lane sweep
    would place the detour's middle leg on top of the very icon it was avoiding. It has
    to be the gap: trailing edge of the first node to the leading edge of the other.
  - The router was fed layout's slot rectangles, but an icon's cell is the glyph square
    centred in a slot roughly twice as wide. Collision tests against slots both missed
    real overlaps and invented false ones. Extracted cellRect() so the router and the
    emitted XML cannot diverge.

Accept-or-reject was not enough on its own. When one endpoint is inside a VPC and the
other outside, EVERY path trespasses on that frame, so the strict rule always fails and
the relaxed pass took whatever it tried first — which is how a line ended up cutting
across a whole VPC. Candidates are now scored (frame offences 500, lane sharing 700,
bends 80, length 1) and the cheapest wins, so an edge that must trespass still gets the
least-bad route.

Waypoints are still written only when load-bearing — a labelled bend or a deliberate
detour — so an unobstructed edge stays drag-friendly.

455 unit tests (27 new, asserting produced geometry rather than algorithm shape).
Verified by rendering the reported diagram in the real editor: the two arrows that
overlapped on the EC2 icon now leave from different sides, and the load balancer's
fan-out leaves from three distinct points.
2026-08-09 13:49:11 +09:00