@synergenius/flow-weaver 0.2.1 → 0.3.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.
- package/README.md +260 -200
- package/dist/cli/commands/docs.d.ts +11 -0
- package/dist/cli/commands/docs.js +77 -0
- package/dist/cli/flow-weaver.mjs +1077 -543
- package/dist/cli/index.js +51 -0
- package/dist/diagram/geometry.d.ts +9 -4
- package/dist/diagram/geometry.js +219 -30
- package/dist/diagram/renderer.js +137 -116
- package/dist/docs/index.d.ts +54 -0
- package/dist/docs/index.js +256 -0
- package/dist/mcp/server.js +2 -0
- package/dist/mcp/tools-docs.d.ts +3 -0
- package/dist/mcp/tools-docs.js +62 -0
- package/dist/mcp/tools-editor.js +3 -1
- package/dist/mcp/tools-query.js +3 -1
- package/dist/mcp/workflow-executor.js +4 -2
- package/package.json +10 -4
package/dist/cli/index.js
CHANGED
|
@@ -29,6 +29,7 @@ import { openapiCommand } from './commands/openapi.js';
|
|
|
29
29
|
import { pluginInitCommand } from './commands/plugin.js';
|
|
30
30
|
import { migrateCommand } from './commands/migrate.js';
|
|
31
31
|
import { changelogCommand } from './commands/changelog.js';
|
|
32
|
+
import { docsListCommand, docsReadCommand, docsSearchCommand } from './commands/docs.js';
|
|
32
33
|
import { marketInitCommand, marketPackCommand, marketPublishCommand, marketInstallCommand, marketSearchCommand, marketListCommand, } from './commands/market.js';
|
|
33
34
|
import { logger } from './utils/logger.js';
|
|
34
35
|
import { getErrorMessage } from '../utils/error-utils.js';
|
|
@@ -585,6 +586,49 @@ program
|
|
|
585
586
|
process.exit(1);
|
|
586
587
|
}
|
|
587
588
|
});
|
|
589
|
+
// Docs command group
|
|
590
|
+
const docsCmd = program.command('docs').description('Browse reference documentation');
|
|
591
|
+
docsCmd
|
|
592
|
+
.command('list', { isDefault: true })
|
|
593
|
+
.description('List available documentation topics')
|
|
594
|
+
.option('--json', 'Output as JSON', false)
|
|
595
|
+
.option('--compact', 'Compact output', false)
|
|
596
|
+
.action(async (options) => {
|
|
597
|
+
try {
|
|
598
|
+
await docsListCommand(options);
|
|
599
|
+
}
|
|
600
|
+
catch (error) {
|
|
601
|
+
logger.error(`Command failed: ${getErrorMessage(error)}`);
|
|
602
|
+
process.exit(1);
|
|
603
|
+
}
|
|
604
|
+
});
|
|
605
|
+
docsCmd
|
|
606
|
+
.command('read <topic>')
|
|
607
|
+
.description('Read a documentation topic')
|
|
608
|
+
.option('--json', 'Output as JSON', false)
|
|
609
|
+
.option('--compact', 'Return compact LLM-friendly version', false)
|
|
610
|
+
.action(async (topic, options) => {
|
|
611
|
+
try {
|
|
612
|
+
await docsReadCommand(topic, options);
|
|
613
|
+
}
|
|
614
|
+
catch (error) {
|
|
615
|
+
logger.error(`Command failed: ${getErrorMessage(error)}`);
|
|
616
|
+
process.exit(1);
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
docsCmd
|
|
620
|
+
.command('search <query>')
|
|
621
|
+
.description('Search across all documentation')
|
|
622
|
+
.option('--json', 'Output as JSON', false)
|
|
623
|
+
.action(async (query, options) => {
|
|
624
|
+
try {
|
|
625
|
+
await docsSearchCommand(query, options);
|
|
626
|
+
}
|
|
627
|
+
catch (error) {
|
|
628
|
+
logger.error(`Command failed: ${getErrorMessage(error)}`);
|
|
629
|
+
process.exit(1);
|
|
630
|
+
}
|
|
631
|
+
});
|
|
588
632
|
// Marketplace command group
|
|
589
633
|
const marketCmd = program.command('market').description('Discover, install, and publish marketplace packages');
|
|
590
634
|
marketCmd
|
|
@@ -724,6 +768,13 @@ program.on('--help', () => {
|
|
|
724
768
|
logger.log(' $ flow-weaver market search openai');
|
|
725
769
|
logger.log(' $ flow-weaver market list');
|
|
726
770
|
logger.newline();
|
|
771
|
+
logger.section('Documentation');
|
|
772
|
+
logger.log(' $ flow-weaver docs');
|
|
773
|
+
logger.log(' $ flow-weaver docs read error-codes');
|
|
774
|
+
logger.log(' $ flow-weaver docs read scaffold --compact');
|
|
775
|
+
logger.log(' $ flow-weaver docs search "missing workflow"');
|
|
776
|
+
logger.log(' $ flow-weaver docs read error-codes --json');
|
|
777
|
+
logger.newline();
|
|
727
778
|
logger.section('Migration & Changelog');
|
|
728
779
|
logger.log(" $ flow-weaver migrate '**/*.ts'");
|
|
729
780
|
logger.log(" $ flow-weaver migrate '**/*.ts' --dry-run");
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { TWorkflowAST } from '../ast/types.js';
|
|
2
|
-
import type { DiagramNode, DiagramGraph, DiagramOptions } from './types.js';
|
|
2
|
+
import type { DiagramNode, DiagramPort, DiagramGraph, DiagramOptions } from './types.js';
|
|
3
3
|
export declare const PORT_RADIUS = 7;
|
|
4
4
|
export declare const PORT_SIZE: number;
|
|
5
5
|
export declare const PORT_GAP = 8;
|
|
@@ -7,16 +7,21 @@ export declare const PORT_PADDING_Y = 18;
|
|
|
7
7
|
export declare const NODE_MIN_WIDTH = 90;
|
|
8
8
|
export declare const NODE_MIN_HEIGHT = 90;
|
|
9
9
|
export declare const BORDER_RADIUS = 6;
|
|
10
|
-
export declare const LAYER_GAP_X =
|
|
10
|
+
export declare const LAYER_GAP_X = 300;
|
|
11
|
+
export declare const LABEL_CLEARANCE = 42;
|
|
12
|
+
export declare const MIN_EDGE_GAP = 112;
|
|
11
13
|
export declare const NODE_GAP_Y = 60;
|
|
12
14
|
export declare const LABEL_HEIGHT = 20;
|
|
13
15
|
export declare const LABEL_GAP = 12;
|
|
14
|
-
export declare const
|
|
16
|
+
export declare const SCOPE_PADDING_X = 140;
|
|
17
|
+
export declare const SCOPE_PADDING_Y = 40;
|
|
15
18
|
export declare const SCOPE_PORT_COLUMN = 50;
|
|
16
|
-
export declare const SCOPE_INNER_GAP_X =
|
|
19
|
+
export declare const SCOPE_INNER_GAP_X = 240;
|
|
17
20
|
export declare const ORTHOGONAL_DISTANCE_THRESHOLD = 300;
|
|
18
21
|
/** Measure text width using pre-computed Montserrat 600/10px SVG character widths */
|
|
19
22
|
export declare function measureText(text: string): number;
|
|
23
|
+
/** Compute the full badge width for a port label (matches renderer badge layout) */
|
|
24
|
+
export declare function portBadgeWidth(port: DiagramPort): number;
|
|
20
25
|
export declare function computeNodeDimensions(node: DiagramNode): void;
|
|
21
26
|
export declare function computePortPositions(node: DiagramNode): void;
|
|
22
27
|
export declare function computeConnectionPath(sx: number, sy: number, tx: number, ty: number): string;
|
package/dist/diagram/geometry.js
CHANGED
|
@@ -11,14 +11,17 @@ export const PORT_PADDING_Y = 18; // matches inputsStyle paddingTop/Bottom
|
|
|
11
11
|
export const NODE_MIN_WIDTH = 90; // matches NODE_MIN_WIDTH in styles.ts
|
|
12
12
|
export const NODE_MIN_HEIGHT = 90; // matches NODE_MIN_HEIGHT in styles.ts
|
|
13
13
|
export const BORDER_RADIUS = 6; // matches wrapperStyle borderRadius
|
|
14
|
-
export const LAYER_GAP_X =
|
|
14
|
+
export const LAYER_GAP_X = 300; // target center-to-center; actual gap adapts to port labels
|
|
15
|
+
export const LABEL_CLEARANCE = 42; // breathing room between opposing port label badges
|
|
16
|
+
export const MIN_EDGE_GAP = 112; // minimum edge-to-edge gap between node boxes
|
|
15
17
|
export const NODE_GAP_Y = 60;
|
|
16
18
|
export const LABEL_HEIGHT = 20; // 13px font + padding
|
|
17
19
|
export const LABEL_GAP = 12; // matches labelRootStyle bottom: calc(100% + 12px)
|
|
18
20
|
// Scope rendering constants
|
|
19
|
-
export const
|
|
21
|
+
export const SCOPE_PADDING_X = 140; // horizontal padding inside scope (between port columns and children)
|
|
22
|
+
export const SCOPE_PADDING_Y = 40; // vertical padding inside scope (top/bottom)
|
|
20
23
|
export const SCOPE_PORT_COLUMN = 50; // width for scoped port column on inner edges
|
|
21
|
-
export const SCOPE_INNER_GAP_X =
|
|
24
|
+
export const SCOPE_INNER_GAP_X = 240; // horizontal gap between children inside scope
|
|
22
25
|
// Routing mode threshold — connections longer than this use orthogonal routing
|
|
23
26
|
// (midpoint of original 250–350 hysteresis thresholds)
|
|
24
27
|
export const ORTHOGONAL_DISTANCE_THRESHOLD = 300;
|
|
@@ -47,6 +50,20 @@ export function measureText(text) {
|
|
|
47
50
|
}
|
|
48
51
|
return width;
|
|
49
52
|
}
|
|
53
|
+
/** Compute the full badge width for a port label (matches renderer badge layout) */
|
|
54
|
+
export function portBadgeWidth(port) {
|
|
55
|
+
const abbrev = TYPE_ABBREVIATIONS[port.dataType] ?? port.dataType;
|
|
56
|
+
const typeWidth = measureText(abbrev);
|
|
57
|
+
const labelWidth = measureText(port.label);
|
|
58
|
+
const pad = 7;
|
|
59
|
+
const divGap = 4;
|
|
60
|
+
return pad + typeWidth + divGap + 1 + divGap + labelWidth + pad;
|
|
61
|
+
}
|
|
62
|
+
/** Total extent of a port label from the port dot center outward (badge + gap + dot radius) */
|
|
63
|
+
function portLabelExtent(port) {
|
|
64
|
+
const badgeGap = 5;
|
|
65
|
+
return PORT_RADIUS + badgeGap + portBadgeWidth(port);
|
|
66
|
+
}
|
|
50
67
|
// ---- Dimension computation ----
|
|
51
68
|
export function computeNodeDimensions(node) {
|
|
52
69
|
const maxPorts = Math.max(node.inputs.length, node.outputs.length);
|
|
@@ -303,8 +320,17 @@ function buildScopeSubGraph(parentNode, parentNt, scopeName, childIds, ast, node
|
|
|
303
320
|
const parentUI = ast.ui?.instances?.find(u => u.name === parentNode.id);
|
|
304
321
|
const uiWidth = parentUI?.expandedWidth ?? parentUI?.width;
|
|
305
322
|
const uiHeight = parentUI?.expandedHeight ?? parentUI?.height;
|
|
306
|
-
|
|
307
|
-
const
|
|
323
|
+
// Minimum inner width so opposing scope port labels don't collide
|
|
324
|
+
const maxLeftLabelExtent = scopeOutputPorts.length > 0
|
|
325
|
+
? Math.max(...scopeOutputPorts.map(portLabelExtent))
|
|
326
|
+
: 0;
|
|
327
|
+
const maxRightLabelExtent = scopeInputPorts.length > 0
|
|
328
|
+
? Math.max(...scopeInputPorts.map(portLabelExtent))
|
|
329
|
+
: 0;
|
|
330
|
+
const minInnerWidth = maxLeftLabelExtent + LABEL_CLEARANCE + maxRightLabelExtent;
|
|
331
|
+
const contentWidth = Math.max(childrenWidth, minInnerWidth);
|
|
332
|
+
const computedWidth = SCOPE_PORT_COLUMN + SCOPE_PADDING_X + contentWidth + SCOPE_PADDING_X + SCOPE_PORT_COLUMN;
|
|
333
|
+
const computedHeight = SCOPE_PADDING_Y * 2 + Math.max(childrenHeight, scopeOutPortsHeight, scopeInPortsHeight);
|
|
308
334
|
parentNode.width = Math.max(parentNode.width, uiWidth ?? computedWidth);
|
|
309
335
|
parentNode.height = Math.max(parentNode.height, uiHeight ?? computedHeight);
|
|
310
336
|
// Store scope data (children positions are in local coordinates, will be offset later)
|
|
@@ -319,15 +345,15 @@ function finalizeScopePositions(parentNode, ast, theme = 'dark') {
|
|
|
319
345
|
if (!children || children.length === 0 || !scopePorts)
|
|
320
346
|
return;
|
|
321
347
|
// Scope inner area (between the two port columns)
|
|
322
|
-
const innerLeft = parentNode.x + SCOPE_PORT_COLUMN +
|
|
323
|
-
const innerRight = parentNode.x + parentNode.width - SCOPE_PORT_COLUMN -
|
|
348
|
+
const innerLeft = parentNode.x + SCOPE_PORT_COLUMN + SCOPE_PADDING_X;
|
|
349
|
+
const innerRight = parentNode.x + parentNode.width - SCOPE_PORT_COLUMN - SCOPE_PADDING_X;
|
|
324
350
|
const innerWidth = innerRight - innerLeft;
|
|
325
351
|
// Compute children block width from local coordinates
|
|
326
352
|
const lastChild = children[children.length - 1];
|
|
327
353
|
const childrenBlockWidth = lastChild.x + lastChild.width;
|
|
328
354
|
// Center children horizontally within the inner area
|
|
329
355
|
const centerOffsetX = innerLeft + (innerWidth - childrenBlockWidth) / 2;
|
|
330
|
-
const scopeOriginY = parentNode.y +
|
|
356
|
+
const scopeOriginY = parentNode.y + SCOPE_PADDING_Y;
|
|
331
357
|
// Offset children to absolute positions
|
|
332
358
|
for (const child of children) {
|
|
333
359
|
child.x += centerOffsetX;
|
|
@@ -432,6 +458,167 @@ function portsColumnHeight(count) {
|
|
|
432
458
|
return 0;
|
|
433
459
|
return PORT_PADDING_Y + count * PORT_SIZE + (count - 1) * PORT_GAP + PORT_PADDING_Y;
|
|
434
460
|
}
|
|
461
|
+
// ---- Position extraction ----
|
|
462
|
+
/**
|
|
463
|
+
* Extract explicit positions from AST metadata.
|
|
464
|
+
* Returns a map of node ID → {x, y} for nodes that have both x and y defined.
|
|
465
|
+
* Sources: ast.ui.startNode, ast.ui.exitNode, instance.config.x/y
|
|
466
|
+
*/
|
|
467
|
+
function extractExplicitPositions(ast) {
|
|
468
|
+
const positions = new Map();
|
|
469
|
+
if (ast.ui?.startNode?.x != null && ast.ui.startNode.y != null) {
|
|
470
|
+
positions.set('Start', { x: ast.ui.startNode.x, y: ast.ui.startNode.y });
|
|
471
|
+
}
|
|
472
|
+
if (ast.ui?.exitNode?.x != null && ast.ui.exitNode.y != null) {
|
|
473
|
+
positions.set('Exit', { x: ast.ui.exitNode.x, y: ast.ui.exitNode.y });
|
|
474
|
+
}
|
|
475
|
+
for (const inst of ast.instances) {
|
|
476
|
+
if (inst.config?.x != null && inst.config.y != null) {
|
|
477
|
+
positions.set(inst.id, { x: inst.config.x, y: inst.config.y });
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return positions;
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Compute the maximum right-side overhang for a layer (external output port labels only).
|
|
484
|
+
* Scope inner-edge port labels face inward and don't extend past the node boundary.
|
|
485
|
+
*/
|
|
486
|
+
function layerOutputExtent(layerNodes, diagramNodes) {
|
|
487
|
+
let max = 0;
|
|
488
|
+
for (const id of layerNodes) {
|
|
489
|
+
const node = diagramNodes.get(id);
|
|
490
|
+
max = Math.max(max, maxPortLabelExtent(node.outputs));
|
|
491
|
+
}
|
|
492
|
+
return max;
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Compute the maximum left-side overhang for a layer (external input port labels only).
|
|
496
|
+
* Scope inner-edge port labels face inward and don't extend past the node boundary.
|
|
497
|
+
*/
|
|
498
|
+
function layerInputExtent(layerNodes, diagramNodes) {
|
|
499
|
+
let max = 0;
|
|
500
|
+
for (const id of layerNodes) {
|
|
501
|
+
const node = diagramNodes.get(id);
|
|
502
|
+
max = Math.max(max, maxPortLabelExtent(node.inputs));
|
|
503
|
+
}
|
|
504
|
+
return max;
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Assign coordinates to nodes using auto-layout layer assignments.
|
|
508
|
+
* Gap between layers adapts to port label extents so labels never overlap,
|
|
509
|
+
* while using LAYER_GAP_X as the target center-to-center distance.
|
|
510
|
+
*/
|
|
511
|
+
function assignLayerCoordinates(layers, diagramNodes) {
|
|
512
|
+
// Pre-compute filtered layers
|
|
513
|
+
const filtered = layers.map(l => l.filter(id => diagramNodes.has(id)));
|
|
514
|
+
let currentX = 0;
|
|
515
|
+
for (let i = 0; i < filtered.length; i++) {
|
|
516
|
+
const layerNodes = filtered[i];
|
|
517
|
+
if (layerNodes.length === 0) {
|
|
518
|
+
currentX += LAYER_GAP_X;
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
const maxWidth = Math.max(...layerNodes.map(id => diagramNodes.get(id).width));
|
|
522
|
+
const totalHeight = layerNodes.reduce((sum, id) => {
|
|
523
|
+
const n = diagramNodes.get(id);
|
|
524
|
+
return sum + n.height + LABEL_HEIGHT + LABEL_GAP;
|
|
525
|
+
}, 0) + (layerNodes.length - 1) * NODE_GAP_Y;
|
|
526
|
+
let currentY = -totalHeight / 2;
|
|
527
|
+
for (const id of layerNodes) {
|
|
528
|
+
const node = diagramNodes.get(id);
|
|
529
|
+
currentY += LABEL_HEIGHT + LABEL_GAP;
|
|
530
|
+
node.x = currentX + (maxWidth - node.width) / 2;
|
|
531
|
+
node.y = currentY;
|
|
532
|
+
currentY += node.height + NODE_GAP_Y;
|
|
533
|
+
}
|
|
534
|
+
// Compute label-aware edge gap to the next layer
|
|
535
|
+
const nextLayerNodes = filtered[i + 1];
|
|
536
|
+
if (nextLayerNodes && nextLayerNodes.length > 0) {
|
|
537
|
+
const outputOverhang = layerOutputExtent(layerNodes, diagramNodes);
|
|
538
|
+
const inputOverhang = layerInputExtent(nextLayerNodes, diagramNodes);
|
|
539
|
+
const labelMinGap = outputOverhang + LABEL_CLEARANCE + inputOverhang;
|
|
540
|
+
const edgeGap = Math.max(labelMinGap, LAYER_GAP_X - maxWidth, MIN_EDGE_GAP);
|
|
541
|
+
currentX += maxWidth + edgeGap;
|
|
542
|
+
}
|
|
543
|
+
else {
|
|
544
|
+
currentX += maxWidth + LAYER_GAP_X;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Hybrid positioning: apply explicit positions to positioned nodes,
|
|
550
|
+
* then auto-layout only the remaining unpositioned nodes.
|
|
551
|
+
*/
|
|
552
|
+
function assignUnpositionedNodes(layers, diagramNodes, explicitPositions) {
|
|
553
|
+
// Apply explicit positions first
|
|
554
|
+
for (const [id, pos] of explicitPositions) {
|
|
555
|
+
const node = diagramNodes.get(id);
|
|
556
|
+
if (node) {
|
|
557
|
+
node.x = pos.x;
|
|
558
|
+
node.y = pos.y;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
// Auto-layout only unpositioned nodes using the same layer-based algorithm
|
|
562
|
+
const filtered = layers.map(l => l.filter(id => diagramNodes.has(id)));
|
|
563
|
+
let currentX = 0;
|
|
564
|
+
for (let i = 0; i < filtered.length; i++) {
|
|
565
|
+
const layerNodes = filtered[i];
|
|
566
|
+
if (layerNodes.length === 0) {
|
|
567
|
+
currentX += LAYER_GAP_X;
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
const unpositioned = layerNodes.filter(id => !explicitPositions.has(id));
|
|
571
|
+
const maxWidth = Math.max(...layerNodes.map(id => diagramNodes.get(id).width));
|
|
572
|
+
if (unpositioned.length > 0) {
|
|
573
|
+
const totalHeight = unpositioned.reduce((sum, id) => {
|
|
574
|
+
const n = diagramNodes.get(id);
|
|
575
|
+
return sum + n.height + LABEL_HEIGHT + LABEL_GAP;
|
|
576
|
+
}, 0) + (unpositioned.length - 1) * NODE_GAP_Y;
|
|
577
|
+
let currentY = -totalHeight / 2;
|
|
578
|
+
for (const id of unpositioned) {
|
|
579
|
+
const node = diagramNodes.get(id);
|
|
580
|
+
currentY += LABEL_HEIGHT + LABEL_GAP;
|
|
581
|
+
node.x = currentX + (maxWidth - node.width) / 2;
|
|
582
|
+
node.y = currentY;
|
|
583
|
+
currentY += node.height + NODE_GAP_Y;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
// Label-aware edge gap
|
|
587
|
+
const nextLayerNodes = filtered[i + 1];
|
|
588
|
+
if (nextLayerNodes && nextLayerNodes.length > 0) {
|
|
589
|
+
const outputOverhang = layerOutputExtent(layerNodes, diagramNodes);
|
|
590
|
+
const inputOverhang = layerInputExtent(nextLayerNodes, diagramNodes);
|
|
591
|
+
const labelMinGap = outputOverhang + LABEL_CLEARANCE + inputOverhang;
|
|
592
|
+
const edgeGap = Math.max(labelMinGap, LAYER_GAP_X - maxWidth, MIN_EDGE_GAP);
|
|
593
|
+
currentX += maxWidth + edgeGap;
|
|
594
|
+
}
|
|
595
|
+
else {
|
|
596
|
+
currentX += maxWidth + LAYER_GAP_X;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* After applying explicit positions, ensure no nodes overlap horizontally.
|
|
602
|
+
* Sorts nodes by x, then pushes any node whose left edge (minus its input
|
|
603
|
+
* label extent) intrudes into the previous node's right edge (plus its
|
|
604
|
+
* output label extent and clearance).
|
|
605
|
+
*/
|
|
606
|
+
function resolveHorizontalOverlaps(diagramNodes) {
|
|
607
|
+
const nodes = [...diagramNodes.values()].sort((a, b) => a.x - b.x);
|
|
608
|
+
for (let i = 1; i < nodes.length; i++) {
|
|
609
|
+
const prev = nodes[i - 1];
|
|
610
|
+
const curr = nodes[i];
|
|
611
|
+
// Only external port labels overhang the node boundary.
|
|
612
|
+
// Scope inner-edge port labels face inward and don't extend past the node box.
|
|
613
|
+
const prevRightExtent = maxPortLabelExtent(prev.outputs);
|
|
614
|
+
const currLeftExtent = maxPortLabelExtent(curr.inputs);
|
|
615
|
+
const minGap = Math.max(prevRightExtent + LABEL_CLEARANCE + currLeftExtent, MIN_EDGE_GAP);
|
|
616
|
+
const actualGap = curr.x - (prev.x + prev.width);
|
|
617
|
+
if (actualGap < minGap) {
|
|
618
|
+
curr.x = prev.x + prev.width + minGap;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
435
622
|
// ---- Main orchestrator ----
|
|
436
623
|
export function buildDiagramGraph(ast, options = {}) {
|
|
437
624
|
const themeName = options.theme ?? 'dark';
|
|
@@ -545,30 +732,32 @@ export function buildDiagramGraph(ast, options = {}) {
|
|
|
545
732
|
continue;
|
|
546
733
|
buildScopeSubGraph(parentNode, parentNt, scopeName, childIds, ast, nodeTypeMap, themeName);
|
|
547
734
|
}
|
|
548
|
-
// Layout
|
|
549
|
-
const
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
currentX += LAYER_GAP_X;
|
|
556
|
-
continue;
|
|
557
|
-
}
|
|
558
|
-
const maxWidth = Math.max(...layerNodes.map(id => diagramNodes.get(id).width));
|
|
559
|
-
const totalHeight = layerNodes.reduce((sum, id) => {
|
|
560
|
-
const n = diagramNodes.get(id);
|
|
561
|
-
return sum + n.height + LABEL_HEIGHT + LABEL_GAP;
|
|
562
|
-
}, 0) + (layerNodes.length - 1) * NODE_GAP_Y;
|
|
563
|
-
let currentY = -totalHeight / 2;
|
|
564
|
-
for (const id of layerNodes) {
|
|
735
|
+
// Layout — use explicit positions when available, auto-layout otherwise
|
|
736
|
+
const explicitPositions = extractExplicitPositions(ast);
|
|
737
|
+
const allPositioned = [...diagramNodes.keys()].every(id => explicitPositions.has(id));
|
|
738
|
+
const nonePositioned = ![...diagramNodes.keys()].some(id => explicitPositions.has(id));
|
|
739
|
+
if (allPositioned) {
|
|
740
|
+
// All nodes have explicit positions — apply them
|
|
741
|
+
for (const [id, pos] of explicitPositions) {
|
|
565
742
|
const node = diagramNodes.get(id);
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
743
|
+
if (node) {
|
|
744
|
+
node.x = pos.x;
|
|
745
|
+
node.y = pos.y;
|
|
746
|
+
}
|
|
570
747
|
}
|
|
571
|
-
|
|
748
|
+
// Resolve horizontal overlaps: scope nodes may be wider than the
|
|
749
|
+
// original positions anticipated, so push downstream nodes apart.
|
|
750
|
+
resolveHorizontalOverlaps(diagramNodes);
|
|
751
|
+
}
|
|
752
|
+
else if (nonePositioned) {
|
|
753
|
+
// No positions — full auto-layout (original behavior)
|
|
754
|
+
const { layers } = layoutWorkflow(ast);
|
|
755
|
+
assignLayerCoordinates(layers, diagramNodes);
|
|
756
|
+
}
|
|
757
|
+
else {
|
|
758
|
+
// Mixed — explicit positions + auto-layout for remaining nodes
|
|
759
|
+
const { layers } = layoutWorkflow(ast);
|
|
760
|
+
assignUnpositionedNodes(layers, diagramNodes, explicitPositions);
|
|
572
761
|
}
|
|
573
762
|
// Compute external port positions
|
|
574
763
|
for (const node of diagramNodes.values()) {
|