@statelyai/layout 0.0.1 → 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/README.md +1 -3
- package/dist/elkjs/index.mjs +10 -7
- package/dist/{index-D2RodsZY.d.mts → index-8xYkohbz.d.mts} +2 -0
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +2 -2
- package/dist/layered/index.d.mts +1 -1
- package/dist/layered/index.mjs +1 -1
- package/dist/{layered-Dd868WZY.mjs → layered-QwJn2gb2.mjs} +399 -53
- package/dist/{spore-fTgSoRLP.mjs → spore-D15xIQKj.mjs} +1 -1
- package/package.json +16 -6
- package/src/box.ts +135 -0
- package/src/elkjs/index.ts +1825 -0
- package/src/elkjs/types.ts +103 -0
- package/src/errors.ts +16 -0
- package/src/fixed.ts +80 -0
- package/src/index.ts +92 -0
- package/src/java-random.ts +46 -0
- package/src/layered/bk-node-placement.ts +715 -0
- package/src/layered/elk-enum-values.ts +171 -0
- package/src/layered/elk-options.generated.ts +315 -0
- package/src/layered/elk-options.ts +98 -0
- package/src/layered/flexible-ports.ts +11 -0
- package/src/layered/high-degree.ts +127 -0
- package/src/layered/index.ts +2138 -0
- package/src/layered/layer-unzipping.ts +217 -0
- package/src/layered/linear-segments-node-placement.ts +447 -0
- package/src/layered/long-edges.ts +405 -0
- package/src/layered/min-width.ts +159 -0
- package/src/layered/multi-edge-wrapping.ts +460 -0
- package/src/layered/network-simplex-node-placement.ts +500 -0
- package/src/layered/network-simplex.ts +346 -0
- package/src/layered/node-promotion.ts +197 -0
- package/src/layered/spacing.ts +37 -0
- package/src/layered/spline-bezier.ts +102 -0
- package/src/layered/strategies.ts +5343 -0
- package/src/layered/stretch-width.ts +136 -0
- package/src/layered/types.ts +111 -0
- package/src/layout.ts +202 -0
- package/src/packing.ts +74 -0
- package/src/random.ts +142 -0
- package/src/spore.ts +103 -0
- package/src/types.ts +84 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/*******************************************************************************
|
|
2
|
+
* Copyright (c) 2016, 2020 Kiel University and others.
|
|
3
|
+
*
|
|
4
|
+
* Translated from ELK v0.11.0 StretchWidthLayerer.java.
|
|
5
|
+
* Source commit: 54123e884b1ae743b453260f713b20c9bf5787f2
|
|
6
|
+
* SPDX-License-Identifier: EPL-2.0
|
|
7
|
+
*******************************************************************************/
|
|
8
|
+
import type { GraphEdge } from "@statelyai/graph";
|
|
9
|
+
import type { AcyclicOrientation, LayerAssigner, LayeredPhaseInput } from "./types";
|
|
10
|
+
|
|
11
|
+
interface StretchNode {
|
|
12
|
+
id: string;
|
|
13
|
+
modelOrder: number;
|
|
14
|
+
index: number;
|
|
15
|
+
size: number;
|
|
16
|
+
rank: number;
|
|
17
|
+
incoming: StretchEdge[];
|
|
18
|
+
outgoing: StretchEdge[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface StretchEdge {
|
|
22
|
+
edge: GraphEdge;
|
|
23
|
+
source: StretchNode;
|
|
24
|
+
target: StretchNode;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function createGraph(input: LayeredPhaseInput, orientation: AcyclicOrientation): StretchNode[] {
|
|
28
|
+
const horizontal = input.direction === "left" || input.direction === "right";
|
|
29
|
+
const nodes = input.graph.nodes.map((node, modelOrder): StretchNode => ({
|
|
30
|
+
id: node.id,
|
|
31
|
+
modelOrder,
|
|
32
|
+
index: modelOrder,
|
|
33
|
+
size: horizontal
|
|
34
|
+
? (input.sizes.get(node.id)?.height ?? 0)
|
|
35
|
+
: (input.sizes.get(node.id)?.width ?? 0),
|
|
36
|
+
rank: 0,
|
|
37
|
+
incoming: [],
|
|
38
|
+
outgoing: [],
|
|
39
|
+
}));
|
|
40
|
+
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
|
41
|
+
for (const graphEdge of input.graph.edges) {
|
|
42
|
+
if (graphEdge.sourceId === graphEdge.targetId) continue;
|
|
43
|
+
const reversed = orientation.reversedEdgeIds.has(graphEdge.id);
|
|
44
|
+
const source = nodeById.get(reversed ? graphEdge.targetId : graphEdge.sourceId);
|
|
45
|
+
const target = nodeById.get(reversed ? graphEdge.sourceId : graphEdge.targetId);
|
|
46
|
+
if (!source || !target) continue;
|
|
47
|
+
const edge = { edge: graphEdge, source, target };
|
|
48
|
+
source.outgoing.push(edge);
|
|
49
|
+
target.incoming.push(edge);
|
|
50
|
+
}
|
|
51
|
+
for (const node of nodes) {
|
|
52
|
+
node.rank = Math.max(
|
|
53
|
+
node.outgoing.length,
|
|
54
|
+
...node.incoming.map((edge) => edge.source.outgoing.length),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
const sorted = [...nodes].sort(
|
|
58
|
+
(left, right) => right.rank - left.rank || left.modelOrder - right.modelOrder,
|
|
59
|
+
);
|
|
60
|
+
for (const [index, node] of sorted.entries()) node.index = index;
|
|
61
|
+
return sorted;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const assignLayersWithStretchWidth: LayerAssigner = (input, orientation) => {
|
|
65
|
+
if (input.graph.nodes.length === 0) return { layerByNodeId: new Map() };
|
|
66
|
+
const nodes = createGraph(input, orientation);
|
|
67
|
+
const minimumSize = Math.max(1, Math.min(...nodes.map((node) => node.size)));
|
|
68
|
+
const maximumSize = Math.max(1, Math.max(...nodes.map((node) => node.size)));
|
|
69
|
+
const normalizedSize = nodes.map((node) => node.size / minimumSize);
|
|
70
|
+
const dummySize = (input.settings["spacing.edgeEdge"] ?? 10) / minimumSize;
|
|
71
|
+
const indegree = nodes.map((node) => node.incoming.length);
|
|
72
|
+
const outdegree = nodes.map((node) => node.outgoing.length);
|
|
73
|
+
const averageOutdegree =
|
|
74
|
+
nodes.reduce((total, node) => total + node.outgoing.length, 0) / nodes.length;
|
|
75
|
+
let maximumWidth = maximumSize / minimumSize;
|
|
76
|
+
|
|
77
|
+
while (true) {
|
|
78
|
+
let widthCurrent = 0;
|
|
79
|
+
let widthUp = 0;
|
|
80
|
+
const layers: StretchNode[][] = [[]];
|
|
81
|
+
let currentLayer = layers[0] as StretchNode[];
|
|
82
|
+
const remainingNodes = [...nodes];
|
|
83
|
+
const remainingOutgoing = [...outdegree];
|
|
84
|
+
const alreadyPlacedInCurrentLayer = new Set<StretchNode>();
|
|
85
|
+
let reset = false;
|
|
86
|
+
|
|
87
|
+
while (remainingNodes.length > 0) {
|
|
88
|
+
const selected = remainingNodes.find((node) => (remainingOutgoing[node.index] ?? 0) <= 0);
|
|
89
|
+
const conditionGoUp = selected
|
|
90
|
+
? widthCurrent -
|
|
91
|
+
(outdegree[selected.index] ?? 0) * dummySize +
|
|
92
|
+
(normalizedSize[selected.index] ?? 0) >
|
|
93
|
+
maximumWidth ||
|
|
94
|
+
widthUp + (indegree[selected.index] ?? 0) * dummySize >
|
|
95
|
+
maximumWidth * averageOutdegree * dummySize
|
|
96
|
+
: false;
|
|
97
|
+
|
|
98
|
+
if (!selected || (conditionGoUp && alreadyPlacedInCurrentLayer.size > 0)) {
|
|
99
|
+
for (const node of currentLayer) {
|
|
100
|
+
for (const edge of node.incoming) {
|
|
101
|
+
remainingOutgoing[edge.source.index] = (remainingOutgoing[edge.source.index] ?? 1) - 1;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
currentLayer = [];
|
|
105
|
+
layers.push(currentLayer);
|
|
106
|
+
alreadyPlacedInCurrentLayer.clear();
|
|
107
|
+
widthCurrent = widthUp;
|
|
108
|
+
widthUp = 0;
|
|
109
|
+
} else if (conditionGoUp) {
|
|
110
|
+
maximumWidth++;
|
|
111
|
+
reset = true;
|
|
112
|
+
break;
|
|
113
|
+
} else {
|
|
114
|
+
currentLayer.push(selected);
|
|
115
|
+
remainingNodes.splice(remainingNodes.indexOf(selected), 1);
|
|
116
|
+
alreadyPlacedInCurrentLayer.add(selected);
|
|
117
|
+
widthCurrent =
|
|
118
|
+
widthCurrent -
|
|
119
|
+
(outdegree[selected.index] ?? 0) * dummySize +
|
|
120
|
+
(normalizedSize[selected.index] ?? 0);
|
|
121
|
+
widthUp += (indegree[selected.index] ?? 0) * dummySize;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (reset) continue;
|
|
126
|
+
const layerByNodeId = new Map<string, number>();
|
|
127
|
+
const layerCount = layers.length;
|
|
128
|
+
for (const [bottomUpLayer, layer] of layers.entries()) {
|
|
129
|
+
for (const node of layer) layerByNodeId.set(node.id, layerCount - bottomUpLayer - 1);
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
layerByNodeId,
|
|
133
|
+
seedOrder: [...layers].reverse().flatMap((layer) => layer.map((node) => node.id)),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { EntityRect, Graph, GraphEdge, GraphNode, GraphPort, Point } from "@statelyai/graph";
|
|
2
|
+
import type { LayoutConstraints } from "@statelyai/graph/layout";
|
|
3
|
+
import type { LayoutDirection } from "../types";
|
|
4
|
+
import type { ElkLayeredOptionValueByName, LayeredAdvancedOptions } from "./elk-options";
|
|
5
|
+
|
|
6
|
+
export interface NodeSize {
|
|
7
|
+
width: number;
|
|
8
|
+
height: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface LayeredSpacing {
|
|
12
|
+
node: number;
|
|
13
|
+
layer: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface LayoutPadding {
|
|
17
|
+
top: number;
|
|
18
|
+
right: number;
|
|
19
|
+
bottom: number;
|
|
20
|
+
left: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface LayeredPhaseInput {
|
|
24
|
+
graph: Graph<unknown, unknown, unknown, unknown>;
|
|
25
|
+
sizes: ReadonlyMap<string, NodeSize>;
|
|
26
|
+
direction: LayoutDirection;
|
|
27
|
+
spacing: LayeredSpacing;
|
|
28
|
+
padding: LayoutPadding;
|
|
29
|
+
constrainedLayerByNodeId: ReadonlyMap<string, number>;
|
|
30
|
+
settings: LayeredAdvancedOptions;
|
|
31
|
+
nodeSettings?: (node: GraphNode) => ElkLayeredOptionValueByName | undefined;
|
|
32
|
+
edgeSettings?: (edge: GraphEdge) => ElkLayeredOptionValueByName | undefined;
|
|
33
|
+
portSettings?: (port: GraphPort, node: GraphNode) => ElkLayeredOptionValueByName | undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface AcyclicOrientation {
|
|
37
|
+
reversedEdgeIds: ReadonlySet<string>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface LayerAssignment {
|
|
41
|
+
layerByNodeId: ReadonlyMap<string, number>;
|
|
42
|
+
/** Layer-internal seed order produced by layerers whose insertion order is observable. */
|
|
43
|
+
seedOrder?: readonly string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface LayerOrder {
|
|
47
|
+
layers: readonly (readonly string[])[];
|
|
48
|
+
/** Internal ELK sweep state retained for exact port-aware placement. */
|
|
49
|
+
inputPortOrderByNodeId?: ReadonlyMap<string, readonly string[]>;
|
|
50
|
+
/** Internal ELK sweep state retained for exact port-aware placement. */
|
|
51
|
+
outputPortOrderByNodeId?: ReadonlyMap<string, readonly string[]>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface NodePlacement {
|
|
55
|
+
rectByNodeId: ReadonlyMap<string, EntityRect>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface EdgeRoutes {
|
|
59
|
+
pointsByEdgeId: ReadonlyMap<string, readonly Point[]>;
|
|
60
|
+
/** ELK spline segment NUB controls retained until long-edge joining. */
|
|
61
|
+
splineNubControlsByEdgeId?: ReadonlyMap<string, readonly Point[]>;
|
|
62
|
+
/** Reversed fixed-side routes that must stay outside the node envelope during compaction. */
|
|
63
|
+
outsideFeedbackEdgeIds?: ReadonlySet<string>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type CycleBreaker = (input: LayeredPhaseInput) => AcyclicOrientation;
|
|
67
|
+
|
|
68
|
+
export type LayerAssigner = (
|
|
69
|
+
input: LayeredPhaseInput,
|
|
70
|
+
orientation: AcyclicOrientation,
|
|
71
|
+
) => LayerAssignment;
|
|
72
|
+
|
|
73
|
+
export type CrossingMinimizer = (
|
|
74
|
+
input: LayeredPhaseInput,
|
|
75
|
+
orientation: AcyclicOrientation,
|
|
76
|
+
assignment: LayerAssignment,
|
|
77
|
+
) => LayerOrder;
|
|
78
|
+
|
|
79
|
+
export type NodePlacer = (input: LayeredPhaseInput, order: LayerOrder) => NodePlacement;
|
|
80
|
+
|
|
81
|
+
export type EdgeRouter = (
|
|
82
|
+
input: LayeredPhaseInput,
|
|
83
|
+
orientation: AcyclicOrientation,
|
|
84
|
+
placement: NodePlacement,
|
|
85
|
+
) => EdgeRoutes;
|
|
86
|
+
|
|
87
|
+
export interface LayeredStrategies {
|
|
88
|
+
breakCycles?: CycleBreaker;
|
|
89
|
+
assignLayers?: LayerAssigner;
|
|
90
|
+
minimizeCrossings?: CrossingMinimizer;
|
|
91
|
+
placeNodes?: NodePlacer;
|
|
92
|
+
routeEdges?: EdgeRouter;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface LayeredLayoutOptions {
|
|
96
|
+
direction?: LayoutDirection;
|
|
97
|
+
spacing?: Partial<LayeredSpacing>;
|
|
98
|
+
padding?: number | Partial<LayoutPadding>;
|
|
99
|
+
constraints?: LayoutConstraints;
|
|
100
|
+
measure?: (node: GraphNode) => NodeSize;
|
|
101
|
+
crossingSweeps?: number;
|
|
102
|
+
strategies?: LayeredStrategies;
|
|
103
|
+
/** ELK-equivalent settings keyed by simplified names without vendor prefixes. */
|
|
104
|
+
settings?: LayeredAdvancedOptions;
|
|
105
|
+
/** Per-node settings for ELK options whose target is a node. */
|
|
106
|
+
nodeSettings?: (node: GraphNode) => ElkLayeredOptionValueByName | undefined;
|
|
107
|
+
/** Per-edge settings for ELK options whose target is an edge. */
|
|
108
|
+
edgeSettings?: (edge: GraphEdge) => ElkLayeredOptionValueByName | undefined;
|
|
109
|
+
/** Per-port settings for ELK options whose target is a port. */
|
|
110
|
+
portSettings?: (port: GraphPort, node: GraphNode) => ElkLayeredOptionValueByName | undefined;
|
|
111
|
+
}
|
package/src/layout.ts
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { getGraphIssues, type Graph, type GraphPatch, type VisualGraph } from "@statelyai/graph";
|
|
2
|
+
import { LayoutError, UnsupportedLayoutError } from "./errors";
|
|
3
|
+
import { boxAlgorithm } from "./box";
|
|
4
|
+
import { fixedAlgorithm } from "./fixed";
|
|
5
|
+
import { layeredAlgorithm } from "./layered";
|
|
6
|
+
import { rectanglePackingAlgorithm } from "./packing";
|
|
7
|
+
import { randomAlgorithm } from "./random";
|
|
8
|
+
import { sporeCompactionAlgorithm, sporeOverlapRemovalAlgorithm } from "./spore";
|
|
9
|
+
import type {
|
|
10
|
+
LayoutAlgorithm,
|
|
11
|
+
LayoutDiagnostic,
|
|
12
|
+
LayoutExecutionContext,
|
|
13
|
+
LayoutPhaseMetrics,
|
|
14
|
+
LayoutRequest,
|
|
15
|
+
LayoutResult,
|
|
16
|
+
LayoutScope,
|
|
17
|
+
} from "./types";
|
|
18
|
+
|
|
19
|
+
const algorithms = new Map<string, LayoutAlgorithm<never>>([
|
|
20
|
+
["box", boxAlgorithm as LayoutAlgorithm<never>],
|
|
21
|
+
["layered", layeredAlgorithm as LayoutAlgorithm<never>],
|
|
22
|
+
["fixed", fixedAlgorithm as LayoutAlgorithm<never>],
|
|
23
|
+
["rectpacking", rectanglePackingAlgorithm as LayoutAlgorithm<never>],
|
|
24
|
+
["random", randomAlgorithm as LayoutAlgorithm<never>],
|
|
25
|
+
["sporeCompaction", sporeCompactionAlgorithm as LayoutAlgorithm<never>],
|
|
26
|
+
["sporeOverlap", sporeOverlapRemovalAlgorithm as LayoutAlgorithm<never>],
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
function supportsScope(algorithm: LayoutAlgorithm<unknown>, scope: LayoutScope): boolean {
|
|
30
|
+
switch (scope.mode) {
|
|
31
|
+
case "full":
|
|
32
|
+
return algorithm.capabilities.full;
|
|
33
|
+
case "incremental":
|
|
34
|
+
return algorithm.capabilities.incremental;
|
|
35
|
+
case "partial":
|
|
36
|
+
return algorithm.capabilities.partial;
|
|
37
|
+
case "route-only":
|
|
38
|
+
return algorithm.capabilities.routeOnly;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function samePoints(
|
|
43
|
+
a: readonly { x: number; y: number }[] | undefined,
|
|
44
|
+
b: readonly { x: number; y: number }[] | undefined,
|
|
45
|
+
): boolean {
|
|
46
|
+
if (a === b) return true;
|
|
47
|
+
if (!a || !b || a.length !== b.length) return false;
|
|
48
|
+
return a.every((point, index) => {
|
|
49
|
+
const other = b[index];
|
|
50
|
+
return other !== undefined && point.x === other.x && point.y === other.y;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getLayoutPatches<N, E, G, P>(
|
|
55
|
+
input: Graph<N, E, G, P> | VisualGraph<N, E, G, P>,
|
|
56
|
+
output: VisualGraph<N, E, G, P>,
|
|
57
|
+
): GraphPatch<N, E>[] {
|
|
58
|
+
const patches: GraphPatch<N, E>[] = [];
|
|
59
|
+
const outputNodes = new Map(output.nodes.map((node) => [node.id, node]));
|
|
60
|
+
const outputEdges = new Map(output.edges.map((edge) => [edge.id, edge]));
|
|
61
|
+
|
|
62
|
+
for (const node of input.nodes) {
|
|
63
|
+
const next = outputNodes.get(node.id);
|
|
64
|
+
if (!next) continue;
|
|
65
|
+
if (
|
|
66
|
+
node.x !== next.x ||
|
|
67
|
+
node.y !== next.y ||
|
|
68
|
+
node.width !== next.width ||
|
|
69
|
+
node.height !== next.height ||
|
|
70
|
+
node.ports !== next.ports
|
|
71
|
+
) {
|
|
72
|
+
patches.push({
|
|
73
|
+
op: "updateNode",
|
|
74
|
+
id: node.id,
|
|
75
|
+
data: {
|
|
76
|
+
x: next.x,
|
|
77
|
+
y: next.y,
|
|
78
|
+
width: next.width,
|
|
79
|
+
height: next.height,
|
|
80
|
+
...(next.ports === undefined ? {} : { ports: next.ports }),
|
|
81
|
+
},
|
|
82
|
+
description: "Apply layout geometry",
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
for (const edge of input.edges) {
|
|
87
|
+
const next = outputEdges.get(edge.id);
|
|
88
|
+
if (!next) continue;
|
|
89
|
+
if (
|
|
90
|
+
edge.x !== next.x ||
|
|
91
|
+
edge.y !== next.y ||
|
|
92
|
+
edge.width !== next.width ||
|
|
93
|
+
edge.height !== next.height ||
|
|
94
|
+
edge.routing !== next.routing ||
|
|
95
|
+
!samePoints(edge.points, next.points)
|
|
96
|
+
) {
|
|
97
|
+
patches.push({
|
|
98
|
+
op: "updateEdge",
|
|
99
|
+
id: edge.id,
|
|
100
|
+
data: {
|
|
101
|
+
x: next.x,
|
|
102
|
+
y: next.y,
|
|
103
|
+
width: next.width,
|
|
104
|
+
height: next.height,
|
|
105
|
+
...(next.routing === undefined ? {} : { routing: next.routing }),
|
|
106
|
+
...(next.points === undefined ? {} : { points: next.points }),
|
|
107
|
+
},
|
|
108
|
+
description: "Apply layout geometry",
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return patches;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Register or replace a layout algorithm for subsequent `getLayout` calls. */
|
|
116
|
+
export function registerLayoutAlgorithm<O>(algorithm: LayoutAlgorithm<O>): () => void {
|
|
117
|
+
const previous = algorithms.get(algorithm.id);
|
|
118
|
+
algorithms.set(algorithm.id, algorithm as LayoutAlgorithm<never>);
|
|
119
|
+
return () => {
|
|
120
|
+
if (previous) algorithms.set(algorithm.id, previous);
|
|
121
|
+
else algorithms.delete(algorithm.id);
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function getLayoutAlgorithm(id: string): LayoutAlgorithm<unknown> | undefined {
|
|
126
|
+
return algorithms.get(id) as LayoutAlgorithm<unknown> | undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Run a registered or inline algorithm against an `@statelyai/graph` graph.
|
|
131
|
+
* The input is not mutated.
|
|
132
|
+
*/
|
|
133
|
+
export async function getLayout<N, E, G, P, O = unknown>(
|
|
134
|
+
request: LayoutRequest<N, E, G, P, O>,
|
|
135
|
+
): Promise<LayoutResult<N, E, G, P>> {
|
|
136
|
+
const startedAt = performance.now();
|
|
137
|
+
const scope = request.scope ?? { mode: "full" };
|
|
138
|
+
const diagnostics: LayoutDiagnostic[] = [];
|
|
139
|
+
const phases: LayoutPhaseMetrics[] = [];
|
|
140
|
+
const algorithm =
|
|
141
|
+
typeof request.algorithm === "object"
|
|
142
|
+
? request.algorithm
|
|
143
|
+
: getLayoutAlgorithm(request.algorithm ?? "layered");
|
|
144
|
+
|
|
145
|
+
if (!algorithm) {
|
|
146
|
+
throw new LayoutError(
|
|
147
|
+
`Unknown layout algorithm: ${request.algorithm ?? "layered"}`,
|
|
148
|
+
"UNKNOWN_ALGORITHM",
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
if (!supportsScope(algorithm as LayoutAlgorithm<unknown>, scope)) {
|
|
152
|
+
throw new UnsupportedLayoutError(`${algorithm.id} does not support ${scope.mode} layout yet`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const issues = getGraphIssues(request.graph as Graph);
|
|
156
|
+
if (issues.length > 0) {
|
|
157
|
+
throw new LayoutError(issues.map((issue) => issue.message).join("; "), "INVALID_GRAPH");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const context: LayoutExecutionContext = {
|
|
161
|
+
scope,
|
|
162
|
+
diagnostics,
|
|
163
|
+
...(request.signal === undefined ? {} : { signal: request.signal }),
|
|
164
|
+
measurePhase(id, run) {
|
|
165
|
+
const phaseStartedAt = performance.now();
|
|
166
|
+
const record = () => {
|
|
167
|
+
phases.push({ id, durationMs: performance.now() - phaseStartedAt });
|
|
168
|
+
};
|
|
169
|
+
try {
|
|
170
|
+
const result = run();
|
|
171
|
+
if (result instanceof Promise) {
|
|
172
|
+
return result.finally(record) as typeof result;
|
|
173
|
+
}
|
|
174
|
+
record();
|
|
175
|
+
return result;
|
|
176
|
+
} catch (error) {
|
|
177
|
+
record();
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
throwIfAborted() {
|
|
182
|
+
if (request.signal?.aborted) {
|
|
183
|
+
throw request.signal.reason ?? new Error("Layout aborted");
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
context.throwIfAborted();
|
|
188
|
+
const graph = await algorithm.layout(request.graph, request.options as O, context);
|
|
189
|
+
context.throwIfAborted();
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
graph,
|
|
193
|
+
patches: getLayoutPatches(request.graph, graph),
|
|
194
|
+
diagnostics,
|
|
195
|
+
metrics: {
|
|
196
|
+
durationMs: performance.now() - startedAt,
|
|
197
|
+
nodeCount: graph.nodes.length,
|
|
198
|
+
edgeCount: graph.edges.length,
|
|
199
|
+
phases,
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
package/src/packing.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Graph, VisualGraph, VisualNode } from "@statelyai/graph";
|
|
2
|
+
import { getNodeSize, type LayoutOptions } from "@statelyai/graph/layout";
|
|
3
|
+
import { getFixedLayout } from "./fixed";
|
|
4
|
+
import type { LayoutPadding } from "./layered";
|
|
5
|
+
import type { LayoutAlgorithm } from "./types";
|
|
6
|
+
|
|
7
|
+
export interface RectanglePackingLayoutOptions extends Pick<
|
|
8
|
+
LayoutOptions,
|
|
9
|
+
"direction" | "measure"
|
|
10
|
+
> {
|
|
11
|
+
spacing?: number;
|
|
12
|
+
padding?: number | Partial<LayoutPadding>;
|
|
13
|
+
targetWidth?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function getPadding(value: RectanglePackingLayoutOptions["padding"]): LayoutPadding {
|
|
17
|
+
if (typeof value === "number") {
|
|
18
|
+
return { top: value, right: value, bottom: value, left: value };
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
top: value?.top ?? 0,
|
|
22
|
+
right: value?.right ?? 0,
|
|
23
|
+
bottom: value?.bottom ?? 0,
|
|
24
|
+
left: value?.left ?? 0,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Deterministic shelf-based rectangle packing for `@statelyai/graph`. */
|
|
29
|
+
export function getRectanglePackingLayout<N, E, G, P>(
|
|
30
|
+
graph: Graph<N, E, G, P> | VisualGraph<N, E, G, P>,
|
|
31
|
+
options: RectanglePackingLayoutOptions = {},
|
|
32
|
+
): VisualGraph<N, E, G, P> {
|
|
33
|
+
const spacing = options.spacing ?? 20;
|
|
34
|
+
const padding = getPadding(options.padding);
|
|
35
|
+
const sizes = new Map(graph.nodes.map((node) => [node.id, getNodeSize(node, options)]));
|
|
36
|
+
const totalArea = [...sizes.values()].reduce(
|
|
37
|
+
(area, size) => area + (size.width + spacing) * (size.height + spacing),
|
|
38
|
+
0,
|
|
39
|
+
);
|
|
40
|
+
const targetWidth = options.targetWidth ?? Math.max(1, Math.sqrt(totalArea) * 1.5);
|
|
41
|
+
let x = padding.left;
|
|
42
|
+
let y = padding.top;
|
|
43
|
+
let rowHeight = 0;
|
|
44
|
+
const nodes: VisualNode<N, P>[] = [];
|
|
45
|
+
|
|
46
|
+
for (const node of graph.nodes) {
|
|
47
|
+
const size = sizes.get(node.id) ?? { width: 0, height: 0 };
|
|
48
|
+
if (x > padding.left && x + size.width > padding.left + targetWidth) {
|
|
49
|
+
x = padding.left;
|
|
50
|
+
y += rowHeight + spacing;
|
|
51
|
+
rowHeight = 0;
|
|
52
|
+
}
|
|
53
|
+
nodes.push({ ...node, x, y, ...size } as VisualNode<N, P>);
|
|
54
|
+
x += size.width + spacing;
|
|
55
|
+
rowHeight = Math.max(rowHeight, size.height);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return getFixedLayout({ ...graph, nodes }, { direction: options.direction ?? graph.direction });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const rectanglePackingAlgorithm: LayoutAlgorithm<RectanglePackingLayoutOptions> = {
|
|
62
|
+
id: "rectpacking",
|
|
63
|
+
capabilities: {
|
|
64
|
+
full: true,
|
|
65
|
+
incremental: false,
|
|
66
|
+
partial: false,
|
|
67
|
+
routeOnly: false,
|
|
68
|
+
hierarchy: false,
|
|
69
|
+
ports: true,
|
|
70
|
+
},
|
|
71
|
+
layout(graph, options) {
|
|
72
|
+
return getRectanglePackingLayout(graph, options ?? {});
|
|
73
|
+
},
|
|
74
|
+
};
|
package/src/random.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/*******************************************************************************
|
|
2
|
+
* Copyright (c) 2010, 2015 Kiel University and others.
|
|
3
|
+
*
|
|
4
|
+
* Translated from ELK v0.11.0 RandomLayoutProvider.java.
|
|
5
|
+
* Source commit: 54123e884b1ae743b453260f713b20c9bf5787f2
|
|
6
|
+
* SPDX-License-Identifier: EPL-2.0
|
|
7
|
+
*******************************************************************************/
|
|
8
|
+
import type { Graph, Point, VisualGraph, VisualNode } from "@statelyai/graph";
|
|
9
|
+
import { getNodeSize, type LayoutOptions } from "@statelyai/graph/layout";
|
|
10
|
+
import { getFixedLayout } from "./fixed";
|
|
11
|
+
import { JavaRandom } from "./java-random";
|
|
12
|
+
import type { LayoutPadding } from "./layered";
|
|
13
|
+
import type { LayoutAlgorithm } from "./types";
|
|
14
|
+
|
|
15
|
+
export interface RandomLayoutOptions extends Pick<LayoutOptions, "direction" | "measure"> {
|
|
16
|
+
spacing?: number;
|
|
17
|
+
padding?: number | Partial<LayoutPadding>;
|
|
18
|
+
aspectRatio?: number;
|
|
19
|
+
seed?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function getPadding(value: RandomLayoutOptions["padding"]): LayoutPadding {
|
|
23
|
+
if (typeof value === "number") {
|
|
24
|
+
return { top: value, right: value, bottom: value, left: value };
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
top: value?.top ?? 15,
|
|
28
|
+
right: value?.right ?? 15,
|
|
29
|
+
bottom: value?.bottom ?? 15,
|
|
30
|
+
left: value?.left ?? 15,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function borderPoint(source: VisualNode, target: VisualNode): Point {
|
|
35
|
+
const sourceCenter = {
|
|
36
|
+
x: source.x + source.width / 2,
|
|
37
|
+
y: source.y + source.height / 2,
|
|
38
|
+
};
|
|
39
|
+
const targetCenter = {
|
|
40
|
+
x: target.x + target.width / 2,
|
|
41
|
+
y: target.y + target.height / 2,
|
|
42
|
+
};
|
|
43
|
+
const dx = targetCenter.x - sourceCenter.x;
|
|
44
|
+
const dy = targetCenter.y - sourceCenter.y;
|
|
45
|
+
if (dx === 0 && dy === 0) return { x: source.x + source.width, y: sourceCenter.y };
|
|
46
|
+
const scale = Math.min(
|
|
47
|
+
dx === 0 ? Infinity : source.width / 2 / Math.abs(dx),
|
|
48
|
+
dy === 0 ? Infinity : source.height / 2 / Math.abs(dy),
|
|
49
|
+
);
|
|
50
|
+
return { x: sourceCenter.x + dx * scale, y: sourceCenter.y + dy * scale };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Seeded random distribution using Java's 48-bit `Random` sequence. */
|
|
54
|
+
export function getRandomLayout<N, E, G, P>(
|
|
55
|
+
graph: Graph<N, E, G, P> | VisualGraph<N, E, G, P>,
|
|
56
|
+
options: RandomLayoutOptions = {},
|
|
57
|
+
): VisualGraph<N, E, G, P> {
|
|
58
|
+
if (graph.nodes.length === 0) return getFixedLayout(graph, options);
|
|
59
|
+
const random = new JavaRandom(options.seed && options.seed !== 0 ? options.seed : Date.now());
|
|
60
|
+
const aspectRatio = Math.fround(options.aspectRatio ?? 1.6);
|
|
61
|
+
const spacing = Math.fround(options.spacing ?? 15);
|
|
62
|
+
const padding = getPadding(options.padding);
|
|
63
|
+
const sizes = new Map(graph.nodes.map((node) => [node.id, getNodeSize(node, options)]));
|
|
64
|
+
const nodeArea = [...sizes.values()].reduce((sum, size) => sum + size.width * size.height, 0);
|
|
65
|
+
const maximumWidth = Math.max(...[...sizes.values()].map((size) => size.width));
|
|
66
|
+
const maximumHeight = Math.max(...[...sizes.values()].map((size) => size.height));
|
|
67
|
+
const edgeFactor = 1 + graph.edges.length;
|
|
68
|
+
const drawArea = nodeArea + 2 * spacing * spacing * edgeFactor * graph.nodes.length;
|
|
69
|
+
const areaRoot = Math.sqrt(drawArea);
|
|
70
|
+
const drawWidth = Math.max(areaRoot * aspectRatio, maximumWidth);
|
|
71
|
+
const drawHeight = Math.max(areaRoot / aspectRatio, maximumHeight);
|
|
72
|
+
const nodes = graph.nodes.map((node): VisualNode<N, P> => {
|
|
73
|
+
const size = sizes.get(node.id) ?? { width: 0, height: 0 };
|
|
74
|
+
return {
|
|
75
|
+
...node,
|
|
76
|
+
x: padding.left + random.nextDouble() * (drawWidth - size.width),
|
|
77
|
+
// ELK v0.11.0 intentionally uses left padding for both axes here.
|
|
78
|
+
y: padding.left + random.nextDouble() * (drawHeight - size.height),
|
|
79
|
+
...size,
|
|
80
|
+
} as VisualNode<N, P>;
|
|
81
|
+
});
|
|
82
|
+
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
|
83
|
+
const totalWidth = drawWidth + padding.left + padding.right;
|
|
84
|
+
const totalHeight = drawHeight + padding.top + padding.bottom;
|
|
85
|
+
const edges = graph.edges.map((edge) => {
|
|
86
|
+
const source = nodeById.get(edge.sourceId);
|
|
87
|
+
const target = nodeById.get(edge.targetId);
|
|
88
|
+
if (!source || !target) return edge;
|
|
89
|
+
const start = borderPoint(source, target);
|
|
90
|
+
const end = borderPoint(target, source);
|
|
91
|
+
const bendCount = random.nextInt(5) + (source === target ? 1 : 0);
|
|
92
|
+
const distance = Math.hypot(end.x - start.x, end.y - start.y);
|
|
93
|
+
const maximumDeviation = distance * 0.2;
|
|
94
|
+
const points: Point[] = [start];
|
|
95
|
+
for (let index = 1; index <= bendCount; index++) {
|
|
96
|
+
const progress = index / (bendCount + 1);
|
|
97
|
+
points.push({
|
|
98
|
+
x: Math.min(
|
|
99
|
+
totalWidth - 1,
|
|
100
|
+
Math.max(
|
|
101
|
+
1,
|
|
102
|
+
start.x +
|
|
103
|
+
(end.x - start.x) * progress +
|
|
104
|
+
random.nextFloat() * maximumDeviation -
|
|
105
|
+
maximumDeviation / 2,
|
|
106
|
+
),
|
|
107
|
+
),
|
|
108
|
+
y: Math.min(
|
|
109
|
+
totalHeight - 1,
|
|
110
|
+
Math.max(
|
|
111
|
+
1,
|
|
112
|
+
start.y +
|
|
113
|
+
(end.y - start.y) * progress +
|
|
114
|
+
random.nextFloat() * maximumDeviation -
|
|
115
|
+
maximumDeviation / 2,
|
|
116
|
+
),
|
|
117
|
+
),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
points.push(end);
|
|
121
|
+
return { ...edge, points, routing: "polyline" as const };
|
|
122
|
+
});
|
|
123
|
+
return getFixedLayout(
|
|
124
|
+
{ ...graph, nodes, edges },
|
|
125
|
+
{ direction: options.direction ?? graph.direction },
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export const randomAlgorithm: LayoutAlgorithm<RandomLayoutOptions> = {
|
|
130
|
+
id: "random",
|
|
131
|
+
capabilities: {
|
|
132
|
+
full: true,
|
|
133
|
+
incremental: false,
|
|
134
|
+
partial: false,
|
|
135
|
+
routeOnly: false,
|
|
136
|
+
hierarchy: false,
|
|
137
|
+
ports: false,
|
|
138
|
+
},
|
|
139
|
+
layout(graph, options) {
|
|
140
|
+
return getRandomLayout(graph, options ?? {});
|
|
141
|
+
},
|
|
142
|
+
};
|