Skip to content

feat(layout-elk): accept JointJS-shaped layout options, with a derived native type - #3547

Merged
Geliogabalus merged 28 commits into
clientIO:masterfrom
kumilingus:feat/layout-elk-option-spellings
Oct 15, 2026
Merged

Geliogabalus merged 28 commits into
clientIO:masterfrom
kumilingus:feat/layout-elk-option-spellings

Conversation

@kumilingus

@kumilingus kumilingus commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Closes clientIO/joint-plus#883.

@joint/layout-elk passed ELK's layout options straight through, so every value had to be written as a string — ELK's wire format, but not what writing them looks like:

'elk.spacing.nodeNode': '6',
'elk.rectpacking.orderBySize': `${controls.orderBySize.checked}`,
'elk.padding': `[top=${top},left=${left},bottom=${bottom},right=${right}]`

Each option now takes the JointJS spelling as well, per option, so one object can mix them:

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.direction': 'RIGHT'                    // enums are strings either way
}

The string spelling stays supported. It is what the ELK reference page linked from every option uses, and what options arriving as data — a saved diagram, a backend's config — are already shaped like.

Types: authored strict, projected native

Each option is declared once, in StrictElk*LayoutOptions, and NativeElk*LayoutOptions is projected from it by a mapped type, so the two spellings cannot drift. The unprefixed names stay permissive and take both, which is what elkLayoutOptions is typed as.

Opting into the strict form is an annotation in your own code, not a flag on the package:

const options: StrictElkLayoutOptions = { 'elk.spacing.nodeNode': 6 };

Native means ELK's own spelling throughout, for the option types and the graph types alike; NativeElkNode is elkjs's node type under this package's name, which is what lets a converted graph reach elk.layout() with no cast at the call site. Raw no longer appears in src/. Every option type leads with Elk (ElkNodeLayoutOptions, StrictElkNodeLayoutOptions, …), where the per-element ones used to lead with the element kind while the graph-level one led with Elk.

The public surface

layout(graphOrCells, opt?): Promise<LayoutResult>
toNativeElkNode(node): NativeElkNode
createWorkerElk(createWorker): WorkerElk

plus the option types, the callback types and the ELK graph types. toNativeElkNode() is the function #883 asked for: it writes a graph's layout options out as the strings ELK takes, throughout — the node's own, its labels', its ports' (and theirs), the edges filed under it (and theirs), and each of its children, as deep as they nest. It returns a new graph; the one passed in is untouched.

Building the ELK graph and applying a result back are not public. They were briefly, and the bookkeeping they pass between them — a map of ELK ids to cells, with each port's position as it was sent — is not a shape worth promising anyone. Nothing is lost by withholding them:

  • To run ELK elsewhere (a Web Worker, a server, a cache), or to change the graph on its way there, pass an elk of your own to layout(). It is any object with layout() and terminate().
  • To see the graph this package builds, that same elk is handed exactly what ELK is handed — every option already a string, so it pastes straight into elk-live. The pre-conversion graph the export used to return did not.

The barrel lists its exports one by one now, so being public is a decision rather than a side effect of which file something is declared in. That is how a helper leaked into the API mid-branch.

Bugs fixed along the way

Found by reviewing this branch:

  • getBBox() ignored ELK's full option id. toElkOption() accepts org.eclipse.elk.padding as well as elk.padding, but getBBox() read the short key only — so the full id laid the graph out with the padding given and measured the box from the 12px default.
  • The defaults were not applied when the graph was built. One of them is elk.json.edgeCoords, which is not a preference: applying a result reads an edge's points as the root's coordinates. Without it, links were routed off by the container's origin — silently, and only on hierarchical graphs.
  • toNativeElkNode() did not always copy. It returned the node, port or label it was given when there was nothing to convert, and ELK writes its coordinates into the graph it is handed — so a graph whose ports carry no layoutOptions was written through, against the function's own "a new graph" contract.
  • parseElkPadding() disagreed with ELK three ways. [TOP=40], [foo=1] and [] are a padding to ELK that names no side — 0 a side — where this read them as no padding at all and fell back to 12. It also rejected an uppercase exponent (4E1, which is what Java's own toString emits) and a leading +.

And one that predates the branch:

  • A partial elk.padding string gave the wrong bbox. getBBox() seeded all four sides with 12 and overrode only the ones the string named, so '[top=40]' gave Rect(12, 40, w-24, h-52) where the correct box is Rect(0, 40, w, h-40). This is also why the two spellings mean the same thing: normalizeSides({ top: 40 }) is exactly what ELK does with '[top=40]', so no @joint/core change was needed.

Scope notes

  • Padding/margin is 6 declarations over 4 keys — elk.padding and elk.margins (graph + node), elk.spacing.portsSurrounding, elk.nodeLabels.padding.
  • KVector is 8 declarations over 3 options — elk.position (on nodes, ports and labels), elk.nodeSize.minimum, elk.port.anchor.
  • KVectorChain is excluded: elk.bendPoints and elk.junctionPoints are layout output, written by ELK's own JSON exporter.
  • Enum sets and integer lists are left for a follow-up — each needs its own shape and serializer.

The conversion is key-aware: 'elk.padding': 20 must become [top=20,…], because plain '20' is silently ignored by ELK and 20 is the most natural thing to write. The runtime key set is derived from the types, so adding a dia.Sides option without listing it fails to compile.

Unrelated fix, called out

examples/layout-elk-containers-and-ports-ts carried two @ts-expect-error directives for #3544 that went stale when Element.PortID landed in #3546 — tsc was already failing there on master. Nothing builds the examples in CI, so it had not been seen. Removed, since otherwise "the examples type-check" is not a claim this PR can make.

Verification

  • tsc, test-ts, eslint clean
  • 102/102 tests; functions 100%, lines 97%+ (gate 95)
  • New yarn test-ts type tests, chained into yarn test. Mutation-tested: reordering the dia.Sides/number branches in the projection trips the number assertions, dropping its terminal string branch trips the index-signature one — they are not vacuous. Likewise for the defaults: flipping elk.json.edgeCoords to CONTAINER fails two tests.
  • The removed API is checked as removed — the five names no longer resolve from the package.
  • All four examples tsc clean against the built dist, and all four were converted to the new spelling.
  • The rectpacking demo renders correctly, and identically with a non-zero root padding — which exercises the getBBox path its zeroed padding normally masks.

One empty changeset: the package is unreleased, so this is part of its first release and the pending "new package" changeset already covers it. A bullet describing what changed since a version nobody has would be noise.

…S shapes

Every option is declared in its JointJS spelling: a number option as `number`,
a boolean one as `boolean`, an `ElkPadding`/`ElkMargin` one as `dia.Sides` and a
`KVector` one as `g.PlainPoint`. The string spellings are projected back from
these in the next commit, so each option stays declared in one place.

This commit does not compile on its own - the index signatures still read
`string | undefined` and the `dia`/`g` imports are not in yet.
Each option now takes either spelling, per option, so one object can mix them:
`{ 'elk.spacing.nodeNode': 6, 'elk.padding': '[top=12,left=12]' }`. The string
form stays legal - it is what ELK's own reference pages use, and what options
arriving as data are already shaped like.

`Strict*ElkLayoutOptions` holds an option to the JointJS spelling alone and is
where every option is declared; `Native*ElkLayoutOptions` is projected from it
onto ELK's string spelling. The five existing names keep their meaning and take
both. Opting into the strict form is an annotation in your own code, not a flag
this package switches on.
…or ELK

Takes an ELK graph whose layout options are in either spelling and returns one
whose every option value is the string ELK takes - the node's own options, its
labels', its ports' (and theirs), the edges filed under it (and theirs), and
each of its children, as deep as they nest.

A new graph: the options may be an object an export callback took straight from
a cell's own data, or a shared - even frozen - constant, and neither is this
package's to rewrite.

`layout()` calls it after `exportGraph()`, so it sees what the export callbacks
left behind, and its result goes to ELK with no assertion at the call site.

A padding is read back in either spelling for the bounding box, which also fixes
`'[top=40]'`: ELK reads a side its padding string does not name as `0` rather
than as the option's `12`, where this had kept the default on every unnamed side.
`yarn test-ts` type-checks `test/types/` under the package's own flags, so the
assertions are held to what a user gets. It runs as part of `yarn test`, which
is what CI runs.

What they pin is what fails silently otherwise: the branch order in
`ToNativeElkOptions` (testing `dia.Sides` before `number` would degrade every
number option to a plain string) and its terminal branch (without it the native
variant's index signature is the strict one, and the type is not native at all).
The rectpacking demo no longer casts a range input to `` `${number}` ``,
stringifies a checkbox, or assembles a padding string by hand - `FOLDER_PADDING`
now goes to ELK as it is. The other three drop the quotes from their numbers and
booleans, and with them the comments explaining that a number goes as a string.

Also drops two `@ts-expect-error` directives the containers-and-ports demo no
longer needs: `@joint/core` types a port id as `dia.Cell.ID` now, so `tsc` had
been failing on the unused directives. Unrelated to the option spellings - it is
only that nothing builds the examples in CI, so nobody had seen it.
…lkNode()`

Says which options take a `dia.Sides` or a point, that a side a padding does not
name is `0` rather than the option's own default, and that the strict spelling is
an annotation in your own code rather than a flag on this package.
`@joint/layout-elk` has not shipped, so a bullet describing what changed since a
version nobody has is noise in its first changelog - the pending "new package"
changeset already covers it. An empty changeset keeps the PR check satisfied.
…ey are imported

`NativeElkNode` was a second name for what `elkGraph.mts` already imported as
`RawElkNode` - two words for one idea, which is what naming the function
`toNativeElkNode()` was meant to settle in the first place. The import alias
carries the name now, and the extra declaration is gone.

`Raw` no longer appears in `src/`: "native" is ELK's own spelling throughout,
for the option types and the graph types alike.
`ElkLayoutOptions` led with `Elk` while `NodeElkLayoutOptions` and its siblings
led with the element kind, so the modifier order flipped between the graph-level
type and the per-element ones - and `StrictNodeElkLayoutOptions` stacked three.

They now read as "the layout options of an ELK node", matching `ElkNode`,
`ElkPort` and `ElkExtendedEdge`, which already lead with `Elk`.
@kumilingus
kumilingus requested review from Geliogabalus and a balanced review from Copilot October 13, 2026 14:47
…e calls

The example called `elk.layout()` on an `elk` it never introduced, and `layout()`
is this package's own headline function - so it read as if it were calling that.
`elk` also already means something else in this README: the instance you hand to
`layout()` as an option.

It now creates the instance, says that `elk.layout()` is elkjs's own method and
that what comes back is an ELK graph rather than a laid out JointJS one, and
introduces `exportGraph()`, which the example leans on and the README had never
mentioned.

Imports `elkjs/lib/elk.bundled.js` rather than `elkjs`, whose entry point is an
API shim with no algorithm in it - `new ELK()` on it needs a worker factory and
would have failed in a browser bundle. A type test holds the example's import
form and call to that.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Full-ID padding produces an incorrect bbox, legacy public type names disappear, and the native return contract can be violated.

3 open findings
What changed in this PR

Adds JointJS-native ELK option values, converts them to ELK’s string format, and fixes padding-based bounding boxes.

Changes:

  • Adds strict, native, and mixed option types.
  • Adds toNativeElkNode() with runtime conversion and tests.
  • Updates examples and fixes partial-padding bbox handling.
File Description
.changeset/​elk-options-unreleased-package.md Records the unreleased-package change.
packages/​joint-layout-elk/​package.json Adds TypeScript tests.
packages/​joint-layout-elk/​README.md Documents option spellings and conversion.
packages/​joint-layout-elk/​src/​elkOptions.mts Implements option conversion and padding parsing.
packages/​joint-layout-elk/​src/​export.mts Uses mixed option types in export drafts.
packages/​joint-layout-elk/​src/​index.mts Exports toNativeElkNode().
packages/​joint-layout-elk/​src/​layout.mts Converts options before layout and updates bbox handling.
packages/​joint-layout-elk/​src/​types/​elkEdgeOptions.mts Adds strict/native/mixed edge options.
packages/​joint-layout-elk/​src/​types/​elkGraph.mts Adds native graph typing.
packages/​joint-layout-elk/​src/​types/​elkLabelOptions.mts Adds strict/native/mixed label options.
packages/​joint-layout-elk/​src/​types/​elkLayoutOptions.mts Adds strict/native/mixed root options.
packages/​joint-layout-elk/​src/​types/​elkNodeOptions.mts Adds strict/native/mixed node options.
packages/​joint-layout-elk/​src/​types/​elkPortOptions.mts Adds strict/native/mixed port options.
packages/​joint-layout-elk/​src/​types/​index.mts Updates type exports.
packages/​joint-layout-elk/​src/​types/​toNativeElkOptions.mts Defines native type projection helpers.
packages/​joint-layout-elk/​test/​index.js Tests conversion, bbox behavior, and workers.
packages/​joint-layout-elk/​test/​types/​api.test-d.mts Tests public API types.
packages/​joint-layout-elk/​test/​types/​assert.mts Adds type-level assertions.
packages/​joint-layout-elk/​test/​types/​options.test-d.mts Tests projected option types.
packages/​joint-layout-elk/​test/​types/​tsconfig.json Configures type tests.
examples/​layout-elk-containers-and-ports-ts/​src/​index.ts Uses native numeric options and current port IDs.
examples/​layout-elk-containers-and-ports-ts/​src/​shapes.ts Uses object padding.
examples/​layout-elk-defaults-ts/​src/​index.ts Uses numeric spacing values.
examples/​layout-elk-dependency-graph-ts/​src/​index.ts Uses native number and boolean values.
examples/​layout-elk-rectpacking-ts/​src/​index.ts Uses native option values and shared padding.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/joint-layout-elk/src/layout.mts Outdated
Comment thread packages/joint-layout-elk/src/types/elkNodeOptions.mts
Comment thread packages/joint-layout-elk/src/elkOptions.mts Outdated
It took `(elements, links, options, elkLayoutOptions)` - the arguments `layout()`
happened to call it with, which made two things leak:

- Splitting cells into elements and links is `splitCells()`'s job, and that is
  private. A caller with a list of cells had to reimplement it, including the
  part that is easy to miss: a cell listed twice becomes two ELK nodes sharing
  an id. It now takes a graph or a list of cells, like `layout()`, so anything
  `layout()` lays out can be exported - subsets included.
- `elkLayoutOptions` is the exported graph's own root options, so it belongs in
  `ExportGraphOptions` rather than beside it. `LayoutOptions` inherits it from
  there now instead of declaring it a second time.

`layout()` still merges its defaults into them; `exportGraph()` takes them as
given, which its JSDoc says.

`splitCells()` moves to `cells.mjs`, shared by both.
…ue handle

`exportGraph()` returned its bookkeeping as three `Map`s - `elementsById`,
`linksById`, `portsById` - and `importLayout()` took them back as three separate
arguments, so applying a result read:

    importLayout(result, data.elementsById, data.linksById, data.portsById, opt)

The maps are real: ELK ids are built from cell ids, and a port's entry also
carries where it was when it was exported, which the result alone cannot say.
None of that is anyone else's business, though. They are now one opaque `cells`
handle, made in `exportGraph()` and read back in `importLayout()`:

    const { elkGraph, cells } = exportGraph(graph, opt);
    importLayout(await elk.layout(toNativeElkNode(elkGraph)), cells, opt);

`ElkGraphPort` is no longer public - it was exported only because `portsById`
leaked it - and `ImportLayoutOptions` is optional now, as `exportGraph()`'s is.

The README documents the three as a path of their own, and says what most people
should reach for instead: a custom `elk`, which is any object with `layout()` and
`terminate()`, and leaves `layout()` doing the export and the import.
…raphLayout()`

`exportGraph()` and `importLayout()` shared no noun, so they did not read as the
pair they are - and next to a function called `layout()`, "import layout" can be
read as the layout settings rather than a computed result. Both now say what
they carry: a graph's layout, out and back.

`ExportGraphOptions` and `ImportLayoutOptions` follow.
`ElkGraphData` was named like the ELK graph types in `types/`, and `cells` reads
in JointJS as a list of models - it is neither. What it holds is the ELK graph
and what each of its ids stands for:

    ExportedGraphLayout { elkGraph: ElkNode; index: ExportedCellIndex }

`importGraphLayout()` takes the index alone, which is all it reads - the exported
graph beside it is the pre-layout one, and having it in scope next to the result
only invites the question of which of the two gets applied.

`Elk*` now means one thing across the package: ELK's own shapes, all in `types/`.
…index

`ExportedCellIndex` was a phantom - a symbol declared with `declare const`, so
no object could ever satisfy it and the only way in or out was a double cast
through `unknown`, at both ends.

The symbol is a real one now, and the index carries it, so `CellIndex` genuinely
is an `ExportedCellIndex`: building one needs no cast at all, and reading it back
is an ordinary downcast to what it was built as.

The symbol is not exported from the package, so an index still cannot be forged
outside it, and it never reaches the worker - only the converted graph is posted.
`CellIndex` is the type now, with its maps in plain sight, declared beside the
`exportGraphLayout()` that builds it and the `importGraphLayout()` that reads it.

It replaces a branded `ExportedCellIndex` that hid the same three maps behind a
second type, a symbol and a cast at each end - machinery to stop people reading
fields a doc comment can tell them not to.

`cells.mjs` is back to `splitCells()` alone.
- `getBBox()` read `elk.padding` under that key alone, while `toElkOption()`
  takes ELK's full option id too - so `org.eclipse.elk.padding` laid the graph
  out with the padding given and measured the box with the default.

- `exportGraphLayout()` took `elkLayoutOptions` as given, where `layout()` fills
  this package's defaults in. One of those is `elk.json.edgeCoords`, which is not
  a preference: `importGraphLayout()` reads an edge's points as the root's
  coordinates, so a graph exported by hand and laid out had its links routed off
  by the container's origin - silently, and only on hierarchical graphs. Both now
  fill them in, through `withDefaultElkLayoutOptions()`.

- `toNativeElkNode()` returned the node, port or label it was given when there
  was nothing to convert, so a graph whose ports carry no `layoutOptions` was
  shared with the caller - and ELK writes its coordinates into the graph it is
  given. It copies everything now, as its "a new graph" contract says.

- `parseElkPadding()` read `[TOP=40]`, `[foo=1]` and `[]` as no padding at all
  and fell back to 12 a side, where ELK reads them as a padding naming no side -
  0 a side. It also missed an uppercase exponent (`4E1`, which is what Java's
  own `toString` emits) and a leading `+`, both of which ELK takes.

Each is covered: the hierarchical round trip fails without the second fix.
…c API

`withDefaultElkLayoutOptions()` landed in `export.mjs`, which the barrel
re-exports whole, so it became public by accident. It sits in `elkOptions.mjs`
now, beside `getElkPadding()` and the defaults themselves - the barrel takes
only `toNativeElkNode()` from there.
`exportGraphLayout()` and `importGraphLayout()` are no longer public, and nor
are the types they passed between them - `ExportedGraphLayout`, `CellIndex`,
`ExportedPort`. What the ids of an ELK graph stand for is this package's own
bookkeeping: a map of them to cells, with each port's position as it was sent.
There is no shape of that worth promising to anyone.

Nothing is lost with them. To run ELK elsewhere - a Web Worker, a server, a
cache - or to change the graph on its way there, pass an `elk` of your own to
`layout()`; it is any object with `layout()` and `terminate()`. And to see the
graph this package builds, that same `elk` is handed exactly what ELK is handed,
with every option already a string - so it pastes into elk-live, which the
pre-conversion graph `exportGraphLayout()` returned did not.

The barrel lists what it exports one by one now, so that being public is a
decision rather than a side effect of where something is declared.
`elkIds.mts` claimed they are "unique across the whole graph", and put that down
to JointJS cell ids being unique across elements and links. That holds within one
graph, and `layout()` also takes cells from several - so a node's id and an edge's
can be the same string.

Nothing reads one as the other: they are looked up apart, which is why the
duplicate check is per kind. The comment says so now instead of claiming an
invariant the escaping does not provide.
`layout()` split them for the graphs to batch on, and the export split them
again for the elements and the links. That was the price of the export taking
a graph or a list of cells like `layout()` does, so that a caller would not have
to reimplement `splitCells()` - and the export is not public any more, so there
is no caller to spare.

It hands the graphs back with the rest now, and `layout()` splits nothing.
- The guard said to keep the runtime option lists in step with the types was not
  one. `new Set<Union>([...])` does not check that the literals cover the union,
  so a `dia.Sides` or point option added to the types and left out of the list
  compiled fine - and then went to ELK as an object, which ELK rejects outright.
  (The check that did exist was the other way round: a listed key that is not
  sides-valued.) There is a real exhaustiveness assertion now, and it names the
  option it is missing.

- `elk.json.shapeCoords` is the twin of `elk.json.edgeCoords`: `importGraphLayout`
  adds each container's position as it walks down, so a node's has to be its
  parent's. It is ELK's own default, but it was neither pinned nor written down,
  and setting it to `ROOT` counts every container's origin twice - silently, and
  only on a graph with containers. Pinned beside `edgeCoords`, where the
  constraint can be read.

- The README's recipe for looking at the graph passes an `elk` with a no-op
  `terminate()`, which is how an `elk` says it can be stopped - so `layout()`
  hands it the signal instead of ignoring a late result, and an abort then does
  nothing until ELK settles. Said so.

- The per-element option types carried a copy of the graph-level doc example,
  citing `elk.spacing.nodeNode` on edges, ports and labels, which have no such
  option, and advertising sides and points on edges, which have neither. Each
  shows one of its own options now.
… not two

The exhaustiveness assertion only looked at the graph's and the node's strict
interfaces, so one declared on an edge, a port or a label could still be left
out of the runtime list and reach ELK unconverted - which ELK rejects. Ports and
labels do declare point options today.

Verified in all three: an unlisted point option on a port or a label, and an
unlisted sides option on an edge, each now name themselves in the error.
`elk.nodeLabels.placement`, `elk.portLabels.placement`, `elk.contentAlignment`,
`elk.nodeSize.constraints` and `elk.nodeSize.options` are sets of enum values
in ELK - every `ENUMSET` option elkjs knows. They were typed as a plain
`string`, which checks nothing, and ELK silently ignores the whole value if
any one of its values is unknown. elkjs takes a set only as one string of
its values and throws on an array.

Type them as readonly arrays of their enum in the strict spelling (the
native one stays `string`), and write such an array out as its values
separated by spaces, as padding and point values already are. The list of
these options is checked against the strict interfaces at compile time.

Their documented defaults were Java expressions (`NodeLabelPlacement.fixed`,
`EnumSet.noneOf(SizeConstraint)`, ...); write them as values instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Geliogabalus
Geliogabalus marked this pull request as ready for review October 15, 2026 08:21
@Geliogabalus
Geliogabalus requested a balanced review from Copilot October 15, 2026 08:21

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Empty sides objects remain unconverted, and custom root-padding changes produce incorrect bounding boxes.

2 open findings
3 resolved since last review

🧠 Review effort: Balanced

Comment thread packages/joint-layout-elk/src/elkOptions.mts
Comment thread packages/joint-layout-elk/src/layout.mts Outdated
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
@Geliogabalus
Geliogabalus merged commit 47c58e4 into clientIO:master Oct 15, 2026
3 checks passed
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.

3 participants