【MES系统】- 工业HMI状态机可视化实践:从手写SVG到Cytoscape.js的技术选型与踩坑 基于 Preact Cytoscape.js 构建实时状态机可视化组件从 SVG 手写到 ReactFlow 踩坑最终用 Cytoscape.js 实现工业级状态流转图的完整历程。需求场景在工业 HMI 应用中需要将 PLC 上报的设备运行状态实时渲染为可视化流程图。状态节点约 20 个之间存在多条流转路径含回环和分支通过 PLC 寄存器位地址上报当前激活状态。核心要求实时反映设备当前所处状态绿色高亮连线清晰展示状态跳转关系部分边上附带操作图标支持拖拽微调节点布局位置自动保存适配工控触摸屏1024×768状态流转图┌──────────────────────────────────────────┐ │ Pause Branch │ ▼ │ Idle ──▶[▶]──▶ Starting ──▶ Running ──▶ Completing ──▶ Complete │ ▲ │ ▲ ▲ │ │ │ │ │ │ ▼ │ │ │ │ └─── Resuming ◄── Paused ◄── Pausing │ │ │ │ │ │ └─── Releasing ◄── Suspended ◄── Suspending │ │ [−] [⏸] │ │ │ │ │ └─────────────────────────────────────────┘ │ │ ┌──────────────────────────────────────────┐ │ │ Stop / Reset Branch │ │ ▼ │ └── Resetting ◄── Stopped ◄── Stopping ◄── Clearing ◄── Aborting ◄──┘ [↻] [■] [↻] ▶ Start ⏸ Pause − Release ↻ Reset ■ Stop17 个节点19 条边6 条关键边上附带操作图标标记。主流程横向展开暂停/挂起分支从 Running 分出并回环中止路径从 Complete 下折进入停止复位分支最终回到 Idle。第一版手写 SVG最初用纯 SVG 手写节点和连线rect做节点、line和polyline做连线、marker做箭头。很快发现问题坐标全靠手工微调改一个节点连带调整五六条连线折线和回环路径在 SVG 中调试极其痛苦节点、连线、箭头频繁重叠反复调整仍不理想所有样式属性散落在 JSX 中可维护性差第二版ReactFlow失败ReactFlowxyflow/reactv12拥有自定义节点/边组件、内置 Dagre 布局、触摸支持等特性。但集成两天后发现ReactFlow v12 深度依赖 React 内部机制Context、ResizeObserver、Handle 系统Preact 的preact/compat兼容层无法完全复现这些行为致命问题容器尺寸检测持续失败 ——The parent container needs a width and a height to render the grapherror #004尝试了绝对定位、ref 测量、requestAnimationFrame延迟渲染等方案均无法解决结论ReactFlow v12 和 Preact 存在深度不兼容不推荐在 Preact 项目中使用。第三版Cytoscape.js最终方案Cytoscape.js 是一个纯 JavaScript 图论可视化库框架无关直接挂载到 DOM 容器npminstallcytoscape cytoscape-dagre三项关键优势解决了之前的所有问题痛点Cytoscape.js 方案框架兼容纯 JSnew cytoscape({ container })直挂 DOM自动布局cytoscape-dagre插件自动层次布局连线渲染贝塞尔曲线 箭头内建零手工坐标缩放/拖拽内置minZoom/maxZoom拖拽 位置持久化动效animation()API 样式过渡系统样式系统CSS-like 选择器声明式管理节点/边样式架构设计PLC / 数据源 ↓ 轮询数据 Global Signal (Preact signals) ↓ subscribe() ├─ 监控面板 → 历史记录 事件日志 └─ 状态机页面 → Cytoscape 图 实时高亮关键设计决策轮询数据走全局 signal不在各组件内独立订阅。顶层页面在数据源连接成功时自动启动全局轮询数据写入 signal所有消费者组件统一读取。这样即使状态机页面未被打开轮询数据也在积累打开时立即可见历史数据。核心实现1. 全局信号// 连接状态 — 顶层页面统一管理exportconstconnectionSignalsignal({connected:false,connectionId:null});// 轮询数据 — 连接建立后自动开始exportconstpollDataSignalsignal({connectionId:null,items:[],lastRefresh:null,active:false,});2. Dagre 自动布局 横向拉伸Dagre 的输出天然偏纵向——列多行少时浪费水平空间。解决方式布局完成后等比拉伸 x 坐标。_stretchLayout(){constnodescy.nodes();constpositionsnodes.map(nn.position());constminXMath.min(...positions.map(pp.x));constmaxXMath.max(...positions.map(pp.x));constscalecontainerW*0.9/(maxX-minX);nodes.forEach(n{n.position({x:(n.position().x-minX)*scalecontainerW*0.05,y:n.position().y});});}3. CSS-like 样式系统constSTYLE[// 节点默认{selector:node,style:{shape:roundrectangle,background-color:#FFFFFF,border-color:#b0b8c4,border-width:2,width:120,height:44,font-size:15px,color:#2d3748,shadow-blur:8,shadow-color:#b0bec5,shadow-opacity:0.35,},},// 节点激活 — 叠加样式{selector:node.active,style:{background-color:#27ae60,border-color:#1e8449,color:#FFFFFF,shadow-color:#27ae60,shadow-blur:16,shadow-opacity:0.55,},},// 边 — 科技感虚线{selector:edge,style:{width:2.5,line-color:#3a5a8c,target-arrow-color:#3a5a8c,target-arrow-shape:triangle,arrow-scale:1.4,curve-style:bezier,line-style:dashed,line-dash-pattern:[8,5],},},// 边图标彩色圆角徽章{selector:edge[icon],style:{label:data(label),font-size:9px,color:#fff,text-background-opacity:1,text-background-shape:roundrectangle,text-background-padding:3px,edge-text-rotation:autorotate,},},{selector:edge[iconstart],style:{text-background-color:#27ae60}},{selector:edge[iconpause],style:{text-background-color:#e67e22}},{selector:edge[iconstop],style:{text-background-color:#e74c3c}},{selector:edge[iconreset],style:{text-background-color:#4a8fdd}},];4. 边图标标记边的data中设置icon类型和对应的 Unicode 符号labelconstedges[{source:Idle,target:Starting,icon:start,label:▶},{source:Running,target:Suspending,icon:pause,label:⏸},{source:Aborted,target:Clearing,icon:reset,label:↻},{source:Clearing,target:Stopping,icon:stop,label:■},];5. 实时状态高亮_processPollItems(items){for(constitemofitems){constidxthis._matchState(item);if(idx0)continue;constnodethis._cy.getElementById(STATE_LIST[idx]);if(item.value){if(!node.hasClass(active)){node.addClass(active);this._startPulse(node);// 呼吸脉冲}}else{node.removeClass(active);// PLC 位变 false → 立即恢复}}}关键点每个位独立处理 true / false。valuetrue→ 加 active class 脉冲valuefalse→ 移除 active class。避免只处理 true 导致状态无法清除的 bug。6. 边虚线流动动效_startEdgeFlow(){constedgesthis._cy.edges();letoffset0;conststep(){offset(offset-113)%13;edges.style(line-dash-offset,offset);this._flowFramerequestAnimationFrame(step);};this._flowFramerequestAnimationFrame(step);}line-style: dashedline-dash-pattern 每帧微调offset产生数据沿线路流动的视觉效果。注意必须显式设置line-style: dashed——仅设line-dash-pattern不生效。另外curve-style: taxi正交折线不支持虚线需用bezier。7. 节点呼吸脉冲_startPulse(node){constw120,h44;construn(){if(!node.hasClass(active))return;// 状态变更时自动停止node.animation({style:{width:w8,height:h5},duration:800}).play().promise(complete).then((){node.animation({style:{width:w,height:h},duration:800}).play().promise(complete).then(()run());});};run();}放大→缩小循环通过hasClass(active)检测自动停止无需手动管理动画引用。8. 拖拽 位置持久化// 拖拽结束 → 存储所有节点坐标到 localStoragethis._cy.on(dragfree,(){constpositions{};this._cy.nodes().forEach(n{positions[n.id()]n.position();});localStorage.setItem(node_positions,JSON.stringify(positions));});// 初始化 → 优先加载已保存位置_loadPositions(){constsavedJSON.parse(localStorage.getItem(node_positions));if(saved){this._cy.nodes().forEach(n{if(saved[n.id()])n.position(saved[n.id()]);});}}// 重置 → 清存储 Dagre 重新布局_resetLayout(){localStorage.removeItem(node_positions);this._cy.layout({name:dagre}).run();}关于 Edge 虚线的一些坑Cytoscape 虚线相关踩坑总结问题原因解决line-dash-pattern不生效没设line-style: dashed先设line-style再设patterntaxi曲线无虚线curve-style: taxi不支持虚线改用bezier或segmentsanimation()驱动 offset 偶有抖动动画 API 与样式共存时冲突改用requestAnimationFramestyle()视觉效果总结元素样式背景容器浅灰蓝渐变 白色圆角边框 投影卡片节点默认白色圆角矩形灰色描边投影节点激活绿色填充 白字 绿色发光 呼吸脉冲连线深蓝灰色贝塞尔虚线2.5px带三角箭头边操作图标彩色圆角徽章覆盖在边中点连线动效虚线 offset 循环偏移产生数据流动感PLC 角标右上角胶囊绿底已连接 / 灰底未连接最终指标指标值框架Preact 10 Cytoscape.js 3.x节点数~20边数~20含图标标记布局Dagre 自动层次 横向拉伸动效边虚线流动 节点呼吸脉冲交互拖拽调整 localStorage 持久化 重置构建增量~500 KB含 Cytoscape Dagre许可证MIT