Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
700 changes: 700 additions & 0 deletions src/renderer/src/layout/annealing.ts

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions src/renderer/src/layout/elkLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -139,10 +140,13 @@ function parseElkResult(node: ElkNode, result: PositionMap): void {
// ─── Public API ──────────────────────────────────────────────────────────────

export async function applyElkLayout(
c4Nodes: Record<string, C4Node>,
c4Relations: Record<string, C4Relation>,
inputNodes: Record<string, C4Node>,
inputRelations: Record<string, C4Relation>,
opts?: { rootOptions?: LayoutOptions; childOptions?: LayoutOptions },
): Promise<PositionMap> {
// 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

Expand Down
61 changes: 60 additions & 1 deletion src/renderer/src/layout/geometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -28,3 +28,62 @@ export function isVisible(node: C4Node, allNodes: Record<string, C4Node>): 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<string, C4Node>,
relations: Record<string, C4Relation>,
): { nodes: Record<string, C4Node>; relations: Record<string, C4Relation> } {
const visible: Record<string, C4Node> = {}
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<string, C4Relation> = {}
const seen = new Set<string>()
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 }
}
130 changes: 130 additions & 0 deletions src/renderer/src/layout/layoutFinalize.ts
Original file line number Diff line number Diff line change
@@ -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<string, C4Node>, 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<string, C4Node>): Map<string, string[]> {
const byParent = new Map<string, string[]>()
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<string, C4Node>, 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<string, C4Node>, positions: PositionMap): PositionMap {
return finalize(nodes, positions, false)
}

/** Separate overlapping siblings at every level, fit parents, separate roots. */
export function finalizeLayout(nodes: Record<string, C4Node>, positions: PositionMap): PositionMap {
return finalize(nodes, positions, true)
}

function finalize(nodes: Record<string, C4Node>, 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
}
Loading
Loading