@invana/graph-layout-elkjs 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +177 -0
- package/dist/index.js +138 -0
- package/dist/index.js.map +1 -0
- package/package.json +60 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { Layout } from '@invana/canvas';
|
|
2
|
+
import { GraphNode, GraphLayer } from '@invana/graph';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `ElkLayout` options. See the
|
|
6
|
+
* [ELK reference](https://eclipse.dev/elk/reference.html) for the full
|
|
7
|
+
* catalogue of algorithms and properties.
|
|
8
|
+
*
|
|
9
|
+
* Every field defaults to `undefined`. When omitted, ELK's own algorithm
|
|
10
|
+
* defaults apply. The {@link ElkLayoutOptions.layoutOptions} escape hatch
|
|
11
|
+
* passes raw property keys (`'elk.algorithm'`, `'elk.spacing.nodeNode'`,
|
|
12
|
+
* etc.) straight through and wins over every convenience field above.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* new ElkLayout({
|
|
16
|
+
* algorithm: 'layered',
|
|
17
|
+
* direction: 'RIGHT',
|
|
18
|
+
* nodeSpacing: 40,
|
|
19
|
+
* layerSpacing: 80,
|
|
20
|
+
* });
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Built-in ELK algorithm names shipped in `elkjs/lib/elk.bundled.js`.
|
|
25
|
+
*
|
|
26
|
+
* `'layered'` (Sugiyama hierarchical, the default) is the right choice for
|
|
27
|
+
* most directed graphs. The other algorithms cover specialised cases:
|
|
28
|
+
*
|
|
29
|
+
* - `'mrtree'` — tidy tree, single root.
|
|
30
|
+
* - `'radial'` — radial tree.
|
|
31
|
+
* - `'force'` — Eades / Fruchterman–Reingold force-directed.
|
|
32
|
+
* - `'stress'` — multi-dimensional scaling stress majorisation.
|
|
33
|
+
* - `'disco'` — disconnected-component packing wrapper.
|
|
34
|
+
* - `'sporeOverlap'` / `'sporeCompaction'` — SPOrE post-processors.
|
|
35
|
+
* - `'box'` / `'rectpacking'` — pack rectangles without edges.
|
|
36
|
+
* - `'random'` — debugging baseline.
|
|
37
|
+
* - `'fixed'` — keep user-supplied coordinates; only resolves edges.
|
|
38
|
+
*
|
|
39
|
+
* Pass any string to use a custom-registered algorithm.
|
|
40
|
+
*/
|
|
41
|
+
type ElkAlgorithmName = 'layered' | 'mrtree' | 'radial' | 'force' | 'stress' | 'disco' | 'sporeOverlap' | 'sporeCompaction' | 'box' | 'rectpacking' | 'random' | 'fixed' | (string & {});
|
|
42
|
+
/** Direction of the primary layout axis. Mapped to `elk.direction`. */
|
|
43
|
+
type ElkDirection = 'UP' | 'DOWN' | 'LEFT' | 'RIGHT';
|
|
44
|
+
/** Symmetric or per-side padding around the graph. Mapped to `elk.padding`. */
|
|
45
|
+
type ElkPadding = number | {
|
|
46
|
+
top?: number;
|
|
47
|
+
right?: number;
|
|
48
|
+
bottom?: number;
|
|
49
|
+
left?: number;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Resolved node bounding box, in canvas units. ELK needs concrete width +
|
|
53
|
+
* height for every node to place them — `ElkLayout` derives these from the
|
|
54
|
+
* resolved node style by default, but you can override per-node via
|
|
55
|
+
* {@link ElkLayoutOptions.nodeSize}.
|
|
56
|
+
*/
|
|
57
|
+
interface NodeSize {
|
|
58
|
+
width: number;
|
|
59
|
+
height: number;
|
|
60
|
+
}
|
|
61
|
+
/** `ElkLayout` constructor options. See top-level module doc. */
|
|
62
|
+
interface ElkLayoutOptions {
|
|
63
|
+
/** `elk.algorithm`. Default: `'layered'`. */
|
|
64
|
+
algorithm?: ElkAlgorithmName;
|
|
65
|
+
/** `elk.direction`. Algorithms that respect direction: `layered`, `mrtree`, ... */
|
|
66
|
+
direction?: ElkDirection;
|
|
67
|
+
/** `elk.spacing.nodeNode` — minimum gap between sibling nodes. */
|
|
68
|
+
nodeSpacing?: number;
|
|
69
|
+
/**
|
|
70
|
+
* `elk.layered.spacing.nodeNodeBetweenLayers` — gap between consecutive
|
|
71
|
+
* layers in the `layered` algorithm. Ignored by other algorithms.
|
|
72
|
+
*/
|
|
73
|
+
layerSpacing?: number;
|
|
74
|
+
/** `elk.spacing.edgeNode` — gap between an edge and a node. */
|
|
75
|
+
edgeNodeSpacing?: number;
|
|
76
|
+
/** `elk.spacing.edgeEdge` — gap between parallel edges. */
|
|
77
|
+
edgeSpacing?: number;
|
|
78
|
+
/**
|
|
79
|
+
* `elk.edgeRouting`. When set, ELK computes node-avoiding edge geometry and
|
|
80
|
+
* `ElkLayout` writes the resulting bend points back as each edge's
|
|
81
|
+
* `style.shape.waypoints` (with `pathType: 'orth'`). Leaving it unset keeps
|
|
82
|
+
* the previous behaviour — only node positions are written, no edge geometry.
|
|
83
|
+
*
|
|
84
|
+
* `'ORTHOGONAL'` is the intended value for `layered` graphs. Routing assumes
|
|
85
|
+
* nodes whose `node.position` is their CENTRE (circle natively; the
|
|
86
|
+
* `composite` shape via `GraphLayer`'s centre-fit). Top-left-origin shapes
|
|
87
|
+
* (e.g. `rect`) would render offset from the computed routes.
|
|
88
|
+
*/
|
|
89
|
+
edgeRouting?: 'ORTHOGONAL' | 'POLYLINE' | 'SPLINES';
|
|
90
|
+
/** `elk.padding` — graph-level padding. */
|
|
91
|
+
padding?: ElkPadding;
|
|
92
|
+
/**
|
|
93
|
+
* Fallback bounding box used when {@link nodeSize} is not provided and
|
|
94
|
+
* the node has no resolvable `style.shape`. Default `{ width: 40, height: 40 }`.
|
|
95
|
+
*/
|
|
96
|
+
defaultNodeSize?: NodeSize;
|
|
97
|
+
/**
|
|
98
|
+
* Per-node bounding box override. Called once per node at the start of
|
|
99
|
+
* `apply()` with the underlying `GraphNode`. When omitted, `ElkLayout`
|
|
100
|
+
* reads `style.shape` via the layer's `resolveNodeStyle` and falls back
|
|
101
|
+
* to {@link defaultNodeSize} when no shape is found.
|
|
102
|
+
*
|
|
103
|
+
* Return tight bounds — ELK adds spacing on top, so over-sized boxes
|
|
104
|
+
* blow up the final layout.
|
|
105
|
+
*/
|
|
106
|
+
nodeSize?: (node: GraphNode) => NodeSize;
|
|
107
|
+
/**
|
|
108
|
+
* Free-form ELK property bag, merged into the root graph's
|
|
109
|
+
* `layoutOptions` after the convenience fields above. Use for any
|
|
110
|
+
* property the typed surface doesn't cover (`elk.layered.crossingMinimization.strategy`,
|
|
111
|
+
* `elk.aspectRatio`, etc.). Later keys win.
|
|
112
|
+
*/
|
|
113
|
+
layoutOptions?: Record<string, string>;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* `ElkLayout` — [ELK](https://eclipse.dev/elk/) `Layout` for `@invana/graph`.
|
|
118
|
+
*
|
|
119
|
+
* ELK is a *one-shot* layout engine: a single `apply()` call snapshots the
|
|
120
|
+
* graph, dispatches it to the wasm-free JS port (`elkjs`), waits for the
|
|
121
|
+
* Promise to settle, then writes positions back to the store in one bulk
|
|
122
|
+
* call. There is no iterative simulation — the run emits exactly one
|
|
123
|
+
* `tick` event, immediately followed by `end`.
|
|
124
|
+
*
|
|
125
|
+
* ## Coordinate convention
|
|
126
|
+
*
|
|
127
|
+
* ELK returns top-left corner coordinates for every node. `@invana/graph`
|
|
128
|
+
* stores **centre** coordinates. The layout converts on write-back using
|
|
129
|
+
* each node's resolved width / height (same numbers it fed to ELK).
|
|
130
|
+
*
|
|
131
|
+
* ## Cancellation
|
|
132
|
+
*
|
|
133
|
+
* `elkjs` does not expose mid-layout cancellation. `stop()` (and a second
|
|
134
|
+
* `apply()` call) instead bump a run token: when the in-flight Promise
|
|
135
|
+
* settles for an obsolete token, its result is dropped on the floor and
|
|
136
|
+
* `end` fires with `reason: 'stopped'`.
|
|
137
|
+
*
|
|
138
|
+
* @example
|
|
139
|
+
* const layout = new ElkLayout({
|
|
140
|
+
* algorithm: 'layered',
|
|
141
|
+
* direction: 'RIGHT',
|
|
142
|
+
* nodeSpacing: 30,
|
|
143
|
+
* layerSpacing: 80,
|
|
144
|
+
* });
|
|
145
|
+
* layout.events.on('end', () => canvas.camera.fitContent(graphLayer.getBounds(), 80));
|
|
146
|
+
* await layout.apply(graphLayer);
|
|
147
|
+
*/
|
|
148
|
+
|
|
149
|
+
declare class ElkLayout extends Layout<GraphLayer> {
|
|
150
|
+
private readonly opts;
|
|
151
|
+
/** Shared ELK instance — `elkjs` is happy to be reused across runs. */
|
|
152
|
+
private readonly elk;
|
|
153
|
+
/**
|
|
154
|
+
* Monotonic run id. Each `apply()` bumps it; the in-flight Promise's
|
|
155
|
+
* captured value is compared against it on settle to decide whether the
|
|
156
|
+
* result is still relevant.
|
|
157
|
+
*/
|
|
158
|
+
private runToken;
|
|
159
|
+
/** True while a run is in flight — guards `stop()` from emitting twice. */
|
|
160
|
+
private running;
|
|
161
|
+
constructor(opts?: ElkLayoutOptions);
|
|
162
|
+
/**
|
|
163
|
+
* Run ELK against `layer`. Resolves when ELK settles OR the run is
|
|
164
|
+
* cancelled by `stop()` / a subsequent `apply()`. Even on cancellation
|
|
165
|
+
* the Promise resolves (never rejects) — the cancel path emits
|
|
166
|
+
* `end: { reason: 'stopped' }` and resolves cleanly.
|
|
167
|
+
*/
|
|
168
|
+
apply(layer: GraphLayer): Promise<void>;
|
|
169
|
+
/**
|
|
170
|
+
* Cancel an in-flight run. The current ELK Promise (if any) is left to
|
|
171
|
+
* settle, but its result is dropped. Positions already in the store are
|
|
172
|
+
* untouched. No-op when idle.
|
|
173
|
+
*/
|
|
174
|
+
stop(): void;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export { type ElkAlgorithmName, type ElkDirection, ElkLayout, type ElkLayoutOptions, type ElkPadding, type NodeSize };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import ELK from 'elkjs/lib/elk.bundled.js';
|
|
2
|
+
import { Layout } from '@invana/canvas';
|
|
3
|
+
|
|
4
|
+
// src/ElkLayout.ts
|
|
5
|
+
var FALLBACK_NODE_SIZE = { width: 40, height: 40 };
|
|
6
|
+
var ElkLayout = class extends Layout {
|
|
7
|
+
opts;
|
|
8
|
+
/** Shared ELK instance — `elkjs` is happy to be reused across runs. */
|
|
9
|
+
elk;
|
|
10
|
+
/**
|
|
11
|
+
* Monotonic run id. Each `apply()` bumps it; the in-flight Promise's
|
|
12
|
+
* captured value is compared against it on settle to decide whether the
|
|
13
|
+
* result is still relevant.
|
|
14
|
+
*/
|
|
15
|
+
runToken = 0;
|
|
16
|
+
/** True while a run is in flight — guards `stop()` from emitting twice. */
|
|
17
|
+
running = false;
|
|
18
|
+
constructor(opts = {}) {
|
|
19
|
+
super();
|
|
20
|
+
this.opts = opts;
|
|
21
|
+
this.elk = new ELK();
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Run ELK against `layer`. Resolves when ELK settles OR the run is
|
|
25
|
+
* cancelled by `stop()` / a subsequent `apply()`. Even on cancellation
|
|
26
|
+
* the Promise resolves (never rejects) — the cancel path emits
|
|
27
|
+
* `end: { reason: 'stopped' }` and resolves cleanly.
|
|
28
|
+
*/
|
|
29
|
+
async apply(layer) {
|
|
30
|
+
this.stop();
|
|
31
|
+
const token = ++this.runToken;
|
|
32
|
+
const store = layer.store;
|
|
33
|
+
const sizeOf = this.opts.nodeSize ?? ((n) => resolveSizeFromLayer(layer, n));
|
|
34
|
+
const ids = [];
|
|
35
|
+
const sizes = [];
|
|
36
|
+
const children = [];
|
|
37
|
+
for (const n of store.nodes()) {
|
|
38
|
+
const size = sizeOf(n) ?? FALLBACK_NODE_SIZE;
|
|
39
|
+
ids.push(n.id);
|
|
40
|
+
sizes.push(size);
|
|
41
|
+
children.push({ id: n.id, width: size.width, height: size.height });
|
|
42
|
+
}
|
|
43
|
+
if (children.length === 0) return;
|
|
44
|
+
const edges = [];
|
|
45
|
+
for (const e of store.edges()) {
|
|
46
|
+
edges.push({ id: e.id, sources: [e.source], targets: [e.target] });
|
|
47
|
+
}
|
|
48
|
+
const layoutOptions = buildLayoutOptions(this.opts);
|
|
49
|
+
const graph = { id: "root", layoutOptions, children, edges };
|
|
50
|
+
this.running = true;
|
|
51
|
+
this.events.emit("start", {});
|
|
52
|
+
let result;
|
|
53
|
+
try {
|
|
54
|
+
result = await this.elk.layout(graph);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
if (token === this.runToken) {
|
|
57
|
+
this.running = false;
|
|
58
|
+
this.events.emit("end", { reason: "completed" });
|
|
59
|
+
throw err;
|
|
60
|
+
}
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (token !== this.runToken) return;
|
|
64
|
+
const resultChildren = result.children ?? [];
|
|
65
|
+
const buffer = new Float32Array(resultChildren.length * 2);
|
|
66
|
+
for (let i = 0; i < resultChildren.length; i++) {
|
|
67
|
+
const child = resultChildren[i];
|
|
68
|
+
const size = sizes[i];
|
|
69
|
+
buffer[i * 2] = (child.x ?? 0) + size.width / 2;
|
|
70
|
+
buffer[i * 2 + 1] = (child.y ?? 0) + size.height / 2;
|
|
71
|
+
}
|
|
72
|
+
store.setPositionsBulk(ids, buffer);
|
|
73
|
+
if (this.opts.edgeRouting !== void 0) {
|
|
74
|
+
const routedEdges = result.edges ?? [];
|
|
75
|
+
store.batch(() => {
|
|
76
|
+
for (const e of routedEdges) {
|
|
77
|
+
const section = e.sections?.[0];
|
|
78
|
+
const waypoints = section ? [section.startPoint, ...section.bendPoints ?? [], section.endPoint].map((p) => ({
|
|
79
|
+
x: p.x,
|
|
80
|
+
y: p.y
|
|
81
|
+
})) : [];
|
|
82
|
+
const prev = store.getEdge(e.id)?.style ?? {};
|
|
83
|
+
store.updateEdge(e.id, {
|
|
84
|
+
style: { ...prev, shape: { ...prev.shape ?? {}, pathType: "orth", waypoints } }
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
this.events.emit("tick", {});
|
|
90
|
+
this.running = false;
|
|
91
|
+
this.events.emit("end", { reason: "completed" });
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Cancel an in-flight run. The current ELK Promise (if any) is left to
|
|
95
|
+
* settle, but its result is dropped. Positions already in the store are
|
|
96
|
+
* untouched. No-op when idle.
|
|
97
|
+
*/
|
|
98
|
+
stop() {
|
|
99
|
+
if (!this.running) return;
|
|
100
|
+
this.running = false;
|
|
101
|
+
this.runToken++;
|
|
102
|
+
this.events.emit("end", { reason: "stopped" });
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
function resolveSizeFromLayer(layer, node) {
|
|
106
|
+
const local = layer.boundsOfNode(node);
|
|
107
|
+
if (!local) return FALLBACK_NODE_SIZE;
|
|
108
|
+
return { width: local.width, height: local.height };
|
|
109
|
+
}
|
|
110
|
+
function buildLayoutOptions(opts) {
|
|
111
|
+
const out = {};
|
|
112
|
+
out["elk.algorithm"] = opts.algorithm ?? "layered";
|
|
113
|
+
if (opts.direction !== void 0) out["elk.direction"] = opts.direction;
|
|
114
|
+
if (opts.nodeSpacing !== void 0) out["elk.spacing.nodeNode"] = String(opts.nodeSpacing);
|
|
115
|
+
if (opts.layerSpacing !== void 0) {
|
|
116
|
+
out["elk.layered.spacing.nodeNodeBetweenLayers"] = String(opts.layerSpacing);
|
|
117
|
+
}
|
|
118
|
+
if (opts.edgeNodeSpacing !== void 0) out["elk.spacing.edgeNode"] = String(opts.edgeNodeSpacing);
|
|
119
|
+
if (opts.edgeSpacing !== void 0) out["elk.spacing.edgeEdge"] = String(opts.edgeSpacing);
|
|
120
|
+
if (opts.edgeRouting !== void 0) out["elk.edgeRouting"] = opts.edgeRouting;
|
|
121
|
+
if (opts.padding !== void 0) out["elk.padding"] = formatPadding(opts.padding);
|
|
122
|
+
if (opts.layoutOptions) Object.assign(out, opts.layoutOptions);
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
function formatPadding(p) {
|
|
126
|
+
if (typeof p === "number") {
|
|
127
|
+
return `[top=${p},right=${p},bottom=${p},left=${p}]`;
|
|
128
|
+
}
|
|
129
|
+
const top = p.top ?? 0;
|
|
130
|
+
const right = p.right ?? 0;
|
|
131
|
+
const bottom = p.bottom ?? 0;
|
|
132
|
+
const left = p.left ?? 0;
|
|
133
|
+
return `[top=${top},right=${right},bottom=${bottom},left=${left}]`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export { ElkLayout };
|
|
137
|
+
//# sourceMappingURL=index.js.map
|
|
138
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/ElkLayout.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * `ElkLayout` — [ELK](https://eclipse.dev/elk/) `Layout` for `@invana/graph`.\n *\n * ELK is a *one-shot* layout engine: a single `apply()` call snapshots the\n * graph, dispatches it to the wasm-free JS port (`elkjs`), waits for the\n * Promise to settle, then writes positions back to the store in one bulk\n * call. There is no iterative simulation — the run emits exactly one\n * `tick` event, immediately followed by `end`.\n *\n * ## Coordinate convention\n *\n * ELK returns top-left corner coordinates for every node. `@invana/graph`\n * stores **centre** coordinates. The layout converts on write-back using\n * each node's resolved width / height (same numbers it fed to ELK).\n *\n * ## Cancellation\n *\n * `elkjs` does not expose mid-layout cancellation. `stop()` (and a second\n * `apply()` call) instead bump a run token: when the in-flight Promise\n * settles for an obsolete token, its result is dropped on the floor and\n * `end` fires with `reason: 'stopped'`.\n *\n * @example\n * const layout = new ElkLayout({\n * algorithm: 'layered',\n * direction: 'RIGHT',\n * nodeSpacing: 30,\n * layerSpacing: 80,\n * });\n * layout.events.on('end', () => canvas.camera.fitContent(graphLayer.getBounds(), 80));\n * await layout.apply(graphLayer);\n */\n\nimport ELK, {\n type ELK as ElkInstance,\n type ElkExtendedEdge,\n type ElkNode,\n type LayoutOptions,\n} from 'elkjs/lib/elk.bundled.js';\n\nimport { Layout } from '@invana/canvas';\nimport type { EdgeStyle, GraphLayer, GraphNode } from '@invana/graph';\n\nimport type {\n ElkLayoutOptions,\n ElkPadding,\n NodeSize,\n} from './types';\n\n/** Fallback bounding box when no shape and no override give us a size. */\nconst FALLBACK_NODE_SIZE: NodeSize = { width: 40, height: 40 };\n\nexport class ElkLayout extends Layout<GraphLayer> {\n private readonly opts: ElkLayoutOptions;\n /** Shared ELK instance — `elkjs` is happy to be reused across runs. */\n private readonly elk: ElkInstance;\n /**\n * Monotonic run id. Each `apply()` bumps it; the in-flight Promise's\n * captured value is compared against it on settle to decide whether the\n * result is still relevant.\n */\n private runToken = 0;\n /** True while a run is in flight — guards `stop()` from emitting twice. */\n private running = false;\n\n constructor(opts: ElkLayoutOptions = {}) {\n super();\n this.opts = opts;\n this.elk = new ELK();\n }\n\n /**\n * Run ELK against `layer`. Resolves when ELK settles OR the run is\n * cancelled by `stop()` / a subsequent `apply()`. Even on cancellation\n * the Promise resolves (never rejects) — the cancel path emits\n * `end: { reason: 'stopped' }` and resolves cleanly.\n */\n async apply(layer: GraphLayer): Promise<void> {\n // Cancel any in-flight run before starting a new one. `stop` bumps the\n // token and emits `end: 'stopped'` for the previous run.\n this.stop();\n\n const token = ++this.runToken;\n const store = layer.store;\n\n // 1. Snapshot nodes + edges, resolving width/height per node.\n const sizeOf = this.opts.nodeSize ?? ((n: GraphNode) => resolveSizeFromLayer(layer, n));\n const ids: string[] = [];\n const sizes: NodeSize[] = [];\n const children: ElkNode[] = [];\n for (const n of store.nodes()) {\n const size = sizeOf(n) ?? FALLBACK_NODE_SIZE;\n ids.push(n.id);\n sizes.push(size);\n children.push({ id: n.id, width: size.width, height: size.height });\n }\n if (children.length === 0) return;\n\n const edges: ElkExtendedEdge[] = [];\n for (const e of store.edges()) {\n edges.push({ id: e.id, sources: [e.source], targets: [e.target] });\n }\n\n // 2. Build the ELK graph + merge convenience options with the\n // free-form passthrough (passthrough wins).\n const layoutOptions = buildLayoutOptions(this.opts);\n const graph: ElkNode = { id: 'root', layoutOptions, children, edges };\n\n this.running = true;\n this.events.emit('start', {});\n\n // 3. Dispatch. `elk.layout` is async — we capture `token` so a stale\n // settle can be ignored cleanly.\n let result: ElkNode;\n try {\n result = await this.elk.layout(graph);\n } catch (err) {\n // ELK threw (typically: malformed property bag). If still relevant,\n // tear down + rethrow so the caller's awaited Promise rejects;\n // otherwise the cancel path already emitted `end: 'stopped'`.\n if (token === this.runToken) {\n this.running = false;\n this.events.emit('end', { reason: 'completed' });\n throw err;\n }\n return;\n }\n\n // 4. Stale settle → nothing to do. The newer run owns the future.\n if (token !== this.runToken) return;\n\n // 5. Convert ELK top-left coordinates to canvas centre coordinates\n // and bulk-write. Iterate `result.children` (its child order is\n // the order we passed in), pairing with `sizes` by index.\n const resultChildren = result.children ?? [];\n const buffer = new Float32Array(resultChildren.length * 2);\n for (let i = 0; i < resultChildren.length; i++) {\n const child = resultChildren[i]!;\n const size = sizes[i]!;\n buffer[i * 2] = (child.x ?? 0) + size.width / 2;\n buffer[i * 2 + 1] = (child.y ?? 0) + size.height / 2;\n }\n // 6a. Apply node positions in their own flush first. This moves the node\n // shapes and re-routes every incident connector once against the new\n // layout.\n store.setPositionsBulk(ids, buffer);\n\n // 6b. When ELK edge routing is on, read back each edge's computed bend\n // points and write them as `style.shape.waypoints` (pathType 'orth')\n // in a SEPARATE flush. This matters: the edge-style write must NOT\n // share a flush with the position write above. A position flush marks\n // every incident connector dirty and re-routes them via a plain\n // `updateConnector(id, {})` at flush end; bundling the waypoint write\n // into that same flush lets that re-route run alongside the\n // waypoint-applying `edge:update`, and the routed path doesn't stick.\n // Writing waypoints in their own flush (no concurrent node moves)\n // mirrors the hover/`rerenderEdge` path that applies cleanly.\n //\n // ELK works in the same coordinate frame as the stored centres, and —\n // for centre-origin shapes (circle, and `composite` via GraphLayer's\n // centre-fit) — the rendered node occupies exactly ELK's node box, so\n // bend points line up with the cards without any per-edge offset.\n if (this.opts.edgeRouting !== undefined) {\n const routedEdges = (result.edges ?? []) as ElkExtendedEdge[];\n store.batch(() => {\n for (const e of routedEdges) {\n // Use the FULL section path — startPoint + bends + endPoint — not\n // just the interior bends. The start/end points sit on the node\n // border where ELK's route leaves/enters perpendicularly, so the\n // `orth` router connects the boundary-anchored endpoints to them\n // along the edge without inventing a spurious out-and-back corner.\n // (Passing only interior bends made orth L-bend across a long\n // misaligned first/last leg → visible \"peaks\" at both ends.)\n const section = e.sections?.[0];\n const waypoints = section\n ? [section.startPoint, ...(section.bendPoints ?? []), section.endPoint].map((p) => ({\n x: p.x,\n y: p.y,\n }))\n : [];\n const prev = (store.getEdge(e.id)?.style as EdgeStyle | undefined) ?? {};\n store.updateEdge(e.id, {\n style: { ...prev, shape: { ...(prev.shape ?? {}), pathType: 'orth', waypoints } },\n });\n }\n });\n }\n\n this.events.emit('tick', {});\n this.running = false;\n this.events.emit('end', { reason: 'completed' });\n }\n\n /**\n * Cancel an in-flight run. The current ELK Promise (if any) is left to\n * settle, but its result is dropped. Positions already in the store are\n * untouched. No-op when idle.\n */\n stop(): void {\n if (!this.running) return;\n this.running = false;\n // Bump the token so the in-flight Promise sees itself as stale.\n this.runToken++;\n this.events.emit('end', { reason: 'stopped' });\n }\n}\n\n// ─── Helpers ─────────────────────────────────────────────────────────────\n\n/**\n * Read the resolved shape's local AABB from {@link GraphLayer.boundsOfNode}\n * and project it to a `{ width, height }`. The layer routes through the\n * shape registry's `static boundsOf` hook, so every registered kind\n * (built-in or custom) flows through the same code path here without a\n * per-kind switch.\n *\n * Falls back to {@link FALLBACK_NODE_SIZE} when the renderer isn't\n * mounted yet, the resolved shape kind isn't registered, or the\n * registered ctor doesn't expose `boundsOf`. Consumers that need a\n * tighter override on a per-node basis can pass `nodeSize: (node) =>\n * ({ width, height })` to bypass this hook entirely.\n */\nfunction resolveSizeFromLayer(layer: GraphLayer, node: GraphNode): NodeSize {\n const local = layer.boundsOfNode(node);\n if (!local) return FALLBACK_NODE_SIZE;\n return { width: local.width, height: local.height };\n}\n\n/**\n * Merge the convenience option fields and the free-form `layoutOptions`\n * passthrough into a single ELK property bag. The passthrough is applied\n * last so users can always override.\n */\nfunction buildLayoutOptions(opts: ElkLayoutOptions): LayoutOptions {\n const out: LayoutOptions = {};\n out['elk.algorithm'] = opts.algorithm ?? 'layered';\n if (opts.direction !== undefined) out['elk.direction'] = opts.direction;\n if (opts.nodeSpacing !== undefined) out['elk.spacing.nodeNode'] = String(opts.nodeSpacing);\n if (opts.layerSpacing !== undefined) {\n out['elk.layered.spacing.nodeNodeBetweenLayers'] = String(opts.layerSpacing);\n }\n if (opts.edgeNodeSpacing !== undefined) out['elk.spacing.edgeNode'] = String(opts.edgeNodeSpacing);\n if (opts.edgeSpacing !== undefined) out['elk.spacing.edgeEdge'] = String(opts.edgeSpacing);\n if (opts.edgeRouting !== undefined) out['elk.edgeRouting'] = opts.edgeRouting;\n if (opts.padding !== undefined) out['elk.padding'] = formatPadding(opts.padding);\n if (opts.layoutOptions) Object.assign(out, opts.layoutOptions);\n return out;\n}\n\n/**\n * ELK's `elk.padding` is a string in the form `'[top=N,right=N,bottom=N,left=N]'`.\n * Symmetric `number` shorthand fills all four sides.\n */\nfunction formatPadding(p: ElkPadding): string {\n if (typeof p === 'number') {\n return `[top=${p},right=${p},bottom=${p},left=${p}]`;\n }\n const top = p.top ?? 0;\n const right = p.right ?? 0;\n const bottom = p.bottom ?? 0;\n const left = p.left ?? 0;\n return `[top=${top},right=${right},bottom=${bottom},left=${left}]`;\n}\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@invana/graph-layout-elkjs",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "ELK.js Layout for @invana/graph.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"typedocOptions": {
|
|
9
|
+
"entryPoints": [
|
|
10
|
+
"src/index.ts"
|
|
11
|
+
]
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"elkjs": "^0.9.3"
|
|
15
|
+
},
|
|
16
|
+
"peerDependencies": {
|
|
17
|
+
"@invana/canvas": "0.0.2",
|
|
18
|
+
"@invana/graph": "0.0.2"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"tsup": "^8.3.5",
|
|
22
|
+
"typescript": "5.9.2",
|
|
23
|
+
"vitest": "^2.1.8",
|
|
24
|
+
"@invana/canvas": "0.0.2",
|
|
25
|
+
"@repo/typescript-config": "0.0.0",
|
|
26
|
+
"@invana/graph": "0.0.2"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"canvas",
|
|
30
|
+
"graph",
|
|
31
|
+
"layout",
|
|
32
|
+
"elk",
|
|
33
|
+
"elkjs",
|
|
34
|
+
"hierarchical",
|
|
35
|
+
"layered"
|
|
36
|
+
],
|
|
37
|
+
"license": "Apache-2.0",
|
|
38
|
+
"module": "./dist/index.js",
|
|
39
|
+
"exports": {
|
|
40
|
+
".": {
|
|
41
|
+
"types": "./dist/index.d.ts",
|
|
42
|
+
"import": "./dist/index.js",
|
|
43
|
+
"default": "./dist/index.js"
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"files": [
|
|
47
|
+
"dist"
|
|
48
|
+
],
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public"
|
|
51
|
+
},
|
|
52
|
+
"scripts": {
|
|
53
|
+
"build": "tsup",
|
|
54
|
+
"dev": "tsup --watch",
|
|
55
|
+
"lint": "eslint src/",
|
|
56
|
+
"check-types": "tsc --noEmit",
|
|
57
|
+
"clean": "rm -rf dist",
|
|
58
|
+
"test": "vitest run"
|
|
59
|
+
}
|
|
60
|
+
}
|