Day 68 · 拓扑图页整合 — 让第 7 周的编辑器在实时数据里活过来
今天是本周最有意思的一次迁移:第 7 周的流程图编辑器是"用户拖节点建图",大屏拓扑页要的是"配置定义图 + 数据驱动状态"。同一个渲染内核,两种工作模式——这正好验证当初分层设计(model/render/interact 分离)的前瞻性:换工作模式时,渲染层原样保留,交互层按需裁剪。今天的目标:屏 C 跑起来后,看到 1 号炉温度超限的瞬间,拓扑上对应节点红边脉冲、连线变红——图形在替数据说话。
目录
一、模式转变:编辑器 → 展示态
1.1 两种工作模式对比
| 维度 | 编辑模式(第 7 周) | 展示模式(今天) |
|---|---|---|
| 图结构来源 | 用户拖拽创建 | 配置文件定义 |
| 节点颜色 | 选中态高亮 | 实时数据驱动 |
| 交互 | 拖拽/框选/连线/删除 | hover 看详情 + 漫游(保留) |
| 重绘触发 | 用户操作 | 数据更新(2s 一次) |
| 动画 | 无常驻 | 告警脉冲(数据驱动) |
1.2 什么保留、什么封存
保留(迁移):GraphModel(改喂配置)、Renderer 三层画布、
命中检测、viewport 漫游(roam)
封存(留在原项目):InteractionManager 的拖拽/框选/连线模式、
锚点热区、Delete 键、双击建节点
🎯 封存 ≠ 删除:编辑能力以后还有用(阶段 2 后期的"组态编辑"功能就是它)。原仓库保留完整编辑器,大屏项目里只带展示态——"同一内核,按需装配"是本周迁移的主旋律。
二、拓扑配置:图结构的声明化
2.1 配置 schema
/**
* 产线拓扑配置(core/types.ts 扩展)
* 与 FlowNode 的区别:deviceId 建立了"图节点 ↔ 数据设备"的映射——状态驱动的钥匙
*/
export interface TopoNode {
id: string;
deviceId: string; // ⭐ 关联 PanelSnapshot.devices 里的设备
label: string;
x: number; y: number; // 固定布局(Day 60 的结论:产线位置是物理事实)
w: number; h: number;
}
export interface TopoEdge {
source: string; // 节点 id
target: string;
}
export interface TopologyConfig {
nodes: TopoNode[];
edges: TopoEdge[];
}
2.2 示例配置(三车间产线)
/**
* 产线拓扑配置:钢轧产线(示例)
* 坐标按 1600×760 逻辑区域布置(屏 C 内容区)
*/
export const TOPOLOGY: TopologyConfig = {
nodes: [
{ id: "n1", deviceId: "feeder", label: "原料上料", x: 80, y: 300, w: 150, h: 64 },
{ id: "n2", deviceId: "f1", label: "1号加热炉", x: 340, y: 300, w: 150, h: 64 },
{ id: "n3", deviceId: "f2", label: "2号加热炉", x: 340, y: 480, w: 150, h: 64 },
{ id: "n4", deviceId: "mill", label: "轧制机组", x: 620, y: 390, w: 150, h: 64 },
{ id: "n5", deviceId: "f3", label: "3号机组", x: 900, y: 390, w: 150, h: 64 },
{ id: "n6", deviceId: "qc", label: "在线质检", x: 1180, y: 300, w: 150, h: 64 },
{ id: "n7", deviceId: "pack", label: "打包入库", x: 1180, y: 480, w: 150, h: 64 },
],
edges: [
{ source: "n1", target: "n2" },
{ source: "n1", target: "n3" },
{ source: "n2", target: "n4" },
{ source: "n3", target: "n4" },
{ source: "n4", target: "n5" },
{ source: "n5", target: "n6" },
{ source: "n5", target: "n7" },
],
};
2.3 服务器扩展:拓扑设备的数据
设备模拟器要为拓扑里的 deviceId 生成状态(在 Day 63 的三台设备基础上补全):
/**
* 快照扩展:拓扑节点状态(服务器端判定,客户端只消费——Day 65 的"业务不进 Store"纪律)
*/
function deviceState(deviceId: string, temperature: number): "ok" | "warn" | "danger" {
if (temperature >= 90) return "danger";
if (temperature >= 80) return "warn";
return "ok";
}
// generateSnapshot 的 devices 数组扩展 state 字段:
// { deviceId: "f1", ..., state: deviceState("f1", temp) }
三、状态驱动渲染:数据到颜色的映射
3.1 渲染循环改造
原编辑器是"事件驱动重绘"(用户操作才画),展示态是"数据驱动重绘"(订阅 Store):
/**
* 拓扑渲染器:第 7 周 Renderer 的展示态改造
* 改动点:drawNode 增加 state 参数;重绘入口改为 update(states)
*/
export class TopoRenderer {
// …(三层画布初始化、viewport 等原样迁移,略)
/**
* 数据驱动重绘入口(Store 订阅回调里调用)
* @param states deviceId → 当前状态
* @param temps deviceId → 最新温度(hover 详情用)
*/
update(states: Map<string, DeviceState>, temps: Map<string, number>): void {
this.drawEdges(states); // 连线颜色跟随两端节点状态
this.drawNodes(states, temps);
}
/** 节点绘制:按状态取色(token 同源,Day 66 的 palette) */
private drawNode(n: TopoNode, state: DeviceState, temp: number): void {
const ctx = this.layerNodes;
const vp = this.vp;
const x = n.x * vp.scale + vp.offsetX;
const y = n.y * vp.scale + vp.offsetY;
// 状态色映射:ok 常规 / warn 黄 / danger 红底脉冲(动画见第四节)
const color =
state === "danger" ? PALETTE.red :
state === "warn" ? PALETTE.yellow :
PALETTE.blue;
ctx.save();
// 底
ctx.fillStyle = state === "danger" ? "rgba(255,77,79,0.18)" : "rgba(26,39,64,0.9)";
ctx.strokeStyle = color;
ctx.lineWidth = state === "ok" ? 1 : 2;
roundRectPath(ctx, x, y, n.w, n.h, 8);
ctx.fill();
ctx.stroke();
// 标签(Day 43 的 ellipsize 迁移复用)
ctx.fillStyle = "#e0e8f0";
ctx.font = "14px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(this.ellipsize(ctx, n.label, n.w - 16), x + n.w / 2, y + n.h / 2 - 8);
// 副标签:实时温度(拓扑页的信息增量——图不止是结构,还有状态)
ctx.fillStyle = color;
ctx.font = "12px " + FONT_NUM;
ctx.fillText(`${temp.toFixed(1)}℃`, x + n.w / 2, y + n.h / 2 + 12);
ctx.restore();
}
/** 连线:任一端 danger 则整条线红 */
private drawEdges(states: Map<string, DeviceState>): void {
for (const e of this.config.edges) {
const s = nodeById(e.source), t = nodeById(e.target);
const hot =
states.get(s.deviceId) === "danger" || states.get(t.deviceId) === "danger";
// 贝塞尔连线(Day 49 的 drawEdge 迁移)
drawEdge(this.layerEdges, anchorOf(s, "right"), anchorOf(t, "left"),
"right", this.vp, hot ? PALETTE.red : "#3a4a6a");
}
}
}
3.2 状态映射的取舍
| 方案 | 说明 | 采用 |
|---|---|---|
| 服务器下发 state | 阈值逻辑在源头,改阈值不用发版前端 | ✅ |
| 前端按温度算 | 离线可判,但阈值硬编码进视图 | ❌(仅作降级兜底) |
四、告警脉冲动效
danger 节点要"跳出来",但遵守 Day 67 的动效预算(Canvas 侧实现):
/**
* 告警节点脉冲:半径 6→10 的呼吸圈,1.2s 周期
* 实现要点:只画一个圆环(成本极低);页面 hidden 时整个更新循环本来就停了
* (屏 C 的数据订阅在 deactivate 时退订——Day 65 的闸门自动覆盖)
*/
private drawPulse(x: number, y: number, w: number, h: number, now: number): void {
const ctx = this.layerNodes;
const t = (now / 1200) % 1; // 1.2s 周期
const r = 6 + t * 6; // 6 → 12
const alpha = 0.5 * (1 - t); // 同步淡出
ctx.save();
ctx.strokeStyle = `rgba(255,77,79,${alpha.toFixed(2)})`;
ctx.lineWidth = 2;
ctx.beginPath();
// 脉冲圈画在节点右上角(角标位置,不遮挡标签)
ctx.arc(x + w - 6, y + 6, r, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
// 更新循环:有 danger 节点时才需要持续重绘(rAF),否则数据驱动单帧即可
private animatePulse(states: Map<string, DeviceState>): void {
const hasDanger = [...states.values()].includes("danger");
if (!hasDanger) { cancelAnimationFrame(this.pulseRaf); return; }
const loop = (now: number): void => {
this.update(states, this.lastTemps); // 重绘全部(含脉冲)
this.pulseRaf = requestAnimationFrame(loop);
};
cancelAnimationFrame(this.pulseRaf);
this.pulseRaf = requestAnimationFrame(loop);
}
🎯 动效预算的 Canvas 版纪律:只有 danger 存在时才跑 rAF 循环;状态恢复即停——动画的存在由数据决定,而不是常开。
五、交互的保留与裁剪
5.1 保留:hover 详情 + 漫游
大屏拓扑仍需要两个交互(值班员的本能操作):
/**
* 展示态交互:hover 显示设备详情浮窗(复用 Day 48 的 hitTest)
*/
canvas.addEventListener("mousemove", (e) => {
const hit = hitTest(e.offsetX, e.offsetY, this.config, this.vp);
if (hit) {
const detail = this.deviceDetail.get(hit.deviceId);
showTooltip(e.offsetX, e.offsetY, `
<b>${hit.label}</b><br/>
温度:${detail.temp.toFixed(1)}℃<br/>
状态:${STATE_TEXT[detail.state]}
`);
} else {
hideTooltip();
}
});
5.2 漫游(缩放平移)
Day 49 的滚轮缩放 + 空格平移代码原样迁移,但调整两点:
// 1. 缩放范围收窄:展示态不需要 0.1~10 的极端缩放
vp.scale = Math.min(2, Math.max(0.6, vp.scale * factor));
// 2. 双击画布 → 复位视图(值班员迷路时的救命键)
canvas.addEventListener("dblclick", () => this.resetViewport());
六、组装屏 C
/**
* 屏 C:产线拓扑
* 生命周期严格遵循 Day 65 的标准屏幕模块模式
*/
export function createTopologyScreen(
el: HTMLElement,
store: ScreenStore
): ScreenModule {
const card = createTechCard("产线拓扑 · 实时状态");
el.appendChild(card.root);
const renderer = new TopoRenderer(card.body, TOPOLOGY);
let unsub: (() => void) | null = null;
return {
activate() {
// 拓扑是慢变量:2s 一次足够(Store 分频参数)
unsub = store.subscribe({
minIntervalMs: 2000,
onSnapshot: (snap) => {
// 快照 → 两张查找表(deviceId → state / temp)
const states = new Map(snap.devices.map((d) => [d.deviceId, d.state]));
const temps = new Map(snap.devices.map((d) => [d.deviceId, d.temperature]));
renderer.update(states, temps);
},
});
},
deactivate() {
unsub?.();
renderer.stop(); // 停脉冲 rAF 等常驻循环
},
};
}
验收场景(服务器把 f1 温度尖峰概率调高后):
观察拓扑页:1 号加热炉节点变 warn 黄 → 温度破 90 变 danger 红
→ 右上角脉冲圈呼吸 → 出炉连线变红 → 告警列表(屏 A)同步出现记录
→ 温度回落 → 全部联动恢复蓝
这一条链路就是"数据驱动图形"的完整叙事——录进演示视频,它是最有说服力的 10 秒。
七、常见坑点
坑 1:拓扑 2s 更新一次导致 hover 详情数据过期
hover 浮窗显示的是 2 秒前的温度。修复:hover 时单独订阅一次 store.subscribe(默认 1s)拿最新值,或接受 2s 滞后并在浮窗标注更新时间。选后者更简单,且诚实。
坑 2:脉冲 rAF 在 deactivate 后还在跑
切屏后拓扑不可见但红圈还在后台画。修复:deactivate 里 renderer.stop()(第六节代码已含)。
坑 3:编辑器代码整体拷贝后 TS 报一堆交互层错误
封存的 InteractionManager 没拷干净、引用悬空。修复:迁移清单制——逐文件核对依赖,只拷渲染/命中/viewport 三块。
坑 4:节点温度标签用非等宽字体
85.3℃ 和 100.0℃ 宽度不同,2s 更新时标签抖动。修复:温度数字用 --font-num(Day 66 的纪律在 Canvas 侧同样适用)。
坑 5:缩放后命中偏移
Day 48 的老坑在迁移中复活(漏了 toScene 逆变换)。修复:hover 命中前先做 viewport 逆变换——旧坑复查是迁移日的必修课。
八、自测挑战
T1 · 屏 C 完整跑通(70 分钟)
拓扑配置 + TopoRenderer 展示态 + 状态联动 + hover 详情 + 漫游,完成第六节验收场景录屏。今天核心作业。
T2 · 多产线切换(25 分钟)
配置第二套拓扑(如铸造线),卡片标题栏加 Tab 切换两套配置(渲染器重建 vs 复用清空重画,选一并说明理由,写 100 字进笔记)。
T3 · 脉冲性能验证(15 分钟)
3 个 danger 节点同时脉冲时 Performance 录制:帧率不降(单圆环的绘制成本几乎为零,验证"低成本高注意力"的动效设计)。
T4 · 拓扑布局工具(进阶,45 分钟)
写一个一次性脚本:输入节点列表,自动生成分层坐标(按 edges 做拓扑排序分层,同层均分 y)。这就是组态编辑器的雏形——记入第 3 个月待办,本周只出脚本不做界面。
九、总结
| 迁移项 | 来源 | 改造 |
|---|---|---|
| 三层画布渲染 | Day 47/49 Renderer | 重绘入口改数据驱动 |
| 贝塞尔连线 | Day 49 drawEdge | 颜色跟随节点状态 |
| 命中检测 | Day 48 hitTest | 只留 hover 详情 |
| viewport 漫游 | Day 49 | 缩放范围收窄 + 双击复位 |
| ellipsize 标签 | Day 43 | 副标签显示实时温度 |
| 脉冲动效 | 新增 | 数据存在才动画的 Canvas 纪律 |
屏 C 就位。明天搬最后一块:屏 B(第 9 周实时面板迁移)+ 全屏联调——三屏轮播下数据、路由、动效、性能的整体验收,为周五交付做准备。