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,1401 @@
1
+ """The dotted chart, with the configuration and navigation of ProM's plug-in.
2
+
3
+ What a dotted chart is (course tutorial W2 L4; Song & van der Aalst 2007)
4
+ ------------------------------------------------------------------------
5
+ Every event is a dot. Three choices decide the picture:
6
+
7
+ * **x** -- *when*: actual time, time since the case started, the same as a
8
+ percentage of the case's duration, or a *logical* position (the event's
9
+ rank in the whole log, or its index inside its case);
10
+ * **rows** -- *what the dot belongs to*: the case (the classic view), or any
11
+ event attribute -- activity, resource, lifecycle transition, ...;
12
+ * **colour** and **shape** -- two more attributes, e.g. colour by activity and
13
+ shape by lifecycle, so start and complete events are distinguishable.
14
+
15
+ Rows can be sorted by name, first event, last event, duration (last − first)
16
+ or number of events. Patterns this makes visible: arrival rate (slope of the
17
+ first dots), batching (vertical stripes), bottlenecks (long horizontal gaps),
18
+ and in the plane-boarding logs, how each boarding strategy changes who blocks
19
+ whom.
20
+
21
+ Navigation
22
+ ----------
23
+ ============================ =============================================
24
+ drag a rectangle zoom into that area
25
+ ⌘ / Ctrl + scroll, pinch zoom around the pointer (⇧ as well: rows only)
26
+ scroll / ⇧ + scroll pan rows / pan time
27
+ Alt + drag, middle drag pan
28
+ double-click, **Reset** show everything
29
+ **Back**, ⌫ undo the last zoom
30
+ click a dot select its case (Esc clears)
31
+ ============================ =============================================
32
+
33
+ The sliders under the chart set the horizontal and vertical zoom factors,
34
+ and the legend in the side panel shows or hides individual values.
35
+
36
+ Colours follow the app's validated categorical palette: the eight most
37
+ frequent values get the eight hues in fixed order and everything else is a
38
+ neutral "Other" -- the legend always names what each colour means.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import bisect
44
+ import math
45
+ from dataclasses import dataclass, field, replace
46
+ from datetime import datetime, timedelta, timezone
47
+
48
+ from PySide6.QtCore import QPointF, QRectF, QSize, Qt, Signal
49
+ from PySide6.QtGui import (
50
+ QColor, QFontMetricsF, QImage, QPainter, QPainterPath, QPen, QPixmap, QPolygonF,
51
+ )
52
+ from PySide6.QtWidgets import (
53
+ QCheckBox, QComboBox, QDoubleSpinBox, QFileDialog, QFormLayout, QHBoxLayout, QListWidget, QListWidgetItem,
54
+ QScrollBar, QSlider, QSplitter, QToolTip, QVBoxLayout, QWidget,
55
+ )
56
+
57
+ from ...mining.stats import format_duration
58
+ from . import style
59
+ from .documents import LogDocument
60
+ from .widgets import (
61
+ suggested_path,
62
+ Card, ElidedLabel, button, flow, hbox, label, modifier_text, scroll, shortcut_text, vbox,
63
+ )
64
+
65
+ X_MODES = ["Actual time", "Time since case start", "Relative to case duration (%)",
66
+ "Logical: order in log", "Logical: position in case"]
67
+ #: Axis titles for each x mode.
68
+ X_TITLES = ["Time", "Time since case start", "Share of case duration (%)",
69
+ "Event order in the log", "Event position in the case"]
70
+ #: Time units for the x axis, as in ProM's dotted chart: with a unit chosen
71
+ #: the axis shows plain numbers in that unit ("0 5 10 …", titled
72
+ #: "… (minutes)"); "Auto" shows clock times / durations instead. Months and
73
+ #: years are fixed lengths (30 and 365 days), because the axis is linear.
74
+ TIME_UNITS = {"Auto": None, "Seconds": 1.0, "Minutes": 60.0, "Hours": 3600.0,
75
+ "Days": 86400.0, "Weeks": 604800.0, "Months (30 d)": 2592000.0,
76
+ "Years (365 d)": 31536000.0}
77
+ UNIT_SUFFIX = {"Seconds": "s", "Minutes": "min", "Hours": "h", "Days": "d", "Weeks": "wk",
78
+ "Months (30 d)": "mo", "Years (365 d)": "yr"}
79
+ SORT_MODES = ["First event", "Last event", "Duration", "Number of events", "Name"]
80
+ STANDARD_COMPONENTS = ["Case", "Activity", "Resource", "Lifecycle"]
81
+ SHAPES = ["circle", "square", "triangle", "diamond", "cross", "star"]
82
+
83
+
84
+ @dataclass
85
+ class DotSettings:
86
+ x_mode: int = 0
87
+ rows: str = "Case"
88
+ sort: int = 0
89
+ descending: bool = False
90
+ colour_by: str = "Activity"
91
+ shape_by: str = "None"
92
+ all_events: bool = False # ignore the classifier's event filter
93
+ dot_size: float = 4.0
94
+ connect: bool = False # line through the events of each case
95
+ unit: str = "Auto" # time unit of the x axis (see TIME_UNITS)
96
+ grid_step: float = 0.0 # distance between gridlines in axis units; 0 = automatic
97
+ grid_lines: bool = True # vertical gridlines
98
+ row_lines: bool = True # a hairline per row when rows are tall enough
99
+ #: Colours picked in the legend: "<attribute>\x1f<value>" -> "#rrggbb".
100
+ #: Values without an entry keep the default palette.
101
+ colours: dict = field(default_factory=dict)
102
+
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # Data preparation (no Qt)
106
+ # ---------------------------------------------------------------------------
107
+ class DotData:
108
+ """All dots of a log under given settings, ready for drawing.
109
+
110
+ Parallel lists keep the per-dot data compact: ``xs[i]``, ``rows[i]``,
111
+ ``colour[i]`` (index into ``colour_values``), ``shape[i]``, ``case[i]``
112
+ (trace index) and ``events[i]``.
113
+ """
114
+
115
+ def __init__(self, document: LogDocument, settings: DotSettings) -> None:
116
+ self.settings = settings
117
+ log = document.log
118
+ classifier = document.classifier
119
+ s = settings
120
+
121
+ def value(component: str, trace, event) -> str:
122
+ if component == "Case":
123
+ return trace.case_id
124
+ if component == "Activity":
125
+ return (classifier.label(event) if classifier.accepts(event)
126
+ else str(event.activity or "–"))
127
+ if component == "Resource":
128
+ return str(event.resource or "–")
129
+ if component == "Lifecycle":
130
+ return str(event.lifecycle or "–")
131
+ if component == "None":
132
+ return ""
133
+ raw = event.get(component)
134
+ return "–" if raw is None else str(raw)
135
+
136
+ # 1. Select events and compute raw x values -------------------------
137
+ stamps = [e.timestamp for t in log.traces for e in t.events if e.timestamp is not None]
138
+ self.origin = min(stamps) if stamps else None
139
+ self.has_time = bool(stamps)
140
+ mode = s.x_mode if self.has_time or s.x_mode >= 3 else 4
141
+
142
+ global_rank: dict[int, int] = {}
143
+ if mode == 3:
144
+ everything = [(e.timestamp or datetime.min.replace(tzinfo=timezone.utc), ti, pos, id(e))
145
+ for ti, t in enumerate(log.traces) for pos, e in enumerate(t.events)]
146
+ everything.sort()
147
+ global_rank = {key: rank for rank, (*_, key) in enumerate(everything)}
148
+
149
+ raw: list[tuple[float, str, str, str, int, object]] = []
150
+ for ti, trace in enumerate(log.traces):
151
+ events = [e for e in trace.events if s.all_events or classifier.accepts(e)]
152
+ times = [e.timestamp for e in events if e.timestamp is not None]
153
+ start = min(times) if times else None
154
+ span = (max(times) - start).total_seconds() if times else 0.0
155
+ for position, event in enumerate(events):
156
+ if mode == 4:
157
+ x = float(position)
158
+ elif mode == 3:
159
+ x = float(global_rank[id(event)])
160
+ elif event.timestamp is None:
161
+ continue
162
+ elif mode == 0:
163
+ x = (event.timestamp - self.origin).total_seconds()
164
+ elif mode == 1:
165
+ x = (event.timestamp - start).total_seconds()
166
+ else:
167
+ x = 100.0 * (event.timestamp - start).total_seconds() / span if span else 0.0
168
+ raw.append((x, value(s.rows, trace, event), value(s.colour_by, trace, event),
169
+ value(s.shape_by, trace, event), ti, event))
170
+ self.mode = mode
171
+
172
+ # 2. Rows: one per distinct component value, sorted ------------------
173
+ stats: dict[str, list[float]] = {} # value -> [first, last, count]
174
+ for x, row_value, *_ in raw:
175
+ entry = stats.setdefault(row_value, [x, x, 0])
176
+ entry[0] = min(entry[0], x)
177
+ entry[1] = max(entry[1], x)
178
+ entry[2] += 1
179
+ keys = {
180
+ 0: lambda v: (stats[v][0], _natural(v)),
181
+ 1: lambda v: (stats[v][1], _natural(v)),
182
+ 2: lambda v: (stats[v][1] - stats[v][0], _natural(v)),
183
+ 3: lambda v: (stats[v][2], _natural(v)),
184
+ 4: lambda v: _natural(v),
185
+ }
186
+ self.row_values = sorted(stats, key=keys[s.sort], reverse=s.descending)
187
+ row_index = {v: i for i, v in enumerate(self.row_values)}
188
+ self.row_stats = stats
189
+
190
+ # 3. Colour and shape assignments, by frequency ----------------------
191
+ def ranked(position: int) -> list[str]:
192
+ counts: dict[str, int] = {}
193
+ for item in raw:
194
+ counts[item[position]] = counts.get(item[position], 0) + 1
195
+ return sorted(counts, key=lambda v: (-counts[v], _natural(v)))
196
+ self.colour_values = ranked(2)
197
+ self.shape_values = ranked(3)
198
+ self.colour_counts = {v: 0 for v in self.colour_values}
199
+ colour_index = {v: i for i, v in enumerate(self.colour_values)}
200
+ shape_index = {v: i for i, v in enumerate(self.shape_values)}
201
+
202
+ self.xs: list[float] = []
203
+ self.rows: list[int] = []
204
+ self.colour: list[int] = []
205
+ self.shape: list[int] = []
206
+ self.case: list[int] = []
207
+ self.events: list[object] = []
208
+ for x, row_value, colour_value, shape_value, ti, event in raw:
209
+ self.xs.append(x)
210
+ self.rows.append(row_index[row_value])
211
+ self.colour.append(colour_index[colour_value])
212
+ self.shape.append(shape_index[shape_value])
213
+ self.case.append(ti)
214
+ self.events.append(event)
215
+ self.colour_counts[colour_value] += 1
216
+ self.x_min = min(self.xs, default=0.0)
217
+ self.x_max = max(self.xs, default=1.0)
218
+ if self.x_max <= self.x_min:
219
+ self.x_max = self.x_min + 1.0
220
+ self.traces = log.traces
221
+ self.classifier = classifier
222
+
223
+ def __len__(self) -> int:
224
+ return len(self.xs)
225
+
226
+ def colour_slot(self, colour_index: int) -> int:
227
+ """Palette slot for a colour value (slots 0-7, then 'Other')."""
228
+ return colour_index if colour_index < 8 else 99
229
+
230
+ def colour_key(self, colour_index: int) -> str:
231
+ return f"{self.settings.colour_by}\x1f{self.colour_values[colour_index]}"
232
+
233
+ def colour_hex(self, colour_index: int) -> str:
234
+ """The dot colour of a value: the one picked in the legend, if any,
235
+ else the palette's."""
236
+ custom = self.settings.colours.get(self.colour_key(colour_index))
237
+ return custom or style.categorical(self.colour_slot(colour_index))
238
+
239
+
240
+ # ---------------------------------------------------------------------------
241
+ # The chart widget
242
+ # ---------------------------------------------------------------------------
243
+ class DottedChart(QWidget):
244
+ """Draws a :class:`DotData` inside a zoomable window over its full extent."""
245
+
246
+ view_changed = Signal()
247
+ case_selected = Signal(int) # trace index, or -1
248
+
249
+ MARGIN_LEFT, MARGIN_TOP, MARGIN_RIGHT, MARGIN_BOTTOM = 74, 10, 14, 46
250
+
251
+ def __init__(self, parent=None) -> None:
252
+ super().__init__(parent)
253
+ self.data: DotData | None = None
254
+ self.hidden_colours: set[int] = set()
255
+ self.selected_case = -1
256
+ # the visible window, in data units: x range and row range
257
+ self.vx0 = self.vx1 = 0.0
258
+ self.vy0 = self.vy1 = 0.0
259
+ self.history: list[tuple[float, float, float, float]] = []
260
+ #: A common x range forced on several charts (the Compare view), so
261
+ #: equal distances mean equal times in every chart.
262
+ self.shared_x: tuple[float, float] | None = None
263
+ self._drag_start: QPointF | None = None
264
+ self._drag_now: QPointF | None = None
265
+ self._panning = False
266
+ self._pan_origin: tuple[QPointF, tuple[float, float, float, float]] | None = None
267
+ self._cache: QPixmap | None = None
268
+ self._cache_key = None
269
+ self._grid: dict[tuple[int, int], list[int]] = {}
270
+ self.setMouseTracking(True)
271
+ self.setFocusPolicy(Qt.StrongFocus)
272
+ self.setMinimumSize(QSize(240, 200))
273
+ self.grabGesture(Qt.PinchGesture)
274
+
275
+ # -- data and view window -------------------------------------------------
276
+ def set_data(self, data: DotData, keep_view: bool = False) -> None:
277
+ self.data = data
278
+ self._invalidate()
279
+ if not keep_view:
280
+ self.history.clear()
281
+ self.reset_view(record=False)
282
+ else:
283
+ self.update()
284
+
285
+ def full_extent(self) -> tuple[float, float, float, float]:
286
+ d = self.data
287
+ if d is None:
288
+ return 0.0, 1.0, 0.0, 1.0
289
+ x_min, x_max = self.shared_x or (d.x_min, d.x_max)
290
+ pad = (x_max - x_min) * 0.01
291
+ return x_min - pad, x_max + pad, 0.0, float(max(len(d.row_values), 1))
292
+
293
+ def reset_view(self, record: bool = True) -> None:
294
+ self.set_view(*self.full_extent(), record=record)
295
+
296
+ def set_view(self, x0: float, x1: float, y0: float, y1: float, record: bool = True) -> None:
297
+ fx0, fx1, fy0, fy1 = self.full_extent()
298
+ # Keep at least a sliver visible and stay inside the data.
299
+ min_w = (fx1 - fx0) / 5000
300
+ if x1 - x0 < min_w:
301
+ middle = (x0 + x1) / 2
302
+ x0, x1 = middle - min_w / 2, middle + min_w / 2
303
+ if y1 - y0 < 0.5:
304
+ middle = (y0 + y1) / 2
305
+ y0, y1 = middle - 0.25, middle + 0.25
306
+ width, height = min(x1 - x0, fx1 - fx0), min(y1 - y0, fy1 - fy0)
307
+ x0 = min(max(x0, fx0), fx1 - width)
308
+ y0 = min(max(y0, fy0), fy1 - height)
309
+ new = (x0, x0 + width, y0, y0 + height)
310
+ if record and new != (self.vx0, self.vx1, self.vy0, self.vy1):
311
+ self.history.append((self.vx0, self.vx1, self.vy0, self.vy1))
312
+ del self.history[:-50]
313
+ self.vx0, self.vx1, self.vy0, self.vy1 = new
314
+ self._invalidate()
315
+ self.view_changed.emit()
316
+
317
+ def back(self) -> None:
318
+ if self.history:
319
+ self.vx0, self.vx1, self.vy0, self.vy1 = self.history.pop()
320
+ self._invalidate()
321
+ self.view_changed.emit()
322
+
323
+ def zoom_factors(self) -> tuple[float, float]:
324
+ fx0, fx1, fy0, fy1 = self.full_extent()
325
+ return (fx1 - fx0) / (self.vx1 - self.vx0), (fy1 - fy0) / (self.vy1 - self.vy0)
326
+
327
+ def set_zoom(self, fx: float | None = None, fy: float | None = None) -> None:
328
+ """Zoom to absolute factors, keeping the centre of the view."""
329
+ full = self.full_extent()
330
+ cx, cy = (self.vx0 + self.vx1) / 2, (self.vy0 + self.vy1) / 2
331
+ w = (full[1] - full[0]) / fx if fx else self.vx1 - self.vx0
332
+ h = (full[3] - full[2]) / fy if fy else self.vy1 - self.vy0
333
+ self.set_view(cx - w / 2, cx + w / 2, cy - h / 2, cy + h / 2, record=False)
334
+
335
+ def zoom_at(self, point: QPointF, fx: float, fy: float) -> None:
336
+ """Zoom by factors around a screen point (the data under it stays put)."""
337
+ rect = self.plot_rect()
338
+ ax = (point.x() - rect.left()) / rect.width()
339
+ ay = (point.y() - rect.top()) / rect.height()
340
+ ax, ay = min(max(ax, 0), 1), min(max(ay, 0), 1)
341
+ x = self.vx0 + ax * (self.vx1 - self.vx0)
342
+ y = self.vy0 + ay * (self.vy1 - self.vy0)
343
+ w = (self.vx1 - self.vx0) / fx
344
+ h = (self.vy1 - self.vy0) / fy
345
+ self.set_view(x - ax * w, x + (1 - ax) * w, y - ay * h, y + (1 - ay) * h, record=False)
346
+
347
+ def pan_by(self, dx_fraction: float, dy_fraction: float) -> None:
348
+ w, h = self.vx1 - self.vx0, self.vy1 - self.vy0
349
+ self.set_view(self.vx0 + dx_fraction * w, self.vx1 + dx_fraction * w,
350
+ self.vy0 + dy_fraction * h, self.vy1 + dy_fraction * h, record=False)
351
+
352
+ # -- geometry --------------------------------------------------------------
353
+ def plot_rect(self) -> QRectF:
354
+ left = self.MARGIN_LEFT + self._label_width()
355
+ return QRectF(left, self.MARGIN_TOP, max(self.width() - left - self.MARGIN_RIGHT, 20),
356
+ max(self.height() - self.MARGIN_TOP - self.MARGIN_BOTTOM, 20))
357
+
358
+ def _label_width(self) -> float:
359
+ """Extra left margin for row names when they are readable."""
360
+ if self.data is None or self._row_height_estimate() < 11:
361
+ return 0.0
362
+ metrics = QFontMetricsF(self._small_font())
363
+ widest = max((metrics.horizontalAdvance(v) for v in self._visible_row_values()), default=0)
364
+ return min(widest + 6, 150.0) - 40 if widest > 34 else 0.0
365
+
366
+ def _row_height_estimate(self) -> float:
367
+ height = max(self.height() - self.MARGIN_TOP - self.MARGIN_BOTTOM, 20)
368
+ return height / max(self.vy1 - self.vy0, 1e-9)
369
+
370
+ def _visible_row_values(self) -> list[str]:
371
+ if self.data is None:
372
+ return []
373
+ first, last = int(math.floor(self.vy0)), int(math.ceil(self.vy1))
374
+ return self.data.row_values[max(first, 0):min(last, len(self.data.row_values))]
375
+
376
+ def to_screen(self, x: float, row: float, rect: QRectF) -> QPointF:
377
+ sx = rect.left() + (x - self.vx0) / (self.vx1 - self.vx0) * rect.width()
378
+ sy = rect.top() + (row + 0.5 - self.vy0) / (self.vy1 - self.vy0) * rect.height()
379
+ return QPointF(sx, sy)
380
+
381
+ def to_data(self, point: QPointF) -> tuple[float, float]:
382
+ rect = self.plot_rect()
383
+ x = self.vx0 + (point.x() - rect.left()) / rect.width() * (self.vx1 - self.vx0)
384
+ y = self.vy0 + (point.y() - rect.top()) / rect.height() * (self.vy1 - self.vy0)
385
+ return x, y
386
+
387
+ @staticmethod
388
+ def _small_font():
389
+ from .. import theme
390
+ return theme.ui_font(10)
391
+
392
+ # -- painting ----------------------------------------------------------------
393
+ def _invalidate(self) -> None:
394
+ self._cache = None
395
+ self.update()
396
+
397
+ def paintEvent(self, _event) -> None: # noqa: N802
398
+ key = (self.width(), self.height(), self.vx0, self.vx1, self.vy0, self.vy1,
399
+ tuple(sorted(self.hidden_colours)), self.selected_case, id(self.data),
400
+ self.devicePixelRatioF(), style.tokens().surface)
401
+ if self._cache is None or self._cache_key != key:
402
+ self._cache = self._render()
403
+ self._cache_key = key
404
+ painter = QPainter(self)
405
+ painter.drawPixmap(0, 0, self._cache)
406
+ # Rubber band on top of the cached picture: dragging stays fluid.
407
+ if self._drag_start is not None and self._drag_now is not None and not self._panning:
408
+ t = style.tokens()
409
+ band = QRectF(self._drag_start, self._drag_now).normalized()
410
+ fill = QColor(t.accent)
411
+ fill.setAlphaF(0.12)
412
+ painter.setPen(QPen(QColor(t.accent), 1, Qt.DashLine))
413
+ painter.setBrush(fill)
414
+ painter.drawRect(band)
415
+
416
+ def _render(self) -> QPixmap:
417
+ t = style.tokens()
418
+ ratio = self.devicePixelRatioF()
419
+ pixmap = QPixmap(int(self.width() * ratio), int(self.height() * ratio))
420
+ pixmap.setDevicePixelRatio(ratio)
421
+ pixmap.fill(QColor(t.surface))
422
+ painter = QPainter(pixmap)
423
+ painter.setRenderHint(QPainter.Antialiasing)
424
+ self.paint_chart(painter, QRectF(0, 0, self.width(), self.height()))
425
+ painter.end()
426
+ return pixmap
427
+
428
+ def paint_chart(self, painter: QPainter, area: QRectF) -> None:
429
+ """Draw axes and dots. Also used for image export."""
430
+ t = style.tokens()
431
+ d = self.data
432
+ self._grid = {}
433
+ if d is None or not len(d):
434
+ painter.setPen(QColor(t.text_muted))
435
+ painter.drawText(area, Qt.AlignCenter, "No events to show with these settings "
436
+ "(does the log have timestamps?)")
437
+ return
438
+ s = d.settings
439
+ rect = self.plot_rect()
440
+ self._paint_axes(painter, rect, t, s.grid_lines)
441
+
442
+ row_h = rect.height() / max(self.vy1 - self.vy0, 1e-9)
443
+ radius = max(1.6, min(s.dot_size, row_h * 0.45 if row_h < 2.2 * s.dot_size else s.dot_size))
444
+ ring = 1.2 if radius >= 3.5 else 0.0
445
+ selected = self.selected_case
446
+ painter.save()
447
+ painter.setClipRect(rect.adjusted(-radius - 1, -radius - 1, radius + 1, radius + 1))
448
+
449
+ # Visible dots, grouped for drawing order and cheap hit-testing later.
450
+ visible: list[int] = []
451
+ x0, x1, y0, y1 = self.vx0, self.vx1, self.vy0 - 1, self.vy1
452
+ for i in range(len(d.xs)):
453
+ if d.colour[i] in self.hidden_colours:
454
+ continue
455
+ x, row = d.xs[i], d.rows[i]
456
+ if x0 <= x <= x1 and y0 <= row <= y1:
457
+ visible.append(i)
458
+
459
+ if s.connect:
460
+ self._paint_connections(painter, rect, visible, t)
461
+
462
+ colours = [QColor(d.colour_hex(c)) for c in range(len(d.colour_values))]
463
+ faded = QColor(t.border)
464
+ ring_pen = QPen(QColor(t.surface), ring) if ring else Qt.NoPen
465
+ cell = 8
466
+ # Unselected first, the selected case last so it sits on top.
467
+ ordered = sorted(visible, key=lambda i: d.case[i] == selected) if selected >= 0 else visible
468
+ for i in ordered:
469
+ point = self.to_screen(d.xs[i], d.rows[i], rect)
470
+ is_dim = selected >= 0 and d.case[i] != selected
471
+ painter.setPen(ring_pen)
472
+ painter.setBrush(faded if is_dim else colours[d.colour[i]])
473
+ _draw_shape(painter, SHAPES[d.shape[i]] if d.shape[i] < len(SHAPES) else "ring",
474
+ point, radius)
475
+ self._grid.setdefault((int(point.x()) // cell, int(point.y()) // cell), []).append(i)
476
+ painter.restore()
477
+ self._visible_count = len(visible)
478
+
479
+ def _paint_connections(self, painter: QPainter, rect: QRectF, visible: list[int], t) -> None:
480
+ d = self.data
481
+ by_case: dict[int, list[int]] = {}
482
+ for i in visible:
483
+ by_case.setdefault(d.case[i], []).append(i)
484
+ pen_colour = QColor(t.text_muted)
485
+ pen_colour.setAlphaF(0.35)
486
+ painter.setPen(QPen(pen_colour, 1))
487
+ for case, indices in by_case.items():
488
+ if len(indices) < 2:
489
+ continue
490
+ indices.sort(key=lambda i: d.xs[i])
491
+ polygon = QPolygonF([self.to_screen(d.xs[i], d.rows[i], rect) for i in indices])
492
+ if case == self.selected_case:
493
+ painter.save()
494
+ painter.setPen(QPen(QColor(t.accent), 1.6))
495
+ painter.drawPolyline(polygon)
496
+ painter.restore()
497
+ else:
498
+ painter.drawPolyline(polygon)
499
+
500
+ def _paint_axes(self, painter: QPainter, rect: QRectF, t, grid: bool) -> None:
501
+ font = self._small_font()
502
+ painter.setFont(font)
503
+ metrics = QFontMetricsF(font)
504
+ d = self.data
505
+ # vertical gridlines; labels only where they do not collide
506
+ ticks = self._x_ticks(rect.width())
507
+ last_label_right = -1e9
508
+ for value, text in ticks:
509
+ x = rect.left() + (value - self.vx0) / (self.vx1 - self.vx0) * rect.width()
510
+ if grid:
511
+ painter.setPen(QPen(QColor(t.grid), 1))
512
+ painter.drawLine(QPointF(x, rect.top()), QPointF(x, rect.bottom()))
513
+ w = metrics.horizontalAdvance(text) + 8
514
+ if x - w / 2 > last_label_right + 6:
515
+ painter.setPen(QColor(t.text_muted))
516
+ painter.drawText(QRectF(x - w / 2, rect.bottom() + 5, w, 16), Qt.AlignCenter, text)
517
+ last_label_right = x + w / 2
518
+ painter.setPen(QPen(QColor(t.axis), 1))
519
+ painter.drawLine(rect.bottomLeft(), rect.bottomRight())
520
+ painter.drawLine(rect.topLeft(), rect.bottomLeft())
521
+
522
+ # axis titles, in the muted text colour
523
+ title_font = self._small_font()
524
+ title_font.setBold(True)
525
+ painter.setFont(title_font)
526
+ painter.setPen(QColor(t.text_secondary))
527
+ x_title = axis_title(d.mode, d.settings.unit)
528
+ if d.mode == 0 and d.origin is not None:
529
+ x_title += f" (from {d.origin:%d %b %Y %H:%M:%S})"
530
+ painter.drawText(QRectF(rect.left(), rect.bottom() + 24, rect.width(), 16),
531
+ Qt.AlignCenter, x_title)
532
+ painter.save()
533
+ painter.translate(11, rect.center().y())
534
+ painter.rotate(-90)
535
+ y_title = d.settings.rows if d.settings.rows != "Case" else "Cases"
536
+ painter.drawText(QRectF(-rect.height() / 2, -8, rect.height(), 16), Qt.AlignCenter,
537
+ f"{y_title} ({len(d.row_values):,})")
538
+ painter.restore()
539
+ painter.setFont(font)
540
+
541
+ # rows: names when there is room, otherwise counts
542
+ row_h = rect.height() / max(self.vy1 - self.vy0, 1e-9)
543
+ first, last = max(int(math.floor(self.vy0)), 0), min(int(math.ceil(self.vy1)), len(d.row_values))
544
+ painter.setPen(QColor(t.text_muted))
545
+ label_left = 22.0
546
+ if row_h >= 11:
547
+ width = rect.left() - 6 - label_left
548
+ for row in range(first, last):
549
+ y = rect.top() + (row + 0.5 - self.vy0) * row_h
550
+ if d.settings.row_lines and row_h >= 16:
551
+ painter.save()
552
+ painter.setPen(QPen(QColor(t.grid), 1))
553
+ painter.drawLine(QPointF(rect.left(), y), QPointF(rect.right(), y))
554
+ painter.restore()
555
+ text = metrics.elidedText(d.row_values[row], Qt.ElideMiddle, width - 2)
556
+ painter.drawText(QRectF(label_left, y - 8, width, 16),
557
+ Qt.AlignRight | Qt.AlignVCenter, text)
558
+ else:
559
+ span = self.vy1 - self.vy0
560
+ step = _nice_step(span / 6)
561
+ value = math.ceil(self.vy0 / step) * step
562
+ while value <= self.vy1:
563
+ y = rect.top() + (value - self.vy0) * row_h
564
+ painter.drawText(QRectF(label_left, y - 8, rect.left() - 6 - label_left, 16),
565
+ Qt.AlignRight | Qt.AlignVCenter, f"{int(value):,}")
566
+ value += step
567
+
568
+ def _x_ticks(self, pixels: float) -> list[tuple[float, str]]:
569
+ """Gridline positions (in data units) and their labels.
570
+
571
+ With a grid step set, lines go exactly every ``grid_step`` axis units
572
+ (never closer than 4 px, so zooming far out does not turn the chart
573
+ grey); otherwise a round step giving a line every ~110 px.
574
+ """
575
+ d = self.data
576
+ s = d.settings
577
+ span = self.vx1 - self.vx0
578
+ count = max(int(pixels / 110), 2)
579
+ unit = axis_unit(d.mode, s.unit) # data units per axis unit
580
+ if s.grid_step > 0:
581
+ step = max(s.grid_step * unit, span / max(pixels / 4, 1))
582
+ elif d.mode in (0, 1) and s.unit == "Auto":
583
+ step = _nice_time_step(span / count)
584
+ else:
585
+ step = _nice_step(span / unit / count) * unit
586
+ if d.mode >= 3:
587
+ step = max(step, 1.0)
588
+ first = math.ceil(self.vx0 / step - 1e-9) * step
589
+ return [(v, self._format_tick(v, step, span)) for v in _frange(first, self.vx1, step)]
590
+
591
+ def _format_tick(self, v: float, step: float, span: float) -> str:
592
+ d = self.data
593
+ if d.mode >= 3:
594
+ return f"{round(v):,}"
595
+ if d.mode == 2:
596
+ return _number(v) + "%"
597
+ if d.settings.unit != "Auto":
598
+ return _number(v / TIME_UNITS[d.settings.unit])
599
+ if d.mode == 0 and d.origin is not None:
600
+ moment = d.origin + timedelta(seconds=v)
601
+ if step >= 86400:
602
+ return moment.strftime("%d %b %Y" if span > 86400 * 300 else "%d %b")
603
+ if step >= 60:
604
+ return moment.strftime("%d %b %H:%M" if span > 86400 else "%H:%M")
605
+ return moment.strftime("%H:%M:%S")
606
+ return "+" + format_duration(v) if v else "0"
607
+
608
+ # -- hit testing ------------------------------------------------------------------
609
+ def dot_at(self, point: QPointF, tolerance: float = 7.0) -> int | None:
610
+ if self.data is None:
611
+ return None
612
+ rect = self.plot_rect()
613
+ cell = 8
614
+ cx, cy = int(point.x()) // cell, int(point.y()) // cell
615
+ best, best_distance = None, tolerance
616
+ for gx in (cx - 1, cx, cx + 1):
617
+ for gy in (cy - 1, cy, cy + 1):
618
+ for i in self._grid.get((gx, gy), []):
619
+ screen = self.to_screen(self.data.xs[i], self.data.rows[i], rect)
620
+ distance = math.hypot(screen.x() - point.x(), screen.y() - point.y())
621
+ if distance <= best_distance:
622
+ best, best_distance = i, distance
623
+ return best
624
+
625
+ def describe_dot(self, i: int) -> str:
626
+ d = self.data
627
+ event = d.events[i]
628
+ trace = d.traces[d.case[i]]
629
+ lines = [f"<b>{d.classifier.label(event) if d.classifier.accepts(event) else event.activity}</b>",
630
+ f"case {trace.case_id}"]
631
+ if event.timestamp:
632
+ lines.append(event.timestamp.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3])
633
+ for key, value in list(event.attributes.items())[:10]:
634
+ if key not in ("concept:name", "time:timestamp"):
635
+ text = str(value)
636
+ lines.append(f"{key}: {text[:60] + '…' if len(text) > 60 else text}")
637
+ return "<br>".join(lines)
638
+
639
+ # -- interaction ------------------------------------------------------------------
640
+ def mousePressEvent(self, event) -> None: # noqa: N802
641
+ self.setFocus()
642
+ if event.button() == Qt.MiddleButton or (event.button() == Qt.LeftButton and
643
+ event.modifiers() & Qt.AltModifier):
644
+ self._panning = True
645
+ self._pan_origin = (event.position(), (self.vx0, self.vx1, self.vy0, self.vy1))
646
+ self.setCursor(Qt.ClosedHandCursor)
647
+ elif event.button() == Qt.LeftButton:
648
+ self._drag_start = event.position()
649
+ self._drag_now = event.position()
650
+
651
+ def mouseMoveEvent(self, event) -> None: # noqa: N802
652
+ if self._panning and self._pan_origin is not None:
653
+ origin, (x0, x1, y0, y1) = self._pan_origin
654
+ rect = self.plot_rect()
655
+ dx = (event.position().x() - origin.x()) / rect.width() * (x1 - x0)
656
+ dy = (event.position().y() - origin.y()) / rect.height() * (y1 - y0)
657
+ self.set_view(x0 - dx, x1 - dx, y0 - dy, y1 - dy, record=False)
658
+ return
659
+ if self._drag_start is not None:
660
+ self._drag_now = event.position()
661
+ self.update()
662
+ return
663
+ index = self.dot_at(event.position())
664
+ if index is None:
665
+ QToolTip.hideText()
666
+ else:
667
+ QToolTip.showText(event.globalPosition().toPoint(), self.describe_dot(index), self)
668
+
669
+ def mouseReleaseEvent(self, event) -> None: # noqa: N802
670
+ if self._panning:
671
+ self._panning = False
672
+ self._pan_origin = None
673
+ self.unsetCursor()
674
+ return
675
+ if self._drag_start is None:
676
+ return
677
+ start, end = self._drag_start, event.position()
678
+ self._drag_start = self._drag_now = None
679
+ if abs(end.x() - start.x()) < 6 and abs(end.y() - start.y()) < 6:
680
+ # A click, not a drag: select (or deselect) the case under the pointer.
681
+ index = self.dot_at(end)
682
+ self.select_case(self.data.case[index] if index is not None else -1)
683
+ return
684
+ (ax, ay), (bx, by) = self.to_data(start), self.to_data(end)
685
+ self.set_view(min(ax, bx), max(ax, bx), min(ay, by), max(ay, by))
686
+
687
+ def mouseDoubleClickEvent(self, event) -> None: # noqa: N802
688
+ self.reset_view()
689
+
690
+ def wheelEvent(self, event) -> None: # noqa: N802
691
+ delta = event.angleDelta()
692
+ modifiers = event.modifiers()
693
+ if modifiers & (Qt.ControlModifier | Qt.MetaModifier):
694
+ factor = 1.0015 ** delta.y()
695
+ only_rows = bool(modifiers & Qt.ShiftModifier)
696
+ self.zoom_at(event.position(), 1.0 if only_rows else factor, factor)
697
+ else:
698
+ # Trackpads send both axes; Shift turns a vertical wheel into horizontal.
699
+ dx, dy = delta.x(), delta.y()
700
+ if modifiers & Qt.ShiftModifier and dx == 0:
701
+ dx, dy = dy, 0
702
+ self.pan_by(-dx / 1200, -dy / 1200)
703
+ event.accept()
704
+
705
+ def event(self, event) -> bool:
706
+ if event.type() == event.Type.NativeGesture:
707
+ try:
708
+ if event.gestureType() == Qt.ZoomNativeGesture:
709
+ factor = 1 + event.value()
710
+ self.zoom_at(event.position(), factor, factor)
711
+ return True
712
+ except AttributeError: # pragma: no cover
713
+ pass
714
+ return super().event(event)
715
+
716
+ def keyPressEvent(self, event) -> None: # noqa: N802
717
+ key = event.key()
718
+ if key == Qt.Key_Escape:
719
+ self.select_case(-1)
720
+ elif key in (Qt.Key_Backspace, Qt.Key_Delete):
721
+ self.back()
722
+ elif key in (Qt.Key_Plus, Qt.Key_Equal):
723
+ self.zoom_at(QPointF(self.plot_rect().center()), 1.5, 1.5)
724
+ elif key == Qt.Key_Minus:
725
+ self.zoom_at(QPointF(self.plot_rect().center()), 1 / 1.5, 1 / 1.5)
726
+ elif key == Qt.Key_0:
727
+ self.reset_view()
728
+ else:
729
+ super().keyPressEvent(event)
730
+
731
+ def select_case(self, case: int) -> None:
732
+ self.selected_case = case
733
+ self._invalidate()
734
+ self.case_selected.emit(case)
735
+
736
+ def resizeEvent(self, event) -> None: # noqa: N802
737
+ self._invalidate()
738
+ super().resizeEvent(event)
739
+
740
+ # -- export ----------------------------------------------------------------------------
741
+ def export_image(self, path: str) -> None:
742
+ if path.lower().endswith(".svg"):
743
+ from PySide6.QtSvg import QSvgGenerator
744
+ generator = QSvgGenerator()
745
+ generator.setFileName(path)
746
+ generator.setSize(self.size())
747
+ generator.setViewBox(QRectF(0, 0, self.width(), self.height()))
748
+ painter = QPainter(generator)
749
+ painter.fillRect(self.rect(), QColor(style.tokens().surface))
750
+ self.paint_chart(painter, QRectF(self.rect()))
751
+ painter.end()
752
+ else:
753
+ image = QImage(self.width() * 2, self.height() * 2, QImage.Format_ARGB32)
754
+ image.setDevicePixelRatio(2)
755
+ image.fill(QColor(style.tokens().surface))
756
+ painter = QPainter(image)
757
+ painter.setRenderHint(QPainter.Antialiasing)
758
+ self.paint_chart(painter, QRectF(self.rect()))
759
+ painter.end()
760
+ image.save(path)
761
+
762
+
763
+ def _draw_shape(painter: QPainter, shape: str, centre: QPointF, r: float) -> None:
764
+ x, y = centre.x(), centre.y()
765
+ if shape == "circle":
766
+ painter.drawEllipse(centre, r, r)
767
+ elif shape == "square":
768
+ painter.drawRect(QRectF(x - r * 0.9, y - r * 0.9, r * 1.8, r * 1.8))
769
+ elif shape == "triangle":
770
+ painter.drawPolygon(QPolygonF([QPointF(x, y - r * 1.15), QPointF(x + r * 1.05, y + r * 0.8),
771
+ QPointF(x - r * 1.05, y + r * 0.8)]))
772
+ elif shape == "diamond":
773
+ painter.drawPolygon(QPolygonF([QPointF(x, y - r * 1.25), QPointF(x + r * 1.1, y),
774
+ QPointF(x, y + r * 1.25), QPointF(x - r * 1.1, y)]))
775
+ elif shape == "cross":
776
+ path = QPainterPath()
777
+ w = r * 0.42
778
+ path.addRect(QRectF(x - r, y - w, 2 * r, 2 * w))
779
+ path.addRect(QRectF(x - w, y - r, 2 * w, 2 * r))
780
+ painter.drawPath(path.simplified())
781
+ elif shape == "star":
782
+ points = []
783
+ for k in range(10):
784
+ radius = r * 1.25 if k % 2 == 0 else r * 0.55
785
+ angle = -math.pi / 2 + k * math.pi / 5
786
+ points.append(QPointF(x + radius * math.cos(angle), y + radius * math.sin(angle)))
787
+ painter.drawPolygon(QPolygonF(points))
788
+ else: # "Other": hollow ring in the fill colour
789
+ brush_colour = painter.brush().color()
790
+ painter.save()
791
+ painter.setBrush(Qt.NoBrush)
792
+ painter.setPen(QPen(brush_colour, 1.4))
793
+ painter.drawEllipse(centre, r * 0.9, r * 0.9)
794
+ painter.restore()
795
+
796
+
797
+ # ---------------------------------------------------------------------------
798
+ # The complete tab: chart, navigation bar, settings, legend, statistics
799
+ # ---------------------------------------------------------------------------
800
+ class DottedChartPanel(QWidget):
801
+ #: The most recent configuration, shared by every dotted chart: switching
802
+ #: to another log's dotted chart shows it the same way (like-for-like
803
+ #: comparison), while each chart keeps its own zoom and selection.
804
+ shared: DotSettings = DotSettings()
805
+
806
+ def __init__(self, document: LogDocument, parent=None) -> None:
807
+ super().__init__(parent)
808
+ self.document = document
809
+ self.settings = replace(DottedChartPanel.shared)
810
+ self._last_x_mode = self.settings.x_mode
811
+ self.chart = DottedChart()
812
+
813
+ # -- navigation bar above the chart
814
+ back = button("‹ Back", self.chart.back, tooltip=f"Undo the last zoom ({shortcut_text('Backspace')})")
815
+ reset = button("Reset", self.chart.reset_view, tooltip="Show everything (double-click, 0)")
816
+ zoom_in = button("+", lambda: self.chart.zoom_at(QPointF(self.chart.plot_rect().center()),
817
+ 1.5, 1.5), tooltip="Zoom in (+)")
818
+ zoom_out = button("-", lambda: self.chart.zoom_at(QPointF(self.chart.plot_rect().center()),
819
+ 1 / 1.5, 1 / 1.5), tooltip="Zoom out (−)")
820
+ export = button("Export image…", self._export)
821
+ self.view_info = ElidedLabel("", "muted")
822
+ hint = label(f"Drag to zoom into an area · {modifier_text('Ctrl')}-scroll or pinch to zoom · "
823
+ f"{modifier_text('Alt')}-drag or middle-drag to pan · "
824
+ "click a dot to select its case", "muted", wrap=True)
825
+
826
+ # -- zoom sliders and scroll bars
827
+ self.h_zoom = self._zoom_slider("Horizontal zoom")
828
+ self.v_zoom = self._zoom_slider("Vertical zoom")
829
+ self.h_bar = QScrollBar(Qt.Horizontal)
830
+ self.v_bar = QScrollBar(Qt.Vertical)
831
+ for bar in (self.h_bar, self.v_bar):
832
+ bar.setRange(0, 0)
833
+ bar.valueChanged.connect(self._scrolled)
834
+ self.h_zoom.valueChanged.connect(lambda v: self._slider_zoom())
835
+ self.v_zoom.valueChanged.connect(lambda v: self._slider_zoom())
836
+ self._syncing = False
837
+
838
+ chart_area = QWidget()
839
+ grid = QHBoxLayout(chart_area)
840
+ grid.setContentsMargins(0, 0, 0, 0)
841
+ grid.setSpacing(2)
842
+ grid.addWidget(self.chart, 1)
843
+ grid.addWidget(self.v_bar)
844
+
845
+ chart_card = Card("Dotted chart")
846
+ chart_card.header.addWidget(back)
847
+ chart_card.header.addWidget(reset)
848
+ chart_card.header.addWidget(zoom_out)
849
+ chart_card.header.addWidget(zoom_in)
850
+ chart_card.header.addWidget(export)
851
+ chart_card.add(chart_area, 1)
852
+ chart_card.add(self.h_bar)
853
+ chart_card.add(hbox(label("Zoom ↔", "muted"), self.h_zoom, 14, label("↕", "muted"),
854
+ self.v_zoom, None))
855
+ chart_card.add(self.view_info)
856
+ chart_card.add(hint)
857
+
858
+ # -- settings side panel
859
+ side = QWidget()
860
+ side_layout = QVBoxLayout(side)
861
+ side_layout.setContentsMargins(0, 0, 4, 0)
862
+ side_layout.setSpacing(12)
863
+ side_layout.addWidget(self._build_settings())
864
+ side_layout.addWidget(self._build_legend())
865
+ side_layout.addWidget(self._build_statistics())
866
+ side_layout.addStretch(1)
867
+ side_scroll = scroll(side)
868
+ side_scroll.setMinimumWidth(304)
869
+ side_scroll.setMaximumWidth(340)
870
+
871
+ splitter = QSplitter()
872
+ splitter.setHandleWidth(12)
873
+ splitter.setStyleSheet("QSplitter::handle { background: transparent; }")
874
+ splitter.addWidget(chart_card)
875
+ splitter.addWidget(side_scroll)
876
+ splitter.setStretchFactor(0, 1)
877
+ splitter.setSizes([900, 290])
878
+ layout = QVBoxLayout(self)
879
+ layout.setContentsMargins(0, 0, 0, 0)
880
+ layout.addWidget(splitter)
881
+
882
+ self.chart.view_changed.connect(self._sync_navigation)
883
+ self.chart.case_selected.connect(self._show_case)
884
+ self.rebuild()
885
+
886
+ # -- settings ------------------------------------------------------------------
887
+ def _combo(self, items: list[str], current: str | int) -> QComboBox:
888
+ box = QComboBox()
889
+ box.addItems(items)
890
+ if isinstance(current, int):
891
+ box.setCurrentIndex(current)
892
+ else:
893
+ box.setCurrentText(current)
894
+ box.setSizeAdjustPolicy(QComboBox.AdjustToMinimumContentsLengthWithIcon)
895
+ box.setMinimumContentsLength(6)
896
+ box.currentIndexChanged.connect(lambda _: self._settings_changed())
897
+ return box
898
+
899
+ def _build_settings(self) -> QWidget:
900
+ log = self.document.log
901
+ extra = sorted({k for t in log.traces[:500] for e in t.events for k in e.attributes}
902
+ - {"concept:name", "time:timestamp", "lifecycle:transition", "org:resource"})
903
+ components = STANDARD_COMPONENTS + extra
904
+ s = self.settings
905
+ self.x_box = self._combo(X_MODES, s.x_mode)
906
+ self.rows_box = self._combo(components, s.rows)
907
+ self.sort_box = self._combo(SORT_MODES, s.sort)
908
+ self.colour_box = self._combo(components + ["None"], s.colour_by)
909
+ self.shape_box = self._combo(["None"] + components, s.shape_by)
910
+ self.descending = QCheckBox("Descending")
911
+ self.all_events = QCheckBox("Show skipped events")
912
+ self.all_events.setToolTip("E.g. show 'start' events too when the classifier keeps "
913
+ "only 'complete' ones")
914
+ self.connect_box = QCheckBox("Connect events of a case")
915
+ # The time unit and the grid step, as in ProM: pick a unit, and the
916
+ # axis counts in it; the grid step is a plain number in that unit.
917
+ self.unit_box = self._combo(list(TIME_UNITS), s.unit)
918
+ self.unit_box.setToolTip("Unit of the x axis. Auto shows clock times and durations; a "
919
+ "unit shows plain numbers (e.g. minutes since the case "
920
+ "started), as in ProM.")
921
+ self.unit_box.currentIndexChanged.disconnect()
922
+ self.unit_box.currentIndexChanged.connect(lambda _: self._settings_changed(keep_view=True))
923
+ self.grid_step = QDoubleSpinBox()
924
+ self.grid_step.setRange(0.0, 1e6)
925
+ self.grid_step.setDecimals(2)
926
+ self.grid_step.setMinimumWidth(100) # room for "1,000.00 min", not 10⁹
927
+ self.grid_step.setSpecialValueText("Auto") # shown at 0
928
+ self.grid_step.setValue(s.grid_step)
929
+ self.grid_step.setToolTip("Distance between the vertical gridlines, in the unit of the "
930
+ "x axis. 0 = automatic (a round number that fits the zoom).")
931
+ self.grid_step.valueChanged.connect(lambda _: self._settings_changed(keep_view=True))
932
+ self.grid_lines_box = QCheckBox("Grid lines")
933
+ self.grid_lines_box.setChecked(s.grid_lines)
934
+ self.grid_lines_box.toggled.connect(lambda _: self._settings_changed(keep_view=True))
935
+ self.row_lines_box = QCheckBox("Row lines")
936
+ self.row_lines_box.setChecked(s.row_lines)
937
+ self.descending.setChecked(s.descending)
938
+ self.all_events.setChecked(s.all_events)
939
+ self.connect_box.setChecked(s.connect)
940
+ self.row_lines_box.toggled.connect(lambda _: self._settings_changed(keep_view=True))
941
+ for box in (self.descending, self.all_events, self.connect_box):
942
+ box.toggled.connect(lambda _: self._settings_changed())
943
+ self.size_slider = QSlider(Qt.Horizontal)
944
+ self.size_slider.setRange(2, 10)
945
+ self.size_slider.setValue(int(s.dot_size))
946
+ self.size_slider.valueChanged.connect(lambda _: self._settings_changed(keep_view=True))
947
+
948
+ self._has_time = any(e.timestamp for t in log.traces[:200] for e in t.events)
949
+ if not self._has_time:
950
+ # No times: only the logical axes make sense. Switch quietly --
951
+ # this panel is still being built, and a log without times should
952
+ # not change the default axis of the next log's chart.
953
+ self.x_box.blockSignals(True)
954
+ self.x_box.setCurrentIndex(4)
955
+ self.x_box.blockSignals(False)
956
+ self.settings.x_mode = self._last_x_mode = 4
957
+ for index in range(3):
958
+ self.x_box.model().item(index).setEnabled(False)
959
+
960
+ card = Card("Configure")
961
+ form = QFormLayout()
962
+ form.setLabelAlignment(Qt.AlignLeft)
963
+ form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
964
+ form.setSpacing(8)
965
+ form.addRow("x axis", self.x_box)
966
+ form.addRow("Rows", self.rows_box)
967
+ form.addRow("Sort rows", self.sort_box)
968
+ form.addRow("", self.descending)
969
+ form.addRow("Colour by", self.colour_box)
970
+ form.addRow("Shape by", self.shape_box)
971
+ form.addRow("Dot size", self.size_slider)
972
+ form.addRow("Time unit", self.unit_box)
973
+ form.addRow("Grid every", self.grid_step)
974
+ card.add(form)
975
+ card.add(hbox(self.grid_lines_box, self.row_lines_box, None))
976
+ card.add(self.all_events)
977
+ card.add(self.connect_box)
978
+ return card
979
+
980
+ def _update_axis_controls(self) -> None:
981
+ """The unit only applies to time axes; the grid step's suffix names
982
+ the unit it is measured in."""
983
+ mode = self.x_box.currentIndex()
984
+ is_time = mode in (0, 1)
985
+ self.unit_box.setEnabled(is_time)
986
+ unit = self.unit_box.currentText()
987
+ if is_time:
988
+ suffix = UNIT_SUFFIX.get(unit, "s")
989
+ elif mode == 2:
990
+ suffix = "%"
991
+ else:
992
+ suffix = "events"
993
+ self.grid_step.blockSignals(True)
994
+ self.grid_step.setSuffix(f" {suffix}")
995
+ self.grid_step.setDecimals(0 if mode >= 3 else 2)
996
+ self.grid_step.blockSignals(False)
997
+
998
+ def _build_legend(self) -> QWidget:
999
+ card = Card("Legend", "Untick to hide. Double-click to show only that value. "
1000
+ "Right-click a value (or select it and press Colour…) to pick its colour.")
1001
+ self.legend = QListWidget()
1002
+ self.legend.setMinimumHeight(140)
1003
+ self.legend.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
1004
+ self.legend.setTextElideMode(Qt.ElideRight)
1005
+ self.legend.itemChanged.connect(self._legend_toggled)
1006
+ self.legend.itemDoubleClicked.connect(self._legend_isolate)
1007
+ self.legend.setContextMenuPolicy(Qt.CustomContextMenu)
1008
+ self.legend.customContextMenuRequested.connect(self._legend_menu)
1009
+ card.add(self.legend)
1010
+ self.colour_button = button("Colour…", self._choose_colour, kind="ghost",
1011
+ tooltip="Pick the colour of the selected value")
1012
+ self.reset_colours_button = button("Reset colours", self._reset_colours, kind="ghost",
1013
+ tooltip="Back to the default palette")
1014
+ card.add(flow(button("Show all", self._legend_show_all, kind="ghost"),
1015
+ self.colour_button, self.reset_colours_button))
1016
+ return card
1017
+
1018
+ # -- choosing colours --------------------------------------------------------------------
1019
+ def _legend_menu(self, point) -> None:
1020
+ from PySide6.QtWidgets import QMenu
1021
+ item = self.legend.itemAt(point)
1022
+ index = item.data(Qt.UserRole) if item is not None else None
1023
+ menu = QMenu(self)
1024
+ if index is not None:
1025
+ self.legend.setCurrentItem(item)
1026
+ menu.addAction("Choose colour…", lambda: self.choose_colour(index))
1027
+ key = self.chart.data.colour_key(index)
1028
+ reset = menu.addAction("Default colour", lambda: self.set_colour(index, None))
1029
+ reset.setEnabled(key in self.settings.colours)
1030
+ menu.addSeparator()
1031
+ menu.addAction("Reset all colours", self._reset_colours)
1032
+ menu.exec(self.legend.viewport().mapToGlobal(point))
1033
+
1034
+ def _choose_colour(self) -> None:
1035
+ item = self.legend.currentItem()
1036
+ index = item.data(Qt.UserRole) if item is not None else None
1037
+ if index is None:
1038
+ self._toast("Select a value in the legend first")
1039
+ return
1040
+ self.choose_colour(index)
1041
+
1042
+ def choose_colour(self, index: int) -> None:
1043
+ from PySide6.QtWidgets import QColorDialog
1044
+ d = self.chart.data
1045
+ value = d.colour_values[index] or "–"
1046
+ colour = QColorDialog.getColor(QColor(d.colour_hex(index)), self,
1047
+ f"Colour of “{value}”")
1048
+ if colour.isValid():
1049
+ self.set_colour(index, colour.name())
1050
+
1051
+ def set_colour(self, index: int, colour: str | None) -> None:
1052
+ """Give a legend value its own dot colour (``None``: the default)."""
1053
+ key = self.chart.data.colour_key(index)
1054
+ if colour is None:
1055
+ self.settings.colours.pop(key, None)
1056
+ else:
1057
+ self.settings.colours[key] = colour
1058
+ self._colours_changed()
1059
+
1060
+ def _reset_colours(self) -> None:
1061
+ prefix = f"{self.settings.colour_by}\x1f"
1062
+ for key in [k for k in self.settings.colours if k.startswith(prefix)]:
1063
+ del self.settings.colours[key]
1064
+ self._colours_changed()
1065
+
1066
+ def _colours_changed(self) -> None:
1067
+ current = self.legend.currentRow()
1068
+ self._fill_legend()
1069
+ self.legend.setCurrentRow(current)
1070
+ self.chart._invalidate()
1071
+
1072
+ def _toast(self, text: str) -> None:
1073
+ QToolTip.showText(self.colour_button.mapToGlobal(self.colour_button.rect().center()),
1074
+ text, self.colour_button)
1075
+
1076
+ def _build_statistics(self) -> QWidget:
1077
+ card = Card("Statistics")
1078
+ self.stats_label = label("", "muted", wrap=True, selectable=True)
1079
+ self.case_label = label("Click a dot to select its case.", "muted", wrap=True,
1080
+ selectable=True)
1081
+ card.add(self.stats_label)
1082
+ card.add(label("Selected case", "sectionLabel"))
1083
+ card.add(self.case_label)
1084
+ return card
1085
+
1086
+ def _settings_changed(self, keep_view: bool = False) -> None:
1087
+ s = self.settings
1088
+ s.x_mode = self.x_box.currentIndex()
1089
+ s.rows = self.rows_box.currentText()
1090
+ s.sort = self.sort_box.currentIndex()
1091
+ s.descending = self.descending.isChecked()
1092
+ s.colour_by = self.colour_box.currentText()
1093
+ s.shape_by = self.shape_box.currentText()
1094
+ s.all_events = self.all_events.isChecked()
1095
+ s.dot_size = float(self.size_slider.value())
1096
+ s.connect = self.connect_box.isChecked()
1097
+ if s.x_mode != self._last_x_mode:
1098
+ if self._last_x_mode != -1:
1099
+ # A step in minutes means nothing on a percentage axis.
1100
+ self.grid_step.blockSignals(True)
1101
+ self.grid_step.setValue(0.0)
1102
+ self.grid_step.blockSignals(False)
1103
+ self._last_x_mode = s.x_mode
1104
+ s.unit = self.unit_box.currentText()
1105
+ self._update_axis_controls()
1106
+ s.grid_step = self.grid_step.value()
1107
+ s.grid_lines = self.grid_lines_box.isChecked()
1108
+ s.row_lines = self.row_lines_box.isChecked()
1109
+ shared = replace(s) # new panels start from these settings
1110
+ if not self._has_time and DottedChartPanel.shared.x_mode < 3:
1111
+ # This log forces a logical axis; that is not a choice to pass on.
1112
+ shared.x_mode = DottedChartPanel.shared.x_mode
1113
+ DottedChartPanel.shared = shared
1114
+ self.rebuild(keep_view)
1115
+
1116
+ def _for_this_log(self, s: DotSettings) -> DotSettings:
1117
+ """``s`` as this log can show it: without times, a time axis becomes
1118
+ logical order."""
1119
+ if not self._has_time and s.x_mode < 3:
1120
+ return replace(s, x_mode=4)
1121
+ return s
1122
+
1123
+ def rebuild(self, keep_view: bool = False) -> None:
1124
+ data = DotData(self.document, self.settings)
1125
+ if not keep_view:
1126
+ self.chart.hidden_colours = set()
1127
+ self.chart.selected_case = -1
1128
+ self._show_case(-1)
1129
+ self.chart.set_data(data, keep_view=keep_view)
1130
+ self._fill_legend()
1131
+ self._update_statistics()
1132
+ self._sync_navigation()
1133
+
1134
+ def showEvent(self, event) -> None: # noqa: N802
1135
+ """Pick up configuration changes made on another log's dotted chart."""
1136
+ super().showEvent(event)
1137
+ shared = self._for_this_log(DottedChartPanel.shared)
1138
+ if shared != self.settings and self.document.log.traces:
1139
+ self._apply_settings_to_controls(shared)
1140
+
1141
+ def _apply_settings_to_controls(self, s: DotSettings) -> None:
1142
+ widgets = (self.x_box, self.rows_box, self.sort_box, self.colour_box, self.shape_box,
1143
+ self.descending, self.all_events, self.connect_box, self.size_slider,
1144
+ self.unit_box, self.grid_step, self.grid_lines_box, self.row_lines_box)
1145
+ for widget in widgets:
1146
+ widget.blockSignals(True)
1147
+ self.x_box.setCurrentIndex(self._for_this_log(s).x_mode)
1148
+ for box, value in ((self.rows_box, s.rows), (self.colour_box, s.colour_by),
1149
+ (self.shape_box, s.shape_by)):
1150
+ index = box.findText(value)
1151
+ box.setCurrentIndex(index if index >= 0 else 0)
1152
+ self.sort_box.setCurrentIndex(s.sort)
1153
+ self.descending.setChecked(s.descending)
1154
+ self.all_events.setChecked(s.all_events)
1155
+ self.connect_box.setChecked(s.connect)
1156
+ self.size_slider.setValue(int(s.dot_size))
1157
+ self.row_lines_box.setChecked(s.row_lines)
1158
+ self.unit_box.setCurrentIndex(max(self.unit_box.findText(s.unit), 0))
1159
+ self.grid_step.setValue(s.grid_step)
1160
+ self.grid_lines_box.setChecked(s.grid_lines)
1161
+ self._last_x_mode = -1
1162
+ for widget in widgets:
1163
+ widget.blockSignals(False)
1164
+ self._settings_changed()
1165
+
1166
+ # -- legend ------------------------------------------------------------------------------
1167
+ def _fill_legend(self) -> None:
1168
+ d = self.chart.data
1169
+ self.legend.blockSignals(True)
1170
+ self.legend.clear()
1171
+ if self.settings.colour_by == "None":
1172
+ item = QListWidgetItem("All events (colour off)")
1173
+ item.setFlags(Qt.ItemIsEnabled)
1174
+ self.legend.addItem(item)
1175
+ else:
1176
+ for index, value in enumerate(d.colour_values):
1177
+ slot = d.colour_slot(index)
1178
+ name = value if value else "–"
1179
+ text = f"{name} ({d.colour_counts[value]:,})"
1180
+ if slot == 99 and d.colour_key(index) not in self.settings.colours:
1181
+ text += " · Other"
1182
+ item = QListWidgetItem(_swatch(d.colour_hex(index)), text)
1183
+ item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsUserCheckable | Qt.ItemIsSelectable)
1184
+ item.setCheckState(Qt.Unchecked if index in self.chart.hidden_colours else Qt.Checked)
1185
+ item.setData(Qt.UserRole, index)
1186
+ item.setToolTip(value)
1187
+ self.legend.addItem(item)
1188
+ self.legend.blockSignals(False)
1189
+
1190
+ def _legend_toggled(self, item: QListWidgetItem) -> None:
1191
+ index = item.data(Qt.UserRole)
1192
+ if index is None:
1193
+ return
1194
+ if item.checkState() == Qt.Checked:
1195
+ self.chart.hidden_colours.discard(index)
1196
+ else:
1197
+ self.chart.hidden_colours.add(index)
1198
+ self.chart._invalidate()
1199
+ self._update_statistics()
1200
+
1201
+ def _legend_isolate(self, item: QListWidgetItem) -> None:
1202
+ index = item.data(Qt.UserRole)
1203
+ if index is None:
1204
+ return
1205
+ d = self.chart.data
1206
+ self.chart.hidden_colours = {i for i in range(len(d.colour_values)) if i != index}
1207
+ self._fill_legend()
1208
+ self.chart._invalidate()
1209
+ self._update_statistics()
1210
+
1211
+ def _legend_show_all(self) -> None:
1212
+ self.chart.hidden_colours = set()
1213
+ self._fill_legend()
1214
+ self.chart._invalidate()
1215
+ self._update_statistics()
1216
+
1217
+ # -- statistics (like ProM's metric panel) --------------------------------------------
1218
+ def _update_statistics(self) -> None:
1219
+ d = self.chart.data
1220
+ if d is None:
1221
+ return
1222
+ shown = [i for i in range(len(d.xs)) if d.colour[i] not in self.chart.hidden_colours]
1223
+ cases = {d.case[i] for i in shown}
1224
+ durations = []
1225
+ for ti in cases:
1226
+ stamps = [e.timestamp for e in d.traces[ti].events if e.timestamp is not None]
1227
+ if len(stamps) > 1:
1228
+ durations.append((max(stamps) - min(stamps)).total_seconds())
1229
+ lines = [f"{len(shown):,} events in {len(cases):,} cases · {len(d.row_values):,} rows"]
1230
+ if durations:
1231
+ durations.sort()
1232
+ mean = sum(durations) / len(durations)
1233
+ lines.append(f"Case duration: mean {format_duration(mean)}, median "
1234
+ f"{format_duration(durations[len(durations) // 2])}, "
1235
+ f"min {format_duration(durations[0])}, max {format_duration(durations[-1])}")
1236
+ if d.origin is not None and d.mode == 0:
1237
+ lines.append(f"Log starts {d.origin.strftime('%Y-%m-%d %H:%M:%S')}")
1238
+ self.stats_label.setText("\n".join(lines))
1239
+
1240
+ def _show_case(self, case: int) -> None:
1241
+ if case < 0:
1242
+ self.case_label.setText("Click a dot to select its case.")
1243
+ return
1244
+ d = self.chart.data
1245
+ trace = d.traces[case]
1246
+ events = trace.events
1247
+ stamps = [e.timestamp for e in events if e.timestamp is not None]
1248
+ duration = format_duration(max(stamps) - min(stamps)) if stamps else "–"
1249
+ sequence = d.classifier and [d.classifier.label(e) for e in events if d.classifier.accepts(e)]
1250
+ runs: list[str] = []
1251
+ for activity in sequence:
1252
+ if runs and runs[-1].split(" ×")[0] == activity:
1253
+ base, _, count = runs[-1].partition(" ×")
1254
+ runs[-1] = f"{base} ×{int(count or 1) + 1}"
1255
+ else:
1256
+ runs.append(activity)
1257
+ attributes = ", ".join(f"{k}={v}" for k, v in trace.attributes.items()
1258
+ if k != "concept:name")
1259
+ self.case_label.setText(
1260
+ f"Case {trace.case_id}: {len(events)} events, duration {duration}\n"
1261
+ + (f"{attributes}\n" if attributes else "")
1262
+ + " → ".join(runs))
1263
+
1264
+ # -- navigation sync -------------------------------------------------------------------
1265
+ @staticmethod
1266
+ def _zoom_slider(tooltip: str) -> QSlider:
1267
+ slider = QSlider(Qt.Horizontal)
1268
+ slider.setRange(0, 1000) # log scale: 1× … 1000×
1269
+ slider.setToolTip(tooltip)
1270
+ slider.setMinimumWidth(60)
1271
+ slider.setMaximumWidth(200)
1272
+ return slider
1273
+
1274
+ def _slider_zoom(self) -> None:
1275
+ if self._syncing:
1276
+ return
1277
+ fx = 1000 ** (self.h_zoom.value() / 1000)
1278
+ fy = 1000 ** (self.v_zoom.value() / 1000)
1279
+ self.chart.set_zoom(fx, fy)
1280
+
1281
+ def _scrolled(self) -> None:
1282
+ if self._syncing:
1283
+ return
1284
+ fx0, fx1, fy0, fy1 = self.chart.full_extent()
1285
+ w, h = self.chart.vx1 - self.chart.vx0, self.chart.vy1 - self.chart.vy0
1286
+ x0 = fx0 + self.h_bar.value() / 10000 * (fx1 - fx0)
1287
+ y0 = fy0 + self.v_bar.value() / 10000 * (fy1 - fy0)
1288
+ self.chart.set_view(x0, x0 + w, y0, y0 + h, record=False)
1289
+
1290
+ def _sync_navigation(self) -> None:
1291
+ chart = self.chart
1292
+ if chart.data is None:
1293
+ return
1294
+ self._syncing = True
1295
+ fx0, fx1, fy0, fy1 = chart.full_extent()
1296
+ zx, zy = chart.zoom_factors()
1297
+ self.h_zoom.setValue(int(round(1000 * math.log(max(zx, 1.0), 1000))))
1298
+ self.v_zoom.setValue(int(round(1000 * math.log(max(zy, 1.0), 1000))))
1299
+ for bar, lo, hi, v0, v1 in ((self.h_bar, fx0, fx1, chart.vx0, chart.vx1),
1300
+ (self.v_bar, fy0, fy1, chart.vy0, chart.vy1)):
1301
+ span = hi - lo
1302
+ page = int(10000 * (v1 - v0) / span) if span else 10000
1303
+ bar.setPageStep(max(page, 1))
1304
+ bar.setSingleStep(max(page // 10, 1))
1305
+ bar.setRange(0, max(10000 - page, 0))
1306
+ bar.setValue(int(10000 * (v0 - lo) / span) if span else 0)
1307
+ bar.setVisible(page < 10000)
1308
+ self._syncing = False
1309
+ d = chart.data
1310
+ if d.mode == 0 and d.origin is not None:
1311
+ a = d.origin + timedelta(seconds=chart.vx0)
1312
+ b = d.origin + timedelta(seconds=chart.vx1)
1313
+ window = f"{a:%d %b %H:%M:%S} – {b:%d %b %H:%M:%S}"
1314
+ elif d.mode in (1,):
1315
+ window = f"{format_duration(max(chart.vx0, 0))} – {format_duration(chart.vx1)}"
1316
+ else:
1317
+ window = f"{chart.vx0:,.0f} – {chart.vx1:,.0f}"
1318
+ rows_shown = max(int(math.ceil(chart.vy1) - math.floor(chart.vy0)), 0)
1319
+ self.view_info.setText(f"{zx:.1f}× · {zy:.1f}× · showing {window}, "
1320
+ f"{min(rows_shown, len(d.row_values)):,} of {len(d.row_values):,} rows")
1321
+
1322
+ def _export(self) -> None:
1323
+ path, _ = QFileDialog.getSaveFileName(self, "Export dotted chart",
1324
+ suggested_path(f"{self.document.name} dotted chart.png"),
1325
+ "PNG image (*.png);;SVG drawing (*.svg)")
1326
+ if path:
1327
+ self.chart.export_image(path)
1328
+
1329
+
1330
+ # ---------------------------------------------------------------------------
1331
+ # Helpers
1332
+ # ---------------------------------------------------------------------------
1333
+ def _swatch(colour: str):
1334
+ from PySide6.QtGui import QIcon
1335
+ pixmap = QPixmap(14, 14)
1336
+ pixmap.fill(Qt.transparent)
1337
+ painter = QPainter(pixmap)
1338
+ painter.setRenderHint(QPainter.Antialiasing)
1339
+ painter.setPen(Qt.NoPen)
1340
+ painter.setBrush(QColor(colour))
1341
+ painter.drawEllipse(QRectF(2, 2, 10, 10))
1342
+ painter.end()
1343
+ return QIcon(pixmap)
1344
+
1345
+
1346
+ def _natural(text: str):
1347
+ """Sort '2' before '10', numbers before words."""
1348
+ return (0, int(text), "") if text.isdigit() else (1, 0, text.lower())
1349
+
1350
+
1351
+ def axis_unit(mode: int, unit: str) -> float:
1352
+ """Data units (seconds, %, events) per unit shown on the axis."""
1353
+ if mode in (0, 1):
1354
+ return TIME_UNITS.get(unit) or 1.0
1355
+ return 1.0
1356
+
1357
+
1358
+ def axis_title(mode: int, unit: str) -> str:
1359
+ if mode in (0, 1) and unit != "Auto":
1360
+ name = unit.split(" (")[0].lower()
1361
+ return ("Time since the first event" if mode == 0 else X_TITLES[1]) + f" ({name})"
1362
+ return X_TITLES[mode]
1363
+
1364
+
1365
+ def _number(value: float) -> str:
1366
+ """12.0 -> "12", 2.5 -> "2.5", 0.125 -> "0.125": no float noise."""
1367
+ text = f"{value:,.3f}".rstrip("0").rstrip(".")
1368
+ return "0" if text in ("-0", "") else text
1369
+
1370
+
1371
+ def _nice_step(raw: float) -> float:
1372
+ if raw <= 0:
1373
+ return 1
1374
+ exponent = math.floor(math.log10(raw))
1375
+ for multiple in (1, 2, 5, 10):
1376
+ step = multiple * 10 ** exponent
1377
+ if step >= raw:
1378
+ return step
1379
+ return 10 ** (exponent + 1)
1380
+
1381
+
1382
+ _TIME_STEPS = [0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60,
1383
+ 120, 300, 600, 900, 1800, 3600, 7200, 10800, 21600, 43200, 86400, 172800, 604800,
1384
+ 1209600, 2592000, 7776000, 15552000, 31536000]
1385
+
1386
+
1387
+ def _nice_time_step(raw: float) -> float:
1388
+ index = bisect.bisect_left(_TIME_STEPS, raw)
1389
+ return _TIME_STEPS[min(index, len(_TIME_STEPS) - 1)]
1390
+
1391
+
1392
+ def _frange(start: float, stop: float, step: float):
1393
+ value = start
1394
+ count = 0
1395
+ while value <= stop + 1e-9 and count < 400:
1396
+ yield value
1397
+ value += step
1398
+ count += 1
1399
+
1400
+
1401
+ __all__ = ["DotData", "DotSettings", "DottedChart", "DottedChartPanel", "vbox", "QImage"]