@statelyai/layout 0.3.0 → 0.4.0

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.
Files changed (110) hide show
  1. package/NOTICE.md +4 -2
  2. package/README.md +150 -11
  3. package/dist/elkjs/index.cjs +2 -2
  4. package/dist/elkjs/index.d.cts +2 -0
  5. package/dist/elkjs/index.d.mts +2 -0
  6. package/dist/elkjs/index.mjs +2 -2
  7. package/dist/elkjs/main.cjs +1 -1
  8. package/dist/elkjs/main.mjs +1 -1
  9. package/dist/elkjs/worker.cjs +2 -2
  10. package/dist/elkjs/worker.mjs +2 -2
  11. package/dist/elkjs-UciYx7lD.mjs +2973 -0
  12. package/dist/elkjs-ggjsBuVP.cjs +3002 -0
  13. package/dist/{index-BEA_pzeT.d.cts → index-B6cWNrMw.d.cts} +9 -1
  14. package/dist/{index-DNTLRsDl.d.mts → index-CJQLUKD9.d.mts} +9 -1
  15. package/dist/{index-DNixtE3h.d.mts → index-DT6lPMfS.d.cts} +85 -5
  16. package/dist/{index-D9dhxrRl.d.cts → index-F1U-fCTu.d.mts} +85 -5
  17. package/dist/index.cjs +6 -3
  18. package/dist/index.d.cts +3 -3
  19. package/dist/index.d.mts +3 -3
  20. package/dist/index.mjs +4 -4
  21. package/dist/layered/index.cjs +3 -1
  22. package/dist/layered/index.d.cts +2 -2
  23. package/dist/layered/index.d.mts +2 -2
  24. package/dist/layered/index.mjs +2 -2
  25. package/dist/{layered-DGFTeQSl.mjs → layered-C7ERzZjR.mjs} +9605 -1655
  26. package/dist/{layered-B2buJqMf.cjs → layered-CBPg6_6_.cjs} +12481 -4435
  27. package/dist/{layout-engine-Dn1PiFwP.cjs → layout-engine-DAw06RhN.cjs} +5 -18
  28. package/dist/{layout-engine-Cs5tatA6.mjs → layout-engine-DC3IYkIy.mjs} +3 -10
  29. package/dist/routing/index.cjs +2 -1
  30. package/dist/routing/index.d.cts +2 -2
  31. package/dist/routing/index.d.mts +2 -2
  32. package/dist/routing/index.mjs +2 -2
  33. package/dist/{routing-A2hrK9MP.mjs → routing-CR8flv8b.mjs} +977 -593
  34. package/dist/{routing-ujEP7Qh6.cjs → routing-QIdGGFBe.cjs} +986 -596
  35. package/package.json +8 -2
  36. package/src/authoring/coordinates.ts +28 -8
  37. package/src/authoring/partial.ts +7 -2
  38. package/src/authoring/routing.ts +4 -1
  39. package/src/constraints.ts +174 -6
  40. package/src/elkjs/index.ts +1392 -449
  41. package/src/elkjs/layout-quality.ts +219 -0
  42. package/src/elkjs/opposing-tracks.ts +161 -0
  43. package/src/elkjs/orthogonal-junctions.ts +174 -0
  44. package/src/elkjs/statechart.ts +9 -3
  45. package/src/elkjs/types.ts +2 -0
  46. package/src/index.ts +2 -0
  47. package/src/internal/elkjs-compatibility.ts +4 -1
  48. package/src/internal/layered-trace.ts +48 -0
  49. package/src/internal/layout-engine.ts +2 -1
  50. package/src/java-random.ts +6 -0
  51. package/src/layered/bk-node-placement.ts +403 -92
  52. package/src/layered/center-labels.ts +398 -0
  53. package/src/layered/compaction-bounds.ts +26 -0
  54. package/src/layered/compaction-errors.ts +2 -0
  55. package/src/layered/compaction-scanline.ts +63 -0
  56. package/src/layered/compound-boundaries.ts +99 -0
  57. package/src/layered/compound.ts +35 -7
  58. package/src/layered/constraint-orientation.ts +141 -0
  59. package/src/layered/crossing-constraints.ts +243 -0
  60. package/src/layered/crossing-counter.ts +221 -0
  61. package/src/layered/crossing-graph.ts +401 -0
  62. package/src/layered/cycle-random.ts +22 -0
  63. package/src/layered/elk-port-lists.ts +243 -0
  64. package/src/layered/end-labels.ts +124 -0
  65. package/src/layered/external-port-dummy.ts +137 -0
  66. package/src/layered/feedback-chains.ts +114 -0
  67. package/src/layered/fixed-self-loop.ts +150 -0
  68. package/src/layered/greedy-switch-decider.ts +356 -0
  69. package/src/layered/grouped-compaction.ts +787 -0
  70. package/src/layered/gwt-sort.ts +53 -0
  71. package/src/layered/hierarchical-port-phases.ts +242 -0
  72. package/src/layered/hierarchical-port-restoration.ts +233 -0
  73. package/src/layered/hierarchical-port-routing.ts +179 -0
  74. package/src/layered/hierarchy-crossing.ts +121 -0
  75. package/src/layered/hierarchy-sweepiness.ts +83 -0
  76. package/src/layered/hints.ts +371 -0
  77. package/src/layered/hyperedge-dummy-merger.ts +195 -0
  78. package/src/layered/index.ts +886 -161
  79. package/src/layered/initial-model-order.ts +404 -0
  80. package/src/layered/inverted-ports.ts +207 -0
  81. package/src/layered/label-dummy-switcher.ts +200 -0
  82. package/src/layered/long-edges.ts +135 -44
  83. package/src/layered/loop-envelopes.ts +184 -0
  84. package/src/layered/native-routing.ts +275 -0
  85. package/src/layered/network-simplex.ts +11 -15
  86. package/src/layered/node-margins.ts +59 -0
  87. package/src/layered/north-south-ports.ts +271 -0
  88. package/src/layered/opposing-tracks.ts +944 -0
  89. package/src/layered/orthogonal-cycle-order.ts +106 -0
  90. package/src/layered/orthogonal-hypersegments.ts +92 -0
  91. package/src/layered/orthogonal-junctions.ts +202 -0
  92. package/src/layered/orthogonal-segments.ts +243 -0
  93. package/src/layered/placement-orientation.ts +22 -0
  94. package/src/layered/port-distributor.ts +192 -0
  95. package/src/layered/port-edge-order.ts +20 -0
  96. package/src/layered/replace-routing.ts +45 -0
  97. package/src/layered/routing-coordinates.ts +32 -0
  98. package/src/layered/separate-exterior-labels.ts +24 -6
  99. package/src/layered/spacing.ts +34 -12
  100. package/src/layered/splitter-order.ts +106 -0
  101. package/src/layered/strategies.ts +2024 -883
  102. package/src/layered/types.ts +13 -0
  103. package/src/layered/weighted-compaction.ts +84 -0
  104. package/src/routing/adapters.ts +32 -0
  105. package/src/routing/algorithms.ts +433 -21
  106. package/src/routing/coordination.ts +8 -3
  107. package/src/routing/index.ts +1 -0
  108. package/src/routing/search.ts +93 -11
  109. package/dist/elkjs-DaHMISnQ.mjs +0 -1677
  110. package/dist/elkjs-dMghiiWW.cjs +0 -1706
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statelyai/layout",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Extensible, graph-native layout algorithms for @statelyai/graph",
5
5
  "license": "EPL-2.0 OR GPL-3.0-or-later",
6
6
  "repository": {
@@ -107,6 +107,7 @@
107
107
  "scripts": {
108
108
  "build": "tsdown --config tsdown.config.mjs",
109
109
  "bench": "vitest bench --run",
110
+ "bench:statecharts": "tsx scripts/bench-statecharts.ts",
110
111
  "demo": "portless --name layout vite --config demo/vite.config.ts",
111
112
  "demo:build": "vite build --config demo/vite.config.ts",
112
113
  "demo:check": "tsx scripts/generate-demo-corpus.ts --check",
@@ -129,6 +130,11 @@
129
130
  "validate:package": "publint --pack npm && tsx scripts/smoke-package.ts",
130
131
  "storybook": "storybook dev -p 6018 --host 127.0.0.1 --no-open",
131
132
  "storybook:build": "storybook build -o dist-storybook",
132
- "storybook:typecheck": "tsc --project stories/tsconfig.json"
133
+ "storybook:typecheck": "tsc --project stories/tsconfig.json",
134
+ "test:parity:compound": "tsx scripts/check-compound-parity.ts",
135
+ "test:parity:self-loops": "tsx scripts/check-mixed-self-loop-parity.ts",
136
+ "test:parity:flat": "tsx scripts/check-flat-parity.ts",
137
+ "test:parity:model-order": "tsx scripts/check-model-order-parity.ts",
138
+ "test:parity:oracle": "vitest run --config vitest.parity.config.ts"
133
139
  }
134
140
  }
@@ -72,7 +72,10 @@ export function worldGeometry<N, E, G, P>(graph: VisualGraph<N, E, G, P>): Visua
72
72
  };
73
73
  }
74
74
 
75
- /** Ancestor frames stay fixed during authoring. Preserve untouched coordinates exactly. */
75
+ /**
76
+ * Convert solved world geometry back to parent-relative coordinates, using
77
+ * each parent's solved position. Untouched coordinates stay exactly as authored.
78
+ */
76
79
  export function nativeGeometry<N, E, G, P>(
77
80
  graph: VisualGraph<N, E, G, P>,
78
81
  original: VisualGraph<N, E, G, P>,
@@ -83,30 +86,47 @@ export function nativeGeometry<N, E, G, P>(
83
86
  const originalEdges = new Map(original.edges.map((e) => [e.id, e]));
84
87
  const worldNodes = new Map(world.nodes.map((n) => [n.id, n]));
85
88
  const worldEdges = new Map(world.edges.map((e) => [e.id, e]));
89
+ const solved = new Map(graph.nodes.map((n) => [n.id, n]));
90
+ const frame = (id: string): Point => {
91
+ const parentId = originalNodes.get(id)?.parentId;
92
+ const parent = parentId == null ? undefined : solved.get(parentId);
93
+ return parent ? { x: parent.x, y: parent.y } : { x: 0, y: 0 };
94
+ };
95
+ const same = (a: Point, b: Point) => a.x === b.x && a.y === b.y;
86
96
  return {
87
97
  ...graph,
88
98
  nodes: graph.nodes.map((n) => {
89
- const p = offsets.nodes.get(n.id)!,
99
+ const p = frame(n.id),
90
100
  before = originalNodes.get(n.id)!,
91
101
  prior = worldNodes.get(n.id)!;
102
+ const kept = same(p, offsets.nodes.get(n.id)!);
92
103
  return {
93
104
  ...n,
94
- x: n.x === prior.x ? before.x : n.x - p.x,
95
- y: n.y === prior.y ? before.y : n.y - p.y,
105
+ x: kept && n.x === prior.x ? before.x : n.x - p.x,
106
+ y: kept && n.y === prior.y ? before.y : n.y - p.y,
96
107
  };
97
108
  }),
98
109
  edges: graph.edges.map((e) => {
99
- const p = offsets.edges.get(e.id)!,
110
+ const old = offsets.edges.get(e.id)!,
100
111
  before = originalEdges.get(e.id)!,
101
112
  prior = worldEdges.get(e.id)!;
113
+ const source = originalNodes.get(e.sourceId)!,
114
+ target = originalNodes.get(e.targetId)!;
115
+ // Same rule as frames(): sibling edges in legacy space use their parent's frame.
116
+ const p =
117
+ (original as VisualGraph & { edgeCoordinateSpace?: string }).edgeCoordinateSpace !==
118
+ "world" && (source.parentId ?? null) === (target.parentId ?? null)
119
+ ? frame(source.id)
120
+ : { x: 0, y: 0 };
121
+ const kept = same(p, old);
102
122
  const points =
103
- JSON.stringify(e.points) === JSON.stringify(prior.points)
123
+ kept && JSON.stringify(e.points) === JSON.stringify(prior.points)
104
124
  ? before.points
105
125
  : e.points?.map((v) => ({ x: v.x - p.x, y: v.y - p.y }));
106
126
  return {
107
127
  ...e,
108
- x: e.x === prior.x ? before.x : e.x - p.x,
109
- y: e.y === prior.y ? before.y : e.y - p.y,
128
+ x: kept && e.x === prior.x ? before.x : e.x - p.x,
129
+ y: kept && e.y === prior.y ? before.y : e.y - p.y,
110
130
  ...(points === undefined ? {} : { points }),
111
131
  };
112
132
  }),
@@ -92,7 +92,9 @@ export function runPartialLayout<N, E, G, P>(
92
92
  throw new LayoutError(`Unknown selected entity: ${id}`, "INVALID_SELECTION");
93
93
  }
94
94
  const containers = new Set(input.nodes.flatMap((n) => (n.parentId == null ? [] : [n.parentId])));
95
- if ([...selectedNodes].some((id) => containers.has(id)))
95
+ // Only the full-layout constraint phase may move containers: the solver
96
+ // keeps their children inside and resizes them to fit.
97
+ if (phase === "selection" && [...selectedNodes].some((id) => containers.has(id)))
96
98
  throw new UnsupportedLayoutError(
97
99
  "Authoring layout cannot move containers; select leaf nodes or use full layout without geometry constraints",
98
100
  );
@@ -288,7 +290,10 @@ export function runPartialLayout<N, E, G, P>(
288
290
  geometry === "routes" ? new Set() : selectedEdges,
289
291
  context.diagnostics,
290
292
  );
291
- const moved = graph.nodes.filter((n) => !samePosition(n, byId.get(n.id)!));
293
+ const moved = graph.nodes.filter((n) => {
294
+ const before = byId.get(n.id)!;
295
+ return !samePosition(n, before) || n.width !== before.width || n.height !== before.height;
296
+ });
292
297
  const movedIds = new Set(moved.map((n) => n.id));
293
298
  const resultNodes = new Map(graph.nodes.map((n) => [n.id, n]));
294
299
  const automatic = new Set<string>();
@@ -24,7 +24,10 @@ export function crossesRect(a: Point, b: Point, rect: EntityRect): boolean {
24
24
  const min = rect[axis],
25
25
  max = min + (axis === "x" ? rect.width : rect.height);
26
26
  if (Math.abs(delta) < 1e-10) {
27
- if (a[axis] <= min || a[axis] >= max) return false;
27
+ // Scope transforms can put a boundary point a few ulps inside a
28
+ // rectangle. Match the existing intersection tolerance for parallel
29
+ // segments so legal boundary travel is not reported as penetration.
30
+ if (a[axis] <= min + 1e-10 || a[axis] >= max - 1e-10) return false;
28
31
  } else {
29
32
  const t1 = (min - a[axis]) / delta,
30
33
  t2 = (max - a[axis]) / delta;
@@ -85,6 +85,14 @@ export function solveGeometryConstraints<N, E, G, P>(
85
85
  const variables = new Map<string, { x: Variable; y: Variable }>();
86
86
  const nodes = new Map(graph.nodes.map((n) => [n.id, n]));
87
87
  const edges = new Map(graph.edges.map((e) => [e.id, e]));
88
+ // Movable containers move and resize with their content. Each one keeps its
89
+ // position most strongly, then its children keep their offsets, then the
90
+ // children keep their own positions; children always stay inside.
91
+ const childrenByParent = new Map<string, (typeof graph.nodes)[number][]>();
92
+ for (const node of graph.nodes)
93
+ if (node.parentId != null && nodes.has(node.parentId) && movableNodes.has(node.parentId))
94
+ childrenByParent.set(node.parentId, [...(childrenByParent.get(node.parentId) ?? []), node]);
95
+ const extents = new Map<string, { right: Variable; bottom: Variable }>();
88
96
  function geometry(ref: GeometryReference) {
89
97
  const node = "nodeId" in ref;
90
98
  const id = node ? ref.nodeId : ref.edgeId;
@@ -95,21 +103,90 @@ export function solveGeometryConstraints<N, E, G, P>(
95
103
  if (movable && !variables.has(key)) {
96
104
  const pair = { x: new Variable(`${key}.x`), y: new Variable(`${key}.y`) };
97
105
  variables.set(key, pair);
98
- solver.addConstraint(new Constraint(pair.x, Operator.Eq, rect.x, Strength.weak / 10));
99
- solver.addConstraint(new Constraint(pair.y, Operator.Eq, rect.y, Strength.weak / 10));
106
+ const parentId = node ? nodes.get(id)?.parentId : undefined;
107
+ const stay =
108
+ parentId != null && childrenByParent.has(parentId)
109
+ ? Strength.weak / 100
110
+ : Strength.weak / 10;
111
+ solver.addConstraint(new Constraint(pair.x, Operator.Eq, rect.x, stay));
112
+ solver.addConstraint(new Constraint(pair.y, Operator.Eq, rect.y, stay));
113
+ if (node && childrenByParent.has(id)) {
114
+ const extent = {
115
+ right: new Variable(`${key}.right`),
116
+ bottom: new Variable(`${key}.bottom`),
117
+ };
118
+ extents.set(id, extent);
119
+ solver.addConstraint(
120
+ new Constraint(extent.right, Operator.Eq, rect.x + rect.width, Strength.weak / 10),
121
+ );
122
+ solver.addConstraint(
123
+ new Constraint(extent.bottom, Operator.Eq, rect.y + rect.height, Strength.weak / 10),
124
+ );
125
+ }
100
126
  }
101
- return { rect, variables: variables.get(key) };
127
+ return { rect, variables: variables.get(key), extent: node ? extents.get(id) : undefined };
102
128
  }
103
129
  function expression(ref: GeometryReference, attribute: GeometryAttribute): Expression {
104
- const { rect, variables: pair } = geometry(ref);
130
+ const { rect, variables: pair, extent: far } = geometry(ref);
105
131
  const axis = attribute === "y" || attribute === "centerY" || attribute === "bottom" ? "y" : "x";
132
+ const start = new Expression(pair?.[axis] ?? rect[axis]);
133
+ if (far && attribute !== "x" && attribute !== "y") {
134
+ const end = new Expression(axis === "x" ? far.right : far.bottom);
135
+ return attribute.startsWith("center") ? start.plus(end).divide(2) : end;
136
+ }
106
137
  const extent = axis === "x" ? rect.width : rect.height;
107
138
  const offset = attribute.startsWith("center")
108
139
  ? extent / 2
109
140
  : attribute === "right" || attribute === "bottom"
110
141
  ? extent
111
142
  : 0;
112
- return new Expression(pair?.[axis] ?? rect[axis], offset);
143
+ return start.plus(offset);
144
+ }
145
+ for (const [parentId, children] of childrenByParent) {
146
+ const parent = geometry({ nodeId: parentId });
147
+ const container = parent.rect;
148
+ // Keep the original padding, header included.
149
+ const pad = {
150
+ left: Math.min(...children.map((child) => child.x - container.x)),
151
+ top: Math.min(...children.map((child) => child.y - container.y)),
152
+ right: Math.min(
153
+ ...children.map((child) => container.x + container.width - child.x - child.width),
154
+ ),
155
+ bottom: Math.min(
156
+ ...children.map((child) => container.y + container.height - child.y - child.height),
157
+ ),
158
+ };
159
+ for (const child of children) {
160
+ const own = geometry({ nodeId: child.id });
161
+ const x = new Expression(own.variables?.x ?? child.x),
162
+ y = new Expression(own.variables?.y ?? child.y);
163
+ const px = new Expression(parent.variables!.x),
164
+ py = new Expression(parent.variables!.y);
165
+ solver.addConstraint(
166
+ new Constraint(x.minus(px), Operator.Eq, child.x - container.x, Strength.weak / 20),
167
+ );
168
+ solver.addConstraint(
169
+ new Constraint(y.minus(py), Operator.Eq, child.y - container.y, Strength.weak / 20),
170
+ );
171
+ const right = expression({ nodeId: child.id }, "right"),
172
+ bottom = expression({ nodeId: child.id }, "bottom");
173
+ solver.addConstraint(new Constraint(x.minus(px), Operator.Ge, Math.max(0, pad.left)));
174
+ solver.addConstraint(new Constraint(y.minus(py), Operator.Ge, Math.max(0, pad.top)));
175
+ solver.addConstraint(
176
+ new Constraint(
177
+ new Expression(parent.extent!.right).minus(right),
178
+ Operator.Ge,
179
+ Math.max(0, pad.right),
180
+ ),
181
+ );
182
+ solver.addConstraint(
183
+ new Constraint(
184
+ new Expression(parent.extent!.bottom).minus(bottom),
185
+ Operator.Ge,
186
+ Math.max(0, pad.bottom),
187
+ ),
188
+ );
189
+ }
113
190
  }
114
191
  const checks: { constraint: LayoutConstraint; expression: Expression; operator: Operator }[] = [];
115
192
  const ids = new Set<string>();
@@ -180,6 +257,85 @@ export function solveGeometryConstraints<N, E, G, P>(
180
257
  }
181
258
  }
182
259
  solver.updateVariables();
260
+ // Overlap is never traded for a constraint. Separate siblings that now
261
+ // overlap, on the axis that separated them before, until none do; a
262
+ // required constraint that needs the overlap fails instead.
263
+ const box = (node: (typeof graph.nodes)[number]) => {
264
+ const pair = variables.get(JSON.stringify(["node", node.id]));
265
+ const extent = extents.get(node.id);
266
+ const x = pair?.x.value() ?? node.x,
267
+ y = pair?.y.value() ?? node.y;
268
+ return {
269
+ x,
270
+ y,
271
+ width: extent ? extent.right.value() - x : node.width,
272
+ height: extent ? extent.bottom.value() - y : node.height,
273
+ };
274
+ };
275
+ const siblingGroups = new Map<string | null, (typeof graph.nodes)[number][]>();
276
+ for (const node of graph.nodes)
277
+ siblingGroups.set(node.parentId ?? null, [
278
+ ...(siblingGroups.get(node.parentId ?? null) ?? []),
279
+ node,
280
+ ]);
281
+ const separated = new Set<string>();
282
+ for (let round = 0; round < 8; round++) {
283
+ let added = false;
284
+ for (const group of siblingGroups.values())
285
+ for (const [index, first] of group.entries())
286
+ for (const second of group.slice(index + 1)) {
287
+ if (!movableNodes.has(first.id) && !movableNodes.has(second.id)) continue;
288
+ const a = box(first),
289
+ b = box(second);
290
+ if (
291
+ a.x >= b.x + b.width ||
292
+ b.x >= a.x + a.width ||
293
+ a.y >= b.y + b.height ||
294
+ b.y >= a.y + a.height
295
+ )
296
+ continue;
297
+ const key = JSON.stringify([first.id, second.id]);
298
+ if (separated.has(key)) continue;
299
+ separated.add(key);
300
+ // Keep the original order on the axis with the larger original gap.
301
+ const gapX = Math.max(
302
+ first.x - second.x - second.width,
303
+ second.x - first.x - first.width,
304
+ );
305
+ const gapY = Math.max(
306
+ first.y - second.y - second.height,
307
+ second.y - first.y - first.height,
308
+ );
309
+ const axis = gapX >= gapY ? "x" : "y";
310
+ const firstLeads =
311
+ axis === "x" ? first.x + first.width <= second.x : first.y + first.height <= second.y;
312
+ const [lead, trail] = firstLeads ? [first, second] : [second, first];
313
+ const end = axis === "x" ? "right" : "bottom";
314
+ const gap = Math.max(0, Math.min(10, Math.max(gapX, gapY)));
315
+ try {
316
+ solver.addConstraint(
317
+ new Constraint(
318
+ expression({ nodeId: trail.id }, axis).minus(expression({ nodeId: lead.id }, end)),
319
+ Operator.Ge,
320
+ gap,
321
+ ),
322
+ );
323
+ } catch {
324
+ const blocking = constraints.find(
325
+ (item) => (item.strength ?? "required") === "required",
326
+ );
327
+ if (blocking)
328
+ constraintFailure(blocking, `would make ${first.id} overlap ${second.id}`);
329
+ throw new LayoutError(
330
+ `Cannot separate ${first.id} from ${second.id}`,
331
+ "UNSATISFIED_CONSTRAINT",
332
+ );
333
+ }
334
+ added = true;
335
+ }
336
+ if (!added) break;
337
+ solver.updateVariables();
338
+ }
183
339
  const warned = new Set<string>();
184
340
  for (const check of checks) {
185
341
  const residual = check.expression.value();
@@ -212,7 +368,19 @@ export function solveGeometryConstraints<N, E, G, P>(
212
368
  ...graph,
213
369
  nodes: graph.nodes.map((node) => {
214
370
  const pair = variables.get(JSON.stringify(["node", node.id]));
215
- return pair ? { ...node, x: coordinate(pair.x), y: coordinate(pair.y) } : node;
371
+ if (!pair) return node;
372
+ const x = coordinate(pair.x),
373
+ y = coordinate(pair.y);
374
+ const extent = extents.get(node.id);
375
+ return extent
376
+ ? {
377
+ ...node,
378
+ x,
379
+ y,
380
+ width: coordinate(extent.right) - x,
381
+ height: coordinate(extent.bottom) - y,
382
+ }
383
+ : { ...node, x, y };
216
384
  }),
217
385
  edges: graph.edges.map((edge) => {
218
386
  const pair = variables.get(JSON.stringify(["label", edge.id]));