frameplot 0.5.0__tar.gz → 0.5.3__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.5.0/src/frameplot.egg-info → frameplot-0.5.3}/PKG-INFO +37 -9
- {frameplot-0.5.0 → frameplot-0.5.3}/README.md +36 -8
- {frameplot-0.5.0 → frameplot-0.5.3}/pyproject.toml +1 -1
- {frameplot-0.5.0 → frameplot-0.5.3}/src/frameplot/layout/__init__.py +255 -8
- frameplot-0.5.3/src/frameplot/layout/order.py +319 -0
- {frameplot-0.5.0 → frameplot-0.5.3}/src/frameplot/layout/place.py +16 -32
- {frameplot-0.5.0 → frameplot-0.5.3}/src/frameplot/layout/rank.py +1 -1
- frameplot-0.5.3/src/frameplot/layout/route.py +3358 -0
- {frameplot-0.5.0 → frameplot-0.5.3}/src/frameplot/layout/scc.py +1 -1
- {frameplot-0.5.0 → frameplot-0.5.3}/src/frameplot/layout/types.py +18 -0
- {frameplot-0.5.0 → frameplot-0.5.3}/src/frameplot/layout/validate.py +42 -5
- {frameplot-0.5.0 → frameplot-0.5.3}/src/frameplot/model.py +10 -3
- {frameplot-0.5.0 → frameplot-0.5.3}/src/frameplot/render/svg.py +92 -2
- {frameplot-0.5.0 → frameplot-0.5.3}/src/frameplot/theme.py +40 -30
- {frameplot-0.5.0 → frameplot-0.5.3/src/frameplot.egg-info}/PKG-INFO +37 -9
- frameplot-0.5.3/tests/test_rendering.py +1888 -0
- frameplot-0.5.0/src/frameplot/layout/order.py +0 -169
- frameplot-0.5.0/src/frameplot/layout/route.py +0 -1550
- frameplot-0.5.0/tests/test_rendering.py +0 -979
- {frameplot-0.5.0 → frameplot-0.5.3}/LICENSE +0 -0
- {frameplot-0.5.0 → frameplot-0.5.3}/setup.cfg +0 -0
- {frameplot-0.5.0 → frameplot-0.5.3}/src/frameplot/__init__.py +0 -0
- {frameplot-0.5.0 → frameplot-0.5.3}/src/frameplot/api.py +0 -0
- {frameplot-0.5.0 → frameplot-0.5.3}/src/frameplot/layout/text.py +0 -0
- {frameplot-0.5.0 → frameplot-0.5.3}/src/frameplot/render/__init__.py +0 -0
- {frameplot-0.5.0 → frameplot-0.5.3}/src/frameplot/render/png.py +0 -0
- {frameplot-0.5.0 → frameplot-0.5.3}/src/frameplot.egg-info/SOURCES.txt +0 -0
- {frameplot-0.5.0 → frameplot-0.5.3}/src/frameplot.egg-info/dependency_links.txt +0 -0
- {frameplot-0.5.0 → frameplot-0.5.3}/src/frameplot.egg-info/requires.txt +0 -0
- {frameplot-0.5.0 → frameplot-0.5.3}/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.5.
|
|
3
|
+
Version: 0.5.3
|
|
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
|
|
@@ -46,13 +46,17 @@ Turn Python-defined pipeline graphs into presentation-ready SVG and PNG diagrams
|
|
|
46
46
|
|
|
47
47
|
All built-in presets stay on a white canvas. The same hero pipeline is rendered below once per theme so you can compare them directly.
|
|
48
48
|
|
|
49
|
-
| Soft Retro | Retro |
|
|
50
|
-
| --- | --- |
|
|
51
|
-
|  |  |
|
|
49
|
+
| Soft Retro | Retro |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
|  |  |
|
|
52
52
|
|
|
53
|
-
|
|
|
54
|
-
| --- | --- |
|
|
55
|
-
|  |  |
|
|
56
|
+
|
|
57
|
+
| Cyberpunk | Monochrome |
|
|
58
|
+
| --- | --- |
|
|
59
|
+
|  |  |
|
|
56
60
|
|
|
57
61
|
## Why frameplot?
|
|
58
62
|
|
|
@@ -101,17 +105,41 @@ pipeline.save_png("pipeline.png")
|
|
|
101
105
|
|
|
102
106
|

|
|
103
107
|
|
|
108
|
+
## Edge-to-Edge Joins
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from frameplot import Edge, Node, Pipeline
|
|
112
|
+
|
|
113
|
+
pipeline = Pipeline(
|
|
114
|
+
nodes=[
|
|
115
|
+
Node("source", "Source", "Primary request"),
|
|
116
|
+
Node("worker", "Worker", "Prepare response"),
|
|
117
|
+
Node("audit", "Audit", "Write side log", fill="#DBEAFE"),
|
|
118
|
+
Node("done", "Done", "Return result", fill="#D9EAD3"),
|
|
119
|
+
],
|
|
120
|
+
edges=[
|
|
121
|
+
Edge("e1", "source", "worker"),
|
|
122
|
+
Edge("e2", "worker", "done"),
|
|
123
|
+
Edge("e3", "audit", "e2", merge_symbol="+", color="#2563EB"),
|
|
124
|
+
],
|
|
125
|
+
)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+

|
|
129
|
+
|
|
104
130
|
## Public API
|
|
105
131
|
|
|
106
132
|
Top-level imports are the supported public API:
|
|
107
133
|
|
|
108
134
|
- `Node(id, title, subtitle=None, fill=None, stroke=None, text_color=None, metadata=None, width=None, height=None)`
|
|
109
|
-
- `Edge(id, source, target, color=None, dashed=False, metadata=None)`
|
|
135
|
+
- `Edge(id, source, target, color=None, dashed=False, merge_symbol=None, metadata=None)`
|
|
110
136
|
- `Group(id, label, node_ids, edge_ids=(), stroke=None, fill=None, metadata=None)`
|
|
111
137
|
- `DetailPanel(id, focus_node_id, label, nodes, edges, groups=(), stroke=None, fill=None, metadata=None)`
|
|
112
138
|
- `Theme(...)`
|
|
113
139
|
- `Pipeline(nodes, edges, groups=(), detail_panel=None, theme=None)`
|
|
114
140
|
|
|
141
|
+
`Edge.target` may reference either a node id or another edge id. When targeting another edge, `merge_symbol="+"` or `"x"` renders a join badge at the merge point.
|
|
142
|
+
|
|
115
143
|
`Pipeline` exposes:
|
|
116
144
|
|
|
117
145
|
- `to_svg() -> str`
|
|
@@ -130,7 +158,7 @@ The hero image at the top and the theme gallery above are generated from [`examp
|
|
|
130
158
|
## Design Notes
|
|
131
159
|
|
|
132
160
|
- Layout is intentionally left-to-right in v0.x.
|
|
133
|
-
- Edge labels are not supported yet.
|
|
161
|
+
- Edge labels are not supported yet, but edge-to-edge joins can render optional `+` / `x` badges.
|
|
134
162
|
- Groups stay visual overlays, and routes leaving or re-entering grouped nodes bend outside grouped areas.
|
|
135
163
|
- Detail panels render as separate lower insets attached to a focus node in the main flow.
|
|
136
164
|
|
|
@@ -17,13 +17,17 @@ Turn Python-defined pipeline graphs into presentation-ready SVG and PNG diagrams
|
|
|
17
17
|
|
|
18
18
|
All built-in presets stay on a white canvas. The same hero pipeline is rendered below once per theme so you can compare them directly.
|
|
19
19
|
|
|
20
|
-
| Soft Retro | Retro |
|
|
21
|
-
| --- | --- |
|
|
22
|
-
|  |  |
|
|
20
|
+
| Soft Retro | Retro |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
|  |  |
|
|
23
23
|
|
|
24
|
-
|
|
|
25
|
-
| --- | --- |
|
|
26
|
-
|  |  |
|
|
27
|
+
|
|
28
|
+
| Cyberpunk | Monochrome |
|
|
29
|
+
| --- | --- |
|
|
30
|
+
|  |  |
|
|
27
31
|
|
|
28
32
|
## Why frameplot?
|
|
29
33
|
|
|
@@ -72,17 +76,41 @@ pipeline.save_png("pipeline.png")
|
|
|
72
76
|
|
|
73
77
|

|
|
74
78
|
|
|
79
|
+
## Edge-to-Edge Joins
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from frameplot import Edge, Node, Pipeline
|
|
83
|
+
|
|
84
|
+
pipeline = Pipeline(
|
|
85
|
+
nodes=[
|
|
86
|
+
Node("source", "Source", "Primary request"),
|
|
87
|
+
Node("worker", "Worker", "Prepare response"),
|
|
88
|
+
Node("audit", "Audit", "Write side log", fill="#DBEAFE"),
|
|
89
|
+
Node("done", "Done", "Return result", fill="#D9EAD3"),
|
|
90
|
+
],
|
|
91
|
+
edges=[
|
|
92
|
+
Edge("e1", "source", "worker"),
|
|
93
|
+
Edge("e2", "worker", "done"),
|
|
94
|
+
Edge("e3", "audit", "e2", merge_symbol="+", color="#2563EB"),
|
|
95
|
+
],
|
|
96
|
+
)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+

|
|
100
|
+
|
|
75
101
|
## Public API
|
|
76
102
|
|
|
77
103
|
Top-level imports are the supported public API:
|
|
78
104
|
|
|
79
105
|
- `Node(id, title, subtitle=None, fill=None, stroke=None, text_color=None, metadata=None, width=None, height=None)`
|
|
80
|
-
- `Edge(id, source, target, color=None, dashed=False, metadata=None)`
|
|
106
|
+
- `Edge(id, source, target, color=None, dashed=False, merge_symbol=None, metadata=None)`
|
|
81
107
|
- `Group(id, label, node_ids, edge_ids=(), stroke=None, fill=None, metadata=None)`
|
|
82
108
|
- `DetailPanel(id, focus_node_id, label, nodes, edges, groups=(), stroke=None, fill=None, metadata=None)`
|
|
83
109
|
- `Theme(...)`
|
|
84
110
|
- `Pipeline(nodes, edges, groups=(), detail_panel=None, theme=None)`
|
|
85
111
|
|
|
112
|
+
`Edge.target` may reference either a node id or another edge id. When targeting another edge, `merge_symbol="+"` or `"x"` renders a join badge at the merge point.
|
|
113
|
+
|
|
86
114
|
`Pipeline` exposes:
|
|
87
115
|
|
|
88
116
|
- `to_svg() -> str`
|
|
@@ -101,7 +129,7 @@ The hero image at the top and the theme gallery above are generated from [`examp
|
|
|
101
129
|
## Design Notes
|
|
102
130
|
|
|
103
131
|
- Layout is intentionally left-to-right in v0.x.
|
|
104
|
-
- Edge labels are not supported yet.
|
|
132
|
+
- Edge labels are not supported yet, but edge-to-edge joins can render optional `+` / `x` badges.
|
|
105
133
|
- Groups stay visual overlays, and routes leaving or re-entering grouped nodes bend outside grouped areas.
|
|
106
134
|
- Detail panels render as separate lower insets attached to a focus node in the main flow.
|
|
107
135
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "frameplot"
|
|
7
|
-
version = "0.5.
|
|
7
|
+
version = "0.5.3"
|
|
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"
|
|
@@ -38,8 +38,14 @@ class _GroupSpacingInfo:
|
|
|
38
38
|
group_node_ids: frozenset[str]
|
|
39
39
|
min_rank: int
|
|
40
40
|
max_rank: int
|
|
41
|
+
min_row: int
|
|
42
|
+
max_row: int
|
|
43
|
+
member_top: float
|
|
44
|
+
member_bottom: float
|
|
41
45
|
member_left: float
|
|
42
46
|
member_right: float
|
|
47
|
+
top_inside_clearance: float
|
|
48
|
+
bottom_inside_clearance: float
|
|
43
49
|
left_inside_clearance: float
|
|
44
50
|
right_inside_clearance: float
|
|
45
51
|
|
|
@@ -77,6 +83,8 @@ def _layout_graph(validated: "ValidatedPipeline | ValidatedDetailPanel") -> Grap
|
|
|
77
83
|
ranks = assign_ranks(validated, scc_result)
|
|
78
84
|
order = order_nodes(validated, ranks)
|
|
79
85
|
rank_gap_overrides: dict[tuple[int, int], float] | None = None
|
|
86
|
+
row_gap_overrides: dict[tuple[int, int], float] | None = None
|
|
87
|
+
row_gap_floor = resolve_theme_metrics(validated.theme).compact_rank_gap
|
|
80
88
|
|
|
81
89
|
for _ in range(MAX_LAYOUT_STABILIZATION_PASSES):
|
|
82
90
|
placed_nodes = place_nodes(
|
|
@@ -85,13 +93,20 @@ def _layout_graph(validated: "ValidatedPipeline | ValidatedDetailPanel") -> Grap
|
|
|
85
93
|
ranks,
|
|
86
94
|
order,
|
|
87
95
|
rank_gap_overrides=rank_gap_overrides,
|
|
96
|
+
row_gap_overrides=row_gap_overrides,
|
|
97
|
+
row_gap_floor=row_gap_floor,
|
|
88
98
|
)
|
|
89
99
|
routed_edges = route_edges(validated, placed_nodes)
|
|
90
100
|
overlays = compute_group_overlays(validated, placed_nodes, routed_edges)
|
|
91
|
-
|
|
92
|
-
|
|
101
|
+
next_rank_overrides = _rank_gap_overrides(validated, placed_nodes, routed_edges, overlays)
|
|
102
|
+
next_row_overrides = _row_gap_overrides(validated, placed_nodes, routed_edges, overlays)
|
|
103
|
+
if (
|
|
104
|
+
next_rank_overrides == (rank_gap_overrides or {})
|
|
105
|
+
and next_row_overrides == (row_gap_overrides or {})
|
|
106
|
+
):
|
|
93
107
|
break
|
|
94
|
-
rank_gap_overrides =
|
|
108
|
+
rank_gap_overrides = next_rank_overrides or None
|
|
109
|
+
row_gap_overrides = next_row_overrides or None
|
|
95
110
|
|
|
96
111
|
return _normalize_graph_layout(placed_nodes, routed_edges, overlays, validated.theme)
|
|
97
112
|
|
|
@@ -107,7 +122,7 @@ def _rank_gap_overrides(
|
|
|
107
122
|
base_gap = metrics.compact_rank_gap
|
|
108
123
|
geometry_by_component = _build_component_geometry(placed_nodes, theme)
|
|
109
124
|
required_gaps: dict[tuple[int, int], float] = {}
|
|
110
|
-
used_lane_positions: dict[tuple[int, int], set[float]] = {}
|
|
125
|
+
used_lane_positions: dict[tuple[int, int], set[tuple[str, float]]] = {}
|
|
111
126
|
|
|
112
127
|
for component_id, geometry in geometry_by_component.items():
|
|
113
128
|
for left_rank in geometry.gap_after_rank:
|
|
@@ -122,13 +137,18 @@ def _rank_gap_overrides(
|
|
|
122
137
|
x = round(start.x, 2)
|
|
123
138
|
for left_rank, (gap_start, gap_end) in geometry.gap_after_rank.items():
|
|
124
139
|
if gap_start + 0.01 < x < gap_end - 0.01:
|
|
125
|
-
used_lane_positions[(component_id, left_rank)].add(x)
|
|
140
|
+
used_lane_positions[(component_id, left_rank)].add((routed_edge.edge.id, x))
|
|
126
141
|
|
|
127
142
|
overrides: dict[tuple[int, int], float] = {}
|
|
128
|
-
for key,
|
|
129
|
-
if len(
|
|
143
|
+
for key, lane_entries in used_lane_positions.items():
|
|
144
|
+
if len(lane_entries) <= 1:
|
|
130
145
|
continue
|
|
131
|
-
|
|
146
|
+
lane_positions = sorted(position for _, position in lane_entries)
|
|
147
|
+
required_span = max(
|
|
148
|
+
lane_positions[-1] - lane_positions[0],
|
|
149
|
+
theme.route_track_gap * len(lane_positions),
|
|
150
|
+
)
|
|
151
|
+
required_gap = base_gap + required_span
|
|
132
152
|
if required_gap > base_gap + EPSILON:
|
|
133
153
|
required_gaps[key] = round(required_gap, 2)
|
|
134
154
|
|
|
@@ -139,6 +159,13 @@ def _rank_gap_overrides(
|
|
|
139
159
|
).items():
|
|
140
160
|
required_gaps[key] = round(max(required_gaps.get(key, 0.0), required_gap), 2)
|
|
141
161
|
|
|
162
|
+
for key, required_gap in _join_target_gap_requirements(
|
|
163
|
+
validated,
|
|
164
|
+
placed_nodes,
|
|
165
|
+
routed_edges,
|
|
166
|
+
).items():
|
|
167
|
+
required_gaps[key] = round(max(required_gaps.get(key, 0.0), required_gap), 2)
|
|
168
|
+
|
|
142
169
|
for key, required_gap in required_gaps.items():
|
|
143
170
|
if required_gap > base_gap + EPSILON:
|
|
144
171
|
overrides[key] = round(required_gap, 2)
|
|
@@ -146,6 +173,114 @@ def _rank_gap_overrides(
|
|
|
146
173
|
return overrides
|
|
147
174
|
|
|
148
175
|
|
|
176
|
+
def _row_gap_overrides(
|
|
177
|
+
validated: "ValidatedPipeline | ValidatedDetailPanel",
|
|
178
|
+
placed_nodes: dict[str, LayoutNode],
|
|
179
|
+
routed_edges: tuple[RoutedEdge, ...],
|
|
180
|
+
overlays: tuple[GroupOverlay, ...],
|
|
181
|
+
) -> dict[tuple[int, int], float]:
|
|
182
|
+
theme = validated.theme
|
|
183
|
+
base_gap = resolve_theme_metrics(theme).compact_rank_gap
|
|
184
|
+
geometry_by_component = _build_component_geometry(placed_nodes, theme)
|
|
185
|
+
used_lane_positions: dict[tuple[int, int], set[tuple[str, float]]] = {}
|
|
186
|
+
required_gaps: dict[tuple[int, int], float] = {}
|
|
187
|
+
|
|
188
|
+
for component_id, geometry in geometry_by_component.items():
|
|
189
|
+
for upper_row in geometry.gap_after_row:
|
|
190
|
+
used_lane_positions[(component_id, upper_row)] = set()
|
|
191
|
+
|
|
192
|
+
for routed_edge in routed_edges:
|
|
193
|
+
component_id = placed_nodes[routed_edge.edge.source].component_id
|
|
194
|
+
geometry = geometry_by_component[component_id]
|
|
195
|
+
for start, end in zip(routed_edge.points, routed_edge.points[1:]):
|
|
196
|
+
if start.y != end.y:
|
|
197
|
+
continue
|
|
198
|
+
y = round(start.y, 2)
|
|
199
|
+
for upper_row, (gap_start, gap_end) in geometry.gap_after_row.items():
|
|
200
|
+
if gap_start + EPSILON < y < gap_end - EPSILON:
|
|
201
|
+
used_lane_positions[(component_id, upper_row)].add((routed_edge.edge.id, y))
|
|
202
|
+
|
|
203
|
+
for key, lane_entries in used_lane_positions.items():
|
|
204
|
+
if not lane_entries:
|
|
205
|
+
continue
|
|
206
|
+
lane_positions = sorted(position for _, position in lane_entries)
|
|
207
|
+
occupied_span = lane_positions[-1] - lane_positions[0]
|
|
208
|
+
required_gap = max(base_gap, occupied_span + theme.route_track_gap)
|
|
209
|
+
if required_gap > base_gap + EPSILON:
|
|
210
|
+
required_gaps[key] = round(required_gap, 2)
|
|
211
|
+
|
|
212
|
+
for key, required_gap in _group_row_boundary_gap_requirements(
|
|
213
|
+
placed_nodes,
|
|
214
|
+
overlays,
|
|
215
|
+
geometry_by_component,
|
|
216
|
+
).items():
|
|
217
|
+
required_gaps[key] = round(max(required_gaps.get(key, 0.0), required_gap), 2)
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
key: round(required_gap, 2)
|
|
221
|
+
for key, required_gap in required_gaps.items()
|
|
222
|
+
if required_gap > base_gap + EPSILON
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _join_target_gap_requirements(
|
|
227
|
+
validated: "ValidatedPipeline | ValidatedDetailPanel",
|
|
228
|
+
placed_nodes: dict[str, LayoutNode],
|
|
229
|
+
routed_edges: tuple[RoutedEdge, ...],
|
|
230
|
+
) -> dict[tuple[int, int], float]:
|
|
231
|
+
route_by_id = {route.edge.id: route for route in routed_edges}
|
|
232
|
+
joins_by_target_segment: dict[tuple[str, int], list[RoutedEdge]] = {}
|
|
233
|
+
|
|
234
|
+
for routed_edge in routed_edges:
|
|
235
|
+
if routed_edge.target_kind != "edge" or routed_edge.target_edge_id is None:
|
|
236
|
+
continue
|
|
237
|
+
if routed_edge.join_segment_index is None:
|
|
238
|
+
continue
|
|
239
|
+
joins_by_target_segment.setdefault(
|
|
240
|
+
(routed_edge.target_edge_id, routed_edge.join_segment_index),
|
|
241
|
+
[],
|
|
242
|
+
).append(routed_edge)
|
|
243
|
+
|
|
244
|
+
required_gaps: dict[tuple[int, int], float] = {}
|
|
245
|
+
badge_diameter = max(validated.theme.arrow_size * 1.8, validated.theme.stroke_width * 6.0)
|
|
246
|
+
join_spacing = max(validated.theme.arrow_size * 1.5, validated.theme.stroke_width * 6.0)
|
|
247
|
+
|
|
248
|
+
for (target_edge_id, join_segment_index), joins in joins_by_target_segment.items():
|
|
249
|
+
target_edge = validated.edge_lookup[target_edge_id]
|
|
250
|
+
target_target = validated.edge_targets[target_edge_id]
|
|
251
|
+
source_node = placed_nodes[target_edge.source]
|
|
252
|
+
target_node = placed_nodes[target_target.node_id]
|
|
253
|
+
target_route = route_by_id.get(target_edge_id)
|
|
254
|
+
if target_route is None:
|
|
255
|
+
continue
|
|
256
|
+
if source_node.component_id != target_node.component_id:
|
|
257
|
+
continue
|
|
258
|
+
if source_node.rank >= target_node.rank or source_node.order != target_node.order:
|
|
259
|
+
continue
|
|
260
|
+
if join_segment_index >= len(target_route.points) - 1:
|
|
261
|
+
continue
|
|
262
|
+
|
|
263
|
+
start = target_route.points[join_segment_index]
|
|
264
|
+
end = target_route.points[join_segment_index + 1]
|
|
265
|
+
if start.y != end.y:
|
|
266
|
+
continue
|
|
267
|
+
|
|
268
|
+
current_segment_length = abs(end.x - start.x)
|
|
269
|
+
required_segment_length = (
|
|
270
|
+
validated.theme.route_track_gap
|
|
271
|
+
+ badge_diameter * len(joins)
|
|
272
|
+
+ join_spacing * max(0, len(joins) - 1)
|
|
273
|
+
)
|
|
274
|
+
if current_segment_length >= required_segment_length - EPSILON:
|
|
275
|
+
continue
|
|
276
|
+
|
|
277
|
+
required_gap = validated.theme.route_track_gap + (required_segment_length - current_segment_length)
|
|
278
|
+
key = (source_node.component_id, source_node.rank)
|
|
279
|
+
required_gaps[key] = max(required_gaps.get(key, 0.0), round(required_gap, 2))
|
|
280
|
+
|
|
281
|
+
return required_gaps
|
|
282
|
+
|
|
283
|
+
|
|
149
284
|
def _group_boundary_gap_requirements(
|
|
150
285
|
placed_nodes: dict[str, LayoutNode],
|
|
151
286
|
overlays: tuple[GroupOverlay, ...],
|
|
@@ -217,6 +352,8 @@ def _build_group_spacing_infos(
|
|
|
217
352
|
if not overlay.group.node_ids:
|
|
218
353
|
continue
|
|
219
354
|
member_nodes = [placed_nodes[node_id] for node_id in overlay.group.node_ids]
|
|
355
|
+
member_top = min(node.y for node in member_nodes)
|
|
356
|
+
member_bottom = max(node.bounds.bottom for node in member_nodes)
|
|
220
357
|
member_left = min(node.x for node in member_nodes)
|
|
221
358
|
member_right = max(node.right for node in member_nodes)
|
|
222
359
|
infos.append(
|
|
@@ -226,8 +363,14 @@ def _build_group_spacing_infos(
|
|
|
226
363
|
group_node_ids=frozenset(overlay.group.node_ids),
|
|
227
364
|
min_rank=min(node.rank for node in member_nodes),
|
|
228
365
|
max_rank=max(node.rank for node in member_nodes),
|
|
366
|
+
min_row=min(node.order for node in member_nodes),
|
|
367
|
+
max_row=max(node.order for node in member_nodes),
|
|
368
|
+
member_top=member_top,
|
|
369
|
+
member_bottom=member_bottom,
|
|
229
370
|
member_left=member_left,
|
|
230
371
|
member_right=member_right,
|
|
372
|
+
top_inside_clearance=member_top - overlay.bounds.y,
|
|
373
|
+
bottom_inside_clearance=overlay.bounds.bottom - member_bottom,
|
|
231
374
|
left_inside_clearance=member_left - overlay.bounds.x,
|
|
232
375
|
right_inside_clearance=overlay.bounds.right - member_right,
|
|
233
376
|
)
|
|
@@ -236,6 +379,52 @@ def _build_group_spacing_infos(
|
|
|
236
379
|
return tuple(infos)
|
|
237
380
|
|
|
238
381
|
|
|
382
|
+
def _group_row_boundary_gap_requirements(
|
|
383
|
+
placed_nodes: dict[str, LayoutNode],
|
|
384
|
+
overlays: tuple[GroupOverlay, ...],
|
|
385
|
+
geometry_by_component: dict[int, "ComponentGeometry"],
|
|
386
|
+
) -> dict[tuple[int, int], float]:
|
|
387
|
+
required_gaps: dict[tuple[int, int], float] = {}
|
|
388
|
+
group_infos = _build_group_spacing_infos(placed_nodes, overlays)
|
|
389
|
+
|
|
390
|
+
for info in group_infos:
|
|
391
|
+
geometry = geometry_by_component.get(info.component_id)
|
|
392
|
+
if geometry is None:
|
|
393
|
+
continue
|
|
394
|
+
|
|
395
|
+
upper_row = _previous_row(geometry, info.min_row)
|
|
396
|
+
top_outside_node = _nearest_outside_node_by_row(
|
|
397
|
+
placed_nodes,
|
|
398
|
+
bounds=info.bounds,
|
|
399
|
+
component_id=info.component_id,
|
|
400
|
+
group_node_ids=info.group_node_ids,
|
|
401
|
+
row_predicate=lambda row: row < info.min_row,
|
|
402
|
+
side="top",
|
|
403
|
+
)
|
|
404
|
+
if upper_row is not None and top_outside_node is not None:
|
|
405
|
+
required_gaps[(info.component_id, upper_row)] = round(
|
|
406
|
+
max(required_gaps.get((info.component_id, upper_row), 0.0), info.top_inside_clearance * 2.0),
|
|
407
|
+
2,
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
lower_row = info.max_row
|
|
411
|
+
bottom_outside_node = _nearest_outside_node_by_row(
|
|
412
|
+
placed_nodes,
|
|
413
|
+
bounds=info.bounds,
|
|
414
|
+
component_id=info.component_id,
|
|
415
|
+
group_node_ids=info.group_node_ids,
|
|
416
|
+
row_predicate=lambda row: row > info.max_row,
|
|
417
|
+
side="bottom",
|
|
418
|
+
)
|
|
419
|
+
if lower_row in geometry.gap_after_row and bottom_outside_node is not None:
|
|
420
|
+
required_gaps[(info.component_id, lower_row)] = round(
|
|
421
|
+
max(required_gaps.get((info.component_id, lower_row), 0.0), info.bottom_inside_clearance * 2.0),
|
|
422
|
+
2,
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
return required_gaps
|
|
426
|
+
|
|
427
|
+
|
|
239
428
|
def _nearest_outside_node(
|
|
240
429
|
placed_nodes: dict[str, LayoutNode],
|
|
241
430
|
*,
|
|
@@ -267,6 +456,37 @@ def _nearest_outside_node(
|
|
|
267
456
|
return candidate
|
|
268
457
|
|
|
269
458
|
|
|
459
|
+
def _nearest_outside_node_by_row(
|
|
460
|
+
placed_nodes: dict[str, LayoutNode],
|
|
461
|
+
*,
|
|
462
|
+
bounds: Bounds,
|
|
463
|
+
component_id: int,
|
|
464
|
+
group_node_ids: frozenset[str],
|
|
465
|
+
row_predicate: Callable[[int], bool],
|
|
466
|
+
side: str,
|
|
467
|
+
) -> LayoutNode | None:
|
|
468
|
+
candidate: LayoutNode | None = None
|
|
469
|
+
|
|
470
|
+
for node_id, node in placed_nodes.items():
|
|
471
|
+
if node.component_id != component_id:
|
|
472
|
+
continue
|
|
473
|
+
if node_id in group_node_ids:
|
|
474
|
+
continue
|
|
475
|
+
if not row_predicate(node.order):
|
|
476
|
+
continue
|
|
477
|
+
if not _bounds_overlap_horizontally(bounds, node.bounds):
|
|
478
|
+
continue
|
|
479
|
+
if candidate is None:
|
|
480
|
+
candidate = node
|
|
481
|
+
continue
|
|
482
|
+
if side == "top" and node.bounds.bottom > candidate.bounds.bottom:
|
|
483
|
+
candidate = node
|
|
484
|
+
if side == "bottom" and node.y < candidate.y:
|
|
485
|
+
candidate = node
|
|
486
|
+
|
|
487
|
+
return candidate
|
|
488
|
+
|
|
489
|
+
|
|
270
490
|
def _nearest_group_neighbor(
|
|
271
491
|
info: _GroupSpacingInfo,
|
|
272
492
|
group_infos: tuple[_GroupSpacingInfo, ...],
|
|
@@ -299,6 +519,10 @@ def _bounds_overlap_vertically(first: Bounds, second: Bounds) -> bool:
|
|
|
299
519
|
return min(first.bottom, second.bottom) - max(first.y, second.y) > EPSILON
|
|
300
520
|
|
|
301
521
|
|
|
522
|
+
def _bounds_overlap_horizontally(first: Bounds, second: Bounds) -> bool:
|
|
523
|
+
return min(first.right, second.right) - max(first.x, second.x) > EPSILON
|
|
524
|
+
|
|
525
|
+
|
|
302
526
|
def _previous_rank(geometry: "ComponentGeometry", rank: int) -> int | None:
|
|
303
527
|
candidates = [candidate_rank for candidate_rank in geometry.rank_right if candidate_rank < rank]
|
|
304
528
|
if not candidates:
|
|
@@ -306,6 +530,13 @@ def _previous_rank(geometry: "ComponentGeometry", rank: int) -> int | None:
|
|
|
306
530
|
return max(candidates)
|
|
307
531
|
|
|
308
532
|
|
|
533
|
+
def _previous_row(geometry: "ComponentGeometry", row: int) -> int | None:
|
|
534
|
+
candidates = [candidate_row for candidate_row in geometry.row_bottom if candidate_row < row]
|
|
535
|
+
if not candidates:
|
|
536
|
+
return None
|
|
537
|
+
return max(candidates)
|
|
538
|
+
|
|
539
|
+
|
|
309
540
|
def _normalize_graph_layout(
|
|
310
541
|
placed_nodes: dict[str, LayoutNode],
|
|
311
542
|
routed_edges: tuple[RoutedEdge, ...],
|
|
@@ -506,6 +737,22 @@ def _shift_edge(route: RoutedEdge, shift_x: float, shift_y: float) -> RoutedEdge
|
|
|
506
737
|
height=route.bounds.height,
|
|
507
738
|
),
|
|
508
739
|
stroke=route.stroke,
|
|
740
|
+
target_kind=route.target_kind,
|
|
741
|
+
target_node_id=route.target_node_id,
|
|
742
|
+
target_edge_id=route.target_edge_id,
|
|
743
|
+
join_point=(
|
|
744
|
+
Point(route.join_point.x + shift_x, route.join_point.y + shift_y)
|
|
745
|
+
if route.join_point is not None
|
|
746
|
+
else None
|
|
747
|
+
),
|
|
748
|
+
badge_center=(
|
|
749
|
+
Point(route.badge_center.x + shift_x, route.badge_center.y + shift_y)
|
|
750
|
+
if route.badge_center is not None
|
|
751
|
+
else None
|
|
752
|
+
),
|
|
753
|
+
join_segment_index=route.join_segment_index,
|
|
754
|
+
show_arrowhead=route.show_arrowhead,
|
|
755
|
+
join_badge_radius=route.join_badge_radius,
|
|
509
756
|
)
|
|
510
757
|
|
|
511
758
|
|