🎯 引言

在数据驱动的时代,数据可视化已成为将复杂数据转化为直观洞察的关键技术。无论是商业分析、科学研究还是产品决策,优秀的数据可视化都能帮助我们更好地理解数据背后的故事。

本文将带你深入数据可视化的世界,从基础的图表制作到复杂的交互式仪表板,从Python的数据处理到JavaScript的前端展示,全面掌握现代数据可视化的核心技术和最佳实践。

📊 数据可视化基础架构

1. 项目结构设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
data-visualization-project/
├── backend/
│ ├── app.py # Flask API服务
│ ├── data_processor.py # 数据处理模块
│ ├── chart_generator.py # 图表生成器
│ └── requirements.txt # Python依赖
├── frontend/
│ ├── src/
│ │ ├── components/ # Vue组件
│ │ ├── utils/ # 工具函数
│ │ ├── charts/ # 图表组件
│ │ └── dashboard/ # 仪表板组件
│ ├── public/
│ └── package.json # 前端依赖
├── data/
│ ├── raw/ # 原始数据
│ ├── processed/ # 处理后数据
│ └── exports/ # 导出数据
└── config/
├── database.py # 数据库配置
└── settings.py # 应用配置

2. 数据处理核心模块

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
# backend/data_processor.py
import pandas as pd
import numpy as np
from datetime import datetime, timedelta
import json
from typing import Dict, List, Any, Optional
import sqlite3
from sqlalchemy import create_engine
import logging

class DataProcessor:
"""数据处理核心类"""

def __init__(self, db_url: str = "sqlite:///data.db"):
self.engine = create_engine(db_url)
self.logger = logging.getLogger(__name__)

def load_data(self, source: str, **kwargs) -> pd.DataFrame:
"""加载数据从各种源"""
try:
if source.endswith('.csv'):
return pd.read_csv(source, **kwargs)
elif source.endswith('.json'):
return pd.read_json(source, **kwargs)
elif source.endswith('.xlsx'):
return pd.read_excel(source, **kwargs)
elif source.startswith('sql:'):
query = source.replace('sql:', '')
return pd.read_sql(query, self.engine)
else:
raise ValueError(f"Unsupported data source: {source}")
except Exception as e:
self.logger.error(f"Error loading data: {e}")
raise

def clean_data(self, df: pd.DataFrame) -> pd.DataFrame:
"""数据清洗"""
# 处理缺失值
df_cleaned = df.copy()

# 数值列:用中位数填充
numeric_columns = df_cleaned.select_dtypes(include=[np.number]).columns
for col in numeric_columns:
df_cleaned[col].fillna(df_cleaned[col].median(), inplace=True)

# 分类列:用众数填充
categorical_columns = df_cleaned.select_dtypes(include=['object']).columns
for col in categorical_columns:
mode_value = df_cleaned[col].mode()
if not mode_value.empty:
df_cleaned[col].fillna(mode_value[0], inplace=True)

# 移除重复行
df_cleaned.drop_duplicates(inplace=True)

# 处理异常值(使用IQR方法)
for col in numeric_columns:
Q1 = df_cleaned[col].quantile(0.25)
Q3 = df_cleaned[col].quantile(0.75)
IQR = Q3 - Q1
lower_bound = Q1 - 1.5 * IQR
upper_bound = Q3 + 1.5 * IQR

# 将异常值替换为边界值
df_cleaned[col] = df_cleaned[col].clip(lower_bound, upper_bound)

return df_cleaned

def aggregate_data(self, df: pd.DataFrame, group_by: List[str],
agg_config: Dict[str, str]) -> pd.DataFrame:
"""数据聚合"""
try:
return df.groupby(group_by).agg(agg_config).reset_index()
except Exception as e:
self.logger.error(f"Error aggregating data: {e}")
raise

def time_series_analysis(self, df: pd.DataFrame, date_col: str,
value_col: str, freq: str = 'D') -> Dict[str, Any]:
"""时间序列分析"""
# 确保日期列是datetime类型
df[date_col] = pd.to_datetime(df[date_col])
df.set_index(date_col, inplace=True)

# 重采样
ts_data = df[value_col].resample(freq).sum()

# 计算趋势和季节性
from statsmodels.tsa.seasonal import seasonal_decompose
decomposition = seasonal_decompose(ts_data, model='additive', period=30)

# 计算统计指标
stats = {
'mean': float(ts_data.mean()),
'std': float(ts_data.std()),
'min': float(ts_data.min()),
'max': float(ts_data.max()),
'trend': decomposition.trend.dropna().tolist(),
'seasonal': decomposition.seasonal.dropna().tolist(),
'residual': decomposition.resid.dropna().tolist(),
'dates': ts_data.index.strftime('%Y-%m-%d').tolist(),
'values': ts_data.tolist()
}

return stats

def correlation_analysis(self, df: pd.DataFrame) -> Dict[str, Any]:
"""相关性分析"""
numeric_df = df.select_dtypes(include=[np.number])
correlation_matrix = numeric_df.corr()

# 转换为前端可用的格式
correlation_data = []
columns = correlation_matrix.columns.tolist()

for i, row_name in enumerate(columns):
for j, col_name in enumerate(columns):
correlation_data.append({
'x': row_name,
'y': col_name,
'value': float(correlation_matrix.iloc[i, j])
})

return {
'data': correlation_data,
'columns': columns
}

def statistical_summary(self, df: pd.DataFrame) -> Dict[str, Any]:
"""统计摘要"""
summary = {}

# 数值列统计
numeric_columns = df.select_dtypes(include=[np.number]).columns
for col in numeric_columns:
summary[col] = {
'type': 'numeric',
'count': int(df[col].count()),
'mean': float(df[col].mean()),
'std': float(df[col].std()),
'min': float(df[col].min()),
'max': float(df[col].max()),
'q25': float(df[col].quantile(0.25)),
'q50': float(df[col].quantile(0.50)),
'q75': float(df[col].quantile(0.75))
}

# 分类列统计
categorical_columns = df.select_dtypes(include=['object']).columns
for col in categorical_columns:
value_counts = df[col].value_counts()
summary[col] = {
'type': 'categorical',
'count': int(df[col].count()),
'unique': int(df[col].nunique()),
'top_values': value_counts.head(10).to_dict()
}

return summary

# 使用示例
if __name__ == "__main__":
processor = DataProcessor()

# 生成示例数据
dates = pd.date_range('2024-01-01', periods=365, freq='D')
sample_data = pd.DataFrame({
'date': dates,
'sales': np.random.normal(1000, 200, 365) + np.sin(np.arange(365) * 2 * np.pi / 30) * 100,
'category': np.random.choice(['A', 'B', 'C'], 365),
'region': np.random.choice(['North', 'South', 'East', 'West'], 365),
'temperature': np.random.normal(20, 10, 365)
})

# 数据处理示例
cleaned_data = processor.clean_data(sample_data)
ts_analysis = processor.time_series_analysis(cleaned_data.copy(), 'date', 'sales')
correlation = processor.correlation_analysis(cleaned_data)
summary = processor.statistical_summary(cleaned_data)

print("数据处理完成!")

3. Flask API服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
# backend/app.py
from flask import Flask, jsonify, request
from flask_cors import CORS
import pandas as pd
import json
from data_processor import DataProcessor
from chart_generator import ChartGenerator
import logging

app = Flask(__name__)
CORS(app)

# 配置日志
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

# 初始化处理器
data_processor = DataProcessor()
chart_generator = ChartGenerator()

@app.route('/api/data/upload', methods=['POST'])
def upload_data():
"""上传数据文件"""
try:
if 'file' not in request.files:
return jsonify({'error': 'No file provided'}), 400

file = request.files['file']
if file.filename == '':
return jsonify({'error': 'No file selected'}), 400

# 保存文件
filename = f"data/raw/{file.filename}"
file.save(filename)

# 加载并预览数据
df = data_processor.load_data(filename)
preview = df.head(10).to_dict('records')

return jsonify({
'message': 'File uploaded successfully',
'filename': filename,
'shape': df.shape,
'columns': df.columns.tolist(),
'preview': preview
})

except Exception as e:
logger.error(f"Error uploading file: {e}")
return jsonify({'error': str(e)}), 500

@app.route('/api/data/process', methods=['POST'])
def process_data():
"""处理数据"""
try:
config = request.json
filename = config.get('filename')
operations = config.get('operations', [])

# 加载数据
df = data_processor.load_data(filename)

# 应用处理操作
for operation in operations:
if operation['type'] == 'clean':
df = data_processor.clean_data(df)
elif operation['type'] == 'aggregate':
df = data_processor.aggregate_data(
df,
operation['group_by'],
operation['agg_config']
)

# 保存处理后的数据
processed_filename = f"data/processed/processed_{filename.split('/')[-1]}"
df.to_csv(processed_filename, index=False)

return jsonify({
'message': 'Data processed successfully',
'processed_filename': processed_filename,
'shape': df.shape,
'preview': df.head(10).to_dict('records')
})

except Exception as e:
logger.error(f"Error processing data: {e}")
return jsonify({'error': str(e)}), 500

@app.route('/api/analysis/summary', methods=['POST'])
def get_summary():
"""获取数据摘要"""
try:
config = request.json
filename = config.get('filename')

df = data_processor.load_data(filename)
summary = data_processor.statistical_summary(df)

return jsonify(summary)

except Exception as e:
logger.error(f"Error getting summary: {e}")
return jsonify({'error': str(e)}), 500

@app.route('/api/analysis/correlation', methods=['POST'])
def get_correlation():
"""获取相关性分析"""
try:
config = request.json
filename = config.get('filename')

df = data_processor.load_data(filename)
correlation = data_processor.correlation_analysis(df)

return jsonify(correlation)

except Exception as e:
logger.error(f"Error getting correlation: {e}")
return jsonify({'error': str(e)}), 500

@app.route('/api/analysis/timeseries', methods=['POST'])
def get_timeseries():
"""获取时间序列分析"""
try:
config = request.json
filename = config.get('filename')
date_col = config.get('date_column')
value_col = config.get('value_column')
freq = config.get('frequency', 'D')

df = data_processor.load_data(filename)
ts_analysis = data_processor.time_series_analysis(df, date_col, value_col, freq)

return jsonify(ts_analysis)

except Exception as e:
logger.error(f"Error getting time series analysis: {e}")
return jsonify({'error': str(e)}), 500

@app.route('/api/charts/generate', methods=['POST'])
def generate_chart():
"""生成图表配置"""
try:
config = request.json
chart_type = config.get('chart_type')
filename = config.get('filename')
chart_config = config.get('chart_config', {})

df = data_processor.load_data(filename)
chart_data = chart_generator.generate_chart_data(df, chart_type, chart_config)

return jsonify(chart_data)

except Exception as e:
logger.error(f"Error generating chart: {e}")
return jsonify({'error': str(e)}), 500

@app.route('/api/dashboard/config', methods=['GET', 'POST'])
def dashboard_config():
"""仪表板配置管理"""
if request.method == 'GET':
# 获取仪表板配置
try:
with open('config/dashboard.json', 'r') as f:
config = json.load(f)
return jsonify(config)
except FileNotFoundError:
return jsonify({'widgets': []})

elif request.method == 'POST':
# 保存仪表板配置
try:
config = request.json
with open('config/dashboard.json', 'w') as f:
json.dump(config, f, indent=2)
return jsonify({'message': 'Dashboard config saved successfully'})
except Exception as e:
logger.error(f"Error saving dashboard config: {e}")
return jsonify({'error': str(e)}), 500

if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0', port=5000)

📈 图表生成器

1. Python图表生成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
# backend/chart_generator.py
import pandas as pd
import numpy as np
from typing import Dict, List, Any
import plotly.graph_objects as go
import plotly.express as px
from plotly.subplots import make_subplots
import json

class ChartGenerator:
"""图表生成器"""

def __init__(self):
self.chart_types = {
'line': self._generate_line_chart,
'bar': self._generate_bar_chart,
'scatter': self._generate_scatter_chart,
'pie': self._generate_pie_chart,
'heatmap': self._generate_heatmap,
'histogram': self._generate_histogram,
'box': self._generate_box_chart,
'area': self._generate_area_chart,
'treemap': self._generate_treemap,
'sunburst': self._generate_sunburst
}

def generate_chart_data(self, df: pd.DataFrame, chart_type: str,
config: Dict[str, Any]) -> Dict[str, Any]:
"""生成图表数据"""
if chart_type not in self.chart_types:
raise ValueError(f"Unsupported chart type: {chart_type}")

return self.chart_types[chart_type](df, config)

def _generate_line_chart(self, df: pd.DataFrame, config: Dict[str, Any]) -> Dict[str, Any]:
"""生成折线图"""
x_col = config.get('x_column')
y_cols = config.get('y_columns', [])

if not x_col or not y_cols:
raise ValueError("x_column and y_columns are required for line chart")

# 准备数据
chart_data = {
'type': 'line',
'data': {
'labels': df[x_col].tolist(),
'datasets': []
},
'options': {
'responsive': True,
'plugins': {
'title': {
'display': True,
'text': config.get('title', 'Line Chart')
},
'legend': {
'display': len(y_cols) > 1
}
},
'scales': {
'x': {
'display': True,
'title': {
'display': True,
'text': config.get('x_label', x_col)
}
},
'y': {
'display': True,
'title': {
'display': True,
'text': config.get('y_label', 'Value')
}
}
}
}
}

# 颜色配置
colors = ['#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF', '#FF9F40']

for i, y_col in enumerate(y_cols):
dataset = {
'label': y_col,
'data': df[y_col].tolist(),
'borderColor': colors[i % len(colors)],
'backgroundColor': colors[i % len(colors)] + '20',
'fill': config.get('fill', False),
'tension': config.get('tension', 0.1)
}
chart_data['data']['datasets'].append(dataset)

return chart_data

def _generate_bar_chart(self, df: pd.DataFrame, config: Dict[str, Any]) -> Dict[str, Any]:
"""生成柱状图"""
x_col = config.get('x_column')
y_col = config.get('y_column')

if not x_col or not y_col:
raise ValueError("x_column and y_column are required for bar chart")

# 数据聚合(如果需要)
if config.get('aggregate', False):
agg_func = config.get('agg_function', 'sum')
df_agg = df.groupby(x_col)[y_col].agg(agg_func).reset_index()
else:
df_agg = df[[x_col, y_col]].copy()

chart_data = {
'type': 'bar',
'data': {
'labels': df_agg[x_col].tolist(),
'datasets': [{
'label': config.get('label', y_col),
'data': df_agg[y_col].tolist(),
'backgroundColor': config.get('color', '#36A2EB'),
'borderColor': config.get('border_color', '#36A2EB'),
'borderWidth': 1
}]
},
'options': {
'responsive': True,
'plugins': {
'title': {
'display': True,
'text': config.get('title', 'Bar Chart')
}
},
'scales': {
'x': {
'title': {
'display': True,
'text': config.get('x_label', x_col)
}
},
'y': {
'title': {
'display': True,
'text': config.get('y_label', y_col)
}
}
}
}
}

return chart_data

def _generate_scatter_chart(self, df: pd.DataFrame, config: Dict[str, Any]) -> Dict[str, Any]:
"""生成散点图"""
x_col = config.get('x_column')
y_col = config.get('y_column')
size_col = config.get('size_column')
color_col = config.get('color_column')

if not x_col or not y_col:
raise ValueError("x_column and y_column are required for scatter chart")

# 准备数据点
data_points = []
for _, row in df.iterrows():
point = {
'x': row[x_col],
'y': row[y_col]
}

if size_col:
point['r'] = row[size_col] / df[size_col].max() * 20 # 标准化大小

data_points.append(point)

chart_data = {
'type': 'scatter',
'data': {
'datasets': [{
'label': config.get('label', 'Data Points'),
'data': data_points,
'backgroundColor': config.get('color', '#FF6384'),
'borderColor': config.get('border_color', '#FF6384')
}]
},
'options': {
'responsive': True,
'plugins': {
'title': {
'display': True,
'text': config.get('title', 'Scatter Plot')
}
},
'scales': {
'x': {
'type': 'linear',
'position': 'bottom',
'title': {
'display': True,
'text': config.get('x_label', x_col)
}
},
'y': {
'title': {
'display': True,
'text': config.get('y_label', y_col)
}
}
}
}
}

return chart_data

def _generate_pie_chart(self, df: pd.DataFrame, config: Dict[str, Any]) -> Dict[str, Any]:
"""生成饼图"""
label_col = config.get('label_column')
value_col = config.get('value_column')

if not label_col or not value_col:
raise ValueError("label_column and value_column are required for pie chart")

# 数据聚合
df_agg = df.groupby(label_col)[value_col].sum().reset_index()

# 颜色生成
colors = [
'#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF',
'#FF9F40', '#FF6384', '#C9CBCF', '#4BC0C0', '#FF6384'
]

chart_data = {
'type': 'pie',
'data': {
'labels': df_agg[label_col].tolist(),
'datasets': [{
'data': df_agg[value_col].tolist(),
'backgroundColor': colors[:len(df_agg)],
'borderWidth': 2,
'borderColor': '#fff'
}]
},
'options': {
'responsive': True,
'plugins': {
'title': {
'display': True,
'text': config.get('title', 'Pie Chart')
},
'legend': {
'position': config.get('legend_position', 'right')
}
}
}
}

return chart_data

def _generate_heatmap(self, df: pd.DataFrame, config: Dict[str, Any]) -> Dict[str, Any]:
"""生成热力图"""
x_col = config.get('x_column')
y_col = config.get('y_column')
value_col = config.get('value_column')

if not all([x_col, y_col, value_col]):
raise ValueError("x_column, y_column, and value_column are required for heatmap")

# 创建数据透视表
pivot_table = df.pivot_table(
values=value_col,
index=y_col,
columns=x_col,
aggfunc='mean',
fill_value=0
)

# 准备热力图数据
heatmap_data = []
for i, y_val in enumerate(pivot_table.index):
for j, x_val in enumerate(pivot_table.columns):
heatmap_data.append({
'x': j,
'y': i,
'v': pivot_table.iloc[i, j]
})

chart_data = {
'type': 'heatmap',
'data': {
'datasets': [{
'label': config.get('label', 'Heatmap'),
'data': heatmap_data,
'backgroundColor': function(ctx) {
# 这里需要在前端实现颜色映射
return 'rgba(255, 99, 132, 0.2)'
}
}]
},
'options': {
'responsive': True,
'plugins': {
'title': {
'display': True,
'text': config.get('title', 'Heatmap')
}
},
'scales': {
'x': {
'type': 'linear',
'position': 'bottom',
'min': 0,
'max': len(pivot_table.columns) - 1,
'ticks': {
'callback': function(value, index, values) {
return pivot_table.columns[value] if value < len(pivot_table.columns) else ''
}
}
},
'y': {
'type': 'linear',
'min': 0,
'max': len(pivot_table.index) - 1,
'ticks': {
'callback': function(value, index, values) {
return pivot_table.index[value] if value < len(pivot_table.index) else ''
}
}
}
}
},
'pivot_data': {
'x_labels': pivot_table.columns.tolist(),
'y_labels': pivot_table.index.tolist(),
'values': pivot_table.values.tolist()
}
}

return chart_data

def _generate_histogram(self, df: pd.DataFrame, config: Dict[str, Any]) -> Dict[str, Any]:
"""生成直方图"""
column = config.get('column')
bins = config.get('bins', 20)

if not column:
raise ValueError("column is required for histogram")

# 计算直方图数据
hist, bin_edges = np.histogram(df[column].dropna(), bins=bins)

# 准备标签(区间中点)
labels = [(bin_edges[i] + bin_edges[i+1]) / 2 for i in range(len(bin_edges)-1)]

chart_data = {
'type': 'bar',
'data': {
'labels': [f'{bin_edges[i]:.2f}-{bin_edges[i+1]:.2f}' for i in range(len(bin_edges)-1)],
'datasets': [{
'label': config.get('label', f'Frequency of {column}'),
'data': hist.tolist(),
'backgroundColor': config.get('color', '#36A2EB'),
'borderColor': config.get('border_color', '#36A2EB'),
'borderWidth': 1
}]
},
'options': {
'responsive': True,
'plugins': {
'title': {
'display': True,
'text': config.get('title', f'Histogram of {column}')
}
},
'scales': {
'x': {
'title': {
'display': True,
'text': config.get('x_label', column)
}
},
'y': {
'title': {
'display': True,
'text': 'Frequency'
}
}
}
}
}

return chart_data

def generate_dashboard_layout(self, widgets: List[Dict[str, Any]]) -> Dict[str, Any]:
"""生成仪表板布局"""
layout = {
'widgets': [],
'layout': {
'rows': [],
'columns': 12 # 使用12列网格系统
}
}

current_row = []
current_width = 0

for widget in widgets:
widget_width = widget.get('width', 6) # 默认占6列

if current_width + widget_width > 12:
# 开始新行
if current_row:
layout['layout']['rows'].append(current_row)
current_row = [widget]
current_width = widget_width
else:
current_row.append(widget)
current_width += widget_width

# 添加最后一行
if current_row:
layout['layout']['rows'].append(current_row)

layout['widgets'] = widgets

return layout

🎨 前端可视化组件

1. Vue图表组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
<!-- frontend/src/components/ChartComponent.vue -->
<template>
<div class="chart-component">
<div class="chart-header" v-if="showHeader">
<h3 class="chart-title">{{ title }}</h3>
<div class="chart-controls">
<button @click="refreshChart" class="btn-refresh">
<i class="icon-refresh"></i>
</button>
<button @click="exportChart" class="btn-export">
<i class="icon-download"></i>
</button>
<button @click="toggleFullscreen" class="btn-fullscreen">
<i class="icon-expand"></i>
</button>
</div>
</div>

<div class="chart-container" :class="{ 'fullscreen': isFullscreen }">
<canvas
ref="chartCanvas"
:width="chartWidth"
:height="chartHeight"
@click="handleChartClick"
></canvas>

<div v-if="loading" class="chart-loading">
<div class="spinner"></div>
<p>Loading chart data...</p>
</div>

<div v-if="error" class="chart-error">
<p>{{ error }}</p>
<button @click="refreshChart" class="btn-retry">Retry</button>
</div>
</div>

<div class="chart-footer" v-if="showFooter">
<div class="chart-stats">
<span>Data points: {{ dataPointCount }}</span>
<span>Last updated: {{ lastUpdated }}</span>
</div>
</div>
</div>
</template>

<script setup>
import { ref, onMounted, onUnmounted, watch, computed, nextTick } from 'vue'
import Chart from 'chart.js/auto'
import { useChartData } from '@/composables/useChartData'
import { useChartExport } from '@/composables/useChartExport'

const props = defineProps({
chartType: {
type: String,
required: true
},
dataSource: {
type: String,
required: true
},
chartConfig: {
type: Object,
default: () => ({})
},
title: {
type: String,
default: 'Chart'
},
showHeader: {
type: Boolean,
default: true
},
showFooter: {
type: Boolean,
default: true
},
autoRefresh: {
type: Boolean,
default: false
},
refreshInterval: {
type: Number,
default: 30000 // 30秒
}
})

const emit = defineEmits(['chart-click', 'data-updated', 'error'])

// 响应式数据
const chartCanvas = ref(null)
const chartInstance = ref(null)
const loading = ref(false)
const error = ref(null)
const isFullscreen = ref(false)
const lastUpdated = ref(new Date().toLocaleString())

// 组合式函数
const {
chartData,
fetchChartData,
loading: dataLoading,
error: dataError
} = useChartData()

const { exportChart: exportChartData } = useChartExport()

// 计算属性
const chartWidth = computed(() => isFullscreen.value ? window.innerWidth : 800)
const chartHeight = computed(() => isFullscreen.value ? window.innerHeight : 400)

const dataPointCount = computed(() => {
if (!chartData.value || !chartData.value.data) return 0
return chartData.value.data.datasets?.reduce((total, dataset) => {
return total + (dataset.data?.length || 0)
}, 0) || 0
})

// 方法
const initChart = async () => {
if (!chartCanvas.value) return

try {
loading.value = true
error.value = null

// 获取图表数据
await fetchChartData(props.dataSource, props.chartType, props.chartConfig)

if (dataError.value) {
error.value = dataError.value
return
}

// 销毁现有图表
if (chartInstance.value) {
chartInstance.value.destroy()
}

// 创建新图表
const ctx = chartCanvas.value.getContext('2d')
chartInstance.value = new Chart(ctx, {
type: chartData.value.type,
data: chartData.value.data,
options: {
...chartData.value.options,
responsive: true,
maintainAspectRatio: false,
onClick: handleChartClick,
plugins: {
...chartData.value.options?.plugins,
tooltip: {
callbacks: {
title: (context) => {
return context[0].label
},
label: (context) => {
return `${context.dataset.label}: ${context.parsed.y}`
}
}
}
}
}
})

lastUpdated.value = new Date().toLocaleString()
emit('data-updated', chartData.value)

} catch (err) {
error.value = err.message
emit('error', err)
} finally {
loading.value = false
}
}

const refreshChart = async () => {
await initChart()
}

const exportChart = () => {
if (chartInstance.value) {
exportChartData(chartInstance.value, props.title)
}
}

const toggleFullscreen = () => {
isFullscreen.value = !isFullscreen.value
nextTick(() => {
if (chartInstance.value) {
chartInstance.value.resize()
}
})
}

const handleChartClick = (event, elements) => {
if (elements.length > 0) {
const element = elements[0]
const datasetIndex = element.datasetIndex
const index = element.index
const dataset = chartData.value.data.datasets[datasetIndex]
const value = dataset.data[index]
const label = chartData.value.data.labels[index]

emit('chart-click', {
datasetIndex,
index,
value,
label,
dataset: dataset.label
})
}
}

// 自动刷新
let refreshTimer = null
const startAutoRefresh = () => {
if (props.autoRefresh && props.refreshInterval > 0) {
refreshTimer = setInterval(refreshChart, props.refreshInterval)
}
}

const stopAutoRefresh = () => {
if (refreshTimer) {
clearInterval(refreshTimer)
refreshTimer = null
}
}

// 监听器
watch(() => props.chartConfig, () => {
initChart()
}, { deep: true })

watch(() => props.autoRefresh, (newValue) => {
if (newValue) {
startAutoRefresh()
} else {
stopAutoRefresh()
}
})

// 生命周期
onMounted(() => {
initChart()
startAutoRefresh()
})

onUnmounted(() => {
stopAutoRefresh()
if (chartInstance.value) {
chartInstance.value.destroy()
}
})
</script>

<style scoped>
.chart-component {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
}

.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #eee;
background: #f8f9fa;
}

.chart-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #333;
}

.chart-controls {
display: flex;
gap: 8px;
}

.chart-controls button {
padding: 8px;
border: none;
border-radius: 4px;
background: #fff;
cursor: pointer;
transition: background-color 0.2s;
}

.chart-controls button:hover {
background: #e9ecef;
}

.chart-container {
position: relative;
padding: 20px;
min-height: 400px;
}

.chart-container.fullscreen {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 9999;
background: white;
padding: 40px;
}

.chart-loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
}

.spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #007bff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 16px;
}

@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}

.chart-error {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
color: #dc3545;
}

.btn-retry {
margin-top: 16px;
padding: 8px 16px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}

.chart-footer {
padding: 12px 20px;
border-top: 1px solid #eee;
background: #f8f9fa;
font-size: 12px;
color: #666;
}

.chart-stats {
display: flex;
justify-content: space-between;
}
</style>

2. 仪表板组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
<!-- frontend/src/components/Dashboard.vue -->
<template>
<div class="dashboard">
<div class="dashboard-header">
<h1 class="dashboard-title">{{ title }}</h1>
<div class="dashboard-controls">
<button @click="addWidget" class="btn-add-widget">
<i class="icon-plus"></i> Add Widget
</button>
<button @click="saveLayout" class="btn-save">
<i class="icon-save"></i> Save Layout
</button>
<button @click="toggleEditMode" class="btn-edit">
<i class="icon-edit"></i> {{ editMode ? 'Exit Edit' : 'Edit' }}
</button>
</div>
</div>

<div class="dashboard-content">
<div class="dashboard-grid" :class="{ 'edit-mode': editMode }">
<div
v-for="widget in widgets"
:key="widget.id"
class="widget-container"
:style="getWidgetStyle(widget)"
@click="selectWidget(widget)"
:class="{
'selected': selectedWidget?.id === widget.id,
'dragging': draggingWidget?.id === widget.id
}"
>
<div class="widget-header" v-if="editMode">
<div class="widget-controls">
<button @click="editWidget(widget)" class="btn-widget-edit">
<i class="icon-settings"></i>
</button>
<button @click="removeWidget(widget.id)" class="btn-widget-remove">
<i class="icon-trash"></i>
</button>
</div>
<div class="widget-drag-handle" @mousedown="startDrag(widget, $event)">
<i class="icon-drag"></i>
</div>
</div>

<div class="widget-content">
<ChartComponent
v-if="widget.type === 'chart'"
:chart-type="widget.config.chartType"
:data-source="widget.config.dataSource"
:chart-config="widget.config.chartConfig"
:title="widget.title"
:auto-refresh="widget.config.autoRefresh"
:refresh-interval="widget.config.refreshInterval"
@chart-click="handleWidgetInteraction(widget, $event)"
/>

<MetricWidget
v-else-if="widget.type === 'metric'"
:value="widget.config.value"
:label="widget.config.label"
:format="widget.config.format"
:trend="widget.config.trend"
:color="widget.config.color"
/>

<TableWidget
v-else-if="widget.type === 'table'"
:data-source="widget.config.dataSource"
:columns="widget.config.columns"
:pagination="widget.config.pagination"
:search="widget.config.search"
/>

<TextWidget
v-else-if="widget.type === 'text'"
:content="widget.config.content"
:style="widget.config.textStyle"
/>
</div>

<div class="widget-resize-handle" v-if="editMode" @mousedown="startResize(widget, $event)">
<i class="icon-resize"></i>
</div>
</div>
</div>
</div>

<!-- Widget Configuration Modal -->
<Modal v-model:visible="showWidgetModal" title="Widget Configuration">
<WidgetConfigForm
:widget="editingWidget"
@save="saveWidget"
@cancel="cancelEditWidget"
/>
</Modal>

<!-- Add Widget Modal -->
<Modal v-model:visible="showAddModal" title="Add New Widget">
<AddWidgetForm
@add="addNewWidget"
@cancel="cancelAddWidget"
/>
</Modal>
</div>
</template>

<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import ChartComponent from './ChartComponent.vue'
import MetricWidget from './MetricWidget.vue'
import TableWidget from './TableWidget.vue'
import TextWidget from './TextWidget.vue'
import Modal from './Modal.vue'
import WidgetConfigForm from './WidgetConfigForm.vue'
import AddWidgetForm from './AddWidgetForm.vue'
import { useDashboard } from '@/composables/useDashboard'
import { useDragAndDrop } from '@/composables/useDragAndDrop'

const props = defineProps({
dashboardId: {
type: String,
default: 'default'
},
title: {
type: String,
default: 'Dashboard'
}
})

// 响应式数据
const editMode = ref(false)
const selectedWidget = ref(null)
const showWidgetModal = ref(false)
const showAddModal = ref(false)
const editingWidget = ref(null)

// 组合式函数
const {
widgets,
loadDashboard,
saveDashboard,
addWidget: addWidgetToStore,
removeWidget: removeWidgetFromStore,
updateWidget
} = useDashboard()

const {
draggingWidget,
startDrag,
startResize
} = useDragAndDrop()

// 计算属性
const gridColumns = computed(() => 12) // 12列网格系统

// 方法
const getWidgetStyle = (widget) => {
return {
gridColumn: `${widget.position.x + 1} / span ${widget.size.width}`,
gridRow: `${widget.position.y + 1} / span ${widget.size.height}`,
minHeight: `${widget.size.height * 100}px`
}
}

const selectWidget = (widget) => {
if (editMode.value) {
selectedWidget.value = widget
}
}

const addWidget = () => {
showAddModal.value = true
}

const addNewWidget = (widgetConfig) => {
const newWidget = {
id: Date.now().toString(),
type: widgetConfig.type,
title: widgetConfig.title,
config: widgetConfig.config,
position: findAvailablePosition(),
size: widgetConfig.size || { width: 6, height: 4 }
}

addWidgetToStore(newWidget)
showAddModal.value = false
}

const findAvailablePosition = () => {
// 简单的位置查找算法
const occupiedPositions = new Set()

widgets.value.forEach(widget => {
for (let x = widget.position.x; x < widget.position.x + widget.size.width; x++) {
for (let y = widget.position.y; y < widget.position.y + widget.size.height; y++) {
occupiedPositions.add(`${x},${y}`)
}
}
})

// 查找第一个可用位置
for (let y = 0; y < 100; y++) {
for (let x = 0; x <= gridColumns.value - 6; x++) {
let canPlace = true
for (let dx = 0; dx < 6; dx++) {
for (let dy = 0; dy < 4; dy++) {
if (occupiedPositions.has(`${x + dx},${y + dy}`)) {
canPlace = false
break
}
}
if (!canPlace) break
}
if (canPlace) {
return { x, y }
}
}
}

return { x: 0, y: 0 }
}

const editWidget = (widget) => {
editingWidget.value = { ...widget }
showWidgetModal.value = true
}

const saveWidget = (updatedWidget) => {
updateWidget(updatedWidget.id, updatedWidget)
showWidgetModal.value = false
editingWidget.value = null
}

const cancelEditWidget = () => {
showWidgetModal.value = false
editingWidget.value = null
}

const cancelAddWidget = () => {
showAddModal.value = false
}

const removeWidget = (widgetId) => {
if (confirm('Are you sure you want to remove this widget?')) {
removeWidgetFromStore(widgetId)
if (selectedWidget.value?.id === widgetId) {
selectedWidget.value = null
}
}
}

const saveLayout = async () => {
try {
await saveDashboard(props.dashboardId)
// 显示成功消息
console.log('Dashboard layout saved successfully')
} catch (error) {
console.error('Error saving dashboard layout:', error)
}
}

const toggleEditMode = () => {
editMode.value = !editMode.value
if (!editMode.value) {
selectedWidget.value = null
}
}

const handleWidgetInteraction = (widget, event) => {
console.log('Widget interaction:', widget.title, event)
// 处理小部件交互事件
}

// 生命周期
onMounted(async () => {
await loadDashboard(props.dashboardId)
})
</script>

<style scoped>
.dashboard {
min-height: 100vh;
background: #f5f5f5;
}

.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 30px;
background: white;
border-bottom: 1px solid #e0e0e0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

.dashboard-title {
margin: 0;
font-size: 28px;
font-weight: 600;
color: #333;
}

.dashboard-controls {
display: flex;
gap: 12px;
}

.dashboard-controls button {
padding: 10px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.2s;
}

.btn-add-widget {
background: #007bff;
color: white;
}

.btn-add-widget:hover {
background: #0056b3;
}

.btn-save {
background: #28a745;
color: white;
}

.btn-save:hover {
background: #1e7e34;
}

.btn-edit {
background: #6c757d;
color: white;
}

.btn-edit:hover {
background: #545b62;
}

.dashboard-content {
padding: 30px;
}

.dashboard-grid {
display: grid;
grid-template-columns: repeat(12, 1fr);
grid-auto-rows: 100px;
gap: 20px;
}

.dashboard-grid.edit-mode {
background-image:
linear-gradient(to right, #e0e0e0 1px, transparent 1px),
linear-gradient(to bottom, #e0e0e0 1px, transparent 1px);
background-size: calc(100% / 12) 100px;
}

.widget-container {
position: relative;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: all 0.2s;
}

.widget-container:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}

.widget-container.selected {
border: 2px solid #007bff;
box-shadow: 0 0 0 4px rgba(0, 123, 255, 0.25);
}

.widget-container.dragging {
opacity: 0.8;
transform: rotate(2deg);
z-index: 1000;
}

.widget-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: #f8f9fa;
border-bottom: 1px solid #e0e0e0;
}

.widget-controls {
display: flex;
gap: 4px;
}

.widget-controls button {
padding: 4px 8px;
border: none;
border-radius: 4px;
background: transparent;
cursor: pointer;
font-size: 12px;
transition: background-color 0.2s;
}

.widget-controls button:hover {
background: #e9ecef;
}

.btn-widget-remove {
color: #dc3545;
}

.widget-drag-handle {
cursor: move;
padding: 4px;
color: #6c757d;
}

.widget-content {
height: calc(100% - 40px);
overflow: hidden;
}

.widget-resize-handle {
position: absolute;
bottom: 0;
right: 0;
width: 20px;
height: 20px;
cursor: se-resize;
background: #007bff;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
}

.widget-resize-handle:hover {
background: #0056b3;
}
</style>

📊 高级可视化技术

1. D3.js集成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
// frontend/src/utils/d3Charts.js
import * as d3 from 'd3'

export class D3ChartManager {
constructor() {
this.charts = new Map()
}

createForceDirectedGraph(containerId, data, options = {}) {
const container = d3.select(`#${containerId}`)
const width = options.width || 800
const height = options.height || 600

// 清除现有内容
container.selectAll('*').remove()

// 创建SVG
const svg = container
.append('svg')
.attr('width', width)
.attr('height', height)
.attr('viewBox', [0, 0, width, height])

// 创建力导向图
const simulation = d3.forceSimulation(data.nodes)
.force('link', d3.forceLink(data.links).id(d => d.id).distance(100))
.force('charge', d3.forceManyBody().strength(-300))
.force('center', d3.forceCenter(width / 2, height / 2))

// 创建连线
const link = svg.append('g')
.attr('class', 'links')
.selectAll('line')
.data(data.links)
.enter().append('line')
.attr('stroke', '#999')
.attr('stroke-opacity', 0.6)
.attr('stroke-width', d => Math.sqrt(d.value))

// 创建节点
const node = svg.append('g')
.attr('class', 'nodes')
.selectAll('circle')
.data(data.nodes)
.enter().append('circle')
.attr('r', d => d.size || 5)
.attr('fill', d => d.color || '#69b3a2')
.call(d3.drag()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended))

// 添加标签
const label = svg.append('g')
.attr('class', 'labels')
.selectAll('text')
.data(data.nodes)
.enter().append('text')
.text(d => d.name)
.attr('font-size', 12)
.attr('dx', 15)
.attr('dy', 4)

// 添加工具提示
const tooltip = d3.select('body').append('div')
.attr('class', 'tooltip')
.style('opacity', 0)
.style('position', 'absolute')
.style('background', 'rgba(0, 0, 0, 0.8)')
.style('color', 'white')
.style('padding', '8px')
.style('border-radius', '4px')
.style('font-size', '12px')

node
.on('mouseover', function(event, d) {
tooltip.transition().duration(200).style('opacity', .9)
tooltip.html(`Name: ${d.name}<br/>Value: ${d.value || 'N/A'}`)
.style('left', (event.pageX + 10) + 'px')
.style('top', (event.pageY - 28) + 'px')
})
.on('mouseout', function(d) {
tooltip.transition().duration(500).style('opacity', 0)
})

// 更新位置
simulation.on('tick', () => {
link
.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => d.target.x)
.attr('y2', d => d.target.y)

node
.attr('cx', d => d.x)
.attr('cy', d => d.y)

label
.attr('x', d => d.x)
.attr('y', d => d.y)
})

// 拖拽函数
function dragstarted(event, d) {
if (!event.active) simulation.alphaTarget(0.3).restart()
d.fx = d.x
d.fy = d.y
}

function dragged(event, d) {
d.fx = event.x
d.fy = event.y
}

function dragended(event, d) {
if (!event.active) simulation.alphaTarget(0)
d.fx = null
d.fy = null
}

// 保存图表实例
this.charts.set(containerId, {
svg,
simulation,
cleanup: () => {
tooltip.remove()
simulation.stop()
}
})

return { svg, simulation }
}

createTreemap(containerId, data, options = {}) {
const container = d3.select(`#${containerId}`)
const width = options.width || 800
const height = options.height || 600

container.selectAll('*').remove()

const svg = container
.append('svg')
.attr('width', width)
.attr('height', height)

// 创建层次结构
const root = d3.hierarchy(data)
.sum(d => d.value)
.sort((a, b) => b.value - a.value)

// 创建treemap布局
const treemap = d3.treemap()
.size([width, height])
.padding(2)
.round(true)

treemap(root)

// 颜色比例尺
const color = d3.scaleOrdinal(d3.schemeCategory10)

// 创建矩形
const leaf = svg.selectAll('g')
.data(root.leaves())
.enter().append('g')
.attr('transform', d => `translate(${d.x0},${d.y0})`)

leaf.append('rect')
.attr('fill', d => color(d.parent.data.name))
.attr('fill-opacity', 0.8)
.attr('width', d => d.x1 - d.x0)
.attr('height', d => d.y1 - d.y0)
.attr('stroke', 'white')
.attr('stroke-width', 1)

// 添加文本标签
leaf.append('text')
.attr('x', 4)
.attr('y', 14)
.text(d => d.data.name)
.attr('font-size', '12px')
.attr('fill', 'white')
.attr('font-weight', 'bold')

leaf.append('text')
.attr('x', 4)
.attr('y', 28)
.text(d => d.data.value)
.attr('font-size', '10px')
.attr('fill', 'white')

return { svg, root }
}

createSankeyDiagram(containerId, data, options = {}) {
const container = d3.select(`#${containerId}`)
const width = options.width || 800
const height = options.height || 600

container.selectAll('*').remove()

const svg = container
.append('svg')
.attr('width', width)
.attr('height', height)

// 创建Sankey生成器
const sankey = d3.sankey()
.nodeWidth(15)
.nodePadding(10)
.extent([[1, 1], [width - 1, height - 6]])

const { nodes, links } = sankey(data)

// 创建连接线
svg.append('g')
.selectAll('path')
.data(links)
.enter().append('path')
.attr('d', d3.sankeyLinkHorizontal())
.attr('stroke', d => d.color || '#999')
.attr('stroke-width', d => Math.max(1, d.width))
.attr('fill', 'none')
.attr('stroke-opacity', 0.5)

// 创建节点
svg.append('g')
.selectAll('rect')
.data(nodes)
.enter().append('rect')
.attr('x', d => d.x0)
.attr('y', d => d.y0)
.attr('height', d => d.y1 - d.y0)
.attr('width', d => d.x1 - d.x0)
.attr('fill', d => d.color || '#69b3a2')
.attr('stroke', '#000')

// 添加节点标签
svg.append('g')
.selectAll('text')
.data(nodes)
.enter().append('text')
.attr('x', d => d.x0 < width / 2 ? d.x1 + 6 : d.x0 - 6)
.attr('y', d => (d.y1 + d.y0) / 2)
.attr('dy', '0.35em')
.attr('text-anchor', d => d.x0 < width / 2 ? 'start' : 'end')
.text(d => d.name)
.attr('font-size', '12px')

return { svg, nodes, links }
}

cleanup(containerId) {
const chart = this.charts.get(containerId)
if (chart && chart.cleanup) {
chart.cleanup()
this.charts.delete(containerId)
}
}
}

// 使用示例
export const createAdvancedCharts = () => {
const chartManager = new D3ChartManager()

// 力导向图数据
const networkData = {
nodes: [
{ id: 'A', name: 'Node A', size: 10, color: '#ff6b6b' },
{ id: 'B', name: 'Node B', size: 15, color: '#4ecdc4' },
{ id: 'C', name: 'Node C', size: 8, color: '#45b7d1' },
{ id: 'D', name: 'Node D', size: 12, color: '#96ceb4' }
],
links: [
{ source: 'A', target: 'B', value: 10 },
{ source: 'B', target: 'C', value: 15 },
{ source: 'C', target: 'D', value: 8 },
{ source: 'D', target: 'A', value: 12 }
]
}

// Treemap数据
const treemapData = {
name: 'root',
children: [
{
name: 'Category A',
children: [
{ name: 'Item 1', value: 100 },
{ name: 'Item 2', value: 80 },
{ name: 'Item 3', value: 60 }
]
},
{
name: 'Category B',
children: [
{ name: 'Item 4', value: 120 },
{ name: 'Item 5', value: 90 }
]
}
]
}

2. 实时数据可视化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
// frontend/src/utils/realTimeChart.js
export class RealTimeChart {
constructor(containerId, options = {}) {
this.containerId = containerId
this.options = {
maxDataPoints: 50,
updateInterval: 1000,
animationDuration: 300,
...options
}

this.data = []
this.chart = null
this.websocket = null
this.updateTimer = null

this.init()
}

init() {
// 初始化Chart.js图表
const ctx = document.getElementById(this.containerId).getContext('2d')

this.chart = new Chart(ctx, {
type: 'line',
data: {
labels: [],
datasets: [{
label: 'Real-time Data',
data: [],
borderColor: '#007bff',
backgroundColor: 'rgba(0, 123, 255, 0.1)',
fill: true,
tension: 0.4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: {
duration: this.options.animationDuration
},
scales: {
x: {
type: 'time',
time: {
displayFormats: {
second: 'HH:mm:ss'
}
},
title: {
display: true,
text: 'Time'
}
},
y: {
beginAtZero: true,
title: {
display: true,
text: 'Value'
}
}
},
plugins: {
legend: {
display: true
},
tooltip: {
mode: 'index',
intersect: false
}
}
}
})
}

connectWebSocket(url) {
this.websocket = new WebSocket(url)

this.websocket.onopen = () => {
console.log('WebSocket connected')
}

this.websocket.onmessage = (event) => {
const data = JSON.parse(event.data)
this.addDataPoint(data.value, data.timestamp)
}

this.websocket.onclose = () => {
console.log('WebSocket disconnected')
// 尝试重连
setTimeout(() => this.connectWebSocket(url), 5000)
}

this.websocket.onerror = (error) => {
console.error('WebSocket error:', error)
}
}

addDataPoint(value, timestamp = new Date()) {
const time = new Date(timestamp)

// 添加新数据点
this.data.push({ x: time, y: value })

// 限制数据点数量
if (this.data.length > this.options.maxDataPoints) {
this.data.shift()
}

// 更新图表
this.updateChart()
}

updateChart() {
this.chart.data.labels = this.data.map(d => d.x)
this.chart.data.datasets[0].data = this.data
this.chart.update('none') // 无动画更新以提高性能
}

startSimulation() {
this.updateTimer = setInterval(() => {
// 模拟数据
const value = Math.random() * 100 + Math.sin(Date.now() / 1000) * 20
this.addDataPoint(value)
}, this.options.updateInterval)
}

stopSimulation() {
if (this.updateTimer) {
clearInterval(this.updateTimer)
this.updateTimer = null
}
}

destroy() {
this.stopSimulation()
if (this.websocket) {
this.websocket.close()
}
if (this.chart) {
this.chart.destroy()
}
}
}

// WebSocket服务器示例(Node.js)
export const createWebSocketServer = () => {
const WebSocket = require('ws')
const wss = new WebSocket.Server({ port: 8080 })

wss.on('connection', (ws) => {
console.log('Client connected')

// 定期发送模拟数据
const interval = setInterval(() => {
const data = {
value: Math.random() * 100,
timestamp: new Date().toISOString()
}

ws.send(JSON.stringify(data))
}, 1000)

ws.on('close', () => {
console.log('Client disconnected')
clearInterval(interval)
})
})

return wss
}

🎯 性能优化策略

1. 大数据集处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
// frontend/src/utils/dataOptimization.js
export class DataOptimizer {
constructor() {
this.worker = null
this.initWorker()
}

initWorker() {
// 创建Web Worker处理大数据集
const workerCode = `
self.onmessage = function(e) {
const { type, data, options } = e.data

switch(type) {
case 'aggregate':
const result = aggregateData(data, options)
self.postMessage({ type: 'aggregate', result })
break
case 'filter':
const filtered = filterData(data, options)
self.postMessage({ type: 'filter', result: filtered })
break
case 'sample':
const sampled = sampleData(data, options)
self.postMessage({ type: 'sample', result: sampled })
break
}
}

function aggregateData(data, options) {
const { groupBy, aggregateBy, method } = options
const groups = {}

data.forEach(item => {
const key = item[groupBy]
if (!groups[key]) {
groups[key] = []
}
groups[key].push(item[aggregateBy])
})

return Object.keys(groups).map(key => ({
[groupBy]: key,
[aggregateBy]: calculateAggregate(groups[key], method)
}))
}

function calculateAggregate(values, method) {
switch(method) {
case 'sum':
return values.reduce((a, b) => a + b, 0)
case 'avg':
return values.reduce((a, b) => a + b, 0) / values.length
case 'max':
return Math.max(...values)
case 'min':
return Math.min(...values)
case 'count':
return values.length
default:
return values.reduce((a, b) => a + b, 0)
}
}

function filterData(data, options) {
const { filters } = options
return data.filter(item => {
return filters.every(filter => {
const { field, operator, value } = filter
const itemValue = item[field]

switch(operator) {
case 'eq': return itemValue === value
case 'ne': return itemValue !== value
case 'gt': return itemValue > value
case 'gte': return itemValue >= value
case 'lt': return itemValue < value
case 'lte': return itemValue <= value
case 'contains': return itemValue.toString().includes(value)
default: return true
}
})
})
}

function sampleData(data, options) {
const { sampleSize, method } = options

if (method === 'random') {
const shuffled = [...data].sort(() => 0.5 - Math.random())
return shuffled.slice(0, sampleSize)
} else if (method === 'systematic') {
const step = Math.floor(data.length / sampleSize)
return data.filter((_, index) => index % step === 0).slice(0, sampleSize)
}

return data.slice(0, sampleSize)
}
`

const blob = new Blob([workerCode], { type: 'application/javascript' })
this.worker = new Worker(URL.createObjectURL(blob))
}

async processLargeDataset(data, operation, options) {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Data processing timeout'))
}, 30000) // 30秒超时

this.worker.onmessage = (e) => {
clearTimeout(timeout)
if (e.data.type === operation) {
resolve(e.data.result)
}
}

this.worker.onerror = (error) => {
clearTimeout(timeout)
reject(error)
}

this.worker.postMessage({ type: operation, data, options })
})
}

// 虚拟滚动实现
createVirtualScroll(container, data, itemHeight, renderItem) {
const containerHeight = container.clientHeight
const visibleCount = Math.ceil(containerHeight / itemHeight) + 2
let scrollTop = 0
let startIndex = 0

const scrollContainer = document.createElement('div')
scrollContainer.style.height = `${data.length * itemHeight}px`
scrollContainer.style.position = 'relative'

const visibleContainer = document.createElement('div')
visibleContainer.style.position = 'absolute'
visibleContainer.style.top = '0'
visibleContainer.style.width = '100%'

scrollContainer.appendChild(visibleContainer)
container.appendChild(scrollContainer)

const updateVisibleItems = () => {
startIndex = Math.floor(scrollTop / itemHeight)
const endIndex = Math.min(startIndex + visibleCount, data.length)

visibleContainer.innerHTML = ''
visibleContainer.style.transform = `translateY(${startIndex * itemHeight}px)`

for (let i = startIndex; i < endIndex; i++) {
const item = renderItem(data[i], i)
item.style.height = `${itemHeight}px`
visibleContainer.appendChild(item)
}
}

container.addEventListener('scroll', (e) => {
scrollTop = e.target.scrollTop
requestAnimationFrame(updateVisibleItems)
})

updateVisibleItems()

return {
update: (newData) => {
data = newData
scrollContainer.style.height = `${data.length * itemHeight}px`
updateVisibleItems()
},
destroy: () => {
container.removeChild(scrollContainer)
}
}
}

// 数据分页
paginateData(data, pageSize = 1000) {
const pages = []
for (let i = 0; i < data.length; i += pageSize) {
pages.push(data.slice(i, i + pageSize))
}
return pages
}

// 数据压缩
compressData(data) {
// 移除重复值
const uniqueData = data.filter((item, index, self) =>
index === self.findIndex(t => JSON.stringify(t) === JSON.stringify(item))
)

// 数值精度优化
return uniqueData.map(item => {
const compressed = {}
for (const [key, value] of Object.entries(item)) {
if (typeof value === 'number') {
compressed[key] = Math.round(value * 100) / 100 // 保留2位小数
} else {
compressed[key] = value
}
}
return compressed
})
}

destroy() {
if (this.worker) {
this.worker.terminate()
}
}
}

2. 缓存策略

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
// frontend/src/utils/cacheManager.js
export class CacheManager {
constructor(options = {}) {
this.maxSize = options.maxSize || 100
this.ttl = options.ttl || 300000 // 5分钟
this.cache = new Map()
this.timers = new Map()
}

set(key, value, customTTL) {
// 清除现有定时器
if (this.timers.has(key)) {
clearTimeout(this.timers.get(key))
}

// 检查缓存大小
if (this.cache.size >= this.maxSize && !this.cache.has(key)) {
// 删除最旧的条目
const firstKey = this.cache.keys().next().value
this.delete(firstKey)
}

// 设置缓存
this.cache.set(key, {
value,
timestamp: Date.now()
})

// 设置过期定时器
const timeout = customTTL || this.ttl
const timer = setTimeout(() => {
this.delete(key)
}, timeout)

this.timers.set(key, timer)
}

get(key) {
const item = this.cache.get(key)
if (!item) return null

// 检查是否过期
if (Date.now() - item.timestamp > this.ttl) {
this.delete(key)
return null
}

return item.value
}

delete(key) {
this.cache.delete(key)
if (this.timers.has(key)) {
clearTimeout(this.timers.get(key))
this.timers.delete(key)
}
}

clear() {
this.cache.clear()
this.timers.forEach(timer => clearTimeout(timer))
this.timers.clear()
}

// 缓存装饰器
cached(ttl) {
return (target, propertyName, descriptor) => {
const originalMethod = descriptor.value

descriptor.value = function(...args) {
const key = `${propertyName}_${JSON.stringify(args)}`
const cached = this.cacheManager.get(key)

if (cached) {
return Promise.resolve(cached)
}

const result = originalMethod.apply(this, args)

if (result instanceof Promise) {
return result.then(data => {
this.cacheManager.set(key, data, ttl)
return data
})
} else {
this.cacheManager.set(key, result, ttl)
return result
}
}

return descriptor
}
}
}

// 使用示例
const cacheManager = new CacheManager({ maxSize: 50, ttl: 600000 })

class DataService {
constructor() {
this.cacheManager = cacheManager
}

@cacheManager.cached(300000) // 缓存5分钟
async fetchChartData(dataSource, chartType) {
const response = await fetch(`/api/charts/data?source=${dataSource}&type=${chartType}`)
return response.json()
}
}

🔧 最佳实践与总结

1. 开发最佳实践

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
## 数据可视化开发最佳实践

### 设计原则
1. **简洁明了**: 避免过度装饰,突出数据本身
2. **用户导向**: 根据目标用户设计合适的可视化方案
3. **响应式设计**: 确保在不同设备上都有良好的显示效果
4. **可访问性**: 考虑色盲用户,提供替代文本

### 性能优化
1. **数据预处理**: 在后端完成数据聚合和过滤
2. **懒加载**: 按需加载图表组件和数据
3. **虚拟化**: 对大数据集使用虚拟滚动
4. **缓存策略**: 合理使用缓存减少重复请求

### 代码组织
1. **组件化**: 将图表封装为可复用组件
2. **配置驱动**: 通过配置文件控制图表行为
3. **错误处理**: 完善的错误处理和用户反馈
4. **测试覆盖**: 编写单元测试和集成测试

### 用户体验
1. **加载状态**: 提供清晰的加载指示器
2. **交互反馈**: 及时响应用户操作
3. **数据更新**: 实时或定期更新数据
4. **导出功能**: 支持图表和数据导出

2. 常见问题解决

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 常见问题解决方案
export const troubleshooting = {
// 图表渲染问题
chartRenderingIssues: {
problem: "图表不显示或显示异常",
solutions: [
"检查容器元素是否存在且有正确的尺寸",
"确认数据格式符合图表库要求",
"检查CSS样式是否影响图表显示",
"验证图表配置选项是否正确"
]
},

// 性能问题
performanceIssues: {
problem: "大数据集导致页面卡顿",
solutions: [
"使用数据采样或分页",
"实现虚拟滚动",
"在Web Worker中处理数据",
"使用Canvas代替SVG渲染"
]
},

// 响应式问题
responsiveIssues: {
problem: "图表在移动设备上显示不正常",
solutions: [
"使用响应式图表配置",
"调整图表尺寸和字体大小",
"简化移动端图表元素",
"提供触摸友好的交互"
]
}
}

🎉 结语

数据可视化是一门融合技术与艺术的学科。通过本文的学习,你已经掌握了从基础图表到复杂仪表板的完整技术栈。记住,优秀的数据可视化不仅要技术过硬,更要能够准确传达数据背后的故事。

在实际项目中,要根据具体需求选择合适的技术方案,平衡功能需求与性能要求,始终以用户体验为中心。随着技术的不断发展,数据可视化领域也在持续演进,保持学习和实践是成为数据可视化专家的必经之路。

希望这份实战指南能够帮助你在数据可视化的道路上走得更远,创造出更加精彩的数据故事!