Skip to content
D
Documentation

Layout — functions a–s

reference
11 min readUpdated

Functions A–S

Import these from @grafloria/engine.

Functions

analyseGraphShape

ts
function analyseGraphShape(nodes: NodeModel[], links: LinkModel[]): GraphShape

assessLabelClearance

Would this layout's edge labels sit on top of a node?

Requirement 2 as a measurement. We are NOT placing labels here — the renderer's edge optimizer does collision-aware placement at render time, and duplicating it would be the exact mistake the card warns against. We ask the cheaper question a LAYOUT can answer: if the label sat at its natural home (the midpoint of the route the engine computed), would it land on a node? If yes, layout has not left the optimizer enough room, and this layout is worse than one that did.

A layout with no labels is vacuously perfect.

ts
function assessLabelClearance(
  nodes: NodeModel[],
  links: LinkModel[],
  result: LayoutResult
): LabelClearanceResult

assessPortRespect

Does each edge actually leave (and enter) in the direction its port faces?

This is requirement 1 of the card as a measurement: "an edge leaving a right port should not require the layout to place the target to the left". We check the sign of the displacement along the port's facing axis. A right port whose target sits to the left scores a violation.

Only AUTHOR-DECLARED ports are judged — the four auto-created default ports carry no authorial intent, so an edge through them can go anywhere (see port-label-bridge.ts). A graph with no declared ports is vacuously perfect, which is the correct answer: there is nothing to respect.

ts
function assessPortRespect(
  nodes: NodeModel[],
  links: LinkModel[],
  portInfos?: PortInfo[]
): PortRespectResult

autoSelectLayout

Run the bake-off and keep the best layout.

IMPORTANT — this MUTATES node positions while it works: each candidate has to be applied to the model before its quality can be measured (crossings are a property of a drawn graph, not of a plan). The winner's positions are re-applied at the end, so the model always lands on the layout we chose, never on the last one we happened to try. DiagramEngine.layout() then commits them through setPosition() as usual.

ts
async function autoSelectLayout(
  diagram: DiagramModel,
  registry: LayoutRegistry,
  options: UnifiedLayoutOptions = {}
): Promise<AutoLayoutResult>

buildCandidates

The candidate pool, in a FIXED order.

Auto-tuning lives here too: a candidate is an algorithm PLUS its knobs, so dagre TB and dagre LR compete as separate candidates and the better direction simply wins on score. That is the whole of "auto-tune" — no separate tuning pass, just more candidates in the bake-off.

ts
function buildCandidates(shape: GraphShape, registry: LayoutRegistry): LayoutCandidate[]

circularLayout

Every node on one ring.

Nodes are ordered by a BFS from the lowest-id node rather than by id, because a circle's readability is entirely about edge crossings and BFS order puts neighbours next to each other. (Ordering by id would be equally deterministic and much uglier — an arbitrary permutation guarantees chords across the whole circle.)

The radius is derived from the nodes' own footprints, so a circular layout of 40 nodes does not stack them on top of each other the way a fixed radius does.

ts
function circularLayout(
  nodes: NodeModel[],
  links: LinkModel[],
  options: CircularLayoutOptions = {}
): LayoutResult

countBends

Total bend count across the routes the layout engine computed.

Bends are one of the four measures the card names, and they only became measurable once the adapters stopped discarding their routing output. A layout whose edges need fewer corners is easier to follow.

Returns undefined when the engine reported no routes — an ABSENT measurement, not a zero. Scoring "0 bends" for an engine that simply never told us would hand it a perfect score for saying nothing, which is how a metric quietly becomes a lie.

ts
function countBends(result: LayoutResult): number | undefined

createArchitectureLayout

architecture — a composition, not a ranking: regions on a grid, boxes sized to their words and aligned in rows, lines straight where boxes line up, bends in the gutters. It writes sizes, zone frames, anchors and bends as well as positions, so it runs inline (no adapter: nothing to ship to a worker) and owns its containers.

ts
function createArchitectureLayout(): RegisteredLayout

createAutoLayout

The auto-selecting layout, as a registry engine.

It is registered under a name like any other layout — deliberately. It takes the registry it lives in so its candidate pool is whatever is actually registered, including layouts an extension host added after start-up.

ts
function createAutoLayout(registry: LayoutRegistry): RegisteredLayout

createBuiltInLayoutAdapters

The layouts that ship in the box.

ELK is included even though it resolves asynchronously; the adapter already handles that.

ts
function createBuiltInLayoutAdapters(): LayoutAdapter[]

createBuiltInLayoutFactories

The same built-ins, as FACTORIES — construct one only when it is asked for.

It is a crash fix that only a live run could have found. Constructing every adapter up-front means constructing ELK, and new ELKLayoutAdapter() calls new ElkConstructor(), which tries to spawn elkjs's OWN nested Worker. Inside a Web Worker that throws _Worker is not a constructor — so the layout worker died on the line that started it, before it had read a single message, and every request to it hung forever.

Nothing caught it because in Node (where the unit tests live) elkjs constructs happily. It reproduced the instant a real Worker ran in a real browser.

Laziness makes the worker pay only for the algorithm actually requested, so asking for force no longer detonates on ELK's behalf.

ts
function createBuiltInLayoutFactories(): Map<string, () => LayoutAdapter>

createDefaultLayoutRegistry

The registry engine.layout() runs against.

Registration order is the override order, and it is deliberate:

force appears in both and the portfolio's wins, because it adds component packing and the shared options vocabulary: the difference between "we expose a force adapter" and "force is a first-class layout"; 3. LAYERED (Cards 1 & 5) — our own Sugiyama. The only engine that honours semantic constraints DURING ranking and ordering, which is why the mental-map/incremental path names it explicitly; 4. AUTO — the scored bake-off. Registered last because it takes the registry, so its candidate pool is whatever is actually in it — including layered, and including anything an extension host adds after start-up.

ts
function createDefaultLayoutRegistry(): LayoutRegistry

createLayout

Turn a raw graph-layout function into a registered layout.

  1. CANONICAL INPUT ORDER — sorted by id, so the same graph laid out after a save/load round-trip produces the same coordinates.
  2. COMPONENT PACKING — a disconnected graph is split, laid out component by component, and the boxes are packed. Implemented ONCE, here, rather than five times in five algorithms. It is a no-op for a connected graph, so it cannot regress an existing layout.
ts
function createLayout(name: string, fn: GraphLayoutFn): RegisteredLayout

createLayoutRng

mulberry32 — a small, fast, well-behaved 32-bit PRNG.

ts
function createLayoutRng(seed: number = DEFAULT_LAYOUT_SEED): LayoutRng

createPortfolioLayouts

ts
function createPortfolioLayouts(): RegisteredLayout[]

declaredPorts

Ports the author actually declared — the ones that may constrain a layout.

ts
function declaredPorts(node: NodeModel): PortModel[]

deriveLabelBoxes

Every label on a link, sized. Empty for the common unlabelled link.

ts
function deriveLabelBoxes(link: LinkModel): LabelBox[]

derivePortInfos

Only DECLARED ports are emitted (see the file header).

ts
function derivePortInfos(nodes: NodeModel[]): PortInfo[]

estimateLabelBox

The estimated content box of a single edge label, including its padding.

ts
function estimateLabelBox(label: LinkLabel, linkId = ''): LabelBox

estimateLayering

ts
function estimateLayering(nodes: NodeModel[], links: LinkModel[]): LayeringEstimate

findConnectedComponents

Split a graph into connected components.

Connectivity is UNDIRECTED — an edge joins its endpoints regardless of which way it points. (A tree whose edges all point away from the root is still one component; treating edges as directed would shatter it into leaves.)

Deterministic: nodes are visited in id order and each node's neighbours are visited in id order, so the components — and the order they come back in — depend only on the graph, never on insertion order.

ts
function findConnectedComponents(
  nodes: readonly NodeModel[],
  links: readonly LinkModel[]
): GraphComponent[]

forceLayout

ts
function forceLayout(
  nodes: NodeModel[],
  links: LinkModel[],
  options: UnifiedLayoutOptions = {}
): Promise<LayoutResult>

fromAdapter

Wrap a legacy LayoutAdapter (dagre/elk/force/spectral/community) as a registry engine.

This is the adaptor that finally makes the orphaned stack reachable. It also does the two things the old path never did:

  1. CANONICAL INPUT ORDER — nodes and links are sorted by id before they reach the algorithm. A seeded PRNG alone does not give reproducibility: map iteration follows insertion order, so an authored diagram and the same diagram loaded from JSON feed the algorithm in different orders and diverge even with the same seed.

  2. OPTION NORMALISATION — direction/nodeSpacing/rankSpacing are translated into whatever the adapter calls them.

ts
function fromAdapter(adapter: LayoutAdapter): RegisteredLayout

gridLayout

Row-major grid. Ignores links entirely — that is the point: a grid is what you reach for when the edges are NOT the story (a palette of components, a set of unconnected cards).

direction chooses the FILL ORDER of the same grid, not a different grid: 'TB' fills left-to-right then down, 'LR' fills top-to-bottom then across, 'BT'/'RL' mirror those.

ts
function gridLayout(
  nodes: NodeModel[],
  _links: LinkModel[],
  options: GridLayoutPortfolioOptions = {}
): LayoutResult

hasDeclaredPorts

Does this node carry author-declared ports? If not, layout may place it freely and route through whichever of the four default sides suits the router.

ts
function hasDeclaredPorts(node: NodeModel): boolean

inStableOrder

Deterministic order for a set of entities.

The second half of reproducibility, and the half that is easy to forget: a seeded PRNG only helps if the graph is CONSUMED in a stable order. Node maps iterate in insertion order, which differs between an authored diagram and the same diagram loaded from JSON — so two "identical" graphs could feed the same seeded generator in different orders and still diverge.

Sorting by id makes the input canonical, which is what actually makes the layout idempotent across a save/load round-trip.

ts
function inStableOrder<T extends { id: string }>(items: readonly T[]): T[]

isDefaultPort

Was this port invented by NodeModel.initializeDefaultPorts() rather than declared by the author?

The model marks them (setMetadata('default', true)), which is the only trustworthy signal — explicitSide is TRUE even for the default ports (they are constructed with side:, and the PortModel constructor sets the flag), so explicitSide cannot be used to tell an authored port from an invented one. That trap is exactly why this helper exists instead of an inline check.

ts
function isDefaultPort(port: PortModel): boolean

isSteppable

Can this adapter be pre-empted mid-run?

ts
function isSteppable(
  adapter: LayoutAdapter
): adapter is SteppableLayoutAdapter

layoutArea

The area of the layout's bounding box, in px². Smaller is tighter.

ts
function layoutArea(result: LayoutResult): number

layoutWithComponentPacking

Run a layout with component packing.

The wrapper every registered layout goes through. See the header: for a CONNECTED graph this is a straight delegation (packing a single component is the identity), so it cannot regress anything; for a disconnected one it is the difference between a tidy row of trees and a pile.

ts
async function layoutWithComponentPacking(
  name: string,
  fn: GraphLayoutFn,
  nodes: readonly NodeModel[],
  links: readonly LinkModel[],
  options: UnifiedLayoutOptions = {}
): Promise<LayoutResult>

linkLabelBox

The single box that must fit on a link: the union of its labels' boxes.

ts
function linkLabelBox(link: LinkModel): LabelBox | undefined

nodeSize

A node's box, with the same defaults every layout in the portfolio uses.

ts
function nodeSize(node: NodeModel): { width: number; height: number }

packAdapter

Component-packing wrapper, preserving the algorithm underneath.

Object.create, not a spread: spreading a class instance copies only its OWN properties, so step()/snapshot() — which live on the prototype — vanish, isSteppable() goes false, and a long-running algorithm silently stops being cancellable. Delegating through the prototype keeps it whole and overrides only apply, which is the one thing packing needs to intercept.

ts
function packAdapter(adapter: LayoutAdapter): LayoutAdapter

packBoxes

Shelf packing, first-fit-decreasing-height.

Returns the top-left OFFSET for every box. Boxes are laid left-to-right into a shelf; when the next box would overflow the target width a new shelf opens below the tallest box on the current one.

ts
function packBoxes(
  boxes: readonly PackBox[],
  options: PackingOptions = {}
): Map<string, { x: number; y: number }>

pickEngineForScale

Pick the engine(s) for a graph too large to bake off, from its STRUCTURE.

Every branch below is backed by a measurement in layout-auto-select.perf.spec.ts (times on the dev machine, generous CI caps in the spec):

tree → portfolio tree: 19ms at n=2000 (vs dagre 702ms, layered 5.3s) DAG, narrow → layered: 156ms at 900-mesh, 479ms at 2025-mesh, 132ms at a 2,000-deep chain — width is what hurts it, and narrow ranks are exactly where dagre's depth pathology lives. DAG, wide+shallow → dagre: 1.3s at a 2,000-node sparse DAG whose ~1,000-wide ranks are what kill layered. DAG, wide+deep → elk layered: the moderate-everywhere engine. cyclic → force: completes in well under a second at this scale; hierarchical engines would first have to break the cycles.

Declared ports bump ELK (the only port-aware candidate) to the front for hierarchical graphs — at this scale we cannot afford to MEASURE port respect across a whole field, so we pick the engine built to honour it.

Returns undefined when none of the preferred engines is registered; the caller then falls back to the (gated) bake-off rather than failing.

ts
function pickEngineForScale(
  shape: GraphShape,
  nodes: NodeModel[],
  links: LinkModel[],
  registry: LayoutRegistry
): ScalePlan | undefined

pickRoot

Choose the node a tree hangs from — or a radial layout centres on.

SOURCE FIRST (in-degree 0), hub second (highest degree).

hub → mid1 → leaf1, leaf2 hub → mid2 → leaf3

the CEO ('hub') has degree 2 and the middle manager ('mid1') has degree 3. A pure highest-degree rule centres the picture on the middle manager and hangs the CEO off the side — the org chart drawn upside down. Sources win; the degree rule is the fallback for a graph that HAS no source (a cycle, an undirected network), which is exactly where "the hub" is the right answer.

Every tie breaks on the lowest id, so the root never depends on insertion order.

ts
function pickRoot(
  ordered: readonly NodeModel[],
  inDegree: Map<string, number>,
  degree: Map<string, number>,
  rootId?: string
): string

radialLayout

Concentric rings by BFS depth from a hub.

The wedge allocation is what makes it readable: each subtree gets an angular slice PROPORTIONAL TO ITS LEAF COUNT, so a bushy branch is not crammed into the same wedge as a single leaf. That is the classic radial-tree construction, and it is crossing-free for trees.

Ring radii are grown to fit — a ring is pushed outward if the nodes on it would not otherwise fit around its circumference, which is the failure mode of every "radius = depth * constant" radial layout at depth 3 and beyond.

ts
function radialLayout(
  nodes: NodeModel[],
  links: LinkModel[],
  options: RadialLayoutOptions = {}
): LayoutResult

removeOverlaps

Separate overlapping nodes, in place, and return the corrected positions.

Deterministic: boxes are swept in (x, id) order, so the result is a pure function of the input positions — never of a map's iteration order.

Returns the same Map instance for convenience.

ts
function removeOverlaps(
  nodes: readonly NodeModel[],
  positions: Map<string, { x: number; y: number }>,
  options: OverlapRemovalOptions = {}
): Map<string, { x: number; y: number }>

reservedLabelSpace

The space every labelled edge needs, as one summary — what a layout engine that cannot take per-edge label boxes (force, spectral, …) uses to inflate its spacing so labels have somewhere to live.

ts
function reservedLabelSpace(links: LinkModel[]): { width: number; height: number }

resolvePortConstraint

ts
function resolvePortConstraint(
  node: NodeModel,
  mode: PortConstraintMode = 'auto'
): ElkPortConstraint

reviveGraph

Rebuild live models from the wire format, on whichever thread we landed on.

The revived models are THROWAWAY: the algorithm mutates them, we read the positions out of the result, and the engine commits those positions onto the REAL nodes via setPosition() so the spatial index and the routing obstacle map see the move. Nothing that happens to these copies escapes.

ts
function reviveGraph(graph: LayoutGraph): {
  nodes: NodeModel[];
  links: LinkModel[];
}

runLayout

Run a named layout against a diagram and COMMIT the result.

The single place positions are written back, shared by DiagramEngine.layout() and by the preset applicator. setPosition() — never a raw write to node.position — because the spatial index, the routing obstacle map and the renderer all hang off the change event it emits.

ts
async function runLayout(
  registry: LayoutRegistry,
  diagram: DiagramModel,
  name: string,
  options: UnifiedLayoutOptions = {}
): Promise<UnifiedLayoutResult>

separateOverlappingNodes

THE OVERLAP PASS — one rule, one place, for every path that produces positions.

It lives outside the five algorithms for the same reason packing does: force and community lay out DIMENSIONLESS POINTS and happily return boxes that intersect (see overlap-removal.ts). For every layout that does not overlap — dagre, ELK, tree, grid, circular, radial — it is a no-op, so it costs them one comparison pass and nothing else.

WHY IT IS EXPORTED, which is the bug.

It used to be a closure inside layoutWithComponentPacking, reachable only from adapter.apply(). But the layout HOST — the path engine.layout() actually takes — does not call apply() for a steppable algorithm: it drives createRun()/step()/snapshot() so the run can report progress and be cancelled. snapshot() was returned RAW.

Force is the only steppable built-in. So the one algorithm that genuinely needs overlap removal was the one algorithm that never got it, and engine.layout('force') handed back overlapping node boxes — while every unit test stayed green, because they all go through apply().

A demo caught it in the first thirty seconds of being pointed at a real browser.

ts
function separateOverlappingNodes(
  nodes: readonly NodeModel[],
  positions: Map<string, { x: number; y: number }>,
  options: UnifiedLayoutOptions = {}
): Map<string, { x: number; y: number }>

Was this page helpful?

Layout — functions a–s — Grafloria