OverviewFilesCommitsIssuesDocsReleasesPeers

codetree / lib / layout.js

693 lines · javascript ref:
/**
 * 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
  }
}