frameplot 0.1.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Small Turtle 2
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,132 @@
1
+ Metadata-Version: 2.4
2
+ Name: frameplot
3
+ Version: 0.1.0
4
+ Summary: Turn Python-defined pipeline graphs into presentation-ready SVG and PNG diagrams.
5
+ Author: Small Turtle 2
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/smturtle2/frameplot
8
+ Project-URL: Repository, https://github.com/smturtle2/frameplot
9
+ Project-URL: Issues, https://github.com/smturtle2/frameplot/issues
10
+ Project-URL: Releases, https://github.com/smturtle2/frameplot/releases
11
+ Keywords: diagram,graph,pipeline,png,svg,visualization
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Multimedia :: Graphics
20
+ Classifier: Topic :: Scientific/Engineering :: Visualization
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Requires-Python: >=3.11
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: CairoSVG>=2.7
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=8.0; extra == "dev"
28
+ Dynamic: license-file
29
+
30
+ # frameplot
31
+
32
+ [![PyPI version](https://img.shields.io/pypi/v/frameplot.svg)](https://pypi.org/project/frameplot/)
33
+ [![Python versions](https://img.shields.io/pypi/pyversions/frameplot.svg)](https://pypi.org/project/frameplot/)
34
+ [![CI](https://github.com/smturtle2/frameplot/actions/workflows/workflow.yml/badge.svg?branch=main)](https://github.com/smturtle2/frameplot/actions/workflows/workflow.yml)
35
+ [![License](https://img.shields.io/github/license/smturtle2/frameplot)](https://github.com/smturtle2/frameplot/blob/main/LICENSE)
36
+
37
+ Turn Python-defined pipeline graphs into presentation-ready SVG and PNG diagrams.
38
+
39
+ [한국어 README](https://github.com/smturtle2/frameplot/blob/main/README.ko.md)
40
+
41
+ ![frameplot hero image](https://raw.githubusercontent.com/smturtle2/frameplot/main/docs/assets/frameplot-hero.png)
42
+
43
+ `frameplot` is a compact Python library for rendering left-to-right pipeline diagrams with clean defaults. Define nodes, edges, groups, and optional detail panels in plain Python, then export polished SVG for documentation or PNG for slides and papers.
44
+
45
+ ## Why frameplot?
46
+
47
+ - Clean left-to-right layout for architecture diagrams, data pipelines, and model overviews
48
+ - SVG-first output with optional PNG export through CairoSVG
49
+ - Detail panels for expanding a summary node into a lower inset mini-graph
50
+ - Themeable typography, spacing, colors, and routing defaults
51
+ - Deterministic rendering from simple dataclass-based inputs
52
+
53
+ ## Install
54
+
55
+ ```bash
56
+ python -m pip install frameplot
57
+ ```
58
+
59
+ PNG export depends on CairoSVG and may require Cairo or libffi packages from the host OS.
60
+
61
+ ## Quickstart
62
+
63
+ ```python
64
+ from frameplot import Edge, Group, Node, Pipeline
65
+
66
+ pipeline = Pipeline(
67
+ nodes=[
68
+ Node("start", "Start", "Receive request"),
69
+ Node("fetch", "Fetch Data", "Load source tables"),
70
+ Node("retry", "Retry", "Loop on transient failure", fill="#FFF2CC"),
71
+ Node("done", "Done", "Return result", fill="#D9EAD3"),
72
+ ],
73
+ edges=[
74
+ Edge("e1", "start", "fetch"),
75
+ Edge("e2", "fetch", "retry", dashed=True),
76
+ Edge("e3", "retry", "fetch", color="#C0504D"),
77
+ Edge("e4", "fetch", "done"),
78
+ ],
79
+ groups=[
80
+ Group("g1", "Execution", ["start", "fetch", "retry"], edge_ids=["e2"]),
81
+ ],
82
+ )
83
+
84
+ svg = pipeline.to_svg()
85
+ pipeline.save_svg("pipeline.svg")
86
+ pipeline.save_png("pipeline.png")
87
+ ```
88
+
89
+ ## Public API
90
+
91
+ Top-level imports are the supported public API:
92
+
93
+ - `Node(id, title, subtitle=None, fill=None, stroke=None, text_color=None, metadata=None, width=None, height=None)`
94
+ - `Edge(id, source, target, color=None, dashed=False, metadata=None)`
95
+ - `Group(id, label, node_ids, edge_ids=(), stroke=None, fill=None, metadata=None)`
96
+ - `DetailPanel(id, focus_node_id, label, nodes, edges, groups=(), stroke=None, fill=None, metadata=None)`
97
+ - `Theme(...)`
98
+ - `Pipeline(nodes, edges, groups=(), detail_panel=None, theme=None)`
99
+
100
+ `Pipeline` exposes:
101
+
102
+ - `to_svg() -> str`
103
+ - `save_svg(path) -> None`
104
+ - `to_png_bytes() -> bytes`
105
+ - `save_png(path) -> None`
106
+
107
+ ## Advanced Example
108
+
109
+ The hero image above is generated from [`examples/sar_backbone_example.py`](https://github.com/smturtle2/frameplot/blob/main/examples/sar_backbone_example.py), which demonstrates:
110
+
111
+ - custom `Theme` values
112
+ - split decoder branches
113
+ - grouped overlays
114
+ - a `DetailPanel` attached to a summary node
115
+
116
+ ## Design Notes
117
+
118
+ - Layout is intentionally left-to-right in v0.x.
119
+ - Edge labels are not supported yet.
120
+ - Groups are visual overlays and do not constrain layout.
121
+ - Detail panels render as separate lower insets attached to a focus node in the main flow.
122
+
123
+ ## Development
124
+
125
+ ```bash
126
+ python -m venv .venv
127
+ source .venv/bin/activate
128
+ python -m pip install -e '.[dev]'
129
+ python -m pytest -q
130
+ ```
131
+
132
+ 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`.
@@ -0,0 +1,103 @@
1
+ # frameplot
2
+
3
+ [![PyPI version](https://img.shields.io/pypi/v/frameplot.svg)](https://pypi.org/project/frameplot/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/frameplot.svg)](https://pypi.org/project/frameplot/)
5
+ [![CI](https://github.com/smturtle2/frameplot/actions/workflows/workflow.yml/badge.svg?branch=main)](https://github.com/smturtle2/frameplot/actions/workflows/workflow.yml)
6
+ [![License](https://img.shields.io/github/license/smturtle2/frameplot)](https://github.com/smturtle2/frameplot/blob/main/LICENSE)
7
+
8
+ Turn Python-defined pipeline graphs into presentation-ready SVG and PNG diagrams.
9
+
10
+ [한국어 README](https://github.com/smturtle2/frameplot/blob/main/README.ko.md)
11
+
12
+ ![frameplot hero image](https://raw.githubusercontent.com/smturtle2/frameplot/main/docs/assets/frameplot-hero.png)
13
+
14
+ `frameplot` is a compact Python library for rendering left-to-right pipeline diagrams with clean defaults. Define nodes, edges, groups, and optional detail panels in plain Python, then export polished SVG for documentation or PNG for slides and papers.
15
+
16
+ ## Why frameplot?
17
+
18
+ - Clean left-to-right layout for architecture diagrams, data pipelines, and model overviews
19
+ - SVG-first output with optional PNG export through CairoSVG
20
+ - Detail panels for expanding a summary node into a lower inset mini-graph
21
+ - Themeable typography, spacing, colors, and routing defaults
22
+ - Deterministic rendering from simple dataclass-based inputs
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ python -m pip install frameplot
28
+ ```
29
+
30
+ PNG export depends on CairoSVG and may require Cairo or libffi packages from the host OS.
31
+
32
+ ## Quickstart
33
+
34
+ ```python
35
+ from frameplot import Edge, Group, Node, Pipeline
36
+
37
+ pipeline = Pipeline(
38
+ nodes=[
39
+ Node("start", "Start", "Receive request"),
40
+ Node("fetch", "Fetch Data", "Load source tables"),
41
+ Node("retry", "Retry", "Loop on transient failure", fill="#FFF2CC"),
42
+ Node("done", "Done", "Return result", fill="#D9EAD3"),
43
+ ],
44
+ edges=[
45
+ Edge("e1", "start", "fetch"),
46
+ Edge("e2", "fetch", "retry", dashed=True),
47
+ Edge("e3", "retry", "fetch", color="#C0504D"),
48
+ Edge("e4", "fetch", "done"),
49
+ ],
50
+ groups=[
51
+ Group("g1", "Execution", ["start", "fetch", "retry"], edge_ids=["e2"]),
52
+ ],
53
+ )
54
+
55
+ svg = pipeline.to_svg()
56
+ pipeline.save_svg("pipeline.svg")
57
+ pipeline.save_png("pipeline.png")
58
+ ```
59
+
60
+ ## Public API
61
+
62
+ Top-level imports are the supported public API:
63
+
64
+ - `Node(id, title, subtitle=None, fill=None, stroke=None, text_color=None, metadata=None, width=None, height=None)`
65
+ - `Edge(id, source, target, color=None, dashed=False, metadata=None)`
66
+ - `Group(id, label, node_ids, edge_ids=(), stroke=None, fill=None, metadata=None)`
67
+ - `DetailPanel(id, focus_node_id, label, nodes, edges, groups=(), stroke=None, fill=None, metadata=None)`
68
+ - `Theme(...)`
69
+ - `Pipeline(nodes, edges, groups=(), detail_panel=None, theme=None)`
70
+
71
+ `Pipeline` exposes:
72
+
73
+ - `to_svg() -> str`
74
+ - `save_svg(path) -> None`
75
+ - `to_png_bytes() -> bytes`
76
+ - `save_png(path) -> None`
77
+
78
+ ## Advanced Example
79
+
80
+ The hero image above is generated from [`examples/sar_backbone_example.py`](https://github.com/smturtle2/frameplot/blob/main/examples/sar_backbone_example.py), which demonstrates:
81
+
82
+ - custom `Theme` values
83
+ - split decoder branches
84
+ - grouped overlays
85
+ - a `DetailPanel` attached to a summary node
86
+
87
+ ## Design Notes
88
+
89
+ - Layout is intentionally left-to-right in v0.x.
90
+ - Edge labels are not supported yet.
91
+ - Groups are visual overlays and do not constrain layout.
92
+ - Detail panels render as separate lower insets attached to a focus node in the main flow.
93
+
94
+ ## Development
95
+
96
+ ```bash
97
+ python -m venv .venv
98
+ source .venv/bin/activate
99
+ python -m pip install -e '.[dev]'
100
+ python -m pytest -q
101
+ ```
102
+
103
+ 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`.
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["setuptools>=80", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "frameplot"
7
+ version = "0.1.0"
8
+ description = "Turn Python-defined pipeline graphs into presentation-ready SVG and PNG diagrams."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ authors = [{ name = "Small Turtle 2" }]
13
+ keywords = ["diagram", "graph", "pipeline", "png", "svg", "visualization"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "Operating System :: OS Independent",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Topic :: Multimedia :: Graphics",
23
+ "Topic :: Scientific/Engineering :: Visualization",
24
+ "Topic :: Software Development :: Libraries :: Python Modules",
25
+ ]
26
+ dependencies = ["CairoSVG>=2.7"]
27
+
28
+ [project.urls]
29
+ Homepage = "https://github.com/smturtle2/frameplot"
30
+ Repository = "https://github.com/smturtle2/frameplot"
31
+ Issues = "https://github.com/smturtle2/frameplot/issues"
32
+ Releases = "https://github.com/smturtle2/frameplot/releases"
33
+
34
+ [project.optional-dependencies]
35
+ dev = ["pytest>=8.0"]
36
+
37
+ [tool.setuptools]
38
+ package-dir = {"" = "src"}
39
+
40
+ [tool.setuptools.packages.find]
41
+ where = ["src"]
42
+
43
+ [tool.pytest.ini_options]
44
+ pythonpath = ["src"]
45
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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"]
@@ -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
+ )