Konva 从入门到实践 - day3

下面是Konva 第 3 天的完整实现。我们在第 2 天可拖拽布局的基础上,增加设备间输送线以及沿输送线流动的动画货物,让 WCS 画面真正“动起来”。


第 3 天目标

  • 在两个设备之间绘制一条输送线(Konva.Line
  • 在输送线上添加一个移动的圆点,模拟货物流动
  • 货物点沿输送线往复运动,并可根据输送线状态改变颜色
  • 拖拽设备时,输送线自动跟随端点移动

完整可运行代码(Day 3)

<!DOCTYPEhtml><htmllang="zh-CN"><head><metacharset="UTF-8"><title>WCS 设备布局 - Day3 连线与流动动画</title><style>body{margin:0;padding:20px;background:#f0f2f5;font-family:sans-serif;}#container{border:1px solid #ccc;background:#fff;width:800px;height:600px;cursor:default;}.info{margin-top:10px;font-size:14px;color:#666;}</style></head><body><h2>仓库设备布局 - 输送线与流动动画</h2><divid="container"></div><divclass="info">拖拽设备时输送线自动跟随。圆点沿输送线流动,模拟货物运输。</div><scriptsrc="https://unpkg.com/konva@9/konva.min.js"></script><script>// ========== 1. 原始设备数据 ==========constlayoutData={layout:[{id:"1782803001807",deviceCode:"stacker",imgName:"ddj",left:480,top:275,width:50,height:40,angle:0,moveLength:200,selected:false},{id:"1782803143726",deviceCode:"conveyor",imgName:"ssx2",left:640,// 稍微调整位置,让两个设备有间距top:275,width:50,height:40,angle:0,moveLength:null,selected:false}]};// ========== 2. 创建画布 ==========conststage=newKonva.Stage({container:'container',width:800,height:600});constlayer=newKonva.Layer();stage.add(layer);// 全局引用letselectionRect=null;letselectedNode=null;constnodeStartPos=newMap();// 输送线相关letconveyorLine=null;// 输送线letcargoDot=null;// 流动圆点letanimation=null;// Konva.Animation 实例letflowDirection=1;// 流动方向:1 正向,-1 反向letflowProgress=0;// 0 ~ 1// ========== 3. 创建选中框 ==========functioncreateSelectionRect(){selectionRect=newKonva.Rect({stroke:'#1e90ff',strokeWidth:2,dash:[4,4],fill:'rgba(30, 144, 255, 0.1)',visible:false,listening:false});layer.add(selectionRect);}createSelectionRect();functionupdateSelectionRect(node){if(!node){selectionRect.visible(false);layer.batchDraw();return;}constbox=node.getClientRect({skipTransform:false});selectionRect.position({x:box.x,y:box.y});selectionRect.size({width:box.width,height:box.height});selectionRect.visible(true);layer.batchDraw();}functionselectNode(node){if(selectedNode===node)return;if(selectedNode){selectedNode.setAttr('selected',false);}selectedNode=node;if(node){node.setAttr('selected',true);updateSelectionRect(node);}else{updateSelectionRect(null);}}stage.on('click',(e)=>{if(e.target===stage)selectNode(null);});// ========== 4. 拖拽处理 ==========functiononDragStart(e){constnode=e.target;nodeStartPos.set(node.id(),{x:node.x(),y:node.y()});}functiononDragMove(e){constnode=e.target;constmoveLength=node.getAttr('moveLength');if(!moveLength)return;conststartPos=nodeStartPos.get(node.id());if(!startPos)return;constdx=node.x()-startPos.x;constdy=node.y()-startPos.y;constdist=Math.sqrt(dx*dx+dy*dy);if(dist>moveLength){constratio=moveLength/dist;node.position({x:startPos.x+dx*ratio,y:startPos.y+dy*ratio});}}functiononDragEnd(e){constnode=e.target;nodeStartPos.delete(node.id());if(selectedNode===node)updateSelectionRect(node);// 拖拽结束后更新输送线端点updateConveyorLine();}functionbindEvents(node){node.on('click',(e)=>{e.evt.stopPropagation();selectNode(node);});node.on('dragstart',onDragStart);node.on('dragmove',onDragMove);node.on('dragend',onDragEnd);}// ========== 5. 创建节点(同Day2) ==========functioncreateDeviceNode(device){returnnewPromise((resolve)=>{constimg=newwindow.Image();img.onload=()=>{constnode=newKonva.Image({id:device.id,image:img,x:device.left,y:device.top,width:device.width,height:device.height,rotation:device.angle,draggable:true,deviceCode:device.deviceCode,moveLength:device.moveLength,selected:false});resolve(node);};img.onerror=()=>{constnode=newKonva.Rect({id:device.id,x:device.left,y:device.top,width:device.width,height:device.height,fill:'#cccccc',stroke:'#333',strokeWidth:1,rotation:device.angle,draggable:true,deviceCode:device.deviceCode,moveLength:device.moveLength,selected:false});resolve(node);};img.src=`images/${device.imgName}.png`;});}// ========== 6. 输送线与动画 ==========// 获取设备节点的输出/输入连接点(这里取设备右边缘/左边缘中点)functiongetDeviceConnectPoints(){conststacker=stage.findOne('#1782803001807');constconveyor=stage.findOne('#1782803143726');if(!stacker||!conveyor)returnnull;conststackerBox=stacker.getClientRect({skipTransform:false});constconveyorBox=conveyor.getClientRect({skipTransform:false});// 堆垛机右边缘中点 -> 输送线左边缘中点return{startX:stackerBox.x+stackerBox.width,startY:stackerBox.y+stackerBox.height/2,endX:conveyorBox.x,endY:conveyorBox.y+conveyorBox.height/2};}functioncreateConveyorLine(){conveyorLine=newKonva.Line({stroke:'#2ecc71',strokeWidth:4,lineCap:'round',lineJoin:'round',points:[0,0,0,0],// 初始占位listening:false});layer.add(conveyorLine);// 流动货物圆点cargoDot=newKonva.Circle({radius:6,fill:'#e67e22',stroke:'#fff',strokeWidth:2,x:0,y:0,listening:false});layer.add(cargoDot);}// 根据端点更新输送线路径functionupdateConveyorLine(){if(!conveyorLine)return;constpoints=getDeviceConnectPoints();if(!points)return;conveyorLine.points([points.startX,points.startY,points.endX,points.endY]);layer.batchDraw();}// 启动流动动画functionstartFlowAnimation(){if(animation)return;animation=newKonva.Animation(()=>{if(!cargoDot||!conveyorLine)return;constpoints=getDeviceConnectPoints();if(!points)return;// 更新进度,0~1 往复constspeed=0.008;flowProgress+=speed*flowDirection;if(flowProgress>=1){flowProgress=1;flowDirection=-1;}elseif(flowProgress<=0){flowProgress=0;flowDirection=1;}// 线性插值计算当前位置constcx=points.startX+(points.endX-points.startX)*flowProgress;constcy=points.startY+(points.endY-points.startY)*flowProgress;cargoDot.position({x:cx,y:cy});layer.batchDraw();});animation.start();}functionstopFlowAnimation(){if(animation){animation.stop();animation=null;}}// ========== 7. 主渲染流程 ==========asyncfunctionrenderLayout(){constnodes=awaitPromise.all(layoutData.layout.map(device=>createDeviceNode(device)));nodes.forEach(node=>{bindEvents(node);layer.add(node);});// 创建输送线和货物点createConveyorLine();updateConveyorLine();// 启动流动动画startFlowAnimation();layer.batchDraw();console.log('Day3 就绪:输送线可随设备移动,货物点往复流动。');}renderLayout();</script></body></html>

关键实现解析

1. 输送线的绘制

  • 使用Konva.Line,将两点连成线段。
  • 连接点取设备包围盒的边缘中点:堆垛机右边缘中点 → 输送线左边缘中点。
  • 通过getDeviceConnectPoints()实时获取两个设备当前的包围盒,计算出端点坐标。
  • 当设备拖拽时(dragend),调用updateConveyorLine()更新线的坐标。

2. 流动动画

  • 创建一个Konva.Circle作为货物点。
  • 使用Konva.Animation驱动,每帧根据进度 (flowProgress) 沿输送线插值位置。
  • 进度在 0 和 1 之间往复变化,通过flowDirection控制方向,模拟货物来回运输。
  • 动画速度通过speed = 0.008控制,可按需调整。

3. 性能与更新

  • 将输送线和货物点都设为listening: false,避免干扰拖拽和点击。
  • dragend事件中更新输送线端点,保证实时跟随。
  • 动画帧内仅更新货物点位置并调用layer.batchDraw(),效率足够。

4. 状态样式(可扩展)

  • 当前默认输送线为绿色 (#2ecc71),货物点为橙色。
  • 你可以在后续根据状态改变颜色,例如:
    conveyorLine.stroke('#e74c3c');// 故障时变红cargoDot.fill('#ff0000');

测试步骤

  1. 确保 Day2 的图片仍在images文件夹(或占位矩形自动工作)。
  2. 打开页面,你会看到两个设备之间多了一条绿色线段。
  3. 一个橙色圆点沿输送线来回移动。
  4. 拖拽堆垛机或输送线,输送线会随设备端点实时更新,圆点运动路径也会同步变化。

第 3 天总结

你已经学会了:

  • 动态绘制Konva.Line并响应节点移动自动更新
  • 使用Konva.Animation创建逐帧动画
  • 线性插值实现沿路径运动
  • 动画速度与方向控制

现在画面已经具备基本的“动态监控”感。明天第 4 天我们将实现状态驱动的视觉变化(颜色、闪烁、文本),让设备能够响应后端数据报警。
如果运行中有任何问题或想调整动画样式,随时告诉我。