pin_drop当前位置:知识文库 ❯ 图文
数据可视化:折线图实现 - 从入门到实践详解
教程简介
折线图是展示数据随时间或有序维度变化趋势的最佳图表类型。通过连接数据点的线段,折线图能够直观地呈现数据的上升、下降、波动和转折,是时间序列分析中最常用的可视化手段。本教程将全面讲解折线图的各类实现,包括基础折线图、多系列折线图、面积填充、平滑曲线、数据点标记、缩放与平移以及实时数据更新,帮助你掌握折线图的完整实现能力。
核心概念
折线图的类型
基础折线图:单条折线展示一个数据系列的趋势
多系列折线图:多条折线展示多个数据系列的对比趋势
面积折线图:折线下方填充颜色,强调趋势的量级
堆叠面积图:多个面积图堆叠,展示总量和各部分贡献
平滑折线图:使用贝塞尔曲线连接数据点,视觉更柔和
阶梯折线图:数据点之间使用水平/垂直线段,适合离散数据
折线图的设计要素
线条样式:线宽、颜色、虚线样式
数据点标记:圆形、方形、三角形等,大小适中
面积填充:半透明渐变填充,不遮挡其他系列
坐标轴:X轴通常为时间轴,Y轴为数值轴
网格线:辅助读取数值,应尽量淡化
图例:多系列时必须提供图例
交互:十字准线、tooltip、数据点高亮
折线图的适用场景
展示数据随时间的变化趋势
比较多个指标的变化趋势
识别数据的周期性和季节性
发现数据的异常点和转折点
实时监控数据的动态变化
平滑曲线算法
折线图常用的平滑算法:
1. 三次贝塞尔曲线(Cardinal Spline)
代码示例
function cardinalSpline(points, tension = 0.3) {
const path = [];
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[Math.max(0, i - 1)];
const p1 = points[i];
const p2 = points[i + 1];
const p3 = points[Math.min(points.length - 1, i + 2)];
const cp1x = p1.x + (p2.x - p0.x) * tension;
const cp1y = p1.y + (p2.y - p0.y) * tension;
const cp2x = p2.x - (p3.x - p1.x) * tension;
const cp2y = p2.y - (p3.y - p1.y) * tension;
path.push({ cp1: {x: cp1x, y: cp1y}, cp2: {x: cp2x, y: cp2y}, end: p2 });
}
return path;
}2. 单调三次插值(Monotone Cubic)
保证曲线单调递增/递减,不会出现折线图中常见的"过冲"现象。
语法与用法
折线图绘制核心逻辑
代码示例
function drawLineChart(ctx, data, config) {
const { margin, plotWidth, plotHeight } = config;
const maxVal = Math.max(...data.values);
const xStep = plotWidth / (data.values.length - 1);
ctx.save();
ctx.translate(margin.left, margin.top);
// 绘制网格
drawGrid(ctx, maxVal, plotWidth, plotHeight);
// 计算数据点坐标
const points = data.values.map((v, i) => ({
x: i * xStep,
y: plotHeight - (v / maxVal) * plotHeight
}));
// 绘制面积填充
ctx.beginPath();
ctx.moveTo(points[0].x, plotHeight);
points.forEach(p => ctx.lineTo(p.x, p.y));
ctx.lineTo(points[points.length - 1].x, plotHeight);
ctx.closePath();
ctx.fillStyle = data.color + '20';
ctx.fill();
// 绘制折线
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
points.slice(1).forEach(p => ctx.lineTo(p.x, p.y));
ctx.strokeStyle = data.color;
ctx.lineWidth = 2;
ctx.stroke();
// 绘制数据点
points.forEach(p => {
ctx.beginPath();
ctx.arc(p.x, p.y, 4, 0, Math.PI * 2);
ctx.fillStyle = '#fff';
ctx.fill();
ctx.strokeStyle = data.color;
ctx.lineWidth = 2;
ctx.stroke();
});
ctx.restore();
}代码示例
示例1:多系列折线图与面积填充
以下示例实现多系列折线图,支持平滑曲线、面积填充和十字准线交互。
代码示例
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>多系列折线图</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f7fa;
display: flex;
justify-content: center;
padding: 30px 20px;
}
.chart-container {
background: #fff;
border-radius: 16px;
padding: 30px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
width: 100%;
max-width: 950px;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
flex-wrap: wrap;
gap: 10px;
}
.chart-title { color: #2c3e50; font-size: 20px; }
.options { display: flex; gap: 12px; align-items: center; }
.toggle-group { display: flex; gap: 4px; }
.toggle-btn {
padding: 5px 14px;
border: 1px solid #ddd;
border-radius: 6px;
background: #fff;
color: #666;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.toggle-btn.active { background: #4e79a7; color: #fff; border-color: #4e79a7; }
.legend { display: flex; gap: 18px; margin-top: 15px; justify-content: center; flex-wrap: wrap; }
.legend-item { display: flex; align-items: center; gap: 6px; font-size: 13px; color: #666; cursor: pointer; }
.legend-item.disabled { opacity: 0.3; }
.legend-line { width: 20px; height: 3px; border-radius: 2px; }
canvas { width: 100%; cursor: crosshair; }
</style>
</head>
<body>
<div class="chart-container">
<div class="chart-header">
<span class="chart-title">股票价格走势</span>
<div class="options">
<div class="toggle-group">
<button class="toggle-btn active" onclick="setSmooth(false)">折线</button>
<button class="toggle-btn" onclick="setSmooth(true)">平滑</button>
</div>
<div class="toggle-group">
<button class="toggle-btn" onclick="setArea(false)">无填充</button>
<button class="toggle-btn active" onclick="setArea(true)">面积填充</button>
</div>
</div>
</div>
<canvas id="lineChart"></canvas>
<div class="legend" id="legend"></div>
</div>
<script>
const stockData = {
labels: ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'],
series: [
{ name: '科技A', color: '#4e79a7', data: [45,52,48,65,72,68,85,92,88,95,102,110], visible: true },
{ name: '金融B', color: '#e15759', data: [30,28,35,42,38,45,50,48,55,60,58,65], visible: true },
{ name: '消费C', color: '#59a14f', data: [55,58,52,60,65,62,70,75,72,78,82,88], visible: true }
]
};
let useSmooth = false;
let useArea = true;
let mouseX = -1;
let animProgress = 0;
const canvas = document.getElementById('lineChart');
const dpr = window.devicePixelRatio || 1;
let displayW = canvas.parentElement.clientWidth - 60;
let displayH = Math.min(420, displayW * 0.48);
canvas.width = displayW * dpr;
canvas.height = displayH * dpr;
canvas.style.width = displayW + 'px';
canvas.style.height = displayH + 'px';
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
const margin = { top: 25, right: 25, bottom: 45, left: 55 };
function renderLegend() {
document.getElementById('legend').innerHTML = stockData.series.map((s, i) =>
`<div class="legend-item ${s.visible ? '' : 'disabled'}" onclick="toggleSeries(${i})">
<div class="legend-line" style="background:${s.color}"></div>${s.name}
</div>`
).join('');
}
function toggleSeries(index) {
stockData.series[index].visible = !stockData.series[index].visible;
renderLegend();
drawChart();
}
function setSmooth(val) {
useSmooth = val;
document.querySelectorAll('.toggle-group')[0].querySelectorAll('.toggle-btn').forEach((btn, i) => {
btn.classList.toggle('active', (i === 0 && !val) || (i === 1 && val));
});
drawChart();
}
function setArea(val) {
useArea = val;
document.querySelectorAll('.toggle-group')[1].querySelectorAll('.toggle-btn').forEach((btn, i) => {
btn.classList.toggle('active', (i === 0 && !val) || (i === 1 && val));
});
drawChart();
}
function getControlPoints(points, tension) {
const cps = [];
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[Math.max(0, i - 1)];
const p1 = points[i];
const p2 = points[i + 1];
const p3 = points[Math.min(points.length - 1, i + 2)];
cps.push({
cp1x: p1.x + (p2.x - p0.x) * tension,
cp1y: p1.y + (p2.y - p0.y) * tension,
cp2x: p2.x - (p3.x - p1.x) * tension,
cp2y: p2.y - (p3.y - p1.y) * tension
});
}
return cps;
}
function drawSmoothLine(ctx, points, tension = 0.3) {
const cps = getControlPoints(points, tension);
ctx.moveTo(points[0].x, points[0].y);
cps.forEach((cp, i) => {
ctx.bezierCurveTo(cp.cp1x, cp.cp1y, cp.cp2x, cp.cp2y, points[i + 1].x, points[i + 1].y);
});
}
function drawChart() {
const pw = displayW - margin.left - margin.right;
const ph = displayH - margin.top - margin.bottom;
ctx.clearRect(0, 0, displayW, displayH);
ctx.save();
ctx.translate(margin.left, margin.top);
const visibleSeries = stockData.series.filter(s => s.visible);
const allValues = visibleSeries.flatMap(s => s.data);
const minVal = Math.min(...allValues);
const maxVal = Math.max(...allValues);
const padding = (maxVal - minVal) * 0.1;
const yMin = Math.floor((minVal - padding) / 5) * 5;
const yMax = Math.ceil((maxVal + padding) / 5) * 5;
const xStep = pw / (stockData.labels.length - 1);
// 网格
for (let i = 0; i <= 6; i++) {
const v = yMin + (yMax - yMin) * i / 6;
const y = ph - ((v - yMin) / (yMax - yMin)) * ph;
ctx.strokeStyle = '#f0f0f0'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(pw, y); ctx.stroke();
ctx.fillStyle = '#999'; ctx.font = '11px sans-serif';
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
ctx.fillText(Math.round(v), -8, y);
}
// X轴标签
stockData.labels.forEach((label, i) => {
ctx.fillStyle = '#888'; ctx.font = '11px sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText(label, i * xStep, ph + 10);
});
// 动画裁剪
ctx.save();
ctx.beginPath();
ctx.rect(0, -5, pw * animProgress + 5, ph + 10);
ctx.clip();
// 绘制每个系列
visibleSeries.forEach(series => {
const points = series.data.map((v, i) => ({
x: i * xStep,
y: ph - ((v - yMin) / (yMax - yMin)) * ph
}));
// 面积填充
if (useArea) {
ctx.beginPath();
if (useSmooth) {
drawSmoothLine(ctx, points);
} else {
ctx.moveTo(points[0].x, points[0].y);
points.slice(1).forEach(p => ctx.lineTo(p.x, p.y));
}
ctx.lineTo(points[points.length - 1].x, ph);
ctx.lineTo(points[0].x, ph);
ctx.closePath();
const grad = ctx.createLinearGradient(0, 0, 0, ph);
grad.addColorStop(0, series.color + '30');
grad.addColorStop(1, series.color + '05');
ctx.fillStyle = grad;
ctx.fill();
}
// 折线
ctx.beginPath();
if (useSmooth) {
drawSmoothLine(ctx, points);
} else {
ctx.moveTo(points[0].x, points[0].y);
points.slice(1).forEach(p => ctx.lineTo(p.x, p.y));
}
ctx.strokeStyle = series.color;
ctx.lineWidth = 2.5;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.stroke();
// 数据点
points.forEach(p => {
ctx.beginPath();
ctx.arc(p.x, p.y, 3.5, 0, Math.PI * 2);
ctx.fillStyle = '#fff';
ctx.fill();
ctx.strokeStyle = series.color;
ctx.lineWidth = 2;
ctx.stroke();
});
});
ctx.restore(); // 恢复裁剪
// 十字准线和tooltip
const plotLeft = margin.left;
const plotRight = margin.left + pw;
if (mouseX >= plotLeft && mouseX <= plotRight) {
const relX = mouseX - margin.left;
const idx = Math.round(relX / xStep);
if (idx >= 0 && idx < stockData.labels.length) {
const snapX = idx * xStep;
// 垂直准线
ctx.strokeStyle = 'rgba(0,0,0,0.1)';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(snapX, 0); ctx.lineTo(snapX, ph); ctx.stroke();
ctx.setLineDash([]);
// 高亮数据点和tooltip内容
const tooltipItems = [];
visibleSeries.forEach(series => {
const v = series.data[idx];
const y = ph - ((v - yMin) / (yMax - yMin)) * ph;
ctx.beginPath();
ctx.arc(snapX, y, 6, 0, Math.PI * 2);
ctx.fillStyle = series.color;
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.stroke();
tooltipItems.push({ name: series.name, value: v, color: series.color });
});
// Tooltip
const lines = [stockData.labels[idx], ...tooltipItems.map(t => `${t.name}: ${t.value}`)];
ctx.font = '12px sans-serif';
const maxW = Math.max(...lines.map(l => ctx.measureText(l).width));
const tw = maxW + 20;
const th = lines.length * 20 + 12;
let tx = snapX + 15;
if (tx + tw > pw) tx = snapX - tw - 15;
ctx.fillStyle = 'rgba(44,62,80,0.92)';
ctx.beginPath();
ctx.roundRect(tx, 5, tw, th, 6);
ctx.fill();
lines.forEach((line, li) => {
ctx.fillStyle = li === 0 ? '#fff' : tooltipItems[li - 1].color;
ctx.font = li === 0 ? 'bold 12px sans-serif' : '12px sans-serif';
ctx.textAlign = 'left';
ctx.fillText(line, tx + 10, 19 + li * 20);
});
}
}
// 坐标轴
ctx.strokeStyle = '#ddd'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(0, ph); ctx.lineTo(pw, ph); ctx.stroke();
ctx.restore();
}
// 交互
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
mouseX = (e.clientX - rect.left) * (displayW / rect.width);
drawChart();
});
canvas.addEventListener('mouseleave', () => {
mouseX = -1;
drawChart();
});
// 入场动画
function animate() {
const start = performance.now();
function step(now) {
animProgress = Math.min(1, (now - start) / 1200);
animProgress = 1 - Math.pow(1 - animProgress, 3);
drawChart();
if (animProgress < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
renderLegend();
animate();
</script>
</body>
</html>示例2:实时数据更新折线图
以下示例实现一个实时更新的折线图,模拟服务器监控数据的动态展示。
代码示例
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>实时数据更新折线图</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #1a1a2e;
padding: 20px;
color: #eee;
}
h1 { text-align: center; margin-bottom: 20px; font-weight: 300; }
.monitor {
max-width: 950px;
margin: 0 auto;
}
.metrics-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-bottom: 20px;
}
.metric-card {
background: #16213e;
border-radius: 12px;
padding: 18px;
border: 1px solid #0f3460;
}
.metric-label { font-size: 12px; color: #888; margin-bottom: 6px; }
.metric-value { font-size: 28px; font-weight: 700; }
.metric-unit { font-size: 14px; color: #888; margin-left: 4px; }
.chart-card {
background: #16213e;
border-radius: 16px;
padding: 25px;
border: 1px solid #0f3460;
margin-bottom: 15px;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.chart-title { font-size: 16px; font-weight: 600; }
.live-badge {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #4ecca3;
}
.live-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: #4ecca3;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
canvas { width: 100%; }
.controls {
display: flex;
gap: 10px;
justify-content: center;
}
.ctrl-btn {
padding: 8px 20px;
border: 1px solid #0f3460;
border-radius: 8px;
background: #16213e;
color: #eee;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.ctrl-btn:hover { background: #0f3460; }
.ctrl-btn.active { background: #4e79a7; border-color: #4e79a7; }
</style>
</head>
<body>
<h1>服务器实时监控</h1>
<div class="monitor">
<div class="metrics-row" id="metrics"></div>
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">CPU 与内存使用率</span>
<div class="live-badge"><div class="live-dot"></div>实时</div>
</div>
<canvas id="realtimeChart"></canvas>
</div>
<div class="controls">
<button class="ctrl-btn active" onclick="setSpeed(1000)">1秒</button>
<button class="ctrl-btn" onclick="setSpeed(500)">0.5秒</button>
<button class="ctrl-btn" onclick="setSpeed(2000)">2秒</button>
<button class="ctrl-btn" onclick="togglePause()">暂停/继续</button>
</div>
</div>
<script>
const MAX_POINTS = 60;
const cpuData = [];
const memData = [];
let paused = false;
let updateInterval = 1000;
let timer = null;
// 初始化数据
for (let i = 0; i < MAX_POINTS; i++) {
cpuData.push(30 + Math.random() * 20);
memData.push(50 + Math.random() * 15);
}
const canvas = document.getElementById('realtimeChart');
const dpr = window.devicePixelRatio || 1;
const displayW = canvas.parentElement.clientWidth - 50;
const displayH = 300;
canvas.width = displayW * dpr;
canvas.height = displayH * dpr;
canvas.style.width = displayW + 'px';
canvas.style.height = displayH + 'px';
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
const margin = { top: 20, right: 20, bottom: 30, left: 45 };
const pw = displayW - margin.left - margin.right;
const ph = displayH - margin.top - margin.bottom;
function generateValue(prev, min, max, volatility) {
const change = (Math.random() - 0.5) * volatility;
return Math.max(min, Math.min(max, prev + change));
}
function updateMetrics() {
const cpu = cpuData[cpuData.length - 1];
const mem = memData[memData.length - 1];
document.getElementById('metrics').innerHTML = `
<div class="metric-card">
<div class="metric-label">CPU 使用率</div>
<div class="metric-value" style="color:#4ecca3">${cpu.toFixed(1)}<span class="metric-unit">%</span></div>
</div>
<div class="metric-card">
<div class="metric-label">内存使用率</div>
<div class="metric-value" style="color:#e15759">${mem.toFixed(1)}<span class="metric-unit">%</span></div>
</div>
<div class="metric-card">
<div class="metric-label">网络流量</div>
<div class="metric-value" style="color:#f28e2b">${(Math.random() * 100 + 50).toFixed(0)}<span class="metric-unit">MB/s</span></div>
</div>
<div class="metric-card">
<div class="metric-label">磁盘 I/O</div>
<div class="metric-value" style="color:#59a14f">${(Math.random() * 50 + 10).toFixed(0)}<span class="metric-unit">MB/s</span></div>
</div>
`;
}
function drawChart() {
ctx.clearRect(0, 0, displayW, displayH);
ctx.save();
ctx.translate(margin.left, margin.top);
const yMin = 0;
const yMax = 100;
// 网格
for (let i = 0; i <= 5; i++) {
const v = yMax * i / 5;
const y = ph - (v / yMax) * ph;
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(pw, y); ctx.stroke();
ctx.fillStyle = '#666'; ctx.font = '10px sans-serif';
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
ctx.fillText(v + '%', -8, y);
}
// 时间标签
const now = new Date();
for (let i = 0; i < 6; i++) {
const x = pw * i / 5;
const sec = Math.round((MAX_POINTS - 1) * (1 - i / 5));
const t = new Date(now.getTime() - sec * updateInterval);
ctx.fillStyle = '#555'; ctx.font = '10px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(t.toLocaleTimeString('zh-CN', {hour:'2-digit',minute:'2-digit',second:'2-digit'}), x, ph + 18);
}
// 绘制函数
function drawSeries(data, color, fillOpacity) {
const xStep = pw / (MAX_POINTS - 1);
const points = data.map((v, i) => ({
x: i * xStep,
y: ph - (v / yMax) * ph
}));
// 面积
ctx.beginPath();
ctx.moveTo(points[0].x, ph);
points.forEach(p => ctx.lineTo(p.x, p.y));
ctx.lineTo(points[points.length - 1].x, ph);
ctx.closePath();
const grad = ctx.createLinearGradient(0, 0, 0, ph);
grad.addColorStop(0, color + fillOpacity);
grad.addColorStop(1, color + '05');
ctx.fillStyle = grad;
ctx.fill();
// 线条
ctx.beginPath();
points.forEach((p, i) => i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y));
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.lineJoin = 'round';
ctx.stroke();
// 最新点高亮
const last = points[points.length - 1];
ctx.beginPath();
ctx.arc(last.x, last.y, 5, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.beginPath();
ctx.arc(last.x, last.y, 8, 0, Math.PI * 2);
ctx.strokeStyle = color + '40';
ctx.lineWidth = 2;
ctx.stroke();
}
drawSeries(memData, '#e15759', '25');
drawSeries(cpuData, '#4ecca3', '25');
// 图例
const legendY = -8;
[{name:'CPU',color:'#4ecca3',x:pw-120},{name:'内存',color:'#e15759',x:pw-50}].forEach(item => {
ctx.strokeStyle = item.color; ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(item.x, legendY); ctx.lineTo(item.x + 18, legendY); ctx.stroke();
ctx.fillStyle = '#aaa'; ctx.font = '11px sans-serif';
ctx.textAlign = 'left';
ctx.fillText(item.name, item.x + 22, legendY + 4);
});
ctx.restore();
}
function addDataPoint() {
if (paused) return;
const lastCpu = cpuData[cpuData.length - 1];
const lastMem = memData[memData.length - 1];
cpuData.push(generateValue(lastCpu, 10, 95, 8));
memData.push(generateValue(lastMem, 30, 90, 4));
if (cpuData.length > MAX_POINTS) cpuData.shift();
if (memData.length > MAX_POINTS) memData.shift();
updateMetrics();
drawChart();
}
function setSpeed(ms) {
updateInterval = ms;
document.querySelectorAll('.ctrl-btn').forEach(btn => btn.classList.remove('active'));
event.target.classList.add('active');
clearInterval(timer);
timer = setInterval(addDataPoint, updateInterval);
}
function togglePause() {
paused = !paused;
}
// 启动
updateMetrics();
drawChart();
timer = setInterval(addDataPoint, updateInterval);
</script>
</body>
</html>示例3:缩放与平移折线图
以下示例实现支持鼠标滚轮缩放和拖拽平移的折线图,适合查看大量时间序列数据。
代码示例
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>缩放与平移折线图</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f7fa;
display: flex;
justify-content: center;
padding: 30px 20px;
}
.chart-container {
background: #fff;
border-radius: 16px;
padding: 30px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
width: 100%;
max-width: 950px;
}
.chart-title { text-align: center; color: #2c3e50; font-size: 20px; margin-bottom: 5px; }
.chart-hint { text-align: center; color: #999; font-size: 13px; margin-bottom: 15px; }
canvas { width: 100%; cursor: grab; }
canvas:active { cursor: grabbing; }
.zoom-info {
text-align: center;
margin-top: 10px;
font-size: 13px;
color: #888;
}
.reset-btn {
display: inline-block;
padding: 4px 12px;
border: 1px solid #ddd;
border-radius: 4px;
background: #fff;
color: #666;
font-size: 12px;
cursor: pointer;
margin-left: 8px;
}
.reset-btn:hover { background: #f0f0f0; }
</style>
</head>
<body>
<div class="chart-container">
<h2 class="chart-title">一年温度数据(支持缩放与平移)</h2>
<p class="chart-hint">鼠标滚轮缩放 / 拖拽平移 / 双击重置</p>
<canvas id="zoomChart"></canvas>
<div class="zoom-info">
当前视图: <span id="viewRange">全部数据</span>
<button class="reset-btn" onclick="resetView()">重置视图</button>
</div>
</div>
<script>
// 生成365天的温度数据
const tempData = [];
const startDate = new Date(2024, 0, 1);
for (let i = 0; i < 365; i++) {
const date = new Date(startDate.getTime() + i * 86400000);
const dayOfYear = i;
// 模拟正弦温度曲线 + 随机波动
const base = 15 + 15 * Math.sin((dayOfYear - 80) / 365 * 2 * Math.PI);
const noise = (Math.random() - 0.5) * 8;
tempData.push({
date,
value: Math.round((base + noise) * 10) / 10,
label: `${date.getMonth() + 1}/${date.getDate()}`
});
}
const canvas = document.getElementById('zoomChart');
const dpr = window.devicePixelRatio || 1;
const displayW = canvas.parentElement.clientWidth - 60;
const displayH = 350;
canvas.width = displayW * dpr;
canvas.height = displayH * dpr;
canvas.style.width = displayW + 'px';
canvas.style.height = displayH + 'px';
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
const margin = { top: 20, right: 20, bottom: 40, left: 50 };
const pw = displayW - margin.left - margin.right;
const ph = displayH - margin.top - margin.bottom;
// 视图状态
let viewStart = 0;
let viewEnd = tempData.length - 1;
let isDragging = false;
let dragStartX = 0;
let dragStartViewStart = 0;
let dragStartViewEnd = 0;
function drawChart() {
ctx.clearRect(0, 0, displayW, displayH);
ctx.save();
ctx.translate(margin.left, margin.top);
const visibleData = tempData.slice(viewStart, viewEnd + 1);
if (visibleData.length === 0) { ctx.restore(); return; }
const values = visibleData.map(d => d.value);
const minVal = Math.min(...values);
const maxVal = Math.max(...values);
const pad = (maxVal - minVal) * 0.1 || 5;
const yMin = Math.floor((minVal - pad) / 5) * 5;
const yMax = Math.ceil((maxVal + pad) / 5) * 5;
// 网格
for (let i = 0; i <= 6; i++) {
const v = yMin + (yMax - yMin) * i / 6;
const y = ph - ((v - yMin) / (yMax - yMin)) * ph;
ctx.strokeStyle = '#f0f0f0'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(pw, y); ctx.stroke();
ctx.fillStyle = '#999'; ctx.font = '11px sans-serif';
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
ctx.fillText(Math.round(v) + 'C', -8, y);
}
// X轴标签
const labelCount = Math.min(12, visibleData.length);
const labelStep = Math.floor(visibleData.length / labelCount);
for (let i = 0; i < visibleData.length; i += labelStep) {
const x = (i / (visibleData.length - 1)) * pw;
ctx.fillStyle = '#888'; ctx.font = '10px sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText(visibleData[i].label, x, ph + 8);
}
// 数据点坐标
const points = visibleData.map((d, i) => ({
x: (i / (visibleData.length - 1)) * pw,
y: ph - ((d.value - yMin) / (yMax - yMin)) * ph
}));
// 面积
ctx.beginPath();
ctx.moveTo(points[0].x, ph);
points.forEach(p => ctx.lineTo(p.x, p.y));
ctx.lineTo(points[points.length - 1].x, ph);
ctx.closePath();
const grad = ctx.createLinearGradient(0, 0, 0, ph);
grad.addColorStop(0, '#4e79a730');
grad.addColorStop(1, '#4e79a705');
ctx.fillStyle = grad;
ctx.fill();
// 折线
ctx.beginPath();
points.forEach((p, i) => i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y));
ctx.strokeStyle = '#4e79a7';
ctx.lineWidth = visibleData.length > 100 ? 1.5 : 2;
ctx.lineJoin = 'round';
ctx.stroke();
// 数据点(数量少时显示)
if (visibleData.length <= 50) {
points.forEach(p => {
ctx.beginPath();
ctx.arc(p.x, p.y, 3, 0, Math.PI * 2);
ctx.fillStyle = '#fff';
ctx.fill();
ctx.strokeStyle = '#4e79a7';
ctx.lineWidth = 1.5;
ctx.stroke();
});
}
// 坐标轴
ctx.strokeStyle = '#ddd'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(0, ph); ctx.lineTo(pw, ph); ctx.stroke();
ctx.restore();
// 更新视图范围信息
const startD = tempData[viewStart].label;
const endD = tempData[viewEnd].label;
document.getElementById('viewRange').textContent = `${startD} - ${endD} (${visibleData.length}天)`;
}
// 缩放
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const mx = (e.clientX - rect.left) * (displayW / rect.width) - margin.left;
const ratio = mx / pw;
const range = viewEnd - viewStart;
const zoomFactor = e.deltaY > 0 ? 1.2 : 0.8;
const newRange = Math.max(10, Math.min(tempData.length - 1, Math.round(range * zoomFactor)));
const center = viewStart + range * ratio;
viewStart = Math.max(0, Math.round(center - newRange * ratio));
viewEnd = Math.min(tempData.length - 1, viewStart + newRange);
viewStart = Math.max(0, viewEnd - newRange);
drawChart();
});
// 平移
canvas.addEventListener('mousedown', (e) => {
isDragging = true;
dragStartX = e.clientX;
dragStartViewStart = viewStart;
dragStartViewEnd = viewEnd;
});
canvas.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const rect = canvas.getBoundingClientRect();
const dx = e.clientX - dragStartX;
const range = dragStartViewEnd - dragStartViewStart;
const shift = -Math.round(dx / pw * range);
viewStart = Math.max(0, Math.min(tempData.length - 1 - range, dragStartViewStart + shift));
viewEnd = viewStart + range;
drawChart();
});
canvas.addEventListener('mouseup', () => isDragging = false);
canvas.addEventListener('mouseleave', () => isDragging = false);
// 双击重置
canvas.addEventListener('dblclick', resetView);
function resetView() {
viewStart = 0;
viewEnd = tempData.length - 1;
drawChart();
}
drawChart();
</script>
</body>
</html>浏览器兼容性
注意事项与最佳实践
数据点密度:数据点过多时隐藏数据点标记,仅显示线条;数据点少时显示标记。
平滑曲线谨慎使用:平滑曲线可能产生"过冲",在数据精确性要求高的场景使用折线。
面积填充透明度:面积填充使用低透明度(10%-30%),避免遮挡其他系列。
实时数据缓冲:实时折线图维护固定长度的数据缓冲区,超出时移除最早的数据。
缩放性能:大数据量缩放时,只渲染视口内的数据点,避免全量绘制。
时间轴格式:根据缩放级别动态调整时间标签格式(年/月/日/时/分)。
缺失数据处理:时间序列中的缺失数据应使用null表示,绘制时跳过而非连接。
多系列层次:先绘制面积再绘制线条,确保线条始终在面积之上。
代码规范示例
代码示例
// 好的做法:折线图配置对象
const lineChartConfig = {
margin: { top: 25, right: 25, bottom: 45, left: 55 },
line: {
width: 2.5,
join: 'round',
cap: 'round',
smooth: true,
tension: 0.3
},
point: {
radius: 4,
hoverRadius: 6,
showThreshold: 50 // 数据点少于50个时显示
},
area: {
enabled: true,
opacity: 0.15
},
animation: {
duration: 1200,
easing: 'easeOutCubic'
},
zoom: {
enabled: true,
minRange: 10,
maxRange: null
}
};
// 好的做法:处理缺失数据
function drawLineWithGaps(ctx, points) {
let drawing = false;
points.forEach((p, i) => {
if (p === null) {
drawing = false;
return;
}
if (!drawing) {
ctx.moveTo(p.x, p.y);
drawing = true;
} else {
ctx.lineTo(p.x, p.y);
}
});
}常见问题与解决方案
问题1:平滑曲线过冲
原因:贝塞尔曲线的控制点计算不当,导致曲线超出数据范围。
解决方案:使用单调三次插值(Monotone Cubic Interpolation),保证曲线不超出相邻数据点的范围。
问题2:实时图表内存泄漏
原因:不断添加数据点但未移除旧数据,数组无限增长。
解决方案:
代码示例
function addDataPoint(buffer, newValue, maxLength) {
buffer.push(newValue);
if (buffer.length > maxLength) {
buffer.shift(); // 移除最早的数据
}
}问题3:多系列折线图交叉时难以追踪
原因:多条线交叉时,用户难以追踪特定系列。
解决方案:
鼠标悬停时高亮当前系列,淡化其他系列
提供图例点击切换系列显示/隐藏
使用不同的线型(实线、虚线、点线)辅助区分
问题4:缩放后X轴标签重叠
原因:缩放后标签间距变小,标签文字重叠。
解决方案:
代码示例
function calculateLabelInterval(visibleCount, maxWidth) {
const labelWidth = 50; // 估计标签宽度
const maxLabels = Math.floor(maxWidth / labelWidth);
return Math.max(1, Math.ceil(visibleCount / maxLabels));
}总结
本教程全面讲解了折线图的各类实现:
基础折线图:单系列折线图的核心绘制逻辑
多系列折线图:多系列对比展示,支持图例切换
面积填充:半透明渐变填充,增强趋势感知
平滑曲线:贝塞尔曲线平滑算法,视觉更柔和
数据点标记:根据数据量动态显示/隐藏
缩放与平移:鼠标滚轮缩放和拖拽平移,适合大数据量浏览
实时数据更新:固定长度缓冲区、定时更新、动画过渡
折线图是时间序列分析的核心工具,掌握其各类变体和交互实现对于构建数据可视化应用至关重要。
常见问题
平滑曲线过冲
贝塞尔曲线的控制点计算不当,导致曲线超出数据范围。
实时图表内存泄漏
- 鼠标悬停时高亮当前系列,淡化其他系列 - 提供图例点击切换系列显示/隐藏 - 使用不同的线型(实线、虚线、点线)辅助区分
多系列折线图交叉时难以追踪
多条线交叉时,用户难以追踪特定系列。
本文由小确幸生活整理发布,转载请注明出处
本文涉及AI创作
内容由AI创作,请仔细甄别