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,655 @@
|
|
|
1
|
+
########################################################################
|
|
2
|
+
## SPINN DESIGN CODE
|
|
3
|
+
# YOUTUBE: (SPINN TV) https://www.youtube.com/@SpinnTV
|
|
4
|
+
# WEBSITE: customwidgets.org
|
|
5
|
+
########################################################################
|
|
6
|
+
|
|
7
|
+
########################################################################
|
|
8
|
+
## QCustomBubbleChart - an interactive packed-circle (bubble) chart.
|
|
9
|
+
##
|
|
10
|
+
## The sentiment / share bubble cloud: one circle per item, AREA proportional to
|
|
11
|
+
## the value, coloured by category and labelled inside when big enough. Circles
|
|
12
|
+
## are packed by a small deterministic force relaxation (push overlaps apart +
|
|
13
|
+
## gravity to a per-category anchor when grouped), then scaled to fill the widget.
|
|
14
|
+
##
|
|
15
|
+
## INTERACTIVE (the reference has all of these):
|
|
16
|
+
## * hover -> a CUSTOM painted tooltip card (never the OS QToolTip), plus a
|
|
17
|
+
## grow animation + glow on the hovered bubble.
|
|
18
|
+
## * zoom -> wheel zooms toward the cursor, a painted +/- control, drag to pan,
|
|
19
|
+
## double-click to reset. zoomIn()/zoomOut()/resetView().
|
|
20
|
+
## * search -> setSearchQuery(text) dims the non-matching bubbles; the painted
|
|
21
|
+
## search button emits searchRequested().
|
|
22
|
+
## * scale -> optional category grouping (`groupByCategory`) + axis labels.
|
|
23
|
+
##
|
|
24
|
+
## setItems([{label, value, category}]) / itemsJson; colours via
|
|
25
|
+
## setCategoryColors({category: colour}). Signals: bubbleClicked(label),
|
|
26
|
+
## searchRequested(), zoomChanged(float).
|
|
27
|
+
########################################################################
|
|
28
|
+
import json
|
|
29
|
+
import math
|
|
30
|
+
|
|
31
|
+
from qtpy.QtCore import Qt, Property, Signal, QRectF, QPointF, QTimer
|
|
32
|
+
from qtpy.QtGui import QColor, QPainter, QPen, QBrush, QFont, QFontMetrics, QPainterPath
|
|
33
|
+
from qtpy.QtWidgets import QWidget, QSizePolicy
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class QCustomBubbleChart(QWidget):
|
|
37
|
+
|
|
38
|
+
bubbleClicked = Signal(str)
|
|
39
|
+
searchRequested = Signal()
|
|
40
|
+
zoomChanged = Signal(float)
|
|
41
|
+
|
|
42
|
+
WIDGET_ICON = "components/icons/bubble_chart.png"
|
|
43
|
+
WIDGET_TOOLTIP = "An interactive packed-circle (bubble) chart"
|
|
44
|
+
WIDGET_MODULE = "Custom_Widgets.QCustomBubbleChart"
|
|
45
|
+
WIDGET_DOM_XML = """
|
|
46
|
+
<ui language='c++'>
|
|
47
|
+
<widget class='QCustomBubbleChart' name='customBubbleChart'>
|
|
48
|
+
<property name='geometry'><rect><x>0</x><y>0</y><width>360</width><height>320</height></rect></property>
|
|
49
|
+
</widget>
|
|
50
|
+
</ui>
|
|
51
|
+
"""
|
|
52
|
+
__catalog__ = {
|
|
53
|
+
"name": "QCustomBubbleChart",
|
|
54
|
+
"props": {
|
|
55
|
+
"itemsJson": {"type": "string", "default": ""},
|
|
56
|
+
"categoriesJson": {"type": "string", "default": ""},
|
|
57
|
+
"padding": {"type": "float", "default": 3.0},
|
|
58
|
+
"showLabels": {"type": "bool", "default": True},
|
|
59
|
+
"minLabelRadius": {"type": "float", "default": 16.0},
|
|
60
|
+
"labelColor": {"type": "color", "default": "#ffffff"},
|
|
61
|
+
"defaultColor": {"type": "color", "default": "#8b90a0"},
|
|
62
|
+
"shadeVariation": {"type": "float", "default": 0.18},
|
|
63
|
+
"hoverGlow": {"type": "bool", "default": True},
|
|
64
|
+
"hoverScale": {"type": "float", "default": 1.16},
|
|
65
|
+
"groupByCategory": {"type": "bool", "default": False},
|
|
66
|
+
"zoomable": {"type": "bool", "default": True},
|
|
67
|
+
"showControls": {"type": "bool", "default": True},
|
|
68
|
+
"searchQuery": {"type": "string", "default": ""},
|
|
69
|
+
"tooltips": {"type": "bool", "default": True},
|
|
70
|
+
"tooltipBgColor": {"type": "color", "default": "#0e1016"},
|
|
71
|
+
"controlColor": {"type": "color", "default": "#f4f6fb"},
|
|
72
|
+
},
|
|
73
|
+
"signals": ["bubbleClicked", "searchRequested", "zoomChanged"],
|
|
74
|
+
# Intentionally no tokens_used: this widget resolves no token roles.
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# Rich editors for the Designer "Custom Properties" dock (see
|
|
78
|
+
# DesignerTools.CustomPropertiesDock).
|
|
79
|
+
DESIGNER_CUSTOM_PROPS = [
|
|
80
|
+
{"name": "itemsJson", "kind": "str", "group": "General"},
|
|
81
|
+
{"name": "categoriesJson", "kind": "str", "group": "General"},
|
|
82
|
+
{"name": "padding", "kind": "float", "group": "General"},
|
|
83
|
+
{"name": "showLabels", "kind": "bool", "group": "General"},
|
|
84
|
+
{"name": "minLabelRadius", "kind": "float", "group": "General"},
|
|
85
|
+
{"name": "labelColor", "kind": "color", "group": "Colors"},
|
|
86
|
+
{"name": "defaultColor", "kind": "color", "group": "Colors"},
|
|
87
|
+
{"name": "shadeVariation", "kind": "float", "group": "General"},
|
|
88
|
+
{"name": "hoverGlow", "kind": "bool", "group": "General"},
|
|
89
|
+
{"name": "hoverScale", "kind": "float", "group": "General"},
|
|
90
|
+
{"name": "groupByCategory", "kind": "bool", "group": "General"},
|
|
91
|
+
{"name": "zoomable", "kind": "bool", "group": "General"},
|
|
92
|
+
{"name": "showControls", "kind": "bool", "group": "General"},
|
|
93
|
+
{"name": "searchQuery", "kind": "str", "group": "General"},
|
|
94
|
+
{"name": "tooltips", "kind": "bool", "group": "General"},
|
|
95
|
+
{"name": "tooltipBgColor", "kind": "color", "group": "Colors"},
|
|
96
|
+
{"name": "controlColor", "kind": "color", "group": "Colors"},
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
_DEFAULT_CATS = {
|
|
100
|
+
"positive": "#3fb27f", "negative": "#e0607e", "neutral": "#8b9cff",
|
|
101
|
+
"up": "#3fb27f", "down": "#e0607e", "default": "#8b90a0",
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
def __init__(self, parent=None, items=None):
|
|
105
|
+
super().__init__(parent)
|
|
106
|
+
self.setObjectName("QCustomBubbleChart")
|
|
107
|
+
self._items = list(items) if items is not None else self._seed()
|
|
108
|
+
self._cats = dict(self._DEFAULT_CATS)
|
|
109
|
+
self._pad = 3.0
|
|
110
|
+
self._show_labels = True
|
|
111
|
+
self._min_label_r = 16.0
|
|
112
|
+
self._label_color = QColor("#ffffff")
|
|
113
|
+
self._default_color = QColor("#8b90a0")
|
|
114
|
+
self._shade = 0.18
|
|
115
|
+
self._hover_glow = True
|
|
116
|
+
self._hover_scale = 1.16
|
|
117
|
+
self._group = False
|
|
118
|
+
self._zoomable = True
|
|
119
|
+
self._show_controls = True
|
|
120
|
+
self._search = ""
|
|
121
|
+
self._tooltips = True
|
|
122
|
+
self._tip_bg = QColor("#0e1016")
|
|
123
|
+
self._ctrl_color = QColor("#f4f6fb")
|
|
124
|
+
# view state
|
|
125
|
+
self._zoom = 1.0
|
|
126
|
+
self._pan = QPointF(0.0, 0.0)
|
|
127
|
+
self._drag_from = None
|
|
128
|
+
self._dragged = False
|
|
129
|
+
# packed data + hit caches
|
|
130
|
+
self._nodes = []
|
|
131
|
+
self._bbox = (0.0, 0.0, 1.0, 1.0)
|
|
132
|
+
self._screen = [] # (label, cx, cy, r, matches) for hit-testing
|
|
133
|
+
self._hover = -1
|
|
134
|
+
self._grow = 1.0
|
|
135
|
+
self._hover_timer = None
|
|
136
|
+
self._plus_rect = QRectF()
|
|
137
|
+
self._minus_rect = QRectF()
|
|
138
|
+
self._search_rect = QRectF()
|
|
139
|
+
self.setMouseTracking(True)
|
|
140
|
+
self.setCursor(Qt.PointingHandCursor)
|
|
141
|
+
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
|
142
|
+
self.setMinimumSize(140, 140)
|
|
143
|
+
self._pack()
|
|
144
|
+
|
|
145
|
+
@staticmethod
|
|
146
|
+
def _seed():
|
|
147
|
+
return [
|
|
148
|
+
{"label": "Travel insurance", "value": 90, "category": "negative"},
|
|
149
|
+
{"label": "Low commission", "value": 82, "category": "positive"},
|
|
150
|
+
{"label": "Wait time", "value": 55, "category": "negative"},
|
|
151
|
+
{"label": "Resolve issue", "value": 60, "category": "positive"},
|
|
152
|
+
{"label": "Shipping price", "value": 48, "category": "negative"},
|
|
153
|
+
{"label": "Leave review", "value": 44, "category": "positive"},
|
|
154
|
+
{"label": "Link account", "value": 40, "category": "neutral"},
|
|
155
|
+
{"label": "High price", "value": 36, "category": "negative"},
|
|
156
|
+
{"label": "Low price", "value": 34, "category": "positive"},
|
|
157
|
+
{"label": "Charge fee", "value": 30, "category": "neutral"},
|
|
158
|
+
{"label": "Send email", "value": 26, "category": "negative"},
|
|
159
|
+
{"label": "Debit card", "value": 24, "category": "positive"},
|
|
160
|
+
{"label": "Coupon code", "value": 30, "category": "negative"},
|
|
161
|
+
{"label": "Buy button", "value": 18, "category": "neutral"},
|
|
162
|
+
{"label": "Ship item", "value": 16, "category": "negative"},
|
|
163
|
+
{"label": "Open card", "value": 20, "category": "positive"},
|
|
164
|
+
{"label": "Use app", "value": 14, "category": "neutral"},
|
|
165
|
+
{"label": "Post item", "value": 22, "category": "negative"},
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
# ------------------------------------------------------------------ #
|
|
169
|
+
## Data
|
|
170
|
+
# ------------------------------------------------------------------ #
|
|
171
|
+
def setItems(self, items):
|
|
172
|
+
self._items = [dict(it) for it in (items or [])]
|
|
173
|
+
self._pack()
|
|
174
|
+
self.update()
|
|
175
|
+
|
|
176
|
+
def setCategoryColors(self, mapping):
|
|
177
|
+
for k, v in (mapping or {}).items():
|
|
178
|
+
self._cats[str(k)] = v if isinstance(v, str) else QColor(v).name()
|
|
179
|
+
self.update()
|
|
180
|
+
|
|
181
|
+
def setSearchQuery(self, text):
|
|
182
|
+
self._search = str(text or "")
|
|
183
|
+
self.update()
|
|
184
|
+
|
|
185
|
+
def items(self):
|
|
186
|
+
return list(self._items)
|
|
187
|
+
|
|
188
|
+
def _cat_color(self, cat):
|
|
189
|
+
return QColor(self._cats.get(cat, self._default_color.name()))
|
|
190
|
+
|
|
191
|
+
# ------------------------------------------------------------------ #
|
|
192
|
+
## View controls
|
|
193
|
+
# ------------------------------------------------------------------ #
|
|
194
|
+
def zoomIn(self):
|
|
195
|
+
self._zoom_to(self._zoom * 1.2, QPointF(self.width() / 2, self.height() / 2))
|
|
196
|
+
|
|
197
|
+
def zoomOut(self):
|
|
198
|
+
self._zoom_to(self._zoom / 1.2, QPointF(self.width() / 2, self.height() / 2))
|
|
199
|
+
|
|
200
|
+
def resetView(self):
|
|
201
|
+
self._zoom = 1.0
|
|
202
|
+
self._pan = QPointF(0.0, 0.0)
|
|
203
|
+
self.zoomChanged.emit(self._zoom)
|
|
204
|
+
self.update()
|
|
205
|
+
|
|
206
|
+
def _zoom_to(self, z, focus):
|
|
207
|
+
z = max(0.5, min(6.0, z))
|
|
208
|
+
c = QPointF(self.width() / 2.0, self.height() / 2.0)
|
|
209
|
+
d = focus - c
|
|
210
|
+
# keep the point under `focus` fixed while zooming
|
|
211
|
+
self._pan = d - (d - self._pan) * (z / self._zoom)
|
|
212
|
+
self._zoom = z
|
|
213
|
+
self.zoomChanged.emit(self._zoom)
|
|
214
|
+
self.update()
|
|
215
|
+
|
|
216
|
+
# ------------------------------------------------------------------ #
|
|
217
|
+
## Packing (deterministic force relaxation, size-independent)
|
|
218
|
+
# ------------------------------------------------------------------ #
|
|
219
|
+
def _pack(self):
|
|
220
|
+
items = self._items
|
|
221
|
+
self._nodes = []
|
|
222
|
+
if not items:
|
|
223
|
+
self._bbox = (0.0, 0.0, 1.0, 1.0)
|
|
224
|
+
return
|
|
225
|
+
vals = [max(1e-6, float(it.get("value", 1))) for it in items]
|
|
226
|
+
vmax = max(vals)
|
|
227
|
+
# per-category anchors on a circle (used only when grouped)
|
|
228
|
+
cats = []
|
|
229
|
+
for it in items:
|
|
230
|
+
c = it.get("category", "default")
|
|
231
|
+
if c not in cats:
|
|
232
|
+
cats.append(c)
|
|
233
|
+
anchors = {}
|
|
234
|
+
if self._group and len(cats) > 1:
|
|
235
|
+
R = 60.0
|
|
236
|
+
for i, c in enumerate(cats):
|
|
237
|
+
a = -math.pi / 2 + 2 * math.pi * i / len(cats)
|
|
238
|
+
anchors[c] = (R * math.cos(a), R * math.sin(a))
|
|
239
|
+
golden = 2.399963229728653
|
|
240
|
+
nodes = []
|
|
241
|
+
for i, it in enumerate(items):
|
|
242
|
+
r = 10.0 + 34.0 * math.sqrt(vals[i] / vmax)
|
|
243
|
+
ang = i * golden
|
|
244
|
+
rad = 6.0 * math.sqrt(i + 0.5)
|
|
245
|
+
ax, ay = anchors.get(it.get("category", "default"), (0.0, 0.0))
|
|
246
|
+
nodes.append({"label": str(it.get("label", "")),
|
|
247
|
+
"cat": it.get("category", "default"), "val": vals[i],
|
|
248
|
+
"x": ax + rad * math.cos(ang), "y": ay + rad * math.sin(ang),
|
|
249
|
+
"r": r, "ax": ax, "ay": ay})
|
|
250
|
+
for _ in range(180):
|
|
251
|
+
for a in range(len(nodes)):
|
|
252
|
+
na = nodes[a]
|
|
253
|
+
for b in range(a + 1, len(nodes)):
|
|
254
|
+
nb = nodes[b]
|
|
255
|
+
dx = nb["x"] - na["x"]; dy = nb["y"] - na["y"]
|
|
256
|
+
d = math.hypot(dx, dy)
|
|
257
|
+
mind = na["r"] + nb["r"] + self._pad
|
|
258
|
+
if 1e-6 < d < mind:
|
|
259
|
+
push = (mind - d) / 2.0
|
|
260
|
+
ux, uy = dx / d, dy / d
|
|
261
|
+
na["x"] -= ux * push; na["y"] -= uy * push
|
|
262
|
+
nb["x"] += ux * push; nb["y"] += uy * push
|
|
263
|
+
elif d <= 1e-6:
|
|
264
|
+
na["x"] -= 0.5; nb["x"] += 0.5
|
|
265
|
+
for n in nodes: # gravity to the (category) anchor
|
|
266
|
+
n["x"] += (n["ax"] - n["x"]) * 0.02
|
|
267
|
+
n["y"] += (n["ay"] - n["y"]) * 0.02
|
|
268
|
+
xs0 = min(n["x"] - n["r"] for n in nodes)
|
|
269
|
+
ys0 = min(n["y"] - n["r"] for n in nodes)
|
|
270
|
+
xs1 = max(n["x"] + n["r"] for n in nodes)
|
|
271
|
+
ys1 = max(n["y"] + n["r"] for n in nodes)
|
|
272
|
+
self._nodes = nodes
|
|
273
|
+
self._bbox = (xs0, ys0, xs1 - xs0, ys1 - ys0)
|
|
274
|
+
|
|
275
|
+
def _base_fit(self):
|
|
276
|
+
m = 12.0
|
|
277
|
+
bx, by, bw, bh = self._bbox
|
|
278
|
+
bw = bw or 1.0; bh = bh or 1.0
|
|
279
|
+
s = min((self.width() - 2 * m) / bw, (self.height() - 2 * m) / bh)
|
|
280
|
+
ox = (self.width() - bw * s) / 2.0 - bx * s
|
|
281
|
+
oy = (self.height() - bh * s) / 2.0 - by * s
|
|
282
|
+
return s, ox, oy
|
|
283
|
+
|
|
284
|
+
def _to_screen(self, n):
|
|
285
|
+
s, ox, oy = self._base_fit()
|
|
286
|
+
c = QPointF(self.width() / 2.0, self.height() / 2.0)
|
|
287
|
+
fx = n["x"] * s + ox
|
|
288
|
+
fy = n["y"] * s + oy
|
|
289
|
+
cx = c.x() + (fx - c.x()) * self._zoom + self._pan.x()
|
|
290
|
+
cy = c.y() + (fy - c.y()) * self._zoom + self._pan.y()
|
|
291
|
+
return cx, cy, n["r"] * s * self._zoom
|
|
292
|
+
|
|
293
|
+
# ------------------------------------------------------------------ #
|
|
294
|
+
## Painting
|
|
295
|
+
# ------------------------------------------------------------------ #
|
|
296
|
+
def paintEvent(self, e):
|
|
297
|
+
if not self._nodes:
|
|
298
|
+
return
|
|
299
|
+
p = QPainter(self)
|
|
300
|
+
p.setRenderHint(QPainter.Antialiasing, True)
|
|
301
|
+
vmax = max((n["val"] for n in self._nodes), default=1.0)
|
|
302
|
+
q = self._search.strip().lower()
|
|
303
|
+
self._screen = []
|
|
304
|
+
for i, n in enumerate(self._nodes):
|
|
305
|
+
cx, cy, r = self._to_screen(n)
|
|
306
|
+
matches = (not q) or (q in n["label"].lower())
|
|
307
|
+
self._screen.append((n["label"], cx, cy, r, matches))
|
|
308
|
+
if i == self._hover:
|
|
309
|
+
continue
|
|
310
|
+
self._draw_bubble(p, n, cx, cy, r, vmax, grow=1.0, glow=False,
|
|
311
|
+
dim=(not matches))
|
|
312
|
+
if 0 <= self._hover < len(self._screen):
|
|
313
|
+
_, cx, cy, r, matches = self._screen[self._hover]
|
|
314
|
+
self._draw_bubble(p, self._nodes[self._hover], cx, cy, r, vmax,
|
|
315
|
+
grow=self._grow, glow=self._hover_glow,
|
|
316
|
+
dim=(not matches))
|
|
317
|
+
if self._show_controls and self._zoomable:
|
|
318
|
+
self._paint_controls(p)
|
|
319
|
+
if self._tooltips and 0 <= self._hover < len(self._screen):
|
|
320
|
+
self._paint_tooltip(p)
|
|
321
|
+
p.end()
|
|
322
|
+
|
|
323
|
+
def _draw_bubble(self, p, n, cx, cy, r, vmax, grow, glow, dim):
|
|
324
|
+
col = self._cat_color(n["cat"])
|
|
325
|
+
frac = n["val"] / vmax
|
|
326
|
+
col = col.lighter(int(100 + self._shade * 100 * (1.0 - frac)))
|
|
327
|
+
if dim:
|
|
328
|
+
col.setAlpha(46)
|
|
329
|
+
rr = r * grow
|
|
330
|
+
if glow and grow > 1.001 and not dim:
|
|
331
|
+
for k in range(3, 0, -1):
|
|
332
|
+
t = k / 3.0
|
|
333
|
+
gc = QColor(col); gc.setAlphaF(0.22 * (1.1 - t))
|
|
334
|
+
p.setPen(Qt.NoPen); p.setBrush(QBrush(gc))
|
|
335
|
+
p.drawEllipse(QPointF(cx, cy), rr + t * 10.0, rr + t * 10.0)
|
|
336
|
+
p.setPen(Qt.NoPen)
|
|
337
|
+
p.setBrush(QBrush(col))
|
|
338
|
+
p.drawEllipse(QPointF(cx, cy), rr, rr)
|
|
339
|
+
if self._show_labels and rr >= self._min_label_r and n["label"] and not dim:
|
|
340
|
+
self._paint_label(p, n["label"], cx, cy, rr)
|
|
341
|
+
|
|
342
|
+
def _paint_label(self, p, text, cx, cy, r):
|
|
343
|
+
f = QFont(self.font()); f.setPointSizeF(max(7.0, r * 0.26)); f.setBold(True)
|
|
344
|
+
p.setFont(f)
|
|
345
|
+
fm = p.fontMetrics()
|
|
346
|
+
elided = fm.elidedText(text, Qt.ElideRight, int(r * 1.7))
|
|
347
|
+
p.setPen(QPen(self._label_color))
|
|
348
|
+
p.drawText(QRectF(cx - r, cy - fm.height() / 2.0, 2 * r, fm.height()),
|
|
349
|
+
Qt.AlignCenter, elided)
|
|
350
|
+
|
|
351
|
+
def _paint_controls(self, p):
|
|
352
|
+
w = 34.0
|
|
353
|
+
x = self.width() - w - 12.0
|
|
354
|
+
y = self.height() / 2.0 - w
|
|
355
|
+
self._plus_rect = QRectF(x, y, w, w)
|
|
356
|
+
self._minus_rect = QRectF(x, y + w, w, w)
|
|
357
|
+
pill = QRectF(x, y, w, 2 * w)
|
|
358
|
+
p.setPen(QPen(QColor(self._ctrl_color).darker(300)))
|
|
359
|
+
bg = QColor(self._tip_bg); bg.setAlpha(210)
|
|
360
|
+
p.setBrush(QBrush(bg))
|
|
361
|
+
p.drawRoundedRect(pill, w / 2.0, w / 2.0)
|
|
362
|
+
pen = QPen(self._ctrl_color, 2.0); pen.setCapStyle(Qt.RoundCap)
|
|
363
|
+
p.setPen(pen)
|
|
364
|
+
c1 = self._plus_rect.center(); c2 = self._minus_rect.center()
|
|
365
|
+
p.drawLine(QPointF(c1.x() - 7, c1.y()), QPointF(c1.x() + 7, c1.y()))
|
|
366
|
+
p.drawLine(QPointF(c1.x(), c1.y() - 7), QPointF(c1.x(), c1.y() + 7))
|
|
367
|
+
p.drawLine(QPointF(c2.x() - 7, c2.y()), QPointF(c2.x() + 7, c2.y()))
|
|
368
|
+
# search button below the zoom pill
|
|
369
|
+
self._search_rect = QRectF(x, y + 2 * w + 10, w, w)
|
|
370
|
+
p.setPen(QPen(QColor(self._ctrl_color).darker(300)))
|
|
371
|
+
p.setBrush(QBrush(bg))
|
|
372
|
+
p.drawEllipse(self._search_rect)
|
|
373
|
+
sc = self._search_rect.center()
|
|
374
|
+
pen2 = QPen(self._ctrl_color, 2.0); pen2.setCapStyle(Qt.RoundCap)
|
|
375
|
+
p.setPen(pen2); p.setBrush(Qt.NoBrush)
|
|
376
|
+
p.drawEllipse(QPointF(sc.x() - 2, sc.y() - 2), 5.5, 5.5)
|
|
377
|
+
p.drawLine(QPointF(sc.x() + 2.2, sc.y() + 2.2), QPointF(sc.x() + 7, sc.y() + 7))
|
|
378
|
+
|
|
379
|
+
def _paint_tooltip(self, p):
|
|
380
|
+
_, cx, cy, r, _ = self._screen[self._hover]
|
|
381
|
+
n = self._nodes[self._hover]
|
|
382
|
+
tf = QFont(self.font()); tf.setBold(True); tf.setPointSizeF(max(9.0, self.font().pointSizeF()))
|
|
383
|
+
sf = QFont(self.font()); sf.setPointSizeF(max(8.0, self.font().pointSizeF() * 0.86))
|
|
384
|
+
fm, fms = QFontMetrics(tf), QFontMetrics(sf)
|
|
385
|
+
cat = n.get("cat", "")
|
|
386
|
+
sub = "%g%s" % (n["val"], " · " + cat if cat and cat != "default" else "")
|
|
387
|
+
padx, pady, dot = 12.0, 9.0, 9.0
|
|
388
|
+
tw = max(fm.horizontalAdvance(n["label"]), fms.horizontalAdvance(sub))
|
|
389
|
+
cw = dot + 8 + tw + 2 * padx
|
|
390
|
+
ch = fm.height() + fms.height() + 2 * pady + 2
|
|
391
|
+
tx = min(max(8.0, cx - cw / 2.0), self.width() - cw - 8.0)
|
|
392
|
+
ty = cy - r - ch - 10.0
|
|
393
|
+
if ty < 6:
|
|
394
|
+
ty = cy + r + 10.0
|
|
395
|
+
card = QRectF(tx, ty, cw, ch)
|
|
396
|
+
path = QPainterPath(); path.addRoundedRect(card, 10, 10)
|
|
397
|
+
p.setPen(QPen(QColor(255, 255, 255, 28)))
|
|
398
|
+
p.setBrush(QBrush(self._tip_bg))
|
|
399
|
+
p.drawPath(path)
|
|
400
|
+
dcx = tx + padx + dot / 2.0
|
|
401
|
+
p.setPen(Qt.NoPen); p.setBrush(QBrush(self._cat_color(cat)))
|
|
402
|
+
p.drawEllipse(QPointF(dcx, ty + pady + fm.height() * 0.5), dot / 2.0, dot / 2.0)
|
|
403
|
+
text_x = tx + padx + dot + 8
|
|
404
|
+
p.setFont(tf); p.setPen(QPen(QColor("#ffffff")))
|
|
405
|
+
p.drawText(QRectF(text_x, ty + pady, tw, fm.height()),
|
|
406
|
+
Qt.AlignVCenter | Qt.AlignLeft, n["label"])
|
|
407
|
+
p.setFont(sf); p.setPen(QPen(QColor("#aab2bd")))
|
|
408
|
+
p.drawText(QRectF(text_x, ty + pady + fm.height() + 2, tw, fms.height()),
|
|
409
|
+
Qt.AlignVCenter | Qt.AlignLeft, sub)
|
|
410
|
+
|
|
411
|
+
# ------------------------------------------------------------------ #
|
|
412
|
+
## Interaction
|
|
413
|
+
# ------------------------------------------------------------------ #
|
|
414
|
+
def _bubble_at(self, pos):
|
|
415
|
+
for i in range(len(self._screen) - 1, -1, -1):
|
|
416
|
+
_, cx, cy, r, _ = self._screen[i]
|
|
417
|
+
if math.hypot(pos.x() - cx, pos.y() - cy) <= r:
|
|
418
|
+
return i
|
|
419
|
+
return -1
|
|
420
|
+
|
|
421
|
+
def mousePressEvent(self, e):
|
|
422
|
+
if e.button() != Qt.LeftButton:
|
|
423
|
+
return super().mousePressEvent(e)
|
|
424
|
+
pos = e.position() if hasattr(e, "position") else QPointF(e.pos())
|
|
425
|
+
if self._show_controls and self._zoomable:
|
|
426
|
+
if self._plus_rect.contains(pos):
|
|
427
|
+
return self.zoomIn()
|
|
428
|
+
if self._minus_rect.contains(pos):
|
|
429
|
+
return self.zoomOut()
|
|
430
|
+
if self._search_rect.contains(pos):
|
|
431
|
+
return self.searchRequested.emit()
|
|
432
|
+
self._drag_from = pos
|
|
433
|
+
self._dragged = False
|
|
434
|
+
super().mousePressEvent(e)
|
|
435
|
+
|
|
436
|
+
def mouseMoveEvent(self, e):
|
|
437
|
+
pos = e.position() if hasattr(e, "position") else QPointF(e.pos())
|
|
438
|
+
if self._drag_from is not None and (e.buttons() & Qt.LeftButton):
|
|
439
|
+
delta = pos - self._drag_from
|
|
440
|
+
if self._zoomable and (self._dragged or delta.manhattanLength() > 4):
|
|
441
|
+
self._dragged = True
|
|
442
|
+
self._pan += delta
|
|
443
|
+
self._drag_from = pos
|
|
444
|
+
self.update()
|
|
445
|
+
return
|
|
446
|
+
i = self._bubble_at(pos)
|
|
447
|
+
if i != self._hover:
|
|
448
|
+
self._hover = i
|
|
449
|
+
self._grow = 1.0 if i >= 0 else self._grow
|
|
450
|
+
self._start_hover_anim()
|
|
451
|
+
self.update()
|
|
452
|
+
super().mouseMoveEvent(e)
|
|
453
|
+
|
|
454
|
+
def mouseReleaseEvent(self, e):
|
|
455
|
+
pos = e.position() if hasattr(e, "position") else QPointF(e.pos())
|
|
456
|
+
if e.button() == Qt.LeftButton and self._drag_from is not None and not self._dragged:
|
|
457
|
+
i = self._bubble_at(pos)
|
|
458
|
+
if i >= 0:
|
|
459
|
+
self.bubbleClicked.emit(self._screen[i][0])
|
|
460
|
+
self._drag_from = None
|
|
461
|
+
self._dragged = False
|
|
462
|
+
super().mouseReleaseEvent(e)
|
|
463
|
+
|
|
464
|
+
def mouseDoubleClickEvent(self, e):
|
|
465
|
+
if self._zoomable:
|
|
466
|
+
self.resetView()
|
|
467
|
+
super().mouseDoubleClickEvent(e)
|
|
468
|
+
|
|
469
|
+
def wheelEvent(self, e):
|
|
470
|
+
if not self._zoomable:
|
|
471
|
+
return super().wheelEvent(e)
|
|
472
|
+
d = e.angleDelta().y()
|
|
473
|
+
if d:
|
|
474
|
+
pos = e.position() if hasattr(e, "position") else QPointF(e.pos())
|
|
475
|
+
self._zoom_to(self._zoom * (1.15 if d > 0 else 1 / 1.15), pos)
|
|
476
|
+
e.accept()
|
|
477
|
+
|
|
478
|
+
def leaveEvent(self, e):
|
|
479
|
+
if self._hover != -1:
|
|
480
|
+
self._hover = -1
|
|
481
|
+
self._start_hover_anim()
|
|
482
|
+
self.update()
|
|
483
|
+
super().leaveEvent(e)
|
|
484
|
+
|
|
485
|
+
def _start_hover_anim(self):
|
|
486
|
+
if self._hover_timer is None:
|
|
487
|
+
self._hover_timer = QTimer(self)
|
|
488
|
+
self._hover_timer.setInterval(16)
|
|
489
|
+
self._hover_timer.timeout.connect(self._hover_tick)
|
|
490
|
+
self._hover_timer.start()
|
|
491
|
+
|
|
492
|
+
def _hover_tick(self):
|
|
493
|
+
target = self._hover_scale if self._hover >= 0 else 1.0
|
|
494
|
+
self._grow += (target - self._grow) * 0.28
|
|
495
|
+
if abs(self._grow - target) < 0.004:
|
|
496
|
+
self._grow = target
|
|
497
|
+
if target == 1.0:
|
|
498
|
+
self._hover_timer.stop()
|
|
499
|
+
self.update()
|
|
500
|
+
|
|
501
|
+
def resizeEvent(self, e):
|
|
502
|
+
self.update()
|
|
503
|
+
super().resizeEvent(e)
|
|
504
|
+
|
|
505
|
+
# ------------------------------------------------------------------ #
|
|
506
|
+
## Designer properties
|
|
507
|
+
# ------------------------------------------------------------------ #
|
|
508
|
+
@Property(str)
|
|
509
|
+
def itemsJson(self):
|
|
510
|
+
try:
|
|
511
|
+
return json.dumps(self._items)
|
|
512
|
+
except Exception:
|
|
513
|
+
return "[]"
|
|
514
|
+
|
|
515
|
+
@itemsJson.setter
|
|
516
|
+
def itemsJson(self, text):
|
|
517
|
+
try:
|
|
518
|
+
data = json.loads(text) if str(text).strip() else []
|
|
519
|
+
if isinstance(data, list):
|
|
520
|
+
self.setItems(data)
|
|
521
|
+
except Exception:
|
|
522
|
+
pass
|
|
523
|
+
|
|
524
|
+
@Property(str)
|
|
525
|
+
def categoriesJson(self):
|
|
526
|
+
return json.dumps(self._cats)
|
|
527
|
+
|
|
528
|
+
@categoriesJson.setter
|
|
529
|
+
def categoriesJson(self, text):
|
|
530
|
+
try:
|
|
531
|
+
data = json.loads(text) if str(text).strip() else {}
|
|
532
|
+
if isinstance(data, dict):
|
|
533
|
+
self.setCategoryColors(data)
|
|
534
|
+
except Exception:
|
|
535
|
+
pass
|
|
536
|
+
|
|
537
|
+
@Property(float)
|
|
538
|
+
def padding(self):
|
|
539
|
+
return self._pad
|
|
540
|
+
|
|
541
|
+
@padding.setter
|
|
542
|
+
def padding(self, v):
|
|
543
|
+
self._pad = max(0.0, float(v)); self._pack(); self.update()
|
|
544
|
+
|
|
545
|
+
@Property(bool)
|
|
546
|
+
def showLabels(self):
|
|
547
|
+
return self._show_labels
|
|
548
|
+
|
|
549
|
+
@showLabels.setter
|
|
550
|
+
def showLabels(self, v):
|
|
551
|
+
self._show_labels = bool(v); self.update()
|
|
552
|
+
|
|
553
|
+
@Property(float)
|
|
554
|
+
def minLabelRadius(self):
|
|
555
|
+
return self._min_label_r
|
|
556
|
+
|
|
557
|
+
@minLabelRadius.setter
|
|
558
|
+
def minLabelRadius(self, v):
|
|
559
|
+
self._min_label_r = max(0.0, float(v)); self.update()
|
|
560
|
+
|
|
561
|
+
@Property(QColor)
|
|
562
|
+
def labelColor(self):
|
|
563
|
+
return self._label_color
|
|
564
|
+
|
|
565
|
+
@labelColor.setter
|
|
566
|
+
def labelColor(self, c):
|
|
567
|
+
self._label_color = QColor(c); self.update()
|
|
568
|
+
|
|
569
|
+
@Property(QColor)
|
|
570
|
+
def defaultColor(self):
|
|
571
|
+
return self._default_color
|
|
572
|
+
|
|
573
|
+
@defaultColor.setter
|
|
574
|
+
def defaultColor(self, c):
|
|
575
|
+
self._default_color = QColor(c); self.update()
|
|
576
|
+
|
|
577
|
+
@Property(float)
|
|
578
|
+
def shadeVariation(self):
|
|
579
|
+
return self._shade
|
|
580
|
+
|
|
581
|
+
@shadeVariation.setter
|
|
582
|
+
def shadeVariation(self, v):
|
|
583
|
+
self._shade = max(0.0, min(1.0, float(v))); self.update()
|
|
584
|
+
|
|
585
|
+
@Property(bool)
|
|
586
|
+
def hoverGlow(self):
|
|
587
|
+
return self._hover_glow
|
|
588
|
+
|
|
589
|
+
@hoverGlow.setter
|
|
590
|
+
def hoverGlow(self, v):
|
|
591
|
+
self._hover_glow = bool(v); self.update()
|
|
592
|
+
|
|
593
|
+
@Property(float)
|
|
594
|
+
def hoverScale(self):
|
|
595
|
+
return self._hover_scale
|
|
596
|
+
|
|
597
|
+
@hoverScale.setter
|
|
598
|
+
def hoverScale(self, v):
|
|
599
|
+
self._hover_scale = max(1.0, float(v))
|
|
600
|
+
|
|
601
|
+
@Property(bool)
|
|
602
|
+
def groupByCategory(self):
|
|
603
|
+
return self._group
|
|
604
|
+
|
|
605
|
+
@groupByCategory.setter
|
|
606
|
+
def groupByCategory(self, v):
|
|
607
|
+
self._group = bool(v); self._pack(); self.update()
|
|
608
|
+
|
|
609
|
+
@Property(bool)
|
|
610
|
+
def zoomable(self):
|
|
611
|
+
return self._zoomable
|
|
612
|
+
|
|
613
|
+
@zoomable.setter
|
|
614
|
+
def zoomable(self, v):
|
|
615
|
+
self._zoomable = bool(v); self.update()
|
|
616
|
+
|
|
617
|
+
@Property(bool)
|
|
618
|
+
def showControls(self):
|
|
619
|
+
return self._show_controls
|
|
620
|
+
|
|
621
|
+
@showControls.setter
|
|
622
|
+
def showControls(self, v):
|
|
623
|
+
self._show_controls = bool(v); self.update()
|
|
624
|
+
|
|
625
|
+
@Property(str)
|
|
626
|
+
def searchQuery(self):
|
|
627
|
+
return self._search
|
|
628
|
+
|
|
629
|
+
@searchQuery.setter
|
|
630
|
+
def searchQuery(self, v):
|
|
631
|
+
self.setSearchQuery(v)
|
|
632
|
+
|
|
633
|
+
@Property(bool)
|
|
634
|
+
def tooltips(self):
|
|
635
|
+
return self._tooltips
|
|
636
|
+
|
|
637
|
+
@tooltips.setter
|
|
638
|
+
def tooltips(self, v):
|
|
639
|
+
self._tooltips = bool(v); self.update()
|
|
640
|
+
|
|
641
|
+
@Property(QColor)
|
|
642
|
+
def tooltipBgColor(self):
|
|
643
|
+
return self._tip_bg
|
|
644
|
+
|
|
645
|
+
@tooltipBgColor.setter
|
|
646
|
+
def tooltipBgColor(self, c):
|
|
647
|
+
self._tip_bg = QColor(c); self.update()
|
|
648
|
+
|
|
649
|
+
@Property(QColor)
|
|
650
|
+
def controlColor(self):
|
|
651
|
+
return self._ctrl_color
|
|
652
|
+
|
|
653
|
+
@controlColor.setter
|
|
654
|
+
def controlColor(self, c):
|
|
655
|
+
self._ctrl_color = QColor(c); self.update()
|