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,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"]
|