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