283 lines
15 KiB
Python
283 lines
15 KiB
Python
from PyQt6.QtWidgets import QWidget, QLabel, QSizePolicy
|
|
from PyQt6.QtGui import QPainter, QColor, QPen, QPainterPath, QLinearGradient
|
|
from PyQt6.QtCore import Qt, QRectF, QPointF
|
|
import math
|
|
|
|
# 统一底色与圆角
|
|
BACKGROUND_COLOR = QColor(245, 246, 250)
|
|
PANEL_BORDER_COLOR = QColor(225, 228, 236)
|
|
CORNER_RADIUS = 10
|
|
|
|
def map_ratio_to_color(r: float) -> QColor:
|
|
hue_stops = [(0.0,0.58),(0.25,0.64),(0.50,0.70),(0.75,0.78),(1.0,0.85)]
|
|
def lerp(a,b,t): return a + (b-a)*t
|
|
if r <= 0: h = hue_stops[0][1]
|
|
elif r >= 1: h = hue_stops[-1][1]
|
|
else:
|
|
for i in range(1,len(hue_stops)):
|
|
if r <= hue_stops[i][0]:
|
|
lpos, lh = hue_stops[i-1]; rpos, rh = hue_stops[i]
|
|
local_t = 0 if rpos==lpos else (r-lpos)/(rpos-lpos)
|
|
h = lerp(lh,rh,local_t); break
|
|
sat = 0.35 + 0.20 * r
|
|
valb = 0.75 + 0.15 * (1 - abs(r-0.5)*2)
|
|
return QColor.fromHsvF(h, sat, valb)
|
|
|
|
class BarChartWidget(QWidget):
|
|
def __init__(self, data, title='', unit='分钟', labels=None, value_format=None, label_rotation: int = 0, parent=None):
|
|
super().__init__(parent)
|
|
self.data = data or []
|
|
self.title = title
|
|
self.unit = unit
|
|
self.labels = labels or [str(i) for i in range(len(self.data))]
|
|
self.value_format = value_format
|
|
self.label_rotation = label_rotation # 0=不旋转, 45=倾斜
|
|
self.setMinimumHeight(180)
|
|
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
|
|
|
def sizeHint(self):
|
|
from PyQt6.QtCore import QSize
|
|
return QSize(500, 260)
|
|
|
|
def setData(self, data, labels=None):
|
|
self.data = data or []
|
|
self.labels = labels or [str(i) for i in range(len(self.data))]
|
|
self.update()
|
|
|
|
def paintEvent(self, event):
|
|
p = QPainter(self)
|
|
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
rect = self.rect()
|
|
p.setPen(QPen(PANEL_BORDER_COLOR,1))
|
|
p.setBrush(BACKGROUND_COLOR)
|
|
# 背景圆角
|
|
p.drawRoundedRect(QRectF(rect).adjusted(0.5,0.5,-0.5,-0.5), CORNER_RADIUS, CORNER_RADIUS)
|
|
if not self.data:
|
|
p.setPen(QColor('#666666'))
|
|
p.drawText(rect, Qt.AlignmentFlag.AlignCenter, '暂无数据')
|
|
p.end(); return
|
|
max_val = max(self.data) or 1
|
|
title_left, title_top = 12, 6
|
|
margin_top = 28
|
|
margin_bottom = 28 if self.label_rotation == 0 else 50
|
|
margin_left, margin_right = 40, 10
|
|
title_height = 18
|
|
w, h = rect.width(), rect.height()
|
|
chart_rect = QRectF(margin_left, margin_top, w - margin_left - margin_right, h - margin_top - margin_bottom)
|
|
bar_count = len(self.data)
|
|
bar_space = chart_rect.width() / bar_count
|
|
bar_width = bar_space * 0.55
|
|
# 标题
|
|
p.setPen(Qt.GlobalColor.black)
|
|
font = p.font(); font.setPointSize(10); font.setBold(True); p.setFont(font)
|
|
p.drawText(QRectF(title_left, title_top, chart_rect.width(), title_height), Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, self.title)
|
|
# 网格
|
|
font.setPointSize(8); font.setBold(False); p.setFont(font)
|
|
pen_grid = QPen(QColor('#dddddd')); pen_grid.setStyle(Qt.PenStyle.DotLine)
|
|
p.setPen(pen_grid)
|
|
grid_lines = 4
|
|
for i in range(grid_lines+1):
|
|
y = chart_rect.top() + chart_rect.height() * (1 - i / grid_lines)
|
|
p.drawLine(QPointF(margin_left, y), QPointF(margin_left + chart_rect.width(), y))
|
|
val = max_val * i / grid_lines
|
|
p.setPen(QColor('#555555'))
|
|
label = f'{val:.0f}' if max_val >= 10 else f'{val:.1f}'
|
|
p.drawText(QRectF(2, y-8, margin_left-4, 14), Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, label)
|
|
p.setPen(pen_grid)
|
|
# 单位
|
|
p.setPen(QColor('#333333'))
|
|
p.drawText(2, margin_top-4, margin_left-4, 14, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignBottom, self.unit)
|
|
# 柱子
|
|
for idx, v in enumerate(self.data):
|
|
x_center = chart_rect.left() + idx * bar_space + bar_space/2
|
|
bar_h = 0 if max_val == 0 else chart_rect.height() * (v / max_val)
|
|
bar_rect = QRectF(x_center - bar_width/2, chart_rect.bottom()-bar_h, bar_width, bar_h)
|
|
ratio = 0.0 if max_val == 0 else max(0.0, min(1.0, v / max_val))
|
|
color = map_ratio_to_color(ratio)
|
|
p.setPen(Qt.PenStyle.NoPen); p.setBrush(color)
|
|
p.drawRoundedRect(bar_rect, 2, 2)
|
|
if v > 0:
|
|
p.setPen(QColor('#222222'))
|
|
sf = p.font(); sf.setPointSize(7); p.setFont(sf)
|
|
val_str = self.value_format(v) if self.value_format else (f'{v:.0f}' if v >= 10 else f'{v:.1f}')
|
|
p.drawText(QRectF(bar_rect.left()-2, bar_rect.top()-14, bar_rect.width()+4, 12), Qt.AlignmentFlag.AlignCenter, val_str)
|
|
# x 标签
|
|
p.setPen(QColor('#333333'))
|
|
lf = p.font(); lf.setPointSize(8); p.setFont(lf)
|
|
for idx, text in enumerate(self.labels):
|
|
x_center = chart_rect.left() + idx * bar_space + bar_space/2
|
|
if self.label_rotation:
|
|
p.save()
|
|
p.translate(x_center, chart_rect.bottom()+4)
|
|
p.rotate(-45)
|
|
# 旋转后使用固定宽度避免重叠 (40px)
|
|
p.drawText(QRectF(-20, 0, 40, 14), Qt.AlignmentFlag.AlignCenter, text)
|
|
p.restore()
|
|
else:
|
|
p.drawText(QRectF(x_center - bar_space/2, chart_rect.bottom()+2, bar_space, 14), Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop, text)
|
|
p.end()
|
|
|
|
class BubbleMetricsWidget(QWidget):
|
|
def __init__(self, metrics, parent=None):
|
|
super().__init__(parent)
|
|
self.metrics = metrics
|
|
self.setMinimumHeight(240)
|
|
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
|
|
|
def sizeHint(self):
|
|
from PyQt6.QtCore import QSize
|
|
return QSize(500, 300)
|
|
|
|
def setMetrics(self, metrics):
|
|
self.metrics = metrics; self.update()
|
|
|
|
def paintEvent(self, event):
|
|
p = QPainter(self)
|
|
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
rect = self.rect()
|
|
p.setPen(QPen(PANEL_BORDER_COLOR,1)); p.setBrush(BACKGROUND_COLOR)
|
|
p.drawRoundedRect(QRectF(rect).adjusted(0.5,0.5,-0.5,-0.5), CORNER_RADIUS, CORNER_RADIUS)
|
|
title_left, title_top, title_height = 12, 6, 20
|
|
margin = 2
|
|
content_rect = QRectF(rect.left()+margin, rect.top()+title_height+margin, rect.width()-2*margin, rect.height()-title_height-2*margin)
|
|
if not self.metrics:
|
|
p.drawText(rect, Qt.AlignmentFlag.AlignCenter, '暂无数据'); p.end(); return
|
|
# 归一化
|
|
minute_values = [(v*60 if u=='h' else v) for (lbl,v,u,c) in self.metrics]
|
|
max_minutes = max(minute_values) if minute_values else 1
|
|
radii_norm = [0.3 + 0.7 * math.sqrt((mv/max_minutes) if max_minutes>0 else 0) for mv in minute_values]
|
|
default_pos = { '全年':(0.20,0.50), '月均':(0.50,0.18), '近7天':(0.80,0.50), '日均':(0.50,0.82) }
|
|
if any(lbl not in default_pos for (lbl,*_) in self.metrics):
|
|
step = 1.0/(len(self.metrics)+1)
|
|
default_pos = {m[0]:(step*(i+1),0.5) for i,m in enumerate(self.metrics)}
|
|
W,H = content_rect.width(), content_rect.height(); base_len = min(W,H)
|
|
centers = [(content_rect.left()+default_pos.get(lbl,(0.5,0.5))[0]*W,
|
|
content_rect.top()+default_pos.get(lbl,(0.5,0.5))[1]*H) for (lbl,*_) in self.metrics]
|
|
constraints=[]
|
|
for (cx,cy),r in zip(centers,radii_norm):
|
|
dist=min(cx-content_rect.left(), content_rect.right()-cx, cy-content_rect.top(), content_rect.bottom()-cy)
|
|
if r>0: constraints.append(dist/(r*base_len))
|
|
for i in range(len(centers)):
|
|
for j in range(i+1,len(centers)):
|
|
dx = centers[i][0]-centers[j][0]; dy = centers[i][1]-centers[j][1]
|
|
d = math.hypot(dx,dy)
|
|
if d>0:
|
|
gap=6; constraints.append((d-gap)/((radii_norm[i]+radii_norm[j])*base_len))
|
|
S = max(0.05, min(constraints) if constraints else 0.5)
|
|
for (label,val,unit,color), r_norm, (cx,cy) in zip(self.metrics, radii_norm, centers):
|
|
R = r_norm * S * base_len
|
|
circle_rect = QRectF(cx-R, cy-R, 2*R, 2*R)
|
|
p.setPen(QPen(QColor('#ffffff'),2)); p.setBrush(QColor(color)); p.drawEllipse(circle_rect)
|
|
p.setPen(QColor('#ffffff'))
|
|
f = p.font(); f.setBold(True); f.setPointSize(max(9,int(R/5))); p.setFont(f)
|
|
txt = f"{val:.0f} 小时" if unit=='h' and val>=10 else (f"{val:.1f} 小时" if unit=='h' else f"{val:.0f} 分钟")
|
|
p.drawText(circle_rect, Qt.AlignmentFlag.AlignCenter, f"{txt}\n{label}")
|
|
# 标题(为空保留对齐占位)
|
|
p.setPen(QColor('#222222'))
|
|
f = p.font(); f.setBold(True); f.setPointSize(10); p.setFont(f)
|
|
p.drawText(QRectF(title_left, title_top, rect.width()-title_left-8, title_height), Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, '')
|
|
p.end()
|
|
|
|
class ScatterChartWidget(QWidget):
|
|
def __init__(self, data, title='', unit='小时', labels=None, parent=None):
|
|
super().__init__(parent)
|
|
self.data = data or []
|
|
self.labels = labels or [str(i) for i in range(len(self.data))]
|
|
self.title = title; self.unit = unit
|
|
self.setMinimumHeight(180)
|
|
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
|
|
|
def sizeHint(self):
|
|
from PyQt6.QtCore import QSize
|
|
return QSize(500, 260)
|
|
|
|
def setData(self, data, labels=None):
|
|
self.data = data or []
|
|
self.labels = labels or [str(i) for i in range(len(self.data))]
|
|
self.update()
|
|
|
|
def paintEvent(self, event):
|
|
p = QPainter(self)
|
|
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
rect = self.rect()
|
|
p.setPen(QPen(PANEL_BORDER_COLOR,1)); p.setBrush(BACKGROUND_COLOR)
|
|
p.drawRoundedRect(QRectF(rect).adjusted(0.5,0.5,-0.5,-0.5), CORNER_RADIUS, CORNER_RADIUS)
|
|
if not self.data:
|
|
p.drawText(rect, Qt.AlignmentFlag.AlignCenter, '暂无数据'); p.end(); return
|
|
title_left,title_top = 12,6
|
|
margin_top,margin_bottom = 28,32
|
|
margin_left,margin_right = 40,10
|
|
title_height = 18
|
|
w,h = rect.width(), rect.height()
|
|
chart_rect = QRectF(margin_left, margin_top, w - margin_left - margin_right, h - margin_top - margin_bottom)
|
|
# 标题
|
|
p.setPen(Qt.GlobalColor.black)
|
|
font = p.font(); font.setPointSize(10); font.setBold(True); p.setFont(font)
|
|
p.drawText(QRectF(title_left, title_top, chart_rect.width(), title_height), Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, self.title)
|
|
# 网格
|
|
max_val = max(self.data) if self.data else 1
|
|
if max_val <= 0: max_val = 1
|
|
grid_lines=4
|
|
font.setPointSize(8); font.setBold(False); p.setFont(font)
|
|
pen_grid = QPen(QColor('#dddddd')); pen_grid.setStyle(Qt.PenStyle.DotLine)
|
|
for i in range(grid_lines+1):
|
|
y = chart_rect.top() + chart_rect.height()*(1 - i/grid_lines)
|
|
p.setPen(pen_grid); p.drawLine(QPointF(chart_rect.left(), y), QPointF(chart_rect.right(), y))
|
|
val = max_val * i / grid_lines
|
|
p.setPen(QColor('#555555'))
|
|
label = f'{val:.0f}' if max_val>=10 else f'{val:.1f}'
|
|
p.drawText(QRectF(2, y-8, margin_left-4, 14), Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, label)
|
|
# 单位
|
|
p.setPen(QColor('#333333'))
|
|
p.drawText(2, margin_top-4, margin_left-4, 14, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignBottom, self.unit)
|
|
# 点
|
|
count = len(self.data)
|
|
x_step = chart_rect.width()/(count-1) if count>1 else chart_rect.width()/2
|
|
points=[]
|
|
for i,v in enumerate(self.data):
|
|
x = chart_rect.left()+i*x_step
|
|
y = chart_rect.bottom() - (v/max_val)*chart_rect.height()
|
|
ratio = max(0.0,min(1.0,v/max_val))
|
|
color = map_ratio_to_color(ratio)
|
|
points.append((x,y,v,color,ratio))
|
|
# 均值线
|
|
avg_val = sum(self.data)/len(self.data) if self.data else 0
|
|
avg_y = chart_rect.bottom() - (avg_val/max_val)*chart_rect.height()
|
|
pen_avg = QPen(QColor('#aaaadd')); pen_avg.setStyle(Qt.PenStyle.DashLine); pen_avg.setWidth(1)
|
|
p.setPen(pen_avg); p.drawLine(QPointF(chart_rect.left(), avg_y), QPointF(chart_rect.right(), avg_y))
|
|
p.setPen(QColor('#666688'))
|
|
f2 = p.font(); f2.setPointSize(7); p.setFont(f2)
|
|
p.drawText(QRectF(chart_rect.right()-60, avg_y-10, 58, 12), Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, f'均值 {avg_val:.1f}')
|
|
# 折线+面积
|
|
if len(points) >=2:
|
|
pen_line = QPen(QColor('#8890c0')); pen_line.setWidth(1); p.setPen(pen_line)
|
|
for i in range(len(points)-1):
|
|
p.drawLine(QPointF(points[i][0], points[i][1]), QPointF(points[i+1][0], points[i+1][1]))
|
|
grad = QLinearGradient(chart_rect.left(), chart_rect.top(), chart_rect.left(), chart_rect.bottom())
|
|
grad.setColorAt(0.0, QColor(200,205,235,90)); grad.setColorAt(1.0, QColor(200,205,235,10))
|
|
path = QPainterPath(QPointF(points[0][0], chart_rect.bottom()))
|
|
for (x,y, *_ ) in points: path.lineTo(x,y)
|
|
path.lineTo(points[-1][0], chart_rect.bottom()); path.closeSubpath(); p.fillPath(path, grad)
|
|
# 极值
|
|
max_point = max(points, key=lambda t:t[2]) if points else None
|
|
min_point = min(points, key=lambda t:t[2]) if points else None
|
|
for (x,y,v,color,ratio) in points:
|
|
r = 5 + 7*ratio
|
|
pen = QPen(QColor('#ffffff'),1.2)
|
|
if max_point and (x,y,v)==max_point[:3]: pen = QPen(QColor('#ff8888'),1.5)
|
|
elif min_point and (x,y,v)==min_point[:3]: pen = QPen(QColor('#66aac6'),1.5)
|
|
p.setPen(pen); p.setBrush(color); p.drawEllipse(QPointF(x,y), r, r)
|
|
label_y_top = y - r - 10; place_above = label_y_top > chart_rect.top()+4
|
|
txt_rect = QRectF(x-24, (label_y_top if place_above else y + r + 2), 48, 12)
|
|
p.setPen(QColor('#222222'))
|
|
sf = p.font(); sf.setPointSize(7); p.setFont(sf)
|
|
val_str = f'{v:.1f}' if v < 10 else f'{v:.0f}'
|
|
p.drawText(txt_rect, Qt.AlignmentFlag.AlignCenter, val_str)
|
|
# x 标签
|
|
p.setPen(QColor('#333333'))
|
|
lf = p.font(); lf.setPointSize(8); p.setFont(lf)
|
|
for i, lab in enumerate(self.labels):
|
|
x = chart_rect.left() + (i * x_step if count>1 else chart_rect.width()/2)
|
|
p.drawText(QRectF(x-20, chart_rect.bottom()+2, 40, 14), Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop, lab)
|
|
p.end()
|