eland
v0.3.2
Published
A modular TypeScript implementation of the Sugiyama-style layered graph layout algorithm (ELK Layered compatible).
Maintainers
Readme
eland
Extensible Layered Algorithm for Network Drawing
A modular TypeScript implementation of the Sugiyama-style layered graph
layout algorithm, following the same observable behavior as Eclipse Layout
Kernel's ELK Layered algorithm. Built from
CLEAN_ROOM_SPEC.md, a clean-room specification of ELK Layered's data model,
phases, and formulas.
Install
npm install elandUsage
import { layout } from "eland";
const a = { id: "a", width: 40, height: 40 };
const b = { id: "b", width: 40, height: 40 };
const c = { id: "c", width: 40, height: 40 };
const graph = {
nodes: [a, b, c],
edges: [
{ sourceId: "a", targetId: "b" },
{ sourceId: "a", targetId: "c" },
],
};
layout(graph); // mutates `graph` in place: x/y/width/height on nodes, sections on edgeslayout() mutates the graph you pass in (writing x, y, width, height
on nodes and ports, and sections on edges) and also returns it. The
top-level graph you pass is a Graph (nodes/edges) — a distinct type
from GraphNode, which is used for actual nodes and for a compound node's
own nested contents (children/containedEdges); see "Architecture" below
for why the two aren't unified.
Re-layout after the user moved nodes
layout(graph); // the first drawing
// ... the user drags node `b`: your UI writes its new position into the graph
b.y += 80;
layout(graph, { interactive: true }); // a valid drawing again, around where `b` was droppedWith interactive: true the graph you pass in is the previous drawing:
the nodes' x/y and the edges' sections from the last layout(). eland
keeps what it can of it:
- A moved node stays where it was dropped, adjusted only to fit its column or the nodes it was dropped among, which make room for it.
- Columns come from positions. A node dropped across a channel from its
neighbour turns the edge into a back edge (or back); one dropped clear of
every column starts its own. A column a drag leaves empty closes up, unless
interactiveCloseEmptiedColumnsisfalse. - Everything else keeps its position, and edges keep their routes, center labels included. Where something has to give way, it moves as little as it can, as a whole.
- Releasing without moving anything gives back the same drawing.
- Nothing is shifted back to the origin: a drawing that grows past it reports
its top-left corner as the graph's
x/y. - A node without a position — added since the last drawing — is laid out as an ordinary layout would place it, fitting in where there is room.
It works in every direction and with both hierarchyLayouts; with
clusters, drag the leaves (their x/y are relative to their cluster, as
always). Animating between the two drawings is up to the caller. The Storybook
has a drag-and-drop story, and storybook/vanilla/src/InteractiveGraph.ts is
a complete example. INTERACTIVE_RELAYOUT.md has the design.
Architecture
The library is organized as a literal pipeline: each phase and sub-step from the spec gets its own file or directory, so the code mirrors the spec's structure and you can trace "what happens to the data" step by step.
src/
types.ts Public graph data model (§3)
options/ Layout options: enums, defaults, spacing, per-element properties (§21)
graph/ Internal working graph model (§4) + public⇄internal converters
hierarchy/ Compound-node recursion, cross-hierarchy strategies
direction/ DIRECTION coordinate transform (§2)
preprocessing/ Connected component splitting + row-packing (§6.1)
phase1-cycle-breaking/ §7
phase2-layering/ §8
intermediate-processors/ Long-edge splitter, port sides, inverted ports, N/S ports, label dummies (§9)
self-loops/ Self-loop classification, slotting, routing (§15)
node-sizing/ Node sizing, free port placement (§18.1), margins
phase3-crossing-minimization/ §10
phase4-node-placement/ Brandes-Köpf placement (§11.2)
phase5-edge-routing/ Orthogonal + polyline + spline routing (§12)
wrapping/ Graph wrapping into rows (WRAPPING_STRATEGY, original design)
labels/ End (head/tail) label placement (§16)
postprocessing/ Long-edge joining, reversed-edge restoration, N/S port routing,
label cleanup, component reassembly, post-compaction (§13, §17)
pipeline/ Orchestrator: wires every phase above in the fixed order from §5
contracts/ Formal per-phase-strategy contract system (see below)
util/ Seeded RNG, Fenwick tree, geometry primitiveslayout() in src/index.ts sits above this pipeline: for a compound
(hierarchical) graph it recurses through src/hierarchy/ bottom-up, calling
the pipeline once per nesting level, before the top-level call below runs.
pipeline/index.ts is the one file that encodes phase order — every other
module is a self-contained function (or small set of functions) operating on
the internal graph, with no knowledge of what runs before or after it.
Data model: Graph vs. GraphNode
layout() takes and returns a Graph ({ nodes: GraphNode[]; edges: GraphEdge[]; width?; height? })
— a type distinct from GraphNode, deliberately. A graph has no parent to
be positioned relative to or attach a port to (no x/y/ports), and no
layout space reserved for it the way a compound node's own label consumes
room inside its parent (no labels) — those are all properties of being
contained by something, which the top-level graph, by definition, never
is. A compound node's own interior is children/containedEdges directly
on that GraphNode (not a nested Graph) — the two are laid out by the
same internal machinery (buildGraph/applyGraph take the two lists
directly, fed either from a Graph's nodes/edges or a compound node's
children/containedEdges), but that's an implementation detail shared
between them, not a reason to unify their public shapes. This mirrors
CLEAN_ROOM_SPEC.md §3.1's own historical shape only partially — the spec
(itself generated from real ELK's public API, which also has no separate
graph type) documents a single Node-rooted model; eland's Graph/GraphNode
split is a deliberate, conscious departure from that for public API clarity,
not an oversight or a faithfulness gap.
Data model: edge endpoints (sourceId/targetId vs. sourcePort/targetPort)
GraphEdge.sourceId/.targetId: string are always required — the only
endpoint addressing eland itself resolves, matching the node's id. This is
also a deliberate departure from real ELK: ELK (Java) resolves an edge's
endpoints by object identity, which is natural in a language where you're
already holding the node reference you just built the graph with. In JS/TS,
especially for a browser-facing layout library, graphs more often arrive as
JSON from a backend, get built by mapping over framework state, or get
handed to other id-addressed tools (Cytoscape, VueFlow) — identity
resolution means a caller who writes a fresh { id: "a" } object literal
(the natural thing to do) gets a confusing runtime error instead of a working
graph. GraphNode.id is correspondingly required (not optional like
x/y/width/height, which eland computes for you) and must be unique
across the whole node tree, at every nesting depth.
GraphEdge.sourcePort/.targetPort?: GraphPort are an optional refinement
on top of sourceId/targetId, never an alternative to them — supply one to
connect to a specific port on that node (validated to actually belong to
the node named by sourceId/targetId). You never have to: a graph of bare
nodes and edges is complete, and eland creates the ports itself.
How it creates them is the mergeEdges option, named and defaulted as in ELK
(org.eclipse.elk.layered.mergeEdges):
false(default) — every edge end that names no port gets a port of its own, so edges meeting a node leave and arrive at separate points along its side and never overlap there.true— a node's incoming edges share one port and its outgoing edges share another. Edges on a shared port run together as a trunk and branch off at junction points.
It can also be set per node in properties.mergeEdges, so one busy hub can
bundle its edges while the rest stay separate. Declared ports are always used
as given, whatever the setting. GraphPort.id stays optional, unlike GraphNode.id:
eland never resolves a port by id (only by direct object reference via
sourcePort/targetPort), so there's nothing forcing every port to have
one — though a caller integrating with a tool that does address ports by
id (e.g. VueFlow's handles) is free to set one for their own purposes.
Internally, the working graph (LayoutNode/LayoutPort/LayoutEdge, what
every phase processor actually operates on) is unaffected by any of this —
it's built fresh by buildGraph from the public graph on every layout()
call and never leaves the process, so it keeps using direct object
references, which remain the simplest and fastest representation for a
graph the algorithm owns for the duration of one call. Id-based addressing
is deliberately a public-API-boundary concern, not something threaded
through the whole pipeline.
Extensibility: contracts
Every phase's ordering rules (e.g. "the long-edge splitter must run before
fixInvertedPorts, which assumes every edge already connects consecutive
layers") used to exist only as prose in doc comments and CLEAN_ROOM_SPEC.md
§20 ("Key Invariants"). That's fine as long as only this codebase's own
phases exist; it stops being safe the moment a phase can be replaced by
external code, since there's then no way to tell a replacement what it's
allowed to assume or required to guarantee, and no way to catch it if it
gets that wrong.
src/contracts/ formalizes this as a small, checked mechanism:
GraphProperty(src/contracts/types.ts) is a closed vocabulary of structural invariants —acyclic,ranked,properLayering(§20 #1),portOrder(§20 #2),sized,placed,routed,positioned. Deliberately excludes aesthetic/quality dimensions like crossing count or edge straightness: nothing downstream actually depends on those for correctness (skipping crossing minimization entirely still produces a valid, just uglier, layout), so they don't belong in a system meant to check hard preconditions.PhaseContractattachesrequires/provides/invalidatesto one concrete strategy, not the abstract slot it fills — e.g. every cycle-breaking strategyprovides: ["acyclic"], but onlyINTERACTIVEadditionallyrequires: ["positioned"](it reads existing x/y as its ordering signal, so it's meaningless against unset defaults). Attaching contracts per-strategy is what avoids ever needing a disjunctive requirement like "acyclic OR (interactive AND positioned)" — only one concrete strategy is ever actually selected for a given run.runContractPipeline(src/contracts/orchestrator.ts) runs a list of contracts, lazily validating eachrequiresthe first time something actually needs it (not eagerly right after the providing phase) — this is both cheaper and more correct, since it also catches anything an intervening hook silently broke in between.PROPERTY_VALIDATORS(src/contracts/property-validators.ts) is one validator per property, not per phase — "doesacycliccurrently hold" has exactly one meaning regardless of which strategy claims to have established it. The record is frozen: custom strategies introduce their own properties per run viaPhaseContract.customProperties(validators travel with the contract that declares them and exist only for that pipeline run — the library keeps no mutable global state), and name collisions with built-ins or other contracts in the same run throw.
Current scope: the entire per-component pipeline runs through this
system. src/pipeline/assemble.ts expresses the §5–§17 step sequence as a
contract array (~26 steps, each declaring requires/provides/
invalidates/forbids — see PIPELINE_STEP_PROPERTIES.md for the full
property analysis), and runScopedContractPipeline executes it. The §7.2
layer-constraint bracket is a scoped property: a transient graph state
whose setup/teardown the orchestrator places automatically by liveness
analysis (from the first step that requires it to the last), rather than
being hand-positioned in the sequence. ContractViolationError is thrown
(and exported) when a requires isn't satisfied, a provides claim turns
out to be false, or a configuration is impossible to schedule (a forbids
inside a scoped property's live range). Validators run lazily and can be
disabled wholesale with validateContracts: false once a customized
pipeline is trusted; the ordering/liveness checks always stay on.
Extending the pipeline
Two mechanisms exist today, both built on PhaseContract: replacing one of
the five headline phases, and inserting your own steps at named hook
anchors around them. Custom code works directly on the internal working
graph, so LayoutGraph and its constituent types (LayoutNode, LayoutEdge,
LayoutPort, ...) are exported on purpose. (One practical note: internal
LayoutNode.ids are synthetic — the caller's node id lives on
node.original.id.)
The contract system — PhaseContract, requires/provides/
invalidates, runContractPipeline — is the mechanism that makes one of
these (replacing a phase) checked and safe. It's an important part of the
extensibility surface, not the whole of it: hooks (PipelineHooks) are
built on top of it (a hook is itself a ScopedPhaseContract at a named
anchor), explainPipeline inspects what it assembled, and LayoutGraph and
friends are what a contract's run() actually receives. All of it is
grouped under one extend namespace/subpath, named for that broader
purpose (see src/extend.ts's doc comment for the full breakdown of what
lives there and why).
Everything on this surface is reachable two ways: namespaced off the main
package (import { extend } from "eland", then extend.explainPipeline(...))
or directly from the eland/extend subpath
(import { explainPipeline } from "eland/extend"). Both are backed by the
same build artifact — extend.explainPipeline === explainPipeline (from
eland/extend) always holds, verified in test/subpath-identity.test.ts —
so pick whichever reads better in your code; there's no behavioral difference.
Types (PhaseContract, LayoutGraph, ScopedPhaseContract, ...) are flat
named exports from both eland and eland/extend rather than reachable
as extend.PhaseContract — a .d.ts-bundling limitation with namespacing
a mostly-type module, not a design choice.
Replacing a phase
Each of the five phases has a custom* option: customCycleBreaking,
customLayering, customCrossingMinimization, customNodePlacement,
customEdgeRouting. Supply a PhaseContract and it fills that slot instead
of the built-in strategy:
import { layout, type LayoutGraph, type PhaseContract } from "eland";
const myCycleBreaker: PhaseContract = {
slot: "cycleBreaking",
strategy: "my-cycle-breaker",
requires: [],
provides: ["acyclic"], // claim only what run() actually guarantees
run(graph: LayoutGraph) {
// Reverse edges in place until no directed cycles remain
// (see src/phase1-cycle-breaking/reverse-edge.ts's reverseEdgeDirection
// for the bookkeeping a reversal must maintain).
return graph;
},
};
layout(graph, { customCycleBreaking: myCycleBreaker });Each built-in slot's contract surface (what a replacement should require and
provide) is documented on the corresponding option in LayoutOptions.
Inserting hook steps
hooks takes arrays of contracts at ten named anchors — beforeCycleBreaking,
afterCycleBreaking, beforeLayering, afterLayering,
beforeCrossingMinimization, afterCrossingMinimization,
beforeNodePlacement, afterNodePlacement, beforeEdgeRouting,
afterEdgeRouting — inserted immediately around the phase when the pipeline
is assembled:
layout(graph, {
hooks: {
afterLayering: [{
slot: "hook:report",
strategy: "layer-report",
requires: ["ranked"], // validated before the hook runs
provides: [],
run(g) { console.log(`${g.layers.length} layers`); return g; },
}],
},
});Things to know about hooks:
- Multiplicity: the pipeline runs once per connected component, per hierarchy nesting level — so does every hook.
- Brackets: the §7.2 layer-constraint preparation is a scoped property
whose live range is computed from who requires it. Hooks at
beforeCycleBreaking/afterLayeringtherefore run outside it (natural graph) by default, and a hook can opt in by requiringlayerConstraintEdgesPrepared. The two anchors between cycle breaking and layering are unavoidably inside the bracket; declareforbids: ["layerConstraintEdgesPrepared"]there to get an assembly-time error instead of silently seeing the prepared graph. - Validation: a hook's
requiresis checked like any step's — requiring something not yet established rejects the configuration before anything runs.
Seeing what you configured
explainPipeline(options) returns the assembled per-component schedule —
every step with its slot, strategy, and contract surface, plus each scoped
property's computed live range — without running a layout. Use it to verify
where a custom slot or hook landed and how the brackets moved. Its errors
field carries the same diagnostics layout() would throw for an
unschedulable configuration (empty means the schedule is valid) — unlike
layout(), explainPipeline never throws for these, so you can inspect the
attempted schedule alongside exactly why it won't run.
Debugging: descriptive internal ids
Internal nodes/ports/edges — including every dummy the pipeline inserts —
normally get short, meaningless ids (n1, p2, e3, ...): fast to generate
and, since no mutable global state survives between runs, guaranteed unique
without any caller coordination. That's also exactly what makes them hard to
read back out of a hook while debugging: nothing in n7 says which of your
nodes it stands in for.
Set debugIds: true to trade that compactness for readability. Real nodes
and edges keep the id/label you gave them; every dummy is named after what
it represents instead of a bare counter — a LONG_EDGE dummy chain between
a and b becomes nodes na-b1, na-b2, ... joined by edges ea-b1,
ea-b2, ...; a NORTH/SOUTH port dummy on node x becomes nx-N/nx-S with
connecting edge ex-N/ex-S; and so on for the other dummy-inserting
processors (§9.3 inverted-port detours, §9.7 high-degree fan-out, §16
label-dummy insertion). Only affects ids surfaced through hooks and
explainPipeline — it changes nothing about the computed layout itself
(positions, routes, layer assignment are identical either way) and defaults
to false since the naming does cost a little extra string-building per
dummy. A couple of dummy kinds still fall back to a generic prefix (§14.2
BREAKING_POINT partition anchors, and label-repositioning's bridge edges)
where no single "represents this node/edge" label applies.
Rules for custom phases and hooks
- Mutate the
LayoutGraphin place and return it, like every built-in phase. - Declare
requires/provides/invalidatesaccurately — the orchestrator trusts your claims and lazily validates them when a later phase depends on one, throwingContractViolationErrorwith the offending strategy's name if a claim turns out to be false. - Respect the Key Invariants (
CLEAN_ROOM_SPEC.md§20) for whatever stage of the pipeline you run at; theGraphPropertyvocabulary is the checked subset of those. - Keep contracts stateless across
run()calls (they execute once per component per nesting level), or manage state deliberately.
Reusable building blocks
A custom phase or hook is usually building something the pipeline itself
already needed. Rather than make you reimplement (and re-debug) these,
eland exports the general-purpose algorithms and data structures it's built
from — reachable two ways, same as the contract system above: namespaced
(import { toolbox } from "eland", then toolbox.FenwickTree) or from the
eland/toolbox subpath (import { FenwickTree } from "eland/toolbox"), both
backed by the same build artifact (toolbox.FenwickTree === FenwickTree
always holds; see test/subpath-identity.test.ts). Box/Point/
SimplexEdge are flat named exports rather than toolbox.Box, for the same
.d.ts-bundling reason noted above:
computeStronglyConnectedComponents(nodes, edges)— Tarjan's SCC algorithm overLayoutNode/LayoutEdge. Useful for any cycle- or component-aware custom cycle-breaking or analysis step. Iterative, so it doesn't overflow the call stack on deep graphs.reverseEdgeDirection(edge)— flips aLayoutEdge's direction in place: togglesreversed, swapssource/target, and fixes up both ports'incomingEdges/outgoingEdgesback-reference arrays. Every built-in cycle-breaking strategy uses this to reverse the edges it identifies; a customcycleBreakingphase (§7) should too, rather than swappingsource/targetby hand — it's easy to update the fields but forget the port back-reference fixup (later phases likeassignPortSidesand crossing minimization read staleincomingEdges/outgoingEdgesotherwise) or forget to togglereversed(the final output step won't restore the caller's original edge direction). A port that ends up shared between a reversed and a non-reversed edge (both landing on the same node's implicit role port, since this reuses the edge's own pre-reversal ports rather than moving it elsewhere) isassignPortSides's problem to resolve, not this function's — its genuine (never-reversed) edges always win a tie, so a reversed sibling never drags them into an unnecessary §9.3 detour, while the reversed edge itself still gets routed around.excludeEdgeFromLayering(edge)— a second way to break a cycle, alongsidereverseEdgeDirectionabove: drops the edge'slayerIndex(target) === layerIndex(source) + 1constraint instead of flipping its direction. Reversal always imposes some ordering on the pair it touches; for a symmetric 2-cycle, whichever direction survives forces the two endpoints into different layers and distorts a shape the input drew as symmetric. Excluding both directions removes that forcing instead of replacing it with a different one, leaving layering free (not guaranteed) to place the pair side by side. The edge is never deleted — only Phase 2 stops seeing it — and a built-in post-layering step settles it against the layer indices that actually came out: forward is re-admitted as an ordinary edge, backward is reversed after all — so exclusion always degrades to exactly the reversal a reversing strategy would have produced, never to a dangling back edge — and same layer is drawn through a helper in that layer: out of the source's east side like any edge, round the right of the layer to the helper, across the layer there, and round the left into the target's west side. Crossing minimisation keeps the helper beside one of the two endpoints — usually between them, outside when that crosses less, as it can for a two-way pair — and counts the edges its loops pass. It does not, on its own, keep the two endpoints adjacent: pair it with aninLayerConstraint(or a custom crossing-minimization strategy) if that matters. UnderbackEdgeRouting: "DIRECT"a same-layer edge is drawn the same way for now; top to bottom would read better there (FUTURE_IMPROVEMENTS.md).constrainsLayering(edge)— the predicate every built-in layering strategy filters through: true unless the edge is a self-loop, already in-layer, or excluded viaexcludeEdgeFromLayeringabove. A custom layering strategy iteratinggraph.edgesby hand should filter through this too, rather than reimplementing the!selfLoop && !inLayercheck, so it also honors edges a custom cycle-breaking phase excluded.createNode/createPort/createEdge/createLabel— the constructors every built-in dummy-inserting processor uses to build a well-formedLayoutNode/LayoutPort/LayoutEdge/LayoutLabel(correct defaults, ids minted from the run's sharedIdGenerator). Needed by any custom phase that inserts its own elements — a custom layering strategy adding its own constraint dummies, a custom routing strategy adding bend dummies.detachEdge(edge)— unhooks an edge from both endpoint ports'incomingEdges/outgoingEdgeslists without reversing it or touchingsource/target. Pairs withreverseEdgeDirectionabove for a custom phase that rewires an edge onto different ports rather than just flipping its direction.countCrossings(graph)/countCrossingsBetweenLayers(left, right)— the §10.2 crossing counter. A custom crossing-minimization strategy needs exactly this to score candidate layer orderings, the same way any custom cycle-breaking strategy needscomputeStronglyConnectedComponentsabove.layerByTopologicalOrder(graph, selectNext)— Kahn's-algorithm layering engine with a pluggable tie-break, already the shared backbone behindINTERACTIVE/BF_MODEL_ORDER/DF_MODEL_ORDERlayering. A custom layering strategy with its own tie-break rule can reuse this directly instead of reimplementing topological layering from scratch.markConflicts(graph),verticalAlignment(graph, markedEdges, sweepDirection, preference)(returns anAlignment),computeInsideBlockShift(graph, alignment, sweepDirection), andhorizontalCompaction(graph, alignment, innerShift, blockSize)— the four composable steps §11.2'sBRANDES_KOEPFnode placement decomposes into (mark → align → inner-shift → compact). A customnodePlacementstrategy wanting a Brandes-Köpf variant — a different conflict-marking rule, say, or reusing alignment for a different purpose — can recombine these directly instead of reimplementing the paper from scratch. The built-in strategy's 5th step (candidate selection across the four sweep/preference combinations) stays internal — it readsnodePlacementBkFixedAlignment/nodePlacementFavorStraightEdgesdirectly rather than being a standalone algorithm over caller-supplied data.solveNetworkSimplex(nodeCount, edges)andweaklyConnectedComponents— the domain-agnostic constrained-optimization solver behindNETWORK_SIMPLEXnode placement andEDGE_LENGTHpost-compaction: minimizesΣ weight·(rank[target] − rank[source])subject to a per-edgeminLength, over an integer-indexed graph you build yourself (SimplexEdge[]). Solves per weakly-connected component internally.FenwickTree— binary indexed tree for O(n log n) prefix-sum / inversion counting, as used by the crossing counter (§10.2).UnionFind— disjoint-set with path compression, as used for connected-component grouping.RandomGenerator— the seeded PRNG (mulberry32) every built-in randomized decision uses; also the type ofLayoutGraph.random.IdGenerator— the per-run sequential id source dummy nodes/ports/edges get; also the type ofLayoutGraph.ids. Construct your own instance, or use the graph's shared one to keep custom-created elements' ids consistent with the rest of the run.at(array, index, label)— the checked-index-access helper used throughout the pipeline: throws aLayoutErrornaming the failure instead of lettingundefinedsilently propagate intoNaNcoordinates.removeFrom(list, item)— splices the first occurrence ofitemout oflistin place, no-op if absent. The array-level operation every edge-rewiring helper (detachEdge,reverseEdgeDirection, every intermediate processor's edge retargeting) bottoms out in.argmin(items, key)— index of the item minimizingkey; ties keep the first (lowest-index) minimizer. The tie-break primitive behindlayerByTopologicalOrder's pluggableselectNext.zeroBox()— an all-zeroBox(margin/padding shape), plus the already-exportedBox/Pointtypes.
API stability
Pre-1.0 (0.x), the API is split into two tiers:
- Core API —
layout(), the public graph types (Graph,GraphNode,GraphEdge,GraphPort, ...),LayoutOptionsInput, and the error classes. Kept as stable as practical during 0.x, but not guaranteed. - Extensibility surface (
@experimental) — the contract system (PhaseContract,ScopedPhaseContract,ScopedProperty,GraphProperty,GraphPropertyName,runContractPipeline,runScopedContractPipeline,PROPERTY_VALIDATORS), the fivecustom*phase slots,hooks(PipelineHooks),validateContracts,explainPipeline, the internal working-graph types (LayoutGraphand friends), and the reusable building blocks (computeStronglyConnectedComponents,solveNetworkSimplex,weaklyConnectedComponents,SimplexEdge,excludeEdgeFromLayering,constrainsLayering,FenwickTree,UnionFind,RandomGenerator,IdGenerator,at,zeroBox) — all also reachable via theeland/toolboxandeland/extendsubpaths (see "Extending the pipeline" and "Reusable building blocks" above). These may change in any 0.x release while the extension model is being finished (seeROAD_TO_1.0.md). So mayinteractiveandinteractiveCloseEmptiedColumns, which are new.
From 1.0.0, the @experimental tags come off and everything exported —
including LayoutGraph and the contract system — is covered by semver:
breaking changes only in major versions. ROAD_TO_1.0.md tracks what has
to be true before that promise can be made.
Scope
This is a from-scratch implementation, not a port, and does not attempt full
parity with every option ELK Layered exposes. It implements the default
strategy for every phase, at full fidelity, plus the structural/spacing
features needed to make that path correct, plus a growing set of the
simpler strategy alternatives. Selecting a strategy that isn't implemented
yet throws NotImplementedError rather than silently substituting a
different algorithm.
Implemented:
- Cycle breaking:
GREEDY_MODEL_ORDER(Eades–Lin–Smyth with a model-order tie-break, default),GREEDY(same heuristic with a random tie-break),DEPTH_FIRST,INTERACTIVE,MODEL_ORDER,SCC_CONNECTIVITY,SCC_NODE_TYPE,DFS_NODE_ORDER,BFS_NODE_ORDER— all 9 strategies. (eland defaults toGREEDY_MODEL_ORDERrather than the spec'sGREEDY, so ambiguous ties resolve by input order instead of the RNG, giving stable, reproducible layerings.) - Layering:
NETWORK_SIMPLEX(default),LONGEST_PATH,LONGEST_PATH_SOURCE,COFFMAN_GRAHAM,INTERACTIVE,BF_MODEL_ORDER,DF_MODEL_ORDER,STRETCH_WIDTH,MIN_WIDTH— everyLAYERING_STRATEGYvalue — plusLAYER_CONSTRAINT,PARTITION, and node promotion (NIKOLOV,NIKOLOV_PIXEL,NIKOLOV_IMPROVED,NIKOLOV_IMPROVED_PIXEL,NO_BOUNDARY,MODEL_ORDER_LEFT_TO_RIGHT,MODEL_ORDER_RIGHT_TO_LEFT,DUMMYNODE_PERCENTAGE,NODECOUNT_PERCENTAGE) — all node promotion strategiesNETWORK_SIMPLEXruns to optimality, as Gansner et al. describe; their final balancing step, which moves nodes that can sit in several layers at equal cost to the least crowded one, is available aslayeringNetworkSimplexBalancing(off by default: it costs small graphs a few crossings). - Crossing minimization:
LAYER_SWEEP(default, barycenter/multi-restart),MEDIAN_LAYER_SWEEP,NONE,INTERACTIVE, plus greedy switch and parallel-edge merging - Node placement:
BRANDES_KOEPF(default, all 5 steps, including ELK's inside-block-shift addition),NETWORK_SIMPLEX(Gansner et al. auxiliary-graph placement, using the confirmed exact edge-weight constants —BASE = 4/2×BASE/8×BASEby real/dummy pairing,NS_PORT = 0.1for north/south port edges, flatBASEfor in-layer edges — perSPEC_GAPS.md),LINEAR_SEGMENTS(pendulum/barycenter relaxation, using the confirmed exactFORW_PENDULUM/BACKW_PENDULUM/RUBBERphase state machine,THRESHOLD_FACTOR = 20/PENDULUM_ITERS = 4/FINAL_ITERS = 3, port-anchor-based deflection formula, and merge-based overlap resolution — perSPEC_GAPS.md; segment membership remains eland's own dummy-chain-only model, a documented remaining divergence),SIMPLE,INTERACTIVE(preserves input y coordinates) — all 5 strategies - Edge routing:
ORTHOGONAL(full routing-segment/dependency-graph/slot pipeline),POLYLINE(the orthogonal route, straightened wherever a straight segment keepsSPACING_EDGE_NODEclear of every other node: an edge goes round a node rather than through it, and never takes a shortcut past its own center label or a junction point —src/postprocessing/polyline-shortcuts.ts), andSPLINES(the same straightened route, with each corner rounded by a cubic Bézier curve as far as the corner has room — the curve stays inside the triangle its control points span, so it is checked clear before it is used, and shrinks to a sharp corner if nothing fits;src/postprocessing/spline-curves.ts, run once the whole layout is done so routes spliced across cluster borders are curved whole). A spline'sbendPointsare Bézier control points, as ELK and Graphviz write them: after the start point they come in triples — control, control, end of that piece — for 3n + 1 points in all, which is exactly what SVG'sCcommand takes; a straight edge has none.SPLINE_ROUTING_MODE = SLOPPYwidens each channel individually based on its own largest vertical edge span (sloppySpacing = splineSloppySpacingFactor × min(1, SPACING_EDGE_EDGE / SPACING_NODE_NODE) × maxVertDiff), per the source-derived formula inSPEC_GAPS.md—splineSloppySpacingFactordefaults to1.0since the spec names the option but not a default value - Ports:
FREE,FIXED_SIDE,FIXED_ORDER,FIXED_RATIO,FIXED_POS, allPORT_ALIGNMENT_*values,PORT_SORTING_STRATEGY(INPUT_ORDERdefault,PORT_DEGREE— center-weighted by degree, EAST/WEST only per §18) - Self-loops (all 3 classifications,
SELF_LOOP_DISTRIBUTIONincludingNORTH_SOUTH/EQUALLY— note self-loops sharing one node's implicit port, i.e. no explicit port given, structurally share one side and can't be independently distributed; give them distinct ports for real distribution across sides), center/head/tail edge labels, connected-component splitting and row-packing FEEDBACK_EDGESisbackEdgeRouting: "LOOP" | "DIRECT"here, an enum rather than ELK's boolean:LOOPistrue, and eland's default. Port sides are assigned once, after layering, when cycle breaking has settled which edges are back edges;LOOPsides every port by its edges' original direction,DIRECTby their direction after cycle breaking (§9.2). The §9.3 inverted-port correction (the detour that makes a side assignment contradicting the internal post-reversal flow direction route validly instead of producing a broken/non-orthogonal path) applies to every node regardless ofPORT_CONSTRAINTSlevel — an earlier, narrower reading restricted it toFIXED_SIDE/FIXED_ORDER/FIXED_POSnodes only, which leftLOOPproducing invalid routes for the commonFREE-constraint case; confirmed against Schulze/Spönemann/von Hanxleden, "Drawing layered graphs with port constraints" (JVLC 2014), where every node's ports are fixed to a side before crossing minimization regardless of constraint level. DefaultLOOP, where ELK's isfalse. It decides how a back edge is drawn, which is a matter of taste:LOOPdraws it as a loop — out of its source by the side the flow runs towards, like any outgoing edge, round, and into its target from behind — so it reads as a back edge at a glance;DIRECTlets it leave its source on the upstream side and run straight back, marked only by its arrowhead, which is shorter and crosses less (SPEC_GAPS.md§3.2 has the numbers). WithDIRECT, a back edge still loops if it shares a port with forward edges (undermergeEdges), since one port has one side; that case, and edges between clusters underNESTED, which always loop, are inFUTURE_IMPROVEMENTS.md.- Center-label dummy repositioning (§9.5): all 3 strategies (
MEDIAN_LAYERdefault,WIDEST_LAYER,CENTER_LAYER) are implemented and selectable vialabelDummyRepositioningStrategy— an option eland had to invent, since §21 never names one for this (seeSPEC_GAPS.md) EDGE_LABELS_INLINEfor center-label dummies: non-inline (default) reservesSPACING_EDGE_LABELabove the label so the connecting edge passes cleanly above it with a real gap; inline moves the through-port toceil((dummyHeight − edgeLabelSpacing − edgeThickness) / 2), routing the edge through the label instead — eliminating that gap, perSPEC_GAPS_RES.md's source-derived formula- "Smart" end-label side selection (
SMART_UP/SMART_DOWN, §16.2): each head/tail label's up and down candidate positions are compared by overlap count against real nodes and end labels placed earlier in the same pass (a deterministic single-pass greedy choice, not a global optimizer); the side with fewer overlaps wins, with ties going to the strategy's preferred direction (up forSMART_UP, down forSMART_DOWN) EDGE_LABELS_SIDE_SELECTIONfor center-label dummies (§16.2,src/labels/center-label-side.ts): previously always hardcoded to the equivalent ofBELOWregardless of the option — now implements the source-recovered structural algorithm exactly: for each run of consecutive LABEL/LONG_EDGE dummies in a layer, a lone label dummy touching the layer's top/bottom edge getsABOVE/BELOWrespectively, a bare 2-node run splits first-ABOVE/second-BELOW, and same-source/target-pair sub-runs of exactly 2 split the same way (elsedefaultSide). Runs after crossing minimization and label-dummy repositioning (both must be final first) and before Phase 4 (the choice feeds node-placement pull-weights).EDGE_LABELS_INLINEdummies are exempt — seeSPEC_GAPS.mdfor why an overlap heuristic (like the end-label case above) isn't available at this pipeline stage at all.- Pre-Phase-4 head/tail label space reservation (§16.1 step 1): each label's
height plus
SPACING_EDGE_LABELis added to its anchor node's margin — conservatively on both the top and bottom, since the label's side isn't chosen until post-routing side selection — so Brandes-Köpf's compaction leaves room for it - All four
DIRECTIONvalues and bothDIRECTION_CONGRUENCYmodes - Graph wrapping:
WRAPPING_STRATEGY=SINGLE_EDGE/MULTI_EDGE(plus theOFFdefault). The spec only names the option and its values — no algorithm is given — so this is an original design: when the routed layout is wider than the §6.1-style aspect-ratio budget (sqrt(node_area) × ASPECT_RATIO), the layer sequence is cut at layer boundaries (SINGLE_EDGEsearches outward for a boundary crossed by at most one edge;MULTI_EDGEcuts wherever the width budget runs out), the resulting runs of layers are stacked vertically as rows like packed components, and each severed edge is re-routed as an exit-right / enter-left connector between its two rows. See the comments insrc/wrapping/for the full design rationale. The separateWRAPPING_CUTTING_STRATEGYoption is not exposed or consulted — the spec lists no values or semantics for it, so cut selection is fully determined byWRAPPING_STRATEGY. - Hierarchical / compound nodes (
node.childrenbelow the root), laid out either cluster by cluster or as one flat graph (hierarchyLayout:NESTED,FLAT), each with its own routing for edges that cross a cluster boundary (crossHierarchyRouting:FORWARD,DIRECT) — seesrc/hierarchy/and "Hierarchy" below. §21'sHIERARCHY_HANDLINGis deliberately not mirrored; see the divergence note there - Post-compaction:
COMPACTION_POST_COMPACTION_STRATEGY— all six values (NONE,LEFT,RIGHT,LEFT_RIGHT_CONSTRAINT_LOCKING,LEFT_RIGHT_CONNECTION_LOCKING,EDGE_LENGTH). After routing, nodes and vertical edge segments are shifted horizontally along a left-to-right constraint graph to close gaps (§17 item 10). The twoLOCKINGvariants compact left, pin some elements at that position (CONSTRAINT_LOCKING: every element with zero constraint-graph edges at all;CONNECTION_LOCKING: elements whose real graph edges skew toward the left, for better average edge length), then compact everything else right — seeSPEC_GAPS.mdfor the recovered algorithm andUNTESTED_FIXES.mdfor how the two locking solvers were verified (direct unit testing ofsrc/postprocessing/post-compaction/solve.ts, not reachable through the public API).EDGE_LENGTHminimizes total edge length instead of drawing width — a genuinely different objective (it's explicitly allowed to widen the drawing) solved via network simplex over the same constraint graph (src/postprocessing/post-compaction/edge-length.ts), sharing its solver core (src/util/network-simplex-solver.ts) withNETWORK_SIMPLEXnode placement rather than duplicating it. Only active withORTHOGONALedge routing, the only style whose routes have vertical segments. TheCOMPACTION_POST_COMPACTION_CONSTRAINTSvaluesSCANLINEandQUADRATICboth route to the same O(n²) pairwise constraint-discovery implementation — the spec frames them purely as a complexity difference (O(n log n) vs O(n²)), never an output difference, so this is a legitimate simplification. EDGE_THICKNESS(§21): factors into every edge-edge spacing decision — dependency-graph conflict/critical thresholds, in-layer U-loop offsets, and post-compaction edge-vs-edge gaps all widen by (roughly) half the sum of the two edges' thicknesses viaedgeEdgeSpacing/edgeEdgeBetweenLayersSpacinginsrc/options/spacing.ts. The one simplification: between-layer routing slots keep a single uniform pitch per channel (`xSlot(k) = channelStartX- k * pitch
) rather than a per-slot variable width — the pitch is widened toedgeEdgeBetweenLayers + max(thickness)across all routable segments in that channel, so the thickest edge present gets enough room but thinner slots in the same channel are not correspondingly narrower. A truly variable-width slot layout would require reworking the bend-point/junction- point/channel-width formulas together and was judged out of scope here. Default (unset, i.e.0`) thicknesses reproduce the prior flat-constant behavior exactly.
- k * pitch
Not implemented (throws NotImplementedError if selected, or is simply
absent as a feature):
NODE_PLACEMENT_NETWORK_SIMPLEX_NODE_FLEXIBILITY(§11.3): accepted but silently ignored by theNETWORK_SIMPLEXnode placement strategy — nodes are always treated as fixed-size with fixed portsCROSSING_MINIMIZATION_SEMI_INTERACTIVE = true(§21: "preserve the relative order of nodes that were not touched between layout runs"):layout()is a pure function with no concept of a previous run's state to compare against, so there's nothing to preserve — rejected explicitly rather than silently ignored (false, the default, is a no-op and works fine)
CONSIDER_MODEL_ORDER_STRATEGY's three non-NONE values (NODES_AND_EDGES,
PREFER_EDGES, PREFER_NODES) are differentiated on two independent axes.
The penalty formula side uses an implicit default influence: each
strategy supplies a default of 1 for whichever of
considerModelOrderNodeInfluence/considerModelOrderPortInfluence it's named
after, but only while that option is still at its 0 default —
NODES_AND_EDGES defaults both to 1, PREFER_NODES only the node influence,
PREFER_EDGES only the port influence. Explicitly configuring a non-zero
influence always overrides the strategy's implicit default, regardless of
which strategy is selected — implemented in scoreGraph in
src/phase3-crossing-minimization/barycenter-sweep.ts.
The comparator resolution side changes which key the barycenter sweep
falls back on when two nodes (or two ports) tie on barycenter: nodes sort by
their own model-order index, except under PREFER_EDGES, which falls back
to the order of the node's incident edge instead (this is what gives dummy
nodes, whose own index is undefined, a meaningful order under that strategy
specifically); ports sort by their connecting edge's model order, except
under PREFER_NODES, which sorts by the model order of the node on the
other end of the connection instead. Implemented in nodeOrderKey/
portOrderKey/modelOrderTieBreak in
src/phase3-crossing-minimization/model-order.ts, plugged into the node
sweep (barycenter-sweep.ts) and port distribution (port-distribution.ts).
See SPEC_GAPS.md for the full writeup — the spec doesn't spell out an exact
mechanism for how these three should differ, only that they're "a family of
model-order-aware variants" on both axes above; this is a documented
interpretation.
CONSIDER_MODEL_ORDER_COMPONENTS's three non-NONE values
(INSIDE_PORT_SIDE_GROUPS/GROUP_MODEL_ORDER/MODEL_ORDER) sort connected
components by minimum node model order before row-packing them (§6.1), same
as NONE otherwise — implemented in packComponents in
src/components/pack.ts. Per SPEC_GAPS.md, the
three strategies only actually differ from each other (and from this
degenerate case) for components carrying external ports from a hierarchical
sub-layout, which eland's current scope never produces (no EXTERNAL_PORT
dummy mechanism), so this is the complete, correct behavior for every case
that can currently arise — not a partial stand-in.
LONGEST_PATH/LONGEST_PATH_SOURCE/COFFMAN_GRAHAM/INTERACTIVE/
BF_MODEL_ORDER/DF_MODEL_ORDER layering and the INTERACTIVE cycle
breaker assume graph is a single connected component (true whenever
SEPARATE_CONNECTED_COMPONENTS is on, the default). INTERACTIVE,
BF_MODEL_ORDER, and DF_MODEL_ORDER layering are all implemented as
Kahn's-algorithm topological sorts that only differ in which "ready" node
(all predecessors already placed) gets processed next — by smallest x,
smallest modelOrder, and a depth-first stack respectively — which
guarantees a valid layering regardless of traversal flavor, at the cost of
BF_MODEL_ORDER not being a literal level-order BFS.
STRETCH_WIDTH and MIN_WIDTH layering are both marked "Experimental" in
the spec with no exact formula given ("derive through experimentation")
— both are from-scratch, documented heuristics rather than transcriptions;
see the comments in src/phase2-layering/stretch-width.ts and min-width.ts
for the derivation. Node promotion's *_PIXEL variants weight each layer by
node.width rather than node count — an approximation, since final computed
widths for auto-sized nodes aren't known until well after Phase 2, when
node promotion runs. DUMMYNODE_PERCENTAGE/NODECOUNT_PERCENTAGE add two
layout options, layeringNodePromotionMaxDummyReductionPercent and
layeringNodePromotionMaxNodeCountPercent (both default 100), and run the
same unbounded sweep as NO_BOUNDARY but stop early once a threshold is
crossed — cumulative dummy-cost reduction, or fraction of nodes promoted,
respectively. The percentage is measured against however much reduction (or
however many promotions) an unbounded sweep in the same node order would
have achieved, not a theoretical global optimum — the spec gives no
reference quantity, so 100% is defined to behave identically to
NO_BOUNDARY by construction, and lower percentages simply cut the same
sweep short. See src/phase2-layering/node-promotion.ts.
SCC_NODE_TYPE is implemented identically to SCC_CONNECTIVITY — this
library has no concept of the "preferred node type ordering" that strategy
additionally consults. BFS_NODE_ORDER reverses edges against a BFS
discovery order rather than detecting true back edges (BFS has no recursion
stack to define them against); both this and SCC_CONNECTIVITY/SCC_NODE_TYPE
fall back to GREEDY if their heuristic doesn't fully resolve a cycle within
a bounded number of passes, guaranteeing a DAG either way.
Hierarchy
A compound node's subtree is laid out as its own independent, self-contained
flat graph (src/hierarchy/layout-compound-node.ts recurses bottom-up,
calling the same buildGraph/runPipeline/applyGraph pipeline used for
the top-level graph once per nesting level), then inset by padding and the
compound node is resized to fit (src/hierarchy/inset-and-resize.ts) — so
by the time any level's own layout runs, every compound child is already an
ordinary fixed-size box from that level's point of view.
A cross-hierarchy edge — one whose endpoints don't share a parent — is
drawn by default. crossHierarchyHandling: "REJECT" refuses such a graph
instead, checked up front by src/hierarchy/validate-no-cross-hierarchy-edges.ts
before any level is built or any geometry written, so the error names the
offending edge, which endpoint isn't reachable from that container, where it
actually lives, and the option that would allow it — rather than surfacing
later as the per-level builder's "unknown node id", which is misleading (the
id is perfectly well known, just not addressable from the level being built).
Divergence from the spec and from ELK. §21's
HIERARCHY_HANDLING(SEPARATE_CHILDREN/INCLUDE_CHILDREN) is not mirrored by name. ELK'sINCLUDE_CHILDRENlays each compound node out bottom-up and routes edges across the borders, which is eland'shierarchyLayout: "NESTED"; both minimise crossings across levels with the same folded sweep and the same option,hierarchicalSweepiness.SEPARATE_CHILDRENleaves edges between clusters unrouted, which iscrossHierarchyHandling: "OMIT".hierarchyLayout: "FLAT"has no ELK counterpart.
Cross-hierarchy edges are resolved by
src/hierarchy/per-level/bridge-cross-hierarchy-edges.ts (§6.2), but
via a deliberately simpler approach than full §14 hierarchical port
routing: rather than dedicated boundary ports positioned to line up with an
edge's exact interior crossing point (which would need boundary-port state
threaded through layering, crossing minimization, and routing across nesting
levels), a cross-hierarchy edge is temporarily replaced, for the duration of
its lowest-common-ancestor's own flat layout, by a synthetic edge connecting
whichever direct children of that ancestor contain the real endpoints (a
node several levels deep is represented by its top-level ancestor there).
The edge is laid out like any ordinary edge between those two subtrees, via
their own ordinary ports. After that level's layout runs, the synthetic
edge's route is copied onto the real edge and the object graph is restored
exactly as the public API promises.
That synthetic carries no record of which descendant each real endpoint
was, so the route it comes back with attaches to the compound node's
boundary. src/hierarchy/per-level/remap-bridged-endpoints.ts fixes
that up before the route is handed over: each end is moved from the
stand-in ancestor's boundary onto the midpoint of the corresponding side of
the real endpoint's own box (found by walking the parent chain back down,
so an endpoint nested any number of levels deep works), rejoined to the
crossing point the layout chose by an orthogonal dog-leg through the
ancestor's interior. Everything the flat layout actually computed — the
interior bend points, the trunk between the two subtrees — is preserved
exactly, and the result stays orthogonal, free of duplicate and redundant
bend points, and inside the ancestor's box.
How the stand-in reaches the boundary is a strategy, chosen by four options
(full specification in COMPOUND_EDGE_ALGORITHMS.md, comparison against ELK
and dagre in COMPOUND_EDGE_STRATEGIES.md). The last two may also be set per
compound node via properties, inherited from the parent; the first two apply
to the whole graph only.
hierarchyLayout— what one layout run covers.NESTED(default) lays each compound node out on its own, innermost first, and hands the piece of an edge inside a cluster to that cluster's own Phase 5, by standing a boundary dummy in its graph before it is laid out; the cluster makes room for the edge and routes it clear of its own contents.FLATinstead lays every leaf out in a single pass with containment as an ordering constraint, places each cluster from its own contents before placing it as a whole, and derives cluster rectangles afterwards — the only layout that layers across levels, at the cost of a compound node's explicit size becoming a minimum and per-cluster layout options meaning nothing. It uses its own node placement, sonodePlacementStrategyhas no effect underFLAT. (UnderNESTED,hierarchicalSweepinesslets the level above reorder a cluster's children; onlyFLATlayers them together.)crossHierarchyRouting— how an edge between clusters is routed. Each hierarchy layout has its own strategies, and today each has one:FORWARDunderNESTED(an edge leaves a cluster by the side the flow runs towards and enters by the side it comes from),DIRECTunderFLAT(the flat layout routes the edge like any other). Left unset, it takes the one that goes withhierarchyLayout; a strategy the layout cannot do is an error.Three geometric strategies —
PORT_BASED,EDGE_BUNDLINGandBRIDGE— were removed in 0.3. They drew the interior piece as a line computed after the cluster was already final, so it was routinely drawn straight across the cluster's own nodes. SeeSTRATEGY_REVIEW.md.crossHierarchyBundling— which crossings share a port:EDGE(default, none),ENDPOINT_NODE(one per far node),ENDPOINT_CLUSTER(one per far cluster),CLUSTER_PAIR(one per neighbouring cluster, direction collapsed). Edges sharing a port merge into a trunk with junction points by way of §12.1.6, so the number of lines crossing a border becomes the number of distinct relationships rather than the number of edges.crossHierarchyHandling—DRAW(default),AGGREGATE(replace the edges between two clusters with one summary line; the originals recordaggregatedInto/aggregatedCount),REJECT, orOMIT(leave them out of the layout entirely and list them ongraph.omittedEdges).hierarchicalSweepiness— ELK's option, with ELK's default0.1: how readily a cluster's own ordering is folded into the level above it (spec §10.8, Schelten's hierarchy-aware layer sweep), so the edges leave a cluster in the order they are heading instead of braiding just outside its border.-1orders every level on its own, bottom-up.NESTEDonly.
Under NESTED the piece of an edge inside a cluster is a real edge of
that cluster's own graph — a boundary-port dummy stands at the border before
the cluster is laid out — so the cluster makes room for it and Phase 5 routes
it clear of the cluster's contents. That happens at every boundary the edge
crosses, not just the outermost, and an explicit sourcePort/targetPort on
the endpoint is honoured. The three strategies that instead drew that piece
after the fact were removed in 0.3; STRATEGY_REVIEW.md records why.
The one case that still falls back to an anonymous stand-in is a cluster that
carries caller-declared ports of its own: eland will not add boundary ports
beside them, since that would reinterpret the node's portConstraints.
This bridging only looks one level down from wherever the edge is declared
— but it no longer requires the edge to already be declared at its true
lowest common ancestor (LCA). src/hierarchy/per-level/rehome-cross-hierarchy-edges.ts
runs once, before any recursive layout begins: it walks the whole node
tree, computes every edge's true LCA regardless of nesting depth or where
it's declared, and temporarily moves it into that container's edge list for
the duration of layout, restoring the original declaration site afterward.
The one-level bridging above is unchanged and still does its job once an
edge is sitting at its (now-guaranteed-correct) LCA. The LayoutError for
"both endpoints resolve to the same direct child" stays in place as a
defensive fallback but should no longer actually fire for any edge this
re-homing pass handles. See SPEC_GAPS.md and FUTURE_IMPROVEMENTS.md for
the full detail, and for what's still out of scope (a dedicated boundary
port per cross-hierarchy edge, and boundary-port positions aligned with an
edge's real interior crossing point — materially bigger undertakings than
either this or the endpoint remapping above).
Development
npm run build # bundle to dist/ via tsup
npm run typecheck # tsc --noEmit
npm test # node's built-in test runnerDemo
storybook/ contains a visual gallery — example graphs, a control surface
for the various strategies, rendered through plain SVG, React, Vue, and
Svelte, doubling as a thin, idiomatic integration reference for each. Each
framework directory is its own independent package (not part of this
package.json's install):
cd storybook/react # or vanilla/ vue/ svelte/
npm install
npm run storybookSee storybook/README.md for the full architecture.
