【阶段1收官】day68-topology-screen

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

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 周实时面板迁移)+ 全屏联调——三屏轮播下数据、路由、动效、性能的整体验收,为周五交付做准备。

评论