iBook/charts.py

311 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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