Canvas 手写实时波形图:一套通用的传感器数据可视化方案

最近在做工业传感器数据的可视化,需要在前端实时绘制波形。找了一圈发现要么库太重(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。


讨论区

你在做传感器可视化时遇到过哪些性能瓶颈?是数据量大卡顿,还是跨域取数麻烦?欢迎交流。

相关推荐
尘中远6 小时前
【Qwt 7.0 系列】快速入门与核心新特性概览 —— 更现代的 Qt 数据可视化库
qt·数据可视化·绘图·qwt·科学绘图
柳杉11 小时前
别再手写大屏了!用这套「3D 地图 + 拖拽脚手架」示例源码,交付效率提升 80%
前端·three.js·数据可视化
尘中远1 天前
【Qwt 7.0 系列】坐标轴与刻度系统 —— 刻度引擎、网格、图例与刻度朝内
qt·数据可视化·qcustomplot·qwt·工业软件·科学绘图
尘中远1 天前
【Qwt 7.0 系列】交互功能详解 —— 平移、缩放、坐标轴交互与数据拾取
qt·数据可视化·绘图·qcustomplot·qwt·科学绘图
尘中远1 天前
【Qwt 7.0 系列】高级科学图表 —— 光谱图、向量场、K线图与极坐标绘图
qt·数据可视化·qwt·工业软件·科学绘图·云图·向量场
江华森2 天前
数据可视化实战:从 seaborn 到 pyecharts 的 7 个动手实验
python·数据可视化
柳杉5 天前
可视化大屏设计器脚手架:从设计到交付的一站式方案
前端·three.js·数据可视化
ZhengEnCi7 天前
P2M-Matplotlib折线图完全指南-从数据可视化到趋势分析的Python绘图利器
python·matlab·数据可视化