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.
- cpnpy/__init__.py +46 -0
- openprocess/__init__.py +57 -0
- openprocess/analysis/__init__.py +0 -0
- openprocess/analysis/state_space.py +521 -0
- openprocess/analysis/state_space_process.py +251 -0
- openprocess/cli.py +742 -0
- openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/answer.pnml +31 -0
- openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/question.md +38 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/answer.pnml +27 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/net.pnml +29 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/question.md +65 -0
- openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/log.txt +1 -0
- openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/question.md +67 -0
- openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/question.md +75 -0
- openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/ts.txt +5 -0
- openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/net.pnml +27 -0
- openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/question.md +65 -0
- openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/log.txt +1 -0
- openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/question.md +62 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/log.txt +1 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m1.pnml +36 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m2.pnml +28 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m3.pnml +30 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/net.pnml +36 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/question.md +69 -0
- openprocess/exercises/pack.md +14 -0
- openprocess/flow/__init__.py +50 -0
- openprocess/flow/box.py +466 -0
- openprocess/flow/boxes/__init__.py +7 -0
- openprocess/flow/boxes/check.py +119 -0
- openprocess/flow/boxes/compare.py +16 -0
- openprocess/flow/boxes/cpn.py +53 -0
- openprocess/flow/boxes/discover.py +124 -0
- openprocess/flow/boxes/filter.py +80 -0
- openprocess/flow/boxes/input.py +124 -0
- openprocess/flow/boxes/output.py +52 -0
- openprocess/flow/boxes/predict.py +186 -0
- openprocess/flow/boxes/science.py +159 -0
- openprocess/flow/boxes/sweeps.py +18 -0
- openprocess/flow/convert.py +187 -0
- openprocess/flow/datasets.py +198 -0
- openprocess/flow/explain.py +115 -0
- openprocess/flow/library.py +222 -0
- openprocess/flow/record.py +385 -0
- openprocess/flow/runner.py +357 -0
- openprocess/flow/sweep.py +92 -0
- openprocess/flow/types.py +290 -0
- openprocess/flow/workflow.py +628 -0
- openprocess/gui/__init__.py +0 -0
- openprocess/gui/app.py +90 -0
- openprocess/gui/arc_editing.py +295 -0
- openprocess/gui/canvas.py +1414 -0
- openprocess/gui/flow/__init__.py +8 -0
- openprocess/gui/flow/canvas.py +854 -0
- openprocess/gui/flow/page.py +972 -0
- openprocess/gui/flow/templates.py +131 -0
- openprocess/gui/flow/viewers.py +665 -0
- openprocess/gui/items.py +1275 -0
- openprocess/gui/learn/answer_boxes.py +978 -0
- openprocess/gui/learn/concealment.py +91 -0
- openprocess/gui/learn/mode.py +1181 -0
- openprocess/gui/panning.py +241 -0
- openprocess/gui/resources/openprocess-icon.png +0 -0
- openprocess/gui/studio/__init__.py +1 -0
- openprocess/gui/studio/__main__.py +3 -0
- openprocess/gui/studio/app.py +4031 -0
- openprocess/gui/studio/charts.py +115 -0
- openprocess/gui/studio/compare_page.py +487 -0
- openprocess/gui/studio/cpn_page.py +1858 -0
- openprocess/gui/studio/definition_view.py +284 -0
- openprocess/gui/studio/derivation_view.py +421 -0
- openprocess/gui/studio/documents.py +152 -0
- openprocess/gui/studio/dotted_chart.py +1401 -0
- openprocess/gui/studio/file_dialogs.py +143 -0
- openprocess/gui/studio/filter_dialog.py +247 -0
- openprocess/gui/studio/graph_builders.py +176 -0
- openprocess/gui/studio/graph_view.py +682 -0
- openprocess/gui/studio/instances.py +413 -0
- openprocess/gui/studio/log_editor.py +675 -0
- openprocess/gui/studio/log_page.py +800 -0
- openprocess/gui/studio/markdown_view.py +127 -0
- openprocess/gui/studio/mathtext.py +260 -0
- openprocess/gui/studio/ml_highlighter.py +75 -0
- openprocess/gui/studio/model_page.py +760 -0
- openprocess/gui/studio/net_comparison.py +124 -0
- openprocess/gui/studio/notes_overlay.py +275 -0
- openprocess/gui/studio/petri_page.py +844 -0
- openprocess/gui/studio/regions_view.py +502 -0
- openprocess/gui/studio/sidebar.py +149 -0
- openprocess/gui/studio/style.py +503 -0
- openprocess/gui/studio/tool_icons.py +134 -0
- openprocess/gui/studio/updates.py +439 -0
- openprocess/gui/studio/widgets.py +899 -0
- openprocess/gui/studio/workers.py +60 -0
- openprocess/gui/studio/workspace.py +447 -0
- openprocess/gui/theme.py +394 -0
- openprocess/gui/tidy.py +86 -0
- openprocess/io/__init__.py +0 -0
- openprocess/io/cpn_reader.py +389 -0
- openprocess/io/cpn_writer.py +357 -0
- openprocess/learn/__init__.py +23 -0
- openprocess/learn/answers.py +188 -0
- openprocess/learn/checks.py +953 -0
- openprocess/learn/computed.py +1180 -0
- openprocess/learn/context.py +145 -0
- openprocess/learn/exam.py +169 -0
- openprocess/learn/exercise-packs.md +325 -0
- openprocess/learn/importer.py +216 -0
- openprocess/learn/notation.py +474 -0
- openprocess/learn/pack.py +511 -0
- openprocess/learn/sheet.py +296 -0
- openprocess/mining/__init__.py +73 -0
- openprocess/mining/analysis.py +689 -0
- openprocess/mining/columns.py +282 -0
- openprocess/mining/compare_nets.py +246 -0
- openprocess/mining/conformance/__init__.py +0 -0
- openprocess/mining/conformance/alignments.py +263 -0
- openprocess/mining/conformance/quality.py +145 -0
- openprocess/mining/conformance/token_replay.py +252 -0
- openprocess/mining/csv_import.py +222 -0
- openprocess/mining/definitions.py +584 -0
- openprocess/mining/dfg.py +187 -0
- openprocess/mining/discovery/__init__.py +0 -0
- openprocess/mining/discovery/alpha.py +168 -0
- openprocess/mining/discovery/heuristics.py +332 -0
- openprocess/mining/discovery/inductive.py +477 -0
- openprocess/mining/discovery/state_regions.py +62 -0
- openprocess/mining/filtering.py +237 -0
- openprocess/mining/footprint.py +183 -0
- openprocess/mining/invariants.py +191 -0
- openprocess/mining/layout.py +279 -0
- openprocess/mining/log.py +364 -0
- openprocess/mining/petrinet.py +354 -0
- openprocess/mining/playout.py +75 -0
- openprocess/mining/pm4py_bridge.py +82 -0
- openprocess/mining/pnml.py +223 -0
- openprocess/mining/processtree.py +216 -0
- openprocess/mining/regions.py +476 -0
- openprocess/mining/stats.py +160 -0
- openprocess/mining/structure.py +374 -0
- openprocess/mining/transition_system.py +409 -0
- openprocess/mining/xes.py +399 -0
- openprocess/ml/__init__.py +0 -0
- openprocess/ml/ast_nodes.py +332 -0
- openprocess/ml/builtins.py +364 -0
- openprocess/ml/colorsets.py +522 -0
- openprocess/ml/errors.py +60 -0
- openprocess/ml/evaluator.py +754 -0
- openprocess/ml/lexer.py +277 -0
- openprocess/ml/multiset.py +417 -0
- openprocess/ml/parser.py +737 -0
- openprocess/ml/values.py +319 -0
- openprocess/model/__init__.py +0 -0
- openprocess/model/declarations.py +617 -0
- openprocess/model/examples.py +98 -0
- openprocess/model/net.py +701 -0
- openprocess/model/plain.py +192 -0
- openprocess/references.py +280 -0
- openprocess/sim/__init__.py +0 -0
- openprocess/sim/binding.py +620 -0
- openprocess/sim/export.py +66 -0
- openprocess/sim/simulator.py +315 -0
- openprocess/teaching/__init__.py +4 -0
- openprocess/teaching/answers.py +4 -0
- openprocess/teaching/checks.py +5 -0
- openprocess/teaching/pack.py +4 -0
- openprocess/teaching/sheet.py +4 -0
- openprocess-0.7.0.dist-info/METADATA +927 -0
- openprocess-0.7.0.dist-info/RECORD +173 -0
- openprocess-0.7.0.dist-info/WHEEL +5 -0
- openprocess-0.7.0.dist-info/entry_points.txt +6 -0
- openprocess-0.7.0.dist-info/licenses/LICENSE +21 -0
- 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"]
|