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.
- frameplot-0.1.0/LICENSE +21 -0
- frameplot-0.1.0/PKG-INFO +132 -0
- frameplot-0.1.0/README.md +103 -0
- frameplot-0.1.0/pyproject.toml +45 -0
- frameplot-0.1.0/setup.cfg +4 -0
- frameplot-0.1.0/src/frameplot/__init__.py +7 -0
- frameplot-0.1.0/src/frameplot/api.py +61 -0
- frameplot-0.1.0/src/frameplot/layout/__init__.py +268 -0
- frameplot-0.1.0/src/frameplot/layout/order.py +169 -0
- frameplot-0.1.0/src/frameplot/layout/place.py +193 -0
- frameplot-0.1.0/src/frameplot/layout/rank.py +54 -0
- frameplot-0.1.0/src/frameplot/layout/route.py +1031 -0
- frameplot-0.1.0/src/frameplot/layout/scc.py +69 -0
- frameplot-0.1.0/src/frameplot/layout/text.py +74 -0
- frameplot-0.1.0/src/frameplot/layout/types.py +165 -0
- frameplot-0.1.0/src/frameplot/layout/validate.py +108 -0
- frameplot-0.1.0/src/frameplot/model.py +129 -0
- frameplot-0.1.0/src/frameplot/render/__init__.py +4 -0
- frameplot-0.1.0/src/frameplot/render/png.py +20 -0
- frameplot-0.1.0/src/frameplot/render/svg.py +306 -0
- frameplot-0.1.0/src/frameplot/theme.py +59 -0
- frameplot-0.1.0/src/frameplot.egg-info/PKG-INFO +132 -0
- frameplot-0.1.0/src/frameplot.egg-info/SOURCES.txt +25 -0
- frameplot-0.1.0/src/frameplot.egg-info/dependency_links.txt +1 -0
- frameplot-0.1.0/src/frameplot.egg-info/requires.txt +4 -0
- frameplot-0.1.0/src/frameplot.egg-info/top_level.txt +1 -0
- frameplot-0.1.0/tests/test_rendering.py +504 -0
frameplot-0.1.0/LICENSE
ADDED
|
@@ -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.
|
frameplot-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/frameplot/)
|
|
33
|
+
[](https://pypi.org/project/frameplot/)
|
|
34
|
+
[](https://github.com/smturtle2/frameplot/actions/workflows/workflow.yml)
|
|
35
|
+
[](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
|
+

|
|
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
|
+
[](https://pypi.org/project/frameplot/)
|
|
4
|
+
[](https://pypi.org/project/frameplot/)
|
|
5
|
+
[](https://github.com/smturtle2/frameplot/actions/workflows/workflow.yml)
|
|
6
|
+
[](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
|
+

|
|
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,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
|
+
)
|