Package name: @graphora/layouts
Responsibility: renderer-independent layout interfaces, built-in layout implementations, and future async or worker-backed layout execution.
Layouts consume immutable graph snapshots from @graphora/core and return plain
layout result objects. They do not mutate GraphStore, mount DOM nodes, or
depend on renderer, interaction, or React packages.
import { createGraphStore } from '@graphora/core'
import {
applyMeasuredNoOverlap,
applyNoOverlap,
createCircularLayout,
createForceLayout,
createGridLayout,
createHierarchicalLayout,
createWorkerLayout
} from '@graphora/layouts'
const store = createGraphStore({
nodes: [{ id: 'a' }, { id: 'b' }, { id: 'c' }],
edges: [{ source: 'a', target: 'b' }]
})
const layout = createCircularLayout({ radius: 160 })
const result = layout.run(store.snapshot())
console.log(result.positions)
console.log(result.bounds)
const forceLayout = createForceLayout({
iterations: 120,
linkDistance: 80,
chargeStrength: -120,
center: { x: 0, y: 0 }
})
const forceResult = forceLayout.run(store.snapshot())
const gridLayout = createGridLayout({ columns: 2, cellWidth: 140 })
const gridResult = gridLayout.run(store.snapshot())
const readableGridResult = applyNoOverlap(gridResult, { minimumDistance: 56 })
const dependencyLayout = createHierarchicalLayout({
direction: 'LR',
rankSpacing: 100,
nodeSpacing: 60
})
const dependencyResult = dependencyLayout.run(store.snapshot())
const geometry = {
units: 'world',
provenance: {
graphVersion: store.snapshot().version,
styleRevision: 0,
metricsRevision: 0
},
nodes: store.snapshot().nodes.map((node) => ({
id: node.id,
key: node.key,
size: { width: 48, height: 48 },
padding: { top: 8, right: 8, bottom: 8, left: 8 }
})),
labels: []
} as const
const measuredDependencyResult = dependencyLayout.run(store.snapshot(), {
geometry
})
const separated = applyMeasuredNoOverlap(store.snapshot(), gridResult, geometry)
if (separated.diagnostics.status !== 'separated') {
console.warn(separated.diagnostics)
}
const workerLayout = createWorkerLayout({
name: 'remote-layout',
transport: {
async run(request) {
console.log(request.runId, request.layoutName)
return forceLayout.run(request.snapshot)
},
cancel(runId) {
console.log('cancel layout run', runId)
},
dispose() {
console.log('release worker resources')
}
}
})
The default circular layout uses center { x: 0, y: 0 }, radius 100, start
angle 0, and counterclockwise ordering. Multi-node graphs are positioned in
snapshot order. Empty graphs return no positions and bounds: null; a
single-node graph is placed at the configured center.
Layout instances expose normalized options for inspection. The corresponding
resolved option types are exported as ResolvedCircularLayoutOptions,
ResolvedForceLayoutOptions, and ResolvedGridLayoutOptions.
The force layout runs a bounded d3-force simulation behind the same public
LayoutResult contract. It uses existing node positions as initial positions
when available, deterministic fallback positions otherwise, and graph edges as
link forces. The V0.1 option surface is intentionally small:
iterations: fixed simulation ticks, default 120center: target center, default { x: 0, y: 0 }linkDistance: desired linked-node distance, default 80chargeStrength: many-body strength, default -120centerStrength: x/y centering strength, default 0.08collisionRadius: optional collision radius, default 0seed: deterministic random source seed, default 1Layout results are intended to be applied by engine/store integration in later work. This package only computes positions.
The grid layout places nodes in deterministic snapshot order. By default it chooses enough columns to make a compact grid from the node count. The option surface is intentionally small:
columns: optional fixed column countcellWidth: horizontal spacing, default 120cellHeight: vertical spacing, default 96center: target center, default { x: 0, y: 0 }applyNoOverlap is a renderer-agnostic post-layout pass over LayoutResult.
It can run after circular, force, grid, or future layouts. It separates node
positions by a minimum distance and returns a new frozen result without mutating
the input result or graph data.
applyMeasuredNoOverlap(snapshot, candidate, geometry, options) is the
rectangle-aware follow-up. It requires one candidate position per active node,
preserves candidate order, treats missing optional measurements as zero-area
points, and separates positive-area padded body rectangles. Touching boundaries
are accepted. Candidate and computed bounds must remain finite and consistent;
geometry view provenance must match an optional structural snapshot view. The
returned result has complete padded bounds and geometry provenance;
diagnostics reports separated, residual-overlap, or
work-limit plus iterations, pair checks, moves, and the exact residual count
when verification completed. The default sweep is bounded by 128 iterations and
the larger of 4,096 or 128 pair checks per node. Dense candidates can still be
quadratic inside that explicit total budget. Candidate route or label updates
are rejected because the pass would make them stale.
Run pnpm measured-no-overlap:diagnostic -- --nodes 2000 to record elapsed
time and deterministic work counts. The timing is diagnostic only and is not a
CI threshold. Optional fixedNodeKeys keep candidate positions fixed; a collision
between two fixed bodies remains a reported residual. Unknown/duplicate keys reject.
Route updates and label placement remain separate follow-ups.
applyComponentPacking(snapshot, candidate, geometry, options) translates weakly
connected components as whole units into deterministic shelves. It preserves
internal positions relative to each other, uses padded body bounds and retains
provenance. Set gap (default 40), optional rowWidth wrapping target and origin.
A component containing any fixedNodeKeys member stays entirely fixed; other
components pack to its right. The frozen result includes per-component bounds,
translations, membership and diagnostics (packed, fixed-conflict, work-limit).
maxPairChecks bounds verification of fixed-component conflicts; exhausted
verification reports an unknown count. It does not repair overlaps within a
component; run measured separation first. Missing measurements are zero-size
points. Complete positions and matching provenance are required; candidates with
route/label updates are rejected. No minimum-area packing guarantee is made.
The measured-layout example includes Overlap components and Pack components.
Run pnpm component-packing:diagnostic -- --nodes 2000 for a deterministic fixture.
The hierarchical layout uses Dagre behind a Graphora-owned adapter for directed dependency graphs and DAGs. It returns node positions only; edge routing, labels, ports, groups, and incremental layout remain separate concerns. The adapter also produces deterministic, finite positions for cyclic graphs, disconnected components, self-loops, and parallel edges, but cyclic edges cannot all follow the chosen rank direction.
Its optional geometry capability consumes padded world-space node boxes and returns complete padded bounds. Missing entries in a partial geometry snapshot remain zero-size points. With no geometry context, it preserves the original position-only behavior and point bounds. Asymmetric padding shifts the returned body center correctly relative to Dagre's padded rectangle.
direction: rank flow (TB, BT, LR, or RL), default TBrankSpacing: distance between ranks, default 80nodeSpacing: distance between nodes within a rank, default 50edgeSpacing: separation used by Dagre between edge paths, default 20center: world-space center of the returned node positions, default
{ x: 0, y: 0 }The dependency is @dagrejs/dagre 3.1.x. It supports the workspace's ESM build
and ships its own TypeScript declarations.
createWorkerLayout adapts async or worker-backed layout execution behind the
same GraphLayout contract. The adapter accepts a small transport object with
run, optional cancel, and optional dispose methods. Graphora does not
expose browser Worker handles from the layout or engine APIs; applications or
future optional packages can own bundler-specific Worker construction behind
the transport boundary.
createBrowserWorkerForceLayout supplies Graphora's runnable browser module
Worker and force-layout entrypoint. It resolves the Worker from
new URL('./force-worker.js', import.meta.url), which the performance example
verifies through the repository's Vite build. The adapter terminates its active
Worker after success, error, cancellation, or timeout; starting a newer run
terminates obsolete CPU work. Call layout.dispose() when the application no
longer owns it. GraphEngine.destroy() does not dispose layouts because one
layout instance may be shared across multiple engine lifecycles.
Worker requests preserve the optional LayoutRunContext, and browser responses
validate and freeze geometry provenance plus the reserved route/label result
schema. All boundary values must remain structured-clone-safe plain data.
createObstacleRouter(options) / ObstacleRouter implements a cancellable,
measured, route-only layout stage. Set complete geometry and explicitly request
edge route output when running it through the engine. getDiagnostics() reports
per-edge success/reuse/fallback and bounded work. cancel() aborts active work;
dispose() also releases cached paths. Direct run contexts accept an optional
AbortSignal. See the concept and example
for defaults, selective rerouting after drag, parallel lanes, loops and limits.