Skip to content

feat(erd-editor): Lay tables out with ELK, from an Auto Layout menu - #445

Merged
dineug merged 3 commits into
mainfrom
feat/elkjs
Sep 6, 2026
Merged

feat(erd-editor): Lay tables out with ELK, from an Auto Layout menu#445
dineug merged 3 commits into
mainfrom
feat/elkjs

Conversation

@dineug

@dineug dineug commented Sep 6, 2026

Copy link
Copy Markdown
Owner

The force simulation was the only way to place a schema, and it says nothing about which table feeds which. This adds three ELK placements beside it.

The menu

Automatic Table Placement becomes Auto Layout, and the flat item becomes a submenu. Quick search ($mod+K) mirrors it.

Force the d3-force simulation that predates this, unchanged
Flow layered + ports, SIMPLE node placement
Tree - vertical layered, direction DOWN
Tree - horizontal layered, direction RIGHT

All four reach the editor through one emitter action carrying the placement, the way openDiffViewer already worked.

Flow, and why the ports matter

A relationship in an ERD meets a specific column, so each endpoint becomes an ELK port ordered by the row it sits on, under portConstraints: FIXED_ORDER. ELK then weighs the crossings the editor will really draw rather than treating a table as one anonymous box.

A fixed order runs clockwise from the top left corner, so an east side is listed down the rows and a west side back up them. That was measured against ELK, not assumed — listing a west side downwards comes out upside down, and two tests fail if the reversal is dropped.

nodePlacement.strategy: SIMPLE is what centres a branch point over what it feeds; the default pulls it onto the longest straight run its layer allows. Verified at real table sizes.

Why a worker, and two traps on the way there

elkjs is 1.6 MB of transpiled Java — larger than the editor itself — so it is reached only from a shared worker, and never touches the main chunk (grep 'Cannot construct an ELK' dist/index.js finds nothing). There is deliberately no in-process rung: that would put a second copy of the bundle in every build that splits no chunk off, and it did, until the fallback came out.

Two failures were only visible past the unit and browser suites:

_Worker is not a constructor. elk-worker.min.js decides it is the worker script when document is undefined and self is not, installs an onmessage handler and exports no constructor. Inside a SharedWorker that is exactly the case. A stub document fixes it — but a static import of elkjs hoists above the statement that installs the stub, so the fix worked in the dev server and not in the build. ELK is now reached through a dynamic import, and a source-invariant test pins that no shipped file imports it statically.

A page reload mid-layout. Vite's dependency scanner does not walk a worker entry, so elkjs was discovered at runtime and the re-optimisation reloaded the page, losing the document. Three configs name it in optimizeDeps.include; app needs the nested @dineug/erd-editor > elkjs/... spelling because elkjs is not its own dependency.

Behaviour

Unlike the simulation there is nothing to weigh before a layout lands, so the ELK placements apply as they arrive — no Apply/Cancel step, no preview overlay, and one undo step puts every table back. The overlay is why a pointless zoom-out sat between the click and the result; it now opens for Force only.

Verification

  • pnpm check, pnpm test (7,370 in erd-editor), pnpm build, pnpm size (+6,780 B, 5,500 B under budget)
  • e2e/specs/automatic-table-placement.spec.ts — the one layer that reads the built package, which is where both traps above showed up
  • Driven by hand in app from a cold cache: menu → Flow → tables placed, no console or page errors

Notes for review

  • Internal identifiers stay layeredVertical / layeredHorizontal: layered is ELK's own algorithm name, and the real tree algorithm (mrtree) is not what these run.
  • The size budget's headroom drops from 12,280 B to 5,500 B. Re-pinning is a deliberate act, so it was left alone.

🤖 Generated with Claude Code

dineug and others added 3 commits September 7, 2026 03:11
schema-gc and export-png each carried their own withTimeout, identical but
for the message it rejects with, and the layout service about to arrive would
have made a third. The one copy now lives in utils/promise.ts and takes that
message as an argument.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The force simulation was the only way to place a schema, and it says nothing
about which table feeds which. ELK answers that: the context menu item is now
a submenu of Force, Flow and the two Tree directions, and the same four reach
the editor through one emitter action that carries the placement.

Flow is the layered algorithm handed a port per relationship endpoint, ordered
by the column row it leaves a table at, so ELK weighs the crossings the editor
will really draw. A fixed order runs clockwise from the top left corner, which
is why an east side is listed down the rows and a west side back up them.

ELK is 1.6 MB of transpiled Java, so it is reached only from a shared worker
and only through a dynamic import: a static one is evaluated before any
statement in its own module, and the stub that gives the worker realm a
document would then be too late to be read. There is no in-process rung, which
would put a second copy of that bundle in every build that splits no chunk off.
A dev server discovers neither import on its own, so three configs name it.

Unlike the simulation there is nothing to weigh before a layout lands, so the
three ELK placements apply as they arrive, take no preview overlay and leave
one undo step behind them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ELK registers the metadata of nine algorithms by default, and the placements
name one. The list is read off the options rather than written out, because a
placement naming an algorithm left out of it would be answered with an error
from inside a worker, which is a poor place to find one.

The bundle is unchanged: every algorithm is compiled into it either way, and
trimming that would take a fork of the java build for about a sixth of its
weight, most of which is the layered algorithm this uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dineug

dineug commented Sep 6, 2026

Copy link
Copy Markdown
Owner Author

Added dca70b69 — ELK now registers only the algorithm the placements name.

new ELK() registers nine algorithms' metadata by default; these placements name one. The list is derived from the options rather than written out, because the drift is what would hurt: a placement naming an algorithm missing from the list fails at runtime inside the worker, which is a poor place to find an error.

export const ELK_ALGORITHMS: string[] = [
  ...new Set(
    Object.values(TablePlacement)
      .filter(isElkPlacement)
      .map(placement => elkLayoutOptions(placement)['elk.algorithm'])
  ),
];

Verified the layout is byte-identical for all three placements with and without the option, before making the change.

On trimming the bundle itself

Worth recording, since it comes up: a custom build can drop unused algorithms — ElkJs.java's registration if-chain keeps them all reachable, so GWT prunes nothing today. But the algorithm we use is the big one. ELK's compiled plugin sources:

plugin size
alg.layered 2,720 kB in use
core + graph + graph.json + alg.common 1,844 kB shared, not removable
mrtree, rectpacking, radial, disco, force, spore 783 kB removable

Dropping all six buys ~15% of 5,347 kB, and GWT already prunes within plugins, so the realised saving would be less. Paying for that with a forked Java/GWT build published to npm — the only shipping route, since vendoring the blob would put ~400 kB gzip into dist and blow the size budget — is not a good trade for a dependency that is external, worker-only, and lazily fetched on first use.

@dineug
dineug merged commit 7d648eb into main Sep 6, 2026
8 checks passed
@dineug
dineug deleted the feat/elkjs branch September 6, 2026 18:51
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.

1 participant