frameplot 0.5.1__tar.gz → 0.5.5__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.1/src/frameplot.egg-info → frameplot-0.5.5}/PKG-INFO +3 -3
- {frameplot-0.5.1 → frameplot-0.5.5}/README.md +2 -2
- {frameplot-0.5.1 → frameplot-0.5.5}/pyproject.toml +1 -1
- {frameplot-0.5.1 → frameplot-0.5.5}/src/frameplot/layout/__init__.py +174 -8
- {frameplot-0.5.1 → frameplot-0.5.5}/src/frameplot/layout/order.py +38 -14
- {frameplot-0.5.1 → frameplot-0.5.5}/src/frameplot/layout/place.py +13 -31
- {frameplot-0.5.1 → frameplot-0.5.5}/src/frameplot/layout/route.py +1447 -242
- {frameplot-0.5.1 → frameplot-0.5.5}/src/frameplot/theme.py +39 -29
- {frameplot-0.5.1 → frameplot-0.5.5/src/frameplot.egg-info}/PKG-INFO +3 -3
- {frameplot-0.5.1 → frameplot-0.5.5}/tests/test_rendering.py +702 -19
- {frameplot-0.5.1 → frameplot-0.5.5}/LICENSE +0 -0
- {frameplot-0.5.1 → frameplot-0.5.5}/setup.cfg +0 -0
- {frameplot-0.5.1 → frameplot-0.5.5}/src/frameplot/__init__.py +0 -0
- {frameplot-0.5.1 → frameplot-0.5.5}/src/frameplot/api.py +0 -0
- {frameplot-0.5.1 → frameplot-0.5.5}/src/frameplot/layout/rank.py +0 -0
- {frameplot-0.5.1 → frameplot-0.5.5}/src/frameplot/layout/scc.py +0 -0
- {frameplot-0.5.1 → frameplot-0.5.5}/src/frameplot/layout/text.py +0 -0
- {frameplot-0.5.1 → frameplot-0.5.5}/src/frameplot/layout/types.py +0 -0
- {frameplot-0.5.1 → frameplot-0.5.5}/src/frameplot/layout/validate.py +0 -0
- {frameplot-0.5.1 → frameplot-0.5.5}/src/frameplot/model.py +0 -0
- {frameplot-0.5.1 → frameplot-0.5.5}/src/frameplot/render/__init__.py +0 -0
- {frameplot-0.5.1 → frameplot-0.5.5}/src/frameplot/render/png.py +0 -0
- {frameplot-0.5.1 → frameplot-0.5.5}/src/frameplot/render/svg.py +0 -0
- {frameplot-0.5.1 → frameplot-0.5.5}/src/frameplot.egg-info/SOURCES.txt +0 -0
- {frameplot-0.5.1 → frameplot-0.5.5}/src/frameplot.egg-info/dependency_links.txt +0 -0
- {frameplot-0.5.1 → frameplot-0.5.5}/src/frameplot.egg-info/requires.txt +0 -0
- {frameplot-0.5.1 → frameplot-0.5.5}/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.5
|
|
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
|
|
@@ -50,9 +50,9 @@ All built-in presets stay on a white canvas. The same hero pipeline is rendered
|
|
|
50
50
|
| --- | --- |
|
|
51
51
|
|  |  |
|
|
52
52
|
|
|
53
|
-
|
|
|
53
|
+
| Research | Dark |
|
|
54
54
|
| --- | --- |
|
|
55
|
-
|  |  |
|
|
56
56
|
|
|
57
57
|
| Cyberpunk | Monochrome |
|
|
58
58
|
| --- | --- |
|
|
@@ -21,9 +21,9 @@ All built-in presets stay on a white canvas. The same hero pipeline is rendered
|
|
|
21
21
|
| --- | --- |
|
|
22
22
|
|  |  |
|
|
23
23
|
|
|
24
|
-
|
|
|
24
|
+
| Research | Dark |
|
|
25
25
|
| --- | --- |
|
|
26
|
-
|  |  |
|
|
27
27
|
|
|
28
28
|
| Cyberpunk | Monochrome |
|
|
29
29
|
| --- | --- |
|
|
@@ -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.5"
|
|
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
|
|
|
@@ -153,6 +173,56 @@ def _rank_gap_overrides(
|
|
|
153
173
|
return overrides
|
|
154
174
|
|
|
155
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
|
+
|
|
156
226
|
def _join_target_gap_requirements(
|
|
157
227
|
validated: "ValidatedPipeline | ValidatedDetailPanel",
|
|
158
228
|
placed_nodes: dict[str, LayoutNode],
|
|
@@ -282,6 +352,8 @@ def _build_group_spacing_infos(
|
|
|
282
352
|
if not overlay.group.node_ids:
|
|
283
353
|
continue
|
|
284
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)
|
|
285
357
|
member_left = min(node.x for node in member_nodes)
|
|
286
358
|
member_right = max(node.right for node in member_nodes)
|
|
287
359
|
infos.append(
|
|
@@ -291,8 +363,14 @@ def _build_group_spacing_infos(
|
|
|
291
363
|
group_node_ids=frozenset(overlay.group.node_ids),
|
|
292
364
|
min_rank=min(node.rank for node in member_nodes),
|
|
293
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,
|
|
294
370
|
member_left=member_left,
|
|
295
371
|
member_right=member_right,
|
|
372
|
+
top_inside_clearance=member_top - overlay.bounds.y,
|
|
373
|
+
bottom_inside_clearance=overlay.bounds.bottom - member_bottom,
|
|
296
374
|
left_inside_clearance=member_left - overlay.bounds.x,
|
|
297
375
|
right_inside_clearance=overlay.bounds.right - member_right,
|
|
298
376
|
)
|
|
@@ -301,6 +379,52 @@ def _build_group_spacing_infos(
|
|
|
301
379
|
return tuple(infos)
|
|
302
380
|
|
|
303
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
|
+
|
|
304
428
|
def _nearest_outside_node(
|
|
305
429
|
placed_nodes: dict[str, LayoutNode],
|
|
306
430
|
*,
|
|
@@ -332,6 +456,37 @@ def _nearest_outside_node(
|
|
|
332
456
|
return candidate
|
|
333
457
|
|
|
334
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
|
+
|
|
335
490
|
def _nearest_group_neighbor(
|
|
336
491
|
info: _GroupSpacingInfo,
|
|
337
492
|
group_infos: tuple[_GroupSpacingInfo, ...],
|
|
@@ -364,6 +519,10 @@ def _bounds_overlap_vertically(first: Bounds, second: Bounds) -> bool:
|
|
|
364
519
|
return min(first.bottom, second.bottom) - max(first.y, second.y) > EPSILON
|
|
365
520
|
|
|
366
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
|
+
|
|
367
526
|
def _previous_rank(geometry: "ComponentGeometry", rank: int) -> int | None:
|
|
368
527
|
candidates = [candidate_rank for candidate_rank in geometry.rank_right if candidate_rank < rank]
|
|
369
528
|
if not candidates:
|
|
@@ -371,6 +530,13 @@ def _previous_rank(geometry: "ComponentGeometry", rank: int) -> int | None:
|
|
|
371
530
|
return max(candidates)
|
|
372
531
|
|
|
373
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
|
+
|
|
374
540
|
def _normalize_graph_layout(
|
|
375
541
|
placed_nodes: dict[str, LayoutNode],
|
|
376
542
|
routed_edges: tuple[RoutedEdge, ...],
|
|
@@ -33,7 +33,7 @@ def order_nodes(
|
|
|
33
33
|
_resort_rank(nodes_by_rank[rank], outgoing, order, validated)
|
|
34
34
|
_refresh_order(nodes_by_rank[rank], order)
|
|
35
35
|
|
|
36
|
-
if
|
|
36
|
+
if isinstance(validated, ValidatedPipeline) and validated.detail_panel is not None:
|
|
37
37
|
_apply_detail_panel_bias(validated, nodes_by_rank, ranks, order)
|
|
38
38
|
|
|
39
39
|
return order
|
|
@@ -42,12 +42,18 @@ def order_nodes(
|
|
|
42
42
|
def _forward_neighbors(
|
|
43
43
|
validated: ValidatedPipeline | ValidatedDetailPanel,
|
|
44
44
|
ranks: dict[str, int],
|
|
45
|
+
*,
|
|
46
|
+
included_node_ids: set[str] | None = None,
|
|
45
47
|
) -> tuple[dict[str, list[str]], dict[str, list[str]]]:
|
|
46
48
|
incoming: dict[str, list[str]] = defaultdict(list)
|
|
47
49
|
outgoing: dict[str, list[str]] = defaultdict(list)
|
|
48
50
|
|
|
49
51
|
for edge in validated.edges:
|
|
50
52
|
target_node_id = validated.edge_targets[edge.id].node_id
|
|
53
|
+
if included_node_ids is not None and (
|
|
54
|
+
edge.source not in included_node_ids or target_node_id not in included_node_ids
|
|
55
|
+
):
|
|
56
|
+
continue
|
|
51
57
|
if ranks[edge.source] < ranks[target_node_id]:
|
|
52
58
|
incoming[target_node_id].append(edge.source)
|
|
53
59
|
outgoing[edge.source].append(target_node_id)
|
|
@@ -111,7 +117,6 @@ def _apply_detail_panel_bias(
|
|
|
111
117
|
for index, node_id in enumerate(rank_focus_nodes):
|
|
112
118
|
order[node_id] = start_row + index
|
|
113
119
|
|
|
114
|
-
|
|
115
120
|
def _detail_focus_path_nodes(
|
|
116
121
|
validated: ValidatedPipeline,
|
|
117
122
|
ranks: dict[str, int],
|
|
@@ -151,22 +156,41 @@ def _reachable(start: str, adjacency: dict[str, list[str]]) -> set[str]:
|
|
|
151
156
|
return seen
|
|
152
157
|
|
|
153
158
|
|
|
154
|
-
def
|
|
159
|
+
def _weak_components(validated: ValidatedPipeline | ValidatedDetailPanel) -> list[tuple[str, ...]]:
|
|
155
160
|
adjacency: dict[str, set[str]] = {node.id: set() for node in validated.nodes}
|
|
156
161
|
for edge in validated.edges:
|
|
157
162
|
target_node_id = validated.edge_targets[edge.id].node_id
|
|
158
163
|
adjacency[edge.source].add(target_node_id)
|
|
159
164
|
adjacency[target_node_id].add(edge.source)
|
|
160
165
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
while queue:
|
|
165
|
-
node_id = queue.popleft()
|
|
166
|
-
for neighbor in sorted(adjacency[node_id], key=validated.node_index.__getitem__):
|
|
167
|
-
if neighbor in seen:
|
|
168
|
-
continue
|
|
169
|
-
seen.add(neighbor)
|
|
170
|
-
queue.append(neighbor)
|
|
166
|
+
components: list[tuple[str, ...]] = []
|
|
167
|
+
seen: set[str] = set()
|
|
171
168
|
|
|
172
|
-
|
|
169
|
+
for node in validated.nodes:
|
|
170
|
+
if node.id in seen:
|
|
171
|
+
continue
|
|
172
|
+
queue = deque([node.id])
|
|
173
|
+
seen.add(node.id)
|
|
174
|
+
members: list[str] = []
|
|
175
|
+
while queue:
|
|
176
|
+
node_id = queue.popleft()
|
|
177
|
+
members.append(node_id)
|
|
178
|
+
for neighbor in sorted(adjacency[node_id], key=validated.node_index.__getitem__):
|
|
179
|
+
if neighbor in seen:
|
|
180
|
+
continue
|
|
181
|
+
seen.add(neighbor)
|
|
182
|
+
queue.append(neighbor)
|
|
183
|
+
members.sort(key=validated.node_index.__getitem__)
|
|
184
|
+
components.append(tuple(members))
|
|
185
|
+
|
|
186
|
+
return components
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _weak_component_nodes(
|
|
190
|
+
validated: ValidatedPipeline | ValidatedDetailPanel,
|
|
191
|
+
start_node_id: str,
|
|
192
|
+
) -> set[str]:
|
|
193
|
+
for component in _weak_components(validated):
|
|
194
|
+
if start_node_id in component:
|
|
195
|
+
return set(component)
|
|
196
|
+
return {start_node_id}
|
|
@@ -13,6 +13,8 @@ def place_nodes(
|
|
|
13
13
|
order: dict[str, int],
|
|
14
14
|
*,
|
|
15
15
|
rank_gap_overrides: dict[tuple[int, int], float] | None = None,
|
|
16
|
+
row_gap_overrides: dict[tuple[int, int], float] | None = None,
|
|
17
|
+
row_gap_floor: float | None = None,
|
|
16
18
|
) -> dict[str, LayoutNode]:
|
|
17
19
|
components = _weak_components(validated)
|
|
18
20
|
placed: dict[str, LayoutNode] = {}
|
|
@@ -41,6 +43,9 @@ def place_nodes(
|
|
|
41
43
|
validated,
|
|
42
44
|
component_nodes,
|
|
43
45
|
local_rows,
|
|
46
|
+
component_id=component_id,
|
|
47
|
+
overrides=row_gap_overrides,
|
|
48
|
+
row_gap_floor=row_gap_floor,
|
|
44
49
|
)
|
|
45
50
|
column_widths = {
|
|
46
51
|
rank: max(measurements[node_id].width for node_id in nodes)
|
|
@@ -132,41 +137,18 @@ def _row_gap_after(
|
|
|
132
137
|
validated: "ValidatedPipeline | ValidatedDetailPanel",
|
|
133
138
|
component_nodes: tuple[str, ...],
|
|
134
139
|
rows: dict[str, int],
|
|
140
|
+
*,
|
|
141
|
+
component_id: int,
|
|
142
|
+
overrides: dict[tuple[int, int], float] | None,
|
|
143
|
+
row_gap_floor: float | None,
|
|
135
144
|
) -> dict[int, float]:
|
|
136
145
|
row_ids = sorted({rows[node_id] for node_id in component_nodes})
|
|
137
|
-
|
|
138
|
-
component_set = set(component_nodes)
|
|
139
|
-
|
|
140
|
-
node_padding = {}
|
|
141
|
-
if hasattr(validated, "groups"):
|
|
142
|
-
for group in validated.groups:
|
|
143
|
-
for node_id in group.node_ids:
|
|
144
|
-
node_padding[node_id] = validated.theme.group_padding
|
|
145
|
-
|
|
146
|
-
row_padding: dict[int, float] = {}
|
|
147
|
-
for row in row_ids:
|
|
148
|
-
nodes_in_row = [n for n in component_nodes if rows[n] == row]
|
|
149
|
-
row_padding[row] = max((node_padding.get(n, 0.0) for n in nodes_in_row), default=0.0)
|
|
150
|
-
|
|
151
|
-
for edge in validated.edges:
|
|
152
|
-
target_node_id = validated.edge_targets[edge.id].node_id
|
|
153
|
-
if edge.source not in component_set or target_node_id not in component_set:
|
|
154
|
-
continue
|
|
155
|
-
source_row = rows[edge.source]
|
|
156
|
-
target_row = rows[target_node_id]
|
|
157
|
-
if source_row == target_row:
|
|
158
|
-
continue
|
|
159
|
-
for row in range(min(source_row, target_row), max(source_row, target_row)):
|
|
160
|
-
crossing_counts[row] = crossing_counts.get(row, 0) + 1
|
|
161
|
-
|
|
146
|
+
base_gap = _base_row_gap(validated.theme) if row_gap_floor is None else row_gap_floor
|
|
162
147
|
gap_after: dict[int, float] = {}
|
|
163
|
-
for
|
|
164
|
-
|
|
165
|
-
padding_add = row_padding[row] + row_padding[row_ids[i+1]]
|
|
148
|
+
for row in row_ids[:-1]:
|
|
149
|
+
override_key = (component_id, row)
|
|
166
150
|
gap_after[row] = round(
|
|
167
|
-
|
|
168
|
-
+ max(0, lanes - 1) * validated.theme.route_track_gap
|
|
169
|
-
+ padding_add,
|
|
151
|
+
max(base_gap, overrides.get(override_key, base_gap)) if overrides is not None else base_gap,
|
|
170
152
|
2,
|
|
171
153
|
)
|
|
172
154
|
return gap_after
|