npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

eland

v0.3.2

Published

A modular TypeScript implementation of the Sugiyama-style layered graph layout algorithm (ELK Layered compatible).

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 eland

Usage

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 edges

layout() 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 dropped

With 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 interactiveCloseEmptiedColumns is false.
  • 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 primitives

layout() 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.
  • PhaseContract attaches requires/provides/invalidates to one concrete strategy, not the abstract slot it fills — e.g. every cycle-breaking strategy provides: ["acyclic"], but only INTERACTIVE additionally requires: ["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 each requires the 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 — "does acyclic currently 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 via PhaseContract.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/afterLayering therefore run outside it (natural graph) by default, and a hook can opt in by requiring layerConstraintEdgesPrepared. The two anchors between cycle breaking and layering are unavoidably inside the bracket; declare forbids: ["layerConstraintEdgesPrepared"] there to get an assembly-time error instead of silently seeing the prepared graph.
  • Validation: a hook's requires is 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 LayoutGraph in place and return it, like every built-in phase.
  • Declare requires/provides/invalidates accurately — the orchestrator trusts your claims and lazily validates them when a later phase depends on one, throwing ContractViolationError with 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; the GraphProperty vocabulary 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 over LayoutNode/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 a LayoutEdge's direction in place: toggles reversed, swaps source/target, and fixes up both ports' incomingEdges/outgoingEdges back-reference arrays. Every built-in cycle-breaking strategy uses this to reverse the edges it identifies; a custom cycleBreaking phase (§7) should too, rather than swapping source/target by hand — it's easy to update the fields but forget the port back-reference fixup (later phases like assignPortSides and crossing minimization read stale incomingEdges/outgoingEdges otherwise) or forget to toggle reversed (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) is assignPortSides'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, alongside reverseEdgeDirection above: drops the edge's layerIndex(target) === layerIndex(source) + 1 constraint 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 an inLayerConstraint (or a custom crossing-minimization strategy) if that matters. Under backEdgeRouting: "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 via excludeEdgeFromLayering above. A custom layering strategy iterating graph.edges by hand should filter through this too, rather than reimplementing the !selfLoop && !inLayer check, 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-formed LayoutNode/LayoutPort/LayoutEdge/LayoutLabel (correct defaults, ids minted from the run's shared IdGenerator). 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/outgoingEdges lists without reversing it or touching source/target. Pairs with reverseEdgeDirection above 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 needs computeStronglyConnectedComponents above.
  • layerByTopologicalOrder(graph, selectNext) — Kahn's-algorithm layering engine with a pluggable tie-break, already the shared backbone behind INTERACTIVE/BF_MODEL_ORDER/DF_MODEL_ORDER layering. 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 an Alignment), computeInsideBlockShift(graph, alignment, sweepDirection), and horizontalCompaction(graph, alignment, innerShift, blockSize) — the four composable steps §11.2's BRANDES_KOEPF node placement decomposes into (mark → align → inner-shift → compact). A custom nodePlacement strategy 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 reads nodePlacementBkFixedAlignment/ nodePlacementFavorStraightEdges directly rather than being a standalone algorithm over caller-supplied data.
  • solveNetworkSimplex(nodeCount, edges) and weaklyConnectedComponents — the domain-agnostic constrained-optimization solver behind NETWORK_SIMPLEX node placement and EDGE_LENGTH post-compaction: minimizes Σ weight·(rank[target] − rank[source]) subject to a per-edge minLength, 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 of LayoutGraph.random.
  • IdGenerator — the per-run sequential id source dummy nodes/ports/edges get; also the type of LayoutGraph.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 a LayoutError naming the failure instead of letting undefined silently propagate into NaN coordinates.
  • removeFrom(list, item) — splices the first occurrence of item out of list in 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 minimizing key; ties keep the first (lowest-index) minimizer. The tie-break primitive behind layerByTopologicalOrder's pluggable selectNext.
  • zeroBox() — an all-zero Box (margin/padding shape), plus the already-exported Box/Point types.

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 five custom* phase slots, hooks (PipelineHooks), validateContracts, explainPipeline, the internal working-graph types (LayoutGraph and friends), and the reusable building blocks (computeStronglyConnectedComponents, solveNetworkSimplex, weaklyConnectedComponents, SimplexEdge, excludeEdgeFromLayering, constrainsLayering, FenwickTree, UnionFind, RandomGenerator, IdGenerator, at, zeroBox) — all also reachable via the eland/toolbox and eland/extend subpaths (see "Extending the pipeline" and "Reusable building blocks" above). These may change in any 0.x release while the extension model is being finished (see ROAD_TO_1.0.md). So may interactive and interactiveCloseEmptiedColumns, 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 to GREEDY_MODEL_ORDER rather than the spec's GREEDY, 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 — every LAYERING_STRATEGY value — plus LAYER_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 strategies NETWORK_SIMPLEX runs 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 as layeringNetworkSimplexBalancing (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×BASE by real/dummy pairing, NS_PORT = 0.1 for north/south port edges, flat BASE for in-layer edges — per SPEC_GAPS.md), LINEAR_SEGMENTS (pendulum/barycenter relaxation, using the confirmed exact FORW_PENDULUM/BACKW_PENDULUM/RUBBER phase state machine, THRESHOLD_FACTOR = 20/PENDULUM_ITERS = 4/FINAL_ITERS = 3, port-anchor-based deflection formula, and merge-based overlap resolution — per SPEC_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 keeps SPACING_EDGE_NODE clear 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), and SPLINES (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's bendPoints are 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's C command takes; a straight edge has none. SPLINE_ROUTING_MODE = SLOPPY widens 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 in SPEC_GAPS.md — splineSloppySpacingFactor defaults to 1.0 since the spec names the option but not a default value
  • Ports: FREE, FIXED_SIDE, FIXED_ORDER, FIXED_RATIO, FIXED_POS, all PORT_ALIGNMENT_* values, PORT_SORTING_STRATEGY (INPUT_ORDER default, PORT_DEGREE — center-weighted by degree, EAST/WEST only per §18)
  • Self-loops (all 3 classifications, SELF_LOOP_DISTRIBUTION including NORTH_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_EDGES is backEdgeRouting: "LOOP" | "DIRECT" here, an enum rather than ELK's boolean: LOOP is true, and eland's default. Port sides are assigned once, after layering, when cycle breaking has settled which edges are back edges; LOOP sides every port by its edges' original direction, DIRECT by 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 of PORT_CONSTRAINTS level — an earlier, narrower reading restricted it to FIXED_SIDE/FIXED_ORDER/FIXED_POS nodes only, which left LOOP producing invalid routes for the common FREE-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. Default LOOP, where ELK's is false. It decides how a back edge is drawn, which is a matter of taste: LOOP draws 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; DIRECT lets 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). With DIRECT, a back edge still loops if it shares a port with forward edges (under mergeEdges), since one port has one side; that case, and edges between clusters under NESTED, which always loop, are in FUTURE_IMPROVEMENTS.md.
  • Center-label dummy repositioning (§9.5): all 3 strategies (MEDIAN_LAYER default, WIDEST_LAYER, CENTER_LAYER) are implemented and selectable via labelDummyRepositioningStrategy — an option eland had to invent, since §21 never names one for this (see SPEC_GAPS.md)
  • EDGE_LABELS_INLINE for center-label dummies: non-inline (default) reserves SPACING_EDGE_LABEL above the label so the connecting edge passes cleanly above it with a real gap; inline moves the through-port to ceil((dummyHeight − edgeLabelSpacing − edgeThickness) / 2), routing the edge through the label instead — eliminating that gap, per SPEC_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 for SMART_UP, down for SMART_DOWN)
  • EDGE_LABELS_SIDE_SELECTION for center-label dummies (§16.2, src/labels/center-label-side.ts): previously always hardcoded to the equivalent of BELOW regardless 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 gets ABOVE/BELOW respectively, a bare 2-node run splits first-ABOVE/second-BELOW, and same-source/target-pair sub-runs of exactly 2 split the same way (else defaultSide). 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_INLINE dummies are exempt — see SPEC_GAPS.md for 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_LABEL is 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 DIRECTION values and both DIRECTION_CONGRUENCY modes
  • Graph wrapping: WRAPPING_STRATEGY = SINGLE_EDGE / MULTI_EDGE (plus the OFF default). 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_EDGE searches outward for a boundary crossed by at most one edge; MULTI_EDGE cuts 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 in src/wrapping/ for the full design rationale. The separate WRAPPING_CUTTING_STRATEGY option is not exposed or consulted — the spec lists no values or semantics for it, so cut selection is fully determined by WRAPPING_STRATEGY.
  • Hierarchical / compound nodes (node.children below 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) — see src/hierarchy/ and "Hierarchy" below. §21's HIERARCHY_HANDLING is 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 two LOCKING variants 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 — see SPEC_GAPS.md for the recovered algorithm and UNTESTED_FIXES.md for how the two locking solvers were verified (direct unit testing of src/postprocessing/post-compaction/solve.ts, not reachable through the public API). EDGE_LENGTH minimizes 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) with NETWORK_SIMPLEX node placement rather than duplicating it. Only active with ORTHOGONAL edge routing, the only style whose routes have vertical segments. The COMPACTION_POST_COMPACTION_CONSTRAINTS values SCANLINE and QUADRATIC both 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 via edgeEdgeSpacing/edgeEdgeBetweenLayersSpacing in src/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 to edgeEdgeBetweenLayers + 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.

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 the NETWORK_SIMPLEX node placement strategy — nodes are always treated as fixed-size with fixed ports
  • CROSSING_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's INCLUDE_CHILDREN lays each compound node out bottom-up and routes edges across the borders, which is eland's hierarchyLayout: "NESTED"; both minimise crossings across levels with the same folded sweep and the same option, hierarchicalSweepiness. SEPARATE_CHILDREN leaves edges between clusters unrouted, which is crossHierarchyHandling: "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. FLAT instead 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, so nodePlacementStrategy has no effect under FLAT. (Under NESTED, hierarchicalSweepiness lets the level above reorder a cluster's children; only FLAT layers them together.)

  • crossHierarchyRouting — how an edge between clusters is routed. Each hierarchy layout has its own strategies, and today each has one: FORWARD under NESTED (an edge leaves a cluster by the side the flow runs towards and enters by the side it comes from), DIRECT under FLAT (the flat layout routes the edge like any other). Left unset, it takes the one that goes with hierarchyLayout; a strategy the layout cannot do is an error.

    Three geometric strategies — PORT_BASED, EDGE_BUNDLING and BRIDGE — 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. See STRATEGY_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 record aggregatedInto/aggregatedCount), REJECT, or OMIT (leave them out of the layout entirely and list them on graph.omittedEdges).

  • hierarchicalSweepiness — ELK's option, with ELK's default 0.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. -1 orders every level on its own, bottom-up. NESTED only.

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 runner

Demo

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 storybook

See storybook/README.md for the full architecture.