第五部分:交互式可视化
5.1 交互功能实现
// 交互式图表组件
class InteractiveChart {
constructor(containerId, options) {
this.container = document.getElementById(containerId);
this.options = options;
this.data = [];
this.selectedPoints = [];
this.zoomLevel = 1;
this.panOffset = { x: 0, y: 0 };
this.init();
}
init() {
// 创建SVG画布
this.svg = d3.select(this.container)
.append("svg")
.attr("width", this.options.width)
.attr("height", this.options.height)
.call(this.setupZoom());
// 设置比例尺
this.setupScales();
// 绑定事件
this.bindEvents();
}
setupZoom() {
return d3.zoom()
.scaleExtent([0.5, 10])
.on("zoom", (event) => this.handleZoom(event));
}
setupScales() {
this.xScale = d3.scaleLinear()
.domain([0, 100])
.range([50, this.options.width - 50]);
this.yScale = d3.scaleLinear()
.domain([0, 100])
.range([this.options.height - 50, 50]);
this.colorScale = d3.scaleSequential()
.domain([0, 100])
.interpolator(d3.interpolateViridis);
}
// 绑定交互事件
bindEvents() {
// 鼠标悬停显示详情
this.svg.on("mousemove", (event) => this.showTooltip(event));
// 点击选择数据点
this.svg.on("click", (event) => this.handleClick(event));
// 右键菜单
this.svg.on("contextmenu", (event) => {
event.preventDefault();
this.showContextMenu(event);
});
}
// 处理缩放
handleZoom(event) {
this.zoomLevel = event.transform.k;
this.panOffset = { x: event.transform.x, y: event.transform.y };
// 更新比例尺
const newXScale = event.transform.rescaleX(this.xScale);
const newYScale = event.transform.rescaleY(this.yScale);
// 更新坐标轴
this.svg.select(".x-axis").call(d3.axisBottom(newXScale));
this.svg.select(".y-axis").call(d3.axisLeft(newYScale));
// 更新数据点
this.updatePoints(newXScale, newYScale);
}
// 显示提示框
showTooltip(event) {
const [mouseX, mouseY] = d3.pointer(event);
const xValue = this.xScale.invert(mouseX);
const yValue = this.yScale.invert(mouseY);
// 查找最近的数据点
const closestPoint = this.findClosestPoint(xValue, yValue);
if (closestPoint && this.distanceToPoint(mouseX, mouseY, closestPoint) < 20) {
// 显示提示框
const tooltip = d3.select("#tooltip");
tooltip.style("left", (event.pageX + 10) + "px")
.style("top", (event.pageY - 20) + "px")
.style("display", "block")
.html(`
<strong>${closestPoint.name}</strong><br>
值: ${closestPoint.value}<br>
分类: ${closestPoint.category}
`);
// 高亮数据点
this.highlightPoint(closestPoint);
} else {
d3.select("#tooltip").style("display", "none");
this.clearHighlight();
}
}
// 区域选择(刷选)
enableBrush() {
const brush = d3.brush()
.extent([[0, 0], [this.options.width, this.options.height]])
.on("brush end", (event) => this.handleBrush(event));
this.svg.append("g")
.attr("class", "brush")
.call(brush);
}
handleBrush(event) {
if (!event.selection) return;
const [[x0, y0], [x1, y1]] = event.selection;
const xMin = this.xScale.invert(x0);
const xMax = this.xScale.invert(x1);
const yMin = this.yScale.invert(y1);
const yMax = this.yScale.invert(y0);
// 筛选范围内的数据点
const selected = this.data.filter(d =>
d.x >= xMin && d.x <= xMax && d.y >= yMin && d.y <= yMax
);
this.onSelectionChange(selected);
}
// 动态过滤
filterByCategory(category) {
const filtered = this.data.filter(d => d.category === category);
this.updateData(filtered);
}
// 动态范围过滤
filterByValueRange(min, max) {
const filtered = this.data.filter(d => d.value >= min && d.value <= max);
this.updateData(filtered);
}
// 更新数据(带动画)
updateData(newData) {
this.data = newData;
// 更新比例尺域
this.xScale.domain(d3.extent(newData, d => d.x));
this.yScale.domain(d3.extent(newData, d => d.y));
// 过渡动画
this.svg.selectAll(".point")
.data(newData)
.transition()
.duration(500)
.attr("cx", d => this.xScale(d.x))
.attr("cy", d => this.yScale(d.y));
// 更新坐标轴
this.svg.select(".x-axis")
.transition()
.duration(500)
.call(d3.axisBottom(this.xScale));
this.svg.select(".y-axis")
.transition()
.duration(500)
.call(d3.axisLeft(this.yScale));
}
}
5.2 动态仪表盘布局
// 仪表盘布局管理器
class DashboardLayout {
constructor(containerId) {
this.container = document.getElementById(containerId);
this.widgets = new Map();
this.layout = null;
this.initGrid();
}
initGrid() {
// 使用GridStack实现拖拽布局
this.grid = GridStack.init({
cellHeight: 80,
verticalMargin: 10,
minRow: 3,
animate: true,
resizable: {
handles: 'e, se, s, sw, w'
}
});
// 保存布局变化
this.grid.on('change', () => this.saveLayout());
}
// 添加小部件
addWidget(widget) {
const id = `widget-${Date.now()}-${Math.random()}`;
const element = this.createWidgetElement(id, widget);
this.grid.addWidget(element, {
x: widget.x || 0,
y: widget.y || 0,
w: widget.w || 2,
h: widget.h || 2
});
this.widgets.set(id, widget);
// 初始化小部件内容
widget.init(element.querySelector('.widget-content'));
}
// 保存布局配置
saveLayout() {
const items = this.grid.getGridItems();
const layout = [];
items.forEach(item => {
const id = item.getAttribute('data-id');
const widget = this.widgets.get(id);
if (widget) {
layout.push({
id: id,
type: widget.type,
x: item.gridstackNode.x,
y: item.gridstackNode.y,
w: item.gridstackNode.w,
h: item.gridstackNode.h
});
}
});
localStorage.setItem('dashboard-layout', JSON.stringify(layout));
}
// 加载保存的布局
loadLayout() {
const saved = localStorage.getItem('dashboard-layout');
if (saved) {
const layout = JSON.parse(saved);
this.grid.removeAll();
layout.forEach(item => {
const widget = this.createWidgetByType(item.type);
widget.x = item.x;
widget.y = item.y;
widget.w = item.w;
widget.h = item.h;
this.addWidget(widget);
});
}
}
// 导出仪表盘配置
exportConfig() {
const config = {
layout: this.getLayout(),
widgets: Array.from(this.widgets.values()).map(w => ({
type: w.type,
config: w.getConfig()
}))
};
return JSON.stringify(config);
}
// 导入仪表盘配置
importConfig(configJson) {
const config = JSON.parse(configJson);
this.grid.removeAll();
this.widgets.clear();
config.widgets.forEach(widgetConfig => {
const widget = this.createWidgetByType(widgetConfig.type);
widget.setConfig(widgetConfig.config);
this.addWidget(widget);
});
}
}
第六部分:可视化最佳实践
6.1 颜色选择
// 颜色方案库
class ColorSchemes {
// 分类数据颜色(不同类别使用不同颜色)
static categorical = {
default: ['#5470c6', '#fac858', '#ee6666', '#73c0de', '#3ba272', '#fc8452', '#9a60b4', '#ea7ccc'],
pastel: ['#a6cee3', '#1f78b4', '#b2df8a', '#33a02c', '#fb9a99', '#e31a23', '#fdbf6f', '#ff7f00'],
vibrant: ['#e41a1c', '#377eb8', '#4daf4a', '#984ea3', '#ff7f00', '#ffff33', '#a65628', '#f781bf']
};
// 顺序数据颜色(从低到高)
static sequential = {
blues: ['#f7fbff', '#deebf7', '#c6dbef', '#9ecae1', '#6baed6', '#4292c6', '#2171b5', '#08519c'],
greens: ['#f7fcf5', '#e5f5e0', '#c7e9c0', '#a1d99b', '#74c476', '#41ab5d', '#238b45', '#005a32'],
viridis: ['#440154', '#482878', '#3e4a89', '#31688e', '#26828e', '#1f9e89', '#35b779', '#6ece58', '#b5de2b', '#fde725']
};
// 发散数据颜色(负值到正值)
static diverging = {
red_blue: ['#b2182b', '#d6604d', '#f4a582', '#fddbc7', '#f7f7f7', '#d1e5f0', '#92c5de', '#4393c3', '#2166ac'],
red_green: ['#a50026', '#d73027', '#f46d43', '#fdae61', '#fee08b', '#ffffbf', '#d9ef8b', '#a6d96a', '#66bd63', '#1a9850', '#006837']
};
// 色盲友好颜色
static colorblind = {
okabe_ito: ['#E69F00', '#56B4E9', '#009E73', '#F0E442', '#0072B2', '#D55E00', '#CC79A7', '#999999']
};
// 获取颜色
static getColorScheme(type, name) {
return this[type]?.[name] || this.categorical.default;
}
// 颜色插值
static interpolate(color1, color2, t) {
const c1 = this.hexToRgb(color1);
const c2 = this.hexToRgb(color2);
const r = Math.round(c1.r + (c2.r - c1.r) * t);
const g = Math.round(c1.g + (c2.g - c1.g) * t);
const b = Math.round(c1.b + (c2.b - c1.b) * t);
return this.rgbToHex(r, g, b);
}
static hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : null;
}
static rgbToHex(r, g, b) {
return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
}
}
6.2 性能优化
// 可视化性能优化
class PerformanceOptimizer {
// 1. 虚拟滚动(处理大量数据点)
setupVirtualScrolling(container, itemHeight, totalItems) {
const visibleCount = Math.ceil(container.clientHeight / itemHeight) + 5;
let startIndex = 0;
const handleScroll = () => {
const scrollTop = container.scrollTop;
const newStartIndex = Math.floor(scrollTop / itemHeight);
if (newStartIndex !== startIndex) {
startIndex = newStartIndex;
this.renderVisibleItems(startIndex, visibleCount);
}
};
container.addEventListener('scroll', () => requestAnimationFrame(handleScroll));
}
// 2. 渲染节流
throttleRender(renderFn, delay = 16) {
let timeoutId = null;
let lastArgs = null;
return function(...args) {
lastArgs = args;
if (!timeoutId) {
timeoutId = setTimeout(() => {
renderFn(...lastArgs);
timeoutId = null;
}, delay);
}
};
}
// 3. 使用Web Worker处理数据
setupWorker(workerScript) {
const worker = new Worker(workerScript);
worker.onmessage = (event) => {
const processedData = event.data;
this.renderChart(processedData);
};
// 发送数据到Worker处理
this.processWithWorker = (rawData) => {
worker.postMessage(rawData);
};
}
// 4. Canvas绘制优化
optimizeCanvasRendering(ctx, canvas, data) {
// 使用离屏Canvas
const offscreen = new OffscreenCanvas(canvas.width, canvas.height);
const offCtx = offscreen.getContext('2d');
// 批量绘制
offCtx.clearRect(0, 0, offscreen.width, offscreen.height);
// 批量操作
offCtx.beginPath();
for (const point of data) {
offCtx.moveTo(point.x, point.y);
offCtx.lineTo(point.x + 2, point.y);
}
offCtx.stroke();
// 一次性复制到主Canvas
ctx.drawImage(offscreen, 0, 0);
}
// 5. 数据缓存
cacheData(key, data, ttl = 60000) {
const cacheItem = {
data: data,
timestamp: Date.now(),
ttl: ttl
};
localStorage.setItem(`viz_cache_${key}`, JSON.stringify(cacheItem));
}
getCachedData(key) {
const cached = localStorage.getItem(`viz_cache_${key}`);
if (!cached) return null;
const item = JSON.parse(cached);
if (Date.now() - item.timestamp < item.ttl) {
return item.data;
}
return null;
}
}
第七部分:移动端可视化
7.1 响应式图表
/* 移动端适配 */
.chart-container {
width: 100%;
height: auto;
min-height: 300px;
}
@media (max-width: 768px) {
.chart-container {
min-height: 250px;
}
/* 增大触摸区域 */
.chart-tooltip {
padding: 8px 12px;
font-size: 14px;
}
/* 简化标签 */
.axis-label {
font-size: 10px;
}
}
// 移动端触摸交互
class MobileChart {
constructor(containerId) {
this.container = document.getElementById(containerId);
this.setupTouchEvents();
}
setupTouchEvents() {
let startX = 0, startY = 0;
this.container.addEventListener('touchstart', (e) => {
const touch = e.touches[0];
startX = touch.clientX;
startY = touch.clientY;
});
this.container.addEventListener('touchmove', (e) => {
const touch = e.touches[0];
const deltaX = touch.clientX - startX;
const deltaY = touch.clientY - startY;
// 拖动平移
this.pan(deltaX, deltaY);
startX = touch.clientX;
startY = touch.clientY;
});
// 双指缩放
let initialDistance = 0;
this.container.addEventListener('touchstart', (e) => {
if (e.touches.length === 2) {
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
initialDistance = Math.sqrt(dx * dx + dy * dy);
}
});
this.container.addEventListener('touchmove', (e) => {
if (e.touches.length === 2) {
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
const currentDistance = Math.sqrt(dx * dx + dy * dy);
const scale = currentDistance / initialDistance;
this.zoom(scale);
initialDistance = currentDistance;
}
});
}
// 长按显示详情
setupLongPress(element, callback) {
let timeoutId;
element.addEventListener('touchstart', () => {
timeoutId = setTimeout(() => {
callback();
}, 500);
});
element.addEventListener('touchend', () => {
clearTimeout(timeoutId);
});
element.addEventListener('touchmove', () => {
clearTimeout(timeoutId);
});
}
}