第八部分:后端集成
8.1 数据API设计
// 数据API控制器
@RestController
@RequestMapping("/api/data")
public class DataApiController {
@Autowired
private DataService dataService;
// 分页查询
@GetMapping("/{dataset}")
public PageResult getData(
@PathVariable String dataset,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "100") int size,
@RequestParam(required = false) String sortBy,
@RequestParam(required = false) String sortDir,
@RequestParam(required = false) String filter) {
return dataService.query(dataset, page, size, sortBy, sortDir, filter);
}
// 聚合查询
@GetMapping("/{dataset}/aggregate")
public AggregationResult aggregate(
@PathVariable String dataset,
@RequestParam String groupBy,
@RequestParam String metric,
@RequestParam String aggregation, // sum, avg, count, min, max
@RequestParam(required = false) String startDate,
@RequestParam(required = false) String endDate) {
return dataService.aggregate(dataset, groupBy, metric, aggregation, startDate, endDate);
}
// 导出CSV
@GetMapping("/{dataset}/export")
public void exportData(
@PathVariable String dataset,
HttpServletResponse response) throws IOException {
response.setContentType("text/csv");
response.setHeader("Content-Disposition", "attachment; filename=data.csv");
dataService.exportToCsv(dataset, response.getWriter());
}
}
第九部分:3D可视化
9.1 Three.js 3D可视化
Three.js是最流行的WebGL库,可以在浏览器中创建复杂的3D可视化效果。3D可视化适合展示三维空间数据、复杂网络关系、地理地形等。
<!DOCTYPE html>
<html>
<head>
<title>3D数据可视化 - Three.js</title>
<style>
body { margin: 0; overflow: hidden; }
#info {
position: absolute;
top: 20px;
left: 20px;
color: white;
background: rgba(0,0,0,0.7);
padding: 10px;
border-radius: 5px;
font-family: Arial;
z-index: 100;
pointer-events: none;
}
#controls {
position: absolute;
bottom: 20px;
left: 20px;
color: white;
background: rgba(0,0,0,0.7);
padding: 10px;
border-radius: 5px;
font-family: Arial;
z-index: 100;
}
button {
margin: 5px;
padding: 5px 10px;
cursor: pointer;
}
</style>
</head>
<body>
<div id="info">
<h2>3D数据可视化 - 销售数据分布</h2>
<p>鼠标拖拽旋转视角 | 右键平移 | 滚轮缩放</p>
</div>
<div id="controls">
<button id="toggle-grid">切换网格</button>
<button id="toggle-labels">切换标签</button>
<button id="reset-view">重置视角</button>
</div>
<!-- 引入Three.js核心库及扩展 -->
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.128.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.128.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { CSS2DRenderer, CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js';
// --- 初始化场景、相机、渲染器 ---
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x050b1a); // 深邃星空蓝黑背景
scene.fog = new THREE.FogExp2(0x050b1a, 0.0008); // 雾效增强景深
// 透视相机 (视角, 宽高比, 近平面, 远平面)
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 2000);
camera.position.set(15, 12, 18);
camera.lookAt(0, 0, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true; // 开启阴影映射
renderer.shadowMap.type = THREE.PCFSoftShadowMap; // 柔和阴影
renderer.setPixelRatio(window.devicePixelRatio);
document.body.appendChild(renderer.domElement);
// CSS2渲染文字标签
const labelRenderer = new CSS2DRenderer();
labelRenderer.setSize(window.innerWidth, window.innerHeight);
labelRenderer.domElement.style.position = 'absolute';
labelRenderer.domElement.style.top = '0px';
labelRenderer.domElement.style.left = '0px';
labelRenderer.domElement.style.pointerEvents = 'none';
document.body.appendChild(labelRenderer.domElement);
// --- 轨道控制 (支持交互) ---
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; // 惯性效果
controls.dampingFactor = 0.05;
controls.rotateSpeed = 1.0;
controls.zoomSpeed = 1.2;
controls.panSpeed = 0.8;
controls.enableZoom = true;
controls.enablePan = true;
controls.target.set(0, 0, 0);
// --- 辅助元素: 网格地面和坐标轴 (增强空间感) ---
// 网格辅助平面
const gridHelper = new THREE.GridHelper(30, 20, 0x88aaff, 0x335588);
gridHelper.position.y = -3;
gridHelper.material.transparent = true;
gridHelper.material.opacity = 0.6;
scene.add(gridHelper);
// 简单的地面反射光晕 (一个半透明平面)
const groundPlane = new THREE.Mesh(
new THREE.PlaneGeometry(25, 25),
new THREE.MeshStandardMaterial({ color: 0x112233, roughness: 0.5, metalness: 0.1, transparent: true, opacity: 0.3 })
);
groundPlane.rotation.x = -Math.PI / 2;
groundPlane.position.y = -3.1;
groundPlane.receiveShadow = true;
scene.add(groundPlane);
// 简易坐标轴 (红X, 绿Z)
const axesHelper = new THREE.AxesHelper(8);
axesHelper.material.transparent = true;
axesHelper.material.opacity = 0.25;
scene.add(axesHelper);
// --- 灯光系统 (营造立体感和氛围) ---
// 环境光
const ambientLight = new THREE.AmbientLight(0x404060);
scene.add(ambientLight);
// 主光源方向光
const dirLight = new THREE.DirectionalLight(0xffffff, 1);
dirLight.position.set(5, 12, 8);
dirLight.castShadow = true;
dirLight.receiveShadow = false;
dirLight.shadow.mapSize.width = 1024;
dirLight.shadow.mapSize.height = 1024;
scene.add(dirLight);
// 补光 - 背光暖色
const backLight = new THREE.PointLight(0xffaa66, 0.5);
backLight.position.set(-3, 5, -5);
scene.add(backLight);
// 冷色侧补光
const fillLight = new THREE.PointLight(0x6688ff, 0.4);
fillLight.position.set(4, 3, 4);
scene.add(fillLight);
// 动态底部光晕 (点光源跟随相机方向轻微变化,这里简单加一个半球光)
const hemiLight = new THREE.HemisphereLight(0x88aaff, 0x332222, 0.5);
scene.add(hemiLight);
// --- 核心: 生成模拟数据 (产品销售数据, 三维: X-区域, Y-销售额, Z-品类) ---
// 数据点结构: { x, y, z, value, category, region, name }
const categories = ['电子产品', '服装', '食品', '家居', '美妆'];
const regions = ['华东', '华南', '华北', '西部', '海外'];
// 颜色映射 (按品类)
const colorMap = {
'电子产品': 0x3b82f6, // 蓝色
'服装': 0xef4444, // 红色
'食品': 0x10b981, // 绿色
'家居': 0xf59e0b, // 橙色
'美妆': 0xec4899 // 粉色
};
// 存储柱状体Mesh和标签对象
const bars = [];
const labels = [];
// 生成随机但有一定规律的数据点 (X:区域索引, Z:品类索引, Y:销售额)
const dataPoints = [];
for (let i = 0; i < regions.length; i++) {
for (let j = 0; j < categories.length; j++) {
// 模拟销售额基数 + 随机波动 + 品类/区域特色
let baseSales = 5000 + Math.random() * 8000;
// 电子产品在华东更高
if (categories[j] === '电子产品' && regions[i] === '华东') baseSales *= 1.5;
// 服装在华南更高
if (categories[j] === '服装' && regions[i] === '华南') baseSales *= 1.4;
// 食品在西部更高
if (categories[j] === '食品' && regions[i] === '西部') baseSales *= 1.3;
const sales = Math.floor(baseSales);
const x = (i - (regions.length-1)/2) * 2.2; // X轴间隔2.2
const z = (j - (categories.length-1)/2) * 2.2; // Z轴间隔2.2
const y = sales / 1800; // 高度缩放系数,使最高柱约6单位
dataPoints.push({
x, y, z,
sales: sales,
category: categories[j],
region: regions[i],
color: colorMap[categories[j]]
});
}
}
// 创建柱状体群组
const barsGroup = new THREE.Group();
dataPoints.forEach(point => {
// 几何体: 长方体 (宽0.8, 高point.y, 深0.8)
const geometry = new THREE.BoxGeometry(0.8, point.y, 0.8);
const material = new THREE.MeshStandardMaterial({
color: point.color,
roughness: 0.3,
metalness: 0.6,
emissive: 0x000000,
emissiveIntensity: 0
});
const cube = new THREE.Mesh(geometry, material);
cube.position.set(point.x, point.y / 2 - 3, point.z); // 地面y=-3,所以中心在y/2-3
cube.castShadow = true;
cube.receiveShadow = false;
cube.userData = { sales: point.sales, category: point.category, region: point.region };
barsGroup.add(cube);
// 添加边框线框,增强科技感
const edgesGeo = new THREE.EdgesGeometry(geometry);
const edgesMat = new THREE.LineBasicMaterial({ color: 0xffffff });
const wireframe = new THREE.LineSegments(edgesGeo, edgesMat);
cube.add(wireframe);
// CSS2D文字标签 (显示销售额)
const div = document.createElement('div');
div.textContent = `${point.sales.toLocaleString()}万`;
div.style.color = '#ffdd99';
div.style.fontSize = '14px';
div.style.fontWeight = 'bold';
div.style.textShadow = '1px 1px 0px black';
div.style.background = 'rgba(0,0,0,0.6)';
div.style.padding = '2px 6px';
div.style.borderRadius = '12px';
div.style.border = '1px solid rgba(255,255,255,0.3)';
div.style.whiteSpace = 'nowrap';
const label = new CSS2DObject(div);
label.position.set(point.x, point.y - 2.5, point.z);
barsGroup.add(label);
labels.push(label);
});
scene.add(barsGroup);
// --- 添加区域和品类的辅助装饰线框 (增强空间理解) ---
// 在X轴方向添加区域分隔线
const xPositions = [-4.4, -2.2, 0, 2.2, 4.4];
regions.forEach((region, idx) => {
const x = xPositions[idx];
// 垂直半透明柱状标识
const pillarMat = new THREE.MeshPhongMaterial({ color: 0x88aaff, transparent: true, opacity: 0.15 });
const pillar = new THREE.Mesh(new THREE.BoxGeometry(0.2, 6, 9), pillarMat);
pillar.position.set(x, 0, 0);
scene.add(pillar);
// 添加区域文字标签 (CSS2D)
const div = document.createElement('div');
div.textContent = region;
div.style.color = '#aaddff';
div.style.fontSize = '16px';
div.style.fontWeight = 'bold';
div.style.background = 'rgba(0,0,0,0.5)';
div.style.padding = '4px 12px';
div.style.borderRadius = '20px';
div.style.border = '1px solid #88aaff';
const label = new CSS2DObject(div);
label.position.set(x, -2, -5.5);
scene.add(label);
});
// 在Z轴方向添加品类文字标签
const zPositions = [-4.4, -2.2, 0, 2.2, 4.4];
categories.forEach((cat, idx) => {
const z = zPositions[idx];
const div = document.createElement('div');
div.textContent = cat;
div.style.color = '#ffcc88';
div.style.fontSize = '16px';
div.style.fontWeight = 'bold';
div.style.background = 'rgba(0,0,0,0.5)';
div.style.padding = '4px 12px';
div.style.borderRadius = '20px';
div.style.border = '1px solid #ffaa66';
const label = new CSS2DObject(div);
label.position.set(-6, -2, z);
scene.add(label);
});
// --- 添加粒子系统: 数据光点 (营造科技感氛围) ---
const particleCount = 1500;
const particlesGeometry = new THREE.BufferGeometry();
const particlePositions = new Float32Array(particleCount * 3);
for (let i = 0; i < particleCount; i++) {
// 分布在柱状区域周围
particlePositions[i*3] = (Math.random() - 0.5) * 22;
particlePositions[i*3+1] = (Math.random() - 0.5) * 12 - 2;
particlePositions[i*3+2] = (Math.random() - 0.5) * 18;
}
particlesGeometry.setAttribute('position', new THREE.BufferAttribute(particlePositions, 3));
const particlesMaterial = new THREE.PointsMaterial({
color: 0x88aaff,
size: 0.08,
transparent: true,
opacity: 0.6,
blending: THREE.AdditiveBlending
});
const particles = new THREE.Points(particlesGeometry, particlesMaterial);
scene.add(particles);
// 缓慢旋转粒子系统
function animateParticles() {
particles.rotation.y += 0.0005;
particles.rotation.x += 0.0003;
requestAnimationFrame(animateParticles);
}
animateParticles();
// --- 动画: 让一些光点沿柱状体上下浮动 (额外效果) ---
const floatingLights = [];
for (let i = 0; i < 60; i++) {
const lightGeo = new THREE.SphereGeometry(0.08, 8, 8);
const lightMat = new THREE.MeshStandardMaterial({ color: 0xffaa66, emissive: 0xff4400, emissiveIntensity: 0.8 });
const lightSphere = new THREE.Mesh(lightGeo, lightMat);
// 随机绑定到一个柱体上
const randomBar = barsGroup.children.find(child => child.isMesh && child.geometry.type === 'BoxGeometry');
if (randomBar) {
lightSphere.userData = { parentBar: randomBar, offsetY: Math.random() * randomBar.geometry.parameters.height };
lightSphere.position.copy(randomBar.position);
lightSphere.position.y += lightSphere.userData.offsetY - 3;
scene.add(lightSphere);
floatingLights.push(lightSphere);
}
}
// 浮动动画
function animateFloatingLights() {
floatingLights.forEach(light => {
if (light.userData && light.userData.parentBar) {
const bar = light.userData.parentBar;
const maxY = bar.position.y + bar.geometry.parameters.height/2;
let yOffset = light.userData.offsetY;
yOffset += 0.01;
if (yOffset > bar.geometry.parameters.height) yOffset = 0;
light.userData.offsetY = yOffset;
light.position.y = bar.position.y - bar.geometry.parameters.height/2 + yOffset;
}
});
requestAnimationFrame(animateFloatingLights);
}
animateFloatingLights();
// --- 辅助功能: 高亮当前悬停的柱体 (射线检测) ---
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
function onMouseMove(event) {
// 计算鼠标位置归一化坐标
mouse.x = (event.clientX / renderer.domElement.clientWidth) * 2 - 1;
mouse.y = -(event.clientY / renderer.domElement.clientHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(barsGroup.children, true);
// 重置所有材质高亮
barsGroup.children.forEach(child => {
if (child.isMesh && child.material) {
child.material.emissiveIntensity = 0;
child.material.color.setHex(child.userData.originalColor || child.material.color.getHex());
}
});
if (intersects.length > 0) {
const hit = intersects[0].object;
if (hit.isMesh) {
// 保存原始颜色
if (!hit.userData.originalColor) hit.userData.originalColor = hit.material.color.getHex();
hit.material.emissiveIntensity = 0.5;
hit.material.color.setHex(0xffaa66);
// 显示详细信息工具提示 (简单实现)
const infoDiv = document.getElementById('info');
if (infoDiv && hit.userData) {
const salesVal = hit.userData.sales || '?';
const categoryVal = hit.userData.category || '?';
const regionVal = hit.userData.region || '?';
infoDiv.innerHTML = `<h2>3D数据可视化 - 销售数据分布</h2>
<p><strong>区域:</strong> ${regionVal} | <strong>品类:</strong> ${categoryVal}</p>
<p><strong>销售额:</strong> ${salesVal.toLocaleString()} 万元</p>
<p>鼠标拖拽旋转视角 | 右键平移 | 滚轮缩放</p>`;
}
}
} else {
// 恢复默认信息
const infoDiv = document.getElementById('info');
if (infoDiv) {
infoDiv.innerHTML = `<h2>3D数据可视化 - 销售数据分布</h2>
<p>鼠标拖拽旋转视角 | 右键平移 | 滚轮缩放</p>`;
}
}
}
window.addEventListener('mousemove', onMouseMove, false);
// --- 控制按钮功能 ---
document.getElementById('toggle-grid').addEventListener('click', () => {
gridHelper.visible = !gridHelper.visible;
groundPlane.visible = !groundPlane.visible;
});
document.getElementById('toggle-labels').addEventListener('click', () => {
labels.forEach(label => { label.visible = !label.visible; });
});
document.getElementById('reset-view').addEventListener('click', () => {
camera.position.set(15, 12, 18);
controls.target.set(0, 0, 0);
controls.update();
});
// --- 动画循环 ---
function animate() {
requestAnimationFrame(animate);
controls.update(); // 更新轨道控制
renderer.render(scene, camera);
labelRenderer.render(scene, camera);
}
animate();
// 窗口适配
window.addEventListener('resize', onWindowResize, false);
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
labelRenderer.setSize(window.innerWidth, window.innerHeight);
}
console.log('3D可视化已启动,支持交互探索');
</script>
</body>
</html>
9.2 3D散点图与气泡图
// 使用Three.js创建3D散点图(适合展示三维数据关系)
class Scatter3D {
constructor(containerId, data, options) {
this.container = document.getElementById(containerId);
this.data = data;
this.options = options;
this.scene = null;
this.camera = null;
this.renderer = null;
this.points = [];
this.init();
}
init() {
// 场景
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0x0a0a2a);
// 相机
this.camera = new THREE.PerspectiveCamera(45, this.container.clientWidth / this.container.clientHeight, 0.1, 1000);
this.camera.position.set(15, 10, 15);
this.camera.lookAt(0, 0, 0);
// 渲染器
this.renderer = new THREE.WebGLRenderer({ antialias: true });
this.renderer.setSize(this.container.clientWidth, this.container.clientHeight);
this.container.appendChild(this.renderer.domElement);
// 轨道控制
this.controls = new THREE.OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true;
// 灯光
this.addLights();
// 辅助元素
this.addHelpers();
// 绘制数据点
this.drawPoints();
// 动画循环
this.animate();
}
addLights() {
const ambientLight = new THREE.AmbientLight(0x404060);
this.scene.add(ambientLight);
const dirLight = new THREE.DirectionalLight(0xffffff, 1);
dirLight.position.set(5, 10, 7);
this.scene.add(dirLight);
const backLight = new THREE.PointLight(0x4466cc, 0.5);
backLight.position.set(-3, 2, -5);
this.scene.add(backLight);
}
addHelpers() {
// 网格辅助
const gridHelper = new THREE.GridHelper(20, 20, 0x88aaff, 0x335588);
gridHelper.position.y = -2;
this.scene.add(gridHelper);
// 简易坐标轴
const axesHelper = new THREE.AxesHelper(8);
this.scene.add(axesHelper);
}
drawPoints() {
// 根据数据值映射颜色和大小
const colorScale = d3.scaleSequential()
.domain([0, 100])
.interpolator(d3.interpolateViridis);
this.data.forEach(point => {
// 确定颜色
const color = new THREE.Color(colorScale(point.value));
// 确定大小(范围0.2-0.8)
const size = 0.2 + (point.value / 100) * 0.6;
// 创建球体
const geometry = new THREE.SphereGeometry(size, 32, 32);
const material = new THREE.MeshStandardMaterial({
color: color,
roughness: 0.3,
metalness: 0.2,
emissive: color,
emissiveIntensity: 0.1
});
const sphere = new THREE.Mesh(geometry, material);
sphere.position.set(point.x, point.y, point.z);
sphere.userData = point;
this.scene.add(sphere);
this.points.push(sphere);
});
}
animate() {
requestAnimationFrame(() => this.animate());
this.controls.update();
this.renderer.render(this.scene, this.camera);
}
}
第十部分:数据故事讲述
10.1 动态叙事可视化
// 动态叙事可视化 - 引导用户逐步理解数据
class NarrativeVisualization {
constructor(containerId, data, storySteps) {
this.container = document.getElementById(containerId);
this.data = data;
this.storySteps = storySteps;
this.currentStep = 0;
this.chart = null;
this.annotations = [];
this.init();
this.setupNavigation();
}
init() {
// 初始化基础图表
this.chart = echarts.init(this.container);
// 添加控制面板
this.addControlPanel();
// 加载第一个故事步骤
this.loadStep(0);
}
addControlPanel() {
const panel = document.createElement('div');
panel.className = 'narrative-controls';
panel.innerHTML = `
<button id="prev-step" disabled>← 上一步</button>
<span id="step-indicator">第 1 / ${this.storySteps.length} 步</span>
<button id="next-step">下一步 →</button>
<div class="step-progress">
<div class="progress-bar" style="width: 0%"></div>
</div>
`;
this.container.parentElement.insertBefore(panel, this.container);
// 绑定事件
document.getElementById('prev-step').onclick = () => this.prevStep();
document.getElementById('next-step').onclick = () => this.nextStep();
}
loadStep(stepIndex) {
const step = this.storySteps[stepIndex];
if (!step) return;
// 更新图表配置
const option = this.buildChartOption(step);
this.chart.setOption(option, true);
// 添加标注
this.addAnnotations(step.annotations);
// 更新控制面板状态
this.updateControls(stepIndex);
// 更新旁白文本
this.updateNarrative(step.narrative);
// 高亮相关数据点
if (step.highlight) {
this.highlightDataPoints(step.highlight);
}
// 播放音效(可选)
if (step.sound) {
this.playSound(step.sound);
}
}
buildChartOption(step) {
// 基础配置
const baseOption = {
title: {
text: step.title,
left: 'center',
textStyle: { fontSize: 18, fontWeight: 'bold' }
},
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: this.data.categories },
yAxis: { type: 'value', name: step.yAxisLabel || '数值' },
series: [{
name: step.seriesName,
type: step.chartType || 'bar',
data: this.data.values,
itemStyle: {
borderRadius: [4, 4, 0, 0],
color: step.colorScheme || '#5470c6'
}
}]
};
// 根据步骤添加特殊效果
if (step.effect === 'explode') {
// 爆炸效果:突出某个柱子
const explodeIndex = step.explodeIndex;
if (explodeIndex !== undefined) {
baseOption.series[0].itemStyle = {
...baseOption.series[0].itemStyle,
color: (params) => {
return params.dataIndex === explodeIndex ? '#ff6600' : '#5470c6';
}
};
}
}
if (step.effect === 'trend') {
// 趋势线
baseOption.series.push({
name: '趋势线',
type: 'line',
data: step.trendData || this.data.values,
smooth: true,
lineStyle: { width: 3, color: '#ff6600', type: 'dashed' },
symbol: 'none'
});
}
return baseOption;
}
addAnnotations(annotations) {
// 清除旧标注
this.annotations.forEach(ann => ann.dispose());
this.annotations = [];
annotations.forEach(ann => {
const graphic = {
type: 'text',
left: ann.left,
top: ann.top,
style: {
text: ann.text,
fill: ann.color || '#333',
fontSize: ann.fontSize || 14,
fontWeight: 'bold',
backgroundColor: 'rgba(255,255,255,0.8)',
padding: [5, 10, 5, 10],
borderRadius: 4
},
z: 100
};
this.chart.setOption({ graphic: graphic });
this.annotations.push({ dispose: () => this.chart.setOption({ graphic: [] }) });
});
}
highlightDataPoints(indices) {
// 高亮指定数据点
this.chart.dispatchAction({
type: 'highlight',
seriesIndex: 0,
dataIndex: indices
});
// 3秒后取消高亮
setTimeout(() => {
this.chart.dispatchAction({
type: 'downplay',
seriesIndex: 0
});
}, 3000);
}
updateNarrative(text) {
let narrativeDiv = document.getElementById('narrative-text');
if (!narrativeDiv) {
narrativeDiv = document.createElement('div');
narrativeDiv.id = 'narrative-text';
narrativeDiv.className = 'narrative-text';
this.container.parentElement.appendChild(narrativeDiv);
}
// 打字机效果
this.typeWriter(narrativeDiv, text);
}
typeWriter(element, text, speed = 30) {
element.innerHTML = '';
let i = 0;
const timer = setInterval(() => {
if (i < text.length) {
element.innerHTML += text.charAt(i);
i++;
} else {
clearInterval(timer);
}
}, speed);
}
updateControls(stepIndex) {
const prevBtn = document.getElementById('prev-step');
const nextBtn = document.getElementById('next-step');
const indicator = document.getElementById('step-indicator');
const progressBar = document.querySelector('.progress-bar');
prevBtn.disabled = stepIndex === 0;
nextBtn.disabled = stepIndex === this.storySteps.length - 1;
indicator.textContent = `第 ${stepIndex + 1} / ${this.storySteps.length} 步`;
progressBar.style.width = `${((stepIndex + 1) / this.storySteps.length) * 100}%`;
}
nextStep() {
if (this.currentStep < this.storySteps.length - 1) {
this.currentStep++;
this.loadStep(this.currentStep);
}
}
prevStep() {
if (this.currentStep > 0) {
this.currentStep--;
this.loadStep(this.currentStep);
}
}
}
第十一部分:异常检测与数据质量可视化
11.1 异常检测可视化
// 异常检测可视化组件
class AnomalyDetectionChart {
constructor(containerId, data) {
this.container = document.getElementById(containerId);
this.data = data;
this.anomalies = [];
this.chart = null;
this.detectAnomalies();
this.initChart();
}
// 基于3σ原则检测异常值
detectAnomalies() {
const values = this.data.map(d => d.value);
const mean = values.reduce((a, b) => a + b, 0) / values.length;
const stdDev = Math.sqrt(values.map(v => Math.pow(v - mean, 2)).reduce((a, b) => a + b, 0) / values.length);
const threshold = 3 * stdDev;
this.anomalies = this.data.filter(d => Math.abs(d.value - mean) > threshold);
console.log(`检测到 ${this.anomalies.length} 个异常点`);
}
initChart() {
this.chart = echarts.init(this.container);
const option = {
title: { text: '异常检测可视化', left: 'center' },
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
xAxis: { type: 'category', data: this.data.map(d => d.name) },
yAxis: { type: 'value', name: '数值' },
series: [
{
name: '正常数据',
type: 'bar',
data: this.data.map(d => ({
value: d.value,
itemStyle: {
color: this.anomalies.includes(d) ? '#ff4444' : '#5470c6'
}
})),
itemStyle: {
borderRadius: [4, 4, 0, 0]
}
},
{
name: '均值线',
type: 'line',
data: Array(this.data.length).fill(this.getMean()),
lineStyle: { color: '#ffaa00', width: 2, type: 'dashed' },
symbol: 'none',
tooltip: { valueFormatter: (value) => `均值: ${value.toFixed(2)}` }
},
{
name: '上下限',
type: 'line',
data: Array(this.data.length).fill(this.getUpperLimit()),
lineStyle: { color: '#ff6666', width: 1, type: 'dotted' },
symbol: 'none'
}
],
visualMap: {
show: false,
pieces: [
{ min: this.getUpperLimit(), color: '#ff4444' },
{ max: this.getUpperLimit(), color: '#5470c6' }
]
}
};
this.chart.setOption(option);
}
getMean() {
const sum = this.data.reduce((a, b) => a + b.value, 0);
return sum / this.data.length;
}
getStdDev() {
const mean = this.getMean();
const squaredDiffs = this.data.map(d => Math.pow(d.value - mean, 2));
return Math.sqrt(squaredDiffs.reduce((a, b) => a + b, 0) / this.data.length);
}
getUpperLimit() {
return this.getMean() + 3 * this.getStdDev();
}
getLowerLimit() {
return this.getMean() - 3 * this.getStdDev();
}
}
第十二部分:地理信息可视化
12.1 地图散点图
// 地图散点图组件
class MapScatterPlot {
constructor(containerId, data) {
this.container = document.getElementById(containerId);
this.data = data;
this.map = null;
this.initMap();
}
initMap() {
// 使用Leaflet.js创建地图
this.map = L.map(this.container).setView([39.9042, 116.4074], 5);
// 添加底图图层
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> contributors'
}).addTo(this.map);
// 添加数据点
this.addMarkers();
// 添加热力图
if (this.data.length > 100) {
this.addHeatmap();
}
}
addMarkers() {
this.data.forEach(point => {
// 根据数值大小决定图标颜色和大小
const size = 8 + (point.value / 100) * 20;
const color = this.getValueColor(point.value);
const circleMarker = L.circleMarker([point.lat, point.lng], {
radius: size,
fillColor: color,
color: '#fff',
weight: 1,
opacity: 1,
fillOpacity: 0.8
}).addTo(this.map);
// 添加弹窗
circleMarker.bindPopup(`
<strong>${point.name}</strong><br>
数值: ${point.value}<br>
分类: ${point.category}
`);
// 聚类效果(当缩放级别较小时)
if (this.map.getZoom() < 8) {
// 实现聚类逻辑
}
});
}
addHeatmap() {
// 添加热力图图层
const heatData = this.data.map(point => [point.lat, point.lng, point.value]);
L.heatLayer(heatData, {
radius: 25,
blur: 15,
maxZoom: 10,
minOpacity: 0.3,
gradient: {
0.2: 'blue',
0.4: 'cyan',
0.6: 'lime',
0.8: 'yellow',
1.0: 'red'
}
}).addTo(this.map);
}
getValueColor(value) {
// 根据数值范围返回颜色
if (value < 20) return '#00ff00';
if (value < 50) return '#ffff00';
if (value < 80) return '#ff8800';
return '#ff0000';
}
}
第十三部分:可视化组件库封装
13.1 通用图表组件
// 通用图表组件类
class ChartComponent {
constructor(containerId, config) {
this.container = document.getElementById(containerId);
this.config = config;
this.chart = null;
this.data = [];
this.eventHandlers = new Map();
this.init();
}
init() {
// 根据配置创建对应类型的图表
const chartTypes = {
'line': echarts,
'bar': echarts,
'pie': echarts,
'scatter': echarts,
'heatmap': echarts
};
const ChartLib = chartTypes[this.config.type] || echarts;
this.chart = ChartLib.init(this.container);
// 设置基础配置
this.setBaseOption();
// 绑定窗口大小变化事件
window.addEventListener('resize', () => this.resize());
}
setBaseOption() {
const baseOption = {
title: {
text: this.config.title,
left: 'center',
textStyle: { fontSize: 16, fontWeight: 'normal' }
},
tooltip: { trigger: 'axis' },
legend: { data: this.config.legend, bottom: 0 },
grid: {
left: '10%',
right: '10%',
containLabel: true
},
toolbox: {
feature: {
saveAsImage: { title: '保存为图片' },
restore: { title: '重置' },
dataZoom: { title: '区域缩放' }
}
}
};
this.chart.setOption(baseOption);
}
setData(data) {
this.data = data;
this.update();
}
update() {
const option = this.buildOption();
this.chart.setOption(option);
}
buildOption() {
// 子类重写
return {};
}
on(event, handler) {
this.chart.on(event, handler);
this.eventHandlers.set(event, handler);
}
off(event) {
if (this.eventHandlers.has(event)) {
this.chart.off(event, this.eventHandlers.get(event));
this.eventHandlers.delete(event);
}
}
resize() {
this.chart.resize();
}
dispose() {
this.chart.dispose();
this.container.innerHTML = '';
}
}
// 柱状图组件
class BarChart extends ChartComponent {
buildOption() {
return {
...super.buildOption(),
xAxis: {
type: 'category',
data: this.data.categories,
axisLabel: { rotate: 45, interval: 0 }
},
yAxis: { type: 'value', name: this.config.yAxisName },
series: [{
name: this.config.seriesName,
type: 'bar',
data: this.data.values,
itemStyle: {
borderRadius: [4, 4, 0, 0],
color: this.config.color || '#5470c6'
},
label: {
show: this.config.showLabel || false,
position: 'top'
}
}]
};
}
}
// 折线图组件
class LineChart extends ChartComponent {
buildOption() {
return {
...super.buildOption(),
xAxis: {
type: 'category',
data: this.data.categories,
boundaryGap: false
},
yAxis: { type: 'value', name: this.config.yAxisName },
series: [{
name: this.config.seriesName,
type: 'line',
data: this.data.values,
smooth: this.config.smooth || true,
lineStyle: { width: 3 },
areaStyle: this.config.area ? { opacity: 0.3 } : undefined,
symbol: 'circle',
symbolSize: 8,
itemStyle: { color: this.config.color || '#5470c6' }
}]
};
}
}
// 饼图组件
class PieChart extends ChartComponent {
buildOption() {
return {
...super.buildOption(),
tooltip: { trigger: 'item' },
series: [{
name: this.config.seriesName,
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2
},
label: {
show: true,
formatter: '{b}: {d}%'
},
emphasis: {
scale: true,
label: { show: true, fontWeight: 'bold' }
},
data: this.data.map(item => ({
name: item.name,
value: item.value,
itemStyle: { color: item.color }
}))
}]
};
}
}
数据可视化是一门融合了设计美学、数据科学和编程技术的综合性学科。本文从多个维度系统性地讲解了可视化技术的实现方法:
3D可视化:使用Three.js创建沉浸式的三维数据展示
数据故事讲述:通过动态叙事引导用户理解数据
异常检测可视化:帮助快速识别数据质量问题
地理信息可视化:在地图上展示数据的空间分布
组件化封装:构建可复用的可视化组件库
数据可视化的核心始终是让数据说话,技术只是工具,洞察才是目的。
来源:https://bncne.cn/