frameplot 0.2.0__tar.gz → 0.3.0__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 (29) hide show
  1. {frameplot-0.2.0/src/frameplot.egg-info → frameplot-0.3.0}/PKG-INFO +3 -3
  2. {frameplot-0.2.0 → frameplot-0.3.0}/README.md +2 -2
  3. {frameplot-0.2.0 → frameplot-0.3.0}/pyproject.toml +1 -1
  4. frameplot-0.3.0/src/frameplot/layout/__init__.py +523 -0
  5. {frameplot-0.2.0 → frameplot-0.3.0}/src/frameplot/layout/place.py +26 -18
  6. {frameplot-0.2.0 → frameplot-0.3.0}/src/frameplot/layout/route.py +543 -24
  7. {frameplot-0.2.0 → frameplot-0.3.0}/src/frameplot/layout/text.py +18 -12
  8. {frameplot-0.2.0 → frameplot-0.3.0}/src/frameplot/model.py +3 -2
  9. {frameplot-0.2.0 → frameplot-0.3.0}/src/frameplot/render/png.py +6 -1
  10. {frameplot-0.2.0 → frameplot-0.3.0}/src/frameplot/render/svg.py +166 -43
  11. frameplot-0.3.0/src/frameplot/theme.py +306 -0
  12. {frameplot-0.2.0 → frameplot-0.3.0/src/frameplot.egg-info}/PKG-INFO +3 -3
  13. {frameplot-0.2.0 → frameplot-0.3.0}/tests/test_rendering.py +321 -0
  14. frameplot-0.2.0/src/frameplot/layout/__init__.py +0 -268
  15. frameplot-0.2.0/src/frameplot/theme.py +0 -157
  16. {frameplot-0.2.0 → frameplot-0.3.0}/LICENSE +0 -0
  17. {frameplot-0.2.0 → frameplot-0.3.0}/setup.cfg +0 -0
  18. {frameplot-0.2.0 → frameplot-0.3.0}/src/frameplot/__init__.py +0 -0
  19. {frameplot-0.2.0 → frameplot-0.3.0}/src/frameplot/api.py +0 -0
  20. {frameplot-0.2.0 → frameplot-0.3.0}/src/frameplot/layout/order.py +0 -0
  21. {frameplot-0.2.0 → frameplot-0.3.0}/src/frameplot/layout/rank.py +0 -0
  22. {frameplot-0.2.0 → frameplot-0.3.0}/src/frameplot/layout/scc.py +0 -0
  23. {frameplot-0.2.0 → frameplot-0.3.0}/src/frameplot/layout/types.py +0 -0
  24. {frameplot-0.2.0 → frameplot-0.3.0}/src/frameplot/layout/validate.py +0 -0
  25. {frameplot-0.2.0 → frameplot-0.3.0}/src/frameplot/render/__init__.py +0 -0
  26. {frameplot-0.2.0 → frameplot-0.3.0}/src/frameplot.egg-info/SOURCES.txt +0 -0
  27. {frameplot-0.2.0 → frameplot-0.3.0}/src/frameplot.egg-info/dependency_links.txt +0 -0
  28. {frameplot-0.2.0 → frameplot-0.3.0}/src/frameplot.egg-info/requires.txt +0 -0
  29. {frameplot-0.2.0 → frameplot-0.3.0}/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.2.0
3
+ Version: 0.3.0
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
@@ -118,7 +118,7 @@ The hero image at the top is a practical example of a **Multi-cloud Data Pipelin
118
118
 
119
119
  - Layout is intentionally left-to-right in v0.x.
120
120
  - Edge labels are not supported yet.
121
- - Groups are visual overlays and do not constrain layout.
121
+ - Groups stay visual overlays, and routes leaving or re-entering grouped nodes bend outside grouped areas.
122
122
  - Detail panels render as separate lower insets attached to a focus node in the main flow.
123
123
 
124
124
  ## Development
@@ -130,4 +130,4 @@ python -m pip install -e '.[dev]'
130
130
  python -m pytest -q
131
131
  ```
132
132
 
133
- Release publishing is automated through GitHub Actions and PyPI Trusted Publishing. Bump the version in `pyproject.toml`, create a tag like `v0.1.0`, and push the tag to trigger a release from `.github/workflows/workflow.yml`.
133
+ Release publishing is automated through GitHub Actions and PyPI Trusted Publishing. Bump the version in `pyproject.toml`, create a tag like `v0.3.0`, and push the tag to trigger a release from `.github/workflows/workflow.yml`.
@@ -89,7 +89,7 @@ The hero image at the top is a practical example of a **Multi-cloud Data Pipelin
89
89
 
90
90
  - Layout is intentionally left-to-right in v0.x.
91
91
  - Edge labels are not supported yet.
92
- - Groups are visual overlays and do not constrain layout.
92
+ - Groups stay visual overlays, and routes leaving or re-entering grouped nodes bend outside grouped areas.
93
93
  - Detail panels render as separate lower insets attached to a focus node in the main flow.
94
94
 
95
95
  ## Development
@@ -101,4 +101,4 @@ python -m pip install -e '.[dev]'
101
101
  python -m pytest -q
102
102
  ```
103
103
 
104
- Release publishing is automated through GitHub Actions and PyPI Trusted Publishing. Bump the version in `pyproject.toml`, create a tag like `v0.1.0`, and push the tag to trigger a release from `.github/workflows/workflow.yml`.
104
+ Release publishing is automated through GitHub Actions and PyPI Trusted Publishing. Bump the version in `pyproject.toml`, create a tag like `v0.3.0`, and push the tag to trigger a release from `.github/workflows/workflow.yml`.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "frameplot"
7
- version = "0.2.0"
7
+ version = "0.3.0"
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"
@@ -0,0 +1,523 @@
1
+ """Internal layout pipeline used by the public frameplot API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+
8
+ from frameplot.layout.order import order_nodes
9
+ from frameplot.layout.place import place_nodes
10
+ from frameplot.layout.rank import assign_ranks
11
+ from frameplot.layout.route import _build_component_geometry, compute_group_overlays, route_edges
12
+ from frameplot.layout.scc import strongly_connected_components
13
+ from frameplot.layout.text import measure_text
14
+ from frameplot.layout.types import (
15
+ Bounds,
16
+ DetailPanelLayout,
17
+ GraphLayout,
18
+ GroupOverlay,
19
+ GuideLine,
20
+ LayoutNode,
21
+ LayoutResult,
22
+ Point,
23
+ RoutedEdge,
24
+ )
25
+ from frameplot.layout.validate import validate_pipeline
26
+ from frameplot.theme import resolve_theme_metrics
27
+
28
+ __all__ = ["build_layout"]
29
+
30
+ EPSILON = 0.01
31
+ MAX_LAYOUT_STABILIZATION_PASSES = 3
32
+
33
+
34
+ @dataclass(slots=True, frozen=True)
35
+ class _GroupSpacingInfo:
36
+ bounds: Bounds
37
+ component_id: int
38
+ group_node_ids: frozenset[str]
39
+ min_rank: int
40
+ max_rank: int
41
+ member_left: float
42
+ member_right: float
43
+ left_inside_clearance: float
44
+ right_inside_clearance: float
45
+
46
+
47
+ def build_layout(pipeline: "Pipeline") -> LayoutResult:
48
+ """Compute positions, routes, and overlays for a pipeline."""
49
+
50
+ validated = validate_pipeline(pipeline)
51
+ theme = validated.theme
52
+ main_graph = _layout_graph(validated)
53
+
54
+ detail_panel = None
55
+ width = main_graph.width
56
+ height = main_graph.height
57
+
58
+ if validated.detail_panel is not None:
59
+ detail_panel = _build_detail_panel_layout(validated.detail_panel, main_graph, theme)
60
+ width = max(width, detail_panel.bounds.right + theme.outer_margin)
61
+ height = max(height, detail_panel.bounds.bottom + theme.outer_margin)
62
+ for guide_line in detail_panel.guide_lines:
63
+ width = max(width, guide_line.bounds.right + theme.outer_margin)
64
+ height = max(height, guide_line.bounds.bottom + theme.outer_margin)
65
+
66
+ return LayoutResult(
67
+ main=main_graph,
68
+ detail_panel=detail_panel,
69
+ width=round(width, 2),
70
+ height=round(height, 2),
71
+ )
72
+
73
+
74
+ def _layout_graph(validated: "ValidatedPipeline | ValidatedDetailPanel") -> GraphLayout:
75
+ measurements = measure_text(validated)
76
+ scc_result = strongly_connected_components(validated)
77
+ ranks = assign_ranks(validated, scc_result)
78
+ order = order_nodes(validated, ranks)
79
+ rank_gap_overrides: dict[tuple[int, int], float] | None = None
80
+
81
+ for _ in range(MAX_LAYOUT_STABILIZATION_PASSES):
82
+ placed_nodes = place_nodes(
83
+ validated,
84
+ measurements,
85
+ ranks,
86
+ order,
87
+ rank_gap_overrides=rank_gap_overrides,
88
+ )
89
+ routed_edges = route_edges(validated, placed_nodes)
90
+ 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 {}):
93
+ break
94
+ rank_gap_overrides = next_overrides or None
95
+
96
+ return _normalize_graph_layout(placed_nodes, routed_edges, overlays, validated.theme)
97
+
98
+
99
+ def _rank_gap_overrides(
100
+ validated: "ValidatedPipeline | ValidatedDetailPanel",
101
+ placed_nodes: dict[str, LayoutNode],
102
+ routed_edges: tuple[RoutedEdge, ...],
103
+ overlays: tuple[GroupOverlay, ...],
104
+ ) -> dict[tuple[int, int], float]:
105
+ theme = validated.theme
106
+ metrics = resolve_theme_metrics(theme)
107
+ base_gap = metrics.compact_rank_gap
108
+ geometry_by_component = _build_component_geometry(placed_nodes, theme)
109
+ required_gaps: dict[tuple[int, int], float] = {}
110
+ used_lane_positions: dict[tuple[int, int], set[float]] = {}
111
+
112
+ for component_id, geometry in geometry_by_component.items():
113
+ for left_rank in geometry.gap_after_rank:
114
+ used_lane_positions[(component_id, left_rank)] = set()
115
+
116
+ for routed_edge in routed_edges:
117
+ component_id = placed_nodes[routed_edge.edge.source].component_id
118
+ geometry = geometry_by_component[component_id]
119
+ for start, end in zip(routed_edge.points, routed_edge.points[1:]):
120
+ if start.x != end.x:
121
+ continue
122
+ x = round(start.x, 2)
123
+ for left_rank, (gap_start, gap_end) in geometry.gap_after_rank.items():
124
+ if gap_start + 0.01 < x < gap_end - 0.01:
125
+ used_lane_positions[(component_id, left_rank)].add(x)
126
+
127
+ overrides: dict[tuple[int, int], float] = {}
128
+ for key, lane_positions in used_lane_positions.items():
129
+ if len(lane_positions) <= 1:
130
+ continue
131
+ required_gap = base_gap + max(lane_positions) - min(lane_positions)
132
+ if required_gap > base_gap + EPSILON:
133
+ required_gaps[key] = round(required_gap, 2)
134
+
135
+ for key, required_gap in _group_boundary_gap_requirements(
136
+ placed_nodes,
137
+ overlays,
138
+ geometry_by_component,
139
+ ).items():
140
+ required_gaps[key] = round(max(required_gaps.get(key, 0.0), required_gap), 2)
141
+
142
+ for key, required_gap in required_gaps.items():
143
+ if required_gap > base_gap + EPSILON:
144
+ overrides[key] = round(required_gap, 2)
145
+
146
+ return overrides
147
+
148
+
149
+ def _group_boundary_gap_requirements(
150
+ placed_nodes: dict[str, LayoutNode],
151
+ overlays: tuple[GroupOverlay, ...],
152
+ geometry_by_component: dict[int, "ComponentGeometry"],
153
+ ) -> dict[tuple[int, int], float]:
154
+ required_gaps: dict[tuple[int, int], float] = {}
155
+ group_infos = _build_group_spacing_infos(placed_nodes, overlays)
156
+
157
+ for info in group_infos:
158
+ geometry = geometry_by_component.get(info.component_id)
159
+ if geometry is None:
160
+ continue
161
+
162
+ right_outside_node = _nearest_outside_node(
163
+ placed_nodes,
164
+ bounds=info.bounds,
165
+ component_id=info.component_id,
166
+ group_node_ids=info.group_node_ids,
167
+ rank_predicate=lambda rank: rank > info.max_rank,
168
+ side="right",
169
+ )
170
+ if info.max_rank in geometry.gap_after_rank and right_outside_node is not None:
171
+ right_boundary_gap = info.right_inside_clearance * 2.0
172
+ right_neighbor = _nearest_group_neighbor(info, group_infos, side="right")
173
+ if right_neighbor is not None and right_outside_node.node.id in right_neighbor.group_node_ids:
174
+ inter_group_gap = max(info.right_inside_clearance, right_neighbor.left_inside_clearance)
175
+ right_boundary_gap = max(
176
+ right_boundary_gap,
177
+ info.right_inside_clearance + inter_group_gap + right_neighbor.left_inside_clearance,
178
+ )
179
+ required_gaps[(info.component_id, info.max_rank)] = round(
180
+ max(required_gaps.get((info.component_id, info.max_rank), 0.0), right_boundary_gap),
181
+ 2,
182
+ )
183
+
184
+ left_rank = _previous_rank(geometry, info.min_rank)
185
+ left_outside_node = _nearest_outside_node(
186
+ placed_nodes,
187
+ bounds=info.bounds,
188
+ component_id=info.component_id,
189
+ group_node_ids=info.group_node_ids,
190
+ rank_predicate=lambda rank: rank < info.min_rank,
191
+ side="left",
192
+ )
193
+ if left_rank is not None and left_outside_node is not None:
194
+ left_boundary_gap = info.left_inside_clearance * 2.0
195
+ left_neighbor = _nearest_group_neighbor(info, group_infos, side="left")
196
+ if left_neighbor is not None and left_outside_node.node.id in left_neighbor.group_node_ids:
197
+ inter_group_gap = max(info.left_inside_clearance, left_neighbor.right_inside_clearance)
198
+ left_boundary_gap = max(
199
+ left_boundary_gap,
200
+ info.left_inside_clearance + inter_group_gap + left_neighbor.right_inside_clearance,
201
+ )
202
+ required_gaps[(info.component_id, left_rank)] = round(
203
+ max(required_gaps.get((info.component_id, left_rank), 0.0), left_boundary_gap),
204
+ 2,
205
+ )
206
+
207
+ return required_gaps
208
+
209
+
210
+ def _build_group_spacing_infos(
211
+ placed_nodes: dict[str, LayoutNode],
212
+ overlays: tuple[GroupOverlay, ...],
213
+ ) -> tuple[_GroupSpacingInfo, ...]:
214
+ infos: list[_GroupSpacingInfo] = []
215
+
216
+ for overlay in overlays:
217
+ if not overlay.group.node_ids:
218
+ continue
219
+ member_nodes = [placed_nodes[node_id] for node_id in overlay.group.node_ids]
220
+ member_left = min(node.x for node in member_nodes)
221
+ member_right = max(node.right for node in member_nodes)
222
+ infos.append(
223
+ _GroupSpacingInfo(
224
+ bounds=overlay.bounds,
225
+ component_id=member_nodes[0].component_id,
226
+ group_node_ids=frozenset(overlay.group.node_ids),
227
+ min_rank=min(node.rank for node in member_nodes),
228
+ max_rank=max(node.rank for node in member_nodes),
229
+ member_left=member_left,
230
+ member_right=member_right,
231
+ left_inside_clearance=member_left - overlay.bounds.x,
232
+ right_inside_clearance=overlay.bounds.right - member_right,
233
+ )
234
+ )
235
+
236
+ return tuple(infos)
237
+
238
+
239
+ def _nearest_outside_node(
240
+ placed_nodes: dict[str, LayoutNode],
241
+ *,
242
+ bounds: Bounds,
243
+ component_id: int,
244
+ group_node_ids: frozenset[str],
245
+ rank_predicate: Callable[[int], bool],
246
+ side: str,
247
+ ) -> LayoutNode | None:
248
+ candidate: LayoutNode | None = None
249
+
250
+ for node_id, node in placed_nodes.items():
251
+ if node.component_id != component_id:
252
+ continue
253
+ if node_id in group_node_ids:
254
+ continue
255
+ if not rank_predicate(node.rank):
256
+ continue
257
+ if not _bounds_overlap_vertically(bounds, node.bounds):
258
+ continue
259
+ if candidate is None:
260
+ candidate = node
261
+ continue
262
+ if side == "right" and node.x < candidate.x:
263
+ candidate = node
264
+ if side == "left" and node.right > candidate.right:
265
+ candidate = node
266
+
267
+ return candidate
268
+
269
+
270
+ def _nearest_group_neighbor(
271
+ info: _GroupSpacingInfo,
272
+ group_infos: tuple[_GroupSpacingInfo, ...],
273
+ *,
274
+ side: str,
275
+ ) -> _GroupSpacingInfo | None:
276
+ candidates: list[_GroupSpacingInfo] = []
277
+
278
+ for other in group_infos:
279
+ if other == info:
280
+ continue
281
+ if other.component_id != info.component_id:
282
+ continue
283
+ if not _bounds_overlap_vertically(info.bounds, other.bounds):
284
+ continue
285
+ if side == "right" and other.min_rank > info.max_rank:
286
+ candidates.append(other)
287
+ if side == "left" and other.max_rank < info.min_rank:
288
+ candidates.append(other)
289
+
290
+ if not candidates:
291
+ return None
292
+
293
+ if side == "right":
294
+ return min(candidates, key=lambda other: (other.member_left, other.bounds.x))
295
+ return max(candidates, key=lambda other: (other.member_right, other.bounds.right))
296
+
297
+
298
+ def _bounds_overlap_vertically(first: Bounds, second: Bounds) -> bool:
299
+ return min(first.bottom, second.bottom) - max(first.y, second.y) > EPSILON
300
+
301
+
302
+ def _previous_rank(geometry: "ComponentGeometry", rank: int) -> int | None:
303
+ candidates = [candidate_rank for candidate_rank in geometry.rank_right if candidate_rank < rank]
304
+ if not candidates:
305
+ return None
306
+ return max(candidates)
307
+
308
+
309
+ def _normalize_graph_layout(
310
+ placed_nodes: dict[str, LayoutNode],
311
+ routed_edges: tuple[RoutedEdge, ...],
312
+ overlays: tuple[GroupOverlay, ...],
313
+ theme: "Theme",
314
+ ) -> GraphLayout:
315
+ content_bounds = _collect_graph_bounds(placed_nodes, routed_edges, overlays)
316
+
317
+ shift_x = max(0.0, theme.outer_margin - content_bounds.x)
318
+ shift_y = max(0.0, theme.outer_margin - content_bounds.y)
319
+ if shift_x or shift_y:
320
+ placed_nodes = {node_id: _shift_node(node, shift_x, shift_y) for node_id, node in placed_nodes.items()}
321
+ routed_edges = tuple(_shift_edge(route, shift_x, shift_y) for route in routed_edges)
322
+ overlays = tuple(_shift_overlay(overlay, shift_x, shift_y) for overlay in overlays)
323
+ content_bounds = _collect_graph_bounds(placed_nodes, routed_edges, overlays)
324
+
325
+ return GraphLayout(
326
+ nodes=placed_nodes,
327
+ edges=routed_edges,
328
+ groups=overlays,
329
+ content_bounds=content_bounds,
330
+ width=round(content_bounds.right + theme.outer_margin, 2),
331
+ height=round(content_bounds.bottom + theme.outer_margin, 2),
332
+ )
333
+
334
+
335
+ def _build_detail_panel_layout(
336
+ validated_panel: "ValidatedDetailPanel",
337
+ main_graph: GraphLayout,
338
+ theme: "Theme",
339
+ ) -> DetailPanelLayout:
340
+ panel_graph = _layout_graph(validated_panel)
341
+ focus_node = main_graph.nodes[validated_panel.panel.focus_node_id]
342
+
343
+ content_width = panel_graph.content_bounds.width
344
+ content_height = panel_graph.content_bounds.height
345
+ label_width = len(validated_panel.panel.label) * theme.subtitle_font_size * 0.62
346
+
347
+ panel_width = max(content_width + theme.detail_panel_padding * 2, label_width + theme.detail_panel_padding * 2)
348
+ panel_height = (
349
+ content_height + theme.detail_panel_header_height + theme.detail_panel_padding * 2
350
+ )
351
+
352
+ desired_x = focus_node.center_x - panel_width / 2
353
+ max_x = max(theme.outer_margin, main_graph.width - theme.outer_margin - panel_width)
354
+ panel_x = round(max(theme.outer_margin, min(desired_x, max_x)), 2)
355
+ panel_y = round(main_graph.content_bounds.bottom + theme.detail_panel_gap, 2)
356
+
357
+ content_x = panel_x + theme.detail_panel_padding
358
+ content_y = panel_y + theme.detail_panel_header_height + theme.detail_panel_padding
359
+ shifted_graph = _shift_graph_layout(
360
+ panel_graph,
361
+ shift_x=content_x - panel_graph.content_bounds.x,
362
+ shift_y=content_y - panel_graph.content_bounds.y,
363
+ )
364
+
365
+ bounds = Bounds(
366
+ x=panel_x,
367
+ y=panel_y,
368
+ width=round(panel_width, 2),
369
+ height=round(panel_height, 2),
370
+ )
371
+
372
+ return DetailPanelLayout(
373
+ panel=validated_panel.panel,
374
+ graph=shifted_graph,
375
+ bounds=bounds,
376
+ stroke=validated_panel.panel.stroke or theme.detail_panel_stroke,
377
+ fill=validated_panel.panel.fill or theme.detail_panel_fill,
378
+ guide_lines=_build_detail_guides(focus_node, bounds, theme),
379
+ )
380
+
381
+
382
+ def _build_detail_guides(
383
+ focus_node: LayoutNode,
384
+ panel_bounds: Bounds,
385
+ theme: "Theme",
386
+ ) -> tuple[GuideLine, ...]:
387
+ metrics = resolve_theme_metrics(theme)
388
+ start_left = Point(
389
+ round(focus_node.x + focus_node.width * metrics.guide_anchor_ratio, 2),
390
+ round(focus_node.bounds.bottom, 2),
391
+ )
392
+ start_right = Point(
393
+ round(focus_node.x + focus_node.width * (1.0 - metrics.guide_anchor_ratio), 2),
394
+ round(focus_node.bounds.bottom, 2),
395
+ )
396
+ shoulder_inset = min(metrics.guide_shoulder_inset_cap, panel_bounds.width * metrics.guide_shoulder_inset_ratio)
397
+ end_left = Point(
398
+ round(panel_bounds.x + shoulder_inset, 2),
399
+ round(panel_bounds.y, 2),
400
+ )
401
+ end_right = Point(
402
+ round(panel_bounds.right - shoulder_inset, 2),
403
+ round(panel_bounds.y, 2),
404
+ )
405
+ flare_x = max(theme.route_track_gap * metrics.guide_flare_min_factor, focus_node.width * metrics.guide_flare_ratio)
406
+ bend_y = round(
407
+ start_left.y
408
+ + max(
409
+ metrics.guide_min_bend_drop,
410
+ min(
411
+ theme.detail_panel_gap * metrics.guide_bend_ratio,
412
+ (panel_bounds.y - start_left.y) * metrics.guide_bend_ratio,
413
+ ),
414
+ ),
415
+ 2,
416
+ )
417
+ mid_left = Point(
418
+ round(min(start_left.x - flare_x, (start_left.x + end_left.x) / 2), 2),
419
+ bend_y,
420
+ )
421
+ mid_right = Point(
422
+ round(max(start_right.x + flare_x, (start_right.x + end_right.x) / 2), 2),
423
+ bend_y,
424
+ )
425
+ guides = (
426
+ GuideLine(
427
+ points=(start_left, mid_left, end_left),
428
+ bounds=_line_bounds((start_left, mid_left, end_left), theme.detail_panel_guide_width),
429
+ stroke=theme.detail_panel_guide_color,
430
+ ),
431
+ GuideLine(
432
+ points=(start_right, mid_right, end_right),
433
+ bounds=_line_bounds((start_right, mid_right, end_right), theme.detail_panel_guide_width),
434
+ stroke=theme.detail_panel_guide_color,
435
+ ),
436
+ )
437
+ return guides
438
+
439
+
440
+ def _collect_graph_bounds(
441
+ nodes: dict[str, LayoutNode],
442
+ edges: tuple[RoutedEdge, ...],
443
+ overlays: tuple[GroupOverlay, ...],
444
+ ) -> Bounds:
445
+ bounds = [node.bounds for node in nodes.values()]
446
+ bounds.extend(route.bounds for route in edges)
447
+ bounds.extend(overlay.bounds for overlay in overlays)
448
+ return Bounds(
449
+ x=min(bound.x for bound in bounds),
450
+ y=min(bound.y for bound in bounds),
451
+ width=max(bound.right for bound in bounds) - min(bound.x for bound in bounds),
452
+ height=max(bound.bottom for bound in bounds) - min(bound.y for bound in bounds),
453
+ )
454
+
455
+
456
+ def _line_bounds(points: tuple[Point, ...], stroke_width: float) -> Bounds:
457
+ padding = max(1.0, stroke_width)
458
+ min_x = min(point.x for point in points) - padding
459
+ min_y = min(point.y for point in points) - padding
460
+ max_x = max(point.x for point in points) + padding
461
+ max_y = max(point.y for point in points) + padding
462
+ return Bounds(x=min_x, y=min_y, width=max_x - min_x, height=max_y - min_y)
463
+
464
+
465
+ def _shift_graph_layout(graph: GraphLayout, shift_x: float, shift_y: float) -> GraphLayout:
466
+ shifted_nodes = {node_id: _shift_node(node, shift_x, shift_y) for node_id, node in graph.nodes.items()}
467
+ shifted_edges = tuple(_shift_edge(route, shift_x, shift_y) for route in graph.edges)
468
+ shifted_groups = tuple(_shift_overlay(overlay, shift_x, shift_y) for overlay in graph.groups)
469
+ shifted_bounds = _collect_graph_bounds(shifted_nodes, shifted_edges, shifted_groups)
470
+ return GraphLayout(
471
+ nodes=shifted_nodes,
472
+ edges=shifted_edges,
473
+ groups=shifted_groups,
474
+ content_bounds=shifted_bounds,
475
+ width=round(max(graph.width + shift_x, shifted_bounds.right), 2),
476
+ height=round(max(graph.height + shift_y, shifted_bounds.bottom), 2),
477
+ )
478
+
479
+
480
+ def _shift_node(node: LayoutNode, shift_x: float, shift_y: float) -> LayoutNode:
481
+ return LayoutNode(
482
+ node=node.node,
483
+ rank=node.rank,
484
+ order=node.order,
485
+ component_id=node.component_id,
486
+ width=node.width,
487
+ height=node.height,
488
+ x=round(node.x + shift_x, 2),
489
+ y=round(node.y + shift_y, 2),
490
+ title_lines=node.title_lines,
491
+ subtitle_lines=node.subtitle_lines,
492
+ title_line_height=node.title_line_height,
493
+ subtitle_line_height=node.subtitle_line_height,
494
+ content_height=node.content_height,
495
+ )
496
+
497
+
498
+ def _shift_edge(route: RoutedEdge, shift_x: float, shift_y: float) -> RoutedEdge:
499
+ return RoutedEdge(
500
+ edge=route.edge,
501
+ points=tuple(Point(point.x + shift_x, point.y + shift_y) for point in route.points),
502
+ bounds=Bounds(
503
+ x=route.bounds.x + shift_x,
504
+ y=route.bounds.y + shift_y,
505
+ width=route.bounds.width,
506
+ height=route.bounds.height,
507
+ ),
508
+ stroke=route.stroke,
509
+ )
510
+
511
+
512
+ def _shift_overlay(overlay: GroupOverlay, shift_x: float, shift_y: float) -> GroupOverlay:
513
+ return GroupOverlay(
514
+ group=overlay.group,
515
+ bounds=Bounds(
516
+ x=overlay.bounds.x + shift_x,
517
+ y=overlay.bounds.y + shift_y,
518
+ width=overlay.bounds.width,
519
+ height=overlay.bounds.height,
520
+ ),
521
+ stroke=overlay.stroke,
522
+ fill=overlay.fill,
523
+ )
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  from collections import defaultdict, deque
4
4
 
5
5
  from frameplot.layout.types import LayoutNode, MeasuredText
6
+ from frameplot.theme import resolve_theme_metrics
6
7
 
7
8
 
8
9
  def place_nodes(
@@ -10,6 +11,8 @@ def place_nodes(
10
11
  measurements: dict[str, MeasuredText],
11
12
  ranks: dict[str, int],
12
13
  order: dict[str, int],
14
+ *,
15
+ rank_gap_overrides: dict[tuple[int, int], float] | None = None,
13
16
  ) -> dict[str, LayoutNode]:
14
17
  components = _weak_components(validated)
15
18
  placed: dict[str, LayoutNode] = {}
@@ -48,6 +51,8 @@ def place_nodes(
48
51
  component_nodes,
49
52
  local_ranks,
50
53
  min_rank=min_rank,
54
+ component_id=component_id,
55
+ overrides=rank_gap_overrides,
51
56
  )
52
57
 
53
58
  row_tops: dict[int, float] = {}
@@ -131,6 +136,17 @@ def _row_gap_after(
131
136
  crossing_counts = {row: 0 for row in row_ids[:-1]}
132
137
  component_set = set(component_nodes)
133
138
 
139
+ node_padding = {}
140
+ if hasattr(validated, "groups"):
141
+ for group in validated.groups:
142
+ for node_id in group.node_ids:
143
+ node_padding[node_id] = validated.theme.group_padding
144
+
145
+ row_padding: dict[int, float] = {}
146
+ for row in row_ids:
147
+ nodes_in_row = [n for n in component_nodes if rows[n] == row]
148
+ row_padding[row] = max((node_padding.get(n, 0.0) for n in nodes_in_row), default=0.0)
149
+
134
150
  for edge in validated.edges:
135
151
  if edge.source not in component_set or edge.target not in component_set:
136
152
  continue
@@ -142,11 +158,13 @@ def _row_gap_after(
142
158
  crossing_counts[row] = crossing_counts.get(row, 0) + 1
143
159
 
144
160
  gap_after: dict[int, float] = {}
145
- for row in row_ids[:-1]:
161
+ for i, row in enumerate(row_ids[:-1]):
146
162
  lanes = crossing_counts.get(row, 0)
163
+ padding_add = row_padding[row] + row_padding[row_ids[i+1]]
147
164
  gap_after[row] = round(
148
165
  _base_row_gap(validated.theme)
149
- + max(0, lanes - 1) * validated.theme.route_track_gap,
166
+ + max(0, lanes - 1) * validated.theme.route_track_gap
167
+ + padding_add,
150
168
  2,
151
169
  )
152
170
  return gap_after
@@ -158,28 +176,18 @@ def _rank_gap_after(
158
176
  ranks: dict[str, int],
159
177
  *,
160
178
  min_rank: int,
179
+ component_id: int,
180
+ overrides: dict[tuple[int, int], float] | None,
161
181
  ) -> dict[int, float]:
162
- component_set = set(component_nodes)
163
182
  normalized_ranks = {node_id: rank - min_rank for node_id, rank in ranks.items()}
164
183
  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
184
 
177
185
  gap_after: dict[int, float] = {}
186
+ compact_gap = _base_rank_gap(validated.theme)
178
187
  for rank in rank_ids[:-1]:
179
- lanes = crossing_counts.get(rank, 0)
188
+ override_key = (component_id, rank + min_rank)
180
189
  gap_after[rank] = round(
181
- _base_rank_gap(validated.theme)
182
- + max(0, lanes - 1) * validated.theme.route_track_gap,
190
+ max(compact_gap, overrides.get(override_key, compact_gap)) if overrides is not None else compact_gap,
183
191
  2,
184
192
  )
185
193
  return gap_after
@@ -190,4 +198,4 @@ def _base_row_gap(theme: "Theme") -> float:
190
198
 
191
199
 
192
200
  def _base_rank_gap(theme: "Theme") -> float:
193
- return max(theme.route_track_gap * 2.5, theme.rank_gap * 0.6)
201
+ return resolve_theme_metrics(theme).compact_rank_gap