Every diagram below was laid out by the standalone elk package and drawn straight to SVG — no mermaid, no diagram DSL. You hand it a graph of nodes and edges; it returns coordinates and orthogonal edge routes.

Branch & merge

A directed graph laid out in layers, top-to-bottom. Note the right-angle (orthogonal) edge routing — the ELK signature.

Start Build Test Merge Deploy

Nested cluster

Compound nodes (clusters) are sized and positioned by the layout; children stay inside their parent. Edges cross cluster borders orthogonally.

Worker pool Input Worker A Worker B Output

Left-to-right dependency graph

The same algorithm with horizontal flow — exactly the shape a package dependency visualization needs. Each node is a package; edges are “depends on”.

my_app my_app_ui my_core elk http meta

Laid out as live Flutter widgets

Because elk is pure Dart, it runs inside Flutter too. Below, ELK positions real, interactive Flutter widgets — each card is a Material widget placed at the coordinates ELK computes, with the orthogonal edges drawn by a CustomPainter. Drag to pan, scroll to zoom, tap a node to select it.

Loading Flutter…
// elk is pure Dart, so it runs in Flutter.
final result = const ElkLayered().layout(ElkGraph(
  layoutOptions: ElkLayoutOptions(direction: ElkDirection.down),
  children: [
    for (final n in nodes)
      ElkNode(id: n.id, width: 168, height: 56),
  ],
  edges: [
    for (final (from, to) in edges)
      ElkEdge(id: '..', sources: [from], targets: [to]),
  ],
));

// Place each node as a real Flutter widget at ELK's coordinates…
Stack(children: [
  for (final spec in nodes)
    if (result.nodesById[spec.id] case final n?)
      Positioned(
        left: n.x, top: n.y,
        width: n.width, height: n.height,
        child: NodeCard(spec),      // a Material card
      ),
  // …and stroke ELK's orthogonal edge routes.
  CustomPaint(painter: EdgePainter(result.edges)),
]);

Pure-Dart layered graph layout (Sugiyama-style), inspired by the Eclipse Layout Kernel (ELK) and its JavaScript port elkjs.

Not a transpile of elkjs (which is GWT-compiled Java) — it's a readable Dart implementation of the same layered algorithm family, so output is ELK-like but not byte-identical to elkjs. See Validation.

Quick start

import 'package:elk/elk.dart';
        
        void main() {
          final result = const ElkLayered().layout(ElkGraph(
            layoutOptions: const ElkLayoutOptions(direction: ElkDirection.down),
            children: [
              ElkNode(id: 'a', width: 80, height: 40),
              ElkNode(id: 'b', width: 80, height: 40),
              ElkNode(id: 'c', width: 80, height: 40),
            ],
            edges: [
              ElkEdge(id: 'e1', sources: ['a'], targets: ['b']),
              ElkEdge(id: 'e2', sources: ['a'], targets: ['c']),
            ],
          ));
        
          for (final node in result.children) {
            print('${node.id}: x=${node.x}, y=${node.y}, ${node.width}x${node.height}');
          }
          for (final edge in result.edges) {
            print('${edge.id}: ${edge.sections.first.points}'); // start, bends…, end
          }
        }
        

Coordinates: a node's x/y is its top-left relative to its parent. Use result.nodesById for a flat map with absolute coordinates.

Configuration

All options live on [ElkLayoutOptions]. Defaults match ELK/elkjs for the layered algorithm as configured by mermaid.

Option Type / values Default Effect
direction down, up, right, left down Primary flow direction (the layering axis).
spacingBaseValue double 40 Base unit; node/edge/layer gaps are derived from it unless set explicitly.
spacingNodeNode double? from base Gap between adjacent nodes in a layer.
spacingEdgeNode double? base × 0.5 Gap between a node and an edge routed past it.
spacingNodeNodeBetweenLayers double? from base Gap between layers.
nodePlacement brandesKoepf, … brandesKoepf Coordinate-assignment strategy (others currently fall back to BK).
fixedAlignment none, leftUp, leftDown, rightUp, rightDown, balanced none Brandes–Köpf alignment; none balances all four (most stable).
considerModelOrder none, nodesAndEdges, preferEdges, preferNodes none Constrain crossing-min to the input order.
forceNodeModelOrder bool false Keep siblings strictly in declaration order.
mergeEdges bool false Merge parallel edges into a shared trunk.
cycleBreaking greedy, … greedy Strategy used to break cycles before layering.

Direction

// Flow left-to-right instead of top-down (e.g. a dependency graph).
        const ElkLayoutOptions(direction: ElkDirection.right);
        

Spacing

// Tighter than the default 40; or set concrete gaps.
        const ElkLayoutOptions(spacingBaseValue: 24);
        const ElkLayoutOptions(spacingNodeNode: 60, spacingNodeNodeBetweenLayers: 80);
        

Model order

Keep sibling nodes in the order you declared them (otherwise crossing minimization is free to reorder them):

const ElkLayoutOptions(forceNodeModelOrder: true);
        

Ports

Give a node ports and reference a port id (instead of the node id) in an edge's sources/targets. Each port is placed on the node border — its side is explicit or inferred from the flow direction and whether the port is used as a source (outgoing side) or target (incoming side) — and ports on a side are ordered to reduce crossings.

final result = const ElkLayered().layout(ElkGraph(
          layoutOptions: const ElkLayoutOptions(direction: ElkDirection.right),
          children: [
            ElkNode(id: 'hub', width: 80, height: 80, ports: [
              ElkPort(id: 'out1'),
              ElkPort(id: 'out2', side: ElkPortSide.east),
            ]),
            ElkNode(id: 'a', width: 80, height: 40),
            ElkNode(id: 'b', width: 80, height: 40),
          ],
          edges: [
            ElkEdge(id: 'e1', sources: ['out1'], targets: ['a']),
            ElkEdge(id: 'e2', sources: ['out2'], targets: ['b']),
          ],
        ));
        // result.nodesById['hub']!.ports gives each port's position on the border;
        // each edge's section starts exactly at its port.
        

Compound graphs (clusters)

A node with children becomes a cluster whose size and position are computed:

final result = const ElkLayered().layout(ElkGraph(
          children: [
            ElkNode(id: 'cluster', children: [
              ElkNode(id: 'c1', width: 80, height: 40),
              ElkNode(id: 'c2', width: 80, height: 40),
            ]),
          ],
          edges: [ElkEdge(id: 'e1', sources: ['c1'], targets: ['c2'])],
        ));
        

Loading elkjs JSON

The graph model mirrors the elkjs JSON, so an existing elkjs graph drops in:

final graph = ElkGraph.fromJson(jsonDecode(elkjsGraphJsonString));
        final result = const ElkLayered().layout(graph);
        

Validating against elkjs

Exact coordinates will never match elkjs (different implementations), but the structure should. tool/validation/ runs the same graph set through both engines and scores agreement:

cd tool/validation
        npm install            # installs real elkjs (once)
        node run_elkjs.mjs     # lays the graphs out with elkjs → elkjs_out.json
        cd ../.. && dart run tool/validation/compare.dart
        

compare.dart prints a structural-agreement table and writes a side-by-side SVG per graph (ours | elkjs) to tool/validation/output/ for visual comparison.

On the bundled graph set, elk agrees with elkjs 100% on layer assignment (which node lands in which layer along the flow axis) and produces zero node overlaps, with comparable bounding-box aspect ratios. Within-layer ordering differs (different crossing-minimization heuristics; symmetric graphs are interchangeable either way) — that's the expected, documented divergence.

License

MIT (see LICENSE). Bundles a vendored copy of dart_dagre (Apache-2.0) as the layered algorithm substrate — see NOTICE and lib/src/dagre/LICENSE.