Skip to content

feat(layout-elk): ELK layout package, with audit fixes and API alignment - #3545

Merged
kumilingus merged 113 commits into
clientIO:masterfrom
kumilingus:fix/layout-elk-audit
Oct 13, 2026
Merged

kumilingus merged 113 commits into
clientIO:masterfrom
kumilingus:fix/layout-elk-audit

Conversation

@kumilingus

@kumilingus kumilingus commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Supersedes #3497 - it is that branch plus 33 commits of fixes and API changes from an audit of the package. The test suite grows from 11 to 76 tests.

API

  • layout() takes a graph or a list of cells, like @joint/layout-directed-graph and @joint/layout-msagl, instead of { graph, elements, links }.
  • No batchName option - a layout always batches as layout.
  • A link whose port exportPort dropped is left out of the layout instead of being routed to the node.
  • setPortAttributes is told whether the port moved.
  • exportElement can size a node around its labels.

Fixes

  • Link ends: an end is derived from the ELK edge's own sources/targets; an end on another link no longer resolves to the far element; an anchor is measured from the position ELK computed, including on a rotated element.
  • Hierarchy: built from parent() rather than from the graph, and keyed by the cell id instead of a string of it - so it also works for a list of cells, and for ids that are numbers.
  • Ports: a port id keeps its type (5 and '5' are two different ports), a port removed mid-layout is skipped, a port the layout did not move is left where it is, and the top-left recorded for a port is the one exportPort left.
    @joint/core types a port id as a string, so every call handing one back to it carries a @ts-expect-error keyed to dia.Element: ports whose ids differ only in type are not kept apart by PortData #3544 - those fail, and are to be deleted, once that issue widens the typings.
  • Sizes: the size ELK computed is applied whenever it differs from the element's; a container goes to ELK without a size of its own, so it shrinks back under algorithms that keep a given size.
  • bbox covers everything ELK laid out - edge routes and labels included - rather than the union of the top-level nodes.
  • Two cells (or two ports) mapping to one ELK id are rejected; a cell listed twice is laid out once.
  • The AbortSignal is handed to any WorkerElk, not only to the one this package creates.
  • A warning when the layout is given a rotated element, which is not taken into account.

The rectpacking example animates with CSS transitions instead of transition().

@kumilingus
kumilingus force-pushed the fix/layout-elk-audit branch from 3aa1fb9 to 7c8ca67 Compare October 12, 2026 12:01
…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.
It named the first rotated element it happened to meet, which reads as if that one
element were the problem - there may be twenty, and fixing the named one just moves
the warning to the next. It also described the mechanism at length without saying
what to do about it.
- A cell's id may be a number, so `if (!parentId)` read an element embedded in a
  container with `id: 0` as having no parent at all: the child was laid out as a
  top-level node and the container as a leaf, with the embedding silently gone.
- The rotation warning ran before `exportElement` could drop the element, so it
  fired on every layout even once the user had excluded the rotated elements it
  told them to exclude. It now reports only an element that is actually laid out.
- `buildEnd()` wrote an element-origin anchor onto any end ELK had anchored on the
  node. If the end gained a port while the layout was running, `topLeft` with
  `useModelGeometry` resolves against that port's bbox instead of the element's,
  so the delta landed the end somewhere else entirely. Such an end is skipped
  again - which it could not be before links on a dropped port were left out of
  the layout, since that was the case the skip used to break.
A container went to ELK as `0` x `0`, on the grounds that ELK sizes it from its
content anyway. ELK does - but it does so whatever size the node is given, so the
placeholder bought nothing, and it had to be undone again whenever `exportElement`
dropped every element inside a container, turning it into a leaf. That restore only
fired when both dimensions were still `0`, so a container with one dimension set in
`exportElement` and all of its embeds dropped was laid out as `height: 0`: ELK
reserved no vertical room for it and the next element sat 20px inside it, while the
element still drew at its real height.

Sending the element's own size removes the placeholder, the restore and the bug
together. Verified against elkjs that a node with children comes back the same size
whether it was given `0` x `0`, its real size, or no size at all.
…mputed

The anchor's delta came from `getRelativePointFromAbsolute()`, which measures from
the element's current position. That is the laid out one only once
`setElementAttributes` has written it, and a callback is free not to - the README
suggests redirecting the result into a `transition()`, and the rectpacking example
does. Until the transition settles the element is still where it was, so every end
ELK anchored on an element came out short by exactly how far the element then moved:
an element going from (400,400) to (12,12) left its link ends 388px adrift.

`importNode` already computes the graph-absolute rect for every node, and it
recurses into a node's children before importing the edges filed under it, so both
ends of an edge are known by the time it is read back. The delta is measured from
that rect instead, which no longer depends on when - or whether - the model catches
up. The rotation is done around the same rect's centre, as
`getRelativePointFromAbsolute()` does around the model's.

Adds nine tests for this and for the rest of the audit: links to links, a port the
element no longer has, a label's other position settings, the size `exportElement`
set on a container whose embeds it dropped, a cell listed twice, a container in no
graph, the batch over the graph its cells are in, and two ids differing only in type.
…own class

`WorkerElk` is exported, its `layout()` takes a `{ signal }`, and its doc promises
the worker is terminated when the layout is aborted. The signal only ever reached it
behind `elk instanceof ElkWorkerClient`, so an implementation of the interface that
is not literally our class - a caller's own, or `createWorkerElk()`'s from a second
copy of the package in one bundle - fell through to `abortable()`: the abort
discarded the result while the worker kept computing.

Checked for `terminate()` instead, which `elkjs` does not have. That also lets the
class be imported as a type only, rather than pulled into the bundle for a type
check, and narrows the other branch to `ELK` without an assertion.
…sizes its node

A port's imported position is the center ELK gave it, moved onto the border of the
node ELK laid out - which is the node's own size, not the element's, whenever
`exportElement` handed ELK a size of its own. That is the frame the caller asked
for, and a port rendering outside a smaller element follows from the size they
declared rather than from the import getting it wrong.

Worth a test because the alternative looks defensible until it is measured: clamping
to the element's size binds on the along-the-side axis too, so every port on that
side collapses onto one corner.
`ElkNodeDraft.labels` is there so ELK can size a node around labels the element does
not itself carry, but the drafts went to ELK untouched - and ELK skips a label with
no text, which is why `buildPorts()` and `buildEdge()` both stamp one on. So the
documented use did nothing, and `ElkLabelDraft` has no `text` for a caller to supply
either. A node label now gets the same placeholder: the caller sizes the label, the
package makes ELK count it.

The doc comment pointed at `elk.nodeSize.constraints: 'NODE_LABELS'` on its own,
which collapses the node to 0x0 whether or not it has labels. It now says what else
that option needs to work.
…ing one ELK id

A port id may be a number, and `5` and `'5'` are two different ports - `ElementView`
tags a port element with `port-id-type` precisely so the two survive a round trip
through the DOM. The export stringified the id for ELK and handed the same string
back to `portProp()`, which looks a port up with `===`: the export half resolved
through the loose `portsMap` and succeeded, the import half threw, and the layout
was left half-applied. The id now goes back exactly as it came, and the two
`hasPort()` calls ask with it rather than with a string.

Two such ports on one element also produced two ELK ports with the same id, with
the second overwriting the first in the lookup, which is the case elements and links
already reject. Ports do too now.

See clientIO#3544 for the `PortData` index that collapses them in the first
place.
…ring of it

`elementIndicesById` and `childElementsByParentId` keyed on `` `${id}` ``, so an
element whose `parent` is the number 5 was nested inside a container whose id is the
string `'5'` - a different cell, which `CellCollection` keeps apart in a `Map`. The
element ended up laid out inside a container it is not in, silently and with no
error.

Both maps are keyed by the id itself now. What is still stringified is the ELK id,
which is a string by definition.
…uild when core widens

Nothing in the package was waiting on clientIO#3544 - a port id is passed
through as it came, which works against both the loose index of today and the strict
one proposed there. That left no signal: if the core fix never lands, or lands and
nobody revisits this package, there is nothing to notice.

`ElkGraphPort.portId`, `SetPortAttributesCallbackParameters.portId` and
`getElkPortId()` now say `dia.Cell.ID`, which is what a port id really is. That meets
`@joint/core`'s `portProp(portId: string)` in exactly one place, and the
`@ts-expect-error` there stops being needed - and so becomes an error - the moment
those typings are widened. Verified by widening `portProp()` locally: the build fails
with TS2578 on that line.

The other three hand-offs need no directive: `getPortRelativeRect()` and both
`hasPort()` calls are given values the typings already consider strings, so there is
nothing to suppress.
Every port is written back with the position ELK returned, which under the default
`elk.portConstraints: 'FIXED_POS'` is the one it was given - the layout then records
as fixed args what the port's group layout had computed. That is the right default
here, since ELK owns the link geometry and any change after a layout means laying
out again, but a caller who wants to treat the two cases differently had no way to
tell them apart.

`hasMoved` says whether the position that came back differs from the one that was
sent. `ElkGraphPort` keeps the sent top-left to compare against.
`portsById` is built when the graph is exported and holds the element directly, so a
port removed during the layout still resolves when the result comes back - and
`portProp()` throws for a port its element no longer has. The import stopped
part-way: the elements it had already reached kept their new positions, the rest did
not, and `layout()` rejected with `Element: unable to find port with id ...`.

The element and link paths both tolerate the graph changing under them - `buildEnd()`
re-reads the end, `importEdges()` checks the label is still there. Ports do now too.
The check is in the loop rather than in the default callback, so a custom
`setPortAttributes` is not handed a port that no longer exists either.
The import clamped every port's center into the node's bounds. That is two operations
written as one: ELK gives a port's box top-left and JointJS wants its center, which is
`+ size / 2`; and ELK draws that box beyond the border it sits on, which has to be
undone. Written as a clamp, the second applied to every port - so a port a group puts
outside the element on purpose (`args.dx`, `ellipseLayout`'s `dr`) was dragged onto
the border, 110 to 100 for a 10px offset.

The geometry alone cannot tell the two apart: a port ELK placed on the east border and
a port pushed 10px out both come back with their box starting at the border. What
separates them is whether ELK moved the port at all, so `toPortCenter()` undoes the
convention only then, and a port handed back untouched keeps its center.

That also leaves `elk.port.borderOffset` intact for anyone setting it through
`exportPort`: the shift is half the port, not everything down to the border.
The import applied a size only to a container, on the grounds that a leaf keeps its
own. But ELK resizes leaves too - `rectpacking`'s white space elimination stretched
five of six tiles in a six tile board, and `elk.nodeSize.constraints` does the same -
and all of it was discarded unless the caller wrote a `setElementAttributes` of their
own to read it back off `elkNode`.

The size now comes through whenever what ELK returned differs from what it was given,
which `ElkGraphElement` records alongside the element. That covers the container case
as a consequence rather than as a special case, and leaves alone a node whose size
was raised through `exportElement` purely to shape the layout: ELK hands that back
untouched, so the element keeps its own size as before.
`setElementAttributes` ran every element's position and size through
`element.transition()`, which walks the model frame by frame and fires a change on
each one. The layout already has the answer, so the model can take it in one write
and the browser can do the animating: an element's position is a `transform` on its
group and a tile's size reaches its `rect` as `width`/`height`, both of which CSS
interpolates.

The callback itself is no longer needed either - `attributes.size` now arrives
whenever ELK resized a tile, which is what it was there to recover.
… against the sent one

`1a3f2a1dd` applied a size when ELK returned something other than what it was given.
That leaves a third value out of the comparison: what the element itself currently
has, which is different again whenever `exportElement` sends a size of its own. The
rectpacking example does exactly that - it resets every tile to `baseSize` before
export - so turning white space elimination off sent the base size, got the base size
back, and the tiles stayed stretched from the run before.

The size ELK laid the node out at is the one the element should end up with. Compared
against the element's own size, so a node that already has it is not written again.

The two overriding cases cannot both be served: one sends a size and wants the result
applied, the other sends a size only to shape the layout and wants its element left
alone. ELK echoes both, so nothing tells them apart. The second writes a
`setElementAttributes` that ignores `attributes.size`.
One test packs four tiles with `rectpacking`, resetting each to its base size in
`exportElement` the way the example does, turns white space elimination on and off
again, and asserts the tiles stretch and then come back. That is the case the sent
size comparison got wrong: `exportElement` sends the base size either way, so ELK
returning it unchanged is not the same as the element already having it.

The other asserts that `attributes` carries no size for a node ELK laid out at the
size it already has. It started out asserting that no `change:size` fired, which
passes either way - `mvc.Model.set` compares with `isEqual`, so an identical size
emits nothing whether it is written or not.
`33935fad0` gave every node the element's own size, on the grounds that ELK computes
a container's size from its content whatever it is given. That holds for `layered`,
which is all it was checked against. `rectpacking` keeps the size it is given: a
container sent its own size stays at whatever it grew to on an earlier layout and
never shrinks back, so shuffling the rectpacking example left every folder with the
dead space its tiles no longer needed.

A container goes in at `0` again. What that commit was reaching for - the leaf case,
where `exportElement` drops every embed and the node needs its real size back - is
kept, and fixed per dimension rather than all or nothing, which was the bug it set
out to solve.

The comment it removed explained this, bar one detail: the size is `0` rather than
absent because `ElkNodeDraft` types it as required, not because elkjs demands a
number. It says so now, and a test holds it: an oversized container, laid out with
`rectpacking`, has to come back fitted to its tiles.
`getBBox()` took the union of the top-level nodes, which leaves out edge routes and
edge labels - ELK reserves room for both, so a layout with a label wider than its
nodes reported a box the layout does not fit in.

ELK already sizes the root node around all of it, plus the root's own padding. The
box is now that size with the padding taken off, the way `@joint/layout-directed-graph`
and `@joint/layout-msagl` take off the margins they were given. `elk.padding` names
the sides it sets and the rest keep ELK's default of `12`.
A tall link label is placed beside the nodes it runs between, so the layout extends
past their union - which is what the bounding box used to be.
`portsById` kept the top-left read from `getPortRelativeRect()`, which is taken before
`exportPort` runs - while the port handed to ELK carries whatever that callback set. For
a port the callback moved, the two disagree.

`importLayout` compares them to tell a port ELK placed itself from one that was put
somewhere on purpose and handed back untouched: the first has its center moved onto the
border, the second is left alone. The stale value made every moved port look placed by
ELK, so it was pulled half a port inwards on each layout - a port stood off the element
by `exportPort` under `FIXED_POS` drifts back into it.
…Cell.ID`

`exportPort`/`exportPortLabel` typed `portId` as a string, while the value passed is
whatever `@joint/core` holds - a port id may be a number, and `5` and `'5'` are two
different ports. `setPortAttributes` and `ElkGraphPort` already say so.

Passing it back to `@joint/core` needs the same `@ts-expect-error` the package carries
elsewhere, so the containers-and-ports example grows two, keyed to clientIO#3544
like the rest. Its `exportPortLabel` also destructured `label/size` unguarded, which a
port with no label text never has - it now goes to ELK without a label instead.
Every `-js` demo and the newer TypeScript ones already run on vite - 53 of the 72
examples - while these four were still on webpack. Per demo that was a 45-line
`webpack.config.js` and eight devDependencies; it is now no config file and three.

Nothing in those configs was specific to the demos: `resolve.fullySpecified: false`
is there for webpack's handling of extensionless `.mjs` imports, and
`publicPath: 'auto'` for the ELK worker, which vite's
`new Worker(new URL(..., import.meta.url), { type: 'module' })` form resolves on its
own. TypeScript goes through esbuild, with `tsc` kept in the build script for the
type checking, and `.scss` needs only `sass`.
The README described a `setElementAttributes` callback the demo no longer has: the
package applies every size ELK computed, so a stretched tile is resized without one.
…arams`

`Params` is what `@joint/react` calls these objects, and it does not read like TS's
own `Parameters<T>`, which means something else.
`layout-elk-ts` and `layout-elk-default-ts` were both generic, and neither said which
one to open first: the plain-looking one lays out a package dependency graph and is the
largest of the four, while the "default" one is the no-callbacks starting point. They
are now `layout-elk-dependency-graph-ts` and `layout-elk-defaults-ts`.

`layout-elk-containers-ports-ts` becomes `layout-elk-containers-and-ports-ts`, the way
the other examples join two subjects (`element-tags-and-badges-js`).
`@joint/core` now types a port id as `dia.Cell.ID`, so `hasPort()` and
`portProp()` take a number as they always did at runtime.
@kumilingus
kumilingus force-pushed the fix/layout-elk-audit branch from 38f716f to 96f3406 Compare October 12, 2026 19:00
@kumilingus
kumilingus merged commit a1d9f03 into clientIO:master Oct 13, 2026
3 checks passed
@kumilingus
kumilingus deleted the fix/layout-elk-audit branch October 13, 2026 09:58
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