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