@zvk/graphs 0.1.1 → 0.1.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/CHANGELOG.md +7 -0
- package/README.md +376 -4
- package/dist/algorithms.d.ts +73 -1
- package/dist/algorithms.js +287 -0
- package/dist/diagnostics.d.ts +74 -2
- package/dist/diagnostics.js +577 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/layout/dag.d.ts +5 -2
- package/dist/layout/dag.js +38 -12
- package/dist/layout/manual.d.ts +3 -2
- package/dist/layout/manual.js +11 -8
- package/dist/layout/tree.d.ts +3 -2
- package/dist/layout/tree.js +15 -8
- package/dist/layout/types.d.ts +76 -2
- package/dist/layout/types.js +336 -9
- package/dist/model.d.ts +47 -0
- package/dist/serialization.d.ts +14 -0
- package/dist/serialization.js +68 -0
- package/dist/styles.css +209 -7
- package/dist/svg.js +234 -19
- package/package.json +22 -8
package/dist/layout/dag.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createGraphIndex, detectGraphCycle, topologicalSortGraph } from "../algorithms.js";
|
|
2
|
-
import {
|
|
2
|
+
import { validateGraphLayout } from "../diagnostics.js";
|
|
3
|
+
import { defaultGraphNodeSize, edgesWithPoints, getGraphEdgeLabelOverlapDiagnostics } from "./types.js";
|
|
3
4
|
function rankedPoint(rank, indexInRank, size, nodeGap, rankGap, direction) {
|
|
4
5
|
if (direction === "left-right") {
|
|
5
6
|
return {
|
|
@@ -12,8 +13,23 @@ function rankedPoint(rank, indexInRank, size, nodeGap, rankGap, direction) {
|
|
|
12
13
|
y: rank * (size.height + rankGap)
|
|
13
14
|
};
|
|
14
15
|
}
|
|
16
|
+
function hintedNumber(values, nodeId) {
|
|
17
|
+
const value = values?.[nodeId];
|
|
18
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
19
|
+
}
|
|
20
|
+
function sortRankBucket(nodeIds, inputOrderByNodeId, orderByNodeId) {
|
|
21
|
+
return [...nodeIds].sort((left, right) => {
|
|
22
|
+
const leftOrder = hintedNumber(orderByNodeId, left) ?? inputOrderByNodeId.get(left) ?? 0;
|
|
23
|
+
const rightOrder = hintedNumber(orderByNodeId, right) ?? inputOrderByNodeId.get(right) ?? 0;
|
|
24
|
+
if (leftOrder !== rightOrder) {
|
|
25
|
+
return leftOrder - rightOrder;
|
|
26
|
+
}
|
|
27
|
+
return (inputOrderByNodeId.get(left) ?? 0) - (inputOrderByNodeId.get(right) ?? 0);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
15
30
|
export function layoutDagGraph(graph, options = {}) {
|
|
16
31
|
const cycle = detectGraphCycle(graph);
|
|
32
|
+
const layoutDiagnostics = validateGraphLayout(graph, options);
|
|
17
33
|
if (cycle) {
|
|
18
34
|
const diagnostic = {
|
|
19
35
|
code: "cycle-detected",
|
|
@@ -26,7 +42,7 @@ export function layoutDagGraph(graph, options = {}) {
|
|
|
26
42
|
nodes: [],
|
|
27
43
|
edges: []
|
|
28
44
|
},
|
|
29
|
-
diagnostics: [diagnostic]
|
|
45
|
+
diagnostics: [diagnostic, ...layoutDiagnostics]
|
|
30
46
|
};
|
|
31
47
|
}
|
|
32
48
|
const size = options.nodeSize ?? defaultGraphNodeSize;
|
|
@@ -36,11 +52,17 @@ export function layoutDagGraph(graph, options = {}) {
|
|
|
36
52
|
const index = createGraphIndex(graph);
|
|
37
53
|
const sorted = topologicalSortGraph(graph);
|
|
38
54
|
const ranks = new Map();
|
|
55
|
+
const rankStrategy = options.rankStrategy ?? "longest-path";
|
|
56
|
+
const inputOrderByNodeId = new Map(graph.nodes.map((node, index) => [node.id, index]));
|
|
39
57
|
for (const nodeId of sorted.nodeIds) {
|
|
40
58
|
const incoming = index.incomingByNodeId.get(nodeId) ?? [];
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
59
|
+
const incomingRanks = incoming.map((edge) => (ranks.get(edge.source) ?? 0) + 1);
|
|
60
|
+
const computedRank = incomingRanks.length === 0
|
|
61
|
+
? 0
|
|
62
|
+
: rankStrategy === "source-depth"
|
|
63
|
+
? Math.min(...incomingRanks)
|
|
64
|
+
: Math.max(...incomingRanks);
|
|
65
|
+
const rank = hintedNumber(options.rankByNodeId, nodeId) ?? computedRank;
|
|
44
66
|
ranks.set(nodeId, rank);
|
|
45
67
|
}
|
|
46
68
|
const rankBuckets = new Map();
|
|
@@ -48,9 +70,10 @@ export function layoutDagGraph(graph, options = {}) {
|
|
|
48
70
|
const rank = ranks.get(node.id) ?? 0;
|
|
49
71
|
rankBuckets.set(rank, [...(rankBuckets.get(rank) ?? []), node.id]);
|
|
50
72
|
}
|
|
73
|
+
const orderedRankBuckets = new Map([...rankBuckets.entries()].map(([rank, nodeIds]) => [rank, sortRankBucket(nodeIds, inputOrderByNodeId, options.orderByNodeId)]));
|
|
51
74
|
const nodes = graph.nodes.map((node) => {
|
|
52
75
|
const rank = ranks.get(node.id) ?? 0;
|
|
53
|
-
const indexInRank =
|
|
76
|
+
const indexInRank = orderedRankBuckets.get(rank)?.indexOf(node.id) ?? 0;
|
|
54
77
|
return {
|
|
55
78
|
...node,
|
|
56
79
|
position: rankedPoint(rank, indexInRank, size, nodeGap, rankGap, direction),
|
|
@@ -58,12 +81,15 @@ export function layoutDagGraph(graph, options = {}) {
|
|
|
58
81
|
};
|
|
59
82
|
});
|
|
60
83
|
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
|
84
|
+
const edges = edgesWithPoints(graph.edges, nodeById, options);
|
|
85
|
+
const positionedGraph = {
|
|
86
|
+
...graph,
|
|
87
|
+
nodes,
|
|
88
|
+
edges
|
|
89
|
+
};
|
|
90
|
+
const edgeLabelDiagnostics = options.warnEdgeLabelOverlaps === true ? getGraphEdgeLabelOverlapDiagnostics(positionedGraph) : [];
|
|
61
91
|
return {
|
|
62
|
-
graph:
|
|
63
|
-
|
|
64
|
-
nodes,
|
|
65
|
-
edges: graph.edges.map((edge) => edgeWithPoints(edge, nodeById))
|
|
66
|
-
},
|
|
67
|
-
diagnostics: sorted.diagnostics
|
|
92
|
+
graph: positionedGraph,
|
|
93
|
+
diagnostics: [...sorted.diagnostics, ...layoutDiagnostics, ...edgeLabelDiagnostics]
|
|
68
94
|
};
|
|
69
95
|
}
|
package/dist/layout/manual.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { type ValidateGraphLayoutOptions } from "../diagnostics.js";
|
|
1
2
|
import type { GraphModel, GraphSize } from "../model.js";
|
|
2
|
-
import { type GraphLayoutResult } from "./types.js";
|
|
3
|
-
export interface ManualLayoutOptions {
|
|
3
|
+
import { type GraphEdgesWithPointsOptions, type GraphLayoutResult } from "./types.js";
|
|
4
|
+
export interface ManualLayoutOptions extends ValidateGraphLayoutOptions, GraphEdgesWithPointsOptions {
|
|
4
5
|
readonly defaultNodeSize?: GraphSize;
|
|
5
6
|
readonly missingPosition?: "error" | "grid" | "zero";
|
|
6
7
|
}
|
package/dist/layout/manual.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { validateGraphLayout } from "../diagnostics.js";
|
|
2
|
+
import { defaultGraphNodeSize, edgesWithPoints, getGraphEdgeLabelOverlapDiagnostics } from "./types.js";
|
|
2
3
|
function gridPosition(index, size) {
|
|
3
4
|
const columns = 4;
|
|
4
5
|
return {
|
|
@@ -27,13 +28,15 @@ export function layoutManualGraph(graph, options = {}) {
|
|
|
27
28
|
};
|
|
28
29
|
});
|
|
29
30
|
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
|
30
|
-
const edges = graph.edges
|
|
31
|
+
const edges = edgesWithPoints(graph.edges, nodeById, options);
|
|
32
|
+
const positionedGraph = {
|
|
33
|
+
...graph,
|
|
34
|
+
nodes,
|
|
35
|
+
edges
|
|
36
|
+
};
|
|
37
|
+
const edgeLabelDiagnostics = options.warnEdgeLabelOverlaps === true ? getGraphEdgeLabelOverlapDiagnostics(positionedGraph) : [];
|
|
31
38
|
return {
|
|
32
|
-
graph:
|
|
33
|
-
|
|
34
|
-
nodes,
|
|
35
|
-
edges
|
|
36
|
-
},
|
|
37
|
-
diagnostics
|
|
39
|
+
graph: positionedGraph,
|
|
40
|
+
diagnostics: [...diagnostics, ...validateGraphLayout(graph, options), ...edgeLabelDiagnostics]
|
|
38
41
|
};
|
|
39
42
|
}
|
package/dist/layout/tree.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { type ValidateGraphLayoutOptions } from "../diagnostics.js";
|
|
1
2
|
import type { GraphModel, GraphSize } from "../model.js";
|
|
2
|
-
import { type GraphLayoutResult } from "./types.js";
|
|
3
|
-
export interface TreeLayoutOptions {
|
|
3
|
+
import { type GraphEdgesWithPointsOptions, type GraphLayoutResult } from "./types.js";
|
|
4
|
+
export interface TreeLayoutOptions extends ValidateGraphLayoutOptions, GraphEdgesWithPointsOptions {
|
|
4
5
|
readonly direction?: "top-bottom" | "left-right";
|
|
5
6
|
readonly nodeSize?: GraphSize;
|
|
6
7
|
readonly siblingGap?: number;
|
package/dist/layout/tree.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createGraphIndex, validateTreeGraph } from "../algorithms.js";
|
|
2
|
-
import {
|
|
2
|
+
import { validateGraphLayout } from "../diagnostics.js";
|
|
3
|
+
import { defaultGraphNodeSize, edgesWithPoints, getGraphEdgeLabelOverlapDiagnostics } from "./types.js";
|
|
3
4
|
function orientPoint(depth, breadth, size, levelGap, direction) {
|
|
4
5
|
if (direction === "left-right") {
|
|
5
6
|
return {
|
|
@@ -13,7 +14,10 @@ function orientPoint(depth, breadth, size, levelGap, direction) {
|
|
|
13
14
|
};
|
|
14
15
|
}
|
|
15
16
|
export function layoutTreeGraph(graph, options = {}) {
|
|
16
|
-
const diagnostics =
|
|
17
|
+
const diagnostics = [
|
|
18
|
+
...validateTreeGraph(graph, options.rootId ? { rootId: options.rootId } : {}),
|
|
19
|
+
...validateGraphLayout(graph, options)
|
|
20
|
+
];
|
|
17
21
|
const size = options.nodeSize ?? defaultGraphNodeSize;
|
|
18
22
|
const siblingGap = options.siblingGap ?? 32;
|
|
19
23
|
const levelGap = options.levelGap ?? 96;
|
|
@@ -57,12 +61,15 @@ export function layoutTreeGraph(graph, options = {}) {
|
|
|
57
61
|
};
|
|
58
62
|
});
|
|
59
63
|
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
|
64
|
+
const edges = edgesWithPoints(graph.edges, nodeById, options);
|
|
65
|
+
const positionedGraph = {
|
|
66
|
+
...graph,
|
|
67
|
+
nodes,
|
|
68
|
+
edges
|
|
69
|
+
};
|
|
70
|
+
const edgeLabelDiagnostics = options.warnEdgeLabelOverlaps === true ? getGraphEdgeLabelOverlapDiagnostics(positionedGraph) : [];
|
|
60
71
|
return {
|
|
61
|
-
graph:
|
|
62
|
-
|
|
63
|
-
nodes,
|
|
64
|
-
edges: graph.edges.map((edge) => edgeWithPoints(edge, nodeById))
|
|
65
|
-
},
|
|
66
|
-
diagnostics
|
|
72
|
+
graph: positionedGraph,
|
|
73
|
+
diagnostics: [...diagnostics, ...edgeLabelDiagnostics]
|
|
67
74
|
};
|
|
68
75
|
}
|
package/dist/layout/types.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { GraphDiagnostic } from "../diagnostics.js";
|
|
2
|
-
import type { GraphEdge, GraphModel, GraphNode, GraphPoint, GraphSize } from "../model.js";
|
|
2
|
+
import type { GraphEdge, GraphId, GraphModel, GraphNode, GraphPoint, GraphSize } from "../model.js";
|
|
3
3
|
export interface PositionedGraphNode<TData = unknown, TKind extends string = string> extends GraphNode<TData, TKind> {
|
|
4
4
|
readonly position: GraphPoint;
|
|
5
5
|
readonly size: GraphSize;
|
|
@@ -7,6 +7,8 @@ export interface PositionedGraphNode<TData = unknown, TKind extends string = str
|
|
|
7
7
|
export interface PositionedGraphEdge<TData = unknown, TKind extends string = string> extends GraphEdge<TData, TKind> {
|
|
8
8
|
readonly points: readonly GraphPoint[];
|
|
9
9
|
readonly labelPosition?: GraphPoint;
|
|
10
|
+
readonly labelSize?: GraphSize;
|
|
11
|
+
readonly routeKind?: GraphEdgeRouteKind;
|
|
10
12
|
}
|
|
11
13
|
export interface PositionedGraph<TNodeData = unknown, TEdgeData = unknown, TNodeKind extends string = string, TEdgeKind extends string = string> extends Omit<GraphModel<TNodeData, TEdgeData, TNodeKind, TEdgeKind>, "nodes" | "edges"> {
|
|
12
14
|
readonly nodes: readonly PositionedGraphNode<TNodeData, TNodeKind>[];
|
|
@@ -17,5 +19,77 @@ export interface GraphLayoutResult<TNodeData = unknown, TEdgeData = unknown, TNo
|
|
|
17
19
|
readonly diagnostics: readonly GraphDiagnostic[];
|
|
18
20
|
}
|
|
19
21
|
export declare const defaultGraphNodeSize: GraphSize;
|
|
22
|
+
export type GraphEdgeRouteKind = "straight" | "curve" | "elbow" | "self-loop";
|
|
23
|
+
export interface GraphEdgeRouteOptions {
|
|
24
|
+
readonly kind?: GraphEdgeRouteKind;
|
|
25
|
+
readonly parallelIndex?: number;
|
|
26
|
+
readonly parallelCount?: number;
|
|
27
|
+
readonly parallelGap?: number;
|
|
28
|
+
readonly selfLoopRadius?: number;
|
|
29
|
+
}
|
|
30
|
+
export interface GraphEdgesWithPointsOptions {
|
|
31
|
+
readonly edgeRouteKind?: GraphEdgeRouteKind;
|
|
32
|
+
readonly parallelGap?: number;
|
|
33
|
+
readonly routeByEdgeId?: Readonly<Record<string, GraphEdgeRouteKind>>;
|
|
34
|
+
readonly selfLoopRadius?: number;
|
|
35
|
+
}
|
|
36
|
+
export interface GraphEdgeRoute {
|
|
37
|
+
readonly kind: GraphEdgeRouteKind;
|
|
38
|
+
readonly points: readonly GraphPoint[];
|
|
39
|
+
readonly labelPosition?: GraphPoint;
|
|
40
|
+
}
|
|
41
|
+
export type GraphEdgeLabelSide = "top" | "right" | "bottom" | "left" | "center";
|
|
42
|
+
export type GraphEdgeLabelTextAnchor = "start" | "middle" | "end";
|
|
43
|
+
export interface GraphEdgeLabelBounds {
|
|
44
|
+
readonly x: number;
|
|
45
|
+
readonly y: number;
|
|
46
|
+
readonly width: number;
|
|
47
|
+
readonly height: number;
|
|
48
|
+
}
|
|
49
|
+
export interface GraphEdgeLabelAnchor {
|
|
50
|
+
readonly anchor: GraphEdgeLabelTextAnchor;
|
|
51
|
+
readonly bounds: GraphEdgeLabelBounds;
|
|
52
|
+
readonly side: GraphEdgeLabelSide;
|
|
53
|
+
readonly textPosition: GraphPoint;
|
|
54
|
+
}
|
|
55
|
+
export interface GraphEdgeLabelAnchorOptions {
|
|
56
|
+
readonly characterWidth?: number;
|
|
57
|
+
readonly height?: number;
|
|
58
|
+
readonly minWidth?: number;
|
|
59
|
+
readonly paddingX?: number;
|
|
60
|
+
readonly textBaselineOffset?: number;
|
|
61
|
+
readonly textTopOffset?: number;
|
|
62
|
+
}
|
|
63
|
+
export interface GraphEdgeLabelOverlapOptions extends GraphEdgeLabelAnchorOptions {
|
|
64
|
+
readonly padding?: number;
|
|
65
|
+
}
|
|
66
|
+
export interface GraphBounds {
|
|
67
|
+
readonly x: number;
|
|
68
|
+
readonly y: number;
|
|
69
|
+
readonly width: number;
|
|
70
|
+
readonly height: number;
|
|
71
|
+
readonly viewBox: string;
|
|
72
|
+
}
|
|
73
|
+
export interface GraphBoundsOptions {
|
|
74
|
+
readonly padding?: number;
|
|
75
|
+
readonly minWidth?: number;
|
|
76
|
+
readonly minHeight?: number;
|
|
77
|
+
readonly includeEdges?: boolean;
|
|
78
|
+
}
|
|
79
|
+
export interface GraphGroupBoundsOptions {
|
|
80
|
+
readonly padding?: number;
|
|
81
|
+
}
|
|
82
|
+
export interface DerivedGraphGroupBounds {
|
|
83
|
+
readonly groupId: GraphId;
|
|
84
|
+
readonly position: GraphPoint;
|
|
85
|
+
readonly size: GraphSize;
|
|
86
|
+
readonly nodeIds: readonly GraphId[];
|
|
87
|
+
}
|
|
20
88
|
export declare function nodeCenter(node: PositionedGraphNode): GraphPoint;
|
|
21
|
-
export declare function
|
|
89
|
+
export declare function deriveGraphEdgeLabelAnchor(edge: PositionedGraphEdge, options?: GraphEdgeLabelAnchorOptions): GraphEdgeLabelAnchor | undefined;
|
|
90
|
+
export declare function getGraphEdgeLabelOverlapDiagnostics(graph: PositionedGraph, options?: GraphEdgeLabelOverlapOptions): readonly GraphDiagnostic[];
|
|
91
|
+
export declare function deriveGraphGroupBounds(graph: PositionedGraph, options?: GraphGroupBoundsOptions): readonly DerivedGraphGroupBounds[];
|
|
92
|
+
export declare function routeGraphEdge(source: PositionedGraphNode, target: PositionedGraphNode, options?: GraphEdgeRouteOptions): GraphEdgeRoute;
|
|
93
|
+
export declare function getGraphBounds(graph: PositionedGraph, options?: GraphBoundsOptions): GraphBounds;
|
|
94
|
+
export declare function edgeWithPoints<TNodeData = unknown, TEdgeData = unknown, TNodeKind extends string = string, TEdgeKind extends string = string>(edge: GraphEdge<TEdgeData, TEdgeKind>, nodes: ReadonlyMap<string, PositionedGraphNode<TNodeData, TNodeKind>>, options?: GraphEdgeRouteOptions): PositionedGraphEdge<TEdgeData, TEdgeKind>;
|
|
95
|
+
export declare function edgesWithPoints<TNodeData = unknown, TEdgeData = unknown, TNodeKind extends string = string, TEdgeKind extends string = string>(edges: readonly GraphEdge<TEdgeData, TEdgeKind>[], nodes: ReadonlyMap<string, PositionedGraphNode<TNodeData, TNodeKind>>, options?: GraphEdgesWithPointsOptions): readonly PositionedGraphEdge<TEdgeData, TEdgeKind>[];
|
package/dist/layout/types.js
CHANGED
|
@@ -5,15 +5,342 @@ export function nodeCenter(node) {
|
|
|
5
5
|
y: node.position.y + node.size.height / 2
|
|
6
6
|
};
|
|
7
7
|
}
|
|
8
|
-
|
|
8
|
+
function midpoint(start, end) {
|
|
9
|
+
return {
|
|
10
|
+
x: normalizeNumber((start.x + end.x) / 2),
|
|
11
|
+
y: normalizeNumber((start.y + end.y) / 2)
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
function normalizeNumber(value) {
|
|
15
|
+
return Object.is(value, -0) ? 0 : value;
|
|
16
|
+
}
|
|
17
|
+
function offsetPoint(start, end, distance) {
|
|
18
|
+
if (distance === 0) {
|
|
19
|
+
return { x: 0, y: 0 };
|
|
20
|
+
}
|
|
21
|
+
const dx = end.x - start.x;
|
|
22
|
+
const dy = end.y - start.y;
|
|
23
|
+
const length = Math.hypot(dx, dy);
|
|
24
|
+
if (length === 0) {
|
|
25
|
+
return { x: distance, y: -distance };
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
x: normalizeNumber((-dy / length) * distance),
|
|
29
|
+
y: normalizeNumber((dx / length) * distance)
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function parallelOffset(options) {
|
|
33
|
+
const parallelCount = options.parallelCount ?? 1;
|
|
34
|
+
const parallelIndex = options.parallelIndex ?? 0;
|
|
35
|
+
const parallelGap = options.parallelGap ?? 14;
|
|
36
|
+
if (parallelCount <= 1) {
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
return (parallelIndex - (parallelCount - 1) / 2) * parallelGap;
|
|
40
|
+
}
|
|
41
|
+
function labelSide(edge, labelPosition) {
|
|
42
|
+
const start = edge.points[0];
|
|
43
|
+
const end = edge.points.at(-1);
|
|
44
|
+
if (!start || !end) {
|
|
45
|
+
return "center";
|
|
46
|
+
}
|
|
47
|
+
const middle = midpoint(start, end);
|
|
48
|
+
const dx = labelPosition.x - middle.x;
|
|
49
|
+
const dy = labelPosition.y - middle.y;
|
|
50
|
+
if (Math.abs(dx) < 1 && Math.abs(dy) < 1) {
|
|
51
|
+
return "center";
|
|
52
|
+
}
|
|
53
|
+
if (Math.abs(dx) > Math.abs(dy)) {
|
|
54
|
+
return dx > 0 ? "right" : "left";
|
|
55
|
+
}
|
|
56
|
+
return dy > 0 ? "bottom" : "top";
|
|
57
|
+
}
|
|
58
|
+
function labelTextAnchor(side) {
|
|
59
|
+
if (side === "right") {
|
|
60
|
+
return "start";
|
|
61
|
+
}
|
|
62
|
+
if (side === "left") {
|
|
63
|
+
return "end";
|
|
64
|
+
}
|
|
65
|
+
return "middle";
|
|
66
|
+
}
|
|
67
|
+
export function deriveGraphEdgeLabelAnchor(edge, options = {}) {
|
|
68
|
+
if (!edge.label || !edge.labelPosition) {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
const characterWidth = options.characterWidth ?? 7;
|
|
72
|
+
const paddingX = options.paddingX ?? 6;
|
|
73
|
+
const minWidth = options.minWidth ?? 24;
|
|
74
|
+
const height = options.height ?? 18;
|
|
75
|
+
const textBaselineOffset = options.textBaselineOffset ?? -6;
|
|
76
|
+
const textTopOffset = options.textTopOffset ?? 13;
|
|
77
|
+
const width = Math.max(minWidth, edge.label.length * characterWidth + paddingX * 2);
|
|
78
|
+
const textPosition = {
|
|
79
|
+
x: edge.labelPosition.x,
|
|
80
|
+
y: edge.labelPosition.y + textBaselineOffset
|
|
81
|
+
};
|
|
82
|
+
const side = labelSide(edge, edge.labelPosition);
|
|
83
|
+
return {
|
|
84
|
+
anchor: labelTextAnchor(side),
|
|
85
|
+
bounds: {
|
|
86
|
+
x: edge.labelPosition.x - width / 2,
|
|
87
|
+
y: textPosition.y - textTopOffset,
|
|
88
|
+
width,
|
|
89
|
+
height
|
|
90
|
+
},
|
|
91
|
+
side,
|
|
92
|
+
textPosition
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function hasRoutedLabel(edge) {
|
|
96
|
+
const points = edge.points;
|
|
97
|
+
return Array.isArray(points) && points.length > 0;
|
|
98
|
+
}
|
|
99
|
+
function paddedLabelBounds(bounds, padding) {
|
|
100
|
+
if (padding === 0) {
|
|
101
|
+
return bounds;
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
x: bounds.x - padding,
|
|
105
|
+
y: bounds.y - padding,
|
|
106
|
+
width: bounds.width + padding * 2,
|
|
107
|
+
height: bounds.height + padding * 2
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function labelBoundsOverlap(first, second) {
|
|
111
|
+
const width = Math.min(first.x + first.width, second.x + second.width) - Math.max(first.x, second.x);
|
|
112
|
+
const height = Math.min(first.y + first.height, second.y + second.height) - Math.max(first.y, second.y);
|
|
113
|
+
if (width <= 0 || height <= 0) {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
width: normalizeNumber(width),
|
|
118
|
+
height: normalizeNumber(height),
|
|
119
|
+
area: normalizeNumber(width * height)
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
export function getGraphEdgeLabelOverlapDiagnostics(graph, options = {}) {
|
|
123
|
+
const padding = Math.max(0, options.padding ?? 0);
|
|
124
|
+
const candidates = [];
|
|
125
|
+
for (const edge of graph.edges) {
|
|
126
|
+
if (!hasRoutedLabel(edge)) {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const anchor = deriveGraphEdgeLabelAnchor(edge, options);
|
|
130
|
+
if (!anchor) {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
candidates.push({
|
|
134
|
+
edge,
|
|
135
|
+
anchor,
|
|
136
|
+
bounds: paddedLabelBounds(anchor.bounds, padding)
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
const diagnostics = [];
|
|
140
|
+
for (let index = 0; index < candidates.length; index += 1) {
|
|
141
|
+
const first = candidates[index];
|
|
142
|
+
if (!first) {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
for (let nextIndex = index + 1; nextIndex < candidates.length; nextIndex += 1) {
|
|
146
|
+
const second = candidates[nextIndex];
|
|
147
|
+
if (!second) {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const overlap = labelBoundsOverlap(first.bounds, second.bounds);
|
|
151
|
+
if (!overlap) {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
diagnostics.push({
|
|
155
|
+
code: "edge-label-overlap-risk",
|
|
156
|
+
severity: "warning",
|
|
157
|
+
message: `Edge label "${second.edge.id}" overlaps the estimated label bounds for edge "${first.edge.id}".`,
|
|
158
|
+
edgeId: second.edge.id,
|
|
159
|
+
category: "layout",
|
|
160
|
+
details: {
|
|
161
|
+
edgeId: second.edge.id,
|
|
162
|
+
labelSide: second.anchor.side,
|
|
163
|
+
overlapArea: overlap.area,
|
|
164
|
+
overlapHeight: overlap.height,
|
|
165
|
+
overlapWidth: overlap.width,
|
|
166
|
+
overlappingEdgeId: first.edge.id,
|
|
167
|
+
overlappingLabelSide: first.anchor.side
|
|
168
|
+
},
|
|
169
|
+
fix: {
|
|
170
|
+
target: "layout",
|
|
171
|
+
title: "Separate edge labels",
|
|
172
|
+
description: "Adjust route hints, spacing, or label copy so estimated edge-label bounds do not overlap."
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return diagnostics;
|
|
178
|
+
}
|
|
179
|
+
export function deriveGraphGroupBounds(graph, options = {}) {
|
|
180
|
+
const padding = options.padding ?? 16;
|
|
181
|
+
const groups = graph.groups ?? [];
|
|
182
|
+
return groups.flatMap((group) => {
|
|
183
|
+
const nodes = graph.nodes.filter((node) => node.groupId === group.id);
|
|
184
|
+
if (group.position && group.size) {
|
|
185
|
+
return [
|
|
186
|
+
{
|
|
187
|
+
groupId: group.id,
|
|
188
|
+
nodeIds: nodes.map((node) => node.id),
|
|
189
|
+
position: group.position,
|
|
190
|
+
size: group.size
|
|
191
|
+
}
|
|
192
|
+
];
|
|
193
|
+
}
|
|
194
|
+
if (nodes.length === 0) {
|
|
195
|
+
return [];
|
|
196
|
+
}
|
|
197
|
+
const minX = Math.min(...nodes.map((node) => node.position.x));
|
|
198
|
+
const minY = Math.min(...nodes.map((node) => node.position.y));
|
|
199
|
+
const maxX = Math.max(...nodes.map((node) => node.position.x + node.size.width));
|
|
200
|
+
const maxY = Math.max(...nodes.map((node) => node.position.y + node.size.height));
|
|
201
|
+
return [
|
|
202
|
+
{
|
|
203
|
+
groupId: group.id,
|
|
204
|
+
nodeIds: nodes.map((node) => node.id),
|
|
205
|
+
position: { x: minX - padding, y: minY - padding },
|
|
206
|
+
size: { width: maxX - minX + padding * 2, height: maxY - minY + padding * 2 }
|
|
207
|
+
}
|
|
208
|
+
];
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
export function routeGraphEdge(source, target, options = {}) {
|
|
212
|
+
const start = nodeCenter(source);
|
|
213
|
+
const end = nodeCenter(target);
|
|
214
|
+
const requestedKind = options.kind ?? "straight";
|
|
215
|
+
const kind = source.id === target.id ? "self-loop" : requestedKind;
|
|
216
|
+
const offset = offsetPoint(start, end, parallelOffset(options));
|
|
217
|
+
if (kind === "self-loop") {
|
|
218
|
+
const radius = options.selfLoopRadius ?? Math.max(24, Math.min(source.size.width, source.size.height) / 2);
|
|
219
|
+
const origin = nodeCenter(source);
|
|
220
|
+
const points = [
|
|
221
|
+
{ x: origin.x, y: origin.y - source.size.height / 2 },
|
|
222
|
+
{ x: origin.x + radius, y: origin.y - source.size.height / 2 - radius },
|
|
223
|
+
{ x: origin.x + radius * 2, y: origin.y },
|
|
224
|
+
{ x: origin.x + radius, y: origin.y + source.size.height / 2 + radius },
|
|
225
|
+
{ x: origin.x, y: origin.y + source.size.height / 2 }
|
|
226
|
+
];
|
|
227
|
+
const labelPosition = points[2];
|
|
228
|
+
return labelPosition ? { kind, points, labelPosition } : { kind, points };
|
|
229
|
+
}
|
|
230
|
+
const routedStart = { x: start.x + offset.x, y: start.y + offset.y };
|
|
231
|
+
const routedEnd = { x: end.x + offset.x, y: end.y + offset.y };
|
|
232
|
+
if (kind === "curve") {
|
|
233
|
+
const middle = midpoint(routedStart, routedEnd);
|
|
234
|
+
const controlOffset = offsetPoint(routedStart, routedEnd, options.parallelGap ?? 24);
|
|
235
|
+
const control = {
|
|
236
|
+
x: middle.x + controlOffset.x,
|
|
237
|
+
y: middle.y + controlOffset.y
|
|
238
|
+
};
|
|
239
|
+
return {
|
|
240
|
+
kind,
|
|
241
|
+
points: [routedStart, control, routedEnd],
|
|
242
|
+
labelPosition: control
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
if (kind === "elbow") {
|
|
246
|
+
const elbow = {
|
|
247
|
+
x: routedEnd.x,
|
|
248
|
+
y: routedStart.y
|
|
249
|
+
};
|
|
250
|
+
return {
|
|
251
|
+
kind,
|
|
252
|
+
points: [routedStart, elbow, routedEnd],
|
|
253
|
+
labelPosition: elbow
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
kind: "straight",
|
|
258
|
+
points: [routedStart, routedEnd],
|
|
259
|
+
labelPosition: midpoint(routedStart, routedEnd)
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
export function getGraphBounds(graph, options = {}) {
|
|
263
|
+
const padding = options.padding ?? 24;
|
|
264
|
+
const minWidth = options.minWidth ?? 1;
|
|
265
|
+
const minHeight = options.minHeight ?? 1;
|
|
266
|
+
const includeEdges = options.includeEdges ?? true;
|
|
267
|
+
const nodePoints = graph.nodes.flatMap((node) => {
|
|
268
|
+
return [
|
|
269
|
+
node.position,
|
|
270
|
+
{
|
|
271
|
+
x: node.position.x + node.size.width,
|
|
272
|
+
y: node.position.y + node.size.height
|
|
273
|
+
}
|
|
274
|
+
];
|
|
275
|
+
});
|
|
276
|
+
const edgePoints = includeEdges ? graph.edges.flatMap((edge) => edge.points) : [];
|
|
277
|
+
const groupPoints = deriveGraphGroupBounds(graph).flatMap((group) => {
|
|
278
|
+
return [
|
|
279
|
+
group.position,
|
|
280
|
+
{
|
|
281
|
+
x: group.position.x + group.size.width,
|
|
282
|
+
y: group.position.y + group.size.height
|
|
283
|
+
}
|
|
284
|
+
];
|
|
285
|
+
});
|
|
286
|
+
const points = [...nodePoints, ...edgePoints, ...groupPoints];
|
|
287
|
+
if (points.length === 0) {
|
|
288
|
+
return {
|
|
289
|
+
x: -padding,
|
|
290
|
+
y: -padding,
|
|
291
|
+
width: Math.max(minWidth, padding * 2),
|
|
292
|
+
height: Math.max(minHeight, padding * 2),
|
|
293
|
+
viewBox: `${-padding} ${-padding} ${Math.max(minWidth, padding * 2)} ${Math.max(minHeight, padding * 2)}`
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
const minX = Math.min(...points.map((point) => point.x));
|
|
297
|
+
const minY = Math.min(...points.map((point) => point.y));
|
|
298
|
+
const maxX = Math.max(...points.map((point) => point.x));
|
|
299
|
+
const maxY = Math.max(...points.map((point) => point.y));
|
|
300
|
+
const x = minX - padding;
|
|
301
|
+
const y = minY - padding;
|
|
302
|
+
const width = Math.max(minWidth, maxX - minX + padding * 2);
|
|
303
|
+
const height = Math.max(minHeight, maxY - minY + padding * 2);
|
|
304
|
+
return {
|
|
305
|
+
x,
|
|
306
|
+
y,
|
|
307
|
+
width,
|
|
308
|
+
height,
|
|
309
|
+
viewBox: `${x} ${y} ${width} ${height}`
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
export function edgeWithPoints(edge, nodes, options = {}) {
|
|
9
313
|
const source = nodes.get(edge.source);
|
|
10
314
|
const target = nodes.get(edge.target);
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
17
|
-
:
|
|
18
|
-
|
|
315
|
+
const route = source && target ? routeGraphEdge(source, target, options) : undefined;
|
|
316
|
+
if (!route) {
|
|
317
|
+
return { ...edge, points: [] };
|
|
318
|
+
}
|
|
319
|
+
return route.labelPosition
|
|
320
|
+
? { ...edge, points: route.points, labelPosition: route.labelPosition, routeKind: route.kind }
|
|
321
|
+
: { ...edge, points: route.points, routeKind: route.kind };
|
|
322
|
+
}
|
|
323
|
+
export function edgesWithPoints(edges, nodes, options = {}) {
|
|
324
|
+
const countsByPair = new Map();
|
|
325
|
+
const indexByPair = new Map();
|
|
326
|
+
for (const edge of edges) {
|
|
327
|
+
const key = `${edge.source}->${edge.target}`;
|
|
328
|
+
countsByPair.set(key, (countsByPair.get(key) ?? 0) + 1);
|
|
329
|
+
}
|
|
330
|
+
return edges.map((edge) => {
|
|
331
|
+
const key = `${edge.source}->${edge.target}`;
|
|
332
|
+
const parallelIndex = indexByPair.get(key) ?? 0;
|
|
333
|
+
indexByPair.set(key, parallelIndex + 1);
|
|
334
|
+
const routeOptions = {
|
|
335
|
+
parallelCount: countsByPair.get(key) ?? 1,
|
|
336
|
+
parallelIndex
|
|
337
|
+
};
|
|
338
|
+
const routeKind = options.routeByEdgeId?.[edge.id] ?? options.edgeRouteKind;
|
|
339
|
+
return edgeWithPoints(edge, nodes, {
|
|
340
|
+
...routeOptions,
|
|
341
|
+
...(routeKind ? { kind: routeKind } : {}),
|
|
342
|
+
...(options.parallelGap !== undefined ? { parallelGap: options.parallelGap } : {}),
|
|
343
|
+
...(options.selfLoopRadius !== undefined ? { selfLoopRadius: options.selfLoopRadius } : {})
|
|
344
|
+
});
|
|
345
|
+
});
|
|
19
346
|
}
|