frameplot 0.5.7__tar.gz → 0.5.8__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 (28) hide show
  1. {frameplot-0.5.7/src/frameplot.egg-info → frameplot-0.5.8}/PKG-INFO +5 -4
  2. {frameplot-0.5.7 → frameplot-0.5.8}/README.md +4 -3
  3. {frameplot-0.5.7 → frameplot-0.5.8}/pyproject.toml +1 -1
  4. {frameplot-0.5.7 → frameplot-0.5.8}/src/frameplot/api.py +5 -2
  5. {frameplot-0.5.7 → frameplot-0.5.8}/src/frameplot/layout/__init__.py +426 -2
  6. {frameplot-0.5.7 → frameplot-0.5.8}/src/frameplot/layout/route.py +90 -17
  7. {frameplot-0.5.7 → frameplot-0.5.8}/src/frameplot/layout/types.py +18 -0
  8. frameplot-0.5.8/src/frameplot/layout/validate.py +373 -0
  9. {frameplot-0.5.7 → frameplot-0.5.8}/src/frameplot/model.py +12 -8
  10. {frameplot-0.5.7 → frameplot-0.5.8/src/frameplot.egg-info}/PKG-INFO +5 -4
  11. {frameplot-0.5.7 → frameplot-0.5.8}/tests/test_rendering.py +94 -27
  12. frameplot-0.5.7/src/frameplot/layout/validate.py +0 -145
  13. {frameplot-0.5.7 → frameplot-0.5.8}/LICENSE +0 -0
  14. {frameplot-0.5.7 → frameplot-0.5.8}/setup.cfg +0 -0
  15. {frameplot-0.5.7 → frameplot-0.5.8}/src/frameplot/__init__.py +0 -0
  16. {frameplot-0.5.7 → frameplot-0.5.8}/src/frameplot/layout/order.py +0 -0
  17. {frameplot-0.5.7 → frameplot-0.5.8}/src/frameplot/layout/place.py +0 -0
  18. {frameplot-0.5.7 → frameplot-0.5.8}/src/frameplot/layout/rank.py +0 -0
  19. {frameplot-0.5.7 → frameplot-0.5.8}/src/frameplot/layout/scc.py +0 -0
  20. {frameplot-0.5.7 → frameplot-0.5.8}/src/frameplot/layout/text.py +0 -0
  21. {frameplot-0.5.7 → frameplot-0.5.8}/src/frameplot/render/__init__.py +0 -0
  22. {frameplot-0.5.7 → frameplot-0.5.8}/src/frameplot/render/png.py +0 -0
  23. {frameplot-0.5.7 → frameplot-0.5.8}/src/frameplot/render/svg.py +0 -0
  24. {frameplot-0.5.7 → frameplot-0.5.8}/src/frameplot/theme.py +0 -0
  25. {frameplot-0.5.7 → frameplot-0.5.8}/src/frameplot.egg-info/SOURCES.txt +0 -0
  26. {frameplot-0.5.7 → frameplot-0.5.8}/src/frameplot.egg-info/dependency_links.txt +0 -0
  27. {frameplot-0.5.7 → frameplot-0.5.8}/src/frameplot.egg-info/requires.txt +0 -0
  28. {frameplot-0.5.7 → frameplot-0.5.8}/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.7
3
+ Version: 0.5.8
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
@@ -133,7 +133,7 @@ Top-level imports are the supported public API:
133
133
 
134
134
  - `Node(id, title, subtitle=None, fill=None, stroke=None, text_color=None, metadata=None, width=None, height=None)`
135
135
  - `Edge(id, source, target, color=None, dashed=False, merge_symbol=None, metadata=None)`
136
- - `Group(id, label, node_ids, edge_ids=(), stroke=None, fill=None, metadata=None)`
136
+ - `Group(id, label, node_ids, edge_ids=(), group_ids=(), stroke=None, fill=None, metadata=None)`
137
137
  - `DetailPanel(id, focus_node_id, label, nodes, edges, groups=(), stroke=None, fill=None, metadata=None)`
138
138
  - `Theme(...)`
139
139
  - `Pipeline(nodes, edges, groups=(), detail_panel=None, theme=None)`
@@ -151,7 +151,8 @@ Top-level imports are the supported public API:
151
151
 
152
152
  - Keep the main graph at one abstraction level. Frameplot lays the pipeline out as a dependency-driven left-to-right graph, not as a freeform block diagram.
153
153
  - Use `DetailPanel` for repeated block internals or per-stage mechanics that would otherwise create long-range edges in the main graph.
154
- - Use `Group` to highlight nearby related nodes, not to force distant nodes to stay together. Groups are visual overlays and routing obstacles, so very wide groups can increase route detours.
154
+ - Use `Group` as a structural container for nearby related nodes. Prefer explicit nesting with `group_ids`; legacy strict-subset node groups are still normalized into a tree for compatibility.
155
+ - Keep group membership tree-shaped. A node can belong to one direct parent group, and a child group can belong to one direct parent group.
155
156
 
156
157
  ## Advanced Example: Multi-cloud Data Pipeline
157
158
 
@@ -165,7 +166,7 @@ The hero image at the top and the theme gallery above are generated from [`examp
165
166
 
166
167
  - Layout is intentionally left-to-right in v0.x.
167
168
  - Edge labels are not supported yet, but edge-to-edge joins can render optional `+` / `x` badges.
168
- - Groups stay visual overlays, and routes leaving or re-entering grouped nodes bend outside grouped areas.
169
+ - Groups with `node_ids` or `group_ids` are structural container blocks in layout, while edge-only groups remain visual highlights.
169
170
  - Detail panels render as separate lower insets attached to a focus node in the main flow.
170
171
  - If a sample looks stretched or routes far outside the intended block, the graph usually mixes stage-level flow with internal logic in one plane; move the internals into a `DetailPanel`.
171
172
 
@@ -104,7 +104,7 @@ Top-level imports are the supported public API:
104
104
 
105
105
  - `Node(id, title, subtitle=None, fill=None, stroke=None, text_color=None, metadata=None, width=None, height=None)`
106
106
  - `Edge(id, source, target, color=None, dashed=False, merge_symbol=None, metadata=None)`
107
- - `Group(id, label, node_ids, edge_ids=(), stroke=None, fill=None, metadata=None)`
107
+ - `Group(id, label, node_ids, edge_ids=(), group_ids=(), stroke=None, fill=None, metadata=None)`
108
108
  - `DetailPanel(id, focus_node_id, label, nodes, edges, groups=(), stroke=None, fill=None, metadata=None)`
109
109
  - `Theme(...)`
110
110
  - `Pipeline(nodes, edges, groups=(), detail_panel=None, theme=None)`
@@ -122,7 +122,8 @@ Top-level imports are the supported public API:
122
122
 
123
123
  - Keep the main graph at one abstraction level. Frameplot lays the pipeline out as a dependency-driven left-to-right graph, not as a freeform block diagram.
124
124
  - Use `DetailPanel` for repeated block internals or per-stage mechanics that would otherwise create long-range edges in the main graph.
125
- - Use `Group` to highlight nearby related nodes, not to force distant nodes to stay together. Groups are visual overlays and routing obstacles, so very wide groups can increase route detours.
125
+ - Use `Group` as a structural container for nearby related nodes. Prefer explicit nesting with `group_ids`; legacy strict-subset node groups are still normalized into a tree for compatibility.
126
+ - Keep group membership tree-shaped. A node can belong to one direct parent group, and a child group can belong to one direct parent group.
126
127
 
127
128
  ## Advanced Example: Multi-cloud Data Pipeline
128
129
 
@@ -136,7 +137,7 @@ The hero image at the top and the theme gallery above are generated from [`examp
136
137
 
137
138
  - Layout is intentionally left-to-right in v0.x.
138
139
  - Edge labels are not supported yet, but edge-to-edge joins can render optional `+` / `x` badges.
139
- - Groups stay visual overlays, and routes leaving or re-entering grouped nodes bend outside grouped areas.
140
+ - Groups with `node_ids` or `group_ids` are structural container blocks in layout, while edge-only groups remain visual highlights.
140
141
  - Detail panels render as separate lower insets attached to a focus node in the main flow.
141
142
  - If a sample looks stretched or routes far outside the intended block, the graph usually mixes stage-level flow with internal logic in one plane; move the internals into a `DetailPanel`.
142
143
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "frameplot"
7
- version = "0.5.7"
7
+ version = "0.5.8"
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"
@@ -23,8 +23,11 @@ class Pipeline:
23
23
  dependency-driven left-to-right auto-layout, so it works best when kept to
24
24
  one abstraction level. If stage flow and internal mechanics are mixed into
25
25
  the same graph, ranks can stretch and routes can become unexpectedly long;
26
- move those internals into a :class:`DetailPanel` instead. Passing
27
- `theme=None` uses the default :class:`Theme`.
26
+ move those internals into a :class:`DetailPanel` instead. Groups with
27
+ `node_ids` or `group_ids` behave as structural container blocks. Prefer
28
+ explicit nesting with `group_ids`; strict-subset legacy node groups are
29
+ still normalized into a tree for compatibility. Passing `theme=None` uses
30
+ the default :class:`Theme`.
28
31
  """
29
32
 
30
33
  nodes: tuple[Node, ...]
@@ -2,9 +2,13 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from collections import defaultdict
6
+ from collections import deque
5
7
  from collections.abc import Callable
6
8
  from dataclasses import dataclass
9
+ from types import SimpleNamespace
7
10
 
11
+ from frameplot.model import Edge, Node
8
12
  from frameplot.layout.order import order_nodes
9
13
  from frameplot.layout.place import place_nodes
10
14
  from frameplot.layout.rank import assign_ranks
@@ -19,7 +23,9 @@ from frameplot.layout.types import (
19
23
  GuideLine,
20
24
  LayoutNode,
21
25
  LayoutResult,
26
+ MeasuredText,
22
27
  Point,
28
+ ResolvedEdgeTarget,
23
29
  RoutedEdge,
24
30
  )
25
31
  from frameplot.layout.validate import validate_pipeline
@@ -50,6 +56,30 @@ class _GroupSpacingInfo:
50
56
  right_inside_clearance: float
51
57
 
52
58
 
59
+ @dataclass(slots=True, frozen=True)
60
+ class _ContainerPlacement:
61
+ leaf_nodes: dict[str, LayoutNode]
62
+ group_bounds: dict[str, Bounds]
63
+ bounds: Bounds
64
+ rank_span: int
65
+ order_span: int
66
+
67
+
68
+ @dataclass(slots=True)
69
+ class _TempValidatedGraph:
70
+ nodes: tuple[Node, ...]
71
+ edges: tuple[Edge, ...]
72
+ groups: tuple[object, ...]
73
+ node_lookup: dict[str, Node]
74
+ edge_lookup: dict[str, Edge]
75
+ edge_targets: dict[str, ResolvedEdgeTarget]
76
+ node_index: dict[str, int]
77
+ edge_index: dict[str, int]
78
+ group_hierarchy: object
79
+ theme: "Theme"
80
+ detail_panel: None = None
81
+
82
+
53
83
  def build_layout(pipeline: "Pipeline") -> LayoutResult:
54
84
  """Compute positions, routes, and overlays for a pipeline."""
55
85
 
@@ -79,6 +109,17 @@ def build_layout(pipeline: "Pipeline") -> LayoutResult:
79
109
 
80
110
  def _layout_graph(validated: "ValidatedPipeline | ValidatedDetailPanel") -> GraphLayout:
81
111
  measurements = measure_text(validated)
112
+ if validated.group_hierarchy.structural_group_ids:
113
+ return _layout_structural_graph(validated, measurements)
114
+ return _layout_flat_graph(validated, measurements)
115
+
116
+
117
+ def _layout_flat_graph(
118
+ validated: "ValidatedPipeline | ValidatedDetailPanel | _TempValidatedGraph",
119
+ measurements: dict[str, MeasuredText],
120
+ *,
121
+ group_bounds_by_id: dict[str, Bounds] | None = None,
122
+ ) -> GraphLayout:
82
123
  scc_result = strongly_connected_components(validated)
83
124
  ranks = assign_ranks(validated, scc_result)
84
125
  order = order_nodes(validated, ranks)
@@ -96,8 +137,13 @@ def _layout_graph(validated: "ValidatedPipeline | ValidatedDetailPanel") -> Grap
96
137
  row_gap_overrides=row_gap_overrides,
97
138
  row_gap_floor=row_gap_floor,
98
139
  )
99
- routed_edges = route_edges(validated, placed_nodes)
100
- overlays = compute_group_overlays(validated, placed_nodes, routed_edges)
140
+ routed_edges = route_edges(validated, placed_nodes, group_bounds_by_id=group_bounds_by_id)
141
+ overlays = compute_group_overlays(
142
+ validated,
143
+ placed_nodes,
144
+ routed_edges,
145
+ group_bounds_by_id=group_bounds_by_id,
146
+ )
101
147
  next_rank_overrides = _rank_gap_overrides(validated, placed_nodes, routed_edges, overlays)
102
148
  next_row_overrides = _row_gap_overrides(validated, placed_nodes, routed_edges, overlays)
103
149
  if (
@@ -111,6 +157,384 @@ def _layout_graph(validated: "ValidatedPipeline | ValidatedDetailPanel") -> Grap
111
157
  return _normalize_graph_layout(placed_nodes, routed_edges, overlays, validated.theme)
112
158
 
113
159
 
160
+ def _layout_structural_graph(
161
+ validated: "ValidatedPipeline | ValidatedDetailPanel",
162
+ measurements: dict[str, MeasuredText],
163
+ ) -> GraphLayout:
164
+ placement = _layout_container(validated, measurements, group_id=None)
165
+ placed_nodes = _reindex_hierarchical_components(validated, placement.leaf_nodes)
166
+ routed_edges = route_edges(validated, placed_nodes, group_bounds_by_id=placement.group_bounds)
167
+ overlays = compute_group_overlays(
168
+ validated,
169
+ placed_nodes,
170
+ routed_edges,
171
+ group_bounds_by_id=placement.group_bounds,
172
+ )
173
+ return _normalize_graph_layout(placed_nodes, routed_edges, overlays, validated.theme)
174
+
175
+
176
+ def _layout_container(
177
+ validated: "ValidatedPipeline | ValidatedDetailPanel",
178
+ measurements: dict[str, MeasuredText],
179
+ *,
180
+ group_id: str | None,
181
+ ) -> _ContainerPlacement:
182
+ hierarchy = validated.group_hierarchy
183
+ direct_node_ids = (
184
+ hierarchy.top_level_node_ids if group_id is None else hierarchy.group_child_node_ids.get(group_id, ())
185
+ )
186
+ direct_group_ids = (
187
+ hierarchy.top_level_group_ids if group_id is None else hierarchy.group_child_group_ids.get(group_id, ())
188
+ )
189
+ child_group_placements = {
190
+ child_group_id: _layout_container(validated, measurements, group_id=child_group_id)
191
+ for child_group_id in direct_group_ids
192
+ }
193
+ temp_validated, temp_measurements = _build_temp_graph(
194
+ validated,
195
+ measurements,
196
+ direct_node_ids=direct_node_ids,
197
+ direct_group_ids=direct_group_ids,
198
+ child_group_placements=child_group_placements,
199
+ container_group_id=group_id,
200
+ )
201
+ temp_graph = _layout_flat_graph(temp_validated, temp_measurements)
202
+ temp_graph = _shift_graph_layout(
203
+ temp_graph,
204
+ shift_x=-temp_graph.content_bounds.x,
205
+ shift_y=-temp_graph.content_bounds.y,
206
+ )
207
+ block_rank_spans = {node_id: 1 for node_id in direct_node_ids}
208
+ block_rank_spans.update({child_group_id: placement.rank_span for child_group_id, placement in child_group_placements.items()})
209
+ block_order_spans = {node_id: 1 for node_id in direct_node_ids}
210
+ block_order_spans.update(
211
+ {child_group_id: placement.order_span for child_group_id, placement in child_group_placements.items()}
212
+ )
213
+ rank_widths = _rank_widths_for_blocks(temp_graph.nodes, block_rank_spans)
214
+ order_heights = _order_heights_for_blocks(temp_graph.nodes, block_order_spans)
215
+ rank_offsets = _cumulative_offsets(rank_widths)
216
+ order_offsets = _cumulative_offsets(order_heights)
217
+
218
+ leaf_nodes: dict[str, LayoutNode] = {}
219
+ group_bounds: dict[str, Bounds] = {}
220
+
221
+ for node_id in direct_node_ids:
222
+ anchor = temp_graph.nodes[node_id]
223
+ leaf_nodes[node_id] = _with_layout_indices(
224
+ anchor,
225
+ rank=rank_offsets[anchor.rank],
226
+ order=order_offsets[anchor.order],
227
+ )
228
+
229
+ for child_group_id, child_placement in child_group_placements.items():
230
+ anchor = temp_graph.nodes[child_group_id]
231
+ shift_x = anchor.x - child_placement.bounds.x
232
+ shift_y = anchor.y - child_placement.bounds.y
233
+ rank_offset = rank_offsets[anchor.rank]
234
+ order_offset = order_offsets[anchor.order]
235
+ for leaf_id, node in child_placement.leaf_nodes.items():
236
+ leaf_nodes[leaf_id] = _with_layout_indices(
237
+ _shift_node(node, shift_x, shift_y),
238
+ rank=node.rank + rank_offset,
239
+ order=node.order + order_offset,
240
+ )
241
+ for nested_group_id, bounds in child_placement.group_bounds.items():
242
+ group_bounds[nested_group_id] = Bounds(
243
+ x=round(bounds.x + shift_x, 2),
244
+ y=round(bounds.y + shift_y, 2),
245
+ width=bounds.width,
246
+ height=bounds.height,
247
+ )
248
+
249
+ if group_id is None:
250
+ return _ContainerPlacement(
251
+ leaf_nodes=leaf_nodes,
252
+ group_bounds=group_bounds,
253
+ bounds=Bounds(
254
+ x=0.0,
255
+ y=0.0,
256
+ width=round(temp_graph.content_bounds.width, 2),
257
+ height=round(temp_graph.content_bounds.height, 2),
258
+ ),
259
+ rank_span=sum(rank_widths.values()) or 1,
260
+ order_span=sum(order_heights.values()) or 1,
261
+ )
262
+
263
+ metrics = resolve_theme_metrics(validated.theme)
264
+ inset_x = validated.theme.group_padding
265
+ inset_top = validated.theme.group_padding + metrics.group_label_padding
266
+ inset_bottom = validated.theme.group_padding
267
+
268
+ shifted_nodes = {
269
+ node_id: _shift_node(node, inset_x, inset_top)
270
+ for node_id, node in leaf_nodes.items()
271
+ }
272
+ shifted_group_bounds = {
273
+ nested_group_id: Bounds(
274
+ x=round(bounds.x + inset_x, 2),
275
+ y=round(bounds.y + inset_top, 2),
276
+ width=bounds.width,
277
+ height=bounds.height,
278
+ )
279
+ for nested_group_id, bounds in group_bounds.items()
280
+ }
281
+ own_bounds = Bounds(
282
+ x=0.0,
283
+ y=0.0,
284
+ width=round(temp_graph.content_bounds.width + inset_x * 2, 2),
285
+ height=round(temp_graph.content_bounds.height + inset_top + inset_bottom, 2),
286
+ )
287
+ shifted_group_bounds[group_id] = own_bounds
288
+ return _ContainerPlacement(
289
+ leaf_nodes=shifted_nodes,
290
+ group_bounds=shifted_group_bounds,
291
+ bounds=own_bounds,
292
+ rank_span=sum(rank_widths.values()) or 1,
293
+ order_span=sum(order_heights.values()) or 1,
294
+ )
295
+
296
+
297
+ def _build_temp_graph(
298
+ validated: "ValidatedPipeline | ValidatedDetailPanel",
299
+ measurements: dict[str, MeasuredText],
300
+ *,
301
+ direct_node_ids: tuple[str, ...],
302
+ direct_group_ids: tuple[str, ...],
303
+ child_group_placements: dict[str, _ContainerPlacement],
304
+ container_group_id: str | None,
305
+ ) -> tuple[_TempValidatedGraph, dict[str, MeasuredText]]:
306
+ hierarchy = validated.group_hierarchy
307
+ block_nodes: list[Node] = []
308
+ node_lookup: dict[str, Node] = {}
309
+ node_index: dict[str, int] = {}
310
+ block_measurements: dict[str, MeasuredText] = {}
311
+
312
+ for index, node_id in enumerate((*direct_node_ids, *direct_group_ids)):
313
+ if node_id in direct_node_ids:
314
+ node = validated.node_lookup[node_id]
315
+ block_measurements[node_id] = measurements[node_id]
316
+ else:
317
+ group = hierarchy.group_lookup[node_id]
318
+ placement = child_group_placements[node_id]
319
+ node = Node(
320
+ id=node_id,
321
+ title=group.label,
322
+ width=placement.bounds.width,
323
+ height=placement.bounds.height,
324
+ fill=group.fill,
325
+ stroke=group.stroke,
326
+ )
327
+ block_measurements[node_id] = MeasuredText(
328
+ title_lines=(),
329
+ subtitle_lines=(),
330
+ title_line_height=0.0,
331
+ subtitle_line_height=0.0,
332
+ content_height=0.0,
333
+ width=placement.bounds.width,
334
+ height=placement.bounds.height,
335
+ )
336
+ block_nodes.append(node)
337
+ node_lookup[node.id] = node
338
+ node_index[node.id] = index
339
+
340
+ projected_edges: list[Edge] = []
341
+ edge_lookup: dict[str, Edge] = {}
342
+ edge_targets: dict[str, ResolvedEdgeTarget] = {}
343
+ edge_index: dict[str, int] = {}
344
+ seen_pairs: set[tuple[str, str]] = set()
345
+
346
+ for edge in validated.edges:
347
+ source_block_id = _container_direct_block_id(
348
+ hierarchy,
349
+ edge.source,
350
+ container_group_id=container_group_id,
351
+ )
352
+ target_block_id = _container_direct_block_id(
353
+ hierarchy,
354
+ validated.edge_targets[edge.id].node_id,
355
+ container_group_id=container_group_id,
356
+ )
357
+ if source_block_id is None or target_block_id is None or source_block_id == target_block_id:
358
+ continue
359
+ if source_block_id not in node_lookup or target_block_id not in node_lookup:
360
+ continue
361
+ pair = (source_block_id, target_block_id)
362
+ if pair in seen_pairs:
363
+ continue
364
+ seen_pairs.add(pair)
365
+ projected_edge = Edge(
366
+ id=f"__proj_{len(projected_edges)}",
367
+ source=source_block_id,
368
+ target=target_block_id,
369
+ color=edge.color,
370
+ dashed=edge.dashed,
371
+ )
372
+ projected_edges.append(projected_edge)
373
+ edge_lookup[projected_edge.id] = projected_edge
374
+ edge_targets[projected_edge.id] = ResolvedEdgeTarget(kind="node", node_id=target_block_id)
375
+ edge_index[projected_edge.id] = len(projected_edges) - 1
376
+
377
+ temp_validated = _TempValidatedGraph(
378
+ nodes=tuple(block_nodes),
379
+ edges=tuple(projected_edges),
380
+ groups=(),
381
+ node_lookup=node_lookup,
382
+ edge_lookup=edge_lookup,
383
+ edge_targets=edge_targets,
384
+ node_index=node_index,
385
+ edge_index=edge_index,
386
+ group_hierarchy=SimpleNamespace( # type: ignore[name-defined]
387
+ structural_group_ids=(),
388
+ edge_only_group_ids=(),
389
+ top_level_group_ids=(),
390
+ top_level_node_ids=tuple(node_lookup),
391
+ group_parent_ids={},
392
+ group_child_group_ids={},
393
+ group_child_node_ids={},
394
+ group_descendant_node_ids={},
395
+ node_parent_group_ids={},
396
+ group_depths={},
397
+ group_lookup={},
398
+ group_index={},
399
+ ),
400
+ theme=validated.theme,
401
+ )
402
+ return temp_validated, block_measurements
403
+
404
+
405
+ def _container_direct_block_id(
406
+ hierarchy,
407
+ node_id: str,
408
+ *,
409
+ container_group_id: str | None,
410
+ ) -> str | None:
411
+ current_group_id = hierarchy.node_parent_group_ids.get(node_id)
412
+ if container_group_id is None:
413
+ if current_group_id is None:
414
+ return node_id
415
+ while hierarchy.group_parent_ids.get(current_group_id) is not None:
416
+ current_group_id = hierarchy.group_parent_ids[current_group_id]
417
+ return current_group_id
418
+
419
+ if current_group_id is None:
420
+ return None
421
+ if current_group_id == container_group_id:
422
+ return node_id
423
+
424
+ while True:
425
+ parent_group_id = hierarchy.group_parent_ids.get(current_group_id)
426
+ if parent_group_id == container_group_id:
427
+ return current_group_id
428
+ if parent_group_id is None:
429
+ return None
430
+ current_group_id = parent_group_id
431
+
432
+
433
+ def _rank_widths_for_blocks(
434
+ nodes: dict[str, LayoutNode],
435
+ spans_by_block_id: dict[str, int],
436
+ ) -> dict[int, int]:
437
+ widths: dict[int, int] = defaultdict(lambda: 1)
438
+ for block_id, node in nodes.items():
439
+ widths[node.rank] = max(widths[node.rank], spans_by_block_id.get(block_id, 1))
440
+ return dict(widths)
441
+
442
+
443
+ def _order_heights_for_blocks(
444
+ nodes: dict[str, LayoutNode],
445
+ spans_by_block_id: dict[str, int],
446
+ ) -> dict[int, int]:
447
+ heights: dict[int, int] = defaultdict(lambda: 1)
448
+ for block_id, node in nodes.items():
449
+ heights[node.order] = max(heights[node.order], spans_by_block_id.get(block_id, 1))
450
+ return dict(heights)
451
+
452
+
453
+ def _cumulative_offsets(spans_by_index: dict[int, int]) -> dict[int, int]:
454
+ offsets: dict[int, int] = {}
455
+ cursor = 0
456
+ for index in sorted(spans_by_index):
457
+ offsets[index] = cursor
458
+ cursor += spans_by_index[index]
459
+ return offsets
460
+
461
+
462
+ def _with_layout_indices(node: LayoutNode, *, rank: int, order: int) -> LayoutNode:
463
+ return LayoutNode(
464
+ node=node.node,
465
+ rank=rank,
466
+ order=order,
467
+ component_id=node.component_id,
468
+ width=node.width,
469
+ height=node.height,
470
+ x=node.x,
471
+ y=node.y,
472
+ title_lines=node.title_lines,
473
+ subtitle_lines=node.subtitle_lines,
474
+ title_line_height=node.title_line_height,
475
+ subtitle_line_height=node.subtitle_line_height,
476
+ content_height=node.content_height,
477
+ )
478
+
479
+
480
+ def _reindex_hierarchical_components(
481
+ validated: "ValidatedPipeline | ValidatedDetailPanel",
482
+ nodes: dict[str, LayoutNode],
483
+ ) -> dict[str, LayoutNode]:
484
+ component_ids = _component_ids_for_validated_graph(validated)
485
+ reindexed: dict[str, LayoutNode] = {}
486
+
487
+ for node_id, node in nodes.items():
488
+ reindexed[node_id] = LayoutNode(
489
+ node=node.node,
490
+ rank=node.rank,
491
+ order=node.order,
492
+ component_id=component_ids[node_id],
493
+ width=node.width,
494
+ height=node.height,
495
+ x=node.x,
496
+ y=node.y,
497
+ title_lines=node.title_lines,
498
+ subtitle_lines=node.subtitle_lines,
499
+ title_line_height=node.title_line_height,
500
+ subtitle_line_height=node.subtitle_line_height,
501
+ content_height=node.content_height,
502
+ )
503
+
504
+ return reindexed
505
+
506
+
507
+ def _component_ids_for_validated_graph(
508
+ validated: "ValidatedPipeline | ValidatedDetailPanel",
509
+ ) -> dict[str, int]:
510
+ adjacency: dict[str, set[str]] = {node.id: set() for node in validated.nodes}
511
+ for edge in validated.edges:
512
+ target_node_id = validated.edge_targets[edge.id].node_id
513
+ adjacency[edge.source].add(target_node_id)
514
+ adjacency[target_node_id].add(edge.source)
515
+
516
+ component_ids: dict[str, int] = {}
517
+ seen: set[str] = set()
518
+ component_id = 0
519
+
520
+ for node in validated.nodes:
521
+ if node.id in seen:
522
+ continue
523
+ queue = deque([node.id])
524
+ seen.add(node.id)
525
+ while queue:
526
+ node_id = queue.popleft()
527
+ component_ids[node_id] = component_id
528
+ for neighbor in sorted(adjacency[node_id], key=validated.node_index.__getitem__):
529
+ if neighbor in seen:
530
+ continue
531
+ seen.add(neighbor)
532
+ queue.append(neighbor)
533
+ component_id += 1
534
+
535
+ return component_ids
536
+
537
+
114
538
  def _rank_gap_overrides(
115
539
  validated: "ValidatedPipeline | ValidatedDetailPanel",
116
540
  placed_nodes: dict[str, LayoutNode],