311 lines
16 KiB
Python
311 lines
16 KiB
Python
from PyQt6.QtWidgets import QWidget, 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
|
||
# 归一化:统一把不同单位数值映射到“分钟等价”尺度,以便半径比较
|
||
# 规则:小时(h) -> v*60;分钟(m) -> v;书本数量(book) -> v*60 (一书折算 1 小时,避免过小)
|
||
minute_values = []
|
||
for (lbl, v, u, c) in self.metrics:
|
||
if u == 'h':
|
||
minute_values.append(v * 60)
|
||
elif u == 'm':
|
||
minute_values.append(v)
|
||
elif u == 'book': # 新增:书籍数量指标
|
||
minute_values.append(v * 60) # 折算:1 本 ≈ 60 分钟,使气泡视觉权重合理
|
||
else: # 未知单位直接使用原值
|
||
minute_values.append(v)
|
||
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]
|
||
# 默认布局:4 指标与 5 指标(包含“已读”)使用不同的预设坐标,尽量避免重叠
|
||
labels = [m[0] for m in self.metrics]
|
||
if '已读' in labels and len(labels) >= 5:
|
||
default_pos = {
|
||
'全年': (0.18, 0.55),
|
||
'月均': (0.38, 0.20),
|
||
'近7天': (0.82, 0.55),
|
||
'日均': (0.38, 0.85),
|
||
'已读': (0.60, 0.55)
|
||
}
|
||
else:
|
||
default_pos = { '全年':(0.20,0.50), '月均':(0.50,0.18), '近7天':(0.80,0.50), '日均':(0.50,0.82), '已读':(0.50,0.50) }
|
||
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)
|
||
if unit == 'h':
|
||
txt_core = f"{val:.0f} 小时" if val >= 10 else f"{val:.1f} 小时"
|
||
elif unit == 'm':
|
||
txt_core = f"{val:.0f} 分钟"
|
||
elif unit == 'book':
|
||
txt_core = f"{val:.0f} 本书"
|
||
else:
|
||
txt_core = f"{val:.0f}"
|
||
p.drawText(circle_rect, Qt.AlignmentFlag.AlignCenter, f"{txt_core}\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()
|