frameplot 0.5.6__tar.gz → 0.5.8__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.6/src/frameplot.egg-info → frameplot-0.5.8}/PKG-INFO +5 -4
- {frameplot-0.5.6 → frameplot-0.5.8}/README.md +4 -3
- {frameplot-0.5.6 → frameplot-0.5.8}/pyproject.toml +1 -1
- {frameplot-0.5.6 → frameplot-0.5.8}/src/frameplot/api.py +5 -2
- {frameplot-0.5.6 → frameplot-0.5.8}/src/frameplot/layout/__init__.py +426 -2
- {frameplot-0.5.6 → frameplot-0.5.8}/src/frameplot/layout/route.py +666 -290
- {frameplot-0.5.6 → frameplot-0.5.8}/src/frameplot/layout/types.py +18 -0
- frameplot-0.5.8/src/frameplot/layout/validate.py +373 -0
- {frameplot-0.5.6 → frameplot-0.5.8}/src/frameplot/model.py +12 -8
- {frameplot-0.5.6 → frameplot-0.5.8/src/frameplot.egg-info}/PKG-INFO +5 -4
- {frameplot-0.5.6 → frameplot-0.5.8}/tests/test_rendering.py +135 -19
- frameplot-0.5.6/src/frameplot/layout/validate.py +0 -145
- {frameplot-0.5.6 → frameplot-0.5.8}/LICENSE +0 -0
- {frameplot-0.5.6 → frameplot-0.5.8}/setup.cfg +0 -0
- {frameplot-0.5.6 → frameplot-0.5.8}/src/frameplot/__init__.py +0 -0
- {frameplot-0.5.6 → frameplot-0.5.8}/src/frameplot/layout/order.py +0 -0
- {frameplot-0.5.6 → frameplot-0.5.8}/src/frameplot/layout/place.py +0 -0
- {frameplot-0.5.6 → frameplot-0.5.8}/src/frameplot/layout/rank.py +0 -0
- {frameplot-0.5.6 → frameplot-0.5.8}/src/frameplot/layout/scc.py +0 -0
- {frameplot-0.5.6 → frameplot-0.5.8}/src/frameplot/layout/text.py +0 -0
- {frameplot-0.5.6 → frameplot-0.5.8}/src/frameplot/render/__init__.py +0 -0
- {frameplot-0.5.6 → frameplot-0.5.8}/src/frameplot/render/png.py +0 -0
- {frameplot-0.5.6 → frameplot-0.5.8}/src/frameplot/render/svg.py +0 -0
- {frameplot-0.5.6 → frameplot-0.5.8}/src/frameplot/theme.py +0 -0
- {frameplot-0.5.6 → frameplot-0.5.8}/src/frameplot.egg-info/SOURCES.txt +0 -0
- {frameplot-0.5.6 → frameplot-0.5.8}/src/frameplot.egg-info/dependency_links.txt +0 -0
- {frameplot-0.5.6 → frameplot-0.5.8}/src/frameplot.egg-info/requires.txt +0 -0
- {frameplot-0.5.6 → frameplot-0.5.8}/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.8
|
|
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
|
|
@@ -133,7 +133,7 @@ Top-level imports are the supported public API:
|
|
|
133
133
|
|
|
134
134
|
- `Node(id, title, subtitle=None, fill=None, stroke=None, text_color=None, metadata=None, width=None, height=None)`
|
|
135
135
|
- `Edge(id, source, target, color=None, dashed=False, merge_symbol=None, metadata=None)`
|
|
136
|
-
- `Group(id, label, node_ids, edge_ids=(), stroke=None, fill=None, metadata=None)`
|
|
136
|
+
- `Group(id, label, node_ids, edge_ids=(), group_ids=(), stroke=None, fill=None, metadata=None)`
|
|
137
137
|
- `DetailPanel(id, focus_node_id, label, nodes, edges, groups=(), stroke=None, fill=None, metadata=None)`
|
|
138
138
|
- `Theme(...)`
|
|
139
139
|
- `Pipeline(nodes, edges, groups=(), detail_panel=None, theme=None)`
|
|
@@ -151,7 +151,8 @@ Top-level imports are the supported public API:
|
|
|
151
151
|
|
|
152
152
|
- Keep the main graph at one abstraction level. Frameplot lays the pipeline out as a dependency-driven left-to-right graph, not as a freeform block diagram.
|
|
153
153
|
- Use `DetailPanel` for repeated block internals or per-stage mechanics that would otherwise create long-range edges in the main graph.
|
|
154
|
-
- Use `Group`
|
|
154
|
+
- Use `Group` as a structural container for nearby related nodes. Prefer explicit nesting with `group_ids`; legacy strict-subset node groups are still normalized into a tree for compatibility.
|
|
155
|
+
- Keep group membership tree-shaped. A node can belong to one direct parent group, and a child group can belong to one direct parent group.
|
|
155
156
|
|
|
156
157
|
## Advanced Example: Multi-cloud Data Pipeline
|
|
157
158
|
|
|
@@ -165,7 +166,7 @@ The hero image at the top and the theme gallery above are generated from [`examp
|
|
|
165
166
|
|
|
166
167
|
- Layout is intentionally left-to-right in v0.x.
|
|
167
168
|
- Edge labels are not supported yet, but edge-to-edge joins can render optional `+` / `x` badges.
|
|
168
|
-
- Groups
|
|
169
|
+
- Groups with `node_ids` or `group_ids` are structural container blocks in layout, while edge-only groups remain visual highlights.
|
|
169
170
|
- Detail panels render as separate lower insets attached to a focus node in the main flow.
|
|
170
171
|
- If a sample looks stretched or routes far outside the intended block, the graph usually mixes stage-level flow with internal logic in one plane; move the internals into a `DetailPanel`.
|
|
171
172
|
|
|
@@ -104,7 +104,7 @@ Top-level imports are the supported public API:
|
|
|
104
104
|
|
|
105
105
|
- `Node(id, title, subtitle=None, fill=None, stroke=None, text_color=None, metadata=None, width=None, height=None)`
|
|
106
106
|
- `Edge(id, source, target, color=None, dashed=False, merge_symbol=None, metadata=None)`
|
|
107
|
-
- `Group(id, label, node_ids, edge_ids=(), stroke=None, fill=None, metadata=None)`
|
|
107
|
+
- `Group(id, label, node_ids, edge_ids=(), group_ids=(), stroke=None, fill=None, metadata=None)`
|
|
108
108
|
- `DetailPanel(id, focus_node_id, label, nodes, edges, groups=(), stroke=None, fill=None, metadata=None)`
|
|
109
109
|
- `Theme(...)`
|
|
110
110
|
- `Pipeline(nodes, edges, groups=(), detail_panel=None, theme=None)`
|
|
@@ -122,7 +122,8 @@ Top-level imports are the supported public API:
|
|
|
122
122
|
|
|
123
123
|
- Keep the main graph at one abstraction level. Frameplot lays the pipeline out as a dependency-driven left-to-right graph, not as a freeform block diagram.
|
|
124
124
|
- Use `DetailPanel` for repeated block internals or per-stage mechanics that would otherwise create long-range edges in the main graph.
|
|
125
|
-
- Use `Group`
|
|
125
|
+
- Use `Group` as a structural container for nearby related nodes. Prefer explicit nesting with `group_ids`; legacy strict-subset node groups are still normalized into a tree for compatibility.
|
|
126
|
+
- Keep group membership tree-shaped. A node can belong to one direct parent group, and a child group can belong to one direct parent group.
|
|
126
127
|
|
|
127
128
|
## Advanced Example: Multi-cloud Data Pipeline
|
|
128
129
|
|
|
@@ -136,7 +137,7 @@ The hero image at the top and the theme gallery above are generated from [`examp
|
|
|
136
137
|
|
|
137
138
|
- Layout is intentionally left-to-right in v0.x.
|
|
138
139
|
- Edge labels are not supported yet, but edge-to-edge joins can render optional `+` / `x` badges.
|
|
139
|
-
- Groups
|
|
140
|
+
- Groups with `node_ids` or `group_ids` are structural container blocks in layout, while edge-only groups remain visual highlights.
|
|
140
141
|
- Detail panels render as separate lower insets attached to a focus node in the main flow.
|
|
141
142
|
- If a sample looks stretched or routes far outside the intended block, the graph usually mixes stage-level flow with internal logic in one plane; move the internals into a `DetailPanel`.
|
|
142
143
|
|
|
@@ -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.8"
|
|
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"
|
|
@@ -23,8 +23,11 @@ class Pipeline:
|
|
|
23
23
|
dependency-driven left-to-right auto-layout, so it works best when kept to
|
|
24
24
|
one abstraction level. If stage flow and internal mechanics are mixed into
|
|
25
25
|
the same graph, ranks can stretch and routes can become unexpectedly long;
|
|
26
|
-
move those internals into a :class:`DetailPanel` instead.
|
|
27
|
-
`
|
|
26
|
+
move those internals into a :class:`DetailPanel` instead. Groups with
|
|
27
|
+
`node_ids` or `group_ids` behave as structural container blocks. Prefer
|
|
28
|
+
explicit nesting with `group_ids`; strict-subset legacy node groups are
|
|
29
|
+
still normalized into a tree for compatibility. Passing `theme=None` uses
|
|
30
|
+
the default :class:`Theme`.
|
|
28
31
|
"""
|
|
29
32
|
|
|
30
33
|
nodes: tuple[Node, ...]
|
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from collections import deque
|
|
5
7
|
from collections.abc import Callable
|
|
6
8
|
from dataclasses import dataclass
|
|
9
|
+
from types import SimpleNamespace
|
|
7
10
|
|
|
11
|
+
from frameplot.model import Edge, Node
|
|
8
12
|
from frameplot.layout.order import order_nodes
|
|
9
13
|
from frameplot.layout.place import place_nodes
|
|
10
14
|
from frameplot.layout.rank import assign_ranks
|
|
@@ -19,7 +23,9 @@ from frameplot.layout.types import (
|
|
|
19
23
|
GuideLine,
|
|
20
24
|
LayoutNode,
|
|
21
25
|
LayoutResult,
|
|
26
|
+
MeasuredText,
|
|
22
27
|
Point,
|
|
28
|
+
ResolvedEdgeTarget,
|
|
23
29
|
RoutedEdge,
|
|
24
30
|
)
|
|
25
31
|
from frameplot.layout.validate import validate_pipeline
|
|
@@ -50,6 +56,30 @@ class _GroupSpacingInfo:
|
|
|
50
56
|
right_inside_clearance: float
|
|
51
57
|
|
|
52
58
|
|
|
59
|
+
@dataclass(slots=True, frozen=True)
|
|
60
|
+
class _ContainerPlacement:
|
|
61
|
+
leaf_nodes: dict[str, LayoutNode]
|
|
62
|
+
group_bounds: dict[str, Bounds]
|
|
63
|
+
bounds: Bounds
|
|
64
|
+
rank_span: int
|
|
65
|
+
order_span: int
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(slots=True)
|
|
69
|
+
class _TempValidatedGraph:
|
|
70
|
+
nodes: tuple[Node, ...]
|
|
71
|
+
edges: tuple[Edge, ...]
|
|
72
|
+
groups: tuple[object, ...]
|
|
73
|
+
node_lookup: dict[str, Node]
|
|
74
|
+
edge_lookup: dict[str, Edge]
|
|
75
|
+
edge_targets: dict[str, ResolvedEdgeTarget]
|
|
76
|
+
node_index: dict[str, int]
|
|
77
|
+
edge_index: dict[str, int]
|
|
78
|
+
group_hierarchy: object
|
|
79
|
+
theme: "Theme"
|
|
80
|
+
detail_panel: None = None
|
|
81
|
+
|
|
82
|
+
|
|
53
83
|
def build_layout(pipeline: "Pipeline") -> LayoutResult:
|
|
54
84
|
"""Compute positions, routes, and overlays for a pipeline."""
|
|
55
85
|
|
|
@@ -79,6 +109,17 @@ def build_layout(pipeline: "Pipeline") -> LayoutResult:
|
|
|
79
109
|
|
|
80
110
|
def _layout_graph(validated: "ValidatedPipeline | ValidatedDetailPanel") -> GraphLayout:
|
|
81
111
|
measurements = measure_text(validated)
|
|
112
|
+
if validated.group_hierarchy.structural_group_ids:
|
|
113
|
+
return _layout_structural_graph(validated, measurements)
|
|
114
|
+
return _layout_flat_graph(validated, measurements)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _layout_flat_graph(
|
|
118
|
+
validated: "ValidatedPipeline | ValidatedDetailPanel | _TempValidatedGraph",
|
|
119
|
+
measurements: dict[str, MeasuredText],
|
|
120
|
+
*,
|
|
121
|
+
group_bounds_by_id: dict[str, Bounds] | None = None,
|
|
122
|
+
) -> GraphLayout:
|
|
82
123
|
scc_result = strongly_connected_components(validated)
|
|
83
124
|
ranks = assign_ranks(validated, scc_result)
|
|
84
125
|
order = order_nodes(validated, ranks)
|
|
@@ -96,8 +137,13 @@ def _layout_graph(validated: "ValidatedPipeline | ValidatedDetailPanel") -> Grap
|
|
|
96
137
|
row_gap_overrides=row_gap_overrides,
|
|
97
138
|
row_gap_floor=row_gap_floor,
|
|
98
139
|
)
|
|
99
|
-
routed_edges = route_edges(validated, placed_nodes)
|
|
100
|
-
overlays = compute_group_overlays(
|
|
140
|
+
routed_edges = route_edges(validated, placed_nodes, group_bounds_by_id=group_bounds_by_id)
|
|
141
|
+
overlays = compute_group_overlays(
|
|
142
|
+
validated,
|
|
143
|
+
placed_nodes,
|
|
144
|
+
routed_edges,
|
|
145
|
+
group_bounds_by_id=group_bounds_by_id,
|
|
146
|
+
)
|
|
101
147
|
next_rank_overrides = _rank_gap_overrides(validated, placed_nodes, routed_edges, overlays)
|
|
102
148
|
next_row_overrides = _row_gap_overrides(validated, placed_nodes, routed_edges, overlays)
|
|
103
149
|
if (
|
|
@@ -111,6 +157,384 @@ def _layout_graph(validated: "ValidatedPipeline | ValidatedDetailPanel") -> Grap
|
|
|
111
157
|
return _normalize_graph_layout(placed_nodes, routed_edges, overlays, validated.theme)
|
|
112
158
|
|
|
113
159
|
|
|
160
|
+
def _layout_structural_graph(
|
|
161
|
+
validated: "ValidatedPipeline | ValidatedDetailPanel",
|
|
162
|
+
measurements: dict[str, MeasuredText],
|
|
163
|
+
) -> GraphLayout:
|
|
164
|
+
placement = _layout_container(validated, measurements, group_id=None)
|
|
165
|
+
placed_nodes = _reindex_hierarchical_components(validated, placement.leaf_nodes)
|
|
166
|
+
routed_edges = route_edges(validated, placed_nodes, group_bounds_by_id=placement.group_bounds)
|
|
167
|
+
overlays = compute_group_overlays(
|
|
168
|
+
validated,
|
|
169
|
+
placed_nodes,
|
|
170
|
+
routed_edges,
|
|
171
|
+
group_bounds_by_id=placement.group_bounds,
|
|
172
|
+
)
|
|
173
|
+
return _normalize_graph_layout(placed_nodes, routed_edges, overlays, validated.theme)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _layout_container(
|
|
177
|
+
validated: "ValidatedPipeline | ValidatedDetailPanel",
|
|
178
|
+
measurements: dict[str, MeasuredText],
|
|
179
|
+
*,
|
|
180
|
+
group_id: str | None,
|
|
181
|
+
) -> _ContainerPlacement:
|
|
182
|
+
hierarchy = validated.group_hierarchy
|
|
183
|
+
direct_node_ids = (
|
|
184
|
+
hierarchy.top_level_node_ids if group_id is None else hierarchy.group_child_node_ids.get(group_id, ())
|
|
185
|
+
)
|
|
186
|
+
direct_group_ids = (
|
|
187
|
+
hierarchy.top_level_group_ids if group_id is None else hierarchy.group_child_group_ids.get(group_id, ())
|
|
188
|
+
)
|
|
189
|
+
child_group_placements = {
|
|
190
|
+
child_group_id: _layout_container(validated, measurements, group_id=child_group_id)
|
|
191
|
+
for child_group_id in direct_group_ids
|
|
192
|
+
}
|
|
193
|
+
temp_validated, temp_measurements = _build_temp_graph(
|
|
194
|
+
validated,
|
|
195
|
+
measurements,
|
|
196
|
+
direct_node_ids=direct_node_ids,
|
|
197
|
+
direct_group_ids=direct_group_ids,
|
|
198
|
+
child_group_placements=child_group_placements,
|
|
199
|
+
container_group_id=group_id,
|
|
200
|
+
)
|
|
201
|
+
temp_graph = _layout_flat_graph(temp_validated, temp_measurements)
|
|
202
|
+
temp_graph = _shift_graph_layout(
|
|
203
|
+
temp_graph,
|
|
204
|
+
shift_x=-temp_graph.content_bounds.x,
|
|
205
|
+
shift_y=-temp_graph.content_bounds.y,
|
|
206
|
+
)
|
|
207
|
+
block_rank_spans = {node_id: 1 for node_id in direct_node_ids}
|
|
208
|
+
block_rank_spans.update({child_group_id: placement.rank_span for child_group_id, placement in child_group_placements.items()})
|
|
209
|
+
block_order_spans = {node_id: 1 for node_id in direct_node_ids}
|
|
210
|
+
block_order_spans.update(
|
|
211
|
+
{child_group_id: placement.order_span for child_group_id, placement in child_group_placements.items()}
|
|
212
|
+
)
|
|
213
|
+
rank_widths = _rank_widths_for_blocks(temp_graph.nodes, block_rank_spans)
|
|
214
|
+
order_heights = _order_heights_for_blocks(temp_graph.nodes, block_order_spans)
|
|
215
|
+
rank_offsets = _cumulative_offsets(rank_widths)
|
|
216
|
+
order_offsets = _cumulative_offsets(order_heights)
|
|
217
|
+
|
|
218
|
+
leaf_nodes: dict[str, LayoutNode] = {}
|
|
219
|
+
group_bounds: dict[str, Bounds] = {}
|
|
220
|
+
|
|
221
|
+
for node_id in direct_node_ids:
|
|
222
|
+
anchor = temp_graph.nodes[node_id]
|
|
223
|
+
leaf_nodes[node_id] = _with_layout_indices(
|
|
224
|
+
anchor,
|
|
225
|
+
rank=rank_offsets[anchor.rank],
|
|
226
|
+
order=order_offsets[anchor.order],
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
for child_group_id, child_placement in child_group_placements.items():
|
|
230
|
+
anchor = temp_graph.nodes[child_group_id]
|
|
231
|
+
shift_x = anchor.x - child_placement.bounds.x
|
|
232
|
+
shift_y = anchor.y - child_placement.bounds.y
|
|
233
|
+
rank_offset = rank_offsets[anchor.rank]
|
|
234
|
+
order_offset = order_offsets[anchor.order]
|
|
235
|
+
for leaf_id, node in child_placement.leaf_nodes.items():
|
|
236
|
+
leaf_nodes[leaf_id] = _with_layout_indices(
|
|
237
|
+
_shift_node(node, shift_x, shift_y),
|
|
238
|
+
rank=node.rank + rank_offset,
|
|
239
|
+
order=node.order + order_offset,
|
|
240
|
+
)
|
|
241
|
+
for nested_group_id, bounds in child_placement.group_bounds.items():
|
|
242
|
+
group_bounds[nested_group_id] = Bounds(
|
|
243
|
+
x=round(bounds.x + shift_x, 2),
|
|
244
|
+
y=round(bounds.y + shift_y, 2),
|
|
245
|
+
width=bounds.width,
|
|
246
|
+
height=bounds.height,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
if group_id is None:
|
|
250
|
+
return _ContainerPlacement(
|
|
251
|
+
leaf_nodes=leaf_nodes,
|
|
252
|
+
group_bounds=group_bounds,
|
|
253
|
+
bounds=Bounds(
|
|
254
|
+
x=0.0,
|
|
255
|
+
y=0.0,
|
|
256
|
+
width=round(temp_graph.content_bounds.width, 2),
|
|
257
|
+
height=round(temp_graph.content_bounds.height, 2),
|
|
258
|
+
),
|
|
259
|
+
rank_span=sum(rank_widths.values()) or 1,
|
|
260
|
+
order_span=sum(order_heights.values()) or 1,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
metrics = resolve_theme_metrics(validated.theme)
|
|
264
|
+
inset_x = validated.theme.group_padding
|
|
265
|
+
inset_top = validated.theme.group_padding + metrics.group_label_padding
|
|
266
|
+
inset_bottom = validated.theme.group_padding
|
|
267
|
+
|
|
268
|
+
shifted_nodes = {
|
|
269
|
+
node_id: _shift_node(node, inset_x, inset_top)
|
|
270
|
+
for node_id, node in leaf_nodes.items()
|
|
271
|
+
}
|
|
272
|
+
shifted_group_bounds = {
|
|
273
|
+
nested_group_id: Bounds(
|
|
274
|
+
x=round(bounds.x + inset_x, 2),
|
|
275
|
+
y=round(bounds.y + inset_top, 2),
|
|
276
|
+
width=bounds.width,
|
|
277
|
+
height=bounds.height,
|
|
278
|
+
)
|
|
279
|
+
for nested_group_id, bounds in group_bounds.items()
|
|
280
|
+
}
|
|
281
|
+
own_bounds = Bounds(
|
|
282
|
+
x=0.0,
|
|
283
|
+
y=0.0,
|
|
284
|
+
width=round(temp_graph.content_bounds.width + inset_x * 2, 2),
|
|
285
|
+
height=round(temp_graph.content_bounds.height + inset_top + inset_bottom, 2),
|
|
286
|
+
)
|
|
287
|
+
shifted_group_bounds[group_id] = own_bounds
|
|
288
|
+
return _ContainerPlacement(
|
|
289
|
+
leaf_nodes=shifted_nodes,
|
|
290
|
+
group_bounds=shifted_group_bounds,
|
|
291
|
+
bounds=own_bounds,
|
|
292
|
+
rank_span=sum(rank_widths.values()) or 1,
|
|
293
|
+
order_span=sum(order_heights.values()) or 1,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _build_temp_graph(
|
|
298
|
+
validated: "ValidatedPipeline | ValidatedDetailPanel",
|
|
299
|
+
measurements: dict[str, MeasuredText],
|
|
300
|
+
*,
|
|
301
|
+
direct_node_ids: tuple[str, ...],
|
|
302
|
+
direct_group_ids: tuple[str, ...],
|
|
303
|
+
child_group_placements: dict[str, _ContainerPlacement],
|
|
304
|
+
container_group_id: str | None,
|
|
305
|
+
) -> tuple[_TempValidatedGraph, dict[str, MeasuredText]]:
|
|
306
|
+
hierarchy = validated.group_hierarchy
|
|
307
|
+
block_nodes: list[Node] = []
|
|
308
|
+
node_lookup: dict[str, Node] = {}
|
|
309
|
+
node_index: dict[str, int] = {}
|
|
310
|
+
block_measurements: dict[str, MeasuredText] = {}
|
|
311
|
+
|
|
312
|
+
for index, node_id in enumerate((*direct_node_ids, *direct_group_ids)):
|
|
313
|
+
if node_id in direct_node_ids:
|
|
314
|
+
node = validated.node_lookup[node_id]
|
|
315
|
+
block_measurements[node_id] = measurements[node_id]
|
|
316
|
+
else:
|
|
317
|
+
group = hierarchy.group_lookup[node_id]
|
|
318
|
+
placement = child_group_placements[node_id]
|
|
319
|
+
node = Node(
|
|
320
|
+
id=node_id,
|
|
321
|
+
title=group.label,
|
|
322
|
+
width=placement.bounds.width,
|
|
323
|
+
height=placement.bounds.height,
|
|
324
|
+
fill=group.fill,
|
|
325
|
+
stroke=group.stroke,
|
|
326
|
+
)
|
|
327
|
+
block_measurements[node_id] = MeasuredText(
|
|
328
|
+
title_lines=(),
|
|
329
|
+
subtitle_lines=(),
|
|
330
|
+
title_line_height=0.0,
|
|
331
|
+
subtitle_line_height=0.0,
|
|
332
|
+
content_height=0.0,
|
|
333
|
+
width=placement.bounds.width,
|
|
334
|
+
height=placement.bounds.height,
|
|
335
|
+
)
|
|
336
|
+
block_nodes.append(node)
|
|
337
|
+
node_lookup[node.id] = node
|
|
338
|
+
node_index[node.id] = index
|
|
339
|
+
|
|
340
|
+
projected_edges: list[Edge] = []
|
|
341
|
+
edge_lookup: dict[str, Edge] = {}
|
|
342
|
+
edge_targets: dict[str, ResolvedEdgeTarget] = {}
|
|
343
|
+
edge_index: dict[str, int] = {}
|
|
344
|
+
seen_pairs: set[tuple[str, str]] = set()
|
|
345
|
+
|
|
346
|
+
for edge in validated.edges:
|
|
347
|
+
source_block_id = _container_direct_block_id(
|
|
348
|
+
hierarchy,
|
|
349
|
+
edge.source,
|
|
350
|
+
container_group_id=container_group_id,
|
|
351
|
+
)
|
|
352
|
+
target_block_id = _container_direct_block_id(
|
|
353
|
+
hierarchy,
|
|
354
|
+
validated.edge_targets[edge.id].node_id,
|
|
355
|
+
container_group_id=container_group_id,
|
|
356
|
+
)
|
|
357
|
+
if source_block_id is None or target_block_id is None or source_block_id == target_block_id:
|
|
358
|
+
continue
|
|
359
|
+
if source_block_id not in node_lookup or target_block_id not in node_lookup:
|
|
360
|
+
continue
|
|
361
|
+
pair = (source_block_id, target_block_id)
|
|
362
|
+
if pair in seen_pairs:
|
|
363
|
+
continue
|
|
364
|
+
seen_pairs.add(pair)
|
|
365
|
+
projected_edge = Edge(
|
|
366
|
+
id=f"__proj_{len(projected_edges)}",
|
|
367
|
+
source=source_block_id,
|
|
368
|
+
target=target_block_id,
|
|
369
|
+
color=edge.color,
|
|
370
|
+
dashed=edge.dashed,
|
|
371
|
+
)
|
|
372
|
+
projected_edges.append(projected_edge)
|
|
373
|
+
edge_lookup[projected_edge.id] = projected_edge
|
|
374
|
+
edge_targets[projected_edge.id] = ResolvedEdgeTarget(kind="node", node_id=target_block_id)
|
|
375
|
+
edge_index[projected_edge.id] = len(projected_edges) - 1
|
|
376
|
+
|
|
377
|
+
temp_validated = _TempValidatedGraph(
|
|
378
|
+
nodes=tuple(block_nodes),
|
|
379
|
+
edges=tuple(projected_edges),
|
|
380
|
+
groups=(),
|
|
381
|
+
node_lookup=node_lookup,
|
|
382
|
+
edge_lookup=edge_lookup,
|
|
383
|
+
edge_targets=edge_targets,
|
|
384
|
+
node_index=node_index,
|
|
385
|
+
edge_index=edge_index,
|
|
386
|
+
group_hierarchy=SimpleNamespace( # type: ignore[name-defined]
|
|
387
|
+
structural_group_ids=(),
|
|
388
|
+
edge_only_group_ids=(),
|
|
389
|
+
top_level_group_ids=(),
|
|
390
|
+
top_level_node_ids=tuple(node_lookup),
|
|
391
|
+
group_parent_ids={},
|
|
392
|
+
group_child_group_ids={},
|
|
393
|
+
group_child_node_ids={},
|
|
394
|
+
group_descendant_node_ids={},
|
|
395
|
+
node_parent_group_ids={},
|
|
396
|
+
group_depths={},
|
|
397
|
+
group_lookup={},
|
|
398
|
+
group_index={},
|
|
399
|
+
),
|
|
400
|
+
theme=validated.theme,
|
|
401
|
+
)
|
|
402
|
+
return temp_validated, block_measurements
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _container_direct_block_id(
|
|
406
|
+
hierarchy,
|
|
407
|
+
node_id: str,
|
|
408
|
+
*,
|
|
409
|
+
container_group_id: str | None,
|
|
410
|
+
) -> str | None:
|
|
411
|
+
current_group_id = hierarchy.node_parent_group_ids.get(node_id)
|
|
412
|
+
if container_group_id is None:
|
|
413
|
+
if current_group_id is None:
|
|
414
|
+
return node_id
|
|
415
|
+
while hierarchy.group_parent_ids.get(current_group_id) is not None:
|
|
416
|
+
current_group_id = hierarchy.group_parent_ids[current_group_id]
|
|
417
|
+
return current_group_id
|
|
418
|
+
|
|
419
|
+
if current_group_id is None:
|
|
420
|
+
return None
|
|
421
|
+
if current_group_id == container_group_id:
|
|
422
|
+
return node_id
|
|
423
|
+
|
|
424
|
+
while True:
|
|
425
|
+
parent_group_id = hierarchy.group_parent_ids.get(current_group_id)
|
|
426
|
+
if parent_group_id == container_group_id:
|
|
427
|
+
return current_group_id
|
|
428
|
+
if parent_group_id is None:
|
|
429
|
+
return None
|
|
430
|
+
current_group_id = parent_group_id
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _rank_widths_for_blocks(
|
|
434
|
+
nodes: dict[str, LayoutNode],
|
|
435
|
+
spans_by_block_id: dict[str, int],
|
|
436
|
+
) -> dict[int, int]:
|
|
437
|
+
widths: dict[int, int] = defaultdict(lambda: 1)
|
|
438
|
+
for block_id, node in nodes.items():
|
|
439
|
+
widths[node.rank] = max(widths[node.rank], spans_by_block_id.get(block_id, 1))
|
|
440
|
+
return dict(widths)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _order_heights_for_blocks(
|
|
444
|
+
nodes: dict[str, LayoutNode],
|
|
445
|
+
spans_by_block_id: dict[str, int],
|
|
446
|
+
) -> dict[int, int]:
|
|
447
|
+
heights: dict[int, int] = defaultdict(lambda: 1)
|
|
448
|
+
for block_id, node in nodes.items():
|
|
449
|
+
heights[node.order] = max(heights[node.order], spans_by_block_id.get(block_id, 1))
|
|
450
|
+
return dict(heights)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _cumulative_offsets(spans_by_index: dict[int, int]) -> dict[int, int]:
|
|
454
|
+
offsets: dict[int, int] = {}
|
|
455
|
+
cursor = 0
|
|
456
|
+
for index in sorted(spans_by_index):
|
|
457
|
+
offsets[index] = cursor
|
|
458
|
+
cursor += spans_by_index[index]
|
|
459
|
+
return offsets
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _with_layout_indices(node: LayoutNode, *, rank: int, order: int) -> LayoutNode:
|
|
463
|
+
return LayoutNode(
|
|
464
|
+
node=node.node,
|
|
465
|
+
rank=rank,
|
|
466
|
+
order=order,
|
|
467
|
+
component_id=node.component_id,
|
|
468
|
+
width=node.width,
|
|
469
|
+
height=node.height,
|
|
470
|
+
x=node.x,
|
|
471
|
+
y=node.y,
|
|
472
|
+
title_lines=node.title_lines,
|
|
473
|
+
subtitle_lines=node.subtitle_lines,
|
|
474
|
+
title_line_height=node.title_line_height,
|
|
475
|
+
subtitle_line_height=node.subtitle_line_height,
|
|
476
|
+
content_height=node.content_height,
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _reindex_hierarchical_components(
|
|
481
|
+
validated: "ValidatedPipeline | ValidatedDetailPanel",
|
|
482
|
+
nodes: dict[str, LayoutNode],
|
|
483
|
+
) -> dict[str, LayoutNode]:
|
|
484
|
+
component_ids = _component_ids_for_validated_graph(validated)
|
|
485
|
+
reindexed: dict[str, LayoutNode] = {}
|
|
486
|
+
|
|
487
|
+
for node_id, node in nodes.items():
|
|
488
|
+
reindexed[node_id] = LayoutNode(
|
|
489
|
+
node=node.node,
|
|
490
|
+
rank=node.rank,
|
|
491
|
+
order=node.order,
|
|
492
|
+
component_id=component_ids[node_id],
|
|
493
|
+
width=node.width,
|
|
494
|
+
height=node.height,
|
|
495
|
+
x=node.x,
|
|
496
|
+
y=node.y,
|
|
497
|
+
title_lines=node.title_lines,
|
|
498
|
+
subtitle_lines=node.subtitle_lines,
|
|
499
|
+
title_line_height=node.title_line_height,
|
|
500
|
+
subtitle_line_height=node.subtitle_line_height,
|
|
501
|
+
content_height=node.content_height,
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
return reindexed
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _component_ids_for_validated_graph(
|
|
508
|
+
validated: "ValidatedPipeline | ValidatedDetailPanel",
|
|
509
|
+
) -> dict[str, int]:
|
|
510
|
+
adjacency: dict[str, set[str]] = {node.id: set() for node in validated.nodes}
|
|
511
|
+
for edge in validated.edges:
|
|
512
|
+
target_node_id = validated.edge_targets[edge.id].node_id
|
|
513
|
+
adjacency[edge.source].add(target_node_id)
|
|
514
|
+
adjacency[target_node_id].add(edge.source)
|
|
515
|
+
|
|
516
|
+
component_ids: dict[str, int] = {}
|
|
517
|
+
seen: set[str] = set()
|
|
518
|
+
component_id = 0
|
|
519
|
+
|
|
520
|
+
for node in validated.nodes:
|
|
521
|
+
if node.id in seen:
|
|
522
|
+
continue
|
|
523
|
+
queue = deque([node.id])
|
|
524
|
+
seen.add(node.id)
|
|
525
|
+
while queue:
|
|
526
|
+
node_id = queue.popleft()
|
|
527
|
+
component_ids[node_id] = component_id
|
|
528
|
+
for neighbor in sorted(adjacency[node_id], key=validated.node_index.__getitem__):
|
|
529
|
+
if neighbor in seen:
|
|
530
|
+
continue
|
|
531
|
+
seen.add(neighbor)
|
|
532
|
+
queue.append(neighbor)
|
|
533
|
+
component_id += 1
|
|
534
|
+
|
|
535
|
+
return component_ids
|
|
536
|
+
|
|
537
|
+
|
|
114
538
|
def _rank_gap_overrides(
|
|
115
539
|
validated: "ValidatedPipeline | ValidatedDetailPanel",
|
|
116
540
|
placed_nodes: dict[str, LayoutNode],
|