Skip to content

feat(joint-layout-elk): new package - ELK layout with container and port support - #3497

Open
Geliogabalus wants to merge 72 commits into
clientIO:masterfrom
Geliogabalus:layout-elk
Open

Geliogabalus wants to merge 72 commits into
clientIO:masterfrom
Geliogabalus:layout-elk

Conversation

@Geliogabalus

@Geliogabalus Geliogabalus commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Introduces @joint/layout-elk, a new package that lays out JointJS graphs with the Eclipse Layout Kernel (ELK) through elkjs. The single entry point is the async layout({ graph, elements?, links? }, options?).

layout() runs once and does not keep the graph laid out. It converts the graph to an ELK graph, runs ELK, and writes the result back onto the graph: positions, container sizes, link vertices and anchors, and port and label positions. All of it is applied in a single graph batch (batchName, default 'layout'), and the batch is closed even if a callback throws.

What gets laid out

  • Containers - embedded elements are laid out inside their parent at any nesting depth, and the parent is resized to fit them (elk.hierarchyHandling: INCLUDE_CHILDREN). A link is filed under the lowest common ancestor of its two ends. Edge coordinates come back graph-absolute (elk.json.edgeCoords: ROOT).
  • Ports - elements with ports are exported with elk.portConstraints: FIXED_POS, so ports stay where JointJS's port layout places them and edges route to that spot. Override it per element (e.g. FIXED_SIDE) to let ELK move them. Port labels are sized through exportPortLabel.
  • Link labels - laid out inline on their edge and written back as position.distance/offset. Each ELK label carries the index of its link label in its id, so the result goes back to the right label even when some labels are dropped from the layout.
  • Selection and order - elements/links choose what is laid out (a link only if both its ends are). The order of each list is also the model order ELK sees.

Customization

  • Export callbacks (exportElement, exportPort, exportPortLabel, exportLink, exportLinkLabel) receive the ELK draft this package computed for that element, port, link or label. Mutate it in place (e.g. its layoutOptions), or return false to drop it from the layout.
  • Import callbacks (setElementAttributes, setPortAttributes, setLinkAttributes) replace the default set()/portProp() calls - e.g. to animate the result with transition().
  • elkLayoutOptions are passed to ELK unmodified. The package ships TypeScript types for the slice of ELK's options it uses.
  • The result includes the layout's bounding box (bbox) and the raw ELK result (elkGraph).

Main thread, Web Worker and aborting

  • By default, ELK runs on the main thread - no setup, and it works anywhere: browsers, Node/SSR, tests and the UMD build. The main-thread copy of ELK (elkjs/lib/elk.bundled.js) is imported dynamically, so bundlers split it into its own chunk, loaded only by the first layout without an elk option.
  • createWorkerElk(createWorker) returns an ELK instance running in a Web Worker the app starts, passed to layout() as elk. The worker script is published as @joint/layout-elk/worker, so elkjs resolves from this package rather than from the app:
    const elk = createWorkerElk(() => new Worker(new URL('@joint/layout-elk/worker', import.meta.url), { type: 'module' }));
    await layout({ graph }, { elk });
    • The worker starts on the first layout and is shared by every later one.
    • A crash rejects only the layout the worker was running; a new worker takes over the layouts still waiting.
    • A worker that fails to load rejects its layouts with an explicit error instead of silently falling back to the main thread.
    • terminate() stops the worker and rejects the layouts not settled yet.
    • The instance talks to ELK's worker script directly rather than through elkjs/lib/elk-api.js, which can neither cancel a layout nor settle one whose worker fails.
  • signal aborts a layout: layout() rejects with the signal's reason and applies nothing to the graph. If a createWorkerElk() worker is running the aborted layout, the worker is terminated. Main-thread ELK, or any other elk instance, can't be stopped, so its result is only ignored.

The app starts the worker itself because how a worker script is located depends on the bundler. While this PR was in progress, the package started its own worker, and that needed a separate workaround for Vite's dev server, esbuild, absolute publicPaths, the UMD build and CSP. The README documents the setup for webpack 5 and Vite, and for running without a bundler.

@joint/core changes

layout-elk reads resolved link labels from the model, without a view:

  • dia.Link - add getComputedLabel()/getComputedLabels(), which resolve a label against defaultLabel and the built-in default. This resolution moves out of LinkView into dia/link-labels.mjs, and LinkView and RotateLabel now use it.
  • dia.Element type fixes - portProp(portId) / portProp(portId, object, opt) overloads, and PortLabelPositionType.

Examples

  • layout-elk-ts - migrated to layout() (previously hand-rolled ELK export/import).
  • layout-elk-default-ts (new) - layout() at its simplest: no callbacks, ELK on the main thread.
  • layout-elk-containers-ports-ts (new) - nested containers, ports with labels, and a wrapped, width-restricted layout.
  • layout-elk-flowchart-ts (new) - an interactive flowchart: add ports and steps, connect them, and reorder steps by dragging. A new layout aborts the one still running.
  • layout-elk-rectpacking-ts (new) - ELK's rectpacking algorithm on folders and files, with toolbar options and an animated transition.

All examples except layout-elk-default-ts run ELK in a Web Worker via createWorkerElk().

Test plan

  • yarn test in packages/joint-layout-elk - 49/49 passing, coverage thresholds met (100% functions). This covers:
    • containers, ports, link labels, selection and order
    • the export/import callbacks
    • batching
    • abort (main thread, worker, and a custom instance)
    • createWorkerElk() - shared worker, aborting a running or a waiting layout, crash, load failure, a worker that can't be started, terminate()
    • loading main-thread ELK again after it failed to load
  • yarn lint in packages/joint-layout-elk - clean
  • All five examples built with webpack 5 and loaded in headless Chrome from a sub-path. Each lays out every element. The worker examples load only the worker files, never the main-thread chunk. The default example runs on the main thread without starting a worker.
  • createWorkerElk() setup checked in real builds:
    • new Worker(new URL('@joint/layout-elk/worker', import.meta.url), { type: 'module' }) - webpack 5, Vite dev server, Vite build
    • import ElkWorker from '@joint/layout-elk/worker?worker' - Vite dev server, Vite build
  • yarn changeset status --since=upstream/master - @joint/layout-elk minor (new package, 4.3.0 → 4.4.0), @joint/core minor

🤖 Generated with Claude Code

…r-port ELK options

- Elements embedded in another element are laid out as a container -
  the parent is resized (and positioned) by ELK to fit its content, at
  any nesting depth. Cross-container links are filed under their
  lowest common ancestor per ELK's hierarchical-edge convention.
- Ports route edges to/from the position JointJS itself already
  computes for them (`elk.portConstraints: FIXED_POS`); the new
  `positionPorts` option lets ELK reposition/reorder them instead.
- Add a `portOptions` callback for per-port ELK layout options,
  alongside the existing `nodeOptions`/`edgeOptions`.
- Default to `elk.hierarchyHandling: INCLUDE_CHILDREN` so edges
  crossing a container's boundary are accounted for during layout.
- Migrate the `layout-elk-ts` example to the package's `layout()` entry
  point instead of hand-rolled ELK export/import.
A fixed (non-random) system diagram exercising the new @joint/layout-elk
capabilities: three containers, eight services connected through ports
(three of which opt into `positionPorts` so ELK orders them to minimize
crossings), and `elk.aspectRatio`/`elk.layered.wrapping.strategy` tuned
to keep the drawing within a soft 1000-unit width budget.
The package itself (README, build config, initial layout()) isn't
covered by an earlier changeset - this PR is its first appearance on
master, so the changelog should read as an introduction, not a bump.
Comment thread packages/joint-core/src/dia/LinkView.mjs Outdated
Comment thread packages/joint-core/src/dia/LinkView.mjs Outdated
Comment thread packages/joint-core/src/linkTools/RotateLabel.mjs Outdated
Geliogabalus and others added 27 commits September 30, 2026 20:33
label()/labels() always returned a label exactly as stored, leaving every
caller to separately re-resolve it against defaultLabel/the built-in default
(LinkView duplicated this merge logic in several places: rendering, label
dragging, RotateLabel). getComputedLabel()/getComputedLabels() centralize
that resolution in link-labels.mjs and expose it directly, and LinkView now
calls them instead of re-implementing the merge inline.

A label (or defaultLabel) may also carry custom properties beyond markup/
attrs/size/position - these pass through resolution unmodified, with the
label's own value winning over defaultLabel's.
# Conflicts:
#	packages/joint-core/src/dia/Link.mjs
#	packages/joint-core/src/dia/LinkView.mjs
#	packages/joint-core/src/dia/link-labels.mjs
#	packages/joint-core/src/linkTools/RotateLabel.mjs
#	packages/joint-core/test/jointjs/links.js
#	packages/joint-core/types/dia.d.ts
… the ELK result

exportLinkLabel can drop labels, so an ELK label's index no longer matched
the link label it was made for. Each ELK label now carries an id with its
link label index, which importLayout reads back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…raints by default

Without port constraints ELK moved ports to another side or reordered them,
contrary to the documented behavior of keeping ports where JointJS places them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An aborted layout() rejects with the signal's reason and applies nothing to
the graph. The default worker is now driven by a small client for ELK's own
worker script instead of elk-api.js, which can neither cancel a layout nor
settle one whose worker is terminated: a layout the worker is busy with is
stopped by terminating it, and a new worker takes over the layouts still
waiting. ELK on the main thread or a custom instance can't be stopped, so only
its result is ignored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… main thread

elk.bundled.js was imported statically, so every app shipped ELK twice - in
the main bundle and in the worker - even though the main-thread copy is only
a fallback. It is now imported dynamically, so bundlers split it into a chunk
of its own (in webpack, the example's main bundle shrinks from 3.5 MB to
1.9 MB). The UMD build, which can't load chunks, still imports it statically.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ils to load

Any worker error used to send the layout in progress to the main thread for
good - including a crash during the layout (e.g. out of memory), which the
same graph would then repeat on the main thread, freezing or crashing the
page. The worker now counts as loaded once it answers its first message: an
error before that still falls back to the main thread, a crash after it
rejects the layout it was busy with, and a new worker takes over the rest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A worker that can't be started or fails to load used to fall back to the
main thread silently - layouts kept working, only blocking the page, so a
misconfigured bundler went unnoticed. It is now reported once with a
console.warn. The README lists the common causes, among them the Vite dev
server, whose dependency pre-bundling breaks the worker file's URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… instance runs

'auto' (default) keeps running ELK in the Web Worker where one can be used and
on the main thread otherwise. 'worker' rejects instead of falling back to the
main thread, so a large layout never blocks the page. 'main' runs ELK on the
main thread without starting a worker, e.g. for tests or debugging.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The new package's `minor` changeset releases it as 4.4.0, alongside @joint/core.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- flowchart: abort a layout still running when a new one starts, so only the
  latest is applied and an earlier one no longer unfreezes the paper too
  soon; keep the zoom level when refitting after a layout; lay out again
  only after a drop that actually reorders; drop the redundant
  exportLinkLabel and the comment describing elk.port.index code that
  doesn't exist; explain the INTERACTIVE layering strategy
- containers-ports: drop the edge-only elk.layered.priority.direction set on
  the root, the unused import and the stale ELK_MAX_WIDTH reference
- rectpacking: keep re-running a layout requested while one fails
- layout-elk-ts: unfreeze the paper when the layout fails; describe the demo
  in its README
- default: don't repeat the package's default algorithm
- webpack: 'auto' publicPath, so the ELK worker and main-thread ELK chunks
  load from wherever the demo is served (the dev server keeps /dist/), and
  anchor the .m?js rule

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…orkerElk()

The package no longer starts a Web Worker of its own. Locating the worker
script depends on the bundler (Vite's dev server, esbuild, an absolute
publicPath, the UMD build and CSP all needed workarounds), so starting it is
now up to the app, which knows its bundler:

- Without an `elk` option, layout() runs ELK on the main thread - it works
  anywhere with no setup. The main-thread ELK chunk is still loaded lazily.
- createWorkerElk(createWorker) returns an ELK instance running in a worker
  the app starts, e.g. new Worker(new URL('@joint/layout-elk/worker',
  import.meta.url), { type: 'module' }) - tested with webpack 5 and Vite (dev
  and build). @joint/layout-elk/worker is a new subpath export, so elkjs
  resolves from this package rather than from the app.
- The instance keeps the worker client's behavior - an aborted layout stops
  the worker busy with it, a crash rejects only the layout it was running -
  but a worker that fails to load now rejects its layouts instead of falling
  back to the main thread. terminate() stops it.
- The `thread` option and the fallback warning are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every ELK example except the default one now starts a worker of its own
(@joint/layout-elk/worker) and passes it to layout() - the flowchart's
superseded layouts now stop the worker busy with them. The default example
keeps layout() at its simplest, on the main thread. The examples' TypeScript
module target is raised to ES2020 for import.meta.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants