frameplot 0.4.0__tar.gz → 0.5.1__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.
Files changed (27) hide show
  1. {frameplot-0.4.0/src/frameplot.egg-info → frameplot-0.5.1}/PKG-INFO +36 -8
  2. {frameplot-0.4.0 → frameplot-0.5.1}/README.md +35 -7
  3. {frameplot-0.4.0 → frameplot-0.5.1}/pyproject.toml +1 -1
  4. {frameplot-0.4.0 → frameplot-0.5.1}/src/frameplot/layout/__init__.py +81 -0
  5. {frameplot-0.4.0 → frameplot-0.5.1}/src/frameplot/layout/order.py +11 -8
  6. {frameplot-0.4.0 → frameplot-0.5.1}/src/frameplot/layout/place.py +6 -4
  7. {frameplot-0.4.0 → frameplot-0.5.1}/src/frameplot/layout/rank.py +1 -1
  8. {frameplot-0.4.0 → frameplot-0.5.1}/src/frameplot/layout/route.py +667 -28
  9. {frameplot-0.4.0 → frameplot-0.5.1}/src/frameplot/layout/scc.py +1 -1
  10. {frameplot-0.4.0 → frameplot-0.5.1}/src/frameplot/layout/types.py +18 -0
  11. {frameplot-0.4.0 → frameplot-0.5.1}/src/frameplot/layout/validate.py +42 -5
  12. {frameplot-0.4.0 → frameplot-0.5.1}/src/frameplot/model.py +10 -3
  13. {frameplot-0.4.0 → frameplot-0.5.1}/src/frameplot/render/svg.py +92 -2
  14. {frameplot-0.4.0 → frameplot-0.5.1}/src/frameplot/theme.py +35 -4
  15. {frameplot-0.4.0 → frameplot-0.5.1/src/frameplot.egg-info}/PKG-INFO +36 -8
  16. {frameplot-0.4.0 → frameplot-0.5.1}/tests/test_rendering.py +278 -12
  17. {frameplot-0.4.0 → frameplot-0.5.1}/LICENSE +0 -0
  18. {frameplot-0.4.0 → frameplot-0.5.1}/setup.cfg +0 -0
  19. {frameplot-0.4.0 → frameplot-0.5.1}/src/frameplot/__init__.py +0 -0
  20. {frameplot-0.4.0 → frameplot-0.5.1}/src/frameplot/api.py +0 -0
  21. {frameplot-0.4.0 → frameplot-0.5.1}/src/frameplot/layout/text.py +0 -0
  22. {frameplot-0.4.0 → frameplot-0.5.1}/src/frameplot/render/__init__.py +0 -0
  23. {frameplot-0.4.0 → frameplot-0.5.1}/src/frameplot/render/png.py +0 -0
  24. {frameplot-0.4.0 → frameplot-0.5.1}/src/frameplot.egg-info/SOURCES.txt +0 -0
  25. {frameplot-0.4.0 → frameplot-0.5.1}/src/frameplot.egg-info/dependency_links.txt +0 -0
  26. {frameplot-0.4.0 → frameplot-0.5.1}/src/frameplot.egg-info/requires.txt +0 -0
  27. {frameplot-0.4.0 → frameplot-0.5.1}/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.4.0
3
+ Version: 0.5.1
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
@@ -38,7 +38,7 @@ Turn Python-defined pipeline graphs into presentation-ready SVG and PNG diagrams
38
38
 
39
39
  [한국어 README](https://github.com/smturtle2/frameplot/blob/main/README.ko.md)
40
40
 
41
- ![frameplot hero image](docs/assets/frameplot-hero-retro.png)
41
+ ![frameplot hero image](docs/assets/frameplot-hero-soft-retro.png)
42
42
 
43
43
  `frameplot` is a compact Python library for rendering left-to-right pipeline diagrams with clean defaults. Define nodes, edges, groups, and optional detail panels in plain Python, then export polished SVG for documentation or high-resolution PNG for slides and papers.
44
44
 
@@ -46,9 +46,13 @@ 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
- | Retro | Pastel | Dark |
50
- | --- | --- | --- |
51
- | ![Retro theme hero](docs/assets/frameplot-hero-retro.png) | ![Pastel theme hero](docs/assets/frameplot-hero-pastel.png) | ![Dark theme hero](docs/assets/frameplot-hero-dark.png) |
49
+ | Soft Retro | Retro |
50
+ | --- | --- |
51
+ | ![Soft Retro theme hero](docs/assets/frameplot-hero-soft-retro.png) | ![Retro theme hero](docs/assets/frameplot-hero-retro.png) |
52
+
53
+ | Pastel | Dark |
54
+ | --- | --- |
55
+ | ![Pastel theme hero](docs/assets/frameplot-hero-pastel.png) | ![Dark theme hero](docs/assets/frameplot-hero-dark.png) |
52
56
 
53
57
  | Cyberpunk | Monochrome |
54
58
  | --- | --- |
@@ -101,17 +105,41 @@ pipeline.save_png("pipeline.png")
101
105
 
102
106
  ![Quickstart result](docs/assets/quickstart.png)
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
+ ![Edge join result](docs/assets/edge-join.png)
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`
@@ -125,12 +153,12 @@ The hero image at the top and the theme gallery above are generated from [`examp
125
153
 
126
154
  - **Complex Routing**: Seamlessly connecting AWS (S3/Lambda) to GCP (Pub/Sub/Dataflow) services.
127
155
  - **Contextual Details**: Using a `DetailPanel` to explain the internal Spark Job Pipeline of the "Dataflow" node.
128
- - **Retro Editorial Styling**: Applying the built-in `Theme.retro()` preset on a white canvas.
156
+ - **Soft Retro Styling**: Applying the built-in `Theme.soft_retro()` preset on a white canvas.
129
157
 
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
 
@@ -9,7 +9,7 @@ Turn Python-defined pipeline graphs into presentation-ready SVG and PNG diagrams
9
9
 
10
10
  [한국어 README](https://github.com/smturtle2/frameplot/blob/main/README.ko.md)
11
11
 
12
- ![frameplot hero image](docs/assets/frameplot-hero-retro.png)
12
+ ![frameplot hero image](docs/assets/frameplot-hero-soft-retro.png)
13
13
 
14
14
  `frameplot` is a compact Python library for rendering left-to-right pipeline diagrams with clean defaults. Define nodes, edges, groups, and optional detail panels in plain Python, then export polished SVG for documentation or high-resolution PNG for slides and papers.
15
15
 
@@ -17,9 +17,13 @@ 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
- | Retro | Pastel | Dark |
21
- | --- | --- | --- |
22
- | ![Retro theme hero](docs/assets/frameplot-hero-retro.png) | ![Pastel theme hero](docs/assets/frameplot-hero-pastel.png) | ![Dark theme hero](docs/assets/frameplot-hero-dark.png) |
20
+ | Soft Retro | Retro |
21
+ | --- | --- |
22
+ | ![Soft Retro theme hero](docs/assets/frameplot-hero-soft-retro.png) | ![Retro theme hero](docs/assets/frameplot-hero-retro.png) |
23
+
24
+ | Pastel | Dark |
25
+ | --- | --- |
26
+ | ![Pastel theme hero](docs/assets/frameplot-hero-pastel.png) | ![Dark theme hero](docs/assets/frameplot-hero-dark.png) |
23
27
 
24
28
  | Cyberpunk | Monochrome |
25
29
  | --- | --- |
@@ -72,17 +76,41 @@ pipeline.save_png("pipeline.png")
72
76
 
73
77
  ![Quickstart result](docs/assets/quickstart.png)
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
+ ![Edge join result](docs/assets/edge-join.png)
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`
@@ -96,12 +124,12 @@ The hero image at the top and the theme gallery above are generated from [`examp
96
124
 
97
125
  - **Complex Routing**: Seamlessly connecting AWS (S3/Lambda) to GCP (Pub/Sub/Dataflow) services.
98
126
  - **Contextual Details**: Using a `DetailPanel` to explain the internal Spark Job Pipeline of the "Dataflow" node.
99
- - **Retro Editorial Styling**: Applying the built-in `Theme.retro()` preset on a white canvas.
127
+ - **Soft Retro Styling**: Applying the built-in `Theme.soft_retro()` preset on a white canvas.
100
128
 
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.4.0"
7
+ version = "0.5.1"
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"
@@ -139,6 +139,13 @@ def _rank_gap_overrides(
139
139
  ).items():
140
140
  required_gaps[key] = round(max(required_gaps.get(key, 0.0), required_gap), 2)
141
141
 
142
+ for key, required_gap in _join_target_gap_requirements(
143
+ validated,
144
+ placed_nodes,
145
+ routed_edges,
146
+ ).items():
147
+ required_gaps[key] = round(max(required_gaps.get(key, 0.0), required_gap), 2)
148
+
142
149
  for key, required_gap in required_gaps.items():
143
150
  if required_gap > base_gap + EPSILON:
144
151
  overrides[key] = round(required_gap, 2)
@@ -146,6 +153,64 @@ def _rank_gap_overrides(
146
153
  return overrides
147
154
 
148
155
 
156
+ def _join_target_gap_requirements(
157
+ validated: "ValidatedPipeline | ValidatedDetailPanel",
158
+ placed_nodes: dict[str, LayoutNode],
159
+ routed_edges: tuple[RoutedEdge, ...],
160
+ ) -> dict[tuple[int, int], float]:
161
+ route_by_id = {route.edge.id: route for route in routed_edges}
162
+ joins_by_target_segment: dict[tuple[str, int], list[RoutedEdge]] = {}
163
+
164
+ for routed_edge in routed_edges:
165
+ if routed_edge.target_kind != "edge" or routed_edge.target_edge_id is None:
166
+ continue
167
+ if routed_edge.join_segment_index is None:
168
+ continue
169
+ joins_by_target_segment.setdefault(
170
+ (routed_edge.target_edge_id, routed_edge.join_segment_index),
171
+ [],
172
+ ).append(routed_edge)
173
+
174
+ required_gaps: dict[tuple[int, int], float] = {}
175
+ badge_diameter = max(validated.theme.arrow_size * 1.8, validated.theme.stroke_width * 6.0)
176
+ join_spacing = max(validated.theme.arrow_size * 1.5, validated.theme.stroke_width * 6.0)
177
+
178
+ for (target_edge_id, join_segment_index), joins in joins_by_target_segment.items():
179
+ target_edge = validated.edge_lookup[target_edge_id]
180
+ target_target = validated.edge_targets[target_edge_id]
181
+ source_node = placed_nodes[target_edge.source]
182
+ target_node = placed_nodes[target_target.node_id]
183
+ target_route = route_by_id.get(target_edge_id)
184
+ if target_route is None:
185
+ continue
186
+ if source_node.component_id != target_node.component_id:
187
+ continue
188
+ if source_node.rank >= target_node.rank or source_node.order != target_node.order:
189
+ continue
190
+ if join_segment_index >= len(target_route.points) - 1:
191
+ continue
192
+
193
+ start = target_route.points[join_segment_index]
194
+ end = target_route.points[join_segment_index + 1]
195
+ if start.y != end.y:
196
+ continue
197
+
198
+ current_segment_length = abs(end.x - start.x)
199
+ required_segment_length = (
200
+ validated.theme.route_track_gap
201
+ + badge_diameter * len(joins)
202
+ + join_spacing * max(0, len(joins) - 1)
203
+ )
204
+ if current_segment_length >= required_segment_length - EPSILON:
205
+ continue
206
+
207
+ required_gap = validated.theme.route_track_gap + (required_segment_length - current_segment_length)
208
+ key = (source_node.component_id, source_node.rank)
209
+ required_gaps[key] = max(required_gaps.get(key, 0.0), round(required_gap, 2))
210
+
211
+ return required_gaps
212
+
213
+
149
214
  def _group_boundary_gap_requirements(
150
215
  placed_nodes: dict[str, LayoutNode],
151
216
  overlays: tuple[GroupOverlay, ...],
@@ -506,6 +571,22 @@ def _shift_edge(route: RoutedEdge, shift_x: float, shift_y: float) -> RoutedEdge
506
571
  height=route.bounds.height,
507
572
  ),
508
573
  stroke=route.stroke,
574
+ target_kind=route.target_kind,
575
+ target_node_id=route.target_node_id,
576
+ target_edge_id=route.target_edge_id,
577
+ join_point=(
578
+ Point(route.join_point.x + shift_x, route.join_point.y + shift_y)
579
+ if route.join_point is not None
580
+ else None
581
+ ),
582
+ badge_center=(
583
+ Point(route.badge_center.x + shift_x, route.badge_center.y + shift_y)
584
+ if route.badge_center is not None
585
+ else None
586
+ ),
587
+ join_segment_index=route.join_segment_index,
588
+ show_arrowhead=route.show_arrowhead,
589
+ join_badge_radius=route.join_badge_radius,
509
590
  )
510
591
 
511
592
 
@@ -47,9 +47,10 @@ def _forward_neighbors(
47
47
  outgoing: dict[str, list[str]] = defaultdict(list)
48
48
 
49
49
  for edge in validated.edges:
50
- if ranks[edge.source] < ranks[edge.target]:
51
- incoming[edge.target].append(edge.source)
52
- outgoing[edge.source].append(edge.target)
50
+ target_node_id = validated.edge_targets[edge.id].node_id
51
+ if ranks[edge.source] < ranks[target_node_id]:
52
+ incoming[target_node_id].append(edge.source)
53
+ outgoing[edge.source].append(target_node_id)
53
54
 
54
55
  return incoming, outgoing
55
56
 
@@ -120,10 +121,11 @@ def _detail_focus_path_nodes(
120
121
  forward_incoming: dict[str, list[str]] = defaultdict(list)
121
122
 
122
123
  for edge in validated.edges:
123
- if edge.dashed or ranks[edge.source] >= ranks[edge.target]:
124
+ target_node_id = validated.edge_targets[edge.id].node_id
125
+ if edge.dashed or ranks[edge.source] >= ranks[target_node_id]:
124
126
  continue
125
- forward_outgoing[edge.source].append(edge.target)
126
- forward_incoming[edge.target].append(edge.source)
127
+ forward_outgoing[edge.source].append(target_node_id)
128
+ forward_incoming[target_node_id].append(edge.source)
127
129
 
128
130
  ancestors = _reachable(focus_node_id, forward_incoming)
129
131
  descendants = _reachable(focus_node_id, forward_outgoing)
@@ -152,8 +154,9 @@ def _reachable(start: str, adjacency: dict[str, list[str]]) -> set[str]:
152
154
  def _weak_component_nodes(validated: ValidatedPipeline, start_node_id: str) -> set[str]:
153
155
  adjacency: dict[str, set[str]] = {node.id: set() for node in validated.nodes}
154
156
  for edge in validated.edges:
155
- adjacency[edge.source].add(edge.target)
156
- adjacency[edge.target].add(edge.source)
157
+ target_node_id = validated.edge_targets[edge.id].node_id
158
+ adjacency[edge.source].add(target_node_id)
159
+ adjacency[target_node_id].add(edge.source)
157
160
 
158
161
  seen = {start_node_id}
159
162
  queue = deque([start_node_id])
@@ -101,8 +101,9 @@ def place_nodes(
101
101
  def _weak_components(validated: "ValidatedPipeline | ValidatedDetailPanel") -> list[tuple[str, ...]]:
102
102
  adjacency: dict[str, set[str]] = {node.id: set() for node in validated.nodes}
103
103
  for edge in validated.edges:
104
- adjacency[edge.source].add(edge.target)
105
- adjacency[edge.target].add(edge.source)
104
+ target_node_id = validated.edge_targets[edge.id].node_id
105
+ adjacency[edge.source].add(target_node_id)
106
+ adjacency[target_node_id].add(edge.source)
106
107
 
107
108
  components: list[tuple[str, ...]] = []
108
109
  seen: set[str] = set()
@@ -148,10 +149,11 @@ def _row_gap_after(
148
149
  row_padding[row] = max((node_padding.get(n, 0.0) for n in nodes_in_row), default=0.0)
149
150
 
150
151
  for edge in validated.edges:
151
- if edge.source not in component_set or edge.target not in component_set:
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:
152
154
  continue
153
155
  source_row = rows[edge.source]
154
- target_row = rows[edge.target]
156
+ target_row = rows[target_node_id]
155
157
  if source_row == target_row:
156
158
  continue
157
159
  for row in range(min(source_row, target_row), max(source_row, target_row)):
@@ -14,7 +14,7 @@ def assign_ranks(validated: ValidatedPipeline, scc_result: SccResult) -> dict[st
14
14
 
15
15
  for edge in validated.edges:
16
16
  source_component = scc_result.node_to_component[edge.source]
17
- target_component = scc_result.node_to_component[edge.target]
17
+ target_component = scc_result.node_to_component[validated.edge_targets[edge.id].node_id]
18
18
  if source_component == target_component:
19
19
  continue
20
20
  predecessors[target_component].add(source_component)