/**
* Compound layout — a graph whose zones are CONTAINERS, not boxes drawn after
* the fact.
*
* The old node view layered nodes into columns and then computed each frame's
* rectangle from the bounding box of its members. Because every column ordered
* its own rows independently, one frame's members in column 2 interleaved with
* another frame's in column 3, and the two rectangles crossed. No amount of
* post-hoc box fitting can fix that: the members were never placed together.
*
* So a container is a layout UNIT. Its contents are laid out in their own frame,
* that frame becomes a box with a size, and the parent lays the box out as if it
* were a single node. Siblings therefore cannot interleave — they are placed as
* whole rectangles in one pass — and a parent encloses its children by
* construction rather than by measurement.
*
* Everything here is pure: same input, same output, no clock, no DOM. Node sizes
* come IN, so expanding a node's code is just a new `h` and another call
* (spec L4).
*/
const DEFAULTS = {
colGap: 130, // horizontal space between dependency columns
rowGap: 26, // vertical space between siblings in a column
pad: 16, // container padding around its contents
labelH: 18, // extra headroom inside a container for its label
// Separation between GROUPS, as opposed to between cards.
//
// A group is not a card and must not get a card's gap. With both at 26px, two
// category boxes sat 26px apart and read as one stack — reported twice as the
// groups overlapping, when in fact they never intersected. Each group needs to
// occupy its own area of the page.
groupGap: 90,
// The shape to aim for, width:height, when nothing says otherwise.
//
// A layer with many members and no edges to spread them has to wrap or it
// becomes an endless column — keet-cli has 207 files, 117 directories and ONE
// import edge, which produced a column 26,012px tall. But wrapping at a FIXED
// height makes every group the same shape whatever it holds. The wrap height
// is derived per group from its own content area and this aspect;
// maxColumnHeight overrides it outright.
aspect: 1.6,
maxColumnHeight: undefined,
// 'median' orders each column to uncross the wires and shorten them; 'id' is
// the old arbitrary order, kept so the difference can be MEASURED rather than
// asserted.
order: 'median',
// 'affinity' re-sequences a column that is about to WRAP so that items sharing
// an outside neighbour stay adjacent. OFF by default: it was written first and
// then measured against the second layout pass below, which knows where those
// neighbours actually ARE rather than merely that they are shared. The two
// fight, and the pass wins — with affinity on, favela-web's routed wires came
// out 5% shorter instead of 16%. Kept because it is the only tool for a board
// where positions cannot help, and so the comparison stays reproducible.
sequence: 'none',
// How many times to lay the whole thing out.
//
// Groups are laid out BOTTOM-UP — a directory is sized before the parent
// decides where the directory goes — so while a group is being laid out, the
// position of everything OUTSIDE it is unknown. That is fatal for ordering,
// because nearly every wire in a directory leaves the directory: the first
// pass can only order members against each other, which for most groups means
// it cannot order them at all.
//
// A second pass fixes that by using the FIRST pass's absolute positions as the
// hint for where each member's outside neighbours will be. Two passes is
// enough — a third moved nothing measurable — and it stays pure: same input,
// same output.
passes: 2
}
// The implicit group that holds everything with no container of its own. Not a
// real container: it is never drawn and gets no padding or label.
const ROOT = '__layout_root__'
/**
* Median-heuristic ordering, sweeping down then up a few times.
*
* For each item, look at the items it connects to in the neighbouring column,
* take the MEDIAN of their positions, and sort by that. An item with no
* neighbours keeps its place, so the result stays stable; ties break by id, so
* the result stays deterministic.
*/
function orderColumns (columns, keys, edges) {
const neighboursUp = new Map()
const neighboursDown = new Map()
for (const [a, b] of edges) {
if (!neighboursDown.has(a)) neighboursDown.set(a, [])
neighboursDown.get(a).push(b)
if (!neighboursUp.has(b)) neighboursUp.set(b, [])
neighboursUp.get(b).push(a)
}
// Start from a deterministic order so the sweeps are reproducible.
for (const k of keys) {
columns.get(k).sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
}
const SWEEPS = 4
for (let pass = 0; pass < SWEEPS; pass++) {
const order = pass % 2 === 0 ? keys.slice(1) : keys.slice(0, -1).reverse()
const lookup = pass % 2 === 0 ? neighboursUp : neighboursDown
for (const k of order) {
const ref = pass % 2 === 0 ? k - 1 : k + 1
const refCol = columns.get(ref)
if (!refCol) continue
const index = new Map(refCol.map((item, i) => [item.id, i]))
const col = columns.get(k)
const current = new Map(col.map((item, i) => [item.id, i]))
const median = new Map()
for (const item of col) {
const ns = (lookup.get(item.id) || [])
.map(id => index.get(id))
.filter(v => v !== undefined)
.sort((a, b) => a - b)
// No neighbours: keep this item where it is rather than dragging it to
// the top, which would shuffle unrelated nodes on every pass.
median.set(item.id, ns.length === 0
? current.get(item.id)
: ns[Math.floor((ns.length - 1) / 2)] + (ns.length % 2 === 0 ? 0.5 : 0))
}
col.sort((a, b) => {
const d = median.get(a.id) - median.get(b.id)
if (d !== 0) return d
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
})
}
}
}
/**
* Sequence ONE column's items so that items sharing connections end up next to
* each other — done BEFORE the column is wrapped into sub-columns.
*
* Median ordering puts an item at the median y of its neighbours in the adjacent
* column. That is the right order while the column stays ONE column, because
* then position-in-the-list is y and y is comparable across columns. A column
* that overflows `wrapHeight` is cut into several sub-columns, and at that point
* the list position stops meaning y: items 1 and 40 can end up side by side
* while 39 and 40 sit a sub-column apart. So the median order is not merely
* useless after a wrap, it scatters clusters — sorting keet-cli's 207 files by a
* median almost none of them have falls back to id order, i.e. alphabetical, and
* files that all import the same module land in five different sub-columns.
*
* For a column that will wrap, sequence by affinity instead: greedily grow runs
* of items that are connected. Affinity is a direct edge (strong — they really
* are joined) plus one per neighbour they have in COMMON, which is what makes a
* cluster a cluster when the members are not joined to each other. Ties break by
* degree then id, so the result is deterministic.
*
* The common neighbours are mostly OUTSIDE the group. Layering only ever needs
* edges between siblings, so that is all `edges` holds — and with only those, a
* directory of twelve files that all import the same module looks completely
* unconnected and this pass does nothing (measured: 0% on every level). Hence
* `external`, the [member, outside-endpoint] pairs for links that leave the
* group: two files pointing at the same outside node are what makes them
* neighbours here.
*/
function sequenceByAffinity (col, edges, external) {
if (col.length < 3) return col
const inCol = new Map(col.map((it, i) => [it.id, i]))
const affinity = col.map(() => new Map())
const bump = (i, j, w) => {
if (i === j) return
affinity[i].set(j, (affinity[i].get(j) || 0) + w)
affinity[j].set(i, (affinity[j].get(i) || 0) + w)
}
const fan = new Map() // a neighbour outside the column -> members attached to it
const attach = (member, outside) => {
const i = inCol.get(member)
if (i === undefined) return
if (!fan.has(outside)) fan.set(outside, [])
fan.get(outside).push(i)
}
for (const [a, b] of edges) {
const ia = inCol.get(a)
const ib = inCol.get(b)
if (ia !== undefined && ib !== undefined) { bump(ia, ib, 3); continue }
if (ia !== undefined) attach(a, b)
else if (ib !== undefined) attach(b, a)
}
for (const [member, outside] of external) attach(member, outside)
// A neighbour that most of the column touches cannot tell you which members
// belong together, and pairing it up is what makes this pass quadratic.
const HUB = 64
for (const raw of fan.values()) {
// The same member can reach one outside node through several links; counting
// it twice would let one heavy importer dominate a pair's affinity.
const members = Array.from(new Set(raw))
if (members.length > HUB) continue
for (let i = 0; i < members.length; i++) {
for (let j = i + 1; j < members.length; j++) bump(members[i], members[j], 1)
}
}
// This is a REFINEMENT of the incoming order, not a replacement for it.
//
// Seeding runs by degree and breaking ties by id throws the median order away,
// and the median order is not worthless after a wrap — within one sub-column,
// list position is still y. Measured, discarding it cost favela-web 4% more
// wire and 6% more crossings while helping elsewhere: a wash bought with a
// regression. So walk the incoming order and, each time an item is placed,
// DRAG ITS CLUSTER ALONG. Where affinity says nothing the median order
// survives untouched.
const order = []
const taken = new Array(col.length).fill(false)
let cursor = 0
while (order.length < col.length) {
while (taken[cursor]) cursor++
const seed = cursor
taken[seed] = true
order.push(seed)
for (;;) {
const tail = order[order.length - 1]
const prev = order.length > 1 ? order[order.length - 2] : -1
let pick = -1
let best = 0
for (let i = 0; i < col.length; i++) {
if (taken[i]) continue
// Weight the tail heavily but let the one before it break ties, so a run
// follows the cluster rather than wandering off down a single strong edge.
const score = (affinity[tail].get(i) || 0) * 4 +
(prev === -1 ? 0 : (affinity[prev].get(i) || 0))
if (score === 0) continue
// Ties go to the earlier item in the incoming order, so the median order
// still decides everything affinity is silent about.
if (score > best) { pick = i; best = score }
}
if (pick === -1) break // this run is exhausted; the next seed starts another
taken[pick] = true
order.push(pick)
}
}
return order.map(i => col[i])
}
function adjacencyOf (edges) {
const out = new Map()
for (const [a, b] of edges) {
if (!out.has(a)) out.set(a, [])
out.get(a).push(b)
}
return out
}
/** Edges that point back at a node still on the DFS stack: a cycle's edges. */
function findBackEdges (items, forward) {
const back = new Set()
const state = new Map() // 0 unvisited, 1 on stack, 2 done
for (const item of items) {
if (state.get(item.id)) continue
// Iterative DFS: a deep graph must not blow the call stack.
const stack = [{ id: item.id, next: 0 }]
state.set(item.id, 1)
while (stack.length > 0) {
const top = stack[stack.length - 1]
const kids = forward.get(top.id) || []
if (top.next >= kids.length) {
state.set(top.id, 2)
stack.pop()
continue
}
const child = kids[top.next++]
const seen = state.get(child) || 0
if (seen === 1) back.add(top.id + ' -> ' + child)
else if (seen === 0) {
state.set(child, 1)
stack.push({ id: child, next: 0 })
}
}
}
return back
}
/**
* @param {object} input
* nodes: [{ id, container, w, h, rank? }] container = a container id or null
* links: [{ from, to }] node ids
* containers: [{ id, parent, label, rank? }] parent = a container id or null
*
* `rank` is an explicit column index. If any sibling in a group has one, ranks
* decide that group's columns outright and edge layering is skipped for it.
* @returns {object} { nodes: [{id,x,y,w,h}], containers: [{id,x,y,w,h,depth,label}],
* width, height }
*/
export function layout (input, opts = {}) {
const O = { ...DEFAULTS, ...opts }
const first = layoutOnce(input, O, null)
if (O.passes < 2) return first
// What pass one learned: where each node actually ended up. Feed that back as
// the hint for outside neighbours and lay the whole thing out again.
const hint = new Map()
for (const n of first.nodes) hint.set(n.id, n.y + n.h / 2)
return layoutOnce(input, O, hint)
}
function layoutOnce (input, O, hint) {
const nodes = input.nodes || []
const links = input.links || []
const containers = input.containers || []
const nodeById = new Map()
for (const n of nodes) nodeById.set(n.id, n)
const containerById = new Map()
for (const c of containers) containerById.set(c.id, c)
// Naming a parent that does not exist puts you at the root rather than
// vanishing from the layout.
function parentOf (c) {
if (!c.parent) return ROOT
return containerById.has(c.parent) ? c.parent : ROOT
}
function groupOf (n) {
if (!n.container) return ROOT
return containerById.has(n.container) ? n.container : ROOT
}
// --- the group tree: which nodes and which child containers each group holds
const childNodes = new Map()
const childGroups = new Map()
const ensure = (id) => {
if (!childNodes.has(id)) childNodes.set(id, [])
if (!childGroups.has(id)) childGroups.set(id, [])
}
ensure(ROOT)
for (const c of containers) ensure(c.id)
for (const c of containers) childGroups.get(parentOf(c)).push(c.id)
for (const n of nodes) childNodes.get(groupOf(n)).push(n.id)
// Which item inside `gid` does this node belong to — itself, or the child
// container that transitively holds it? This is what turns a link between two
// deeply nested nodes into an edge between the two containers that the parent
// can actually order.
const itemOfNodeIn = new Map()
function resolveItem (gid, nodeId) {
const cached = itemOfNodeIn.get(gid)
if (cached && cached.has(nodeId)) return cached.get(nodeId)
const n = nodeById.get(nodeId)
if (!n) return null
let cursor = groupOf(n)
let item = nodeId
const guard = containers.length + 2
for (let i = 0; i <= guard; i++) {
if (cursor === gid) break
const c = containerById.get(cursor)
if (!c) return null // not inside gid at all
item = cursor
cursor = parentOf(c)
if (i === guard) return null // malformed parent chain; ignore the link
}
if (cursor !== gid) return null
if (!itemOfNodeIn.has(gid)) itemOfNodeIn.set(gid, new Map())
itemOfNodeIn.get(gid).set(nodeId, item)
return item
}
// --- lay out one group in its own frame, bottom-up. dx/dy are relative to the
// group's own origin.
const laidOut = new Map()
function layoutGroup (gid) {
if (laidOut.has(gid)) return laidOut.get(gid)
const items = []
for (const cid of childGroups.get(gid)) {
const box = layoutGroup(cid)
const c = containerById.get(cid)
items.push({ id: cid, kind: 'container', w: box.w, h: box.h, rank: c && c.rank })
}
for (const nid of childNodes.get(gid)) {
const n = nodeById.get(nid)
items.push({ id: nid, kind: 'node', w: n.w || 0, h: n.h || 0, rank: n.rank })
}
// How tall this group may grow before it wraps.
//
// A single fixed height made every group the same shape regardless of what
// it held: a group of 26 modules came out 1892x950, a slab. Target a square
// -ish box for the CONTENT THIS GROUP ACTUALLY HAS, so a big group grows in
// both directions instead of only sideways. An explicit option still wins.
const contentArea = items.reduce((sum, it) => sum + it.w * it.h, 0)
const wrapHeight = O.maxColumnHeight !== undefined
? O.maxColumnHeight
: Math.max(240, Math.sqrt(contentArea / Math.max(0.2, O.aspect)) * 1.6)
// Edges between items of THIS group. A link whose endpoints resolve to the
// same item is internal to that item and says nothing about ordering here.
const itemIds = new Set(items.map(i => i.id))
const edges = []
// Links that LEAVE this group, as [member, outside endpoint]. Useless for
// layering — an outside node has no column here — but they are what tells
// the sequencing pass which members belong together.
const external = []
for (const l of links) {
const a = resolveItem(gid, l.from)
const b = resolveItem(gid, l.to)
const inA = a !== null && itemIds.has(a)
const inB = b !== null && itemIds.has(b)
if (inA && inB) {
if (a !== b) edges.push([a, b])
continue
}
if (inA) external.push([a, l.to])
else if (inB) external.push([b, l.from])
}
// Where each member's OUTSIDE neighbours sat last pass, as one median y per
// member. This is the only thing a group knows about the rest of the board,
// and without it a directory whose every import crosses a directory boundary
// has nothing whatever to sort by.
//
// Three variants were measured on four real modules; this one wins and the
// two that sound better both lose:
// - hinting from EVERY neighbour, internal ones included, reordered
// favela-web enough to widen its board from 4328 to 5140 and the router
// REFUSED the result outright;
// - hinting only the members with no sibling edges — "never override the
// median with less information" — gave -4%/-2%/+4% against this one's
// -10%/-16%/+7%.
// So the hint is applied to every member that has an outside neighbour, even
// where the median heuristic also has an opinion.
const hintY = new Map()
if (hint) {
const acc = new Map()
for (const [member, outside] of external) {
const y = hint.get(outside)
if (y === undefined) continue
if (!acc.has(member)) acc.set(member, [])
acc.get(member).push(y)
}
for (const [member, ys] of acc) {
ys.sort((a, b) => a - b)
hintY.set(member, ys[Math.floor((ys.length - 1) / 2)])
}
}
// Layering, with BACK EDGES REMOVED first.
//
// Bounded longest-path relaxation looks safe but is not: around a cycle it
// keeps relaxing until the iteration cap, so on the real system level 15
// groups landed at depths 0, 1, 47, 48, 49, 50. Depth 50 on a 15-item graph
// is a runaway, not a layer, and it is what spread the board into a strip.
//
// A depth-first pass marks every edge that points back at a node still on
// the stack; those edges are what a cycle is made of, and they carry no
// layering information. The rest is a DAG and relaxes in one pass per layer.
const forward = adjacencyOf(edges)
const backEdges = findBackEdges(items, forward)
const dagEdges = edges.filter(([a, b]) => !backEdges.has(a + ' -> ' + b))
// An explicit RANK, where the caller already knows which column something
// belongs in. Layering derives columns from edges, which is the right thing
// when edges are the only information there is — but a caller like the
// relation view knows each item's hop distance and side exactly, and
// re-deriving that from edges throws it away.
//
// All or nothing per group: if ANYTHING here is ranked, ranks decide the
// whole group and an unranked sibling sits at 0. Mixing a rank with a relaxed
// depth would be a silent guess about which the caller meant.
const ranked = items.some(i => typeof i.rank === 'number')
const depth = new Map()
for (const i of items) depth.set(i.id, ranked ? (typeof i.rank === 'number' ? i.rank : 0) : 0)
if (!ranked) {
for (let pass = 0; pass < items.length; pass++) {
let moved = false
for (const [a, b] of dagEdges) {
if (depth.get(b) < depth.get(a) + 1) { depth.set(b, depth.get(a) + 1); moved = true }
}
if (!moved) break
}
}
const columns = new Map()
for (const i of items) {
const d = depth.get(i.id)
if (!columns.has(d)) columns.set(d, [])
columns.get(d).push(i)
}
const keys = Array.from(columns.keys()).sort((a, b) => a - b)
// Order each column to UNCROSS the wires.
//
// Sorting a column by id is arbitrary with respect to the edges, so wires
// criss-cross for no reason other than the order their endpoints happen to
// sit in. Crossings are a LAYOUT problem: no amount of clever routing fixes
// two nodes that are in the wrong order. This is the median heuristic —
// sweep down, then up, placing each item at the median position of the
// neighbours it is joined to, which is the standard way to do it.
if (O.order === 'median') orderColumns(columns, keys, edges)
else for (const k of keys) columns.get(k).sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
// Pull each column into the order its OUTSIDE neighbours sit in.
//
// Only the items that have a hint move, and they move only into the slots
// those items already occupy between them, so an unconnected item never gets
// dragged across the column by a number that does not apply to it.
if (hint) {
for (const k of keys) {
const col = columns.get(k)
const slots = []
const movable = []
for (let i = 0; i < col.length; i++) {
if (hintY.has(col[i].id)) { slots.push(i); movable.push(col[i]) }
}
if (movable.length < 2) continue
movable.sort((a, b) => {
const d = hintY.get(a.id) - hintY.get(b.id)
if (d !== 0) return d
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
})
for (let i = 0; i < slots.length; i++) col[slots[i]] = movable[i]
}
}
// Connectivity-aware sequencing, BEFORE the wrap — and only for the columns
// that are actually going to wrap. A column that fits keeps its median
// order, because there y is still comparable to the next column's.
if (O.sequence === 'affinity') {
for (const k of keys) {
const col = columns.get(k)
let stack = 0
for (const it of col) {
stack += it.h + (it.kind === 'container' ? O.groupGap : O.rowGap)
}
if (stack > wrapHeight) columns.set(k, sequenceByAffinity(col, edges, external))
}
}
// Columns run left to right in dependency order, and WRAP into a new row
// when the board gets too wide — word-wrap, for columns.
//
// Without this the level is a strip: the real system level put 15 groups
// across 6 dependency layers and came out 6374x1408, over 4:1, so most of it
// is off-screen one way while the other is empty. Wrapping keeps the reading
// order and packs the board toward something a screen can hold.
const targetWidth = Math.max(900, Math.sqrt(contentArea * Math.max(0.2, O.aspect)) * 1.7)
// How strongly each column is tied to the one before it, so the wrap can
// break where the board is WEAKLY connected instead of wherever the width
// happens to run out. Breaking through the middle of a tightly connected
// run is what scatters a cluster across two rows.
const seamWeight = new Map()
for (let ki = 1; ki < keys.length; ki++) {
const prev = new Set(columns.get(keys[ki - 1]).map(it => it.id))
const here = new Set(columns.get(keys[ki]).map(it => it.id))
let weight = 0
for (const [a, b] of edges) {
if ((prev.has(a) && here.has(b)) || (prev.has(b) && here.has(a))) weight++
}
seamWeight.set(keys[ki], weight)
}
const weights = Array.from(seamWeight.values()).sort((a, b) => a - b)
const medianSeam = weights.length > 0 ? weights[Math.floor(weights.length / 2)] : 0
const placed = []
let x = 0
let rowY = 0
let rowH = 0
let rowIndex = 0
let maxH = 0
let maxRight = 0
for (const k of keys) {
const col = columns.get(k)
const colWidth = col.reduce((w, it) => Math.max(w, it.w), 0)
// A heavy seam is worth some overflow to keep intact; a light one is
// where the row should break.
const seam = seamWeight.get(k) || 0
const allowance = seam > medianSeam ? 1.45 : 1.0
if (x > 0 && x + colWidth > targetWidth * allowance) {
x = 0
rowY += rowH + (col.some(it => it.kind === 'container') ? O.groupGap : O.rowGap)
rowH = 0
rowIndex++
}
// One LAYER may occupy several columns. Wrapping keeps x monotonic in
// dependency depth — every member of this layer is placed before the next
// layer starts — so a supplier still sits left of its consumer.
let y = 0
let colW = 0
let holdsGroup = false
let prev = null
const wrapAt = wrapHeight
for (const item of col) {
// The gap BEFORE this item: a group on either side of the boundary earns
// the group gap, otherwise it is a card gap.
if (prev !== null) {
const gap = (prev.kind === 'container' || item.kind === 'container')
? O.groupGap
: O.rowGap
y += gap
}
if (y > 0 && y + item.h > wrapAt) {
x += colW + (holdsGroup ? O.groupGap : O.colGap)
y = 0
colW = 0
holdsGroup = false
}
// `row` is reported because once columns wrap, "left of" is no longer
// the whole story: order is row-major, and a caller that wants to reason
// about it needs to know which row an item landed in.
placed.push({ id: item.id, kind: item.kind, dx: x, dy: rowY + y, w: item.w, h: item.h, row: rowIndex })
y += item.h
if (item.w > colW) colW = item.w
if (item.kind === 'container') holdsGroup = true
if (y > rowH) rowH = y
if (rowY + y > maxH) maxH = rowY + y
prev = item
}
x += colW + (holdsGroup ? O.groupGap : O.colGap)
if (x > maxRight) maxRight = x
}
const contentW = Math.max(0, maxRight - Math.min(O.colGap, O.groupGap))
const isRoot = gid === ROOT
const padL = isRoot ? 0 : O.pad
const padT = isRoot ? 0 : O.pad + O.labelH
const box = {
w: contentW + padL * 2,
h: maxH + padT + (isRoot ? 0 : O.pad),
padL,
padT,
placed
}
laidOut.set(gid, box)
return box
}
const rootBox = layoutGroup(ROOT)
// --- absolute positions: walk down, adding each group's origin and padding.
const outNodes = []
const outContainers = []
function emit (gid, originX, originY, depth) {
const box = laidOut.get(gid)
const baseX = originX + box.padL
const baseY = originY + box.padT
for (const p of box.placed) {
const x = baseX + p.dx
const y = baseY + p.dy
if (p.kind === 'node') {
outNodes.push({ id: p.id, x, y, w: p.w, h: p.h, row: p.row })
} else {
const c = containerById.get(p.id)
outContainers.push({
id: p.id,
label: c && c.label !== undefined ? c.label : null,
x,
y,
w: p.w,
h: p.h,
row: p.row,
depth: depth + 1
})
emit(p.id, x, y, depth + 1)
}
}
}
emit(ROOT, 0, 0, 0)
outNodes.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
outContainers.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
return {
nodes: outNodes,
containers: outContainers,
width: rootBox.w,
height: rootBox.h
}
}