pyqt5-chart-widget 1.0.0__tar.gz

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.
File without changes
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.1
2
+ Name: pyqt5-chart-widget
3
+ Version: 1.0.0
4
+ Summary: Lightweight interactive chart widget for PyQt5
5
+ Home-page: https://github.com/EgorKonstrukt/pyqt5-chart-widget
6
+ Author: Zarrakun
7
+ Author-email: egormajndi@gmail.com
8
+ License: MIT
9
+ Keywords: pyqt5,chart,plot,widget,gui
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Topic :: Software Development :: User Interfaces
15
+ Requires-Python: >=3.8
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: PyQt5>=5.15
File without changes
@@ -0,0 +1,3 @@
1
+ [build-system]
2
+ requires = ["setuptools", "wheel"]
3
+ build-backend = "setuptools.build_meta:__legacy__"
@@ -0,0 +1,4 @@
1
+ from .chart_widget import ChartWidget, _LineItem, _ScatterItem, _InfLine
2
+
3
+ __all__ = ["ChartWidget", "_LineItem", "_ScatterItem", "_InfLine"]
4
+ __version__ = "1.0.0"
@@ -0,0 +1,611 @@
1
+ from __future__ import annotations
2
+ import csv
3
+ import math
4
+ import statistics
5
+ from typing import List, Optional, Tuple, Union
6
+ from PyQt5.QtWidgets import (QWidget, QSizePolicy, QFileDialog,
7
+ QToolButton, QHBoxLayout, QVBoxLayout, QStyle)
8
+ from PyQt5.QtCore import Qt, QRect, QPointF, QSize
9
+ from PyQt5.QtGui import (QPainter, QPen, QBrush, QColor, QFont,
10
+ QFontMetrics, QPainterPath, QWheelEvent,
11
+ QMouseEvent, QPixmap)
12
+
13
+ try:
14
+ from i18n import tr
15
+ except ImportError:
16
+ _FB: dict = {
17
+ "chart_widget.btn_fit": "Fit",
18
+ "chart_widget.btn_fit_tip": "Auto-fit view to data (double-click on chart)",
19
+ "chart_widget.btn_csv": "CSV",
20
+ "chart_widget.btn_csv_tip": "Export data to CSV",
21
+ "chart_widget.btn_img": "Image",
22
+ "chart_widget.btn_img_tip": "Export chart as PNG image",
23
+ "chart_widget.btn_analytics": "Stats",
24
+ "chart_widget.btn_analytics_tip": "Show / hide analytics panel",
25
+ "chart_widget.csv_title": "Export to CSV",
26
+ "chart_widget.csv_filter": "CSV files (*.csv);;All files (*)",
27
+ "chart_widget.img_title": "Export image",
28
+ "chart_widget.img_filter": "PNG images (*.png);;All files (*)",
29
+ "chart_widget.tooltip_x": "X",
30
+ "chart_widget.tooltip_y": "Y",
31
+ "chart_widget.analytics_line": "Line {n}",
32
+ "chart_widget.analytics_scatter": "Scatter {n}",
33
+ "chart_widget.analytics_n": "n",
34
+ "chart_widget.analytics_xmin": "x min",
35
+ "chart_widget.analytics_xmax": "x max",
36
+ "chart_widget.analytics_ymin": "y min",
37
+ "chart_widget.analytics_ymax": "y max",
38
+ "chart_widget.analytics_mean": "mean y",
39
+ "chart_widget.analytics_std": "std y",
40
+ }
41
+ def tr(key: str, **kwargs) -> str:
42
+ text = _FB.get(key, key.split(".")[-1])
43
+ return text.format(**kwargs) if kwargs else text
44
+
45
+ _ML, _MT, _MR, _MB = 58, 14, 20, 40
46
+ _ZOOM_FACTOR = 1.15
47
+ _SNAP_RADIUS_PX = 40
48
+ _TANGENT_HALF_FRAC = 0.18
49
+ _ANALYTICS_PAD = 8
50
+ _ANALYTICS_ROW_H = 17
51
+ _ANALYTICS_MAX_SERIES = 6
52
+ _TOOLTIP_MARGIN = 14
53
+ _SNAP_DOT_R = 5.0
54
+
55
+
56
+ def _nice_ticks(lo: float, hi: float, n: int = 7) -> List[float]:
57
+ if hi <= lo:
58
+ return [lo]
59
+ span = hi - lo
60
+ raw = span / max(n - 1, 1)
61
+ mag = 10 ** math.floor(math.log10(raw)) if raw > 0 else 1.0
62
+ step = mag
63
+ for s in (mag, mag * 2, mag * 2.5, mag * 5, mag * 10):
64
+ if span / s <= n + 1:
65
+ step = s
66
+ break
67
+ start = math.floor(lo / step) * step
68
+ ticks: List[float] = []
69
+ v = start
70
+ while v <= hi + step * 0.001:
71
+ if v >= lo - step * 0.001:
72
+ ticks.append(round(v, 10))
73
+ v = round(v + step, 10)
74
+ return ticks
75
+
76
+
77
+ def _fmt(v: float) -> str:
78
+ if v == 0:
79
+ return "0"
80
+ if abs(v) >= 1000 or (abs(v) < 0.001 and v != 0):
81
+ return f"{v:.3g}"
82
+ if abs(v) >= 100:
83
+ return f"{v:.0f}"
84
+ if abs(v) >= 10:
85
+ return f"{v:.1f}"
86
+ return f"{v:.3g}"
87
+
88
+
89
+ class _InfLine:
90
+ def __init__(self, chart: "ChartWidget", horizontal: bool, value: float, pen: QPen):
91
+ self._chart = chart
92
+ self.horizontal = horizontal
93
+ self.value = value
94
+ self.pen = pen
95
+ self.visible = False
96
+ def setValue(self, v: float):
97
+ self.value = v
98
+ self._chart.update()
99
+ def setVisible(self, v: bool):
100
+ self.visible = v
101
+ self._chart.update()
102
+
103
+
104
+ class _ScatterItem:
105
+ def __init__(self, chart: "ChartWidget", size: int, color: QColor):
106
+ self._chart = chart
107
+ self.xs: List[float] = []
108
+ self.ys: List[float] = []
109
+ self.size = size
110
+ self.color = color
111
+ def setData(self, x=None, y=None, **_):
112
+ self.xs = list(x) if x is not None else []
113
+ self.ys = list(y) if y is not None else []
114
+ self._chart._schedule_autofit()
115
+
116
+
117
+ class _LineItem:
118
+ def __init__(self, chart: "ChartWidget", pen: QPen):
119
+ self._chart = chart
120
+ self.xs: List[float] = []
121
+ self.ys: List[float] = []
122
+ self.pen = pen
123
+ def setData(self, xs=None, ys=None):
124
+ self.xs = list(xs) if xs is not None else []
125
+ self.ys = list(ys) if ys is not None else []
126
+ self._chart._schedule_autofit()
127
+
128
+
129
+ _AnyItem = Union[_LineItem, _ScatterItem]
130
+
131
+
132
+ class _PlotCanvas(QWidget):
133
+ def __init__(self, chart: "ChartWidget"):
134
+ super().__init__(chart)
135
+ self._chart = chart
136
+ self._pan_start: Optional[QPointF] = None
137
+ self._pan_vx0 = 0.0
138
+ self._pan_vy0 = 0.0
139
+ self._mouse_pos: Optional[QPointF] = None
140
+ self._show_analytics = False
141
+ self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
142
+ self.setFocusPolicy(Qt.FocusPolicy.WheelFocus)
143
+ self.setMouseTracking(True)
144
+
145
+ def toggleAnalytics(self):
146
+ self._show_analytics = not self._show_analytics
147
+ self.update()
148
+
149
+ def _plot_rect(self) -> QRect:
150
+ return QRect(_ML, _MT,
151
+ max(1, self.width() - _ML - _MR),
152
+ max(1, self.height() - _MT - _MB))
153
+
154
+ def _to_pt(self, xv: float, yv: float,
155
+ x0: float, dx: float, y0: float, dy: float, pr: QRect) -> QPointF:
156
+ return QPointF(
157
+ pr.left() + (xv - x0) / dx * pr.width(),
158
+ pr.bottom() - (yv - y0) / dy * pr.height(),
159
+ )
160
+
161
+ def _find_nearest(self, mouse: QPointF, pr: QRect,
162
+ x0: float, dx: float, y0: float, dy: float
163
+ ) -> Optional[Tuple[float, float, float, _AnyItem]]:
164
+ best_d = float("inf")
165
+ best = None
166
+ c = self._chart
167
+ for item in c._lines + c._scatters:
168
+ for xi, yi in zip(item.xs, item.ys):
169
+ pt = self._to_pt(xi, yi, x0, dx, y0, dy, pr)
170
+ d = math.hypot(pt.x() - mouse.x(), pt.y() - mouse.y())
171
+ if d < best_d:
172
+ best_d = d
173
+ best = (xi, yi, d, item)
174
+ return best if best and best[2] <= _SNAP_RADIUS_PX else None
175
+
176
+ def _tangent_slope(self, item: _AnyItem, xi: float) -> Optional[float]:
177
+ if not isinstance(item, _LineItem) or len(item.xs) < 2:
178
+ return None
179
+ pts = list(zip(item.xs, item.ys))
180
+ idx = min(range(len(pts)), key=lambda i: abs(pts[i][0] - xi))
181
+ n = len(pts)
182
+ if idx == 0:
183
+ ddx, ddy = pts[1][0] - pts[0][0], pts[1][1] - pts[0][1]
184
+ elif idx == n - 1:
185
+ ddx, ddy = pts[-1][0] - pts[-2][0], pts[-1][1] - pts[-2][1]
186
+ else:
187
+ ddx, ddy = pts[idx+1][0] - pts[idx-1][0], pts[idx+1][1] - pts[idx-1][1]
188
+ return ddy / ddx if abs(ddx) > 1e-15 else None
189
+
190
+ def wheelEvent(self, ev: QWheelEvent):
191
+ pr = self._plot_rect()
192
+ if not pr.contains(ev.pos()):
193
+ return
194
+ c = self._chart
195
+ cx = c._vx0 + (ev.pos().x() - pr.left()) / pr.width() * (c._vx1 - c._vx0)
196
+ cy = c._vy0 + (pr.bottom() - ev.pos().y()) / pr.height() * (c._vy1 - c._vy0)
197
+ factor = 1.0 / _ZOOM_FACTOR if ev.angleDelta().y() > 0 else _ZOOM_FACTOR
198
+ c._vx0 = cx + (c._vx0 - cx) * factor
199
+ c._vx1 = cx + (c._vx1 - cx) * factor
200
+ c._vy0 = cy + (c._vy0 - cy) * factor
201
+ c._vy1 = cy + (c._vy1 - cy) * factor
202
+ self.update()
203
+ ev.accept()
204
+
205
+ def mousePressEvent(self, ev: QMouseEvent):
206
+ if ev.button() in (Qt.MouseButton.LeftButton, Qt.MouseButton.MiddleButton):
207
+ self._pan_start = QPointF(ev.pos())
208
+ self._pan_vx0 = self._chart._vx0
209
+ self._pan_vy0 = self._chart._vy0
210
+ self.setCursor(Qt.CursorShape.ClosedHandCursor)
211
+ ev.accept()
212
+
213
+ def mouseMoveEvent(self, ev: QMouseEvent):
214
+ pr = self._plot_rect()
215
+ self._mouse_pos = QPointF(ev.pos()) if pr.contains(ev.pos()) else None
216
+ if self._pan_start is not None:
217
+ c = self._chart
218
+ vdx = c._vx1 - c._vx0
219
+ vdy = c._vy1 - c._vy0
220
+ ddx = (ev.pos().x() - self._pan_start.x()) / pr.width() * vdx
221
+ ddy = (ev.pos().y() - self._pan_start.y()) / pr.height() * vdy
222
+ c._vx0 = self._pan_vx0 - ddx
223
+ c._vx1 = self._pan_vx0 - ddx + vdx
224
+ c._vy0 = self._pan_vy0 + ddy
225
+ c._vy1 = self._pan_vy0 + ddy + vdy
226
+ self.update()
227
+
228
+ def mouseReleaseEvent(self, ev: QMouseEvent):
229
+ self._pan_start = None
230
+ self.setCursor(Qt.CursorShape.ArrowCursor)
231
+
232
+ def mouseDoubleClickEvent(self, ev: QMouseEvent):
233
+ self._chart.autofit()
234
+
235
+ def leaveEvent(self, ev):
236
+ self._mouse_pos = None
237
+ self.update()
238
+
239
+ def paintEvent(self, _):
240
+ c = self._chart
241
+ p = QPainter(self)
242
+ p.setRenderHint(QPainter.RenderHint.Antialiasing)
243
+ pal = self.palette()
244
+ bg = pal.window().color()
245
+ fg = pal.windowText().color()
246
+ ax_col = QColor(fg); ax_col.setAlpha(80)
247
+ gr_col = QColor(fg); gr_col.setAlpha(40)
248
+ lb_col = QColor(fg); lb_col.setAlpha(200)
249
+ p.fillRect(self.rect(), bg)
250
+ pr = self._plot_rect()
251
+ x0, x1 = c._vx0, c._vx1
252
+ y0, y1 = c._vy0, c._vy1
253
+ dx = x1 - x0 or 1.0
254
+ dy = y1 - y0 or 1.0
255
+ fm = QFontMetrics(c._font)
256
+ p.setFont(c._font)
257
+ xt = _nice_ticks(x0, x1)
258
+ yt = _nice_ticks(y0, y1)
259
+ p.setPen(QPen(gr_col, 1, Qt.PenStyle.DotLine))
260
+ for tv in xt:
261
+ sx = int(pr.left() + (tv - x0) / dx * pr.width())
262
+ p.drawLine(sx, pr.top(), sx, pr.bottom())
263
+ for tv in yt:
264
+ sy = int(pr.bottom() - (tv - y0) / dy * pr.height())
265
+ p.drawLine(pr.left(), sy, pr.right(), sy)
266
+ p.setPen(QPen(ax_col, 1))
267
+ p.drawRect(pr)
268
+ p.setPen(lb_col)
269
+ for tv in xt:
270
+ sx = int(pr.left() + (tv - x0) / dx * pr.width())
271
+ lbl = _fmt(tv)
272
+ lw = fm.horizontalAdvance(lbl)
273
+ p.drawText(sx - lw // 2, pr.bottom() + fm.height() + 2, lbl)
274
+ for tv in yt:
275
+ sy = int(pr.bottom() - (tv - y0) / dy * pr.height())
276
+ lbl = _fmt(tv)
277
+ lw = fm.horizontalAdvance(lbl)
278
+ p.drawText(pr.left() - lw - 6, sy + fm.ascent() // 2, lbl)
279
+ if c._label_bottom:
280
+ lw = fm.horizontalAdvance(c._label_bottom)
281
+ p.drawText(pr.left() + (pr.width() - lw) // 2, self.height() - 3, c._label_bottom)
282
+ if c._label_left:
283
+ p.save()
284
+ p.translate(11, pr.top() + pr.height() // 2)
285
+ p.rotate(-90)
286
+ lw = fm.horizontalAdvance(c._label_left)
287
+ p.drawText(-lw // 2, fm.ascent() // 2, c._label_left)
288
+ p.restore()
289
+ p.setClipRect(pr)
290
+ for ln in c._inflines:
291
+ if not ln.visible:
292
+ continue
293
+ p.setPen(ln.pen)
294
+ if ln.horizontal:
295
+ sy = int(pr.bottom() - (ln.value - y0) / dy * pr.height())
296
+ p.drawLine(pr.left(), sy, pr.right(), sy)
297
+ else:
298
+ sx = int(pr.left() + (ln.value - x0) / dx * pr.width())
299
+ p.drawLine(sx, pr.top(), sx, pr.bottom())
300
+ for item in c._lines:
301
+ if len(item.xs) < 2:
302
+ continue
303
+ pts = [self._to_pt(xi, yi, x0, dx, y0, dy, pr)
304
+ for xi, yi in zip(item.xs, item.ys)]
305
+ path = QPainterPath()
306
+ path.moveTo(pts[0])
307
+ for pt in pts[1:]:
308
+ path.lineTo(pt)
309
+ p.setPen(item.pen)
310
+ p.setBrush(Qt.BrushStyle.NoBrush)
311
+ p.drawPath(path)
312
+ for item in c._scatters:
313
+ if not item.xs:
314
+ continue
315
+ p.setPen(QPen(item.color.darker(150), 1))
316
+ p.setBrush(QBrush(item.color))
317
+ r = item.size / 2.0
318
+ for xi, yi in zip(item.xs, item.ys):
319
+ pt = self._to_pt(xi, yi, x0, dx, y0, dy, pr)
320
+ p.drawEllipse(pt, r, r)
321
+ if self._mouse_pos is not None:
322
+ self._paint_crosshair(p, pr, x0, dx, y0, dy, fg, bg)
323
+ p.setClipping(False)
324
+ if self._show_analytics:
325
+ self._paint_analytics(p, pr, fg, bg, fm)
326
+ p.end()
327
+
328
+ def _paint_crosshair(self, p: QPainter, pr: QRect,
329
+ x0: float, dx: float, y0: float, dy: float,
330
+ fg: QColor, bg: QColor):
331
+ mp = self._mouse_pos
332
+ c = self._chart
333
+ nearest = self._find_nearest(mp, pr, x0, dx, y0, dy)
334
+ ch_col = QColor(fg); ch_col.setAlpha(80)
335
+ p.setPen(QPen(ch_col, 1, Qt.PenStyle.DashLine))
336
+ p.setBrush(Qt.BrushStyle.NoBrush)
337
+ p.drawLine(int(mp.x()), pr.top(), int(mp.x()), pr.bottom())
338
+ p.drawLine(pr.left(), int(mp.y()), pr.right(), int(mp.y()))
339
+ if nearest is None:
340
+ return
341
+ xi, yi, _, item = nearest
342
+ snap = self._to_pt(xi, yi, x0, dx, y0, dy, pr)
343
+ dot_col = QColor(fg); dot_col.setAlpha(220)
344
+ p.setPen(QPen(dot_col, 1))
345
+ p.setBrush(QBrush(dot_col))
346
+ p.drawEllipse(snap, _SNAP_DOT_R, _SNAP_DOT_R)
347
+ slope = self._tangent_slope(item, xi)
348
+ if slope is not None:
349
+ half = (c._vx1 - c._vx0) * _TANGENT_HALF_FRAC
350
+ tp0 = self._to_pt(xi - half, yi - slope * half, x0, dx, y0, dy, pr)
351
+ tp1 = self._to_pt(xi + half, yi + slope * half, x0, dx, y0, dy, pr)
352
+ tg_col = QColor(fg); tg_col.setAlpha(140)
353
+ p.setPen(QPen(tg_col, 1, Qt.PenStyle.DotLine))
354
+ p.setBrush(Qt.BrushStyle.NoBrush)
355
+ p.drawLine(tp0, tp1)
356
+ self._paint_tooltip(p, pr, xi, yi, snap, fg, bg)
357
+
358
+ def _paint_tooltip(self, p: QPainter, pr: QRect,
359
+ xi: float, yi: float, snap: QPointF,
360
+ fg: QColor, bg: QColor):
361
+ fm = QFontMetrics(self._chart._font)
362
+ p.setFont(self._chart._font)
363
+ lx = f"{tr('chart_widget.tooltip_x')}: {_fmt(xi)}"
364
+ ly = f"{tr('chart_widget.tooltip_y')}: {_fmt(yi)}"
365
+ tw = max(fm.horizontalAdvance(lx), fm.horizontalAdvance(ly)) + 16
366
+ th = fm.height() * 2 + 12
367
+ tx = int(snap.x()) + _TOOLTIP_MARGIN
368
+ ty = int(snap.y()) - th - 4
369
+ if tx + tw > pr.right(): tx = int(snap.x()) - tw - _TOOLTIP_MARGIN
370
+ if ty < pr.top(): ty = int(snap.y()) + 8
371
+ bg_ = QColor(bg); bg_.setAlpha(215)
372
+ br_ = QColor(fg); br_.setAlpha(100)
373
+ p.setBrush(QBrush(bg_))
374
+ p.setPen(QPen(br_, 1))
375
+ p.drawRoundedRect(tx, ty, tw, th, 4, 4)
376
+ p.setPen(fg)
377
+ p.drawText(tx + 8, ty + fm.ascent() + 4, lx)
378
+ p.drawText(tx + 8, ty + fm.ascent() + 4 + fm.height(), ly)
379
+
380
+ def _paint_analytics(self, p: QPainter, pr: QRect,
381
+ fg: QColor, bg: QColor, fm: QFontMetrics):
382
+ c = self._chart
383
+ named: List[Tuple[str, _AnyItem]] = []
384
+ for i, it in enumerate(c._lines):
385
+ if it.xs:
386
+ named.append((tr("chart_widget.analytics_line", n=i + 1), it))
387
+ for i, it in enumerate(c._scatters):
388
+ if it.xs:
389
+ named.append((tr("chart_widget.analytics_scatter", n=i + 1), it))
390
+ named = named[:_ANALYTICS_MAX_SERIES]
391
+ if not named:
392
+ return
393
+ row_keys = [
394
+ "chart_widget.analytics_n",
395
+ "chart_widget.analytics_xmin",
396
+ "chart_widget.analytics_xmax",
397
+ "chart_widget.analytics_ymin",
398
+ "chart_widget.analytics_ymax",
399
+ "chart_widget.analytics_mean",
400
+ "chart_widget.analytics_std",
401
+ ]
402
+ row_lbls = [tr(k) for k in row_keys]
403
+ table: List[List[str]] = []
404
+ for _, it in named:
405
+ n = len(it.xs)
406
+ st = _fmt(statistics.stdev(it.ys)) if n > 1 else "—"
407
+ table.append([
408
+ str(n),
409
+ _fmt(min(it.xs)), _fmt(max(it.xs)),
410
+ _fmt(min(it.ys)), _fmt(max(it.ys)),
411
+ _fmt(statistics.mean(it.ys)), st,
412
+ ])
413
+ lbl_w = max(fm.horizontalAdvance(l) for l in row_lbls) + 10
414
+ val_w = max(fm.horizontalAdvance(v) for row in table for v in row) + 10
415
+ hdr_h = fm.height() + 6
416
+ rh = _ANALYTICS_ROW_H
417
+ pad = _ANALYTICS_PAD
418
+ n_ser = len(named)
419
+ total_w = pad * 2 + lbl_w + val_w * n_ser
420
+ total_h = pad * 2 + hdr_h + rh * len(row_lbls)
421
+ ax = pr.right() - total_w - 4
422
+ ay = pr.top() + 4
423
+ bg_ = QColor(bg); bg_.setAlpha(210)
424
+ brd_ = QColor(fg); brd_.setAlpha(70)
425
+ p.setBrush(QBrush(bg_))
426
+ p.setPen(QPen(brd_, 1))
427
+ p.drawRoundedRect(ax, ay, total_w, total_h, 4, 4)
428
+ bold_f = QFont(self._chart._font); bold_f.setBold(True)
429
+ hdr_col = QColor(fg); hdr_col.setAlpha(220)
430
+ lbl_col = QColor(fg); lbl_col.setAlpha(150)
431
+ for ci, (name, _) in enumerate(named):
432
+ cx = ax + pad + lbl_w + ci * val_w + val_w // 2
433
+ p.setFont(bold_f)
434
+ p.setPen(hdr_col)
435
+ p.drawText(cx - fm.horizontalAdvance(name) // 2, ay + pad + fm.ascent(), name)
436
+ p.setFont(self._chart._font)
437
+ for ri, lbl in enumerate(row_lbls):
438
+ ry = ay + pad + hdr_h + ri * rh
439
+ p.setPen(lbl_col)
440
+ p.drawText(ax + pad, ry + fm.ascent(), lbl)
441
+ p.setPen(hdr_col)
442
+ for ci, row in enumerate(table):
443
+ val = row[ri]
444
+ cx = ax + pad + lbl_w + ci * val_w
445
+ vw = fm.horizontalAdvance(val)
446
+ p.drawText(cx + (val_w - vw) // 2, ry + fm.ascent(), val)
447
+
448
+ def grab_image(self) -> QPixmap:
449
+ return self.grab()
450
+
451
+
452
+ class ChartWidget(QWidget):
453
+ def __init__(self, parent=None):
454
+ super().__init__(parent)
455
+ self._lines: List[_LineItem] = []
456
+ self._scatters: List[_ScatterItem] = []
457
+ self._inflines: List[_InfLine] = []
458
+ self._label_left = ""
459
+ self._label_bottom = ""
460
+ self._font = QFont("Arial", 8)
461
+ self._vx0 = 0.0; self._vx1 = 1.0
462
+ self._vy0 = 0.0; self._vy1 = 1.0
463
+ self._canvas = _PlotCanvas(self)
464
+ self._btn_fit = self._make_btn("SP_FileDialogContentsView",
465
+ "chart_widget.btn_fit",
466
+ "chart_widget.btn_fit_tip",
467
+ self.autofit)
468
+ self._btn_stats = self._make_btn("SP_FileDialogInfoView",
469
+ "chart_widget.btn_analytics",
470
+ "chart_widget.btn_analytics_tip",
471
+ self._canvas.toggleAnalytics)
472
+ self._btn_csv = self._make_btn("SP_DialogSaveButton",
473
+ "chart_widget.btn_csv",
474
+ "chart_widget.btn_csv_tip",
475
+ self.exportCsv)
476
+ self._btn_img = self._make_btn("SP_DialogSaveButton",
477
+ "chart_widget.btn_img",
478
+ "chart_widget.btn_img_tip",
479
+ self.exportImage)
480
+ tb = QHBoxLayout()
481
+ tb.setContentsMargins(0, 0, 0, 0)
482
+ tb.setSpacing(2)
483
+ tb.addStretch()
484
+ for btn in (self._btn_fit, self._btn_stats, self._btn_csv, self._btn_img):
485
+ tb.addWidget(btn)
486
+ root = QVBoxLayout(self)
487
+ root.setContentsMargins(0, 0, 0, 0)
488
+ root.setSpacing(0)
489
+ root.addLayout(tb)
490
+ root.addWidget(self._canvas, 1)
491
+ self.setMinimumSize(200, 140)
492
+ self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
493
+
494
+ def _make_btn(self, icon_sp: str, lbl_key: str,
495
+ tip_key: str, slot) -> QToolButton:
496
+ btn = QToolButton(self)
497
+ btn.setText(tr(lbl_key))
498
+ btn.setToolTip(tr(tip_key))
499
+ btn.setIcon(self.style().standardIcon(
500
+ getattr(QStyle.StandardPixmap, icon_sp)))
501
+ btn.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
502
+ btn.setFixedHeight(24)
503
+ btn.clicked.connect(slot)
504
+ return btn
505
+
506
+ def showGrid(self, x: bool = True, y: bool = True, alpha: float = 0.3):
507
+ self._canvas.update()
508
+
509
+ def setLabel(self, side: str, text: str):
510
+ if side == "left": self._label_left = text
511
+ elif side == "bottom": self._label_bottom = text
512
+ self._canvas.update()
513
+
514
+ def plot(self, color: str = "#e74c3c", width: int = 2) -> _LineItem:
515
+ pen = QPen(QColor(color), width)
516
+ pen.setCapStyle(Qt.PenCapStyle.RoundCap)
517
+ pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
518
+ item = _LineItem(self, pen)
519
+ self._lines.append(item)
520
+ return item
521
+
522
+ def addScatter(self, size: int = 10, color: str = "#27ae60") -> _ScatterItem:
523
+ item = _ScatterItem(self, size, QColor(color))
524
+ self._scatters.append(item)
525
+ return item
526
+
527
+ def addItem(self, item):
528
+ if isinstance(item, _LineItem) and item not in self._lines:
529
+ self._lines.append(item)
530
+ elif isinstance(item, _ScatterItem) and item not in self._scatters:
531
+ self._scatters.append(item)
532
+ self._canvas.update()
533
+
534
+ def addLine(self, y: Optional[float] = None, x: Optional[float] = None,
535
+ color: str = "#f39c12", width: int = 1, dashed: bool = True) -> _InfLine:
536
+ horiz = y is not None
537
+ val = y if horiz else (x if x is not None else 0.0)
538
+ pen = QPen(QColor(color), width,
539
+ Qt.PenStyle.DashLine if dashed else Qt.PenStyle.SolidLine)
540
+ ln = _InfLine(self, horiz, val, pen)
541
+ self._inflines.append(ln)
542
+ return ln
543
+
544
+ def _all_xy(self) -> Tuple[List[float], List[float]]:
545
+ xs: List[float] = []
546
+ ys: List[float] = []
547
+ for item in self._lines + self._scatters:
548
+ xs.extend(item.xs); ys.extend(item.ys)
549
+ for ln in self._inflines:
550
+ if ln.visible:
551
+ (ys if ln.horizontal else xs).append(ln.value)
552
+ return xs, ys
553
+
554
+ def _data_bounds(self) -> Tuple[float, float, float, float]:
555
+ xs, ys = self._all_xy()
556
+ if not xs or not ys:
557
+ return 0.0, 1.0, 0.0, 1.0
558
+ x0, x1 = min(xs), max(xs)
559
+ y0, y1 = min(ys), max(ys)
560
+ if x0 == x1: x0 -= 1.0; x1 += 1.0
561
+ if y0 == y1: y0 -= 1.0; y1 += 1.0
562
+ px = (x1 - x0) * 0.05
563
+ py = (y1 - y0) * 0.08
564
+ return x0 - px, x1 + px, y0 - py, y1 + py
565
+
566
+ def _schedule_autofit(self):
567
+ self.autofit()
568
+
569
+ def autofit(self):
570
+ x0, x1, y0, y1 = self._data_bounds()
571
+ self._vx0 = x0; self._vx1 = x1
572
+ self._vy0 = y0; self._vy1 = y1
573
+ self._canvas.update()
574
+
575
+ def update(self):
576
+ self._canvas.update()
577
+ super().update()
578
+
579
+ def exportCsv(self):
580
+ path, _ = QFileDialog.getSaveFileName(
581
+ self, tr("chart_widget.csv_title"), "chart_data.csv",
582
+ tr("chart_widget.csv_filter"))
583
+ if not path:
584
+ return
585
+ series = []
586
+ for i, it in enumerate(self._lines):
587
+ if it.xs:
588
+ series.append((f"line{i}_x", f"line{i}_y", it.xs, it.ys))
589
+ for i, it in enumerate(self._scatters):
590
+ if it.xs:
591
+ series.append((f"scatter{i}_x", f"scatter{i}_y", it.xs, it.ys))
592
+ if not series:
593
+ return
594
+ max_rows = max(len(s[2]) for s in series)
595
+ with open(path, "w", newline="", encoding="utf-8") as f:
596
+ w = csv.writer(f)
597
+ w.writerow([col for s in series for col in (s[0], s[1])])
598
+ for row in range(max_rows):
599
+ w.writerow([
600
+ v for s in series
601
+ for v in (s[2][row] if row < len(s[2]) else "",
602
+ s[3][row] if row < len(s[3]) else "")
603
+ ])
604
+
605
+ def exportImage(self):
606
+ path, _ = QFileDialog.getSaveFileName(
607
+ self, tr("chart_widget.img_title"), "chart.png",
608
+ tr("chart_widget.img_filter"))
609
+ if not path:
610
+ return
611
+ self._canvas.grab_image().save(path)
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.1
2
+ Name: pyqt5-chart-widget
3
+ Version: 1.0.0
4
+ Summary: Lightweight interactive chart widget for PyQt5
5
+ Home-page: https://github.com/EgorKonstrukt/pyqt5-chart-widget
6
+ Author: Zarrakun
7
+ Author-email: egormajndi@gmail.com
8
+ License: MIT
9
+ Keywords: pyqt5,chart,plot,widget,gui
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Topic :: Software Development :: User Interfaces
15
+ Requires-Python: >=3.8
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: PyQt5>=5.15
@@ -0,0 +1,11 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ setup.py
5
+ pyqt5_chart_widget/__init__.py
6
+ pyqt5_chart_widget/chart_widget.py
7
+ pyqt5_chart_widget.egg-info/PKG-INFO
8
+ pyqt5_chart_widget.egg-info/SOURCES.txt
9
+ pyqt5_chart_widget.egg-info/dependency_links.txt
10
+ pyqt5_chart_widget.egg-info/requires.txt
11
+ pyqt5_chart_widget.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ pyqt5_chart_widget
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,24 @@
1
+ from setuptools import setup, find_packages
2
+
3
+ setup(
4
+ name="pyqt5-chart-widget",
5
+ version="1.0.0",
6
+ description="Lightweight interactive chart widget for PyQt5",
7
+ long_description=open("README.md", encoding="utf-8").read(),
8
+ long_description_content_type="text/markdown",
9
+ author="Zarrakun",
10
+ author_email="egormajndi@gmail.com",
11
+ url="https://github.com/EgorKonstrukt/pyqt5-chart-widget",
12
+ license="MIT",
13
+ packages=find_packages(include=["pyqt5_chart_widget*"]),
14
+ python_requires=">=3.8",
15
+ install_requires=["PyQt5>=5.15"],
16
+ keywords=["pyqt5", "chart", "plot", "widget", "gui"],
17
+ classifiers=[
18
+ "Programming Language :: Python :: 3",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Operating System :: OS Independent",
21
+ "Intended Audience :: Developers",
22
+ "Topic :: Software Development :: User Interfaces",
23
+ ],
24
+ )