frameplot 0.1.0__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.
@@ -0,0 +1,1031 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import defaultdict
4
+ from dataclasses import dataclass, field
5
+
6
+ from frameplot.layout.types import Bounds, GroupOverlay, LayoutNode, Point, RoutedEdge, union_bounds
7
+
8
+ EPSILON = 0.01
9
+
10
+
11
+ @dataclass(slots=True)
12
+ class ComponentGeometry:
13
+ rank_left: dict[int, float]
14
+ rank_right: dict[int, float]
15
+ row_top: dict[int, float]
16
+ row_bottom: dict[int, float]
17
+ gap_after_rank: dict[int, tuple[float, float]]
18
+ gap_before_rank: dict[int, tuple[float, float]]
19
+ gap_after_row: dict[int, tuple[float, float]]
20
+ gap_before_row: dict[int, tuple[float, float]]
21
+ outer_top: float
22
+ outer_bottom: float
23
+ outer_right: float
24
+
25
+
26
+ @dataclass(slots=True)
27
+ class CandidatePath:
28
+ points: tuple[Point, ...]
29
+ reserved_segments: tuple[int, ...]
30
+
31
+
32
+ @dataclass(slots=True, frozen=True)
33
+ class StoredSegment:
34
+ start: float
35
+ end: float
36
+ edge_id: str
37
+ overlap_locked: bool
38
+
39
+
40
+ @dataclass(slots=True)
41
+ class Occupancy:
42
+ horizontal: dict[float, list[StoredSegment]] = field(
43
+ default_factory=lambda: defaultdict(list)
44
+ )
45
+ vertical: dict[float, list[StoredSegment]] = field(
46
+ default_factory=lambda: defaultdict(list)
47
+ )
48
+
49
+
50
+ def route_edges(
51
+ validated: "ValidatedPipeline | ValidatedDetailPanel",
52
+ nodes: dict[str, LayoutNode],
53
+ ) -> tuple[RoutedEdge, ...]:
54
+ component_geometry = _build_component_geometry(nodes, validated.theme)
55
+ forward_outgoing: dict[str, list["Edge"]] = defaultdict(list)
56
+ forward_incoming: dict[str, list["Edge"]] = defaultdict(list)
57
+
58
+ for edge in validated.edges:
59
+ source_node = nodes[edge.source]
60
+ target_node = nodes[edge.target]
61
+ if edge.source != edge.target and source_node.rank < target_node.rank:
62
+ forward_outgoing[edge.source].append(edge)
63
+ forward_incoming[target_node.node.id].append(edge)
64
+
65
+ pair_offsets = _assign_pair_offsets(validated, nodes)
66
+ back_slots = _assign_back_slots(validated, nodes)
67
+ self_loop_slots = _assign_self_loop_slots(validated)
68
+ occupancies: dict[int, Occupancy] = defaultdict(Occupancy)
69
+ routed_by_id: dict[str, RoutedEdge] = {}
70
+
71
+ ordered_edges = sorted(
72
+ validated.edges,
73
+ key=lambda edge: _edge_route_order(edge, nodes, validated.edge_index[edge.id]),
74
+ )
75
+
76
+ for edge in ordered_edges:
77
+ source_node = nodes[edge.source]
78
+ target_node = nodes[edge.target]
79
+ geometry = component_geometry[source_node.component_id]
80
+
81
+ if edge.source == edge.target:
82
+ points = _select_self_loop_route(
83
+ source_node=source_node,
84
+ geometry=geometry,
85
+ occupancy=occupancies[source_node.component_id],
86
+ base_slot=self_loop_slots[edge.id],
87
+ pair_offset=pair_offsets.get(edge.id, 0.0),
88
+ theme=validated.theme,
89
+ )
90
+ _reserve_points(occupancies[source_node.component_id], points, edge.id)
91
+ elif source_node.rank < target_node.rank:
92
+ candidate = _select_forward_route(
93
+ edge=edge,
94
+ source_node=source_node,
95
+ target_node=target_node,
96
+ geometry=geometry,
97
+ nodes=nodes,
98
+ occupancy=occupancies[source_node.component_id],
99
+ outgoing_count=len(forward_outgoing[edge.source]),
100
+ incoming_count=len(forward_incoming[edge.target]),
101
+ pair_offset=pair_offsets.get(edge.id, 0.0),
102
+ theme=validated.theme,
103
+ )
104
+ points = candidate.points
105
+ _reserve_candidate(occupancies[source_node.component_id], candidate, edge.id)
106
+ else:
107
+ points = _select_back_edge_route(
108
+ source_node=source_node,
109
+ target_node=target_node,
110
+ geometry=geometry,
111
+ occupancy=occupancies[source_node.component_id],
112
+ base_slot=back_slots[edge.id],
113
+ pair_offset=pair_offsets.get(edge.id, 0.0),
114
+ theme=validated.theme,
115
+ )
116
+ _reserve_points(occupancies[source_node.component_id], points, edge.id)
117
+
118
+ routed_by_id[edge.id] = RoutedEdge(
119
+ edge=edge,
120
+ points=points,
121
+ bounds=_bounds_for_points(points, validated.theme.stroke_width, validated.theme.arrow_size),
122
+ stroke=edge.color or validated.theme.edge_color,
123
+ )
124
+
125
+ return tuple(routed_by_id[edge.id] for edge in validated.edges)
126
+
127
+
128
+ def compute_group_overlays(
129
+ validated: "ValidatedPipeline | ValidatedDetailPanel",
130
+ nodes: dict[str, LayoutNode],
131
+ routed_edges: tuple[RoutedEdge, ...],
132
+ ) -> tuple[GroupOverlay, ...]:
133
+ edge_lookup = {route.edge.id: route for route in routed_edges}
134
+ overlays: list[GroupOverlay] = []
135
+
136
+ for group in validated.groups:
137
+ group_bounds = [nodes[node_id].bounds for node_id in group.node_ids]
138
+ group_bounds.extend(edge_lookup[edge_id].bounds for edge_id in group.edge_ids)
139
+ bounds = union_bounds(group_bounds).expand(validated.theme.group_padding)
140
+ label_padding = validated.theme.subtitle_font_size + validated.theme.node_padding_y
141
+ bounds = Bounds(
142
+ x=bounds.x,
143
+ y=bounds.y - label_padding * 0.5,
144
+ width=bounds.width,
145
+ height=bounds.height + label_padding * 0.5,
146
+ )
147
+ overlays.append(
148
+ GroupOverlay(
149
+ group=group,
150
+ bounds=bounds,
151
+ stroke=group.stroke or validated.theme.group_stroke,
152
+ fill=group.fill or validated.theme.group_fill,
153
+ )
154
+ )
155
+
156
+ return tuple(overlays)
157
+
158
+
159
+ def _build_component_geometry(
160
+ nodes: dict[str, LayoutNode],
161
+ theme: "Theme",
162
+ ) -> dict[int, ComponentGeometry]:
163
+ by_component: dict[int, list[LayoutNode]] = defaultdict(list)
164
+ for node in nodes.values():
165
+ by_component[node.component_id].append(node)
166
+
167
+ geometry_by_component: dict[int, ComponentGeometry] = {}
168
+ for component_id, component_nodes in by_component.items():
169
+ rank_nodes: dict[int, list[LayoutNode]] = defaultdict(list)
170
+ row_nodes: dict[int, list[LayoutNode]] = defaultdict(list)
171
+
172
+ for node in component_nodes:
173
+ rank_nodes[node.rank].append(node)
174
+ row_nodes[node.order].append(node)
175
+
176
+ sorted_ranks = sorted(rank_nodes)
177
+ sorted_rows = sorted(row_nodes)
178
+
179
+ rank_left = {rank: min(node.x for node in rank_nodes[rank]) for rank in sorted_ranks}
180
+ rank_right = {rank: max(node.right for node in rank_nodes[rank]) for rank in sorted_ranks}
181
+ row_top = {row: min(node.y for node in row_nodes[row]) for row in sorted_rows}
182
+ row_bottom = {row: max(node.bounds.bottom for node in row_nodes[row]) for row in sorted_rows}
183
+
184
+ gap_after_rank: dict[int, tuple[float, float]] = {}
185
+ gap_before_rank: dict[int, tuple[float, float]] = {}
186
+ for left_rank, right_rank in zip(sorted_ranks, sorted_ranks[1:]):
187
+ gap = (rank_right[left_rank], rank_left[right_rank])
188
+ gap_after_rank[left_rank] = gap
189
+ gap_before_rank[right_rank] = gap
190
+
191
+ gap_after_row: dict[int, tuple[float, float]] = {}
192
+ gap_before_row: dict[int, tuple[float, float]] = {}
193
+ for upper_row, lower_row in zip(sorted_rows, sorted_rows[1:]):
194
+ gap = (row_bottom[upper_row], row_top[lower_row])
195
+ gap_after_row[upper_row] = gap
196
+ gap_before_row[lower_row] = gap
197
+
198
+ geometry_by_component[component_id] = ComponentGeometry(
199
+ rank_left=rank_left,
200
+ rank_right=rank_right,
201
+ row_top=row_top,
202
+ row_bottom=row_bottom,
203
+ gap_after_rank=gap_after_rank,
204
+ gap_before_rank=gap_before_rank,
205
+ gap_after_row=gap_after_row,
206
+ gap_before_row=gap_before_row,
207
+ outer_top=round(min(row_top.values()) - theme.back_edge_gap, 2),
208
+ outer_bottom=round(max(row_bottom.values()) + theme.back_edge_gap, 2),
209
+ outer_right=round(max(rank_right.values()) + theme.back_edge_gap * 2, 2),
210
+ )
211
+
212
+ return geometry_by_component
213
+
214
+
215
+ def _assign_pair_offsets(
216
+ validated: "ValidatedPipeline | ValidatedDetailPanel",
217
+ nodes: dict[str, LayoutNode],
218
+ ) -> dict[str, float]:
219
+ groups: dict[tuple[str, str], list["Edge"]] = defaultdict(list)
220
+ offsets: dict[str, float] = {}
221
+
222
+ for edge in validated.edges:
223
+ groups[(edge.source, edge.target)].append(edge)
224
+
225
+ for edges in groups.values():
226
+ ordered = sorted(edges, key=lambda edge: edge.id)
227
+ max_offset = min(
228
+ validated.theme.route_track_gap * 0.4,
229
+ min(nodes[ordered[0].source].height, nodes[ordered[0].target].height) / 4,
230
+ )
231
+ centered = _centered_offsets(len(ordered), max_offset if max_offset > 0 else 0.0)
232
+ for edge, offset in zip(ordered, centered, strict=True):
233
+ offsets[edge.id] = offset
234
+
235
+ return offsets
236
+
237
+
238
+ def _assign_back_slots(
239
+ validated: "ValidatedPipeline | ValidatedDetailPanel",
240
+ nodes: dict[str, LayoutNode],
241
+ ) -> dict[str, int]:
242
+ by_component: dict[int, list["Edge"]] = defaultdict(list)
243
+ for edge in validated.edges:
244
+ source_node = nodes[edge.source]
245
+ target_node = nodes[edge.target]
246
+ if edge.source != edge.target and source_node.rank >= target_node.rank:
247
+ by_component[source_node.component_id].append(edge)
248
+
249
+ slots: dict[str, int] = {}
250
+ for edges in by_component.values():
251
+ ordered = sorted(
252
+ edges,
253
+ key=lambda edge: (
254
+ nodes[edge.source].rank,
255
+ nodes[edge.target].rank,
256
+ nodes[edge.source].order,
257
+ nodes[edge.target].order,
258
+ edge.id,
259
+ ),
260
+ )
261
+ for slot, edge in enumerate(ordered):
262
+ slots[edge.id] = slot
263
+ return slots
264
+
265
+
266
+ def _assign_self_loop_slots(validated: "ValidatedPipeline | ValidatedDetailPanel") -> dict[str, int]:
267
+ by_node: dict[str, list["Edge"]] = defaultdict(list)
268
+ for edge in validated.edges:
269
+ if edge.source == edge.target:
270
+ by_node[edge.source].append(edge)
271
+
272
+ slots: dict[str, int] = {}
273
+ for edges in by_node.values():
274
+ for slot, edge in enumerate(sorted(edges, key=lambda edge: edge.id)):
275
+ slots[edge.id] = slot
276
+ return slots
277
+
278
+
279
+ def _edge_route_order(edge: "Edge", nodes: dict[str, LayoutNode], edge_index: int) -> tuple[int, int, int, int, int]:
280
+ source_node = nodes[edge.source]
281
+ target_node = nodes[edge.target]
282
+ if edge.source == edge.target:
283
+ kind = 2
284
+ elif source_node.rank < target_node.rank:
285
+ kind = 0
286
+ else:
287
+ kind = 1
288
+
289
+ return (
290
+ kind,
291
+ abs(target_node.rank - source_node.rank),
292
+ abs(target_node.order - source_node.order),
293
+ source_node.rank,
294
+ edge_index,
295
+ )
296
+
297
+
298
+ def _select_forward_route(
299
+ *,
300
+ edge: "Edge",
301
+ source_node: LayoutNode,
302
+ target_node: LayoutNode,
303
+ geometry: ComponentGeometry,
304
+ nodes: dict[str, LayoutNode],
305
+ occupancy: Occupancy,
306
+ outgoing_count: int,
307
+ incoming_count: int,
308
+ pair_offset: float,
309
+ theme: "Theme",
310
+ ) -> CandidatePath:
311
+ candidates = _build_forward_candidates(
312
+ source_node=source_node,
313
+ target_node=target_node,
314
+ geometry=geometry,
315
+ pair_offset=pair_offset,
316
+ theme=theme,
317
+ )
318
+
319
+ best: CandidatePath | None = None
320
+ best_score: float | None = None
321
+ fallback: CandidatePath | None = None
322
+ fallback_score: float | None = None
323
+
324
+ for kind, candidate in candidates:
325
+ score = _forward_score(
326
+ kind=kind,
327
+ candidate=candidate,
328
+ source_node=source_node,
329
+ target_node=target_node,
330
+ nodes=nodes,
331
+ outgoing_count=outgoing_count,
332
+ incoming_count=incoming_count,
333
+ theme=theme,
334
+ )
335
+
336
+ if fallback_score is None or score < fallback_score:
337
+ fallback = candidate
338
+ fallback_score = score
339
+
340
+ if _candidate_conflicts(candidate, occupancy):
341
+ continue
342
+
343
+ if best_score is None or score < best_score:
344
+ best = candidate
345
+ best_score = score
346
+
347
+ if best is not None:
348
+ return best
349
+ assert fallback is not None
350
+ return fallback
351
+
352
+
353
+ def _route_forward(
354
+ *,
355
+ edge: "Edge",
356
+ source_node: LayoutNode,
357
+ target_node: LayoutNode,
358
+ geometry: ComponentGeometry,
359
+ nodes: dict[str, LayoutNode],
360
+ outgoing_count: int,
361
+ incoming_count: int,
362
+ pair_offset: float,
363
+ theme: "Theme",
364
+ ) -> tuple[Point, ...]:
365
+ return _select_forward_route(
366
+ edge=edge,
367
+ source_node=source_node,
368
+ target_node=target_node,
369
+ geometry=geometry,
370
+ nodes=nodes,
371
+ occupancy=Occupancy(),
372
+ outgoing_count=outgoing_count,
373
+ incoming_count=incoming_count,
374
+ pair_offset=pair_offset,
375
+ theme=theme,
376
+ ).points
377
+
378
+
379
+ def _build_forward_candidates(
380
+ *,
381
+ source_node: LayoutNode,
382
+ target_node: LayoutNode,
383
+ geometry: ComponentGeometry,
384
+ pair_offset: float,
385
+ theme: "Theme",
386
+ ) -> list[tuple[str, CandidatePath]]:
387
+ candidates: list[tuple[str, CandidatePath]] = []
388
+
389
+ if source_node.order == target_node.order:
390
+ candidates.append(("direct", _direct_same_row_candidate(source_node, target_node, geometry, pair_offset, theme)))
391
+
392
+ if source_node.rank in geometry.gap_after_rank:
393
+ for lane_x in _ordered_gap_positions(geometry.gap_after_rank[source_node.rank], theme, "start"):
394
+ candidates.append(
395
+ (
396
+ "column_source",
397
+ _column_source_candidate(source_node, target_node, lane_x, pair_offset),
398
+ )
399
+ )
400
+
401
+ if target_node.rank in geometry.gap_before_rank:
402
+ for lane_x in _ordered_gap_positions(geometry.gap_before_rank[target_node.rank], theme, "end"):
403
+ candidates.append(
404
+ (
405
+ "column_target",
406
+ _column_target_candidate(source_node, target_node, lane_x, pair_offset),
407
+ )
408
+ )
409
+
410
+ for lane_y in _source_row_lane_positions(source_node, target_node, geometry, theme):
411
+ candidates.append(("row_source", _row_source_candidate(source_node, target_node, lane_y, pair_offset)))
412
+
413
+ for lane_y in _target_row_lane_positions(source_node, target_node, geometry, theme):
414
+ candidates.append(("row_target", _row_target_candidate(source_node, target_node, lane_y, pair_offset)))
415
+
416
+ for lane_y in _outer_row_lane_positions(geometry, theme):
417
+ candidates.append(("outer_row", _outer_row_candidate(source_node, target_node, lane_y, pair_offset)))
418
+
419
+ for lane_x in _outer_column_lane_positions(geometry, theme):
420
+ candidates.append(("outer_column", _outer_column_candidate(source_node, target_node, lane_x, pair_offset)))
421
+
422
+ return candidates
423
+
424
+
425
+ def _direct_same_row_candidate(
426
+ source_node: LayoutNode,
427
+ target_node: LayoutNode,
428
+ geometry: ComponentGeometry,
429
+ pair_offset: float,
430
+ theme: "Theme",
431
+ ) -> CandidatePath:
432
+ y = round(source_node.center_y + pair_offset, 2)
433
+ source = Point(source_node.right, y)
434
+ target = Point(target_node.x, y)
435
+ split_x = _stub_x_after_rank(source_node.rank, geometry, theme)
436
+ merge_x = _stub_x_before_rank(target_node.rank, geometry, theme)
437
+
438
+ if split_x >= merge_x - EPSILON:
439
+ return _build_candidate(
440
+ source,
441
+ [(target, "reserve")],
442
+ )
443
+
444
+ return _build_candidate(
445
+ source,
446
+ [
447
+ (Point(split_x, y), "stub"),
448
+ (Point(merge_x, y), "reserve"),
449
+ (target, "stub"),
450
+ ],
451
+ )
452
+
453
+
454
+ def _column_source_candidate(
455
+ source_node: LayoutNode,
456
+ target_node: LayoutNode,
457
+ lane_x: float,
458
+ pair_offset: float,
459
+ ) -> CandidatePath:
460
+ source = _right_port(source_node, pair_offset)
461
+ target = _left_port(target_node, pair_offset)
462
+ return _build_candidate(
463
+ source,
464
+ [
465
+ (Point(lane_x, source.y), "stub"),
466
+ (Point(lane_x, target.y), "reserve"),
467
+ (target, "reserve"),
468
+ ],
469
+ )
470
+
471
+
472
+ def _column_target_candidate(
473
+ source_node: LayoutNode,
474
+ target_node: LayoutNode,
475
+ lane_x: float,
476
+ pair_offset: float,
477
+ ) -> CandidatePath:
478
+ source = _right_port(source_node, pair_offset)
479
+ target = _left_port(target_node, pair_offset)
480
+ return _build_candidate(
481
+ source,
482
+ [
483
+ (Point(lane_x, source.y), "reserve"),
484
+ (Point(lane_x, target.y), "reserve"),
485
+ (target, "stub"),
486
+ ],
487
+ )
488
+
489
+
490
+ def _row_source_candidate(
491
+ source_node: LayoutNode,
492
+ target_node: LayoutNode,
493
+ lane_y: float,
494
+ pair_offset: float,
495
+ ) -> CandidatePath:
496
+ source = _vertical_port(source_node, pair_offset, lane_y)
497
+ target = _vertical_port(target_node, pair_offset, lane_y)
498
+ return _build_candidate(
499
+ source,
500
+ [
501
+ (Point(source.x, lane_y), "stub"),
502
+ (Point(target.x, lane_y), "reserve"),
503
+ (target, "reserve"),
504
+ ],
505
+ )
506
+
507
+
508
+ def _row_target_candidate(
509
+ source_node: LayoutNode,
510
+ target_node: LayoutNode,
511
+ lane_y: float,
512
+ pair_offset: float,
513
+ ) -> CandidatePath:
514
+ source = _vertical_port(source_node, pair_offset, lane_y)
515
+ target = _vertical_port(target_node, pair_offset, lane_y)
516
+ return _build_candidate(
517
+ source,
518
+ [
519
+ (Point(source.x, lane_y), "reserve"),
520
+ (Point(target.x, lane_y), "reserve"),
521
+ (target, "stub"),
522
+ ],
523
+ )
524
+
525
+
526
+ def _outer_row_candidate(
527
+ source_node: LayoutNode,
528
+ target_node: LayoutNode,
529
+ lane_y: float,
530
+ pair_offset: float,
531
+ ) -> CandidatePath:
532
+ source = _vertical_port(source_node, pair_offset, lane_y)
533
+ target = _vertical_port(target_node, pair_offset, lane_y)
534
+ return _build_candidate(
535
+ source,
536
+ [
537
+ (Point(source.x, lane_y), "reserve"),
538
+ (Point(target.x, lane_y), "reserve"),
539
+ (target, "reserve"),
540
+ ],
541
+ )
542
+
543
+
544
+ def _outer_column_candidate(
545
+ source_node: LayoutNode,
546
+ target_node: LayoutNode,
547
+ lane_x: float,
548
+ pair_offset: float,
549
+ ) -> CandidatePath:
550
+ source = _right_port(source_node, pair_offset)
551
+ target = _left_port(target_node, pair_offset)
552
+ return _build_candidate(
553
+ source,
554
+ [
555
+ (Point(lane_x, source.y), "reserve"),
556
+ (Point(lane_x, target.y), "reserve"),
557
+ (target, "reserve"),
558
+ ],
559
+ )
560
+
561
+
562
+ def _build_candidate(
563
+ start: Point,
564
+ segments: list[tuple[Point, str]],
565
+ ) -> CandidatePath:
566
+ points = [start]
567
+ kinds: list[str] = []
568
+
569
+ for point, kind in segments:
570
+ if point == points[-1]:
571
+ continue
572
+ points.append(point)
573
+ kinds.append(kind)
574
+
575
+ reserved = tuple(index for index, kind in enumerate(kinds) if kind == "reserve")
576
+ if not reserved and len(points) >= 2:
577
+ reserved = tuple(range(len(points) - 1))
578
+
579
+ return CandidatePath(points=tuple(points), reserved_segments=reserved)
580
+
581
+
582
+ def _source_row_lane_positions(
583
+ source_node: LayoutNode,
584
+ target_node: LayoutNode,
585
+ geometry: ComponentGeometry,
586
+ theme: "Theme",
587
+ ) -> tuple[float, ...]:
588
+ lanes: list[float] = []
589
+
590
+ if target_node.order >= source_node.order:
591
+ gap = geometry.gap_after_row.get(source_node.order)
592
+ if gap is not None:
593
+ lanes.extend(_ordered_gap_positions(gap, theme, "start"))
594
+ if target_node.order <= source_node.order:
595
+ gap = geometry.gap_before_row.get(source_node.order)
596
+ if gap is not None:
597
+ lanes.extend(_ordered_gap_positions(gap, theme, "end"))
598
+
599
+ return tuple(lanes)
600
+
601
+
602
+ def _target_row_lane_positions(
603
+ source_node: LayoutNode,
604
+ target_node: LayoutNode,
605
+ geometry: ComponentGeometry,
606
+ theme: "Theme",
607
+ ) -> tuple[float, ...]:
608
+ lanes: list[float] = []
609
+
610
+ if source_node.order <= target_node.order:
611
+ gap = geometry.gap_before_row.get(target_node.order)
612
+ if gap is not None:
613
+ lanes.extend(_ordered_gap_positions(gap, theme, "end"))
614
+ if source_node.order >= target_node.order:
615
+ gap = geometry.gap_after_row.get(target_node.order)
616
+ if gap is not None:
617
+ lanes.extend(_ordered_gap_positions(gap, theme, "start"))
618
+
619
+ return tuple(lanes)
620
+
621
+
622
+ def _outer_row_lane_positions(
623
+ geometry: ComponentGeometry,
624
+ theme: "Theme",
625
+ ) -> tuple[float, ...]:
626
+ return (
627
+ round(geometry.outer_top, 2),
628
+ round(geometry.outer_top - theme.route_track_gap, 2),
629
+ round(geometry.outer_bottom, 2),
630
+ round(geometry.outer_bottom + theme.route_track_gap, 2),
631
+ )
632
+
633
+
634
+ def _outer_column_lane_positions(
635
+ geometry: ComponentGeometry,
636
+ theme: "Theme",
637
+ ) -> tuple[float, ...]:
638
+ return (
639
+ round(geometry.outer_right, 2),
640
+ round(geometry.outer_right + theme.route_track_gap, 2),
641
+ )
642
+
643
+
644
+ def _ordered_gap_positions(
645
+ gap: tuple[float, float],
646
+ theme: "Theme",
647
+ preferred_edge: str,
648
+ ) -> tuple[float, ...]:
649
+ positions = _lane_positions(gap[0], gap[1], theme.route_track_gap)
650
+ boundary = gap[0] if preferred_edge == "start" else gap[1]
651
+ return tuple(
652
+ sorted(
653
+ positions,
654
+ key=lambda position: (
655
+ abs(position - boundary),
656
+ position if preferred_edge == "start" else -position,
657
+ ),
658
+ )
659
+ )
660
+
661
+
662
+ def _lane_positions(start: float, end: float, step: float) -> tuple[float, ...]:
663
+ width = end - start
664
+ if width <= step:
665
+ return (round((start + end) / 2, 2),)
666
+
667
+ count = max(1, int(width // step))
668
+ total = step * (count - 1)
669
+ origin = (start + end) / 2 - total / 2
670
+ return tuple(round(origin + step * index, 2) for index in range(count))
671
+
672
+
673
+ def _stub_x_after_rank(rank: int, geometry: ComponentGeometry, theme: "Theme") -> float:
674
+ if rank not in geometry.gap_after_rank:
675
+ return round(geometry.rank_right[rank], 2)
676
+ start, end = geometry.gap_after_rank[rank]
677
+ return round(min(end, start + min(theme.route_track_gap, (end - start) / 2)), 2)
678
+
679
+
680
+ def _stub_x_before_rank(rank: int, geometry: ComponentGeometry, theme: "Theme") -> float:
681
+ if rank not in geometry.gap_before_rank:
682
+ return round(geometry.rank_left[rank], 2)
683
+ start, end = geometry.gap_before_rank[rank]
684
+ return round(max(start, end - min(theme.route_track_gap, (end - start) / 2)), 2)
685
+
686
+
687
+ def _right_port(node: LayoutNode, pair_offset: float) -> Point:
688
+ return Point(node.right, round(node.center_y + pair_offset, 2))
689
+
690
+
691
+ def _left_port(node: LayoutNode, pair_offset: float) -> Point:
692
+ return Point(node.x, round(node.center_y + pair_offset, 2))
693
+
694
+
695
+ def _vertical_port(node: LayoutNode, pair_offset: float, lane_y: float) -> Point:
696
+ x = round(node.center_x + pair_offset, 2)
697
+ if lane_y >= node.center_y:
698
+ return Point(x, node.bounds.bottom)
699
+ return Point(x, node.y)
700
+
701
+
702
+ def _forward_score(
703
+ *,
704
+ kind: str,
705
+ candidate: CandidatePath,
706
+ source_node: LayoutNode,
707
+ target_node: LayoutNode,
708
+ nodes: dict[str, LayoutNode],
709
+ outgoing_count: int,
710
+ incoming_count: int,
711
+ theme: "Theme",
712
+ ) -> float:
713
+ points = candidate.points
714
+ length = _path_length(points)
715
+ bends = _bend_count(points)
716
+ backwards = _backwards_distance(points)
717
+ collisions = _count_node_collisions(points, nodes, {source_node.node.id, target_node.node.id}, theme)
718
+
719
+ score = collisions * 100000 + backwards * 1000 + bends * 120 + length
720
+
721
+ if kind.startswith("row_"):
722
+ score += 50
723
+ if kind == "outer_row":
724
+ score += 280
725
+ if kind == "outer_column":
726
+ score += 340
727
+ if kind == "direct":
728
+ score -= 30
729
+ if kind.endswith("source") and outgoing_count > 1:
730
+ score -= 90
731
+ if kind.endswith("target") and incoming_count > 1:
732
+ score -= 90
733
+ if kind.endswith("source") and incoming_count > 1:
734
+ score += 25
735
+ if kind.endswith("target") and outgoing_count > 1:
736
+ score += 25
737
+
738
+ return score
739
+
740
+
741
+ def _candidate_conflicts(candidate: CandidatePath, occupancy: Occupancy) -> bool:
742
+ reserved_segments = set(candidate.reserved_segments)
743
+ for segment_index in range(len(candidate.points) - 1):
744
+ start = candidate.points[segment_index]
745
+ end = candidate.points[segment_index + 1]
746
+ if _segment_conflicts(
747
+ start,
748
+ end,
749
+ occupancy,
750
+ check_overlap=segment_index in reserved_segments,
751
+ ):
752
+ return True
753
+ return False
754
+
755
+
756
+ def _segment_conflicts(
757
+ start: Point,
758
+ end: Point,
759
+ occupancy: Occupancy,
760
+ *,
761
+ check_overlap: bool,
762
+ ) -> bool:
763
+ if start.x == end.x:
764
+ x = round(start.x, 2)
765
+ top, bottom = sorted((start.y, end.y))
766
+ for segment in occupancy.vertical.get(x, ()):
767
+ if (
768
+ check_overlap
769
+ and segment.overlap_locked
770
+ and min(bottom, segment.end) - max(top, segment.start) > EPSILON
771
+ ):
772
+ return True
773
+ for y, horizontal_segments in occupancy.horizontal.items():
774
+ if top + EPSILON < y < bottom - EPSILON:
775
+ for segment in horizontal_segments:
776
+ if segment.start + EPSILON < x < segment.end - EPSILON:
777
+ return True
778
+ return False
779
+
780
+ if start.y == end.y:
781
+ y = round(start.y, 2)
782
+ left, right = sorted((start.x, end.x))
783
+ for segment in occupancy.horizontal.get(y, ()):
784
+ if (
785
+ check_overlap
786
+ and segment.overlap_locked
787
+ and min(right, segment.end) - max(left, segment.start) > EPSILON
788
+ ):
789
+ return True
790
+ for x, vertical_segments in occupancy.vertical.items():
791
+ if left + EPSILON < x < right - EPSILON:
792
+ for segment in vertical_segments:
793
+ if segment.start + EPSILON < y < segment.end - EPSILON:
794
+ return True
795
+ return False
796
+
797
+ return True
798
+
799
+
800
+ def _reserve_candidate(occupancy: Occupancy, candidate: CandidatePath, edge_id: str) -> None:
801
+ _reserve_points(
802
+ occupancy,
803
+ candidate.points,
804
+ edge_id,
805
+ overlap_locked_segments=set(candidate.reserved_segments),
806
+ )
807
+
808
+
809
+ def _reserve_points(
810
+ occupancy: Occupancy,
811
+ points: tuple[Point, ...],
812
+ edge_id: str,
813
+ *,
814
+ overlap_locked_segments: set[int] | None = None,
815
+ ) -> None:
816
+ overlap_locked_segments = overlap_locked_segments or set(range(len(points) - 1))
817
+
818
+ for segment_index, (start, end) in enumerate(zip(points, points[1:])):
819
+ overlap_locked = segment_index in overlap_locked_segments
820
+ if start.x == end.x:
821
+ top, bottom = sorted((start.y, end.y))
822
+ if bottom - top > EPSILON:
823
+ occupancy.vertical[round(start.x, 2)].append(
824
+ StoredSegment(top, bottom, edge_id, overlap_locked)
825
+ )
826
+ elif start.y == end.y:
827
+ left, right = sorted((start.x, end.x))
828
+ if right - left > EPSILON:
829
+ occupancy.horizontal[round(start.y, 2)].append(
830
+ StoredSegment(left, right, edge_id, overlap_locked)
831
+ )
832
+
833
+
834
+ def _points_conflict(points: tuple[Point, ...], occupancy: Occupancy) -> bool:
835
+ for start, end in zip(points, points[1:]):
836
+ if _segment_conflicts(start, end, occupancy, check_overlap=True):
837
+ return True
838
+ return False
839
+
840
+
841
+ def _select_back_edge_route(
842
+ *,
843
+ source_node: LayoutNode,
844
+ target_node: LayoutNode,
845
+ geometry: ComponentGeometry,
846
+ occupancy: Occupancy,
847
+ base_slot: int,
848
+ pair_offset: float,
849
+ theme: "Theme",
850
+ ) -> tuple[Point, ...]:
851
+ best_points: tuple[Point, ...] | None = None
852
+
853
+ for slot in range(base_slot, base_slot + 8):
854
+ points = _route_back_edge(
855
+ source_node=source_node,
856
+ target_node=target_node,
857
+ geometry=geometry,
858
+ slot=slot,
859
+ pair_offset=pair_offset,
860
+ theme=theme,
861
+ )
862
+ best_points = points
863
+ if not _points_conflict(points, occupancy):
864
+ return points
865
+
866
+ assert best_points is not None
867
+ return best_points
868
+
869
+
870
+ def _select_self_loop_route(
871
+ *,
872
+ source_node: LayoutNode,
873
+ geometry: ComponentGeometry,
874
+ occupancy: Occupancy,
875
+ base_slot: int,
876
+ pair_offset: float,
877
+ theme: "Theme",
878
+ ) -> tuple[Point, ...]:
879
+ best_points: tuple[Point, ...] | None = None
880
+
881
+ for slot in range(base_slot, base_slot + 8):
882
+ points = _route_self_loop(
883
+ source_node,
884
+ geometry,
885
+ slot,
886
+ pair_offset,
887
+ theme,
888
+ )
889
+ best_points = points
890
+ if not _points_conflict(points, occupancy):
891
+ return points
892
+
893
+ assert best_points is not None
894
+ return best_points
895
+
896
+
897
+ def _route_back_edge(
898
+ *,
899
+ source_node: LayoutNode,
900
+ target_node: LayoutNode,
901
+ geometry: ComponentGeometry,
902
+ slot: int,
903
+ pair_offset: float,
904
+ theme: "Theme",
905
+ ) -> tuple[Point, ...]:
906
+ source = _right_port(source_node, pair_offset)
907
+ target = _left_port(target_node, pair_offset)
908
+ lane_y = round(geometry.outer_top - theme.back_edge_gap * slot, 2)
909
+ exit_x = round(source_node.right + theme.route_track_gap * (slot + 1), 2)
910
+ entry_x = round(target_node.x - theme.route_track_gap * (slot + 1), 2)
911
+ return _collapse_points(
912
+ (
913
+ source,
914
+ Point(exit_x, source.y),
915
+ Point(exit_x, lane_y),
916
+ Point(entry_x, lane_y),
917
+ Point(entry_x, target.y),
918
+ target,
919
+ )
920
+ )
921
+
922
+
923
+ def _route_self_loop(
924
+ node: LayoutNode,
925
+ geometry: ComponentGeometry,
926
+ slot: int,
927
+ pair_offset: float,
928
+ theme: "Theme",
929
+ ) -> tuple[Point, ...]:
930
+ source = _right_port(node, pair_offset)
931
+ target = _left_port(node, pair_offset)
932
+ loop_x = round(node.right + theme.route_track_gap * (slot + 1), 2)
933
+ lane_y = round(geometry.outer_top - theme.route_track_gap * (slot + 1), 2)
934
+ return _collapse_points(
935
+ (
936
+ source,
937
+ Point(loop_x, source.y),
938
+ Point(loop_x, lane_y),
939
+ Point(node.x - theme.route_track_gap, lane_y),
940
+ Point(node.x - theme.route_track_gap, target.y),
941
+ target,
942
+ )
943
+ )
944
+
945
+
946
+ def _centered_offsets(count: int, gap: float) -> list[float]:
947
+ if count <= 1 or gap == 0:
948
+ return [0.0] * count
949
+ start = -gap * (count - 1) / 2
950
+ return [round(start + gap * index, 2) for index in range(count)]
951
+
952
+
953
+ def _collapse_points(points: tuple[Point, ...]) -> tuple[Point, ...]:
954
+ collapsed: list[Point] = []
955
+ for point in points:
956
+ if collapsed and point == collapsed[-1]:
957
+ continue
958
+ collapsed.append(point)
959
+ return tuple(collapsed)
960
+
961
+
962
+ def _bend_count(points: tuple[Point, ...]) -> int:
963
+ directions: list[str] = []
964
+ for start, end in zip(points, points[1:]):
965
+ if start.x == end.x and start.y != end.y:
966
+ directions.append("v")
967
+ elif start.y == end.y and start.x != end.x:
968
+ directions.append("h")
969
+
970
+ bends = 0
971
+ for previous, current in zip(directions, directions[1:]):
972
+ if previous != current:
973
+ bends += 1
974
+ return bends
975
+
976
+
977
+ def _path_length(points: tuple[Point, ...]) -> float:
978
+ return sum(abs(start.x - end.x) + abs(start.y - end.y) for start, end in zip(points, points[1:]))
979
+
980
+
981
+ def _backwards_distance(points: tuple[Point, ...]) -> float:
982
+ backwards = 0.0
983
+ for start, end in zip(points, points[1:]):
984
+ if end.x < start.x:
985
+ backwards += start.x - end.x
986
+ return backwards
987
+
988
+
989
+ def _count_node_collisions(
990
+ points: tuple[Point, ...],
991
+ nodes: dict[str, LayoutNode],
992
+ ignored_node_ids: set[str],
993
+ theme: "Theme",
994
+ ) -> int:
995
+ collisions = 0
996
+ padding = max(theme.stroke_width * 2, 2.0)
997
+ for start, end in zip(points, points[1:]):
998
+ for node_id, node in nodes.items():
999
+ if node_id in ignored_node_ids:
1000
+ continue
1001
+ if _segment_hits_bounds(start, end, node.bounds, padding):
1002
+ collisions += 1
1003
+ return collisions
1004
+
1005
+
1006
+ def _segment_hits_bounds(start: Point, end: Point, bounds: Bounds, padding: float) -> bool:
1007
+ left = bounds.x - padding
1008
+ right = bounds.right + padding
1009
+ top = bounds.y - padding
1010
+ bottom = bounds.bottom + padding
1011
+
1012
+ if start.x == end.x:
1013
+ x = start.x
1014
+ segment_top, segment_bottom = sorted((start.y, end.y))
1015
+ return left < x < right and segment_bottom > top and segment_top < bottom
1016
+
1017
+ if start.y == end.y:
1018
+ y = start.y
1019
+ segment_left, segment_right = sorted((start.x, end.x))
1020
+ return top < y < bottom and segment_right > left and segment_left < right
1021
+
1022
+ return False
1023
+
1024
+
1025
+ def _bounds_for_points(points: tuple[Point, ...], stroke_width: float, arrow_size: float) -> Bounds:
1026
+ padding = max(stroke_width, arrow_size)
1027
+ min_x = min(point.x for point in points) - padding
1028
+ min_y = min(point.y for point in points) - padding
1029
+ max_x = max(point.x for point in points) + padding
1030
+ max_y = max(point.y for point in points) + padding
1031
+ return Bounds(x=min_x, y=min_y, width=max_x - min_x, height=max_y - min_y)