frameplot 0.5.1__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.1/src/frameplot.egg-info → frameplot-0.5.3}/PKG-INFO +3 -3
- {frameplot-0.5.1 → frameplot-0.5.3}/README.md +2 -2
- {frameplot-0.5.1 → frameplot-0.5.3}/pyproject.toml +1 -1
- {frameplot-0.5.1 → frameplot-0.5.3}/src/frameplot/layout/__init__.py +174 -8
- {frameplot-0.5.1 → frameplot-0.5.3}/src/frameplot/layout/order.py +160 -13
- {frameplot-0.5.1 → frameplot-0.5.3}/src/frameplot/layout/place.py +13 -31
- {frameplot-0.5.1 → frameplot-0.5.3}/src/frameplot/layout/route.py +1411 -242
- {frameplot-0.5.1 → frameplot-0.5.3}/src/frameplot/theme.py +39 -29
- {frameplot-0.5.1 → frameplot-0.5.3/src/frameplot.egg-info}/PKG-INFO +3 -3
- {frameplot-0.5.1 → frameplot-0.5.3}/tests/test_rendering.py +676 -19
- {frameplot-0.5.1 → frameplot-0.5.3}/LICENSE +0 -0
- {frameplot-0.5.1 → frameplot-0.5.3}/setup.cfg +0 -0
- {frameplot-0.5.1 → frameplot-0.5.3}/src/frameplot/__init__.py +0 -0
- {frameplot-0.5.1 → frameplot-0.5.3}/src/frameplot/api.py +0 -0
- {frameplot-0.5.1 → frameplot-0.5.3}/src/frameplot/layout/rank.py +0 -0
- {frameplot-0.5.1 → frameplot-0.5.3}/src/frameplot/layout/scc.py +0 -0
- {frameplot-0.5.1 → frameplot-0.5.3}/src/frameplot/layout/text.py +0 -0
- {frameplot-0.5.1 → frameplot-0.5.3}/src/frameplot/layout/types.py +0 -0
- {frameplot-0.5.1 → frameplot-0.5.3}/src/frameplot/layout/validate.py +0 -0
- {frameplot-0.5.1 → frameplot-0.5.3}/src/frameplot/model.py +0 -0
- {frameplot-0.5.1 → frameplot-0.5.3}/src/frameplot/render/__init__.py +0 -0
- {frameplot-0.5.1 → frameplot-0.5.3}/src/frameplot/render/png.py +0 -0
- {frameplot-0.5.1 → frameplot-0.5.3}/src/frameplot/render/svg.py +0 -0
- {frameplot-0.5.1 → frameplot-0.5.3}/src/frameplot.egg-info/SOURCES.txt +0 -0
- {frameplot-0.5.1 → frameplot-0.5.3}/src/frameplot.egg-info/dependency_links.txt +0 -0
- {frameplot-0.5.1 → frameplot-0.5.3}/src/frameplot.egg-info/requires.txt +0 -0
- {frameplot-0.5.1 → 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
|
|
@@ -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.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
|
|
|
@@ -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,10 @@ 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, ValidatedDetailPanel):
|
|
37
|
+
_apply_detail_panel_shared_source_lanes(validated, ranks, order)
|
|
38
|
+
|
|
39
|
+
if isinstance(validated, ValidatedPipeline) and validated.detail_panel is not None:
|
|
37
40
|
_apply_detail_panel_bias(validated, nodes_by_rank, ranks, order)
|
|
38
41
|
|
|
39
42
|
return order
|
|
@@ -42,12 +45,18 @@ def order_nodes(
|
|
|
42
45
|
def _forward_neighbors(
|
|
43
46
|
validated: ValidatedPipeline | ValidatedDetailPanel,
|
|
44
47
|
ranks: dict[str, int],
|
|
48
|
+
*,
|
|
49
|
+
included_node_ids: set[str] | None = None,
|
|
45
50
|
) -> tuple[dict[str, list[str]], dict[str, list[str]]]:
|
|
46
51
|
incoming: dict[str, list[str]] = defaultdict(list)
|
|
47
52
|
outgoing: dict[str, list[str]] = defaultdict(list)
|
|
48
53
|
|
|
49
54
|
for edge in validated.edges:
|
|
50
55
|
target_node_id = validated.edge_targets[edge.id].node_id
|
|
56
|
+
if included_node_ids is not None and (
|
|
57
|
+
edge.source not in included_node_ids or target_node_id not in included_node_ids
|
|
58
|
+
):
|
|
59
|
+
continue
|
|
51
60
|
if ranks[edge.source] < ranks[target_node_id]:
|
|
52
61
|
incoming[target_node_id].append(edge.source)
|
|
53
62
|
outgoing[edge.source].append(target_node_id)
|
|
@@ -112,6 +121,125 @@ def _apply_detail_panel_bias(
|
|
|
112
121
|
order[node_id] = start_row + index
|
|
113
122
|
|
|
114
123
|
|
|
124
|
+
def _apply_detail_panel_shared_source_lanes(
|
|
125
|
+
validated: ValidatedDetailPanel,
|
|
126
|
+
ranks: dict[str, int],
|
|
127
|
+
order: dict[str, int],
|
|
128
|
+
) -> None:
|
|
129
|
+
group_membership = _group_membership(validated)
|
|
130
|
+
components = _weak_components(validated)
|
|
131
|
+
incoming, outgoing = _forward_neighbors(validated, ranks)
|
|
132
|
+
|
|
133
|
+
for component_nodes in components:
|
|
134
|
+
component_set = set(component_nodes)
|
|
135
|
+
shared_sources = [
|
|
136
|
+
node_id
|
|
137
|
+
for node_id in component_nodes
|
|
138
|
+
if _is_detail_panel_shared_source(
|
|
139
|
+
node_id,
|
|
140
|
+
component_set,
|
|
141
|
+
group_membership,
|
|
142
|
+
incoming,
|
|
143
|
+
outgoing,
|
|
144
|
+
order,
|
|
145
|
+
)
|
|
146
|
+
]
|
|
147
|
+
if not shared_sources:
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
shared_sources.sort(key=lambda node_id: (order[node_id], validated.node_index[node_id], node_id))
|
|
151
|
+
shared_source_ids = set(shared_sources)
|
|
152
|
+
nonshared_rows = _reorder_component_nodes(
|
|
153
|
+
validated,
|
|
154
|
+
ranks,
|
|
155
|
+
component_set - shared_source_ids,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
for index, node_id in enumerate(shared_sources):
|
|
159
|
+
order[node_id] = index
|
|
160
|
+
for node_id, row in nonshared_rows.items():
|
|
161
|
+
order[node_id] = row + len(shared_sources)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _is_detail_panel_shared_source(
|
|
165
|
+
node_id: str,
|
|
166
|
+
component_nodes: set[str],
|
|
167
|
+
group_membership: dict[str, tuple[str, ...]],
|
|
168
|
+
incoming: dict[str, list[str]],
|
|
169
|
+
outgoing: dict[str, list[str]],
|
|
170
|
+
order: dict[str, int],
|
|
171
|
+
) -> bool:
|
|
172
|
+
if group_membership.get(node_id):
|
|
173
|
+
return False
|
|
174
|
+
if incoming.get(node_id):
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
targets = [target_id for target_id in outgoing.get(node_id, ()) if target_id in component_nodes]
|
|
178
|
+
if len(set(targets)) < 2:
|
|
179
|
+
return False
|
|
180
|
+
|
|
181
|
+
downstream_lanes = {
|
|
182
|
+
(
|
|
183
|
+
order[target_id],
|
|
184
|
+
group_membership.get(target_id, ()),
|
|
185
|
+
)
|
|
186
|
+
for target_id in targets
|
|
187
|
+
}
|
|
188
|
+
return len(downstream_lanes) >= 2
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _group_membership(
|
|
192
|
+
validated: ValidatedPipeline | ValidatedDetailPanel,
|
|
193
|
+
) -> dict[str, tuple[str, ...]]:
|
|
194
|
+
memberships: dict[str, list[str]] = defaultdict(list)
|
|
195
|
+
for group in validated.groups:
|
|
196
|
+
for node_id in group.node_ids:
|
|
197
|
+
memberships[node_id].append(group.id)
|
|
198
|
+
return {
|
|
199
|
+
node_id: tuple(sorted(group_ids))
|
|
200
|
+
for node_id, group_ids in memberships.items()
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _reorder_component_nodes(
|
|
205
|
+
validated: ValidatedPipeline | ValidatedDetailPanel,
|
|
206
|
+
ranks: dict[str, int],
|
|
207
|
+
included_node_ids: set[str],
|
|
208
|
+
) -> dict[str, int]:
|
|
209
|
+
if not included_node_ids:
|
|
210
|
+
return {}
|
|
211
|
+
|
|
212
|
+
nodes_by_rank: dict[int, list[str]] = defaultdict(list)
|
|
213
|
+
for node_id in included_node_ids:
|
|
214
|
+
nodes_by_rank[ranks[node_id]].append(node_id)
|
|
215
|
+
|
|
216
|
+
for rank_nodes in nodes_by_rank.values():
|
|
217
|
+
rank_nodes.sort(key=validated.node_index.__getitem__)
|
|
218
|
+
|
|
219
|
+
local_order = {
|
|
220
|
+
node_id: index
|
|
221
|
+
for rank_nodes in nodes_by_rank.values()
|
|
222
|
+
for index, node_id in enumerate(rank_nodes)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
incoming, outgoing = _forward_neighbors(
|
|
226
|
+
validated,
|
|
227
|
+
ranks,
|
|
228
|
+
included_node_ids=included_node_ids,
|
|
229
|
+
)
|
|
230
|
+
ordered_ranks = sorted(nodes_by_rank)
|
|
231
|
+
|
|
232
|
+
for _ in range(4):
|
|
233
|
+
for rank in ordered_ranks[1:]:
|
|
234
|
+
_resort_rank(nodes_by_rank[rank], incoming, local_order, validated)
|
|
235
|
+
_refresh_order(nodes_by_rank[rank], local_order)
|
|
236
|
+
for rank in reversed(ordered_ranks[:-1]):
|
|
237
|
+
_resort_rank(nodes_by_rank[rank], outgoing, local_order, validated)
|
|
238
|
+
_refresh_order(nodes_by_rank[rank], local_order)
|
|
239
|
+
|
|
240
|
+
return local_order
|
|
241
|
+
|
|
242
|
+
|
|
115
243
|
def _detail_focus_path_nodes(
|
|
116
244
|
validated: ValidatedPipeline,
|
|
117
245
|
ranks: dict[str, int],
|
|
@@ -151,22 +279,41 @@ def _reachable(start: str, adjacency: dict[str, list[str]]) -> set[str]:
|
|
|
151
279
|
return seen
|
|
152
280
|
|
|
153
281
|
|
|
154
|
-
def
|
|
282
|
+
def _weak_components(validated: ValidatedPipeline | ValidatedDetailPanel) -> list[tuple[str, ...]]:
|
|
155
283
|
adjacency: dict[str, set[str]] = {node.id: set() for node in validated.nodes}
|
|
156
284
|
for edge in validated.edges:
|
|
157
285
|
target_node_id = validated.edge_targets[edge.id].node_id
|
|
158
286
|
adjacency[edge.source].add(target_node_id)
|
|
159
287
|
adjacency[target_node_id].add(edge.source)
|
|
160
288
|
|
|
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)
|
|
289
|
+
components: list[tuple[str, ...]] = []
|
|
290
|
+
seen: set[str] = set()
|
|
171
291
|
|
|
172
|
-
|
|
292
|
+
for node in validated.nodes:
|
|
293
|
+
if node.id in seen:
|
|
294
|
+
continue
|
|
295
|
+
queue = deque([node.id])
|
|
296
|
+
seen.add(node.id)
|
|
297
|
+
members: list[str] = []
|
|
298
|
+
while queue:
|
|
299
|
+
node_id = queue.popleft()
|
|
300
|
+
members.append(node_id)
|
|
301
|
+
for neighbor in sorted(adjacency[node_id], key=validated.node_index.__getitem__):
|
|
302
|
+
if neighbor in seen:
|
|
303
|
+
continue
|
|
304
|
+
seen.add(neighbor)
|
|
305
|
+
queue.append(neighbor)
|
|
306
|
+
members.sort(key=validated.node_index.__getitem__)
|
|
307
|
+
components.append(tuple(members))
|
|
308
|
+
|
|
309
|
+
return components
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _weak_component_nodes(
|
|
313
|
+
validated: ValidatedPipeline | ValidatedDetailPanel,
|
|
314
|
+
start_node_id: str,
|
|
315
|
+
) -> set[str]:
|
|
316
|
+
for component in _weak_components(validated):
|
|
317
|
+
if start_node_id in component:
|
|
318
|
+
return set(component)
|
|
319
|
+
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
|