iBook/charts.py

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()