Day 56 · BOSS 战:工厂设备监控面板 — 图表模块的毕业考核
六天攒下的全部零件今天上生产线:Day 52/53 的 option 骨架与联动、Day 54 的性能武器、Day 55 的四类图表与主题、第 7 周的多层画布与交互内核(大屏壳)。产出是一个工厂设备监控面板——它不是练习,是阶段 2 智慧工厂大屏项目的图表模块预演:第 9 周学完 WebSocket,把模拟数据源换成真实推送,接口不变、零改动上线。
目录
- 一、目标与验收标准
- 二、架构设计:可拔插数据源是灵魂
- 三、布局实现:1920×1080 大屏壳
- 四、模块实现:五个图表区
- 五、数据流:模拟源的完整实现
- 六、实时更新策略与性能验收
- 七、生命周期与内存
- 八、常见坑点
- 九、自测挑战
- 十、总结与第 8 周毕业
一、目标与验收标准
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].healthvs 固定厂区均值
三个模块都是"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 智慧工厂大屏项目的门口——图表模块就绪,只差实时数据这股东风。