Преглед изворни кода

路口详情:扩展支持任意 N 路口数字孪生(3 路/5 路);mock 加 JNC900003 三岔/JNC900005 五岔示例
- IntersectionMap{Videos}.vue:解耦 N/E/S/W 硬编码为 forEach 遍历;新增 _polygonApothem(均匀正多边形 apothem =
halfRoad/tan(180°/N))+ _buildPolygonPoints(2 端点法,4 路口端点重合 collapse 成方形、3
路口等价等边三角形);_buildArmsAndPolygon 检测 armsConfig key/rotation 变化自动重建;per-arm 信号面板按 arm
数自适应;getArmSignal 多路口 fallback 按 rotation 分类到 NS/EW;createArrowIcon 去掉 SVG 加载消除与 dyeArm 并发叠图
- mock/api.js:apiGetCrossingDetailData 修 detector 注入污染(多路口不再强加 NESW key)+ 新增 armVideos 按 arm
顺序循环分配视频;apiGetDetectorMonitorData 多路口走 per-arm 数据
- mock_data.json:JNC900003/900005 配 2 箭头 per arm(对齐 simple 模式);菜单/列表/configs 4 处插入示例
- CameraVideoDialog.vue:dirKey + armVideos 支持,每条 arm 视频独立对应

画安 пре 4 месеци
родитељ
комит
9ca454547c

+ 22 - 5
src/components/ui/CameraVideoDialog.vue

@@ -44,6 +44,7 @@ export default {
     return {
       currentDir: this.initialDir,
       cornerVideos: null,
+      armVideos: null,    // 多路口 per-arm 视频映射(来自 currentRoute.armVideos)
       cameraList: this.cameras.slice(),
       loading: false,
     };
@@ -58,8 +59,10 @@ export default {
         return t === '枪机' || t === '球机' || t === 1 || t === 2;
       });
       const opts = list.map(c => {
-        const dir = POSITION_TO_DIR[c.position];
-        return dir ? { label: c.position || DIR_LABEL[dir], value: dir } : null;
+        // 优先用 dirKey(多路口 arm_1 等任意方向),否则按 position 中文名映射回 N/E/S/W
+        const dir = c.dirKey || POSITION_TO_DIR[c.position];
+        if (!dir) return null;
+        return { label: c.position || DIR_LABEL[dir] || dir, value: dir };
       }).filter(Boolean);
       // 如果没拿到 cameras(独立场景),退化成 4 个固定方向
       if (!opts.length) {
@@ -68,8 +71,20 @@ export default {
       return opts;
     },
     videoSrc() {
+      // 优先 armVideos[currentDir]:多路口下每条 arm 一个独立视频,与 arm 对应
+      if (this.armVideos && this.armVideos[this.currentDir]) {
+        const v = this.armVideos[this.currentDir];
+        return typeof v === 'string' ? v : (v && v.url) || '';
+      }
       if (!this.cornerVideos) return '';
-      const corner = DIR_TO_CORNER[this.currentDir] || 'nw';
+      // 4 路口 N/E/S/W → 直接映射到 nw/ne/se/sw 四角
+      let corner = DIR_TO_CORNER[this.currentDir];
+      if (!corner) {
+        // 兜底:找不到对应方向时按字符串 hash 选一个 corner video
+        const corners = ['nw', 'ne', 'se', 'sw'];
+        const hash = String(this.currentDir).split('').reduce((s, c) => s + c.charCodeAt(0), 0);
+        corner = corners[hash % corners.length];
+      }
       const v = this.cornerVideos[corner];
       return typeof v === 'string' ? v : (v && v.url) || '';
     },
@@ -93,8 +108,9 @@ export default {
       this.loading = true;
       try {
         const data = await apiGetCrossingDetailData(id, { iconMode: 'simple' });
-        const cv = data && data.currentRoute && data.currentRoute.cornerVideos;
-        this.cornerVideos = cv || null;
+        const route = (data && data.currentRoute) || {};
+        this.cornerVideos = route.cornerVideos || null;
+        this.armVideos = route.armVideos || null;
         // 父组件没传 cameras 时从 detail 兜底取
         if (!this.cameras.length && data && data.intersectionData && Array.isArray(data.intersectionData.cameras)) {
           this.cameraList = data.intersectionData.cameras;
@@ -102,6 +118,7 @@ export default {
       } catch (e) {
         console.warn('[CameraVideoDialog] load detail failed:', e);
         this.cornerVideos = null;
+        this.armVideos = null;
       } finally {
         this.loading = false;
       }

+ 233 - 58
src/components/ui/IntersectionMap.vue

@@ -102,8 +102,130 @@ export default {
     }
   },
   methods: {
+    // ============== 多路口扩展辅助 ==============
+    _armDirs() {
+      const cfg = this.mapData && this.mapData.armsConfig;
+      if (cfg && Object.keys(cfg).length > 0) return Object.keys(cfg);
+      return ['N', 'E', 'S', 'W'];
+    },
+    _armRotation(dir, armConfig) {
+      if (armConfig && typeof armConfig.rotation === 'number') return armConfig.rotation;
+      const defaults = { N: 0, E: 90, S: 180, W: 270 };
+      return defaults[dir] !== undefined ? defaults[dir] : 0;
+    },
+    getArmSignal(dir, signals) {
+      if (!signals) return null;
+      if (signals.arms && signals.arms[dir]) return signals.arms[dir];
+      if (dir === 'N' || dir === 'S') return signals.ns || null;
+      if (dir === 'E' || dir === 'W') return signals.ew || null;
+      const cfg = (this.mapData && this.mapData.armsConfig) || {};
+      const armCfg = cfg[dir];
+      if (armCfg && typeof armCfg.rotation === 'number') {
+        const rot = ((armCfg.rotation % 180) + 180) % 180;
+        if (rot < 45 || rot >= 135) return signals.ns || signals.ew || null;
+        return signals.ew || signals.ns || null;
+      }
+      return signals.ns || signals.ew || null;
+    },
+
+    /** 计算多边形 apothem。详见 IntersectionMapVideos._polygonApothem。 */
+    _polygonApothem() {
+      const { halfRoad } = this.sizeConfig;
+      const cfg = (this.mapData && this.mapData.armsConfig) || {};
+      const dirs = this._armDirs();
+      const N = dirs.length;
+      if (N < 3) return halfRoad;
+      const angles = dirs
+        .map(dir => ((this._armRotation(dir, cfg[dir]) % 360) + 360) % 360)
+        .sort((a, b) => a - b);
+      const expected = 360 / N;
+      let isUniform = true;
+      for (let i = 0; i < N; i++) {
+        const a = angles[i];
+        const b = i + 1 < N ? angles[i + 1] : angles[0] + 360;
+        if (Math.abs((b - a) - expected) > 1) { isUniform = false; break; }
+      }
+      if (!isUniform) return halfRoad;
+      const t = Math.tan((180 / N) * Math.PI / 180);
+      if (t <= 0.0001) return halfRoad;
+      return halfRoad / t;
+    },
+
+    /** 计算中心多边形顶点。详见 IntersectionMapVideos._buildPolygonPoints。 */
+    _buildPolygonPoints(cx, cy) {
+      const { halfRoad } = this.sizeConfig;
+      const cfg = (this.mapData && this.mapData.armsConfig) || {};
+      const dirs = this._armDirs();
+      if (dirs.length < 2) {
+        return [
+          cx - halfRoad, cy - halfRoad,
+          cx + halfRoad, cy - halfRoad,
+          cx + halfRoad, cy + halfRoad,
+          cx - halfRoad, cy + halfRoad,
+        ];
+      }
+      const angles = dirs
+        .map(dir => ((this._armRotation(dir, cfg[dir]) % 360) + 360) % 360)
+        .sort((a, b) => a - b);
+
+      const apothem = this._polygonApothem();
+      const halfWidth = halfRoad;
+      const armCornerWorld = (theta, side) => {
+        const rad = theta * Math.PI / 180;
+        const sx = side === 'right' ? halfWidth : -halfWidth;
+        return {
+          x: cx + apothem * Math.sin(rad) + sx * Math.cos(rad),
+          y: cy - apothem * Math.cos(rad) + sx * Math.sin(rad),
+        };
+      };
+
+      const pts = [];
+      for (let i = 0; i < angles.length; i++) {
+        const a = angles[i];
+        const b = i + 1 < angles.length ? angles[i + 1] : angles[0] + 360;
+        const right = armCornerWorld(a, 'right');
+        const left = armCornerWorld(b, 'left');
+        pts.push(right.x, right.y, left.x, left.y);
+      }
+      return pts;
+    },
+
+    /** 重建中心多边形 + arms,在 armsConfig key 集合或 rotation 变化时调用 */
+    _buildArmsAndPolygon() {
+      if (this._centerPolygon) {
+        this._centerPolygon.destroy();
+        this._centerPolygon = null;
+      }
+      Object.values(this.armsNodes).forEach(arm => arm && arm.destroy());
+      this.armsNodes = {};
+
+      const { stageSize } = this.sizeConfig;
+      const center = stageSize / 2;
+      const apothem = this._polygonApothem();
+
+      this._centerPolygon = new Konva.Line({
+        points: this._buildPolygonPoints(center, center),
+        closed: true,
+        fill: this.C.ROAD,
+      });
+      this.layer.add(this._centerPolygon);
+
+      const cfg = (this.mapData && this.mapData.armsConfig) || {};
+      this._armDirs().forEach(dir => {
+        const armCfg = cfg[dir];
+        const rotation = this._armRotation(dir, armCfg);
+        const rad = rotation * Math.PI / 180;
+        const x = center + apothem * Math.sin(rad);
+        const y = center - apothem * Math.cos(rad);
+        this.armsNodes[dir] = this.createRoadArm(x, y, rotation);
+        this.layer.add(this.armsNodes[dir]);
+      });
+
+      if (this._centerPanelGroup) this._centerPanelGroup.moveToTop();
+    },
+
     initKonvaStage() {
-      const { stageSize, halfRoad, roadWidth } = this.sizeConfig;
+      const { stageSize } = this.sizeConfig;
       const center = stageSize / 2;
 
       this.stage = new Konva.Stage({
@@ -115,15 +237,7 @@ export default {
       this.stage.add(this.layer);
 
       this.layer.add(new Konva.Rect({ width: stageSize, height: stageSize, fill: this.C.BG }));
-      this.layer.add(new Konva.Rect({ x: center - halfRoad, y: center - halfRoad, width: roadWidth, height: roadWidth, fill: this.C.ROAD }));
-
-      this.armsNodes = {
-        N: this.createRoadArm(center, center - halfRoad, 0),
-        E: this.createRoadArm(center + halfRoad, center, 90),
-        S: this.createRoadArm(center, center + halfRoad, 180),
-        W: this.createRoadArm(center - halfRoad, center, 270)
-      };
-      Object.values(this.armsNodes).forEach(arm => this.layer.add(arm));
+      this._buildArmsAndPolygon();
 
       this.createCenterPanel(center);
       this.layer.draw();
@@ -195,48 +309,97 @@ export default {
       return group;
     },
 
+    /** 把面板里旧文字节点全部销毁,准备重渲染 */
+    _clearPanelText() {
+      (this._panelTextNodes || []).forEach(n => n.destroy());
+      this._panelTextNodes = [];
+    },
+
+    /** 添加一行:左侧 label + 右侧倒计时值 */
+    _addPanelRow(labelX, labelY, valX, valY, labelText, valText, valColor, labelFs, valFs) {
+      const label = new Konva.Text({
+        x: labelX, y: labelY, text: labelText,
+        fontSize: labelFs, fontFamily: 'monospace', fontStyle: 'bold', fill: this.C.WHITE,
+      });
+      const val = new Konva.Text({
+        x: valX, y: valY, text: valText,
+        fontSize: valFs, fontFamily: 'monospace', fontStyle: 'bold', fill: valColor,
+      });
+      this._centerPanelGroup.add(label);
+      this._centerPanelGroup.add(val);
+      this._panelTextNodes.push(label, val);
+    },
+
+    /** 渲染中心面板内容:4 路口走 NS/EW 两行;多路口走 per-arm 多行 */
+    _renderCenterPanelContent(signals) {
+      this._clearPanelText();
+      if (!signals || !this._centerPanelGroup) return;
+
+      const dirs = Object.keys(this.armsNodes);
+      const isLegacy4Way = dirs.length === 4 && ['N', 'E', 'S', 'W'].every(d => dirs.includes(d));
+      const center = this.sizeConfig.stageSize / 2;
+
+      if (isLegacy4Way) {
+        const nsSignal = this.getArmSignal('N', signals);
+        const ewSignal = this.getArmSignal('E', signals);
+        this._panelBgRect.width(160);
+        this._panelBgRect.height(90);
+        this._centerPanelGroup.x(center - 80);
+        this._centerPanelGroup.y(center - 45);
+        if (nsSignal) {
+          const c = nsSignal.isGreen ? this.C.SIGNAL_GREEN : this.C.SIGNAL_RED;
+          this._addPanelRow(15, 22, 90, 15, `${nsSignal.phaseName}:`, String(nsSignal.time || 0).padStart(2, '0'), c, 18, 28);
+        }
+        if (ewSignal) {
+          const c = ewSignal.isGreen ? this.C.SIGNAL_GREEN : this.C.SIGNAL_RED;
+          this._addPanelRow(15, 55, 90, 48, `${ewSignal.phaseName}:`, String(ewSignal.time || 0).padStart(2, '0'), c, 18, 28);
+        }
+      } else {
+        const N = dirs.length;
+        const ROW_H = Math.max(20, Math.min(28, Math.floor(120 / N)));
+        const labelFs = Math.max(11, Math.min(16, ROW_H - 6));
+        const valFs = Math.max(13, Math.min(22, ROW_H - 2));
+        const padTop = 8;
+        const newH = ROW_H * N + padTop * 2;
+        const newW = 160;
+        this._panelBgRect.width(newW);
+        this._panelBgRect.height(newH);
+        this._centerPanelGroup.x(center - newW / 2);
+        this._centerPanelGroup.y(center - newH / 2);
+
+        dirs.forEach((dir, i) => {
+          const sig = this.getArmSignal(dir, signals);
+          if (!sig) return;
+          const color = sig.isGreen ? this.C.SIGNAL_GREEN : this.C.SIGNAL_RED;
+          const yBase = padTop + i * ROW_H;
+          const labelY = yBase + (ROW_H - labelFs) / 2;
+          const valY = yBase + (ROW_H - valFs) / 2;
+          this._addPanelRow(
+            10, labelY, 92, valY,
+            `${sig.phaseName || dir}:`,
+            String(sig.time || 0).padStart(2, '0'),
+            color, labelFs, valFs
+          );
+        });
+      }
+    },
+
     createCenterPanel(center) {
+      // 仅创建底框;文字内容由 _renderCenterPanelContent() 在 updateDynamicSignals 时按 arm 数动态渲染
       const panelGroup = new Konva.Group({ x: center - 80, y: center - 45 });
-      panelGroup.add(new Konva.Rect({ width: 160, height: 90, fill: this.C.PANEL_BG, cornerRadius: 8 }));
-      
-      const labelFont = { fontSize: 18, fontFamily: 'monospace', fontStyle: 'bold', fill: this.C.WHITE }; 
-      const valueFont = { fontSize: 28, fontFamily: 'monospace', fontStyle: 'bold' };              
-      
-      this.panelNodes.nsLabel = new Konva.Text({ ...labelFont, x: 15, y: 22, text: '相位-:' });
-      this.panelNodes.nsVal = new Konva.Text({ ...valueFont, x: 90, y: 15, text: '--', fill: this.C.SIGNAL_GREEN });
-      
-      this.panelNodes.ewLabel = new Konva.Text({ ...labelFont, x: 15, y: 55, text: '相位-:' });
-      this.panelNodes.ewVal = new Konva.Text({ ...valueFont, x: 90, y: 48, text: '--', fill: this.C.SIGNAL_GREEN });
-      
-      panelGroup.add(this.panelNodes.nsLabel, this.panelNodes.nsVal, this.panelNodes.ewLabel, this.panelNodes.ewVal);
+      this._panelBgRect = new Konva.Rect({ width: 160, height: 90, fill: this.C.PANEL_BG, cornerRadius: 8 });
+      panelGroup.add(this._panelBgRect);
       this.layer.add(panelGroup);
+      this._centerPanelGroup = panelGroup;
+      this._panelTextNodes = [];
     },
 
-    createArrowIcon(type, x, y, color = this.C.WHITE) {
-      const maxH = 40;
+    createArrowIcon(type, x, y) {
+      // 仅创建空 group,箭头 SVG 图像由 updateDynamicSignals 的 dyeArm 统一加载,
+      // 避免与 dyeArm 并发 loadSvgImage 时同位置叠加 2 张图。
       const group = new Konva.Group({ x, y });
       if (type === 'R') group.scaleX(-1);
       group._arrowMeta = { type };
-
-      const svgUrl = arrowSvgMap[type];
-      if (svgUrl) {
-        loadSvgImage(svgUrl, color).then(imgObj => {
-          const natW = imgObj.naturalWidth || imgObj.width;
-          const natH = imgObj.naturalHeight || imgObj.height;
-          const scale = Math.min(maxH / natH, 1);
-          const w = Math.round(natW * scale);
-          const h = Math.round(natH * scale);
-          group.add(new Konva.Image({
-            image: imgObj,
-            x: -w / 2,
-            y: -h - 5,
-            width: w,
-            height: h,
-            name: 'arrowImg'
-          }));
-          if (this.layer) this.layer.draw();
-        });
-      }
       return group;
     },
 
@@ -262,9 +425,22 @@ export default {
       const config = this.mapData.armsConfig;
       if (!config) return;
 
+      // arm 集合或 rotation 变化 → 重建中心多边形和 arms
+      const newKeys = Object.keys(config).sort().join(',');
+      const currentKeys = Object.keys(this.armsNodes).sort().join(',');
+      const rotChanged = Object.keys(config).some(dir => {
+        const arm = this.armsNodes[dir];
+        if (!arm) return true;
+        return arm.rotation() !== this._armRotation(dir, config[dir]);
+      });
+      if (newKeys !== currentKeys || rotChanged) {
+        this._buildArmsAndPolygon();
+      }
+
       Object.keys(config).forEach(dir => {
         const armData = config[dir];
         const armNode = this.armsNodes[dir];
+        if (!armNode) return;
 
         if (armNode.cameraNode) armNode.cameraNode.destroy();
         if (armData.cameraType > 0) {
@@ -291,11 +467,6 @@ export default {
       if (!signals) return;
       const config = this.mapData.armsConfig || {};
 
-      const nsColor = signals.ns.isGreen ? this.C.SIGNAL_GREEN : this.C.SIGNAL_RED;
-      const ewColor = signals.ew.isGreen ? this.C.SIGNAL_GREEN : this.C.SIGNAL_RED;
-      const nsActiveTypes = signals.ns.activeArrowTypes || [];
-      const ewActiveTypes = signals.ew.activeArrowTypes || [];
-
       const dyeArm = (dir, armNode, signalColor, activeTypes) => {
         armNode.lightGroup.getChildren().forEach(r => r.fill(signalColor));
         const lanes = (config[dir] && config[dir].lanes) || [];
@@ -336,16 +507,20 @@ export default {
         });
       };
 
-      dyeArm('N', this.armsNodes.N, nsColor, nsActiveTypes);
-      dyeArm('S', this.armsNodes.S, nsColor, nsActiveTypes);
-      dyeArm('E', this.armsNodes.E, ewColor, ewActiveTypes);
-      dyeArm('W', this.armsNodes.W, ewColor, ewActiveTypes);
-
-      this.panelNodes.nsLabel.text(`${signals.ns.phaseName}:`);
-      this.panelNodes.nsVal.text(signals.ns.time.toString().padStart(2, '0')).fill(nsColor);
+      // 按 arm 遍历染色:每条 arm 独立读 signals(多路口走 signals.arms[dir],
+      // 4 路口走兼容层 getArmSignal 把 N/S→ns、E/W→ew 映射回去)
+      Object.keys(this.armsNodes).forEach(dir => {
+        const armNode = this.armsNodes[dir];
+        if (!armNode) return;
+        const armSignal = this.getArmSignal(dir, signals);
+        if (!armSignal) return;
+        const signalColor = armSignal.isGreen ? this.C.SIGNAL_GREEN : this.C.SIGNAL_RED;
+        const activeTypes = armSignal.activeArrowTypes || [];
+        dyeArm(dir, armNode, signalColor, activeTypes);
+      });
 
-      this.panelNodes.ewLabel.text(`${signals.ew.phaseName}:`);
-      this.panelNodes.ewVal.text(signals.ew.time.toString().padStart(2, '0')).fill(ewColor);
+      // 中心面板:4 路口走 NS/EW 两行,多路口走 per-arm 多行(自适应高度)
+      this._renderCenterPanelContent(signals);
 
       this.layer.draw();
     }

+ 277 - 69
src/components/ui/IntersectionMapVideos.vue

@@ -157,9 +157,158 @@ export default {
     },
   },
   methods: {
-    // ================= 以下为原有的 Konva 绘制逻辑,完全保持不变 =================
+    // ============== 多路口扩展辅助 ==============
+    /** 获取本路口所有 arm 方向 key(4 路口默认 N/E/S/W;多路口走 armsConfig) */
+    _armDirs() {
+      const cfg = this.mapData && this.mapData.armsConfig;
+      if (cfg && Object.keys(cfg).length > 0) return Object.keys(cfg);
+      return ['N', 'E', 'S', 'W'];
+    },
+
+    /** 计算 arm 旋转角度。优先用 armConfig.rotation;否则按 4 路口默认 N=0/E=90/S=180/W=270。 */
+    _armRotation(dir, armConfig) {
+      if (armConfig && typeof armConfig.rotation === 'number') return armConfig.rotation;
+      const defaults = { N: 0, E: 90, S: 180, W: 270 };
+      return defaults[dir] !== undefined ? defaults[dir] : 0;
+    },
+
+    /** 取某 arm 方向的信号数据。
+     *  - 优先 signals.arms[dir](多路口完整数据)
+     *  - 4 路口兼容:N/S→ns、E/W→ew
+     *  - 多路口 fallback(signals.arms 缺失时,例如 CrossingDetailPanel.onScanTick 仅写 NS/EW):
+     *    按 arm rotation 分类到 NS 或 EW,让多路口也呈现 4 路口式的相位互补色彩
+     *    rotation 折算到 [0,180):[0,45) ∪ [135,180) → NS,[45,135) → EW */
+    getArmSignal(dir, signals) {
+      if (!signals) return null;
+      if (signals.arms && signals.arms[dir]) return signals.arms[dir];
+      if (dir === 'N' || dir === 'S') return signals.ns || null;
+      if (dir === 'E' || dir === 'W') return signals.ew || null;
+      const cfg = (this.mapData && this.mapData.armsConfig) || {};
+      const armCfg = cfg[dir];
+      if (armCfg && typeof armCfg.rotation === 'number') {
+        const rot = ((armCfg.rotation % 180) + 180) % 180;
+        if (rot < 45 || rot >= 135) return signals.ns || signals.ew || null;
+        return signals.ew || signals.ns || null;
+      }
+      return signals.ns || signals.ew || null;
+    },
+
+    /** 计算多边形 apothem(中心到边的垂直距离),决定 arm 锚点距离中心多远。
+     *  - 均匀正多边形(所有 arm 角度间隔相等):apothem = halfRoad / tan(180°/N)
+     *    → 多边形边长 = 2×halfRoad = arm 宽(320),相邻 arm 端点重合,得到规则 N 边形
+     *    例:3 路口 120° → apothem ≈ 92.4,正三角形;4 路口 → apothem = halfRoad,方形;5 路口 72° → apothem ≈ 220
+     *  - 非均匀(T 型 180° 缺口、Y 型不等角):用 halfRoad 兜底,得 2N 边形(arm 边 + 连接边) */
+    _polygonApothem() {
+      const { halfRoad } = this.sizeConfig;
+      const cfg = (this.mapData && this.mapData.armsConfig) || {};
+      const dirs = this._armDirs();
+      const N = dirs.length;
+      if (N < 3) return halfRoad;
+      const angles = dirs
+        .map(dir => ((this._armRotation(dir, cfg[dir]) % 360) + 360) % 360)
+        .sort((a, b) => a - b);
+      const expected = 360 / N;
+      let isUniform = true;
+      for (let i = 0; i < N; i++) {
+        const a = angles[i];
+        const b = i + 1 < N ? angles[i + 1] : angles[0] + 360;
+        if (Math.abs((b - a) - expected) > 1) { isUniform = false; break; }
+      }
+      if (!isUniform) return halfRoad;
+      const t = Math.tan((180 / N) * Math.PI / 180);
+      if (t <= 0.0001) return halfRoad;
+      return halfRoad / t;
+    },
+
+    /** 计算中心多边形顶点(设计坐标)。
+     *  策略:每条 arm 锚定边的两个端点作多边形顶点;apothem 由 _polygonApothem 决定。
+     *    - 均匀 N 路口(apothem = halfRoad/tan(180°/N)):相邻 arm 端点重合,规则 N 边形(边长 = arm 宽)
+     *    - 4 路口 90°:apothem = halfRoad,方形 4 角
+     *    - 非均匀(T/Y 型):apothem = halfRoad,2N 边形(arm 边 + 连接边) */
+    _buildPolygonPoints(cx, cy) {
+      const { halfRoad } = this.sizeConfig;
+      const cfg = (this.mapData && this.mapData.armsConfig) || {};
+      const dirs = this._armDirs();
+      if (dirs.length < 2) {
+        return [
+          cx - halfRoad, cy - halfRoad,
+          cx + halfRoad, cy - halfRoad,
+          cx + halfRoad, cy + halfRoad,
+          cx - halfRoad, cy + halfRoad,
+        ];
+      }
+      const angles = dirs
+        .map(dir => ((this._armRotation(dir, cfg[dir]) % 360) + 360) % 360)
+        .sort((a, b) => a - b);
+
+      const apothem = this._polygonApothem();
+      const halfWidth = halfRoad;  // arm 宽度的一半保持不变 = 160
+      // 给定 arm 旋转角度 θ,计算它的"右端"或"左端"锚点端点:
+      //   arm 锚点世界 = (cx + apothem×sin(θ), cy − apothem×cos(θ))
+      //   端点 = 锚点 ± halfWidth × 锚定边切线方向(arm 的右/左 perpendicular)
+      const armCornerWorld = (theta, side) => {
+        const rad = theta * Math.PI / 180;
+        const sx = side === 'right' ? halfWidth : -halfWidth;
+        return {
+          x: cx + apothem * Math.sin(rad) + sx * Math.cos(rad),
+          y: cy - apothem * Math.cos(rad) + sx * Math.sin(rad),
+        };
+      };
+
+      // 用每条 arm 的两个锚点端点作多边形顶点;当 apothem = halfRoad/tan(180°/N) 且角度均匀时,
+      // 相邻 arm 的端点会重合 → 等价 N 边形规则形(边长 = arm 宽 = 320)。
+      const pts = [];
+      for (let i = 0; i < angles.length; i++) {
+        const a = angles[i];
+        const b = i + 1 < angles.length ? angles[i + 1] : angles[0] + 360;
+        const right = armCornerWorld(a, 'right');
+        const left = armCornerWorld(b, 'left');
+        pts.push(right.x, right.y, left.x, left.y);
+      }
+      return pts;
+    },
+
+    /** 重建中心多边形 + 所有 arm。在 mapData.armsConfig 的 key 集合或 rotation 改变时调用。 */
+    _buildArmsAndPolygon() {
+      // 销毁旧的多边形和 arms
+      if (this._centerPolygon) {
+        this._centerPolygon.destroy();
+        this._centerPolygon = null;
+      }
+      Object.values(this.armsNodes).forEach(arm => arm && arm.destroy());
+      this.armsNodes = {};
+
+      const { stageSize } = this.sizeConfig;
+      const center = stageSize / 2;
+      const apothem = this._polygonApothem();
+
+      // 中心多边形
+      this._centerPolygon = new Konva.Line({
+        points: this._buildPolygonPoints(center, center),
+        closed: true,
+        fill: this.C.ROAD,
+      });
+      this.layer.add(this._centerPolygon);
+
+      // arms:按 _armDirs 数据驱动创建,锚点距离 = apothem(均匀 N 边形时缩放,4 路口下与 halfRoad 等价)
+      const cfg = (this.mapData && this.mapData.armsConfig) || {};
+      this._armDirs().forEach(dir => {
+        const armCfg = cfg[dir];
+        const rotation = this._armRotation(dir, armCfg);
+        const rad = rotation * Math.PI / 180;
+        const x = center + apothem * Math.sin(rad);
+        const y = center - apothem * Math.cos(rad);
+        this.armsNodes[dir] = this.createRoadArm(x, y, rotation);
+        this.layer.add(this.armsNodes[dir]);
+      });
+
+      // 保持中心面板在最上层(如果存在的话)
+      if (this._centerPanelGroup) this._centerPanelGroup.moveToTop();
+    },
+
+    // ================= 以下为原有的 Konva 绘制逻辑 =================
     initKonvaStage() {
-      const { stageSize, halfRoad, roadWidth } = this.sizeConfig;
+      const { stageSize } = this.sizeConfig;
       const center = stageSize / 2;
 
       this.stage = new Konva.Stage({
@@ -171,15 +320,9 @@ export default {
       this.stage.add(this.layer);
 
       this.layer.add(new Konva.Rect({ width: stageSize, height: stageSize, fill: this.C.BG }));
-      this.layer.add(new Konva.Rect({ x: center - halfRoad, y: center - halfRoad, width: roadWidth, height: roadWidth, fill: this.C.ROAD }));
 
-      this.armsNodes = {
-        N: this.createRoadArm(center, center - halfRoad, 0),
-        E: this.createRoadArm(center + halfRoad, center, 90),
-        S: this.createRoadArm(center, center + halfRoad, 180),
-        W: this.createRoadArm(center - halfRoad, center, 270)
-      };
-      Object.values(this.armsNodes).forEach(arm => this.layer.add(arm));
+      // 中心多边形 + arms(4 路口走默认 N/E/S/W;多路口走 armsConfig)
+      this._buildArmsAndPolygon();
 
       this.createCenterPanel(center);
       this.layer.draw();
@@ -262,49 +405,101 @@ export default {
     },
 
     createCenterPanel(center) {
+      // 仅创建底框;文字内容由 _renderCenterPanelContent() 在 updateDynamicSignals 时按 arm 数动态渲染
       const panelGroup = new Konva.Group({ x: center - 80, y: center - 45 });
-      panelGroup.add(new Konva.Rect({ width: 160, height: 90, fill: this.C.PANEL_BG, cornerRadius: 8 }));
-      
-      const labelFont = { fontSize: 18, fontFamily: 'monospace', fontStyle: 'bold', fill: this.C.WHITE }; 
-      const valueFont = { fontSize: 28, fontFamily: 'monospace', fontStyle: 'bold' };              
-      
-      this.panelNodes.nsLabel = new Konva.Text({ ...labelFont, x: 15, y: 22, text: '相位-:' });
-      this.panelNodes.nsVal = new Konva.Text({ ...valueFont, x: 90, y: 15, text: '--', fill: this.C.SIGNAL_GREEN });
-      
-      this.panelNodes.ewLabel = new Konva.Text({ ...labelFont, x: 15, y: 55, text: '相位-:' });
-      this.panelNodes.ewVal = new Konva.Text({ ...valueFont, x: 90, y: 48, text: '--', fill: this.C.SIGNAL_GREEN });
-      
-      panelGroup.add(this.panelNodes.nsLabel, this.panelNodes.nsVal, this.panelNodes.ewLabel, this.panelNodes.ewVal);
+      this._panelBgRect = new Konva.Rect({ width: 160, height: 90, fill: this.C.PANEL_BG, cornerRadius: 8 });
+      panelGroup.add(this._panelBgRect);
       this.layer.add(panelGroup);
+      this._centerPanelGroup = panelGroup;
+      this._panelTextNodes = [];
     },
 
-    createArrowIcon(type, x, y, color = this.C.WHITE) {
-      const maxH = 40; // 最大高度
-      const group = new Konva.Group({ x, y });
-      if (type === 'R') group.scaleX(-1);
-      group._arrowMeta = { type };
+    /** 把面板里旧文字节点全部销毁,准备重渲染 */
+    _clearPanelText() {
+      (this._panelTextNodes || []).forEach(n => n.destroy());
+      this._panelTextNodes = [];
+    },
 
-      const svgUrl = arrowSvgMap[type];
-      if (svgUrl) {
-        loadSvgImage(svgUrl, color).then(imgObj => {
-          // 按原始比例等比缩放,高度不超过 maxH
-          const natW = imgObj.naturalWidth || imgObj.width;
-          const natH = imgObj.naturalHeight || imgObj.height;
-          const scale = Math.min(maxH / natH, 1);
-          const w = Math.round(natW * scale);
-          const h = Math.round(natH * scale);
-          const konvaImg = new Konva.Image({
-            image: imgObj,
-            x: -w / 2,
-            y: -h - 5,
-            width: w,
-            height: h,
-            name: 'arrowImg'
-          });
-          group.add(konvaImg);
-          if (this.layer) this.layer.draw();
+    /** 往面板里添加一行:左侧 label + 右侧倒计时值 */
+    _addPanelRow(labelX, labelY, valX, valY, labelText, valText, valColor, labelFs, valFs) {
+      const label = new Konva.Text({
+        x: labelX, y: labelY, text: labelText,
+        fontSize: labelFs, fontFamily: 'monospace', fontStyle: 'bold', fill: this.C.WHITE,
+      });
+      const val = new Konva.Text({
+        x: valX, y: valY, text: valText,
+        fontSize: valFs, fontFamily: 'monospace', fontStyle: 'bold', fill: valColor,
+      });
+      this._centerPanelGroup.add(label);
+      this._centerPanelGroup.add(val);
+      this._panelTextNodes.push(label, val);
+    },
+
+    /** 渲染中心面板内容:4 路口(N/E/S/W)走 NS/EW 两行;多路口或非 NESW key 走 per-arm 多行 */
+    _renderCenterPanelContent(signals) {
+      this._clearPanelText();
+      if (!signals || !this._centerPanelGroup) return;
+
+      const dirs = Object.keys(this.armsNodes);
+      const isLegacy4Way = dirs.length === 4 && ['N', 'E', 'S', 'W'].every(d => dirs.includes(d));
+
+      const center = this.sizeConfig.stageSize / 2;
+
+      if (isLegacy4Way) {
+        // 旧模式:NS / EW 两行,与原视觉一致
+        const nsSignal = this.getArmSignal('N', signals);
+        const ewSignal = this.getArmSignal('E', signals);
+        this._panelBgRect.width(160);
+        this._panelBgRect.height(90);
+        this._centerPanelGroup.x(center - 80);
+        this._centerPanelGroup.y(center - 45);
+        if (nsSignal) {
+          const c = nsSignal.isGreen ? this.C.SIGNAL_GREEN : this.C.SIGNAL_RED;
+          this._addPanelRow(15, 22, 90, 15, `${nsSignal.phaseName}:`, String(nsSignal.time || 0).padStart(2, '0'), c, 18, 28);
+        }
+        if (ewSignal) {
+          const c = ewSignal.isGreen ? this.C.SIGNAL_GREEN : this.C.SIGNAL_RED;
+          this._addPanelRow(15, 55, 90, 48, `${ewSignal.phaseName}:`, String(ewSignal.time || 0).padStart(2, '0'), c, 18, 28);
+        }
+      } else {
+        // 多路口模式:每条 arm 一行
+        const N = dirs.length;
+        const ROW_H = Math.max(20, Math.min(28, Math.floor(120 / N)));
+        const labelFs = Math.max(11, Math.min(16, ROW_H - 6));
+        const valFs = Math.max(13, Math.min(22, ROW_H - 2));
+        const padTop = 8;
+        const newH = ROW_H * N + padTop * 2;
+        const newW = 160;
+        this._panelBgRect.width(newW);
+        this._panelBgRect.height(newH);
+        this._centerPanelGroup.x(center - newW / 2);
+        this._centerPanelGroup.y(center - newH / 2);
+
+        dirs.forEach((dir, i) => {
+          const sig = this.getArmSignal(dir, signals);
+          if (!sig) return;
+          const color = sig.isGreen ? this.C.SIGNAL_GREEN : this.C.SIGNAL_RED;
+          const yBase = padTop + i * ROW_H;
+          // label 垂直居中(按其字号)
+          const labelY = yBase + (ROW_H - labelFs) / 2;
+          const valY = yBase + (ROW_H - valFs) / 2;
+          this._addPanelRow(
+            10, labelY, 92, valY,
+            `${sig.phaseName || dir}:`,
+            String(sig.time || 0).padStart(2, '0'),
+            color, labelFs, valFs
+          );
         });
       }
+    },
+
+    createArrowIcon(type, x, y) {
+      // 仅创建空 group,箭头 SVG 图像由 updateDynamicSignals 的 dyeArm 统一加载,
+      // 避免本函数与 dyeArm 并发 loadSvgImage 在 imgCache 命中瞬时 resolve 时
+      // 各自 add 一张 Konva.Image,导致同位置叠加 2 张图(视觉上多了一个箭头)。
+      const group = new Konva.Group({ x, y });
+      if (type === 'R') group.scaleX(-1);
+      group._arrowMeta = { type };
       return group;
     },
 
@@ -350,11 +545,11 @@ export default {
       const { laneWidth } = this.sizeConfig;
       const detY = -190; // 与原 cameraNode 锚点一致
 
-      // 全路口连续编号:N→E→S→W 顺时针累加,每条车道一个号;
+      // 全路口连续编号:按 arm 顺序累加,每条车道一个号;
       // 每个方向内按司机视角"左→右"(arm-local 最外侧 → 最内侧)编号。
       let badgeNum = 1;
 
-      ['N', 'E', 'S', 'W'].forEach(dir => {
+      Object.keys(this.armsNodes).forEach(dir => {
         const armNode = this.armsNodes[dir];
         if (!armNode) return;
 
@@ -391,7 +586,7 @@ export default {
     /** 把 mapData 的检测器初值缓存为波动中心;显示值初始化为基础值 */
     syncDetectorBase() {
       const cfg = (this.mapData && this.mapData.armsConfig) || {};
-      ['N', 'E', 'S', 'W'].forEach(dir => {
+      Object.keys(this.armsNodes).forEach(dir => {
         const det = cfg[dir] && cfg[dir].detector;
         this.detectorBase[dir] = det ? { flow: det.flow, occupancy: det.occupancy } : null;
       });
@@ -400,7 +595,7 @@ export default {
     /** 用 detectorBase 当前值刷一次显示文本(当前画布版未绘制流量/占有率文字,
      *  保留方法以便未来恢复显示;polling 仍在跑,detectorBase 持续更新供 DetectorTable 等下游消费) */
     updateDetectorTexts() {
-      ['N', 'E', 'S', 'W'].forEach(dir => {
+      Object.keys(this.armsNodes).forEach(dir => {
         const base = this.detectorBase[dir];
         const node = this.detectorNodes[dir];
         if (!base || !node) return;
@@ -418,7 +613,7 @@ export default {
         const res = await apiGetDetectorMonitorData(id);
         const arms = res && res.armsDetector;
         if (!arms) return;
-        ['N', 'E', 'S', 'W'].forEach(dir => {
+        Object.keys(this.armsNodes).forEach(dir => {
           const d = arms[dir];
           if (d) this.detectorBase[dir] = { flow: d.flow, occupancy: d.occupancy };
         });
@@ -444,7 +639,7 @@ export default {
     /** 切换 video/detector:用 visible() 而不是 destroy 重建,开销最小 */
     applyDisplayMode() {
       const showDetector = this.displayMode === 'detector';
-      ['N', 'E', 'S', 'W'].forEach(dir => {
+      Object.keys(this.armsNodes).forEach(dir => {
         const arm = this.armsNodes[dir];
         if (arm && arm.cameraNode) arm.cameraNode.visible(!showDetector);
         if (this.detectorNodes[dir]) this.detectorNodes[dir].visible(showDetector);
@@ -553,7 +748,7 @@ export default {
 
     closeAllCameraDialogs() {
       if (!this.dialogManager || typeof this.dialogManager.closeDialog !== 'function') return;
-      ['N', 'E', 'S', 'W'].forEach(dir => {
+      Object.keys(this.armsNodes).forEach(dir => {
         this.dialogManager.closeDialog(`camera-video-${this._uid}-${dir}`);
       });
     },
@@ -653,9 +848,23 @@ export default {
       const config = this.mapData.armsConfig;
       if (!config) return;
 
+      // 检查 arm 集合或旋转角度是否变化(mapData 异步到达 / 路口切换)
+      // → 触发重建中心多边形和 arms,确保 N 边形几何与新数据匹配
+      const newKeys = Object.keys(config).sort().join(',');
+      const currentKeys = Object.keys(this.armsNodes).sort().join(',');
+      const rotChanged = Object.keys(config).some(dir => {
+        const arm = this.armsNodes[dir];
+        if (!arm) return true;
+        return arm.rotation() !== this._armRotation(dir, config[dir]);
+      });
+      if (newKeys !== currentKeys || rotChanged) {
+        this._buildArmsAndPolygon();
+      }
+
       Object.keys(config).forEach(dir => {
         const armData = config[dir];
         const armNode = this.armsNodes[dir];
+        if (!armNode) return;
 
         if (armNode.cameraNode) armNode.cameraNode.destroy();
         if (armData.cameraType > 0) {
@@ -685,11 +894,6 @@ export default {
       if (!signals) return;
       const config = this.mapData.armsConfig || {};
 
-      const nsColor = signals.ns.isGreen ? this.C.SIGNAL_GREEN : this.C.SIGNAL_RED;
-      const ewColor = signals.ew.isGreen ? this.C.SIGNAL_GREEN : this.C.SIGNAL_RED;
-      const nsActiveTypes = signals.ns.activeArrowTypes || [];
-      const ewActiveTypes = signals.ew.activeArrowTypes || [];
-
       const dyeArm = (dir, armNode, pedColor, vehicleColor, activeTypes) => {
         // 灯带颜色(人行道信号)
         armNode.lightGroup.getChildren().forEach(r => r.fill(pedColor));
@@ -734,19 +938,23 @@ export default {
 
       // 灯带代表人行道:P1/P3绿灯期间正常(车绿人红、车红人绿),其余时段人行道全红
       const pedAllRed = signals.pedAllRed || false;
-      const nsPedColor = pedAllRed ? this.C.SIGNAL_RED : (signals.ns.isGreen ? this.C.SIGNAL_RED : this.C.SIGNAL_GREEN);
-      const ewPedColor = pedAllRed ? this.C.SIGNAL_RED : (signals.ew.isGreen ? this.C.SIGNAL_RED : this.C.SIGNAL_GREEN);
-
-      dyeArm('N', this.armsNodes.N, nsPedColor, nsColor, nsActiveTypes);
-      dyeArm('S', this.armsNodes.S, nsPedColor, nsColor, nsActiveTypes);
-      dyeArm('E', this.armsNodes.E, ewPedColor, ewColor, ewActiveTypes);
-      dyeArm('W', this.armsNodes.W, ewPedColor, ewColor, ewActiveTypes);
 
-      this.panelNodes.nsLabel.text(`${signals.ns.phaseName}:`);
-      this.panelNodes.nsVal.text(signals.ns.time.toString().padStart(2, '0')).fill(nsColor);
+      // 按 arm 遍历染色:每条 arm 独立读 signals(多路口用 signals.arms[dir],
+      // 4 路口走兼容层 getArmSignal 把 N/S→ns、E/W→ew 映射回去),与原 4 路口行为一致
+      Object.keys(this.armsNodes).forEach(dir => {
+        const armNode = this.armsNodes[dir];
+        if (!armNode) return;
+        const armSignal = this.getArmSignal(dir, signals);
+        if (!armSignal) return;
+        const isGreen = !!armSignal.isGreen;
+        const vehicleColor = isGreen ? this.C.SIGNAL_GREEN : this.C.SIGNAL_RED;
+        const pedColor = pedAllRed ? this.C.SIGNAL_RED : (isGreen ? this.C.SIGNAL_RED : this.C.SIGNAL_GREEN);
+        const activeTypes = armSignal.activeArrowTypes || [];
+        dyeArm(dir, armNode, pedColor, vehicleColor, activeTypes);
+      });
 
-      this.panelNodes.ewLabel.text(`${signals.ew.phaseName}:`);
-      this.panelNodes.ewVal.text(signals.ew.time.toString().padStart(2, '0')).fill(ewColor);
+      // 中心面板:4 路口走 NS/EW 两行,多路口走 per-arm 多行(自适应高度)
+      this._renderCenterPanelContent(signals);
 
       this.layer.draw();
     }

+ 74 - 9
src/mock/api.js

@@ -1047,10 +1047,21 @@ export async function apiGetCrossingDetailData(id, { iconMode = 'default' } = {}
     const armDetectors = _detectorsToArmConfig(detectors)
     config.detectors = detectors
     config.armsConfig = config.armsConfig || {}
-    ;['N', 'E', 'S', 'W'].forEach(d => {
-      config.armsConfig[d] = config.armsConfig[d] || {}
-      config.armsConfig[d].detector = armDetectors[d]
-    })
+    const existingKeys = Object.keys(config.armsConfig)
+    if (existingKeys.length === 0) {
+      // armsConfig 空:按 4 路口默认填充 NESW 结构
+      ;['N', 'E', 'S', 'W'].forEach(d => {
+        config.armsConfig[d] = { detector: armDetectors[d] }
+      })
+    } else {
+      // 已有结构:只对已存在的 NESW key 注入 detector(4 路口兼容),
+      // 不再创建新的 NESW key,避免污染多路口(arm_1/2/3 等)的 armsConfig
+      ;['N', 'E', 'S', 'W'].forEach(d => {
+        if (config.armsConfig[d]) {
+          config.armsConfig[d].detector = armDetectors[d]
+        }
+      })
+    }
   }
 
   // 从真实阶段数据推导周期和相位
@@ -1130,6 +1141,15 @@ export async function apiGetCrossingDetailData(id, { iconMode = 'default' } = {}
   const phaseDiff = (seed * 7) % 25
   const coordTime = (seed * 13) % 60
 
+  // 多路口(armsConfig key 不是 NESW)按 arm 顺序循环分配 4 个 mp4,让每条 arm 的视频独立对应
+  const armKeys = (config.armsConfig && Object.keys(config.armsConfig)) || []
+  const isMultiWay = armKeys.length > 0 && !(armKeys.length === 4 && ['N','E','S','W'].every(d => armKeys.includes(d)))
+  let armVideos = null
+  if (isMultiWay) {
+    armVideos = {}
+    armKeys.forEach((k, i) => { armVideos[k] = VIDEOS[i % VIDEOS.length] })
+  }
+
   return ok({
     currentRoute: {
       id, name: point ? point.name : id,
@@ -1138,6 +1158,7 @@ export async function apiGetCrossingDetailData(id, { iconMode = 'default' } = {}
       time: cycleLength + 's',
       mainVideo: pickVideo(seed),
       cornerVideos: _makeCornerVideos(seed),
+      armVideos,
     },
     intersectionData: config,
     phaseData,
@@ -1167,21 +1188,65 @@ export async function apiGetCrossingDetailData(id, { iconMode = 'default' } = {}
   })
 }
 
+/** 多路口(非 NESW key)按实际 armsConfig 生成 per-arm 检测器数据 */
+function _detectorPerArmSnapshot(id, armsConfig, bucketIdx) {
+  const seed = _idSeed(id || '')
+  const armsDetector = {}
+  const tableData = []
+  let badgeNum = 1
+  Object.keys(armsConfig).forEach((dir, dirIdx) => {
+    const lanes = ((armsConfig[dir] && armsConfig[dir].lanes) || []).filter(l => l)
+    if (lanes.length === 0) return
+    const i = dirIdx + 1
+    const baseFlow = 60 + ((seed * (i * 7 + 13)) % 160)
+    const baseOcc = 15 + ((seed * (i * 11 + 17)) % 50)
+    const flowNoise = (((seed ^ (i * 257) ^ (bucketIdx * 9176)) >>> 0) % 10000) / 10000
+    const occNoise = (((seed ^ (i * 521) ^ (bucketIdx * 4093)) >>> 0) % 10000) / 10000
+    const armFlow = Math.max(0, Math.round(baseFlow * (1 + (flowNoise - 0.5) * 0.3)))
+    const armOcc = Math.max(0, Math.min(100, Math.round(baseOcc + (occNoise - 0.5) * 10)))
+    armsDetector[dir] = { index: i, flow: armFlow, occupancy: armOcc }
+
+    lanes.forEach((laneType, laneIdx) => {
+      const j = badgeNum
+      const lFlow = Math.max(0, Math.round((60 + ((seed * (j * 7 + 13)) % 160)) * (1 + ((((seed ^ (j * 257) ^ (bucketIdx * 9176)) >>> 0) % 10000) / 10000 - 0.5) * 0.3)))
+      const lOcc = Math.max(0, Math.min(100, Math.round((15 + ((seed * (j * 11 + 17)) % 50)) + ((((seed ^ (j * 521) ^ (bucketIdx * 4093)) >>> 0) % 10000) / 10000 - 0.5) * 10)))
+      tableData.push({
+        id: badgeNum,
+        name: `${dir} 第${laneIdx + 1}车道`,
+        flow: lFlow,
+        occupancy: `${lOcc}%`,
+      })
+      badgeNum++
+    })
+  })
+  return { timeTicks: [180, 150, 120, 90, 60, 30], tableData, armsDetector }
+}
+
 /**
  * GET /api/detector/monitor/:id — 检测器运行数据监视
  * 返回两套视图:
- *   - armsDetector: { N/E/S/W → {index, flow, occupancy} } 给旧的方向级消费方(pollDetectorData 兜底用)
- *   - tableData:    按车道展开的 16 条数据,编号 1..16 与画布徽章顺序一致
- *                   (N→E→S→W 顺时针;每方向内司机视角左→右)
+ *   - armsDetector: { N/E/S/W or arm_X → {index, flow, occupancy} }
+ *   - tableData:    按车道展开(4 路口 16 条;多路口按实际 arm × lane 数生成)
  * 同 5s 桶内重复调用返回相同值(确定性噪声),保证画布与弹窗轮询同窗口取数一致。
  */
 export async function apiGetDetectorMonitorData(id) {
   await delay(150)
   const bucketIdx = Math.floor(Date.now() / 5000)
-  // 方向级(保留给 armsDetector 字段,兼容旧消费方)
+
+  // 多路口路径:按实际 armsConfig 生成 per-arm 数据
+  const config = DB.intersectionConfigs[id]
+  const armsConfig = config && config.armsConfig
+  if (armsConfig) {
+    const armKeys = Object.keys(armsConfig)
+    const isMultiWay = !(armKeys.length === 4 && ['N','E','S','W'].every(d => armKeys.includes(d)))
+    if (isMultiWay) {
+      return ok(_detectorPerArmSnapshot(id, armsConfig, bucketIdx))
+    }
+  }
+
+  // 4 路口默认路径(原逻辑)
   const dirSnap = _detectorBucketSnapshot(id, bucketIdx)
   const armsDetector = _bucketArmsDetector(dirSnap)
-  // 车道级(弹窗表格用,与画布徽章一一对应)
   const laneSnap = _detectorLaneBucketSnapshot(id, bucketIdx)
   return ok({
     timeTicks: [180, 150, 120, 90, 60, 30],

+ 104 - 0
src/mock/mock_data.json

@@ -7341,6 +7341,18 @@
                   "isOpen": false,
                   "children": [
                     {
+                      "id": "JNC900003",
+                      "label": "[示例]三岔路口",
+                      "lng": 116.69,
+                      "lat": 39.90
+                    },
+                    {
+                      "id": "JNC900005",
+                      "label": "[示例]五岔路口",
+                      "lng": 116.70,
+                      "lat": 39.91
+                    },
+                    {
                       "id": "JNC000220",
                       "label": "TZ-367_畅和东路与大营东街路口",
                       "lng": 116.743531,
@@ -11809,6 +11821,18 @@
                   "isOpen": false,
                   "children": [
                     {
+                      "id": "JNC900003",
+                      "label": "[示例]三岔路口",
+                      "lng": 116.69,
+                      "lat": 39.90
+                    },
+                    {
+                      "id": "JNC900005",
+                      "label": "[示例]五岔路口",
+                      "lng": 116.70,
+                      "lat": 39.91
+                    },
+                    {
                       "id": "JNC000220",
                       "label": "TZ-367_畅和东路与大营东街路口",
                       "lng": 116.743531,
@@ -26323,6 +26347,36 @@
       "isKey": true,
       "lng": 116.7101952,
       "lat": 39.90528795
+    },
+    {
+      "id": "JNC900003",
+      "index": 9001,
+      "name": "[示例]三岔路口",
+      "subArea": "REG000200",
+      "ip": "192.168.99.100",
+      "status": "在线",
+      "timeOffset": "无偏差",
+      "cycle": 90,
+      "version": "V3.2.0",
+      "node": "通州节点1",
+      "isKey": true,
+      "lng": 116.69,
+      "lat": 39.90
+    },
+    {
+      "id": "JNC900005",
+      "index": 9002,
+      "name": "[示例]五岔路口",
+      "subArea": "REG000200",
+      "ip": "192.168.99.200",
+      "status": "在线",
+      "timeOffset": "无偏差",
+      "cycle": 125,
+      "version": "V3.2.0",
+      "node": "通州节点1",
+      "isKey": true,
+      "lng": 116.70,
+      "lat": 39.91
     }
   ],
   "securityRoutes": [
@@ -28562,6 +28616,56 @@
           "position": "西进口"
         }
       ]
+    },
+    "JNC900003": {
+      "signals": {
+        "arms": {
+          "arm_1": { "phaseName": "P1", "time": 30, "isGreen": true,  "activeArrowTypes": ["S"] },
+          "arm_2": { "phaseName": "P2", "time": 30, "isGreen": false, "activeArrowTypes": [] },
+          "arm_3": { "phaseName": "P3", "time": 30, "isGreen": false, "activeArrowTypes": [] }
+        },
+        "pedAllRed": false,
+        "ns": { "phaseName": "P1", "time": 30, "isGreen": true,  "activeArrowTypes": ["S"] },
+        "ew": { "phaseName": "P2", "time": 30, "isGreen": false, "activeArrowTypes": [] }
+      },
+      "armsConfig": {
+        "arm_1": { "rotation": 0,   "lanes": ["L", "S", null, null], "cameraType": 1 },
+        "arm_2": { "rotation": 120, "lanes": ["L", "S", null, null], "cameraType": 1 },
+        "arm_3": { "rotation": 240, "lanes": ["U", "S", null, null], "cameraType": 1 }
+      },
+      "cameras": [
+        { "intersection": "[示例]三岔路口", "intersectionId": "JNC900003", "cameraId": "CAM900003_01", "loginName": "admin_x_01", "password": "******", "cameraType": "枪机", "port": 554, "ip": "192.168.99.100", "enabled": true, "position": "arm_1", "dirKey": "arm_1" },
+        { "intersection": "[示例]三岔路口", "intersectionId": "JNC900003", "cameraId": "CAM900003_02", "loginName": "admin_x_02", "password": "******", "cameraType": "枪机", "port": 555, "ip": "192.168.99.101", "enabled": true, "position": "arm_2", "dirKey": "arm_2" },
+        { "intersection": "[示例]三岔路口", "intersectionId": "JNC900003", "cameraId": "CAM900003_03", "loginName": "admin_x_03", "password": "******", "cameraType": "枪机", "port": 556, "ip": "192.168.99.102", "enabled": true, "position": "arm_3", "dirKey": "arm_3" }
+      ]
+    },
+    "JNC900005": {
+      "signals": {
+        "arms": {
+          "arm_1": { "phaseName": "P1", "time": 25, "isGreen": true,  "activeArrowTypes": ["S"] },
+          "arm_2": { "phaseName": "P2", "time": 25, "isGreen": false, "activeArrowTypes": [] },
+          "arm_3": { "phaseName": "P3", "time": 25, "isGreen": false, "activeArrowTypes": [] },
+          "arm_4": { "phaseName": "P4", "time": 25, "isGreen": false, "activeArrowTypes": [] },
+          "arm_5": { "phaseName": "P5", "time": 25, "isGreen": false, "activeArrowTypes": [] }
+        },
+        "pedAllRed": false,
+        "ns": { "phaseName": "P1", "time": 25, "isGreen": true,  "activeArrowTypes": ["S"] },
+        "ew": { "phaseName": "P2", "time": 25, "isGreen": false, "activeArrowTypes": [] }
+      },
+      "armsConfig": {
+        "arm_1": { "rotation": 0,   "lanes": ["L", "S", null, null], "cameraType": 1 },
+        "arm_2": { "rotation": 72,  "lanes": ["L", "S", null, null], "cameraType": 1 },
+        "arm_3": { "rotation": 144, "lanes": ["U", "S", null, null], "cameraType": 1 },
+        "arm_4": { "rotation": 216, "lanes": ["U", "S", null, null], "cameraType": 1 },
+        "arm_5": { "rotation": 288, "lanes": ["L", "S", null, null], "cameraType": 1 }
+      },
+      "cameras": [
+        { "intersection": "[示例]五岔路口", "intersectionId": "JNC900005", "cameraId": "CAM900005_01", "loginName": "admin_y_01", "password": "******", "cameraType": "枪机", "port": 554, "ip": "192.168.99.200", "enabled": true, "position": "arm_1", "dirKey": "arm_1" },
+        { "intersection": "[示例]五岔路口", "intersectionId": "JNC900005", "cameraId": "CAM900005_02", "loginName": "admin_y_02", "password": "******", "cameraType": "枪机", "port": 555, "ip": "192.168.99.201", "enabled": true, "position": "arm_2", "dirKey": "arm_2" },
+        { "intersection": "[示例]五岔路口", "intersectionId": "JNC900005", "cameraId": "CAM900005_03", "loginName": "admin_y_03", "password": "******", "cameraType": "枪机", "port": 556, "ip": "192.168.99.202", "enabled": true, "position": "arm_3", "dirKey": "arm_3" },
+        { "intersection": "[示例]五岔路口", "intersectionId": "JNC900005", "cameraId": "CAM900005_04", "loginName": "admin_y_04", "password": "******", "cameraType": "枪机", "port": 557, "ip": "192.168.99.203", "enabled": true, "position": "arm_4", "dirKey": "arm_4" },
+        { "intersection": "[示例]五岔路口", "intersectionId": "JNC900005", "cameraId": "CAM900005_05", "loginName": "admin_y_05", "password": "******", "cameraType": "枪机", "port": 558, "ip": "192.168.99.204", "enabled": true, "position": "arm_5", "dirKey": "arm_5" }
+      ]
     }
   },
   "trunkLineMenuTree": [