frameplot 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- frameplot/__init__.py +7 -0
- frameplot/api.py +61 -0
- frameplot/layout/__init__.py +268 -0
- frameplot/layout/order.py +169 -0
- frameplot/layout/place.py +193 -0
- frameplot/layout/rank.py +54 -0
- frameplot/layout/route.py +1031 -0
- frameplot/layout/scc.py +69 -0
- frameplot/layout/text.py +74 -0
- frameplot/layout/types.py +165 -0
- frameplot/layout/validate.py +108 -0
- frameplot/model.py +129 -0
- frameplot/render/__init__.py +4 -0
- frameplot/render/png.py +20 -0
- frameplot/render/svg.py +306 -0
- frameplot/theme.py +59 -0
- frameplot-0.1.0.dist-info/METADATA +132 -0
- frameplot-0.1.0.dist-info/RECORD +21 -0
- frameplot-0.1.0.dist-info/WHEEL +5 -0
- frameplot-0.1.0.dist-info/licenses/LICENSE +21 -0
- frameplot-0.1.0.dist-info/top_level.txt +1 -0
frameplot/layout/scc.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from frameplot.layout.types import ValidatedPipeline
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(slots=True)
|
|
9
|
+
class SccResult:
|
|
10
|
+
components: tuple[tuple[str, ...], ...]
|
|
11
|
+
node_to_component: dict[str, int]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def strongly_connected_components(validated: ValidatedPipeline) -> SccResult:
|
|
15
|
+
adjacency: dict[str, list[str]] = {node.id: [] for node in validated.nodes}
|
|
16
|
+
for edge in validated.edges:
|
|
17
|
+
adjacency[edge.source].append(edge.target)
|
|
18
|
+
|
|
19
|
+
for neighbors in adjacency.values():
|
|
20
|
+
neighbors.sort(key=validated.node_index.__getitem__)
|
|
21
|
+
|
|
22
|
+
index = 0
|
|
23
|
+
stack: list[str] = []
|
|
24
|
+
active: set[str] = set()
|
|
25
|
+
indices: dict[str, int] = {}
|
|
26
|
+
lowlinks: dict[str, int] = {}
|
|
27
|
+
components: list[list[str]] = []
|
|
28
|
+
|
|
29
|
+
def visit(node_id: str) -> None:
|
|
30
|
+
nonlocal index
|
|
31
|
+
indices[node_id] = index
|
|
32
|
+
lowlinks[node_id] = index
|
|
33
|
+
index += 1
|
|
34
|
+
stack.append(node_id)
|
|
35
|
+
active.add(node_id)
|
|
36
|
+
|
|
37
|
+
for neighbor in adjacency[node_id]:
|
|
38
|
+
if neighbor not in indices:
|
|
39
|
+
visit(neighbor)
|
|
40
|
+
lowlinks[node_id] = min(lowlinks[node_id], lowlinks[neighbor])
|
|
41
|
+
elif neighbor in active:
|
|
42
|
+
lowlinks[node_id] = min(lowlinks[node_id], indices[neighbor])
|
|
43
|
+
|
|
44
|
+
if lowlinks[node_id] == indices[node_id]:
|
|
45
|
+
component: list[str] = []
|
|
46
|
+
while True:
|
|
47
|
+
member = stack.pop()
|
|
48
|
+
active.remove(member)
|
|
49
|
+
component.append(member)
|
|
50
|
+
if member == node_id:
|
|
51
|
+
break
|
|
52
|
+
component.sort(key=validated.node_index.__getitem__)
|
|
53
|
+
components.append(component)
|
|
54
|
+
|
|
55
|
+
for node in validated.nodes:
|
|
56
|
+
if node.id not in indices:
|
|
57
|
+
visit(node.id)
|
|
58
|
+
|
|
59
|
+
components.sort(key=lambda members: min(validated.node_index[node_id] for node_id in members))
|
|
60
|
+
|
|
61
|
+
node_to_component: dict[str, int] = {}
|
|
62
|
+
for component_id, component in enumerate(components):
|
|
63
|
+
for node_id in component:
|
|
64
|
+
node_to_component[node_id] = component_id
|
|
65
|
+
|
|
66
|
+
return SccResult(
|
|
67
|
+
components=tuple(tuple(component) for component in components),
|
|
68
|
+
node_to_component=node_to_component,
|
|
69
|
+
)
|
frameplot/layout/text.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import textwrap
|
|
5
|
+
|
|
6
|
+
from frameplot.layout.types import MeasuredText, ValidatedPipeline
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
TITLE_CHAR_WIDTH = 0.58
|
|
10
|
+
SUBTITLE_CHAR_WIDTH = 0.54
|
|
11
|
+
LINE_HEIGHT_RATIO = 1.25
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def measure_text(validated: ValidatedPipeline) -> dict[str, MeasuredText]:
|
|
15
|
+
theme = validated.theme
|
|
16
|
+
title_char_limit = max(10, int(theme.max_text_width / (theme.title_font_size * TITLE_CHAR_WIDTH)))
|
|
17
|
+
subtitle_char_limit = max(
|
|
18
|
+
10, int(theme.max_text_width / (theme.subtitle_font_size * SUBTITLE_CHAR_WIDTH))
|
|
19
|
+
)
|
|
20
|
+
measurements: dict[str, MeasuredText] = {}
|
|
21
|
+
|
|
22
|
+
for node in validated.nodes:
|
|
23
|
+
title_lines = tuple(_wrap(node.title, title_char_limit))
|
|
24
|
+
subtitle_lines = tuple(_wrap(node.subtitle, subtitle_char_limit)) if node.subtitle else ()
|
|
25
|
+
|
|
26
|
+
title_width = _max_line_width(title_lines, theme.title_font_size, TITLE_CHAR_WIDTH)
|
|
27
|
+
subtitle_width = _max_line_width(
|
|
28
|
+
subtitle_lines, theme.subtitle_font_size, SUBTITLE_CHAR_WIDTH
|
|
29
|
+
)
|
|
30
|
+
text_width = max(title_width, subtitle_width, theme.min_node_width - theme.node_padding_x * 2)
|
|
31
|
+
|
|
32
|
+
title_line_height = theme.title_font_size * LINE_HEIGHT_RATIO
|
|
33
|
+
subtitle_line_height = theme.subtitle_font_size * LINE_HEIGHT_RATIO
|
|
34
|
+
|
|
35
|
+
content_height = len(title_lines) * title_line_height
|
|
36
|
+
if subtitle_lines:
|
|
37
|
+
content_height += theme.inter_text_gap + len(subtitle_lines) * subtitle_line_height
|
|
38
|
+
|
|
39
|
+
auto_width = theme.node_padding_x * 2 + text_width
|
|
40
|
+
auto_height = theme.node_padding_y * 2 + content_height
|
|
41
|
+
|
|
42
|
+
width = max(node.width or 0.0, auto_width, theme.min_node_width)
|
|
43
|
+
height = max(node.height or 0.0, auto_height, theme.min_node_height)
|
|
44
|
+
|
|
45
|
+
measurements[node.id] = MeasuredText(
|
|
46
|
+
title_lines=title_lines,
|
|
47
|
+
subtitle_lines=subtitle_lines,
|
|
48
|
+
title_line_height=title_line_height,
|
|
49
|
+
subtitle_line_height=subtitle_line_height,
|
|
50
|
+
content_height=content_height,
|
|
51
|
+
width=round(width, 2),
|
|
52
|
+
height=round(height, 2),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
return measurements
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _wrap(value: str | None, width: int) -> list[str]:
|
|
59
|
+
if not value:
|
|
60
|
+
return []
|
|
61
|
+
wrapped = textwrap.wrap(
|
|
62
|
+
value,
|
|
63
|
+
width=width,
|
|
64
|
+
break_long_words=True,
|
|
65
|
+
break_on_hyphens=False,
|
|
66
|
+
drop_whitespace=True,
|
|
67
|
+
)
|
|
68
|
+
return wrapped or [value]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _max_line_width(lines: tuple[str, ...], font_size: float, ratio: float) -> float:
|
|
72
|
+
if not lines:
|
|
73
|
+
return 0.0
|
|
74
|
+
return math.ceil(max(len(line) for line in lines) * font_size * ratio)
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from frameplot.model import DetailPanel, Edge, Group, Node
|
|
6
|
+
from frameplot.theme import Theme
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(slots=True, frozen=True)
|
|
10
|
+
class Point:
|
|
11
|
+
x: float
|
|
12
|
+
y: float
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(slots=True, frozen=True)
|
|
16
|
+
class Bounds:
|
|
17
|
+
x: float
|
|
18
|
+
y: float
|
|
19
|
+
width: float
|
|
20
|
+
height: float
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def right(self) -> float:
|
|
24
|
+
return self.x + self.width
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def bottom(self) -> float:
|
|
28
|
+
return self.y + self.height
|
|
29
|
+
|
|
30
|
+
def expand(self, padding: float) -> "Bounds":
|
|
31
|
+
return Bounds(
|
|
32
|
+
x=self.x - padding,
|
|
33
|
+
y=self.y - padding,
|
|
34
|
+
width=self.width + padding * 2,
|
|
35
|
+
height=self.height + padding * 2,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def union_bounds(bounds: list[Bounds]) -> Bounds:
|
|
40
|
+
min_x = min(bound.x for bound in bounds)
|
|
41
|
+
min_y = min(bound.y for bound in bounds)
|
|
42
|
+
max_x = max(bound.right for bound in bounds)
|
|
43
|
+
max_y = max(bound.bottom for bound in bounds)
|
|
44
|
+
return Bounds(x=min_x, y=min_y, width=max_x - min_x, height=max_y - min_y)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(slots=True)
|
|
48
|
+
class ValidatedPipeline:
|
|
49
|
+
nodes: tuple[Node, ...]
|
|
50
|
+
edges: tuple[Edge, ...]
|
|
51
|
+
groups: tuple[Group, ...]
|
|
52
|
+
node_lookup: dict[str, Node]
|
|
53
|
+
edge_lookup: dict[str, Edge]
|
|
54
|
+
node_index: dict[str, int]
|
|
55
|
+
edge_index: dict[str, int]
|
|
56
|
+
theme: Theme
|
|
57
|
+
detail_panel: "ValidatedDetailPanel | None" = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(slots=True)
|
|
61
|
+
class ValidatedDetailPanel:
|
|
62
|
+
panel: DetailPanel
|
|
63
|
+
nodes: tuple[Node, ...]
|
|
64
|
+
edges: tuple[Edge, ...]
|
|
65
|
+
groups: tuple[Group, ...]
|
|
66
|
+
node_lookup: dict[str, Node]
|
|
67
|
+
edge_lookup: dict[str, Edge]
|
|
68
|
+
node_index: dict[str, int]
|
|
69
|
+
edge_index: dict[str, int]
|
|
70
|
+
theme: Theme
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(slots=True)
|
|
74
|
+
class MeasuredText:
|
|
75
|
+
title_lines: tuple[str, ...]
|
|
76
|
+
subtitle_lines: tuple[str, ...]
|
|
77
|
+
title_line_height: float
|
|
78
|
+
subtitle_line_height: float
|
|
79
|
+
content_height: float
|
|
80
|
+
width: float
|
|
81
|
+
height: float
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(slots=True)
|
|
85
|
+
class LayoutNode:
|
|
86
|
+
node: Node
|
|
87
|
+
rank: int
|
|
88
|
+
order: int
|
|
89
|
+
component_id: int
|
|
90
|
+
width: float
|
|
91
|
+
height: float
|
|
92
|
+
x: float
|
|
93
|
+
y: float
|
|
94
|
+
title_lines: tuple[str, ...]
|
|
95
|
+
subtitle_lines: tuple[str, ...]
|
|
96
|
+
title_line_height: float
|
|
97
|
+
subtitle_line_height: float
|
|
98
|
+
content_height: float
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def bounds(self) -> Bounds:
|
|
102
|
+
return Bounds(self.x, self.y, self.width, self.height)
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def right(self) -> float:
|
|
106
|
+
return self.x + self.width
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def center_x(self) -> float:
|
|
110
|
+
return self.x + self.width / 2
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def center_y(self) -> float:
|
|
114
|
+
return self.y + self.height / 2
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(slots=True)
|
|
118
|
+
class RoutedEdge:
|
|
119
|
+
edge: Edge
|
|
120
|
+
points: tuple[Point, ...]
|
|
121
|
+
bounds: Bounds
|
|
122
|
+
stroke: str
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(slots=True)
|
|
126
|
+
class GroupOverlay:
|
|
127
|
+
group: Group
|
|
128
|
+
bounds: Bounds
|
|
129
|
+
stroke: str
|
|
130
|
+
fill: str
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(slots=True)
|
|
134
|
+
class GuideLine:
|
|
135
|
+
points: tuple[Point, ...]
|
|
136
|
+
bounds: Bounds
|
|
137
|
+
stroke: str
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass(slots=True)
|
|
141
|
+
class GraphLayout:
|
|
142
|
+
nodes: dict[str, LayoutNode]
|
|
143
|
+
edges: tuple[RoutedEdge, ...]
|
|
144
|
+
groups: tuple[GroupOverlay, ...]
|
|
145
|
+
content_bounds: Bounds
|
|
146
|
+
width: float
|
|
147
|
+
height: float
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass(slots=True)
|
|
151
|
+
class DetailPanelLayout:
|
|
152
|
+
panel: DetailPanel
|
|
153
|
+
graph: GraphLayout
|
|
154
|
+
bounds: Bounds
|
|
155
|
+
stroke: str
|
|
156
|
+
fill: str
|
|
157
|
+
guide_lines: tuple[GuideLine, ...]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass(slots=True)
|
|
161
|
+
class LayoutResult:
|
|
162
|
+
main: GraphLayout
|
|
163
|
+
detail_panel: DetailPanelLayout | None
|
|
164
|
+
width: float
|
|
165
|
+
height: float
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from frameplot.layout.types import ValidatedDetailPanel, ValidatedPipeline
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def validate_pipeline(pipeline: "Pipeline") -> ValidatedPipeline:
|
|
7
|
+
from frameplot.api import Pipeline
|
|
8
|
+
|
|
9
|
+
if not isinstance(pipeline, Pipeline):
|
|
10
|
+
raise TypeError("pipeline must be a Pipeline instance.")
|
|
11
|
+
|
|
12
|
+
validated_graph = _validate_graph_parts(
|
|
13
|
+
nodes=tuple(pipeline.nodes),
|
|
14
|
+
edges=tuple(pipeline.edges),
|
|
15
|
+
groups=tuple(pipeline.groups),
|
|
16
|
+
owner_label="Pipeline",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
detail_panel = None
|
|
20
|
+
if pipeline.detail_panel is not None:
|
|
21
|
+
if pipeline.detail_panel.focus_node_id not in validated_graph["node_lookup"]:
|
|
22
|
+
raise ValueError(
|
|
23
|
+
f"DetailPanel {pipeline.detail_panel.id} references missing focus node: "
|
|
24
|
+
f"{pipeline.detail_panel.focus_node_id}"
|
|
25
|
+
)
|
|
26
|
+
validated_panel = _validate_graph_parts(
|
|
27
|
+
nodes=tuple(pipeline.detail_panel.nodes),
|
|
28
|
+
edges=tuple(pipeline.detail_panel.edges),
|
|
29
|
+
groups=tuple(pipeline.detail_panel.groups),
|
|
30
|
+
owner_label=f"DetailPanel {pipeline.detail_panel.id}",
|
|
31
|
+
)
|
|
32
|
+
detail_panel = ValidatedDetailPanel(
|
|
33
|
+
panel=pipeline.detail_panel,
|
|
34
|
+
nodes=validated_panel["nodes"],
|
|
35
|
+
edges=validated_panel["edges"],
|
|
36
|
+
groups=validated_panel["groups"],
|
|
37
|
+
node_lookup=validated_panel["node_lookup"],
|
|
38
|
+
edge_lookup=validated_panel["edge_lookup"],
|
|
39
|
+
node_index=validated_panel["node_index"],
|
|
40
|
+
edge_index=validated_panel["edge_index"],
|
|
41
|
+
theme=pipeline.theme,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
return ValidatedPipeline(
|
|
45
|
+
nodes=validated_graph["nodes"],
|
|
46
|
+
edges=validated_graph["edges"],
|
|
47
|
+
groups=validated_graph["groups"],
|
|
48
|
+
node_lookup=validated_graph["node_lookup"],
|
|
49
|
+
edge_lookup=validated_graph["edge_lookup"],
|
|
50
|
+
node_index=validated_graph["node_index"],
|
|
51
|
+
edge_index=validated_graph["edge_index"],
|
|
52
|
+
theme=pipeline.theme,
|
|
53
|
+
detail_panel=detail_panel,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _validate_graph_parts(
|
|
58
|
+
*,
|
|
59
|
+
nodes: tuple["Node", ...],
|
|
60
|
+
edges: tuple["Edge", ...],
|
|
61
|
+
groups: tuple["Group", ...],
|
|
62
|
+
owner_label: str,
|
|
63
|
+
) -> dict[str, object]:
|
|
64
|
+
if not nodes:
|
|
65
|
+
raise ValueError(f"{owner_label} must contain at least one node.")
|
|
66
|
+
|
|
67
|
+
node_lookup: dict[str, object] = {}
|
|
68
|
+
edge_lookup: dict[str, object] = {}
|
|
69
|
+
node_index: dict[str, int] = {}
|
|
70
|
+
edge_index: dict[str, int] = {}
|
|
71
|
+
|
|
72
|
+
for index, node in enumerate(nodes):
|
|
73
|
+
if node.id in node_lookup:
|
|
74
|
+
raise ValueError(f"Duplicate node id: {node.id}")
|
|
75
|
+
node_lookup[node.id] = node
|
|
76
|
+
node_index[node.id] = index
|
|
77
|
+
|
|
78
|
+
for index, edge in enumerate(edges):
|
|
79
|
+
if edge.id in edge_lookup:
|
|
80
|
+
raise ValueError(f"Duplicate edge id: {edge.id}")
|
|
81
|
+
if edge.source not in node_lookup:
|
|
82
|
+
raise ValueError(f"Edge {edge.id} references missing source node: {edge.source}")
|
|
83
|
+
if edge.target not in node_lookup:
|
|
84
|
+
raise ValueError(f"Edge {edge.id} references missing target node: {edge.target}")
|
|
85
|
+
edge_lookup[edge.id] = edge
|
|
86
|
+
edge_index[edge.id] = index
|
|
87
|
+
|
|
88
|
+
for group in groups:
|
|
89
|
+
missing_nodes = [node_id for node_id in group.node_ids if node_id not in node_lookup]
|
|
90
|
+
missing_edges = [edge_id for edge_id in group.edge_ids if edge_id not in edge_lookup]
|
|
91
|
+
if missing_nodes:
|
|
92
|
+
raise ValueError(
|
|
93
|
+
f"Group {group.id} references missing node ids: {', '.join(sorted(missing_nodes))}"
|
|
94
|
+
)
|
|
95
|
+
if missing_edges:
|
|
96
|
+
raise ValueError(
|
|
97
|
+
f"Group {group.id} references missing edge ids: {', '.join(sorted(missing_edges))}"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
"nodes": nodes,
|
|
102
|
+
"edges": edges,
|
|
103
|
+
"groups": groups,
|
|
104
|
+
"node_lookup": node_lookup,
|
|
105
|
+
"edge_lookup": edge_lookup,
|
|
106
|
+
"node_index": node_index,
|
|
107
|
+
"edge_index": edge_index,
|
|
108
|
+
}
|
frameplot/model.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Public data models used to describe frameplot diagrams."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(slots=True)
|
|
10
|
+
class Node:
|
|
11
|
+
"""Describe a rendered node in the main graph or a detail panel.
|
|
12
|
+
|
|
13
|
+
`id` and `title` are required. Optional colors override the active theme for
|
|
14
|
+
this node only, and `width` or `height` can pin the rendered box size.
|
|
15
|
+
Surrounding whitespace is stripped from text fields.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
id: str
|
|
19
|
+
title: str
|
|
20
|
+
subtitle: str | None = None
|
|
21
|
+
fill: str | None = None
|
|
22
|
+
stroke: str | None = None
|
|
23
|
+
text_color: str | None = None
|
|
24
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
25
|
+
width: float | None = None
|
|
26
|
+
height: float | None = None
|
|
27
|
+
|
|
28
|
+
def __post_init__(self) -> None:
|
|
29
|
+
self.id = self.id.strip()
|
|
30
|
+
self.title = self.title.strip()
|
|
31
|
+
if self.subtitle is not None:
|
|
32
|
+
self.subtitle = self.subtitle.strip() or None
|
|
33
|
+
if not self.id:
|
|
34
|
+
raise ValueError("Node id must not be empty.")
|
|
35
|
+
if not self.title:
|
|
36
|
+
raise ValueError("Node title must not be empty.")
|
|
37
|
+
if self.width is not None and self.width <= 0:
|
|
38
|
+
raise ValueError("Node width must be positive.")
|
|
39
|
+
if self.height is not None and self.height <= 0:
|
|
40
|
+
raise ValueError("Node height must be positive.")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(slots=True)
|
|
44
|
+
class Edge:
|
|
45
|
+
"""Connect two nodes with a directional edge.
|
|
46
|
+
|
|
47
|
+
`source` and `target` must reference node identifiers in the same graph.
|
|
48
|
+
Set `dashed=True` to draw a secondary or conditional flow.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
id: str
|
|
52
|
+
source: str
|
|
53
|
+
target: str
|
|
54
|
+
color: str | None = None
|
|
55
|
+
dashed: bool = False
|
|
56
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
57
|
+
|
|
58
|
+
def __post_init__(self) -> None:
|
|
59
|
+
self.id = self.id.strip()
|
|
60
|
+
self.source = self.source.strip()
|
|
61
|
+
self.target = self.target.strip()
|
|
62
|
+
if not self.id:
|
|
63
|
+
raise ValueError("Edge id must not be empty.")
|
|
64
|
+
if not self.source or not self.target:
|
|
65
|
+
raise ValueError("Edge source and target must not be empty.")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(slots=True)
|
|
69
|
+
class Group:
|
|
70
|
+
"""Highlight related nodes or edges with a labeled overlay.
|
|
71
|
+
|
|
72
|
+
Groups are visual only. They do not constrain layout or change routing.
|
|
73
|
+
At least one node or edge reference is required.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
id: str
|
|
77
|
+
label: str
|
|
78
|
+
node_ids: tuple[str, ...]
|
|
79
|
+
edge_ids: tuple[str, ...] = ()
|
|
80
|
+
stroke: str | None = None
|
|
81
|
+
fill: str | None = None
|
|
82
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
83
|
+
|
|
84
|
+
def __post_init__(self) -> None:
|
|
85
|
+
self.id = self.id.strip()
|
|
86
|
+
self.label = self.label.strip()
|
|
87
|
+
self.node_ids = tuple(node_id.strip() for node_id in self.node_ids if node_id.strip())
|
|
88
|
+
self.edge_ids = tuple(edge_id.strip() for edge_id in self.edge_ids if edge_id.strip())
|
|
89
|
+
if not self.id:
|
|
90
|
+
raise ValueError("Group id must not be empty.")
|
|
91
|
+
if not self.label:
|
|
92
|
+
raise ValueError("Group label must not be empty.")
|
|
93
|
+
if not self.node_ids and not self.edge_ids:
|
|
94
|
+
raise ValueError("Group must reference at least one node or edge.")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(slots=True)
|
|
98
|
+
class DetailPanel:
|
|
99
|
+
"""Expand a focus node into a lower inset with its own mini-graph.
|
|
100
|
+
|
|
101
|
+
The focus node must exist in the main pipeline graph, and the panel must
|
|
102
|
+
contain at least one node.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
id: str
|
|
106
|
+
focus_node_id: str
|
|
107
|
+
label: str
|
|
108
|
+
nodes: tuple[Node, ...]
|
|
109
|
+
edges: tuple[Edge, ...]
|
|
110
|
+
groups: tuple[Group, ...] = ()
|
|
111
|
+
stroke: str | None = None
|
|
112
|
+
fill: str | None = None
|
|
113
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
114
|
+
|
|
115
|
+
def __post_init__(self) -> None:
|
|
116
|
+
self.id = self.id.strip()
|
|
117
|
+
self.focus_node_id = self.focus_node_id.strip()
|
|
118
|
+
self.label = self.label.strip()
|
|
119
|
+
self.nodes = tuple(self.nodes)
|
|
120
|
+
self.edges = tuple(self.edges)
|
|
121
|
+
self.groups = tuple(self.groups)
|
|
122
|
+
if not self.id:
|
|
123
|
+
raise ValueError("DetailPanel id must not be empty.")
|
|
124
|
+
if not self.focus_node_id:
|
|
125
|
+
raise ValueError("DetailPanel focus_node_id must not be empty.")
|
|
126
|
+
if not self.label:
|
|
127
|
+
raise ValueError("DetailPanel label must not be empty.")
|
|
128
|
+
if not self.nodes:
|
|
129
|
+
raise ValueError("DetailPanel must contain at least one node.")
|
frameplot/render/png.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def svg_to_png_bytes(svg: str) -> bytes:
|
|
7
|
+
"""Convert an SVG string into PNG bytes with CairoSVG."""
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
import cairosvg
|
|
11
|
+
except ImportError as exc: # pragma: no cover - dependency issue
|
|
12
|
+
raise RuntimeError("CairoSVG is required for PNG export.") from exc
|
|
13
|
+
return cairosvg.svg2png(bytestring=svg.encode("utf-8"))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def save_png(svg: str, path: str | Path) -> None:
|
|
17
|
+
"""Write PNG bytes generated from `svg` to `path`."""
|
|
18
|
+
|
|
19
|
+
output = svg_to_png_bytes(svg)
|
|
20
|
+
Path(path).write_bytes(output)
|