Skip to main content
Version: 4.3

ELK layout

The ELK layout package arranges JointJS graphs with the Eclipse Layout Kernel (ELK), through its JavaScript port elkjs. It ships as @joint/layout-elk.

ELK is a collection of layout algorithms rather than a single one. The default, layered, is a Sugiyama-style layout for directed graphs, but the same call can pack rectangles, draw trees, or run a force or stress layout. The package is optimized mostly for layered (see Choosing an algorithm). What sets the package apart from the other automatic layouts is how much of a diagram it takes into account:

  • Containers - embedded elements become nested ELK nodes, at any depth. Each container is sized to fit its content, and links that cross a container's boundary are routed as part of the same pass.
  • Ports - links are routed to the exact port they connect to, and ELK can move ports along an element's side, or to another side, to reduce crossings.
  • Labels - link labels are placed along their route, port labels can be placed by ELK too, and room can be reserved for element labels.
  • Off the main thread - ELK can run in a Web Worker, so laying out a large graph does not freeze the page.

layout() is a one-off, asynchronous transform. It builds an ELK graph from your JointJS graph, runs ELK on it, and writes the result - positions, sizes, link vertices and anchors, label positions - back onto the cells. It does not keep the graph laid out afterwards: call it again after further changes.

This page focuses on everyday usage and examples. The package's functions, options and callbacks are listed in the API reference.

Installation​

Install the ELK layout open-source package (@joint/layout-elk) using your package manager:

npm add @joint/layout-elk

The package depends on @joint/core and on elkjs, which are both installed for you.

Now import the package:

import { layout } from '@joint/layout-elk';
There is also a UMD version available

Place the UMD distribution of the plugin into the root of your package.

One way to do that is via NPM
  1. Install the ELK layout open-source package (@joint/layout-elk) using NPM:

    npm add @joint/layout-elk
  2. Navigate to the newly created node_modules folder.

  3. Copy the @joint/layout-elk/dist/umd/index.js file and paste it into the root of your package, together with elkjs/lib/elk.bundled.js.

The UMD build keeps elkjs external and reads it from the ELK global, so load elk.bundled.js before it:

index.html
<script src="joint.js"></script>
<script src="elk.bundled.js"></script>
<script src="index.js"></script>

Access the package through the joint.layout.ELK namespace:

index.js
joint.layout.ELK.layout(graph).then(({ bbox }) => {
console.log('width:', bbox.width, 'height:', bbox.height);
});

In the UMD build, ELK always runs on the main thread, unless you start a worker yourself - see Running ELK in a Web Worker.

Laying out a graph​

layout() takes a dia.Graph (or an array of cells, see Laying out part of a graph) and an optional object of options. The ELK settings go in elkLayoutOptions, keyed by their ELK option ids:

import { dia, shapes } from '@joint/core';
import { layout } from '@joint/layout-elk';

const graph = new dia.Graph({}, { cellNamespace: shapes });
const paper = new dia.Paper({
el: document.getElementById('paper'),
model: graph,
cellViewNamespace: shapes,
frozen: true
});

const a = new shapes.standard.Rectangle({ size: { width: 80, height: 40 }, attrs: { label: { text: 'A' }}});
const b = new shapes.standard.Rectangle({ size: { width: 80, height: 40 }, attrs: { label: { text: 'B' }}});
const link = new shapes.standard.Link({ source: { id: a.id }, target: { id: b.id }});

graph.addCells([a, b, link]);

const { bbox } = await layout(graph, {
elkLayoutOptions: {
'elk.direction': 'RIGHT',
'elk.edgeRouting': 'ORTHOGONAL',
'elk.spacing.nodeNode': 30
}
});

paper.unfreeze();
paper.transformToFitContent({ contentArea: bbox, padding: 20 });

The returned promise resolves with:

PropertyDescription
bboxA g.Rect - the tight bounding box of the laid out cells.
elkGraphThe raw ELK layout result, for anything the package does not apply itself (e.g. junction points).

Unlike the Directed graph and MSAGL layouts, layout() is asynchronous - elkjs computes layouts asynchronously even on the main thread. Await it before you read positions, fit the paper, or export the graph. Creating the paper frozen and unfreezing it once the layout is applied, as above, keeps the cells from rendering at their old positions first.

The whole result is applied in a single 'layout' batch on the graph, so it emits one combined change rather than one per element, port and link.

Defaults​

The package fills in the following root options. Anything you pass in elkLayoutOptions takes precedence.

OptionDefaultWhy
elk.algorithm'layered'ELK's general-purpose algorithm for directed graphs.
elk.hierarchyHandling'INCLUDE_CHILDREN'Lays out containers in the same pass as their parent, so that links crossing a container's boundary are routed correctly.
elk.layered.considerModelOrder.portModelOrder'true'Keeps ports in the order of the element's ports.items when ELK is allowed to reorder them.
elk.json.edgeCoords'ROOT'Returns link routes and labels relative to the graph, whichever container a link is in. See Limitations.
elk.json.shapeCoords'PARENT'Returns element positions relative to their container.

Example​

A small system diagram laid out with the package defaults alone - no callbacks, just a few ELK options. The two containers are sized around their services, and all three kinds of link are laid out: port to port, port to element, and container to container.

Layout options​

ELK is configured entirely through options, and the ELK option reference lists them all, along with the algorithms each one applies to. The package's types cover them, so your editor completes option ids and their values.

The elkLayoutOptions passed to layout() apply to the root of the ELK graph - the diagram as a whole. Options for individual elements, ports and links are set separately, see Options per element, port and link.

Choosing an algorithm​

Set elk.algorithm to pick one:

AlgorithmUse it for
layeredDirected graphs, flowcharts, data flow and dependency diagrams - nodes in layers, links pointing one way. The default.
mrtreeTrees.
radialTrees, with the root in the center.
force, stressUndirected graphs, where no direction should dominate.
rectpackingPacking boxes without links - dashboards, galleries, treemap-like overviews.
boxPacking boxes into rows.
discoLaying out disconnected components next to each other.

Each algorithm has options of its own, prefixed with its name (elk.layered.*, elk.rectpacking.*, ...).

info

The package is optimized mostly for the layered algorithm. Its defaults, and the way it hands containers, ports and labels to ELK, are tuned for layered and tested with it. Other algorithms work, but they may ignore ports, port sides or label placement. Expect to tune more options yourself, and check the ELK option reference for which options each algorithm supports.

Option values​

ELK itself takes every option value as a string. The package also takes the natural JavaScript spelling of each value, and converts it for you - per option, so one object can mix the two:

elkLayoutOptions: {
'elk.spacing.nodeNode': 6, // or '6'
'elk.rectpacking.orderBySize': true, // or 'true'
'elk.padding': { left: 10, vertical: 40 }, // or '[top=40,left=10,bottom=40,right=0]'
'elk.position': { x: 10, y: 20 }, // or '10,20'
'elk.nodeLabels.placement': ['INSIDE', 'V_TOP', 'H_LEFT'], // or 'INSIDE V_TOP H_LEFT'
'elk.direction': 'RIGHT' // enums are strings either way
}
Option typeJavaScript spelling
Numbers, booleans6, true
Paddings and margins (elk.padding, elk.margins, elk.spacing.portsSurrounding, elk.nodeLabels.padding)A dia.Sides - a number for every side, or an object naming top / right / bottom / left / horizontal / vertical.
Points (elk.position, elk.nodeSize.minimum, elk.port.anchor){ x, y }, including a g.Point.
Sets of enum values (elk.nodeLabels.placement, elk.portLabels.placement, elk.contentAlignment, elk.nodeSize.constraints, elk.nodeSize.options)An array of the option's values. [] is the empty set.

A padding, for example:

'elk.padding': 10                                      // 10 on every side
'elk.padding': { left: 50, right: 20, vertical: 10 } // 10 at the top and bottom

Two things are worth knowing:

  • A side a padding does not name is 0, not the option's own default. ELK reads '[top=40]' the same way. The option's default only applies when the option is not set at all.
  • ELK silently ignores a set of enum values if any one of them is unknown - 'INSIDE BOGUS' does not even apply INSIDE. The array spelling is type-checked, which makes it the safer one.

The string spelling stays fully supported: it is what the ELK reference uses, and what options stored as data - in a saved diagram, or a backend's configuration - already look like. The same conversions apply to the layoutOptions you set in export callbacks.

To be held to the JavaScript spelling alone, annotate your options with the StrictElkLayoutOptions type. NativeElkLayoutOptions is the other direction - ELK's string spelling alone. Each has a per-node, per-port, per-edge and per-label counterpart (StrictElkNodeLayoutOptions, NativeElkEdgeLayoutOptions, and so on):

import type { StrictElkLayoutOptions } from '@joint/layout-elk';

const options: StrictElkLayoutOptions = {
'elk.spacing.nodeNode': 6,
// Type error: the strict spelling takes `40`, not `'40'`.
'elk.layered.spacing.nodeNodeBetweenLayers': '40'
};

If you build an ELK graph of your own and want to write its options in the JavaScript spelling too, toNativeElkNode(node) returns a copy of the graph with every option, at every level, converted to the string ELK takes.

ELK reads options at two levels. The elkLayoutOptions passed to layout() are set on the root of the ELK graph. Every node, port, edge and label in it has layoutOptions of its own, which configure that one item - a node's padding or port constraints, a port's side, a label's placement. ELK does not pass options down from the root: an option meant for one element, or for a container's content, has to be set on that node, and a value set there takes precedence over the root one.

Per-item options are set in export callbacks, which let you change what ELK is given for each cell. Each one receives a draft the package has already filled in - the ELK node, port, edge or label - to change in place before it is sent to ELK:

CallbackCalled forReceives
exportElementevery element{ element, elkNode }
exportPortevery port{ element, portId, elkPort }
exportPortLabelevery port{ element, portId, elkPortLabel }
exportLinkevery link{ link, elkEdge }
exportLinkLabelevery link label{ link, labelIndex, elkEdgeLabel }

Set an option on a draft's layoutOptions, in either spelling:

await layout(graph, {
exportElement: ({ element, elkNode }) => {
// Keep the start element in the first layer.
if (element.get('isStart')) {
elkNode.layoutOptions['elk.layered.layering.layerConstraint'] = 'FIRST';
}
},
exportLink: ({ link, elkEdge }) => {
// Prefer straight lines for the main flow.
if (link.get('isMain')) {
elkEdge.layoutOptions['elk.layered.priority.straightness'] = 10;
}
}
});

A draft's size can be changed too. exportElement may give ELK a size other than the element's own - as the rectangle packing example below does, to start every layout from a tile's original size.

Returning false leaves the cell out of the ELK graph:

  • exportElement - the element is not laid out, nor is anything embedded in it, nor any link connected to either.
  • exportPort - the port is not laid out, nor are the links connected to it.
  • exportLink, exportPortLabel, exportLinkLabel - the link or label is not laid out.

Anything left out keeps the position or route it already had.

Options on containers​

The root options do not reach a container's content, so it is laid out with ELK's defaults unless you set the container's options too. Set them in exportElement, which can also give each container a different algorithm or spacing from the root:

await layout(graph, {
elkLayoutOptions: {
'elk.algorithm': 'rectpacking'
},
exportElement: ({ element, elkNode }) => {
if (element.getEmbeddedCells().length > 0) {
elkNode.layoutOptions['elk.algorithm'] = 'rectpacking';
elkNode.layoutOptions['elk.spacing.nodeNode'] = 10;
}
}
});

elk.hierarchyHandling decides how the levels are combined. The default 'INCLUDE_CHILDREN' lays out the whole hierarchy in one pass, which links crossing container boundaries need. With 'SEPARATE_CHILDREN', each container is laid out on its own first and is then a fixed-size box for its parent - all that algorithms without links, such as rectpacking, need.

Example​

A storage drive's folders and files, packed with the rectpacking algorithm. Each folder is a container, and each file a tile whose area is proportional to the file's size. Change the options in the toolbar to re-run the layout.

  • Goal - elk.rectpacking.widthApproximation.optimizationGoal.
  • Aspect ratio - elk.aspectRatio, the width to height ratio the packing aims for.
  • White space - elk.rectpacking.whiteSpaceElimination.strategy, which stretches tiles to fill the gaps left in a folder. The package applies whatever size ELK computes, so stretched tiles are resized - and exportElement hands ELK each tile's original size, so a tile does not keep growing from one layout to the next.
  • Order by size - elk.rectpacking.orderBySize. Otherwise files are packed in model order, which Shuffle changes.

The root uses 'SEPARATE_CHILDREN': every folder packs its own files first, then the root packs the folders. A new layout started while one is still running aborts it with an AbortSignal.

info

The elements move to their new positions with a CSS transition (see styles.css). The layout sets each position and size once, and the browser animates the change - no callback needed.

Applying the result​

By default, the package applies the result itself: element.set() for an element's position and size, element.portProp() for a port and its label, and link.set() for a link's vertices, anchors and label positions.

Import callbacks replace that default. Each one receives the attributes the package computed, and applying them is up to you - to set them with options of your own, to store them somewhere else, or to skip part of them:

CallbackReceives
setElementAttributes{ element, attributes: { position, size? }, elkNode }
setPortAttributes{ element, portId, attributes: { position, label? }, elkPort, hasMoved }
setLinkAttributes{ link, attributes: { vertices, source?, target?, labels? }, elkEdge }

For example, to keep a layout out of the undo history, pass the CommandManager a flag it is set up to skip:

const commandManager = new dia.CommandManager({
graph,
cmdBeforeAdd: (cmdName, cell, graph, options = {}) => !options.ignoreCommandManager
});

const opt = { ignoreCommandManager: true };

await layout(graph, {
setElementAttributes: ({ element, attributes }) => element.set(attributes, opt),
setPortAttributes: ({ element, portId, attributes }) => element.portProp(portId, attributes, opt),
setLinkAttributes: ({ link, attributes }) => link.set(attributes, opt)
});

By default, the layout is recorded as a single command - the result is applied in one 'layout' batch - so one undo reverts the whole layout.

The ELK result for the cell (elkNode, elkPort, elkEdge) is passed along, for anything the package does not apply itself.

Elements, ports and labels​

How each part of a JointJS diagram is handed to ELK, and what to set to change it.

Elements and containers​

Every element becomes an ELK node with the element's size. An element with embedded elements becomes a container node instead: it is sent to ELK without a size of its own, and ELK computes one from its content. Both the container and its children are then moved and resized for you. Containers can be nested at any depth.

The space between a container's border and its content is ELK's elk.padding option, 12 on every side by default. Set it per container - to leave room for a container's title, for instance:

await layout(graph, {
exportElement: ({ element, elkNode }) => {
if (element.getEmbeddedCells().length === 0) return;
elkNode.layoutOptions['elk.padding'] = { top: 30, horizontal: 12, bottom: 12 };
}
});

Ports​

Each port becomes an ELK port. By default, every element with ports is exported with elk.portConstraints: 'FIXED_POS': ELK keeps each port exactly where the element's port groups put it, and routes links to that spot. Nothing needs to be set for that.

To let ELK move the ports, change the constraint on the element's node - 'FIXED_SIDE' keeps each port on its side but lets ELK slide it along to reduce crossings, 'FREE' lets ELK pick the side too. Set it in exportElement: the package sets 'FIXED_POS' on every node with ports, and that per-node value takes precedence over one in elkLayoutOptions. Unless a port's elk.port.side is set, ELK works its side out from the port's current position - set it in exportPort to choose the side yourself:

const service = new shapes.standard.Rectangle({
size: { width: 120, height: 60 },
ports: {
groups: {
// Positioned by ELK - see below.
in: { position: { name: 'absolute' }},
out: { position: { name: 'absolute' }}
},
items: [
{ id: 'in1', group: 'in' },
{ id: 'out1', group: 'out' },
{ id: 'out2', group: 'out' }
]
}
});

await layout(graph, {
exportElement: ({ element, elkNode }) => {
if (element.hasPorts()) {
elkNode.layoutOptions['elk.portConstraints'] = 'FIXED_SIDE';
}
},
exportPort: ({ element, portId, elkPort }) => {
const { group } = element.getPort(portId);
elkPort.layoutOptions['elk.port.side'] = (group === 'in') ? 'WEST' : 'EAST';
}
});

The position ELK returns for each port is written to the port's position.args, as the port's center relative to the element. That is why the port groups above use the absolute port layout, which reads exactly those arguments - other port layouts recompute the position from the port group and ignore where ELK put the port.

Port labels​

A port label starts at 0 by 0, which leaves the port without a label in the ELK graph. To have ELK place a port's label, give it a size in exportPortLabel. The package does not measure rendered text, so the size has to come from your data - stored on the port, or estimated from the label text:

await layout(graph, {
exportPortLabel: ({ element, portId, elkPortLabel }) => {
const size = element.portProp(portId, 'label/size');
if (!size) return false;
elkPortLabel.width = size.width;
elkPortLabel.height = size.height;
}
});

The label position ELK computes is written to the port's label.position.args, as an offset from the port's center. Give the port group a manual port label layout, which reads exactly those arguments.

A link is laid out when both of its ends connect to elements that are laid out - to the element itself or to one of its ports. A link connected to a point, or to another link, is left out of the layout and keeps the route it already has.

Each link label is sent to ELK with the label's size, which ELK keeps clear of nodes and other labels when it places the label along the route. Give your labels a size that matches how they render. Setting it once in a link's defaultLabel is the easiest way:

const link = new shapes.standard.Link({
source: { id: a.id },
target: { id: b.id },
defaultLabel: { size: { width: 60, height: 20 }},
labels: [{ attrs: { text: { text: 'request' }}}]
});

A label without a size is still sent to ELK, but with no area to keep clear.

Element labels​

Element labels are not sent to ELK by default. A JointJS element label is an attribute of the shape rather than a separate object, so ELK knows nothing about it, and a container is sized around its children alone - its title can end up overlapping them.

To have ELK reserve room for a label, add one to the ELK node in exportElement. ELK only needs the label's size, so estimate it from the text, and tell ELK where the label goes and that the node must be large enough to hold it:

const LABEL_FONT_SIZE = 14;

await layout(graph, {
exportElement: ({ element, elkNode }) => {
const text = element.attr('label/text');
if (!text) return;
elkNode.labels = [{
// A rough estimate of the rendered text size.
width: Math.ceil(text.length * LABEL_FONT_SIZE * 0.6),
height: LABEL_FONT_SIZE + 4,
layoutOptions: {}
}];
// Inside the node, at the top.
elkNode.layoutOptions['elk.nodeLabels.placement'] = ['INSIDE', 'V_TOP', 'H_CENTER'];
// Make the node fit the label, but never smaller than it is now.
// (A container's draft size is 0, so it is still sized around its children.)
elkNode.layoutOptions['elk.nodeSize.constraints'] = ['NODE_LABELS', 'MINIMUM_SIZE'];
elkNode.layoutOptions['elk.nodeSize.minimum'] = { x: elkNode.width, y: elkNode.height };
}
});

ELK then grows a container at the top to fit the label above its children, and widens it if the label is wider than its content. A leaf element grows to fit a label that does not fit in it. ELK's computed size is applied to the element.

Keep MINIMUM_SIZE and elk.nodeSize.minimum together with NODE_LABELS. With NODE_LABELS alone, ELK sizes a leaf element to its label only, so an element with a short label shrinks.

The package does not move the label itself - it stays where the shape's label attributes put it. To draw it where ELK placed it, read the label's position from elkNode.labels in a setElementAttributes callback. ELK gives the top-left corner of the label's box, relative to the element. The callback replaces the default import, so it applies the element's position and size too:

await layout(graph, {
exportElement: ({ element, elkNode }) => {
// Add the label as above.
},
setElementAttributes: ({ element, attributes, elkNode }) => {
element.set(attributes);
const [label] = elkNode.labels || [];
if (!label) return;
element.attr('label', {
x: label.x + label.width / 2,
y: label.y,
textAnchor: 'middle',
textVerticalAnchor: 'top'
});
}
});
tip

Prefer ['INSIDE', 'V_TOP', 'H_CENTER'] for a container title. With a corner placement such as H_LEFT, ELK reserves the label's space beside the children as well as above them, which makes the container much wider.

When all you need is a fixed amount of room for a title, the elk.padding option on a container is a simpler alternative - see Elements and containers.

Example​

A simplified web platform, with containers nested two levels deep and services connected through ports. The callbacks:

  • exportElement switches every element with ports to 'FIXED_SIDE', so ELK may reorder ports along their side to reduce crossings, and hands ELK each container's padding - room for its title.
  • exportPort puts inputs on the left (WEST) and outputs at the bottom (SOUTH) of each service, whichever side their port group would otherwise use.
  • exportPortLabel sizes each port label from a size estimated from its text when the port is added (see shapes.js).

The elk.layered.wrapping.strategy option wraps the layers onto more rows, to keep the drawing close to the elk.aspectRatio instead of growing one long row.

Laying out part of a graph​

Pass an array of cells instead of a graph to lay out only those:

// Lay out a selection, with its embedded elements and the links between its elements.
// Everything else in the graph is left alone.
await layout(graph.getSubgraph(selectedElements, { deep: true }));

The array is both the selection and the order:

  • Elements - only the given elements are laid out. An element whose parent is not in the array is laid out as a top-level element.
  • Links - a link is laid out only if both of the elements it connects are in the array too.
  • Order - top-level elements, each container's children, and the links are sent to ELK in the order they appear in the array. Algorithms that respect model order (such as rectpacking, or layered with its considerModelOrder options) follow it. A cell listed more than once is laid out once.

An element that is in no graph is laid out like any other, but emits no change events - which makes it useful as a temporary container that only exists to group others for the layout.

Running ELK in a Web Worker​

By default, ELK runs on the main thread. That needs no setup and works anywhere - browsers, Node, tests - but ELK blocks the page while it runs: a few milliseconds for a small graph, and up to seconds for one with thousands of elements.

To keep the page responsive, run ELK in a Web Worker. Start one with createWorkerElk(), and pass it to layout() as the elk option:

import { layout, createWorkerElk } from '@joint/layout-elk';

const elk = createWorkerElk(() => new Worker(
new URL('@joint/layout-elk/worker', import.meta.url),
{ type: 'module' }
));

await layout(graph, { elk });

// Once no longer needed.
elk.terminate();

createWorkerElk() takes a function that starts the worker, running the package's worker script, @joint/layout-elk/worker. Starting it is up to you, because only your bundler knows where the script ends up:

  • webpack 5, Vite - new Worker(new URL('@joint/layout-elk/worker', import.meta.url), { type: 'module' }), as above. Both emit the worker script as a file of its own. With webpack, keep output.publicPath at 'auto' (the default), so that the file is found wherever the app is served from.
  • Vite - alternatively, import ElkWorker from '@joint/layout-elk/worker?worker', then createWorkerElk(() => new ElkWorker()).
  • No bundler (e.g. the UMD build) - serve a copy of elkjs/lib/elk-worker.min.js, then createWorkerElk(() => new Worker('/path/to/elk-worker.min.js')).

The returned instance:

  • Starts the worker on its first layout, then lays out every graph in it, one at a time - share one instance between layouts.
  • Stops an aborted layout it is busy with by terminating the worker. A new worker takes over the layouts still waiting.
  • Rejects the layout during which the worker crashes (e.g. out of memory). A new worker takes over the layouts still waiting.
  • Rejects every layout when the worker fails to load - its script isn't served where createWorker starts it from, or a CSP worker-src blocks it - with an error saying so, rather than falling back to the main thread. The next layout tries again with a new worker.
  • terminate() terminates the worker. Layouts not settled yet are rejected, and a later layout starts a new worker.

In the ESM build, the main-thread copy of ELK is imported dynamically, so bundlers split it into a chunk of its own that is only loaded by the first layout without an elk option. An app that always passes a worker never loads it.

Aborting a layout​

Because layout() is asynchronous, the graph may change while ELK is still computing. Pass an AbortSignal to drop a layout that is no longer wanted, or takes too long. An aborted layout() rejects with the signal's reason (an AbortError by default) and applies nothing to the graph:

For example, when the layout re-runs on every change, abort the previous run so only the latest result is applied:

let controller;

async function runLayout() {
controller?.abort();
controller = new AbortController();
try {
await layout(graph, { signal: controller.signal });
} catch (error) {
// The previous run rejects when it is aborted - ignore it.
if (error.name !== 'AbortError') throw error;
}
}

Or give up on a layout that takes too long (it rejects with a TimeoutError):

await layout(graph, { signal: AbortSignal.timeout(5000) });

ELK cannot stop a layout in progress. A layout a createWorkerElk() worker is busy with is stopped by terminating the worker. A layout on the main thread, or in any other ELK instance, keeps running - only its result is ignored.

Debugging ELK​

The elk option accepts any object with layout() and terminate() methods, and it is handed exactly what ELK is handed - with every option already converted to ELK's string spelling. Wrap ELK in one to see the graph, for example to paste it into elk-live when tuning options:

import ELK from 'elkjs/lib/elk.bundled.js';

const realElk = new ELK();

await layout(graph, {
elk: {
layout: (elkGraph) => {
// The ELK graph (JSON) exactly as ELK receives it - paste it into elk-live.
console.log(JSON.stringify(elkGraph, null, 2));
return realElk.layout(elkGraph);
},
terminate: () => {}
}
});

The same hook runs ELK anywhere else - a server, a cache - or changes the graph on its way there. layout() still applies the result for you.

note

An object with terminate() is one layout() can stop, so layout() hands it the abort signal instead of merely ignoring a late result. A wrapper like the one above cannot stop elkjs, so an abort only takes effect once ELK settles.

Limitations​

  • Ids must be unique as strings. ELK ids are strings, and the ELK graph uses the cell and port ids converted to strings - the same ids you see in the export callbacks and in elkGraph. Ids that differ only in type (5 and '5') are not unique to ELK, and layout() rejects them. Don't mix such ids among the laid out cells, or among the ports of one element, or change them to unique ones before calling layout().
  • Element rotation is not taken into account. An ELK node is a box with no rotation, so a rotated element is laid out as if it were not rotated, and ELK reserves the wrong area for it. layout() warns the first time it sees one - leave rotated elements out of the layout.
  • Link-to-link connections are not supported. ELK edges connect nodes and ports only, so a link whose source or target is another link (or a point) is left out of the layout and keeps its current route. You can simulate it with a junction: a small element where the links meet, connected to the other ends with ordinary links. See the ELK layout with ports and clusters demo.
  • Link coordinates are graph-absolute. The package sets elk.json.edgeCoords to 'ROOT', and the default import applies link routes as they are. If you override it (e.g. with 'CONTAINER'), the vertices, anchors and labels of links inside containers end up misplaced unless you convert them yourself in setLinkAttributes. The same goes for the raw elkGraph in the result.

License​

The package itself is licensed under the MPL-2.0 and contains no ELK code. It depends on elkjs, which is dual-licensed under the Eclipse Public License 2.0 or GPL-3.0-or-later - you can use it under the EPL-2.0. elkjs is installed as a regular dependency and never inlined into the package's own builds, but an application that bundles the package does ship elkjs code (the main-thread chunk, and the worker script if you use one): keep elkjs's license notice with it, for example with your bundler's license extraction.