@graph-ir/mcp-server 0.2.0 → 0.2.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.
@@ -1,28 +1,21 @@
1
- /**
2
- * Graph-IR Layout Tool
3
- *
4
- * Computes graph layout using ELK algorithms.
5
- */
6
1
  import ELKConstructor from 'elkjs/lib/elk.bundled.js';
7
- // Initialize ELK instance (handle ESM default export)
8
- const ELKClass = ELKConstructor.default ?? ELKConstructor;
9
- // @ts-expect-error - ELK ESM/CJS interop
2
+ import { validateIR, } from '@graph-ir/core';
3
+ const ELKClass = ELKConstructor.default ??
4
+ ELKConstructor;
5
+ // @ts-expect-error elkjs exposes a CJS-shaped constructor to ESM.
10
6
  const elk = new ELKClass();
11
- // ============================================================================
12
- // Algorithm Mapping
13
- // ============================================================================
14
7
  const ALGORITHM_MAPPING = {
15
8
  layered: 'org.eclipse.elk.layered',
16
9
  force: 'org.eclipse.elk.force',
10
+ tree: 'org.eclipse.elk.mrtree',
17
11
  mrtree: 'org.eclipse.elk.mrtree',
18
12
  box: 'org.eclipse.elk.box',
19
13
  stress: 'org.eclipse.elk.stress',
20
14
  };
21
- const DIRECTION_MAPPING = {
22
- RIGHT: 'RIGHT',
23
- DOWN: 'DOWN',
24
- LEFT: 'LEFT',
25
- UP: 'UP',
15
+ const EDGE_ROUTING_MAPPING = {
16
+ orthogonal: 'ORTHOGONAL',
17
+ polyline: 'POLYLINE',
18
+ spline: 'SPLINES',
26
19
  };
27
20
  const DEFAULT_OPTIONS = {
28
21
  nodeWidth: 150,
@@ -31,232 +24,643 @@ const DEFAULT_OPTIONS = {
31
24
  verticalSpacing: 50,
32
25
  direction: 'RIGHT',
33
26
  };
34
- // ============================================================================
35
- // Main Layout Function
36
- // ============================================================================
37
- /**
38
- * Compute layout for Graph-IR using ELK
39
- */
40
- export async function layoutIR(irJson, algorithm = 'layered', options = {}) {
41
- // Parse JSON
27
+ const REQUEST_OPTION_KEYS = new Set([
28
+ 'nodeWidth',
29
+ 'nodeHeight',
30
+ 'horizontalSpacing',
31
+ 'verticalSpacing',
32
+ 'direction',
33
+ ]);
34
+ const REQUEST_DIRECTIONS = new Set(['RIGHT', 'DOWN', 'LEFT', 'UP']);
35
+ const ELK_ROOT_ID = '__graph_ir_internal_root__';
36
+ export async function layoutIR(irJson, requestedAlgorithm, options = {}) {
42
37
  let ir;
43
38
  try {
44
39
  ir = JSON.parse(irJson);
45
40
  }
46
- catch (e) {
47
- return {
48
- success: false,
49
- errors: [`Invalid JSON: ${e instanceof Error ? e.message : 'Parse error'}`],
50
- algorithm,
51
- };
41
+ catch (error) {
42
+ return failed(requestedAlgorithm ?? 'layered', [{
43
+ code: 'INVALID_JSON',
44
+ path: '',
45
+ message: `Invalid JSON: ${error instanceof Error ? error.message : 'Parse error'}`,
46
+ }]);
52
47
  }
53
- // Validate structure
54
- if (!ir.nodes || !Array.isArray(ir.nodes)) {
55
- return {
56
- success: false,
57
- errors: ['Invalid Graph-IR structure: missing nodes array'],
58
- algorithm,
59
- };
48
+ const validation = validateIR(ir);
49
+ if (!validation.valid) {
50
+ return failed(requestedAlgorithm ?? 'layered', validation.errors.map(({ code, path, message }) => ({ code, path, message })));
60
51
  }
61
- if (!ir.edges || !Array.isArray(ir.edges)) {
62
- return {
63
- success: false,
64
- errors: ['Invalid Graph-IR structure: missing edges array'],
65
- algorithm,
66
- };
52
+ const graph = ir;
53
+ const requestErrors = validateRequestOptions(options);
54
+ if (requestErrors.length > 0) {
55
+ return failed(requestedAlgorithm ?? graph.layoutOptions?.algorithm ?? 'layered', requestErrors);
67
56
  }
68
- const layoutOptions = {
69
- ...DEFAULT_OPTIONS,
70
- ...options,
71
- };
57
+ const conflict = optionConflicts(graph, requestedAlgorithm, options);
58
+ if (conflict.length > 0) {
59
+ return failed(requestedAlgorithm ?? graph.layoutOptions?.algorithm ?? 'layered', conflict);
60
+ }
61
+ const algorithm = requestedAlgorithm ?? graph.layoutOptions?.algorithm ?? 'layered';
62
+ const capabilityErrors = unsupportedCapabilities(graph, algorithm, options);
63
+ if (capabilityErrors.length > 0)
64
+ return failed(algorithm, capabilityErrors);
65
+ const requestOptions = normalizeOptions(graph, options);
72
66
  try {
73
- // Convert to ELK format
74
- const elkGraph = graphIRToELK(ir, layoutOptions, algorithm);
75
- // Run ELK layout
76
- const layoutedElk = await elk.layout(elkGraph);
77
- // Convert back to Graph-IR
78
- const layoutedIR = elkToGraphIR(layoutedElk, ir, algorithm);
67
+ const elkGraph = graphIRToELK(graph, requestOptions, algorithm);
68
+ const resolvedElk = await elk.layout(elkGraph);
69
+ const verification = verifyResolvedLayout(graph, resolvedElk);
70
+ if (verification.length > 0)
71
+ return failed(algorithm, verification);
79
72
  return {
80
73
  success: true,
81
- ir: layoutedIR,
74
+ resolved: elkToResolvedLayout(resolvedElk, graph, algorithm),
82
75
  errors: [],
83
76
  algorithm,
84
77
  };
85
78
  }
86
- catch (e) {
87
- return {
88
- success: false,
89
- errors: [`Layout failed: ${e instanceof Error ? e.message : 'Unknown error'}`],
90
- algorithm,
91
- };
79
+ catch (error) {
80
+ return failed(algorithm, [{
81
+ code: 'LAYOUT_FAILED',
82
+ path: '',
83
+ message: `Layout failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
84
+ }]);
85
+ }
86
+ }
87
+ export async function layoutIRFromToolArguments(args) {
88
+ if (!args || typeof args !== 'object' || Array.isArray(args)) {
89
+ return failed('layered', [{
90
+ code: 'INVALID_LAYOUT_REQUEST',
91
+ path: '',
92
+ message: 'Tool arguments must be an object',
93
+ }]);
94
+ }
95
+ const record = args;
96
+ const errors = [];
97
+ for (const key of Object.keys(record)) {
98
+ if (!['ir', 'algorithm', 'options'].includes(key)) {
99
+ errors.push({
100
+ code: 'INVALID_LAYOUT_REQUEST',
101
+ path: `/${key}`,
102
+ message: `Unknown layout tool argument "${key}"`,
103
+ });
104
+ }
105
+ }
106
+ if (typeof record.ir !== 'string') {
107
+ errors.push({
108
+ code: 'INVALID_LAYOUT_REQUEST',
109
+ path: '/ir',
110
+ message: 'ir must be a JSON string',
111
+ });
112
+ }
113
+ if (record.algorithm !== undefined && typeof record.algorithm !== 'string') {
114
+ errors.push({
115
+ code: 'INVALID_LAYOUT_REQUEST',
116
+ path: '/algorithm',
117
+ message: 'algorithm must be a string',
118
+ });
119
+ }
120
+ errors.push(...validateRequestOptions(record.options ?? {}));
121
+ if (errors.length > 0) {
122
+ return failed(typeof record.algorithm === 'string' ? record.algorithm : 'layered', errors);
123
+ }
124
+ return layoutIR(record.ir, record.algorithm, record.options);
125
+ }
126
+ function validateRequestOptions(value) {
127
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
128
+ return [{
129
+ code: 'INVALID_LAYOUT_REQUEST',
130
+ path: '/options',
131
+ message: 'options must be an object',
132
+ }];
133
+ }
134
+ const options = value;
135
+ const errors = [];
136
+ for (const [key, option] of Object.entries(options)) {
137
+ const path = `/options/${key}`;
138
+ if (!REQUEST_OPTION_KEYS.has(key)) {
139
+ errors.push({ code: 'INVALID_LAYOUT_REQUEST', path, message: `Unknown layout option "${key}"` });
140
+ }
141
+ else if (key === 'direction') {
142
+ if (typeof option !== 'string' || !REQUEST_DIRECTIONS.has(option)) {
143
+ errors.push({ code: 'INVALID_LAYOUT_REQUEST', path, message: 'Invalid layout direction' });
144
+ }
145
+ }
146
+ else if (typeof option !== 'number' || !Number.isFinite(option) || option < 0) {
147
+ errors.push({ code: 'INVALID_LAYOUT_REQUEST', path, message: 'Layout size and spacing must be finite and nonnegative' });
148
+ }
92
149
  }
150
+ return errors;
151
+ }
152
+ function optionConflicts(graph, requestedAlgorithm, rawOptions) {
153
+ const errors = [];
154
+ if (requestedAlgorithm &&
155
+ graph.layoutOptions?.algorithm &&
156
+ requestedAlgorithm !== graph.layoutOptions.algorithm) {
157
+ errors.push({
158
+ code: 'LAYOUT_OPTION_CONFLICT',
159
+ path: '/layoutOptions/algorithm',
160
+ message: 'Transient algorithm conflicts with authored layout algorithm',
161
+ });
162
+ }
163
+ if (typeof rawOptions.direction === 'string' &&
164
+ graph.layoutOptions?.direction &&
165
+ rawOptions.direction !== graph.layoutOptions.direction) {
166
+ errors.push({
167
+ code: 'LAYOUT_OPTION_CONFLICT',
168
+ path: '/layoutOptions/direction',
169
+ message: 'Transient direction conflicts with authored layout direction',
170
+ });
171
+ }
172
+ const authoredSpacing = graph.layoutOptions?.spacing;
173
+ const spacingConflicts = [
174
+ ['horizontalSpacing', 'nodeNode'],
175
+ ['verticalSpacing', 'layerSpacing'],
176
+ ];
177
+ for (const [requestKey, authoredKey] of spacingConflicts) {
178
+ const requested = rawOptions[requestKey];
179
+ const authored = authoredSpacing?.[authoredKey];
180
+ if (typeof requested === 'number' && authored !== undefined && requested !== authored) {
181
+ errors.push({
182
+ code: 'LAYOUT_OPTION_CONFLICT',
183
+ path: `/layoutOptions/spacing/${authoredKey}`,
184
+ message: `Transient ${requestKey} conflicts with authored ${authoredKey}`,
185
+ });
186
+ }
187
+ }
188
+ return errors;
189
+ }
190
+ function unsupportedCapabilities(graph, algorithm, rawOptions) {
191
+ const errors = [];
192
+ const unsupported = (path, message) => errors.push({
193
+ code: 'UNSUPPORTED_LAYOUT_CAPABILITY',
194
+ path,
195
+ message,
196
+ });
197
+ if (!(algorithm in ALGORITHM_MAPPING)) {
198
+ unsupported('/layoutOptions/algorithm', `Algorithm "${algorithm}" is not supported`);
199
+ }
200
+ const layered = algorithm === 'layered';
201
+ const directional = layered || algorithm === 'tree' || algorithm === 'mrtree';
202
+ if (!directional && (graph.layoutOptions?.direction || rawOptions.direction !== undefined)) {
203
+ unsupported(graph.layoutOptions?.direction ? '/layoutOptions/direction' : '/options/direction', `Algorithm "${algorithm}" does not honor direction`);
204
+ }
205
+ if (!layered && graph.layoutOptions?.spacing) {
206
+ unsupported('/layoutOptions/spacing', `Algorithm "${algorithm}" does not honor portable spacing`);
207
+ }
208
+ if (!layered && (rawOptions.horizontalSpacing !== undefined || rawOptions.verticalSpacing !== undefined)) {
209
+ unsupported('/options', `Algorithm "${algorithm}" does not honor transient spacing`);
210
+ }
211
+ if (!layered && graph.layoutOptions?.edgeRouting) {
212
+ unsupported('/layoutOptions/edgeRouting', `Algorithm "${algorithm}" does not honor graph edge routing`);
213
+ }
214
+ if (graph.layoutOptions?.edgeRouting === 'spline') {
215
+ unsupported('/layoutOptions/edgeRouting', 'Spline control-point semantics are not represented by ResolvedLayout');
216
+ }
217
+ if (algorithm === 'box' && graph.edges.length > 0) {
218
+ unsupported('/edges', 'Algorithm "box" does not route authored edges');
219
+ }
220
+ if (!layered) {
221
+ graph.edges.forEach((edge, index) => {
222
+ if (edge.source === edge.target) {
223
+ unsupported(`/edges/${index}`, `Algorithm "${algorithm}" does not produce a verified self-loop route`);
224
+ }
225
+ });
226
+ }
227
+ if (!layered && hasNestedNodes(graph.nodes)) {
228
+ unsupported('/nodes', `Algorithm "${algorithm}" does not support verified compound layout`);
229
+ }
230
+ if (graph.constraints?.length)
231
+ unsupported('/constraints', 'Spatial constraints require a solver');
232
+ if (graph.profile)
233
+ unsupported('/profile', 'Notation profiles require a profile-aware layout adapter');
234
+ if (graph.interaction)
235
+ unsupported('/interaction', 'Sequence interactions require a sequence-aware layout adapter');
236
+ if (graph.layoutOptions?.hierarchyHandling === 'SEPARATE_CHILDREN') {
237
+ unsupported('/layoutOptions/hierarchyHandling', 'SEPARATE_CHILDREN is not supported');
238
+ }
239
+ visitNodes(graph.nodes, '/nodes', (node, path) => {
240
+ if (node.labelPlacement)
241
+ unsupported(`${path}/labelPlacement`, 'Node label placement is not supported');
242
+ if (node.classifier)
243
+ unsupported(`${path}/classifier`, 'Classifier semantics require a class-aware layout adapter');
244
+ if (node.position)
245
+ unsupported(`${path}/position`, 'Exact authored positions are not supported');
246
+ if (node.pinned)
247
+ unsupported(`${path}/pinned`, 'Pinned positions are not supported');
248
+ if (node.children?.length && node.dimensions) {
249
+ unsupported(`${path}/dimensions`, 'Fixed outer dimensions for containers are not supported');
250
+ }
251
+ const layout = node.layoutOptions;
252
+ if (!layered && node.ports?.length)
253
+ unsupported(`${path}/ports`, `Algorithm "${algorithm}" does not preserve authored port sides`);
254
+ if (layout?.portAlignment)
255
+ unsupported(`${path}/layoutOptions/portAlignment`, 'Port alignment is not supported');
256
+ if (layout?.minimumSize)
257
+ unsupported(`${path}/layoutOptions/minimumSize`, 'Minimum size is not supported');
258
+ if (layout?.sizeConstraints?.length)
259
+ unsupported(`${path}/layoutOptions/sizeConstraints`, 'Size constraints are not supported');
260
+ if (node.ports?.length && layout?.portConstraints === 'FREE') {
261
+ unsupported(`${path}/layoutOptions/portConstraints`, 'FREE would ignore authored port sides');
262
+ }
263
+ if (layout?.portConstraints === 'FIXED_ORDER' || layout?.portConstraints === 'FIXED_POS') {
264
+ unsupported(`${path}/layoutOptions/portConstraints`, `${layout.portConstraints} is not supported`);
265
+ }
266
+ node.ports?.forEach((port, index) => {
267
+ if (port.position !== undefined) {
268
+ unsupported(`${path}/ports/${index}/position`, 'Exact port position is not supported');
269
+ }
270
+ });
271
+ });
272
+ graph.edges.forEach((edge, index) => {
273
+ if (edge.routeIntent)
274
+ unsupported(`/edges/${index}/routeIntent`, 'Authored route intent is not supported');
275
+ if (edge.labelPlacement)
276
+ unsupported(`/edges/${index}/labelPlacement`, 'Edge label placement is not supported');
277
+ if (edge.style?.routing)
278
+ unsupported(`/edges/${index}/style/routing`, 'Per-edge routing is not supported');
279
+ if (edge.sourceEnd)
280
+ unsupported(`/edges/${index}/sourceEnd`, 'Relationship-end semantics are not supported');
281
+ if (edge.targetEnd)
282
+ unsupported(`/edges/${index}/targetEnd`, 'Relationship-end semantics are not supported');
283
+ });
284
+ return errors;
285
+ }
286
+ function normalizeOptions(graph, raw) {
287
+ const authoredSpacing = graph.layoutOptions?.spacing;
288
+ return {
289
+ nodeWidth: numberOption(raw.nodeWidth, DEFAULT_OPTIONS.nodeWidth),
290
+ nodeHeight: numberOption(raw.nodeHeight, DEFAULT_OPTIONS.nodeHeight),
291
+ horizontalSpacing: numberOption(raw.horizontalSpacing, authoredSpacing?.nodeNode ?? DEFAULT_OPTIONS.horizontalSpacing),
292
+ verticalSpacing: numberOption(raw.verticalSpacing, authoredSpacing?.layerSpacing ?? DEFAULT_OPTIONS.verticalSpacing),
293
+ direction: (typeof raw.direction === 'string'
294
+ ? raw.direction
295
+ : graph.layoutOptions?.direction ?? DEFAULT_OPTIONS.direction),
296
+ };
297
+ }
298
+ function numberOption(value, fallback) {
299
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
93
300
  }
94
- // ============================================================================
95
- // Conversion Functions
96
- // ============================================================================
97
- /**
98
- * Convert Graph-IR to ELK graph format
99
- */
100
301
  export function graphIRToELK(ir, options, algorithm = 'layered') {
101
- const opts = { ...DEFAULT_OPTIONS, ...options };
102
- // Build root ELK graph
302
+ const identifiers = buildElkIdentifiers(ir);
303
+ const authoredSpacing = ir.layoutOptions?.spacing;
304
+ const normalized = {
305
+ ...DEFAULT_OPTIONS,
306
+ ...options,
307
+ horizontalSpacing: options.horizontalSpacing ??
308
+ authoredSpacing?.nodeNode ?? DEFAULT_OPTIONS.horizontalSpacing,
309
+ verticalSpacing: options.verticalSpacing ??
310
+ authoredSpacing?.layerSpacing ?? DEFAULT_OPTIONS.verticalSpacing,
311
+ direction: options.direction ?? ir.layoutOptions?.direction ?? DEFAULT_OPTIONS.direction,
312
+ };
103
313
  const elkLayoutOptions = {
104
- 'elk.algorithm': ALGORITHM_MAPPING[algorithm] || ALGORITHM_MAPPING.layered,
105
- 'elk.direction': DIRECTION_MAPPING[opts.direction] || 'RIGHT',
106
- 'elk.spacing.nodeNode': String(opts.horizontalSpacing),
107
- 'elk.layered.spacing.nodeNodeBetweenLayers': String(opts.verticalSpacing),
314
+ 'elk.algorithm': ALGORITHM_MAPPING[algorithm] ?? ALGORITHM_MAPPING.layered,
315
+ 'elk.direction': normalized.direction,
316
+ 'elk.spacing.nodeNode': String(normalized.horizontalSpacing),
317
+ 'elk.layered.spacing.nodeNodeBetweenLayers': String(normalized.verticalSpacing),
318
+ 'elk.hierarchyHandling': 'INCLUDE_CHILDREN',
108
319
  };
109
- const elkGraph = {
110
- id: 'root',
320
+ if (authoredSpacing?.nodeEdge !== undefined) {
321
+ elkLayoutOptions['elk.spacing.edgeNode'] = String(authoredSpacing.nodeEdge);
322
+ }
323
+ if (authoredSpacing?.edgeEdge !== undefined) {
324
+ elkLayoutOptions['elk.spacing.edgeEdge'] = String(authoredSpacing.edgeEdge);
325
+ }
326
+ if (ir.layoutOptions?.edgeRouting) {
327
+ elkLayoutOptions['elk.edgeRouting'] = EDGE_ROUTING_MAPPING[ir.layoutOptions.edgeRouting];
328
+ }
329
+ const root = {
330
+ id: ELK_ROOT_ID,
111
331
  layoutOptions: elkLayoutOptions,
112
- children: ir.nodes.map((node) => convertNodeToELK(node, opts)),
113
- edges: ir.edges.map((edge) => convertEdgeToELK(edge)),
332
+ children: ir.nodes.map((node) => nodeToELK(node, normalized, identifiers)),
333
+ edges: [],
114
334
  };
115
- return elkGraph;
335
+ distributeEdges(ir, root, identifiers);
336
+ return root;
116
337
  }
117
- /**
118
- * Convert a Graph-IR node to ELK format
119
- */
120
- function convertNodeToELK(node, opts) {
121
- const elkNode = {
122
- id: node.id,
123
- width: opts.nodeWidth,
124
- height: opts.nodeHeight,
125
- labels: node.label ? [{ text: node.label }] : undefined,
338
+ function buildElkIdentifiers(ir) {
339
+ const identifiers = {
340
+ nodes: new Map(),
341
+ ports: new Map(),
342
+ edges: new Map(),
126
343
  };
127
- // Convert ports
128
- if (node.ports && node.ports.length > 0) {
129
- elkNode.ports = node.ports.map((port) => convertPortToELK(port));
344
+ let nodeIndex = 0;
345
+ let portIndex = 0;
346
+ visitNodes(ir.nodes, '/nodes', (node) => {
347
+ identifiers.nodes.set(node.id, `n${nodeIndex++}`);
348
+ for (const port of node.ports ?? []) {
349
+ identifiers.ports.set(portKey(node.id, port.id), `p${portIndex++}`);
350
+ }
351
+ });
352
+ ir.edges.forEach((edge, index) => identifiers.edges.set(edge.id, `e${index}`));
353
+ return identifiers;
354
+ }
355
+ function distributeEdges(ir, root, identifiers) {
356
+ const ancestorPaths = new Map();
357
+ collectAncestorPaths(ir.nodes, ancestorPaths);
358
+ const elkNodes = new Map();
359
+ collectElkNodes(root, elkNodes);
360
+ for (const edge of ir.edges) {
361
+ const sourceAncestors = ancestorPaths.get(edge.source) ?? [];
362
+ const targetAncestors = ancestorPaths.get(edge.target) ?? [];
363
+ let owner = root;
364
+ for (let index = 0; index < Math.min(sourceAncestors.length, targetAncestors.length); index += 1) {
365
+ if (sourceAncestors[index] !== targetAncestors[index])
366
+ break;
367
+ owner = elkNodes.get(identifiers.nodes.get(sourceAncestors[index])) ?? root;
368
+ }
369
+ owner.edges = [...(owner.edges ?? []), edgeToELK(edge, identifiers)];
370
+ }
371
+ }
372
+ function collectAncestorPaths(nodes, target, ancestors = []) {
373
+ for (const node of nodes) {
374
+ target.set(node.id, ancestors);
375
+ if (node.children) {
376
+ collectAncestorPaths(node.children, target, [...ancestors, node.id]);
377
+ }
378
+ }
379
+ }
380
+ function hasNestedNodes(nodes) {
381
+ return nodes.some((node) => Boolean(node.children?.length));
382
+ }
383
+ function nodeToELK(node, options, identifiers) {
384
+ const hasChildren = Boolean(node.children?.length);
385
+ const elkNode = { id: identifiers.nodes.get(node.id) };
386
+ if (!hasChildren) {
387
+ elkNode.width = node.dimensions?.width ?? options.nodeWidth;
388
+ elkNode.height = node.dimensions?.height ?? options.nodeHeight;
389
+ }
390
+ if (node.label)
391
+ elkNode.labels = [{ text: node.label }];
392
+ if (node.ports?.length) {
393
+ elkNode.ports = node.ports.map((port) => portToELK(node.id, port, identifiers));
130
394
  elkNode.layoutOptions = {
131
395
  ...elkNode.layoutOptions,
132
396
  'elk.portConstraints': 'FIXED_SIDE',
133
397
  };
134
398
  }
135
- // Convert children (compound nodes)
136
- if (node.children && node.children.length > 0) {
137
- elkNode.children = node.children.map((child) => convertNodeToELK(child, opts));
399
+ if (hasChildren) {
400
+ elkNode.children = node.children.map((child) => nodeToELK(child, options, identifiers));
401
+ const padding = node.layoutOptions?.padding ?? {};
138
402
  elkNode.layoutOptions = {
139
403
  ...elkNode.layoutOptions,
140
- 'elk.padding': '[top=10,left=10,bottom=10,right=10]',
404
+ 'elk.hierarchyHandling': 'INCLUDE_CHILDREN',
405
+ 'elk.padding': `[top=${padding.top ?? 0},left=${padding.left ?? 0},bottom=${padding.bottom ?? 0},right=${padding.right ?? 0}]`,
141
406
  };
142
407
  }
143
408
  return elkNode;
144
409
  }
145
- /**
146
- * Convert a Graph-IR port to ELK format
147
- */
148
- function convertPortToELK(port) {
149
- const sideMapping = {
150
- NORTH: 'NORTH',
151
- SOUTH: 'SOUTH',
152
- EAST: 'EAST',
153
- WEST: 'WEST',
154
- };
410
+ function portToELK(ownerId, port, identifiers) {
155
411
  return {
156
- id: port.id,
157
- layoutOptions: {
158
- 'elk.port.side': sideMapping[port.side] || 'EAST',
159
- },
160
- };
161
- }
162
- /**
163
- * Convert a Graph-IR edge to ELK format
164
- */
165
- function convertEdgeToELK(edge) {
166
- const elkEdge = {
167
- id: edge.id,
168
- sources: [edge.sourcePort ? `${edge.source}.${edge.sourcePort}` : edge.source],
169
- targets: [edge.targetPort ? `${edge.target}.${edge.targetPort}` : edge.target],
412
+ id: identifiers.ports.get(portKey(ownerId, port.id)),
413
+ width: 1,
414
+ height: 1,
415
+ layoutOptions: { 'elk.port.side': port.side },
170
416
  };
171
- return elkEdge;
172
417
  }
173
- /**
174
- * Convert ELK result back to Graph-IR format
175
- */
176
- export function elkToGraphIR(elkResult, originalIR, algorithm) {
177
- // Build lookup for ELK nodes
178
- const elkNodeMap = new Map();
179
- collectElkNodes(elkResult, elkNodeMap);
180
- // Build lookup for original edges
181
- const originalEdgeMap = new Map(originalIR.edges.map((e) => [e.id, e]));
182
- // Convert nodes
183
- const layoutedNodes = convertElkNodesToGraphIR(originalIR.nodes, elkNodeMap);
184
- // Convert edges
185
- const layoutedEdges = convertElkEdgesToGraphIR(elkResult.edges || [], originalEdgeMap);
418
+ function edgeToELK(edge, identifiers) {
186
419
  return {
187
- ...originalIR,
188
- nodes: layoutedNodes,
189
- edges: layoutedEdges,
190
- layout: {
191
- width: elkResult.width || 0,
192
- height: elkResult.height || 0,
193
- algorithm,
194
- },
420
+ id: identifiers.edges.get(edge.id),
421
+ sources: [edge.sourcePort
422
+ ? identifiers.ports.get(portKey(edge.source, edge.sourcePort))
423
+ : identifiers.nodes.get(edge.source)],
424
+ targets: [edge.targetPort
425
+ ? identifiers.ports.get(portKey(edge.target, edge.targetPort))
426
+ : identifiers.nodes.get(edge.target)],
195
427
  };
196
428
  }
197
- /**
198
- * Collect all ELK nodes into a map for quick lookup
199
- */
200
- function collectElkNodes(node, map) {
201
- if (node.id !== 'root') {
202
- map.set(node.id, node);
203
- }
204
- if (node.children) {
205
- for (const child of node.children) {
206
- collectElkNodes(child, map);
207
- }
208
- }
429
+ function portKey(nodeId, portId) {
430
+ return JSON.stringify([nodeId, portId]);
209
431
  }
210
- /**
211
- * Convert ELK nodes back to Graph-IR nodes with layout info
212
- */
213
- function convertElkNodesToGraphIR(originalNodes, elkNodeMap) {
214
- return originalNodes.map((node) => {
215
- const elkNode = elkNodeMap.get(node.id);
216
- // Build the layouted node without children first
217
- const { children: _, ...nodeWithoutChildren } = node;
218
- const layoutedNode = {
219
- ...nodeWithoutChildren,
220
- layout: {
221
- x: elkNode?.x ?? 0,
222
- y: elkNode?.y ?? 0,
223
- width: elkNode?.width ?? 150,
224
- height: elkNode?.height ?? 50,
225
- },
226
- };
227
- // Handle children recursively
228
- if (node.children && node.children.length > 0) {
229
- layoutedNode.children = convertElkNodesToGraphIR(node.children, elkNodeMap);
432
+ export function verifyResolvedLayout(graph, root) {
433
+ const errors = [];
434
+ const identifiers = buildElkIdentifiers(graph);
435
+ const elkNodes = new Map();
436
+ const elkParents = new Map();
437
+ collectElkNodes(root, elkNodes, elkParents);
438
+ const authoredParents = new Map();
439
+ collectAuthoredParents(graph.nodes, authoredParents);
440
+ const elkEdges = new Map();
441
+ collectElkEdges(root, elkEdges);
442
+ if (!finiteNonnegative(root.width) || !finiteNonnegative(root.height)) {
443
+ errors.push({
444
+ code: 'LAYOUT_VERIFICATION_FAILED',
445
+ path: '',
446
+ message: 'Resolved graph bounds must be finite and nonnegative',
447
+ });
448
+ }
449
+ visitNodes(graph.nodes, '/nodes', (node, path) => {
450
+ const resolved = elkNodes.get(identifiers.nodes.get(node.id));
451
+ if (!resolved) {
452
+ errors.push({ code: 'LAYOUT_VERIFICATION_FAILED', path, message: 'Layout omitted node' });
453
+ return;
230
454
  }
231
- return layoutedNode;
232
- });
233
- }
234
- /**
235
- * Convert ELK edges back to Graph-IR edges with routing info
236
- */
237
- function convertElkEdgesToGraphIR(elkEdges, originalEdgeMap) {
238
- return elkEdges.map((elkEdge) => {
239
- const originalEdge = originalEdgeMap.get(elkEdge.id);
240
- // Extract routing points from sections
241
- const points = [];
242
- if (elkEdge.sections) {
243
- for (const section of elkEdge.sections) {
244
- points.push(section.startPoint);
245
- if (section.bendPoints) {
246
- points.push(...section.bendPoints);
455
+ if (!finite(resolved.x) || !finite(resolved.y) ||
456
+ !finiteNonnegative(resolved.width) || !finiteNonnegative(resolved.height)) {
457
+ errors.push({
458
+ code: 'LAYOUT_VERIFICATION_FAILED',
459
+ path,
460
+ message: 'Resolved node rectangle must be finite with nonnegative size',
461
+ });
462
+ }
463
+ const resolvedParent = elkParents.get(identifiers.nodes.get(node.id));
464
+ const expectedParent = authoredParents.get(node.id);
465
+ const expectedElkParent = expectedParent === undefined
466
+ ? undefined
467
+ : identifiers.nodes.get(expectedParent);
468
+ if (resolvedParent !== expectedElkParent) {
469
+ errors.push({
470
+ code: 'LAYOUT_VERIFICATION_FAILED',
471
+ path,
472
+ message: 'Resolved containment differs from authored children',
473
+ });
474
+ }
475
+ if (!node.children?.length && node.dimensions) {
476
+ if (resolved.width !== node.dimensions.width || resolved.height !== node.dimensions.height) {
477
+ errors.push({
478
+ code: 'LAYOUT_VERIFICATION_FAILED',
479
+ path: `${path}/dimensions`,
480
+ message: 'Resolved leaf outer size differs from authored fixed dimensions',
481
+ });
482
+ }
483
+ }
484
+ if (node.children?.length && node.layoutOptions?.padding) {
485
+ const padding = node.layoutOptions.padding;
486
+ for (const child of resolved.children ?? []) {
487
+ const x = child.x ?? 0;
488
+ const y = child.y ?? 0;
489
+ const width = child.width ?? 0;
490
+ const height = child.height ?? 0;
491
+ if (x < (padding.left ?? 0) ||
492
+ y < (padding.top ?? 0) ||
493
+ x + width > (resolved.width ?? 0) - (padding.right ?? 0) ||
494
+ y + height > (resolved.height ?? 0) - (padding.bottom ?? 0)) {
495
+ errors.push({
496
+ code: 'LAYOUT_VERIFICATION_FAILED',
497
+ path: `${path}/layoutOptions/padding`,
498
+ message: 'Resolved child bounds violate authored container padding',
499
+ });
500
+ break;
247
501
  }
248
- points.push(section.endPoint);
249
502
  }
250
503
  }
504
+ node.ports?.forEach((port, portIndex) => {
505
+ const resolvedPort = resolved.ports?.find((candidate) => candidate.id === identifiers.ports.get(portKey(node.id, port.id)));
506
+ const portPath = `${path}/ports/${portIndex}`;
507
+ if (!resolvedPort) {
508
+ errors.push({
509
+ code: 'LAYOUT_VERIFICATION_FAILED',
510
+ path: portPath,
511
+ message: 'Layout omitted authored port',
512
+ });
513
+ return;
514
+ }
515
+ if (!finite(resolvedPort.x) || !finite(resolvedPort.y) ||
516
+ !finiteNonnegative(resolvedPort.width) || !finiteNonnegative(resolvedPort.height)) {
517
+ errors.push({
518
+ code: 'LAYOUT_VERIFICATION_FAILED',
519
+ path: portPath,
520
+ message: 'Resolved port geometry must be finite with nonnegative size',
521
+ });
522
+ }
523
+ if (!portRemainsOnSide(resolved, resolvedPort, port.side)) {
524
+ errors.push({
525
+ code: 'LAYOUT_VERIFICATION_FAILED',
526
+ path: `${portPath}/side`,
527
+ message: `Resolved port is not on authored ${port.side} side`,
528
+ });
529
+ }
530
+ });
531
+ });
532
+ graph.edges.forEach((edge, index) => {
533
+ const resolved = elkEdges.get(identifiers.edges.get(edge.id));
534
+ if (!resolved) {
535
+ errors.push({
536
+ code: 'LAYOUT_VERIFICATION_FAILED',
537
+ path: `/edges/${index}`,
538
+ message: 'Layout omitted authored edge',
539
+ });
540
+ return;
541
+ }
542
+ const sections = resolved.edge.sections ?? [];
543
+ const points = sections.flatMap((section) => [
544
+ section.startPoint,
545
+ ...(section.bendPoints ?? []),
546
+ section.endPoint,
547
+ ]);
548
+ if (sections.length === 0 || points.some((point) => !finite(point.x) || !finite(point.y))) {
549
+ errors.push({
550
+ code: 'LAYOUT_VERIFICATION_FAILED',
551
+ path: `/edges/${index}`,
552
+ message: 'Resolved edge route must contain finite section points',
553
+ });
554
+ }
555
+ });
556
+ return errors;
557
+ }
558
+ function portRemainsOnSide(node, port, side) {
559
+ if (node.width === undefined || node.height === undefined ||
560
+ port.x === undefined || port.y === undefined)
561
+ return false;
562
+ const centerX = port.x + (port.width ?? 0) / 2;
563
+ const centerY = port.y + (port.height ?? 0) / 2;
564
+ const epsilonX = (port.width ?? 0) / 2 + 1e-6;
565
+ const epsilonY = (port.height ?? 0) / 2 + 1e-6;
566
+ const withinHorizontalSpan = centerX >= -1e-6 && centerX <= node.width + 1e-6;
567
+ const withinVerticalSpan = centerY >= -1e-6 && centerY <= node.height + 1e-6;
568
+ if (side === 'WEST')
569
+ return Math.abs(centerX) <= epsilonX && withinVerticalSpan;
570
+ if (side === 'EAST') {
571
+ return Math.abs(centerX - node.width) <= epsilonX && withinVerticalSpan;
572
+ }
573
+ if (side === 'NORTH')
574
+ return Math.abs(centerY) <= epsilonY && withinHorizontalSpan;
575
+ return Math.abs(centerY - node.height) <= epsilonY && withinHorizontalSpan;
576
+ }
577
+ export function elkToResolvedLayout(elkResult, original, algorithm) {
578
+ const identifiers = buildElkIdentifiers(original);
579
+ const nodeMap = new Map();
580
+ collectElkNodes(elkResult, nodeMap);
581
+ const edgeMap = new Map();
582
+ collectElkEdges(elkResult, edgeMap);
583
+ return {
584
+ graphId: original.id,
585
+ nodes: resolveNodes(original.nodes, nodeMap, identifiers),
586
+ edges: original.edges.flatMap((edge) => {
587
+ const resolved = edgeMap.get(identifiers.edges.get(edge.id)) ?? edgeMap.get(edge.id);
588
+ return resolved ? [resolveEdge(resolved, edge.id)] : [];
589
+ }),
590
+ bounds: { width: elkResult.width ?? 0, height: elkResult.height ?? 0 },
591
+ algorithm,
592
+ };
593
+ }
594
+ function resolveNodes(nodes, map, identifiers) {
595
+ return nodes.map((node) => {
596
+ const resolved = map.get(identifiers?.nodes.get(node.id) ?? node.id) ?? map.get(node.id);
251
597
  return {
252
- ...originalEdge,
253
- id: elkEdge.id,
254
- source: originalEdge?.source ?? '',
255
- target: originalEdge?.target ?? '',
256
- layout: {
257
- points,
598
+ id: node.id,
599
+ rectangle: {
600
+ x: resolved?.x ?? 0,
601
+ y: resolved?.y ?? 0,
602
+ width: resolved?.width ?? 0,
603
+ height: resolved?.height ?? 0,
258
604
  },
605
+ children: node.children ? resolveNodes(node.children, map, identifiers) : undefined,
259
606
  };
260
607
  });
261
608
  }
609
+ function resolveEdge(collected, authoredId) {
610
+ const { edge, offsetX, offsetY } = collected;
611
+ const points = [];
612
+ for (const section of edge.sections ?? []) {
613
+ points.push(absolutePoint(section.startPoint, offsetX, offsetY));
614
+ points.push(...(section.bendPoints ?? []).map((point) => absolutePoint(point, offsetX, offsetY)));
615
+ points.push(absolutePoint(section.endPoint, offsetX, offsetY));
616
+ }
617
+ return { id: authoredId, points };
618
+ }
619
+ function absolutePoint(point, offsetX, offsetY) {
620
+ return { x: point.x + offsetX, y: point.y + offsetY };
621
+ }
622
+ function collectElkNodes(node, target, parents, parentId) {
623
+ if (node.id !== ELK_ROOT_ID)
624
+ target.set(node.id, node);
625
+ if (node.id !== ELK_ROOT_ID)
626
+ parents?.set(node.id, parentId);
627
+ const childParent = node.id === ELK_ROOT_ID ? undefined : node.id;
628
+ for (const child of node.children ?? []) {
629
+ collectElkNodes(child, target, parents, childParent);
630
+ }
631
+ }
632
+ function collectElkEdges(node, target, parentX = 0, parentY = 0) {
633
+ const offsetX = parentX + (node.id === ELK_ROOT_ID ? 0 : node.x ?? 0);
634
+ const offsetY = parentY + (node.id === ELK_ROOT_ID ? 0 : node.y ?? 0);
635
+ for (const edge of node.edges ?? []) {
636
+ target.set(edge.id, { edge, offsetX, offsetY });
637
+ }
638
+ for (const child of node.children ?? []) {
639
+ collectElkEdges(child, target, offsetX, offsetY);
640
+ }
641
+ }
642
+ function finite(value) {
643
+ return typeof value === 'number' && Number.isFinite(value);
644
+ }
645
+ function finiteNonnegative(value) {
646
+ return finite(value) && value >= 0;
647
+ }
648
+ function collectAuthoredParents(nodes, target, parentId) {
649
+ for (const node of nodes) {
650
+ target.set(node.id, parentId);
651
+ if (node.children)
652
+ collectAuthoredParents(node.children, target, node.id);
653
+ }
654
+ }
655
+ function visitNodes(nodes, base, visit) {
656
+ nodes.forEach((node, index) => {
657
+ const path = `${base}/${index}`;
658
+ visit(node, path);
659
+ if (node.children)
660
+ visitNodes(node.children, `${path}/children`, visit);
661
+ });
662
+ }
663
+ function failed(algorithm, errors) {
664
+ return { success: false, errors, algorithm };
665
+ }
262
666
  //# sourceMappingURL=layout.js.map