Repository navigation
feat(joint-layout-elk): new package - ELK layout with container and port support - #3497
Open
Geliogabalus wants to merge 72 commits into
Open
Geliogabalus wants to merge 72 commits into
Geliogabalus wants to merge 72 commits into
Conversation
…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.
kumilingus
reviewed
Sep 30, 2026
kumilingus
reviewed
Sep 30, 2026
kumilingus
reviewed
Sep 30, 2026
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
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/corechangeslayout-elkreads resolved link labels from the model, without a view:dia.Link- addgetComputedLabel()/getComputedLabels(), which resolve a label againstdefaultLabeland the built-in default. This resolution moves out ofLinkViewintodia/link-labels.mjs, andLinkViewandRotateLabelnow use it.dia.Elementtype fixes -portProp(portId)/portProp(portId, object, opt)overloads, andPortLabelPositionType.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-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'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- 49/49 passing, coverage thresholds met (100% functions). This covers:createWorkerElk()- shared worker, aborting a running or a waiting layout, crash, 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 changeset status --since=upstream/master-@joint/layout-elkminor (new package, 4.3.0 → 4.4.0),@joint/coreminor🤖 Generated with Claude Code