retroflow 0.8.2__py3-none-any.whl

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.
retroflow/router.py ADDED
@@ -0,0 +1,343 @@
1
+ """
2
+ Edge routing module for flowchart generation.
3
+
4
+ Handles orthogonal routing of edges between boxes with:
5
+ - Port assignment (where edges connect to boxes)
6
+ - Non-overlapping edge paths
7
+ - Minimal crossings
8
+ """
9
+
10
+ from dataclasses import dataclass, field
11
+ from enum import Enum
12
+ from typing import Dict, List, Optional, Set, Tuple
13
+
14
+
15
+ class PortSide(Enum):
16
+ """Which side of a box a port is on."""
17
+
18
+ TOP = "top"
19
+ BOTTOM = "bottom"
20
+ LEFT = "left"
21
+ RIGHT = "right"
22
+
23
+
24
+ @dataclass
25
+ class Port:
26
+ """A connection point on a box."""
27
+
28
+ node: str
29
+ side: PortSide
30
+ offset: int # Offset from left/top of box
31
+ x: int = 0 # Absolute x coordinate
32
+ y: int = 0 # Absolute y coordinate
33
+
34
+
35
+ @dataclass
36
+ class BoxInfo:
37
+ """Information about a rendered box."""
38
+
39
+ name: str
40
+ x: int
41
+ y: int
42
+ width: int
43
+ height: int
44
+ layer: int
45
+ position: int
46
+
47
+
48
+ @dataclass
49
+ class EdgeRoute:
50
+ """A routed edge between two boxes."""
51
+
52
+ source: str
53
+ target: str
54
+ source_port: Port
55
+ target_port: Port
56
+ waypoints: List[Tuple[int, int]] = field(default_factory=list)
57
+
58
+
59
+ class EdgeRouter:
60
+ """
61
+ Routes edges between boxes using orthogonal paths.
62
+ """
63
+
64
+ def __init__(self):
65
+ self.boxes: Dict[str, BoxInfo] = {}
66
+ self.used_ports: Dict[str, Dict[PortSide, Set[int]]] = {}
67
+
68
+ def set_boxes(self, boxes: Dict[str, BoxInfo]) -> None:
69
+ """Set the box information for routing."""
70
+ self.boxes = boxes
71
+ self.used_ports = {name: {side: set() for side in PortSide} for name in boxes}
72
+
73
+ def route_edges(
74
+ self, edges: List[Tuple[str, str]], layers: List[List[str]]
75
+ ) -> List[EdgeRoute]:
76
+ """
77
+ Route all edges between boxes.
78
+
79
+ Args:
80
+ edges: List of (source, target) node pairs
81
+ layers: List of layers, each containing node names
82
+
83
+ Returns:
84
+ List of EdgeRoute objects with routing information
85
+ """
86
+ routes: List[EdgeRoute] = []
87
+
88
+ # Build layer lookup
89
+ node_layer = {}
90
+ node_position = {}
91
+ for layer_idx, layer in enumerate(layers):
92
+ for pos_idx, node in enumerate(layer):
93
+ node_layer[node] = layer_idx
94
+ node_position[node] = pos_idx
95
+
96
+ # Group edges by source for port allocation
97
+ edges_by_source: Dict[str, List[str]] = {}
98
+ edges_by_target: Dict[str, List[str]] = {}
99
+
100
+ for source, target in edges:
101
+ if source not in edges_by_source:
102
+ edges_by_source[source] = []
103
+ edges_by_source[source].append(target)
104
+
105
+ if target not in edges_by_target:
106
+ edges_by_target[target] = []
107
+ edges_by_target[target].append(source)
108
+
109
+ # Sort edges by target position for consistent port allocation
110
+ for source in edges_by_source:
111
+ edges_by_source[source].sort(key=lambda t: node_position.get(t, 0))
112
+
113
+ for target in edges_by_target:
114
+ edges_by_target[target].sort(key=lambda s: node_position.get(s, 0))
115
+
116
+ # Route each edge
117
+ for source, target in edges:
118
+ # Skip dummy nodes - they're handled in the path
119
+ if source.startswith("__dummy_") or target.startswith("__dummy_"):
120
+ # For dummy nodes, we just pass through
121
+ route = self._route_through_dummy(
122
+ source, target, node_layer, node_position, edges
123
+ )
124
+ else:
125
+ route = self._route_edge(
126
+ source,
127
+ target,
128
+ node_layer,
129
+ node_position,
130
+ edges_by_source.get(source, []),
131
+ edges_by_target.get(target, []),
132
+ )
133
+
134
+ if route:
135
+ routes.append(route)
136
+
137
+ return routes
138
+
139
+ def _route_edge(
140
+ self,
141
+ source: str,
142
+ target: str,
143
+ node_layer: Dict[str, int],
144
+ node_position: Dict[str, int],
145
+ source_targets: List[str],
146
+ target_sources: List[str],
147
+ ) -> Optional[EdgeRoute]:
148
+ """Route a single edge between two boxes."""
149
+ if source not in self.boxes or target not in self.boxes:
150
+ return None
151
+
152
+ src_box = self.boxes[source]
153
+ tgt_box = self.boxes[target]
154
+
155
+ src_layer = node_layer.get(source, 0)
156
+ tgt_layer = node_layer.get(target, 0)
157
+
158
+ # Determine port sides based on relative layer positions
159
+ if tgt_layer > src_layer:
160
+ # Target is below source - standard downward flow
161
+ src_side = PortSide.BOTTOM
162
+ tgt_side = PortSide.TOP
163
+ elif tgt_layer < src_layer:
164
+ # Target is above source - upward flow (back edge)
165
+ src_side = PortSide.TOP
166
+ tgt_side = PortSide.BOTTOM
167
+ else:
168
+ # Same layer - horizontal flow
169
+ if node_position.get(target, 0) > node_position.get(source, 0):
170
+ src_side = PortSide.RIGHT
171
+ tgt_side = PortSide.LEFT
172
+ else:
173
+ src_side = PortSide.LEFT
174
+ tgt_side = PortSide.RIGHT
175
+
176
+ # Allocate ports
177
+ src_port = self._allocate_port(
178
+ source,
179
+ src_side,
180
+ src_box,
181
+ source_targets.index(target) if target in source_targets else 0,
182
+ len(source_targets),
183
+ )
184
+ tgt_port = self._allocate_port(
185
+ target,
186
+ tgt_side,
187
+ tgt_box,
188
+ target_sources.index(source) if source in target_sources else 0,
189
+ len(target_sources),
190
+ )
191
+
192
+ # Calculate waypoints for orthogonal routing
193
+ waypoints = self._calculate_waypoints(src_port, tgt_port, src_box, tgt_box)
194
+
195
+ return EdgeRoute(
196
+ source=source,
197
+ target=target,
198
+ source_port=src_port,
199
+ target_port=tgt_port,
200
+ waypoints=waypoints,
201
+ )
202
+
203
+ def _route_through_dummy(
204
+ self,
205
+ source: str,
206
+ target: str,
207
+ node_layer: Dict[str, int],
208
+ node_position: Dict[str, int],
209
+ all_edges: List[Tuple[str, str]],
210
+ ) -> Optional[EdgeRoute]:
211
+ """Handle routing through dummy nodes."""
212
+ # For dummy nodes, just create straight vertical segments
213
+ if source not in self.boxes or target not in self.boxes:
214
+ return None
215
+
216
+ src_box = self.boxes[source]
217
+ tgt_box = self.boxes[target]
218
+
219
+ src_layer = node_layer.get(source, 0)
220
+ tgt_layer = node_layer.get(target, 0)
221
+
222
+ if tgt_layer > src_layer:
223
+ src_side = PortSide.BOTTOM
224
+ tgt_side = PortSide.TOP
225
+ else:
226
+ src_side = PortSide.TOP
227
+ tgt_side = PortSide.BOTTOM
228
+
229
+ src_port = Port(
230
+ node=source,
231
+ side=src_side,
232
+ offset=0,
233
+ x=src_box.x + src_box.width // 2,
234
+ y=src_box.y + src_box.height if src_side == PortSide.BOTTOM else src_box.y,
235
+ )
236
+
237
+ tgt_port = Port(
238
+ node=target,
239
+ side=tgt_side,
240
+ offset=0,
241
+ x=tgt_box.x + tgt_box.width // 2,
242
+ y=tgt_box.y if tgt_side == PortSide.TOP else tgt_box.y + tgt_box.height,
243
+ )
244
+
245
+ return EdgeRoute(
246
+ source=source,
247
+ target=target,
248
+ source_port=src_port,
249
+ target_port=tgt_port,
250
+ waypoints=[(src_port.x, src_port.y), (tgt_port.x, tgt_port.y)],
251
+ )
252
+
253
+ def _allocate_port(
254
+ self, node: str, side: PortSide, box: BoxInfo, index: int, total: int
255
+ ) -> Port:
256
+ """
257
+ Allocate a port on a box side, distributing ports evenly.
258
+ For bottom ports, y is at the bottom border (box.y + box.height - 1).
259
+ For top ports, y is at the top border (box.y).
260
+ """
261
+ if side in (PortSide.TOP, PortSide.BOTTOM):
262
+ # Distribute horizontally across the box width
263
+ available_width = box.width - 2 # Exclude corners
264
+ if total == 1:
265
+ offset = available_width // 2
266
+ else:
267
+ spacing = available_width // (total + 1)
268
+ offset = spacing * (index + 1)
269
+
270
+ x = box.x + 1 + offset
271
+ # Bottom port at the bottom border, top port at top border
272
+ if side == PortSide.BOTTOM:
273
+ y = box.y + box.height - 1 # At bottom border
274
+ else:
275
+ y = box.y # At top border
276
+
277
+ else: # LEFT or RIGHT
278
+ # Distribute vertically
279
+ available_height = box.height - 2 # Exclude corners
280
+ if total == 1:
281
+ offset = available_height // 2
282
+ else:
283
+ spacing = available_height // (total + 1)
284
+ offset = spacing * (index + 1)
285
+
286
+ x = box.x if side == PortSide.LEFT else box.x + box.width - 1
287
+ y = box.y + 1 + offset
288
+
289
+ # Track used port
290
+ self.used_ports[node][side].add(offset)
291
+
292
+ return Port(node=node, side=side, offset=offset, x=x, y=y)
293
+
294
+ def _calculate_waypoints(
295
+ self, src_port: Port, tgt_port: Port, src_box: BoxInfo, tgt_box: BoxInfo
296
+ ) -> List[Tuple[int, int]]:
297
+ """
298
+ Calculate waypoints for orthogonal edge routing.
299
+ Returns list of (x, y) coordinates for the path.
300
+ """
301
+ waypoints = [(src_port.x, src_port.y)]
302
+
303
+ src_x, src_y = src_port.x, src_port.y
304
+ tgt_x, tgt_y = tgt_port.x, tgt_port.y
305
+
306
+ if src_port.side == PortSide.BOTTOM and tgt_port.side == PortSide.TOP:
307
+ # Standard downward flow
308
+ if src_x == tgt_x:
309
+ # Direct vertical line
310
+ waypoints.append((tgt_x, tgt_y))
311
+ else:
312
+ # Need horizontal segment in between
313
+ mid_y = src_y + (tgt_y - src_y) // 2
314
+ waypoints.append((src_x, mid_y))
315
+ waypoints.append((tgt_x, mid_y))
316
+ waypoints.append((tgt_x, tgt_y))
317
+
318
+ elif src_port.side == PortSide.TOP and tgt_port.side == PortSide.BOTTOM:
319
+ # Upward flow (back edge)
320
+ # Route around the side
321
+ if src_x == tgt_x:
322
+ waypoints.append((tgt_x, tgt_y))
323
+ else:
324
+ mid_y = src_y - 2
325
+ waypoints.append((src_x, mid_y))
326
+ waypoints.append((tgt_x, mid_y))
327
+ waypoints.append((tgt_x, tgt_y))
328
+
329
+ elif src_port.side in (PortSide.LEFT, PortSide.RIGHT):
330
+ # Horizontal flow
331
+ if src_y == tgt_y:
332
+ waypoints.append((tgt_x, tgt_y))
333
+ else:
334
+ mid_x = src_x + (tgt_x - src_x) // 2
335
+ waypoints.append((mid_x, src_y))
336
+ waypoints.append((mid_x, tgt_y))
337
+ waypoints.append((tgt_x, tgt_y))
338
+
339
+ else:
340
+ # Default: direct path
341
+ waypoints.append((tgt_x, tgt_y))
342
+
343
+ return waypoints