Repository navigation
feat(joint-layout-elk): new package - ELK layout with container and port support - #3497
Open
Geliogabalus wants to merge 75 commits into
Open
Geliogabalus wants to merge 75 commits into
Geliogabalus wants to merge 75 commits into
Conversation
kumilingus
requested changes
Sep 23, 2026
kumilingus
reviewed
Sep 30, 2026
kumilingus
reviewed
Sep 30, 2026
kumilingus
reviewed
Sep 30, 2026
kumilingus
reviewed
Sep 30, 2026
kumilingus
reviewed
Sep 30, 2026
kumilingus
reviewed
Sep 30, 2026
Geliogabalus
requested review from
kumilingus
and
a balanced review from Copilot
October 8, 2026 08:49
There was a problem hiding this comment.
🟡 Changes recommended
Export callback handling, worker failure cleanup, abort reasons, and new flowchart port connectivity contain unresolved functional issues.
4 open findings
What changed in this PR
Introduces @joint/layout-elk for asynchronous ELK-based JointJS graph layout, including containers, ports, labels, workers, cancellation, and examples.
Changes:
- Adds the ELK layout package, public types, worker support, tests, and documentation.
- Adds computed link-label APIs and port typing fixes to
@joint/core. - Adds four demos and migrates the existing ELK demo.
| File | Description |
|---|---|
.changeset/brave-labels-resolve.md |
Records computed-label APIs. |
.changeset/layout-elk-new-package.md |
Records the new package. |
.changeset/port-label-position-type.md |
Records the port-label type fix. |
.changeset/port-prop-overloads.md |
Records new portProp() overloads. |
package.json |
Adds ELK package packing. |
yarn.lock |
Registers package and demos. |
packages/joint-core/types/dia.d.ts |
Adds label and port types. |
packages/joint-core/test/ts/index.test.ts |
Tests TypeScript port APIs. |
packages/joint-core/test/jointjs/linkView.js |
Tests invalid label positions. |
packages/joint-core/test/jointjs/links.js |
Tests computed labels. |
packages/joint-core/src/linkTools/RotateLabel.mjs |
Uses computed labels. |
packages/joint-core/src/dia/LinkView.mjs |
Centralizes label resolution. |
packages/joint-core/src/dia/Link.mjs |
Exposes computed-label methods. |
packages/joint-core/src/dia/link-labels.mjs |
Implements label resolution. |
packages/joint-layout-elk/LICENSE |
Supplies package license. |
packages/joint-layout-elk/.gitignore |
Ignores generated output. |
packages/joint-layout-elk/CHANGELOG.md |
Initializes the changelog. |
packages/joint-layout-elk/README.md |
Documents package usage. |
packages/joint-layout-elk/SECURITY.md |
Documents security reporting. |
packages/joint-layout-elk/coverage.json |
Defines coverage thresholds. |
packages/joint-layout-elk/eslint.config.mjs |
Configures linting. |
packages/joint-layout-elk/karma.conf.js |
Configures browser tests. |
packages/joint-layout-elk/package.json |
Defines package publication and builds. |
packages/joint-layout-elk/rollup.config.mjs |
Builds UMD and test bundles. |
packages/joint-layout-elk/tsconfig.json |
Defines base TypeScript settings. |
packages/joint-layout-elk/tsconfig.cjs.json |
Defines CommonJS compilation. |
packages/joint-layout-elk/tsconfig.esm.json |
Defines ESM compilation. |
packages/joint-layout-elk/test/index.html |
Adds a browser test harness. |
packages/joint-layout-elk/test/index.js |
Tests layout and worker behavior. |
packages/joint-layout-elk/src/abort.mts |
Implements cancellation helpers. |
packages/joint-layout-elk/src/defaultElk.mts |
Manages shared main-thread ELK. |
packages/joint-layout-elk/src/elk.worker.mts |
Publishes the ELK worker entry. |
packages/joint-layout-elk/src/export.mts |
Converts JointJS graphs to ELK. |
packages/joint-layout-elk/src/import.mts |
Applies ELK results to JointJS. |
packages/joint-layout-elk/src/index.mts |
Exposes the public API. |
packages/joint-layout-elk/src/labelIds.mts |
Maps ELK labels to link labels. |
packages/joint-layout-elk/src/layout.mts |
Implements the layout entry point. |
packages/joint-layout-elk/src/mainThreadElk.mts |
Dynamically loads ELK. |
packages/joint-layout-elk/src/workerElk.mts |
Implements worker lifecycle handling. |
packages/joint-layout-elk/src/types/index.mts |
Exports ELK-facing types. |
packages/joint-layout-elk/src/types/elkEdgeOptions.mts |
Types edge options. |
packages/joint-layout-elk/src/types/elkEnums.mts |
Types ELK enum values. |
packages/joint-layout-elk/src/types/elkGraph.mts |
Narrows ELK graph types. |
packages/joint-layout-elk/src/types/elkLabelOptions.mts |
Types label options. |
packages/joint-layout-elk/src/types/elkLayoutOptions.mts |
Types root layout options. |
packages/joint-layout-elk/src/types/elkNodeOptions.mts |
Types node options. |
packages/joint-layout-elk/src/types/elkPortOptions.mts |
Types port options. |
examples/layout-elk-ts/package.json |
Uses the new package. |
examples/layout-elk-ts/README.md |
Updates demo documentation. |
examples/layout-elk-ts/src/index.ts |
Migrates to layout(). |
examples/layout-elk-ts/tsconfig.json |
Updates TypeScript settings. |
examples/layout-elk-ts/webpack.config.js |
Bundles the worker correctly. |
examples/layout-elk-default-ts/.gitignore |
Ignores demo output. |
examples/layout-elk-default-ts/index.html |
Adds default-demo markup. |
examples/layout-elk-default-ts/package.json |
Defines default-demo dependencies. |
examples/layout-elk-default-ts/README.md |
Documents the default demo. |
examples/layout-elk-default-ts/src/example.ts |
Defines the example graph. |
examples/layout-elk-default-ts/src/index.ts |
Demonstrates default layout. |
examples/layout-elk-default-ts/src/shapes.ts |
Defines demo shapes. |
examples/layout-elk-default-ts/src/styles.scss |
Styles the default demo. |
examples/layout-elk-default-ts/tsconfig.json |
Configures TypeScript. |
examples/layout-elk-default-ts/webpack.config.js |
Configures bundling. |
examples/layout-elk-containers-ports-ts/.gitignore |
Ignores demo output. |
examples/layout-elk-containers-ports-ts/index.html |
Adds demo markup. |
examples/layout-elk-containers-ports-ts/package.json |
Defines demo dependencies. |
examples/layout-elk-containers-ports-ts/README.md |
Documents container layout. |
examples/layout-elk-containers-ports-ts/src/example.ts |
Defines a nested graph. |
examples/layout-elk-containers-ports-ts/src/index.ts |
Customizes ports and containers. |
examples/layout-elk-containers-ports-ts/src/shapes.ts |
Defines container demo shapes. |
examples/layout-elk-containers-ports-ts/src/styles.scss |
Styles the container demo. |
examples/layout-elk-containers-ports-ts/tsconfig.json |
Configures TypeScript. |
examples/layout-elk-containers-ports-ts/webpack.config.js |
Configures worker bundling. |
examples/layout-elk-flowchart-ts/.gitignore |
Ignores demo output. |
examples/layout-elk-flowchart-ts/index.html |
Adds flowchart markup. |
examples/layout-elk-flowchart-ts/package.json |
Defines flowchart dependencies. |
examples/layout-elk-flowchart-ts/README.md |
Documents interactions. |
examples/layout-elk-flowchart-ts/src/example.ts |
Defines the flowchart graph. |
examples/layout-elk-flowchart-ts/src/index.ts |
Implements interactive layout. |
examples/layout-elk-flowchart-ts/src/shapes.ts |
Defines flowchart shapes. |
examples/layout-elk-flowchart-ts/src/styles.scss |
Styles the flowchart. |
examples/layout-elk-flowchart-ts/tsconfig.json |
Configures TypeScript. |
examples/layout-elk-flowchart-ts/webpack.config.js |
Configures worker bundling. |
examples/layout-elk-rectpacking-ts/.gitignore |
Ignores demo output. |
examples/layout-elk-rectpacking-ts/index.html |
Adds packing controls. |
examples/layout-elk-rectpacking-ts/package.json |
Defines demo dependencies. |
examples/layout-elk-rectpacking-ts/README.md |
Documents rectangle packing. |
examples/layout-elk-rectpacking-ts/src/example.ts |
Defines folder/file data. |
examples/layout-elk-rectpacking-ts/src/index.ts |
Implements interactive packing. |
examples/layout-elk-rectpacking-ts/src/shapes.ts |
Defines packing shapes. |
examples/layout-elk-rectpacking-ts/src/styles.scss |
Styles the packing demo. |
examples/layout-elk-rectpacking-ts/tsconfig.json |
Configures TypeScript. |
examples/layout-elk-rectpacking-ts/webpack.config.js |
Configures worker bundling. |
🧠 Review effort: Balanced
Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.
There was a problem hiding this comment.
🟡 Changes recommended
ELK identifier collisions, excluded-child sizing, and a numeric label-position regression can produce incorrect runtime behavior.
9 open findings
Generate collision-free IDs for exported graph entities · New Use keyboard-accessible buttons for demo controls · New Use keyboard-accessible buttons for demo controls · New Use keyboard-accessible buttons for demo controls · New Normalize numeric label positions before reading distance · New Use parent size when all embedded children are excluded · New Shorten changelog entry and make it verb-led · New Correct the label inline behavior documentation · New Document the UMD build's required ELK dependency · New
4 resolved since last review
🧠 Review effort: Balanced
Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.
…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.
… 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>
createWorkerElk()'s client took the first unsettled layout to be the one the worker was busy with. Two cases broke that: - A layout aborted while waiting its turn is still laid out by the worker - a crash during it was blamed on the next layout, which never ran. - A layout whose graph can't be posted (e.g. DataCloneError for a function an export callback left in it) was rejected but left queued - an abort then no longer terminated the busy worker, and a restart stopped re-posting at it, leaving the layouts behind it pending forever. The client now keeps the ids posted to the current worker in order, and rejects a layout that can't be posted instead of queuing it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ExportPortLabelCallback is typed to return `false` to drop the label, and the README promises that for every export callback, but its return value was ignored. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…as it is `??` replaced a `null` reason with a new AbortError - the default is now only made up where the browser doesn't support `AbortSignal.reason`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…opt), and PortLabelPositionType Re-applies the port typing fixes from the port-label-position-type and port-prop-overloads changesets on top of master's dia.d.ts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…omputedLabels() @joint/core made dia.Link#getComputedLabels() protected in its types (clientIO#3528). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Geliogabalus
force-pushed
the
layout-elk
branch
from
October 8, 2026 15:18
cd49b05 to
d0625d4
Compare
… exported embeds as a leaf - Element, port, link and label ids went into the ELK graph as they were: element `a`'s port `p` and element `a:p` shared the ELK id `a:p`, and an element could share the root's id `root`. Cell and port ids are now escaped (`\` and `:`), so nodes, ports, labels and the root (now `::root`) can't collide - ordinary ids stay as they are. - A container whose embeds exportElement all dropped went to ELK as a 0x0 container, so ELK made no room for it. It is now laid out as a leaf, with its own size. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…inline` override Nothing reads a link label's `inline` property any more - the package places every link label inline by default. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The UMD build keeps elkjs external and runs ELK through the `ELK` global - the README said it needed no setup, and that main-thread ELK is always imported dynamically. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



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 asynclayout({ 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
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).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 throughexportPortLabel.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.elements/linkschoose 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
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. itslayoutOptions), or returnfalseto drop it from the layout.setElementAttributes,setPortAttributes,setLinkAttributes) replace the defaultset()/portProp()calls - e.g. to animate the result withtransition().elkLayoutOptionsare passed to ELK unmodified. The package ships TypeScript types for the slice of ELK's options it uses.bbox) and the raw ELK result (elkGraph).Main thread, Web Worker and aborting
elkjs/lib/elk.bundled.js) is imported dynamically, so bundlers split it into its own chunk, loaded only by the first layout without anelkoption.createWorkerElk(createWorker)returns an ELK instance running in a Web Worker the app starts, passed tolayout()aselk. The worker script is published as@joint/layout-elk/worker, soelkjsresolves from this package rather than from the app:terminate()stops the worker and rejects the layouts not settled yet.elkjs/lib/elk-api.js, which can neither cancel a layout nor settle one whose worker fails.signalaborts a layout:layout()rejects with the signal's reason and applies nothing to the graph. If acreateWorkerElk()worker is running the aborted layout, the worker is terminated. Main-thread ELK, or any otherelkinstance, 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/corechangesdia.Elementtype fixes -portProp(portId)/portProp(portId, object, opt)overloads, andPortLabelPositionType.layout-elkwrites port positions withportProp(portId, object).layout-elksizes link labels from the model, without a view, throughdia.Link#getComputedLabels()- the label resolution againstdefaultLabeland the built-in default from #3528, already on master. That method isprotectedin@joint/core's types for now and will become public in the future, solayout-elkcalls it under a// @ts-expect-errorcomment saying so. Once the method is public, TypeScript reports the directive as unused, a reminder to remove it.Examples
layout-elk-ts- migrated tolayout()(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-rectpacking-ts(new) - ELK'srectpackingalgorithm on folders and files, with toolbar options and an animated transition.All examples except
layout-elk-default-tsrun ELK in a Web Worker viacreateWorkerElk().Test plan
yarn testinpackages/joint-layout-elk- 53/53 passing, coverage thresholds met (100% functions). This covers:falsereturnnullreason)createWorkerElk()- shared worker, aborting a running or a waiting layout, a crash (including one during an aborted layout), a layout that can't be sent to the worker, load failure, a worker that can't be started,terminate()yarn lintinpackages/joint-layout-elk- cleancreateWorkerElk()setup checked in real builds:new Worker(new URL('@joint/layout-elk/worker', import.meta.url), { type: 'module' })- webpack 5, Vite dev server, Vite buildimport ElkWorker from '@joint/layout-elk/worker?worker'- Vite dev server, Vite buildyarn test-tsinpackages/joint-core- passing (covers theportProp()overloads andPortLabelPositionType)yarn install --immutable- cleanyarn changeset status --since=upstream/master-@joint/layout-elkminor (new package, 4.3.0 → 4.4.0),@joint/corepatch (the port type fixes)🤖 Generated with Claude Code