@zvk/graphs 0.1.0 → 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.
@@ -1,5 +1,6 @@
1
1
  import { createGraphIndex, detectGraphCycle, topologicalSortGraph } from "../algorithms.js";
2
- import { defaultGraphNodeSize, edgeWithPoints } from "./types.js";
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 rank = incoming.reduce((nextRank, edge) => {
42
- return Math.max(nextRank, (ranks.get(edge.source) ?? 0) + 1);
43
- }, 0);
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 = rankBuckets.get(rank)?.indexOf(node.id) ?? 0;
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
- ...graph,
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
  }
@@ -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
  }
@@ -1,4 +1,5 @@
1
- import { defaultGraphNodeSize, edgeWithPoints } from "./types.js";
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.map((edge) => edgeWithPoints(edge, nodeById));
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
- ...graph,
34
- nodes,
35
- edges
36
- },
37
- diagnostics
39
+ graph: positionedGraph,
40
+ diagnostics: [...diagnostics, ...validateGraphLayout(graph, options), ...edgeLabelDiagnostics]
38
41
  };
39
42
  }
@@ -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;
@@ -1,5 +1,6 @@
1
1
  import { createGraphIndex, validateTreeGraph } from "../algorithms.js";
2
- import { defaultGraphNodeSize, edgeWithPoints } from "./types.js";
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 = validateTreeGraph(graph, options.rootId ? { rootId: options.rootId } : {});
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
- ...graph,
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
  }
@@ -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 edgeWithPoints<TNodeData = unknown, TEdgeData = unknown, TNodeKind extends string = string, TEdgeKind extends string = string>(edge: GraphEdge<TEdgeData, TEdgeKind>, nodes: ReadonlyMap<string, PositionedGraphNode<TNodeData, TNodeKind>>): PositionedGraphEdge<TEdgeData, TEdgeKind>;
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>[];
@@ -5,15 +5,342 @@ export function nodeCenter(node) {
5
5
  y: node.position.y + node.size.height / 2
6
6
  };
7
7
  }
8
- export function edgeWithPoints(edge, nodes) {
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 points = source && target ? [nodeCenter(source), nodeCenter(target)] : [];
12
- const labelPosition = points.length === 2 && points[0] && points[1]
13
- ? {
14
- x: (points[0].x + points[1].x) / 2,
15
- y: (points[0].y + points[1].y) / 2
16
- }
17
- : undefined;
18
- return labelPosition ? { ...edge, points, labelPosition } : { ...edge, points };
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
  }