frameplot 0.5.1__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 (27) hide show
  1. {frameplot-0.5.1/src/frameplot.egg-info → frameplot-0.5.3}/PKG-INFO +3 -3
  2. {frameplot-0.5.1 → frameplot-0.5.3}/README.md +2 -2
  3. {frameplot-0.5.1 → frameplot-0.5.3}/pyproject.toml +1 -1
  4. {frameplot-0.5.1 → frameplot-0.5.3}/src/frameplot/layout/__init__.py +174 -8
  5. {frameplot-0.5.1 → frameplot-0.5.3}/src/frameplot/layout/order.py +160 -13
  6. {frameplot-0.5.1 → frameplot-0.5.3}/src/frameplot/layout/place.py +13 -31
  7. {frameplot-0.5.1 → frameplot-0.5.3}/src/frameplot/layout/route.py +1411 -242
  8. {frameplot-0.5.1 → frameplot-0.5.3}/src/frameplot/theme.py +39 -29
  9. {frameplot-0.5.1 → frameplot-0.5.3/src/frameplot.egg-info}/PKG-INFO +3 -3
  10. {frameplot-0.5.1 → frameplot-0.5.3}/tests/test_rendering.py +676 -19
  11. {frameplot-0.5.1 → frameplot-0.5.3}/LICENSE +0 -0
  12. {frameplot-0.5.1 → frameplot-0.5.3}/setup.cfg +0 -0
  13. {frameplot-0.5.1 → frameplot-0.5.3}/src/frameplot/__init__.py +0 -0
  14. {frameplot-0.5.1 → frameplot-0.5.3}/src/frameplot/api.py +0 -0
  15. {frameplot-0.5.1 → frameplot-0.5.3}/src/frameplot/layout/rank.py +0 -0
  16. {frameplot-0.5.1 → frameplot-0.5.3}/src/frameplot/layout/scc.py +0 -0
  17. {frameplot-0.5.1 → frameplot-0.5.3}/src/frameplot/layout/text.py +0 -0
  18. {frameplot-0.5.1 → frameplot-0.5.3}/src/frameplot/layout/types.py +0 -0
  19. {frameplot-0.5.1 → frameplot-0.5.3}/src/frameplot/layout/validate.py +0 -0
  20. {frameplot-0.5.1 → frameplot-0.5.3}/src/frameplot/model.py +0 -0
  21. {frameplot-0.5.1 → frameplot-0.5.3}/src/frameplot/render/__init__.py +0 -0
  22. {frameplot-0.5.1 → frameplot-0.5.3}/src/frameplot/render/png.py +0 -0
  23. {frameplot-0.5.1 → frameplot-0.5.3}/src/frameplot/render/svg.py +0 -0
  24. {frameplot-0.5.1 → frameplot-0.5.3}/src/frameplot.egg-info/SOURCES.txt +0 -0
  25. {frameplot-0.5.1 → frameplot-0.5.3}/src/frameplot.egg-info/dependency_links.txt +0 -0
  26. {frameplot-0.5.1 → frameplot-0.5.3}/src/frameplot.egg-info/requires.txt +0 -0
  27. {frameplot-0.5.1 → 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.1
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
@@ -50,9 +50,9 @@ All built-in presets stay on a white canvas. The same hero pipeline is rendered
50
50
  | --- | --- |
51
51
  | ![Soft Retro theme hero](docs/assets/frameplot-hero-soft-retro.png) | ![Retro theme hero](docs/assets/frameplot-hero-retro.png) |
52
52
 
53
- | Pastel | Dark |
53
+ | Research | Dark |
54
54
  | --- | --- |
55
- | ![Pastel theme hero](docs/assets/frameplot-hero-pastel.png) | ![Dark theme hero](docs/assets/frameplot-hero-dark.png) |
55
+ | ![Research theme hero](docs/assets/frameplot-hero-research.png) | ![Dark theme hero](docs/assets/frameplot-hero-dark.png) |
56
56
 
57
57
  | Cyberpunk | Monochrome |
58
58
  | --- | --- |
@@ -21,9 +21,9 @@ All built-in presets stay on a white canvas. The same hero pipeline is rendered
21
21
  | --- | --- |
22
22
  | ![Soft Retro theme hero](docs/assets/frameplot-hero-soft-retro.png) | ![Retro theme hero](docs/assets/frameplot-hero-retro.png) |
23
23
 
24
- | Pastel | Dark |
24
+ | Research | Dark |
25
25
  | --- | --- |
26
- | ![Pastel theme hero](docs/assets/frameplot-hero-pastel.png) | ![Dark theme hero](docs/assets/frameplot-hero-dark.png) |
26
+ | ![Research theme hero](docs/assets/frameplot-hero-research.png) | ![Dark theme hero](docs/assets/frameplot-hero-dark.png) |
27
27
 
28
28
  | Cyberpunk | Monochrome |
29
29
  | --- | --- |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "frameplot"
7
- version = "0.5.1"
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
 
@@ -153,6 +173,56 @@ def _rank_gap_overrides(
153
173
  return overrides
154
174
 
155
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
+
156
226
  def _join_target_gap_requirements(
157
227
  validated: "ValidatedPipeline | ValidatedDetailPanel",
158
228
  placed_nodes: dict[str, LayoutNode],
@@ -282,6 +352,8 @@ def _build_group_spacing_infos(
282
352
  if not overlay.group.node_ids:
283
353
  continue
284
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)
285
357
  member_left = min(node.x for node in member_nodes)
286
358
  member_right = max(node.right for node in member_nodes)
287
359
  infos.append(
@@ -291,8 +363,14 @@ def _build_group_spacing_infos(
291
363
  group_node_ids=frozenset(overlay.group.node_ids),
292
364
  min_rank=min(node.rank for node in member_nodes),
293
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,
294
370
  member_left=member_left,
295
371
  member_right=member_right,
372
+ top_inside_clearance=member_top - overlay.bounds.y,
373
+ bottom_inside_clearance=overlay.bounds.bottom - member_bottom,
296
374
  left_inside_clearance=member_left - overlay.bounds.x,
297
375
  right_inside_clearance=overlay.bounds.right - member_right,
298
376
  )
@@ -301,6 +379,52 @@ def _build_group_spacing_infos(
301
379
  return tuple(infos)
302
380
 
303
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
+
304
428
  def _nearest_outside_node(
305
429
  placed_nodes: dict[str, LayoutNode],
306
430
  *,
@@ -332,6 +456,37 @@ def _nearest_outside_node(
332
456
  return candidate
333
457
 
334
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
+
335
490
  def _nearest_group_neighbor(
336
491
  info: _GroupSpacingInfo,
337
492
  group_infos: tuple[_GroupSpacingInfo, ...],
@@ -364,6 +519,10 @@ def _bounds_overlap_vertically(first: Bounds, second: Bounds) -> bool:
364
519
  return min(first.bottom, second.bottom) - max(first.y, second.y) > EPSILON
365
520
 
366
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
+
367
526
  def _previous_rank(geometry: "ComponentGeometry", rank: int) -> int | None:
368
527
  candidates = [candidate_rank for candidate_rank in geometry.rank_right if candidate_rank < rank]
369
528
  if not candidates:
@@ -371,6 +530,13 @@ def _previous_rank(geometry: "ComponentGeometry", rank: int) -> int | None:
371
530
  return max(candidates)
372
531
 
373
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
+
374
540
  def _normalize_graph_layout(
375
541
  placed_nodes: dict[str, LayoutNode],
376
542
  routed_edges: tuple[RoutedEdge, ...],
@@ -33,7 +33,10 @@ def order_nodes(
33
33
  _resort_rank(nodes_by_rank[rank], outgoing, order, validated)
34
34
  _refresh_order(nodes_by_rank[rank], order)
35
35
 
36
- if getattr(validated, "detail_panel", None) is not None:
36
+ if isinstance(validated, ValidatedDetailPanel):
37
+ _apply_detail_panel_shared_source_lanes(validated, ranks, order)
38
+
39
+ if isinstance(validated, ValidatedPipeline) and validated.detail_panel is not None:
37
40
  _apply_detail_panel_bias(validated, nodes_by_rank, ranks, order)
38
41
 
39
42
  return order
@@ -42,12 +45,18 @@ def order_nodes(
42
45
  def _forward_neighbors(
43
46
  validated: ValidatedPipeline | ValidatedDetailPanel,
44
47
  ranks: dict[str, int],
48
+ *,
49
+ included_node_ids: set[str] | None = None,
45
50
  ) -> tuple[dict[str, list[str]], dict[str, list[str]]]:
46
51
  incoming: dict[str, list[str]] = defaultdict(list)
47
52
  outgoing: dict[str, list[str]] = defaultdict(list)
48
53
 
49
54
  for edge in validated.edges:
50
55
  target_node_id = validated.edge_targets[edge.id].node_id
56
+ if included_node_ids is not None and (
57
+ edge.source not in included_node_ids or target_node_id not in included_node_ids
58
+ ):
59
+ continue
51
60
  if ranks[edge.source] < ranks[target_node_id]:
52
61
  incoming[target_node_id].append(edge.source)
53
62
  outgoing[edge.source].append(target_node_id)
@@ -112,6 +121,125 @@ def _apply_detail_panel_bias(
112
121
  order[node_id] = start_row + index
113
122
 
114
123
 
124
+ def _apply_detail_panel_shared_source_lanes(
125
+ validated: ValidatedDetailPanel,
126
+ ranks: dict[str, int],
127
+ order: dict[str, int],
128
+ ) -> None:
129
+ group_membership = _group_membership(validated)
130
+ components = _weak_components(validated)
131
+ incoming, outgoing = _forward_neighbors(validated, ranks)
132
+
133
+ for component_nodes in components:
134
+ component_set = set(component_nodes)
135
+ shared_sources = [
136
+ node_id
137
+ for node_id in component_nodes
138
+ if _is_detail_panel_shared_source(
139
+ node_id,
140
+ component_set,
141
+ group_membership,
142
+ incoming,
143
+ outgoing,
144
+ order,
145
+ )
146
+ ]
147
+ if not shared_sources:
148
+ continue
149
+
150
+ shared_sources.sort(key=lambda node_id: (order[node_id], validated.node_index[node_id], node_id))
151
+ shared_source_ids = set(shared_sources)
152
+ nonshared_rows = _reorder_component_nodes(
153
+ validated,
154
+ ranks,
155
+ component_set - shared_source_ids,
156
+ )
157
+
158
+ for index, node_id in enumerate(shared_sources):
159
+ order[node_id] = index
160
+ for node_id, row in nonshared_rows.items():
161
+ order[node_id] = row + len(shared_sources)
162
+
163
+
164
+ def _is_detail_panel_shared_source(
165
+ node_id: str,
166
+ component_nodes: set[str],
167
+ group_membership: dict[str, tuple[str, ...]],
168
+ incoming: dict[str, list[str]],
169
+ outgoing: dict[str, list[str]],
170
+ order: dict[str, int],
171
+ ) -> bool:
172
+ if group_membership.get(node_id):
173
+ return False
174
+ if incoming.get(node_id):
175
+ return False
176
+
177
+ targets = [target_id for target_id in outgoing.get(node_id, ()) if target_id in component_nodes]
178
+ if len(set(targets)) < 2:
179
+ return False
180
+
181
+ downstream_lanes = {
182
+ (
183
+ order[target_id],
184
+ group_membership.get(target_id, ()),
185
+ )
186
+ for target_id in targets
187
+ }
188
+ return len(downstream_lanes) >= 2
189
+
190
+
191
+ def _group_membership(
192
+ validated: ValidatedPipeline | ValidatedDetailPanel,
193
+ ) -> dict[str, tuple[str, ...]]:
194
+ memberships: dict[str, list[str]] = defaultdict(list)
195
+ for group in validated.groups:
196
+ for node_id in group.node_ids:
197
+ memberships[node_id].append(group.id)
198
+ return {
199
+ node_id: tuple(sorted(group_ids))
200
+ for node_id, group_ids in memberships.items()
201
+ }
202
+
203
+
204
+ def _reorder_component_nodes(
205
+ validated: ValidatedPipeline | ValidatedDetailPanel,
206
+ ranks: dict[str, int],
207
+ included_node_ids: set[str],
208
+ ) -> dict[str, int]:
209
+ if not included_node_ids:
210
+ return {}
211
+
212
+ nodes_by_rank: dict[int, list[str]] = defaultdict(list)
213
+ for node_id in included_node_ids:
214
+ nodes_by_rank[ranks[node_id]].append(node_id)
215
+
216
+ for rank_nodes in nodes_by_rank.values():
217
+ rank_nodes.sort(key=validated.node_index.__getitem__)
218
+
219
+ local_order = {
220
+ node_id: index
221
+ for rank_nodes in nodes_by_rank.values()
222
+ for index, node_id in enumerate(rank_nodes)
223
+ }
224
+
225
+ incoming, outgoing = _forward_neighbors(
226
+ validated,
227
+ ranks,
228
+ included_node_ids=included_node_ids,
229
+ )
230
+ ordered_ranks = sorted(nodes_by_rank)
231
+
232
+ for _ in range(4):
233
+ for rank in ordered_ranks[1:]:
234
+ _resort_rank(nodes_by_rank[rank], incoming, local_order, validated)
235
+ _refresh_order(nodes_by_rank[rank], local_order)
236
+ for rank in reversed(ordered_ranks[:-1]):
237
+ _resort_rank(nodes_by_rank[rank], outgoing, local_order, validated)
238
+ _refresh_order(nodes_by_rank[rank], local_order)
239
+
240
+ return local_order
241
+
242
+
115
243
  def _detail_focus_path_nodes(
116
244
  validated: ValidatedPipeline,
117
245
  ranks: dict[str, int],
@@ -151,22 +279,41 @@ def _reachable(start: str, adjacency: dict[str, list[str]]) -> set[str]:
151
279
  return seen
152
280
 
153
281
 
154
- def _weak_component_nodes(validated: ValidatedPipeline, start_node_id: str) -> set[str]:
282
+ def _weak_components(validated: ValidatedPipeline | ValidatedDetailPanel) -> list[tuple[str, ...]]:
155
283
  adjacency: dict[str, set[str]] = {node.id: set() for node in validated.nodes}
156
284
  for edge in validated.edges:
157
285
  target_node_id = validated.edge_targets[edge.id].node_id
158
286
  adjacency[edge.source].add(target_node_id)
159
287
  adjacency[target_node_id].add(edge.source)
160
288
 
161
- seen = {start_node_id}
162
- queue = deque([start_node_id])
163
-
164
- while queue:
165
- node_id = queue.popleft()
166
- for neighbor in sorted(adjacency[node_id], key=validated.node_index.__getitem__):
167
- if neighbor in seen:
168
- continue
169
- seen.add(neighbor)
170
- queue.append(neighbor)
289
+ components: list[tuple[str, ...]] = []
290
+ seen: set[str] = set()
171
291
 
172
- return seen
292
+ for node in validated.nodes:
293
+ if node.id in seen:
294
+ continue
295
+ queue = deque([node.id])
296
+ seen.add(node.id)
297
+ members: list[str] = []
298
+ while queue:
299
+ node_id = queue.popleft()
300
+ members.append(node_id)
301
+ for neighbor in sorted(adjacency[node_id], key=validated.node_index.__getitem__):
302
+ if neighbor in seen:
303
+ continue
304
+ seen.add(neighbor)
305
+ queue.append(neighbor)
306
+ members.sort(key=validated.node_index.__getitem__)
307
+ components.append(tuple(members))
308
+
309
+ return components
310
+
311
+
312
+ def _weak_component_nodes(
313
+ validated: ValidatedPipeline | ValidatedDetailPanel,
314
+ start_node_id: str,
315
+ ) -> set[str]:
316
+ for component in _weak_components(validated):
317
+ if start_node_id in component:
318
+ return set(component)
319
+ return {start_node_id}
@@ -13,6 +13,8 @@ def place_nodes(
13
13
  order: dict[str, int],
14
14
  *,
15
15
  rank_gap_overrides: dict[tuple[int, int], float] | None = None,
16
+ row_gap_overrides: dict[tuple[int, int], float] | None = None,
17
+ row_gap_floor: float | None = None,
16
18
  ) -> dict[str, LayoutNode]:
17
19
  components = _weak_components(validated)
18
20
  placed: dict[str, LayoutNode] = {}
@@ -41,6 +43,9 @@ def place_nodes(
41
43
  validated,
42
44
  component_nodes,
43
45
  local_rows,
46
+ component_id=component_id,
47
+ overrides=row_gap_overrides,
48
+ row_gap_floor=row_gap_floor,
44
49
  )
45
50
  column_widths = {
46
51
  rank: max(measurements[node_id].width for node_id in nodes)
@@ -132,41 +137,18 @@ def _row_gap_after(
132
137
  validated: "ValidatedPipeline | ValidatedDetailPanel",
133
138
  component_nodes: tuple[str, ...],
134
139
  rows: dict[str, int],
140
+ *,
141
+ component_id: int,
142
+ overrides: dict[tuple[int, int], float] | None,
143
+ row_gap_floor: float | None,
135
144
  ) -> dict[int, float]:
136
145
  row_ids = sorted({rows[node_id] for node_id in component_nodes})
137
- crossing_counts = {row: 0 for row in row_ids[:-1]}
138
- component_set = set(component_nodes)
139
-
140
- node_padding = {}
141
- if hasattr(validated, "groups"):
142
- for group in validated.groups:
143
- for node_id in group.node_ids:
144
- node_padding[node_id] = validated.theme.group_padding
145
-
146
- row_padding: dict[int, float] = {}
147
- for row in row_ids:
148
- nodes_in_row = [n for n in component_nodes if rows[n] == row]
149
- row_padding[row] = max((node_padding.get(n, 0.0) for n in nodes_in_row), default=0.0)
150
-
151
- for edge in validated.edges:
152
- target_node_id = validated.edge_targets[edge.id].node_id
153
- if edge.source not in component_set or target_node_id not in component_set:
154
- continue
155
- source_row = rows[edge.source]
156
- target_row = rows[target_node_id]
157
- if source_row == target_row:
158
- continue
159
- for row in range(min(source_row, target_row), max(source_row, target_row)):
160
- crossing_counts[row] = crossing_counts.get(row, 0) + 1
161
-
146
+ base_gap = _base_row_gap(validated.theme) if row_gap_floor is None else row_gap_floor
162
147
  gap_after: dict[int, float] = {}
163
- for i, row in enumerate(row_ids[:-1]):
164
- lanes = crossing_counts.get(row, 0)
165
- padding_add = row_padding[row] + row_padding[row_ids[i+1]]
148
+ for row in row_ids[:-1]:
149
+ override_key = (component_id, row)
166
150
  gap_after[row] = round(
167
- _base_row_gap(validated.theme)
168
- + max(0, lanes - 1) * validated.theme.route_track_gap
169
- + padding_add,
151
+ max(base_gap, overrides.get(override_key, base_gap)) if overrides is not None else base_gap,
170
152
  2,
171
153
  )
172
154
  return gap_after