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/__init__.py
ADDED
|
@@ -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"]
|
frameplot/api.py
ADDED
|
@@ -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
|
+
)
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict, deque
|
|
4
|
+
|
|
5
|
+
from frameplot.layout.types import ValidatedDetailPanel, ValidatedPipeline
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def order_nodes(
|
|
9
|
+
validated: ValidatedPipeline | ValidatedDetailPanel,
|
|
10
|
+
ranks: dict[str, int],
|
|
11
|
+
) -> dict[str, int]:
|
|
12
|
+
nodes_by_rank: dict[int, list[str]] = defaultdict(list)
|
|
13
|
+
for node in validated.nodes:
|
|
14
|
+
nodes_by_rank[ranks[node.id]].append(node.id)
|
|
15
|
+
|
|
16
|
+
for rank_nodes in nodes_by_rank.values():
|
|
17
|
+
rank_nodes.sort(key=validated.node_index.__getitem__)
|
|
18
|
+
|
|
19
|
+
order = {
|
|
20
|
+
node_id: index
|
|
21
|
+
for rank_nodes in nodes_by_rank.values()
|
|
22
|
+
for index, node_id in enumerate(rank_nodes)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
incoming, outgoing = _forward_neighbors(validated, ranks)
|
|
26
|
+
ordered_ranks = sorted(nodes_by_rank)
|
|
27
|
+
|
|
28
|
+
for _ in range(4):
|
|
29
|
+
for rank in ordered_ranks[1:]:
|
|
30
|
+
_resort_rank(nodes_by_rank[rank], incoming, order, validated)
|
|
31
|
+
_refresh_order(nodes_by_rank[rank], order)
|
|
32
|
+
for rank in reversed(ordered_ranks[:-1]):
|
|
33
|
+
_resort_rank(nodes_by_rank[rank], outgoing, order, validated)
|
|
34
|
+
_refresh_order(nodes_by_rank[rank], order)
|
|
35
|
+
|
|
36
|
+
if getattr(validated, "detail_panel", None) is not None:
|
|
37
|
+
_apply_detail_panel_bias(validated, nodes_by_rank, ranks, order)
|
|
38
|
+
|
|
39
|
+
return order
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _forward_neighbors(
|
|
43
|
+
validated: ValidatedPipeline | ValidatedDetailPanel,
|
|
44
|
+
ranks: dict[str, int],
|
|
45
|
+
) -> tuple[dict[str, list[str]], dict[str, list[str]]]:
|
|
46
|
+
incoming: dict[str, list[str]] = defaultdict(list)
|
|
47
|
+
outgoing: dict[str, list[str]] = defaultdict(list)
|
|
48
|
+
|
|
49
|
+
for edge in validated.edges:
|
|
50
|
+
if ranks[edge.source] < ranks[edge.target]:
|
|
51
|
+
incoming[edge.target].append(edge.source)
|
|
52
|
+
outgoing[edge.source].append(edge.target)
|
|
53
|
+
|
|
54
|
+
return incoming, outgoing
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _resort_rank(
|
|
58
|
+
rank_nodes: list[str],
|
|
59
|
+
neighbors: dict[str, list[str]],
|
|
60
|
+
order: dict[str, int],
|
|
61
|
+
validated: ValidatedPipeline | ValidatedDetailPanel,
|
|
62
|
+
) -> None:
|
|
63
|
+
rank_nodes.sort(
|
|
64
|
+
key=lambda node_id: (
|
|
65
|
+
_barycenter(neighbors.get(node_id, ()), order, validated.node_index[node_id]),
|
|
66
|
+
validated.node_index[node_id],
|
|
67
|
+
node_id,
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _barycenter(neighbors: list[str] | tuple[str, ...], order: dict[str, int], fallback: int) -> float:
|
|
73
|
+
if not neighbors:
|
|
74
|
+
return float(fallback)
|
|
75
|
+
return sum(order[neighbor] for neighbor in neighbors) / len(neighbors)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _refresh_order(rank_nodes: list[str], order: dict[str, int]) -> None:
|
|
79
|
+
for index, node_id in enumerate(rank_nodes):
|
|
80
|
+
order[node_id] = index
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _apply_detail_panel_bias(
|
|
84
|
+
validated: ValidatedPipeline,
|
|
85
|
+
nodes_by_rank: dict[int, list[str]],
|
|
86
|
+
ranks: dict[str, int],
|
|
87
|
+
order: dict[str, int],
|
|
88
|
+
) -> None:
|
|
89
|
+
focus_node_id = validated.detail_panel.panel.focus_node_id
|
|
90
|
+
focus_path_nodes = _detail_focus_path_nodes(validated, ranks, focus_node_id)
|
|
91
|
+
if not focus_path_nodes:
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
component_nodes = _weak_component_nodes(validated, focus_node_id)
|
|
95
|
+
component_ranks = sorted({ranks[node_id] for node_id in component_nodes})
|
|
96
|
+
max_row = max(len(nodes_by_rank[rank]) for rank in component_ranks) - 1
|
|
97
|
+
|
|
98
|
+
for rank in component_ranks:
|
|
99
|
+
rank_nodes = nodes_by_rank[rank]
|
|
100
|
+
rank_focus_nodes = [node_id for node_id in rank_nodes if node_id in focus_path_nodes]
|
|
101
|
+
if not rank_focus_nodes:
|
|
102
|
+
_refresh_order(rank_nodes, order)
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
rank_other_nodes = [node_id for node_id in rank_nodes if node_id not in focus_path_nodes]
|
|
106
|
+
start_row = max_row - len(rank_focus_nodes) + 1
|
|
107
|
+
|
|
108
|
+
for index, node_id in enumerate(rank_other_nodes):
|
|
109
|
+
order[node_id] = index
|
|
110
|
+
for index, node_id in enumerate(rank_focus_nodes):
|
|
111
|
+
order[node_id] = start_row + index
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _detail_focus_path_nodes(
|
|
115
|
+
validated: ValidatedPipeline,
|
|
116
|
+
ranks: dict[str, int],
|
|
117
|
+
focus_node_id: str,
|
|
118
|
+
) -> set[str]:
|
|
119
|
+
forward_outgoing: dict[str, list[str]] = defaultdict(list)
|
|
120
|
+
forward_incoming: dict[str, list[str]] = defaultdict(list)
|
|
121
|
+
|
|
122
|
+
for edge in validated.edges:
|
|
123
|
+
if edge.dashed or ranks[edge.source] >= ranks[edge.target]:
|
|
124
|
+
continue
|
|
125
|
+
forward_outgoing[edge.source].append(edge.target)
|
|
126
|
+
forward_incoming[edge.target].append(edge.source)
|
|
127
|
+
|
|
128
|
+
ancestors = _reachable(focus_node_id, forward_incoming)
|
|
129
|
+
descendants = _reachable(focus_node_id, forward_outgoing)
|
|
130
|
+
focus_path = ancestors | descendants | {focus_node_id}
|
|
131
|
+
|
|
132
|
+
if focus_path:
|
|
133
|
+
return focus_path
|
|
134
|
+
return {focus_node_id}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _reachable(start: str, adjacency: dict[str, list[str]]) -> set[str]:
|
|
138
|
+
seen: set[str] = set()
|
|
139
|
+
queue = deque([start])
|
|
140
|
+
|
|
141
|
+
while queue:
|
|
142
|
+
node_id = queue.popleft()
|
|
143
|
+
for neighbor in adjacency.get(node_id, ()):
|
|
144
|
+
if neighbor in seen:
|
|
145
|
+
continue
|
|
146
|
+
seen.add(neighbor)
|
|
147
|
+
queue.append(neighbor)
|
|
148
|
+
|
|
149
|
+
return seen
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _weak_component_nodes(validated: ValidatedPipeline, start_node_id: str) -> set[str]:
|
|
153
|
+
adjacency: dict[str, set[str]] = {node.id: set() for node in validated.nodes}
|
|
154
|
+
for edge in validated.edges:
|
|
155
|
+
adjacency[edge.source].add(edge.target)
|
|
156
|
+
adjacency[edge.target].add(edge.source)
|
|
157
|
+
|
|
158
|
+
seen = {start_node_id}
|
|
159
|
+
queue = deque([start_node_id])
|
|
160
|
+
|
|
161
|
+
while queue:
|
|
162
|
+
node_id = queue.popleft()
|
|
163
|
+
for neighbor in sorted(adjacency[node_id], key=validated.node_index.__getitem__):
|
|
164
|
+
if neighbor in seen:
|
|
165
|
+
continue
|
|
166
|
+
seen.add(neighbor)
|
|
167
|
+
queue.append(neighbor)
|
|
168
|
+
|
|
169
|
+
return seen
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict, deque
|
|
4
|
+
|
|
5
|
+
from frameplot.layout.types import LayoutNode, MeasuredText
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def place_nodes(
|
|
9
|
+
validated: "ValidatedPipeline | ValidatedDetailPanel",
|
|
10
|
+
measurements: dict[str, MeasuredText],
|
|
11
|
+
ranks: dict[str, int],
|
|
12
|
+
order: dict[str, int],
|
|
13
|
+
) -> dict[str, LayoutNode]:
|
|
14
|
+
components = _weak_components(validated)
|
|
15
|
+
placed: dict[str, LayoutNode] = {}
|
|
16
|
+
current_top = validated.theme.outer_margin
|
|
17
|
+
|
|
18
|
+
for component_id, component_nodes in enumerate(components):
|
|
19
|
+
local_ranks = {node_id: ranks[node_id] for node_id in component_nodes}
|
|
20
|
+
local_rows = {node_id: order[node_id] for node_id in component_nodes}
|
|
21
|
+
min_rank = min(local_ranks.values())
|
|
22
|
+
|
|
23
|
+
nodes_by_rank: dict[int, list[str]] = defaultdict(list)
|
|
24
|
+
for node_id in component_nodes:
|
|
25
|
+
nodes_by_rank[local_ranks[node_id] - min_rank].append(node_id)
|
|
26
|
+
|
|
27
|
+
for rank_nodes in nodes_by_rank.values():
|
|
28
|
+
rank_nodes.sort(key=lambda node_id: (local_rows[node_id], validated.node_index[node_id]))
|
|
29
|
+
|
|
30
|
+
component_rows = sorted({local_rows[node_id] for node_id in component_nodes})
|
|
31
|
+
ranks_in_component = sorted(nodes_by_rank)
|
|
32
|
+
|
|
33
|
+
row_heights = {
|
|
34
|
+
row: max(measurements[node_id].height for node_id in component_nodes if local_rows[node_id] == row)
|
|
35
|
+
for row in component_rows
|
|
36
|
+
}
|
|
37
|
+
row_gap_after = _row_gap_after(
|
|
38
|
+
validated,
|
|
39
|
+
component_nodes,
|
|
40
|
+
local_rows,
|
|
41
|
+
)
|
|
42
|
+
column_widths = {
|
|
43
|
+
rank: max(measurements[node_id].width for node_id in nodes)
|
|
44
|
+
for rank, nodes in nodes_by_rank.items()
|
|
45
|
+
}
|
|
46
|
+
rank_gap_after = _rank_gap_after(
|
|
47
|
+
validated,
|
|
48
|
+
component_nodes,
|
|
49
|
+
local_ranks,
|
|
50
|
+
min_rank=min_rank,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
row_tops: dict[int, float] = {}
|
|
54
|
+
cursor_y = current_top
|
|
55
|
+
for index, row in enumerate(component_rows):
|
|
56
|
+
row_tops[row] = cursor_y
|
|
57
|
+
cursor_y += row_heights[row]
|
|
58
|
+
if index < len(component_rows) - 1:
|
|
59
|
+
cursor_y += row_gap_after[row]
|
|
60
|
+
component_height = cursor_y - current_top
|
|
61
|
+
|
|
62
|
+
x_positions: dict[int, float] = {}
|
|
63
|
+
cursor_x = validated.theme.outer_margin
|
|
64
|
+
for rank in ranks_in_component:
|
|
65
|
+
x_positions[rank] = cursor_x
|
|
66
|
+
cursor_x += column_widths[rank]
|
|
67
|
+
if rank != ranks_in_component[-1]:
|
|
68
|
+
cursor_x += rank_gap_after[rank]
|
|
69
|
+
|
|
70
|
+
for rank in ranks_in_component:
|
|
71
|
+
for node_id in nodes_by_rank[rank]:
|
|
72
|
+
node = validated.node_lookup[node_id]
|
|
73
|
+
measured = measurements[node_id]
|
|
74
|
+
row = local_rows[node_id]
|
|
75
|
+
placed[node_id] = LayoutNode(
|
|
76
|
+
node=node,
|
|
77
|
+
rank=local_ranks[node_id],
|
|
78
|
+
order=row,
|
|
79
|
+
component_id=component_id,
|
|
80
|
+
width=round(column_widths[rank], 2),
|
|
81
|
+
height=round(row_heights[row], 2),
|
|
82
|
+
x=round(x_positions[rank], 2),
|
|
83
|
+
y=round(row_tops[row], 2),
|
|
84
|
+
title_lines=measured.title_lines,
|
|
85
|
+
subtitle_lines=measured.subtitle_lines,
|
|
86
|
+
title_line_height=measured.title_line_height,
|
|
87
|
+
subtitle_line_height=measured.subtitle_line_height,
|
|
88
|
+
content_height=measured.content_height,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
current_top += component_height + validated.theme.component_gap
|
|
92
|
+
|
|
93
|
+
return placed
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _weak_components(validated: "ValidatedPipeline | ValidatedDetailPanel") -> list[tuple[str, ...]]:
|
|
97
|
+
adjacency: dict[str, set[str]] = {node.id: set() for node in validated.nodes}
|
|
98
|
+
for edge in validated.edges:
|
|
99
|
+
adjacency[edge.source].add(edge.target)
|
|
100
|
+
adjacency[edge.target].add(edge.source)
|
|
101
|
+
|
|
102
|
+
components: list[tuple[str, ...]] = []
|
|
103
|
+
seen: set[str] = set()
|
|
104
|
+
|
|
105
|
+
for node in validated.nodes:
|
|
106
|
+
if node.id in seen:
|
|
107
|
+
continue
|
|
108
|
+
queue = deque([node.id])
|
|
109
|
+
seen.add(node.id)
|
|
110
|
+
members: list[str] = []
|
|
111
|
+
while queue:
|
|
112
|
+
node_id = queue.popleft()
|
|
113
|
+
members.append(node_id)
|
|
114
|
+
for neighbor in sorted(adjacency[node_id], key=validated.node_index.__getitem__):
|
|
115
|
+
if neighbor in seen:
|
|
116
|
+
continue
|
|
117
|
+
seen.add(neighbor)
|
|
118
|
+
queue.append(neighbor)
|
|
119
|
+
members.sort(key=validated.node_index.__getitem__)
|
|
120
|
+
components.append(tuple(members))
|
|
121
|
+
|
|
122
|
+
return components
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _row_gap_after(
|
|
126
|
+
validated: "ValidatedPipeline | ValidatedDetailPanel",
|
|
127
|
+
component_nodes: tuple[str, ...],
|
|
128
|
+
rows: dict[str, int],
|
|
129
|
+
) -> dict[int, float]:
|
|
130
|
+
row_ids = sorted({rows[node_id] for node_id in component_nodes})
|
|
131
|
+
crossing_counts = {row: 0 for row in row_ids[:-1]}
|
|
132
|
+
component_set = set(component_nodes)
|
|
133
|
+
|
|
134
|
+
for edge in validated.edges:
|
|
135
|
+
if edge.source not in component_set or edge.target not in component_set:
|
|
136
|
+
continue
|
|
137
|
+
source_row = rows[edge.source]
|
|
138
|
+
target_row = rows[edge.target]
|
|
139
|
+
if source_row == target_row:
|
|
140
|
+
continue
|
|
141
|
+
for row in range(min(source_row, target_row), max(source_row, target_row)):
|
|
142
|
+
crossing_counts[row] = crossing_counts.get(row, 0) + 1
|
|
143
|
+
|
|
144
|
+
gap_after: dict[int, float] = {}
|
|
145
|
+
for row in row_ids[:-1]:
|
|
146
|
+
lanes = crossing_counts.get(row, 0)
|
|
147
|
+
gap_after[row] = round(
|
|
148
|
+
_base_row_gap(validated.theme)
|
|
149
|
+
+ max(0, lanes - 1) * validated.theme.route_track_gap,
|
|
150
|
+
2,
|
|
151
|
+
)
|
|
152
|
+
return gap_after
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _rank_gap_after(
|
|
156
|
+
validated: "ValidatedPipeline | ValidatedDetailPanel",
|
|
157
|
+
component_nodes: tuple[str, ...],
|
|
158
|
+
ranks: dict[str, int],
|
|
159
|
+
*,
|
|
160
|
+
min_rank: int,
|
|
161
|
+
) -> dict[int, float]:
|
|
162
|
+
component_set = set(component_nodes)
|
|
163
|
+
normalized_ranks = {node_id: rank - min_rank for node_id, rank in ranks.items()}
|
|
164
|
+
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
|
+
|
|
177
|
+
gap_after: dict[int, float] = {}
|
|
178
|
+
for rank in rank_ids[:-1]:
|
|
179
|
+
lanes = crossing_counts.get(rank, 0)
|
|
180
|
+
gap_after[rank] = round(
|
|
181
|
+
_base_rank_gap(validated.theme)
|
|
182
|
+
+ max(0, lanes - 1) * validated.theme.route_track_gap,
|
|
183
|
+
2,
|
|
184
|
+
)
|
|
185
|
+
return gap_after
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _base_row_gap(theme: "Theme") -> float:
|
|
189
|
+
return max(theme.route_track_gap * 1.5, theme.node_gap * 0.75)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _base_rank_gap(theme: "Theme") -> float:
|
|
193
|
+
return max(theme.route_track_gap * 2.5, theme.rank_gap * 0.6)
|