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

路口详情:检测器弹窗按车道展开 + 默认贴右侧控制方式区域

  - IntersectionMapVideos 画布加回检测器图标(球机造型),位置移到徽章列上方;图标和编号通过 -armRotation
  反向旋转,N/E/S/W 四向都保持朝上
  - 每条进口道按车道画编号徽章,N→E→S→W 顺时针累加(每方向内司机视角左→右)
  - mock/api.js 新增 _detectorLaneBucketSnapshot,apiGetDetectorMonitorData 的 tableData 改为车道级 16
  条,编号与画布徽章一一对应;armsDetector 字段保留以兼容原有消费方
  - openDetectorDialog 默认定位到当前路口面板的 .detail-panel-right 区域(位置+尺寸),rect 按 1920
  设计宽换算回设计坐标传给 SmartDialog;找不到时回退居中 620×360
画安 пре 3 недеља
родитељ
комит
12fadb0f12
2 измењених фајлова са 150 додато и 71 уклоњено
  1. 93 64
      src/components/ui/IntersectionMapVideos.vue
  2. 57 7
      src/mock/api.js

+ 93 - 64
src/components/ui/IntersectionMapVideos.vue

@@ -62,14 +62,8 @@ import XgVideoPlayer from '@/components/ui/XgVideoPlayer.vue';
 import SegmentedRadio from '@/components/ui/SegmentedRadio.vue';
 import { apiGetDetectorMonitorData } from '@/api';
 
-// 检测器锚点 = 原摄像机所在的 world 坐标(armNode 局部 (-80,-190) 经 arm 旋转后)。
-// 图标位于锚点上方,下方一行 [流量/占有率(右对齐) ①]。
-const DETECTOR_POSITIONS = {
-  N: { x: 370, y: 100 },
-  E: { x: 800, y: 370 },
-  S: { x: 530, y: 800 },
-  W: { x: 100, y: 530 },
-};
+// 检测器现挂在 armNode 内部,位置使用 arm-local 坐标(与 createArrowIcon 一致),
+// y=-190 对应原摄像机锚点(路口前方约一倍车道处的虚拟检测门架),由 arm 旋转自动定位到四个方向。
 
 // 方向箭头 SVG 路径映射
 // 所有方向臂共用同一套图标(以N方向/向下驶入为基准),由 createRoadArm 的 rotation 自动旋转
@@ -377,70 +371,81 @@ export default {
       return group;
     },
 
-    /**
-     * 检测器条目布局(N 数据行在图标上方,其他方向在下方;编号在左、文字两行在右):
-     *   N 方向:       其他方向:
-     *     ①  流量:1230   [icon]
-     *        占有率:50%   ①  流量:1230
-     *     [icon]              占有率:50%
-     */
-    createDetectorIcon(num, dir, x, y) {
-      const group = new Konva.Group({ x, y });
+    /** 检测器图标(球机造型)。放在进口道中线、徽章列上方;
+     *  `armRotation` 用来反向旋转,让所有方向的图标都朝上,与 arm 旋转无关。 */
+    createDetectorIcon(armRotation) {
       const stroke = '#7fb6ff';
-      const fill = '#3a7fd1';
-
-      // 1) 顶部图标:沿用原 球机 造型(圆角矩形 + 内嵌镜头圆 + 底部支架横杆+竖杆)
-      const iconG = new Konva.Group({ x: 0, y: 2 });
-      iconG.add(new Konva.Rect({
+      const group = new Konva.Group({
+        x: -80, y: -240,
+        rotation: -armRotation,
+      });
+      group.add(new Konva.Rect({
         x: -18, y: -32, width: 36, height: 32,
         stroke, strokeWidth: 2.5, cornerRadius: 8,
         fill: 'rgba(58,127,209,0.2)',
       }));
-      iconG.add(new Konva.Circle({ x: 0, y: -16, radius: 7, stroke, strokeWidth: 2.5 }));
-      iconG.add(new Konva.Line({ points: [-13, 0, 13, 0], stroke, strokeWidth: 2.5, lineCap: 'round' }));
-      iconG.add(new Konva.Line({ points: [0, 0, 0, 8], stroke, strokeWidth: 2.5 }));
-      group.add(iconG);
-
-      // 2) 数据行 Y 偏移:N 在图标上方,其他在下方
-      const dataY = dir === 'N' ? -62 : 42;
+      group.add(new Konva.Circle({ x: 0, y: -16, radius: 7, stroke, strokeWidth: 2.5 }));
+      group.add(new Konva.Line({ points: [-13, 0, 13, 0], stroke, strokeWidth: 2.5, lineCap: 'round' }));
+      group.add(new Konva.Line({ points: [0, 0, 0, 8], stroke, strokeWidth: 2.5 }));
+      return group;
+    },
 
-      // 3) 编号圆圈(左侧)
-      const badgeCx = -55;
-      group.add(new Konva.Circle({ x: badgeCx, y: dataY, radius: 13, fill, stroke, strokeWidth: 1.5 }));
+    /** 检测器编号徽章(小圆 + 数字)。文字反向旋转,保证四个方向都朝上。 */
+    createDetectorBadge(lx, ly, num, armRotation) {
+      const stroke = '#7fb6ff';
+      const fill = '#3a7fd1';
+      const group = new Konva.Group();
+      group.add(new Konva.Circle({ x: lx, y: ly, radius: 13, fill, stroke, strokeWidth: 1.5 }));
       group.add(new Konva.Text({
-        x: badgeCx - 13, y: dataY - 9, width: 26, align: 'center',
+        x: lx, y: ly,
+        offsetX: 13, offsetY: 9, // 把旋转中心从左上角搬到 (lx, ly)
+        width: 26, align: 'center',
         text: String(num), fontSize: 16, fontStyle: 'bold', fill: '#fff',
+        rotation: -armRotation,
       }));
-
-      // 4) 流量 / 占有率两行(编号右侧 6px 间隔)
-      const textX = badgeCx + 13 + 6;
-      const flowText = new Konva.Text({
-        x: textX, y: dataY - 18, text: '', fontSize: 18, fontStyle: 'bold', fill: '#fff', fontFamily: 'monospace',
-      });
-      const occText = new Konva.Text({
-        x: textX, y: dataY + 2, text: '', fontSize: 18, fontStyle: 'bold', fill: '#fff', fontFamily: 'monospace',
-      });
-      group.add(flowText, occText);
-      group.flowText = flowText;
-      group.occText = occText;
       return group;
     },
 
-    /** (重)创建四个方向的检测器节点;如已存在先 destroy */
+    /** (重)创建四个方向的检测器组:图标 + 每条车道一个编号徽章。
+     *  组挂在 armNode 内,arm 旋转/平移自动定位;图标和数字通过反向旋转保持朝上。 */
     renderDetectors() {
       const cfg = (this.mapData && this.mapData.armsConfig) || {};
+      const { laneWidth } = this.sizeConfig;
+      const detY = -190; // 与原 cameraNode 锚点一致
+
+      // 全路口连续编号:N→E→S→W 顺时针累加,每条车道一个号;
+      // 每个方向内按司机视角"左→右"(arm-local 最外侧 → 最内侧)编号。
+      let badgeNum = 1;
+
       ['N', 'E', 'S', 'W'].forEach(dir => {
-        if (this.detectorNodes[dir]) {
-          this.detectorNodes[dir].destroy();
-          this.detectorNodes[dir] = null;
+        const armNode = this.armsNodes[dir];
+        if (!armNode) return;
+
+        // 清理旧检测器组
+        if (armNode.detectorGroup) {
+          armNode.detectorGroup.destroy();
+          armNode.detectorGroup = null;
         }
-        const det = cfg[dir] && cfg[dir].detector;
-        if (!det) return;
-        const pos = DETECTOR_POSITIONS[dir];
-        const node = this.createDetectorIcon(det.index, dir, pos.x, pos.y);
-        node.visible(this.displayMode === 'detector');
-        this.layer.add(node);
-        this.detectorNodes[dir] = node;
+        this.detectorNodes[dir] = null;
+
+        const armData = cfg[dir];
+        if (!armData) return;
+        const lanes = armData.lanes || [];
+        const armRotation = armNode.rotation() || 0;
+
+        const detGroup = new Konva.Group();
+        // 检测器图标(一方向一个,徽章列上方)
+        detGroup.add(this.createDetectorIcon(armRotation));
+        // 车道徽章
+        for (let index = lanes.length - 1; index >= 0; index--) {
+          const lx = -20 - index * laneWidth;
+          detGroup.add(this.createDetectorBadge(lx, detY, badgeNum++, armRotation));
+        }
+
+        detGroup.visible(this.displayMode === 'detector');
+        armNode.add(detGroup);
+        armNode.detectorGroup = detGroup;
+        this.detectorNodes[dir] = detGroup;
       });
       this.syncDetectorBase();
       this.updateDetectorTexts();
@@ -455,14 +460,15 @@ export default {
       });
     },
 
-    /** 用 detectorBase 当前值刷一次显示文本 */
+    /** 用 detectorBase 当前值刷一次显示文本(当前画布版未绘制流量/占有率文字,
+     *  保留方法以便未来恢复显示;polling 仍在跑,detectorBase 持续更新供 DetectorTable 等下游消费) */
     updateDetectorTexts() {
       ['N', 'E', 'S', 'W'].forEach(dir => {
         const base = this.detectorBase[dir];
         const node = this.detectorNodes[dir];
         if (!base || !node) return;
-        node.flowText.text(`流量:${base.flow}`);
-        node.occText.text(`占有率:${base.occupancy}%`);
+        if (node.flowText) node.flowText.text(`流量:${base.flow}`);
+        if (node.occText) node.occText.text(`占有率:${base.occupancy}%`);
       });
       if (this.layer) this.layer.batchDraw();
     },
@@ -524,18 +530,41 @@ export default {
       return '';
     },
 
-    /** 检测器模式下打开弹窗:DetectorTable 居中展示「检测器运行数据监视」 */
+    /** 检测器模式下打开弹窗:默认贴到本路口详情面板右侧(控制方式区域)覆盖显示。
+     *  无法定位时回退到居中默认尺寸。SmartDialog 用 1920 设计宽度做缩放,所以这里把
+     *  实际像素 rect 换算回设计坐标传过去。 */
     openDetectorDialog() {
       if (!this.dialogManager || typeof this.dialogManager.openDialog !== 'function') return;
-      this.dialogManager.openDialog({
+
+      const cfg = {
         id: `detector-monitor-${this._uid}`,
         title: '检测器运行数据监视',
         component: 'DetectorTable',
-        width: 620,
-        height: 360,
         noPadding: false,
         data: { intersectionId: this.getIntersectionId() },
-      });
+      };
+
+      const DESIGN_WIDTH = 1920;
+      const scale = window.innerWidth / DESIGN_WIDTH;
+      const root = this.$el && this.$el.closest && this.$el.closest('.crossing-detail-panel');
+      const rightPanel = root && root.querySelector(':scope > .detail-panel-right');
+
+      if (rightPanel && scale > 0) {
+        const rect = rightPanel.getBoundingClientRect();
+        if (rect.width > 0 && rect.height > 0) {
+          cfg.center = false;
+          cfg.position = { x: rect.left / scale, y: rect.top / scale };
+          cfg.width = Math.round(rect.width / scale);
+          cfg.height = Math.round(rect.height / scale);
+        }
+      }
+      // 兜底:找不到右侧面板(独立使用场景)则居中 620×360
+      if (cfg.width == null) {
+        cfg.width = 620;
+        cfg.height = 360;
+      }
+
+      this.dialogManager.openDialog(cfg);
     },
 
     closeDetectorDialog() {

+ 57 - 7
src/mock/api.js

@@ -193,6 +193,52 @@ function _bucketArmsDetector(lanes) {
   return out
 }
 
+/**
+ * 按车道展开的检测器快照——与画布徽章一一对应。
+ * 编号顺序:N→E→S→W 顺时针累加;每个方向内司机视角左→右(arm-local 最外侧 → 最内侧)。
+ * 4 方向 × lanesPerDir 条车道 = 全局连续编号 1..(4*lanesPerDir)。
+ */
+function _detectorLaneBucketSnapshot(id, bucketIdx, name, lanesPerDir = 4) {
+  const seed = _idSeed(id || '')
+  const dirs = [
+    { dir: 'N', label: '北' },
+    { dir: 'E', label: '东' },
+    { dir: 'S', label: '南' },
+    { dir: 'W', label: '西' },
+  ]
+  const out = []
+  let badgeNum = 1
+  dirs.forEach(d => {
+    // 司机视角左→右:arm-local 最外侧(laneIdx = lanesPerDir-1)→ 最内侧(laneIdx = 0)
+    for (let laneIdx = lanesPerDir - 1; laneIdx >= 0; laneIdx--) {
+      const driverPos = lanesPerDir - laneIdx // 1..N,司机左数第几条
+      const i = badgeNum
+      const baseFlow = 60 + ((seed * (i * 7 + 13)) % 160)
+      const baseOcc = 15 + ((seed * (i * 11 + 17)) % 50)
+      const flowNoise01 = (((seed ^ (i * 257) ^ (bucketIdx * 9176)) >>> 0) % 10000) / 10000
+      const occNoise01 = (((seed ^ (i * 521) ^ (bucketIdx * 4093)) >>> 0) % 10000) / 10000
+      const flow = Math.max(0, Math.round(baseFlow * (1 + (flowNoise01 - 0.5) * 0.3)))
+      const occupancy = Math.max(0, Math.min(100, Math.round(baseOcc + (occNoise01 - 0.5) * 10)))
+      out.push({
+        intersection: name || id,
+        intersectionId: id,
+        detectorId: `DT${(id || '000').slice(-6)}_${String(badgeNum).padStart(2, '0')}`,
+        index: badgeNum,
+        direction: d.dir,
+        laneIndex: laneIdx,
+        driverPos,
+        position: `${d.label}进口`,
+        name: `${d.label}进口 第${driverPos}车道`,
+        flow,
+        occupancy,
+        enabled: true,
+      })
+      badgeNum++
+    }
+  })
+  return out
+}
+
 // 保留旧入口名,让调用方零修改(_makeIntersectionConfig 等仍能用)
 function _makeDetectors(id, name /* seed 不再使用:bucket 自己用 _idSeed */) {
   const bucketIdx = Math.floor(Date.now() / 5000)
@@ -1123,19 +1169,23 @@ export async function apiGetCrossingDetailData(id, { iconMode = 'default' } = {}
 
 /**
  * GET /api/detector/monitor/:id — 检测器运行数据监视
- * 返回单一数据源的两种视图:
- *   - armsDetector: { N/E/S/W → {index, flow, occupancy} } 给画布 ① ② ③ ④ 用
- *   - tableData:    8 条 lane-level 数据 给弹窗表格用
- * 同 5s 桶内重复调用返回相同值(确定性噪声),保证画布和弹窗轮询在同一窗口内值一致。
+ * 返回两套视图:
+ *   - armsDetector: { N/E/S/W → {index, flow, occupancy} } 给旧的方向级消费方(pollDetectorData 兜底用)
+ *   - tableData:    按车道展开的 16 条数据,编号 1..16 与画布徽章顺序一致
+ *                   (N→E→S→W 顺时针;每方向内司机视角左→右)
+ * 同 5s 桶内重复调用返回相同值(确定性噪声),保证画布与弹窗轮询同窗口取数一致。
  */
 export async function apiGetDetectorMonitorData(id) {
   await delay(150)
   const bucketIdx = Math.floor(Date.now() / 5000)
-  const lanes = _detectorBucketSnapshot(id, bucketIdx)
-  const armsDetector = _bucketArmsDetector(lanes)
+  // 方向级(保留给 armsDetector 字段,兼容旧消费方)
+  const dirSnap = _detectorBucketSnapshot(id, bucketIdx)
+  const armsDetector = _bucketArmsDetector(dirSnap)
+  // 车道级(弹窗表格用,与画布徽章一一对应)
+  const laneSnap = _detectorLaneBucketSnapshot(id, bucketIdx)
   return ok({
     timeTicks: [180, 150, 120, 90, 60, 30],
-    tableData: lanes.map(l => ({
+    tableData: laneSnap.map(l => ({
       id: l.index,
       name: l.name,
       flow: l.flow,