lsh
2026-04-21 7443e8040d9a7669a8117c8a6937dbd4bd792709
添加环穿轨道
3个文件已添加
7个文件已修改
15460 ■■■■■ 已修改文件
.gitignore 4 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
.oxfmtrc.json 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/components/MapCanvas.js 3356 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/components/MapCanvasBak.js 258 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/static/js/basMap/editor.js 8793 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/static/js/basMap/mapTrackGeometry.js 1195 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/basMap/editor.html 1847 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/watch/console.html 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/watch/fakeTrace.html 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/watch/stationTrace.html 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.gitignore
@@ -34,7 +34,9 @@
### VS Code ###
.vscode/
.history/
.cursor/
### LOG ###
stock
LOG_PATH_IS_UNDEFINED
LOG_PATH_IS_UNDEFINED
.oxfmtrc.json
New file
@@ -0,0 +1,4 @@
{
  "singleQuote": true,
  "trailingComma": "none"
}
src/main/webapp/components/MapCanvas.js
Diff too large
src/main/webapp/components/MapCanvasBak.js
New file
@@ -0,0 +1,258 @@
//MapCanvas.js 里废弃的函数,暂时保留在这里,方便后续如有需要再恢复
// function getMappingInfo(point) {
//   let angle, path, vector, x, y;
//   this.allSmoothList.find((smoothList) => {
//     const smoothLength = smoothList.length;
//     const existPath = smoothList.find((currentPath, i) => {
//       const prevPath = smoothList[(i - 1 + smoothLength) % smoothLength];
//       if (currentPath.type === "line") {
//         const prevPathEndPoint =
//           prevPath.type === "arc"
//             ? {
//               x: prevPath.arcEndX,
//               y: prevPath.arcEndY,
//             }
//             : prevPath;
//         return this.isPointOnSegment(point, prevPathEndPoint, currentPath);
//       } else {
//         const start = {
//           x: currentPath.arcStartX,
//           y: currentPath.arcStartY,
//         };
//         const middle = {
//           x: currentPath.arcMiddleX,
//           y: currentPath.arcMiddleY,
//         };
//         const end = { x: currentPath.arcEndX, y: currentPath.arcEndY };
//         return (
//           this.isPointOnSegment(point, start, middle) ||
//           this.isPointOnSegment(point, middle, end)
//         );
//       }
//     });
//     if (!existPath) {
//       return false;
//     }
//     path = existPath;
//     vector = this.normalizeVector(existPath, point);
//     if (existPath.type === "arc") {
//       x = existPath.x + vector.x * existPath.radius;
//       y = existPath.y + vector.y * existPath.radius;
//       angle = Math.atan2(vector.y, vector.x);
//     } else {
//       x = point.x;
//       y = point.y;
//       angle = null;
//     }
//     return true;
//   });
//   return {
//     angle,
//     path,
//     x,
//     y,
//     vector,
//   };
// }
// setDeviceInfo(type, res) {
//   const deviceTypeInfo = this.DEVICE_MAP[type];
//   let devices = Array.isArray(res) ? res : res && res.code === 200 ? res.data : null;
//   if (!devices) {
//     return;
//   }
//   for (var i = 0; i < devices.length; i++) {
//     const device = devices[i];
//     const id = parseInt(device[deviceTypeInfo.idName]);
//     const sprite = deviceTypeInfo.pixiMap.get(id);
//     if (!sprite) {
//       continue;
//     }
//     // 找到sprite了就更新颜色和文字
//     const taskNo = device.taskNo;
//     if (taskNo != null && taskNo > 0) {
//       sprite.textObj.text = id + '(' + taskNo + ')';
//     } else {
//       sprite.textObj.text = String(id);
//     }
//     this.applyEditorLikeTrackDeviceTextStyle(sprite.textObj);
//     const status = device[deviceTypeInfo.statusInfo.name];
//     const statusColor = deviceTypeInfo.statusInfo.getStatus(status);
//     deviceTypeInfo.statusInfo.updateTextureColor(sprite, statusColor);
//     const oldSprite = {
//       ...sprite,
//       x: sprite.x,
//       y: sprite.y,
//       mappingInfo: { ...sprite.mappingInfo }
//     };
//     // 后端传过来的是左上角的点,这里取中心点
//     device.width = sprite.width;
//     device.height = sprite.height;
//     const sourcePoint = {
//       x: device.x + device.width / 2,
//       y: device.y + device.height / 2
//     };
//     const mappingInfo = this.getMappingInfo({ ...device, ...sourcePoint });
//     sprite.mappingInfo = mappingInfo;
//     sprite.time = Date.now();
//     if (!oldSprite.time) {
//       sprite.x = sprite.mappingInfo.x;
//       sprite.y = sprite.mappingInfo.y;
//       sprite.rotation = sprite.mappingInfo.rotate;
//       sprite.path = sprite.mappingInfo.path;
//       sprite.vector = sprite.mappingInfo.vector;
//       continue;
//     }
//     // const curveDistance = this.getCurveDistance(
//     //   oldSprite.mappingInfo,sprite.mappingInfo,
//     //   oldSprite.mappingInfo.path,sprite.mappingInfo.path,
//     //   oldSprite.mappingInfo.angle,sprite.mappingInfo.angle
//     // );
//     const curveDistance = G.calcDistance(oldSprite.mappingInfo, sprite.mappingInfo);
//     const deltaTime = (sprite.time - oldSprite.time) / 1000 || 0;
//     if (deltaTime > 0 && deltaTime <= 10 && curveDistance > 0) {
//       // 保存本次帧间隔,供 ticker 判断数据新鲜度
//       sprite.lastDeltaTime = deltaTime;
//       // 用指数移动平均(α=0.35)代替窗口均值,对速度变化响应更快
//       const v = curveDistance / deltaTime;
//       const alpha = 0.35;
//       sprite.maV =
//         typeof sprite.maV === 'number' && isFinite(sprite.maV)
//           ? sprite.maV * (1 - alpha) + v * alpha
//           : v;
//     }
//     if (curveDistance < EPSILON) {
//       // 已无剩余路程或新数据与当前目标一致:必须收尾并移除 ticker,否则会一直跑
//       this.finishDeviceMotion(sprite);
//     } else {
//       sprite.isFinish = false;
//       if (sprite.ticker) {
//         // 已有插值回调:mappingInfo 已在上方更新,继续追新目标;勿 return(会中断其它设备)
//         continue;
//       }
//       const Gm = window.BasMapTrackGeometry;
//       const trackElForMove =
//         this.map2 && sprite.pathId != null
//           ? this.map2.find((item) => item && item.id === sprite.pathId)
//           : null;
//       const pathListForMove =
//         trackElForMove &&
//         trackElForMove.pathList &&
//         trackElForMove.pathList.length
//           ? trackElForMove.pathList
//           : null;
//       if (!pathListForMove || !Gm) {
//         this.finishDeviceMotion(sprite);
//         continue;
//       }
//       const fn = () => {
//         if (sprite.isFinish) {
//           return;
//         }
//         const restDistance = G.calcDistance(sprite, sprite.mappingInfo);
//         if (restDistance <= EPSILON) {
//           this.finishDeviceMotion(sprite);
//           return;
//         }
//         const dtMs =
//           this.pixiApp && this.pixiApp.ticker && typeof this.pixiApp.ticker.deltaMS === 'number'
//             ? this.pixiApp.ticker.deltaMS
//             : 16.667;
//         const dt = Math.max(0, dtMs) / 1000;
//         if (dt <= 0) {
//           return;
//         }
//         const baseV =
//           typeof sprite.maV === 'number' && isFinite(sprite.maV) && sprite.maV > 0
//             ? sprite.maV
//             : Math.max(sprite.width * 2, 20);
//         // 数据新鲜度判断:
//         // 服务器帧间隔内有新数据到来 → 全速追目标(不减速,避免下一帧"追赶抖动")
//         // 数据停止推送 → 线性减速收敛到终点
//         const msSinceUpdate = sprite.time ? Date.now() - sprite.time : 0;
//         const typicalIntervalMs = (sprite.lastDeltaTime || 1) * 1000;
//         const isStale = msSinceUpdate > typicalIntervalMs * 1.5;
//         const easing = isStale ? Math.min(1.0, restDistance / Math.max(sprite.width, 1)) : 1.0;
//         const smoothDistance = Math.min(restDistance, dt * baseV * easing);
//         const path = sprite.path;
//         const movePoint =
//           trackElForMove &&
//           trackElForMove.type === 'annulus' &&
//           Gm.snapToAnnulusOuterPath
//             ? Gm.snapToAnnulusOuterPath(sprite.x, sprite.y, path)
//             : sprite;
//         const angle = Math.atan2(sprite.y - path.y, sprite.x - path.x);
//         const p = Gm.getPositionAfterMove({
//           point: movePoint,
//           pathList: pathListForMove,
//           path,
//           deltaDistance: smoothDistance,
//           angle
//         });
//         let px = p.x;
//         let py = p.y;
//         if (
//           trackElForMove &&
//           trackElForMove.type === 'annulus' &&
//           Gm.centerAnnulusBandPoint
//         ) {
//           const c = Gm.centerAnnulusBandPoint(trackElForMove, p.x, p.y, p.path);
//           px = c.x;
//           py = c.y;
//         }
//         sprite.path = p.path;
//         sprite.x = px;
//         sprite.y = py;
//         sprite.rotation = this.getRotate(p, p.path);
//         const restDistanceAfter = G.calcDistance(
//           { x: sprite.x, y: sprite.y },
//           sprite.mappingInfo
//         );
//         if (restDistanceAfter <= EPSILON || smoothDistance >= restDistance) {
//           this.finishDeviceMotion(sprite);
//           return;
//         }
//       };
//       sprite.ticker = fn;
//       this.pixiApp.ticker.add(sprite.ticker);
//     }
//   }
//   this.scheduleAdjustLabels();
// },
// const list = [{
//   x: 853.92,
//   y: 457
// }, {
//   x: 953.92,
//   y: 457
// }, {
//   x: 1053.92,
//   y: 457
// }, {
//   x: 1153.92,
//   y: 457
// }, {
//   x: 1253.92,
//   y: 457
// }, {
//   x: 1353.92,
//   y: 457
// }, {
//   x: 1453.92,
//   y: 457
// }, {
//   x: 1553.92,
//   y: 457
// }, {
//   x: 1653.92,
//   y: 457
// }];
// const raw = {
//   crnStatus: 'machine-auto',
//   offset: 1,
//   crnId: 15,
//   pathId: 'el_1775197504724'
// };
src/main/webapp/static/js/basMap/editor.js
Diff too large
src/main/webapp/static/js/basMap/mapTrackGeometry.js
New file
@@ -0,0 +1,1195 @@
/**
 * 环穿 / 平滑轨道几何:直角多边形转圆弧路径、PIXI 绘制、设备朝向等。
 * 供 basMap 编辑器与监控 MapCanvas 共用。需在页面中先于 editor.js / MapCanvas.js 引入。
 * 设备外观(CRN / RGV)绘制与 MapCanvas 贴图一致,见 drawCrnDeviceGraphics / drawRgvDeviceGraphics。
 */
(function (global) {
  'use strict';
  var TYPE_META = {
    shelf: {
      label: '货架',
      shortLabel: 'SHELF',
      fill: 0x7d96bf,
      border: 0x4f6486
    },
    repairHub: {
      label: '维修站台',
      shortLabel: 'HUB',
      fill: 0x8eb89a,
      border: 0x4a6b55
    },
    devp: {
      label: '输送线',
      shortLabel: 'DEVP',
      fill: 0xf0b06f,
      border: 0xa45f21
    },
    crn: {
      label: '堆垛机轨道',
      shortLabel: 'CRN',
      fill: 0x68bfd0,
      border: 0x1d6e81,
      alpha: 0.12
    },
    dualCrn: {
      label: '双工位轨道',
      shortLabel: 'DCRN',
      fill: 0x54c1a4,
      border: 0x0f7b62,
      alpha: 0.12
    },
    rgv: {
      label: 'RGV轨道',
      shortLabel: 'RGV',
      fill: 0xc691e9,
      border: 0x744b98,
      alpha: 0.12
    },
    annulus: {
      label: '环穿',
      shortLabel: 'ANNULUS',
      fill: 0xe6b3b3,
      border: 0xcc6666,
      alpha: 0
    }
  };
  function normalizeVector(p1, p2) {
    var dx = p2.x - p1.x;
    var dy = p2.y - p1.y;
    var length = Math.sqrt(dx * dx + dy * dy);
    return { x: dx / length, y: dy / length };
  }
  function calcDistance(p1, p2) {
    return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2));
  }
  function safeParseJson(text) {
    if (!text || typeof text !== 'string') {
      return null;
    }
    try {
      return JSON.parse(text);
    } catch (e) {
      return null;
    }
  }
  /** @param {string|object|null|undefined} raw */
  function parseDeviceFormValue(raw) {
    if (!raw) {
      return null;
    }
    if (typeof raw === 'object') {
      return raw;
    }
    return safeParseJson(raw);
  }
  /**
   * 设备长宽:有 `deviceLength`/`deviceWidth` 则用像素值,否则用轨道自动尺寸。
   * @param {object|null|undefined} item
   * @param {number} fallbackAlong
   * @param {number} fallbackAcross
   * @returns {{ along: number, across: number }}
   */
  function normalizeDeviceSizeOverride(item, fallbackAlong, fallbackAcross) {
    if (!item) {
      return { along: fallbackAlong, across: fallbackAcross };
    }
    var overrideAlong = Math.round(Number(item.deviceLength));
    var overrideAcross = Math.round(Number(item.deviceWidth));
    var along = isFinite(overrideAlong) && overrideAlong > 0 ? overrideAlong : fallbackAlong;
    var across = isFinite(overrideAcross) && overrideAcross > 0 ? overrideAcross : fallbackAcross;
    return {
      along: Math.max(2, along),
      across: Math.max(2, across)
    };
  }
  function getNormalizeAngle(angle, startAngle, endAngle) {
    // 将 angle 归一化到 [startAngle, startAngle + 2*PI) 范围
    var twoPi = 2 * Math.PI;
    var rangeStart = startAngle;
    var normalized = angle % twoPi;
    if (normalized < 0) {
      normalized += twoPi;
    }
    var normalizedStart = rangeStart % twoPi;
    if (normalizedStart < 0) {
      normalizedStart += twoPi;
    }
    // 计算相对偏移
    var offset = normalized - normalizedStart;
    if (offset < 0) {
      offset += twoPi;
    }
    return rangeStart + offset;
  }
  function getPositionAfterMove(params) {
    var point = params.point;
    var pathList = params.pathList;
    var deltaDistance = params.deltaDistance;
    var angle = params.angle;
    var path, pathIndex;
    if (params.path) {
      path = params.path;
      pathIndex = pathList.indexOf(path);
    } else {
      pathIndex = params.pathIndex;
      path = pathList[pathIndex];
    }
    var nextIndex = (pathIndex + 1) % pathList.length;
    var prevIndex = (pathIndex - 1 + pathList.length) % pathList.length;
    var isBackward = deltaDistance < 0;
    var moveDistance = Math.abs(deltaDistance);
    if (moveDistance === 0) {
      return {
        x: point.x,
        y: point.y,
        path: path,
        angle: angle
      };
    }
    if (path.type === 'line') {
      var targetPoint = isBackward ? { x: path.startX, y: path.startY } : { x: path.x, y: path.y };
      var restDistance = calcDistance(point, targetPoint);
      var vector = normalizeVector(point, targetPoint);
      if (moveDistance < restDistance) {
        // vector 指向本段目标端(正向为终点,反向为起点)。反向时 deltaDistance<0,
        // 必须用 +moveDistance 沿 vector 走,不能用 vector*deltaDistance(负号会把位移折向另一端)。
        var stepAlong = isBackward ? moveDistance : deltaDistance;
        var x = point.x + vector.x * stepAlong;
        var y = point.y + vector.y * stepAlong;
        return { x: x, y: y, path: path };
      }
      // pathList等于1说明就1条轨道,直接返回对应端点
      if (pathList.length === 1) {
        return {
          x: targetPoint.x,
          y: targetPoint.y,
          path: path
        };
      }
      return getPositionAfterMove({
        point: targetPoint,
        pathList: pathList,
        pathIndex: isBackward ? prevIndex : nextIndex,
        deltaDistance: isBackward ? deltaDistance + restDistance : deltaDistance - restDistance
      });
    }
    var startAngle = path.startAngle;
    var endAngle = path.endAngle;
    var inferredAngle = Math.atan2(point.y - path.y, point.x - path.x);
    var tmpCurrentAngle = angle != null ? angle : inferredAngle;
    var currentAngle = getNormalizeAngle(tmpCurrentAngle, startAngle, endAngle);
    var restDistance2 = isBackward
      ? Math.abs((currentAngle - startAngle) * path.radius)
      : Math.abs((endAngle - currentAngle) * path.radius);
    if (moveDistance < restDistance2) {
      var deltaAngle = (deltaDistance / path.radius) * -path.crossProduct;
      var newAngle = currentAngle + deltaAngle;
      return {
        x: path.x + path.radius * Math.cos(newAngle),
        y: path.y + path.radius * Math.sin(newAngle),
        path: path,
        angle: newAngle
      };
    }
    return getPositionAfterMove({
      point: isBackward
        ? { x: path.arcStartX, y: path.arcStartY }
        : { x: path.arcEndX, y: path.arcEndY },
      pathList: pathList,
      pathIndex: isBackward ? prevIndex : nextIndex,
      deltaDistance: isBackward ? deltaDistance + restDistance2 : deltaDistance - restDistance2,
      angle: isBackward ? path.startAngle : path.endAngle
    });
  }
  function getAllDistance(pathList) {
    var totalDistance = 0;
    pathList.forEach(function (path) {
      if (path.type === 'line') {
        totalDistance += calcDistance(path, { x: path.startX, y: path.startY });
      } else {
        totalDistance += Math.abs(
          ((path.endAngle - path.startAngle) % (Math.PI * 2)) * path.radius
        );
      }
    });
    return totalDistance;
  }
  /** 相对轨道窄边(厚度方向)整体缩小车体,避免水平/垂直轨道上贴满轨道带 */
  var TRACK_DEVICE_BOX_SCALE = 0.9;
  /**
   * 环穿单侧内缩距离:按车宽 across 的比例估算(优先 `value.deviceList[].deviceWidth`,否则环穿自动规则),且不低于下限。
   * 元素可设 `annulusBandInset`(非负)覆盖。
   */
  var ANNULUS_INSET_FROM_DEVICE_RATIO = 0.26;
  /** @type {number} 默认最小内缩(像素) */
  var ANNULUS_INSET_MIN_PIXELS = 5;
  /**
   * 直线轨道上设备图标像素尺寸(沿轨道方向 × 垂直轨道方向)。
   * 与 drawCrnDeviceGraphics / drawRgvDeviceGraphics 的 width、height 语义一致,不再在别处做「再乘 2」。
   * @param {{ width: number, height: number }} rect 轨道外接矩形
   * @param {"crn"|"dualCrn"|"rgv"} trackType
   * @returns {{ along: number, across: number }}
   */
  function getDevicePixelBoxForTrack(rect, trackType) {
    var shortLen = Math.min(rect.width, rect.height);
    var across = shortLen;
    var along = shortLen * 2;
    along = Math.round(along * TRACK_DEVICE_BOX_SCALE);
    across = Math.round(across * TRACK_DEVICE_BOX_SCALE);
    return {
      along: Math.max(2, along),
      across: Math.max(2, across)
    };
  }
  /**
   * 轨道上设备「自动」像素尺寸(沿轨道方向 × 垂直轨道方向)。
   * - 直线轨道:来自 getDevicePixelBoxForTrack
   * - 环穿:按历史逻辑缩放到 shortLength 的 15%
   * @param {{ type: string, width: number, height: number, pathList?: any[] }} rect
   * @returns {{ along: number, across: number } | null}
   */
  function getAutoTrackDeviceBox(rect) {
    if (!rect) {
      return null;
    }
    var rectW = Number(rect.width);
    var rectH = Number(rect.height);
    if (!isFinite(rectW) || !isFinite(rectH) || rectW <= 0 || rectH <= 0) {
      return null;
    }
    if (rect.type === 'annulus') {
      var shortLength = Math.min(rectW, rectH);
      var box = getDevicePixelBoxForTrack({ width: shortLength, height: shortLength }, 'rgv');
      var scale = (shortLength * 0.15) / box.across;
      return {
        along: Math.max(2, Math.round(box.along * scale)),
        across: Math.max(2, Math.round(box.across * scale))
      };
    }
    return getDevicePixelBoxForTrack({ width: rectW, height: rectH }, rect.type);
  }
  function getDeviceInfo(rect) {
    var isHorizontal = rect.width > rect.height;
    var longLength = isHorizontal ? rect.width : rect.height;
    var shortLength = isHorizontal ? rect.height : rect.width;
    var deviceForm = parseDeviceFormValue(rect.value);
    if (!deviceForm || !deviceForm.deviceList || deviceForm.deviceList.length === 0) {
      return deviceForm;
    }
    if (rect.type === 'annulus') {
      var pathList = rect.pathList || [];
      if (!pathList.length) {
        return deviceForm;
      }
      var allDistance = getAllDistance(pathList);
      var autoBox = getAutoTrackDeviceBox(rect);
      var ab = annulusBandContext(rect, rect.shape || 'rect');
      deviceForm.deviceList.forEach(function (item) {
        var deltaDistance = (allDistance * item.progress) / 100;
        var startPoint = {
          x: pathList[0].arcStartX,
          y: pathList[0].arcStartY
        };
        var moved = getPositionAfterMove({
          point: startPoint,
          pathList: pathList,
          pathIndex: 0,
          deltaDistance: deltaDistance
        });
        var centered = shiftAnnulusPointToBandCenter(
          moved.x,
          moved.y,
          moved.path,
          ab.inset,
          ab.refInside
        );
        var fallbackAlong = autoBox ? autoBox.along : Math.max(2, Math.round(shortLength * 0.3));
        var fallbackAcross = autoBox ? autoBox.across : Math.max(2, Math.round(shortLength * 0.15));
        var size = normalizeDeviceSizeOverride(item, fallbackAlong, fallbackAcross);
        var width = size.along;
        var height = size.across;
        item.x = centered.x;
        item.y = centered.y;
        item.path = moved.path;
        item.width = width;
        item.height = height;
      });
      return deviceForm;
    }
    var box = getAutoTrackDeviceBox(rect) || getDevicePixelBoxForTrack(rect, rect.type);
    deviceForm.deviceList.forEach(function (item) {
      var size = normalizeDeviceSizeOverride(item, box.along, box.across);
      var inset = (shortLength - size.across) / 2;
      var distance = (item.progress * (longLength - 2 * shortLength)) / 100;
      if (isHorizontal) {
        item.x = rect.x + distance;
        item.y = rect.y + inset;
        item.width = size.along;
        item.height = size.across;
      } else {
        item.x = rect.x + inset;
        item.y = rect.y + distance;
        item.width = size.across;
        item.height = size.along;
      }
    });
    return deviceForm;
  }
  function getLShapePointList(sprite) {
    var turningPoint;
    var LPointList;
    if (!sprite.turningPoint) {
      var rate = 1 / 3;
      var minDistance = Math.min(sprite.width, sprite.height);
      var shortDistance = rate * minDistance;
      var longDistance = sprite.height - shortDistance;
      if (sprite.shape === 'L1') {
        turningPoint = {
          x: sprite.x + shortDistance,
          y: sprite.y + longDistance
        };
      } else if (sprite.shape === 'L2') {
        turningPoint = {
          x: sprite.x + sprite.width - shortDistance,
          y: sprite.y + sprite.height - longDistance
        };
      } else if (sprite.shape === 'L3') {
        turningPoint = {
          x: sprite.x + shortDistance,
          y: sprite.y + sprite.height - longDistance
        };
      } else if (sprite.shape === 'L4') {
        turningPoint = {
          x: sprite.x + sprite.width - shortDistance,
          y: sprite.y + longDistance
        };
      }
    } else {
      turningPoint = sprite.turningPoint;
    }
    sprite.turningPoint = turningPoint;
    if (sprite.shape === 'L1') {
      LPointList = [
        { x: sprite.x, y: sprite.y, direction: 'up' },
        { x: turningPoint.x, y: sprite.y, direction: 'right' },
        Object.assign({}, turningPoint, { direction: 'down' }),
        { x: sprite.x + sprite.width, y: turningPoint.y, direction: 'right' },
        {
          x: sprite.x + sprite.width,
          y: sprite.y + sprite.height,
          direction: 'down'
        },
        { x: sprite.x, y: sprite.y + sprite.height, direction: 'left' }
      ];
    } else if (sprite.shape === 'L2') {
      LPointList = [
        {
          x: sprite.x + sprite.width,
          y: sprite.y + sprite.height,
          direction: 'bottom'
        },
        { x: turningPoint.x, y: sprite.y + sprite.height, direction: 'left' },
        Object.assign({}, turningPoint, { direction: 'up' }),
        { x: sprite.x, y: turningPoint.y, direction: 'left' },
        { x: sprite.x, y: sprite.y, direction: 'up' },
        { x: sprite.x + sprite.width, y: sprite.y, direction: 'right' }
      ];
    } else if (sprite.shape === 'L3') {
      LPointList = [
        { x: sprite.x, y: sprite.y + sprite.height, direction: 'bottom' },
        { x: turningPoint.x, y: sprite.y + sprite.height, direction: 'right' },
        Object.assign({}, turningPoint, { direction: 'up' }),
        { x: sprite.x + sprite.width, y: turningPoint.y, direction: 'right' },
        { x: sprite.x + sprite.width, y: sprite.y, direction: 'up' },
        { x: sprite.x, y: sprite.y, direction: 'left' }
      ];
    } else {
      LPointList = [
        { x: sprite.x + sprite.width, y: sprite.y, direction: 'up' },
        { x: turningPoint.x, y: sprite.y, direction: 'left' },
        Object.assign({}, turningPoint, { direction: 'down' }),
        { x: sprite.x, y: turningPoint.y, direction: 'left' },
        { x: sprite.x, y: sprite.y + sprite.height, direction: 'down' },
        {
          x: sprite.x + sprite.width,
          y: sprite.y + sprite.height,
          direction: 'right'
        }
      ];
    }
    return LPointList;
  }
  /**
   * 射线法判点是否在简单多边形内(用于找参考内点,算内向法向)。
   * @param {{ x: number, y: number }} pt
   * @param {{ x: number, y: number }[]} ring
   * @returns {boolean}
   */
  function pointInPolygon(pt, ring) {
    var inside = false;
    var n = ring.length;
    var i;
    for (i = 0; i < n; i++) {
      var a = ring[i];
      var b = ring[(i + 1) % n];
      var intersects =
        a.y > pt.y !== b.y > pt.y &&
        pt.x < ((b.x - a.x) * (pt.y - a.y)) / (b.y - a.y + 1e-12) + a.x;
      if (intersects) {
        inside = !inside;
      }
    }
    return inside;
  }
  /**
   * 在尖点环包围盒内采样,得到多边形内部一点,供直线段上判断「指向内侧」的法向。
   * @param {{ x: number, y: number }[]} pointList
   * @returns {{ x: number, y: number }}
   */
  function findInteriorRefPoint(pointList) {
    var minX = Infinity;
    var minY = Infinity;
    var maxX = -Infinity;
    var maxY = -Infinity;
    var i;
    for (i = 0; i < pointList.length; i++) {
      var p = pointList[i];
      minX = Math.min(minX, p.x);
      minY = Math.min(minY, p.y);
      maxX = Math.max(maxX, p.x);
      maxY = Math.max(maxY, p.y);
    }
    var cx = (minX + maxX) / 2;
    var cy = (minY + maxY) / 2;
    if (pointInPolygon({ x: cx, y: cy }, pointList)) {
      return { x: cx, y: cy };
    }
    var g;
    for (g = 1; g <= 6; g++) {
      var sj;
      for (sj = 1; sj < g; sj++) {
        var si;
        for (si = 1; si < g; si++) {
          var tx = minX + ((maxX - minX) * si) / g;
          var ty = minY + ((maxY - minY) * sj) / g;
          if (pointInPolygon({ x: tx, y: ty }, pointList)) {
            return { x: tx, y: ty };
          }
        }
      }
    }
    return { x: cx, y: cy };
  }
  /**
   * 有向边 (ax,ay)→(bx,by) 沿环前进方向的单位内向法向量(逆时针环时内侧在前进方向左侧)。
   * @param {number} ax
   * @param {number} ay
   * @param {number} bx
   * @param {number} by
   * @param {boolean} ccwPolygon 尖点环有向面积 >0 时为逆时针
   * @returns {{ x: number, y: number }}
   */
  function inwardNormalUnitForward(ax, ay, bx, by, ccwPolygon) {
    var dx = bx - ax;
    var dy = by - ay;
    var len = Math.sqrt(dx * dx + dy * dy) || 1;
    var ux = dx / len;
    var uy = dy / len;
    if (ccwPolygon) {
      return { x: -uy, y: ux };
    }
    return { x: uy, y: -ux };
  }
  /**
   * 两直线 p1+t*u1 与 p2+s*u2 的交点(u 已为单位向量则 t 为几何长度)。
   * @param {{ x: number, y: number }} p1
   * @param {{ x: number, y: number }} u1
   * @param {{ x: number, y: number }} p2
   * @param {{ x: number, y: number }} u2
   * @returns {{ x: number, y: number }}
   */
  function intersectLines(p1, u1, p2, u2) {
    var cr = u1.x * u2.y - u1.y * u2.x;
    if (Math.abs(cr) < 1e-9) {
      return { x: p1.x, y: p1.y };
    }
    var dx = p2.x - p1.x;
    var dy = p2.y - p1.y;
    var t = (dx * u2.y - dy * u2.x) / cr;
    return { x: p1.x + t * u1.x, y: p1.y + t * u1.y };
  }
  /**
   * 直角顶点环向内偏移。
   * 凹角:沿环前进向量 vIn=curr−prev、vOut=next−curr 的叉积与环向一致时判定(CCW 环上凸角 cross>0、凹角 cross<0),
   * 新顶点为 curr+inset*(nIn+nOut);凸角为两邻边平移后的直线交点。
   * (smoothRightAnglePath 用的是 curr→prev 与 curr→next,与「沿环前进」差一符号,不能复用其叉积判凹凸。)
   * @param {{ x: number, y: number, direction?: string }[]} sharpCorners
   * @param {number} inset
   * @returns {{ x: number, y: number, direction?: string }[]}
   */
  function offsetOrthogonalSharpRing(sharpCorners, inset) {
    var area2 = 0;
    var nj;
    for (nj = 0; nj < sharpCorners.length; nj++) {
      var pj0 = sharpCorners[nj];
      var pj1 = sharpCorners[(nj + 1) % sharpCorners.length];
      area2 += pj0.x * pj1.y - pj1.x * pj0.y;
    }
    var ccwPolygon = area2 > 0;
    var n = sharpCorners.length;
    var out = [];
    var i;
    for (i = 0; i < n; i++) {
      var prev = sharpCorners[(i - 1 + n) % n];
      var curr = sharpCorners[i];
      var next = sharpCorners[(i + 1) % n];
      var vInx = curr.x - prev.x;
      var vIny = curr.y - prev.y;
      var vOutx = next.x - curr.x;
      var vOuty = next.y - curr.y;
      var crossWalk = vInx * vOuty - vIny * vOutx;
      var reflex = ccwPolygon ? crossWalk < 0 : crossWalk > 0;
      var nIn = inwardNormalUnitForward(prev.x, prev.y, curr.x, curr.y, ccwPolygon);
      var nOut = inwardNormalUnitForward(curr.x, curr.y, next.x, next.y, ccwPolygon);
      var ix;
      var iy;
      if (reflex) {
        ix = curr.x + (nIn.x + nOut.x) * inset;
        iy = curr.y + (nIn.y + nOut.y) * inset;
      } else {
        var lenIn = Math.sqrt(vInx * vInx + vIny * vIny) || 1;
        var lenOut = Math.sqrt(vOutx * vOutx + vOuty * vOuty) || 1;
        var uIn = { x: vInx / lenIn, y: vIny / lenIn };
        var uOut = { x: vOutx / lenOut, y: vOuty / lenOut };
        var oIn = { x: prev.x + nIn.x * inset, y: prev.y + nIn.y * inset };
        var oOut = { x: curr.x + nOut.x * inset, y: curr.y + nOut.y * inset };
        var inter = intersectLines(oIn, uIn, oOut, uOut);
        ix = inter.x;
        iy = inter.y;
      }
      out.push({ x: ix, y: iy, direction: sharpCorners[i].direction });
    }
    return out;
  }
  /**
   * 解析环穿单侧内缩像素距离;若传入 `precomputedSharp` 则不再重复求尖点环。
   * @param {{ width: number, height: number, shape?: string, annulusBandInset?: number, value?: string|object }} sprite
   * @param {{ x: number, y: number }[]} [precomputedSharp]
   * @returns {number}
   */
  // function resolveAnnulusBandInset(sprite, precomputedSharp) {
  //   var override = sprite && sprite.annulusBandInset;
  //   if (typeof override === 'number' && isFinite(override) && override >= 0) {
  //     return override;
  //   }
  //   var tmp = { type: 'annulus', width: sprite.width, height: sprite.height };
  //   var box = getAutoTrackDeviceBox(tmp);
  //   var shortLen = Math.min(Number(sprite.width) || 0, Number(sprite.height) || 0);
  //   var fallbackAlong = box ? box.along : Math.max(2, Math.round(shortLen * 0.3));
  //   var fallbackAcross = box ? box.across : Math.max(2, Math.round(shortLen * 0.15));
  //   var form = parseDeviceFormValue(sprite && sprite.value);
  //   var across = fallbackAcross;
  //   console.log('fallbackAcross', across);
  //   if (form && form.deviceList && form.deviceList.length) {
  //     var di;
  //     for (di = 0; di < form.deviceList.length; di++) {
  //       var sz = normalizeDeviceSizeOverride(form.deviceList[di], fallbackAlong, fallbackAcross);
  //       if (sz.across > across) {
  //         across = sz.across;
  //       }
  //     }
  //   }
  //   var inset = Math.max(
  //     ANNULUS_INSET_MIN_PIXELS,
  //     Math.round(across * ANNULUS_INSET_FROM_DEVICE_RATIO)
  //   );
  //   var maxByBox = Math.floor(Math.min(sprite.width, sprite.height) / 2) - 3;
  //   var ring = precomputedSharp || getSharpCornerList(sprite, sprite.shape || 'rect');
  //   var minEdge = Infinity;
  //   var ri;
  //   for (ri = 0; ri < ring.length; ri++) {
  //     var d = calcDistance(ring[ri], ring[(ri + 1) % ring.length]);
  //     if (d > 0 && d < minEdge) {
  //       minEdge = d;
  //     }
  //   }
  //   var maxByEdge = minEdge === Infinity ? inset : Math.floor(minEdge / 2) - 1;
  //   var cap = Math.min(isFinite(maxByBox) ? maxByBox : inset, isFinite(maxByEdge) ? maxByEdge : inset);
  //   if (cap > 0 && inset > cap) {
  //     inset = cap;
  //   }
  //   return Math.max(0, inset);
  // }
  /**
   * 环穿共用:尖点环、内侧参考点、inset(只算一遍尖点,避免多处重复 getSharpCornerList)。
   * @param {object} sprite x/y/width/height/shape/turningPoint/annulusBandInset
   * @param {string} defaultShape
   * @returns {{ sharp: object[], refInside: {x:number,y:number}, inset: number }}
   */
  function annulusBandContext(sprite, defaultShape) {
    sprite.shape = sprite.shape || defaultShape;
    var sharp = getSharpCornerList(sprite, defaultShape);
    return {
      sharp: sharp,
      refInside: findInteriorRefPoint(sharp),
      inset: 6
    };
  }
  /**
   * 由元素外接框与形状得到未圆角前的顶点环(rect 或 L1–L4)。
   * @param {{ x: number, y: number, width: number, height: number, shape?: string, turningPoint?: object }} sprite
   * @param {string} defaultShape
   * @returns {{ x: number, y: number, direction?: string }[]}
   */
  function getSharpCornerList(sprite, defaultShape) {
    var rectPointList = [
      { x: sprite.x, y: sprite.y, direction: 'right' },
      { x: sprite.x + sprite.width, y: sprite.y, direction: 'down' },
      {
        x: sprite.x + sprite.width,
        y: sprite.y + sprite.height,
        direction: 'left'
      },
      { x: sprite.x, y: sprite.y + sprite.height, direction: 'up' }
    ];
    var shape = sprite.shape || defaultShape;
    return shape === 'rect' ? rectPointList : getLShapePointList(sprite);
  }
  function getIsStillHalf(target) {
    var curList = getLShapePointList(target);
    var count = 0;
    for (var i = 0; i < curList.length; i++) {
      var p0 = curList[i];
      var p1 = curList[(i + 1) % curList.length];
      var p2 = curList[(i + 2) % curList.length];
      var p3 = curList[(i + 3) % curList.length];
      var d1 = calcDistance(p0, p1);
      var d2 = calcDistance(p1, p2);
      var d3 = calcDistance(p2, p3);
      if (target.halfList.includes((i + 1) % curList.length) && d2 <= d1 && d2 <= d3) {
        count += 1;
      }
    }
    return count === 2;
  }
  function setRadiusInPoint(pointList, sprite) {
    sprite.halfList = [];
    var pointLength = pointList.length;
    var i;
    for (i = 0; i < pointLength; i++) {
      var currentPoint = pointList[i];
      var next1 = pointList[(i + 1) % pointLength];
      var next2 = pointList[(i + 2) % pointLength];
      var next3 = pointList[(i + 3) % pointLength];
      var v1 = normalizeVector(currentPoint, next1);
      var v2 = normalizeVector(next1, next2);
      var v3 = normalizeVector(next2, next3);
      var finalV = { x: v1.x + v2.x + v3.x, y: v1.y + v2.y + v3.y };
      var distance = finalV.x * finalV.x + finalV.y * finalV.y;
      var d1 = calcDistance(currentPoint, next1);
      var d2 = calcDistance(next1, next2);
      var d3 = calcDistance(next2, next3);
      if (distance === 1 && d2 <= d1 && d2 <= d3) {
        var radius = d2 / 2;
        next1.isHalf = true;
        next2.isHalf = true;
        next1.radius = radius;
        next2.radius = radius;
        sprite.halfList.push((i + 1) % pointLength);
      }
    }
    for (i = 0; i < pointLength; i++) {
      currentPoint = pointList[i];
      if (currentPoint.isHalf) {
        continue;
      }
      var prevPoint = pointList[(i - 1 + pointLength) % pointLength];
      var nextPoint = pointList[(i + 1 + pointLength) % pointLength];
      var rad = Math.min.apply(
        Math,
        [prevPoint.radius, nextPoint.radius].filter(function (item) {
          return item;
        })
      );
      currentPoint.radius = rad;
    }
  }
  function smoothRightAnglePath(points) {
    var smoothedPath = [];
    var n = points.length;
    var i;
    for (i = 0; i < n; i++) {
      var prev = points[(i - 1 + n) % n];
      var curr = points[i];
      var next = points[(i + 1) % n];
      var radius = curr.radius;
      var toPrev = normalizeVector(curr, prev);
      var toNext = normalizeVector(curr, next);
      var dotProduct = toPrev.x * toNext.x + toPrev.y * toNext.y;
      var isRightAngle = Math.abs(dotProduct) < 0.1;
      if (isRightAngle) {
        var arcStart = {
          x: curr.x + toPrev.x * radius,
          y: curr.y + toPrev.y * radius
        };
        var arcEnd = {
          x: curr.x + toNext.x * radius,
          y: curr.y + toNext.y * radius
        };
        var arcCenter = {
          x: curr.x + toPrev.x * radius + toNext.x * radius,
          y: curr.y + toPrev.y * radius + toNext.y * radius
        };
        var startAngle = Math.atan2(arcStart.y - arcCenter.y, arcStart.x - arcCenter.x);
        var endAngle = Math.atan2(arcEnd.y - arcCenter.y, arcEnd.x - arcCenter.x);
        var crossProduct = toPrev.x * toNext.y - toPrev.y * toNext.x;
        if (crossProduct > 0) {
          if (endAngle > startAngle) {
            endAngle -= Math.PI * 2;
          }
        } else {
          if (endAngle < startAngle) {
            endAngle += Math.PI * 2;
          }
        }
        smoothedPath.push({
          x: arcCenter.x,
          y: arcCenter.y,
          radius: radius,
          startAngle: startAngle,
          endAngle: endAngle,
          type: 'arc',
          arcStartX: arcStart.x,
          arcStartY: arcStart.y,
          arcEndX: arcEnd.x,
          arcEndY: arcEnd.y,
          crossProduct: crossProduct
        });
      }
    }
    var length = smoothedPath.length;
    var newSmoothedPath = [];
    for (i = 0; i < length; i++) {
      var currArc = smoothedPath[i];
      var nextArc = smoothedPath[(i + 1 + length) % length];
      newSmoothedPath.push(currArc);
      if (currArc.arcEndX !== nextArc.arcStartX || currArc.arcEndY !== nextArc.arcStartY) {
        newSmoothedPath.push({
          startX: currArc.arcEndX,
          startY: currArc.arcEndY,
          type: 'line',
          x: nextArc.arcStartX,
          y: nextArc.arcStartY
        });
      }
    }
    return newSmoothedPath;
  }
  /**
   * 仅描边/填充轮廓:沿 pathList 走笔并 closePath(不 endFill,便于连续多圈描边)。
   * @param {PIXI.Graphics} smoothedGraphics
   * @param {object[]} path
   */
  function traceSmoothedPath(smoothedGraphics, path) {
    if (!path || !path.length) {
      return;
    }
    var startPoint = path[0];
    if (startPoint.type === 'arc') {
      smoothedGraphics.moveTo(startPoint.arcStartX, startPoint.arcStartY);
    } else {
      smoothedGraphics.moveTo(startPoint.x, startPoint.y);
    }
    var i;
    for (i = 0; i < path.length; i++) {
      var point = path[i];
      if (point.type === 'line') {
        smoothedGraphics.lineTo(point.x, point.y);
      } else if (point.type === 'arc') {
        smoothedGraphics.arc(
          point.x,
          point.y,
          point.radius,
          point.startAngle,
          point.endAngle,
          point.endAngle < point.startAngle
        );
      }
    }
    smoothedGraphics.closePath();
  }
  /**
   * 填充闭合外圈并 endFill(调用方需已 beginFill)。
   * @param {PIXI.Graphics} smoothedGraphics
   * @param {object[]} path
   */
  function drawSmoothedPath(smoothedGraphics, path) {
    traceSmoothedPath(smoothedGraphics, path);
    smoothedGraphics.endFill();
  }
  /**
   * PIXI beginHole 要求洞与外轮廓绕向相反:段逆序且圆弧起终点对调。
   * @param {object[]} path smoothRightAnglePath 结果
   * @returns {object[]}
   */
  function reverseSmoothedSegmentsForHole(path) {
    var n = path.length;
    var out = [];
    var i;
    for (i = n - 1; i >= 0; i--) {
      var seg = path[i];
      if (seg.type === 'line') {
        out.push({
          type: 'line',
          startX: seg.x,
          startY: seg.y,
          x: seg.startX,
          y: seg.startY
        });
      } else if (seg.type === 'arc') {
        out.push({
          type: 'arc',
          x: seg.x,
          y: seg.y,
          radius: seg.radius,
          startAngle: seg.endAngle,
          endAngle: seg.startAngle,
          arcStartX: seg.arcEndX,
          arcStartY: seg.arcEndY,
          arcEndX: seg.arcStartX,
          arcEndY: seg.arcStartY,
          crossProduct: seg.crossProduct
        });
      }
    }
    return out;
  }
  /**
   * 外填充 + 内洞(双轨带),调用方需已 beginFill。
   * @param {PIXI.Graphics} smoothedGraphics
   * @param {object[]} outerPath
   * @param {object[]} innerPath
   */
  function drawSmoothedPathWithHole(smoothedGraphics, outerPath, innerPath) {
    traceSmoothedPath(smoothedGraphics, outerPath);
    smoothedGraphics.beginHole();
    traceSmoothedPath(smoothedGraphics, reverseSmoothedSegmentsForHole(innerPath));
    smoothedGraphics.endHole();
    smoothedGraphics.endFill();
  }
  /**
   * 生成外圈与内圈 smooth pathList;内圈由正交尖点环 offset 后再 setRadius/smooth。
   * @param {{ x: number, y: number, width: number, height: number, shape?: string, turningPoint?: object, annulusBandInset?: number }} sprite
   * @param {string} defaultShape
   * @returns {{ outerPath: object[], innerPath: object[]|null, inset: number }}
   */
  function buildOuterAndInnerPaths(sprite, defaultShape) {
    var ctx = annulusBandContext(sprite, defaultShape);
    var sharp = ctx.sharp;
    var sharpClone = sharp.map(function (p) {
      return { x: p.x, y: p.y, direction: p.direction };
    });
    setRadiusInPoint(sharp, sprite);
    var outerPath = smoothRightAnglePath(sharp);
    var inset = ctx.inset;
    var minSide = Math.min(sprite.width, sprite.height);
    if (inset <= 0 || minSide <= inset * 2 + 4) {
      return { outerPath: outerPath, innerPath: null, inset: inset };
    }
    var innerSharp = offsetOrthogonalSharpRing(sharpClone, inset);
    var innerMeta = {};
    setRadiusInPoint(innerSharp, innerMeta);
    var innerPath = smoothRightAnglePath(innerSharp);
    return { outerPath: outerPath, innerPath: innerPath, inset: inset };
  }
  /**
   * 外轮廓上一点沿法向(直线段)或径向(圆弧段)内移 half,落在双轨几何中线。
   * @param {number} x
   * @param {number} y
   * @param {object} path 当前段 line|arc
   * @param {number} inset 单侧带宽
   * @param {{ x: number, y: number }} refInside 多边形内参考点
   * @returns {{ x: number, y: number }}
   */
  function shiftAnnulusPointToBandCenter(x, y, path, inset, refInside) {
    var half = inset / 2;
    if (half <= 0) {
      return { x: x, y: y };
    }
    if (path.type === 'line') {
      var tx = path.x - path.startX;
      var ty = path.y - path.startY;
      var tlen = Math.sqrt(tx * tx + ty * ty) || 1;
      var nx = -ty / tlen;
      var ny = tx / tlen;
      var mx = (path.startX + path.x) / 2;
      var my = (path.startY + path.y) / 2;
      if ((refInside.x - mx) * nx + (refInside.y - my) * ny < 0) {
        nx = -nx;
        ny = -ny;
      }
      return { x: x + nx * half, y: y + ny * half };
    }
    if (path.type === 'arc') {
      var ox = x - path.x;
      var oy = y - path.y;
      var olen = Math.sqrt(ox * ox + oy * oy) || 1;
      return { x: x - (ox / olen) * half, y: y - (oy / olen) * half };
    }
    return { x: x, y: y };
  }
  /**
   * 将外圈 path 上坐标映射到轨道带中线(监控/条码与编辑器一致)。
   * @param {{ type: string, x: number, y: number, width: number, height: number, shape?: string, turningPoint?: object, annulusBandInset?: number }} element
   * @param {number} x
   * @param {number} y
   * @param {object} path
   * @returns {{ x: number, y: number }}
   */
  function centerAnnulusBandPoint(element, x, y, path) {
    if (!element || element.type !== 'annulus' || path == null) {
      return { x: x, y: y };
    }
    var ctx = annulusBandContext(element, element.shape || 'rect');
    return shiftAnnulusPointToBandCenter(x, y, path, ctx.inset, ctx.refInside);
  }
  /**
   * 将坐标压回环穿外圈 path(直线段投影、圆弧段落到半径上)。
   * sprite 已做 centerAnnulusBandPoint 时不能直接作为 getPositionAfterMove 的起点,否则会沿「点→段终点」斜移漂移。
   * @param {number} x
   * @param {number} y
   * @param {{ type: string, startX?: number, startY?: number, x?: number, y?: number, radius?: number }|null|undefined} path
   * @returns {{ x: number, y: number }}
   */
  function snapToAnnulusOuterPath(x, y, path) {
    if (!path) {
      return { x: x, y: y };
    }
    if (path.type === 'line') {
      var sx = path.startX;
      var sy = path.startY;
      var ex = path.x;
      var ey = path.y;
      var dx = ex - sx;
      var dy = ey - sy;
      var len2 = dx * dx + dy * dy;
      if (len2 < 1e-12) {
        return { x: sx, y: sy };
      }
      var t = ((x - sx) * dx + (y - sy) * dy) / len2;
      t = Math.max(0, Math.min(1, t));
      return { x: sx + t * dx, y: sy + t * dy };
    }
    if (path.type === 'arc') {
      var ox = path.x;
      var oy = path.y;
      var r = path.radius;
      if (!isFinite(r) || r <= 0) {
        return { x: x, y: y };
      }
      var ang = getNormalizeAngle(Math.atan2(y - oy, x - ox), path.startAngle, path.endAngle);
      return { x: ox + r * Math.cos(ang), y: oy + r * Math.sin(ang) };
    }
    return { x: x, y: y };
  }
  /**
   * 监控轨道层:已保存的 pathList 描外圈 + 按几何重算内圈再描一圈。
   * @param {PIXI.Graphics} smoothedGraphics
   * @param {{ pathList: object[], x: number, y: number, width: number, height: number, shape?: string, turningPoint?: object, annulusBandInset?: number }} item
   * @param {string} defaultShape
   */
  function strokeAnnulusDualOutline(smoothedGraphics, item, defaultShape) {
    var outerPath = item && item.pathList;
    if (!outerPath || !outerPath.length) {
      return;
    }
    var tmp = {
      x: item.x,
      y: item.y,
      width: item.width,
      height: item.height,
      shape: item.shape,
      turningPoint: item.turningPoint,
      annulusBandInset: item.annulusBandInset
    };
    var pair = buildOuterAndInnerPaths(tmp, item.shape || defaultShape);
    traceSmoothedPath(smoothedGraphics, outerPath);
    if (pair.innerPath && pair.innerPath.length) {
      traceSmoothedPath(smoothedGraphics, pair.innerPath);
    }
  }
  /**
   * 编辑器环穿填充:写回 sprite.pathList(外圈),绘制外填+内洞。
   * PIXI 中带洞填充通常不会给洞边界描边,故在 endFill 后再沿内圈 trace 一遍,与监控轨 strokeAnnulusDualOutline 一致,
   * 使 trackLayer / guideLayer / selectionLayer 均能看到内圈轮廓。
   * @param {PIXI.Graphics} smoothedGraphics
   * @param {object} sprite 元素或预览 rect
   * @param {string} defaultShape 如 annulusShape
   */
  function startDrawSmoothedPath(smoothedGraphics, sprite, defaultShape) {
    var pair = buildOuterAndInnerPaths(sprite, defaultShape);
    sprite.pathList = pair.outerPath;
    if (!pair.innerPath || !pair.innerPath.length) {
      drawSmoothedPath(smoothedGraphics, pair.outerPath);
      return;
    }
    drawSmoothedPathWithHole(smoothedGraphics, pair.outerPath, pair.innerPath);
    traceSmoothedPath(smoothedGraphics, pair.innerPath);
  }
  function getRotate(point, path) {
    var vector = normalizeVector(point, path);
    if (path.type === 'arc') {
      var angleToCenter = Math.atan2(vector.y, vector.x);
      return angleToCenter + (Math.PI / 2) * path.crossProduct;
    }
    return Math.atan2(vector.y, vector.x);
  }
  /**
   * 堆垛机 / 双工位设备图标(与 MapCanvas#createCrnTexture / createCrnTextureColoredDevice 一致)
   * @param {PIXI.Graphics} g
   * @param {number} deviceWidth 图标总宽(像素,与 getDevicePixelBoxForTrack.along 一致)
   * @param {number} deviceHeight 图标总高(与 getDevicePixelBoxForTrack.across 一致)
   * @param {number} bodyColor 驾驶舱填充色
   */
  function drawCrnDeviceGraphics(g, deviceWidth, deviceHeight, bodyColor) {
    var yTop = Math.round(deviceHeight * 0.1);
    g.beginFill(0x999999);
    g.drawRect(2, yTop, 3, deviceHeight - yTop - 2);
    g.drawRect(deviceWidth - 5, yTop, 3, deviceHeight - yTop - 2);
    g.endFill();
    g.beginFill(0x999999);
    g.drawRect(0, yTop, deviceWidth, 3);
    g.endFill();
    var cabW = Math.round(deviceWidth * 0.68);
    var cabH = Math.round(deviceHeight * 0.38);
    var cabX = Math.round((deviceWidth - cabW) / 2);
    var cabY = Math.round(deviceHeight * 0.52 - cabH / 2);
    g.beginFill(bodyColor);
    g.drawRect(cabX, cabY, cabW, cabH);
    g.endFill();
    var winW = Math.round(cabW * 0.6);
    var winH = Math.round(cabH * 0.45);
    var winX = cabX + Math.round((cabW - winW) / 2);
    var winY = cabY + Math.round((cabH - winH) / 2);
    g.beginFill(0xd0e8ff);
    g.drawRect(winX, winY, winW, winH);
    g.endFill();
    var forkW = Math.round(deviceWidth * 0.8);
    var forkH = Math.max(2, Math.round(deviceHeight * 0.08));
    var forkX = Math.round((deviceWidth - forkW) / 2);
    var forkY = cabY + cabH;
    g.beginFill(0x666666);
    g.drawRect(forkX, forkY, forkW, forkH);
    g.endFill();
  }
  /**
   * RGV 设备图标(与 MapCanvas#createRgvTextureColoredDevice 一致)
   * @param {PIXI.Graphics} g
   * @param {number} width
   * @param {number} height
   * @param {number} bodyColor 车体填充色
   */
  function drawRgvDeviceGraphics(g, width, height, bodyColor) {
    var bodyW = Math.round(width * 0.8);
    var bodyH = Math.round(height * 0.55);
    var bodyX = Math.round((width - bodyW) / 2);
    var bodyY = Math.round((height - bodyH) / 2);
    g.beginFill(bodyColor);
    g.drawRect(bodyX, bodyY, bodyW, bodyH);
    g.endFill();
    var winW = Math.round(bodyW * 0.55);
    var winH = Math.round(bodyH * 0.45);
    var winX = bodyX + Math.round((bodyW - winW) / 2);
    var winY = bodyY + Math.round((bodyH - winH) / 2);
    g.beginFill(0xd0e8ff);
    g.drawRect(winX, winY, winW, winH);
    g.endFill();
    var wheelW = Math.max(2, Math.round(width * 0.12));
    var wheelH = Math.max(2, Math.round(height * 0.1));
    var wheelY = bodyY + bodyH;
    var wheelGap = Math.round((width - wheelW * 2) / 3);
    var wheelX1 = wheelGap;
    var wheelX2 = width - wheelGap - wheelW;
    g.beginFill(0x333333);
    g.drawRect(wheelX1, wheelY - Math.round(wheelH / 2), wheelW, wheelH);
    g.drawRect(wheelX2, wheelY - Math.round(wheelH / 2), wheelW, wheelH);
    g.endFill();
  }
  global.BasMapTrackGeometry = {
    TYPE_META: TYPE_META,
    normalizeVector: normalizeVector,
    calcDistance: calcDistance,
    safeParseJson: safeParseJson,
    getNormalizeAngle: getNormalizeAngle,
    getPositionAfterMove: getPositionAfterMove,
    getAllDistance: getAllDistance,
    getDeviceInfo: getDeviceInfo,
    getDevicePixelBoxForTrack: getDevicePixelBoxForTrack,
    getAutoTrackDeviceBox: getAutoTrackDeviceBox,
    getLShapePointList: getLShapePointList,
    getIsStillHalf: getIsStillHalf,
    setRadiusInPoint: setRadiusInPoint,
    smoothRightAnglePath: smoothRightAnglePath,
    drawSmoothedPath: drawSmoothedPath,
    traceSmoothedPath: traceSmoothedPath,
    strokeAnnulusDualOutline: strokeAnnulusDualOutline,
    centerAnnulusBandPoint: centerAnnulusBandPoint,
    snapToAnnulusOuterPath: snapToAnnulusOuterPath,
    startDrawSmoothedPath: startDrawSmoothedPath,
    getRotate: getRotate,
    drawCrnDeviceGraphics: drawCrnDeviceGraphics,
    drawRgvDeviceGraphics: drawRgvDeviceGraphics
  };
})(typeof window !== 'undefined' ? window : this);
src/main/webapp/views/basMap/editor.html
@@ -1,6 +1,6 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <head>
    <meta charset="utf-8">
    <title>自由画布地图编辑器</title>
    <meta name="renderer" content="webkit">
@@ -9,797 +9,1178 @@
    <link rel="stylesheet" href="../../static/vue/element/element.css">
    <link rel="stylesheet" href="../../static/css/cool.css">
    <style>
        :root {
            --page-bg:
                radial-gradient(1180px 540px at -10% -16%, rgba(24, 113, 181, 0.14), transparent 58%),
                radial-gradient(920px 480px at 110% -12%, rgba(14, 148, 136, 0.12), transparent 56%),
                linear-gradient(180deg, #eef4f9 0%, #f8fbfd 100%);
            --card-bg: rgba(255, 255, 255, 0.94);
            --card-border: rgba(216, 226, 238, 0.96);
            --text-main: #213448;
            --text-sub: #63788e;
            --primary: #2f79d6;
            --accent: #169a82;
            --warn: #f08a3c;
            --danger: #d85a5a;
        }
      :root {
        --page-bg:
          radial-gradient(1180px 540px at -10% -16%, rgba(24, 113, 181, 0.14), transparent 58%),
          radial-gradient(920px 480px at 110% -12%, rgba(14, 148, 136, 0.12), transparent 56%),
          linear-gradient(180deg, #eef4f9 0%, #f8fbfd 100%);
        --card-bg: rgba(255, 255, 255, 0.94);
        --card-border: rgba(216, 226, 238, 0.96);
        --text-main: #213448;
        --text-sub: #63788e;
        --primary: #2f79d6;
        --accent: #169a82;
        --warn: #f08a3c;
        --danger: #d85a5a;
      }
        [v-cloak] { display: none; }
      [v-cloak] {
        display: none;
      }
        html, body {
            margin: 0;
            height: 100%;
            font-family: "Avenir Next", "PingFang SC", "Microsoft YaHei", sans-serif;
            color: var(--text-main);
            background: var(--page-bg);
            overflow: hidden;
        }
      html,
      body {
        margin: 0;
        height: 100%;
        font-family: 'Avenir Next', 'PingFang SC', 'Microsoft YaHei', sans-serif;
        color: var(--text-main);
        background: var(--page-bg);
        overflow: hidden;
      }
        .editor-shell {
            width: 100%;
            height: 100vh;
            margin: 0 auto;
            padding: 8px;
            box-sizing: border-box;
            display: flex;
            flex-direction: column;
            gap: 0;
        }
      .editor-shell {
        width: 100%;
        height: 100vh;
        margin: 0 auto;
        padding: 8px;
        box-sizing: border-box;
        display: flex;
        flex-direction: column;
        gap: 0;
      }
        .panel-card {
            border-radius: 24px;
            border: 1px solid var(--card-border);
            background: var(--card-bg);
            box-shadow: 0 16px 32px rgba(39, 62, 92, 0.08);
        }
      .panel-card {
        border-radius: 24px;
        border: 1px solid var(--card-border);
        background: var(--card-bg);
        box-shadow: 0 16px 32px rgba(39, 62, 92, 0.08);
      }
        .workspace {
            min-height: 0;
            flex: 1 1 auto;
            display: flex;
        }
      .workspace {
        min-height: 0;
        flex: 1 1 auto;
        display: flex;
      }
        .panel-card {
            display: flex;
            flex-direction: column;
            overflow: hidden;
        }
      .panel-card {
        display: flex;
        flex-direction: column;
        overflow: hidden;
      }
        .panel-head {
            display: flex;
            align-items: center;
            justify-content: space-between;
            gap: 10px;
            padding: 18px 20px 14px;
            border-bottom: 1px solid rgba(221, 230, 239, 0.94);
        }
      .panel-head {
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: 10px;
        padding: 18px 20px 14px;
        border-bottom: 1px solid rgba(221, 230, 239, 0.94);
      }
        .panel-head h2 {
            margin: 0;
            font-size: 16px;
            font-weight: 700;
        }
      .panel-head h2 {
        margin: 0;
        font-size: 16px;
        font-weight: 700;
      }
        .panel-body {
            padding: 16px 18px 18px;
            display: flex;
            flex-direction: column;
            gap: 14px;
            min-height: 0;
            overflow: auto;
        }
      .panel-body {
        padding: 16px 18px 18px;
        display: flex;
        flex-direction: column;
        gap: 14px;
        min-height: 0;
        overflow: auto;
      }
        .tool-section,
        .status-stack,
        .action-list {
            display: flex;
            flex-direction: column;
            gap: 8px;
        }
      .tool-section,
      .status-stack,
      .action-list {
        display: flex;
        flex-direction: column;
        gap: 8px;
      }
        .tool-section-label {
            font-size: 12px;
            color: var(--text-sub);
            letter-spacing: 0.08em;
            text-transform: uppercase;
        }
      .tool-section-label {
        font-size: 12px;
        color: var(--text-sub);
        letter-spacing: 0.08em;
        text-transform: uppercase;
      }
        .tool-grid {
            display: grid;
            grid-template-columns: repeat(2, minmax(0, 1fr));
            gap: 8px;
        }
      .tool-section-label-sub {
        font-size: 12px;
        color: var(--text-sub);
        letter-spacing: 0.08em;
        text-transform: uppercase;
        line-height: 1.75;
      }
        .tool-card-btn {
            appearance: none;
            border: 1px solid rgba(193, 205, 219, 0.9);
            background: rgba(255, 255, 255, 0.96);
            border-radius: 14px;
            padding: 10px 12px;
            text-align: left;
            cursor: pointer;
            transition: all 0.18s ease;
            color: var(--text-main);
            display: flex;
            flex-direction: column;
            gap: 4px;
            min-height: 68px;
        }
      .tool-grid {
        display: grid;
        grid-template-columns: repeat(2, minmax(0, 1fr));
        gap: 8px;
      }
        .tool-card-btn.active {
            border-color: rgba(55, 127, 212, 0.45);
            background: rgba(235, 244, 253, 0.98);
            box-shadow: 0 8px 18px rgba(47, 121, 214, 0.14);
        }
      .tool-el-popover {
        height: 100%;
      }
        .tool-card-btn strong {
            font-size: 13px;
            font-weight: 700;
        }
      .tool-el-popover > .el-popover__reference-wrapper {
        display: block;
        height: 100%;
      }
        .tool-card-btn span {
            font-size: 12px;
            color: var(--text-sub);
            line-height: 1.5;
        }
      .tool-el-popover .tool-card-btn {
        height: 100%;
      }
        .status-card,
        .selection-summary,
        .note-card {
            border-radius: 16px;
            border: 1px solid rgba(218, 227, 236, 0.92);
            background: rgba(248, 251, 254, 0.92);
            padding: 12px 14px;
        }
      .tool-card-btn {
        appearance: none;
        border: 1px solid rgba(193, 205, 219, 0.9);
        background: rgba(255, 255, 255, 0.96);
        border-radius: 14px;
        padding: 10px 12px;
        text-align: left;
        cursor: pointer;
        transition: all 0.18s ease;
        color: var(--text-main);
        display: flex;
        flex-direction: column;
        gap: 4px;
        min-height: 68px;
      }
        .status-card strong,
        .selection-summary strong,
        .note-card strong {
            display: block;
            font-size: 13px;
            margin-bottom: 6px;
        }
      .tool-card-btn.active {
        border-color: rgba(55, 127, 212, 0.45);
        background: rgba(235, 244, 253, 0.98);
        box-shadow: 0 8px 18px rgba(47, 121, 214, 0.14);
      }
        .status-card span,
        .selection-summary span,
        .note-card span {
            display: block;
            font-size: 12px;
            color: var(--text-sub);
            line-height: 1.65;
        }
      .tool-card-btn strong {
        font-size: 13px;
        font-weight: 700;
      }
        .selection-summary strong {
            font-size: 14px;
            color: var(--text-main);
        }
      .tool-card-btn span {
        font-size: 12px;
        color: var(--text-sub);
        line-height: 1.5;
      }
        .canvas-toolbar {
            padding: 16px 18px 14px;
            border-bottom: 1px solid rgba(221, 230, 239, 0.94);
            display: flex;
            align-items: flex-start;
            justify-content: space-between;
            gap: 12px;
            flex-wrap: wrap;
        }
      .status-card,
      .selection-summary,
      .note-card {
        border-radius: 16px;
        border: 1px solid rgba(218, 227, 236, 0.92);
        background: rgba(248, 251, 254, 0.92);
        padding: 12px 14px;
      }
        .canvas-toolbar-main {
            flex: 1 1 420px;
            display: flex;
            flex-direction: column;
            gap: 8px;
        }
      .status-card strong,
      .selection-summary strong,
      .note-card strong {
        display: block;
        font-size: 13px;
        margin-bottom: 6px;
      }
        .canvas-toolbar-title {
            display: flex;
            flex-direction: column;
            gap: 4px;
        }
      .status-card span,
      .selection-summary span,
      .note-card span {
        display: block;
        font-size: 12px;
        color: var(--text-sub);
        line-height: 1.65;
      }
        .canvas-toolbar-title h1 {
            margin: 0;
            font-size: 24px;
            font-weight: 700;
            letter-spacing: 0.3px;
        }
      .selection-summary strong {
        font-size: 14px;
        color: var(--text-main);
      }
        .canvas-toolbar-title span {
            font-size: 13px;
            line-height: 1.65;
            color: var(--text-sub);
        }
      .canvas-toolbar {
        padding: 16px 18px 14px;
        border-bottom: 1px solid rgba(221, 230, 239, 0.94);
        display: flex;
        align-items: flex-start;
        justify-content: space-between;
        gap: 12px;
        flex-wrap: wrap;
      }
        .canvas-toolbar-meta,
        .canvas-toolbar-actions {
            display: flex;
            align-items: center;
            gap: 8px;
            flex-wrap: wrap;
        }
      .canvas-toolbar-main {
        flex: 1 1 420px;
        display: flex;
        flex-direction: column;
        gap: 8px;
      }
        .canvas-toolbar-actions {
            justify-content: flex-end;
            flex: 0 1 760px;
        }
      .canvas-toolbar-title {
        display: flex;
        flex-direction: column;
        gap: 4px;
      }
        .canvas-toolbar-actions .el-input__inner,
        .canvas-toolbar-actions .el-button {
            border-radius: 10px;
        }
      .canvas-toolbar-title h1 {
        margin: 0;
        font-size: 24px;
        font-weight: 700;
        letter-spacing: 0.3px;
      }
        .canvas-meta {
            font-size: 12px;
            color: var(--text-sub);
        }
      .canvas-toolbar-title span {
        font-size: 13px;
        line-height: 1.65;
        color: var(--text-sub);
      }
        .canvas-card {
            flex: 1 1 auto;
            min-width: 0;
            min-height: 0;
        }
      .canvas-toolbar-meta,
      .canvas-toolbar-actions {
        display: flex;
        align-items: center;
        gap: 8px;
        flex-wrap: wrap;
      }
        .canvas-wrap {
            position: relative;
            flex: 1 1 auto;
            min-height: 0;
            background:
                linear-gradient(180deg, rgba(245, 249, 252, 0.95) 0%, rgba(251, 252, 254, 0.98) 100%);
        }
      .canvas-toolbar-actions {
        justify-content: flex-end;
        flex: 0 1 760px;
      }
        .canvas-stage {
            position: absolute;
            inset: 0;
            overflow: hidden;
            background: #f6f9fc;
        }
      .canvas-toolbar-actions .el-input__inner,
      .canvas-toolbar-actions .el-button {
        border-radius: 10px;
      }
        .canvas-host {
            position: absolute;
            inset: 0;
            background: #f6f9fc;
        }
      .canvas-meta {
        font-size: 12px;
        color: var(--text-sub);
      }
        .canvas-overlay-layer {
            position: absolute;
            inset: 0;
            pointer-events: none;
            z-index: 5;
        }
      .canvas-card {
        flex: 1 1 auto;
        min-width: 0;
        min-height: 0;
      }
        .canvas-loading-mask {
            position: absolute;
            inset: 0;
            z-index: 4;
            display: flex;
            align-items: center;
            justify-content: center;
            background: rgba(246, 249, 252, 0.82);
            backdrop-filter: blur(2px);
        }
      .canvas-wrap {
        position: relative;
        flex: 1 1 auto;
        min-height: 0;
        background: linear-gradient(
          180deg,
          rgba(245, 249, 252, 0.95) 0%,
          rgba(251, 252, 254, 0.98) 100%
        );
      }
        .canvas-loading-card {
            display: flex;
            flex-direction: column;
            align-items: center;
            gap: 8px;
            min-width: 220px;
            padding: 18px 22px;
            border-radius: 18px;
            background: rgba(255, 255, 255, 0.96);
            border: 1px solid rgba(55, 127, 212, 0.16);
            box-shadow: 0 18px 40px rgba(66, 94, 136, 0.12);
            color: var(--text-main);
        }
      .canvas-stage {
        position: absolute;
        inset: 0;
        overflow: hidden;
        background: #f6f9fc;
      }
        .canvas-loading-card strong {
            font-size: 18px;
        }
      .canvas-host {
        position: absolute;
        inset: 0;
        background: #f6f9fc;
      }
        .canvas-loading-card span {
            font-size: 13px;
            color: var(--text-sub);
        }
      .canvas-overlay-layer {
        position: absolute;
        inset: 0;
        pointer-events: none;
        z-index: 5;
      }
        .overlay-panel {
            position: absolute;
            top: 14px;
            bottom: 14px;
            width: 300px;
            display: flex;
            flex-direction: column;
            border-radius: 22px;
            border: 1px solid rgba(214, 224, 236, 0.96);
            background: #ffffff;
            box-shadow: 0 8px 18px rgba(31, 55, 82, 0.06);
            overflow: hidden;
            pointer-events: auto;
            contain: layout paint;
        }
      .canvas-loading-mask {
        position: absolute;
        inset: 0;
        z-index: 4;
        display: flex;
        align-items: center;
        justify-content: center;
        background: rgba(246, 249, 252, 0.82);
        backdrop-filter: blur(2px);
      }
      .canvas-loading-card {
        display: flex;
        flex-direction: column;
        align-items: center;
        gap: 8px;
        min-width: 220px;
        padding: 18px 22px;
        border-radius: 18px;
        background: rgba(255, 255, 255, 0.96);
        border: 1px solid rgba(55, 127, 212, 0.16);
        box-shadow: 0 18px 40px rgba(66, 94, 136, 0.12);
        color: var(--text-main);
      }
      .canvas-loading-card strong {
        font-size: 18px;
      }
      .canvas-loading-card span {
        font-size: 13px;
        color: var(--text-sub);
      }
      .overlay-panel {
        position: absolute;
        top: 14px;
        bottom: 14px;
        width: 300px;
        display: flex;
        flex-direction: column;
        border-radius: 22px;
        border: 1px solid rgba(214, 224, 236, 0.96);
        background: #ffffff;
        box-shadow: 0 8px 18px rgba(31, 55, 82, 0.06);
        overflow: hidden;
        pointer-events: auto;
        contain: layout paint;
      }
      .overlay-left {
        left: 14px;
      }
      .overlay-right {
        right: 14px;
        width: 340px;
      }
      .overlay-panel.collapsed {
        width: 68px;
        bottom: auto;
      }
      .overlay-panel.collapsed .panel-body {
        display: none;
      }
      .overlay-panel.collapsed .panel-head {
        align-items: flex-start;
        padding: 12px 10px;
      }
      .overlay-panel.collapsed .panel-head h2 {
        writing-mode: vertical-rl;
        text-orientation: mixed;
        font-size: 14px;
        line-height: 1;
      }
      .overlay-panel.collapsed .canvas-meta {
        display: none;
      }
      .panel-head-actions {
        display: flex;
        align-items: center;
        gap: 8px;
      }
      .panel-toggle {
        appearance: none;
        border: 1px solid rgba(190, 203, 217, 0.96);
        background: rgba(255, 255, 255, 0.96);
        color: var(--text-main);
        width: 30px;
        height: 30px;
        border-radius: 10px;
        cursor: pointer;
        font-size: 14px;
        font-weight: 700;
        line-height: 1;
      }
      .panel-toggle:hover {
        border-color: rgba(55, 127, 212, 0.4);
        color: var(--primary);
      }
      .prop-grid {
        display: grid;
        grid-template-columns: repeat(2, minmax(0, 1fr));
        gap: 10px;
      }
      .prop-grid .device-divider {
        margin: 0px 0 6px;
      }
      .prop-grid .span-2 {
        grid-column: span 2;
      }
      .field-stack {
        display: flex;
        flex-direction: column;
        gap: 6px;
      }
      .field-label {
        font-size: 12px;
        color: var(--text-sub);
        line-height: 1.4;
      }
      .field-required {
        color: #d85b52;
        font-weight: 700;
      }
      .field-help {
        font-size: 12px;
        color: var(--text-sub);
        line-height: 1.6;
      }
      .direction-grid {
        display: grid;
        grid-template-columns: repeat(4, minmax(0, 1fr));
        gap: 8px;
      }
      .direction-chip {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        gap: 6px;
        min-height: 34px;
        border: 1px solid rgba(55, 127, 212, 0.18);
        border-radius: 12px;
        background: #fff;
        color: var(--text-main);
        cursor: pointer;
        transition: all 0.16s ease;
      }
      .direction-chip:hover {
        border-color: rgba(55, 127, 212, 0.45);
        color: var(--primary);
      }
      .direction-chip.active {
        border-color: rgba(55, 127, 212, 0.85);
        background: rgba(85, 145, 227, 0.12);
        color: var(--primary);
        box-shadow: inset 0 0 0 1px rgba(85, 145, 227, 0.1);
      }
      .direction-arrow {
        font-size: 16px;
        font-weight: 700;
        line-height: 1;
      }
      .check-grid {
        display: grid;
        grid-template-columns: repeat(2, minmax(0, 1fr));
        gap: 8px 12px;
      }
      .json-box {
        font-family: Menlo, Monaco, Consolas, 'Liberation Mono', monospace;
      }
      .footer-note {
        font-size: 12px;
        color: var(--text-sub);
        line-height: 1.75;
      }
      @media (max-width: 1380px) {
        .overlay-left {
            left: 14px;
          width: 260px;
        }
        .overlay-right {
            right: 14px;
            width: 340px;
          width: 300px;
        }
      }
      @media (max-width: 980px) {
        .canvas-card {
          min-height: 0;
        }
        .overlay-panel.collapsed {
            width: 68px;
            bottom: auto;
        .canvas-wrap {
          min-height: 0;
        }
        .overlay-panel.collapsed .panel-body {
            display: none;
        .overlay-left,
        .overlay-right {
          width: min(280px, calc(100% - 28px));
        }
        .overlay-panel.collapsed .panel-head {
            align-items: flex-start;
            padding: 12px 10px;
        }
        .overlay-panel.collapsed .panel-head h2 {
            writing-mode: vertical-rl;
            text-orientation: mixed;
            font-size: 14px;
            line-height: 1;
        }
        .overlay-panel.collapsed .canvas-meta {
            display: none;
        }
        .panel-head-actions {
            display: flex;
            align-items: center;
            gap: 8px;
        }
        .panel-toggle {
            appearance: none;
            border: 1px solid rgba(190, 203, 217, 0.96);
            background: rgba(255, 255, 255, 0.96);
            color: var(--text-main);
            width: 30px;
            height: 30px;
            border-radius: 10px;
            cursor: pointer;
            font-size: 14px;
            font-weight: 700;
            line-height: 1;
        }
        .panel-toggle:hover {
            border-color: rgba(55, 127, 212, 0.4);
            color: var(--primary);
        }
        .prop-grid {
            display: grid;
            grid-template-columns: repeat(2, minmax(0, 1fr));
            gap: 10px;
        }
        .prop-grid .span-2 {
            grid-column: span 2;
        }
        .field-stack {
            display: flex;
            flex-direction: column;
            gap: 6px;
        }
        .field-label {
            font-size: 12px;
            color: var(--text-sub);
            line-height: 1.4;
        }
        .field-required {
            color: #d85b52;
            font-weight: 700;
        }
        .field-help {
            font-size: 12px;
            color: var(--text-sub);
            line-height: 1.6;
        }
        .direction-grid {
            display: grid;
            grid-template-columns: repeat(4, minmax(0, 1fr));
            gap: 8px;
        }
        .direction-chip {
            display: inline-flex;
            align-items: center;
            justify-content: center;
            gap: 6px;
            min-height: 34px;
            border: 1px solid rgba(55, 127, 212, 0.18);
            border-radius: 12px;
            background: #fff;
            color: var(--text-main);
            cursor: pointer;
            transition: all 0.16s ease;
        }
        .direction-chip:hover {
            border-color: rgba(55, 127, 212, 0.45);
            color: var(--primary);
        }
        .direction-chip.active {
            border-color: rgba(55, 127, 212, 0.85);
            background: rgba(85, 145, 227, 0.12);
            color: var(--primary);
            box-shadow: inset 0 0 0 1px rgba(85, 145, 227, 0.1);
        }
        .direction-arrow {
            font-size: 16px;
            font-weight: 700;
            line-height: 1;
        }
        .check-grid {
            display: grid;
            grid-template-columns: repeat(2, minmax(0, 1fr));
            gap: 8px 12px;
        }
        .json-box {
            font-family: Menlo, Monaco, Consolas, "Liberation Mono", monospace;
        }
        .footer-note {
            font-size: 12px;
            color: var(--text-sub);
            line-height: 1.75;
        }
        @media (max-width: 1380px) {
            .overlay-left {
                width: 260px;
            }
            .overlay-right {
                width: 300px;
            }
        }
        @media (max-width: 980px) {
            .canvas-card {
                min-height: 0;
            }
            .canvas-wrap {
                min-height: 0;
            }
            .overlay-left,
            .overlay-right {
                width: min(280px, calc(100% - 28px));
            }
        }
      }
    </style>
</head>
<body>
<div id="app" class="editor-shell" v-cloak>
    <section class="workspace">
  </head>
  <body>
    <div id="app" class="editor-shell" v-cloak>
      <section class="workspace">
        <main class="panel-card canvas-panel canvas-card">
            <div class="canvas-toolbar">
                    <div class="canvas-toolbar-main">
                        <div class="canvas-toolbar-title">
                            <h1>WCS地图编辑器</h1>
                        </div>
                    <div class="canvas-toolbar-meta">
                        <span class="canvas-meta">楼层: {{ currentLev ? currentLev + 'F' : '--' }}</span>
                        <span class="canvas-meta">缩放: {{ viewPercent }}%</span>
                        <span class="canvas-meta">模式: {{ toolLabel(activeTool) }}</span>
                        <span class="canvas-meta">选中: {{ selectedIds.length }}</span>
                        <span class="canvas-meta">元素: {{ doc ? doc.elements.length : 0 }}</span>
                        <span class="canvas-meta">画布: {{ Math.round(doc ? doc.canvasWidth : 0) }} x {{ Math.round(doc ? doc.canvasHeight : 0) }}</span>
                        <span class="canvas-meta">渲染倍率: {{ formatNumber(pixiResolution) }}x</span>
                        <span class="canvas-meta">FPS: {{ fpsText }}</span>
                    </div>
                </div>
                <div class="canvas-toolbar-actions">
                    <el-select v-model="floorPickerLev" size="small" placeholder="选择楼层" @change="handleFloorChange" style="width: 120px;">
                        <el-option v-for="lev in levOptions" :key="'lev-' + lev" :label="lev + 'F'" :value="lev"></el-option>
                    </el-select>
                    <el-button size="small" plain @click="openBlankDialog">新建自由画布</el-button>
                    <el-button size="small" plain @click="triggerImportExcel">导入 Excel</el-button>
                    <el-button size="small" plain @click="triggerImportMap">导入地图</el-button>
                    <el-button size="small" plain @click="exportMapPackage">导出地图</el-button>
                    <el-button size="small" plain @click="loadCurrentFloor">重新读取</el-button>
                    <el-button size="small" plain @click="fitContent">适配全图</el-button>
                    <el-button size="small" plain @click="resetView">回到画布</el-button>
                    <el-button size="small" @click="undo" :disabled="undoStack.length === 0">撤销</el-button>
                    <el-button size="small" @click="redo" :disabled="redoStack.length === 0">重做</el-button>
                    <el-button size="small" type="primary" plain :loading="savingAll" :disabled="dirtyDraftCount === 0 || saving" @click="saveAllDocs">保存全部楼层<span v-if="dirtyDraftCount > 0">({{ dirtyDraftCount }})</span></el-button>
                    <el-button size="small" type="primary" :loading="saving" :disabled="savingAll" @click="saveDoc">保存当前楼层</el-button>
                </div>
          <div class="canvas-toolbar">
            <div class="canvas-toolbar-main">
              <div class="canvas-toolbar-title">
                <h1>PixiJS 自由画布地图编辑器</h1>
              </div>
              <div class="canvas-toolbar-meta">
                <span class="canvas-meta">楼层: {{ currentLev ? currentLev + 'F' : '--' }}</span>
                <span class="canvas-meta">缩放: {{ viewPercent }}%</span>
                <span class="canvas-meta">模式: {{ toolLabel(activeTool) }}</span>
                <span class="canvas-meta">选中: {{ selectedIds.length }}</span>
                <span class="canvas-meta">元素: {{ doc ? doc.elements.length : 0 }}</span>
                <span class="canvas-meta"
                  >画布: {{ Math.round(doc ? doc.canvasWidth : 0) }} x {{ Math.round(doc ?
                  doc.canvasHeight : 0) }}</span
                >
                <span class="canvas-meta">渲染倍率: {{ formatNumber(pixiResolution) }}x</span>
                <span class="canvas-meta">FPS: {{ fpsText }}</span>
              </div>
            </div>
                <div class="canvas-wrap">
                    <div class="canvas-stage" ref="canvasStage">
                        <div class="canvas-host" ref="canvasHost"></div>
                        <div v-if="loadingFloor" class="canvas-loading-mask">
                            <div class="canvas-loading-card">
                                <strong>正在加载 {{ switchingFloorLev || floorPickerLev || currentLev || '--' }}F</strong>
                                <span>画布和缓存正在切换,请稍候。</span>
                            </div>
                        </div>
                    </div>
                <div class="canvas-overlay-layer">
                    <aside class="overlay-panel overlay-left" :class="{ collapsed: toolPanelCollapsed }">
                        <div class="panel-head">
                            <h2>工具面板</h2>
                            <div class="panel-head-actions">
                                <span class="canvas-meta">{{ toolLabel(activeTool) }}</span>
                                <button type="button" class="panel-toggle" @click="toggleToolPanel">{{ toolPanelCollapsed ? '>' : '<' }}</button>
                            </div>
                        </div>
                        <div class="panel-body">
                            <div class="tool-section">
                                <div class="tool-section-label">交互</div>
                                <div class="tool-grid">
                                    <button
                                        v-for="tool in interactionTools"
                                        :key="tool.key"
                                        type="button"
                                        class="tool-card-btn"
                                        :class="{ active: activeTool === tool.key }"
                                        @click="setTool(tool.key)">
                                        <strong>{{ tool.label }}</strong>
                                        <span>{{ tool.desc }}</span>
                                    </button>
                                </div>
                            </div>
                            <div class="tool-section">
                                <div class="tool-section-label">绘制元素</div>
                                <div class="tool-grid">
                                    <button
                                        v-for="tool in drawTools"
                                        :key="tool.key"
                                        type="button"
                                        class="tool-card-btn"
                                        :class="{ active: activeTool === tool.key }"
                                        @click="setTool(tool.key)">
                                        <strong>{{ tool.label }}</strong>
                                        <span>{{ tool.desc }}</span>
                                    </button>
                                </div>
                            </div>
                            <div class="tool-section">
                                <div class="tool-section-label">编辑动作</div>
                                <div class="action-list">
                                    <el-button size="small" plain @click="copySelection" :disabled="selectedIds.length === 0">复制</el-button>
                                    <el-button size="small" plain @click="pasteClipboard" :disabled="clipboard.length === 0">粘贴</el-button>
                                    <el-button size="small" plain @click="duplicateSelection" :disabled="selectedIds.length === 0">复制偏移</el-button>
                                    <el-button size="small" plain @click="fitSelection" :disabled="selectedIds.length === 0">聚焦选中</el-button>
                                    <el-button size="small" type="danger" plain @click="deleteSelection" :disabled="selectedIds.length === 0">删除选中</el-button>
                                </div>
                            </div>
                            <div class="status-stack">
                                <div class="status-card">
                                    <strong>快捷键</strong>
                                    <span>`Delete` 删除,`Ctrl/Cmd + Z` 撤销,`Ctrl/Cmd + Shift + Z` / `Ctrl/Cmd + Y` 重做。</span>
                                    <span>`Ctrl/Cmd + C / V` 复制粘贴,按住空格可临时拖动画布,`Shift + 点击` 可增减单个选中。</span>
                                    <span>`阵列` 工具: 先选中一个货架 / 轨道模板,再拖一条水平或竖直线自动补齐一排;货架会按 `排-列` 规则继续编号。</span>
                                </div>
                                <div class="status-card">
                                    <strong>当前状态</strong>
                                    <span>楼层: {{ currentLev ? currentLev + 'F' : '--' }}</span>
                                    <span>指针: {{ pointerStatus }}</span>
                                    <span v-if="arrayPreviewCount > 0">阵列预览: 将生成 {{ arrayPreviewCount }} 个</span>
                                    <span>未保存: {{ isDirty ? '是' : '否' }}</span>
                                </div>
                            </div>
                        </div>
                    </aside>
                    <aside class="overlay-panel overlay-right" :class="{ collapsed: inspectorPanelCollapsed }">
                        <div class="panel-head">
                            <h2>属性面板</h2>
                            <div class="panel-head-actions">
                                <span class="canvas-meta" v-if="singleSelectedElement">{{ singleSelectedElement.type }}</span>
                                <span class="canvas-meta" v-else>{{ selectedIds.length > 1 ? '多选' : '画布' }}</span>
                                <button type="button" class="panel-toggle" @click="toggleInspectorPanel">{{ inspectorPanelCollapsed ? '<' : '>' }}</button>
                            </div>
                        </div>
                        <div class="panel-body">
                            <div class="selection-summary">
                                <strong v-if="singleSelectedElement">单元素编辑</strong>
                                <strong v-else-if="selectedIds.length > 1">多选编辑</strong>
                                <strong v-else>未选中元素</strong>
                                <span v-if="singleSelectedElement">位置 {{ formatNumber(singleSelectedElement.x) }}, {{ formatNumber(singleSelectedElement.y) }} | 尺寸 {{ formatNumber(singleSelectedElement.width) }} x {{ formatNumber(singleSelectedElement.height) }}</span>
                                <span v-else-if="selectedIds.length > 1">当前已选 {{ selectedIds.length }} 个元素,可整体移动、复制或删除。</span>
                                <span v-else-if="activeTool === 'array'">先选中一个货架 / 轨道元素,再拖一条线生成阵列。</span>
                                <span v-else>选择工具后点击元素,或切换框选工具框出一组元素。</span>
                            </div>
                            <div class="tool-section">
                                <div class="tool-section-label">画布设置</div>
                                <div class="prop-grid">
                                    <el-input v-model.trim="canvasForm.width" size="small" placeholder="画布宽度"></el-input>
                                    <el-input v-model.trim="canvasForm.height" size="small" placeholder="画布高度"></el-input>
                                    <el-button class="span-2" size="small" plain @click="applyCanvasSize">应用画布尺寸</el-button>
                                </div>
                            </div>
                            <template v-if="singleSelectedElement">
                                <div class="tool-section">
                                    <div class="tool-section-label">几何属性</div>
                                    <div class="prop-grid">
                                        <el-input size="small" :value="singleSelectedElement.type" disabled></el-input>
                                        <el-input size="small" :value="singleSelectedElement.id" disabled></el-input>
                                        <el-input v-model.trim="geometryForm.x" size="small" placeholder="X"></el-input>
                                        <el-input v-model.trim="geometryForm.y" size="small" placeholder="Y"></el-input>
                                        <el-input v-model.trim="geometryForm.width" size="small" placeholder="宽度"></el-input>
                                        <el-input v-model.trim="geometryForm.height" size="small" placeholder="高度"></el-input>
                                        <el-button class="span-2" size="small" plain @click="applyGeometry">应用几何</el-button>
                                    </div>
                                </div>
                                <div v-if="singleSelectedElement.type === 'devp'" class="tool-section">
                                    <div class="tool-section-label">输送站点配置</div>
                                    <div class="prop-grid">
                                        <div class="field-stack">
                                            <span class="field-label">站号</span>
                                            <el-input v-model.trim="devpForm.stationId" size="small" placeholder="请输入输送站点站号"></el-input>
                                        </div>
                                        <div class="field-stack">
                                            <span class="field-label">PLC 编号</span>
                                            <el-input v-model.trim="devpForm.deviceNo" size="small" placeholder="请输入输送站点 PLC 编号"></el-input>
                                        </div>
                                        <div class="field-stack span-2">
                                            <span class="field-label">方向</span>
                                            <div class="direction-grid">
                                                <button
                                                    v-for="item in devpDirectionOptions"
                                                    :key="item.key"
                                                    type="button"
                                                    class="direction-chip"
                                                    :class="{ active: isDevpDirectionActive(item.key) }"
                                                    @click="toggleDevpDirection(item.key)">
                                                    <span class="direction-arrow">{{ item.arrow }}</span>
                                                    <span>{{ item.label }}</span>
                                                </button>
                                            </div>
                                            <div class="field-help">点击箭头切换方向,可同时选择多个方向。</div>
                                        </div>
                                        <div class="field-stack span-2">
                                            <span class="field-label">站点类型</span>
                                            <div class="check-grid">
                                                <el-checkbox v-model="devpForm.isBarcodeStation">条码站</el-checkbox>
                                                <el-checkbox v-model="devpForm.isInStation">入站点</el-checkbox>
                                                <el-checkbox v-model="devpForm.isOutStation">出站点</el-checkbox>
                                                <el-checkbox v-model="devpForm.runBlockReassign">堵塞重分配</el-checkbox>
                                                <el-checkbox v-model="devpForm.isOutOrder">出库排序</el-checkbox>
                                                <el-checkbox v-model="devpForm.isLiftTransfer">顶升移栽</el-checkbox>
                                            </div>
                                        </div>
                                        <div class="field-stack">
                                            <span class="field-label">条码索引<span v-if="devpRequiresBarcodeIndex" class="field-required"> 必填</span></span>
                                            <el-input v-model.trim="devpForm.barcodeIdx" size="small" placeholder="条码站时必填,例如 1"></el-input>
                                        </div>
                                        <div class="field-stack">
                                            <span class="field-label">条码站站号<span v-if="devpRequiresBarcodeLink" class="field-required"> 必填</span></span>
                                            <el-input v-model.trim="devpForm.barcodeStation" size="small" placeholder="入站点时必填,填写条码站站号"></el-input>
                                        </div>
                                        <div class="field-stack">
                                            <span class="field-label">条码站 PLC 编号<span v-if="devpRequiresBarcodeLink" class="field-required"> 必填</span></span>
                                            <el-input v-model.trim="devpForm.barcodeStationDeviceNo" size="small" placeholder="入站点时必填,填写条码站 PLC 编号"></el-input>
                                        </div>
                                        <div class="field-stack">
                                            <span class="field-label">退回站站号<span v-if="devpRequiresBackStation" class="field-required"> 必填</span></span>
                                            <el-input v-model.trim="devpForm.backStation" size="small" placeholder="条码站时必填,填写退回站站号"></el-input>
                                        </div>
                                        <div class="field-stack">
                                            <span class="field-label">退回站 PLC 编号<span v-if="devpRequiresBackStation" class="field-required"> 必填</span></span>
                                            <el-input v-model.trim="devpForm.backStationDeviceNo" size="small" placeholder="条码站时必填,填写退回站 PLC 编号"></el-input>
                                        </div>
                                        <div class="footer-note span-2">
                                            勾选“入站点”后,必须填写条码站站号和条码站 PLC 编号。
                                            勾选“条码站”后,必须填写条码索引、退回站站号和退回站 PLC 编号。
                                        </div>
                                        <el-button class="span-2" size="small" type="primary" plain @click="applyDevpForm">应用输送线配置</el-button>
                                    </div>
                                </div>
                                <div v-if="singleSelectedDeviceElement" class="tool-section">
                                    <div class="tool-section-label">{{ getDeviceConfigLabel(singleSelectedDeviceElement.type) }}</div>
                                    <div class="prop-grid">
                                        <el-input size="small" :value="getDeviceConfigKeyLabel(singleSelectedDeviceElement.type, deviceForm.valueKey)" disabled></el-input>
                                        <el-input v-model.trim="deviceForm.deviceNo" size="small" placeholder="设备编号"></el-input>
                                        <el-button class="span-2" size="small" type="primary" plain @click="applyDeviceForm">应用设备参数</el-button>
                                    </div>
                                    <div class="footer-note" style="padding-top: 8px;">
                                        这里只改设备编号相关键,原始 JSON 里的其他字段会保留;下面仍可直接查看或手工修改 JSON。
                                    </div>
                                </div>
                                <div class="tool-section">
                                    <div class="tool-section-label">{{ singleSelectedElement.type === 'devp' ? '原始 JSON 预览' : (singleSelectedDeviceElement ? '原始 JSON 预览 / 手工编辑' : '值 / JSON 编辑') }}</div>
                                    <el-input
                                        class="json-box"
                                        type="textarea"
                                        :rows="8"
                                        v-model="valueEditorText"
                                        :readonly="singleSelectedElement.type === 'devp'">
                                    </el-input>
                                    <el-button
                                        v-if="singleSelectedElement.type !== 'devp'"
                                        size="small"
                                        type="primary"
                                        plain
                                        @click="applyRawValue"
                                    >应用值</el-button>
                                </div>
                            </template>
                            <div v-if="selectedShelfElements.length > 0" class="tool-section">
                                <div class="tool-section-label">货架自动填充</div>
                                <div class="prop-grid">
                                    <el-input v-model.trim="shelfFillForm.startValue" size="small" placeholder="起始值,例如 12-1"></el-input>
                                    <el-input size="small" :value="'已选货架 ' + selectedShelfElements.length + ' 个'" disabled></el-input>
                                    <el-select v-model="shelfFillForm.rowStep" size="small" placeholder="排方向">
                                        <el-option label="上到下递减" value="desc"></el-option>
                                        <el-option label="上到下递增" value="asc"></el-option>
                                    </el-select>
                                    <el-select v-model="shelfFillForm.colStep" size="small" placeholder="列方向">
                                        <el-option label="左到右递增" value="asc"></el-option>
                                        <el-option label="左到右递减" value="desc"></el-option>
                                    </el-select>
                                    <el-button class="span-2" size="small" type="primary" plain @click="applyShelfAutoFill">按排列填充货架值</el-button>
                                </div>
                                <div class="footer-note" style="padding-top: 8px;">
                                    会按选中货架的实际空间排列分组填充。默认规则是上到下排号递减、左到右列号递增。
                                </div>
                            </div>
                        </div>
                    </aside>
                </div>
            <div class="canvas-toolbar-actions">
              <el-select
                v-model="floorPickerLev"
                size="small"
                placeholder="选择楼层"
                @change="handleFloorChange"
                style="width: 120px"
              >
                <el-option
                  v-for="lev in levOptions"
                  :key="'lev-' + lev"
                  :label="lev + 'F'"
                  :value="lev"
                ></el-option>
              </el-select>
              <el-button size="small" plain @click="openBlankDialog">新建自由画布</el-button>
              <!-- <el-button size="small" plain @click="triggerImportExcel">导入 Excel</el-button> -->
              <el-button size="small" plain @click="triggerImportMap">导入地图</el-button>
              <el-button size="small" plain @click="exportMapPackage">导出地图</el-button>
              <el-button size="small" plain @click="loadCurrentFloor">重新读取</el-button>
              <el-button size="small" plain @click="fitContent">适配全图</el-button>
              <el-button size="small" plain @click="resetView">回到画布</el-button>
              <el-button size="small" @click="undo" :disabled="undoStack.length === 0"
                >撤销</el-button
              >
              <el-button size="small" @click="redo" :disabled="redoStack.length === 0"
                >重做</el-button
              >
              <el-button
                size="small"
                type="primary"
                plain
                :loading="savingAll"
                :disabled="dirtyDraftCount === 0 || saving"
                @click="saveAllDocs"
                >保存全部楼层<span v-if="dirtyDraftCount > 0"
                  >({{ dirtyDraftCount }})</span
                ></el-button
              >
              <el-button
                size="small"
                type="primary"
                :loading="saving"
                :disabled="savingAll"
                @click="saveDoc"
                >保存当前楼层</el-button
              >
            </div>
          </div>
          <div class="canvas-wrap">
            <div class="canvas-stage" ref="canvasStage">
              <div class="canvas-host" ref="canvasHost"></div>
              <div v-if="loadingFloor" class="canvas-loading-mask">
                <div class="canvas-loading-card">
                  <strong
                    >正在加载 {{ switchingFloorLev || floorPickerLev || currentLev || '--'
                    }}F</strong
                  >
                  <span>画布和缓存正在切换,请稍候。</span>
                </div>
              </div>
            </div>
            <div class="canvas-overlay-layer">
              <aside class="overlay-panel overlay-left" :class="{ collapsed: toolPanelCollapsed }">
                <div class="panel-head">
                  <h2>工具面板</h2>
                  <div class="panel-head-actions">
                    <span class="canvas-meta">{{ toolLabel(activeTool) }}</span>
                    <button type="button" class="panel-toggle" @click="toggleToolPanel">
                      {{ toolPanelCollapsed ? '>' : '<' }}
                    </button>
                  </div>
                </div>
                <div class="panel-body">
                  <div class="tool-section">
                    <div class="tool-section-label">交互</div>
                    <div class="tool-grid">
                      <button
                        v-for="tool in interactionTools"
                        :key="tool.key"
                        type="button"
                        class="tool-card-btn"
                        :class="{ active: activeTool === tool.key }"
                        @click="setTool(tool.key)"
                      >
                        <strong>{{ tool.label }}</strong>
                        <span>{{ tool.desc }}</span>
                      </button>
                    </div>
                  </div>
                  <div class="tool-section">
                    <div class="tool-section-label">绘制元素</div>
                    <div class="tool-grid">
                      <template v-for="tool in drawTools">
                        <el-popover
                          class="tool-el-popover"
                          v-if="tool.key === 'annulus'"
                          :key="tool.key"
                          placement="right"
                          title="请选择绘制的形状"
                          width="520"
                          trigger="click"
                        >
                          <el-radio-group v-model="annulusShape" size="mini">
                            <el-radio-button label="rect">圆角矩形</el-radio-button>
                            <el-radio-button label="L1">圆角L型</el-radio-button>
                            <el-radio-button label="L2">圆角L型旋转90°</el-radio-button>
                            <el-radio-button label="L3">圆角L型旋转180°</el-radio-button>
                            <el-radio-button label="L4">圆角L型旋转270°</el-radio-button>
                          </el-radio-group>
                          <button
                            slot="reference"
                            type="button"
                            class="tool-card-btn"
                            :class="{ active: activeTool === tool.key }"
                            @click="setTool(tool.key)"
                          >
                            <strong>{{ tool.label }}</strong>
                            <span>{{ tool.desc }}</span>
                          </button>
                        </el-popover>
                        <button
                          v-else
                          :key="tool.key"
                          type="button"
                          class="tool-card-btn"
                          :class="{ active: activeTool === tool.key }"
                          @click="setTool(tool.key)"
                        >
                          <strong>{{ tool.label }}</strong>
                          <span>{{ tool.desc }}</span>
                        </button>
                      </template>
                    </div>
                  </div>
                  <div class="tool-section">
                    <div class="tool-section-label">编辑动作</div>
                    <div class="action-list">
                      <el-button
                        size="small"
                        plain
                        @click="copySelection"
                        :disabled="selectedIds.length === 0"
                        >复制</el-button
                      >
                      <el-button
                        size="small"
                        plain
                        @click="pasteClipboard"
                        :disabled="clipboard.length === 0"
                        >粘贴</el-button
                      >
                      <el-button
                        size="small"
                        plain
                        @click="duplicateSelection"
                        :disabled="selectedIds.length === 0"
                        >复制偏移</el-button
                      >
                      <el-button
                        size="small"
                        plain
                        @click="fitSelection"
                        :disabled="selectedIds.length === 0"
                        >聚焦选中</el-button
                      >
                      <el-button
                        size="small"
                        type="danger"
                        plain
                        @click="deleteSelection"
                        :disabled="selectedIds.length === 0"
                        >删除选中</el-button
                      >
                    </div>
                  </div>
                  <div class="status-stack">
                    <div class="status-card">
                      <strong>快捷键</strong>
                      <span
                        >`Delete` 删除,`Ctrl/Cmd + Z` 撤销,`Ctrl/Cmd + Shift + Z` / `Ctrl/Cmd + Y`
                        重做。</span
                      >
                      <span
                        >`Ctrl/Cmd + C / V` 复制粘贴,按住空格可临时拖动画布,`Shift + 点击`
                        可增减单个选中。</span
                      >
                      <span
                        >`阵列` 工具: 先选中一个货架 /
                        维修站台模板,再拖一条水平或竖直线自动补齐一排;货架会按 `排-列`
                        规则继续编号。</span
                      >
                    </div>
                    <div class="status-card">
                      <strong>当前状态</strong>
                      <span>楼层: {{ currentLev ? currentLev + 'F' : '--' }}</span>
                      <span>指针: {{ pointerStatus }}</span>
                      <span v-if="arrayPreviewCount > 0"
                        >阵列预览: 将生成 {{ arrayPreviewCount }} 个</span
                      >
                      <span>未保存: {{ isDirty ? '是' : '否' }}</span>
                    </div>
                  </div>
                </div>
              </aside>
              <aside
                class="overlay-panel overlay-right"
                :class="{ collapsed: inspectorPanelCollapsed }"
              >
                <div class="panel-head">
                  <h2>属性面板</h2>
                  <div class="panel-head-actions">
                    <span class="canvas-meta" v-if="singleSelectedElement"
                      >{{ singleSelectedElement.type }}</span
                    >
                    <span class="canvas-meta" v-else
                      >{{ selectedIds.length > 1 ? '多选' : '画布' }}</span
                    >
                    <button type="button" class="panel-toggle" @click="toggleInspectorPanel">
                      {{ inspectorPanelCollapsed ? '<' : '>' }}
                    </button>
                  </div>
                </div>
                <div class="panel-body">
                  <div class="selection-summary">
                    <strong v-if="singleSelectedElement">单元素编辑</strong>
                    <strong v-else-if="selectedIds.length > 1">多选编辑</strong>
                    <strong v-else>未选中元素</strong>
                    <span v-if="singleSelectedElement"
                      >位置 {{ formatNumber(singleSelectedElement.x) }}, {{
                      formatNumber(singleSelectedElement.y) }} | 尺寸 {{
                      formatNumber(singleSelectedElement.width) }} x {{
                      formatNumber(singleSelectedElement.height) }}</span
                    >
                    <span v-else-if="selectedIds.length > 1"
                      >当前已选 {{ selectedIds.length }} 个元素,可整体移动、复制或删除。</span
                    >
                    <span v-else-if="activeTool === 'array'"
                      >先选中一个货架 / 维修站台元素,再拖一条线生成阵列。</span
                    >
                    <span v-else>选择工具后点击元素,或切换框选工具框出一组元素。</span>
                  </div>
                  <div class="tool-section">
                    <div class="tool-section-label">画布设置</div>
                    <div class="prop-grid">
                      <el-input
                        v-model.trim="canvasForm.width"
                        size="small"
                        placeholder="画布宽度"
                      ></el-input>
                      <el-input
                        v-model.trim="canvasForm.height"
                        size="small"
                        placeholder="画布高度"
                      ></el-input>
                      <el-button class="span-2" size="small" plain @click="applyCanvasSize"
                        >应用画布尺寸</el-button
                      >
                    </div>
                  </div>
                  <template v-if="singleSelectedElement">
                    <div class="tool-section">
                      <div class="tool-section-label">几何属性</div>
                      <div class="prop-grid">
                        <el-input
                          size="small"
                          :value="singleSelectedElement.type"
                          disabled
                        ></el-input>
                        <el-input
                          size="small"
                          :value="singleSelectedElement.id"
                          disabled
                        ></el-input>
                        <el-input
                          v-model.trim="geometryForm.x"
                          size="small"
                          placeholder="X"
                        ></el-input>
                        <el-input
                          v-model.trim="geometryForm.y"
                          size="small"
                          placeholder="Y"
                        ></el-input>
                        <el-input
                          v-model.trim="geometryForm.width"
                          size="small"
                          placeholder="宽度"
                        ></el-input>
                        <el-input
                          v-model.trim="geometryForm.height"
                          size="small"
                          placeholder="高度"
                        ></el-input>
                        <el-button class="span-2" size="small" plain @click="applyGeometry"
                          >应用几何</el-button
                        >
                      </div>
                    </div>
                    <div v-if="singleSelectedElement.type === 'devp'" class="tool-section">
                      <div class="tool-section-label">输送站点配置</div>
                      <div class="prop-grid">
                        <div class="field-stack">
                          <span class="field-label">站号</span>
                          <el-input
                            v-model.trim="devpForm.stationId"
                            size="small"
                            placeholder="请输入输送站点站号"
                          ></el-input>
                        </div>
                        <div class="field-stack">
                          <span class="field-label">PLC 编号</span>
                          <el-input
                            v-model.trim="devpForm.deviceNo"
                            size="small"
                            placeholder="请输入输送站点 PLC 编号"
                          ></el-input>
                        </div>
                        <div class="field-stack span-2">
                          <span class="field-label">方向</span>
                          <div class="direction-grid">
                            <button
                              v-for="item in devpDirectionOptions"
                              :key="item.key"
                              type="button"
                              class="direction-chip"
                              :class="{ active: isDevpDirectionActive(item.key) }"
                              @click="toggleDevpDirection(item.key)"
                            >
                              <span class="direction-arrow">{{ item.arrow }}</span>
                              <span>{{ item.label }}</span>
                            </button>
                          </div>
                          <div class="field-help">点击箭头切换方向,可同时选择多个方向。</div>
                        </div>
                        <div class="field-stack span-2">
                          <span class="field-label">站点类型</span>
                          <div class="check-grid">
                            <el-checkbox v-model="devpForm.isBarcodeStation">条码站</el-checkbox>
                            <el-checkbox v-model="devpForm.isInStation">入站点</el-checkbox>
                            <el-checkbox v-model="devpForm.isOutStation">出站点</el-checkbox>
                            <el-checkbox v-model="devpForm.runBlockReassign"
                              >堵塞重分配</el-checkbox
                            >
                            <el-checkbox v-model="devpForm.isOutOrder">出库排序</el-checkbox>
                            <el-checkbox v-model="devpForm.isLiftTransfer">顶升移栽</el-checkbox>
                          </div>
                        </div>
                        <div class="field-stack">
                          <span class="field-label"
                            >条码索引<span v-if="devpRequiresBarcodeIndex" class="field-required">
                              必填</span
                            ></span
                          >
                          <el-input
                            v-model.trim="devpForm.barcodeIdx"
                            size="small"
                            placeholder="条码站时必填,例如 1"
                          ></el-input>
                        </div>
                        <div class="field-stack">
                          <span class="field-label"
                            >条码站站号<span v-if="devpRequiresBarcodeLink" class="field-required">
                              必填</span
                            ></span
                          >
                          <el-input
                            v-model.trim="devpForm.barcodeStation"
                            size="small"
                            placeholder="入站点时必填,填写条码站站号"
                          ></el-input>
                        </div>
                        <div class="field-stack">
                          <span class="field-label"
                            >条码站 PLC 编号<span
                              v-if="devpRequiresBarcodeLink"
                              class="field-required"
                            >
                              必填</span
                            ></span
                          >
                          <el-input
                            v-model.trim="devpForm.barcodeStationDeviceNo"
                            size="small"
                            placeholder="入站点时必填,填写条码站 PLC 编号"
                          ></el-input>
                        </div>
                        <div class="field-stack">
                          <span class="field-label"
                            >退回站站号<span v-if="devpRequiresBackStation" class="field-required">
                              必填</span
                            ></span
                          >
                          <el-input
                            v-model.trim="devpForm.backStation"
                            size="small"
                            placeholder="条码站时必填,填写退回站站号"
                          ></el-input>
                        </div>
                        <div class="field-stack">
                          <span class="field-label"
                            >退回站 PLC 编号<span
                              v-if="devpRequiresBackStation"
                              class="field-required"
                            >
                              必填</span
                            ></span
                          >
                          <el-input
                            v-model.trim="devpForm.backStationDeviceNo"
                            size="small"
                            placeholder="条码站时必填,填写退回站 PLC 编号"
                          ></el-input>
                        </div>
                        <div class="footer-note span-2">
                          勾选“入站点”后,必须填写条码站站号和条码站 PLC 编号。
                          勾选“条码站”后,必须填写条码索引、退回站站号和退回站 PLC 编号。
                        </div>
                        <el-button
                          class="span-2"
                          size="small"
                          type="primary"
                          plain
                          @click="applyDevpForm"
                          >应用输送线配置</el-button
                        >
                      </div>
                    </div>
                    <div v-if="singleSelectedDeviceElement" class="tool-section">
                      <div class="tool-section-label">
                        {{ getDeviceConfigLabel(singleSelectedDeviceElement.type) }}
                      </div>
                      <div class="prop-grid">
                        <el-input size="small" value="轨道ID" disabled></el-input>
                        <el-input
                          v-model.trim="deviceForm.trackId"
                          size="small"
                          placeholder="轨道ID(默认递增,从 1 开始)"
                        ></el-input>
                        <el-input size="small" value="条码起始值" disabled></el-input>
                        <el-input
                          v-model.trim="deviceForm.barCodeStart"
                          size="small"
                          placeholder="轨道条码起始值,默认 0"
                        ></el-input>
                        <el-input size="small" value="条码结束值" disabled></el-input>
                        <el-input
                          v-model.trim="deviceForm.barCodeEnd"
                          size="small"
                          placeholder="轨道条码结束值,默认 100000"
                        ></el-input>
                        <div
                          class="tool-section-label-sub span-2"
                          v-if="deviceForm.deviceList.length > 0"
                        >
                          设备列表
                        </div>
                        <template v-for="(item, index) in deviceForm.deviceList">
                          <el-input
                            size="small"
                            :value="getDeviceConfigKeyLabel(singleSelectedDeviceElement.type, item.valueKey)"
                            disabled
                          ></el-input>
                          <el-input
                            v-model.trim="item.deviceNo"
                            size="small"
                            placeholder="设备编号"
                          ></el-input>
                          <el-input size="small" value="起始位置" disabled></el-input>
                          <div
                            style="display: flex; align-items: center; gap: 10px; margin-left: 12px"
                          >
                            <el-slider
                              v-model="item.progress"
                              :max="100"
                              :min="0"
                              show-tooltip
                              style="flex: 1"
                            ></el-slider>
                            <span
                              style="
                                font-size: 12px;
                                color: var(--text-sub);
                                width: 30px;
                                text-align: right;
                              "
                              >{{ item.progress }}%</span
                            >
                          </div>
                          <el-input size="small" value="设备长(沿轨道)" disabled></el-input>
                          <el-input
                            v-model.trim="item.deviceLength"
                            size="small"
                            :placeholder="getDeviceLengthPlaceholder()"
                          ></el-input>
                          <el-input size="small" value="设备宽(垂直轨道)" disabled></el-input>
                          <el-input
                            v-model.trim="item.deviceWidth"
                            size="small"
                            :placeholder="getDeviceWidthPlaceholder()"
                          ></el-input>
                          <!-- <div class="field-help span-2">默认(自动): 长 {{ getAutoTrackDeviceLengthValue() }} | 宽 {{ getAutoTrackDeviceWidthValue() }}</div> -->
                          <el-divider
                            class="device-divider span-2"
                            v-if="index < deviceForm.deviceList.length - 1"
                          ></el-divider>
                        </template>
                        <el-button size="small" type="primary" plain @click="addDeviceForm"
                          >添加设备</el-button
                        >
                        <el-button size="small" type="primary" plain @click="applyDeviceForm"
                          >应用设备参数</el-button
                        >
                      </div>
                      <div class="footer-note" style="padding-top: 8px">
                        这里只改设备编号相关键,原始 JSON
                        里的其他字段会保留;下面仍可直接查看或手工修改 JSON。
                      </div>
                    </div>
                    <div class="tool-section">
                      <div class="tool-section-label">
                        {{ singleSelectedElement.type === 'devp' ? '原始 JSON 预览' : (singleSelectedDeviceElement ? '原始 JSON 预览 / 手工编辑' : '值 / JSON 编辑') }}
                      </div>
                      <el-input
                        class="json-box"
                        type="textarea"
                        :rows="8"
                        v-model="valueEditorText"
                        :readonly="singleSelectedElement.type === 'devp'"
                      >
                      </el-input>
                      <el-button
                        v-if="singleSelectedElement.type !== 'devp'"
                        size="small"
                        type="primary"
                        plain
                        @click="applyRawValue"
                        >应用值</el-button
                      >
                    </div>
                  </template>
                  <div v-if="selectedShelfElements.length > 0" class="tool-section">
                    <div class="tool-section-label">货架自动填充</div>
                    <div class="prop-grid">
                      <el-input
                        v-model.trim="shelfFillForm.startValue"
                        size="small"
                        placeholder="起始值,例如 12-1"
                      ></el-input>
                      <el-input
                        size="small"
                        :value="'已选货架 ' + selectedShelfElements.length + ' 个'"
                        disabled
                      ></el-input>
                      <el-select v-model="shelfFillForm.rowStep" size="small" placeholder="排方向">
                        <el-option label="上到下递减" value="desc"></el-option>
                        <el-option label="上到下递增" value="asc"></el-option>
                      </el-select>
                      <el-select v-model="shelfFillForm.colStep" size="small" placeholder="列方向">
                        <el-option label="左到右递增" value="asc"></el-option>
                        <el-option label="左到右递减" value="desc"></el-option>
                      </el-select>
                      <el-button
                        class="span-2"
                        size="small"
                        type="primary"
                        plain
                        @click="applyShelfAutoFill"
                        >按排列填充货架值</el-button
                      >
                    </div>
                    <div class="footer-note" style="padding-top: 8px">
                      会按选中货架的实际空间排列分组填充。默认规则是上到下排号递减、左到右列号递增。
                    </div>
                  </div>
                </div>
              </aside>
            </div>
          </div>
        </main>
    </section>
      </section>
    <el-dialog title="新建自由画布" :visible.sync="blankDialogVisible" width="420px" class="dialog-panel" append-to-body>
      <el-dialog
        title="新建自由画布"
        :visible.sync="blankDialogVisible"
        width="420px"
        class="dialog-panel"
        append-to-body
      >
        <el-form label-width="90px" size="small">
            <el-form-item label="楼层">
                <el-input v-model.trim="blankForm.lev"></el-input>
            </el-form-item>
            <el-form-item label="宽度">
                <el-input v-model.trim="blankForm.width"></el-input>
            </el-form-item>
            <el-form-item label="高度">
                <el-input v-model.trim="blankForm.height"></el-input>
            </el-form-item>
          <el-form-item label="楼层">
            <el-input v-model.trim="blankForm.lev"></el-input>
          </el-form-item>
          <el-form-item label="宽度">
            <el-input v-model.trim="blankForm.width"></el-input>
          </el-form-item>
          <el-form-item label="高度">
            <el-input v-model.trim="blankForm.height"></el-input>
          </el-form-item>
        </el-form>
        <div slot="footer">
            <el-button @click="blankDialogVisible = false">取消</el-button>
            <el-button type="primary" @click="createBlankMap">创建</el-button>
          <el-button @click="blankDialogVisible = false">取消</el-button>
          <el-button type="primary" @click="createBlankMap">创建</el-button>
        </div>
    </el-dialog>
      </el-dialog>
    <input ref="importInput" type="file" style="display:none;" @change="handleImportExcel">
    <input ref="mapImportInput" type="file" accept=".json,application/json" style="display:none;" @change="handleImportMap">
</div>
      <input ref="importInput" type="file" style="display: none" @change="handleImportExcel" />
      <input
        ref="mapImportInput"
        type="file"
        accept=".json,application/json"
        style="display: none"
        @change="handleImportMap"
      />
    </div>
<script type="text/javascript" src="../../static/js/jquery/jquery-3.3.1.min.js"></script>
<script type="text/javascript" src="../../static/js/common.js" charset="utf-8"></script>
<script type="text/javascript" src="../../static/vue/js/vue.min.js"></script>
<script type="text/javascript" src="../../static/vue/element/element.js"></script>
<script type="text/javascript" src="../../static/js/pixi-legacy.min.js"></script>
<script type="text/javascript" src="../../static/js/basMap/editor.js?v=20260321e"></script>
</body>
    <script type="text/javascript" src="../../static/js/jquery/jquery-3.3.1.min.js"></script>
    <script type="text/javascript" src="../../static/js/common.js" charset="utf-8"></script>
    <script type="text/javascript" src="../../static/vue/js/vue.min.js"></script>
    <script type="text/javascript" src="../../static/vue/element/element.js"></script>
    <script type="text/javascript" src="../../static/js/pixi-legacy.min.js"></script>
    <script
      type="text/javascript"
      src="../../static/js/basMap/mapTrackGeometry.js?v=20260406b"
    ></script>
    <script type="text/javascript" src="../../static/js/basMap/editor.js?v=20260321e"></script>
  </body>
</html>
src/main/webapp/views/watch/console.html
@@ -553,6 +553,7 @@
        <script src="../../components/WatchDualCrnCard.js"></script>
        <script src="../../components/DevpCard.js"></script>
        <script src="../../components/WatchRgvCard.js"></script>
    <script src="../../static/js/basMap/mapTrackGeometry.js?v=20260406a"></script>
        <script src="../../components/MapCanvas.js?v=20260320_crn_size_fix1"></script>
        <script>
            let ws;
src/main/webapp/views/watch/fakeTrace.html
@@ -517,6 +517,7 @@
    </div>
</div>
<script src="../../static/js/basMap/mapTrackGeometry.js?v=20260406a"></script>
<script src="../../components/MapCanvas.js?v=20260320_crn_size_fix1"></script>
<script>
    var fakeTraceWs = null;
src/main/webapp/views/watch/stationTrace.html
@@ -567,6 +567,7 @@
    </div>
</div>
<script src="../../static/js/basMap/mapTrackGeometry.js?v=20260406a"></script>
<script src="../../components/MapCanvas.js?v=20260319_station_trace_v1"></script>
<script>
    var stationTraceWs = null;