diff --git a/src/renderer/src/components/SmartLayoutButton.tsx b/src/renderer/src/components/SmartLayoutButton.tsx index 9d799271..43f05ae1 100644 --- a/src/renderer/src/components/SmartLayoutButton.tsx +++ b/src/renderer/src/components/SmartLayoutButton.tsx @@ -44,6 +44,7 @@ const IconInfo = () => ( function smartLayoutProgressLabel(p: SmartLayoutProgress | null): string | null { if (!p) return null if (p.phase === 'candidates') return `Trying ${p.total} algorithms… ${p.done}/${p.total}` + if (p.phase === 'ranking') return `Scoring layouts… ${p.done}/${p.total}` if (p.phase === 'refining-a') return 'Refining with simulated annealing…' if (p.phase === 'refining-b') return 'Refining containers…' return 'Polishing…' diff --git a/src/renderer/src/layout/crossingOpt.ts b/src/renderer/src/layout/crossingOpt.ts index f64f5cdc..5f02ca6b 100644 --- a/src/renderer/src/layout/crossingOpt.ts +++ b/src/renderer/src/layout/crossingOpt.ts @@ -27,8 +27,11 @@ * * ## Complexity * - * O(passes × |siblings|² × (|edges|² + |edges| × |nodes|)) per group. - * For typical C4 diagrams this runs in < 5 ms total. + * Each candidate move is scored by `localCost` — only the pairs involving + * the edges and rects it moves — so one evaluation costs + * O(|moved edges| × (|edges| + |nodes|) + |edges| × |moved nodes|) instead + * of re-scoring the whole group. Decisions are identical to comparing full + * costs, since both are exact integer counts. * * References: * Jünger & Mutzel (1997), "2-Layer Straightline Crossing Minimisation" @@ -155,6 +158,62 @@ function layoutCost( return crossings * CROSSING_WEIGHT + overdraws * OVERDRAW_WEIGHT } +/** + * The part of `layoutCost` a move can change: edge pairs where at least one + * edge touches a moved node (`touched`), and (edge, node) pairs where the + * edge is touched or the node itself moved. Each pair is visited once, and + * which pairs are visited depends only on `touched`/`movedIds` — so + * `localCost` after a move minus `localCost` before it equals the change in + * `layoutCost`, without re-testing every pair the move cannot affect. + */ +function localCost( + pos: Record, + edges: EdgePair[], + touched: boolean[], + movedIds: string[], + ancestors: Record>, + allIds: string[] +): number { + let crossings = 0 + let overdraws = 0 + const noAncestors = new Set() + + for (let i = 0; i < edges.length; i++) { + const a = edges[i] + const r1 = pos[a.src], r2 = pos[a.tgt] + if (!r1 || !r2) continue + const p1 = centre(r1), p2 = centre(r2) + const srcAnc = ancestors[a.src] ?? noAncestors + const tgtAnc = ancestors[a.tgt] ?? noAncestors + + if (touched[i]) { + for (const nid of allIds) { + if (nid === a.src || nid === a.tgt) continue + if (srcAnc.has(nid) || tgtAnc.has(nid)) continue + const rect = pos[nid] + if (rect && segmentIntersectsRect(p1, p2, rect)) overdraws++ + } + for (let j = 0; j < edges.length; j++) { + if (j === i || (touched[j] && j < i)) continue + const b = edges[j] + if (a.src === b.src || a.src === b.tgt || a.tgt === b.src || a.tgt === b.tgt) continue + const r3 = pos[b.src], r4 = pos[b.tgt] + if (!r3 || !r4) continue + if (intersects(p1, p2, centre(r3), centre(r4))) crossings++ + } + } else { + for (const nid of movedIds) { + if (nid === a.src || nid === a.tgt) continue + if (srcAnc.has(nid) || tgtAnc.has(nid)) continue + const rect = pos[nid] + if (rect && segmentIntersectsRect(p1, p2, rect)) overdraws++ + } + } + } + + return crossings * CROSSING_WEIGHT + overdraws * OVERDRAW_WEIGHT +} + // ── Optional: full diagram cost (for logging / debugging) ──────────────────── export interface LayoutMetrics { @@ -309,6 +368,13 @@ export function minimizeCrossings( childrenOf.get(key)!.push(n.id) } + /** The node plus all its descendants. */ + function subtreeOf(nodeId: string, out: string[] = []): string[] { + out.push(nodeId) + for (const childId of childrenOf.get(nodeId) ?? []) subtreeOf(childId, out) + return out + } + /** Shift all descendants' absolute positions after a parent swap */ function shiftDescendants(nodeId: string, dx: number, dy: number): void { for (const childId of childrenOf.get(nodeId) ?? []) { @@ -378,14 +444,18 @@ export function minimizeCrossings( const ia = swapIds[i], ib = swapIds[j] const ra = abs[ia], rb = abs[ib] - const before = layoutCost(abs, relevant, ancestors, allIds) + // Only the two rects move while the swap is evaluated (their + // descendants follow once it is accepted). + const moved = [ia, ib] + const touched = relevant.map(e => e.src === ia || e.src === ib || e.tgt === ia || e.tgt === ib) + const before = localCost(abs, relevant, touched, moved, ancestors, allIds) // Swap top-left positions; each node retains its own dimensions const sx = ra.x, sy = ra.y ra.x = rb.x; ra.y = rb.y rb.x = sx; rb.y = sy - const after = layoutCost(abs, relevant, ancestors, allIds) + const after = localCost(abs, relevant, touched, moved, ancestors, allIds) if (after < before) { // Accept swap — propagate position deltas to descendants @@ -450,7 +520,10 @@ export function minimizeCrossings( for (const id of swapIds) { const r = abs[id] if (!r) continue - const baseCost = layoutCost(abs, relevant, ancestors, allIds) + const moved = subtreeOf(id) + const movedSet = new Set(moved) + const touched = relevant.map(e => movedSet.has(e.src) || movedSet.has(e.tgt)) + const baseCost = localCost(abs, relevant, touched, moved, ancestors, allIds) let bestDx = 0, bestDy = 0, bestCost = baseCost // X axis @@ -458,7 +531,7 @@ export function minimizeCrossings( if (wouldCollide(id, r, dx, 0)) continue r.x += dx shiftDescendants(id, dx, 0) - const c = layoutCost(abs, relevant, ancestors, allIds) + const c = localCost(abs, relevant, touched, moved, ancestors, allIds) if (c < bestCost) { bestCost = c; bestDx = dx; bestDy = 0 } r.x -= dx shiftDescendants(id, -dx, 0) @@ -468,7 +541,7 @@ export function minimizeCrossings( if (wouldCollide(id, r, 0, dy)) continue r.y += dy shiftDescendants(id, 0, dy) - const c = layoutCost(abs, relevant, ancestors, allIds) + const c = localCost(abs, relevant, touched, moved, ancestors, allIds) if (c < bestCost) { bestCost = c; bestDx = 0; bestDy = dy } r.y -= dy shiftDescendants(id, 0, -dy) diff --git a/src/renderer/src/layout/smartLayout.ts b/src/renderer/src/layout/smartLayout.ts index b8f43ee2..ab5dde35 100644 --- a/src/renderer/src/layout/smartLayout.ts +++ b/src/renderer/src/layout/smartLayout.ts @@ -981,18 +981,38 @@ function projectPositions(nodes: Record, positions: PositionMap) return out } -async function runCandidate( +/** A candidate layout straight out of its engine, before any post-processing. */ +export interface RawCandidate { + name: string + positions: PositionMap +} + +/** Run one engine; failures and empty results drop the candidate. */ +async function generateCandidate( name: string, + run: () => Promise | PositionMap, +): Promise { + try { + const positions = await run() + return Object.keys(positions).length === 0 ? null : { name, positions } + } catch (err) { + console.warn(`[smartLayout] candidate ${name} failed:`, err) + return null + } +} + +/** + * Score one raw candidate 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. + */ +function scoreCandidate( nodes: Record, relations: Record, - positionsPromise: Promise | PositionMap, -): Promise { + candidate: RawCandidate, +): SmartLayoutCandidate | null { try { - 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 raw: PositionMap = { ...candidate.positions } const swap = minimizeCrossings(projectPositions(nodes, raw), relations) for (const [id, p] of Object.entries(swap)) { const prev = raw[id] @@ -1002,23 +1022,40 @@ async function runCandidate( const projected = projectPositions(nodes, positions) const metrics = computeLayoutMetrics(projected, relations) const score = computeCompositeScore(projected, relations) - return { name, positions, metrics, score } + return { name: candidate.name, positions, metrics, score } } catch (err) { - console.warn(`[smartLayout] candidate ${name} failed:`, err) + console.warn(`[smartLayout] candidate ${candidate.name} failed:`, err) return null } } +/** Result when no candidate survived: leave the diagram as it is. */ +function unchangedResult( + nodes: Record, + relations: Record, +): SmartLayoutResult { + const baseline = computeLayoutMetrics(nodes, relations) + const score = computeCompositeScore(nodes, relations) + return { + baseline, + winner: { name: 'baseline', metrics: baseline, score, positions: {} }, + candidates: [], + planarity: computePlanarityScore(nodes, relations), + refinement: { before: score.composite, after: score.composite, iterations: 0 }, + keptCurrent: true, + } +} + /** * Result of the ELK candidate-generation phase. * * `done: true` — all candidates failed; `result` is a baseline fallback and - * the SA phase should be skipped. - * `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. + * the worker phase should be skipped. + * `done: false` — raw candidates are ready; pass `nodes / relations / raw` + * to `runSmartLayoutWorkerPhase` (normally in the Web + * Worker). `nodes` and `relations` are the visible + * projection of the input — the worker phase must use them, + * not the raw input. */ export type ELKPhaseResult = | { done: true; result: SmartLayoutResult } @@ -1026,8 +1063,7 @@ export type ELKPhaseResult = done: false nodes: Record relations: Record - valid: SmartLayoutCandidate[] - baseline: LayoutMetrics + raw: RawCandidate[] } /** @@ -1035,7 +1071,7 @@ export type ELKPhaseResult = * trying multiple algorithms rather than just spinning opaquely. */ export type SmartLayoutProgress = - | { phase: 'candidates'; done: number; total: number } + | { phase: 'candidates' | 'ranking'; done: number; total: number } | { phase: 'refining-a' | 'refining-b' | 'refining-c' } export type SmartLayoutOnProgress = (progress: SmartLayoutProgress) => void @@ -1045,8 +1081,9 @@ export type SmartLayoutOnProgress = (progress: SmartLayoutProgress) => void * * elk-worker.min.js is a standalone Web Worker script and cannot be imported * inside another worker, so this phase must never be called from inside the - * SA worker. The result is either an early-exit baseline (`done: true`) or - * the ranked candidate set ready for `runSmartLayoutSAPhase` (`done: false`). + * Smart Layout worker. It only runs the engines; everything CPU-heavy that + * follows (crossing minimisation, scoring, annealing) belongs to + * `runSmartLayoutWorkerPhase`, off the main thread. */ export async function runSmartLayoutELKPhase( inputNodes: Record, @@ -1059,128 +1096,98 @@ export async function runSmartLayoutELKPhase( // bundled into the Web Worker chunk — it's code-split and fetched only when // this function is called from the main thread. const { applyElkLayout } = await import('./elkLayout') - const baseline = computeLayoutMetrics(nodes, relations) const depth = maxContainmentDepth(metamodel) const mmDirection: 'DOWN' | 'RIGHT' = depth >= 2 ? 'RIGHT' : 'DOWN' - // Ten structurally different candidates run in parallel. - // stress / force / mrtree may degrade on compound graphs — runCandidate - // swallows failures so the remaining set still wins. - const candidatePromises: Promise[] = [ - runCandidate( + // Ten structurally different candidates. stress / force / mrtree may + // degrade on compound graphs — generateCandidate swallows failures so the + // remaining set still wins. + const engines: [name: string, run: () => Promise | PositionMap][] = [ + [ 'Layered TB · Brandes-Köpf · model-order', - nodes, relations, - applyElkLayout(nodes, relations, { + () => applyElkLayout(nodes, relations, { rootOptions: elkLayered('DOWN', 'BRANDES_KOEPF', true), childOptions: elkLayeredChild('RIGHT', 'BRANDES_KOEPF'), }), - ), - runCandidate( + ], + [ 'Layered LR · Brandes-Köpf · model-order', - nodes, relations, - applyElkLayout(nodes, relations, { + () => applyElkLayout(nodes, relations, { rootOptions: elkLayered('RIGHT', 'BRANDES_KOEPF', true), childOptions: elkLayeredChild('DOWN', 'BRANDES_KOEPF'), }), - ), - runCandidate( + ], + [ 'Layered TB · NetworkSimplex', - nodes, relations, - applyElkLayout(nodes, relations, { + () => applyElkLayout(nodes, relations, { rootOptions: elkLayered('DOWN', 'NETWORK_SIMPLEX', false), childOptions: elkLayeredChild('RIGHT', 'NETWORK_SIMPLEX'), }), - ), - runCandidate( + ], + [ 'Layered TB · Longest-Path · Brandes-Köpf', - nodes, relations, - applyElkLayout(nodes, relations, { + () => applyElkLayout(nodes, relations, { rootOptions: elkLayered('DOWN', 'BRANDES_KOEPF', true, 'LONGEST_PATH'), childOptions: elkLayeredChild('RIGHT', 'BRANDES_KOEPF'), }), - ), - runCandidate( + ], + [ 'Layered TB · MinWidth · Brandes-Köpf', - nodes, relations, - applyElkLayout(nodes, relations, { + () => applyElkLayout(nodes, relations, { rootOptions: elkLayered('DOWN', 'BRANDES_KOEPF', true, 'MIN_WIDTH'), childOptions: elkLayeredChild('RIGHT', 'BRANDES_KOEPF'), }), - ), - runCandidate( + ], + [ `Metamodel-aware (root ${mmDirection}, Brandes-Köpf)`, - nodes, relations, - applyElkLayout(nodes, relations, { + () => applyElkLayout(nodes, relations, { rootOptions: elkLayered(mmDirection, 'BRANDES_KOEPF', true), childOptions: elkLayeredChild(mmDirection === 'DOWN' ? 'RIGHT' : 'DOWN', 'BRANDES_KOEPF'), }), - ), - runCandidate( + ], + [ 'Stress majorisation (Gansner)', - nodes, relations, - applyElkLayout(nodes, relations, { + () => applyElkLayout(nodes, relations, { rootOptions: elkStress(), childOptions: elkLayeredChild('RIGHT', 'BRANDES_KOEPF'), }), - ), - runCandidate( + ], + [ 'Force-directed (Eades)', - nodes, relations, - applyElkLayout(nodes, relations, { + () => applyElkLayout(nodes, relations, { rootOptions: elkForce(), childOptions: elkLayeredChild('RIGHT', 'BRANDES_KOEPF'), }), - ), - runCandidate( + ], + [ 'Mr.Tree (Reingold-Tilford)', - nodes, relations, - applyElkLayout(nodes, relations, { + () => applyElkLayout(nodes, relations, { rootOptions: elkMrTree(), childOptions: elkLayeredChild('RIGHT', 'BRANDES_KOEPF'), }), - ), - runCandidate( + ], + [ 'Radical (semantic C4)', - nodes, relations, - applyRadicalLayout(nodes, relations), - ), + () => applyRadicalLayout(nodes, relations), + ], ] - // Report progress as each candidate settles (order of completion, not of - // the array above) so the UI can show "N/10 algorithms tried" live instead - // of a single opaque spinner for the whole ~1s ensemble run. - const total = candidatePromises.length - let doneCount = 0 + // Engines run one at a time with a yield in between: bundled ELK runs a + // whole layout synchronously on this (main) thread, so queueing all ten at + // once would freeze the UI for their combined time. One at a time, the + // longest freeze is a single layout and "N/10 algorithms tried" advances live. + const total = engines.length + const raw: RawCandidate[] = [] onProgress?.({ phase: 'candidates', done: 0, total }) - const tracked = candidatePromises.map((p) => p.finally(() => { - doneCount++ - onProgress?.({ phase: 'candidates', done: doneCount, total }) - })) - const candidates = await Promise.all(tracked) - - const valid = candidates.filter((c): c is SmartLayoutCandidate => c !== null) - if (valid.length === 0) { - const baselineScore = computeCompositeScore(nodes, relations) - return { - done: true, - result: { - baseline, - winner: { name: 'baseline', metrics: baseline, score: baselineScore, positions: {} }, - candidates: [], - planarity: computePlanarityScore(nodes, relations), - refinement: { before: baselineScore.composite, after: baselineScore.composite, iterations: 0 }, - keptCurrent: true, - }, - } + for (let i = 0; i < total; i++) { + const [name, run] = engines[i] + const candidate = await generateCandidate(name, run) + if (candidate) raw.push(candidate) + onProgress?.({ phase: 'candidates', done: i + 1, total }) + await yieldToUI() } - - // Composite ranking — crossings dominate (W=100) but the layout still - // gets penalised for node overlap, edge-length spaghetti, and bad aspect - // ratio. This matches what users *perceive* as messy far better than - // raw crossing count alone. - valid.sort((a, b) => a.score.composite - b.score.composite) - - return { done: false, nodes, relations, valid, baseline } + if (raw.length === 0) return { done: true, result: unchangedResult(nodes, relations) } + return { done: false, nodes, relations, raw } } export async function runSmartLayoutCore( @@ -1191,31 +1198,47 @@ export async function runSmartLayoutCore( ): Promise { const elkResult = await runSmartLayoutELKPhase(nodes, relations, metamodel, onProgress) if (elkResult.done) return elkResult.result - return runSmartLayoutSAPhase(elkResult.nodes, elkResult.relations, elkResult.valid, elkResult.baseline, onProgress) + return runSmartLayoutWorkerPhase(elkResult.nodes, elkResult.relations, elkResult.raw, onProgress) } /** - * SA-only refinement phase — runs phases A / B / C of the smart layout - * pipeline on pre-computed ELK candidates. + * Everything after the engines have run: rank the raw candidates (crossing + * minimisation, finalising, scoring), then refine the winner with annealing + * phases A / B / C. * * Separated from runSmartLayoutCore so that it can be executed in a Web * Worker that does NOT include ELK (elk-worker.min.js is a standalone worker - * 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. + * script that cannot be imported inside another worker). Only the engines + * run on the main thread; all of this runs off it. * * `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( +export async function runSmartLayoutWorkerPhase( nodes: Record, relations: Record, - valid: SmartLayoutCandidate[], // sorted ascending by composite score - baseline: LayoutMetrics, + raw: RawCandidate[], onProgress?: SmartLayoutOnProgress, options: { seed?: number } = {}, ): Promise { + const baseline = computeLayoutMetrics(nodes, relations) + + // Composite ranking — crossings dominate but the layout still gets + // penalised for node overlap, edge-length spaghetti and bad aspect ratio, + // which matches what users *perceive* as messy far better than raw + // crossing count alone. + const valid: SmartLayoutCandidate[] = [] + onProgress?.({ phase: 'ranking', done: 0, total: raw.length }) + for (let i = 0; i < raw.length; i++) { + const scored = scoreCandidate(nodes, relations, raw[i]) + if (scored) valid.push(scored) + onProgress?.({ phase: 'ranking', done: i + 1, total: raw.length }) + await yieldToUI() + } + if (valid.length === 0) return unchangedResult(nodes, relations) + valid.sort((a, b) => a.score.composite - b.score.composite) + const rng = createRng(options.seed ?? graphSeed(nodes, relations)) // Phase A multi-start: refine the top-K candidates independently and diff --git a/src/renderer/src/layout/smartLayout.worker.ts b/src/renderer/src/layout/smartLayout.worker.ts index 2828352a..00c4d454 100644 --- a/src/renderer/src/layout/smartLayout.worker.ts +++ b/src/renderer/src/layout/smartLayout.worker.ts @@ -1,33 +1,32 @@ /** - * Web Worker entry point for Smart Layout — SA refinement only. + * Web Worker entry point for Smart Layout — everything after the engines. * * The ELK candidate generation phase runs on the main thread (elk-worker.min.js * is itself a web-worker script and cannot be imported inside another worker). - * This worker receives the pre-ranked ELK candidates and runs the CPU-intensive - * SA refinement phases A / B / C off the main thread. + * This worker receives the raw candidates and does the CPU-intensive rest off + * the main thread: crossing minimisation and scoring of every candidate, then + * the annealing phases A / B / C. * * Protocol * ───────── - * Main → Worker { nodes, relations, valid, baseline } (nodes/relations = visible projection from the ELK phase) + * Main → Worker { nodes, relations, raw } (nodes/relations = visible projection from the ELK phase) * Worker → Main { type: 'result', result: SmartLayoutResult } * | { type: 'error', message: string } * | { type: 'progress', progress: SmartLayoutProgress } */ -import { runSmartLayoutSAPhase, type SmartLayoutResult, type SmartLayoutCandidate, type SmartLayoutProgress } from './smartLayout' +import { runSmartLayoutWorkerPhase, type SmartLayoutResult, type RawCandidate, type SmartLayoutProgress } from './smartLayout' import type { C4Node, C4Relation } from '../types/c4' -import type { LayoutMetrics } from './crossingOpt' self.onmessage = async (e: MessageEvent) => { - const { nodes, relations, valid, baseline } = e.data as { + const { nodes, relations, raw } = e.data as { nodes: Record relations: Record - valid: SmartLayoutCandidate[] - baseline: LayoutMetrics + raw: RawCandidate[] } try { - const result: SmartLayoutResult = await runSmartLayoutSAPhase( - nodes, relations, valid, baseline, + const result: SmartLayoutResult = await runSmartLayoutWorkerPhase( + nodes, relations, raw, (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 e0489d02..debbe1fb 100644 --- a/src/renderer/src/layout/smartLayoutRunner.ts +++ b/src/renderer/src/layout/smartLayoutRunner.ts @@ -33,7 +33,8 @@ export type { SmartLayoutProgress, SmartLayoutOnProgress } * Protocol (split across main thread / worker): * 1. Main thread: ELK candidate generation (elk-worker.min.js cannot run * inside a nested worker, so this must stay on the main thread). - * 2. Worker: SA refinement phases A / B / C — CPU-intensive, off-thread. + * 2. Worker: candidate ranking (crossing minimisation, scoring) and the + * annealing phases A / B / C — CPU-intensive, off-thread. * * Falls back to `runSmartLayoutCore` in-thread when the Worker API is not * available (Node.js / Vitest). @@ -56,9 +57,9 @@ export async function runSmartLayout( return elkResult.result } - const { nodes: visibleNodes, relations: visibleRelations, valid, baseline } = elkResult + const { nodes: visibleNodes, relations: visibleRelations, raw } = elkResult - // Phase 2: SA refinement in the worker. + // Phase 2: ranking + annealing in the worker. return new Promise((resolve, reject) => { const worker = new SmartLayoutWorkerClass() @@ -84,6 +85,6 @@ export async function runSmartLayout( reject(new Error(e.message ?? 'smartLayout worker error')) } - worker.postMessage({ nodes: visibleNodes, relations: visibleRelations, valid, baseline }) + worker.postMessage({ nodes: visibleNodes, relations: visibleRelations, raw }) }) } diff --git a/tests/layoutPipeline.test.ts b/tests/layoutPipeline.test.ts index 3a7bfec3..20018c6a 100644 --- a/tests/layoutPipeline.test.ts +++ b/tests/layoutPipeline.test.ts @@ -7,6 +7,8 @@ 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 { minimizeCrossings, totalLayoutCost } from '../src/renderer/src/layout/crossingOpt' +import { runSmartLayoutELKPhase, type SmartLayoutProgress } from '../src/renderer/src/layout/smartLayout' 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 { @@ -83,3 +85,40 @@ describe('finalizeLayout', () => { expect(out.S.height).toBeGreaterThanOrEqual(120 + 60) }) }) + +/** Six systems in a row wired so that the row order crosses edges. */ +function crossedRow(): { nodes: Record; relations: Record } { + const nodes: Record = {} + for (let i = 0; i < 6; i++) nodes[`s${i}`] = node(`s${i}`, 'system', { x: i * 300, y: 0 }) + nodes.p = node('p', 'person', { x: 700, y: -300 }) + nodes.q = node('q', 'person', { x: 700, y: 400 }) + const relations: Record = { + r1: rel('r1', 'p', 's0'), r2: rel('r2', 'p', 's5'), r3: rel('r3', 'q', 's1'), + r4: rel('r4', 'q', 's4'), r5: rel('r5', 's0', 's3'), r6: rel('r6', 's2', 's5'), + } + return { nodes, relations } +} + +describe('minimizeCrossings', () => { + it('never increases the crossing/overdraw cost', () => { + const { nodes, relations } = crossedRow() + const updates = minimizeCrossings(nodes, relations) + const after: Record = {} + for (const [id, n] of Object.entries(nodes)) after[id] = updates[id] ? { ...n, ...updates[id] } : n + expect(Object.keys(updates).length).toBeGreaterThan(0) + expect(totalLayoutCost(after, relations)).toBeLessThan(totalLayoutCost(nodes, relations)) + }) +}) + +describe('runSmartLayoutELKPhase', () => { + it('only runs the engines, one at a time, reporting each', async () => { + const { nodes, relations } = crossedRow() + const progress: SmartLayoutProgress[] = [] + const result = await runSmartLayoutELKPhase(nodes, relations, undefined, (p) => progress.push(p)) + if (result.done) throw new Error('expected candidates') + + expect(result.raw).toHaveLength(10) + expect(result.raw.every((c) => !('score' in c))).toBe(true) + expect(progress.map((p) => ('done' in p ? p.done : -1))).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) + }) +})