Konva 从入门到实践 - day4

下面是 Konva 第 4 天 的完整代码。我们在第 3 天可拖拽、可流动的基础上,实现了:

  • 设备状态文本(跟随设备移动)
  • 设备故障时自动变红并闪烁
  • 立库货位区域,实时显示占用/空闲状态
  • setInterval 模拟后端数据推送,随机改变设备与货位状态

第 4 天目标

  • 根据状态动态改变设备颜色(正常 / 故障)
  • 故障设备周期性闪烁(透明度变化)
  • 每个设备上方显示状态文字(正常 / 故障 / 维护)
  • 增加立库货位网格,根据数据切换占用/空闲颜色
  • 所有状态更新由模拟的实时数据驱动

完整可运行代码(Day 4)

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>WCS 设备布局 - Day4 状态驱动与报警</title>
  <style>
    body {
      margin: 0;
      padding: 20px;
      background: #f0f2f5;
      font-family: sans-serif;
    }
    #container {
      border: 1px solid #ccc;
      background: #fff;
      width: 800px;
      height: 700px;  /* 稍微加高,放置立库 */
      cursor: default;
    }
    .info {
      margin-top: 10px;
      font-size: 14px;
      color: #666;
    }
  </style>
</head>
<body>
  <h2>仓库设备布局 - 状态驱动与报警</h2>
  <div id="container"></div>
  <div class="info">模拟后端数据每 2 秒更新一次。故障设备会变红闪烁,立库货位颜色代表占用状态。</div>

  <script src="https://unpkg.com/konva@9/konva.min.js"></script>
  <script>
    // ========== 1. 设备数据 ==========
    const layoutData = {
      layout: [
        {
          id: "1782803001807",
          deviceCode: "stacker",
          imgName: "ddj",
          left: 480,
          top: 275,
          width: 50,
          height: 40,
          angle: 0,
          moveLength: 200,
          selected: false,
          status: "normal"    // normal / fault / maintenance
        },
        {
          id: "1782803143726",
          deviceCode: "conveyor",
          imgName: "ssx2",
          left: 640,
          top: 275,
          width: 50,
          height: 40,
          angle: 0,
          moveLength: null,
          selected: false,
          status: "normal"
        }
      ]
    };

    // ========== 2. 立库货位数据 ==========
    // 模拟一个 4 行 × 3 列的立库
    const storageRows = 4;
    const storageCols = 3;
    const storageData = [];
    for (let r = 0; r < storageRows; r++) {
      for (let c = 0; c < storageCols; c++) {
        storageData.push({
          id: `storage_${r}_${c}`,
          row: r,
          col: c,
          occupied: Math.random() > 0.5  // 初始随机占用状态
        });
      }
    }

    // ========== 3. 画布初始化 ==========
    const stage = new Konva.Stage({
      container: 'container',
      width: 800,
      height: 700
    });
    const layer = new Konva.Layer();
    stage.add(layer);

    // 全局引用
    let selectionRect = null;
    let selectedNode = null;
    const nodeStartPos = new Map();
    let conveyorLine = null;
    let cargoDot = null;
    let animation = null;
    let flowDirection = 1;
    let flowProgress = 0;

    // 存储状态文本和闪烁动画的映射
    const statusTexts = new Map();   // key: device id, value: Konva.Text
    const blinkTimers = new Map();   // key: device id, value: interval timer

    // ========== 4. 创建选中框 ==========
    function createSelectionRect() {
      selectionRect = new Konva.Rect({
        stroke: '#1e90ff',
        strokeWidth: 2,
        dash: [4, 4],
        fill: 'rgba(30, 144, 255, 0.1)',
        visible: false,
        listening: false
      });
      layer.add(selectionRect);
    }
    createSelectionRect();

    function updateSelectionRect(node) {
      if (!node) {
        selectionRect.visible(false);
        layer.batchDraw();
        return;
      }
      const box = 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();
    }

    function selectNode(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);
    });

    // ========== 5. 拖拽处理 ==========
    function onDragStart(e) {
      const node = e.target;
      nodeStartPos.set(node.id(), { x: node.x(), y: node.y() });
    }

    function onDragMove(e) {
      const node = e.target;
      const moveLength = node.getAttr('moveLength');
      if (!moveLength) return;
      const startPos = nodeStartPos.get(node.id());
      if (!startPos) return;
      const dx = node.x() - startPos.x;
      const dy = node.y() - startPos.y;
      const dist = Math.sqrt(dx * dx + dy * dy);
      if (dist > moveLength) {
        const ratio = moveLength / dist;
        node.position({
          x: startPos.x + dx * ratio,
          y: startPos.y + dy * ratio
        });
      }
    }

    function onDragEnd(e) {
      const node = e.target;
      nodeStartPos.delete(node.id());
      if (selectedNode === node) updateSelectionRect(node);
      updateConveyorLine();
      updateStatusTextPosition(node);
    }

    function bindEvents(node) {
      node.on('click', (e) => {
        e.evt.stopPropagation();
        selectNode(node);
      });
      node.on('dragstart', onDragStart);
      node.on('dragmove', onDragMove);
      node.on('dragend', onDragEnd);
    }

    // ========== 6. 创建节点(含状态文本) ==========
    function createDeviceNode(device) {
      return new Promise((resolve) => {
        const img = new window.Image();
        img.onload = () => {
          const node = new Konva.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,
            status: device.status
          });
          resolve(node);
        };
        img.onerror = () => {
          const node = new Konva.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,
            status: device.status
          });
          resolve(node);
        };
        img.src = `images/${device.imgName}.png`;
      });
    }

    // 为设备创建状态文本
    function createStatusText(node) {
      const text = new Konva.Text({
        text: node.getAttr('status') === 'normal' ? '正常' : '故障',
        fontSize: 12,
        fill: node.getAttr('status') === 'normal' ? '#27ae60' : '#e74c3c',
        fontStyle: 'bold',
        listening: false
      });
      // 将文本放置在节点上方中间
      const box = node.getClientRect({ skipTransform: false });
      text.position({
        x: box.x + box.width / 2 - text.width() / 2,
        y: box.y - 18
      });
      layer.add(text);
      statusTexts.set(node.id(), text);
      return text;
    }

    function updateStatusTextPosition(node) {
      const text = statusTexts.get(node.id());
      if (!text) return;
      const box = node.getClientRect({ skipTransform: false });
      text.position({
        x: box.x + box.width / 2 - text.width() / 2,
        y: box.y - 18
      });
      layer.batchDraw();
    }

    // ========== 7. 状态驱动更新函数 ==========
    function updateDeviceStatus(id, newStatus) {
      const node = stage.findOne('#' + id);
      if (!node) return;
      const oldStatus = node.getAttr('status');
      if (oldStatus === newStatus) return;

      node.setAttr('status', newStatus);
      const text = statusTexts.get(id);

      // 清除之前的闪烁动画
      if (blinkTimers.has(id)) {
        clearInterval(blinkTimers.get(id));
        blinkTimers.delete(id);
        // 恢复透明度
        node.opacity(1);
      }

      if (newStatus === 'fault') {
        // 设备变红
        if (node.getClassName() === 'Rect') {
          node.fill('#e74c3c');
        }
        // 更新文本
        if (text) {
          text.text('故障');
          text.fill('#e74c3c');
          updateStatusTextPosition(node);
        }
        // 启动闪烁
        let opacityFlag = true;
        const timer = setInterval(() => {
          node.opacity(opacityFlag ? 0.3 : 1);
          opacityFlag = !opacityFlag;
          layer.batchDraw();
        }, 400);
        blinkTimers.set(id, timer);
      } else if (newStatus === 'normal') {
        if (node.getClassName() === 'Rect') {
          node.fill('#2ecc71');
        }
        if (text) {
          text.text('正常');
          text.fill('#27ae60');
          updateStatusTextPosition(node);
        }
      } else if (newStatus === 'maintenance') {
        if (node.getClassName() === 'Rect') {
          node.fill('#f39c12');
        }
        if (text) {
          text.text('维护');
          text.fill('#f39c12');
          updateStatusTextPosition(node);
        }
      }

      layer.batchDraw();
    }

    // ========== 8. 立库货位渲染 ==========
    const storageRects = [];
    const storageTexts = [];

    function createStorageCells() {
      const startX = 50;
      const startY = 450;
      const cellWidth = 60;
      const cellHeight = 40;
      const gap = 4;

      storageData.forEach((cell) => {
        const x = startX + cell.col * (cellWidth + gap);
        const y = startY + cell.row * (cellHeight + gap);

        const rect = new Konva.Rect({
          id: cell.id,
          x: x,
          y: y,
          width: cellWidth,
          height: cellHeight,
          fill: cell.occupied ? '#e74c3c' : '#2ecc71',
          stroke: '#555',
          strokeWidth: 1,
          listening: false
        });
        layer.add(rect);
        storageRects.push(rect);

        const text = new Konva.Text({
          x: x + 5,
          y: y + cellHeight / 2 - 6,
          text: cell.occupied ? '占用' : '空闲',
          fontSize: 12,
          fill: '#fff',
          listening: false
        });
        layer.add(text);
        storageTexts.push({ text, cellId: cell.id });
      });
    }

    function updateStorageCell(cellId, occupied) {
      const rect = layer.findOne('#' + cellId);
      if (!rect) return;
      rect.fill(occupied ? '#e74c3c' : '#2ecc71');
      const item = storageTexts.find(t => t.cellId === cellId);
      if (item) {
        item.text.text(occupied ? '占用' : '空闲');
        item.text.fill('#fff');
      }
    }

    // ========== 9. 模拟实时数据推送 ==========
    function randomStatusUpdate() {
      // 随机更新设备状态
      layoutData.layout.forEach(device => {
        // 10% 概率改变状态
        if (Math.random() < 0.1) {
          const statuses = ['normal', 'fault', 'maintenance'];
          const newStatus = statuses[Math.floor(Math.random() * 3)];
          updateDeviceStatus(device.id, newStatus);
        }
      });

      // 随机更新立库货位(30% 概率切换占用状态)
      storageData.forEach(cell => {
        if (Math.random() < 0.3) {
          cell.occupied = !cell.occupied;
          updateStorageCell(cell.id, cell.occupied);
        }
      });

      layer.batchDraw();
    }

    // 启动模拟,每 2 秒执行一次
    setInterval(randomStatusUpdate, 2000);

    // ========== 10. 输送线与动画(同 Day3) ==========
    function getDeviceConnectPoints() {
      const stacker = stage.findOne('#1782803001807');
      const conveyor = stage.findOne('#1782803143726');
      if (!stacker || !conveyor) return null;
      const stackerBox = stacker.getClientRect({ skipTransform: false });
      const conveyorBox = 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
      };
    }

    function createConveyorLine() {
      conveyorLine = new Konva.Line({
        stroke: '#2ecc71',
        strokeWidth: 4,
        lineCap: 'round',
        lineJoin: 'round',
        points: [0, 0, 0, 0],
        listening: false
      });
      layer.add(conveyorLine);

      cargoDot = new Konva.Circle({
        radius: 6,
        fill: '#e67e22',
        stroke: '#fff',
        strokeWidth: 2,
        x: 0,
        y: 0,
        listening: false
      });
      layer.add(cargoDot);
    }

    function updateConveyorLine() {
      if (!conveyorLine) return;
      const points = getDeviceConnectPoints();
      if (!points) return;
      conveyorLine.points([points.startX, points.startY, points.endX, points.endY]);
      layer.batchDraw();
    }

    function startFlowAnimation() {
      if (animation) return;
      animation = new Konva.Animation(() => {
        if (!cargoDot || !conveyorLine) return;
        const points = getDeviceConnectPoints();
        if (!points) return;
        const speed = 0.008;
        flowProgress += speed * flowDirection;
        if (flowProgress >= 1) {
          flowProgress = 1;
          flowDirection = -1;
        } else if (flowProgress <= 0) {
          flowProgress = 0;
          flowDirection = 1;
        }
        const cx = points.startX + (points.endX - points.startX) * flowProgress;
        const cy = points.startY + (points.endY - points.startY) * flowProgress;
        cargoDot.position({ x: cx, y: cy });
        layer.batchDraw();
      });
      animation.start();
    }

    // ========== 11. 主渲染流程 ==========
    async function renderLayout() {
      const nodes = await Promise.all(
        layoutData.layout.map(device => createDeviceNode(device))
      );

      nodes.forEach(node => {
        bindEvents(node);
        layer.add(node);
        createStatusText(node);  // 创建状态文字
      });

      // 创建立库区域
      createStorageCells();

      // 创建输送线
      createConveyorLine();
      updateConveyorLine();
      startFlowAnimation();

      layer.batchDraw();
      console.log('Day4 就绪:实时状态驱动,故障报警闪烁,立库货位动态更新。');
    }

    renderLayout();
  </script>
</body>
</html>

新增功能详解

1. 设备状态文字

  • 每个设备上方显示一个 Konva.Text,初始值为"正常"。
  • 文本位置根据设备包围盒自动计算,并在设备拖拽结束(dragend)时更新位置。
  • 当状态改变时,文字内容、颜色同步变化。

2. 故障报警与闪烁

  • updateDeviceStatus(id, newStatus) 函数负责:
    • 更改设备节点的自定义属性 status
    • 清除之前可能存在的闪烁定时器
    • 如果是 fault 状态,将设备颜色设为红色,并启动一个 setInterval 循环改变 opacity 实现闪烁
    • 恢复正常状态时清除定时器,恢复透明度和颜色

3. 立库货位

  • 新增一个 4 行 × 3 列的立库区域,用 Konva.Rect 绘制每个货位。
  • 货位颜色:绿色表示空闲,红色表示占用。
  • 货位旁显示文字"空闲"或"占用"。
  • 模拟数据推送时,随机切换部分货位的占用状态。

4. 模拟实时数据

  • 使用 setInterval 每 2 秒执行一次 randomStatusUpdate
    • 有 10% 的概率改变每个设备的状态(正常/故障/维护)
    • 有 30% 的概率切换每个立库货位的占用状态
  • 你可以将此函数替换为真实的 WebSocket 回调。

测试要点

  • 运行页面后,立库区域出现,货位颜色随机切换。
  • 几秒后,堆垛机或输送线可能会变红并开始闪烁,上方文字变为"故障"。
  • 设备拖拽时,状态文本跟随移动。
  • 输送线动画与之前相同。

第 4 天总结

你已经学会了:

  • 给节点附加动态文本并保持跟随
  • 根据业务状态修改节点外观
  • 使用定时器实现循环动画(故障闪烁)
  • 管理多个节点的状态并批量更新
  • 构建简单的立库可视化组件并实时响应数据

明天(第 5 天)我们将把所有功能整合为一个完整的 WCS 监控面板,包含设备图元库、属性面板、编辑/监控双模式切换,并导出你最初那样的 JSON 数据。