frameplot 0.5.0__tar.gz → 0.5.3__tar.gz

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.
Files changed (30) hide show
  1. {frameplot-0.5.0/src/frameplot.egg-info → frameplot-0.5.3}/PKG-INFO +37 -9
  2. {frameplot-0.5.0 → frameplot-0.5.3}/README.md +36 -8
  3. {frameplot-0.5.0 → frameplot-0.5.3}/pyproject.toml +1 -1
  4. {frameplot-0.5.0 → frameplot-0.5.3}/src/frameplot/layout/__init__.py +255 -8
  5. frameplot-0.5.3/src/frameplot/layout/order.py +319 -0
  6. {frameplot-0.5.0 → frameplot-0.5.3}/src/frameplot/layout/place.py +16 -32
  7. {frameplot-0.5.0 → frameplot-0.5.3}/src/frameplot/layout/rank.py +1 -1
  8. frameplot-0.5.3/src/frameplot/layout/route.py +3358 -0
  9. {frameplot-0.5.0 → frameplot-0.5.3}/src/frameplot/layout/scc.py +1 -1
  10. {frameplot-0.5.0 → frameplot-0.5.3}/src/frameplot/layout/types.py +18 -0
  11. {frameplot-0.5.0 → frameplot-0.5.3}/src/frameplot/layout/validate.py +42 -5
  12. {frameplot-0.5.0 → frameplot-0.5.3}/src/frameplot/model.py +10 -3
  13. {frameplot-0.5.0 → frameplot-0.5.3}/src/frameplot/render/svg.py +92 -2
  14. {frameplot-0.5.0 → frameplot-0.5.3}/src/frameplot/theme.py +40 -30
  15. {frameplot-0.5.0 → frameplot-0.5.3/src/frameplot.egg-info}/PKG-INFO +37 -9
  16. frameplot-0.5.3/tests/test_rendering.py +1888 -0
  17. frameplot-0.5.0/src/frameplot/layout/order.py +0 -169
  18. frameplot-0.5.0/src/frameplot/layout/route.py +0 -1550
  19. frameplot-0.5.0/tests/test_rendering.py +0 -979
  20. {frameplot-0.5.0 → frameplot-0.5.3}/LICENSE +0 -0
  21. {frameplot-0.5.0 → frameplot-0.5.3}/setup.cfg +0 -0
  22. {frameplot-0.5.0 → frameplot-0.5.3}/src/frameplot/__init__.py +0 -0
  23. {frameplot-0.5.0 → frameplot-0.5.3}/src/frameplot/api.py +0 -0
  24. {frameplot-0.5.0 → frameplot-0.5.3}/src/frameplot/layout/text.py +0 -0
  25. {frameplot-0.5.0 → frameplot-0.5.3}/src/frameplot/render/__init__.py +0 -0
  26. {frameplot-0.5.0 → frameplot-0.5.3}/src/frameplot/render/png.py +0 -0
  27. {frameplot-0.5.0 → frameplot-0.5.3}/src/frameplot.egg-info/SOURCES.txt +0 -0
  28. {frameplot-0.5.0 → frameplot-0.5.3}/src/frameplot.egg-info/dependency_links.txt +0 -0
  29. {frameplot-0.5.0 → frameplot-0.5.3}/src/frameplot.egg-info/requires.txt +0 -0
  30. {frameplot-0.5.0 → frameplot-0.5.3}/src/frameplot.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: frameplot
3
- Version: 0.5.0
3
+ Version: 0.5.3
4
4
  Summary: Turn Python-defined pipeline graphs into presentation-ready SVG and PNG diagrams.
5
5
  Author: Small Turtle 2
6
6
  License-Expression: MIT
@@ -46,13 +46,17 @@ Turn Python-defined pipeline graphs into presentation-ready SVG and PNG diagrams
46
46
 
47
47
  All built-in presets stay on a white canvas. The same hero pipeline is rendered below once per theme so you can compare them directly.
48
48
 
49
- | Soft Retro | Retro | Pastel |
50
- | --- | --- | --- |
51
- | ![Soft Retro theme hero](docs/assets/frameplot-hero-soft-retro.png) | ![Retro theme hero](docs/assets/frameplot-hero-retro.png) | ![Pastel theme hero](docs/assets/frameplot-hero-pastel.png) |
49
+ | Soft Retro | Retro |
50
+ | --- | --- |
51
+ | ![Soft Retro theme hero](docs/assets/frameplot-hero-soft-retro.png) | ![Retro theme hero](docs/assets/frameplot-hero-retro.png) |
52
52
 
53
- | Dark | Cyberpunk | Monochrome |
54
- | --- | --- | --- |
55
- | ![Dark theme hero](docs/assets/frameplot-hero-dark.png) | ![Cyberpunk theme hero](docs/assets/frameplot-hero-cyberpunk.png) | ![Monochrome theme hero](docs/assets/frameplot-hero-monochrome.png) |
53
+ | Research | Dark |
54
+ | --- | --- |
55
+ | ![Research theme hero](docs/assets/frameplot-hero-research.png) | ![Dark theme hero](docs/assets/frameplot-hero-dark.png) |
56
+
57
+ | Cyberpunk | Monochrome |
58
+ | --- | --- |
59
+ | ![Cyberpunk theme hero](docs/assets/frameplot-hero-cyberpunk.png) | ![Monochrome theme hero](docs/assets/frameplot-hero-monochrome.png) |
56
60
 
57
61
  ## Why frameplot?
58
62
 
@@ -101,17 +105,41 @@ pipeline.save_png("pipeline.png")
101
105
 
102
106
  ![Quickstart result](docs/assets/quickstart.png)
103
107
 
108
+ ## Edge-to-Edge Joins
109
+
110
+ ```python
111
+ from frameplot import Edge, Node, Pipeline
112
+
113
+ pipeline = Pipeline(
114
+ nodes=[
115
+ Node("source", "Source", "Primary request"),
116
+ Node("worker", "Worker", "Prepare response"),
117
+ Node("audit", "Audit", "Write side log", fill="#DBEAFE"),
118
+ Node("done", "Done", "Return result", fill="#D9EAD3"),
119
+ ],
120
+ edges=[
121
+ Edge("e1", "source", "worker"),
122
+ Edge("e2", "worker", "done"),
123
+ Edge("e3", "audit", "e2", merge_symbol="+", color="#2563EB"),
124
+ ],
125
+ )
126
+ ```
127
+
128
+ ![Edge join result](docs/assets/edge-join.png)
129
+
104
130
  ## Public API
105
131
 
106
132
  Top-level imports are the supported public API:
107
133
 
108
134
  - `Node(id, title, subtitle=None, fill=None, stroke=None, text_color=None, metadata=None, width=None, height=None)`
109
- - `Edge(id, source, target, color=None, dashed=False, metadata=None)`
135
+ - `Edge(id, source, target, color=None, dashed=False, merge_symbol=None, metadata=None)`
110
136
  - `Group(id, label, node_ids, edge_ids=(), stroke=None, fill=None, metadata=None)`
111
137
  - `DetailPanel(id, focus_node_id, label, nodes, edges, groups=(), stroke=None, fill=None, metadata=None)`
112
138
  - `Theme(...)`
113
139
  - `Pipeline(nodes, edges, groups=(), detail_panel=None, theme=None)`
114
140
 
141
+ `Edge.target` may reference either a node id or another edge id. When targeting another edge, `merge_symbol="+"` or `"x"` renders a join badge at the merge point.
142
+
115
143
  `Pipeline` exposes:
116
144
 
117
145
  - `to_svg() -> str`
@@ -130,7 +158,7 @@ The hero image at the top and the theme gallery above are generated from [`examp
130
158
  ## Design Notes
131
159
 
132
160
  - Layout is intentionally left-to-right in v0.x.
133
- - Edge labels are not supported yet.
161
+ - Edge labels are not supported yet, but edge-to-edge joins can render optional `+` / `x` badges.
134
162
  - Groups stay visual overlays, and routes leaving or re-entering grouped nodes bend outside grouped areas.
135
163
  - Detail panels render as separate lower insets attached to a focus node in the main flow.
136
164
 
@@ -17,13 +17,17 @@ Turn Python-defined pipeline graphs into presentation-ready SVG and PNG diagrams
17
17
 
18
18
  All built-in presets stay on a white canvas. The same hero pipeline is rendered below once per theme so you can compare them directly.
19
19
 
20
- | Soft Retro | Retro | Pastel |
21
- | --- | --- | --- |
22
- | ![Soft Retro theme hero](docs/assets/frameplot-hero-soft-retro.png) | ![Retro theme hero](docs/assets/frameplot-hero-retro.png) | ![Pastel theme hero](docs/assets/frameplot-hero-pastel.png) |
20
+ | Soft Retro | Retro |
21
+ | --- | --- |
22
+ | ![Soft Retro theme hero](docs/assets/frameplot-hero-soft-retro.png) | ![Retro theme hero](docs/assets/frameplot-hero-retro.png) |
23
23
 
24
- | Dark | Cyberpunk | Monochrome |
25
- | --- | --- | --- |
26
- | ![Dark theme hero](docs/assets/frameplot-hero-dark.png) | ![Cyberpunk theme hero](docs/assets/frameplot-hero-cyberpunk.png) | ![Monochrome theme hero](docs/assets/frameplot-hero-monochrome.png) |
24
+ | Research | Dark |
25
+ | --- | --- |
26
+ | ![Research theme hero](docs/assets/frameplot-hero-research.png) | ![Dark theme hero](docs/assets/frameplot-hero-dark.png) |
27
+
28
+ | Cyberpunk | Monochrome |
29
+ | --- | --- |
30
+ | ![Cyberpunk theme hero](docs/assets/frameplot-hero-cyberpunk.png) | ![Monochrome theme hero](docs/assets/frameplot-hero-monochrome.png) |
27
31
 
28
32
  ## Why frameplot?
29
33
 
@@ -72,17 +76,41 @@ pipeline.save_png("pipeline.png")
72
76
 
73
77
  ![Quickstart result](docs/assets/quickstart.png)
74
78
 
79
+ ## Edge-to-Edge Joins
80
+
81
+ ```python
82
+ from frameplot import Edge, Node, Pipeline
83
+
84
+ pipeline = Pipeline(
85
+ nodes=[
86
+ Node("source", "Source", "Primary request"),
87
+ Node("worker", "Worker", "Prepare response"),
88
+ Node("audit", "Audit", "Write side log", fill="#DBEAFE"),
89
+ Node("done", "Done", "Return result", fill="#D9EAD3"),
90
+ ],
91
+ edges=[
92
+ Edge("e1", "source", "worker"),
93
+ Edge("e2", "worker", "done"),
94
+ Edge("e3", "audit", "e2", merge_symbol="+", color="#2563EB"),
95
+ ],
96
+ )
97
+ ```
98
+
99
+ ![Edge join result](docs/assets/edge-join.png)
100
+
75
101
  ## Public API
76
102
 
77
103
  Top-level imports are the supported public API:
78
104
 
79
105
  - `Node(id, title, subtitle=None, fill=None, stroke=None, text_color=None, metadata=None, width=None, height=None)`
80
- - `Edge(id, source, target, color=None, dashed=False, metadata=None)`
106
+ - `Edge(id, source, target, color=None, dashed=False, merge_symbol=None, metadata=None)`
81
107
  - `Group(id, label, node_ids, edge_ids=(), stroke=None, fill=None, metadata=None)`
82
108
  - `DetailPanel(id, focus_node_id, label, nodes, edges, groups=(), stroke=None, fill=None, metadata=None)`
83
109
  - `Theme(...)`
84
110
  - `Pipeline(nodes, edges, groups=(), detail_panel=None, theme=None)`
85
111
 
112
+ `Edge.target` may reference either a node id or another edge id. When targeting another edge, `merge_symbol="+"` or `"x"` renders a join badge at the merge point.
113
+
86
114
  `Pipeline` exposes:
87
115
 
88
116
  - `to_svg() -> str`
@@ -101,7 +129,7 @@ The hero image at the top and the theme gallery above are generated from [`examp
101
129
  ## Design Notes
102
130
 
103
131
  - Layout is intentionally left-to-right in v0.x.
104
- - Edge labels are not supported yet.
132
+ - Edge labels are not supported yet, but edge-to-edge joins can render optional `+` / `x` badges.
105
133
  - Groups stay visual overlays, and routes leaving or re-entering grouped nodes bend outside grouped areas.
106
134
  - Detail panels render as separate lower insets attached to a focus node in the main flow.
107
135
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "frameplot"
7
- version = "0.5.0"
7
+ version = "0.5.3"
8
8
  description = "Turn Python-defined pipeline graphs into presentation-ready SVG and PNG diagrams."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -38,8 +38,14 @@ class _GroupSpacingInfo:
38
38
  group_node_ids: frozenset[str]
39
39
  min_rank: int
40
40
  max_rank: int
41
+ min_row: int
42
+ max_row: int
43
+ member_top: float
44
+ member_bottom: float
41
45
  member_left: float
42
46
  member_right: float
47
+ top_inside_clearance: float
48
+ bottom_inside_clearance: float
43
49
  left_inside_clearance: float
44
50
  right_inside_clearance: float
45
51
 
@@ -77,6 +83,8 @@ def _layout_graph(validated: "ValidatedPipeline | ValidatedDetailPanel") -> Grap
77
83
  ranks = assign_ranks(validated, scc_result)
78
84
  order = order_nodes(validated, ranks)
79
85
  rank_gap_overrides: dict[tuple[int, int], float] | None = None
86
+ row_gap_overrides: dict[tuple[int, int], float] | None = None
87
+ row_gap_floor = resolve_theme_metrics(validated.theme).compact_rank_gap
80
88
 
81
89
  for _ in range(MAX_LAYOUT_STABILIZATION_PASSES):
82
90
  placed_nodes = place_nodes(
@@ -85,13 +93,20 @@ def _layout_graph(validated: "ValidatedPipeline | ValidatedDetailPanel") -> Grap
85
93
  ranks,
86
94
  order,
87
95
  rank_gap_overrides=rank_gap_overrides,
96
+ row_gap_overrides=row_gap_overrides,
97
+ row_gap_floor=row_gap_floor,
88
98
  )
89
99
  routed_edges = route_edges(validated, placed_nodes)
90
100
  overlays = compute_group_overlays(validated, placed_nodes, routed_edges)
91
- next_overrides = _rank_gap_overrides(validated, placed_nodes, routed_edges, overlays)
92
- if next_overrides == (rank_gap_overrides or {}):
101
+ next_rank_overrides = _rank_gap_overrides(validated, placed_nodes, routed_edges, overlays)
102
+ next_row_overrides = _row_gap_overrides(validated, placed_nodes, routed_edges, overlays)
103
+ if (
104
+ next_rank_overrides == (rank_gap_overrides or {})
105
+ and next_row_overrides == (row_gap_overrides or {})
106
+ ):
93
107
  break
94
- rank_gap_overrides = next_overrides or None
108
+ rank_gap_overrides = next_rank_overrides or None
109
+ row_gap_overrides = next_row_overrides or None
95
110
 
96
111
  return _normalize_graph_layout(placed_nodes, routed_edges, overlays, validated.theme)
97
112
 
@@ -107,7 +122,7 @@ def _rank_gap_overrides(
107
122
  base_gap = metrics.compact_rank_gap
108
123
  geometry_by_component = _build_component_geometry(placed_nodes, theme)
109
124
  required_gaps: dict[tuple[int, int], float] = {}
110
- used_lane_positions: dict[tuple[int, int], set[float]] = {}
125
+ used_lane_positions: dict[tuple[int, int], set[tuple[str, float]]] = {}
111
126
 
112
127
  for component_id, geometry in geometry_by_component.items():
113
128
  for left_rank in geometry.gap_after_rank:
@@ -122,13 +137,18 @@ def _rank_gap_overrides(
122
137
  x = round(start.x, 2)
123
138
  for left_rank, (gap_start, gap_end) in geometry.gap_after_rank.items():
124
139
  if gap_start + 0.01 < x < gap_end - 0.01:
125
- used_lane_positions[(component_id, left_rank)].add(x)
140
+ used_lane_positions[(component_id, left_rank)].add((routed_edge.edge.id, x))
126
141
 
127
142
  overrides: dict[tuple[int, int], float] = {}
128
- for key, lane_positions in used_lane_positions.items():
129
- if len(lane_positions) <= 1:
143
+ for key, lane_entries in used_lane_positions.items():
144
+ if len(lane_entries) <= 1:
130
145
  continue
131
- required_gap = base_gap + max(lane_positions) - min(lane_positions)
146
+ lane_positions = sorted(position for _, position in lane_entries)
147
+ required_span = max(
148
+ lane_positions[-1] - lane_positions[0],
149
+ theme.route_track_gap * len(lane_positions),
150
+ )
151
+ required_gap = base_gap + required_span
132
152
  if required_gap > base_gap + EPSILON:
133
153
  required_gaps[key] = round(required_gap, 2)
134
154
 
@@ -139,6 +159,13 @@ def _rank_gap_overrides(
139
159
  ).items():
140
160
  required_gaps[key] = round(max(required_gaps.get(key, 0.0), required_gap), 2)
141
161
 
162
+ for key, required_gap in _join_target_gap_requirements(
163
+ validated,
164
+ placed_nodes,
165
+ routed_edges,
166
+ ).items():
167
+ required_gaps[key] = round(max(required_gaps.get(key, 0.0), required_gap), 2)
168
+
142
169
  for key, required_gap in required_gaps.items():
143
170
  if required_gap > base_gap + EPSILON:
144
171
  overrides[key] = round(required_gap, 2)
@@ -146,6 +173,114 @@ def _rank_gap_overrides(
146
173
  return overrides
147
174
 
148
175
 
176
+ def _row_gap_overrides(
177
+ validated: "ValidatedPipeline | ValidatedDetailPanel",
178
+ placed_nodes: dict[str, LayoutNode],
179
+ routed_edges: tuple[RoutedEdge, ...],
180
+ overlays: tuple[GroupOverlay, ...],
181
+ ) -> dict[tuple[int, int], float]:
182
+ theme = validated.theme
183
+ base_gap = resolve_theme_metrics(theme).compact_rank_gap
184
+ geometry_by_component = _build_component_geometry(placed_nodes, theme)
185
+ used_lane_positions: dict[tuple[int, int], set[tuple[str, float]]] = {}
186
+ required_gaps: dict[tuple[int, int], float] = {}
187
+
188
+ for component_id, geometry in geometry_by_component.items():
189
+ for upper_row in geometry.gap_after_row:
190
+ used_lane_positions[(component_id, upper_row)] = set()
191
+
192
+ for routed_edge in routed_edges:
193
+ component_id = placed_nodes[routed_edge.edge.source].component_id
194
+ geometry = geometry_by_component[component_id]
195
+ for start, end in zip(routed_edge.points, routed_edge.points[1:]):
196
+ if start.y != end.y:
197
+ continue
198
+ y = round(start.y, 2)
199
+ for upper_row, (gap_start, gap_end) in geometry.gap_after_row.items():
200
+ if gap_start + EPSILON < y < gap_end - EPSILON:
201
+ used_lane_positions[(component_id, upper_row)].add((routed_edge.edge.id, y))
202
+
203
+ for key, lane_entries in used_lane_positions.items():
204
+ if not lane_entries:
205
+ continue
206
+ lane_positions = sorted(position for _, position in lane_entries)
207
+ occupied_span = lane_positions[-1] - lane_positions[0]
208
+ required_gap = max(base_gap, occupied_span + theme.route_track_gap)
209
+ if required_gap > base_gap + EPSILON:
210
+ required_gaps[key] = round(required_gap, 2)
211
+
212
+ for key, required_gap in _group_row_boundary_gap_requirements(
213
+ placed_nodes,
214
+ overlays,
215
+ geometry_by_component,
216
+ ).items():
217
+ required_gaps[key] = round(max(required_gaps.get(key, 0.0), required_gap), 2)
218
+
219
+ return {
220
+ key: round(required_gap, 2)
221
+ for key, required_gap in required_gaps.items()
222
+ if required_gap > base_gap + EPSILON
223
+ }
224
+
225
+
226
+ def _join_target_gap_requirements(
227
+ validated: "ValidatedPipeline | ValidatedDetailPanel",
228
+ placed_nodes: dict[str, LayoutNode],
229
+ routed_edges: tuple[RoutedEdge, ...],
230
+ ) -> dict[tuple[int, int], float]:
231
+ route_by_id = {route.edge.id: route for route in routed_edges}
232
+ joins_by_target_segment: dict[tuple[str, int], list[RoutedEdge]] = {}
233
+
234
+ for routed_edge in routed_edges:
235
+ if routed_edge.target_kind != "edge" or routed_edge.target_edge_id is None:
236
+ continue
237
+ if routed_edge.join_segment_index is None:
238
+ continue
239
+ joins_by_target_segment.setdefault(
240
+ (routed_edge.target_edge_id, routed_edge.join_segment_index),
241
+ [],
242
+ ).append(routed_edge)
243
+
244
+ required_gaps: dict[tuple[int, int], float] = {}
245
+ badge_diameter = max(validated.theme.arrow_size * 1.8, validated.theme.stroke_width * 6.0)
246
+ join_spacing = max(validated.theme.arrow_size * 1.5, validated.theme.stroke_width * 6.0)
247
+
248
+ for (target_edge_id, join_segment_index), joins in joins_by_target_segment.items():
249
+ target_edge = validated.edge_lookup[target_edge_id]
250
+ target_target = validated.edge_targets[target_edge_id]
251
+ source_node = placed_nodes[target_edge.source]
252
+ target_node = placed_nodes[target_target.node_id]
253
+ target_route = route_by_id.get(target_edge_id)
254
+ if target_route is None:
255
+ continue
256
+ if source_node.component_id != target_node.component_id:
257
+ continue
258
+ if source_node.rank >= target_node.rank or source_node.order != target_node.order:
259
+ continue
260
+ if join_segment_index >= len(target_route.points) - 1:
261
+ continue
262
+
263
+ start = target_route.points[join_segment_index]
264
+ end = target_route.points[join_segment_index + 1]
265
+ if start.y != end.y:
266
+ continue
267
+
268
+ current_segment_length = abs(end.x - start.x)
269
+ required_segment_length = (
270
+ validated.theme.route_track_gap
271
+ + badge_diameter * len(joins)
272
+ + join_spacing * max(0, len(joins) - 1)
273
+ )
274
+ if current_segment_length >= required_segment_length - EPSILON:
275
+ continue
276
+
277
+ required_gap = validated.theme.route_track_gap + (required_segment_length - current_segment_length)
278
+ key = (source_node.component_id, source_node.rank)
279
+ required_gaps[key] = max(required_gaps.get(key, 0.0), round(required_gap, 2))
280
+
281
+ return required_gaps
282
+
283
+
149
284
  def _group_boundary_gap_requirements(
150
285
  placed_nodes: dict[str, LayoutNode],
151
286
  overlays: tuple[GroupOverlay, ...],
@@ -217,6 +352,8 @@ def _build_group_spacing_infos(
217
352
  if not overlay.group.node_ids:
218
353
  continue
219
354
  member_nodes = [placed_nodes[node_id] for node_id in overlay.group.node_ids]
355
+ member_top = min(node.y for node in member_nodes)
356
+ member_bottom = max(node.bounds.bottom for node in member_nodes)
220
357
  member_left = min(node.x for node in member_nodes)
221
358
  member_right = max(node.right for node in member_nodes)
222
359
  infos.append(
@@ -226,8 +363,14 @@ def _build_group_spacing_infos(
226
363
  group_node_ids=frozenset(overlay.group.node_ids),
227
364
  min_rank=min(node.rank for node in member_nodes),
228
365
  max_rank=max(node.rank for node in member_nodes),
366
+ min_row=min(node.order for node in member_nodes),
367
+ max_row=max(node.order for node in member_nodes),
368
+ member_top=member_top,
369
+ member_bottom=member_bottom,
229
370
  member_left=member_left,
230
371
  member_right=member_right,
372
+ top_inside_clearance=member_top - overlay.bounds.y,
373
+ bottom_inside_clearance=overlay.bounds.bottom - member_bottom,
231
374
  left_inside_clearance=member_left - overlay.bounds.x,
232
375
  right_inside_clearance=overlay.bounds.right - member_right,
233
376
  )
@@ -236,6 +379,52 @@ def _build_group_spacing_infos(
236
379
  return tuple(infos)
237
380
 
238
381
 
382
+ def _group_row_boundary_gap_requirements(
383
+ placed_nodes: dict[str, LayoutNode],
384
+ overlays: tuple[GroupOverlay, ...],
385
+ geometry_by_component: dict[int, "ComponentGeometry"],
386
+ ) -> dict[tuple[int, int], float]:
387
+ required_gaps: dict[tuple[int, int], float] = {}
388
+ group_infos = _build_group_spacing_infos(placed_nodes, overlays)
389
+
390
+ for info in group_infos:
391
+ geometry = geometry_by_component.get(info.component_id)
392
+ if geometry is None:
393
+ continue
394
+
395
+ upper_row = _previous_row(geometry, info.min_row)
396
+ top_outside_node = _nearest_outside_node_by_row(
397
+ placed_nodes,
398
+ bounds=info.bounds,
399
+ component_id=info.component_id,
400
+ group_node_ids=info.group_node_ids,
401
+ row_predicate=lambda row: row < info.min_row,
402
+ side="top",
403
+ )
404
+ if upper_row is not None and top_outside_node is not None:
405
+ required_gaps[(info.component_id, upper_row)] = round(
406
+ max(required_gaps.get((info.component_id, upper_row), 0.0), info.top_inside_clearance * 2.0),
407
+ 2,
408
+ )
409
+
410
+ lower_row = info.max_row
411
+ bottom_outside_node = _nearest_outside_node_by_row(
412
+ placed_nodes,
413
+ bounds=info.bounds,
414
+ component_id=info.component_id,
415
+ group_node_ids=info.group_node_ids,
416
+ row_predicate=lambda row: row > info.max_row,
417
+ side="bottom",
418
+ )
419
+ if lower_row in geometry.gap_after_row and bottom_outside_node is not None:
420
+ required_gaps[(info.component_id, lower_row)] = round(
421
+ max(required_gaps.get((info.component_id, lower_row), 0.0), info.bottom_inside_clearance * 2.0),
422
+ 2,
423
+ )
424
+
425
+ return required_gaps
426
+
427
+
239
428
  def _nearest_outside_node(
240
429
  placed_nodes: dict[str, LayoutNode],
241
430
  *,
@@ -267,6 +456,37 @@ def _nearest_outside_node(
267
456
  return candidate
268
457
 
269
458
 
459
+ def _nearest_outside_node_by_row(
460
+ placed_nodes: dict[str, LayoutNode],
461
+ *,
462
+ bounds: Bounds,
463
+ component_id: int,
464
+ group_node_ids: frozenset[str],
465
+ row_predicate: Callable[[int], bool],
466
+ side: str,
467
+ ) -> LayoutNode | None:
468
+ candidate: LayoutNode | None = None
469
+
470
+ for node_id, node in placed_nodes.items():
471
+ if node.component_id != component_id:
472
+ continue
473
+ if node_id in group_node_ids:
474
+ continue
475
+ if not row_predicate(node.order):
476
+ continue
477
+ if not _bounds_overlap_horizontally(bounds, node.bounds):
478
+ continue
479
+ if candidate is None:
480
+ candidate = node
481
+ continue
482
+ if side == "top" and node.bounds.bottom > candidate.bounds.bottom:
483
+ candidate = node
484
+ if side == "bottom" and node.y < candidate.y:
485
+ candidate = node
486
+
487
+ return candidate
488
+
489
+
270
490
  def _nearest_group_neighbor(
271
491
  info: _GroupSpacingInfo,
272
492
  group_infos: tuple[_GroupSpacingInfo, ...],
@@ -299,6 +519,10 @@ def _bounds_overlap_vertically(first: Bounds, second: Bounds) -> bool:
299
519
  return min(first.bottom, second.bottom) - max(first.y, second.y) > EPSILON
300
520
 
301
521
 
522
+ def _bounds_overlap_horizontally(first: Bounds, second: Bounds) -> bool:
523
+ return min(first.right, second.right) - max(first.x, second.x) > EPSILON
524
+
525
+
302
526
  def _previous_rank(geometry: "ComponentGeometry", rank: int) -> int | None:
303
527
  candidates = [candidate_rank for candidate_rank in geometry.rank_right if candidate_rank < rank]
304
528
  if not candidates:
@@ -306,6 +530,13 @@ def _previous_rank(geometry: "ComponentGeometry", rank: int) -> int | None:
306
530
  return max(candidates)
307
531
 
308
532
 
533
+ def _previous_row(geometry: "ComponentGeometry", row: int) -> int | None:
534
+ candidates = [candidate_row for candidate_row in geometry.row_bottom if candidate_row < row]
535
+ if not candidates:
536
+ return None
537
+ return max(candidates)
538
+
539
+
309
540
  def _normalize_graph_layout(
310
541
  placed_nodes: dict[str, LayoutNode],
311
542
  routed_edges: tuple[RoutedEdge, ...],
@@ -506,6 +737,22 @@ def _shift_edge(route: RoutedEdge, shift_x: float, shift_y: float) -> RoutedEdge
506
737
  height=route.bounds.height,
507
738
  ),
508
739
  stroke=route.stroke,
740
+ target_kind=route.target_kind,
741
+ target_node_id=route.target_node_id,
742
+ target_edge_id=route.target_edge_id,
743
+ join_point=(
744
+ Point(route.join_point.x + shift_x, route.join_point.y + shift_y)
745
+ if route.join_point is not None
746
+ else None
747
+ ),
748
+ badge_center=(
749
+ Point(route.badge_center.x + shift_x, route.badge_center.y + shift_y)
750
+ if route.badge_center is not None
751
+ else None
752
+ ),
753
+ join_segment_index=route.join_segment_index,
754
+ show_arrowhead=route.show_arrowhead,
755
+ join_badge_radius=route.join_badge_radius,
509
756
  )
510
757
 
511
758