# 双色球数据可视化避坑指南:用Pandas+PyQt5实现动态气泡图与历史趋势分析
如果你曾经尝试过用Python分析双色球数据,大概率会遇到这样的场景:好不容易用Pandas把几千期历史数据清洗干净,准备大展身手做可视化时,却发现Matplotlib生成的静态图表既不够直观,又缺乏交互性。更让人头疼的是,当你想把这些分析结果打包成一个桌面应用分享给朋友时,传统的Tkinter界面显得过于简陋,而用Flask或Django做Web应用又显得杀鸡用牛刀。
我最初做数据分析时,也在这个问题上卡了很久。直到后来把Pandas的数据处理能力、PyQt5的现代化界面和WebEngineView的交互式图表结合起来,才真正找到了一个优雅的解决方案。今天要分享的,就是我在这个过程中踩过的坑、总结的经验,以及如何用这套技术栈打造一个既专业又美观的数据可视化工具。
## 1. 环境搭建与核心库选择:为什么是PyQt5+WebEngineView?
很多Python开发者一提到GUI开发,第一反应就是Tkinter。确实,Tkinter作为Python的标准GUI库,学习曲线平缓,入门简单。但当你需要展示复杂的数据可视化时,它的局限性就暴露无遗——样式定制困难、图表渲染性能一般,而且界面风格停留在上个世纪。
PyQt5则完全是另一个层面的工具。它基于Qt框架,提供了现代化的界面组件和强大的图形渲染能力。更重要的是,PyQt5内置了QWebEngineView组件,可以无缝嵌入现代Web技术。这意味着你可以用ECharts、D3.js、Plotly等前端可视化库来渲染图表,然后在Python应用中直接显示。
### 1.1 安装依赖:避免版本冲突的实战经验
先来看看基础环境的搭建。这里有个关键点:PyQt5和PyQtWebEngine的版本必须严格匹配,否则会出现各种奇怪的运行时错误。
```bash
# 推荐使用conda环境管理,避免系统Python环境被污染
conda create -n lottery-viz python=3.9
conda activate lottery-viz
# 核心依赖安装 - 指定版本是关键
pip install PyQt5==5.15.9
pip install PyQtWebEngine==5.15.6
pip install pandas==2.0.3
pip install numpy==1.24.3
# 数据处理辅助库
pip install requests==2.31.0 # 用于数据获取
pip install beautifulsoup4==4.12.2 # 可选,用于网页解析
```
> 注意:PyQt5 5.15.9和PyQtWebEngine 5.15.6是目前最稳定的组合。我在多个项目中测试过,这个版本搭配在Windows、macOS和Linux上都能稳定运行。
安装完成后,可以用下面这个简单的测试脚本验证环境是否正常:
```python
import sys
from PyQt5.QtWidgets import QApplication, QMainWindow
from PyQt5.QtWebEngineWidgets import QWebEngineView
from PyQt5.QtCore import QUrl
class TestWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("环境测试")
self.setGeometry(100, 100, 800, 600)
# 创建WebEngineView组件
self.browser = QWebEngineView()
self.browser.setUrl(QUrl("https://echarts.apache.org/examples/zh/index.html"))
self.setCentralWidget(self.browser)
if __name__ == "__main__":
app = QApplication(sys.argv)
window = TestWindow()
window.show()
sys.exit(app.exec_())
```
如果这个程序能正常启动并显示ECharts的示例页面,说明WebEngineView组件工作正常。这里我直接加载了ECharts官网,是因为后续我们会用ECharts来制作交互式图表。
### 1.2 项目结构设计:模块化思维的重要性
在开始编码之前,合理的项目结构能让你后期维护时少掉很多头发。下面是我推荐的结构:
```
lottery_visualization/
├── data/ # 数据目录
│ ├── raw/ # 原始数据
│ ├── processed/ # 处理后的数据
│ └── cache/ # 缓存文件
├── src/ # 源代码
│ ├── core/ # 核心模块
│ │ ├── data_fetcher.py # 数据获取
│ │ ├── data_processor.py # 数据处理
│ │ └── analyzer.py # 分析逻辑
│ ├── ui/ # 界面模块
│ │ ├── main_window.py # 主窗口
│ │ ├── data_tab.py # 数据标签页
│ │ ├── chart_tab.py # 图表标签页
│ │ └── styles.py # 样式定义
│ ├── web/ # Web相关
│ │ ├── templates/ # HTML模板
│ │ ├── static/ # 静态资源
│ │ └── chart_generators.py # 图表生成器
│ └── utils/ # 工具函数
│ ├── logger.py # 日志配置
│ └── helpers.py # 辅助函数
├── config/ # 配置文件
├── tests/ # 测试代码
├── requirements.txt # 依赖列表
└── main.py # 程序入口
```
这种模块化的设计有几个明显的好处:
- **职责分离**:数据处理、界面逻辑、图表生成各司其职
- **易于测试**:每个模块都可以单独测试
- **便于维护**:修改一个功能不会影响其他部分
- **团队协作友好**:不同开发者可以并行工作
## 2. 数据获取与清洗:Pandas实战技巧
数据质量直接决定可视化效果。双色球数据虽然结构相对简单,但实际处理时还是会遇到各种坑。
### 2.1 多源数据获取策略
我建议不要只依赖单一数据源。官方数据虽然权威,但访问可能不稳定。可以准备多个备用数据源,比如公开的数据API、历史数据存档等。
下面是一个健壮的数据获取类实现:
```python
import pandas as pd
import requests
import json
from datetime import datetime
from typing import Optional, Dict, List
import time
class LotteryDataFetcher:
"""双色球数据获取器,支持多数据源和缓存"""
def __init__(self, cache_days: int = 7):
self.cache_days = cache_days
self.data_sources = [
self._fetch_from_official,
self._fetch_from_backup_api,
self._fetch_from_local_archive
]
def fetch_data(self, force_refresh: bool = False) -> pd.DataFrame:
"""获取数据,优先使用缓存"""
cache_file = "data/cache/lottery_data.parquet"
# 检查缓存是否有效
if not force_refresh and self._is_cache_valid(cache_file):
print("使用缓存数据")
return pd.read_parquet(cache_file)
# 尝试多个数据源
for source_func in self.data_sources:
try:
print(f"尝试从 {source_func.__name__} 获取数据...")
df = source_func()
if df is not None and len(df) > 100: # 确保数据量足够
# 保存到缓存
df.to_parquet(cache_file)
print(f"数据获取成功,共 {len(df)} 期")
return df
except Exception as e:
print(f"数据源 {source_func.__name__} 失败: {e}")
continue
# 所有数据源都失败,尝试加载本地备份
return self._load_fallback_data()
def _fetch_from_official(self) -> Optional[pd.DataFrame]:
"""从官方数据源获取"""
try:
url = "https://webapi.sporttery.cn/gateway/lottery/getHistoryPageListV1.qry"
params = {
"gameNo": "85",
"provinceId": "0",
"pageSize": "30",
"pageNo": "1",
"termLimits": "1000"
}
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Referer": "https://www.lottery.gov.cn/"
}
response = requests.get(url, params=params, headers=headers, timeout=10)
response.raise_for_status()
data = response.json()
if data.get("success"):
records = data["value"]["list"]
return self._parse_official_data(records)
except Exception as e:
print(f"官方数据源获取失败: {e}")
return None
def _parse_official_data(self, records: List[Dict]) -> pd.DataFrame:
"""解析官方数据格式"""
data_list = []
for record in records:
try:
# 解析期号
issue = record.get("lotteryDrawNum", "")
# 解析日期
draw_time = record.get("lotteryDrawTime", "")
draw_date = datetime.strptime(draw_time, "%Y-%m-%d %H:%M:%S")
# 解析号码
numbers = record.get("lotteryDrawResult", "")
if numbers:
nums = numbers.split()
if len(nums) >= 7:
red_balls = [int(n) for n in nums[:6]]
blue_ball = int(nums[6])
data_list.append({
"issue": issue,
"date": draw_date,
"red1": red_balls[0],
"red2": red_balls[1],
"red3": red_balls[2],
"red4": red_balls[3],
"red5": red_balls[4],
"red6": red_balls[5],
"blue": blue_ball
})
except Exception as e:
print(f"解析记录失败: {e}, 记录: {record}")
continue
return pd.DataFrame(data_list)
```
这个类的设计有几个关键点:
1. **缓存机制**:使用Parquet格式缓存数据,比CSV更快更省空间
2. **多数据源**:主数据源失败时自动切换备用源
3. **错误处理**:每个步骤都有try-catch,避免程序崩溃
4. **数据验证**:确保获取的数据量足够且格式正确
### 2.2 数据清洗与特征工程
原始数据获取后,需要经过清洗和特征工程才能用于分析。下面这个表格总结了双色球数据清洗的关键步骤:
| 步骤 | 目的 | 具体操作 | 注意事项 |
|------|------|----------|----------|
| 缺失值处理 | 确保数据完整性 | 检查每期是否有6红1蓝 | 历史早期数据可能格式不同 |
| 异常值检测 | 排除错误数据 | 检查号码是否在有效范围(红球1-33,蓝球1-16) | 注意数据源可能的编码错误 |
| 重复数据检查 | 避免重复分析 | 根据期号去重 | 保留最新数据 |
| 时间序列排序 | 保证时序正确 | 按开奖日期升序排列 | 日期格式统一化 |
| 特征工程 | 增强分析维度 | 添加星期、月份、季节等时间特征 | 考虑节假日影响 |
特征工程是提升分析深度的关键。除了基本的时间特征,还可以计算一些统计特征:
```python
class DataProcessor:
"""数据处理器,负责清洗和特征工程"""
def process(self, df: pd.DataFrame) -> pd.DataFrame:
"""主处理流程"""
df_clean = self._basic_cleaning(df)
df_features = self._add_basic_features(df_clean)
df_advanced = self._add_advanced_features(df_features)
return df_advanced
def _basic_cleaning(self, df: pd.DataFrame) -> pd.DataFrame:
"""基础清洗"""
# 1. 去除完全重复的行
df = df.drop_duplicates(subset=['issue'], keep='last')
# 2. 按日期排序
df = df.sort_values('date').reset_index(drop=True)
# 3. 验证号码范围
red_cols = [f'red{i}' for i in range(1, 7)]
for col in red_cols:
df[col] = df[col].clip(1, 33)
df['blue'] = df['blue'].clip(1, 16)
# 4. 处理可能的缺失值
df[red_cols] = df[red_cols].fillna(method='ffill')
df['blue'] = df['blue'].fillna(method='ffill')
return df
def _add_basic_features(self, df: pd.DataFrame) -> pd.DataFrame:
"""添加基础特征"""
# 时间特征
df['year'] = df['date'].dt.year
df['month'] = df['date'].dt.month
df['day'] = df['date'].dt.day
df['weekday'] = df['date'].dt.weekday # 0=周一, 6=周日
df['week_of_year'] = df['date'].dt.isocalendar().week
df['quarter'] = df['date'].dt.quarter
# 基础统计特征
red_cols = [f'red{i}' for i in range(1, 7)]
df['red_sum'] = df[red_cols].sum(axis=1)
df['red_mean'] = df[red_cols].mean(axis=1)
df['red_std'] = df[red_cols].std(axis=1)
# 奇偶特征
df['red_odd_count'] = (df[red_cols] % 2).sum(axis=1)
df['red_even_count'] = 6 - df['red_odd_count']
# 大小特征(以17为界)
df['red_small_count'] = (df[red_cols] <= 16).sum(axis=1)
df['red_large_count'] = 6 - df['red_small_count']
return df
def _add_advanced_features(self, df: pd.DataFrame) -> pd.DataFrame:
"""添加高级特征"""
red_cols = [f'red{i}' for i in range(1, 7)]
# 区间分布特征
def get_zone(number):
if number <= 11:
return 1
elif number <= 22:
return 2
else:
return 3
for i in range(1, 7):
df[f'red{i}_zone'] = df[f'red{i}'].apply(get_zone)
# 计算每个区间出现的红球数量
df['zone1_count'] = df[[f'red{i}_zone' for i in range(1, 7)]].apply(
lambda row: (row == 1).sum(), axis=1
)
df['zone2_count'] = df[[f'red{i}_zone' for i in range(1, 7)]].apply(
lambda row: (row == 2).sum(), axis=1
)
df['zone3_count'] = df[[f'red{i}_zone' for i in range(1, 7)]].apply(
lambda row: (row == 3).sum(), axis=1
)
# 连号特征
def count_consecutive(numbers):
sorted_nums = sorted(numbers)
count = 0
for i in range(len(sorted_nums) - 1):
if sorted_nums[i + 1] - sorted_nums[i] == 1:
count += 1
return count
df['consecutive_pairs'] = df[red_cols].apply(
lambda row: count_consecutive(row.tolist()), axis=1
)
# 质数特征
def is_prime(n):
if n < 2:
return False
for i in range(2, int(n ** 0.5) + 1):
if n % i == 0:
return False
return True
df['prime_count'] = df[red_cols].apply(
lambda row: sum(is_prime(x) for x in row), axis=1
)
# 添加滞后特征(过去几期的特征)
for lag in [1, 3, 5, 10]:
df[f'red_sum_lag{lag}'] = df['red_sum'].shift(lag)
df[f'blue_lag{lag}'] = df['blue'].shift(lag)
df[f'odd_count_lag{lag}'] = df['red_odd_count'].shift(lag)
return df
```
这些特征工程不仅让数据更丰富,也为后续的可视化分析提供了更多维度。比如,你可以分析“每周几的开奖号码有什么特点”、“不同季节的号码分布是否有差异”等有趣的问题。
## 3. PyQt5界面设计与WebEngineView集成
有了高质量的数据,接下来就是如何优雅地展示它们。PyQt5的现代化界面配合WebEngineView的Web技术,可以创造出既美观又功能强大的数据可视化应用。
### 3.1 主窗口架构设计
主窗口采用标签页设计,每个标签页负责不同的功能模块。这种设计让用户能够快速切换不同的分析视图。
```python
import sys
from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
QHBoxLayout, QTabWidget, QStatusBar)
from PyQt5.QtCore import Qt, QUrl
from PyQt5.QtWebEngineWidgets import QWebEngineView
from PyQt5.QtWebChannel import QWebChannel
class MainWindow(QMainWindow):
"""主窗口类"""
def __init__(self):
super().__init__()
self.setWindowTitle("双色球数据可视化分析系统")
self.setGeometry(100, 100, 1400, 900)
# 应用样式
self.setStyleSheet(self._get_stylesheet())
# 初始化数据
self.data_processor = DataProcessor()
self.chart_generator = ChartGenerator()
# 设置Web通道
self.web_channel = QWebChannel()
self.bridge = JSBridge(self)
self.web_channel.registerObject("pyBridge", self.bridge)
# 初始化UI
self.init_ui()
# 加载数据
self.load_data_async()
def init_ui(self):
"""初始化用户界面"""
central_widget = QWidget()
main_layout = QVBoxLayout()
# 创建标签页
self.tab_widget = QTabWidget()
# 添加各个标签页
self.data_tab = DataTab(self)
self.trend_tab = TrendTab(self)
self.distribution_tab = DistributionTab(self)
self.bubble_tab = BubbleChartTab(self)
self.hot_tab = HotNumbersTab(self)
self.tab_widget.addTab(self.data_tab, "📊 数据浏览")
self.tab_widget.addTab(self.trend_tab, "📈 趋势分析")
self.tab_widget.addTab(self.distribution_tab, "🎯 分布分析")
self.tab_widget.addTab(self.bubble_tab, "🫧 动态气泡图")
self.tab_widget.addTab(self.hot_tab, "🔥 热号分析")
main_layout.addWidget(self.tab_widget)
central_widget.setLayout(main_layout)
self.setCentralWidget(central_widget)
# 添加状态栏
self.status_bar = QStatusBar()
self.setStatusBar(self.status_bar)
self.status_bar.showMessage("就绪")
def _get_stylesheet(self):
"""返回应用样式表"""
return """
QMainWindow {
background-color: #f8fafc;
}
QTabWidget::pane {
border: 1px solid #e2e8f0;
border-radius: 8px;
background-color: white;
margin: 5px;
}
QTabBar::tab {
background-color: #f1f5f9;
color: #64748b;
padding: 10px 20px;
margin-right: 2px;
border-top-left-radius: 6px;
border-top-right-radius: 6px;
font-weight: 500;
font-size: 14px;
}
QTabBar::tab:selected {
background-color: white;
color: #3b82f6;
border-bottom: 2px solid #3b82f6;
}
QTabBar::tab:hover {
background-color: #e2e8f0;
}
QGroupBox {
font-weight: bold;
border: 1px solid #e2e8f0;
border-radius: 8px;
margin-top: 10px;
padding-top: 15px;
background-color: white;
}
QGroupBox::title {
subcontrol-origin: margin;
subcontrol-position: top left;
left: 15px;
padding: 0 10px;
color: #475569;
}
QPushButton {
background-color: #3b82f6;
color: white;
border: none;
border-radius: 6px;
padding: 8px 16px;
font-weight: 500;
font-size: 14px;
}
QPushButton:hover {
background-color: #2563eb;
}
QPushButton:pressed {
background-color: #1d4ed8;
}
QPushButton:disabled {
background-color: #94a3b8;
}
"""
```
这个主窗口的设计有几个亮点:
1. **现代化样式**:使用渐变色和圆角,符合现代UI设计趋势
2. **响应式布局**:使用弹性布局,适应不同窗口大小
3. **状态反馈**:状态栏实时显示系统状态
4. **异步加载**:数据加载不阻塞界面响应
### 3.2 WebEngineView深度集成
WebEngineView的强大之处在于可以无缝集成现代Web技术。下面以动态气泡图为例,展示如何将ECharts图表嵌入到PyQt5应用中。
首先,创建一个专门的气泡图标签页:
```python
class BubbleChartTab(QWidget):
"""动态气泡图标签页"""
def __init__(self, parent=None):
super().__init__(parent)
self.main_window = parent
self.init_ui()
def init_ui(self):
"""初始化界面"""
layout = QVBoxLayout()
# 控制面板
control_panel = self.create_control_panel()
layout.addWidget(control_panel)
# 图表显示区域
self.web_view = QWebEngineView()
self.web_view.page().setWebChannel(self.main_window.web_channel)
layout.addWidget(self.web_view, 1)
self.setLayout(layout)
# 初始加载一个示例图表
self.load_sample_chart()
def create_control_panel(self):
"""创建控制面板"""
panel = QWidget()
panel_layout = QHBoxLayout()
# 时间范围选择
time_label = QLabel("时间范围:")
self.time_combo = QComboBox()
self.time_combo.addItems(["全部数据", "最近1年", "最近2年", "最近5年", "自定义"])
self.time_combo.currentTextChanged.connect(self.update_chart)
# 图表类型选择
type_label = QLabel("图表类型:")
self.type_combo = QComboBox()
self.type_combo.addItems(["频率气泡图", "时间趋势气泡图", "相关性气泡图"])
self.type_combo.currentTextChanged.connect(self.update_chart)
# 动画控制
self.animation_check = QCheckBox("启用动画")
self.animation_check.setChecked(True)
self.animation_check.stateChanged.connect(self.update_chart)
# 刷新按钮
refresh_btn = QPushButton("刷新图表")
refresh_btn.clicked.connect(self.update_chart)
# 导出按钮
export_btn = QPushButton("导出图表")
export_btn.clicked.connect(self.export_chart)
panel_layout.addWidget(time_label)
panel_layout.addWidget(self.time_combo)
panel_layout.addStretch(1)
panel_layout.addWidget(type_label)
panel_layout.addWidget(self.type_combo)
panel_layout.addStretch(1)
panel_layout.addWidget(self.animation_check)
panel_layout.addWidget(refresh_btn)
panel_layout.addWidget(export_btn)
panel.setLayout(panel_layout)
return panel
def load_sample_chart(self):
"""加载示例图表"""
html_content = self.generate_bubble_chart_html()
self.web_view.setHtml(html_content)
def generate_bubble_chart_html(self, chart_type="frequency"):
"""生成气泡图HTML"""
# 这里使用ECharts生成气泡图
html_template = """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>双色球号码分布气泡图</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<style>
body, html {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
background-color: #f8fafc;
font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif;
}
#chart-container {
width: 100%;
height: 100%;
}
.tooltip-content {
background: white;
border-radius: 6px;
padding: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
border: 1px solid #e2e8f0;
}
.tooltip-title {
font-weight: 600;
color: #1e293b;
margin-bottom: 8px;
font-size: 14px;
}
.tooltip-item {
display: flex;
justify-content: space-between;
margin: 4px 0;
font-size: 13px;
}
.tooltip-label {
color: #64748b;
}
.tooltip-value {
color: #3b82f6;
font-weight: 500;
}
</style>
</head>
<body>
<div id="chart-container"></div>
<script>
// 初始化ECharts实例
const chartDom = document.getElementById('chart-container');
const myChart = echarts.init(chartDom);
// 从Python获取数据
let chartData = [];
let maxFrequency = 0;
// 模拟数据 - 实际应用中从Python传递
function generateMockData() {
const data = [];
// 红球数据
for (let i = 1; i <= 33; i++) {
const frequency = Math.floor(Math.random() * 200) + 50;
const lastAppear = Math.floor(Math.random() * 50) + 1;
const hotLevel = Math.random();
data.push({
name: `红球 ${i}`,
value: [i, frequency, lastAppear, hotLevel * 100],
itemStyle: {
color: getRedBallColor(i)
}
});
if (frequency > maxFrequency) {
maxFrequency = frequency;
}
}
// 蓝球数据
for (let i = 1; i <= 16; i++) {
const frequency = Math.floor(Math.random() * 100) + 20;
const lastAppear = Math.floor(Math.random() * 30) + 1;
const hotLevel = Math.random();
data.push({
name: `蓝球 ${i}`,
value: [i + 33, frequency, lastAppear, hotLevel * 100],
itemStyle: {
color: getBlueBallColor(i)
}
});
if (frequency > maxFrequency) {
maxFrequency = frequency;
}
}
return data;
}
// 颜色生成函数
function getRedBallColor(number) {
const hue = (number * 10) % 360;
return `hsl(${hue}, 70%, 60%)`;
}
function getBlueBallColor(number) {
const hue = 220 + (number * 5) % 40;
return `hsl(${hue}, 80%, 60%)`;
}
// 生成图表配置
function getOption(data) {
return {
backgroundColor: '#f8fafc',
title: {
text: '双色球号码出现频率气泡图',
left: 'center',
top: 20,
textStyle: {
color: '#1e293b',
fontSize: 20,
fontWeight: '600'
},
subtext: '气泡大小表示出现频率,颜色区分红蓝球',
subtextStyle: {
color: '#64748b',
fontSize: 14
}
},
tooltip: {
trigger: 'item',
formatter: function(params) {
const [x, frequency, lastAppear, hotLevel] = params.value;
const isRed = x <= 33;
const ballType = isRed ? '红球' : '蓝球';
const ballNumber = isRed ? x : x - 33;
return `
<div class="tooltip-content">
<div class="tooltip-title">${ballType} ${ballNumber}</div>
<div class="tooltip-item">
<span class="tooltip-label">出现次数:</span>
<span class="tooltip-value">${frequency}</span>
</div>
<div class="tooltip-item">
<span class="tooltip-label">最近出现:</span>
<span class="tooltip-value">${lastAppear} 期前</span>
</div>
<div class="tooltip-item">
<span class="tooltip-label">热度指数:</span>
<span class="tooltip-value">${hotLevel.toFixed(1)}</span>
</div>
</div>`;
}
},
grid: {
left: '10%',
right: '10%',
bottom: '15%',
top: '15%',
containLabel: true
},
xAxis: {
type: 'category',
name: '球号',
nameLocation: 'middle',
nameGap: 30,
axisLine: {
lineStyle: {
color: '#cbd5e1'
}
},
axisLabel: {
color: '#64748b',
fontSize: 12,
interval: function(index) {
// 只显示部分标签,避免重叠
return index % 5 === 0;
}
},
splitLine: {
show: true,
lineStyle: {
color: '#e2e8f0',
type: 'dashed'
}
}
},
yAxis: {
type: 'value',
name: '出现频率',
nameLocation: 'middle',
nameGap: 40,
axisLine: {
lineStyle: {
color: '#cbd5e1'
}
},
axisLabel: {
color: '#64748b',
fontSize: 12
},
splitLine: {
lineStyle: {
color: '#e2e8f0',
type: 'dashed'
}
}
},
visualMap: {
show: false,
min: 0,
max: 100,
dimension: 3,
inRange: {
colorAlpha: [0.4, 1]
}
},
series: [{
type: 'scatter',
symbolSize: function(data) {
// 气泡大小与频率成正比
return Math.sqrt(data[1]) * 2;
},
data: data,
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
},
animation: true,
animationDuration: 1000,
animationEasing: 'cubicOut'
}]
};
}
// 生成数据并渲染图表
const mockData = generateMockData();
const option = getOption(mockData);
myChart.setOption(option);
// 响应窗口大小变化
window.addEventListener('resize', function() {
myChart.resize();
});
// 与Python交互
if (typeof pyBridge !== 'undefined') {
// 可以在这里调用Python函数
console.log('WebChannel连接成功');
}
</script>
</body>
</html>
"""
return html_template
```
这个气泡图有几个值得注意的特性:
1. **交互式提示**:鼠标悬停时显示详细数据
2. **视觉编码**:用颜色区分红蓝球,用气泡大小表示频率
3. **动画效果**:气泡出现时有平滑的动画
4. **响应式设计**:自动适应容器大小变化
### 3.3 Python与JavaScript双向通信
WebEngineView的真正威力在于可以实现Python和JavaScript之间的双向通信。通过QWebChannel,我们可以让前端图表和后端数据实时交互。
```python
from PyQt5.QtCore import QObject, pyqtSlot, pyqtSignal
class JSBridge(QObject):
"""JavaScript桥接器,实现Python与JS通信"""
# 定义信号
dataRequested = pyqtSignal(str)
chartUpdated = pyqtSignal(dict)
def __init__(self, parent=None):
super().__init__(parent)
self.data_cache = {}
@pyqtSlot(str, result=str)
def getChartData(self, chart_type):
"""JavaScript调用:获取图表数据"""
print(f"JavaScript请求数据: {chart_type}")
if chart_type == "frequency":
data = self.generate_frequency_data()
elif chart_type == "trend":
data = self.generate_trend_data()
elif chart_type == "correlation":
data = self.generate_correlation_data()
else:
data = {"error": "未知图表类型"}
# 转换为JSON字符串返回
import json
return json.dumps(data, ensure_ascii=False)
@pyqtSlot(str, str)
def updateFilter(self, filter_type, value):
"""JavaScript调用:更新过滤器"""
print(f"过滤器更新: {filter_type} = {value}")
# 这里可以触发数据重新加载
self.dataRequested.emit(filter_type)
@pyqtSlot(str)
def exportChart(self, format_type):
"""JavaScript调用:导出图表"""
print(f"导出图表格式: {format_type}")
# 调用图表导出功能
self.export_chart_image(format_type)
def generate_frequency_data(self):
"""生成频率数据"""
# 这里从Pandas DataFrame计算频率
df = self.parent().data_processor.processed_data
red_freq = {}
blue_freq = {}
# 计算红球频率
for i in range(1, 34):
count = 0
for col in ['red1', 'red2', 'red3', 'red4', 'red5', 'red6']:
count += (df[col] == i).sum()
red_freq[str(i)] = int(count)
# 计算蓝球频率
for i in range(1, 17):
count = (df['blue'] == i).sum()
blue_freq[str(i)] = int(count)
return {
"red": red_freq,
"blue": blue_freq,
"total_draws": len(df),
"update_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
}
```
在JavaScript端,可以这样调用Python函数:
```javascript
// 获取数据
function loadChartData() {
if (typeof pyBridge !== 'undefined') {
pyBridge.getChartData('frequency').then(function(result) {
const data = JSON.parse(result);
updateChartWithData(data);
});
}
}
// 更新过滤器
function updateTimeRange(range) {
if (typeof pyBridge !== 'undefined') {
pyBridge.updateFilter('time_range', range);
}
}
// 监听Python信号
if (typeof pyBridge !== 'undefined') {
// 数据更新信号
pyBridge.dataRequested.connect(function(filterType) {
console.log('数据请求信号:', filterType);
// 重新加载数据
loadChartData();
});
// 图表更新信号
pyBridge.chartUpdated.connect(function(data) {
console.log('图表更新信号:', data);
// 更新图表
updateChart(data);
});
}
```
这种双向通信机制让应用变得非常灵活。比如,用户在前端调整了时间范围,JavaScript可以立即通知Python重新计算数据,Python计算完成后又通过信号通知JavaScript更新图表。
## 4. 高级可视化技巧与性能优化
当数据量变大时,可视化性能就变得至关重要。下面分享一些我在实际项目中总结的优化技巧。
### 4.1 大数据量下的性能优化
双色球历史数据虽然只有几千期,但当我们计算每个号码的出现频率、时间趋势等指标时,数据量会急剧增加。以下是一些优化策略:
**策略一:数据分块加载**
```python
class OptimizedDataLoader:
"""优化数据加载器"""
def __init__(self, chunk_size=1000):
self.chunk_size = chunk_size
self.data_cache = {}
def load_data_chunked(self, file_path):
"""分块加载数据"""
import pandas as pd
# 使用迭代器分块读取
chunks = []
for chunk in pd.read_csv(file_path, chunksize=self.chunk_size):
# 对每个块进行预处理
processed_chunk = self.preprocess_chunk(chunk)
chunks.append(processed_chunk)
# 更新进度
yield len(chunks) * self.chunk_size
# 合并所有块
return pd.concat(chunks, ignore_index=True)
def preprocess_chunk(self, chunk):
"""预处理数据块"""
# 只处理必要的列
required_cols = ['issue', 'date', 'red1', 'red2', 'red3',
'red4', 'red5', 'red6', 'blue']
if all(col in chunk.columns for col in required_cols):
return chunk[required_cols]
return chunk
```
**策略二:使用更高效的数据格式**
Parquet格式比CSV更适合大数据存储:
```python
def convert_to_parquet(csv_path, parquet_path):
"""将CSV转换为Parquet格式"""
import pandas as pd
import pyarrow as pa
import pyarrow.parquet as pq
# 读取CSV
df = pd.read_csv(csv_path)
# 转换为PyArrow Table
table = pa.Table.from_pandas(df)
# 写入Parquet
pq.write_table(table, parquet_path, compression='snappy')
# 验证文件大小
import os
csv_size = os.path.getsize(csv_path) / 1024 / 1024
parquet_size = os.path.getsize(parquet_path) / 1024 / 1024
print(f"CSV大小: {csv_size:.2f} MB")
print(f"Parquet大小: {parquet_size:.2f} MB")
print(f"压缩比: {csv_size/parquet_size:.1f}x")
```
**策略三:前端渲染优化**
对于动态气泡图,可以使用ECharts的增量渲染:
```javascript
// 增量更新数据
function updateChartIncrementally(newData) {
const option = myChart.getOption();
// 只更新数据部分
option.series[0].data = newData;
// 使用setOption的notMerge模式
myChart.setOption({
series: [{
data: newData
}]
}, {
notMerge: true,
lazyUpdate: true // 延迟更新,提高性能
});
}
// 虚拟滚动 - 只渲染可见区域的数据
function implementVirtualScroll(data, containerHeight, itemHeight) {
const visibleCount = Math.ceil(containerHeight / itemHeight);
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = startIndex + visibleCount;
return data.slice(startIndex, endIndex);
}
```
### 4.2 多图表联动与交互
一个专业的数据可视化系统应该支持图表之间的联动。比如,在气泡图上点击某个号码,趋势图应该自动显示该号码的历史走势。
```python
class ChartLinkManager:
"""图表联动管理器"""
def __init__(self):
self.charts = {}
self.selected_number = None
def register_chart(self, chart_id, chart_instance):
"""注册图表"""
self.charts[chart_id] = chart_instance
def on_number_selected(self, number, ball_type):
"""号码被选中时的处理"""
self.selected_number = (number, ball_type)
# 通知所有图表更新
for chart_id, chart in self.charts.items():
if hasattr(chart, 'update_for_selected_number'):
chart.update_for_selected_number(number, ball_type)
def create_linkage_script(self):
"""生成联动JavaScript代码"""
return """
<script>
// 图表联动逻辑
function setupChartLinkage() {
// 监听所有图表的点击事件
const charts = ['bubbleChart', 'trendChart', 'distributionChart'];
charts.forEach(chartId => {
const chart = echarts.getInstanceByDom(document.getElementById(chartId));
if (chart) {
chart.on('click', function(params) {
if (params.componentType === 'series') {
const data = params.data;
const number = extractNumberFromData(data);
const ballType = extractBallTypeFromData(data);
// 通知Python
if (typeof pyBridge !== 'undefined') {
pyBridge.onNumberSelected(number, ballType);
}
// 高亮所有图表中的这个号码
highlightNumberInAllCharts(number, ballType);
}
});
}
});
}
function highlightNumberInAllCharts(number, ballType) {
// 在所有图表中高亮显示这个号码
const charts = ['bubbleChart', 'trendChart', 'distributionChart'];
charts.forEach(chartId => {
const chart = echarts.getInstanceByDom(document.getElementById(chartId));
if (chart) {
const option = chart.getOption();
// 更新高亮状态
option.series.forEach(series => {
if (series.data) {
series.data.forEach(item => {
if (shouldHighlight(item, number, ballType)) {
item.itemStyle = item.itemStyle || {};
item.itemStyle.borderWidth = 3;
item.itemStyle.borderColor = '#ff6b6b';
item.itemStyle.shadowBlur = 15;
item.itemStyle.shadowColor = 'rgba(255, 107, 107, 0.5)';
}
});
}
});
chart.setOption(option);
}
});
}
// 页面加载完成后设置联动
document.addEventListener('DOMContentLoaded', setupChartLinkage);
</script>
"""
```
### 4.3 响应式设计适配
用户可能在不同设备上使用这个应用,因此响应式设计很重要:
```python
class ResponsiveLayout:
"""响应式布局管理器"""
def __init__(self, min_width=800, min_height=600):
self.min_width = min_width
self.min_height = min_height
self.breakpoints = {
'xs': 576, # 超小屏幕
'sm': 768, # 小屏幕
'md': 992, # 中等屏幕
'lg': 1200, # 大屏幕
'xl': 1400 # 超大屏幕
}
def get_layout_config(self, width, height):
"""根据窗口大小返回布局配置"""
if width < self.breakpoints['sm']:
# 移动端布局
return {
'columns': 1,
'chart_height': '400px',
'font_size': '12px',
'show_legend': False,
'compact_mode': True
}
elif width < self.breakpoints['md']:
# 平板布局
return {
'columns': 2,
'chart_height': '500px',
'font_size': '13px',
'show_legend': True,
'compact_mode': True
}
else:
# 桌面布局
return {
'columns': 3,
'chart_height': '600px',
'font_size': '14px',
'show_legend': True,
'compact_mode': False
}
def adjust_chart_options(self, options, layout_config):
"""根据布局配置调整图表选项"""
adjusted = options.copy()
# 调整标题大小
if 'title' in adjusted:
adjusted['title']['textStyle'] = adjusted['title'].get('textStyle', {})
adjusted['title']['textStyle']['fontSize'] = layout_config['font_size']
# 调整图例
if 'legend' in adjusted:
adjusted['legend']['show'] = layout_config['show_legend']
if layout_config['compact_mode']:
adjusted['legend']['orient'] = 'horizontal'
adjusted['legend']['top'] = 'bottom'
else:
adjusted['legend']['orient'] = 'vertical'
adjusted['legend']['right'] = '10%'
# 调整网格
if 'grid' in adjusted:
if layout_config['compact_mode']:
adjusted['grid']['top'] = '15%'
adjusted['grid']['bottom'] = '25%'
else:
adjusted['grid']['top'] = '10%'
adjusted['grid']['bottom'] = '15%'
return adjusted
```
在实际使用中,我发现在窗口大小变化时,需要动态调整图表配置。PyQt5的resizeEvent可以很好地处理这个需求:
```python
class ResponsiveWebView(QWebEngineView):
"""响应式WebView"""
def resizeEvent(self, event):
"""窗口大小变化时调整图表"""
super().resizeEvent(event)
# 获取新的窗口大小
width = event.size().width()
height = event.size().height()
# 获取布局配置
layout_config = self.parent().layout_manager.get_layout_config(width, height)
# 通知JavaScript调整图表
self.page().runJavaScript(f"""
if (typeof currentChart !== 'undefined') {{
const layoutConfig = {json.dumps(layout_config)};
adjustChartLayout(layoutConfig);
}}
""")
```
## 5. 实战案例:完整的数据分析流程
让我们通过一个完整的案例,看看如何将前面介绍的技术组合起来,实现一个真正的双色球数据分析应用。
### 5.1 案例背景与需求
假设我们要分析以下问题:
1. 哪些号码是"热号"(近期频繁出现)?
2. 号码的出现是否有季节性规律?
3. 红球之间的组合有什么特点?
### 5.2 实现步骤
**步骤1:数据准备**
```python
def prepare_analysis_data():
"""准备分析数据"""
# 1. 获取数据
fetcher = LotteryDataFetcher()
raw_data = fetcher.fetch_data()
# 2. 数据清洗
processor = DataProcessor()
cleaned_data = processor.process(raw_data)
# 3. 计算衍生指标
analyzer = DataAnalyzer(cleaned_data)
# 计算频率指标
frequency_df = analyzer.calculate_frequency()
# 计算时间趋势
trend_df = analyzer.calculate_trend_by_month()
# 计算组合频率
combination_df = analyzer.calculate_combinations(top_n=20)
return {
'raw': raw_data,
'cleaned': cleaned_data,
'frequency': frequency_df,
'trend': trend_df,
'combinations': combination_df
}
```
**步骤2:可视化设计**
针对不同的分析需求,设计不同的图表:
```python
def create_analysis_dashboard(data_dict):
"""创建分析仪表板"""
dashboard_html = """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>双色球多维分析仪表板</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<style>
.dashboard {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-gap: 20px;
padding: 20px;
background-color: #f8fafc;
}
.chart-card {
background: white;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
padding: 20px;
transition: transform 0.3s ease;
}
.chart-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
}
.chart-title {
font-size: 18px;
font-weight: 600;
color: #1e293b;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 2px solid #e2e8f0;
}
.chart-container {
width: 100%;
height: 400px;
}
.full-width {
grid-column: 1 / -1;
}
@media (max-width: 1200px) {
.dashboard {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="dashboard">
"""
# 添加热力图 - 显示每个号码每月的出现频率
dashboard_html += create_heatmap_chart(data_dict['trend'])
# 添加气泡图 - 显示号码总体频率
dashboard_html += create_bubble_chart(data_dict['frequency'])
# 添加组合关系图 - 显示经常一起出现的号码
dashboard_html += create_combination_chart(data_dict['combinations'])
# 添加时间趋势图 - 显示热门号码的变化趋势
dashboard_html += create_trend_chart(data_dict['cleaned'])
dashboard_html += """
</div>
<script>
// 初始化所有图表
function initCharts() {
// 热力图
initHeatmap();
// 气泡图
initBubbleChart();
// 组合关系图
initCombinationChart();
// 趋势图
initTrendChart();
}
// 页面加载完成后初始化
document.addEventListener('DOMContentLoaded', initCharts);
</script>
</body>
</html>
"""
return dashboard_html
```
**步骤3:交互功能实现**
```python
class InteractiveDashboard:
"""交互式仪表板"""
def __init__(self, data_dict):
self.data = data_dict
self.selected_filters = {}
def create_filter_controls(self):
"""创建过滤器控件"""
filters_html = """
<div class="filter-panel">
<div class="filter-group">
<label>时间范围:</label>
<select id="time-range" onchange="updateTimeRange(this.value)">
<option value="all">全部时间</option>
<option value="1y">最近1年</option>
<option value="2y">最近2年</option>
<option value="5y">最近5年</option>
</select>
</div>
<div class="filter-group">
<label>号码类型:</label>
<select id="ball-type" onchange="updateBallType(this.value)">
<option value="all">全部号码</option>
<option value="red">仅红球</option>
<option value="blue">仅蓝球</option>
</select>
</div>
<div class="filter-group">
<label>排序方式:</label>
<select id="sort-by" onchange="updateSortBy(this.value)">
<option value="frequency">按频率排序</option>
<option value="recent">按最近出现</option>
<option value="hot">按热度排序</option>
</select>
</div>
<button onclick="resetFilters()" class="reset-btn">重置过滤器</button>
</div>
<script>
function updateTimeRange(range) {
// 更新数据并刷新图表
pyBridge.updateTimeRange(range);
}
function updateBallType(type) {
pyBridge.updateBallType(type);
}
function updateSortBy(method) {
pyBridge.updateSortBy(method);
}
function resetFilters() {
document.getElementById('time-range').value = 'all';
document.getElementById('ball-type').value = 'all';
document.getElementById('sort-by').value = 'frequency';
// 重置所有过滤器
pyBridge.resetFilters();
}
</script>
"""
return filters_html
def update_charts_based_on_filters(self):
"""根据过滤器更新图表"""
filtered_data = self.apply_filters()
# 更新各个图表
updated_charts = {
'heatmap': self.update_heatmap(filtered_data),
'bubble': self.update_bubble_chart(filtered_data),
'combination': self.update_combination_chart(filtered_data),
'trend': self.update_trend_chart(filtered_data)
}
return updated_charts
def apply_filters(self):
"""应用所有过滤器"""
filtered = self.data['cleaned'].copy()
# 时间范围过滤
if 'time_range' in self.selected_filters:
time_range = self.selected_filters['time_range']
if time_range != 'all':
# 计算时间范围
cutoff_date = self.calculate_cutoff_date(time_range)
filtered = filtered[filtered['date'] >= cutoff_date]
# 号码类型过滤(在图表级别处理,不在数据级别)
return filtered
```
### 5.3 性能监控与调试
在开发过程中,性能监控很重要。我通常会添加一些监控代码:
```python
import time
from functools import wraps
def performance_monitor(func):
"""性能监控装饰器"""
@wraps(func)
def wrapper(*args, **kwargs):
start_time = time.time()
result = func(*args, **kwargs)
end_time = time.time()
elapsed = end_time - start_time
print(f"{func.__name__} 执行时间: {elapsed:.3f}秒")
# 记录到日志文件
with open('performance.log', 'a') as f:
f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - {func.__name__}: {elapsed:.3f}s\n")
return result
return wrapper
class PerformanceProfiler:
"""性能分析器"""
def __init__(self):
self.metrics = {}
def start_timing(self, operation_name):
"""开始计时"""
self.metrics[operation_name] = {
'start': time.time(),
'end': None,
'duration': None
}
def end_timing(self, operation_name):
"""结束计时"""
if operation_name in self.metrics:
self.metrics[operation_name]['end'] = time.time()
duration = self.metrics[operation_name]['end'] - self.metrics[operation_name]['start']
self.metrics[operation_name]['duration'] = duration
def get_report(self):
"""获取性能报告"""
report = "性能分析报告\n"
report += "=" * 50 + "\n"
for op_name, metric in self.metrics.items():
if metric['duration'] is not None:
report += f"{op_name}: {metric['duration']:.3f}秒\n"
# 找出最耗时的操作
if self.metrics:
slowest = max(self.metrics.items(),
key=lambda x: x[1]['duration'] if x[1]['duration'] else 0)
report += f"\n最耗时操作: {slowest[0]} ({slowest[1]['duration']:.3f}秒)\n"
return report
```
在实际项目中,我发现数据加载和图表初始化的性能最关键。通过上述监控工具,可以快速定位性能瓶颈。
### 5.4 部署与分发
完成开发后,如何将应用分享给其他人使用?PyInstaller是一个很好的选择:
```bash
# 创建spec文件
pyi-makespec --onefile --windowed --name LotteryViz main.py
# 编辑spec文件,添加数据文件
# 在spec文件的datas部分添加:
# datas=[('data/', 'data/'), ('web/templates/', 'web/templates/')]
# 打包应用
pyinstaller LotteryViz.spec
```
对于更复杂的部署,可以考虑使用Docker:
```dockerfile
FROM python:3.9-slim
# 安装系统依赖
RUN apt-get update && apt-get install -y \
libgl1-mesa-glx \
libglib2.0-0 \
libsm6 \
libxrender1 \
libxext6 \
&& rm -rf /var/lib/apt/lists/*
# 设置工作目录
WORKDIR /app
# 复制依赖文件
COPY requirements.txt .
# 安装Python依赖
RUN pip install --no-cache-dir -r requirements.txt
# 复制应用代码
COPY . .
# 创建非root用户
RUN useradd -m -u 1000 appuser
USER appuser
# 运行应用
CMD ["python", "main.py"]
```
构建和运行Docker容器:
```bash
# 构建镜像
docker build -t lottery-viz .
# 运行容器
docker run -p 8080:8080 -v $(pwd)/data:/app/data lottery-viz
```
## 6. 避坑指南与最佳实践
在开发过程中,我遇到了不少坑。这里总结一些常见问题及其解决方案:
### 6.1 常见问题与解决方案
| 问题 | 现象 | 原因 | 解决方案 |
|------|------|------|----------|
| WebEngineView白屏 | 页面不显示,只有空白 | Qt WebEngine组件缺失或版本不匹配 | 确保安装PyQtWebEngine且版本匹配 |
| 图表渲染慢 | 界面卡顿,响应延迟 | 数据量过大或图表配置复杂 | 使用数据分页、虚拟滚动、简化图表 |
| 内存泄漏 | 应用运行越久越卡 | Python对象未正确释放 | 使用QObject.parent机制管理生命周期 |
| 跨平台兼容性问题 | 在Windows正常,macOS/Linux异常 | 路径分隔符、字体渲染差异 | 使用os.path.join,测试多平台 |
| 打包后资源丢失 | 打包后图片、样式失效 | 资源文件未包含在打包中 | 使用PyInstaller的datas选项 |
### 6.2 代码质量保证
**使用类型提示提高代码可读性:**
```python
from typing import Dict, List, Optional, Tuple, Union
import pandas as pd
from datetime import datetime
def calculate_number_frequency(
data: pd.DataFrame,
number_range: Tuple[int, int],
ball_type: str = 'red'
) -> Dict[int, int]:
"""
计算号码出现频率
Args:
data: 包含开奖数据的DataFrame
number_range: 号码范围,如(1, 33)表示红球
ball_type: 球类型,'red'或'blue'
Returns:
字典,键为号码,值为出现次数
"""
if ball_type not in ['red', 'blue']:
raise ValueError("ball_type必须是'red'或'blue'")
start, end = number_range
frequency = {i: 0 for i in range(start, end + 1)}
if ball_type == 'red':
red_cols = [f'red{i}' for i in range(1, 7)]
for col in red_cols:
for num in data[col]:
if start <= num <= end:
frequency[num] += 1
else:
for num in data['blue']:
if start <= num <= end:
frequency[num] += 1
return frequency
```
**编写单元测试确保功能正确:**
```python
import unittest
import pandas as pd
import numpy as np
from datetime import datetime
class TestLotteryAnalysis(unittest.TestCase):
"""测试数据分析功能"""
def setUp(self):
"""测试前准备数据"""
# 创建测试数据
dates = pd.date_range('2023-01-01', periods=100, freq='D')
self.test_data = pd.DataFrame({
'date': dates,
'red1': np.random.randint(1, 34, 100),
'red2': np.random.randint(1, 34, 100),
'red3': np.random.randint(1, 34, 100),
'red4': np.random.randint(1, 34, 100),
'red5': np.random.randint(1, 34, 100),
'red6': np.random.randint(1, 34, 100),
'blue': np.random.randint(1, 17, 100)
})
def test_frequency_calculation(self):
"""测试频率计算"""
from src.core.analyzer import calculate_number_frequency
frequency = calculate_number_frequency(
self.test_data,
(1, 33),
'red'
)
# 验证结果
self.assertIsInstance(frequency, dict)
self.assertEqual(len(frequency), 33)
# 所有频率应该都是非负数
for count in frequency.values():
self.assertGreaterEqual(count, 0)
def test_data_validation(self):
"""测试数据验证"""
from src.core.data_processor import validate_data
# 测试有效数据
is_valid, errors = validate_data(self.test_data)
self.assertTrue(is_valid)
self.assertEqual(len(errors), 0)
# 测试无效数据(包含超出范围的号码)
invalid_data = self.test_data.copy()
invalid_data.loc[0, 'red1'] = 40 # 超出范围
is_valid, errors = validate_data(invalid_data)
self.assertFalse(is_valid)
self.assertGreater(len(errors), 0)
def test_chart_generation(self):
"""测试图表生成"""
from src.web.chart_generators import generate_bubble_chart
# 生成测试数据
test_freq = {i: np.random.randint(0, 100) for i in range(1, 34)}
# 生成图表HTML
html = generate_bubble_chart(test_freq, "测试气泡图")
# 验证HTML包含必要的元素
self.assertIn('<!DOCTYPE html>', html)
self.assertIn('echarts', html)
self.assertIn('测试气泡图', html)
# 验证HTML是有效的
self.assertTrue(html.strip().startswith('<!DOCTYPE html>'))
self.assertTrue(html.strip().endswith('</html>'))
if __name__ == '__main__':
unittest.main()
```
### 6.3 用户体验优化
**添加加载状态提示:**
```python
class LoadingOverlay(QWidget):
"""加载覆盖层"""
def __init__(self, parent=None):
super().__init__(parent)
self.setAttribute(Qt.WA_TransparentForMouseEvents)
self.init_ui()
def init_ui(self):
"""初始化界面"""
self.setStyleSheet("""
background-color: rgba(255, 255