QT-PyQt-PySide-Custom-Widgets-Pro 1.1.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.
- custom_widgets_pro/__init__.py +73 -0
- custom_widgets_pro/_catalog.py +25 -0
- custom_widgets_pro/_cli.py +132 -0
- custom_widgets_pro/_license.py +419 -0
- custom_widgets_pro/_version.py +14 -0
- custom_widgets_pro/datatable/__init__.py +17 -0
- custom_widgets_pro/datatable/datatable_pro.py +319 -0
- custom_widgets_pro/datatable/export.py +143 -0
- custom_widgets_pro/datatable/frozen_view.py +112 -0
- custom_widgets_pro/datatable/grouping.py +309 -0
- custom_widgets_pro/datatable/pivot.py +100 -0
- custom_widgets_pro/datatable/provider.py +98 -0
- custom_widgets_pro/datatable/virtual_model.py +451 -0
- custom_widgets_pro/widgets/__init__.py +7 -0
- custom_widgets_pro/widgets/charts/QCustomBeeswarm.py +317 -0
- custom_widgets_pro/widgets/charts/QCustomBubbleChart.py +655 -0
- custom_widgets_pro/widgets/charts/QCustomCandlestickChart.py +522 -0
- custom_widgets_pro/widgets/charts/QCustomDivergingBarChart.py +430 -0
- custom_widgets_pro/widgets/charts/QCustomDotMatrix.py +266 -0
- custom_widgets_pro/widgets/charts/QCustomFunnelChart.py +474 -0
- custom_widgets_pro/widgets/charts/QCustomGanttChart.py +376 -0
- custom_widgets_pro/widgets/charts/QCustomHeatmap.py +555 -0
- custom_widgets_pro/widgets/charts/QCustomRadarChart.py +600 -0
- custom_widgets_pro/widgets/charts/QCustomRadialBars.py +441 -0
- custom_widgets_pro/widgets/charts/QCustomRadialLines.py +554 -0
- custom_widgets_pro/widgets/charts/QCustomRangeBarChart.py +504 -0
- custom_widgets_pro/widgets/charts/QCustomSankey.py +539 -0
- custom_widgets_pro/widgets/charts/QCustomScatterChart.py +623 -0
- custom_widgets_pro/widgets/charts/__init__.py +1 -0
- custom_widgets_pro/widgets/data/QCustomCodeEditor.py +391 -0
- custom_widgets_pro/widgets/data/QCustomNodeGraph.py +1030 -0
- custom_widgets_pro/widgets/data/QCustomRichTextEditor.py +189 -0
- custom_widgets_pro/widgets/data/QCustomTableToolbar.py +566 -0
- custom_widgets_pro/widgets/data/__init__.py +1 -0
- custom_widgets_pro/widgets/media/QCustomImageViewer.py +300 -0
- custom_widgets_pro/widgets/media/QCustomMediaGrid.py +197 -0
- custom_widgets_pro/widgets/media/QCustomMediaTimeline.py +607 -0
- custom_widgets_pro/widgets/media/QCustomVideoPlayer.py +326 -0
- custom_widgets_pro/widgets/media/__init__.py +1 -0
- qt_pyqt_pyside_custom_widgets_pro-1.1.0.dist-info/METADATA +112 -0
- qt_pyqt_pyside_custom_widgets_pro-1.1.0.dist-info/RECORD +45 -0
- qt_pyqt_pyside_custom_widgets_pro-1.1.0.dist-info/WHEEL +5 -0
- qt_pyqt_pyside_custom_widgets_pro-1.1.0.dist-info/entry_points.txt +2 -0
- qt_pyqt_pyside_custom_widgets_pro-1.1.0.dist-info/licenses/LICENSE +91 -0
- qt_pyqt_pyside_custom_widgets_pro-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
########################################################################
|
|
2
|
+
## SPINN DESIGN CODE
|
|
3
|
+
# YOUTUBE: (SPINN TV) https://www.youtube.com/@SpinnTV
|
|
4
|
+
# WEBSITE: customwidgets.org
|
|
5
|
+
########################################################################
|
|
6
|
+
|
|
7
|
+
########################################################################
|
|
8
|
+
## QCustomRadarChart - a painted radar / spider chart.
|
|
9
|
+
##
|
|
10
|
+
## N axes radiating from a centre, one filled polygon per series. Used to
|
|
11
|
+
## compare several entities across the same set of measures - the shape of the
|
|
12
|
+
## polygon is the comparison, which no cartesian chart gives you.
|
|
13
|
+
##
|
|
14
|
+
## This is the catalog's first polar chart of any kind. Rendered entirely with
|
|
15
|
+
## QPainter and NO QtCharts: Qt Charts is GPLv3-or-commercial with no LGPL
|
|
16
|
+
## option, so anything built on it cannot ship inside a proprietary wheel (see
|
|
17
|
+
## docs/design/mui-charts-gap.md). A test asserts the import graph stays clean.
|
|
18
|
+
##
|
|
19
|
+
## Data goes in with setAxes([...]) + addSeries(name, values) in code, or the
|
|
20
|
+
## axesCsv / seriesCsv properties in Qt Designer, following the same convention
|
|
21
|
+
## as the other charts:
|
|
22
|
+
##
|
|
23
|
+
## axesCsv = "Speed,Power,Range,Agility"
|
|
24
|
+
## seriesCsv = "Alpha=80,60,90,70;Beta=60,90,50,80"
|
|
25
|
+
##
|
|
26
|
+
## Emits seriesHovered(int) and axisClicked(int); -1 means "nothing".
|
|
27
|
+
########################################################################
|
|
28
|
+
import math
|
|
29
|
+
|
|
30
|
+
from qtpy.QtCore import Qt, Signal, Property, QRectF, QSize, QPointF
|
|
31
|
+
from qtpy.QtGui import (QColor, QPainter, QPen, QBrush, QPolygonF, QFontMetrics)
|
|
32
|
+
from qtpy.QtWidgets import QWidget, QSizePolicy
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class QCustomRadarChart(QWidget):
|
|
36
|
+
seriesHovered = Signal(int)
|
|
37
|
+
axisClicked = Signal(int)
|
|
38
|
+
|
|
39
|
+
WIDGET_ICON = "components/icons/radar.png"
|
|
40
|
+
WIDGET_TOOLTIP = "A painted radar / spider chart"
|
|
41
|
+
WIDGET_MODULE = "Custom_Widgets.QCustomRadarChart"
|
|
42
|
+
WIDGET_DOM_XML = """
|
|
43
|
+
<ui language='c++'>
|
|
44
|
+
<widget class='QCustomRadarChart' name='customRadarChart'>
|
|
45
|
+
<property name='geometry'><rect><x>0</x><y>0</y><width>320</width><height>300</height></rect></property>
|
|
46
|
+
<property name='axesCsv'><string>Speed,Power,Range,Agility,Cost</string></property>
|
|
47
|
+
<property name='seriesCsv'><string>Alpha=80,60,90,70,50</string></property>
|
|
48
|
+
</widget>
|
|
49
|
+
</ui>
|
|
50
|
+
"""
|
|
51
|
+
__catalog__ = {
|
|
52
|
+
"name": "QCustomRadarChart",
|
|
53
|
+
"props": {"axesCsv": {"type": "string", "default": ""},
|
|
54
|
+
"seriesCsv": {"type": "string", "default": ""},
|
|
55
|
+
"seriesColorsCsv": {"type": "string", "default": ""},
|
|
56
|
+
"maxValue": {"type": "float", "default": 0.0},
|
|
57
|
+
"rings": {"type": "int", "default": 4},
|
|
58
|
+
"gridStyle": {"type": "enum", "values": ["polygon", "circle"],
|
|
59
|
+
"default": "polygon"},
|
|
60
|
+
"startAngle": {"type": "int", "default": 90},
|
|
61
|
+
"fillOpacity": {"type": "float", "default": 0.25},
|
|
62
|
+
"lineWidth": {"type": "float", "default": 2.0},
|
|
63
|
+
"showAxisLabels": {"type": "bool", "default": True},
|
|
64
|
+
"showRingLabels": {"type": "bool", "default": False},
|
|
65
|
+
"showMarkers": {"type": "bool", "default": True},
|
|
66
|
+
"showLegend": {"type": "bool", "default": True},
|
|
67
|
+
"gridColor": {"type": "color", "default": "#e2e8f0"},
|
|
68
|
+
"axisColor": {"type": "color", "default": "#64748b"},
|
|
69
|
+
"labelColor": {"type": "color", "default": "#0f172a"}},
|
|
70
|
+
"signals": ["seriesHovered", "axisClicked"],
|
|
71
|
+
"tokens_used": ["on-surface", "on-surface-muted", "surface-muted"],
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
# Rich editors for the Designer "Custom Properties" dock (see
|
|
75
|
+
# DesignerTools.CustomPropertiesDock).
|
|
76
|
+
DESIGNER_CUSTOM_PROPS = [
|
|
77
|
+
{"name": "axesCsv", "kind": "str", "group": "General"},
|
|
78
|
+
{"name": "seriesCsv", "kind": "str", "group": "General"},
|
|
79
|
+
{"name": "seriesColorsCsv", "kind": "str", "group": "Colors"},
|
|
80
|
+
{"name": "maxValue", "kind": "float", "group": "General"},
|
|
81
|
+
{"name": "rings", "kind": "int", "group": "General"},
|
|
82
|
+
{"name": "gridStyle", "kind": "choice", "group": "General"},
|
|
83
|
+
{"name": "startAngle", "kind": "int", "group": "General"},
|
|
84
|
+
{"name": "fillOpacity", "kind": "float", "group": "General"},
|
|
85
|
+
{"name": "lineWidth", "kind": "float", "group": "General"},
|
|
86
|
+
{"name": "showAxisLabels", "kind": "bool", "group": "General"},
|
|
87
|
+
{"name": "showRingLabels", "kind": "bool", "group": "General"},
|
|
88
|
+
{"name": "showMarkers", "kind": "bool", "group": "General"},
|
|
89
|
+
{"name": "showLegend", "kind": "bool", "group": "General"},
|
|
90
|
+
{"name": "gridColor", "kind": "color", "group": "Colors"},
|
|
91
|
+
{"name": "axisColor", "kind": "color", "group": "Colors"},
|
|
92
|
+
{"name": "labelColor", "kind": "color", "group": "Colors"},
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
_DEFAULT_COLORS = ["#2563eb", "#16a34a", "#f59e0b", "#dc2626", "#7c3aed",
|
|
96
|
+
"#0891b2"]
|
|
97
|
+
_LEGEND_SWATCH = 10
|
|
98
|
+
|
|
99
|
+
def __init__(self, parent=None, axes=None, series=None):
|
|
100
|
+
super().__init__(parent)
|
|
101
|
+
self.setObjectName("QCustomRadarChart")
|
|
102
|
+
self._axes = [] # axis labels
|
|
103
|
+
self._series = [] # list of (name, [values])
|
|
104
|
+
self._colors = [] # explicit per-series colours
|
|
105
|
+
self._maxValue = 0.0 # 0 = derive from the data
|
|
106
|
+
self._rings = 4
|
|
107
|
+
self._gridStyle = "polygon"
|
|
108
|
+
self._startAngle = 90 # degrees, counter-clockwise from +x
|
|
109
|
+
self._fillOpacity = 0.25
|
|
110
|
+
self._lineWidth = 2.0
|
|
111
|
+
self._showAxisLabels = True
|
|
112
|
+
self._showRingLabels = False
|
|
113
|
+
self._showMarkers = True
|
|
114
|
+
self._showLegend = True
|
|
115
|
+
self._hover = -1
|
|
116
|
+
|
|
117
|
+
self._gridColor = QColor("#e2e8f0")
|
|
118
|
+
# Was #cbd5e1 -- the light "outline" value, 1.48:1, so the spokes that
|
|
119
|
+
# give a radar its shape were invisible until a theme was applied. This
|
|
120
|
+
# is light-theme on-surface-muted, matching the token QSS. The web
|
|
121
|
+
# rings above stay faint.
|
|
122
|
+
self._axisColor = QColor("#64748b")
|
|
123
|
+
self._labelColor = QColor("#0f172a")
|
|
124
|
+
|
|
125
|
+
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
|
126
|
+
self.setMouseTracking(True)
|
|
127
|
+
if axes:
|
|
128
|
+
self.setAxes(axes)
|
|
129
|
+
for name, values in (series or []):
|
|
130
|
+
self.addSeries(name, values)
|
|
131
|
+
|
|
132
|
+
# ------------------------------------------------------------------ #
|
|
133
|
+
## Data
|
|
134
|
+
# ------------------------------------------------------------------ #
|
|
135
|
+
def setAxes(self, labels):
|
|
136
|
+
"""Replace the axis labels. Existing series are re-fitted to the new
|
|
137
|
+
count so a shortened axis list cannot leave a series over-long."""
|
|
138
|
+
self._axes = [str(x) for x in (labels or [])]
|
|
139
|
+
self._series = [(name, self._fit(values)) for name, values in self._series]
|
|
140
|
+
self.update()
|
|
141
|
+
|
|
142
|
+
def axes(self):
|
|
143
|
+
return list(self._axes)
|
|
144
|
+
|
|
145
|
+
def axisCount(self):
|
|
146
|
+
return len(self._axes)
|
|
147
|
+
|
|
148
|
+
def _fit(self, values):
|
|
149
|
+
"""Pad with zeros / truncate so a series matches the axis count."""
|
|
150
|
+
count = len(self._axes)
|
|
151
|
+
values = [float(v) for v in values][:count]
|
|
152
|
+
return values + [0.0] * (count - len(values))
|
|
153
|
+
|
|
154
|
+
def addSeries(self, name, values, color=None):
|
|
155
|
+
self._series.append((str(name), self._fit(values)))
|
|
156
|
+
if color is not None:
|
|
157
|
+
while len(self._colors) < len(self._series) - 1:
|
|
158
|
+
self._colors.append(None)
|
|
159
|
+
self._colors.append(QColor(color))
|
|
160
|
+
self.update()
|
|
161
|
+
return len(self._series) - 1
|
|
162
|
+
|
|
163
|
+
def setSeries(self, series):
|
|
164
|
+
"""Replace every series. Each item is (name, values)."""
|
|
165
|
+
self._series = []
|
|
166
|
+
for item in series or []:
|
|
167
|
+
if isinstance(item, dict):
|
|
168
|
+
self._series.append((str(item.get("name", "")),
|
|
169
|
+
self._fit(item.get("values", []))))
|
|
170
|
+
elif isinstance(item, (tuple, list)) and len(item) >= 2:
|
|
171
|
+
self._series.append((str(item[0]), self._fit(item[1])))
|
|
172
|
+
self.update()
|
|
173
|
+
|
|
174
|
+
def series(self):
|
|
175
|
+
return [(name, list(values)) for name, values in self._series]
|
|
176
|
+
|
|
177
|
+
def seriesCount(self):
|
|
178
|
+
return len(self._series)
|
|
179
|
+
|
|
180
|
+
def removeSeries(self, index):
|
|
181
|
+
if 0 <= index < len(self._series):
|
|
182
|
+
del self._series[index]
|
|
183
|
+
if index < len(self._colors):
|
|
184
|
+
del self._colors[index]
|
|
185
|
+
self._hover = -1
|
|
186
|
+
self.update()
|
|
187
|
+
return True
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
def clearSeries(self):
|
|
191
|
+
self._series = []
|
|
192
|
+
self._colors = []
|
|
193
|
+
self._hover = -1
|
|
194
|
+
self.update()
|
|
195
|
+
|
|
196
|
+
def seriesColor(self, index):
|
|
197
|
+
if 0 <= index < len(self._colors) and self._colors[index] is not None:
|
|
198
|
+
return QColor(self._colors[index])
|
|
199
|
+
if not self._DEFAULT_COLORS:
|
|
200
|
+
return QColor("#2563eb")
|
|
201
|
+
return QColor(self._DEFAULT_COLORS[index % len(self._DEFAULT_COLORS)])
|
|
202
|
+
|
|
203
|
+
def maximum(self):
|
|
204
|
+
"""The value the outermost ring represents."""
|
|
205
|
+
if self._maxValue > 0:
|
|
206
|
+
return self._maxValue
|
|
207
|
+
peak = 0.0
|
|
208
|
+
for _name, values in self._series:
|
|
209
|
+
for v in values:
|
|
210
|
+
peak = max(peak, v)
|
|
211
|
+
return peak or 1.0
|
|
212
|
+
|
|
213
|
+
# ------------------------------------------------------------------ #
|
|
214
|
+
## Geometry
|
|
215
|
+
# ------------------------------------------------------------------ #
|
|
216
|
+
def sizeHint(self):
|
|
217
|
+
return QSize(320, 300)
|
|
218
|
+
|
|
219
|
+
def minimumSizeHint(self):
|
|
220
|
+
return QSize(120, 120)
|
|
221
|
+
|
|
222
|
+
def _legendHeight(self):
|
|
223
|
+
if not (self._showLegend and self._series):
|
|
224
|
+
return 0.0
|
|
225
|
+
return QFontMetrics(self.font()).height() + 10
|
|
226
|
+
|
|
227
|
+
def _plotRect(self):
|
|
228
|
+
pad = 8.0
|
|
229
|
+
bottom = self._legendHeight()
|
|
230
|
+
if self._showAxisLabels and self._axes:
|
|
231
|
+
pad += QFontMetrics(self.font()).height() + 6
|
|
232
|
+
side = min(self.width() - 2 * pad, self.height() - 2 * pad - bottom)
|
|
233
|
+
side = max(10.0, side)
|
|
234
|
+
return QRectF((self.width() - side) / 2.0,
|
|
235
|
+
(self.height() - bottom - side) / 2.0, side, side)
|
|
236
|
+
|
|
237
|
+
def _angleFor(self, index):
|
|
238
|
+
"""Radians for axis `index`, running clockwise from startAngle."""
|
|
239
|
+
count = max(1, len(self._axes))
|
|
240
|
+
return math.radians(self._startAngle) - index * (2 * math.pi / count)
|
|
241
|
+
|
|
242
|
+
def _pointAt(self, index, fraction):
|
|
243
|
+
"""Cartesian point `fraction` (0..1) of the way along an axis."""
|
|
244
|
+
rect = self._plotRect()
|
|
245
|
+
radius = rect.width() / 2.0 * max(0.0, min(1.0, fraction))
|
|
246
|
+
angle = self._angleFor(index)
|
|
247
|
+
return QPointF(rect.center().x() + radius * math.cos(angle),
|
|
248
|
+
rect.center().y() - radius * math.sin(angle))
|
|
249
|
+
|
|
250
|
+
def _ringLabelPoint(self, fraction):
|
|
251
|
+
"""Where a ring's value label sits.
|
|
252
|
+
|
|
253
|
+
On the bisector between the first two axes, not along axis 0: an axis
|
|
254
|
+
already carries its own label at the rim, and the outermost ring label
|
|
255
|
+
collided with it.
|
|
256
|
+
"""
|
|
257
|
+
rect = self._plotRect()
|
|
258
|
+
count = max(1, len(self._axes))
|
|
259
|
+
angle = self._angleFor(0) - (math.pi / count)
|
|
260
|
+
radius = rect.width() / 2.0 * max(0.0, min(1.0, fraction))
|
|
261
|
+
return QPointF(rect.center().x() + radius * math.cos(angle),
|
|
262
|
+
rect.center().y() - radius * math.sin(angle))
|
|
263
|
+
|
|
264
|
+
def _polygonFor(self, values):
|
|
265
|
+
peak = self.maximum()
|
|
266
|
+
poly = QPolygonF()
|
|
267
|
+
for i, value in enumerate(values):
|
|
268
|
+
poly.append(self._pointAt(i, value / peak if peak else 0.0))
|
|
269
|
+
return poly
|
|
270
|
+
|
|
271
|
+
# ------------------------------------------------------------------ #
|
|
272
|
+
## Painting
|
|
273
|
+
# ------------------------------------------------------------------ #
|
|
274
|
+
def paintEvent(self, e):
|
|
275
|
+
p = QPainter(self)
|
|
276
|
+
p.setRenderHint(QPainter.Antialiasing)
|
|
277
|
+
p.setFont(self.font())
|
|
278
|
+
if not self._axes:
|
|
279
|
+
return
|
|
280
|
+
|
|
281
|
+
self._paintGrid(p)
|
|
282
|
+
self._paintAxes(p)
|
|
283
|
+
for index, (_name, values) in enumerate(self._series):
|
|
284
|
+
self._paintSeries(p, index, values)
|
|
285
|
+
if self._showAxisLabels:
|
|
286
|
+
self._paintAxisLabels(p)
|
|
287
|
+
if self._showLegend and self._series:
|
|
288
|
+
self._paintLegend(p)
|
|
289
|
+
|
|
290
|
+
def _paintGrid(self, p):
|
|
291
|
+
rect = self._plotRect()
|
|
292
|
+
p.setBrush(Qt.NoBrush)
|
|
293
|
+
p.setPen(QPen(self._gridColor, 1))
|
|
294
|
+
for ring in range(1, max(1, self._rings) + 1):
|
|
295
|
+
fraction = ring / float(max(1, self._rings))
|
|
296
|
+
if self._gridStyle == "circle":
|
|
297
|
+
radius = rect.width() / 2.0 * fraction
|
|
298
|
+
p.drawEllipse(rect.center(), radius, radius)
|
|
299
|
+
else:
|
|
300
|
+
poly = QPolygonF([self._pointAt(i, fraction)
|
|
301
|
+
for i in range(len(self._axes))])
|
|
302
|
+
p.drawPolygon(poly)
|
|
303
|
+
if self._showRingLabels:
|
|
304
|
+
value = self.maximum() * fraction
|
|
305
|
+
point = self._ringLabelPoint(fraction)
|
|
306
|
+
p.setPen(QPen(self._labelColor))
|
|
307
|
+
p.drawText(QPointF(point.x() + 3, point.y() - 2), "%g" % value)
|
|
308
|
+
p.setPen(QPen(self._gridColor, 1))
|
|
309
|
+
|
|
310
|
+
def _paintAxes(self, p):
|
|
311
|
+
p.setPen(QPen(self._axisColor, 1))
|
|
312
|
+
for i in range(len(self._axes)):
|
|
313
|
+
p.drawLine(self._plotRect().center(), self._pointAt(i, 1.0))
|
|
314
|
+
|
|
315
|
+
def _paintSeries(self, p, index, values):
|
|
316
|
+
colour = self.seriesColor(index)
|
|
317
|
+
hovered = index == self._hover
|
|
318
|
+
# A hovered series is drawn more opaque rather than moved, so the
|
|
319
|
+
# shapes stay comparable while one is emphasised.
|
|
320
|
+
fill = QColor(colour)
|
|
321
|
+
opacity = self._fillOpacity * (1.9 if hovered else 1.0)
|
|
322
|
+
fill.setAlphaF(max(0.0, min(1.0, opacity)))
|
|
323
|
+
poly = self._polygonFor(values)
|
|
324
|
+
|
|
325
|
+
p.setBrush(QBrush(fill))
|
|
326
|
+
p.setPen(QPen(colour, self._lineWidth + (1.0 if hovered else 0.0)))
|
|
327
|
+
p.drawPolygon(poly)
|
|
328
|
+
|
|
329
|
+
if self._showMarkers:
|
|
330
|
+
p.setBrush(QBrush(colour))
|
|
331
|
+
p.setPen(Qt.NoPen)
|
|
332
|
+
radius = 3.0 + (1.0 if hovered else 0.0)
|
|
333
|
+
for point in poly:
|
|
334
|
+
p.drawEllipse(point, radius, radius)
|
|
335
|
+
|
|
336
|
+
def _paintAxisLabels(self, p):
|
|
337
|
+
fm = QFontMetrics(self.font())
|
|
338
|
+
p.setPen(QPen(self._labelColor))
|
|
339
|
+
for i, label in enumerate(self._axes):
|
|
340
|
+
anchor = self._pointAt(i, 1.08)
|
|
341
|
+
width = fm.horizontalAdvance(label)
|
|
342
|
+
height = fm.height()
|
|
343
|
+
# Nudge the box so a label never overlaps its own axis tip: left of
|
|
344
|
+
# centre reads right-aligned, right of centre left-aligned.
|
|
345
|
+
centre = self._plotRect().center()
|
|
346
|
+
if anchor.x() < centre.x() - 1:
|
|
347
|
+
x = anchor.x() - width
|
|
348
|
+
elif anchor.x() > centre.x() + 1:
|
|
349
|
+
x = anchor.x()
|
|
350
|
+
else:
|
|
351
|
+
x = anchor.x() - width / 2.0
|
|
352
|
+
y = anchor.y() - height / 2.0
|
|
353
|
+
p.drawText(QRectF(x, y, width, height),
|
|
354
|
+
int(Qt.AlignCenter), label)
|
|
355
|
+
|
|
356
|
+
def _paintLegend(self, p):
|
|
357
|
+
fm = QFontMetrics(self.font())
|
|
358
|
+
swatch = self._LEGEND_SWATCH
|
|
359
|
+
gap = 14.0
|
|
360
|
+
widths = [swatch + 5 + fm.horizontalAdvance(name)
|
|
361
|
+
for name, _v in self._series]
|
|
362
|
+
total = sum(widths) + gap * (len(widths) - 1)
|
|
363
|
+
x = (self.width() - total) / 2.0
|
|
364
|
+
y = self.height() - self._legendHeight() + 4
|
|
365
|
+
|
|
366
|
+
for index, (name, _values) in enumerate(self._series):
|
|
367
|
+
colour = self.seriesColor(index)
|
|
368
|
+
p.setPen(Qt.NoPen)
|
|
369
|
+
p.setBrush(QBrush(colour))
|
|
370
|
+
p.drawEllipse(QRectF(x, y + (fm.height() - swatch) / 2.0,
|
|
371
|
+
swatch, swatch))
|
|
372
|
+
p.setPen(QPen(self._labelColor))
|
|
373
|
+
p.drawText(QRectF(x + swatch + 5, y, widths[index], fm.height()),
|
|
374
|
+
int(Qt.AlignLeft | Qt.AlignVCenter), name)
|
|
375
|
+
x += widths[index] + gap
|
|
376
|
+
|
|
377
|
+
# ------------------------------------------------------------------ #
|
|
378
|
+
## Interaction
|
|
379
|
+
# ------------------------------------------------------------------ #
|
|
380
|
+
def seriesAt(self, pos):
|
|
381
|
+
"""Index of the series whose polygon contains a point, or -1.
|
|
382
|
+
|
|
383
|
+
Later series are checked first so the one painted on top wins, which
|
|
384
|
+
is what the user sees.
|
|
385
|
+
"""
|
|
386
|
+
point = QPointF(pos)
|
|
387
|
+
for index in range(len(self._series) - 1, -1, -1):
|
|
388
|
+
if self._polygonFor(self._series[index][1]).containsPoint(
|
|
389
|
+
point, Qt.OddEvenFill):
|
|
390
|
+
return index
|
|
391
|
+
return -1
|
|
392
|
+
|
|
393
|
+
def axisAt(self, pos):
|
|
394
|
+
"""Index of the nearest axis to a point, or -1 when outside the plot."""
|
|
395
|
+
if not self._axes:
|
|
396
|
+
return -1
|
|
397
|
+
rect = self._plotRect()
|
|
398
|
+
centre = rect.center()
|
|
399
|
+
dx, dy = QPointF(pos).x() - centre.x(), QPointF(pos).y() - centre.y()
|
|
400
|
+
distance = math.hypot(dx, dy)
|
|
401
|
+
if distance > rect.width() / 2.0 or distance < 1e-6:
|
|
402
|
+
return -1
|
|
403
|
+
angle = math.atan2(-dy, dx)
|
|
404
|
+
step = 2 * math.pi / len(self._axes)
|
|
405
|
+
offset = (math.radians(self._startAngle) - angle) % (2 * math.pi)
|
|
406
|
+
return int(round(offset / step)) % len(self._axes)
|
|
407
|
+
|
|
408
|
+
def mouseMoveEvent(self, e):
|
|
409
|
+
index = self.seriesAt(e.pos())
|
|
410
|
+
if index != self._hover:
|
|
411
|
+
self._hover = index
|
|
412
|
+
self.seriesHovered.emit(index)
|
|
413
|
+
self.update()
|
|
414
|
+
super().mouseMoveEvent(e)
|
|
415
|
+
|
|
416
|
+
def leaveEvent(self, e):
|
|
417
|
+
if self._hover != -1:
|
|
418
|
+
self._hover = -1
|
|
419
|
+
self.seriesHovered.emit(-1)
|
|
420
|
+
self.update()
|
|
421
|
+
super().leaveEvent(e)
|
|
422
|
+
|
|
423
|
+
def mouseReleaseEvent(self, e):
|
|
424
|
+
if e.button() == Qt.LeftButton:
|
|
425
|
+
index = self.axisAt(e.pos())
|
|
426
|
+
if index >= 0:
|
|
427
|
+
self.axisClicked.emit(index)
|
|
428
|
+
super().mouseReleaseEvent(e)
|
|
429
|
+
|
|
430
|
+
# ------------------------------------------------------------------ #
|
|
431
|
+
## Designer properties
|
|
432
|
+
# ------------------------------------------------------------------ #
|
|
433
|
+
@Property(str)
|
|
434
|
+
def axesCsv(self):
|
|
435
|
+
return ",".join(self._axes)
|
|
436
|
+
|
|
437
|
+
@axesCsv.setter
|
|
438
|
+
def axesCsv(self, text):
|
|
439
|
+
self.setAxes([t.strip() for t in str(text).replace(";", ",").split(",")
|
|
440
|
+
if t.strip()])
|
|
441
|
+
|
|
442
|
+
@Property(str)
|
|
443
|
+
def seriesCsv(self):
|
|
444
|
+
return ";".join("%s=%s" % (name, ",".join("%g" % v for v in values))
|
|
445
|
+
for name, values in self._series)
|
|
446
|
+
|
|
447
|
+
@seriesCsv.setter
|
|
448
|
+
def seriesCsv(self, text):
|
|
449
|
+
parsed = []
|
|
450
|
+
for index, chunk in enumerate(str(text).replace("|", ";").split(";")):
|
|
451
|
+
chunk = chunk.strip()
|
|
452
|
+
if not chunk:
|
|
453
|
+
continue
|
|
454
|
+
if "=" in chunk:
|
|
455
|
+
name, _, raw = chunk.partition("=")
|
|
456
|
+
name = name.strip() or "Series %d" % (len(parsed) + 1)
|
|
457
|
+
else:
|
|
458
|
+
name, raw = "Series %d" % (len(parsed) + 1), chunk
|
|
459
|
+
values = []
|
|
460
|
+
for token in raw.split(","):
|
|
461
|
+
token = token.strip()
|
|
462
|
+
if not token:
|
|
463
|
+
continue
|
|
464
|
+
try:
|
|
465
|
+
values.append(float(token))
|
|
466
|
+
except ValueError:
|
|
467
|
+
pass
|
|
468
|
+
if values:
|
|
469
|
+
parsed.append((name, values))
|
|
470
|
+
self.setSeries(parsed)
|
|
471
|
+
|
|
472
|
+
@Property(str)
|
|
473
|
+
def seriesColorsCsv(self):
|
|
474
|
+
return ",".join(c.name() if c is not None else "" for c in self._colors)
|
|
475
|
+
|
|
476
|
+
@seriesColorsCsv.setter
|
|
477
|
+
def seriesColorsCsv(self, text):
|
|
478
|
+
colors = []
|
|
479
|
+
for token in str(text).replace(";", ",").split(","):
|
|
480
|
+
token = token.strip()
|
|
481
|
+
colour = QColor(token) if token else QColor()
|
|
482
|
+
colors.append(colour if colour.isValid() else None)
|
|
483
|
+
self._colors = colors
|
|
484
|
+
self.update()
|
|
485
|
+
|
|
486
|
+
@Property(float)
|
|
487
|
+
def maxValue(self):
|
|
488
|
+
return self._maxValue
|
|
489
|
+
|
|
490
|
+
@maxValue.setter
|
|
491
|
+
def maxValue(self, value):
|
|
492
|
+
self._maxValue = max(0.0, float(value))
|
|
493
|
+
self.update()
|
|
494
|
+
|
|
495
|
+
@Property(int)
|
|
496
|
+
def rings(self):
|
|
497
|
+
return self._rings
|
|
498
|
+
|
|
499
|
+
@rings.setter
|
|
500
|
+
def rings(self, value):
|
|
501
|
+
self._rings = max(1, int(value))
|
|
502
|
+
self.update()
|
|
503
|
+
|
|
504
|
+
@Property(str)
|
|
505
|
+
def gridStyle(self):
|
|
506
|
+
return self._gridStyle
|
|
507
|
+
|
|
508
|
+
@gridStyle.setter
|
|
509
|
+
def gridStyle(self, value):
|
|
510
|
+
self._gridStyle = "circle" if str(value) == "circle" else "polygon"
|
|
511
|
+
self.update()
|
|
512
|
+
|
|
513
|
+
@Property(int)
|
|
514
|
+
def startAngle(self):
|
|
515
|
+
return self._startAngle
|
|
516
|
+
|
|
517
|
+
@startAngle.setter
|
|
518
|
+
def startAngle(self, value):
|
|
519
|
+
self._startAngle = int(value) % 360
|
|
520
|
+
self.update()
|
|
521
|
+
|
|
522
|
+
@Property(float)
|
|
523
|
+
def fillOpacity(self):
|
|
524
|
+
return self._fillOpacity
|
|
525
|
+
|
|
526
|
+
@fillOpacity.setter
|
|
527
|
+
def fillOpacity(self, value):
|
|
528
|
+
self._fillOpacity = max(0.0, min(1.0, float(value)))
|
|
529
|
+
self.update()
|
|
530
|
+
|
|
531
|
+
@Property(float)
|
|
532
|
+
def lineWidth(self):
|
|
533
|
+
return self._lineWidth
|
|
534
|
+
|
|
535
|
+
@lineWidth.setter
|
|
536
|
+
def lineWidth(self, value):
|
|
537
|
+
self._lineWidth = max(0.0, float(value))
|
|
538
|
+
self.update()
|
|
539
|
+
|
|
540
|
+
@Property(bool)
|
|
541
|
+
def showAxisLabels(self):
|
|
542
|
+
return self._showAxisLabels
|
|
543
|
+
|
|
544
|
+
@showAxisLabels.setter
|
|
545
|
+
def showAxisLabels(self, value):
|
|
546
|
+
self._showAxisLabels = bool(value)
|
|
547
|
+
self.update()
|
|
548
|
+
|
|
549
|
+
@Property(bool)
|
|
550
|
+
def showRingLabels(self):
|
|
551
|
+
return self._showRingLabels
|
|
552
|
+
|
|
553
|
+
@showRingLabels.setter
|
|
554
|
+
def showRingLabels(self, value):
|
|
555
|
+
self._showRingLabels = bool(value)
|
|
556
|
+
self.update()
|
|
557
|
+
|
|
558
|
+
@Property(bool)
|
|
559
|
+
def showMarkers(self):
|
|
560
|
+
return self._showMarkers
|
|
561
|
+
|
|
562
|
+
@showMarkers.setter
|
|
563
|
+
def showMarkers(self, value):
|
|
564
|
+
self._showMarkers = bool(value)
|
|
565
|
+
self.update()
|
|
566
|
+
|
|
567
|
+
@Property(bool)
|
|
568
|
+
def showLegend(self):
|
|
569
|
+
return self._showLegend
|
|
570
|
+
|
|
571
|
+
@showLegend.setter
|
|
572
|
+
def showLegend(self, value):
|
|
573
|
+
self._showLegend = bool(value)
|
|
574
|
+
self.updateGeometry()
|
|
575
|
+
self.update()
|
|
576
|
+
|
|
577
|
+
# -- colours (from tokens via qproperty) --
|
|
578
|
+
@Property(QColor)
|
|
579
|
+
def gridColor(self):
|
|
580
|
+
return self._gridColor
|
|
581
|
+
|
|
582
|
+
@gridColor.setter
|
|
583
|
+
def gridColor(self, c):
|
|
584
|
+
self._gridColor = QColor(c); self.update()
|
|
585
|
+
|
|
586
|
+
@Property(QColor)
|
|
587
|
+
def axisColor(self):
|
|
588
|
+
return self._axisColor
|
|
589
|
+
|
|
590
|
+
@axisColor.setter
|
|
591
|
+
def axisColor(self, c):
|
|
592
|
+
self._axisColor = QColor(c); self.update()
|
|
593
|
+
|
|
594
|
+
@Property(QColor)
|
|
595
|
+
def labelColor(self):
|
|
596
|
+
return self._labelColor
|
|
597
|
+
|
|
598
|
+
@labelColor.setter
|
|
599
|
+
def labelColor(self, c):
|
|
600
|
+
self._labelColor = QColor(c); self.update()
|