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

@@ -23,6 +23,7 @@
*/
import type { Operation } from "./operations"
import { groupColour } from "./render"
import type { BoxShape } from "./types"
/** A node in the graph the caller wants drawn. */
@@ -33,6 +34,12 @@ export interface GraphNode {
shape?: BoxShape
/** Catalog stencil name. When set the node renders as an icon rather than a box. */
icon?: string
/**
* Semantic group name, e.g. "remote" or "local". Nodes sharing a group get the same
* fill colour from the engine's palette, assigned in order of first appearance — the
* caller names the grouping and never touches a colour.
*/
group?: string
}
/** An arrow. Direction matters: it is what determines the layering. */
@@ -302,8 +309,19 @@ export function graphToOperations(
},
]
const byId = new Map(nodes.map((n) => [n.id, n]))
// Groups become colours here, in order of first appearance, so "the second group named
// is green" holds for every diagram the engine draws. The caller only names groups.
const groupIndex = new Map<string, number>()
for (const n of nodes)
if (n.group && !groupIndex.has(n.group))
groupIndex.set(n.group, groupIndex.size)
const add = (id: string, parent: string): Operation => {
const n = byId.get(id) as GraphNode
const colour =
n.group !== undefined
? groupColour(groupIndex.get(n.group) ?? 0)
: null
return n.icon
? {
op: "add_icon",
@@ -318,6 +336,9 @@ export function graphToOperations(
parent,
label: n.label,
...(n.shape && n.shape !== "box" ? { shape: n.shape } : {}),
...(colour
? { fill: colour.fill, stroke: colour.stroke }
: {}),
}
}