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/__init__.py +48 -0
- retroflow/generator.py +1803 -0
- retroflow/layout.py +239 -0
- retroflow/parser.py +99 -0
- retroflow/py.typed +0 -0
- retroflow/renderer.py +486 -0
- retroflow/router.py +343 -0
- retroflow-0.8.2.dist-info/METADATA +445 -0
- retroflow-0.8.2.dist-info/RECORD +11 -0
- retroflow-0.8.2.dist-info/WHEEL +4 -0
- retroflow-0.8.2.dist-info/licenses/LICENSE +21 -0
retroflow/generator.py
ADDED
|
@@ -0,0 +1,1803 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main flowchart generator module.
|
|
3
|
+
|
|
4
|
+
Combines parsing, layout, and rendering to produce
|
|
5
|
+
beautiful ASCII flowcharts.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Dict, List, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
13
|
+
|
|
14
|
+
from .layout import LayoutResult, NetworkXLayout
|
|
15
|
+
from .parser import Parser
|
|
16
|
+
from .renderer import (
|
|
17
|
+
ARROW_CHARS,
|
|
18
|
+
BOX_CHARS,
|
|
19
|
+
LINE_CHARS,
|
|
20
|
+
BoxDimensions,
|
|
21
|
+
BoxRenderer,
|
|
22
|
+
Canvas,
|
|
23
|
+
TitleRenderer,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class LayerBoundary:
|
|
29
|
+
"""Boundary information for a layer."""
|
|
30
|
+
|
|
31
|
+
layer_idx: int
|
|
32
|
+
top_y: int # Top of layer (where boxes start)
|
|
33
|
+
bottom_y: int # Bottom of layer (including shadow)
|
|
34
|
+
gap_start_y: int # Start of gap below this layer
|
|
35
|
+
gap_end_y: int # End of gap (start of next layer)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class ColumnBoundary:
|
|
40
|
+
"""Boundary information for a column (layer in LR mode)."""
|
|
41
|
+
|
|
42
|
+
layer_idx: int
|
|
43
|
+
left_x: int # Left edge of column (where boxes start)
|
|
44
|
+
right_x: int # Right edge of column (including shadow)
|
|
45
|
+
gap_start_x: int # Start of gap to the right of this column
|
|
46
|
+
gap_end_x: int # End of gap (start of next column)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class FlowchartGenerator:
|
|
50
|
+
"""
|
|
51
|
+
Generate ASCII flowcharts from simple text descriptions.
|
|
52
|
+
|
|
53
|
+
Example:
|
|
54
|
+
>>> generator = FlowchartGenerator()
|
|
55
|
+
>>> flowchart = generator.generate('''
|
|
56
|
+
... A -> B
|
|
57
|
+
... B -> C
|
|
58
|
+
... ''')
|
|
59
|
+
>>> print(flowchart)
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
max_text_width: int = 22,
|
|
65
|
+
min_box_width: int = 10,
|
|
66
|
+
horizontal_spacing: int = 12,
|
|
67
|
+
vertical_spacing: int = 3,
|
|
68
|
+
shadow: bool = True,
|
|
69
|
+
rounded: bool = False,
|
|
70
|
+
compact: bool = False,
|
|
71
|
+
font: Optional[str] = None,
|
|
72
|
+
title: Optional[str] = None,
|
|
73
|
+
direction: str = "TB",
|
|
74
|
+
):
|
|
75
|
+
"""
|
|
76
|
+
Initialize the flowchart generator.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
max_text_width: Maximum width for text inside boxes before wrapping
|
|
80
|
+
min_box_width: Minimum box width
|
|
81
|
+
horizontal_spacing: Space between boxes horizontally
|
|
82
|
+
vertical_spacing: Space between boxes vertically
|
|
83
|
+
shadow: Whether to draw box shadows
|
|
84
|
+
rounded: Whether to use rounded corners (╭╮╯╰) instead of square
|
|
85
|
+
compact: Whether to use compact boxes (no vertical padding)
|
|
86
|
+
font: Font name for PNG output (e.g., "Cascadia Code", "Monaco")
|
|
87
|
+
title: Optional title to display above the flowchart
|
|
88
|
+
direction: Flow direction - "TB" (top-to-bottom) or "LR" (left-to-right)
|
|
89
|
+
"""
|
|
90
|
+
self.max_text_width = max_text_width
|
|
91
|
+
self.min_box_width = min_box_width
|
|
92
|
+
self.horizontal_spacing = horizontal_spacing
|
|
93
|
+
self.vertical_spacing = vertical_spacing
|
|
94
|
+
self.shadow = shadow
|
|
95
|
+
self.rounded = rounded
|
|
96
|
+
self.compact = compact
|
|
97
|
+
self.font = font
|
|
98
|
+
self.title = title
|
|
99
|
+
self.direction = direction.upper()
|
|
100
|
+
|
|
101
|
+
if self.direction not in ("TB", "LR"):
|
|
102
|
+
raise ValueError(
|
|
103
|
+
"direction must be 'TB' (top-to-bottom) or 'LR' (left-to-right)"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
self.parser = Parser()
|
|
107
|
+
self.layout_engine = NetworkXLayout()
|
|
108
|
+
self.box_renderer = BoxRenderer(
|
|
109
|
+
max_text_width=max_text_width,
|
|
110
|
+
shadow=shadow,
|
|
111
|
+
rounded=rounded,
|
|
112
|
+
compact=compact,
|
|
113
|
+
)
|
|
114
|
+
self.title_renderer = TitleRenderer()
|
|
115
|
+
|
|
116
|
+
def generate(self, input_text: str, title: Optional[str] = None) -> str:
|
|
117
|
+
"""
|
|
118
|
+
Generate an ASCII flowchart from input text.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
input_text: Multi-line string with connections like "A -> B"
|
|
122
|
+
title: Optional title to display (overrides instance title)
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
ASCII art flowchart as a string
|
|
126
|
+
"""
|
|
127
|
+
# Use provided title or fall back to instance title
|
|
128
|
+
effective_title = title if title is not None else self.title
|
|
129
|
+
|
|
130
|
+
# Parse input
|
|
131
|
+
connections = self.parser.parse(input_text)
|
|
132
|
+
|
|
133
|
+
# Run layout
|
|
134
|
+
layout_result = self.layout_engine.layout(connections)
|
|
135
|
+
|
|
136
|
+
# Calculate box dimensions for each node
|
|
137
|
+
box_dimensions = self._calculate_all_box_dimensions(layout_result)
|
|
138
|
+
|
|
139
|
+
# Calculate actual pixel positions - leave margin for back edges
|
|
140
|
+
# Each back edge needs 3 chars of space, plus 4 for min line before arrow
|
|
141
|
+
num_back_edges = len(layout_result.back_edges)
|
|
142
|
+
back_edge_margin = (4 + num_back_edges * 3) if num_back_edges > 0 else 0
|
|
143
|
+
|
|
144
|
+
if self.direction == "LR":
|
|
145
|
+
box_positions = self._calculate_positions_horizontal(
|
|
146
|
+
layout_result, box_dimensions, top_margin=back_edge_margin
|
|
147
|
+
)
|
|
148
|
+
else:
|
|
149
|
+
box_positions = self._calculate_positions(
|
|
150
|
+
layout_result, box_dimensions, left_margin=back_edge_margin
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# Calculate layer boundaries for safe edge routing
|
|
154
|
+
layer_boundaries = self._calculate_layer_boundaries(
|
|
155
|
+
layout_result, box_dimensions
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# Calculate column boundaries for LR mode
|
|
159
|
+
column_boundaries: List[ColumnBoundary] = []
|
|
160
|
+
if self.direction == "LR":
|
|
161
|
+
column_boundaries = self._calculate_column_boundaries(
|
|
162
|
+
layout_result, box_dimensions
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# Calculate canvas size
|
|
166
|
+
canvas_width, canvas_height = self._calculate_canvas_size(
|
|
167
|
+
box_dimensions, box_positions
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# Calculate title dimensions and offset
|
|
171
|
+
title_height = 0
|
|
172
|
+
title_width = 0
|
|
173
|
+
diagram_x_offset = 0
|
|
174
|
+
title_x_offset = 0
|
|
175
|
+
|
|
176
|
+
if effective_title:
|
|
177
|
+
title_width, title_height = self.title_renderer.calculate_title_dimensions(
|
|
178
|
+
effective_title
|
|
179
|
+
)
|
|
180
|
+
title_height += 2 # Add spacing below title
|
|
181
|
+
|
|
182
|
+
# Determine centering: center title above diagram or diagram under title
|
|
183
|
+
if title_width > canvas_width:
|
|
184
|
+
# Title is wider - center diagram under title
|
|
185
|
+
diagram_x_offset = (title_width - canvas_width) // 2
|
|
186
|
+
canvas_width = title_width
|
|
187
|
+
else:
|
|
188
|
+
# Diagram is wider - center title above diagram
|
|
189
|
+
title_x_offset = (canvas_width - title_width) // 2
|
|
190
|
+
|
|
191
|
+
# Create canvas with padding and title space
|
|
192
|
+
canvas = Canvas(canvas_width + 5, canvas_height + title_height + 5)
|
|
193
|
+
|
|
194
|
+
# Draw title if present, centered above the diagram
|
|
195
|
+
if effective_title:
|
|
196
|
+
self.title_renderer.draw_title(
|
|
197
|
+
canvas, title_x_offset, 0, effective_title, title_width
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# Offset box positions for title and centering
|
|
201
|
+
if title_height > 0 or diagram_x_offset > 0:
|
|
202
|
+
box_positions = {
|
|
203
|
+
name: (x + diagram_x_offset, y + title_height)
|
|
204
|
+
for name, (x, y) in box_positions.items()
|
|
205
|
+
}
|
|
206
|
+
# Also offset layer boundaries by title height
|
|
207
|
+
if title_height > 0:
|
|
208
|
+
layer_boundaries = [
|
|
209
|
+
LayerBoundary(
|
|
210
|
+
layer_idx=lb.layer_idx,
|
|
211
|
+
top_y=lb.top_y + title_height,
|
|
212
|
+
bottom_y=lb.bottom_y + title_height,
|
|
213
|
+
gap_start_y=lb.gap_start_y + title_height,
|
|
214
|
+
gap_end_y=lb.gap_end_y + title_height,
|
|
215
|
+
)
|
|
216
|
+
for lb in layer_boundaries
|
|
217
|
+
]
|
|
218
|
+
# And column boundaries for LR mode
|
|
219
|
+
if self.direction == "LR" and diagram_x_offset > 0:
|
|
220
|
+
column_boundaries = [
|
|
221
|
+
ColumnBoundary(
|
|
222
|
+
layer_idx=cb.layer_idx,
|
|
223
|
+
left_x=cb.left_x + diagram_x_offset,
|
|
224
|
+
right_x=cb.right_x + diagram_x_offset,
|
|
225
|
+
gap_start_x=cb.gap_start_x + diagram_x_offset,
|
|
226
|
+
gap_end_x=cb.gap_end_x + diagram_x_offset,
|
|
227
|
+
)
|
|
228
|
+
for cb in column_boundaries
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
# Draw boxes first
|
|
232
|
+
self._draw_boxes(canvas, box_dimensions, box_positions, layout_result)
|
|
233
|
+
|
|
234
|
+
# Draw forward edges with layer-aware routing
|
|
235
|
+
if self.direction == "LR":
|
|
236
|
+
self._draw_edges_horizontal(
|
|
237
|
+
canvas,
|
|
238
|
+
layout_result,
|
|
239
|
+
box_dimensions,
|
|
240
|
+
box_positions,
|
|
241
|
+
column_boundaries,
|
|
242
|
+
title_height,
|
|
243
|
+
)
|
|
244
|
+
else:
|
|
245
|
+
self._draw_edges(
|
|
246
|
+
canvas, layout_result, box_dimensions, box_positions, layer_boundaries
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
# Draw back edges along the margin
|
|
250
|
+
if layout_result.back_edges:
|
|
251
|
+
if self.direction == "LR":
|
|
252
|
+
self._draw_back_edges_horizontal(
|
|
253
|
+
canvas, layout_result, box_dimensions, box_positions, title_height
|
|
254
|
+
)
|
|
255
|
+
else:
|
|
256
|
+
self._draw_back_edges(
|
|
257
|
+
canvas, layout_result, box_dimensions, box_positions
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
return canvas.render()
|
|
261
|
+
|
|
262
|
+
def save_txt(
|
|
263
|
+
self, input_text: str, filename: str, boxes_only: bool = False
|
|
264
|
+
) -> None:
|
|
265
|
+
"""
|
|
266
|
+
Generate flowchart and save to a text file.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
input_text: Multi-line string with connections
|
|
270
|
+
filename: Output filename (should end in .txt)
|
|
271
|
+
boxes_only: If True, only draw boxes without edges (ignored)
|
|
272
|
+
"""
|
|
273
|
+
flowchart = self.generate(input_text)
|
|
274
|
+
output_path = Path(filename)
|
|
275
|
+
output_path.write_text(flowchart, encoding="utf-8")
|
|
276
|
+
|
|
277
|
+
def save_png(
|
|
278
|
+
self,
|
|
279
|
+
input_text: str,
|
|
280
|
+
filename: str,
|
|
281
|
+
font_size: int = 16,
|
|
282
|
+
bg_color: str = "#FFFFFF",
|
|
283
|
+
fg_color: str = "#000000",
|
|
284
|
+
padding: int = 20,
|
|
285
|
+
font: Optional[str] = None,
|
|
286
|
+
scale: int = 2,
|
|
287
|
+
) -> None:
|
|
288
|
+
"""
|
|
289
|
+
Generate flowchart and save as a high-resolution PNG image.
|
|
290
|
+
|
|
291
|
+
The PNG rendering is faithful to the ASCII version, using a monospace
|
|
292
|
+
font to preserve the exact character layout and box-drawing characters.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
input_text: Multi-line string with connections like "A -> B"
|
|
296
|
+
filename: Output filename (should end in .png)
|
|
297
|
+
font_size: Font size in points (higher = higher resolution)
|
|
298
|
+
bg_color: Background color as hex string (e.g., "#FFFFFF")
|
|
299
|
+
fg_color: Foreground/text color as hex string (e.g., "#000000")
|
|
300
|
+
padding: Padding around the diagram in pixels
|
|
301
|
+
font: Font name to use (overrides instance font if provided)
|
|
302
|
+
scale: Resolution multiplier for crisp output (default 2 for retina)
|
|
303
|
+
|
|
304
|
+
Example:
|
|
305
|
+
>>> generator = FlowchartGenerator(font="Cascadia Code")
|
|
306
|
+
>>> generator.save_png("A -> B -> C", "flowchart.png", font_size=24)
|
|
307
|
+
"""
|
|
308
|
+
ascii_art = self.generate(input_text)
|
|
309
|
+
lines = ascii_art.split("\n")
|
|
310
|
+
|
|
311
|
+
# Use provided font, fall back to instance font, then system defaults
|
|
312
|
+
font_name = font or self.font
|
|
313
|
+
# Apply scale multiplier to font size for higher resolution output
|
|
314
|
+
scaled_font_size = font_size * scale
|
|
315
|
+
loaded_font = self._load_monospace_font(scaled_font_size, font_name)
|
|
316
|
+
|
|
317
|
+
# Calculate character dimensions using a reference character
|
|
318
|
+
bbox = loaded_font.getbbox("M")
|
|
319
|
+
char_width = bbox[2] - bbox[0]
|
|
320
|
+
char_height = bbox[3] - bbox[1]
|
|
321
|
+
line_height = int(char_height * 1.2) # Add some line spacing
|
|
322
|
+
|
|
323
|
+
# Scale padding to match resolution
|
|
324
|
+
scaled_padding = padding * scale
|
|
325
|
+
|
|
326
|
+
# Calculate image dimensions
|
|
327
|
+
max_line_len = max(len(line) for line in lines) if lines else 0
|
|
328
|
+
img_width = char_width * max_line_len + scaled_padding * 2
|
|
329
|
+
img_height = line_height * len(lines) + scaled_padding * 2
|
|
330
|
+
|
|
331
|
+
# Ensure minimum dimensions (scaled)
|
|
332
|
+
img_width = max(img_width, 100 * scale)
|
|
333
|
+
img_height = max(img_height, 100 * scale)
|
|
334
|
+
|
|
335
|
+
# Create image and draw text
|
|
336
|
+
img = Image.new("RGB", (img_width, img_height), bg_color)
|
|
337
|
+
draw = ImageDraw.Draw(img)
|
|
338
|
+
|
|
339
|
+
y = scaled_padding
|
|
340
|
+
for line in lines:
|
|
341
|
+
draw.text((scaled_padding, y), line, font=loaded_font, fill=fg_color)
|
|
342
|
+
y += line_height
|
|
343
|
+
|
|
344
|
+
# Save the image
|
|
345
|
+
output_path = Path(filename)
|
|
346
|
+
img.save(output_path, "PNG")
|
|
347
|
+
|
|
348
|
+
def _load_monospace_font(
|
|
349
|
+
self, font_size: int, font_name: Optional[str] = None
|
|
350
|
+
) -> ImageFont.FreeTypeFont:
|
|
351
|
+
"""
|
|
352
|
+
Load a monospace font for PNG rendering.
|
|
353
|
+
|
|
354
|
+
Tries the following in order:
|
|
355
|
+
1. User-specified font name if provided
|
|
356
|
+
2. Common system monospace fonts
|
|
357
|
+
3. Pillow's default font
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
font_size: Font size in points
|
|
361
|
+
font_name: Optional font name (e.g., "Cascadia Code", "Monaco")
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
A PIL ImageFont object
|
|
365
|
+
"""
|
|
366
|
+
# Build list of fonts to try
|
|
367
|
+
fonts_to_try = []
|
|
368
|
+
|
|
369
|
+
# Add user-specified font first
|
|
370
|
+
if font_name:
|
|
371
|
+
fonts_to_try.append(font_name)
|
|
372
|
+
|
|
373
|
+
# Common monospace fonts across different systems
|
|
374
|
+
fonts_to_try.extend(
|
|
375
|
+
[
|
|
376
|
+
# Linux
|
|
377
|
+
"DejaVuSansMono",
|
|
378
|
+
"DejaVu Sans Mono",
|
|
379
|
+
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
|
|
380
|
+
# macOS
|
|
381
|
+
"Monaco",
|
|
382
|
+
"Menlo",
|
|
383
|
+
"/System/Library/Fonts/Monaco.ttf",
|
|
384
|
+
"/System/Library/Fonts/Menlo.ttc",
|
|
385
|
+
# Windows
|
|
386
|
+
"Consolas",
|
|
387
|
+
"Cascadia Code",
|
|
388
|
+
"Courier New",
|
|
389
|
+
"C:/Windows/Fonts/consola.ttf",
|
|
390
|
+
]
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
for font in fonts_to_try:
|
|
394
|
+
try:
|
|
395
|
+
return ImageFont.truetype(font, font_size)
|
|
396
|
+
except OSError:
|
|
397
|
+
continue
|
|
398
|
+
|
|
399
|
+
# Fall back to Pillow's default font
|
|
400
|
+
try:
|
|
401
|
+
return ImageFont.load_default(size=font_size)
|
|
402
|
+
except TypeError:
|
|
403
|
+
# Older Pillow versions don't support size parameter
|
|
404
|
+
return ImageFont.load_default()
|
|
405
|
+
|
|
406
|
+
def _calculate_all_box_dimensions(
|
|
407
|
+
self, layout_result: LayoutResult
|
|
408
|
+
) -> Dict[str, BoxDimensions]:
|
|
409
|
+
"""Calculate dimensions for all boxes, ensuring minimum size."""
|
|
410
|
+
dimensions = {}
|
|
411
|
+
|
|
412
|
+
for node_name in layout_result.nodes:
|
|
413
|
+
dims = self.box_renderer.calculate_box_dimensions(node_name)
|
|
414
|
+
|
|
415
|
+
# Ensure minimum width
|
|
416
|
+
if dims.width < self.min_box_width:
|
|
417
|
+
dims = BoxDimensions(
|
|
418
|
+
width=self.min_box_width,
|
|
419
|
+
height=dims.height,
|
|
420
|
+
text_lines=dims.text_lines,
|
|
421
|
+
padding=dims.padding,
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
dimensions[node_name] = dims
|
|
425
|
+
|
|
426
|
+
return dimensions
|
|
427
|
+
|
|
428
|
+
def _calculate_positions(
|
|
429
|
+
self,
|
|
430
|
+
layout_result: LayoutResult,
|
|
431
|
+
box_dimensions: Dict[str, BoxDimensions],
|
|
432
|
+
left_margin: int = 0,
|
|
433
|
+
) -> Dict[str, Tuple[int, int]]:
|
|
434
|
+
"""
|
|
435
|
+
Calculate actual x,y positions for each box.
|
|
436
|
+
Centers nodes within each layer.
|
|
437
|
+
|
|
438
|
+
Args:
|
|
439
|
+
layout_result: The layout result from the layout engine
|
|
440
|
+
box_dimensions: Dictionary of box dimensions
|
|
441
|
+
left_margin: Extra space on left for back edge routing
|
|
442
|
+
"""
|
|
443
|
+
positions: Dict[str, Tuple[int, int]] = {}
|
|
444
|
+
|
|
445
|
+
# Calculate dimensions for each layer
|
|
446
|
+
layer_heights: List[int] = []
|
|
447
|
+
layer_widths: List[List[int]] = []
|
|
448
|
+
|
|
449
|
+
for layer in layout_result.layers:
|
|
450
|
+
max_height = 0
|
|
451
|
+
widths = []
|
|
452
|
+
|
|
453
|
+
for node_name in layer:
|
|
454
|
+
dims = box_dimensions[node_name]
|
|
455
|
+
# Include shadow in height calculation
|
|
456
|
+
box_height = dims.height + (2 if self.shadow else 0)
|
|
457
|
+
max_height = max(max_height, box_height)
|
|
458
|
+
# Include shadow in width calculation
|
|
459
|
+
box_width = dims.width + (1 if self.shadow else 0)
|
|
460
|
+
widths.append(box_width)
|
|
461
|
+
|
|
462
|
+
layer_heights.append(max_height)
|
|
463
|
+
layer_widths.append(widths)
|
|
464
|
+
|
|
465
|
+
# Calculate cumulative y positions (top of each layer)
|
|
466
|
+
y_positions: List[int] = [0]
|
|
467
|
+
for height in layer_heights[:-1]:
|
|
468
|
+
y_positions.append(y_positions[-1] + height + self.vertical_spacing)
|
|
469
|
+
|
|
470
|
+
# Calculate total width of each layer
|
|
471
|
+
layer_total_widths = []
|
|
472
|
+
for widths in layer_widths:
|
|
473
|
+
if widths:
|
|
474
|
+
total = sum(widths) + self.horizontal_spacing * (len(widths) - 1)
|
|
475
|
+
else:
|
|
476
|
+
total = 0
|
|
477
|
+
layer_total_widths.append(total)
|
|
478
|
+
|
|
479
|
+
# Find maximum layer width for centering
|
|
480
|
+
max_layer_width = max(layer_total_widths) if layer_total_widths else 0
|
|
481
|
+
|
|
482
|
+
# Assign x,y positions
|
|
483
|
+
for layer_idx, layer in enumerate(layout_result.layers):
|
|
484
|
+
widths = layer_widths[layer_idx]
|
|
485
|
+
total_width = layer_total_widths[layer_idx]
|
|
486
|
+
|
|
487
|
+
# Center this layer, plus left margin for back edges
|
|
488
|
+
start_x = left_margin + (max_layer_width - total_width) // 2
|
|
489
|
+
|
|
490
|
+
current_x = start_x
|
|
491
|
+
for pos_idx, node_name in enumerate(layer):
|
|
492
|
+
positions[node_name] = (current_x, y_positions[layer_idx])
|
|
493
|
+
current_x += widths[pos_idx] + self.horizontal_spacing
|
|
494
|
+
|
|
495
|
+
return positions
|
|
496
|
+
|
|
497
|
+
def _calculate_positions_horizontal(
|
|
498
|
+
self,
|
|
499
|
+
layout_result: LayoutResult,
|
|
500
|
+
box_dimensions: Dict[str, BoxDimensions],
|
|
501
|
+
top_margin: int = 0,
|
|
502
|
+
) -> Dict[str, Tuple[int, int]]:
|
|
503
|
+
"""
|
|
504
|
+
Calculate actual x,y positions for each box in horizontal (LR) mode.
|
|
505
|
+
Layers become columns, nodes within a layer stack vertically.
|
|
506
|
+
|
|
507
|
+
Args:
|
|
508
|
+
layout_result: The layout result from the layout engine
|
|
509
|
+
box_dimensions: Dictionary of box dimensions
|
|
510
|
+
top_margin: Extra space on top for back edge routing
|
|
511
|
+
"""
|
|
512
|
+
positions: Dict[str, Tuple[int, int]] = {}
|
|
513
|
+
|
|
514
|
+
# Calculate dimensions for each layer (now columns)
|
|
515
|
+
layer_widths: List[int] = [] # Max width per layer (column)
|
|
516
|
+
layer_heights: List[List[int]] = [] # Heights of nodes in each layer
|
|
517
|
+
|
|
518
|
+
for layer in layout_result.layers:
|
|
519
|
+
max_width = 0
|
|
520
|
+
heights = []
|
|
521
|
+
|
|
522
|
+
for node_name in layer:
|
|
523
|
+
dims = box_dimensions[node_name]
|
|
524
|
+
# Include shadow in width calculation
|
|
525
|
+
box_width = dims.width + (1 if self.shadow else 0)
|
|
526
|
+
max_width = max(max_width, box_width)
|
|
527
|
+
# Include shadow in height calculation
|
|
528
|
+
box_height = dims.height + (2 if self.shadow else 0)
|
|
529
|
+
heights.append(box_height)
|
|
530
|
+
|
|
531
|
+
layer_widths.append(max_width)
|
|
532
|
+
layer_heights.append(heights)
|
|
533
|
+
|
|
534
|
+
# Calculate cumulative x positions (left edge of each layer/column)
|
|
535
|
+
x_positions: List[int] = [0]
|
|
536
|
+
for width in layer_widths[:-1]:
|
|
537
|
+
x_positions.append(x_positions[-1] + width + self.horizontal_spacing)
|
|
538
|
+
|
|
539
|
+
# Calculate total height of each layer (column)
|
|
540
|
+
layer_total_heights = []
|
|
541
|
+
for heights in layer_heights:
|
|
542
|
+
if heights:
|
|
543
|
+
total = sum(heights) + self.vertical_spacing * (len(heights) - 1)
|
|
544
|
+
else:
|
|
545
|
+
total = 0
|
|
546
|
+
layer_total_heights.append(total)
|
|
547
|
+
|
|
548
|
+
# Find maximum layer height for centering
|
|
549
|
+
max_layer_height = max(layer_total_heights) if layer_total_heights else 0
|
|
550
|
+
|
|
551
|
+
# Assign x,y positions
|
|
552
|
+
for layer_idx, layer in enumerate(layout_result.layers):
|
|
553
|
+
heights = layer_heights[layer_idx]
|
|
554
|
+
total_height = layer_total_heights[layer_idx]
|
|
555
|
+
|
|
556
|
+
# Center this layer vertically, plus top margin for back edges
|
|
557
|
+
start_y = top_margin + (max_layer_height - total_height) // 2
|
|
558
|
+
|
|
559
|
+
current_y = start_y
|
|
560
|
+
for pos_idx, node_name in enumerate(layer):
|
|
561
|
+
positions[node_name] = (x_positions[layer_idx], current_y)
|
|
562
|
+
current_y += heights[pos_idx] + self.vertical_spacing
|
|
563
|
+
|
|
564
|
+
return positions
|
|
565
|
+
|
|
566
|
+
def _calculate_layer_boundaries(
|
|
567
|
+
self,
|
|
568
|
+
layout_result: LayoutResult,
|
|
569
|
+
box_dimensions: Dict[str, BoxDimensions],
|
|
570
|
+
) -> List[LayerBoundary]:
|
|
571
|
+
"""
|
|
572
|
+
Calculate the y-boundaries for each layer.
|
|
573
|
+
|
|
574
|
+
This information is used for safe edge routing - horizontal segments
|
|
575
|
+
should be placed in the gaps between layers where no boxes exist.
|
|
576
|
+
|
|
577
|
+
Returns:
|
|
578
|
+
List of LayerBoundary objects, one per layer
|
|
579
|
+
"""
|
|
580
|
+
boundaries: List[LayerBoundary] = []
|
|
581
|
+
|
|
582
|
+
# Calculate layer heights (same logic as _calculate_positions)
|
|
583
|
+
layer_heights: List[int] = []
|
|
584
|
+
for layer in layout_result.layers:
|
|
585
|
+
max_height = 0
|
|
586
|
+
for node_name in layer:
|
|
587
|
+
dims = box_dimensions[node_name]
|
|
588
|
+
box_height = dims.height + (2 if self.shadow else 0)
|
|
589
|
+
max_height = max(max_height, box_height)
|
|
590
|
+
layer_heights.append(max_height)
|
|
591
|
+
|
|
592
|
+
# Calculate y positions for each layer
|
|
593
|
+
y_positions: List[int] = [0]
|
|
594
|
+
for height in layer_heights[:-1]:
|
|
595
|
+
y_positions.append(y_positions[-1] + height + self.vertical_spacing)
|
|
596
|
+
|
|
597
|
+
# Build boundary objects
|
|
598
|
+
num_layers = len(layout_result.layers)
|
|
599
|
+
for i in range(num_layers):
|
|
600
|
+
top_y = y_positions[i]
|
|
601
|
+
bottom_y = top_y + layer_heights[i] - 1 # -1 because it's inclusive
|
|
602
|
+
|
|
603
|
+
# Gap starts after the shadow (bottom_y is already inclusive of shadow)
|
|
604
|
+
gap_start_y = top_y + layer_heights[i]
|
|
605
|
+
|
|
606
|
+
# Gap ends at the start of the next layer (or canvas edge)
|
|
607
|
+
if i < num_layers - 1:
|
|
608
|
+
gap_end_y = y_positions[i + 1] - 1
|
|
609
|
+
else:
|
|
610
|
+
gap_end_y = gap_start_y + self.vertical_spacing # Last layer
|
|
611
|
+
|
|
612
|
+
boundaries.append(
|
|
613
|
+
LayerBoundary(
|
|
614
|
+
layer_idx=i,
|
|
615
|
+
top_y=top_y,
|
|
616
|
+
bottom_y=bottom_y,
|
|
617
|
+
gap_start_y=gap_start_y,
|
|
618
|
+
gap_end_y=gap_end_y,
|
|
619
|
+
)
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
return boundaries
|
|
623
|
+
|
|
624
|
+
def _calculate_column_boundaries(
|
|
625
|
+
self,
|
|
626
|
+
layout_result: LayoutResult,
|
|
627
|
+
box_dimensions: Dict[str, BoxDimensions],
|
|
628
|
+
) -> List[ColumnBoundary]:
|
|
629
|
+
"""
|
|
630
|
+
Calculate the x-boundaries for each column (layer in LR mode).
|
|
631
|
+
|
|
632
|
+
This information is used for safe edge routing - vertical segments
|
|
633
|
+
should be placed in the gaps between columns where no boxes exist.
|
|
634
|
+
|
|
635
|
+
Returns:
|
|
636
|
+
List of ColumnBoundary objects, one per layer/column
|
|
637
|
+
"""
|
|
638
|
+
boundaries: List[ColumnBoundary] = []
|
|
639
|
+
|
|
640
|
+
# Calculate layer widths (same logic as _calculate_positions_horizontal)
|
|
641
|
+
layer_widths: List[int] = []
|
|
642
|
+
for layer in layout_result.layers:
|
|
643
|
+
max_width = 0
|
|
644
|
+
for node_name in layer:
|
|
645
|
+
dims = box_dimensions[node_name]
|
|
646
|
+
box_width = dims.width + (1 if self.shadow else 0)
|
|
647
|
+
max_width = max(max_width, box_width)
|
|
648
|
+
layer_widths.append(max_width)
|
|
649
|
+
|
|
650
|
+
# Calculate x positions for each layer
|
|
651
|
+
x_positions: List[int] = [0]
|
|
652
|
+
for width in layer_widths[:-1]:
|
|
653
|
+
x_positions.append(x_positions[-1] + width + self.horizontal_spacing)
|
|
654
|
+
|
|
655
|
+
# Build boundary objects
|
|
656
|
+
num_layers = len(layout_result.layers)
|
|
657
|
+
for i in range(num_layers):
|
|
658
|
+
left_x = x_positions[i]
|
|
659
|
+
right_x = left_x + layer_widths[i] - 1 # -1 because it's inclusive
|
|
660
|
+
|
|
661
|
+
# Gap starts after the shadow (right_x is already inclusive of shadow)
|
|
662
|
+
gap_start_x = left_x + layer_widths[i]
|
|
663
|
+
|
|
664
|
+
# Gap ends at the start of the next layer (or canvas edge)
|
|
665
|
+
if i < num_layers - 1:
|
|
666
|
+
gap_end_x = x_positions[i + 1] - 1
|
|
667
|
+
else:
|
|
668
|
+
gap_end_x = gap_start_x + self.horizontal_spacing # Last layer
|
|
669
|
+
|
|
670
|
+
boundaries.append(
|
|
671
|
+
ColumnBoundary(
|
|
672
|
+
layer_idx=i,
|
|
673
|
+
left_x=left_x,
|
|
674
|
+
right_x=right_x,
|
|
675
|
+
gap_start_x=gap_start_x,
|
|
676
|
+
gap_end_x=gap_end_x,
|
|
677
|
+
)
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
return boundaries
|
|
681
|
+
|
|
682
|
+
def _calculate_canvas_size(
|
|
683
|
+
self,
|
|
684
|
+
box_dimensions: Dict[str, BoxDimensions],
|
|
685
|
+
box_positions: Dict[str, Tuple[int, int]],
|
|
686
|
+
) -> Tuple[int, int]:
|
|
687
|
+
"""Calculate required canvas dimensions."""
|
|
688
|
+
max_x = 0
|
|
689
|
+
max_y = 0
|
|
690
|
+
|
|
691
|
+
for node_name, (x, y) in box_positions.items():
|
|
692
|
+
dims = box_dimensions[node_name]
|
|
693
|
+
right = x + dims.width + (2 if self.shadow else 0)
|
|
694
|
+
bottom = y + dims.height + (2 if self.shadow else 0)
|
|
695
|
+
max_x = max(max_x, right)
|
|
696
|
+
max_y = max(max_y, bottom)
|
|
697
|
+
|
|
698
|
+
return max_x, max_y
|
|
699
|
+
|
|
700
|
+
def _draw_boxes(
|
|
701
|
+
self,
|
|
702
|
+
canvas: Canvas,
|
|
703
|
+
box_dimensions: Dict[str, BoxDimensions],
|
|
704
|
+
box_positions: Dict[str, Tuple[int, int]],
|
|
705
|
+
layout_result: LayoutResult,
|
|
706
|
+
) -> None:
|
|
707
|
+
"""Draw all boxes on the canvas."""
|
|
708
|
+
for node_name in layout_result.nodes:
|
|
709
|
+
dims = box_dimensions[node_name]
|
|
710
|
+
x, y = box_positions[node_name]
|
|
711
|
+
self.box_renderer.draw_box(canvas, x, y, dims)
|
|
712
|
+
|
|
713
|
+
def _draw_edges(
|
|
714
|
+
self,
|
|
715
|
+
canvas: Canvas,
|
|
716
|
+
layout_result: LayoutResult,
|
|
717
|
+
box_dimensions: Dict[str, BoxDimensions],
|
|
718
|
+
box_positions: Dict[str, Tuple[int, int]],
|
|
719
|
+
layer_boundaries: List[LayerBoundary],
|
|
720
|
+
) -> None:
|
|
721
|
+
"""Draw all forward edges on the canvas (skip back edges)."""
|
|
722
|
+
|
|
723
|
+
# Build lookup for node layers
|
|
724
|
+
node_layer = {name: node.layer for name, node in layout_result.nodes.items()}
|
|
725
|
+
|
|
726
|
+
# Group edges by source to allocate ports properly
|
|
727
|
+
edges_from: Dict[str, List[str]] = {}
|
|
728
|
+
edges_to: Dict[str, List[str]] = {}
|
|
729
|
+
|
|
730
|
+
for source, target in layout_result.edges:
|
|
731
|
+
# Skip back edges (edges going to earlier or same layer)
|
|
732
|
+
if (source, target) in layout_result.back_edges:
|
|
733
|
+
continue
|
|
734
|
+
|
|
735
|
+
src_layer = node_layer.get(source, 0)
|
|
736
|
+
tgt_layer = node_layer.get(target, 0)
|
|
737
|
+
|
|
738
|
+
# Only draw forward edges (target layer > source layer)
|
|
739
|
+
if tgt_layer <= src_layer:
|
|
740
|
+
continue
|
|
741
|
+
|
|
742
|
+
if source not in edges_from:
|
|
743
|
+
edges_from[source] = []
|
|
744
|
+
edges_from[source].append(target)
|
|
745
|
+
|
|
746
|
+
if target not in edges_to:
|
|
747
|
+
edges_to[target] = []
|
|
748
|
+
edges_to[target].append(source)
|
|
749
|
+
|
|
750
|
+
# Sort edges for consistent port allocation
|
|
751
|
+
for source in edges_from:
|
|
752
|
+
edges_from[source].sort(key=lambda t: layout_result.nodes[t].position)
|
|
753
|
+
for target in edges_to:
|
|
754
|
+
edges_to[target].sort(key=lambda s: layout_result.nodes[s].position)
|
|
755
|
+
|
|
756
|
+
# Draw each forward edge
|
|
757
|
+
for source, target in layout_result.edges:
|
|
758
|
+
if (source, target) in layout_result.back_edges:
|
|
759
|
+
continue
|
|
760
|
+
|
|
761
|
+
src_layer = node_layer.get(source, 0)
|
|
762
|
+
tgt_layer = node_layer.get(target, 0)
|
|
763
|
+
|
|
764
|
+
if tgt_layer <= src_layer:
|
|
765
|
+
continue
|
|
766
|
+
|
|
767
|
+
self._draw_edge(
|
|
768
|
+
canvas,
|
|
769
|
+
source,
|
|
770
|
+
target,
|
|
771
|
+
box_dimensions,
|
|
772
|
+
box_positions,
|
|
773
|
+
edges_from.get(source, []),
|
|
774
|
+
edges_to.get(target, []),
|
|
775
|
+
layer_boundaries,
|
|
776
|
+
src_layer,
|
|
777
|
+
tgt_layer,
|
|
778
|
+
layout_result,
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
def _draw_edge(
|
|
782
|
+
self,
|
|
783
|
+
canvas: Canvas,
|
|
784
|
+
source: str,
|
|
785
|
+
target: str,
|
|
786
|
+
box_dimensions: Dict[str, BoxDimensions],
|
|
787
|
+
box_positions: Dict[str, Tuple[int, int]],
|
|
788
|
+
source_targets: List[str],
|
|
789
|
+
target_sources: List[str],
|
|
790
|
+
layer_boundaries: List[LayerBoundary],
|
|
791
|
+
src_layer: int,
|
|
792
|
+
tgt_layer: int,
|
|
793
|
+
layout_result: LayoutResult,
|
|
794
|
+
) -> None:
|
|
795
|
+
"""Draw a single edge from source to target with layer-aware routing."""
|
|
796
|
+
src_dims = box_dimensions[source]
|
|
797
|
+
tgt_dims = box_dimensions[target]
|
|
798
|
+
src_x, src_y = box_positions[source]
|
|
799
|
+
tgt_x, tgt_y = box_positions[target]
|
|
800
|
+
|
|
801
|
+
# Check if boxes overlap horizontally (inside borders)
|
|
802
|
+
src_left = src_x + 1
|
|
803
|
+
src_right = src_x + src_dims.width - 2
|
|
804
|
+
tgt_left = tgt_x + 1
|
|
805
|
+
tgt_right = tgt_x + tgt_dims.width - 2
|
|
806
|
+
|
|
807
|
+
overlap_left = max(src_left, tgt_left)
|
|
808
|
+
overlap_right = min(src_right, tgt_right)
|
|
809
|
+
has_overlap = overlap_left < overlap_right
|
|
810
|
+
|
|
811
|
+
# Check if there are boxes in intermediate layers that would block a direct path
|
|
812
|
+
boxes_in_path = False
|
|
813
|
+
if has_overlap and tgt_layer - src_layer > 1:
|
|
814
|
+
# Check intermediate layers for boxes that would be crossed
|
|
815
|
+
for layer_idx in range(src_layer + 1, tgt_layer):
|
|
816
|
+
for node_name in layout_result.layers[layer_idx]:
|
|
817
|
+
node_dims = box_dimensions[node_name]
|
|
818
|
+
node_x, node_y = box_positions[node_name]
|
|
819
|
+
node_left = node_x
|
|
820
|
+
node_right = node_x + node_dims.width
|
|
821
|
+
|
|
822
|
+
# Check if this box's x range overlaps with the edge's x range
|
|
823
|
+
if node_left < overlap_right and node_right > overlap_left:
|
|
824
|
+
boxes_in_path = True
|
|
825
|
+
break
|
|
826
|
+
if boxes_in_path:
|
|
827
|
+
break
|
|
828
|
+
|
|
829
|
+
if has_overlap and not boxes_in_path:
|
|
830
|
+
# Boxes overlap and no obstructions - find overlapping targets
|
|
831
|
+
overlapping_targets = []
|
|
832
|
+
for t in source_targets:
|
|
833
|
+
t_dims = box_dimensions[t]
|
|
834
|
+
t_x, _ = box_positions[t]
|
|
835
|
+
t_left = t_x + 1
|
|
836
|
+
t_right = t_x + t_dims.width - 2
|
|
837
|
+
t_overlap_left = max(src_left, t_left)
|
|
838
|
+
t_overlap_right = min(src_right, t_right)
|
|
839
|
+
if t_overlap_left < t_overlap_right:
|
|
840
|
+
overlapping_targets.append(t)
|
|
841
|
+
|
|
842
|
+
# Distribute ports within the overlap region for overlapping targets
|
|
843
|
+
overlap_width = overlap_right - overlap_left
|
|
844
|
+
overlap_count = len(overlapping_targets)
|
|
845
|
+
overlap_idx = overlapping_targets.index(target)
|
|
846
|
+
|
|
847
|
+
if overlap_count == 1:
|
|
848
|
+
# Single overlapping target - use center of overlap
|
|
849
|
+
port_x = (overlap_left + overlap_right) // 2
|
|
850
|
+
else:
|
|
851
|
+
# Multiple overlapping targets - distribute within overlap
|
|
852
|
+
if overlap_width >= overlap_count * 2:
|
|
853
|
+
# Enough space to distribute
|
|
854
|
+
spacing = overlap_width // (overlap_count + 1)
|
|
855
|
+
port_x = overlap_left + spacing * (overlap_idx + 1)
|
|
856
|
+
else:
|
|
857
|
+
# Tight space - just use center offset slightly
|
|
858
|
+
port_x = overlap_left + (overlap_width * (overlap_idx + 1)) // (
|
|
859
|
+
overlap_count + 1
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
# Use same x for both source and target (straight line)
|
|
863
|
+
src_port_x = port_x
|
|
864
|
+
tgt_port_x = port_x
|
|
865
|
+
else:
|
|
866
|
+
# No horizontal overlap or boxes in path - use distributed ports
|
|
867
|
+
# Source: exit from bottom
|
|
868
|
+
src_port_count = len(source_targets)
|
|
869
|
+
src_port_idx = source_targets.index(target)
|
|
870
|
+
src_port_x = self._calculate_port_x(
|
|
871
|
+
src_x, src_dims.width, src_port_idx, src_port_count
|
|
872
|
+
)
|
|
873
|
+
|
|
874
|
+
# Target: enter from top
|
|
875
|
+
tgt_port_count = len(target_sources)
|
|
876
|
+
tgt_port_idx = target_sources.index(source)
|
|
877
|
+
tgt_port_x = self._calculate_port_x(
|
|
878
|
+
tgt_x, tgt_dims.width, tgt_port_idx, tgt_port_count
|
|
879
|
+
)
|
|
880
|
+
|
|
881
|
+
src_port_y = src_y + src_dims.height - 1 # Bottom border
|
|
882
|
+
tgt_port_y = tgt_y # Top border
|
|
883
|
+
|
|
884
|
+
# Modify source bottom border to show exit point (tee down)
|
|
885
|
+
canvas.set(src_port_x, src_port_y, LINE_CHARS["tee_down"])
|
|
886
|
+
|
|
887
|
+
# Calculate path
|
|
888
|
+
# Start below source (through shadow - arrow lines overwrite shadows)
|
|
889
|
+
start_y = src_port_y + 1
|
|
890
|
+
# End at target top
|
|
891
|
+
end_y = tgt_port_y
|
|
892
|
+
|
|
893
|
+
# Check if we need to route around boxes (when there are obstructions)
|
|
894
|
+
if boxes_in_path:
|
|
895
|
+
# Route to the right side of all boxes to avoid crossing them
|
|
896
|
+
max_right_x = src_x + src_dims.width
|
|
897
|
+
for layer_idx in range(src_layer + 1, tgt_layer):
|
|
898
|
+
for node_name in layout_result.layers[layer_idx]:
|
|
899
|
+
node_dims = box_dimensions[node_name]
|
|
900
|
+
node_x, _ = box_positions[node_name]
|
|
901
|
+
node_right = node_x + node_dims.width + (2 if self.shadow else 0)
|
|
902
|
+
max_right_x = max(max_right_x, node_right)
|
|
903
|
+
|
|
904
|
+
# Route: down, right to bypass, down, left to target
|
|
905
|
+
route_x = max_right_x + 2 # Go 2 chars to the right of all boxes
|
|
906
|
+
|
|
907
|
+
# Use the mid_y from source layer for the first horizontal segment
|
|
908
|
+
mid_y = self._get_safe_horizontal_y(layer_boundaries, src_layer, start_y)
|
|
909
|
+
|
|
910
|
+
# Vertical from source to mid
|
|
911
|
+
self._draw_vertical_line(canvas, src_port_x, start_y, mid_y - 1)
|
|
912
|
+
|
|
913
|
+
# Corner turning right
|
|
914
|
+
canvas.set(src_port_x, mid_y, LINE_CHARS["corner_bottom_left"])
|
|
915
|
+
|
|
916
|
+
# Horizontal segment to the route column
|
|
917
|
+
self._draw_horizontal_line(canvas, src_port_x, route_x, mid_y)
|
|
918
|
+
|
|
919
|
+
# Corner turning down
|
|
920
|
+
canvas.set(route_x, mid_y, LINE_CHARS["corner_top_right"])
|
|
921
|
+
|
|
922
|
+
# Find the y position for the horizontal segment above the target
|
|
923
|
+
tgt_mid_y = self._get_safe_horizontal_y(
|
|
924
|
+
layer_boundaries, tgt_layer - 1, start_y
|
|
925
|
+
)
|
|
926
|
+
|
|
927
|
+
# Vertical segment down the right side
|
|
928
|
+
self._draw_vertical_line(canvas, route_x, mid_y + 1, tgt_mid_y - 1)
|
|
929
|
+
|
|
930
|
+
# Corner turning left (line comes from above, exits left)
|
|
931
|
+
canvas.set(route_x, tgt_mid_y, LINE_CHARS["corner_bottom_right"])
|
|
932
|
+
|
|
933
|
+
# Horizontal segment back toward target
|
|
934
|
+
self._draw_horizontal_line(canvas, tgt_port_x, route_x, tgt_mid_y)
|
|
935
|
+
|
|
936
|
+
# Corner turning down to target (line comes from right, exits down)
|
|
937
|
+
canvas.set(tgt_port_x, tgt_mid_y, LINE_CHARS["corner_top_left"])
|
|
938
|
+
|
|
939
|
+
# Vertical to target
|
|
940
|
+
self._draw_vertical_line(canvas, tgt_port_x, tgt_mid_y + 1, end_y - 2)
|
|
941
|
+
|
|
942
|
+
# Arrow
|
|
943
|
+
canvas.set(tgt_port_x, tgt_port_y - 1, ARROW_CHARS["down"])
|
|
944
|
+
|
|
945
|
+
elif src_port_x == tgt_port_x:
|
|
946
|
+
# Direct vertical line (stop before arrow position)
|
|
947
|
+
self._draw_vertical_line(canvas, src_port_x, start_y, end_y - 2)
|
|
948
|
+
# Draw arrow one row above target box (doesn't overwrite border)
|
|
949
|
+
canvas.set(tgt_port_x, tgt_port_y - 1, ARROW_CHARS["down"])
|
|
950
|
+
else:
|
|
951
|
+
# Need to route with horizontal segment
|
|
952
|
+
# Use layer-aware routing: place horizontal segment in the gap zone
|
|
953
|
+
# below the source layer where no boxes can exist
|
|
954
|
+
mid_y = self._get_safe_horizontal_y(layer_boundaries, src_layer, start_y)
|
|
955
|
+
|
|
956
|
+
# Vertical from source to mid
|
|
957
|
+
self._draw_vertical_line(canvas, src_port_x, start_y, mid_y - 1)
|
|
958
|
+
|
|
959
|
+
# Corner at source column
|
|
960
|
+
if tgt_port_x > src_port_x:
|
|
961
|
+
canvas.set(src_port_x, mid_y, LINE_CHARS["corner_bottom_left"])
|
|
962
|
+
else:
|
|
963
|
+
canvas.set(src_port_x, mid_y, LINE_CHARS["corner_bottom_right"])
|
|
964
|
+
|
|
965
|
+
# Horizontal segment
|
|
966
|
+
self._draw_horizontal_line(canvas, src_port_x, tgt_port_x, mid_y)
|
|
967
|
+
|
|
968
|
+
# Corner at target column
|
|
969
|
+
if tgt_port_x > src_port_x:
|
|
970
|
+
canvas.set(tgt_port_x, mid_y, LINE_CHARS["corner_top_right"])
|
|
971
|
+
else:
|
|
972
|
+
canvas.set(tgt_port_x, mid_y, LINE_CHARS["corner_top_left"])
|
|
973
|
+
|
|
974
|
+
# Vertical from mid to target (stop before arrow position)
|
|
975
|
+
self._draw_vertical_line(canvas, tgt_port_x, mid_y + 1, end_y - 2)
|
|
976
|
+
|
|
977
|
+
# Draw arrow one row above target box (doesn't overwrite border)
|
|
978
|
+
canvas.set(tgt_port_x, tgt_port_y - 1, ARROW_CHARS["down"])
|
|
979
|
+
|
|
980
|
+
def _get_safe_horizontal_y(
|
|
981
|
+
self,
|
|
982
|
+
layer_boundaries: List[LayerBoundary],
|
|
983
|
+
src_layer: int,
|
|
984
|
+
start_y: int,
|
|
985
|
+
) -> int:
|
|
986
|
+
"""
|
|
987
|
+
Get a safe y-coordinate for horizontal edge routing.
|
|
988
|
+
|
|
989
|
+
Places the horizontal segment in the gap zone below the source layer,
|
|
990
|
+
ensuring it doesn't pass through any boxes.
|
|
991
|
+
|
|
992
|
+
Args:
|
|
993
|
+
layer_boundaries: List of layer boundary information
|
|
994
|
+
src_layer: The layer index of the source node
|
|
995
|
+
start_y: The y-coordinate where the edge starts (below source box)
|
|
996
|
+
|
|
997
|
+
Returns:
|
|
998
|
+
A y-coordinate in the gap zone that's safe for horizontal routing
|
|
999
|
+
"""
|
|
1000
|
+
if src_layer < len(layer_boundaries):
|
|
1001
|
+
boundary = layer_boundaries[src_layer]
|
|
1002
|
+
# Place horizontal line in the middle of the gap zone
|
|
1003
|
+
gap_middle = (boundary.gap_start_y + boundary.gap_end_y) // 2
|
|
1004
|
+
# Ensure we're at least at start_y (below the source shadow)
|
|
1005
|
+
return max(gap_middle, start_y + 1)
|
|
1006
|
+
else:
|
|
1007
|
+
# Fallback: just below the start
|
|
1008
|
+
return start_y + 2
|
|
1009
|
+
|
|
1010
|
+
def _get_safe_vertical_x(
|
|
1011
|
+
self,
|
|
1012
|
+
column_boundaries: List[ColumnBoundary],
|
|
1013
|
+
src_layer: int,
|
|
1014
|
+
start_x: int,
|
|
1015
|
+
) -> int:
|
|
1016
|
+
"""
|
|
1017
|
+
Get a safe x-coordinate for vertical edge routing in LR mode.
|
|
1018
|
+
|
|
1019
|
+
Places the vertical segment in the gap zone to the right of the source
|
|
1020
|
+
layer, ensuring it doesn't pass through any boxes.
|
|
1021
|
+
|
|
1022
|
+
Args:
|
|
1023
|
+
column_boundaries: List of column boundary information
|
|
1024
|
+
src_layer: The layer index of the source node
|
|
1025
|
+
start_x: The x-coordinate where the edge starts (after source box)
|
|
1026
|
+
|
|
1027
|
+
Returns:
|
|
1028
|
+
An x-coordinate in the gap zone that's safe for vertical routing
|
|
1029
|
+
"""
|
|
1030
|
+
if src_layer < len(column_boundaries):
|
|
1031
|
+
boundary = column_boundaries[src_layer]
|
|
1032
|
+
# Place vertical line in the middle of the gap zone
|
|
1033
|
+
gap_middle = (boundary.gap_start_x + boundary.gap_end_x) // 2
|
|
1034
|
+
# Ensure we're at least at start_x (after the source shadow)
|
|
1035
|
+
return max(gap_middle, start_x + 1)
|
|
1036
|
+
else:
|
|
1037
|
+
# Fallback: just after the start
|
|
1038
|
+
return start_x + 2
|
|
1039
|
+
|
|
1040
|
+
def _calculate_port_x(
|
|
1041
|
+
self, box_x: int, box_width: int, port_idx: int, port_count: int
|
|
1042
|
+
) -> int:
|
|
1043
|
+
"""Calculate x position for a port on a box."""
|
|
1044
|
+
if port_count == 1:
|
|
1045
|
+
# Single port: center of box
|
|
1046
|
+
return box_x + box_width // 2
|
|
1047
|
+
else:
|
|
1048
|
+
# Multiple ports: distribute evenly
|
|
1049
|
+
usable_width = box_width - 4 # Leave margins
|
|
1050
|
+
spacing = usable_width // (port_count + 1)
|
|
1051
|
+
return box_x + 2 + spacing * (port_idx + 1)
|
|
1052
|
+
|
|
1053
|
+
def _draw_vertical_line(
|
|
1054
|
+
self, canvas: Canvas, x: int, y_start: int, y_end: int
|
|
1055
|
+
) -> None:
|
|
1056
|
+
"""Draw a vertical line from y_start to y_end."""
|
|
1057
|
+
if y_start > y_end:
|
|
1058
|
+
y_start, y_end = y_end, y_start
|
|
1059
|
+
|
|
1060
|
+
for y in range(y_start, y_end + 1):
|
|
1061
|
+
current = canvas.get(x, y)
|
|
1062
|
+
if current == LINE_CHARS["horizontal"]:
|
|
1063
|
+
canvas.set(x, y, LINE_CHARS["cross"])
|
|
1064
|
+
elif current == " " or current == BOX_CHARS["shadow"]:
|
|
1065
|
+
canvas.set(x, y, LINE_CHARS["vertical"])
|
|
1066
|
+
|
|
1067
|
+
def _draw_horizontal_line(
|
|
1068
|
+
self, canvas: Canvas, x_start: int, x_end: int, y: int
|
|
1069
|
+
) -> None:
|
|
1070
|
+
"""Draw a horizontal line from x_start to x_end (exclusive of endpoints)."""
|
|
1071
|
+
if x_start > x_end:
|
|
1072
|
+
x_start, x_end = x_end, x_start
|
|
1073
|
+
|
|
1074
|
+
for x in range(x_start + 1, x_end):
|
|
1075
|
+
current = canvas.get(x, y)
|
|
1076
|
+
if current == LINE_CHARS["vertical"]:
|
|
1077
|
+
canvas.set(x, y, LINE_CHARS["cross"])
|
|
1078
|
+
elif current == " " or current == BOX_CHARS["shadow"]:
|
|
1079
|
+
canvas.set(x, y, LINE_CHARS["horizontal"])
|
|
1080
|
+
|
|
1081
|
+
def _draw_back_edges(
|
|
1082
|
+
self,
|
|
1083
|
+
canvas: Canvas,
|
|
1084
|
+
layout_result: LayoutResult,
|
|
1085
|
+
box_dimensions: Dict[str, BoxDimensions],
|
|
1086
|
+
box_positions: Dict[str, Tuple[int, int]],
|
|
1087
|
+
) -> None:
|
|
1088
|
+
"""
|
|
1089
|
+
Draw back edges (cycle edges) along the left margin of the diagram.
|
|
1090
|
+
|
|
1091
|
+
Back edges exit from the bottom-left of the source box, route down
|
|
1092
|
+
then left to the margin, up along the margin, then right and up
|
|
1093
|
+
to enter the target from the left.
|
|
1094
|
+
|
|
1095
|
+
If there are boxes between the margin and the target, the edge routes
|
|
1096
|
+
below those boxes to avoid crossing through them.
|
|
1097
|
+
"""
|
|
1098
|
+
if not layout_result.back_edges:
|
|
1099
|
+
return
|
|
1100
|
+
|
|
1101
|
+
margin_x = 2 # Starting route column for back edges
|
|
1102
|
+
|
|
1103
|
+
# Sort back edges by source layer (draw deeper ones first)
|
|
1104
|
+
node_layer = {name: node.layer for name, node in layout_result.nodes.items()}
|
|
1105
|
+
|
|
1106
|
+
sorted_back_edges = sorted(
|
|
1107
|
+
layout_result.back_edges,
|
|
1108
|
+
key=lambda e: node_layer.get(e[0], 0),
|
|
1109
|
+
reverse=True,
|
|
1110
|
+
)
|
|
1111
|
+
|
|
1112
|
+
# Track entries per target for offset
|
|
1113
|
+
target_entry_count: Dict[str, int] = {}
|
|
1114
|
+
|
|
1115
|
+
# Track used margin positions to offset multiple back edges
|
|
1116
|
+
margin_offset = 0
|
|
1117
|
+
|
|
1118
|
+
for source, target in sorted_back_edges:
|
|
1119
|
+
src_dims = box_dimensions[source]
|
|
1120
|
+
tgt_dims = box_dimensions[target]
|
|
1121
|
+
src_x, src_y = box_positions[source]
|
|
1122
|
+
tgt_x, tgt_y = box_positions[target]
|
|
1123
|
+
|
|
1124
|
+
# Use offset margin for multiple back edges
|
|
1125
|
+
route_x = margin_x + margin_offset
|
|
1126
|
+
margin_offset += 3 # Space out multiple back edges (increased for clarity)
|
|
1127
|
+
|
|
1128
|
+
# Track how many edges already entered this target
|
|
1129
|
+
entry_idx = target_entry_count.get(target, 0)
|
|
1130
|
+
target_entry_count[target] = entry_idx + 1
|
|
1131
|
+
|
|
1132
|
+
# Exit point: bottom of source box, then route to left margin
|
|
1133
|
+
exit_border_y = src_y + src_dims.height - 1 # Bottom border
|
|
1134
|
+
exit_below_y = exit_border_y + (2 if self.shadow else 1) # Below shadow
|
|
1135
|
+
|
|
1136
|
+
# Entry point: left side of target box
|
|
1137
|
+
# Offset vertically for multiple entries to same target
|
|
1138
|
+
entry_x = tgt_x
|
|
1139
|
+
base_entry_y = tgt_y + 1 # Start from top of content
|
|
1140
|
+
entry_y = base_entry_y + entry_idx
|
|
1141
|
+
|
|
1142
|
+
# Ensure entry_y is within the box
|
|
1143
|
+
max_entry_y = tgt_y + tgt_dims.height - 2
|
|
1144
|
+
if entry_y > max_entry_y:
|
|
1145
|
+
entry_y = max_entry_y
|
|
1146
|
+
|
|
1147
|
+
# Check if there are boxes between margin and target that would block
|
|
1148
|
+
# the horizontal path at entry_y
|
|
1149
|
+
boxes_in_path = []
|
|
1150
|
+
for node_name, (node_x, node_y) in box_positions.items():
|
|
1151
|
+
if node_name == target:
|
|
1152
|
+
continue
|
|
1153
|
+
node_dims = box_dimensions[node_name]
|
|
1154
|
+
node_right = node_x + node_dims.width + (1 if self.shadow else 0)
|
|
1155
|
+
node_bottom = node_y + node_dims.height + (2 if self.shadow else 0)
|
|
1156
|
+
|
|
1157
|
+
# Check if this box is between margin and target horizontally
|
|
1158
|
+
# AND overlaps with entry_y vertically
|
|
1159
|
+
if node_x > route_x and node_right < entry_x:
|
|
1160
|
+
if node_y <= entry_y < node_bottom:
|
|
1161
|
+
boxes_in_path.append((node_name, node_x, node_y, node_dims))
|
|
1162
|
+
|
|
1163
|
+
# Draw the back edge path:
|
|
1164
|
+
# 1. Mark exit on source bottom-left corner area
|
|
1165
|
+
exit_x = src_x + 1 + (margin_offset - 3) # Offset exit point too
|
|
1166
|
+
if exit_x >= src_x + src_dims.width - 1:
|
|
1167
|
+
exit_x = src_x + 1
|
|
1168
|
+
|
|
1169
|
+
canvas.set(exit_x, exit_border_y, LINE_CHARS["tee_down"])
|
|
1170
|
+
|
|
1171
|
+
# 2. Short vertical line down from source (through shadow)
|
|
1172
|
+
for y in range(exit_border_y + 1, exit_below_y + 1):
|
|
1173
|
+
canvas.set(exit_x, y, LINE_CHARS["vertical"])
|
|
1174
|
+
|
|
1175
|
+
# 3. Corner turning left
|
|
1176
|
+
canvas.set(exit_x, exit_below_y, LINE_CHARS["corner_bottom_right"])
|
|
1177
|
+
|
|
1178
|
+
# 4. Horizontal line left to margin
|
|
1179
|
+
for x in range(route_x + 1, exit_x):
|
|
1180
|
+
current = canvas.get(x, exit_below_y)
|
|
1181
|
+
if current == LINE_CHARS["vertical"]:
|
|
1182
|
+
canvas.set(x, exit_below_y, LINE_CHARS["cross"])
|
|
1183
|
+
elif current == " " or current == BOX_CHARS["shadow"]:
|
|
1184
|
+
canvas.set(x, exit_below_y, LINE_CHARS["horizontal"])
|
|
1185
|
+
|
|
1186
|
+
# 5. Corner at margin (turning up)
|
|
1187
|
+
canvas.set(route_x, exit_below_y, LINE_CHARS["corner_bottom_left"])
|
|
1188
|
+
|
|
1189
|
+
if boxes_in_path:
|
|
1190
|
+
# Need to route around boxes
|
|
1191
|
+
# Strategy: go up to above the target layer (in the gap),
|
|
1192
|
+
# then route horizontally, then down into target from above
|
|
1193
|
+
# This avoids crossing boxes in the same layer as the target
|
|
1194
|
+
|
|
1195
|
+
# Find the top of the target (where we want to enter from above)
|
|
1196
|
+
safe_y = tgt_y - 2 # Position in gap above target layer
|
|
1197
|
+
|
|
1198
|
+
# Find a safe approach x (to the right of blocking boxes)
|
|
1199
|
+
max_blocking_right = max(
|
|
1200
|
+
node_x + node_dims.width + (2 if self.shadow else 1)
|
|
1201
|
+
for _, node_x, _, node_dims in boxes_in_path
|
|
1202
|
+
)
|
|
1203
|
+
# Approach from the right of blocking boxes, but left of target
|
|
1204
|
+
approach_x = min(max_blocking_right + 2, entry_x - 4)
|
|
1205
|
+
if approach_x < route_x + 4:
|
|
1206
|
+
approach_x = route_x + 4
|
|
1207
|
+
|
|
1208
|
+
# 6a. Vertical line up the margin to safe_y
|
|
1209
|
+
for y in range(safe_y + 1, exit_below_y):
|
|
1210
|
+
current = canvas.get(route_x, y)
|
|
1211
|
+
if current == LINE_CHARS["horizontal"]:
|
|
1212
|
+
canvas.set(route_x, y, LINE_CHARS["cross"])
|
|
1213
|
+
elif current == " " or current == BOX_CHARS["shadow"]:
|
|
1214
|
+
canvas.set(route_x, y, LINE_CHARS["vertical"])
|
|
1215
|
+
|
|
1216
|
+
# 7a. Corner at safe_y (turning right)
|
|
1217
|
+
canvas.set(route_x, safe_y, LINE_CHARS["corner_top_left"])
|
|
1218
|
+
|
|
1219
|
+
# 8a. Horizontal line to approach position
|
|
1220
|
+
for x in range(route_x + 1, approach_x):
|
|
1221
|
+
current = canvas.get(x, safe_y)
|
|
1222
|
+
if current == LINE_CHARS["vertical"]:
|
|
1223
|
+
canvas.set(x, safe_y, LINE_CHARS["cross"])
|
|
1224
|
+
elif current == " " or current == BOX_CHARS["shadow"]:
|
|
1225
|
+
canvas.set(x, safe_y, LINE_CHARS["horizontal"])
|
|
1226
|
+
|
|
1227
|
+
# 9a. Corner turning down toward target
|
|
1228
|
+
canvas.set(approach_x, safe_y, LINE_CHARS["corner_top_right"])
|
|
1229
|
+
|
|
1230
|
+
# 10a. Vertical line down to entry level
|
|
1231
|
+
for y in range(safe_y + 1, entry_y):
|
|
1232
|
+
current = canvas.get(approach_x, y)
|
|
1233
|
+
if current == LINE_CHARS["horizontal"]:
|
|
1234
|
+
canvas.set(approach_x, y, LINE_CHARS["cross"])
|
|
1235
|
+
elif current == " " or current == BOX_CHARS["shadow"]:
|
|
1236
|
+
canvas.set(approach_x, y, LINE_CHARS["vertical"])
|
|
1237
|
+
|
|
1238
|
+
# 11a. Corner at entry_y turning right to target
|
|
1239
|
+
canvas.set(approach_x, entry_y, LINE_CHARS["corner_bottom_left"])
|
|
1240
|
+
|
|
1241
|
+
# 12a. Horizontal line to arrow position
|
|
1242
|
+
for x in range(approach_x + 1, entry_x - 1):
|
|
1243
|
+
current = canvas.get(x, entry_y)
|
|
1244
|
+
if current == " " or current == BOX_CHARS["shadow"]:
|
|
1245
|
+
canvas.set(x, entry_y, LINE_CHARS["horizontal"])
|
|
1246
|
+
|
|
1247
|
+
# 13a. Arrow
|
|
1248
|
+
canvas.set(entry_x - 1, entry_y, ARROW_CHARS["right"])
|
|
1249
|
+
else:
|
|
1250
|
+
# No boxes in path - draw directly
|
|
1251
|
+
# 6. Vertical line up the margin
|
|
1252
|
+
for y in range(entry_y + 1, exit_below_y):
|
|
1253
|
+
current = canvas.get(route_x, y)
|
|
1254
|
+
if current == LINE_CHARS["horizontal"]:
|
|
1255
|
+
canvas.set(route_x, y, LINE_CHARS["cross"])
|
|
1256
|
+
elif current == " " or current == BOX_CHARS["shadow"]:
|
|
1257
|
+
canvas.set(route_x, y, LINE_CHARS["vertical"])
|
|
1258
|
+
|
|
1259
|
+
# 7. Corner at target level (turning right)
|
|
1260
|
+
current = canvas.get(route_x, entry_y)
|
|
1261
|
+
if current == LINE_CHARS["vertical"]:
|
|
1262
|
+
canvas.set(route_x, entry_y, LINE_CHARS["tee_right"])
|
|
1263
|
+
elif current == LINE_CHARS["horizontal"]:
|
|
1264
|
+
canvas.set(route_x, entry_y, LINE_CHARS["tee_down"])
|
|
1265
|
+
elif current == " " or current == BOX_CHARS["shadow"]:
|
|
1266
|
+
canvas.set(route_x, entry_y, LINE_CHARS["corner_top_left"])
|
|
1267
|
+
|
|
1268
|
+
# 8. Horizontal line from margin to target
|
|
1269
|
+
for x in range(route_x + 1, entry_x - 1):
|
|
1270
|
+
current = canvas.get(x, entry_y)
|
|
1271
|
+
if current == LINE_CHARS["vertical"]:
|
|
1272
|
+
canvas.set(x, entry_y, LINE_CHARS["cross"])
|
|
1273
|
+
elif current == LINE_CHARS["corner_top_left"]:
|
|
1274
|
+
canvas.set(x, entry_y, LINE_CHARS["tee_down"])
|
|
1275
|
+
elif current == " " or current == BOX_CHARS["shadow"]:
|
|
1276
|
+
canvas.set(x, entry_y, LINE_CHARS["horizontal"])
|
|
1277
|
+
|
|
1278
|
+
# 9. Arrow one column before target box
|
|
1279
|
+
canvas.set(entry_x - 1, entry_y, ARROW_CHARS["right"])
|
|
1280
|
+
|
|
1281
|
+
def _draw_edges_horizontal(
|
|
1282
|
+
self,
|
|
1283
|
+
canvas: Canvas,
|
|
1284
|
+
layout_result: LayoutResult,
|
|
1285
|
+
box_dimensions: Dict[str, BoxDimensions],
|
|
1286
|
+
box_positions: Dict[str, Tuple[int, int]],
|
|
1287
|
+
column_boundaries: List[ColumnBoundary],
|
|
1288
|
+
title_height: int = 0,
|
|
1289
|
+
) -> None:
|
|
1290
|
+
"""Draw all forward edges in horizontal (LR) mode."""
|
|
1291
|
+
|
|
1292
|
+
# Build lookup for node layers
|
|
1293
|
+
node_layer = {name: node.layer for name, node in layout_result.nodes.items()}
|
|
1294
|
+
|
|
1295
|
+
# Group edges by source to allocate ports properly
|
|
1296
|
+
edges_from: Dict[str, List[str]] = {}
|
|
1297
|
+
edges_to: Dict[str, List[str]] = {}
|
|
1298
|
+
|
|
1299
|
+
for source, target in layout_result.edges:
|
|
1300
|
+
# Skip back edges
|
|
1301
|
+
if (source, target) in layout_result.back_edges:
|
|
1302
|
+
continue
|
|
1303
|
+
|
|
1304
|
+
src_layer = node_layer.get(source, 0)
|
|
1305
|
+
tgt_layer = node_layer.get(target, 0)
|
|
1306
|
+
|
|
1307
|
+
# Only draw forward edges (target layer > source layer)
|
|
1308
|
+
if tgt_layer <= src_layer:
|
|
1309
|
+
continue
|
|
1310
|
+
|
|
1311
|
+
if source not in edges_from:
|
|
1312
|
+
edges_from[source] = []
|
|
1313
|
+
edges_from[source].append(target)
|
|
1314
|
+
|
|
1315
|
+
if target not in edges_to:
|
|
1316
|
+
edges_to[target] = []
|
|
1317
|
+
edges_to[target].append(source)
|
|
1318
|
+
|
|
1319
|
+
# Sort edges for consistent port allocation (by vertical position)
|
|
1320
|
+
for source in edges_from:
|
|
1321
|
+
edges_from[source].sort(key=lambda t: box_positions[t][1])
|
|
1322
|
+
for target in edges_to:
|
|
1323
|
+
edges_to[target].sort(key=lambda s: box_positions[s][1])
|
|
1324
|
+
|
|
1325
|
+
# Draw each forward edge
|
|
1326
|
+
for source, target in layout_result.edges:
|
|
1327
|
+
if (source, target) in layout_result.back_edges:
|
|
1328
|
+
continue
|
|
1329
|
+
|
|
1330
|
+
src_layer = node_layer.get(source, 0)
|
|
1331
|
+
tgt_layer = node_layer.get(target, 0)
|
|
1332
|
+
|
|
1333
|
+
if tgt_layer <= src_layer:
|
|
1334
|
+
continue
|
|
1335
|
+
|
|
1336
|
+
self._draw_edge_horizontal(
|
|
1337
|
+
canvas,
|
|
1338
|
+
source,
|
|
1339
|
+
target,
|
|
1340
|
+
box_dimensions,
|
|
1341
|
+
box_positions,
|
|
1342
|
+
edges_from.get(source, []),
|
|
1343
|
+
edges_to.get(target, []),
|
|
1344
|
+
column_boundaries,
|
|
1345
|
+
src_layer,
|
|
1346
|
+
tgt_layer,
|
|
1347
|
+
layout_result,
|
|
1348
|
+
)
|
|
1349
|
+
|
|
1350
|
+
def _draw_edge_horizontal(
|
|
1351
|
+
self,
|
|
1352
|
+
canvas: Canvas,
|
|
1353
|
+
source: str,
|
|
1354
|
+
target: str,
|
|
1355
|
+
box_dimensions: Dict[str, BoxDimensions],
|
|
1356
|
+
box_positions: Dict[str, Tuple[int, int]],
|
|
1357
|
+
source_targets: List[str],
|
|
1358
|
+
target_sources: List[str],
|
|
1359
|
+
column_boundaries: List[ColumnBoundary],
|
|
1360
|
+
src_layer: int,
|
|
1361
|
+
tgt_layer: int,
|
|
1362
|
+
layout_result: LayoutResult,
|
|
1363
|
+
) -> None:
|
|
1364
|
+
"""Draw a single edge from source to target in horizontal (LR) mode."""
|
|
1365
|
+
src_dims = box_dimensions[source]
|
|
1366
|
+
tgt_dims = box_dimensions[target]
|
|
1367
|
+
src_x, src_y = box_positions[source]
|
|
1368
|
+
tgt_x, tgt_y = box_positions[target]
|
|
1369
|
+
|
|
1370
|
+
# Check if boxes overlap vertically (inside borders)
|
|
1371
|
+
src_top = src_y + 1
|
|
1372
|
+
src_bottom = src_y + src_dims.height - 2
|
|
1373
|
+
tgt_top = tgt_y + 1
|
|
1374
|
+
tgt_bottom = tgt_y + tgt_dims.height - 2
|
|
1375
|
+
|
|
1376
|
+
overlap_top = max(src_top, tgt_top)
|
|
1377
|
+
overlap_bottom = min(src_bottom, tgt_bottom)
|
|
1378
|
+
has_overlap = overlap_top < overlap_bottom
|
|
1379
|
+
|
|
1380
|
+
# Check if there are boxes in intermediate columns that would block the path
|
|
1381
|
+
boxes_in_path = False
|
|
1382
|
+
if has_overlap and tgt_layer - src_layer > 1:
|
|
1383
|
+
for layer_idx in range(src_layer + 1, tgt_layer):
|
|
1384
|
+
for node_name in layout_result.layers[layer_idx]:
|
|
1385
|
+
node_dims = box_dimensions[node_name]
|
|
1386
|
+
node_x, node_y = box_positions[node_name]
|
|
1387
|
+
node_top = node_y
|
|
1388
|
+
node_bottom = node_y + node_dims.height
|
|
1389
|
+
|
|
1390
|
+
# Check if this box's y range overlaps with the edge's y range
|
|
1391
|
+
if node_top < overlap_bottom and node_bottom > overlap_top:
|
|
1392
|
+
boxes_in_path = True
|
|
1393
|
+
break
|
|
1394
|
+
if boxes_in_path:
|
|
1395
|
+
break
|
|
1396
|
+
|
|
1397
|
+
if has_overlap and not boxes_in_path:
|
|
1398
|
+
# Boxes overlap vertically and no obstructions
|
|
1399
|
+
overlapping_targets = []
|
|
1400
|
+
for t in source_targets:
|
|
1401
|
+
t_dims = box_dimensions[t]
|
|
1402
|
+
t_x, t_y = box_positions[t]
|
|
1403
|
+
t_top = t_y + 1
|
|
1404
|
+
t_bottom = t_y + t_dims.height - 2
|
|
1405
|
+
t_overlap_top = max(src_top, t_top)
|
|
1406
|
+
t_overlap_bottom = min(src_bottom, t_bottom)
|
|
1407
|
+
if t_overlap_top < t_overlap_bottom:
|
|
1408
|
+
overlapping_targets.append(t)
|
|
1409
|
+
|
|
1410
|
+
# Distribute ports within the overlap region
|
|
1411
|
+
overlap_height = overlap_bottom - overlap_top
|
|
1412
|
+
overlap_count = len(overlapping_targets)
|
|
1413
|
+
overlap_idx = overlapping_targets.index(target)
|
|
1414
|
+
|
|
1415
|
+
if overlap_count == 1:
|
|
1416
|
+
port_y = (overlap_top + overlap_bottom) // 2
|
|
1417
|
+
else:
|
|
1418
|
+
if overlap_height >= overlap_count * 2:
|
|
1419
|
+
spacing = overlap_height // (overlap_count + 1)
|
|
1420
|
+
port_y = overlap_top + spacing * (overlap_idx + 1)
|
|
1421
|
+
else:
|
|
1422
|
+
port_y = overlap_top + (overlap_height * (overlap_idx + 1)) // (
|
|
1423
|
+
overlap_count + 1
|
|
1424
|
+
)
|
|
1425
|
+
|
|
1426
|
+
src_port_y = port_y
|
|
1427
|
+
tgt_port_y = port_y
|
|
1428
|
+
else:
|
|
1429
|
+
# No vertical overlap or boxes in path - use distributed ports
|
|
1430
|
+
src_port_count = len(source_targets)
|
|
1431
|
+
src_port_idx = source_targets.index(target)
|
|
1432
|
+
src_port_y = self._calculate_port_y(
|
|
1433
|
+
src_y, src_dims.height, src_port_idx, src_port_count
|
|
1434
|
+
)
|
|
1435
|
+
|
|
1436
|
+
tgt_port_count = len(target_sources)
|
|
1437
|
+
tgt_port_idx = target_sources.index(source)
|
|
1438
|
+
tgt_port_y = self._calculate_port_y(
|
|
1439
|
+
tgt_y, tgt_dims.height, tgt_port_idx, tgt_port_count
|
|
1440
|
+
)
|
|
1441
|
+
|
|
1442
|
+
# Exit from right side of source box
|
|
1443
|
+
src_port_x = src_x + src_dims.width - 1
|
|
1444
|
+
# Enter left side of target box
|
|
1445
|
+
tgt_port_x = tgt_x
|
|
1446
|
+
|
|
1447
|
+
# Modify source right border to show exit point (tee right)
|
|
1448
|
+
canvas.set(src_port_x, src_port_y, LINE_CHARS["tee_left"])
|
|
1449
|
+
|
|
1450
|
+
# Calculate path
|
|
1451
|
+
start_x = src_port_x + 1 # After source (through shadow)
|
|
1452
|
+
end_x = tgt_port_x
|
|
1453
|
+
|
|
1454
|
+
# Check if we need to route around boxes
|
|
1455
|
+
if boxes_in_path:
|
|
1456
|
+
# Route below all boxes to avoid crossing them
|
|
1457
|
+
max_bottom_y = src_y + src_dims.height
|
|
1458
|
+
for layer_idx in range(src_layer + 1, tgt_layer):
|
|
1459
|
+
for node_name in layout_result.layers[layer_idx]:
|
|
1460
|
+
node_dims = box_dimensions[node_name]
|
|
1461
|
+
node_x, node_y = box_positions[node_name]
|
|
1462
|
+
node_bottom = node_y + node_dims.height + (2 if self.shadow else 0)
|
|
1463
|
+
max_bottom_y = max(max_bottom_y, node_bottom)
|
|
1464
|
+
|
|
1465
|
+
# Route: right, down to bypass, right, up to target
|
|
1466
|
+
route_y = max_bottom_y + 2 # Go 2 rows below all boxes
|
|
1467
|
+
|
|
1468
|
+
# Use the mid_x from source layer for the first vertical segment
|
|
1469
|
+
mid_x = self._get_safe_vertical_x(column_boundaries, src_layer, start_x)
|
|
1470
|
+
|
|
1471
|
+
# Horizontal from source to mid
|
|
1472
|
+
self._draw_horizontal_line(canvas, start_x - 1, mid_x, src_port_y)
|
|
1473
|
+
|
|
1474
|
+
# Corner turning down
|
|
1475
|
+
canvas.set(mid_x, src_port_y, LINE_CHARS["corner_top_right"])
|
|
1476
|
+
|
|
1477
|
+
# Vertical segment down to route_y
|
|
1478
|
+
self._draw_vertical_line(canvas, mid_x, src_port_y + 1, route_y - 1)
|
|
1479
|
+
|
|
1480
|
+
# Corner turning right
|
|
1481
|
+
canvas.set(mid_x, route_y, LINE_CHARS["corner_bottom_left"])
|
|
1482
|
+
|
|
1483
|
+
# Find the x position for the vertical segment before the target
|
|
1484
|
+
tgt_mid_x = self._get_safe_vertical_x(
|
|
1485
|
+
column_boundaries, tgt_layer - 1, start_x
|
|
1486
|
+
)
|
|
1487
|
+
|
|
1488
|
+
# Horizontal segment below boxes
|
|
1489
|
+
self._draw_horizontal_line(canvas, mid_x, tgt_mid_x, route_y)
|
|
1490
|
+
|
|
1491
|
+
# Corner turning up
|
|
1492
|
+
canvas.set(tgt_mid_x, route_y, LINE_CHARS["corner_bottom_right"])
|
|
1493
|
+
|
|
1494
|
+
# Vertical segment up toward target
|
|
1495
|
+
self._draw_vertical_line(canvas, tgt_mid_x, tgt_port_y + 1, route_y - 1)
|
|
1496
|
+
|
|
1497
|
+
# Corner turning right to target
|
|
1498
|
+
canvas.set(tgt_mid_x, tgt_port_y, LINE_CHARS["corner_top_left"])
|
|
1499
|
+
|
|
1500
|
+
# Horizontal to target
|
|
1501
|
+
self._draw_horizontal_line(canvas, tgt_mid_x, end_x - 1, tgt_port_y)
|
|
1502
|
+
|
|
1503
|
+
# Arrow
|
|
1504
|
+
canvas.set(tgt_port_x - 1, tgt_port_y, ARROW_CHARS["right"])
|
|
1505
|
+
|
|
1506
|
+
elif src_port_y == tgt_port_y:
|
|
1507
|
+
# Direct horizontal line
|
|
1508
|
+
# Note: _draw_horizontal_line is exclusive of endpoints, so we adjust
|
|
1509
|
+
# start_x - 1 so the line begins at start_x, and end_x - 1 so it ends
|
|
1510
|
+
# at end_x - 2 (one position before the arrow at end_x - 1)
|
|
1511
|
+
self._draw_horizontal_line(canvas, start_x - 1, end_x - 1, src_port_y)
|
|
1512
|
+
canvas.set(tgt_port_x - 1, tgt_port_y, ARROW_CHARS["right"])
|
|
1513
|
+
else:
|
|
1514
|
+
# Need to route with vertical segment
|
|
1515
|
+
# Use column-aware routing: place vertical segment in the gap zone
|
|
1516
|
+
# to the right of the source layer where no boxes can exist
|
|
1517
|
+
mid_x = self._get_safe_vertical_x(column_boundaries, src_layer, start_x)
|
|
1518
|
+
|
|
1519
|
+
# Horizontal from source to mid
|
|
1520
|
+
# Adjust for exclusive endpoints: start_x - 1 so line begins at start_x,
|
|
1521
|
+
# mid_x so line ends at mid_x - 1 (just before the corner at mid_x)
|
|
1522
|
+
self._draw_horizontal_line(canvas, start_x - 1, mid_x, src_port_y)
|
|
1523
|
+
|
|
1524
|
+
# Corner at source row
|
|
1525
|
+
if tgt_port_y > src_port_y:
|
|
1526
|
+
canvas.set(mid_x, src_port_y, LINE_CHARS["corner_top_right"])
|
|
1527
|
+
else:
|
|
1528
|
+
canvas.set(mid_x, src_port_y, LINE_CHARS["corner_bottom_right"])
|
|
1529
|
+
|
|
1530
|
+
# Vertical segment
|
|
1531
|
+
self._draw_vertical_line(canvas, mid_x, src_port_y, tgt_port_y)
|
|
1532
|
+
|
|
1533
|
+
# Corner at target row
|
|
1534
|
+
if tgt_port_y > src_port_y:
|
|
1535
|
+
canvas.set(mid_x, tgt_port_y, LINE_CHARS["corner_bottom_left"])
|
|
1536
|
+
else:
|
|
1537
|
+
canvas.set(mid_x, tgt_port_y, LINE_CHARS["corner_top_left"])
|
|
1538
|
+
|
|
1539
|
+
# Horizontal from mid to target
|
|
1540
|
+
# Adjust for exclusive endpoints: mid_x so line begins at mid_x + 1,
|
|
1541
|
+
# end_x - 1 so line ends at end_x - 2 (just before the arrow at end_x - 1)
|
|
1542
|
+
self._draw_horizontal_line(canvas, mid_x, end_x - 1, tgt_port_y)
|
|
1543
|
+
|
|
1544
|
+
# Arrow
|
|
1545
|
+
canvas.set(tgt_port_x - 1, tgt_port_y, ARROW_CHARS["right"])
|
|
1546
|
+
|
|
1547
|
+
def _calculate_port_y(
|
|
1548
|
+
self, box_y: int, box_height: int, port_idx: int, port_count: int
|
|
1549
|
+
) -> int:
|
|
1550
|
+
"""Calculate y position for a port on a box (horizontal mode)."""
|
|
1551
|
+
if port_count == 1:
|
|
1552
|
+
return box_y + box_height // 2
|
|
1553
|
+
else:
|
|
1554
|
+
usable_height = box_height - 4
|
|
1555
|
+
spacing = usable_height // (port_count + 1)
|
|
1556
|
+
return box_y + 2 + spacing * (port_idx + 1)
|
|
1557
|
+
|
|
1558
|
+
def _draw_back_edges_horizontal(
|
|
1559
|
+
self,
|
|
1560
|
+
canvas: Canvas,
|
|
1561
|
+
layout_result: LayoutResult,
|
|
1562
|
+
box_dimensions: Dict[str, BoxDimensions],
|
|
1563
|
+
box_positions: Dict[str, Tuple[int, int]],
|
|
1564
|
+
title_height: int = 0,
|
|
1565
|
+
) -> None:
|
|
1566
|
+
"""
|
|
1567
|
+
Draw back edges (cycle edges) along the top margin in horizontal mode.
|
|
1568
|
+
|
|
1569
|
+
Back edges exit from the top-right of the source box, route up
|
|
1570
|
+
to the margin, left along the margin, then down to enter the
|
|
1571
|
+
target from the top.
|
|
1572
|
+
|
|
1573
|
+
If there are boxes between the margin and the target, the edge routes
|
|
1574
|
+
to the right of those boxes to avoid crossing through them.
|
|
1575
|
+
"""
|
|
1576
|
+
if not layout_result.back_edges:
|
|
1577
|
+
return
|
|
1578
|
+
|
|
1579
|
+
margin_y = 2 + title_height # Starting route row for back edges
|
|
1580
|
+
|
|
1581
|
+
# Sort back edges by source layer (draw deeper ones first)
|
|
1582
|
+
node_layer = {name: node.layer for name, node in layout_result.nodes.items()}
|
|
1583
|
+
|
|
1584
|
+
sorted_back_edges = sorted(
|
|
1585
|
+
layout_result.back_edges,
|
|
1586
|
+
key=lambda e: node_layer.get(e[0], 0),
|
|
1587
|
+
reverse=True,
|
|
1588
|
+
)
|
|
1589
|
+
|
|
1590
|
+
# Track entries per target for offset
|
|
1591
|
+
target_entry_count: Dict[str, int] = {}
|
|
1592
|
+
|
|
1593
|
+
# Track used margin positions to offset multiple back edges
|
|
1594
|
+
margin_offset = 0
|
|
1595
|
+
|
|
1596
|
+
for source, target in sorted_back_edges:
|
|
1597
|
+
src_dims = box_dimensions[source]
|
|
1598
|
+
tgt_dims = box_dimensions[target]
|
|
1599
|
+
src_x, src_y = box_positions[source]
|
|
1600
|
+
tgt_x, tgt_y = box_positions[target]
|
|
1601
|
+
|
|
1602
|
+
# Use offset margin for multiple back edges
|
|
1603
|
+
route_y = margin_y + margin_offset
|
|
1604
|
+
margin_offset += 3 # Space out multiple back edges (increased for clarity)
|
|
1605
|
+
|
|
1606
|
+
# Track how many edges already entered this target
|
|
1607
|
+
entry_idx = target_entry_count.get(target, 0)
|
|
1608
|
+
target_entry_count[target] = entry_idx + 1
|
|
1609
|
+
|
|
1610
|
+
# Exit point: right side of source box, near top
|
|
1611
|
+
exit_border_x = src_x + src_dims.width - 1
|
|
1612
|
+
exit_right_x = exit_border_x + (2 if self.shadow else 1)
|
|
1613
|
+
|
|
1614
|
+
# Entry point: top side of target box
|
|
1615
|
+
entry_y = tgt_y
|
|
1616
|
+
base_entry_x = tgt_x + 1
|
|
1617
|
+
entry_x = base_entry_x + entry_idx
|
|
1618
|
+
|
|
1619
|
+
# Ensure entry_x is within the box
|
|
1620
|
+
max_entry_x = tgt_x + tgt_dims.width - 2
|
|
1621
|
+
if entry_x > max_entry_x:
|
|
1622
|
+
entry_x = max_entry_x
|
|
1623
|
+
|
|
1624
|
+
# Check if there are boxes between margin and target that would block
|
|
1625
|
+
# the vertical path at entry_x
|
|
1626
|
+
boxes_in_descent_path = []
|
|
1627
|
+
for node_name, (node_x, node_y) in box_positions.items():
|
|
1628
|
+
if node_name == target:
|
|
1629
|
+
continue
|
|
1630
|
+
node_dims = box_dimensions[node_name]
|
|
1631
|
+
node_right = node_x + node_dims.width + (1 if self.shadow else 0)
|
|
1632
|
+
node_bottom = node_y + node_dims.height + (2 if self.shadow else 0)
|
|
1633
|
+
|
|
1634
|
+
# Check if this box is between margin and target vertically
|
|
1635
|
+
# AND overlaps with entry_x horizontally
|
|
1636
|
+
if node_y > route_y and node_bottom < entry_y:
|
|
1637
|
+
if node_x <= entry_x < node_right:
|
|
1638
|
+
boxes_in_descent_path.append(
|
|
1639
|
+
(node_name, node_x, node_y, node_dims)
|
|
1640
|
+
)
|
|
1641
|
+
|
|
1642
|
+
# Draw the back edge path:
|
|
1643
|
+
# 1. Mark exit on source right side near top
|
|
1644
|
+
exit_y = src_y + 1 + (margin_offset - 3)
|
|
1645
|
+
if exit_y >= src_y + src_dims.height - 1:
|
|
1646
|
+
exit_y = src_y + 1
|
|
1647
|
+
|
|
1648
|
+
canvas.set(exit_border_x, exit_y, LINE_CHARS["tee_left"])
|
|
1649
|
+
|
|
1650
|
+
# Check if there are boxes between source and margin that would block
|
|
1651
|
+
# the upward vertical path at exit_right_x
|
|
1652
|
+
boxes_in_ascent_path = []
|
|
1653
|
+
for node_name, (node_x, node_y) in box_positions.items():
|
|
1654
|
+
if node_name == source:
|
|
1655
|
+
continue
|
|
1656
|
+
node_dims = box_dimensions[node_name]
|
|
1657
|
+
node_right = node_x + node_dims.width + (1 if self.shadow else 0)
|
|
1658
|
+
node_bottom = node_y + node_dims.height + (2 if self.shadow else 0)
|
|
1659
|
+
|
|
1660
|
+
# Check if this box is between margin and source vertically
|
|
1661
|
+
# AND overlaps with exit_right_x horizontally
|
|
1662
|
+
if node_y > route_y and node_bottom < exit_y:
|
|
1663
|
+
if node_x <= exit_right_x < node_right:
|
|
1664
|
+
boxes_in_ascent_path.append(
|
|
1665
|
+
(node_name, node_x, node_y, node_dims)
|
|
1666
|
+
)
|
|
1667
|
+
|
|
1668
|
+
if boxes_in_ascent_path:
|
|
1669
|
+
# Need to route around boxes on the way up to the margin
|
|
1670
|
+
# Strategy: go further right past all blocking boxes, then go up
|
|
1671
|
+
|
|
1672
|
+
# Find the rightmost edge of blocking boxes
|
|
1673
|
+
max_blocking_right = max(
|
|
1674
|
+
node_x + node_dims.width + (2 if self.shadow else 1)
|
|
1675
|
+
for _, node_x, _, node_dims in boxes_in_ascent_path
|
|
1676
|
+
)
|
|
1677
|
+
|
|
1678
|
+
# Turn up position: to the right of all blocking boxes
|
|
1679
|
+
turn_up_x = max_blocking_right + 1
|
|
1680
|
+
|
|
1681
|
+
# 2a. Horizontal line right from source to turn_up_x
|
|
1682
|
+
for x in range(exit_border_x + 1, turn_up_x):
|
|
1683
|
+
current = canvas.get(x, exit_y)
|
|
1684
|
+
if current == " " or current == BOX_CHARS["shadow"]:
|
|
1685
|
+
canvas.set(x, exit_y, LINE_CHARS["horizontal"])
|
|
1686
|
+
|
|
1687
|
+
# 3a. Corner turning up at turn_up_x
|
|
1688
|
+
canvas.set(turn_up_x, exit_y, LINE_CHARS["corner_bottom_right"])
|
|
1689
|
+
|
|
1690
|
+
# 4a. Vertical line up to margin
|
|
1691
|
+
for y in range(route_y + 1, exit_y):
|
|
1692
|
+
current = canvas.get(turn_up_x, y)
|
|
1693
|
+
if current == LINE_CHARS["horizontal"]:
|
|
1694
|
+
canvas.set(turn_up_x, y, LINE_CHARS["cross"])
|
|
1695
|
+
elif current == " " or current == BOX_CHARS["shadow"]:
|
|
1696
|
+
canvas.set(turn_up_x, y, LINE_CHARS["vertical"])
|
|
1697
|
+
|
|
1698
|
+
# 5a. Corner at margin (turning left)
|
|
1699
|
+
canvas.set(turn_up_x, route_y, LINE_CHARS["corner_top_right"])
|
|
1700
|
+
|
|
1701
|
+
# Update exit_right_x for the horizontal line along margin
|
|
1702
|
+
exit_right_x = turn_up_x
|
|
1703
|
+
else:
|
|
1704
|
+
# No boxes in ascent path - draw directly
|
|
1705
|
+
# 2. Short horizontal line right from source (through shadow)
|
|
1706
|
+
for x in range(exit_border_x + 1, exit_right_x + 1):
|
|
1707
|
+
canvas.set(x, exit_y, LINE_CHARS["horizontal"])
|
|
1708
|
+
|
|
1709
|
+
# 3. Corner turning up (line enters from left, exits upward)
|
|
1710
|
+
canvas.set(exit_right_x, exit_y, LINE_CHARS["corner_bottom_right"])
|
|
1711
|
+
|
|
1712
|
+
# 4. Vertical line up to margin
|
|
1713
|
+
for y in range(route_y + 1, exit_y):
|
|
1714
|
+
current = canvas.get(exit_right_x, y)
|
|
1715
|
+
if current == LINE_CHARS["horizontal"]:
|
|
1716
|
+
canvas.set(exit_right_x, y, LINE_CHARS["cross"])
|
|
1717
|
+
elif current == " " or current == BOX_CHARS["shadow"]:
|
|
1718
|
+
canvas.set(exit_right_x, y, LINE_CHARS["vertical"])
|
|
1719
|
+
|
|
1720
|
+
# 5. Corner at margin (turning left)
|
|
1721
|
+
canvas.set(exit_right_x, route_y, LINE_CHARS["corner_top_right"])
|
|
1722
|
+
|
|
1723
|
+
# 6. Horizontal line left along the margin
|
|
1724
|
+
for x in range(entry_x + 1, exit_right_x):
|
|
1725
|
+
current = canvas.get(x, route_y)
|
|
1726
|
+
if current == LINE_CHARS["vertical"]:
|
|
1727
|
+
canvas.set(x, route_y, LINE_CHARS["cross"])
|
|
1728
|
+
elif current == " " or current == BOX_CHARS["shadow"]:
|
|
1729
|
+
canvas.set(x, route_y, LINE_CHARS["horizontal"])
|
|
1730
|
+
|
|
1731
|
+
if boxes_in_descent_path:
|
|
1732
|
+
# Need to route around boxes
|
|
1733
|
+
# Strategy: continue LEFT on the margin past all blocking boxes,
|
|
1734
|
+
# then turn down and enter the target from the left side.
|
|
1735
|
+
# This avoids crossing boxes in the same column as the target.
|
|
1736
|
+
|
|
1737
|
+
# Find the leftmost x of all blocking boxes
|
|
1738
|
+
min_blocking_left = min(
|
|
1739
|
+
node_x for _, node_x, _, _ in boxes_in_descent_path
|
|
1740
|
+
)
|
|
1741
|
+
|
|
1742
|
+
# Turn down position: to the left of all blocking boxes
|
|
1743
|
+
turn_down_x = min_blocking_left - 2
|
|
1744
|
+
|
|
1745
|
+
# Calculate entry y position inside the target box
|
|
1746
|
+
target_entry_y = tgt_y + 1 + entry_idx
|
|
1747
|
+
max_target_entry_y = tgt_y + tgt_dims.height - 2
|
|
1748
|
+
if target_entry_y > max_target_entry_y:
|
|
1749
|
+
target_entry_y = max_target_entry_y
|
|
1750
|
+
|
|
1751
|
+
# Continue horizontal line from entry_x to turn_down_x
|
|
1752
|
+
# (the original line was drawn from exit_right_x to entry_x+1)
|
|
1753
|
+
for x in range(turn_down_x + 1, entry_x + 1):
|
|
1754
|
+
current = canvas.get(x, route_y)
|
|
1755
|
+
if current == LINE_CHARS["vertical"]:
|
|
1756
|
+
canvas.set(x, route_y, LINE_CHARS["cross"])
|
|
1757
|
+
elif current == " " or current == BOX_CHARS["shadow"]:
|
|
1758
|
+
canvas.set(x, route_y, LINE_CHARS["horizontal"])
|
|
1759
|
+
|
|
1760
|
+
# 7a. Corner at turn_down_x, route_y (turning down)
|
|
1761
|
+
canvas.set(turn_down_x, route_y, LINE_CHARS["corner_top_left"])
|
|
1762
|
+
|
|
1763
|
+
# 8a. Vertical line down to target_entry_y
|
|
1764
|
+
for y in range(route_y + 1, target_entry_y):
|
|
1765
|
+
current = canvas.get(turn_down_x, y)
|
|
1766
|
+
if current == LINE_CHARS["horizontal"]:
|
|
1767
|
+
canvas.set(turn_down_x, y, LINE_CHARS["cross"])
|
|
1768
|
+
elif current == " " or current == BOX_CHARS["shadow"]:
|
|
1769
|
+
canvas.set(turn_down_x, y, LINE_CHARS["vertical"])
|
|
1770
|
+
|
|
1771
|
+
# 9a. Corner at turn_down_x, target_entry_y (turning right)
|
|
1772
|
+
corner_char = LINE_CHARS["corner_bottom_left"]
|
|
1773
|
+
canvas.set(turn_down_x, target_entry_y, corner_char)
|
|
1774
|
+
|
|
1775
|
+
# 10a. Horizontal line to arrow position
|
|
1776
|
+
for x in range(turn_down_x + 1, tgt_x - 1):
|
|
1777
|
+
current = canvas.get(x, target_entry_y)
|
|
1778
|
+
if current == " " or current == BOX_CHARS["shadow"]:
|
|
1779
|
+
canvas.set(x, target_entry_y, LINE_CHARS["horizontal"])
|
|
1780
|
+
|
|
1781
|
+
# 11a. Arrow (entering from left)
|
|
1782
|
+
canvas.set(tgt_x - 1, target_entry_y, ARROW_CHARS["right"])
|
|
1783
|
+
else:
|
|
1784
|
+
# No boxes in path - draw directly
|
|
1785
|
+
# 7. Corner at target column (turning down)
|
|
1786
|
+
current = canvas.get(entry_x, route_y)
|
|
1787
|
+
if current == LINE_CHARS["horizontal"]:
|
|
1788
|
+
canvas.set(entry_x, route_y, LINE_CHARS["tee_down"])
|
|
1789
|
+
elif current == LINE_CHARS["vertical"]:
|
|
1790
|
+
canvas.set(entry_x, route_y, LINE_CHARS["tee_down"])
|
|
1791
|
+
elif current == " " or current == BOX_CHARS["shadow"]:
|
|
1792
|
+
canvas.set(entry_x, route_y, LINE_CHARS["corner_top_left"])
|
|
1793
|
+
|
|
1794
|
+
# 8. Vertical line from margin to target (stop before arrow)
|
|
1795
|
+
for y in range(route_y + 1, entry_y - 1):
|
|
1796
|
+
current = canvas.get(entry_x, y)
|
|
1797
|
+
if current == LINE_CHARS["horizontal"]:
|
|
1798
|
+
canvas.set(entry_x, y, LINE_CHARS["cross"])
|
|
1799
|
+
elif current == " " or current == BOX_CHARS["shadow"]:
|
|
1800
|
+
canvas.set(entry_x, y, LINE_CHARS["vertical"])
|
|
1801
|
+
|
|
1802
|
+
# 9. Arrow one row above target box
|
|
1803
|
+
canvas.set(entry_x, entry_y - 1, ARROW_CHARS["down"])
|