From ac1a1053219ff0fa8fcb6c4adcca3181aad830c5 Mon Sep 17 00:00:00 2001 From: Tomasz Zajac Date: Mon, 28 Sep 2026 19:32:58 +0200 Subject: [PATCH] fix(layout): make Smart Layout refinement deterministic and never worse Measured on the bundled sample model and synthetic landscapes, the simulated-annealing stage ended worse than the best raw ELK candidate in most runs, left overlapping boxes, and gave a different layout on every click. Collapsed containers made every ELK candidate throw. - Layout engines only see the visible graph: hidden children are dropped and their relations re-attached to the visible ancestor. The store feeds layouts the same collapse/expand/hidden-relation rules the canvas uses and no longer overwrites a collapsed node's stored size. - Overlap between siblings is a hard constraint for the annealer, and every candidate and phase is scored after fitting parents and separating boxes (layoutFinalize.ts), so the score describes what gets rendered. - The refined layout competes with the best raw candidate and with the current layout; if nothing beats the current one, nothing changes. - New annealing engine (annealing.ts): seeded PRNG from the diagram's structure, work-based budgets instead of milliseconds, step size separate from a calibrated temperature, no aestheticBoost discontinuity, and an incremental proxy energy that only re-tests pairs a move affects. - Radical layout recurses by parentId (nested systems, databases, queues), breaks cycles from the entry points before layering, and sizes external systems from their children. - Fix the SA throughput benchmark's stale import. Co-Authored-By: Claude Opus 5.5 --- src/renderer/src/layout/annealing.ts | 700 +++++++++++ src/renderer/src/layout/elkLayout.ts | 8 +- src/renderer/src/layout/geometry.ts | 61 +- src/renderer/src/layout/layoutFinalize.ts | 130 ++ src/renderer/src/layout/radicalLayout.ts | 841 ++++--------- src/renderer/src/layout/scoreWeights.ts | 56 + src/renderer/src/layout/smartLayout.ts | 1099 +++++------------ src/renderer/src/layout/smartLayout.worker.ts | 7 +- src/renderer/src/layout/smartLayoutRunner.ts | 4 +- src/renderer/src/store/diagramStore.ts | 150 ++- tests/annealing.test.ts | 127 ++ tests/layoutPipeline.test.ts | 85 ++ tests/radicalLayout.test.ts | 60 + tests/saThroughput.bench.mjs | 12 +- tests/smartLayout.test.ts | 84 +- 15 files changed, 1965 insertions(+), 1459 deletions(-) create mode 100644 src/renderer/src/layout/annealing.ts create mode 100644 src/renderer/src/layout/layoutFinalize.ts create mode 100644 src/renderer/src/layout/scoreWeights.ts create mode 100644 tests/annealing.test.ts create mode 100644 tests/layoutPipeline.test.ts diff --git a/src/renderer/src/layout/annealing.ts b/src/renderer/src/layout/annealing.ts new file mode 100644 index 00000000..c3b09d84 --- /dev/null +++ b/src/renderer/src/layout/annealing.ts @@ -0,0 +1,700 @@ +/** + * Simulated-annealing refinement for Smart Layout (Davidson & Harel 1996). + * + * A "group" is a set of siblings — the root nodes, or the children of one + * compound. Each move translates one sibling (its subtree follows) or swaps + * two siblings, and is accepted by the Metropolis criterion. + * + * Design points: + * - **Deterministic.** Randomness comes from a seeded PRNG and budgets are + * counted in sweeps, not milliseconds, so the same diagram always gets + * the same layout (a wall-clock deadline only guards pathological cases). + * - **Overlap is a hard constraint.** A move may not create or worsen an + * overlap between siblings (with a clearance gap); a soft penalty let the + * annealer trade crossings for boxes stacked on top of each other. + * - **Step size and temperature are separate.** The step σ is in pixels, + * the temperature is in energy units and is calibrated from sampled + * uphill moves so that an average uphill move starts at ~50 % acceptance. + * - **Incremental energy.** ProxyEnergy re-evaluates only the edges and + * node pairs touched by a move instead of the whole diagram. + */ +import type { C4Node, C4Relation } from '../types/c4' +import { + W_CROSS, W_OVERDRAW, W_LMEAN, W_LMAX, W_ASPECT, + LEN_MEAN_KNEE, LEN_MAX_KNEE, overlapCost, aspectPenalty, +} from './scoreWeights' + +// ─── Seeded randomness ──────────────────────────────────────────────────── + +export type Rng = () => number + +/** mulberry32 — small, fast and plenty for layout perturbations. */ +export function createRng(seed: number): Rng { + let a = seed >>> 0 + return () => { + a = (a + 0x6d2b79f5) >>> 0 + let t = a + t = Math.imul(t ^ (t >>> 15), t | 1) + t ^= t + Math.imul(t ^ (t >>> 7), t | 61) + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +/** + * FNV-1a over the diagram's structure — same diagram, same seed. Compound + * sizes are left out: layout refits them, and re-running Smart Layout on + * its own result must see the same seed. + */ +export function graphSeed(nodes: Record, relations: Record): number { + let h = 0x811c9dc5 + const mix = (s: string): void => { + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i) + h = Math.imul(h, 0x01000193) + } + } + const compounds = new Set() + for (const n of Object.values(nodes)) if (n.parentId && nodes[n.parentId]) compounds.add(n.parentId) + for (const id of Object.keys(nodes).sort()) { + const n = nodes[id] + const size = compounds.has(id) ? '' : `${n.width}|${n.height}` + mix(`${id}|${n.parentId ?? ''}|${size};`) + } + for (const id of Object.keys(relations).sort()) { + const r = relations[id] + mix(`${r.sourceId}>${r.targetId};`) + } + return h >>> 0 +} + +/** Box-Muller — N(0, 1). */ +export function gaussian(rng: Rng): number { + let u = 0, v = 0 + while (u === 0) u = rng() + while (v === 0) v = rng() + return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v) +} + +// ─── Indexed graph ──────────────────────────────────────────────────────── + +export interface LayoutGraph { + n: number + ids: string[] + indexOf: Map + parent: Int32Array + /** Parents before children. */ + order: Int32Array + w: Float64Array + h: Float64Array + /** Current parent-relative positions; the annealer writes its result back here. */ + relX: Float64Array + relY: Float64Array + /** Node itself plus all descendants. */ + subtree: Int32Array[] + /** n×n: 1 when the pair is the same node or ancestor/descendant. */ + related: Uint8Array + src: Int32Array + tgt: Int32Array + E: number + incident: Int32Array[] + meanArea: number + meanDim: number +} + +export function buildLayoutGraph( + nodes: Record, + relations: Record, +): LayoutGraph { + const ids = Object.keys(nodes) + const n = ids.length + const indexOf = new Map() + ids.forEach((id, i) => indexOf.set(id, i)) + + const parent = new Int32Array(n).fill(-1) + const w = new Float64Array(n), h = new Float64Array(n) + const relX = new Float64Array(n), relY = new Float64Array(n) + const kids: number[][] = Array.from({ length: n }, () => []) + let area = 0, dim = 0 + for (let i = 0; i < n; i++) { + const node = nodes[ids[i]] + const p = node.parentId !== undefined ? indexOf.get(node.parentId) : undefined + if (p !== undefined) { parent[i] = p; kids[p].push(i) } + w[i] = node.width + h[i] = node.height + relX[i] = node.x + relY[i] = node.y + area += node.width * node.height + dim += (node.width + node.height) / 2 + } + + const order: number[] = [] + const subtree: Int32Array[] = new Array(n) + const collect = (i: number, out: number[]): void => { + out.push(i) + for (const c of kids[i]) collect(c, out) + } + for (let i = 0; i < n; i++) { + const out: number[] = [] + collect(i, out) + subtree[i] = Int32Array.from(out) + if (parent[i] === -1) order.push(...out) + } + + const related = new Uint8Array(n * n) + for (let i = 0; i < n; i++) { + related[i * n + i] = 1 + for (let a = parent[i]; a !== -1; a = parent[a]) { + related[i * n + a] = 1 + related[a * n + i] = 1 + } + } + + const src: number[] = [], tgt: number[] = [] + const incidentLists: number[][] = Array.from({ length: n }, () => []) + for (const r of Object.values(relations)) { + const s = indexOf.get(r.sourceId), t = indexOf.get(r.targetId) + if (s === undefined || t === undefined || s === t) continue + const e = src.length + src.push(s) + tgt.push(t) + incidentLists[s].push(e) + incidentLists[t].push(e) + } + + return { + n, ids, indexOf, parent, order: Int32Array.from(order), + w, h, relX, relY, subtree, related, + src: Int32Array.from(src), tgt: Int32Array.from(tgt), E: src.length, + incident: incidentLists.map((l) => Int32Array.from(l)), + meanArea: n > 0 ? area / n : 0, + meanDim: n > 0 ? dim / n : 1, + } +} + +// ─── Energy evaluators ──────────────────────────────────────────────────── + +export interface Move { node: number; dx: number; dy: number } + +/** + * An energy the annealer can probe. `apply` translates the listed nodes + * (with their subtrees) and returns the energy afterwards; the move stays + * pending until `commit` or `revert`. + */ +export interface Evaluator { + energy(): number + apply(moves: readonly Move[]): number + commit(): void + revert(): void +} + +// Segment primitives: strict orientation test, rect shrunk by 1 px so a +// segment grazing a border doesn't count (mirrors crossingOpt.ts). +function segCross( + ax: number, ay: number, bx: number, by: number, + cx: number, cy: number, dx: number, dy: number, +): boolean { + const o1 = (dy - cy) * (bx - cx) - (by - cy) * (dx - cx) > 0 + const o2 = (dy - cy) * (ax - cx) - (ay - cy) * (dx - cx) > 0 + if (o1 === o2) return false + const o3 = (by - ay) * (cx - ax) - (cy - ay) * (bx - ax) > 0 + const o4 = (by - ay) * (dx - ax) - (dy - ay) * (bx - ax) > 0 + return o3 !== o4 +} + +function segHitsRect( + px: number, py: number, qx: number, qy: number, + rx: number, ry: number, rw: number, rh: number, +): boolean { + const x0 = rx + 1, y0 = ry + 1 + const x1 = rx + rw - 1, y1 = ry + rh - 1 + if (x1 <= x0 || y1 <= y0) return false + if (px > x0 && px < x1 && py > y0 && py < y1) return true + if (qx > x0 && qx < x1 && qy > y0 && qy < y1) return true + let t0 = 0, t1 = 1 + const ddx = qx - px, ddy = qy - py + const clip = (p: number, q: number): boolean => { + if (p === 0) return q >= 0 + const t = q / p + if (p < 0) { if (t > t1) return false; if (t > t0) t0 = t } + else { if (t < t0) return false; if (t < t1) t1 = t } + return true + } + return clip(-ddx, px - x0) && clip(ddx, x1 - px) && clip(-ddy, py - y0) && clip(ddy, y1 - py) +} + +/** + * Cheap stand-in for the render-aware score: straight centre-to-centre + * edges, sibling overlap, edge lengths and bounding-box aspect ratio. + * + * Totals are maintained incrementally: a move only re-counts the crossing + * pairs, edge/node overdraw pairs and node overlap pairs it can change. + * Nodes related to an edge's endpoints (ancestors *and* descendants) are + * never obstacles for it — the renderer excludes them the same way. + */ +export class ProxyEnergy implements Evaluator { + private readonly g: LayoutGraph + private readonly ax: Float64Array + private readonly ay: Float64Array + private readonly len: Float64Array + private crossings = 0 + private overdraws = 0 + private overlapArea = 0 + private totalLen = 0 + private cur = 0 + + private readonly nodeMark: Int32Array + private readonly edgeMark: Int32Array + private epoch = 0 + private readonly S: number[] = [] + private readonly M: number[] = [] + private pending: { + moves: readonly Move[] + crossings: number; overdraws: number; overlapArea: number; totalLen: number; energy: number + oldLen: number[] + } | null = null + + constructor(g: LayoutGraph) { + this.g = g + this.ax = new Float64Array(g.n) + this.ay = new Float64Array(g.n) + for (const i of g.order) { + const p = g.parent[i] + this.ax[i] = g.relX[i] + (p === -1 ? 0 : this.ax[p]) + this.ay[i] = g.relY[i] + (p === -1 ? 0 : this.ay[p]) + } + this.len = new Float64Array(g.E) + this.nodeMark = new Int32Array(g.n) + this.edgeMark = new Int32Array(g.E) + this.recomputeTotals() + } + + energy(): number { return this.cur } + + /** Full O(E² + E·n + n²) recount — used once up front and by tests. */ + recomputeTotals(): void { + const { n, E } = this.g + let c = 0, o = 0, a = 0, total = 0 + for (let e = 0; e < E; e++) { + for (let f = e + 1; f < E; f++) if (this.edgesCross(e, f)) c++ + for (let k = 0; k < n; k++) if (this.edgeHitsNode(e, k)) o++ + this.len[e] = this.edgeLength(e) + total += this.len[e] + } + for (let i = 0; i < n; i++) for (let j = i + 1; j < n; j++) a += this.overlapOf(i, j) + this.crossings = c + this.overdraws = o + this.overlapArea = a + this.totalLen = total + this.cur = this.totalEnergy() + } + + apply(moves: readonly Move[]): number { + const g = this.g + const ep = ++this.epoch + const S = this.S, M = this.M + S.length = 0 + M.length = 0 + for (const m of moves) { + for (const k of g.subtree[m.node]) { + if (this.nodeMark[k] !== ep) { this.nodeMark[k] = ep; S.push(k) } + } + } + for (const k of S) { + for (const e of g.incident[k]) { + if (this.edgeMark[e] !== ep) { this.edgeMark[e] = ep; M.push(e) } + } + } + + const [c0, o0, a0] = this.localCounts() + this.shift(moves, 1) + const [c1, o1, a1] = this.localCounts() + + const oldLen: number[] = new Array(M.length) + let totalLen = this.totalLen + for (let i = 0; i < M.length; i++) { + const e = M[i] + oldLen[i] = this.len[e] + this.len[e] = this.edgeLength(e) + totalLen += this.len[e] - oldLen[i] + } + + this.pending = { + moves, + crossings: this.crossings, overdraws: this.overdraws, + overlapArea: this.overlapArea, totalLen: this.totalLen, energy: this.cur, + oldLen, + } + this.crossings += c1 - c0 + this.overdraws += o1 - o0 + this.overlapArea += a1 - a0 + this.totalLen = totalLen + this.cur = this.totalEnergy() + return this.cur + } + + commit(): void { this.pending = null } + + revert(): void { + const p = this.pending + if (!p) return + this.shift(p.moves, -1) + for (let i = 0; i < this.M.length; i++) this.len[this.M[i]] = p.oldLen[i] + this.crossings = p.crossings + this.overdraws = p.overdraws + this.overlapArea = p.overlapArea + this.totalLen = p.totalLen + this.cur = p.energy + this.pending = null + } + + private shift(moves: readonly Move[], sign: 1 | -1): void { + for (const m of moves) { + const dx = m.dx * sign, dy = m.dy * sign + for (const k of this.g.subtree[m.node]) { this.ax[k] += dx; this.ay[k] += dy } + } + } + + /** + * Counts over every pair a pending move can affect: edge pairs with at + * least one moved edge, (edge, node) pairs with a moved edge or node, and + * node pairs with at least one moved node. Each pair is visited exactly + * once, and the visited set depends only on the marks — so counting + * before and after the shift yields an exact delta. + */ + private localCounts(): [number, number, number] { + const { n, E } = this.g + const ep = this.epoch + let c = 0, o = 0, a = 0 + for (const e of this.M) { + for (let f = 0; f < E; f++) { + if (f === e || (this.edgeMark[f] === ep && f < e)) continue + if (this.edgesCross(e, f)) c++ + } + for (let k = 0; k < n; k++) if (this.edgeHitsNode(e, k)) o++ + } + for (let f = 0; f < E; f++) { + if (this.edgeMark[f] === ep) continue + for (const k of this.S) if (this.edgeHitsNode(f, k)) o++ + } + for (const i of this.S) { + for (let j = 0; j < n; j++) { + if (j === i || (this.nodeMark[j] === ep && j < i)) continue + a += this.overlapOf(i, j) + } + } + return [c, o, a] + } + + private edgesCross(e: number, f: number): boolean { + const { src, tgt, w, h } = this.g + const a = src[e], b = tgt[e], c = src[f], d = tgt[f] + if (a === c || a === d || b === c || b === d) return false + const { ax, ay } = this + return segCross( + ax[a] + w[a] / 2, ay[a] + h[a] / 2, ax[b] + w[b] / 2, ay[b] + h[b] / 2, + ax[c] + w[c] / 2, ay[c] + h[c] / 2, ax[d] + w[d] / 2, ay[d] + h[d] / 2, + ) + } + + private edgeHitsNode(e: number, k: number): boolean { + const { src, tgt, w, h, related, n } = this.g + const s = src[e], t = tgt[e] + if (related[k * n + s] || related[k * n + t]) return false + const { ax, ay } = this + return segHitsRect( + ax[s] + w[s] / 2, ay[s] + h[s] / 2, ax[t] + w[t] / 2, ay[t] + h[t] / 2, + ax[k], ay[k], w[k], h[k], + ) + } + + private overlapOf(i: number, j: number): number { + const { w, h, related, n } = this.g + if (related[i * n + j]) return 0 + const { ax, ay } = this + const ox = Math.min(ax[i] + w[i], ax[j] + w[j]) - Math.max(ax[i], ax[j]) + if (ox <= 0) return 0 + const oy = Math.min(ay[i] + h[i], ay[j] + h[j]) - Math.max(ay[i], ay[j]) + return oy > 0 ? ox * oy : 0 + } + + private edgeLength(e: number): number { + const { src, tgt, w, h } = this.g + const s = src[e], t = tgt[e] + const dx = this.ax[s] + w[s] / 2 - (this.ax[t] + w[t] / 2) + const dy = this.ay[s] + h[s] / 2 - (this.ay[t] + h[t] / 2) + return Math.sqrt(dx * dx + dy * dy) + } + + private totalEnergy(): number { + const { n, E, w, h, meanArea, meanDim } = this.g + let maxLen = 0 + for (let e = 0; e < E; e++) if (this.len[e] > maxLen) maxLen = this.len[e] + const meanInNodes = E > 0 && meanDim > 0 ? this.totalLen / E / meanDim : 0 + const maxInNodes = meanDim > 0 ? maxLen / meanDim : 0 + + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity + for (let i = 0; i < n; i++) { + if (this.ax[i] < minX) minX = this.ax[i] + if (this.ay[i] < minY) minY = this.ay[i] + if (this.ax[i] + w[i] > maxX) maxX = this.ax[i] + w[i] + if (this.ay[i] + h[i] > maxY) maxY = this.ay[i] + h[i] + } + + return this.crossings * W_CROSS + + this.overdraws * W_OVERDRAW + + overlapCost(meanArea > 0 ? this.overlapArea / meanArea : 0) + + Math.max(0, meanInNodes - LEN_MEAN_KNEE) ** 2 * W_LMEAN + + Math.max(0, maxInNodes - LEN_MAX_KNEE) ** 2 * W_LMAX + + aspectPenalty(maxX - minX, maxY - minY) * W_ASPECT + } +} + +/** + * Wraps a whole-diagram scoring function (the render-aware composite) as an + * Evaluator. Every probe is a full re-score, so it is only used for short + * polishing runs. + */ +export class FunctionEnergy implements Evaluator { + private cur: number + private pending: { moves: readonly Move[]; energy: number } | null = null + + constructor( + private readonly g: LayoutGraph, + private readonly work: Record, + private readonly relations: Record, + private readonly fn: (nodes: Record, relations: Record) => number, + ) { + this.cur = fn(work, relations) + } + + energy(): number { return this.cur } + + apply(moves: readonly Move[]): number { + this.shift(moves, 1) + this.pending = { moves, energy: this.cur } + this.cur = this.fn(this.work, this.relations) + return this.cur + } + + commit(): void { this.pending = null } + + revert(): void { + if (!this.pending) return + this.shift(this.pending.moves, -1) + this.cur = this.pending.energy + this.pending = null + } + + private shift(moves: readonly Move[], sign: 1 | -1): void { + for (const m of moves) { + const node = this.work[this.g.ids[m.node]] + node.x += m.dx * sign + node.y += m.dy * sign + } + } +} + +// ─── Annealer ───────────────────────────────────────────────────────────── + +/** Parent-relative box the group's members must stay inside. */ +export interface Bounds { minX: number; minY: number; maxX: number; maxY: number } + +export interface AnnealOptions { + rng: Rng + restarts: number + /** Sweeps per restart; one sweep = one translation per member + one swap. */ + sweeps: number + /** Initial step σ as a fraction of the group's bounding-box span. */ + stepFactor: number + /** Scales the calibrated starting temperature (< 1 = gentler). */ + tempFactor: number + /** Probe moves used to calibrate the starting temperature. */ + calibrationSamples: number + /** Minimum clear space between siblings. */ + gap: number + bounds?: Bounds + /** performance.now()-style timestamp after which the run stops early. */ + deadline: number +} + +export interface AnnealResult { + before: number + after: number + evaluations: number +} + +const now = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now()) + +/** Fraction of the initial step/temperature left at the end of a restart. */ +const FINAL_FRACTION = 0.03 +/** Sweeps without a new best before the one reheat of a restart. */ +const STAGNATION_LIMIT = 12 + +/** + * Anneal the positions of `group` (sibling node indices). On return the + * evaluator and `g.relX/relY` hold the best arrangement found. + */ +export function anneal(ev: Evaluator, g: LayoutGraph, group: number[], opts: AnnealOptions): AnnealResult { + const G = group.length + const before = ev.energy() + if (G < 2) return { before, after: before, evaluations: 0 } + + const { rng, gap, bounds } = opts + const gx = Float64Array.from(group, (i) => g.relX[i]) + const gy = Float64Array.from(group, (i) => g.relY[i]) + const gw = Float64Array.from(group, (i) => g.w[i]) + const gh = Float64Array.from(group, (i) => g.h[i]) + + let cur = before + let best = before + const bestX = gx.slice(), bestY = gy.slice() + let evaluations = 0 + + const overlapWithOthers = (k: number, x: number, y: number): number => { + let sum = 0 + for (let m = 0; m < G; m++) { + if (m === k) continue + const ox = Math.min(x + gw[k], gx[m] + gw[m]) - Math.max(x, gx[m]) + gap + if (ox <= 0) continue + const oy = Math.min(y + gh[k], gy[m] + gh[m]) - Math.max(y, gy[m]) + gap + if (oy > 0) sum += ox * oy + } + return sum + } + const inBounds = (k: number, x: number, y: number): boolean => + !bounds || (x >= bounds.minX && y >= bounds.minY && x + gw[k] <= bounds.maxX && y + gh[k] <= bounds.maxY) + + // A move may not leave the bounds (unless already outside) and may not + // create or worsen overlap with any sibling. + const translationAllowed = (k: number, x: number, y: number): boolean => { + if (!inBounds(k, x, y) && inBounds(k, gx[k], gy[k])) return false + const after = overlapWithOthers(k, x, y) + return after === 0 || after < overlapWithOthers(k, gx[k], gy[k]) + } + const swapAllowed = (a: number, b: number): boolean => { + const overlapBefore = overlapWithOthers(a, gx[a], gy[a]) + overlapWithOthers(b, gx[b], gy[b]) + const wasInside = inBounds(a, gx[a], gy[a]) && inBounds(b, gx[b], gy[b]) + const ax = gx[a], ay = gy[a] + gx[a] = gx[b]; gy[a] = gy[b]; gx[b] = ax; gy[b] = ay + const overlapAfter = overlapWithOthers(a, gx[a], gy[a]) + overlapWithOthers(b, gx[b], gy[b]) + const inside = inBounds(a, gx[a], gy[a]) && inBounds(b, gx[b], gy[b]) + gx[b] = gx[a]; gy[b] = gy[a]; gx[a] = ax; gy[a] = ay + return (inside || !wasInside) && (overlapAfter === 0 || overlapAfter < overlapBefore) + } + + const moves: Move[] = [] + const ks: number[] = [] + const probe = (): number => { + evaluations++ + return ev.apply(moves.slice()) + } + const settle = (): void => { + ev.commit() + for (let i = 0; i < ks.length; i++) { gx[ks[i]] += moves[i].dx; gy[ks[i]] += moves[i].dy } + } + const setMove = (...entries: [k: number, dx: number, dy: number][]): void => { + moves.length = 0 + ks.length = 0 + for (const [k, dx, dy] of entries) { moves.push({ node: group[k], dx, dy }); ks.push(k) } + } + const tryMove = (T: number): void => { + const e = probe() + const dE = e - cur + if (dE <= 0 || rng() < Math.exp(-dE / T)) { + settle() + cur = e + if (cur < best) { best = cur; bestX.set(gx); bestY.set(gy) } + } else { + ev.revert() + } + } + const jumpTo = (tx: Float64Array, ty: Float64Array): void => { + const entries: [number, number, number][] = [] + for (let k = 0; k < G; k++) { + if (tx[k] !== gx[k] || ty[k] !== gy[k]) entries.push([k, tx[k] - gx[k], ty[k] - gy[k]]) + } + if (entries.length === 0) return + setMove(...entries) + cur = probe() + settle() + } + + // Temperature such that an average sampled uphill move is accepted with p ≈ ½. + const calibrate = (sigma: number): number => { + let sum = 0, count = 0 + for (let s = 0; s < opts.calibrationSamples; s++) { + const k = Math.floor(rng() * G) + const dx = gaussian(rng) * sigma, dy = gaussian(rng) * sigma + if (!translationAllowed(k, gx[k] + dx, gy[k] + dy)) continue + setMove([k, dx, dy]) + const e = probe() + ev.revert() + if (e > cur) { sum += e - cur; count++ } + } + return count > 0 ? sum / count / Math.LN2 : 1 + } + + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity + for (let k = 0; k < G; k++) { + minX = Math.min(minX, gx[k]); minY = Math.min(minY, gy[k]) + maxX = Math.max(maxX, gx[k] + gw[k]); maxY = Math.max(maxY, gy[k] + gh[k]) + } + const span = Math.max(maxX - minX, maxY - minY, 200) + const cooling = Math.pow(FINAL_FRACTION, 1 / Math.max(1, opts.sweeps)) + + run: for (let restart = 0; restart < opts.restarts; restart++) { + if (now() > opts.deadline) break + if (restart > 0) jumpTo(bestX, bestY) + // Later restarts take bigger steps to leave the best basin found so far. + const sigma0 = span * opts.stepFactor * (1 + 0.5 * restart) + const T0 = calibrate(sigma0) * opts.tempFactor + let f = 1 + let stagnant = 0 + let reheated = false + + for (let sweep = 0; sweep < opts.sweeps; sweep++) { + if (now() > opts.deadline) break run + const sigma = sigma0 * f + const T = Math.max(T0 * f, 1e-9) + const bestBefore = best + + for (let k = 0; k < G; k++) { + const dx = gaussian(rng) * sigma, dy = gaussian(rng) * sigma + if (!translationAllowed(k, gx[k] + dx, gy[k] + dy)) continue + setMove([k, dx, dy]) + tryMove(T) + } + + // One swap per sweep — the big jump translation alone rarely makes. + // Of three random partners take the nearest: far swaps almost always lose. + const a = Math.floor(rng() * G) + let b = -1 + let bestDist = Infinity + for (let trial = 0; trial < 3; trial++) { + let c = Math.floor(rng() * G) + if (c === a) c = (c + 1) % G + const d = (gx[c] - gx[a]) ** 2 + (gy[c] - gy[a]) ** 2 + if (d < bestDist) { bestDist = d; b = c } + } + if (b !== -1 && swapAllowed(a, b)) { + setMove([a, gx[b] - gx[a], gy[b] - gy[a]], [b, gx[a] - gx[b], gy[a] - gy[b]]) + tryMove(T * 0.5) + } + + if (best < bestBefore) { + stagnant = 0 + } else if (++stagnant >= STAGNATION_LIMIT && !reheated && sweep < (opts.sweeps * 2) / 3) { + // Davidson-Harel "kick": trapped in a basin — reheat once instead of cooling on. + f = 0.6 + reheated = true + stagnant = 0 + continue + } + f *= cooling + } + } + + jumpTo(bestX, bestY) + for (let k = 0; k < G; k++) { g.relX[group[k]] = gx[k]; g.relY[group[k]] = gy[k] } + return { before, after: best, evaluations } +} diff --git a/src/renderer/src/layout/elkLayout.ts b/src/renderer/src/layout/elkLayout.ts index d064cb9e..2ff2287c 100644 --- a/src/renderer/src/layout/elkLayout.ts +++ b/src/renderer/src/layout/elkLayout.ts @@ -16,6 +16,7 @@ import ELK from 'elkjs/lib/elk.bundled.js' import type { ElkNode, ElkExtendedEdge, LayoutOptions } from 'elkjs' import { C4Node, C4Relation, PositionMap, COLLAPSED_HEIGHT, COLLAPSED_WIDTH, isContainerType } from '../types/c4' import { ELK_ROOT_SPACING, ELK_CHILD_SPACING } from './elkSpacingBase' +import { projectToVisibleGraph } from './geometry' const elk = new ELK() @@ -139,10 +140,13 @@ function parseElkResult(node: ElkNode, result: PositionMap): void { // ─── Public API ────────────────────────────────────────────────────────────── export async function applyElkLayout( - c4Nodes: Record, - c4Relations: Record, + inputNodes: Record, + inputRelations: Record, opts?: { rootOptions?: LayoutOptions; childOptions?: LayoutOptions }, ): Promise { + // ELK throws on edges whose endpoints aren't in its graph, which is what + // an edge to a child of a collapsed container would be. + const { nodes: c4Nodes, relations: c4Relations } = projectToVisibleGraph(inputNodes, inputRelations) const elkNodeMap: ElkNodeMap = {} const childOpts = opts?.childOptions ?? CHILD_OPTIONS diff --git a/src/renderer/src/layout/geometry.ts b/src/renderer/src/layout/geometry.ts index 3cd91121..db1010a8 100644 --- a/src/renderer/src/layout/geometry.ts +++ b/src/renderer/src/layout/geometry.ts @@ -8,7 +8,7 @@ * silently mis-size such a node while the others handled it correctly. * Consolidated here so every caller shares one (correct) definition. */ -import { C4Node, COLLAPSED_HEIGHT, COLLAPSED_WIDTH, NODE_SIZES, isContainerType } from '../types/c4' +import { C4Node, C4Relation, COLLAPSED_HEIGHT, COLLAPSED_WIDTH, NODE_SIZES, isContainerType } from '../types/c4' export function effectiveWidth(n: C4Node): number { if (isContainerType(n.type) && n.collapsed) return COLLAPSED_WIDTH[n.type] @@ -28,3 +28,62 @@ export function isVisible(node: C4Node, allNodes: Record): boole if (parent.collapsed) return false return isVisible(parent, allNodes) } + +/** + * Space a compound node keeps around its children: the header plus a + * two-line label on top, a plain margin on the other sides. Shared by the + * layout pipeline and the store's fitParentToChildren so the size Smart + * Layout scores is the size that ends up on the canvas. + */ +export function compoundPadding(type: string): { top: number; side: number; bottom: number } { + const margin = type === 'container' ? 20 : 30 + return { top: 120, side: margin, bottom: margin } +} + +/** + * The graph as the canvas draws it: nodes hidden under a collapsed ancestor + * are dropped, collapsed containers take their collapsed size, and every + * relation is re-attached to the nearest visible ancestor of each endpoint + * (the same aggregation deriveRFEdges applies). Relations that collapse + * into a self-loop or duplicate an earlier source→target pair are dropped. + * + * Layout engines must only ever see this projection — ELK rejects edges + * that reference nodes absent from its graph, and scoring hidden nodes + * optimises a picture nobody sees. + */ +export function projectToVisibleGraph( + nodes: Record, + relations: Record, +): { nodes: Record; relations: Record } { + const visible: Record = {} + for (const n of Object.values(nodes)) { + if (!isVisible(n, nodes)) continue + if (isContainerType(n.type) && n.collapsed) { + const w = COLLAPSED_WIDTH[n.type] + const h = COLLAPSED_HEIGHT[n.type] + visible[n.id] = n.width === w && n.height === h ? n : { ...n, width: w, height: h } + } else { + visible[n.id] = n + } + } + + const visibleAncestor = (id: string): string | undefined => { + let cur: C4Node | undefined = nodes[id] + while (cur && !visible[cur.id]) cur = cur.parentId ? nodes[cur.parentId] : undefined + return cur?.id + } + + const projected: Record = {} + const seen = new Set() + for (const id of Object.keys(relations).sort()) { + const r = relations[id] + const s = visibleAncestor(r.sourceId) + const t = visibleAncestor(r.targetId) + if (!s || !t || s === t) continue + const key = `${s}\u0000${t}` + if (seen.has(key)) continue + seen.add(key) + projected[id] = s === r.sourceId && t === r.targetId ? r : { ...r, sourceId: s, targetId: t } + } + return { nodes: visible, relations: projected } +} diff --git a/src/renderer/src/layout/layoutFinalize.ts b/src/renderer/src/layout/layoutFinalize.ts new file mode 100644 index 00000000..7f0437aa --- /dev/null +++ b/src/renderer/src/layout/layoutFinalize.ts @@ -0,0 +1,130 @@ +/** + * Turns a raw layout into what the canvas will actually show, so Smart + * Layout scores the final picture instead of an intermediate one: + * + * 1. Deepest compounds first: push overlapping children apart, then fit + * the parent around them (children shifted to the padding origin, + * parent resized, never below its type's minimum size). + * 2. Finally push overlapping root nodes apart. + * + * Separation is deterministic: pairs are visited in a fixed order and each + * overlap is resolved along its axis of least penetration, split evenly. + */ +import { C4Node, NODE_SIZES, PositionMap } from '../types/c4' +import { compoundPadding } from './geometry' + +/** Minimum clear space between root-level nodes. */ +export const ROOT_GAP = 40 +/** Minimum clear space between siblings inside a compound. */ +export const CHILD_GAP = 20 + +type Box = { x: number; y: number; width: number; height: number } + +function boxOf(nodes: Record, positions: PositionMap, id: string): Box { + const p = positions[id] + const n = nodes[id] + return { + x: p?.x ?? n.x, + y: p?.y ?? n.y, + width: p?.width ?? n.width, + height: p?.height ?? n.height, + } +} + +function childrenByParent(nodes: Record): Map { + const byParent = new Map() + for (const n of Object.values(nodes)) { + if (!n.parentId || !nodes[n.parentId]) continue + const list = byParent.get(n.parentId) + if (list) list.push(n.id) + else byParent.set(n.parentId, [n.id]) + } + return byParent +} + +function depthOf(nodes: Record, id: string): number { + let d = 0 + let cur: C4Node | undefined = nodes[id] + while (cur?.parentId) { d++; cur = nodes[cur.parentId] } + return d +} + +/** Push boxes apart until no pair is closer than `gap`. Mutates `boxes`. */ +export function separateBoxes(boxes: Box[], gap: number, maxPasses = 200): void { + for (let pass = 0; pass < maxPasses; pass++) { + let moved = false + for (let i = 0; i < boxes.length; i++) { + const a = boxes[i] + for (let j = i + 1; j < boxes.length; j++) { + const b = boxes[j] + const ox = Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x) + gap + if (ox <= 0) continue + const oy = Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y) + gap + if (oy <= 0) continue + moved = true + if (ox <= oy) { + const dir = a.x + a.width / 2 <= b.x + b.width / 2 ? 1 : -1 + a.x -= (dir * ox) / 2 + b.x += (dir * ox) / 2 + } else { + const dir = a.y + a.height / 2 <= b.y + b.height / 2 ? 1 : -1 + a.y -= (dir * oy) / 2 + b.y += (dir * oy) / 2 + } + } + } + if (!moved) return + } +} + +/** + * Fit every compound parent to its children (deepest first). Children are + * shifted so their bounding box starts at the padding origin, and the parent + * is resized to hug them. + */ +export function fitParentsToChildren(nodes: Record, positions: PositionMap): PositionMap { + return finalize(nodes, positions, false) +} + +/** Separate overlapping siblings at every level, fit parents, separate roots. */ +export function finalizeLayout(nodes: Record, positions: PositionMap): PositionMap { + return finalize(nodes, positions, true) +} + +function finalize(nodes: Record, positions: PositionMap, separate: boolean): PositionMap { + const out: PositionMap = {} + for (const id of Object.keys(nodes)) out[id] = boxOf(nodes, positions, id) + + const byParent = childrenByParent(nodes) + const parentIds = [...byParent.keys()].sort((a, b) => depthOf(nodes, b) - depthOf(nodes, a)) + + for (const pid of parentIds) { + const childIds = byParent.get(pid)! + const boxes = childIds.map((id) => out[id] as Box) + if (separate) separateBoxes(boxes, CHILD_GAP) + + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity + for (const b of boxes) { + minX = Math.min(minX, b.x) + minY = Math.min(minY, b.y) + maxX = Math.max(maxX, b.x + b.width) + maxY = Math.max(maxY, b.y + b.height) + } + const parent = nodes[pid] + const pad = compoundPadding(parent.type) + const dx = pad.side - minX + const dy = pad.top - minY + for (const b of boxes) { b.x += dx; b.y += dy } + + const min = NODE_SIZES[parent.type] ?? { width: 0, height: 0 } + const p = out[pid] as Box + p.width = Math.max(maxX - minX + 2 * pad.side, min.width) + p.height = Math.max(maxY - minY + pad.top + pad.bottom, min.height) + } + + if (separate) { + const roots = Object.values(nodes).filter((n) => !n.parentId || !nodes[n.parentId]).map((n) => out[n.id] as Box) + separateBoxes(roots, ROOT_GAP) + } + return out +} diff --git a/src/renderer/src/layout/radicalLayout.ts b/src/renderer/src/layout/radicalLayout.ts index 9a41c65e..857b582f 100644 --- a/src/renderer/src/layout/radicalLayout.ts +++ b/src/renderer/src/layout/radicalLayout.ts @@ -2,33 +2,45 @@ * Radical Layout — C4-semantic layered layout algorithm. * * Produces human-quality layouts by understanding C4 model semantics: - * 1. Components stack vertically inside containers (topo-sorted) - * 2. Containers left-to-right inside systems (topo-sorted by data flow) - * 3. Internal systems layered by dependency depth from user entry points - * 4. Persons centered above their primary containers - * 5. External systems in a right column, ordered by calling container - * 6. Downstream systems centered below their upstream sources + * 1. Every compound is laid out bottom-up. Leaf children of a container + * stack in a column (two columns past three); any other children run + * in a row ordered by data flow, wrapping when there are many. + * 2. Internal root nodes are layered by dependency depth from the user + * entry points. Cycles are broken first, from the entry points, so a + * call-back never pushes the entry system below the one it calls. + * 3. Persons centred above what they use. + * 4. External nodes in a right column, aligned with their callers. + * 5. Downstream nodes centred below their upstream sources. * * This is NOT a generic graph layout — it understands C4 hierarchy. */ -import { C4Node, C4Relation, PositionMap, NODE_SIZES, COLLAPSED_HEIGHT, COLLAPSED_WIDTH } from '../types/c4' -import { effectiveWidth, effectiveHeight, isVisible } from './geometry' +import { C4Node, C4Relation, PositionMap, NODE_SIZES } from '../types/c4' +import { effectiveWidth, effectiveHeight, compoundPadding, projectToVisibleGraph } from './geometry' // ─── Configuration ─────────────────────────────────────────────────────────── -const COMPOUND_PAD_TOP = 70 -const COMPOUND_PAD_SIDE = 20 -const COMPOUND_PAD_BOTTOM = 20 -const COMPONENT_GAP = 20 // vertical gap between stacked components -const CONTAINER_GAP = 20 // horizontal gap between containers -const SYSTEM_GAP = 40 // horizontal gap between systems in the same layer +const CHILD_GAP = 20 // gap between siblings inside a compound +const SYSTEM_GAP = 40 // horizontal gap between root nodes in the same layer const PERSON_GAP = 40 // horizontal gap between persons const PERSON_SYS_GAP = 40 // vertical gap: person row → first system layer const SYSTEM_LAYER_GAP = 60 // vertical gap between system layers const EXTERNAL_COL_GAP = 60 // gap: right edge of internal layout → external column -const EXTERNAL_ROW_GAP = 40 // vertical gap between external systems +const EXTERNAL_ROW_GAP = 40 // vertical gap between external nodes const GRID_SIZE = 20 // snap unit +/** Leaf children of a container stay in one column up to this many. */ +const MAX_SINGLE_COLUMN = 3 +/** Other children stay in one row up to this many, then wrap into a grid. */ +const MAX_SINGLE_ROW = 4 + +type Dag = Map> + +interface Ctx { + nodes: Record + relations: C4Relation[] + children: Map + result: PositionMap +} // ─── Helpers ───────────────────────────────────────────────────────────────── @@ -36,77 +48,69 @@ function snap(v: number): number { return Math.round(v / GRID_SIZE) * GRID_SIZE } -function getVisibleChildren( - parentId: string | undefined, - nodes: Record -): C4Node[] { - return Object.values(nodes).filter( - (n) => n.parentId === parentId && isVisible(n, nodes) - ) +/** The root-level ancestor of a node (the node itself when it is a root). */ +function rootOf(id: string, nodes: Record): string { + let cur = nodes[id] + while (cur?.parentId && nodes[cur.parentId]) cur = nodes[cur.parentId] + return cur?.id ?? id } -/** Walk up parent chain to root ancestor */ -function rootAncestor(id: string, allNodes: Record): string { - const n = allNodes[id] - if (!n?.parentId) return id - return rootAncestor(n.parentId, allNodes) +/** The ancestor-or-self of `id` that is a direct child of `parentId`. */ +function childUnder(id: string, parentId: string, nodes: Record): string | undefined { + let cur: C4Node | undefined = nodes[id] + while (cur) { + if (cur.parentId === parentId) return cur.id + cur = cur.parentId ? nodes[cur.parentId] : undefined + } + return undefined } -/** Find the nearest container ancestor (or the node itself if it's a container) */ -function containerAncestor( - id: string, - allNodes: Record -): string | null { - const n = allNodes[id] - if (!n) return null - if (n.type === 'container') return id - if (n.parentId) { - const parent = allNodes[n.parentId] - if (parent?.type === 'container') return parent.id - return containerAncestor(n.parentId, allNodes) - } - return null +/** + * What a relation endpoint visually attaches to at root level: the + * top-level block inside its root (e.g. the container of a component), or + * the root itself. + */ +function anchorOf(id: string, nodes: Record): string { + const root = rootOf(id, nodes) + if (id === root) return id + return childUnder(id, root, nodes) ?? root } -/** Compute absolute coordinate by walking up the parent chain */ -function absolutePos( - id: string, - axis: 'x' | 'y', - allNodes: Record, - result: PositionMap -): number | null { - const pos = result[id] - if (!pos) return null - let val = pos[axis] - let current = allNodes[id] - while (current?.parentId) { - const parentPos = result[current.parentId] - if (!parentPos) break - val += parentPos[axis] - current = allNodes[current.parentId] +/** Absolute coordinate, walking up the parent chain. Null if any link is unplaced. */ +function absolutePos(id: string, axis: 'x' | 'y', nodes: Record, result: PositionMap): number | null { + let val = 0 + let cur: C4Node | undefined = nodes[id] + while (cur) { + const pos = result[cur.id] + if (!pos) return null + val += pos[axis] + cur = cur.parentId ? nodes[cur.parentId] : undefined } return val } -// ─── Topological Sort (Kahn's algorithm) ───────────────────────────────────── +function sizeOf(id: string, ctx: Ctx): { width: number; height: number } { + const p = ctx.result[id] + const n = ctx.nodes[id] + return { width: p?.width ?? effectiveWidth(n), height: p?.height ?? effectiveHeight(n) } +} + +// ─── DAG utilities ─────────────────────────────────────────────────────────── -function topoSort(dag: Map>): string[] { +/** Kahn's algorithm with deterministic tie-breaking by id. */ +function topoSort(dag: Dag): string[] { const inDeg = new Map() for (const [id] of dag) inDeg.set(id, 0) for (const [, targets] of dag) { - for (const t of targets) { - inDeg.set(t, (inDeg.get(t) ?? 0) + 1) - } + for (const t of targets) inDeg.set(t, (inDeg.get(t) ?? 0) + 1) } const queue: string[] = [] - for (const [id, deg] of inDeg) { - if (deg === 0) queue.push(id) - } + for (const [id, deg] of inDeg) if (deg === 0) queue.push(id) const ordered: string[] = [] while (queue.length > 0) { - queue.sort() // deterministic tie-breaking by ID + queue.sort() const id = queue.shift()! ordered.push(id) for (const t of dag.get(id) ?? []) { @@ -116,325 +120,107 @@ function topoSort(dag: Map>): string[] { } } - // Handle cycles: append remaining nodes + // Only reachable with a cyclic input — keep every node anyway. const placed = new Set(ordered) - for (const [id] of dag) { - if (!placed.has(id)) ordered.push(id) - } + for (const [id] of dag) if (!placed.has(id)) ordered.push(id) return ordered } -// ─── Phase 1: Bottom-up inner layout ───────────────────────────────────────── - -/** - * Stack children inside a container: - * - If ≤3 children: vertical column - * - If >3 children: 2-column grid (left-to-right, top-to-bottom) - * Topo-sorted by internal edges. - */ -function layoutComponents( - containerId: string, - allNodes: Record, - allRelations: Record, - result: PositionMap -): void { - const children = getVisibleChildren(containerId, allNodes) - if (children.length === 0) return - - // Build internal DAG (edges between siblings in the same container) - const childSet = new Set(children.map((c) => c.id)) - const dag = new Map>() - for (const c of children) dag.set(c.id, new Set()) - - for (const rel of Object.values(allRelations)) { - if (childSet.has(rel.sourceId) && childSet.has(rel.targetId)) { - dag.get(rel.sourceId)!.add(rel.targetId) - } - } - - const order = topoSort(dag) - - const compW = NODE_SIZES.component.width - const compH = NODE_SIZES.component.height - - const cols = order.length > 3 ? 2 : 1 - - for (let i = 0; i < order.length; i++) { - const col = i % cols - const row = Math.floor(i / cols) - result[order[i]] = { - x: COMPOUND_PAD_SIDE + col * (compW + COMPONENT_GAP), - y: COMPOUND_PAD_TOP + row * (compH + COMPONENT_GAP), - width: compW, - height: compH, - } - } -} - -/** Compute container size from its laid-out children. */ -function sizeContainer( - containerId: string, - allNodes: Record, - result: PositionMap -): void { - const container = allNodes[containerId] - if (!container) return - - if (container.collapsed) { - result[containerId] = { - x: result[containerId]?.x ?? 0, - y: result[containerId]?.y ?? 0, - width: COLLAPSED_WIDTH.container, - height: COLLAPSED_HEIGHT.container, - } - return - } - - const children = Object.values(allNodes).filter( - (n) => n.parentId === containerId && result[n.id] - ) - - if (children.length === 0) { - result[containerId] = { - x: result[containerId]?.x ?? 0, - y: result[containerId]?.y ?? 0, - width: NODE_SIZES.container.width, - height: NODE_SIZES.container.height, - } - return - } - - let maxRight = 0, - maxBottom = 0 - for (const child of children) { - const pos = result[child.id] - maxRight = Math.max(maxRight, pos.x + (pos.width ?? effectiveWidth(child))) - maxBottom = Math.max( - maxBottom, - pos.y + (pos.height ?? effectiveHeight(child)) - ) - } - - result[containerId] = { - x: result[containerId]?.x ?? 0, - y: result[containerId]?.y ?? 0, - width: Math.max(maxRight + COMPOUND_PAD_SIDE, NODE_SIZES.container.width), - height: Math.max( - maxBottom + COMPOUND_PAD_BOTTOM, - NODE_SIZES.container.height - ), - } -} - /** - * Layout containers inside a system: - * 1. Layout components in each container (vertical stack) - * 2. Size each container - * 3. Topo-sort containers by cross-container data flow - * 4. Place containers in a horizontal row - * 5. Size the system + * Drop the edges that close a cycle, found by depth-first search from + * `starts` first, then from every other node by id. Searching from the + * entry points keeps the natural direction: with A↔B and users entering at + * A, it is B→A that gets dropped. */ -function layoutContainersInSystem( - systemId: string, - allNodes: Record, - allRelations: Record, - result: PositionMap -): void { - const system = allNodes[systemId] - if (!system) return - - if (system.collapsed) { - result[systemId] = { - x: result[systemId]?.x ?? 0, - y: result[systemId]?.y ?? 0, - width: COLLAPSED_WIDTH[system.type] ?? effectiveWidth(system), - height: COLLAPSED_HEIGHT[system.type] ?? effectiveHeight(system), - } - return - } - - const containers = getVisibleChildren(systemId, allNodes).filter( - (n) => n.type === 'container' - ) - - if (containers.length === 0) { - // Leaf system / non-system root node — keep its own size - result[systemId] = { - x: result[systemId]?.x ?? 0, - y: result[systemId]?.y ?? 0, - width: effectiveWidth(system), - height: effectiveHeight(system), - } - return - } - - // Layout components inside each container, then size it - for (const ctn of containers) { - layoutComponents(ctn.id, allNodes, allRelations, result) - sizeContainer(ctn.id, allNodes, result) - } - - // Build container-level DAG from cross-container relations - const containerIds = new Set(containers.map((c) => c.id)) - const nodeToContainer = new Map() - for (const ctn of containers) { - nodeToContainer.set(ctn.id, ctn.id) - for (const child of getVisibleChildren(ctn.id, allNodes)) { - nodeToContainer.set(child.id, ctn.id) - } - } - - const dag = new Map>() - for (const ctn of containers) dag.set(ctn.id, new Set()) - - for (const rel of Object.values(allRelations)) { - const srcCtn = nodeToContainer.get(rel.sourceId) - const tgtCtn = nodeToContainer.get(rel.targetId) - if ( - srcCtn && - tgtCtn && - srcCtn !== tgtCtn && - containerIds.has(srcCtn) && - containerIds.has(tgtCtn) - ) { - dag.get(srcCtn)!.add(tgtCtn) - } - } - - const order = topoSort(dag) - - // Place containers in a horizontal row - let x = COMPOUND_PAD_SIDE - for (const ctnId of order) { - const pos = result[ctnId] - if (pos) { - pos.x = x - pos.y = COMPOUND_PAD_TOP +function breakCycles(dag: Dag, starts: string[]): Dag { + const out: Dag = new Map() + for (const [id] of dag) out.set(id, new Set()) + const state = new Map() + const visit = (id: string): void => { + state.set(id, 'active') + for (const t of [...(dag.get(id) ?? [])].sort()) { + const st = state.get(t) + if (st === 'active') continue + out.get(id)!.add(t) + if (st === undefined) visit(t) } - x += (result[ctnId]?.width ?? NODE_SIZES.container.width) + CONTAINER_GAP + state.set(id, 'done') } - - // Size the system to fit its containers - let maxRight = 0, - maxBottom = 0 - for (const ctnId of order) { - const pos = result[ctnId] - if (!pos) continue - maxRight = Math.max( - maxRight, - pos.x + (pos.width ?? NODE_SIZES.container.width) - ) - maxBottom = Math.max( - maxBottom, - pos.y + (pos.height ?? NODE_SIZES.container.height) - ) - } - - result[systemId] = { - x: result[systemId]?.x ?? 0, - y: result[systemId]?.y ?? 0, - width: Math.max(maxRight + COMPOUND_PAD_SIDE, NODE_SIZES.system.width), - height: Math.max(maxBottom + COMPOUND_PAD_BOTTOM, NODE_SIZES.system.height), + for (const id of [...starts, ...[...dag.keys()].sort()]) { + if (dag.has(id) && !state.has(id)) visit(id) } + return out } -// ─── Phase 2: Root-level classification and layering ───────────────────────── - -function classifyRootNodes(allNodes: Record): { - persons: C4Node[] - internalNodes: C4Node[] - externalNodes: C4Node[] -} { - const persons: C4Node[] = [] - const internalNodes: C4Node[] = [] - const externalNodes: C4Node[] = [] - - for (const n of Object.values(allNodes)) { - if (n.parentId) continue - if (n.external) { - externalNodes.push(n) - } else if (n.type === 'person') { - persons.push(n) - } else { - internalNodes.push(n) - } +/** Longest-path layer of every node of an acyclic DAG (sources at 0). */ +function longestPathLayers(dag: Dag): Map { + const layers = new Map() + for (const id of topoSort(dag)) { + const l = layers.get(id) ?? 0 + layers.set(id, l) + for (const t of dag.get(id) ?? []) layers.set(t, Math.max(layers.get(t) ?? 0, l + 1)) } - - return { persons, internalNodes, externalNodes } + return layers } -/** Build root-level DAG by collapsing all relations to root ancestors */ -function buildRootDAG( - allNodes: Record, - allRelations: Record -): Map> { - const rootIds = new Set( - Object.values(allNodes) - .filter((n) => !n.parentId) - .map((n) => n.id) - ) - - const dag = new Map>() - for (const id of rootIds) dag.set(id, new Set()) - - for (const rel of Object.values(allRelations)) { - const srcRoot = rootAncestor(rel.sourceId, allNodes) - const tgtRoot = rootAncestor(rel.targetId, allNodes) - if (srcRoot !== tgtRoot && rootIds.has(srcRoot) && rootIds.has(tgtRoot)) { - dag.get(srcRoot)!.add(tgtRoot) - } - } - - return dag -} +// ─── Phase 1: Bottom-up compound layout ────────────────────────────────────── /** - * Assign layers to internal nodes using longest-path from persons. - * Ensures downstream systems are placed below upstream ones even when - * a person connects directly to both. + * Lay out the subtree under `id`, writing children positions (relative to + * their parent) and every compound's size into `ctx.result`. */ -function assignLayers( - persons: C4Node[], - internalNodes: C4Node[], - rootDAG: Map> -): Map { - const layers = new Map() - const internalIds = new Set(internalNodes.map((s) => s.id)) - - // Initialise: internal nodes reachable from persons → layer 0 - for (const p of persons) { - for (const target of rootDAG.get(p.id) ?? []) { - if (internalIds.has(target)) { - layers.set(target, Math.max(layers.get(target) ?? 0, 0)) - } - } - } - - // Bellman-Ford relaxation: propagate longest path through internal nodes - let changed = true - for (let iter = 0; iter < internalNodes.length + 1 && changed; iter++) { - changed = false - for (const [srcId, targets] of rootDAG) { - if (!internalIds.has(srcId)) continue - const srcLayer = layers.get(srcId) - if (srcLayer === undefined) continue - for (const tgtId of targets) { - if (!internalIds.has(tgtId)) continue - const newLayer = srcLayer + 1 - if (newLayer > (layers.get(tgtId) ?? -1)) { - layers.set(tgtId, newLayer) - changed = true - } - } - } +function layoutCompound(id: string, ctx: Ctx): void { + const node = ctx.nodes[id] + const kids = ctx.children.get(id) ?? [] + if (kids.length === 0) { + ctx.result[id] = { x: 0, y: 0, width: effectiveWidth(node), height: effectiveHeight(node) } + return } - - // Unreachable internal nodes → layer 0 - for (const n of internalNodes) { - if (!layers.has(n.id)) layers.set(n.id, 0) + for (const k of kids) layoutCompound(k.id, ctx) + + // Order children by data flow between them (relations of any descendant + // count for the child that contains it). + const dag: Dag = new Map(kids.map((k) => [k.id, new Set()])) + for (const r of ctx.relations) { + const a = childUnder(r.sourceId, id, ctx.nodes) + const b = childUnder(r.targetId, id, ctx.nodes) + if (a && b && a !== b) dag.get(a)!.add(b) + } + const order = topoSort(breakCycles(dag, [])) + + const allLeaves = kids.every((k) => !ctx.children.has(k.id)) + const cols = node.type === 'container' && allLeaves + ? (order.length > MAX_SINGLE_COLUMN ? 2 : 1) + : (order.length > MAX_SINGLE_ROW ? Math.ceil(Math.sqrt(order.length * 1.6)) : order.length) + const rows = Math.ceil(order.length / cols) + + const colW = new Array(cols).fill(0) + const rowH = new Array(rows).fill(0) + order.forEach((cid, i) => { + const s = sizeOf(cid, ctx) + colW[i % cols] = Math.max(colW[i % cols], s.width) + rowH[Math.floor(i / cols)] = Math.max(rowH[Math.floor(i / cols)], s.height) + }) + + const pad = compoundPadding(node.type) + const colX: number[] = [] + const rowY: number[] = [] + for (let c = 0; c < cols; c++) colX.push(c === 0 ? pad.side : colX[c - 1] + colW[c - 1] + CHILD_GAP) + for (let r = 0; r < rows; r++) rowY.push(r === 0 ? pad.top : rowY[r - 1] + rowH[r - 1] + CHILD_GAP) + + order.forEach((cid, i) => { + const p = ctx.result[cid] + p.x = colX[i % cols] + p.y = rowY[Math.floor(i / cols)] + }) + + const min = NODE_SIZES[node.type] ?? { width: 0, height: 0 } + ctx.result[id] = { + x: 0, + y: 0, + width: Math.max(colX[cols - 1] + colW[cols - 1] + pad.side, min.width), + height: Math.max(rowY[rows - 1] + rowH[rows - 1] + pad.bottom, min.height), } - - return layers } // ─── Barycenter sort (Sugiyama crossing-reduction) ─────────────────────────── @@ -450,15 +236,14 @@ function assignLayers( */ function barycenterSort( layerGroups: Map, - rootDAG: Map>, + rootDAG: Dag, iterations: number = 4 ): void { if (layerGroups.size === 0) return const maxLayer = Math.max(...Array.from(layerGroups.keys())) if (maxLayer < 1) return // nothing to reorder against - // Build reverse DAG once - const reverseDAG = new Map>() + const reverseDAG: Dag = new Map() for (const [src, targets] of rootDAG) { if (!reverseDAG.has(src)) reverseDAG.set(src, new Set()) for (const t of targets) { @@ -467,13 +252,10 @@ function barycenterSort( } } - // Position index per node (0-based within its layer) const posOf = new Map() - for (const [, nodes] of layerGroups) { - nodes.forEach((n, i) => posOf.set(n.id, i)) - } + for (const [, nodes] of layerGroups) nodes.forEach((n, i) => posOf.set(n.id, i)) - const sortLayer = (L: number, neighborsOf: Map>): boolean => { + const sortLayer = (L: number, neighborsOf: Dag): boolean => { const nodes = layerGroups.get(L) if (!nodes || nodes.length < 2) return false const scored = nodes.map((n) => { @@ -487,7 +269,6 @@ function barycenterSort( } return { n, bc: count > 0 ? sum / count : posOf.get(n.id) ?? 0 } }) - // Stable sort with current position as tie-breaker scored.sort((a, b) => { if (a.bc !== b.bc) return a.bc - b.bc return (posOf.get(a.n.id) ?? 0) - (posOf.get(b.n.id) ?? 0) @@ -504,294 +285,184 @@ function barycenterSort( for (let iter = 0; iter < iterations; iter++) { let anyChange = false - // Down sweep: re-order each layer using upstream (parents in reverseDAG) - for (let L = 1; L <= maxLayer; L++) { - if (sortLayer(L, reverseDAG)) anyChange = true - } - // Up sweep: re-order each layer using downstream (children in rootDAG) - for (let L = maxLayer - 1; L >= 0; L--) { - if (sortLayer(L, rootDAG)) anyChange = true - } + for (let L = 1; L <= maxLayer; L++) if (sortLayer(L, reverseDAG)) anyChange = true + for (let L = maxLayer - 1; L >= 0; L--) if (sortLayer(L, rootDAG)) anyChange = true if (!anyChange) break } } -// ─── Phase 3: Coordinate assignment ────────────────────────────────────────── +// ─── Phase 2: Root placement ───────────────────────────────────────────────── export function applyRadicalLayout( - c4Nodes: Record, - c4Relations: Record + inputNodes: Record, + inputRelations: Record ): PositionMap { + const { nodes, relations: relationMap } = projectToVisibleGraph(inputNodes, inputRelations) + const relations = Object.values(relationMap) + const children = new Map() + for (const n of Object.values(nodes)) { + if (!n.parentId || !nodes[n.parentId]) continue + const list = children.get(n.parentId) + if (list) list.push(n) + else children.set(n.parentId, [n]) + } const result: PositionMap = {} + const ctx: Ctx = { nodes, relations, children, result } - // ── Classify root nodes ──────────────────────────────────────────────── - const { persons, internalNodes, externalNodes } = classifyRootNodes(c4Nodes) + const roots = Object.values(nodes).filter((n) => !n.parentId || !nodes[n.parentId]) + for (const r of roots) layoutCompound(r.id, ctx) - // ── Bottom-up layout of internal nodes ───────────────────────────────── - for (const node of internalNodes) { - if (node.type === 'system' && !node.collapsed) { - layoutContainersInSystem(node.id, c4Nodes, c4Relations, result) - } else if (node.type === 'container' && !node.collapsed) { - layoutComponents(node.id, c4Nodes, c4Relations, result) - sizeContainer(node.id, c4Nodes, result) - } else { - result[node.id] = { - x: 0, - y: 0, - width: effectiveWidth(node), - height: effectiveHeight(node), - } - } + const persons = roots.filter((n) => n.type === 'person' && !n.external) + const externalNodes = roots.filter((n) => n.external) + const internalNodes = roots.filter((n) => !n.external && n.type !== 'person') + + // ── Root-level DAG ───────────────────────────────────────────────────── + const rootDAG: Dag = new Map(roots.map((n) => [n.id, new Set()])) + for (const rel of relations) { + const s = rootOf(rel.sourceId, nodes) + const t = rootOf(rel.targetId, nodes) + if (s !== t) rootDAG.get(s)?.add(t) } - // ── Layer assignment ─────────────────────────────────────────────────── - const rootDAG = buildRootDAG(c4Nodes, c4Relations) - const nodeLayers = assignLayers(persons, internalNodes, rootDAG) + // ── Layer assignment (internal nodes only) ───────────────────────────── + const internalIds = new Set(internalNodes.map((n) => n.id)) + const internalDAG: Dag = new Map() + for (const id of internalIds) { + internalDAG.set(id, new Set([...rootDAG.get(id)!].filter((t) => internalIds.has(t)))) + } + const entries = [...new Set(persons.flatMap((p) => [...rootDAG.get(p.id)!].filter((t) => internalIds.has(t))))].sort() + const nodeLayers = longestPathLayers(breakCycles(internalDAG, entries)) - // Group internal nodes by layer const layerGroups = new Map() for (const node of internalNodes) { const layer = nodeLayers.get(node.id) ?? 0 if (!layerGroups.has(layer)) layerGroups.set(layer, []) layerGroups.get(layer)!.push(node) } - - // ── Barycenter sweep: reorder each layer to minimise crossings ───────── - // Runs before X-placement so layer 0 (entry systems) gets ordered by - // who they call, and downstream layers get ordered by who calls them. barycenterSort(layerGroups, rootDAG) // ── Place internal nodes layer by layer ──────────────────────────────── - const personRowH = persons.length > 0 ? NODE_SIZES.person.height : 0 + const personRowH = persons.reduce((m, p) => Math.max(m, sizeOf(p.id, ctx).height), 0) let currentY = personRowH > 0 ? personRowH + PERSON_SYS_GAP : 0 - - const maxLayer = - layerGroups.size > 0 - ? Math.max(...Array.from(layerGroups.keys())) - : -1 + const placedRoots = new Set() + const maxLayer = layerGroups.size > 0 ? Math.max(...Array.from(layerGroups.keys())) : -1 for (let layer = 0; layer <= maxLayer; layer++) { const systems = layerGroups.get(layer) ?? [] if (systems.length === 0) continue if (layer === 0) { - // First layer: place in a row starting at x = 0 let x = 0 for (const sys of systems) { - if (result[sys.id]) { - result[sys.id].x = snap(x) - result[sys.id].y = snap(currentY) - } - x += (result[sys.id]?.width ?? effectiveWidth(sys)) + SYSTEM_GAP + result[sys.id].x = snap(x) + result[sys.id].y = snap(currentY) + x += sizeOf(sys.id, ctx).width + SYSTEM_GAP } } else { - // Downstream layers: center below upstream sources, then resolve overlaps - const layerSystems = layerGroups.get(layer)! - - for (const sys of layerSystems) { + // Centre below already-placed upstream sources, then resolve overlaps. + for (const sys of systems) { const sourceCentersX: number[] = [] - for (const rel of Object.values(c4Relations)) { - const srcRoot = rootAncestor(rel.sourceId, c4Nodes) - const tgtRoot = rootAncestor(rel.targetId, c4Nodes) - if (tgtRoot === sys.id && srcRoot !== sys.id) { - const srcCtn = - containerAncestor(rel.sourceId, c4Nodes) ?? rel.sourceId - const absX = absolutePos(srcCtn, 'x', c4Nodes, result) - const w = result[srcCtn]?.width ?? NODE_SIZES.container.width - if (absX !== null) sourceCentersX.push(absX + w / 2) - } - } - - const sysW = result[sys.id]?.width ?? effectiveWidth(sys) - let sysX: number - if (sourceCentersX.length > 0) { - const avgX = - sourceCentersX.reduce((a, b) => a + b, 0) / - sourceCentersX.length - sysX = avgX - sysW / 2 - } else { - sysX = 0 - } - - if (result[sys.id]) { - result[sys.id].x = snap(Math.max(0, sysX)) - result[sys.id].y = snap(currentY) + for (const rel of relations) { + if (rootOf(rel.targetId, nodes) !== sys.id) continue + const srcRoot = rootOf(rel.sourceId, nodes) + if (srcRoot === sys.id || !placedRoots.has(srcRoot)) continue + const anchor = anchorOf(rel.sourceId, nodes) + const absX = absolutePos(anchor, 'x', nodes, result) + if (absX !== null) sourceCentersX.push(absX + sizeOf(anchor, ctx).width / 2) } + const sysW = sizeOf(sys.id, ctx).width + const sysX = sourceCentersX.length > 0 + ? sourceCentersX.reduce((a, b) => a + b, 0) / sourceCentersX.length - sysW / 2 + : 0 + result[sys.id].x = snap(Math.max(0, sysX)) + result[sys.id].y = snap(currentY) } - // Resolve horizontal overlaps within this layer - const sorted = [...layerSystems].sort( - (a, b) => (result[a.id]?.x ?? 0) - (result[b.id]?.x ?? 0) - ) + const sorted = [...systems].sort((a, b) => result[a.id].x - result[b.id].x) for (let i = 1; i < sorted.length; i++) { const prev = result[sorted[i - 1].id] const cur = result[sorted[i].id] - if (!prev || !cur) continue - const prevRight = prev.x + (prev.width ?? effectiveWidth(sorted[i - 1])) - const minX = prevRight + SYSTEM_GAP + const minX = prev.x + sizeOf(sorted[i - 1].id, ctx).width + SYSTEM_GAP if (cur.x < minX) cur.x = snap(minX) } } - // Advance Y past the tallest node in this layer - let maxH = 0 - for (const sys of systems) { - maxH = Math.max( - maxH, - result[sys.id]?.height ?? effectiveHeight(sys) - ) - } + for (const sys of systems) placedRoots.add(sys.id) + const maxH = systems.reduce((m, s) => Math.max(m, sizeOf(s.id, ctx).height), 0) currentY += maxH + SYSTEM_LAYER_GAP } - // ── Place persons centered above their target containers ─────────────── + // ── Persons centred above what they use ──────────────────────────────── if (persons.length > 0) { - const personW = NODE_SIZES.person.width - const personH = NODE_SIZES.person.height - - type PP = { person: C4Node; targetX: number } - const pps: PP[] = [] - + const pps: { person: C4Node; targetX: number }[] = [] for (const p of persons) { - const weights = new Map() - for (const rel of Object.values(c4Relations)) { - let targetId: string | null = null - if (rel.sourceId === p.id) targetId = rel.targetId - else if (rel.targetId === p.id) targetId = rel.sourceId - else continue - - const ctn = containerAncestor(targetId, c4Nodes) - const key = ctn ?? rootAncestor(targetId, c4Nodes) - weights.set(key, (weights.get(key) ?? 0) + 1) - } - - if (weights.size > 0) { - let sumX = 0, - totalW = 0 - for (const [keyId, w] of weights) { - const absX = absolutePos(keyId, 'x', c4Nodes, result) - const nodeW = - result[keyId]?.width ?? - (c4Nodes[keyId] ? effectiveWidth(c4Nodes[keyId]) : 200) - if (absX !== null) { - sumX += (absX + nodeW / 2) * w - totalW += w - } - } - pps.push({ - person: p, - targetX: totalW > 0 ? sumX / totalW - personW / 2 : 0, - }) - } else { - pps.push({ person: p, targetX: 0 }) + const personW = sizeOf(p.id, ctx).width + let sumX = 0, count = 0 + for (const rel of relations) { + const other = rel.sourceId === p.id ? rel.targetId : rel.targetId === p.id ? rel.sourceId : null + if (!other || !placedRoots.has(rootOf(other, nodes))) continue + const anchor = anchorOf(other, nodes) + const absX = absolutePos(anchor, 'x', nodes, result) + if (absX === null) continue + sumX += absX + sizeOf(anchor, ctx).width / 2 + count++ } + pps.push({ person: p, targetX: count > 0 ? sumX / count - personW / 2 : 0 }) } - // Sort left-to-right by ideal X pps.sort((a, b) => a.targetX - b.targetX) - - // Place at y = 0 for (const pp of pps) { - result[pp.person.id] = { - x: snap(pp.targetX), - y: 0, - width: personW, - height: personH, - } + result[pp.person.id].x = snap(pp.targetX) + result[pp.person.id].y = 0 } - - // Resolve horizontal overlaps: push rightward for (let i = 1; i < pps.length; i++) { const prev = result[pps[i - 1].person.id] const cur = result[pps[i].person.id] - const minX = prev.x + personW + PERSON_GAP + const minX = prev.x + sizeOf(pps[i - 1].person.id, ctx).width + PERSON_GAP if (cur.x < minX) cur.x = snap(minX) } + for (const pp of pps) placedRoots.add(pp.person.id) } - // ── Place external nodes in a right column ───────────────────────────── + // ── External nodes in a right column ─────────────────────────────────── if (externalNodes.length > 0) { - // Find rightmost edge of internal layout let maxRight = 0 - for (const id of Object.keys(result)) { - const n = c4Nodes[id] - if (!n || n.parentId) continue - const pos = result[id] - maxRight = Math.max( - maxRight, - pos.x + (pos.width ?? effectiveWidth(n)) - ) - } - + for (const id of placedRoots) maxRight = Math.max(maxRight, result[id].x + sizeOf(id, ctx).width) const extX = snap(maxRight + EXTERNAL_COL_GAP) - // Order externals by calling node's absolute Y (align vertically with callers) - type ES = { node: C4Node; callerAbsY: number } - const scored: ES[] = [] - - for (const ext of externalNodes) { - let sumY = 0 - let count = 0 - - for (const rel of Object.values(c4Relations)) { - let callerId: string | null = null - if (rel.targetId === ext.id) callerId = rel.sourceId - else if (rel.sourceId === ext.id) callerId = rel.targetId - else continue - - // Find the best ancestor to align with - const ctn = containerAncestor(callerId, c4Nodes) - const alignId = ctn ?? rootAncestor(callerId, c4Nodes) - const absY = absolutePos(alignId, 'y', c4Nodes, result) - const absH = result[alignId]?.height ?? NODE_SIZES.container.height - if (absY !== null) { - sumY += absY + absH / 2 - count++ - } + const scored = externalNodes.map((ext) => { + let sumY = 0, count = 0 + for (const rel of relations) { + const other = rel.targetId === ext.id ? rel.sourceId : rel.sourceId === ext.id ? rel.targetId : null + if (!other || !placedRoots.has(rootOf(other, nodes))) continue + const anchor = anchorOf(other, nodes) + const absY = absolutePos(anchor, 'y', nodes, result) + if (absY === null) continue + sumY += absY + sizeOf(anchor, ctx).height / 2 + count++ } - - scored.push({ - node: ext, - callerAbsY: count > 0 ? sumY / count : Infinity, - }) - } - + return { node: ext, callerAbsY: count > 0 ? sumY / count : Infinity } + }) scored.sort((a, b) => a.callerAbsY - b.callerAbsY) - const extW = 240 - const extH = 100 - for (const s of scored) { - // Align external node's center-Y with the weighted average of its callers - let targetY = s.callerAbsY - extH / 2 - if (!isFinite(targetY)) targetY = 0 - - result[s.node.id] = { - x: extX, - y: snap(Math.max(0, targetY)), - width: extW, - height: extH, - } + const h = sizeOf(s.node.id, ctx).height + const targetY = isFinite(s.callerAbsY) ? s.callerAbsY - h / 2 : 0 + result[s.node.id].x = extX + result[s.node.id].y = snap(Math.max(0, targetY)) } - - // Resolve vertical overlaps among externals - const sortedExts = scored.map((s) => s.node.id) - for (let i = 1; i < sortedExts.length; i++) { - const prev = result[sortedExts[i - 1]] - const cur = result[sortedExts[i]] - if (!prev || !cur) continue - const prevBottom = prev.y + (prev.height ?? extH) - const minY = prevBottom + EXTERNAL_ROW_GAP + for (let i = 1; i < scored.length; i++) { + const prev = result[scored[i - 1].node.id] + const cur = result[scored[i].node.id] + const minY = prev.y + sizeOf(scored[i - 1].node.id, ctx).height + EXTERNAL_ROW_GAP if (cur.y < minY) cur.y = snap(minY) } } // ── Final grid snap ──────────────────────────────────────────────────── - for (const id of Object.keys(result)) { - const p = result[id] + for (const p of Object.values(result)) { p.x = snap(p.x) p.y = snap(p.y) } - return result } diff --git a/src/renderer/src/layout/scoreWeights.ts b/src/renderer/src/layout/scoreWeights.ts new file mode 100644 index 00000000..836ed86b --- /dev/null +++ b/src/renderer/src/layout/scoreWeights.ts @@ -0,0 +1,56 @@ +/** + * Weights of the Smart Layout aesthetic cost, shared by the full + * render-aware score (smartLayout.ts) and the incremental annealing energy + * (annealing.ts) so the two cannot drift apart. + * + * Tuned so that: + * 1 visible crossing ≈ 80 + * 1 unit node overlap ≈ 150 (plus a quadratic shock) + * 1 long edge ≈ 5 + * bad aspect ratio ≈ 10–40 + * sparse compactness ≈ 0–30 + * → crossings still dominate, but ranking and annealing have *gradient* + * even when the crossing count is locally constant. + * + * Revised after observed failure modes (Person/External-System placed + * centrally with very long edges through the diagram): + * - edgeLengthMean catches uniformly-spread layouts the outlier-only + * edgeLengthExcess couldn't see. + * - leafCentrality pushes degree-1/2 nodes to the periphery so they stop + * sitting between two clusters and crossing everything. + */ + +export const W_CROSS = 80 +export const W_OVERDRAW = 12 +export const W_STUBLOOP = 30 +/** Raised from 50 — overlap used to be cheaper than the edge length it saved. */ +export const W_OVERLAP = 150 +/** edgeLengthExcess (long-tail outliers). */ +export const W_LONG = 5 +/** edgeLengthMean (global tightness). */ +export const W_LMEAN = 25 +/** edgeLengthMax — one edge spanning the whole canvas while the mean looks fine. */ +export const W_LMAX = 30 +export const W_LEAF = 20 +/** Tall (>2:1) layouts are hard to read in any presentation viewport; paired with a cubic exponent. */ +export const W_ASPECT = 30 +export const W_COMPACT = 8 +export const W_SYMMETRY = 4 + +/** Mean edge length (in node sizes) above which the length term kicks in. */ +export const LEN_MEAN_KNEE = 3 +/** Longest edge (in node sizes) above which the max-length term kicks in. */ +export const LEN_MAX_KNEE = 5 +/** Bounding-box aspect ratio the aspect term aims for. */ +export const TARGET_ASPECT = Math.SQRT2 + +/** Overlap cost for `overlap` expressed in mean-node-area units. */ +export function overlapCost(overlap: number): number { + return overlap * W_OVERLAP + overlap * overlap * W_OVERLAP * 5 +} + +export function aspectPenalty(width: number, height: number): number { + if (width <= 0 || height <= 0) return 0 + const ar = width > height ? width / height : height / width + return Math.max(0, ar - TARGET_ASPECT) ** 3 +} diff --git a/src/renderer/src/layout/smartLayout.ts b/src/renderer/src/layout/smartLayout.ts index f0877a96..b8f43ee2 100644 --- a/src/renderer/src/layout/smartLayout.ts +++ b/src/renderer/src/layout/smartLayout.ts @@ -47,33 +47,34 @@ import { applyRadicalLayout } from './radicalLayout' import { minimizeCrossings, computeLayoutMetrics, type LayoutMetrics } from './crossingOpt' import { pickSides } from './portAllocator' import { ELK_ROOT_SPACING, ELK_CHILD_SPACING } from './elkSpacingBase' +import { compoundPadding, projectToVisibleGraph } from './geometry' +import { finalizeLayout, ROOT_GAP, CHILD_GAP } from './layoutFinalize' +import { + anneal, buildLayoutGraph, createRng, graphSeed, FunctionEnergy, ProxyEnergy, + type AnnealOptions, type Bounds, type LayoutGraph, type Rng, +} from './annealing' +import { + W_CROSS, W_OVERDRAW, W_STUBLOOP, W_LONG, W_LMEAN, W_LMAX, W_LEAF, W_ASPECT, W_COMPACT, W_SYMMETRY, + LEN_MEAN_KNEE, LEN_MAX_KNEE, overlapCost, aspectPenalty, +} from './scoreWeights' // ─── Composite aesthetic score ──────────────────────────────────────────── // // Davidson-Harel (1996) cost function adapted to architecture diagrams. -// Six components, each normalised so the weights are commensurable: +// Components, each normalised so the weights (scoreWeights.ts) are +// commensurable: // -// crossings — straight-line edge crossings (oracle from crossingOpt) +// crossings — Bézier-sampled edge crossings, as rendered // overdraws — edge passing through unrelated node bbox -// nodeOverlap — overlap area between sibling pairs / overall, scaled -// to mean node area (catches layouts that visually -// collide even if they have 0 crossings) -// edgeLengthExcess — (edges far longer than the median count more) -// penalises spaghetti where one edge spans the canvas -// aspectPenalty — bounding-box aspect ratio deviation from sqrt(2); -// prevents 5000-px-wide tape layouts that look bad in -// any presentation viewport -// compactness — total area of bounding box / sum of node areas; -// discourages sparseness without coupling to overlap -// -// Weights tuned so that: -// 1 visible crossing ≈ 100 -// 1 unit node overlap ≈ 50 -// 1 long edge ≈ 5 -// bad aspect ratio ≈ 10–40 -// sparse compactness ≈ 0–30 -// → crossings still dominate, but the SA / ranking has *gradient* even -// when the crossing count is locally constant. +// nodeOverlap — overlap area between non-nested pairs, scaled to +// mean node area +// edgeLengthExcess — edges far longer than the median +// edgeLengthMean — mean edge length in node sizes +// edgeLengthMax — longest edge in node sizes +// leafCentrality — low-degree nodes sitting near the centre of mass +// aspectPenalty — bounding-box aspect ratio deviation from sqrt(2) +// compactness — bounding-box area / sum of node areas +// symmetry — distance from a mirror-symmetric arrangement interface CompositeScore { crossings: number @@ -96,34 +97,6 @@ interface CompositeScore { composite: number } -// Empirically-tuned weights, revised after observing real failure modes -// (Person/External-System placed centrally with very long edges through -// the diagram). Two new dominant signals: -// - edgeLengthMean: catches uniformly-spread layouts the old -// edgeLengthExcess (outliers only) couldn't see. -// - leafCentrality: pushes degree-1/2 nodes to the periphery so they -// stop sitting between two clusters and crossing everything. -const W_CROSS = 80 -const W_OVERDRAW = 12 -const W_STUBLOOP = 30 -const W_OVERLAP = 150 // raised from 50 — SA was happy to push leaves *into* - // foreign compound containers if it shortened edges. - // With knee=5 on edgeLengthMax that bias was strong. -const W_LONG = 5 // edgeLengthExcess (long-tail outliers) -const W_LMEAN = 25 // edgeLengthMean (global tightness) -const W_LMAX = 30 // edgeLengthMax (single longest edge in node-sizes) — NEW - // catches diagrams where mean is fine but one edge - // spans the whole canvas (the failure mode in the - // tall-layout screenshot — a few super-long verticals - // between top and bottom system). -const W_LEAF = 20 // leafCentrality -const W_ASPECT = 30 // raised from 10 — tall (>2:1) layouts are very hard - // to read in any presentation viewport. Combined with - // the new cubic exponent below this dominates SA's - // choice between portrait and landscape arrangements. -const W_COMPACT = 8 -const W_SYMMETRY = 4 - function buildAbsCenters( nodes: Record, ): Record { @@ -256,7 +229,12 @@ function computeRenderAwareEdgeMetrics( // 8 was occasionally letting overdraw through. const SAMPLES = 16 // Pre-build all sample polylines + endpoints. - const polys: { samples: { x: number; y: number }[]; sId: string; tId: string }[] = [] + const polys: { + samples: { x: number; y: number }[] + sId: string + tId: string + minX: number; minY: number; maxX: number; maxY: number + }[] = [] let stubLoopPenalty = 0 for (const e of edges) { @@ -289,11 +267,15 @@ function computeRenderAwareEdgeMetrics( if (dotT < 0) stubLoopPenalty += dotT * dotT } - polys.push({ - samples: sampleCubicBezier(sb.x, sb.y, c1x, c1y, c2x, c2y, tb.x, tb.y, SAMPLES), - sId: e.sourceId, - tId: e.targetId, - }) + const samples = sampleCubicBezier(sb.x, sb.y, c1x, c1y, c2x, c2y, tb.x, tb.y, SAMPLES) + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity + for (const pt of samples) { + if (pt.x < minX) minX = pt.x + if (pt.y < minY) minY = pt.y + if (pt.x > maxX) maxX = pt.x + if (pt.y > maxY) maxY = pt.y + } + polys.push({ samples, sId: e.sourceId, tId: e.targetId, minX, minY, maxX, maxY }) } // Crossings on actual sampled polylines. @@ -304,6 +286,7 @@ function computeRenderAwareEdgeMetrics( const B = polys[j] // Edges sharing an endpoint trivially "meet" — don't count those. if (A.sId === B.sId || A.sId === B.tId || A.tId === B.sId || A.tId === B.tId) continue + if (A.maxX < B.minX || B.maxX < A.minX || A.maxY < B.minY || B.maxY < A.minY) continue let crossed = false // Inner double loop — break early once we found one crossing per pair. // (One "crossing" between two edges is the same visual artefact whether @@ -322,25 +305,24 @@ function computeRenderAwareEdgeMetrics( } } - // Overdraws: sample points landing in unrelated bboxes. - // We tolerate the first/last sample (which is on the source/target border). + // Overdraws: sample points landing in unrelated bboxes, counted once per + // (edge, node). The first/last sample sits on the source/target border. + // Ancestors and descendants of either endpoint are not obstacles — the + // renderer excludes them the same way. const ids = Object.keys(abs) let renderedOverdraws = 0 for (const P of polys) { - const seen = new Set() - for (let i = 1; i < P.samples.length - 1; i++) { - const pt = P.samples[i] - for (const id of ids) { - if (id === P.sId || id === P.tId) continue - // Skip ancestors of source/target — the edge is inside a parent box. - if (ancestors[P.sId]?.has(id) || ancestors[P.tId]?.has(id)) continue - if (seen.has(id)) continue - const r = abs[id] - // Skip parent-of-someone if the sample is also in some descendant box; - // we still penalise once per (edge, container) pair to avoid over-counting. + for (const id of ids) { + if (id === P.sId || id === P.tId) continue + if (ancestors[P.sId]?.has(id) || ancestors[P.tId]?.has(id)) continue + if (ancestors[id]?.has(P.sId) || ancestors[id]?.has(P.tId)) continue + const r = abs[id] + if (r.x > P.maxX || r.x + r.w < P.minX || r.y > P.maxY || r.y + r.h < P.minY) continue + for (let i = 1; i < P.samples.length - 1; i++) { + const pt = P.samples[i] if (pointInBox(pt.x, pt.y, r.x, r.y, r.w, r.h, -2)) { renderedOverdraws++ - seen.add(id) + break } } } @@ -354,6 +336,18 @@ export function computeCompositeScore( relations: Record, ): CompositeScore { const base = computeLayoutMetrics(nodes, relations) + return { ...scoreParts(nodes, relations), crossings: base.crossings, overdraws: base.overdraws } +} + +/** Composite cost only — what the polishing annealer minimises. */ +export function compositeEnergy(nodes: Record, relations: Record): number { + return scoreParts(nodes, relations).composite +} + +function scoreParts( + nodes: Record, + relations: Record, +): Omit { const abs = buildAbsCenters(nodes) const ancestors = buildAncestors(nodes) const ids = Object.keys(abs) @@ -410,7 +404,7 @@ export function computeCompositeScore( // Express mean edge length in "node sizes". Below 3 is tight (good), // above 5 is spread, above 8 is bad. Quadratic above the threshold. const lenInNodes = meanDimForLen > 0 ? meanLen / meanDimForLen : 0 - const edgeLengthMean = Math.max(0, lenInNodes - 3) ** 2 + const edgeLengthMean = Math.max(0, lenInNodes - LEN_MEAN_KNEE) ** 2 // ── 2d. Edge-length MAX in node sizes ───────────────────────────────── // Mean is misleading when many short intra-container edges drag it down @@ -419,7 +413,7 @@ export function computeCompositeScore( // edge separately, with a knee at 8 node-sizes. const maxLen = lengths.length > 0 ? lengths[lengths.length - 1] : 0 const maxInNodes = meanDimForLen > 0 ? maxLen / meanDimForLen : 0 - const edgeLengthMax = Math.max(0, maxInNodes - 5) ** 2 + const edgeLengthMax = Math.max(0, maxInNodes - LEN_MAX_KNEE) ** 2 // ── 2c. Leaf centrality ─ low-degree nodes should sit on the periphery ─ // Compute degree from relations. Compound-children of a low-degree leaf @@ -470,16 +464,10 @@ export function computeCompositeScore( if (r.y + r.h > maxY) maxY = r.y + r.h } const bbW = maxX - minX, bbH = maxY - minY - let aspectPenalty = 0 - if (bbW > 0 && bbH > 0) { - const ar = Math.max(bbW / bbH, bbH / bbW) - const TARGET = Math.SQRT2 // ≈ 1.414, golden-ish - // Cubic instead of quadratic — a 2.5:1 portrait layout (the screenshot - // failure mode) was scoring ar-target ≈ 1.1, squared ≈ 1.2, basically - // negligible. Cubed ≈ 1.3 × W_ASPECT(30) = 40, which finally dominates - // the swap between a tall single-column layout and a square one. - aspectPenalty = Math.max(0, ar - TARGET) ** 3 - } + // Cubic, not quadratic — a 2.5:1 portrait layout scored ≈ 1.2 squared, + // basically negligible; cubed × W_ASPECT it finally outweighs the swap + // between a tall single-column layout and a square one. + const aspect = aspectPenalty(bbW, bbH) // ── 4. Compactness: bbox area / sum of node areas ───────────────────── let compactness = 0 @@ -500,22 +488,17 @@ export function computeCompositeScore( render.renderedCrossings * W_CROSS + render.renderedOverdraws * W_OVERDRAW + render.stubLoopPenalty * W_STUBLOOP - + nodeOverlap * W_OVERLAP - + nodeOverlap * nodeOverlap * W_OVERLAP * 5 // quadratic shock — any overlap > ~30% - // dwarfs every aesthetic gain. Keeps SA - // from shoving leaves into compounds. + + overlapCost(nodeOverlap) + lengthExcess * W_LONG + edgeLengthMean * W_LMEAN + edgeLengthMax * W_LMAX + leafCentrality * W_LEAF - + aspectPenalty * W_ASPECT + + aspect * W_ASPECT + compactness * W_COMPACT + symNorm * W_SYMMETRY return { - crossings: base.crossings, renderedCrossings: render.renderedCrossings, - overdraws: base.overdraws, renderedOverdraws: render.renderedOverdraws, stubLoopPenalty: render.stubLoopPenalty, nodeOverlap, @@ -523,7 +506,7 @@ export function computeCompositeScore( edgeLengthMean, edgeLengthMax, leafCentrality, - aspectPenalty, + aspectPenalty: aspect, compactness, symmetryDeficit: symNorm, composite, @@ -580,10 +563,13 @@ const COMMON_SPACING: LayoutOptions = { 'elk.layered.crossingMinimization.greedySwitch.type': 'TWO_SIDED', } +const CHILD_PAD = compoundPadding('container') const CHILD_SPACING: LayoutOptions = { ...COMMON_SPACING, ...ELK_CHILD_SPACING, - 'elk.padding': '[top=40, right=20, bottom=20, left=20]', + // Same header room finalizeLayout gives every compound, so ELK plans + // for the size the container will actually have. + 'elk.padding': `[top=${CHILD_PAD.top}, right=${CHILD_PAD.side}, bottom=${CHILD_PAD.bottom}, left=${CHILD_PAD.side}]`, } function elkLayered( @@ -779,19 +765,22 @@ function computePlanarityScore( return { crossingEdges: crossed.size, totalEdges: total, ratio, verdict } } -// ─── Simulated-annealing position refinement ────────────────────────────── +// ─── Simulated-annealing refinement ─────────────────────────────────────── +// +// Davidson-Harel 1996 (engine in annealing.ts). After the ensemble ranks +// the candidates, three phases refine the winner: // -// Davidson-Harel 1996. After the ensemble winner is chosen, perturb each -// root-level node's position by Gaussian noise (subtree shifts together -// because root-relative children inherit the parent's translation -// implicitly — we only mutate root-level (x, y)). Accept the new -// arrangement with Metropolis probability exp(-ΔE / T); cool T -// geometrically. +// A. root nodes, cheap incremental proxy energy, run on the top two +// candidates — whichever ends in the better basin continues; +// B. the children of every compound, same proxy, confined to the parent +// so containers don't balloon and collide with their neighbours; +// C. root nodes again, full render-aware composite, gentle polish. // -// Why this matters: ELK's layered already runs sibling-swap per layer, -// but it cannot move a node *between layers* or shift one root by 200 px -// to clear a crossing. SA can — and it escapes the local minima where -// greedy sibling-swap (crossingOpt.ts) gets stuck. +// Budgets are counted in estimated work (pairwise geometry tests), not +// milliseconds, so the same diagram always gets the same layout on any +// machine. They are sized to roughly 0.3–0.5 s per phase on a large +// diagram; the deadlines sit well above that and only cap pathological +// inputs or very slow machines. interface SARefinement { positions: PositionMap @@ -800,662 +789,162 @@ interface SARefinement { iterations: number } -// ─── Fast SA energy: precomputed context + incremental abs ────────────── -// -// SA evaluates energy thousands of times per chain. The original -// proxy implementation rebuilt ancestors / abs / edge-list / mean-area -// on every call (and internally `computeLayoutMetrics` rebuilt them again -// — double work). All of these are *invariant* during SA (topology + -// node dimensions never change — only positions). Hoisting them into a -// context cuts per-eval cost roughly in half and removes per-call -// allocations. -// -// Additional incremental win: when SA perturbs a single root, only that -// root's *subtree* changes absolute position. We shift those entries in -// `ctx.abs` in-place by (dx, dy) and revert on rejection — no full rebuild. -// -// Net effect: ~3–5× more SA iterations in the same wall-clock budget. +/** Phase A work per candidate, in pairwise tests. */ +const PHASE_A_WORK = 4e6 +/** Phase B work shared by all compounds, in pairwise tests. */ +const PHASE_B_WORK = 5e6 +/** Phase C work, in units of E² + E·n + n² (one full composite score). */ +const PHASE_C_WORK = 3e6 +/** How far (as a fraction of the parent's size) phase B lets children reshape it. */ +const COMPOUND_SLACK = 0.5 +const PHASE_A_DEADLINE_MS = 3000 +const PHASE_B_DEADLINE_MS = 1500 +const PHASE_C_DEADLINE_MS = 1500 + +const now = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now()) +const clamp = (v: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, v)) -interface AbsRect { - x: number; y: number; w: number; h: number; cx: number; cy: number +/** + * Sweeps per restart that fit `work` pairwise tests. A proxy probe moving + * one member re-tests its subtree's nodes and incident edges against every + * edge and node: ≈ (E + n) · (|subtree| + |incident edges|). + */ +function sweepsFor( + g: LayoutGraph, group: number[], work: number, restarts: number, min: number, max: number, +): number { + let moved = 0 + for (const i of group) { + const sub = g.subtree[i] + moved += sub.length + for (const k of sub) moved += g.incident[k].length + } + const probeCost = (g.E + g.n) * (moved / group.length) + group.length + const probes = work / probeCost + return clamp(Math.round(probes / (restarts * (group.length + 1))), min, max) } -interface EnergyContext { - ids: string[] // all node ids (stable order) - edgesSrc: string[] // parallel arrays = no per-call .filter() - edgesTgt: string[] - edgeCount: number - ancestors: Record> - descendants: Record // closure of children, for incremental shifts - meanDim: number // constant — node W/H never change - meanArea: number // constant - abs: Record // mutable buffer, reused across evals +function readPositions(g: LayoutGraph): PositionMap { + const out: PositionMap = {} + for (let i = 0; i < g.n; i++) { + out[g.ids[i]] = { x: g.relX[i], y: g.relY[i], width: g.w[i], height: g.h[i] } + } + return out } -function buildDescendants(nodes: Record): Record { - const children: Record = {} - for (const n of Object.values(nodes)) { - if (n.parentId) (children[n.parentId] ??= []).push(n.id) - } - const descendants: Record = {} - const collect = (id: string, out: string[]): void => { - out.push(id) - const kids = children[id] - if (kids) for (const c of kids) collect(c, out) - } - for (const id of Object.keys(nodes)) { - const out: string[] = [] - collect(id, out) - descendants[id] = out - } - return descendants +function rootsOf(g: LayoutGraph): number[] { + const roots: number[] = [] + for (let i = 0; i < g.n; i++) if (g.parent[i] === -1) roots.push(i) + return roots } -function buildEnergyContext( +function refineRoots( nodes: Record, relations: Record, -): EnergyContext { - const ids = Object.keys(nodes) - const ancestors = buildAncestors(nodes) - const descendants = buildDescendants(nodes) - - let totalArea = 0 - let meanDimSum = 0 - for (const id of ids) { - const n = nodes[id] - totalArea += n.width * n.height - meanDimSum += (n.width + n.height) / 2 - } - const meanArea = totalArea / Math.max(ids.length, 1) - const meanDim = ids.length > 0 ? meanDimSum / ids.length : 1 - - const edgesSrc: string[] = [] - const edgesTgt: string[] = [] - for (const r of Object.values(relations)) { - if (nodes[r.sourceId] && nodes[r.targetId]) { - edgesSrc.push(r.sourceId) - edgesTgt.push(r.targetId) - } - } - - const abs: Record = {} - for (const id of ids) { - const n = nodes[id] - abs[id] = { x: 0, y: 0, w: n.width, h: n.height, cx: 0, cy: 0 } - } - - return { - ids, - edgesSrc, edgesTgt, edgeCount: edgesSrc.length, - ancestors, descendants, - meanDim, meanArea, - abs, - } -} - -/** Full abs rebuild from current `work` positions. O(N + ΣdepthChain). */ -function recomputeAbs(work: Record, ctx: EnergyContext): void { - const visited = new Set() - const visit = (id: string): void => { - if (visited.has(id)) return - const n = work[id] - if (!n) { visited.add(id); return } - const a = ctx.abs[id] - if (!a) { visited.add(id); return } - if (!n.parentId) { - a.x = n.x; a.y = n.y - } else { - visit(n.parentId) - const p = ctx.abs[n.parentId] - if (p) { a.x = p.x + n.x; a.y = p.y + n.y } - else { a.x = n.x; a.y = n.y } - } - a.cx = a.x + a.w / 2 - a.cy = a.y + a.h / 2 - visited.add(id) - } - for (const id of ctx.ids) visit(id) -} - -/** Shift a node's whole subtree in `ctx.abs` by (dx, dy). O(|subtree|). */ -function shiftSubtreeAbs(rootId: string, dx: number, dy: number, ctx: EnergyContext): void { - const sub = ctx.descendants[rootId] - if (!sub) return - for (const id of sub) { - const a = ctx.abs[id] - if (a) { a.x += dx; a.y += dy; a.cx += dx; a.cy += dy } - } -} - -// Geometry primitives inlined to avoid Point/Rect object allocations -// inside the hot loop. Mirrors crossingOpt.ts: strict CCW intersection, -// rect shrunk by 1 px so grazing edges don't count as overdraw. -function _segCrossXY( - ax: number, ay: number, bx: number, by: number, - cx: number, cy: number, dx: number, dy: number, -): boolean { - const o1 = (dy - cy) * (bx - cx) - (by - cy) * (dx - cx) > 0 - const o2 = (dy - cy) * (ax - cx) - (ay - cy) * (dx - cx) > 0 - if (o1 === o2) return false - const o3 = (by - ay) * (cx - ax) - (cy - ay) * (bx - ax) > 0 - const o4 = (by - ay) * (dx - ax) - (dy - ay) * (bx - ax) > 0 - return o3 !== o4 -} - -function _segHitsRect( - px: number, py: number, qx: number, qy: number, - rx: number, ry: number, rw: number, rh: number, -): boolean { - const x0 = rx + 1, y0 = ry + 1 - const x1 = rx + rw - 1, y1 = ry + rh - 1 - if (x1 <= x0 || y1 <= y0) return false - if (px > x0 && px < x1 && py > y0 && py < y1) return true - if (qx > x0 && qx < x1 && qy > y0 && qy < y1) return true - // Liang-Barsky-style: clip segment against rect. - let t0 = 0, t1 = 1 - const ddx = qx - px, ddy = qy - py - const ps = [-ddx, ddx, -ddy, ddy] - const qs = [px - x0, x1 - px, py - y0, y1 - py] - for (let i = 0; i < 4; i++) { - if (ps[i] === 0) { - if (qs[i] < 0) return false - } else { - const t = qs[i] / ps[i] - if (ps[i] < 0) { if (t > t1) return false; if (t > t0) t0 = t } - else { if (t < t0) return false; if (t < t1) t1 = t } - } - } - return true -} - -/** - * Context-based energy proxy used inside the SA inner loop. - * - * Hot inner loop — avoids: - * - rebuilding ancestors / abs / edge-list / mean-area / mean-dim - * - calls to computeLayoutMetrics (which would rebuild them again) - * - Object.values / Object.keys allocations - * - Math.hypot (sqrt is amortised — total length only) - * - * Caller MUST keep `ctx.abs` in sync with `work` (via recomputeAbs once, - * then shiftSubtreeAbs for incremental moves). - */ -function proxyEnergyCtx(ctx: EnergyContext): number { - const { ids, edgesSrc, edgesTgt, edgeCount, ancestors, meanDim, meanArea, abs } = ctx - const N = ids.length - - // Crossings + overdraws. - let crossings = 0 - let overdraws = 0 - for (let i = 0; i < edgeCount; i++) { - const a_src = edgesSrc[i], a_tgt = edgesTgt[i] - const r1 = abs[a_src], r2 = abs[a_tgt] - const p1x = r1.cx, p1y = r1.cy - const p2x = r2.cx, p2y = r2.cy - const srcAnc = ancestors[a_src] - const tgtAnc = ancestors[a_tgt] - - for (let k = 0; k < N; k++) { - const nid = ids[k] - if (nid === a_src || nid === a_tgt) continue - if (srcAnc && srcAnc.has(nid)) continue - if (tgtAnc && tgtAnc.has(nid)) continue - const r = abs[nid] - if (_segHitsRect(p1x, p1y, p2x, p2y, r.x, r.y, r.w, r.h)) overdraws++ - } - - for (let j = i + 1; j < edgeCount; j++) { - const b_src = edgesSrc[j], b_tgt = edgesTgt[j] - if (a_src === b_src || a_src === b_tgt || a_tgt === b_src || a_tgt === b_tgt) continue - const r3 = abs[b_src], r4 = abs[b_tgt] - if (_segCrossXY(p1x, p1y, p2x, p2y, r3.cx, r3.cy, r4.cx, r4.cy)) crossings++ - } - } - - // Pairwise sibling overlap (skip ancestor/descendant pairs). - let overlapArea = 0 - for (let i = 0; i < N; i++) { - const id_i = ids[i] - const a = abs[id_i] - const anc_i = ancestors[id_i] - const ax2 = a.x + a.w - const ay2 = a.y + a.h - for (let j = i + 1; j < N; j++) { - const id_j = ids[j] - if (anc_i && anc_i.has(id_j)) continue - const anc_j = ancestors[id_j] - if (anc_j && anc_j.has(id_i)) continue - const b = abs[id_j] - const ox = (ax2 < b.x + b.w ? ax2 : b.x + b.w) - (a.x > b.x ? a.x : b.x) - if (ox <= 0) continue - const oy = (ay2 < b.y + b.h ? ay2 : b.y + b.h) - (a.y > b.y ? a.y : b.y) - if (oy <= 0) continue - overlapArea += ox * oy - } - } - const overlap = meanArea > 0 ? overlapArea / meanArea : 0 - - // Edge length stats (squared lengths first; sqrt only for final mean+max). - let totalLen = 0 - let maxLenSq = 0 - for (let i = 0; i < edgeCount; i++) { - const a = abs[edgesSrc[i]], b = abs[edgesTgt[i]] - const dx = a.cx - b.cx, dy = a.cy - b.cy - const lenSq = dx * dx + dy * dy - totalLen += Math.sqrt(lenSq) - if (lenSq > maxLenSq) maxLenSq = lenSq - } - const meanLen = edgeCount > 0 ? totalLen / edgeCount : 0 - const maxLen = Math.sqrt(maxLenSq) - const lenInNodes = meanDim > 0 ? meanLen / meanDim : 0 - const maxInNodes = meanDim > 0 ? maxLen / meanDim : 0 - const edgeLengthMean = Math.max(0, lenInNodes - 3) ** 2 - const edgeLengthMax = Math.max(0, maxInNodes - 5) ** 2 - - // Aspect penalty. - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity - for (let i = 0; i < N; i++) { - const r = abs[ids[i]] - if (r.x < minX) minX = r.x - if (r.y < minY) minY = r.y - const rx2 = r.x + r.w, ry2 = r.y + r.h - if (rx2 > maxX) maxX = rx2 - if (ry2 > maxY) maxY = ry2 - } - const bbW = maxX - minX, bbH = maxY - minY - let aspectPenalty = 0 - if (bbW > 0 && bbH > 0) { - const ar = bbW > bbH ? bbW / bbH : bbH / bbW - aspectPenalty = Math.max(0, ar - Math.SQRT2) ** 3 - } - - const aestheticBoost = crossings === 0 ? 2 : 1 - - return crossings * W_CROSS - + overdraws * W_OVERDRAW - + overlap * W_OVERLAP - + overlap * overlap * W_OVERLAP * 5 - + edgeLengthMean * W_LMEAN * aestheticBoost - + edgeLengthMax * W_LMAX * aestheticBoost - + aspectPenalty * W_ASPECT * aestheticBoost -} - -/** - * Yields control back to the browser event loop so the UI can paint the - * layout spinner and process input events between SA restarts. - * - * Uses `scheduler.yield()` (Chrome 129+ / Electron 32+) when available — - * it re-queues as a high-priority task with near-zero overhead. Falls back - * to `setTimeout(0)` (~1 ms in non-throttled Electron) on older runtimes. - */ -function yieldToUI(): Promise { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const _sched = (globalThis as any).scheduler - if (_sched != null && typeof _sched.yield === 'function') { - return _sched.yield() as Promise - } - return new Promise((resolve) => setTimeout(resolve, 0)) + positions: PositionMap, + rng: Rng, + deadline: number, +): SARefinement { + const g = buildLayoutGraph(projectPositions(nodes, positions), relations) + const group = rootsOf(g) + const restarts = 4 + const res = anneal(new ProxyEnergy(g), g, group, { + rng, restarts, + sweeps: sweepsFor(g, group, PHASE_A_WORK, restarts, 12, 150), + stepFactor: 0.06, tempFactor: 1, calibrationSamples: 24, + gap: ROOT_GAP, deadline, + }) + return { positions: readPositions(g), before: res.before, after: res.after, iterations: res.evaluations } } -async function refineWithSimulatedAnnealing( +function refineCompounds( nodes: Record, relations: Record, positions: PositionMap, - rootIds: string[], - budgetMs = 250, - options: { energyFn?: (n: Record, r: Record) => number; initialTempFactor?: number } = {}, -): Promise { - if (rootIds.length < 2) { - const m = computeCompositeScore(nodes, relations) - return { positions, before: m.composite, after: m.composite, iterations: 0 } - } - - const work: Record = {} - for (const [id, n] of Object.entries(nodes)) { - const p = positions[id] - work[id] = p - ? { ...n, x: p.x, y: p.y, width: p.width ?? n.width, height: p.height ?? n.height } - : { ...n } - } - - // SA energy: by default the cheap proxy (now context-based, ~3-5× faster - // per evaluation than a fresh-allocating proxy). Phase C passes a custom - // energyFn (full render-aware composite) and falls back to the slow path - // because the composite sampling can't share our incremental abs buffer - // cleanly. - const customEnergy = options.energyFn - const ctx = customEnergy ? null : buildEnergyContext(nodes, relations) - const energy = (): number => { - if (customEnergy) return customEnergy(work, relations) - recomputeAbs(work, ctx!) // initial / post-restore full sync - return proxyEnergyCtx(ctx!) - } - // Fast incremental version: caller already kept ctx.abs in sync. - const energyIncremental = (): number => { - if (customEnergy) return customEnergy(work, relations) - return proxyEnergyCtx(ctx!) + rng: Rng, + deadline: number, +): SARefinement { + const work = projectPositions(nodes, positions) + const g = buildLayoutGraph(work, relations) + const ev = new ProxyEnergy(g) + const before = ev.energy() + + const groups = new Map() + for (let i = 0; i < g.n; i++) { + const p = g.parent[i] + if (p === -1) continue + const list = groups.get(p) + if (list) list.push(i) + else groups.set(p, [i]) + } + const depth = (i: number): number => { + let d = 0 + for (let p = g.parent[i]; p !== -1; p = g.parent[p]) d++ + return d } - const before = energy() - let curCost = before - let bestCost = before - const bestSnapshot: Record = {} - for (const id of rootIds) bestSnapshot[id] = { x: work[id].x, y: work[id].y } - - // Temperature schedule. Start with a perturbation near 8 % of bbox span so - // the first sweeps can swap two roots; cool to a few px so the final - // sweeps polish positions. `initialTempFactor` lets the polish phase - // start at a much lower T (gentle fine-tuning, no large jumps). - const tempScale = options.initialTempFactor ?? 1 - const bbox = computeRootBBox(work, rootIds) - const span = Math.max(bbox.w, bbox.h, 200) - - // Snapshot of the input positions — restart 0 starts here for diversity - // (restarts 1+ start from the best basin found so far). Without this every - // restart converges to the same local minimum and the multi-restart loop - // is wasted work. - const initialSnapshot: Record = {} - for (const id of rootIds) initialSnapshot[id] = { x: work[id].x, y: work[id].y } - - const now = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now()) - - // Multi-restart SA with diversified seeding + reheat-on-stagnation. - // • Restart 0 → starts from the input (preserves ELK/winner intent). - // • Restart k>0 → starts from best basin perturbed by initial T (escapes - // to a neighbouring basin while retaining the structural backbone). - // • Inside each chain: when no improvement for STAGNATION_LIMIT sweeps, - // reheat T to 60 % of initial. Davidson-Harel "kick" — recovers SA - // when it gets trapped in a bad minimum after greedy descent. - const RESTARTS = 4 - const perRun = budgetMs / RESTARTS - let totalIter = 0 - // Reheat after this many consecutive sweeps with no new global-best cost. - const STAGNATION_LIMIT = 12 - - for (let restart = 0; restart < RESTARTS; restart++) { - // Seed positions: restart 0 = input, restart k>0 = best so far. - const seed = restart === 0 ? initialSnapshot : bestSnapshot - for (const id of rootIds) { - work[id].x = seed[id].x - work[id].y = seed[id].y - } - if (ctx) recomputeAbs(work, ctx) - curCost = restart === 0 ? before : bestCost - // For restart 0 the seed equals the input; recompute to be safe in case - // `before` was computed against an out-of-sync abs buffer. - if (restart === 0 && ctx) curCost = proxyEnergyCtx(ctx) - - // Initial temperature: restart 0 modest, later restarts hotter so they - // can leap out of the seed's basin. - const T0 = span * (0.06 + 0.04 * restart) * tempScale // 6/10/14/18 % × scale - let T = T0 - const T_END = 3 - const cooling = 0.95 - const t0 = now() - let sweepsSinceImprovement = 0 - - while (now() - t0 < perRun && T > T_END) { - const bestBeforeSweep = bestCost - // One sweep = one perturbation per root node, plus one swap. - // Mixing translation + swap follows Davidson-Harel (1996) — pure - // translation SA gets stuck because swapping two roots is many - // small Gaussian steps away. - for (const id of rootIds) { - const n = work[id] - const oldX = n.x - const oldY = n.y - const dx = gaussian() * T - const dy = gaussian() * T - n.x += dx - n.y += dy - if (ctx) shiftSubtreeAbs(id, dx, dy, ctx) - const cost = energyIncremental() - const dE = cost - curCost - if (dE < 0 || Math.random() < Math.exp(-dE / Math.max(T, 0.5))) { - curCost = cost - if (cost < bestCost) { - bestCost = cost - for (const rid of rootIds) bestSnapshot[rid] = { x: work[rid].x, y: work[rid].y } - } - } else { - n.x = oldX - n.y = oldY - if (ctx) shiftSubtreeAbs(id, -dx, -dy, ctx) - } - totalIter++ - } - // Discrete swap move once per sweep (large neighbourhood jump). - // Bias toward swapping spatially-nearby roots — random global swaps - // are almost always destructive and rejected. Nearby swaps are more - // likely to be accepted (small absolute cost change) and effective at - // unsticking from a bad basin. - if (rootIds.length >= 2) { - const i = Math.floor(Math.random() * rootIds.length) - let j = i - // 3 candidate picks, choose the one closest to i. O(1) extra cost - // and noticeably improves swap acceptance ratio in practice. - let bestDist = Infinity - const a = work[rootIds[i]] - for (let trial = 0; trial < 3; trial++) { - let cand = Math.floor(Math.random() * rootIds.length) - if (cand === i) cand = (cand + 1) % rootIds.length - const c = work[rootIds[cand]] - const d = (c.x - a.x) ** 2 + (c.y - a.y) ** 2 - if (d < bestDist) { bestDist = d; j = cand } - } - const idA = rootIds[i], idB = rootIds[j] - const aa = work[idA] - const bb = work[idB] - const ax = aa.x, ay = aa.y - const bx = bb.x, by = bb.y - const dxA = bx - ax, dyA = by - ay - aa.x = bx; aa.y = by - bb.x = ax; bb.y = ay - if (ctx) { - shiftSubtreeAbs(idA, dxA, dyA, ctx) - shiftSubtreeAbs(idB, -dxA, -dyA, ctx) - } - const cost = energyIncremental() - const dE = cost - curCost - // Swap is a large jump; tighten the temperature for uphill acceptance. - if (dE < 0 || Math.random() < Math.exp(-dE / Math.max(T * 0.5, 0.5))) { - curCost = cost - if (cost < bestCost) { - bestCost = cost - for (const rid of rootIds) bestSnapshot[rid] = { x: work[rid].x, y: work[rid].y } - } - } else { - aa.x = ax; aa.y = ay - bb.x = bx; bb.y = by - if (ctx) { - shiftSubtreeAbs(idA, -dxA, -dyA, ctx) - shiftSubtreeAbs(idB, dxA, dyA, ctx) - } - } - totalIter++ - } + const parents = [...groups.keys()].sort((a, b) => depth(b) - depth(a) || a - b) - if (bestCost < bestBeforeSweep) { - sweepsSinceImprovement = 0 - T *= cooling - } else { - sweepsSinceImprovement++ - if (sweepsSinceImprovement >= STAGNATION_LIMIT) { - // Davidson-Harel "kick": no new global best for a while means the - // chain is trapped in a basin greedy descent can't climb out of. - // Reheat instead of cooling further so the next sweeps can jump - // to a neighbouring basin. - T = T0 * 0.6 - sweepsSinceImprovement = 0 - } else { - T *= cooling - } - } + let iterations = 0 + const restarts = 2 + const compounds = parents.filter((p) => groups.get(p)!.length >= 2).length + for (const p of parents) { + const group = groups.get(p)! + if (group.length < 2) continue + // Children may reshape their parent by up to half its size (the parent + // is refitted and roots re-separated afterwards) but not wander off. + const pad = compoundPadding(work[g.ids[p]].type) + const slackX = g.w[p] * COMPOUND_SLACK + const slackY = g.h[p] * COMPOUND_SLACK + const bounds: Bounds = { + minX: pad.side - slackX, + minY: pad.top, + maxX: g.w[p] - pad.side + slackX, + maxY: g.h[p] - pad.bottom + slackY, } - // Yield between SA restarts so the event loop can paint the spinner - // and handle input. Each restart is ~perRun ms of sync CPU work. - await yieldToUI() - } - - // Restore best positions and emit them. - for (const id of rootIds) { - work[id].x = bestSnapshot[id].x - work[id].y = bestSnapshot[id].y - } - const out: PositionMap = { ...positions } - for (const id of rootIds) { - const prev = positions[id] ?? { x: 0, y: 0 } - out[id] = { ...prev, x: bestSnapshot[id].x, y: bestSnapshot[id].y } - } - return { positions: out, before, after: bestCost, iterations: totalIter } -} - -function computeRootBBox( - nodes: Record, - rootIds: string[], -): { w: number; h: number } { - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity - for (const id of rootIds) { - const n = nodes[id] - if (!n) continue - if (n.x < minX) minX = n.x - if (n.y < minY) minY = n.y - if (n.x + n.width > maxX) maxX = n.x + n.width - if (n.y + n.height > maxY) maxY = n.y + n.height + const res = anneal(ev, g, group, { + rng, restarts, + sweeps: sweepsFor(g, group, PHASE_B_WORK / compounds, restarts, 6, 100), + stepFactor: 0.08, tempFactor: 1, calibrationSamples: 12, + gap: CHILD_GAP, bounds, deadline, + }) + iterations += res.evaluations } - if (!isFinite(minX)) return { w: 0, h: 0 } - return { w: maxX - minX, h: maxY - minY } + return { positions: readPositions(g), before, after: ev.energy(), iterations } } -/** Box-Muller transform — N(0, 1). */ -function gaussian(): number { - let u = 0, v = 0 - while (u === 0) u = Math.random() - while (v === 0) v = Math.random() - return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v) -} - -/** - * Per-compound SA pass (#1). - * - * Root-only SA cannot rearrange children inside a compound — if ELK packed - * the components of a Container suboptimally, no amount of root shuffling - * fixes it. This walks every parent with ≥ 2 children and runs a small SA - * on those children's (parent-relative) positions. Energy is the same - * proxy, evaluated globally — children moves still affect global crossings, - * which is exactly what we want. - * - * Budget is split evenly across compound groups so a diagram with many - * containers doesn't blow the wall-clock target. - */ -async function refinePerCompound( +function polishRoots( nodes: Record, relations: Record, positions: PositionMap, - budgetMs: number, -): Promise { - const byParent: Record = {} - for (const n of Object.values(nodes)) { - if (n.parentId) (byParent[n.parentId] ??= []).push(n.id) - } - const groups = Object.values(byParent).filter((g) => g.length >= 2) - if (groups.length === 0) { - return { positions, before: 0, after: 0, iterations: 0 } - } - const perGroup = Math.max(60, budgetMs / groups.length) - let pos = positions - let before = 0 - let after = 0 - let iterations = 0 - for (let i = 0; i < groups.length; i++) { - const childIds = groups[i] - const result = await refineWithSimulatedAnnealing(nodes, relations, pos, childIds, perGroup) - if (i === 0) before = result.before - after = result.after - iterations += result.iterations - pos = result.positions - } - return { positions: pos, before, after, iterations } + rng: Rng, + deadline: number, +): SARefinement { + const work = projectPositions(nodes, positions) + const g = buildLayoutGraph(work, relations) + const group = rootsOf(g) + // Every probe is a full render-aware re-score, so size the run by cost. + const probeCost = g.E * g.E + g.E * g.n + g.n * g.n + 1 + const probes = clamp(Math.round(PHASE_C_WORK / probeCost), 30, 4000) + const opts: AnnealOptions = { + rng, restarts: 1, + sweeps: clamp(Math.round(probes / (group.length + 1)), 2, 200), + stepFactor: 0.02, tempFactor: 0.25, calibrationSamples: 6, + gap: ROOT_GAP, deadline, + } + const res = anneal(new FunctionEnergy(g, work, relations, compositeEnergy), g, group, opts) + return { positions: readPositions(g), before: res.before, after: res.after, iterations: res.evaluations } } /** - * Re-fit every compound parent to the bounding box of its (possibly moved) - * children. SA per-compound rearranges children in parent-relative space - * but does NOT enforce that they stay inside the parent's original bbox — - * children can drift outside, leaving the parent visually empty while - * components float in the void next to it. This pass restores the - * "container hugs its children" invariant. - * - * For each parent (deepest first): - * 1. Compute child-bbox in parent-relative coords. - * 2. Translate every child by (-bboxMinX + padX, -bboxMinY + padY) so the - * bbox starts at the padding offset. - * 3. Set parent width/height to bboxW + 2·padX (resp. height + topPad + padY). - * - * Padding mirrors the values used by ELK / fitParentToChildren in the store. + * Yields control back to the event loop between phases so progress + * messages are delivered and, on the in-thread fallback, the UI can paint. */ -const PARENT_PAD_X = 16 -const PARENT_PAD_TOP = 120 // header (30) + 2-line label (~52) + breathing room -const PARENT_PAD_BOTTOM = 16 - -function fitParentsToChildren( - nodes: Record, - positions: PositionMap, -): PositionMap { - // Build child list per parent. - const byParent: Record = {} - for (const n of Object.values(nodes)) { - if (n.parentId) (byParent[n.parentId] ??= []).push(n.id) - } - const parentIds = Object.keys(byParent) - if (parentIds.length === 0) return positions - - // Sort parents deepest-first so a container is fitted before its system. - const depthOf = (id: string): number => { - let d = 0, cur: C4Node | undefined = nodes[id] - while (cur?.parentId) { d++; cur = nodes[cur.parentId] } - return d - } - parentIds.sort((a, b) => depthOf(b) - depthOf(a)) - - const out: PositionMap = { ...positions } - const px = (id: string): { x: number; y: number; w: number; h: number } => { - const p = out[id] - const n = nodes[id] - return { - x: p?.x ?? n.x, - y: p?.y ?? n.y, - w: p?.width ?? n.width, - h: p?.height ?? n.height, - } - } - - for (const pid of parentIds) { - const childIds = byParent[pid] - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity - for (const cid of childIds) { - const c = px(cid) - if (c.x < minX) minX = c.x - if (c.y < minY) minY = c.y - if (c.x + c.w > maxX) maxX = c.x + c.w - if (c.y + c.h > maxY) maxY = c.y + c.h - } - if (!isFinite(minX)) continue - - // Shift children so they start at (PARENT_PAD_X, PARENT_PAD_TOP). - const dx = PARENT_PAD_X - minX - const dy = PARENT_PAD_TOP - minY - for (const cid of childIds) { - const c = px(cid) - out[cid] = { x: c.x + dx, y: c.y + dy, width: c.w, height: c.h } - } - - // Resize parent to fit shifted children. - const newW = (maxX - minX) + 2 * PARENT_PAD_X - const newH = (maxY - minY) + PARENT_PAD_TOP + PARENT_PAD_BOTTOM - const p = px(pid) - out[pid] = { x: p.x, y: p.y, width: newW, height: newH } +function yieldToUI(): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const _sched = (globalThis as any).scheduler + if (_sched != null && typeof _sched.yield === 'function') { + return _sched.yield() as Promise } - return out + return new Promise((resolve) => setTimeout(resolve, 0)) } // ─── Candidate runner ───────────────────────────────────────────────────── @@ -1475,8 +964,10 @@ export interface SmartLayoutResult { baseline: LayoutMetrics /** Planarity verdict of the winning layout. */ planarity: PlanarityScore - /** SA refinement statistics (cost before vs. after annealing). */ + /** Composite cost of the refined candidate before and after annealing. */ refinement: { before: number; after: number; iterations: number } + /** True when nothing Smart Layout produced beat the layout it was given. */ + keptCurrent: boolean } function projectPositions(nodes: Record, positions: PositionMap): Record { @@ -1497,19 +988,18 @@ async function runCandidate( positionsPromise: Promise | PositionMap, ): Promise { try { - const positions = await positionsPromise - if (Object.keys(positions).length === 0) return null - // Score after geometric crossing minimisation so all candidates compete fairly. - const projected = projectPositions(nodes, positions) - const swap = minimizeCrossings(projected, relations) - if (Object.keys(swap).length > 0) { - for (const [id, p] of Object.entries(swap)) { - const n = projected[id] - if (n) { n.x = p.x; n.y = p.y } - const prev = positions[id] - if (prev) positions[id] = { ...prev, x: p.x, y: p.y } - } + const raw = await positionsPromise + if (Object.keys(raw).length === 0) return null + // Score after geometric crossing minimisation so all candidates compete + // fairly, and after finalising (sibling swaps can collide boxes of + // different sizes) so the score describes what would be rendered. + const swap = minimizeCrossings(projectPositions(nodes, raw), relations) + for (const [id, p] of Object.entries(swap)) { + const prev = raw[id] + if (prev) raw[id] = { ...prev, x: p.x, y: p.y } } + const positions = finalizeLayout(nodes, raw) + const projected = projectPositions(nodes, positions) const metrics = computeLayoutMetrics(projected, relations) const score = computeCompositeScore(projected, relations) return { name, positions, metrics, score } @@ -1524,12 +1014,21 @@ async function runCandidate( * * `done: true` — all candidates failed; `result` is a baseline fallback and * the SA phase should be skipped. - * `done: false` — ranked candidates are ready; pass `valid / rootIds / baseline` - * to `runSmartLayoutSAPhase` (or the SA Web Worker). + * `done: false` — ranked candidates are ready; pass `nodes / relations / + * valid / baseline` to `runSmartLayoutSAPhase` (or the SA + * Web Worker). `nodes` and `relations` are the visible + * projection of the input — the SA phase must use them, not + * the raw input. */ export type ELKPhaseResult = | { done: true; result: SmartLayoutResult } - | { done: false; valid: SmartLayoutCandidate[]; rootIds: string[]; baseline: LayoutMetrics } + | { + done: false + nodes: Record + relations: Record + valid: SmartLayoutCandidate[] + baseline: LayoutMetrics + } /** * In-run progress, reported so the UI can show that Smart Layout is actually @@ -1550,11 +1049,12 @@ export type SmartLayoutOnProgress = (progress: SmartLayoutProgress) => void * the ranked candidate set ready for `runSmartLayoutSAPhase` (`done: false`). */ export async function runSmartLayoutELKPhase( - nodes: Record, - relations: Record, + inputNodes: Record, + inputRelations: Record, metamodel?: Metamodel, onProgress?: SmartLayoutOnProgress, ): Promise { + const { nodes, relations } = projectToVisibleGraph(inputNodes, inputRelations) // Dynamic import so ELK (with its elk-worker.min.js CJS dependency) is NOT // bundled into the Web Worker chunk — it's code-split and fetched only when // this function is called from the main thread. @@ -1669,6 +1169,7 @@ export async function runSmartLayoutELKPhase( candidates: [], planarity: computePlanarityScore(nodes, relations), refinement: { before: baselineScore.composite, after: baselineScore.composite, iterations: 0 }, + keptCurrent: true, }, } } @@ -1679,8 +1180,7 @@ export async function runSmartLayoutELKPhase( // raw crossing count alone. valid.sort((a, b) => a.score.composite - b.score.composite) - const rootIds = Object.values(nodes).filter((n) => !n.parentId).map((n) => n.id) - return { done: false, valid, rootIds, baseline } + return { done: false, nodes, relations, valid, baseline } } export async function runSmartLayoutCore( @@ -1691,7 +1191,7 @@ export async function runSmartLayoutCore( ): Promise { const elkResult = await runSmartLayoutELKPhase(nodes, relations, metamodel, onProgress) if (elkResult.done) return elkResult.result - return runSmartLayoutSAPhase(nodes, relations, elkResult.valid, elkResult.rootIds, elkResult.baseline, onProgress) + return runSmartLayoutSAPhase(elkResult.nodes, elkResult.relations, elkResult.valid, elkResult.baseline, onProgress) } /** @@ -1703,71 +1203,76 @@ export async function runSmartLayoutCore( * script that cannot be imported inside another worker). The ELK candidate * generation always runs on the main thread; this function handles the * CPU-intensive SA refinement off-thread. + * + * `nodes` / `relations` must be the visible projection returned by the ELK + * phase. `seed` overrides the structure-derived seed (a "try another + * arrangement" action would pass a different one). */ export async function runSmartLayoutSAPhase( nodes: Record, relations: Record, valid: SmartLayoutCandidate[], // sorted ascending by composite score - rootIds: string[], baseline: LayoutMetrics, onProgress?: SmartLayoutOnProgress, + options: { seed?: number } = {}, ): Promise { - // Phase A multi-start: refine the top-K ELK candidates independently and - // pick whichever Phase A leaves in the best basin. + const rng = createRng(options.seed ?? graphSeed(nodes, relations)) + + // Phase A multi-start: refine the top-K candidates independently and + // continue with whichever scores better as it would be rendered (the + // proxy energy only approximates that). onProgress?.({ phase: 'refining-a' }) - const PHASEA_K = Math.min(2, valid.length) - const phaseABudget = 400 - let phaseA = await refineWithSimulatedAnnealing(nodes, relations, valid[0].positions, rootIds, phaseABudget) - let winner = valid[0] - for (let k = 1; k < PHASEA_K; k++) { - const alt = await refineWithSimulatedAnnealing(nodes, relations, valid[k].positions, rootIds, phaseABudget) - if (alt.after < phaseA.after) { - phaseA = alt - winner = valid[k] - } - } - // Refit parents in case root SA loosened sibling spacing. - const fittedA = fitParentsToChildren(nodes, phaseA.positions) + const deadlineA = now() + PHASE_A_DEADLINE_MS + let best: { from: SmartLayoutCandidate; run: SARefinement; positions: PositionMap; composite: number } | null = null + let iterationsA = 0 + for (let k = 0; k < Math.min(2, valid.length); k++) { + const run = refineRoots(nodes, relations, valid[k].positions, rng, deadlineA) + iterationsA += run.iterations + const positions = finalizeLayout(nodes, run.positions) + const composite = compositeEnergy(projectPositions(nodes, positions), relations) + if (!best || composite < best.composite) best = { from: valid[k], run, positions, composite } + } + const refinedFrom = best!.from + await yieldToUI() + onProgress?.({ phase: 'refining-b' }) - const phaseB = await refinePerCompound(nodes, relations, fittedA, 200) - // Critical: per-compound SA mutates child positions in parent-relative - // space WITHOUT enforcing they stay inside the parent. Refit so containers - // hug their (possibly drifted) children before the final polish. - const fittedB = fitParentsToChildren(nodes, phaseB.positions) + const phaseB = refineCompounds(nodes, relations, best!.positions, rng, now() + PHASE_B_DEADLINE_MS) + await yieldToUI() + onProgress?.({ phase: 'refining-c' }) - const phaseC = await refineWithSimulatedAnnealing( - nodes, relations, fittedB, rootIds, 200, - { - energyFn: (n, r) => computeCompositeScore(n, r).composite, - initialTempFactor: 0.4, // gentle fine-tuning, no large jumps - }, - ) - const finalPositions = fitParentsToChildren(nodes, phaseC.positions) - const sa: SARefinement = { + const phaseC = polishRoots(nodes, relations, finalizeLayout(nodes, phaseB.positions), rng, now() + PHASE_C_DEADLINE_MS) + const finalPositions = finalizeLayout(nodes, phaseC.positions) + const refined = projectPositions(nodes, finalPositions) + + // Never hand back something worse than we already had: the refined + // layout competes with the best unrefined candidate and with the layout + // the user is looking at right now, all scored the same way. + let winner: SmartLayoutCandidate = { + name: refinedFrom.name, positions: finalPositions, - before: phaseA.before, - after: phaseC.after, - iterations: phaseA.iterations + phaseB.iterations + phaseC.iterations, + metrics: computeLayoutMetrics(refined, relations), + score: computeCompositeScore(refined, relations), } + if (valid[0].score.composite < winner.score.composite) winner = valid[0] - const refined = projectPositions(nodes, sa.positions) - const refinedMetrics = computeLayoutMetrics(refined, relations) - const refinedScore = computeCompositeScore(refined, relations) - const finalWinner: SmartLayoutCandidate = { - name: winner.name, - positions: sa.positions, - metrics: refinedMetrics, - score: refinedScore, + const currentScore = computeCompositeScore(nodes, relations) + const keptCurrent = currentScore.composite <= winner.score.composite + if (keptCurrent) { + const positions: PositionMap = {} + for (const n of Object.values(nodes)) positions[n.id] = { x: n.x, y: n.y, width: n.width, height: n.height } + winner = { name: 'Current layout', positions, metrics: baseline, score: currentScore } } - const planarity = computePlanarityScore(refined, relations) return { baseline, - winner: finalWinner, + winner, candidates: valid, - planarity, - refinement: { before: sa.before, after: sa.after, iterations: sa.iterations }, + planarity: computePlanarityScore(projectPositions(nodes, winner.positions), relations), + refinement: { + before: refinedFrom.score.composite, + after: winner.score.composite, + iterations: iterationsA + phaseB.iterations + phaseC.iterations, + }, + keptCurrent, } } - - diff --git a/src/renderer/src/layout/smartLayout.worker.ts b/src/renderer/src/layout/smartLayout.worker.ts index adcb0996..2828352a 100644 --- a/src/renderer/src/layout/smartLayout.worker.ts +++ b/src/renderer/src/layout/smartLayout.worker.ts @@ -8,7 +8,7 @@ * * Protocol * ───────── - * Main → Worker { nodes, relations, valid, rootIds, baseline } + * Main → Worker { nodes, relations, valid, baseline } (nodes/relations = visible projection from the ELK phase) * Worker → Main { type: 'result', result: SmartLayoutResult } * | { type: 'error', message: string } * | { type: 'progress', progress: SmartLayoutProgress } @@ -19,16 +19,15 @@ import type { C4Node, C4Relation } from '../types/c4' import type { LayoutMetrics } from './crossingOpt' self.onmessage = async (e: MessageEvent) => { - const { nodes, relations, valid, rootIds, baseline } = e.data as { + const { nodes, relations, valid, baseline } = e.data as { nodes: Record relations: Record valid: SmartLayoutCandidate[] - rootIds: string[] baseline: LayoutMetrics } try { const result: SmartLayoutResult = await runSmartLayoutSAPhase( - nodes, relations, valid, rootIds, baseline, + nodes, relations, valid, baseline, (progress: SmartLayoutProgress) => self.postMessage({ type: 'progress', progress }), ) self.postMessage({ type: 'result', result }) diff --git a/src/renderer/src/layout/smartLayoutRunner.ts b/src/renderer/src/layout/smartLayoutRunner.ts index c8492718..e0489d02 100644 --- a/src/renderer/src/layout/smartLayoutRunner.ts +++ b/src/renderer/src/layout/smartLayoutRunner.ts @@ -56,7 +56,7 @@ export async function runSmartLayout( return elkResult.result } - const { valid, rootIds, baseline } = elkResult + const { nodes: visibleNodes, relations: visibleRelations, valid, baseline } = elkResult // Phase 2: SA refinement in the worker. return new Promise((resolve, reject) => { @@ -84,6 +84,6 @@ export async function runSmartLayout( reject(new Error(e.message ?? 'smartLayout worker error')) } - worker.postMessage({ nodes, relations, valid, rootIds, baseline }) + worker.postMessage({ nodes: visibleNodes, relations: visibleRelations, valid, baseline }) }) } diff --git a/src/renderer/src/store/diagramStore.ts b/src/renderer/src/store/diagramStore.ts index 763c2d33..bc886c1c 100644 --- a/src/renderer/src/store/diagramStore.ts +++ b/src/renderer/src/store/diagramStore.ts @@ -23,6 +23,7 @@ import { Presentation, SlideCanvasState, NodePosition, + PositionMap, NODE_SIZES, COLLAPSED_HEIGHT, COLLAPSED_WIDTH, @@ -45,6 +46,7 @@ import { applyTreeLayout } from '../layout/elkLayout' import { applyRadicalLayout } from '../layout/radicalLayout' import { runSmartLayout, type SmartLayoutProgress } from '../layout/smartLayoutRunner' import { minimizeCrossings } from '../layout/crossingOpt' +import { compoundPadding } from '../layout/geometry' import { LiveColaLayout } from '../layout/liveColaLayout' import { documents, useDocumentsStore } from './documentStore' import { isViewerProfile } from '../runtime' @@ -358,6 +360,62 @@ function filterForView( return { nodes, relations } } +interface LayoutInput { + nodes: Record + relations: Record + viewFilter: Set | undefined + viewCollapsedSet: Set + expandedSet: Set | undefined +} + +/** + * The graph a layout algorithm should see for the active view: the same + * node filter, collapse rules (view-collapsed, per-view collapsed and + * expanded overrides) and hidden relations the canvas applies when it + * renders. `collapsed` on the returned nodes is the effective state. + */ +function layoutInputForView(state: DiagramStore): LayoutInput { + const view = state.activeViewId ? state.views[state.activeViewId] : undefined + const viewFilter = computeViewNodeSet(view, state.c4Nodes) + const viewCollapsedSet = computeViewCollapsedSet(viewFilter, state.c4Nodes) + for (const id of view?.collapsedNodeIds ?? []) viewCollapsedSet.add(id) + const expandedSet = view?.expandedNodeIds?.length ? new Set(view.expandedNodeIds) : undefined + + const filtered = filterForView(state.c4Nodes, state.c4Relations, viewFilter) + const nodes: Record = {} + for (const [id, n] of Object.entries(filtered.nodes)) { + const collapsed = isEffectivelyCollapsed(n, viewCollapsedSet, expandedSet) + nodes[id] = collapsed === n.collapsed ? n : { ...n, collapsed } + } + const hidden = new Set(view?.hiddenRelationIds ?? []) + const relations: Record = {} + for (const [id, r] of Object.entries(filtered.relations)) { + if (!hidden.has(id)) relations[id] = r + } + return { nodes, relations, viewFilter, viewCollapsedSet, expandedSet } +} + +/** + * Write a layout result into the model. Collapsed nodes keep their stored + * size: the canvas draws them at the collapsed size anyway, and the stored + * one is what they expand back to. + */ +function applyLayoutPositions( + c4Nodes: Record, + positions: PositionMap, + input: LayoutInput, +): void { + for (const [id, pos] of Object.entries(positions)) { + const node = c4Nodes[id] + if (!node) continue + node.x = pos.x + node.y = pos.y + if (input.nodes[id]?.collapsed) continue + if (pos.width) node.width = pos.width + if (pos.height) node.height = pos.height + } +} + /** * Walk up the parent chain. Returns true if the node is hidden because * one of its ancestors is collapsed (model or view-collapsed). @@ -1182,7 +1240,7 @@ interface DiagramStore { _sync: () => void _pushUndo: () => void _markMilestoneEdit: () => void - _resizeParentsBottomUp: (viewFilter?: Set, viewCollapsedSet?: Set) => void + _resizeParentsBottomUp: (viewFilter?: Set, viewCollapsedSet?: Set, expandedSet?: Set) => void } // ─── Live layout singleton (not serialisable → kept outside store) ─────────── @@ -2804,9 +2862,9 @@ export const useDiagramStore = create()( if (viewFilter) children = children.filter((c) => viewFilter.has(c.id)) if (children.length === 0) return - // Padding matching ELK CHILD_OPTIONS (direction RIGHT, same for both levels) - const padRight = (parent.type === 'container') ? 20 : 30 - const padBottom = (parent.type === 'container') ? 20 : 30 + const pad = compoundPadding(parent.type) + const padRight = pad.side + const padBottom = pad.bottom let maxRight = 0 let maxBottom = 0 @@ -2841,7 +2899,7 @@ export const useDiagramStore = create()( // ── layout ─────────────────────────────────────────────────────────── /** Resize every parent container bottom-up after children have moved. */ - _resizeParentsBottomUp(viewFilter, viewCollapsedSet) { + _resizeParentsBottomUp(viewFilter, viewCollapsedSet, expandedSet) { const allNodes = Object.values(get().c4Nodes) // Only process parents that are in the view (or all if no filter) const relevantNodes = viewFilter @@ -2856,7 +2914,7 @@ export const useDiagramStore = create()( } parentIds.sort((a, b) => nodeDepth(b) - nodeDepth(a)) // deepest first for (const pid of parentIds) { - get().fitParentToChildren(pid, viewFilter, viewCollapsedSet) + get().fitParentToChildren(pid, viewFilter, viewCollapsedSet, expandedSet) } }, @@ -2864,29 +2922,17 @@ export const useDiagramStore = create()( if (get().appMode !== 'designer') return set((state) => { state.isLayoutRunning = true }) try { - const state = get() - const view = state.activeViewId ? state.views[state.activeViewId] : undefined - const vf = computeViewNodeSet(view, state.c4Nodes) - const vcs = computeViewCollapsedSet(vf, state.c4Nodes) - const { nodes: c4Nodes, relations: c4Relations } = filterForView(state.c4Nodes, state.c4Relations, vf, vcs) - const positions = applyRadicalLayout(c4Nodes, c4Relations) - set((state) => { - for (const [id, pos] of Object.entries(positions)) { - const node = state.c4Nodes[id] - if (!node) continue - node.x = pos.x - node.y = pos.y - if (pos.width) node.width = pos.width - if (pos.height) node.height = pos.height - } - }) + const input = layoutInputForView(get()) + const { viewFilter: vf, viewCollapsedSet: vcs, expandedSet } = input + const positions = applyRadicalLayout(input.nodes, input.relations) + set((state) => { applyLayoutPositions(state.c4Nodes, positions, input) }) // Resize parents bottom-up for any compound nodes not sized by radical - get()._resizeParentsBottomUp(vf, vcs) + get()._resizeParentsBottomUp(vf, vcs, expandedSet) get()._sync() // Crossing minimisation: swap siblings to reduce edge crossings/overlaps - const { nodes: viewNodes, relations: viewRels } = filterForView(get().c4Nodes, get().c4Relations, vf, vcs) + const { nodes: viewNodes, relations: viewRels } = layoutInputForView(get()) const crossOpt = minimizeCrossings(viewNodes, viewRels) if (Object.keys(crossOpt).length > 0) { set((state) => { @@ -2895,12 +2941,12 @@ export const useDiagramStore = create()( if (n) { n.x = pos.x; n.y = pos.y } } }) - get()._resizeParentsBottomUp(vf, vcs) + get()._resizeParentsBottomUp(vf, vcs, expandedSet) get()._sync() } // Post-layout collision safety - const { nodes: safetyNodes } = filterForView(get().c4Nodes, get().c4Relations, vf, vcs) + const { nodes: safetyNodes } = layoutInputForView(get()) const rootIds = Object.values(safetyNodes) .filter((n) => !n.parentId) .map((n) => n.id) @@ -2925,27 +2971,15 @@ export const useDiagramStore = create()( if (get().appMode === 'metamodel') return set((state) => { state.isLayoutRunning = true }) try { - const state = get() - const view = state.activeViewId ? state.views[state.activeViewId] : undefined - const vf = computeViewNodeSet(view, state.c4Nodes) - const vcs = computeViewCollapsedSet(vf, state.c4Nodes) - const { nodes: c4Nodes, relations: c4Relations } = filterForView(state.c4Nodes, state.c4Relations, vf, vcs) - const positions = await applyTreeLayout(c4Nodes, c4Relations) + const input = layoutInputForView(get()) + const { viewFilter: vf, viewCollapsedSet: vcs, expandedSet } = input + const positions = await applyTreeLayout(input.nodes, input.relations) get()._markMilestoneEdit() - set((state) => { - for (const [id, pos] of Object.entries(positions)) { - const node = state.c4Nodes[id] - if (!node) continue - node.x = pos.x - node.y = pos.y - if (pos.width) node.width = pos.width - if (pos.height) node.height = pos.height - } - }) - get()._resizeParentsBottomUp(vf, vcs) + set((state) => { applyLayoutPositions(state.c4Nodes, positions, input) }) + get()._resizeParentsBottomUp(vf, vcs, expandedSet) // Final collision-safety pass at root level. - const { nodes: safetyNodes } = filterForView(get().c4Nodes, get().c4Relations, vf, vcs) + const { nodes: safetyNodes } = layoutInputForView(get()) const rootIds = Object.values(safetyNodes) .filter((n) => !n.parentId) .map((n) => n.id) @@ -2998,33 +3032,25 @@ export const useDiagramStore = create()( } set((state) => { state.isLayoutRunning = true; state.smartLayoutProgress = { phase: 'candidates', done: 0, total: 10 } }) try { - const state = get() - const view = state.activeViewId ? state.views[state.activeViewId] : undefined - const vf = computeViewNodeSet(view, state.c4Nodes) - const vcs = computeViewCollapsedSet(vf, state.c4Nodes) - const { nodes: c4Nodes, relations: c4Relations } = filterForView(state.c4Nodes, state.c4Relations, vf, vcs) - const result = await runSmartLayout(c4Nodes, c4Relations, state.metamodel as Metamodel | undefined, (progress) => { + const input = layoutInputForView(get()) + const { viewFilter: vf, viewCollapsedSet: vcs, expandedSet } = input + const result = await runSmartLayout(input.nodes, input.relations, get().metamodel as Metamodel | undefined, (progress) => { set((s) => { s.smartLayoutProgress = progress }) }) if (result.candidates.length === 0) { get().pushNotification('Smart layout: no candidate produced a result.', 'warning') return } + if (result.keptCurrent) { + get().pushNotification('Smart layout: the current layout already scores best — nothing changed.', 'info') + return + } get()._markMilestoneEdit() - set((state) => { - for (const [id, pos] of Object.entries(result.winner.positions)) { - const node = state.c4Nodes[id] - if (!node) continue - node.x = pos.x - node.y = pos.y - if (pos.width) node.width = pos.width - if (pos.height) node.height = pos.height - } - }) - get()._resizeParentsBottomUp(vf, vcs) + set((state) => { applyLayoutPositions(state.c4Nodes, result.winner.positions, input) }) + get()._resizeParentsBottomUp(vf, vcs, expandedSet) // Final collision-safety pass at root level (same as ELK/Radical paths). - const { nodes: safetyNodes } = filterForView(get().c4Nodes, get().c4Relations, vf, vcs) + const { nodes: safetyNodes } = layoutInputForView(get()) const rootIds = Object.values(safetyNodes) .filter((n) => !n.parentId) .map((n) => n.id) diff --git a/tests/annealing.test.ts b/tests/annealing.test.ts new file mode 100644 index 00000000..87bebdb8 --- /dev/null +++ b/tests/annealing.test.ts @@ -0,0 +1,127 @@ +/** + * The annealing engine behind Smart Layout's refinement phases. + * + * Guards the three properties the refinement relies on: the incremental + * energy is exact (a drifting delta silently steers the search wrong), the + * annealer never creates overlapping siblings, and a seed fully determines + * the outcome. + */ +import { describe, it, expect } from 'vitest' +import { + anneal, buildLayoutGraph, createRng, graphSeed, ProxyEnergy, type LayoutGraph, +} from '../src/renderer/src/layout/annealing' +import type { C4Node, C4Relation } from '../src/renderer/src/types/c4' + +function node(id: string, x: number, y: number, w = 200, h = 100, parentId?: string): C4Node { + return { id, type: parentId ? 'container' : 'system', label: id, x, y, width: w, height: h, collapsed: false, parentId } +} + +/** Twelve roots (some with children) wired pseudo-randomly and scattered so they cross and overlap. */ +function scatteredGraph(seed: number): { nodes: Record; relations: Record } { + const rng = createRng(seed) + const nodes: Record = {} + const leaves: string[] = [] + for (let i = 0; i < 12; i++) { + nodes[`s${i}`] = node(`s${i}`, rng() * 1600, rng() * 1000, 360, 260) + if (i % 3 === 0) { + for (let j = 0; j < 2; j++) { + const id = `s${i}c${j}` + nodes[id] = node(id, 20 + j * 160, 120, 140, 80, `s${i}`) + leaves.push(id) + } + } else { + leaves.push(`s${i}`) + } + } + const relations: Record = {} + for (let k = 0; k < 24; k++) { + const s = leaves[Math.floor(rng() * leaves.length)] + const t = leaves[Math.floor(rng() * leaves.length)] + if (s !== t) relations[`r${k}`] = { id: `r${k}`, sourceId: s, targetId: t } + } + return { nodes, relations } +} + +function roots(g: LayoutGraph): number[] { + return Array.from({ length: g.n }, (_, i) => i).filter((i) => g.parent[i] === -1) +} + +describe('ProxyEnergy', () => { + it('keeps its incremental totals equal to a full recount', () => { + const { nodes, relations } = scatteredGraph(3) + const g = buildLayoutGraph(nodes, relations) + const ev = new ProxyEnergy(g) + const rng = createRng(11) + const rootIdx = roots(g) + for (let i = 0; i < 500; i++) { + const a = rootIdx[Math.floor(rng() * rootIdx.length)] + const b = rootIdx[Math.floor(rng() * rootIdx.length)] + const moves = [{ node: a, dx: (rng() - 0.5) * 400, dy: (rng() - 0.5) * 400 }] + if (a !== b && rng() < 0.3) moves.push({ node: b, dx: (rng() - 0.5) * 400, dy: (rng() - 0.5) * 400 }) + ev.apply(moves) + if (rng() < 0.5) ev.commit() + else ev.revert() + + const incremental = ev.energy() + ev.recomputeTotals() + expect(ev.energy()).toBeCloseTo(incremental, 6) + } + }) +}) + +describe('anneal', () => { + const GAP = 40 + + function siblingsOverlap(g: LayoutGraph, group: number[]): boolean { + for (let a = 0; a < group.length; a++) { + for (let b = a + 1; b < group.length; b++) { + const i = group[a], j = group[b] + const ox = Math.min(g.relX[i] + g.w[i], g.relX[j] + g.w[j]) - Math.max(g.relX[i], g.relX[j]) + GAP + const oy = Math.min(g.relY[i] + g.h[i], g.relY[j] + g.h[j]) - Math.max(g.relY[i], g.relY[j]) + GAP + if (ox > 1e-6 && oy > 1e-6) return true + } + } + return false + } + + function run(seed: number): { g: LayoutGraph; group: number[]; before: number; after: number } { + // A clean grid (no overlaps) with crossing wiring — plenty to improve. + const { nodes, relations } = scatteredGraph(5) + Object.values(nodes).filter((n) => !n.parentId).forEach((n, i) => { + n.x = (i % 4) * 500 + n.y = Math.floor(i / 4) * 400 + }) + const g = buildLayoutGraph(nodes, relations) + const group = roots(g) + const res = anneal(new ProxyEnergy(g), g, group, { + rng: createRng(seed), restarts: 3, sweeps: 40, + stepFactor: 0.06, tempFactor: 1, calibrationSamples: 16, + gap: GAP, deadline: Infinity, + }) + return { g, group, before: res.before, after: res.after } + } + + it('lowers the energy without ever creating overlapping siblings', () => { + const { g, group, before, after } = run(1) + expect(after).toBeLessThan(before) + expect(siblingsOverlap(g, group)).toBe(false) + }) + + it('is fully determined by its seed', () => { + const a = run(7) + const b = run(7) + expect(Array.from(a.g.relX)).toEqual(Array.from(b.g.relX)) + expect(Array.from(a.g.relY)).toEqual(Array.from(b.g.relY)) + expect(a.after).toBe(b.after) + }) +}) + +describe('graphSeed', () => { + it('depends on structure, not on object key order or positions', () => { + const { nodes, relations } = scatteredGraph(2) + const reordered = Object.fromEntries(Object.entries(nodes).reverse().map(([id, n]) => [id, { ...n, x: n.x + 99 }])) + expect(graphSeed(reordered, relations)).toBe(graphSeed(nodes, relations)) + const { r0: _dropped, ...fewer } = relations + expect(graphSeed(nodes, fewer)).not.toBe(graphSeed(nodes, relations)) + }) +}) diff --git a/tests/layoutPipeline.test.ts b/tests/layoutPipeline.test.ts new file mode 100644 index 00000000..3a7bfec3 --- /dev/null +++ b/tests/layoutPipeline.test.ts @@ -0,0 +1,85 @@ +/** + * Layout pipeline plumbing shared by every layout engine: what graph the + * engines see (visible projection) and how a raw layout is turned into the + * one that gets rendered (finalizeLayout). + */ +import { describe, it, expect } from 'vitest' +import { projectToVisibleGraph } from '../src/renderer/src/layout/geometry' +import { finalizeLayout, separateBoxes, ROOT_GAP } from '../src/renderer/src/layout/layoutFinalize' +import { applyElkLayout } from '../src/renderer/src/layout/elkLayout' +import { COLLAPSED_HEIGHT, COLLAPSED_WIDTH, NODE_SIZES, type C4Node, type C4Relation } from '../src/renderer/src/types/c4' + +function node(id: string, type: C4Node['type'], extras: Partial = {}): C4Node { + return { id, type, label: id, x: 0, y: 0, collapsed: false, ...NODE_SIZES[type], ...extras } +} +function rel(id: string, sourceId: string, targetId: string): C4Relation { + return { id, sourceId, targetId } +} + +/** A collapsed system whose hidden containers carry relations. */ +function collapsedScene(): { nodes: Record; relations: Record } { + const nodes: Record = { + p: node('p', 'person'), + A: node('A', 'system', { collapsed: true, width: 900, height: 600 }), + a1: node('a1', 'container', { parentId: 'A', x: 20, y: 120 }), + a2: node('a2', 'container', { parentId: 'A', x: 600, y: 450 }), + B: node('B', 'system'), + } + const relations: Record = { + r1: rel('r1', 'p', 'a1'), + r2: rel('r2', 'a2', 'B'), + r3: rel('r3', 'a1', 'a2'), // both ends hidden in the same box → self-loop + r4: rel('r4', 'a1', 'B'), // duplicates r2 once projected + } + return { nodes, relations } +} + +describe('projectToVisibleGraph', () => { + it('drops hidden nodes and re-attaches their relations to the visible ancestor', () => { + const { nodes, relations } = projectToVisibleGraph(collapsedScene().nodes, collapsedScene().relations) + + expect(Object.keys(nodes).sort()).toEqual(['A', 'B', 'p']) + expect(nodes.A.width).toBe(COLLAPSED_WIDTH.system) + expect(nodes.A.height).toBe(COLLAPSED_HEIGHT.system) + expect(Object.values(relations).map((r) => `${r.sourceId}>${r.targetId}`).sort()).toEqual(['A>B', 'p>A']) + }) +}) + +describe('applyElkLayout with a collapsed container', () => { + it('lays out the visible graph instead of failing on edges to hidden children', async () => { + const { nodes, relations } = collapsedScene() + const positions = await applyElkLayout(nodes, relations) + expect(Object.keys(positions).sort()).toEqual(['A', 'B', 'p']) + }) +}) + +describe('finalizeLayout', () => { + it('pushes overlapping roots apart to the root gap', () => { + const boxes = [ + { x: 0, y: 0, width: 200, height: 100 }, + { x: 50, y: 20, width: 200, height: 100 }, + { x: 60, y: 30, width: 200, height: 100 }, + ] + separateBoxes(boxes, ROOT_GAP) + for (let i = 0; i < boxes.length; i++) { + for (let j = i + 1; j < boxes.length; j++) { + const a = boxes[i], b = boxes[j] + const ox = Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x) + ROOT_GAP + const oy = Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y) + ROOT_GAP + expect(ox > 1e-6 && oy > 1e-6).toBe(false) + } + } + }) + + it('fits compounds around their children with header room and a minimum size', () => { + const nodes: Record = { + S: node('S', 'system', { width: 50, height: 50 }), + c: node('c', 'component', { parentId: 'S', x: -300, y: -300, width: 100, height: 60 }), + } + const out = finalizeLayout(nodes, {}) + expect(out.c.y).toBe(120) + expect(out.c.x).toBeGreaterThan(0) + expect(out.S.width).toBeGreaterThanOrEqual(NODE_SIZES.system.width) + expect(out.S.height).toBeGreaterThanOrEqual(120 + 60) + }) +}) diff --git a/tests/radicalLayout.test.ts b/tests/radicalLayout.test.ts index 12f35acb..640f8eef 100644 --- a/tests/radicalLayout.test.ts +++ b/tests/radicalLayout.test.ts @@ -674,4 +674,64 @@ describe('Radical Layout — Edge Cases', () => { } } }) + + it('lays out nested systems and non-container children (databases, queues)', () => { + const nodes: Record = { + sys: { id: 'sys', type: 'system', label: 'Platform', collapsed: false, x: 0, y: 0, ...NODE_SIZES.system }, + sub: { id: 'sub', type: 'system', label: 'Payments', parentId: 'sys', collapsed: false, x: 0, y: 0, ...NODE_SIZES.system }, + api: { id: 'api', type: 'container', label: 'API', parentId: 'sub', collapsed: false, x: 0, y: 0, ...NODE_SIZES.container }, + db: { id: 'db', type: 'database', label: 'DB', parentId: 'sub', collapsed: false, x: 0, y: 0, ...NODE_SIZES.database }, + q: { id: 'q', type: 'queue', label: 'Queue', parentId: 'sys', collapsed: false, x: 0, y: 0, ...NODE_SIZES.queue }, + } + const relations: Record = { + r1: { id: 'r1', sourceId: 'api', targetId: 'db' }, + r2: { id: 'r2', sourceId: 'api', targetId: 'q' }, + } + const positions = applyRadicalLayout(nodes, relations) + + for (const id of Object.keys(nodes)) expect(positions[id], `missing ${id}`).toBeDefined() + for (const n of Object.values(nodes)) { + if (!n.parentId) continue + const p = positions[n.id], parent = positions[n.parentId] + expect(p.x + (p.width ?? n.width), `${n.id} sticks out of ${n.parentId}`).toBeLessThanOrEqual(parent.width!) + expect(p.y + (p.height ?? n.height), `${n.id} sticks out of ${n.parentId}`).toBeLessThanOrEqual(parent.height!) + } + expect(overlaps( + positions.api.x, positions.api.y, positions.api.width!, positions.api.height!, + positions.db.x, positions.db.y, positions.db.width!, positions.db.height!, + )).toBe(false) + }) + + it('keeps the entry system above the system that calls back into it', () => { + const sys = (id: string): C4Node => ({ id, type: 'system', label: id, collapsed: false, x: 0, y: 0, ...NODE_SIZES.system }) + const nodes: Record = { + user: { id: 'user', type: 'person', label: 'User', collapsed: false, x: 0, y: 0, ...NODE_SIZES.person }, + A: sys('A'), B: sys('B'), C: sys('C'), + } + const relations: Record = { + r1: { id: 'r1', sourceId: 'user', targetId: 'A' }, + r2: { id: 'r2', sourceId: 'A', targetId: 'B' }, + r3: { id: 'r3', sourceId: 'B', targetId: 'A' }, + r4: { id: 'r4', sourceId: 'B', targetId: 'C' }, + } + const positions = applyRadicalLayout(nodes, relations) + expect(positions.A.y).toBeLessThan(positions.B.y) + expect(positions.B.y).toBeLessThan(positions.C.y) + }) + + it('sizes an expanded external system around its children', () => { + const nodes: Record = { + S: { id: 'S', type: 'system', label: 'S', collapsed: false, x: 0, y: 0, ...NODE_SIZES.system }, + X: { id: 'X', type: 'system', label: 'X', external: true, collapsed: false, x: 0, y: 0, ...NODE_SIZES.system }, + x1: { id: 'x1', type: 'container', label: 'x1', parentId: 'X', collapsed: false, x: 0, y: 0, ...NODE_SIZES.container }, + x2: { id: 'x2', type: 'container', label: 'x2', parentId: 'X', collapsed: false, x: 0, y: 0, ...NODE_SIZES.container }, + } + const relations: Record = { r1: { id: 'r1', sourceId: 'S', targetId: 'x1' } } + const positions = applyRadicalLayout(nodes, relations) + for (const id of ['x1', 'x2']) { + expect(positions[id]).toBeDefined() + expect(positions[id].x + positions[id].width!).toBeLessThanOrEqual(positions.X.width!) + expect(positions[id].y + positions[id].height!).toBeLessThanOrEqual(positions.X.height!) + } + }) }) diff --git a/tests/saThroughput.bench.mjs b/tests/saThroughput.bench.mjs index 96270e24..1ac480cc 100644 --- a/tests/saThroughput.bench.mjs +++ b/tests/saThroughput.bench.mjs @@ -1,13 +1,15 @@ /** - * Benchmark: SA iterations achieved within a fixed wall-clock budget. + * Benchmark: Smart Layout wall-clock time and SA throughput. * - * Runs `runSmartLayout` on a moderately complex scenario (the fintech-ish - * synthetic graph below) and reports SA throughput as iterations/second. + * Runs `runSmartLayoutCore` on a moderately complex scenario (the + * fintech-ish synthetic graph below) and reports SA throughput as + * iterations/second. Budgets are work-based, so iterations and composite + * should be identical on every run — only the wall-clock time varies. * * Run: node --import tsx tests/saThroughput.bench.mjs */ -const { runSmartLayout } = await import( +const { runSmartLayoutCore } = await import( '../src/renderer/src/layout/smartLayout.ts' ) @@ -71,7 +73,7 @@ const totals = { wall: [], iter: [], composite: [] } for (let i = 0; i < RUNS; i++) { const { nodes, relations } = fintechLike() const t0 = performance.now() - const result = await runSmartLayout(nodes, relations) + const result = await runSmartLayoutCore(nodes, relations) const dt = performance.now() - t0 totals.wall.push(dt) totals.iter.push(result.refinement.iterations) diff --git a/tests/smartLayout.test.ts b/tests/smartLayout.test.ts index d11dd531..6a4ede6a 100644 --- a/tests/smartLayout.test.ts +++ b/tests/smartLayout.test.ts @@ -10,7 +10,7 @@ */ import { describe, it, expect } from 'vitest' import { computeCompositeScore, runSmartLayoutCore } from '../src/renderer/src/layout/smartLayout' -import type { C4Node, C4Relation } from '../src/renderer/src/types/c4' +import type { C4Node, C4Relation, PositionMap } from '../src/renderer/src/types/c4' function node(id: string, x: number, y: number, w = 200, h = 100, parentId?: string): C4Node { return { id, type: 'system', label: id, x, y, width: w, height: h, collapsed: false, parentId } @@ -119,3 +119,85 @@ describe('runSmartLayoutCore (end-to-end ensemble + SA refinement)', () => { expect(result.winner.metrics.weightedCost).toBeLessThan(result.baseline.weightedCost) }) }) + +/** Person → web app → two services → database, plus an external system, all dumped on top of each other. */ +function pileUp(): { nodes: Record; relations: Record } { + const nodes: Record = { + user: { ...node('user', 0, 0, 120, 70), type: 'person' }, + sys: node('sys', 10, 10, 900, 500), + web: { ...node('web', 20, 120, 200, 90, 'sys'), type: 'container' }, + api: { ...node('api', 30, 130, 200, 90, 'sys'), type: 'container' }, + jobs: { ...node('jobs', 40, 140, 200, 90, 'sys'), type: 'container' }, + db: { ...node('db', 50, 150, 130, 80, 'sys'), type: 'database' }, + pay: { ...node('pay', 20, 20, 240, 110), external: true }, + crm: node('crm', 30, 30, 240, 110), + } + const relations: Record = { + r1: rel('r1', 'user', 'web'), + r2: rel('r2', 'web', 'api'), + r3: rel('r3', 'api', 'db'), + r4: rel('r4', 'jobs', 'db'), + r5: rel('r5', 'api', 'pay'), + r6: rel('r6', 'jobs', 'crm'), + r7: rel('r7', 'user', 'crm'), + } + return { nodes, relations } +} + +function applyPositions(nodes: Record, positions: PositionMap): Record { + const out: Record = {} + for (const [id, n] of Object.entries(nodes)) { + const p = positions[id] + out[id] = p ? { ...n, x: p.x, y: p.y, width: p.width ?? n.width, height: p.height ?? n.height } : n + } + return out +} + +describe('runSmartLayoutCore guarantees', () => { + it('returns no overlapping siblings and never loses to its own best candidate', async () => { + const { nodes, relations } = pileUp() + const result = await runSmartLayoutCore(nodes, relations) + const placed = applyPositions(nodes, result.winner.positions) + + const byParent = new Map() + for (const n of Object.values(placed)) { + const key = n.parentId ?? '' + byParent.set(key, [...(byParent.get(key) ?? []), n]) + } + for (const siblings of byParent.values()) { + for (let i = 0; i < siblings.length; i++) { + for (let j = i + 1; j < siblings.length; j++) { + const a = siblings[i], b = siblings[j] + const ox = Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x) + const oy = Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y) + expect(ox > 0 && oy > 0, `${a.id} overlaps ${b.id}`).toBe(false) + } + } + } + + const bestCandidate = Math.min(...result.candidates.map((c) => c.score.composite)) + expect(result.winner.score.composite).toBeLessThanOrEqual(bestCandidate) + expect(result.winner.score.composite).toBeLessThanOrEqual(computeCompositeScore(nodes, relations).composite) + }) + + it('gives the same diagram the same layout every time', async () => { + const a = await runSmartLayoutCore(pileUp().nodes, pileUp().relations) + const b = await runSmartLayoutCore(pileUp().nodes, pileUp().relations) + expect(b.winner.positions).toEqual(a.winner.positions) + }) + + it('keeps the current layout when running again cannot beat it', async () => { + const { nodes, relations } = pileUp() + const first = await runSmartLayoutCore(nodes, relations) + const second = await runSmartLayoutCore(applyPositions(nodes, first.winner.positions), relations) + expect(second.keptCurrent).toBe(true) + }) + + it('runs every candidate when a collapsed container hides related children', async () => { + const { nodes, relations } = pileUp() + nodes.sys = { ...nodes.sys, collapsed: true } + const result = await runSmartLayoutCore(nodes, relations) + expect(result.candidates).toHaveLength(10) + expect(result.winner.positions.web).toBeUndefined() + }) +})