【图表+ECharts】day56-boss-dashboard

作者:mario 发布时间: 2026-09-03 阅读量:5 评论数:0

Day 56 · BOSS 战:工厂设备监控面板 — 图表模块的毕业考核

六天攒下的全部零件今天上生产线:Day 52/53 的 option 骨架与联动、Day 54 的性能武器、Day 55 的四类图表与主题、第 7 周的多层画布与交互内核(大屏壳)。产出是一个工厂设备监控面板——它不是练习,是阶段 2 智慧工厂大屏项目的图表模块预演:第 9 周学完 WebSocket,把模拟数据源换成真实推送,接口不变、零改动上线。


目录


一、目标与验收标准

1.1 面板布局(1920×1080)

┌────────────────────────────────────────────────────────────┐
│  顶部标题栏:XX 工厂设备监控中心          时间  2026-09-01  │
├──────────┬──────────────────────────────┬──────────────────┤
│ KPI 翻牌区│                              │                  │
│ 总产量    │       中央实时曲线区           │  仪表盘区        │
│ 告警数    │   1号炉温度 + 2号炉温度        │  炉温 gauge ×2   │
│ OEE      │   (流式滚动 + 阈值线)        │  压力 gauge ×1   │
│ 设备在线率│                              │                  │
├──────────┼──────────────────────────────┤                  │
│ 能耗排名  │   健康度雷达图                 │                  │
│ (象形柱)  │   (3号机组 vs 厂区平均)        │                  │
└──────────┴──────────────────────────────┴──────────────────┘

1.2 硬性验收指标

指标 标准 检验方法
实时性 数据 1s 更新一次,曲线平滑滚动 肉眼观察 10 分钟
帧率 全面板运行时 ≥ 55fps Performance 录制 30s
内存 连续运行 30 分钟 JS Heap 无持续增长 Memory 堆快照对比
告警联动 温度 ≥ 90 时 gauge 变红 + 曲线超限区有标注 手动触发模拟告警
代码分层 数据源 / 布局 / 图表模块 / 生命周期各自独立文件 目录结构检查

二、架构设计:可拔插数据源是灵魂

2.1 分层架构

DataSource(数据源接口)
   ├── MockDataSource    模拟源(本周用:定时器 + 随机数)
   └── WsDataSource      WebSocket 源(第 9 周实现,本周只留接口)
          │ 推送 Snapshot(统一数据快照)
          ▼
PanelApp(面板应用)
   ├── 订阅快照 → 分发到各图表模块
   ├── 管理生命周期(init / start / stop / dispose)
   ▼
五个图表模块(每个模块 = 一个工厂函数 + 一个 ECharts 实例)

2.2 数据契约(types.ts)

/**
 * 单台设备的状态快照(数据契约:模拟源和 WebSocket 源都必须产出这个结构)
 */
export interface DeviceSnapshot {
  deviceId: string;
  name: string;           // "1号炉"
  temperature: number;    // 温度 ℃
  pressure: number;       // 压力 MPa
  health: number[];       // 健康度五维 [温稳,振动,能耗,故障率,维护]
}

/**
 * 一次推送的完整面板数据
 */
export interface PanelSnapshot {
  ts: number;                  // 时间戳
  devices: DeviceSnapshot[];   // 各设备状态
  kpi: {
    totalOutput: number;       // 总产量
    alarmCount: number;        // 当前告警数
    oee: number;               // 综合设备效率 %
    onlineRate: number;        // 在线率 %
  };
  energyRanking: { name: string; value: number }[];   // 能耗排名
}

/**
 * 数据源接口:模拟源与 WebSocket 源的共同契约
 */
export interface DataSource {
  /** 订阅推送,返回取消订阅函数 */
  subscribe(listener: (snap: PanelSnapshot) => void): () => void;
  /** 启动 / 停止(停止后不再推送) */
  start(): void;
  stop(): void;
}

🎯 为什么这是灵魂:第 9 周的真实接入 = 新写一个 WsDataSource implements DataSource,面板代码一行不改。接口先行的设计在第 1 周 ES Module 分层时就埋过种子——今天是它的收获时刻。


三、布局实现:1920×1080 大屏壳

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>工厂设备监控面板</title>
  <style>
    /* 设计稿基准:1920×1080,等比缩放到任意屏幕 */
    #screen {
      width: 1920px; height: 1080px;
      position: relative;
      background: radial-gradient(ellipse at center, #0d1421 0%, #080c14 100%);
      transform-origin: left top;
      font-family: sans-serif;
      color: #8fa3c0;
    }
    /* Grid 布局划分五个区(阶段 0 的 CSS Grid 直接复用) */
    #panel {
      position: absolute; inset: 70px 20px 20px;
      display: grid;
      grid-template-columns: 420px 1fr 420px;
      grid-template-rows: 1fr 340px;
      gap: 16px;
    }
    .card {
      background: rgba(26, 39, 64, 0.35);
      border: 1px solid rgba(58, 74, 106, 0.4);
      border-radius: 8px;
      padding: 12px;
    }
    #kpi-card   { grid-column: 1; grid-row: 1; display: flex; flex-wrap: wrap; gap: 12px; align-content: flex-start; }
    #line-card  { grid-column: 2; grid-row: 1; }
    #rank-card  { grid-column: 1; grid-row: 2; }
    #radar-card { grid-column: 2; grid-row: 2; }
    #gauge-card { grid-column: 3; grid-row: 1 / 3; display: flex; flex-direction: column; gap: 12px; }
    .card h3 { margin: 0 0 8px; font-size: 16px; color: #c0ccdd; }
    .chart { width: 100%; height: calc(100% - 30px); }
  </style>
</head>
<body>
  <div id="screen">
    <header>XX 工厂设备监控中心 <span id="clock"></span></header>
    <div id="panel">
      <div class="card" id="kpi-card"><h3>关键指标</h3></div>
      <div class="card" id="line-card"><h3>实时温度监控</h3><div class="chart"></div></div>
      <div class="card" id="rank-card"><h3>车间能耗排名</h3><div class="chart"></div></div>
      <div class="card" id="radar-card"><h3>设备健康度评估</h3><div class="chart"></div></div>
      <div class="card" id="gauge-card"><h3>关键设备仪表</h3></div>
    </div>
  </div>
  <script type="module" src="./src/main.ts"></script>
</body>
</html>

缩放函数(Day 55 第 7 节的 fitScreen)和高清初始化(devicePixelRatio × scale)直接复用,此处不重复。


四、模块实现:五个图表区

4.1 KPI 翻牌区(纯 DOM,非 ECharts)

/**
 * KPI 卡片:数值变化时滚动动画(数字翻牌器)
 * 用 DOM 做(4 个数字不值得上 ECharts)
 */
export function createKpiCard(container: HTMLElement): { update: (kpi: PanelSnapshot["kpi"]) => void } {
  const items = [
    { key: "totalOutput", label: "总产量(件)", color: "#00c6ff" },
    { key: "alarmCount",  label: "当前告警",     color: "#ff4d4f" },
    { key: "oee",         label: "设备综合效率", color: "#00ff88" },
    { key: "onlineRate",  label: "设备在线率",   color: "#ffd666" },
  ] as const;

  const els = items.map((it) => {
    const div = document.createElement("div");
    div.style.cssText = "width:calc(50% - 6px);padding:14px;background:rgba(13,20,33,0.6);border-radius:8px;";
    div.innerHTML = `<div style="font-size:12px;color:#6a7a94">${it.label}</div>
                     <div style="font-size:34px;color:${it.color};font-weight:600">--</div>`;
    container.appendChild(div);
    return { el: div.querySelector("div:last-child")!, key: it.key, last: 0 };
  });

  return {
    update(kpi) {
      for (const it of els) {
        const target = kpi[it.key];
        // 数值滚动动画:从上次值插值到新值(300ms)
        animateValue(it.last, target, 300, (v) => {
          it.el.textContent = typeof target === "number" && it.key !== "totalOutput"
            ? `${v.toFixed(1)}${it.key === "alarmCount" ? "" : "%"}`
            : String(Math.round(v));
        });
        it.last = target;
      }
    },
  };
}

/** 数值插值动画(Day 50 的 animate 复用) */
function animateValue(from: number, to: number, dur: number, onTick: (v: number) => void): void {
  const start = performance.now();
  const frame = (now: number): void => {
    const t = Math.min((now - start) / dur, 1);
    onTick(from + (to - from) * (1 - Math.pow(1 - t, 3)));
    if (t < 1) requestAnimationFrame(frame);
  };
  requestAnimationFrame(frame);
}

4.2 中央实时曲线(本周核心模块)

import * as echarts from "echarts";
import type { PanelSnapshot } from "./types";

/**
 * 实时温度曲线:定长滚动窗口 + 增量更新 + 阈值标注
 */
export function createLineChart(
  el: HTMLElement,
  deviceNames: string[]
): { update: (snap: PanelSnapshot) => void } {
  const chart = echarts.init(el, "industrial-dark");
  const WINDOW = 120;   // 滚动窗口:120 个点(约 2 分钟)

  // 各系列的定长缓冲
  const buffers = new Map<string, [number, number][]>();
  deviceNames.forEach((n) => buffers.set(n, []));

  chart.setOption({
    grid: { left: 50, right: 20, top: 40, bottom: 30 },
    tooltip: { trigger: "axis" },
    legend: { top: 5 },
    xAxis: { type: "time" },
    yAxis: {
      type: "value",
      min: 60,
      max: (v: { max: number }) => Math.max(100, v.max + 5),   // 自动扩展但不收缩到阈值线下
    },
    series: deviceNames.map((name) => ({
      type: "line" as const,
      name,
      showSymbol: false,
      smooth: true,
      animation: false,               // ⭐ 实时流关动画(Day 54 坑 3)
      data: [] as [number, number][],
      markLine: name === deviceNames[0] ? {
        symbol: "none",
        data: [{ yAxis: 90, lineStyle: { color: "#ff4d4f", type: "dashed" },
                 label: { formatter: "告警阈值 90℃" } }],
      } : undefined,
    })),
  });

  return {
    update(snap) {
      for (const d of snap.devices) {
        const buf = buffers.get(d.name);
        if (!buf) continue;
        buf.push([snap.ts, d.temperature]);
        if (buf.length > WINDOW) buf.shift();   // 定长:砍掉最旧
      }
      // 局部更新:只传 series.data(setOption 合并机制,Day 52 第五节)
      chart.setOption({
        series: deviceNames.map((n) => ({ data: [...buffers.get(n)!] })),
      });
    },
  };
}

💡 为什么这里不用 appendData:定长窗口需要"删旧点",appendData 只能追加(Day 54 第 6 节的限制)。120 个点的全量替换成本可忽略——技术选型跟着约束走,不跟着"更高级"走

4.3 其余三个模块

  • 仪表盘区createGaugeRow — 三个 Day 55 进度环(2 温度 + 1 压力),ringColor 状态色 + valueAnimation
  • 能耗排名createRankChart — Day 55 双层 pictorialBar,数据按 value 降序后 setOption
  • 健康雷达createRadarChart — Day 55 雷达双系列,snap.devices[2].health vs 固定厂区均值

三个模块都是"init + 返回 update(snap)"的同构结构——统一的生命周期形状让组装代码极简


五、数据流:模拟源的完整实现

import type { DataSource, PanelSnapshot } from "./types";

/**
 * 模拟数据源:定时器驱动的传感器仿真
 * ⚠️ 仅开发/测试环境使用,生产环境接入 WsDataSource(第 9 周)
 * 仿真模型:正弦基线 + 噪声 + 小概率尖峰(模拟偶发工况波动)
 */
export class MockDataSource implements DataSource {
  private timer = 0;
  private listeners: ((s: PanelSnapshot) => void)[] = [];
  private tick = 0;

  /** 订阅推送 */
  subscribe(listener: (s: PanelSnapshot) => void): () => void {
    this.listeners.push(listener);
    return () => {
      this.listeners = this.listeners.filter((l) => l !== listener);
    };
  }

  start(): void {
    if (this.timer) return;
    this.timer = window.setInterval(() => this.emit(), 1000);
  }

  stop(): void {
    clearInterval(this.timer);
    this.timer = 0;
  }

  /** 生成并推送一帧快照 */
  private emit(): void {
    this.tick++;
    const t = Date.now();
    // 温度:80 ± 正弦波动 ± 噪声,万分之五概率尖峰到 92(触发告警色)
    const temp = (base: number) => {
      const wave = 5 * Math.sin(this.tick / 40 + base);
      const noise = (Math.random() - 0.5) * 2;
      const spike = Math.random() < 0.0005 ? 12 : 0;
      return Number((base + wave + noise + spike).toFixed(1));
    };

    const snap: PanelSnapshot = {
      ts: t,
      devices: [
        { deviceId: "f1", name: "1号炉", temperature: temp(80), pressure: 2.1 + Math.random() * 0.2,
          health: [92, 68, 85, 74, 90] },
        { deviceId: "f2", name: "2号炉", temperature: temp(78), pressure: 2.0 + Math.random() * 0.2,
          health: [88, 75, 80, 82, 76] },
        { deviceId: "f3", name: "3号机组", temperature: temp(82), pressure: 2.3 + Math.random() * 0.2,
          health: [90, 72, 88, 79, 92] },
      ],
      kpi: {
        totalOutput: 125800 + this.tick * 3,
        alarmCount: Math.random() < 0.03 ? 1 : 0,
        oee: 76 + Math.sin(this.tick / 60) * 4,
        onlineRate: 98 + Math.random() * 1.5,
      },
      energyRanking: [
        { name: "3车间", value: 98 }, { name: "1车间", value: 85 },
        { name: "4车间", value: 72 }, { name: "2车间", value: 60 },
      ].map((r) => ({ ...r, value: r.value + Math.random() * 3 })),
    };

    this.listeners.forEach((l) => l(snap));
  }
}

六、实时更新策略与性能验收

6.1 更新频率分级(不是所有模块都要每秒刷)

模块 更新频率 原因
实时曲线 每帧快照(1s) 趋势本命
gauge 每帧(动画 800ms 衔接 1s 间隔) 指针连续感
KPI 每 5 帧一次 数字变化太快看不清
雷达 每 30 帧一次(约 30s) 健康度是慢变量
排名 每 60 帧一次 排名更慢,且频繁重排晃眼
/** 组装:按频率分发(帧计数取模) */
let frame = 0;
const unsubscribe = source.subscribe((snap) => {
  lineChart.update(snap);
  gaugeRow.update(snap);
  if (frame % 5 === 0) kpiCard.update(snap.kpi);
  if (frame % 30 === 0) radarChart.update(snap);
  if (frame % 60 === 0) rankChart.update(snap);
  frame++;
});

🎯 分级更新的本质是"信息变化速率匹配渲染速率"——慢变量高频刷新是浪费(还闪眼),这是大屏性能与体验的双重优化。

6.2 性能验收实测(参考值,i5-12400P / Chrome 126)

指标 实测 达标
稳态帧率 58~60fps
单次快照处理耗时 < 4ms(5 个图表合计)
30 分钟 JS Heap 稳定在 ±2MB 波动
GPU 内存(canvas) 无持续增长

七、生命周期与内存

/**
 * 面板应用:统一生命周期入口
 * 单页应用 / 大屏轮播场景都需要能干净地启动和销毁
 */
export class PanelApp {
  private source: DataSource;
  private charts: echarts.ECharts[] = [];
  private unsub: (() => void) | null = null;

  constructor(private root: HTMLElement) {
    this.source = new MockDataSource();   // ⭐ 第 9 周换 WsDataSource 只改这一行
  }

  start(): void {
    // 初始化各模块(细节见第四节)……
    // this.charts.push(...)
    this.source.start();
  }

  /** 干净销毁:停数据 → 退订 → 逐个 dispose → 清 DOM */
  destroy(): void {
    this.source.stop();
    this.unsub?.();
    for (const c of this.charts) c.dispose();   // Day 54 的 dispose 铁律
    this.charts = [];
    this.root.innerHTML = "";
  }
}

八、常见坑点

坑 1:曲线窗口"越滚越卡"

缓冲数组没做定长(只 push 不 shift),数据无限增长。修复:buf.length > WINDOW && buf.shift()(见 4.2)。

坑 2:y 轴范围抖动

自动范围随每个点伸缩,曲线上下弹跳。修复:max 用函数式"只增不减"或干脆固定窗口(min: 60),见 4.2。

坑 3:gauge 动画抽搐

1s 更新 + 默认 1s 动画永远播不完。修复:animationDuration: 800,低于数据间隔。

坑 4:排名频繁重排晃眼

排名数据微抖导致第 1、2 名每秒互换位置。修复:降频(60 帧一次)+ 排名数据加滞回(差距 < 5 不换序)。

坑 5:销毁后定时器还在推送

destroy() 忘了 source.stop(),listener 已退订但定时器空转(KPI 模块若直接改 DOM 还会报错)。修复:生命周期函数按 7 的顺序严格执行。

坑 6:多实例 init 到同一容器

大屏轮播切换时重复 init。修复:destroy 里 dispose + 清 DOM,或 getInstanceByDom 查重复用。


九、自测挑战

T1 · 完整跑通面板(90 分钟)

按第二~五节实现完整面板:大屏壳 + 五模块 + 模拟源 + 分频更新。今天核心作业。验收跑 1.2 的全部指标。

T2 · 告警状态联动(30 分钟)

任一设备温度 ≥ 90 时:对应 gauge 变红 + KPI 告警数 +1 + 曲线该点用 visualMap 或 markPoint 标红点。温度回落后全部恢复。

T3 · 历史回放模式(进阶,45 分钟)

加"实时 / 回放"切换:回放模式用时间倍速(1×/8×/32×)重放最近 10 分钟缓冲数据。思考:DataSource 接口需要扩展吗?还是应该独立于数据源实现?(提示:回放是"消费端节奏问题",不是"生产端问题"——加在 PanelApp 层。)

T4 · 打包与体积(20 分钟)

Vite 生产构建,对比全量引入 vs 按需引入(echarts/core)的 dist 体积。目标:按需版 < 400KB gzipped 400KB 级别以下。


十、总结与第 8 周毕业

10.1 本周知识的全景落位

知识 在面板里的位置
Day 50/51 手写图表 你看懂了每个配置项背后的事
Day 52 option/合并机制 所有模块的局部更新(setOption({ series: [{ data }] })
Day 53 dataset/联动/markLine 曲线阈值线、雷达对比(如做了多 grid 版则为轴联动)
Day 54 性能武器 实时流关动画、定长窗口、分级更新、dispose
Day 55 四类图表 + 主题 gauge / radar / pictorialBar / industrial-dark
第 7 周大屏壳知识 transform scale + devicePixelRatio 高清适配
第 1 周分层思想 DataSource 接口 + 模块化目录结构

10.2 毕业检查清单

  • [ ] 五模块面板跑通,30 分钟稳定(帧率 + 内存双指标截图)
  • [ ] DataSource 接口清晰:MockDataSource 可完整替换为 WsDataSource
  • [ ] 生命周期干净:destroy 后无定时器泄漏、无 DOM 残留
  • [ ] GitHub 推送 + README(GIF 演示 + 性能数据表)
  • [ ] 博客《工业监控面板:ECharts 从选型到性能》——把 Day 54 压测表和今天的实测数据都放进去

10.3 下周预告:第 9 周(Day 57-63)WebSocket 实时通信

本周的 MockDataSource 下周迎来真身:

  • Day 57-58:WebSocket 协议与 API(握手、心跳、重连策略)
  • Day 59:二进制协议(ArrayBuffer/TypedArray——传感器高频数据编码)
  • Day 60:MQTT 与工业物联网协议全景(为什么工厂用 MQTT 不用裸 WS)
  • Day 61:断线重连 + 数据补传 + 消息队列缓冲
  • Day 62:性能:高频消息的批处理与节流(对接本周 appendData 模式)
  • Day 63:BOSS 战——把本周面板的 MockDataSource 换成真实 WsDataSource,接本地 WebSocket 服务器,完成"模拟→实时"的最后一块拼图

第 8 周毕业。面板跑起来的那一刻,你已经站在阶段 2 智慧工厂大屏项目的门口——图表模块就绪,只差实时数据这股东风。

评论