最近在做工业传感器数据的可视化,需要在前端实时绘制波形。找了一圈发现要么库太重(Chart.js 塞不进嵌入式页面),要么定制性不够(ECharts 的实时刷新有延迟)。干脆用 Canvas 手写一个,500 行代码搞定,复制到 HTML 文件双击就能跑。
先看效果
把下面代码保存为 .html 文件,用浏览器打开:
- 蓝色波形实时刷新,模拟光谱数据
- 点击「切换信号源」可以在光谱/正弦/方波之间切换
- 点击「噪声开关」看有噪和无噪的对比
- 点击「暂停/继续」冻结当前画面
完整代码
xml
<!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 {
background: #0f172a;
color: #e2e8f0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 20px;
min-height: 100vh;
}
h1 { font-size: 22px; margin-bottom: 8px; font-weight: 600; letter-spacing: -0.5px; }
.subtitle { color: #64748b; font-size: 14px; margin-bottom: 28px; }
#canvas {
border: 1px solid #334155;
border-radius: 12px;
background: #1e293b;
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
}
.controls {
margin-top: 24px;
display: flex;
gap: 12px;
flex-wrap: wrap;
justify-content: center;
}
button {
background: #3b82f6;
color: white;
border: none;
padding: 10px 24px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.2s;
box-shadow: 0 4px 12px rgba(59,130,246,0.3);
}
button:hover {
background: #2563eb;
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(59,130,246,0.4);
}
button:active { transform: translateY(0); }
button.secondary {
background: #475569;
box-shadow: 0 4px 12px rgba(71,85,105,0.3);
}
button.secondary:hover {
background: #334155;
box-shadow: 0 6px 16px rgba(71,85,105,0.4);
}
.info {
margin-top: 20px;
font-size: 13px;
color: #64748b;
font-family: 'SF Mono', 'Courier New', monospace;
background: #1e293b;
padding: 10px 20px;
border-radius: 8px;
border: 1px solid #334155;
}
.legend {
margin-top: 16px;
display: flex;
gap: 24px;
font-size: 13px;
color: #94a3b8;
}
.legend-item { display: flex; align-items: center; gap: 6px; }
.dot { width: 10px; height: 10px; border-radius: 50%; }
.dot.blue { background: #38bdf8; }
.dot.green { background: #4ade80; }
</style>
</head>
<body>
<h1>实时波形显示器</h1>
<div class="subtitle">基于 Canvas 的传感器数据可视化方案 · 零依赖 · 即开即用</div>
<canvas id="canvas" width="800" height="400"></canvas>
<div class="controls">
<button onclick="togglePause()">⏸ 暂停 / 继续</button>
<button class="secondary" onclick="changeSignal()">🔄 切换信号源</button>
<button class="secondary" onclick="toggleNoise()">📡 噪声开关</button>
</div>
<div class="info" id="info">FPS: 60 | 数据点: 200 | 信号源: 模拟光谱</div>
<div class="legend">
<div class="legend-item"><div class="dot blue"></div>波形数据</div>
<div class="legend-item"><div class="dot green"></div>网格基准</div>
</div>
<script>
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const width = canvas.width;
const height = canvas.height;
let isRunning = true;
let showNoise = true;
let signalType = 'spectrum'; // 'spectrum' | 'sine' | 'square'
let frameCount = 0;
let lastTime = performance.now();
// ========== 数据生成层 ==========
// 模拟光谱数据:380-780nm 范围,带一个漂移主峰和噪声
function generateSpectrumData(points = 200) {
const data = [];
const peakWavelength = 550 + Math.sin(Date.now() / 2000) * 50;
for (let i = 0; i < points; i++) {
const wavelength = 380 + (i / points) * 400;
const peak = 200 * Math.exp(-Math.pow((wavelength - peakWavelength) / 40, 2));
const noise = showNoise ? (Math.random() - 0.5) * 30 : 0;
const baseline = 50 + Math.sin(wavelength / 50) * 20;
data.push({ x: wavelength, y: Math.max(0, baseline + peak + noise) });
}
return data;
}
// 正弦波
function generateSineData(points = 200) {
const data = [];
const t = Date.now() / 1000;
for (let i = 0; i < points; i++) {
const x = 380 + (i / points) * 400;
const y = 150 + 100 * Math.sin((x / 20) + t) + (showNoise ? Math.random() * 20 : 0);
data.push({ x, y: Math.max(0, y) });
}
return data;
}
// 方波
function generateSquareData(points = 200) {
const data = [];
const t = Date.now() / 1000;
for (let i = 0; i < points; i++) {
const x = 380 + (i / points) * 400;
const phase = ((x / 50) + t) % (Math.PI * 2);
const y = phase < Math.PI ? 250 : 50;
data.push({ x, y: y + (showNoise ? Math.random() * 20 : 0) });
}
return data;
}
function getData() {
switch(signalType) {
case 'spectrum': return generateSpectrumData();
case 'sine': return generateSineData();
case 'square': return generateSquareData();
default: return generateSpectrumData();
}
}
// ========== 绘制层 ==========
function drawGrid() {
ctx.strokeStyle = '#334155';
ctx.lineWidth = 0.5;
// 竖线
for (let i = 0; i <= 10; i++) {
const x = (i / 10) * width;
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, height); ctx.stroke();
}
// 横线
for (let i = 0; i <= 5; i++) {
const y = (i / 5) * height;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(width, y); ctx.stroke();
}
}
function drawLabels(maxY) {
ctx.fillStyle = '#64748b';
ctx.font = '12px sans-serif';
ctx.fillText('380nm', 12, height - 12);
ctx.fillText('780nm', width - 55, height - 12);
ctx.fillText(`Max: ${maxY.toFixed(0)}`, 12, 24);
// Y轴刻度
for (let i = 0; i <= 5; i++) {
const val = (maxY * 1.2 * (5 - i) / 5).toFixed(0);
ctx.fillText(val, 5, (i / 5) * height + 14);
}
}
function draw() {
// 背景
ctx.fillStyle = '#1e293b';
ctx.fillRect(0, 0, width, height);
drawGrid();
const data = getData();
if (data.length < 2) return;
const maxY = Math.max(...data.map(d => d.y), 1);
// 绘制波形
ctx.strokeStyle = '#38bdf8';
ctx.lineWidth = 2.5;
ctx.lineJoin = 'round';
ctx.beginPath();
data.forEach((point, i) => {
const px = ((point.x - 380) / 400) * width;
const py = height - (point.y / (maxY * 1.2)) * height;
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
});
ctx.stroke();
// 数据点
ctx.fillStyle = '#0ea5e9';
data.forEach(point => {
const px = ((point.x - 380) / 400) * width;
const py = height - (point.y / (maxY * 1.2)) * height;
ctx.beginPath();
ctx.arc(px, py, 2.5, 0, Math.PI * 2);
ctx.fill();
});
drawLabels(maxY);
// FPS 计数
frameCount++;
const now = performance.now();
if (now - lastTime >= 1000) {
const typeName = signalType === 'spectrum' ? '模拟光谱' : signalType === 'sine' ? '正弦波' : '方波';
document.getElementById('info').textContent =
`FPS: ${frameCount} | 数据点: ${data.length} | 信号源: ${typeName} | 噪声: ${showNoise ? '开' : '关'}`;
frameCount = 0;
lastTime = now;
}
}
function animate() {
if (isRunning) draw();
requestAnimationFrame(animate);
}
// ========== 控制层 ==========
function togglePause() {
isRunning = !isRunning;
}
function changeSignal() {
const types = ['spectrum', 'sine', 'square'];
const idx = types.indexOf(signalType);
signalType = types[(idx + 1) % types.length];
}
function toggleNoise() {
showNoise = !showNoise;
}
// 启动
animate();
</script>
</body>
</html>
代码结构拆解
1. 数据生成层
三种信号源共用一套接口:
generateSpectrumData():模拟光谱数据,主峰位置随时间漂移,叠加随机噪声generateSineData():标准正弦波,用于对比平滑信号的绘制效果generateSquareData():方波,测试 Canvas 对突变边缘的绘制性能
yaml
// 核心思路:把业务数据统一成 {x, y} 数组,绘制层完全不关心数据来源
const data = [
{ x: 380.0, y: 1024 },
{ x: 382.0, y: 1056 },
// ...
];
2. 坐标映射
把物理量(波长 380-780nm,强度 0-max)映射到 Canvas 像素坐标:
JavaScript
ini
const px = ((wavelength - 380) / 400) * canvasWidth; // X轴:线性映射
const py = canvasHeight - (intensity / maxIntensity) * canvasHeight; // Y轴:翻转(Canvas 原点在左上角)
这里有个细节:Y 轴做了 maxY * 1.2 的留边,避免波形顶到画布边缘。
3. 性能优化点
- requestAnimationFrame :比
setInterval更稳,帧率自适应屏幕刷新率 - 单 Canvas 分层:没有开多个 Canvas(双缓冲方案),因为 200 个数据点绘制开销极小,单 Canvas 足够
- 避免闭包泄漏 :
animate()用全局状态,不每次创建新函数
4. 可扩展方向
接入真实数据流:
csharp
// 替换 getData() 里的模拟生成逻辑
async function getData() {
const response = await fetch('/api/spectrum');
return await response.json(); // [{x, y}, ...]
}
多通道对比:
ini
// 同时绘制两条波形,用不同颜色区分
ctx.strokeStyle = '#38bdf8'; // 通道 A
// ...draw line A
ctx.strokeStyle = '#f472b6'; // 通道 B
// ...draw line B
导出图片:
ini
const link = document.createElement('a');
link.download = 'spectrum.png';
link.href = canvas.toDataURL();
link.click();
为什么不用现成图表库?
表格
| 方案 | 包体积 | 实时刷新 | 定制性 | 适用场景 |
|---|---|---|---|---|
| ECharts | ~800KB | 有延迟(数据更新需 setOption) | 高 | 复杂交互图表 |
| Chart.js | ~200KB | 一般 | 中 | 常规统计图表 |
| D3.js | ~300KB | 需手动管理 | 极高 | 复杂可视化 |
| 手写 Canvas | 0KB | 60fps 丝滑 | 完全可控 | 实时波形 |
对于传感器波形这种"高频刷新 + 简单几何"的场景,Canvas 原生 API 是最轻最快的方案。
小结
这套代码的核心价值是分层清晰 :数据层负责生成/接入信号,绘制层负责像素映射,控制层负责交互。三层解耦后,换数据源只需改 getData(),换样式只需改 draw(),互不影响。
代码已整理到 GitHub,欢迎 Star 和提 Issue。
讨论区:
你在做传感器可视化时遇到过哪些性能瓶颈?是数据量大卡顿,还是跨域取数麻烦?欢迎交流。