openprocess 0.7.0__py3-none-any.whl

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 (173) hide show
  1. cpnpy/__init__.py +46 -0
  2. openprocess/__init__.py +57 -0
  3. openprocess/analysis/__init__.py +0 -0
  4. openprocess/analysis/state_space.py +521 -0
  5. openprocess/analysis/state_space_process.py +251 -0
  6. openprocess/cli.py +742 -0
  7. openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/answer.pnml +31 -0
  8. openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/question.md +38 -0
  9. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/answer.pnml +27 -0
  10. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/net.pnml +29 -0
  11. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/question.md +65 -0
  12. openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/log.txt +1 -0
  13. openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/question.md +67 -0
  14. openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/question.md +75 -0
  15. openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/ts.txt +5 -0
  16. openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/net.pnml +27 -0
  17. openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/question.md +65 -0
  18. openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/log.txt +1 -0
  19. openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/question.md +62 -0
  20. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/log.txt +1 -0
  21. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m1.pnml +36 -0
  22. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m2.pnml +28 -0
  23. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m3.pnml +30 -0
  24. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/net.pnml +36 -0
  25. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/question.md +69 -0
  26. openprocess/exercises/pack.md +14 -0
  27. openprocess/flow/__init__.py +50 -0
  28. openprocess/flow/box.py +466 -0
  29. openprocess/flow/boxes/__init__.py +7 -0
  30. openprocess/flow/boxes/check.py +119 -0
  31. openprocess/flow/boxes/compare.py +16 -0
  32. openprocess/flow/boxes/cpn.py +53 -0
  33. openprocess/flow/boxes/discover.py +124 -0
  34. openprocess/flow/boxes/filter.py +80 -0
  35. openprocess/flow/boxes/input.py +124 -0
  36. openprocess/flow/boxes/output.py +52 -0
  37. openprocess/flow/boxes/predict.py +186 -0
  38. openprocess/flow/boxes/science.py +159 -0
  39. openprocess/flow/boxes/sweeps.py +18 -0
  40. openprocess/flow/convert.py +187 -0
  41. openprocess/flow/datasets.py +198 -0
  42. openprocess/flow/explain.py +115 -0
  43. openprocess/flow/library.py +222 -0
  44. openprocess/flow/record.py +385 -0
  45. openprocess/flow/runner.py +357 -0
  46. openprocess/flow/sweep.py +92 -0
  47. openprocess/flow/types.py +290 -0
  48. openprocess/flow/workflow.py +628 -0
  49. openprocess/gui/__init__.py +0 -0
  50. openprocess/gui/app.py +90 -0
  51. openprocess/gui/arc_editing.py +295 -0
  52. openprocess/gui/canvas.py +1414 -0
  53. openprocess/gui/flow/__init__.py +8 -0
  54. openprocess/gui/flow/canvas.py +854 -0
  55. openprocess/gui/flow/page.py +972 -0
  56. openprocess/gui/flow/templates.py +131 -0
  57. openprocess/gui/flow/viewers.py +665 -0
  58. openprocess/gui/items.py +1275 -0
  59. openprocess/gui/learn/answer_boxes.py +978 -0
  60. openprocess/gui/learn/concealment.py +91 -0
  61. openprocess/gui/learn/mode.py +1181 -0
  62. openprocess/gui/panning.py +241 -0
  63. openprocess/gui/resources/openprocess-icon.png +0 -0
  64. openprocess/gui/studio/__init__.py +1 -0
  65. openprocess/gui/studio/__main__.py +3 -0
  66. openprocess/gui/studio/app.py +4031 -0
  67. openprocess/gui/studio/charts.py +115 -0
  68. openprocess/gui/studio/compare_page.py +487 -0
  69. openprocess/gui/studio/cpn_page.py +1858 -0
  70. openprocess/gui/studio/definition_view.py +284 -0
  71. openprocess/gui/studio/derivation_view.py +421 -0
  72. openprocess/gui/studio/documents.py +152 -0
  73. openprocess/gui/studio/dotted_chart.py +1401 -0
  74. openprocess/gui/studio/file_dialogs.py +143 -0
  75. openprocess/gui/studio/filter_dialog.py +247 -0
  76. openprocess/gui/studio/graph_builders.py +176 -0
  77. openprocess/gui/studio/graph_view.py +682 -0
  78. openprocess/gui/studio/instances.py +413 -0
  79. openprocess/gui/studio/log_editor.py +675 -0
  80. openprocess/gui/studio/log_page.py +800 -0
  81. openprocess/gui/studio/markdown_view.py +127 -0
  82. openprocess/gui/studio/mathtext.py +260 -0
  83. openprocess/gui/studio/ml_highlighter.py +75 -0
  84. openprocess/gui/studio/model_page.py +760 -0
  85. openprocess/gui/studio/net_comparison.py +124 -0
  86. openprocess/gui/studio/notes_overlay.py +275 -0
  87. openprocess/gui/studio/petri_page.py +844 -0
  88. openprocess/gui/studio/regions_view.py +502 -0
  89. openprocess/gui/studio/sidebar.py +149 -0
  90. openprocess/gui/studio/style.py +503 -0
  91. openprocess/gui/studio/tool_icons.py +134 -0
  92. openprocess/gui/studio/updates.py +439 -0
  93. openprocess/gui/studio/widgets.py +899 -0
  94. openprocess/gui/studio/workers.py +60 -0
  95. openprocess/gui/studio/workspace.py +447 -0
  96. openprocess/gui/theme.py +394 -0
  97. openprocess/gui/tidy.py +86 -0
  98. openprocess/io/__init__.py +0 -0
  99. openprocess/io/cpn_reader.py +389 -0
  100. openprocess/io/cpn_writer.py +357 -0
  101. openprocess/learn/__init__.py +23 -0
  102. openprocess/learn/answers.py +188 -0
  103. openprocess/learn/checks.py +953 -0
  104. openprocess/learn/computed.py +1180 -0
  105. openprocess/learn/context.py +145 -0
  106. openprocess/learn/exam.py +169 -0
  107. openprocess/learn/exercise-packs.md +325 -0
  108. openprocess/learn/importer.py +216 -0
  109. openprocess/learn/notation.py +474 -0
  110. openprocess/learn/pack.py +511 -0
  111. openprocess/learn/sheet.py +296 -0
  112. openprocess/mining/__init__.py +73 -0
  113. openprocess/mining/analysis.py +689 -0
  114. openprocess/mining/columns.py +282 -0
  115. openprocess/mining/compare_nets.py +246 -0
  116. openprocess/mining/conformance/__init__.py +0 -0
  117. openprocess/mining/conformance/alignments.py +263 -0
  118. openprocess/mining/conformance/quality.py +145 -0
  119. openprocess/mining/conformance/token_replay.py +252 -0
  120. openprocess/mining/csv_import.py +222 -0
  121. openprocess/mining/definitions.py +584 -0
  122. openprocess/mining/dfg.py +187 -0
  123. openprocess/mining/discovery/__init__.py +0 -0
  124. openprocess/mining/discovery/alpha.py +168 -0
  125. openprocess/mining/discovery/heuristics.py +332 -0
  126. openprocess/mining/discovery/inductive.py +477 -0
  127. openprocess/mining/discovery/state_regions.py +62 -0
  128. openprocess/mining/filtering.py +237 -0
  129. openprocess/mining/footprint.py +183 -0
  130. openprocess/mining/invariants.py +191 -0
  131. openprocess/mining/layout.py +279 -0
  132. openprocess/mining/log.py +364 -0
  133. openprocess/mining/petrinet.py +354 -0
  134. openprocess/mining/playout.py +75 -0
  135. openprocess/mining/pm4py_bridge.py +82 -0
  136. openprocess/mining/pnml.py +223 -0
  137. openprocess/mining/processtree.py +216 -0
  138. openprocess/mining/regions.py +476 -0
  139. openprocess/mining/stats.py +160 -0
  140. openprocess/mining/structure.py +374 -0
  141. openprocess/mining/transition_system.py +409 -0
  142. openprocess/mining/xes.py +399 -0
  143. openprocess/ml/__init__.py +0 -0
  144. openprocess/ml/ast_nodes.py +332 -0
  145. openprocess/ml/builtins.py +364 -0
  146. openprocess/ml/colorsets.py +522 -0
  147. openprocess/ml/errors.py +60 -0
  148. openprocess/ml/evaluator.py +754 -0
  149. openprocess/ml/lexer.py +277 -0
  150. openprocess/ml/multiset.py +417 -0
  151. openprocess/ml/parser.py +737 -0
  152. openprocess/ml/values.py +319 -0
  153. openprocess/model/__init__.py +0 -0
  154. openprocess/model/declarations.py +617 -0
  155. openprocess/model/examples.py +98 -0
  156. openprocess/model/net.py +701 -0
  157. openprocess/model/plain.py +192 -0
  158. openprocess/references.py +280 -0
  159. openprocess/sim/__init__.py +0 -0
  160. openprocess/sim/binding.py +620 -0
  161. openprocess/sim/export.py +66 -0
  162. openprocess/sim/simulator.py +315 -0
  163. openprocess/teaching/__init__.py +4 -0
  164. openprocess/teaching/answers.py +4 -0
  165. openprocess/teaching/checks.py +5 -0
  166. openprocess/teaching/pack.py +4 -0
  167. openprocess/teaching/sheet.py +4 -0
  168. openprocess-0.7.0.dist-info/METADATA +927 -0
  169. openprocess-0.7.0.dist-info/RECORD +173 -0
  170. openprocess-0.7.0.dist-info/WHEEL +5 -0
  171. openprocess-0.7.0.dist-info/entry_points.txt +6 -0
  172. openprocess-0.7.0.dist-info/licenses/LICENSE +21 -0
  173. openprocess-0.7.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,682 @@
1
+ """A zoomable canvas for laid-out graphs, and builders for each graph kind.
2
+
3
+ One canvas serves every picture in the mining workspace:
4
+
5
+ * Petri nets (discovered or imported), optionally with a live marking for
6
+ the token game and with conformance overlays;
7
+ * directly-follows graphs ("process maps") in frequency or performance mode;
8
+ * dependency graphs from the Heuristics Miner;
9
+ * reachability / coverability graphs.
10
+
11
+ Positions come from :func:`openprocess.mining.layout.layered_layout` unless the
12
+ model already carries coordinates (a PNML file drawn by another tool).
13
+
14
+ Interaction: scroll to pan (trackpad friendly), pinch or ⌘/Ctrl + scroll to
15
+ zoom, drag the background to pan, click a node to emit ``node_clicked``.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import math
21
+ from dataclasses import dataclass, field
22
+
23
+ from PySide6.QtCore import QPointF, QRectF, QSize, Qt, Signal
24
+ from PySide6.QtGui import (
25
+ QBrush, QColor, QFont, QFontMetricsF, QImage, QPainter, QPainterPath, QPen,
26
+ )
27
+ from PySide6.QtWidgets import (
28
+ QFrame, QGraphicsItem, QGraphicsObject, QGraphicsPathItem, QGraphicsScene, QGraphicsView,
29
+ QHBoxLayout, QLabel, QToolButton,
30
+ )
31
+
32
+ from ...mining.layout import layered_layout
33
+ from ..panning import CanvasPanning
34
+ from . import style
35
+ from .widgets import shortcut_text
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Scene description (what to draw), independent of Qt items
40
+ # ---------------------------------------------------------------------------
41
+ @dataclass
42
+ class NodeSpec:
43
+ id: str
44
+ kind: str # place | transition | silent | activity | start | end | state
45
+ text: str = ""
46
+ subtext: str = "" # second line (counts, durations)
47
+ below: str = "" # label under the node (place names)
48
+ fill: str | None = None
49
+ stroke: str | None = None
50
+ tokens: int = 0
51
+ badges: list[tuple[str, str]] = field(default_factory=list) # (text, colour)
52
+ tooltip: str = ""
53
+ emphasis: bool = False # thicker outline (enabled / initial state)
54
+ dimmed: bool = False
55
+
56
+
57
+ @dataclass
58
+ class EdgeSpec:
59
+ source: str
60
+ target: str
61
+ text: str = ""
62
+ width: float = 1.4
63
+ colour: str | None = None
64
+ dashed: bool = False
65
+ tooltip: str = ""
66
+ arrow: bool = True # False: a plain line (a tree's "contains")
67
+
68
+
69
+ def _font(size: float = 11.5, bold: bool = False) -> QFont:
70
+ from .. import theme
71
+ font = theme.ui_font(size)
72
+ font.setBold(bold)
73
+ return font
74
+
75
+
76
+ def node_size(spec: NodeSpec) -> tuple[float, float]:
77
+ metrics = QFontMetricsF(_font(12, True))
78
+ if spec.kind == "place":
79
+ return 36.0, 36.0
80
+ if spec.kind in ("start", "end"):
81
+ return 26.0, 26.0
82
+ if spec.kind == "operator":
83
+ return 34.0, 34.0
84
+ if spec.kind == "silent":
85
+ return 14.0, 40.0
86
+ if spec.kind == "state":
87
+ small = QFontMetricsF(_font(10.5))
88
+ return max(small.horizontalAdvance(spec.text) + 22, 44.0), 30.0
89
+ width = metrics.horizontalAdvance(spec.text) + 28
90
+ if spec.subtext:
91
+ width = max(width, QFontMetricsF(_font(10.5)).horizontalAdvance(spec.subtext) + 28)
92
+ if spec.kind == "activity":
93
+ return max(width, 104.0), 48.0 if spec.subtext else 40.0
94
+ return max(width, 64.0), 42.0 if not spec.subtext else 50.0
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # Items
99
+ # ---------------------------------------------------------------------------
100
+ class NodeItem(QGraphicsObject):
101
+ def __init__(self, spec: NodeSpec) -> None:
102
+ super().__init__()
103
+ self.spec = spec
104
+ self.w, self.h = node_size(spec)
105
+ self.hovered = False
106
+ self.setAcceptHoverEvents(True)
107
+ self.setZValue(2)
108
+ if spec.tooltip:
109
+ self.setToolTip(spec.tooltip)
110
+ self.setCursor(Qt.PointingHandCursor)
111
+
112
+ def boundingRect(self) -> QRectF: # noqa: N802
113
+ extra_bottom = 24 if self.spec.below else 6
114
+ return QRectF(-self.w / 2 - 20, -self.h / 2 - 16, self.w + 40, self.h + 16 + extra_bottom + 6)
115
+
116
+ def shape_rect(self) -> QRectF:
117
+ return QRectF(-self.w / 2, -self.h / 2, self.w, self.h)
118
+
119
+ def is_round(self) -> bool:
120
+ return self.spec.kind in ("place", "start", "end", "operator")
121
+
122
+ def boundary_point(self, towards: QPointF) -> QPointF:
123
+ """Where a line from the centre towards ``towards`` leaves the outline."""
124
+ centre = self.pos()
125
+ dx, dy = towards.x() - centre.x(), towards.y() - centre.y()
126
+ if dx == 0 and dy == 0:
127
+ return centre
128
+ if self.is_round():
129
+ length = math.hypot(dx, dy)
130
+ r = self.w / 2 + 1
131
+ return QPointF(centre.x() + dx / length * r, centre.y() + dy / length * r)
132
+ half_w, half_h = self.w / 2 + 1, self.h / 2 + 1
133
+ scale = min(half_w / abs(dx) if dx else math.inf, half_h / abs(dy) if dy else math.inf)
134
+ return QPointF(centre.x() + dx * scale, centre.y() + dy * scale)
135
+
136
+ def hoverEnterEvent(self, event) -> None: # noqa: N802
137
+ self.hovered = True
138
+ self.update()
139
+
140
+ def hoverLeaveEvent(self, event) -> None: # noqa: N802
141
+ self.hovered = False
142
+ self.update()
143
+
144
+ def mousePressEvent(self, event) -> None: # noqa: N802
145
+ scene = self.scene()
146
+ if isinstance(scene, GraphScene) and event.button() == Qt.LeftButton:
147
+ scene.node_clicked.emit(self.spec.id)
148
+ event.accept()
149
+ return
150
+ super().mousePressEvent(event)
151
+
152
+ def paint(self, painter: QPainter, option, widget=None) -> None:
153
+ t = style.tokens()
154
+ spec = self.spec
155
+ painter.setRenderHint(QPainter.Antialiasing)
156
+ rect = self.shape_rect()
157
+ stroke = QColor(spec.stroke or t.node_stroke)
158
+ if spec.dimmed:
159
+ stroke.setAlphaF(0.35)
160
+ width = 2.4 if spec.emphasis else 1.4
161
+ if self.hovered:
162
+ stroke = QColor(t.selection)
163
+ width = 2.2
164
+ painter.setPen(QPen(stroke, width))
165
+ fill = QColor(spec.fill or t.node_fill)
166
+
167
+ if spec.kind == "place":
168
+ painter.setBrush(fill)
169
+ painter.drawEllipse(rect)
170
+ self._paint_tokens(painter, rect, t)
171
+ elif spec.kind in ("start", "end"):
172
+ painter.setBrush(QColor(spec.fill or t.text_secondary))
173
+ painter.setPen(Qt.NoPen)
174
+ painter.drawEllipse(rect)
175
+ painter.setBrush(QColor(t.surface))
176
+ if spec.kind == "start":
177
+ path = QPainterPath()
178
+ path.moveTo(-3.5, -6)
179
+ path.lineTo(6.5, 0)
180
+ path.lineTo(-3.5, 6)
181
+ path.closeSubpath()
182
+ painter.drawPath(path)
183
+ else:
184
+ painter.drawRect(QRectF(-4.5, -4.5, 9, 9))
185
+ elif spec.kind == "operator":
186
+ painter.setBrush(fill)
187
+ painter.drawEllipse(rect)
188
+ painter.setPen(QColor(spec.stroke or t.text))
189
+ painter.setFont(_font(15, True))
190
+ painter.drawText(rect, Qt.AlignCenter, spec.text)
191
+ elif spec.kind == "silent":
192
+ painter.setBrush(QColor(spec.fill or t.silent_fill))
193
+ painter.drawRoundedRect(rect, 2.5, 2.5)
194
+ else:
195
+ radius = 9 if spec.kind in ("activity", "state") else 7
196
+ painter.setBrush(fill)
197
+ painter.drawRoundedRect(rect, radius, radius)
198
+ ink = QColor(style.text_on(fill.name())) if spec.fill else QColor(t.text)
199
+ if spec.dimmed:
200
+ ink.setAlphaF(0.45)
201
+ painter.setPen(ink)
202
+ if spec.kind == "state":
203
+ painter.setFont(_font(10.5))
204
+ painter.drawText(rect, Qt.AlignCenter, spec.text)
205
+ elif spec.subtext:
206
+ painter.setFont(_font(12, True))
207
+ painter.drawText(rect.adjusted(6, 5, -6, -rect.height() / 2 + 3),
208
+ Qt.AlignHCenter | Qt.AlignBottom, spec.text)
209
+ painter.setFont(_font(10.5))
210
+ sub = QColor(ink)
211
+ sub.setAlphaF(0.75 * ink.alphaF())
212
+ painter.setPen(sub)
213
+ painter.drawText(rect.adjusted(6, rect.height() / 2 + 1, -6, -4),
214
+ Qt.AlignHCenter | Qt.AlignTop, spec.subtext)
215
+ else:
216
+ painter.setFont(_font(12, True))
217
+ painter.drawText(rect, Qt.AlignCenter, spec.text)
218
+
219
+ if spec.below:
220
+ painter.setPen(QColor(t.text_muted))
221
+ painter.setFont(_font(10))
222
+ metrics = QFontMetricsF(painter.font())
223
+ text = metrics.elidedText(spec.below, Qt.ElideRight, 150)
224
+ text_width = metrics.horizontalAdvance(text)
225
+ painter.drawText(QRectF(-text_width / 2 - 2, rect.bottom() + 3, text_width + 4, 16),
226
+ Qt.AlignCenter, text)
227
+
228
+ # Badges sit above the node's top-right corner, stacked leftwards.
229
+ x = rect.right() + 6
230
+ for text, colour in reversed(spec.badges):
231
+ painter.setFont(_font(10, True))
232
+ metrics = QFontMetricsF(painter.font())
233
+ w = metrics.horizontalAdvance(text) + 12
234
+ x -= w
235
+ badge = QRectF(x, rect.top() - 13, w, 17)
236
+ painter.setPen(QPen(QColor(t.surface), 2))
237
+ painter.setBrush(QColor(colour))
238
+ painter.drawRoundedRect(badge, 8.5, 8.5)
239
+ painter.setPen(QColor(style.text_on(colour)))
240
+ painter.drawText(badge, Qt.AlignCenter, text)
241
+ x -= 3
242
+
243
+ def _paint_tokens(self, painter: QPainter, rect: QRectF, t) -> None:
244
+ count = self.spec.tokens
245
+ if count <= 0:
246
+ return
247
+ painter.setPen(Qt.NoPen)
248
+ painter.setBrush(QColor(t.text))
249
+ centres = {1: [(0, 0)], 2: [(-5.5, 0), (5.5, 0)], 3: [(-5.5, 4), (5.5, 4), (0, -5.5)],
250
+ 4: [(-5, -5), (5, -5), (-5, 5), (5, 5)]}.get(count)
251
+ if centres is None:
252
+ painter.setPen(QColor(t.text))
253
+ painter.setFont(_font(12, True))
254
+ painter.drawText(rect, Qt.AlignCenter, "ω" if count == math.inf else str(count))
255
+ return
256
+ for dx, dy in centres:
257
+ painter.drawEllipse(QPointF(dx, dy), 3.8, 3.8)
258
+
259
+
260
+ class EdgeItem(QGraphicsPathItem):
261
+ def __init__(self, spec: EdgeSpec, source: NodeItem, target: NodeItem,
262
+ route: list[QPointF]) -> None:
263
+ super().__init__()
264
+ self.spec = spec
265
+ self.source_item, self.target_item = source, target
266
+ self.route = route
267
+ self.setZValue(1)
268
+ if spec.tooltip:
269
+ self.setToolTip(spec.tooltip)
270
+ self.label_rect: QRectF | None = None
271
+ #: Sideways offset (px) for a straight edge that has a twin running
272
+ #: the other way, so the two do not draw on top of each other.
273
+ self.bend = 0.0
274
+ self.label_point = QPointF()
275
+ self.arrow = QPainterPath()
276
+ self.rebuild()
277
+
278
+ def rebuild(self) -> None:
279
+ s, t = self.source_item, self.target_item
280
+ if s is t:
281
+ self._self_loop()
282
+ return
283
+ points = [s.pos()] + [p for p in self.route[1:-1]] + [t.pos()]
284
+ start = s.boundary_point(points[1])
285
+ end = t.boundary_point(points[-2])
286
+ points[0], points[-1] = start, end
287
+ path = QPainterPath(start)
288
+ if len(points) == 2 and self.bend:
289
+ middle = (start + end) / 2
290
+ dx, dy = end.x() - start.x(), end.y() - start.y()
291
+ length = math.hypot(dx, dy) or 1
292
+ control = middle + QPointF(-dy / length * self.bend, dx / length * self.bend)
293
+ start = s.boundary_point(control)
294
+ end = t.boundary_point(control)
295
+ path = QPainterPath(start)
296
+ path.quadTo(control, end)
297
+ elif len(points) == 2:
298
+ path.lineTo(end)
299
+ else:
300
+ # Catmull-Rom spline through the dummy points, as cubic Béziers.
301
+ extended = [points[0]] + points + [points[-1]]
302
+ for i in range(1, len(extended) - 2):
303
+ p0, p1, p2, p3 = extended[i - 1], extended[i], extended[i + 1], extended[i + 2]
304
+ c1 = p1 + (p2 - p0) / 6
305
+ c2 = p2 - (p3 - p1) / 6
306
+ path.cubicTo(c1, c2, p2)
307
+ self.setPath(path)
308
+ self._arrowhead(path)
309
+ middle = path.pointAtPercent(0.5)
310
+ self.label_point = middle
311
+
312
+ #: Geometry of self-loops, shared with the layout so it can reserve room.
313
+ LOOP_HEIGHT = 46.0
314
+
315
+ def _self_loop(self) -> None:
316
+ """A loop above the node: leaves the top edge on the right, returns on the left.
317
+
318
+ Its width is capped: a loop taken a thousand times would otherwise be
319
+ drawn as a thick blob. The frequency is in the label, and the
320
+ tooltip, not in the stroke.
321
+ """
322
+ node = self.source_item
323
+ top = node.pos() + QPointF(0, -node.h / 2)
324
+ spread = min(node.w * 0.22, 20.0)
325
+ rise = self.LOOP_HEIGHT
326
+ start = top + QPointF(spread, 0)
327
+ end = top + QPointF(-spread, 0)
328
+ path = QPainterPath(start)
329
+ path.cubicTo(top + QPointF(spread + 30, -rise), top + QPointF(-spread - 30, -rise), end)
330
+ self.setPath(path)
331
+ self._arrowhead(path)
332
+ # The curve's highest point is at 3/4 of the control height.
333
+ self.label_point = top + QPointF(0, -rise * 0.75 - 12)
334
+
335
+ @property
336
+ def stroke_width(self) -> float:
337
+ width = self.spec.width
338
+ return min(width, 2.4) if self.source_item is self.target_item else width
339
+
340
+ def _arrowhead(self, path: QPainterPath) -> None:
341
+ """A filled triangle at the end, and a copy of the path that stops at its base.
342
+
343
+ Stopping the line at the arrow's base (rather than at the tip) keeps
344
+ thick lines from bulging out around the arrowhead. An edge without an
345
+ arrow (``spec.arrow`` False) is just the path.
346
+ """
347
+ if not self.spec.arrow:
348
+ self.arrow = QPainterPath()
349
+ self.draw_path = path
350
+ return
351
+ size = 7.0 + 0.9 * min(self.stroke_width, 4.0)
352
+ length = max(path.length(), 1.0)
353
+ tip = path.pointAtPercent(1.0)
354
+ base_percent = path.percentAtLength(max(length - size * 0.85, 0.0))
355
+ base = path.pointAtPercent(base_percent)
356
+ angle = math.atan2(tip.y() - base.y(), tip.x() - base.x())
357
+ left = tip - QPointF(math.cos(angle - 0.4) * size, math.sin(angle - 0.4) * size)
358
+ right = tip - QPointF(math.cos(angle + 0.4) * size, math.sin(angle + 0.4) * size)
359
+ self.arrow = QPainterPath(tip)
360
+ self.arrow.lineTo(left)
361
+ self.arrow.lineTo(right)
362
+ self.arrow.closeSubpath()
363
+ trimmed = QPainterPath(path.pointAtPercent(0.0))
364
+ steps = 48
365
+ for k in range(1, steps + 1):
366
+ trimmed.lineTo(path.pointAtPercent(base_percent * k / steps))
367
+ self.draw_path = trimmed
368
+
369
+ def boundingRect(self) -> QRectF: # noqa: N802
370
+ return super().boundingRect().united(self.arrow.boundingRect()).adjusted(-60, -20, 60, 20)
371
+
372
+ def paint(self, painter: QPainter, option, widget=None) -> None:
373
+ t = style.tokens()
374
+ painter.setRenderHint(QPainter.Antialiasing)
375
+ colour = QColor(self.spec.colour or t.text_secondary)
376
+ pen = QPen(colour, self.stroke_width, Qt.DashLine if self.spec.dashed else Qt.SolidLine)
377
+ pen.setCapStyle(Qt.FlatCap)
378
+ pen.setJoinStyle(Qt.RoundJoin)
379
+ painter.setPen(pen)
380
+ painter.setBrush(Qt.NoBrush)
381
+ painter.drawPath(self.draw_path)
382
+ painter.setPen(Qt.NoPen)
383
+ painter.setBrush(colour)
384
+ painter.drawPath(self.arrow)
385
+ if self.spec.text:
386
+ painter.setFont(_font(10))
387
+ metrics = QFontMetricsF(painter.font())
388
+ w = metrics.horizontalAdvance(self.spec.text) + 10
389
+ box = QRectF(self.label_point.x() - w / 2, self.label_point.y() - 9, w, 18)
390
+ painter.setBrush(QColor(t.canvas))
391
+ painter.drawRoundedRect(box, 5, 5)
392
+ painter.setPen(QColor(t.text_secondary))
393
+ painter.drawText(box, Qt.AlignCenter, self.spec.text)
394
+
395
+
396
+ # ---------------------------------------------------------------------------
397
+ # Scene and view
398
+ # ---------------------------------------------------------------------------
399
+ class GraphScene(QGraphicsScene):
400
+ node_clicked = Signal(str)
401
+
402
+ def __init__(self) -> None:
403
+ super().__init__()
404
+ self.nodes: dict[str, NodeItem] = {}
405
+ self.edges: list[EdgeItem] = []
406
+
407
+ def populate(self, nodes: list[NodeSpec], edges: list[EdgeSpec],
408
+ positions: dict[str, tuple[float, float]] | None = None,
409
+ layer_gap: float = 70.0) -> None:
410
+ """Replace the scene contents. Lays out unless ``positions`` is given."""
411
+ self.clear()
412
+ self.nodes, self.edges = {}, []
413
+ sizes = {n.id: node_size(n) for n in nodes}
414
+ # Make room between columns for the widest edge label, so labels
415
+ # never sit on top of the nodes they connect (capped for sanity).
416
+ label_metrics = QFontMetricsF(_font(10))
417
+ widest = max((label_metrics.horizontalAdvance(e.text) for e in edges if e.text), default=0)
418
+ layer_gap = max(layer_gap, min(widest + 34, 280))
419
+ # A node with a self-loop gets extra (symmetric) height in the layout,
420
+ # so the loop and its label never run into the node above.
421
+ looped = {e.source for e in edges if e.source == e.target}
422
+ for node_id in looped:
423
+ if node_id in sizes:
424
+ w, h = sizes[node_id]
425
+ sizes[node_id] = (w, h + 2 * (EdgeItem.LOOP_HEIGHT + 8))
426
+ routes: dict[int, list[tuple[float, float]]] = {}
427
+ if positions is None or any(n.id not in positions for n in nodes):
428
+ layout = layered_layout(sizes, [(e.source, e.target) for e in edges],
429
+ layer_gap=layer_gap)
430
+ positions, routes = layout.positions, layout.routes
431
+ for spec in nodes:
432
+ item = NodeItem(spec)
433
+ x, y = positions[spec.id]
434
+ item.setPos(x, y)
435
+ self.addItem(item)
436
+ self.nodes[spec.id] = item
437
+ pairs = {(e.source, e.target) for e in edges if e.source != e.target}
438
+ for index, spec in enumerate(edges):
439
+ if spec.source not in self.nodes or spec.target not in self.nodes:
440
+ continue
441
+ route = [QPointF(*p) for p in routes.get(index, [])]
442
+ if len(route) < 2:
443
+ route = [self.nodes[spec.source].pos(), self.nodes[spec.target].pos()]
444
+ item = EdgeItem(spec, self.nodes[spec.source], self.nodes[spec.target], route)
445
+ if len(route) == 2 and (spec.target, spec.source) in pairs:
446
+ item.bend = 16.0
447
+ item.rebuild()
448
+ self.addItem(item)
449
+ self.edges.append(item)
450
+ rect = self.itemsBoundingRect().adjusted(-40, -40, 40, 40)
451
+ self.setSceneRect(rect)
452
+
453
+ def update_node(self, spec: NodeSpec) -> None:
454
+ """Change a node's appearance in place (token game, overlays)."""
455
+ item = self.nodes.get(spec.id)
456
+ if item is not None:
457
+ item.prepareGeometryChange()
458
+ item.spec = spec
459
+ item.setToolTip(spec.tooltip)
460
+ item.update()
461
+
462
+
463
+ class ZoomControls(QFrame):
464
+ """The small floating zoom bar in the bottom-right corner of a canvas.
465
+
466
+ ``−`` / ``+`` zoom around the centre of the view, the percentage resets
467
+ to 100 % (actual size) when clicked, and ``Fit`` shows the whole graph.
468
+ """
469
+
470
+ def __init__(self, view) -> None:
471
+ super().__init__(view)
472
+ self.view = view
473
+ self.setObjectName("zoomControls")
474
+ layout = QHBoxLayout(self)
475
+ layout.setContentsMargins(3, 2, 3, 2)
476
+ layout.setSpacing(0)
477
+
478
+ def tool(text: str, tip: str, slot, width: int | None = None) -> QToolButton:
479
+ b = QToolButton(self)
480
+ b.setText(text)
481
+ b.setToolTip(tip)
482
+ b.setAutoRaise(True)
483
+ b.setCursor(Qt.PointingHandCursor)
484
+ b.setFocusPolicy(Qt.NoFocus)
485
+ if width:
486
+ b.setFixedWidth(width)
487
+ b.clicked.connect(slot)
488
+ layout.addWidget(b)
489
+ return b
490
+
491
+ self.minus = tool("−", f"Zoom out ({shortcut_text('Ctrl+-')})", view.zoom_out, 26)
492
+ self.percent = tool("100%", "Actual size (100 %)", view.actual_size, 50)
493
+ self.plus = tool("+", f"Zoom in ({shortcut_text('Ctrl++')})", view.zoom_in, 26)
494
+ divider = QFrame(self)
495
+ divider.setFrameShape(QFrame.VLine)
496
+ divider.setFixedWidth(9)
497
+ layout.addWidget(divider)
498
+ self.fit = tool("Fit", f"Zoom to fit the whole graph ({shortcut_text('Ctrl+0')})", view.fit)
499
+ view.zoom_changed.connect(self.show_zoom)
500
+ self.restyle()
501
+
502
+ def restyle(self) -> None:
503
+ t = style.tokens()
504
+ self.setStyleSheet(
505
+ f"QFrame#zoomControls {{ background: {t.surface}; border: 1px solid {t.border}; "
506
+ f"border-radius: 7px; }}"
507
+ f"QFrame#zoomControls QToolButton {{ color: {t.text_secondary}; border: none; "
508
+ f"padding: 2px 6px; font-size: 13px; border-radius: 5px; }}"
509
+ f"QFrame#zoomControls QToolButton:hover {{ background: {t.surface_alt}; "
510
+ f"color: {t.text}; }}"
511
+ f"QFrame#zoomControls QFrame {{ color: {t.border}; }}")
512
+
513
+ def show_zoom(self, scale: float) -> None:
514
+ self.percent.setText(f"{round(scale * 100)}%")
515
+ self.minus.setEnabled(scale > self.view.MIN_ZOOM + 1e-6)
516
+ self.plus.setEnabled(scale < self.view.MAX_ZOOM - 1e-6)
517
+
518
+
519
+ class GraphView(CanvasPanning, QGraphicsView):
520
+ """Pan/zoom view with 'fit', a floating zoom bar and image export.
521
+
522
+ Zooming: the ``− 100% + Fit`` bar, ⌘/Ctrl + scroll wheel, trackpad pinch,
523
+ the View menu (⌘+ / ⌘− / ⌘0 fit), or plain + / − / 0 / 1 while the canvas
524
+ has keyboard focus. Drag (with either the left or the middle button) to pan.
525
+ """
526
+
527
+ zoom_changed = Signal(float)
528
+ MIN_ZOOM = 0.05
529
+ MAX_ZOOM = 5.0
530
+ STEP = 1.25
531
+
532
+ def __init__(self, scene: GraphScene | None = None, parent=None,
533
+ zoom_controls: bool = True) -> None:
534
+ # Keep a Python reference: QGraphicsView does not own its scene, so a
535
+ # temporary GraphScene() would be garbage-collected immediately.
536
+ self._scene = scene or GraphScene()
537
+ super().__init__(self._scene, parent)
538
+ self.setRenderHints(QPainter.Antialiasing | QPainter.TextAntialiasing)
539
+ self.setDragMode(QGraphicsView.ScrollHandDrag)
540
+ self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse)
541
+ self.setViewportUpdateMode(QGraphicsView.BoundingRectViewportUpdate)
542
+ self.setFrameShape(QGraphicsView.NoFrame)
543
+ self.setMinimumSize(QSize(200, 160))
544
+ self.setFocusPolicy(Qt.StrongFocus)
545
+ self.grabGesture(Qt.PinchGesture)
546
+ #: While True, resizing the view re-fits the graph. Any manual zoom
547
+ #: turns it off, so the user's zoom level is respected.
548
+ self.auto_fit = True
549
+ self.zoom_controls = ZoomControls(self) if zoom_controls else None
550
+ self._place_controls()
551
+ self.init_canvas()
552
+
553
+ @property
554
+ def graph(self) -> GraphScene:
555
+ return self._scene
556
+
557
+ def zoom(self) -> float:
558
+ return self.transform().m11()
559
+
560
+ def drawBackground(self, painter: QPainter, rect: QRectF) -> None: # noqa: N802
561
+ t = style.tokens()
562
+ painter.fillRect(rect, QColor(t.canvas))
563
+ # A faint dot grid, the quietest way to say "this is a canvas".
564
+ spacing = 24
565
+ painter.setPen(QPen(QColor(t.grid), 1.4))
566
+ left = int(math.floor(rect.left() / spacing) * spacing)
567
+ top = int(math.floor(rect.top() / spacing) * spacing)
568
+ if self.transform().m11() > 0.45:
569
+ points = [QPointF(x, y) for x in range(left, int(rect.right()) + 1, spacing)
570
+ for y in range(top, int(rect.bottom()) + 1, spacing)]
571
+ if len(points) < 20000:
572
+ painter.drawPoints(points)
573
+
574
+ def fit(self) -> None:
575
+ self.auto_fit = True
576
+ rect = self.graph.sceneRect()
577
+ if rect.isEmpty():
578
+ return
579
+ self.fitInView(rect, Qt.KeepAspectRatio)
580
+ # Never blow a small graph up beyond 140 %: it looks cartoonish.
581
+ if self.transform().m11() > 1.4:
582
+ self.resetTransform()
583
+ self.scale(1.4, 1.4)
584
+ self.centerOn(rect.center())
585
+ self.zoom_changed.emit(self.transform().m11())
586
+
587
+ def resizeEvent(self, event) -> None: # noqa: N802
588
+ super().resizeEvent(event)
589
+ self._place_controls()
590
+ if self.auto_fit:
591
+ self.fit()
592
+
593
+ def _place_controls(self) -> None:
594
+ if self.zoom_controls is None:
595
+ return
596
+ self.zoom_controls.adjustSize()
597
+ size = self.zoom_controls.sizeHint()
598
+ # Hide the bar when the canvas is too small to carry it comfortably.
599
+ # Anchor to the viewport, so the bar never sits on a scroll bar.
600
+ area = self.viewport().geometry()
601
+ self.zoom_controls.setVisible(area.width() >= size.width() + 60 and area.height() >= 90)
602
+ self.zoom_controls.move(area.right() - size.width() - 9,
603
+ area.bottom() - size.height() - 9)
604
+ self.zoom_controls.raise_()
605
+
606
+ def zoom_by(self, factor: float, around_centre: bool = False, at=None) -> None:
607
+ self.auto_fit = False
608
+ current = self.transform().m11()
609
+ target = min(max(current * factor, self.MIN_ZOOM), self.MAX_ZOOM)
610
+ if target == current:
611
+ return
612
+ self.zoom_about(target / current, QPointF(self.viewport().rect().center())
613
+ if around_centre else at)
614
+ self.zoom_changed.emit(target)
615
+
616
+ def zoom_in(self) -> None:
617
+ self.zoom_by(self.STEP, around_centre=True)
618
+
619
+ def zoom_out(self) -> None:
620
+ self.zoom_by(1 / self.STEP, around_centre=True)
621
+
622
+ def actual_size(self) -> None:
623
+ self.zoom_by(1 / self.transform().m11(), around_centre=True)
624
+
625
+ def keyPressEvent(self, event) -> None: # noqa: N802
626
+ key = event.key()
627
+ if key in (Qt.Key_Plus, Qt.Key_Equal):
628
+ self.zoom_in()
629
+ elif key in (Qt.Key_Minus, Qt.Key_Underscore):
630
+ self.zoom_out()
631
+ elif key == Qt.Key_0:
632
+ self.fit()
633
+ elif key == Qt.Key_1:
634
+ self.actual_size()
635
+ else:
636
+ super().keyPressEvent(event)
637
+ return
638
+ event.accept()
639
+
640
+ def wheelEvent(self, event) -> None: # noqa: N802
641
+ if event.modifiers() & (Qt.ControlModifier | Qt.MetaModifier):
642
+ self.zoom_by(1.0015 ** event.angleDelta().y(), at=event.position())
643
+ event.accept()
644
+ else:
645
+ self.wheel_pan(event)
646
+
647
+ def event(self, event) -> bool:
648
+ # macOS trackpad pinch arrives as a native zoom gesture.
649
+ if event.type() == event.Type.NativeGesture:
650
+ try:
651
+ if event.gestureType() == Qt.ZoomNativeGesture:
652
+ self.zoom_by(1 + event.value(),
653
+ at=self.viewport().mapFromGlobal(event.globalPosition()))
654
+ return True
655
+ except AttributeError: # pragma: no cover
656
+ pass
657
+ return super().event(event)
658
+
659
+ def export_png(self, path: str, scale: float = 2.0) -> None:
660
+ rect = self.graph.sceneRect()
661
+ image = QImage(int(rect.width() * scale), int(rect.height() * scale), QImage.Format_ARGB32)
662
+ image.fill(QColor(style.tokens().canvas))
663
+ painter = QPainter(image)
664
+ painter.setRenderHints(QPainter.Antialiasing | QPainter.TextAntialiasing)
665
+ self.graph.render(painter, QRectF(image.rect()), rect)
666
+ painter.end()
667
+ image.save(path)
668
+
669
+ def export_svg(self, path: str) -> None:
670
+ from PySide6.QtSvg import QSvgGenerator
671
+ rect = self.graph.sceneRect()
672
+ generator = QSvgGenerator()
673
+ generator.setFileName(path)
674
+ generator.setSize(rect.size().toSize())
675
+ generator.setViewBox(QRectF(0, 0, rect.width(), rect.height()))
676
+ painter = QPainter(generator)
677
+ self.graph.render(painter, QRectF(0, 0, rect.width(), rect.height()), rect)
678
+ painter.end()
679
+
680
+
681
+ __all__ = ["EdgeSpec", "GraphScene", "GraphView", "ZoomControls", "NodeItem", "NodeSpec", "QGraphicsItem",
682
+ "QBrush"]