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.
frameplot/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Public API for building pipeline diagrams with frameplot."""
2
+
3
+ from frameplot.api import Pipeline
4
+ from frameplot.model import DetailPanel, Edge, Group, Node
5
+ from frameplot.theme import Theme
6
+
7
+ __all__ = ["DetailPanel", "Edge", "Group", "Node", "Pipeline", "Theme"]
frameplot/api.py ADDED
@@ -0,0 +1,61 @@
1
+ """High-level rendering API for frameplot diagrams."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+ from frameplot.layout import build_layout
9
+ from frameplot.model import DetailPanel, Edge, Group, Node
10
+ from frameplot.render import render_svg, save_png, svg_to_png_bytes
11
+ from frameplot.theme import Theme
12
+
13
+ __all__ = ["Pipeline"]
14
+
15
+
16
+ @dataclass(slots=True)
17
+ class Pipeline:
18
+ """Describe and render a pipeline diagram.
19
+
20
+ The constructor accepts any iterable of nodes, edges, and groups, then
21
+ normalizes them to tuples for deterministic rendering. Passing `theme=None`
22
+ uses the default :class:`Theme`.
23
+ """
24
+
25
+ nodes: tuple[Node, ...]
26
+ edges: tuple[Edge, ...]
27
+ groups: tuple[Group, ...] = ()
28
+ detail_panel: DetailPanel | None = None
29
+ theme: Theme | None = field(default_factory=Theme)
30
+
31
+ def __post_init__(self) -> None:
32
+ self.nodes = tuple(self.nodes)
33
+ self.edges = tuple(self.edges)
34
+ self.groups = tuple(self.groups)
35
+ if self.theme is None:
36
+ self.theme = Theme()
37
+
38
+ def to_svg(self) -> str:
39
+ """Render the pipeline as an SVG document string."""
40
+
41
+ layout = build_layout(self)
42
+ return render_svg(layout, self.theme)
43
+
44
+ def save_svg(self, path: str | Path) -> None:
45
+ """Write the rendered SVG document to `path` using UTF-8 encoding."""
46
+
47
+ Path(path).write_text(self.to_svg(), encoding="utf-8")
48
+
49
+ def to_png_bytes(self) -> bytes:
50
+ """Render the pipeline to PNG bytes with CairoSVG.
51
+
52
+ Raises:
53
+ RuntimeError: If CairoSVG is not installed in the active environment.
54
+ """
55
+
56
+ return svg_to_png_bytes(self.to_svg())
57
+
58
+ def save_png(self, path: str | Path) -> None:
59
+ """Render the pipeline to PNG and write it to `path`."""
60
+
61
+ save_png(self.to_svg(), path)
@@ -0,0 +1,268 @@
1
+ """Internal layout pipeline used by the public frameplot API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from frameplot.layout.order import order_nodes
6
+ from frameplot.layout.place import place_nodes
7
+ from frameplot.layout.rank import assign_ranks
8
+ from frameplot.layout.route import compute_group_overlays, route_edges
9
+ from frameplot.layout.scc import strongly_connected_components
10
+ from frameplot.layout.text import measure_text
11
+ from frameplot.layout.types import (
12
+ Bounds,
13
+ DetailPanelLayout,
14
+ GraphLayout,
15
+ GroupOverlay,
16
+ GuideLine,
17
+ LayoutNode,
18
+ LayoutResult,
19
+ Point,
20
+ RoutedEdge,
21
+ )
22
+ from frameplot.layout.validate import validate_pipeline
23
+
24
+ __all__ = ["build_layout"]
25
+
26
+
27
+ def build_layout(pipeline: "Pipeline") -> LayoutResult:
28
+ """Compute positions, routes, and overlays for a pipeline."""
29
+
30
+ validated = validate_pipeline(pipeline)
31
+ theme = validated.theme
32
+ main_graph = _layout_graph(validated)
33
+
34
+ detail_panel = None
35
+ width = main_graph.width
36
+ height = main_graph.height
37
+
38
+ if validated.detail_panel is not None:
39
+ detail_panel = _build_detail_panel_layout(validated.detail_panel, main_graph, theme)
40
+ width = max(width, detail_panel.bounds.right + theme.outer_margin)
41
+ height = max(height, detail_panel.bounds.bottom + theme.outer_margin)
42
+ for guide_line in detail_panel.guide_lines:
43
+ width = max(width, guide_line.bounds.right + theme.outer_margin)
44
+ height = max(height, guide_line.bounds.bottom + theme.outer_margin)
45
+
46
+ return LayoutResult(
47
+ main=main_graph,
48
+ detail_panel=detail_panel,
49
+ width=round(width, 2),
50
+ height=round(height, 2),
51
+ )
52
+
53
+
54
+ def _layout_graph(validated: "ValidatedPipeline | ValidatedDetailPanel") -> GraphLayout:
55
+ measurements = measure_text(validated)
56
+ scc_result = strongly_connected_components(validated)
57
+ ranks = assign_ranks(validated, scc_result)
58
+ order = order_nodes(validated, ranks)
59
+ placed_nodes = place_nodes(validated, measurements, ranks, order)
60
+ routed_edges = route_edges(validated, placed_nodes)
61
+ overlays = compute_group_overlays(validated, placed_nodes, routed_edges)
62
+ return _normalize_graph_layout(placed_nodes, routed_edges, overlays, validated.theme)
63
+
64
+
65
+ def _normalize_graph_layout(
66
+ placed_nodes: dict[str, LayoutNode],
67
+ routed_edges: tuple[RoutedEdge, ...],
68
+ overlays: tuple[GroupOverlay, ...],
69
+ theme: "Theme",
70
+ ) -> GraphLayout:
71
+ content_bounds = _collect_graph_bounds(placed_nodes, routed_edges, overlays)
72
+
73
+ shift_x = max(0.0, theme.outer_margin - content_bounds.x)
74
+ shift_y = max(0.0, theme.outer_margin - content_bounds.y)
75
+ if shift_x or shift_y:
76
+ placed_nodes = {node_id: _shift_node(node, shift_x, shift_y) for node_id, node in placed_nodes.items()}
77
+ routed_edges = tuple(_shift_edge(route, shift_x, shift_y) for route in routed_edges)
78
+ overlays = tuple(_shift_overlay(overlay, shift_x, shift_y) for overlay in overlays)
79
+ content_bounds = _collect_graph_bounds(placed_nodes, routed_edges, overlays)
80
+
81
+ return GraphLayout(
82
+ nodes=placed_nodes,
83
+ edges=routed_edges,
84
+ groups=overlays,
85
+ content_bounds=content_bounds,
86
+ width=round(content_bounds.right + theme.outer_margin, 2),
87
+ height=round(content_bounds.bottom + theme.outer_margin, 2),
88
+ )
89
+
90
+
91
+ def _build_detail_panel_layout(
92
+ validated_panel: "ValidatedDetailPanel",
93
+ main_graph: GraphLayout,
94
+ theme: "Theme",
95
+ ) -> DetailPanelLayout:
96
+ panel_graph = _layout_graph(validated_panel)
97
+ focus_node = main_graph.nodes[validated_panel.panel.focus_node_id]
98
+
99
+ content_width = panel_graph.content_bounds.width
100
+ content_height = panel_graph.content_bounds.height
101
+ label_width = len(validated_panel.panel.label) * theme.subtitle_font_size * 0.62
102
+
103
+ panel_width = max(content_width + theme.detail_panel_padding * 2, label_width + theme.detail_panel_padding * 2)
104
+ panel_height = (
105
+ content_height + theme.detail_panel_header_height + theme.detail_panel_padding * 2
106
+ )
107
+
108
+ desired_x = focus_node.center_x - panel_width / 2
109
+ max_x = max(theme.outer_margin, main_graph.width - theme.outer_margin - panel_width)
110
+ panel_x = round(max(theme.outer_margin, min(desired_x, max_x)), 2)
111
+ panel_y = round(main_graph.content_bounds.bottom + theme.detail_panel_gap, 2)
112
+
113
+ content_x = panel_x + theme.detail_panel_padding
114
+ content_y = panel_y + theme.detail_panel_header_height + theme.detail_panel_padding
115
+ shifted_graph = _shift_graph_layout(
116
+ panel_graph,
117
+ shift_x=content_x - panel_graph.content_bounds.x,
118
+ shift_y=content_y - panel_graph.content_bounds.y,
119
+ )
120
+
121
+ bounds = Bounds(
122
+ x=panel_x,
123
+ y=panel_y,
124
+ width=round(panel_width, 2),
125
+ height=round(panel_height, 2),
126
+ )
127
+
128
+ return DetailPanelLayout(
129
+ panel=validated_panel.panel,
130
+ graph=shifted_graph,
131
+ bounds=bounds,
132
+ stroke=validated_panel.panel.stroke or theme.detail_panel_stroke,
133
+ fill=validated_panel.panel.fill or theme.detail_panel_fill,
134
+ guide_lines=_build_detail_guides(focus_node, bounds, theme),
135
+ )
136
+
137
+
138
+ def _build_detail_guides(
139
+ focus_node: LayoutNode,
140
+ panel_bounds: Bounds,
141
+ theme: "Theme",
142
+ ) -> tuple[GuideLine, ...]:
143
+ start_left = Point(round(focus_node.x + focus_node.width * 0.2, 2), round(focus_node.bounds.bottom, 2))
144
+ start_right = Point(
145
+ round(focus_node.x + focus_node.width * 0.8, 2),
146
+ round(focus_node.bounds.bottom, 2),
147
+ )
148
+ shoulder_inset = min(40.0, panel_bounds.width * 0.18)
149
+ end_left = Point(
150
+ round(panel_bounds.x + shoulder_inset, 2),
151
+ round(panel_bounds.y, 2),
152
+ )
153
+ end_right = Point(
154
+ round(panel_bounds.right - shoulder_inset, 2),
155
+ round(panel_bounds.y, 2),
156
+ )
157
+ flare_x = max(theme.route_track_gap * 1.5, focus_node.width * 0.18)
158
+ bend_y = round(
159
+ start_left.y + max(18.0, min(theme.detail_panel_gap * 0.45, (panel_bounds.y - start_left.y) * 0.45)),
160
+ 2,
161
+ )
162
+ mid_left = Point(
163
+ round(min(start_left.x - flare_x, (start_left.x + end_left.x) / 2), 2),
164
+ bend_y,
165
+ )
166
+ mid_right = Point(
167
+ round(max(start_right.x + flare_x, (start_right.x + end_right.x) / 2), 2),
168
+ bend_y,
169
+ )
170
+ guides = (
171
+ GuideLine(
172
+ points=(start_left, mid_left, end_left),
173
+ bounds=_line_bounds((start_left, mid_left, end_left), theme.detail_panel_guide_width),
174
+ stroke=theme.detail_panel_guide_color,
175
+ ),
176
+ GuideLine(
177
+ points=(start_right, mid_right, end_right),
178
+ bounds=_line_bounds((start_right, mid_right, end_right), theme.detail_panel_guide_width),
179
+ stroke=theme.detail_panel_guide_color,
180
+ ),
181
+ )
182
+ return guides
183
+
184
+
185
+ def _collect_graph_bounds(
186
+ nodes: dict[str, LayoutNode],
187
+ edges: tuple[RoutedEdge, ...],
188
+ overlays: tuple[GroupOverlay, ...],
189
+ ) -> Bounds:
190
+ bounds = [node.bounds for node in nodes.values()]
191
+ bounds.extend(route.bounds for route in edges)
192
+ bounds.extend(overlay.bounds for overlay in overlays)
193
+ return Bounds(
194
+ x=min(bound.x for bound in bounds),
195
+ y=min(bound.y for bound in bounds),
196
+ width=max(bound.right for bound in bounds) - min(bound.x for bound in bounds),
197
+ height=max(bound.bottom for bound in bounds) - min(bound.y for bound in bounds),
198
+ )
199
+
200
+
201
+ def _line_bounds(points: tuple[Point, ...], stroke_width: float) -> Bounds:
202
+ padding = max(1.0, stroke_width)
203
+ min_x = min(point.x for point in points) - padding
204
+ min_y = min(point.y for point in points) - padding
205
+ max_x = max(point.x for point in points) + padding
206
+ max_y = max(point.y for point in points) + padding
207
+ return Bounds(x=min_x, y=min_y, width=max_x - min_x, height=max_y - min_y)
208
+
209
+
210
+ def _shift_graph_layout(graph: GraphLayout, shift_x: float, shift_y: float) -> GraphLayout:
211
+ shifted_nodes = {node_id: _shift_node(node, shift_x, shift_y) for node_id, node in graph.nodes.items()}
212
+ shifted_edges = tuple(_shift_edge(route, shift_x, shift_y) for route in graph.edges)
213
+ shifted_groups = tuple(_shift_overlay(overlay, shift_x, shift_y) for overlay in graph.groups)
214
+ shifted_bounds = _collect_graph_bounds(shifted_nodes, shifted_edges, shifted_groups)
215
+ return GraphLayout(
216
+ nodes=shifted_nodes,
217
+ edges=shifted_edges,
218
+ groups=shifted_groups,
219
+ content_bounds=shifted_bounds,
220
+ width=round(max(graph.width + shift_x, shifted_bounds.right), 2),
221
+ height=round(max(graph.height + shift_y, shifted_bounds.bottom), 2),
222
+ )
223
+
224
+
225
+ def _shift_node(node: LayoutNode, shift_x: float, shift_y: float) -> LayoutNode:
226
+ return LayoutNode(
227
+ node=node.node,
228
+ rank=node.rank,
229
+ order=node.order,
230
+ component_id=node.component_id,
231
+ width=node.width,
232
+ height=node.height,
233
+ x=round(node.x + shift_x, 2),
234
+ y=round(node.y + shift_y, 2),
235
+ title_lines=node.title_lines,
236
+ subtitle_lines=node.subtitle_lines,
237
+ title_line_height=node.title_line_height,
238
+ subtitle_line_height=node.subtitle_line_height,
239
+ content_height=node.content_height,
240
+ )
241
+
242
+
243
+ def _shift_edge(route: RoutedEdge, shift_x: float, shift_y: float) -> RoutedEdge:
244
+ return RoutedEdge(
245
+ edge=route.edge,
246
+ points=tuple(Point(point.x + shift_x, point.y + shift_y) for point in route.points),
247
+ bounds=Bounds(
248
+ x=route.bounds.x + shift_x,
249
+ y=route.bounds.y + shift_y,
250
+ width=route.bounds.width,
251
+ height=route.bounds.height,
252
+ ),
253
+ stroke=route.stroke,
254
+ )
255
+
256
+
257
+ def _shift_overlay(overlay: GroupOverlay, shift_x: float, shift_y: float) -> GroupOverlay:
258
+ return GroupOverlay(
259
+ group=overlay.group,
260
+ bounds=Bounds(
261
+ x=overlay.bounds.x + shift_x,
262
+ y=overlay.bounds.y + shift_y,
263
+ width=overlay.bounds.width,
264
+ height=overlay.bounds.height,
265
+ ),
266
+ stroke=overlay.stroke,
267
+ fill=overlay.fill,
268
+ )
@@ -0,0 +1,169 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import defaultdict, deque
4
+
5
+ from frameplot.layout.types import ValidatedDetailPanel, ValidatedPipeline
6
+
7
+
8
+ def order_nodes(
9
+ validated: ValidatedPipeline | ValidatedDetailPanel,
10
+ ranks: dict[str, int],
11
+ ) -> dict[str, int]:
12
+ nodes_by_rank: dict[int, list[str]] = defaultdict(list)
13
+ for node in validated.nodes:
14
+ nodes_by_rank[ranks[node.id]].append(node.id)
15
+
16
+ for rank_nodes in nodes_by_rank.values():
17
+ rank_nodes.sort(key=validated.node_index.__getitem__)
18
+
19
+ order = {
20
+ node_id: index
21
+ for rank_nodes in nodes_by_rank.values()
22
+ for index, node_id in enumerate(rank_nodes)
23
+ }
24
+
25
+ incoming, outgoing = _forward_neighbors(validated, ranks)
26
+ ordered_ranks = sorted(nodes_by_rank)
27
+
28
+ for _ in range(4):
29
+ for rank in ordered_ranks[1:]:
30
+ _resort_rank(nodes_by_rank[rank], incoming, order, validated)
31
+ _refresh_order(nodes_by_rank[rank], order)
32
+ for rank in reversed(ordered_ranks[:-1]):
33
+ _resort_rank(nodes_by_rank[rank], outgoing, order, validated)
34
+ _refresh_order(nodes_by_rank[rank], order)
35
+
36
+ if getattr(validated, "detail_panel", None) is not None:
37
+ _apply_detail_panel_bias(validated, nodes_by_rank, ranks, order)
38
+
39
+ return order
40
+
41
+
42
+ def _forward_neighbors(
43
+ validated: ValidatedPipeline | ValidatedDetailPanel,
44
+ ranks: dict[str, int],
45
+ ) -> tuple[dict[str, list[str]], dict[str, list[str]]]:
46
+ incoming: dict[str, list[str]] = defaultdict(list)
47
+ outgoing: dict[str, list[str]] = defaultdict(list)
48
+
49
+ for edge in validated.edges:
50
+ if ranks[edge.source] < ranks[edge.target]:
51
+ incoming[edge.target].append(edge.source)
52
+ outgoing[edge.source].append(edge.target)
53
+
54
+ return incoming, outgoing
55
+
56
+
57
+ def _resort_rank(
58
+ rank_nodes: list[str],
59
+ neighbors: dict[str, list[str]],
60
+ order: dict[str, int],
61
+ validated: ValidatedPipeline | ValidatedDetailPanel,
62
+ ) -> None:
63
+ rank_nodes.sort(
64
+ key=lambda node_id: (
65
+ _barycenter(neighbors.get(node_id, ()), order, validated.node_index[node_id]),
66
+ validated.node_index[node_id],
67
+ node_id,
68
+ )
69
+ )
70
+
71
+
72
+ def _barycenter(neighbors: list[str] | tuple[str, ...], order: dict[str, int], fallback: int) -> float:
73
+ if not neighbors:
74
+ return float(fallback)
75
+ return sum(order[neighbor] for neighbor in neighbors) / len(neighbors)
76
+
77
+
78
+ def _refresh_order(rank_nodes: list[str], order: dict[str, int]) -> None:
79
+ for index, node_id in enumerate(rank_nodes):
80
+ order[node_id] = index
81
+
82
+
83
+ def _apply_detail_panel_bias(
84
+ validated: ValidatedPipeline,
85
+ nodes_by_rank: dict[int, list[str]],
86
+ ranks: dict[str, int],
87
+ order: dict[str, int],
88
+ ) -> None:
89
+ focus_node_id = validated.detail_panel.panel.focus_node_id
90
+ focus_path_nodes = _detail_focus_path_nodes(validated, ranks, focus_node_id)
91
+ if not focus_path_nodes:
92
+ return
93
+
94
+ component_nodes = _weak_component_nodes(validated, focus_node_id)
95
+ component_ranks = sorted({ranks[node_id] for node_id in component_nodes})
96
+ max_row = max(len(nodes_by_rank[rank]) for rank in component_ranks) - 1
97
+
98
+ for rank in component_ranks:
99
+ rank_nodes = nodes_by_rank[rank]
100
+ rank_focus_nodes = [node_id for node_id in rank_nodes if node_id in focus_path_nodes]
101
+ if not rank_focus_nodes:
102
+ _refresh_order(rank_nodes, order)
103
+ continue
104
+
105
+ rank_other_nodes = [node_id for node_id in rank_nodes if node_id not in focus_path_nodes]
106
+ start_row = max_row - len(rank_focus_nodes) + 1
107
+
108
+ for index, node_id in enumerate(rank_other_nodes):
109
+ order[node_id] = index
110
+ for index, node_id in enumerate(rank_focus_nodes):
111
+ order[node_id] = start_row + index
112
+
113
+
114
+ def _detail_focus_path_nodes(
115
+ validated: ValidatedPipeline,
116
+ ranks: dict[str, int],
117
+ focus_node_id: str,
118
+ ) -> set[str]:
119
+ forward_outgoing: dict[str, list[str]] = defaultdict(list)
120
+ forward_incoming: dict[str, list[str]] = defaultdict(list)
121
+
122
+ for edge in validated.edges:
123
+ if edge.dashed or ranks[edge.source] >= ranks[edge.target]:
124
+ continue
125
+ forward_outgoing[edge.source].append(edge.target)
126
+ forward_incoming[edge.target].append(edge.source)
127
+
128
+ ancestors = _reachable(focus_node_id, forward_incoming)
129
+ descendants = _reachable(focus_node_id, forward_outgoing)
130
+ focus_path = ancestors | descendants | {focus_node_id}
131
+
132
+ if focus_path:
133
+ return focus_path
134
+ return {focus_node_id}
135
+
136
+
137
+ def _reachable(start: str, adjacency: dict[str, list[str]]) -> set[str]:
138
+ seen: set[str] = set()
139
+ queue = deque([start])
140
+
141
+ while queue:
142
+ node_id = queue.popleft()
143
+ for neighbor in adjacency.get(node_id, ()):
144
+ if neighbor in seen:
145
+ continue
146
+ seen.add(neighbor)
147
+ queue.append(neighbor)
148
+
149
+ return seen
150
+
151
+
152
+ def _weak_component_nodes(validated: ValidatedPipeline, start_node_id: str) -> set[str]:
153
+ adjacency: dict[str, set[str]] = {node.id: set() for node in validated.nodes}
154
+ for edge in validated.edges:
155
+ adjacency[edge.source].add(edge.target)
156
+ adjacency[edge.target].add(edge.source)
157
+
158
+ seen = {start_node_id}
159
+ queue = deque([start_node_id])
160
+
161
+ while queue:
162
+ node_id = queue.popleft()
163
+ for neighbor in sorted(adjacency[node_id], key=validated.node_index.__getitem__):
164
+ if neighbor in seen:
165
+ continue
166
+ seen.add(neighbor)
167
+ queue.append(neighbor)
168
+
169
+ return seen
@@ -0,0 +1,193 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import defaultdict, deque
4
+
5
+ from frameplot.layout.types import LayoutNode, MeasuredText
6
+
7
+
8
+ def place_nodes(
9
+ validated: "ValidatedPipeline | ValidatedDetailPanel",
10
+ measurements: dict[str, MeasuredText],
11
+ ranks: dict[str, int],
12
+ order: dict[str, int],
13
+ ) -> dict[str, LayoutNode]:
14
+ components = _weak_components(validated)
15
+ placed: dict[str, LayoutNode] = {}
16
+ current_top = validated.theme.outer_margin
17
+
18
+ for component_id, component_nodes in enumerate(components):
19
+ local_ranks = {node_id: ranks[node_id] for node_id in component_nodes}
20
+ local_rows = {node_id: order[node_id] for node_id in component_nodes}
21
+ min_rank = min(local_ranks.values())
22
+
23
+ nodes_by_rank: dict[int, list[str]] = defaultdict(list)
24
+ for node_id in component_nodes:
25
+ nodes_by_rank[local_ranks[node_id] - min_rank].append(node_id)
26
+
27
+ for rank_nodes in nodes_by_rank.values():
28
+ rank_nodes.sort(key=lambda node_id: (local_rows[node_id], validated.node_index[node_id]))
29
+
30
+ component_rows = sorted({local_rows[node_id] for node_id in component_nodes})
31
+ ranks_in_component = sorted(nodes_by_rank)
32
+
33
+ row_heights = {
34
+ row: max(measurements[node_id].height for node_id in component_nodes if local_rows[node_id] == row)
35
+ for row in component_rows
36
+ }
37
+ row_gap_after = _row_gap_after(
38
+ validated,
39
+ component_nodes,
40
+ local_rows,
41
+ )
42
+ column_widths = {
43
+ rank: max(measurements[node_id].width for node_id in nodes)
44
+ for rank, nodes in nodes_by_rank.items()
45
+ }
46
+ rank_gap_after = _rank_gap_after(
47
+ validated,
48
+ component_nodes,
49
+ local_ranks,
50
+ min_rank=min_rank,
51
+ )
52
+
53
+ row_tops: dict[int, float] = {}
54
+ cursor_y = current_top
55
+ for index, row in enumerate(component_rows):
56
+ row_tops[row] = cursor_y
57
+ cursor_y += row_heights[row]
58
+ if index < len(component_rows) - 1:
59
+ cursor_y += row_gap_after[row]
60
+ component_height = cursor_y - current_top
61
+
62
+ x_positions: dict[int, float] = {}
63
+ cursor_x = validated.theme.outer_margin
64
+ for rank in ranks_in_component:
65
+ x_positions[rank] = cursor_x
66
+ cursor_x += column_widths[rank]
67
+ if rank != ranks_in_component[-1]:
68
+ cursor_x += rank_gap_after[rank]
69
+
70
+ for rank in ranks_in_component:
71
+ for node_id in nodes_by_rank[rank]:
72
+ node = validated.node_lookup[node_id]
73
+ measured = measurements[node_id]
74
+ row = local_rows[node_id]
75
+ placed[node_id] = LayoutNode(
76
+ node=node,
77
+ rank=local_ranks[node_id],
78
+ order=row,
79
+ component_id=component_id,
80
+ width=round(column_widths[rank], 2),
81
+ height=round(row_heights[row], 2),
82
+ x=round(x_positions[rank], 2),
83
+ y=round(row_tops[row], 2),
84
+ title_lines=measured.title_lines,
85
+ subtitle_lines=measured.subtitle_lines,
86
+ title_line_height=measured.title_line_height,
87
+ subtitle_line_height=measured.subtitle_line_height,
88
+ content_height=measured.content_height,
89
+ )
90
+
91
+ current_top += component_height + validated.theme.component_gap
92
+
93
+ return placed
94
+
95
+
96
+ def _weak_components(validated: "ValidatedPipeline | ValidatedDetailPanel") -> list[tuple[str, ...]]:
97
+ adjacency: dict[str, set[str]] = {node.id: set() for node in validated.nodes}
98
+ for edge in validated.edges:
99
+ adjacency[edge.source].add(edge.target)
100
+ adjacency[edge.target].add(edge.source)
101
+
102
+ components: list[tuple[str, ...]] = []
103
+ seen: set[str] = set()
104
+
105
+ for node in validated.nodes:
106
+ if node.id in seen:
107
+ continue
108
+ queue = deque([node.id])
109
+ seen.add(node.id)
110
+ members: list[str] = []
111
+ while queue:
112
+ node_id = queue.popleft()
113
+ members.append(node_id)
114
+ for neighbor in sorted(adjacency[node_id], key=validated.node_index.__getitem__):
115
+ if neighbor in seen:
116
+ continue
117
+ seen.add(neighbor)
118
+ queue.append(neighbor)
119
+ members.sort(key=validated.node_index.__getitem__)
120
+ components.append(tuple(members))
121
+
122
+ return components
123
+
124
+
125
+ def _row_gap_after(
126
+ validated: "ValidatedPipeline | ValidatedDetailPanel",
127
+ component_nodes: tuple[str, ...],
128
+ rows: dict[str, int],
129
+ ) -> dict[int, float]:
130
+ row_ids = sorted({rows[node_id] for node_id in component_nodes})
131
+ crossing_counts = {row: 0 for row in row_ids[:-1]}
132
+ component_set = set(component_nodes)
133
+
134
+ for edge in validated.edges:
135
+ if edge.source not in component_set or edge.target not in component_set:
136
+ continue
137
+ source_row = rows[edge.source]
138
+ target_row = rows[edge.target]
139
+ if source_row == target_row:
140
+ continue
141
+ for row in range(min(source_row, target_row), max(source_row, target_row)):
142
+ crossing_counts[row] = crossing_counts.get(row, 0) + 1
143
+
144
+ gap_after: dict[int, float] = {}
145
+ for row in row_ids[:-1]:
146
+ lanes = crossing_counts.get(row, 0)
147
+ gap_after[row] = round(
148
+ _base_row_gap(validated.theme)
149
+ + max(0, lanes - 1) * validated.theme.route_track_gap,
150
+ 2,
151
+ )
152
+ return gap_after
153
+
154
+
155
+ def _rank_gap_after(
156
+ validated: "ValidatedPipeline | ValidatedDetailPanel",
157
+ component_nodes: tuple[str, ...],
158
+ ranks: dict[str, int],
159
+ *,
160
+ min_rank: int,
161
+ ) -> dict[int, float]:
162
+ component_set = set(component_nodes)
163
+ normalized_ranks = {node_id: rank - min_rank for node_id, rank in ranks.items()}
164
+ rank_ids = sorted({normalized_ranks[node_id] for node_id in component_nodes})
165
+ crossing_counts = {rank: 0 for rank in rank_ids[:-1]}
166
+
167
+ for edge in validated.edges:
168
+ if edge.source not in component_set or edge.target not in component_set:
169
+ continue
170
+ source_rank = normalized_ranks[edge.source]
171
+ target_rank = normalized_ranks[edge.target]
172
+ if source_rank == target_rank:
173
+ continue
174
+ for rank in range(min(source_rank, target_rank), max(source_rank, target_rank)):
175
+ crossing_counts[rank] = crossing_counts.get(rank, 0) + 1
176
+
177
+ gap_after: dict[int, float] = {}
178
+ for rank in rank_ids[:-1]:
179
+ lanes = crossing_counts.get(rank, 0)
180
+ gap_after[rank] = round(
181
+ _base_rank_gap(validated.theme)
182
+ + max(0, lanes - 1) * validated.theme.route_track_gap,
183
+ 2,
184
+ )
185
+ return gap_after
186
+
187
+
188
+ def _base_row_gap(theme: "Theme") -> float:
189
+ return max(theme.route_track_gap * 1.5, theme.node_gap * 0.75)
190
+
191
+
192
+ def _base_rank_gap(theme: "Theme") -> float:
193
+ return max(theme.route_track_gap * 2.5, theme.rank_gap * 0.6)