Browse Source

相位图与十字路口图联动,路线指示图标改用SVG动态变色

  1. SignalTimingChart 扫描线每秒 emit scan-tick 事件,CrossingDetailPanel 监听并根据当前相位动态更新十字路口信号状态
  2. IntersectionMap 和 IntersectionMapVideos 统一改用 SVG 图标(assets/images/svg),通过替换 SVG fill
  属性实现动态变色:当前相位允许的方向绿色,其他方向红色
  3. 所有方向臂共用同一套 SVG 图标(以N方向为基准),由 Konva rotation 自动旋转,右转用左转图标水平翻转
  4. SVG 图标按原始比例等比缩放(高度最大40px),带缓存机制避免重复加载
  5. phaseData 新增第8列方向标记(ns/ew),用于判断当前相位对应南北还是东西方向
  6. 所有路口统一显示4个车道图标(掉头/左转/直行/右转)
画安 3 weeks ago
parent
commit
82c2d947d5

+ 14 - 0
src/assets/images/svg/icon_straight_down.svg

@@ -0,0 +1,14 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
+ "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
+ width="21.000000pt" height="62.000000pt" viewBox="0 0 21.000000 62.000000"
+ preserveAspectRatio="xMidYMid meet">
+
+<g transform="translate(0.000000,62.000000) scale(0.100000,-0.100000)"
+fill="#000000" stroke="none">
+<path d="M88 383 l-3 -238 -37 -3 -37 -3 42 -59 c23 -33 45 -60 49 -60 3 0 25
+26 47 57 l41 58 -35 3 -35 3 0 240 c0 204 -2 239 -15 239 -12 0 -15 -36 -17
+-237z"/>
+</g>
+</svg>

+ 14 - 0
src/assets/images/svg/icon_straight_left.svg

@@ -0,0 +1,14 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
+ "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
+ width="61.000000pt" height="19.000000pt" viewBox="0 0 61.000000 19.000000"
+ preserveAspectRatio="xMidYMid meet">
+
+<g transform="translate(0.000000,19.000000) scale(0.100000,-0.100000)"
+fill="#000000" stroke="none">
+<path d="M61 138 l-54 -41 34 -28 c19 -15 46 -35 62 -44 l27 -16 0 35 0 36
+240 0 c207 0 240 2 240 15 0 13 -33 15 -240 15 l-240 0 0 35 c0 19 -3 35 -7
+34 -5 0 -32 -19 -62 -41z"/>
+</g>
+</svg>

+ 14 - 0
src/assets/images/svg/icon_straight_right.svg

@@ -0,0 +1,14 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
+ "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
+ width="62.000000pt" height="20.000000pt" viewBox="0 0 62.000000 20.000000"
+ preserveAspectRatio="xMidYMid meet">
+
+<g transform="translate(0.000000,20.000000) scale(0.100000,-0.100000)"
+fill="#000000" stroke="none">
+<path d="M480 156 l0 -36 -240 0 c-207 0 -240 -2 -240 -15 0 -13 33 -15 240
+-15 l240 0 0 -35 c0 -19 2 -35 5 -35 3 0 31 19 62 42 l57 41 -31 26 c-18 14
+-45 34 -62 44 l-31 18 0 -35z"/>
+</g>
+</svg>

+ 14 - 0
src/assets/images/svg/icon_straight_up.svg

@@ -0,0 +1,14 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
+ "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
+ width="19.000000pt" height="61.000000pt" viewBox="0 0 19.000000 61.000000"
+ preserveAspectRatio="xMidYMid meet">
+
+<g transform="translate(0.000000,61.000000) scale(0.100000,-0.100000)"
+fill="#000000" stroke="none">
+<path d="M50 545 c-22 -30 -40 -57 -40 -60 0 -3 16 -5 35 -5 l35 0 0 -240 c0
+-207 2 -240 15 -240 13 0 15 33 15 240 l0 240 35 0 c19 0 35 2 35 5 0 8 -80
+115 -85 115 -2 0 -23 -25 -45 -55z"/>
+</g>
+</svg>

+ 15 - 0
src/assets/images/svg/icon_turn_down_left.svg

@@ -0,0 +1,15 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
+ "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
+ width="39.000000pt" height="64.000000pt" viewBox="0 0 39.000000 64.000000"
+ preserveAspectRatio="xMidYMid meet">
+
+<g transform="translate(0.000000,64.000000) scale(0.100000,-0.100000)"
+fill="#000000" stroke="none">
+<path d="M10 415 c0 -229 5 -260 50 -300 17 -15 40 -21 101 -24 l79 -3 0 -35
+c0 -19 3 -33 8 -31 20 9 122 82 122 87 0 6 -104 82 -122 89 -4 2 -8 -12 -8
+-32 l0 -36 -56 0 c-57 0 -90 12 -111 39 -9 11 -12 79 -13 239 l0 222 -25 0
+-25 0 0 -215z"/>
+</g>
+</svg>

+ 17 - 0
src/assets/images/svg/icon_turn_down_left_uturn.svg

@@ -0,0 +1,17 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
+ "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
+ width="39.000000pt" height="68.000000pt" viewBox="0 0 39.000000 68.000000"
+ preserveAspectRatio="xMidYMid meet">
+
+<g transform="translate(0.000000,68.000000) scale(0.100000,-0.100000)"
+fill="#000000" stroke="none">
+<path d="M10 435 c0 -274 5 -300 62 -335 24 -15 50 -20 101 -20 l67 0 0 -35
+c0 -19 2 -35 5 -35 9 0 125 83 125 89 0 6 -105 82 -122 89 -4 2 -8 -12 -8 -32
+l0 -36 -56 0 c-86 0 -123 27 -124 93 l0 37 51 0 c72 0 117 24 140 74 10 23 19
+53 19 69 0 24 3 27 35 27 l36 0 -32 48 c-70 102 -60 100 -113 24 l-47 -67 36
+-3 c32 -3 35 -6 35 -36 0 -19 -9 -45 -21 -60 -18 -23 -28 -26 -80 -26 l-59 0
+0 190 0 190 -25 0 -25 0 0 -245z"/>
+</g>
+</svg>

+ 15 - 0
src/assets/images/svg/icon_turn_left_down.svg

@@ -0,0 +1,15 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
+ "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
+ width="62.000000pt" height="37.000000pt" viewBox="0 0 62.000000 37.000000"
+ preserveAspectRatio="xMidYMid meet">
+
+<g transform="translate(0.000000,37.000000) scale(0.100000,-0.100000)"
+fill="#000000" stroke="none">
+<path d="M155 356 c-56 -25 -70 -50 -74 -137 l-3 -79 -34 0 c-19 0 -34 -2 -34
+-5 0 -9 83 -125 89 -125 6 0 82 105 89 122 2 4 -12 8 -32 8 l-36 0 0 56 c0 57
+12 90 39 111 11 9 79 12 239 13 l222 0 0 25 0 25 -217 0 c-158 -1 -226 -4
+-248 -14z"/>
+</g>
+</svg>

+ 17 - 0
src/assets/images/svg/icon_turn_left_down_uturn.svg

@@ -0,0 +1,17 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
+ "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
+ width="68.000000pt" height="37.000000pt" viewBox="0 0 68.000000 37.000000"
+ preserveAspectRatio="xMidYMid meet">
+
+<g transform="translate(0.000000,37.000000) scale(0.100000,-0.100000)"
+fill="#000000" stroke="none">
+<path d="M153 355 c-50 -22 -73 -68 -73 -147 l0 -68 -35 0 c-19 0 -35 -2 -35
+-5 0 -10 83 -125 90 -125 7 0 90 115 90 125 0 3 -16 5 -35 5 l-35 0 0 59 c0
+49 4 65 26 90 21 25 33 31 65 31 l39 0 0 -57 c0 -39 6 -68 19 -89 21 -35 75
+-64 119 -64 28 0 31 -3 34 -35 l3 -35 63 44 c35 25 64 47 65 50 1 3 -27 25
+-63 50 l-65 46 -3 -35 c-3 -32 -6 -35 -36 -35 -19 0 -45 9 -60 21 -23 18 -26
+28 -26 80 l0 59 190 0 190 0 0 25 0 25 -247 0 c-190 -1 -256 -4 -280 -15z"/>
+</g>
+</svg>

+ 15 - 0
src/assets/images/svg/icon_turn_right_up.svg

@@ -0,0 +1,15 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
+ "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
+ width="62.000000pt" height="37.000000pt" viewBox="0 0 62.000000 37.000000"
+ preserveAspectRatio="xMidYMid meet">
+
+<g transform="translate(0.000000,37.000000) scale(0.100000,-0.100000)"
+fill="#000000" stroke="none">
+<path d="M475 303 c-52 -75 -52 -73 -11 -73 l36 0 0 -56 c0 -57 -12 -90 -39
+-111 -11 -9 -79 -12 -238 -13 l-223 0 0 -25 0 -25 215 0 c229 0 260 5 300 50
+15 17 21 40 24 101 l3 79 34 0 c19 0 34 2 34 5 0 9 -83 125 -89 125 -4 0 -24
+-26 -46 -57z"/>
+</g>
+</svg>

+ 18 - 0
src/assets/images/svg/icon_turn_right_up_uturn.svg

@@ -0,0 +1,18 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
+ "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
+ width="68.000000pt" height="37.000000pt" viewBox="0 0 68.000000 37.000000"
+ preserveAspectRatio="xMidYMid meet">
+
+<g transform="translate(0.000000,37.000000) scale(0.100000,-0.100000)"
+fill="#000000" stroke="none">
+<path d="M532 300 c-23 -32 -42 -61 -42 -65 0 -3 16 -5 35 -5 l35 0 0 -59 c0
+-49 -4 -65 -26 -90 -21 -25 -33 -31 -65 -31 l-39 0 0 58 c0 38 -6 67 -19 88
+-21 35 -75 64 -119 64 -28 0 -31 3 -34 35 l-3 35 -61 -43 c-33 -23 -60 -46
+-60 -52 0 -5 27 -29 60 -52 l61 -43 3 35 c3 32 6 35 36 35 19 0 45 -9 60 -21
+23 -18 26 -28 26 -80 l0 -59 -190 0 -190 0 0 -25 0 -25 245 0 c274 0 300 5
+335 62 15 24 20 50 20 101 l0 67 35 0 c19 0 35 2 35 5 0 10 -83 125 -90 125
+-3 0 -25 -27 -48 -60z"/>
+</g>
+</svg>

+ 15 - 0
src/assets/images/svg/icon_turn_up_left.svg

@@ -0,0 +1,15 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
+ "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
+ width="39.000000pt" height="64.000000pt" viewBox="0 0 39.000000 64.000000"
+ preserveAspectRatio="xMidYMid meet">
+
+<g transform="translate(0.000000,64.000000) scale(0.100000,-0.100000)"
+fill="#000000" stroke="none">
+<path d="M80 580 c-30 -22 -56 -43 -57 -48 -2 -6 96 -81 119 -90 4 -2 8 12 8
+32 l0 36 59 0 c49 0 65 -4 90 -26 l31 -26 0 -224 0 -224 25 0 25 0 0 215 c0
+242 -6 271 -62 305 -24 15 -50 20 -100 20 l-68 0 0 35 c0 19 -3 35 -7 35 -5 0
+-33 -19 -63 -40z"/>
+</g>
+</svg>

+ 17 - 0
src/assets/images/svg/icon_turn_up_left_uturn.svg

@@ -0,0 +1,17 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
+ "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
+ width="39.000000pt" height="68.000000pt" viewBox="0 0 39.000000 68.000000"
+ preserveAspectRatio="xMidYMid meet">
+
+<g transform="translate(0.000000,68.000000) scale(0.100000,-0.100000)"
+fill="#000000" stroke="none">
+<path d="M80 629 c-30 -22 -55 -44 -55 -49 0 -9 108 -90 119 -90 3 0 6 16 6
+35 l0 35 59 0 c49 0 65 -4 90 -26 25 -21 31 -33 31 -65 l0 -39 -57 0 c-39 0
+-68 -6 -89 -19 -35 -21 -64 -75 -64 -119 0 -28 -3 -31 -35 -34 l-36 -3 47 -67
+c53 -76 43 -78 113 25 l32 47 -36 0 c-34 0 -35 1 -35 38 0 58 31 82 103 82
+l57 0 0 -190 0 -190 25 0 25 0 -1 243 c0 270 -5 299 -58 334 -27 18 -48 23
+-102 23 l-69 0 0 35 c0 19 -3 35 -7 35 -5 -1 -33 -19 -63 -41z"/>
+</g>
+</svg>

+ 46 - 1
src/components/ui/CrossingDetailPanel.vue

@@ -12,7 +12,7 @@
                     </div>
                 </div>
 
-                <SignalTimingChart :cycleLength="cycleLength" :currentTime="currentSec" :phaseData="mockPhaseData" :showScanLine="dataReady" :autoScan="dataReady" />
+                <SignalTimingChart :cycleLength="cycleLength" :currentTime="currentSec" :phaseData="mockPhaseData" :showScanLine="dataReady" :autoScan="dataReady" @scan-tick="onScanTick" />
             </div>
         </div>
 
@@ -221,6 +221,51 @@ export default {
         if (this._ro) this._ro.disconnect();
     },
     methods: {
+        onScanTick(activeTime) {
+            if (!this.mockPhaseData || this.mockPhaseData.length === 0) return;
+            // 只看第一轨道(trackIdx=0)的相位
+            const phase = this.mockPhaseData.find(p => p[0] === 0 && activeTime >= p[1] && activeTime < p[2]);
+            if (!phase) return;
+
+            const type = phase[5];       // green/stripe/yellow/red
+            const iconValue = phase[6];  // 如 "STRAIGHT_DOWN,STRAIGHT_UP"
+            const direction = phase[7];  // ns/ew
+            const phaseName = phase[3];  // P1/P2 等
+            const endTime = phase[2];
+            const remaining = Math.max(0, Math.round(endTime - activeTime));
+
+            const nsGreen = (type === 'green' && direction === 'ns');
+            const ewGreen = (type === 'green' && direction === 'ew');
+
+            // 从图标值解析当前允许的行驶方向类型
+            // STRAIGHT→S, TURN_*_LEFT→L, *_UTURN→U
+            let activeArrowTypes = [];
+            if ((nsGreen || ewGreen) && iconValue) {
+                const icons = iconValue.split(',');
+                icons.forEach(ic => {
+                    if (ic.includes('UTURN')) activeArrowTypes.push('U');
+                    if (ic.includes('TURN') && !ic.includes('UTURN')) activeArrowTypes.push('L');
+                    if (ic.includes('STRAIGHT')) activeArrowTypes.push('S');
+                });
+                // 去重
+                activeArrowTypes = [...new Set(activeArrowTypes)];
+            }
+
+            this.$set(this.intersectionData, 'signals', {
+                ns: {
+                    phaseName: nsGreen ? (phaseName || '南北') : (this.intersectionData.signals?.ns?.phaseName || '南北'),
+                    time: remaining,
+                    isGreen: nsGreen,
+                    activeArrowTypes: nsGreen ? activeArrowTypes : []
+                },
+                ew: {
+                    phaseName: ewGreen ? (phaseName || '东西') : (this.intersectionData.signals?.ew?.phaseName || '东西'),
+                    time: remaining,
+                    isGreen: ewGreen,
+                    activeArrowTypes: ewGreen ? activeArrowTypes : []
+                }
+            });
+        },
         initScaleObserver() {
             const ro = new ResizeObserver(entries => {
                 const { width } = entries[0].contentRect;

+ 106 - 21
src/components/ui/IntersectionMap.vue

@@ -7,6 +7,43 @@
 <script>
 import Konva from 'konva';
 
+const arrowSvgMap = {
+  S: require('@/assets/images/svg/icon_straight_down.svg'),
+  L: require('@/assets/images/svg/icon_turn_down_left.svg'),
+  U: require('@/assets/images/svg/icon_turn_down_left_uturn.svg'),
+  R: require('@/assets/images/svg/icon_turn_down_left.svg'),
+};
+
+const imgCache = {};
+function loadSvgImage(svgUrl, fillColor) {
+  const cacheKey = svgUrl + '|' + fillColor;
+  if (imgCache[cacheKey]) return Promise.resolve(imgCache[cacheKey]);
+  if (svgUrl.startsWith('data:image/svg+xml;base64,')) {
+    const base64 = svgUrl.split(',')[1];
+    let svgText = atob(base64);
+    svgText = svgText.replace(/fill="#[0-9a-fA-F]{3,8}"/g, `fill="${fillColor}"`);
+    const encoded = btoa(svgText);
+    const dataUrl = 'data:image/svg+xml;base64,' + encoded;
+    return new Promise(resolve => {
+      const img = new Image();
+      img.onload = () => { imgCache[cacheKey] = img; resolve(img); };
+      img.src = dataUrl;
+    });
+  }
+  return fetch(svgUrl)
+    .then(r => r.text())
+    .then(svgText => {
+      const colored = svgText.replace(/fill="#[0-9a-fA-F]{3,8}"/g, `fill="${fillColor}"`);
+      const blob = new Blob([colored], { type: 'image/svg+xml' });
+      const url = URL.createObjectURL(blob);
+      return new Promise(resolve => {
+        const img = new Image();
+        img.onload = () => { imgCache[cacheKey] = img; resolve(img); };
+        img.src = url;
+      });
+    });
+}
+
 export default {
   name: 'IntersectionMap',
   props: {
@@ -176,14 +213,30 @@ export default {
     },
 
     createArrowIcon(type, x, y, color = this.C.WHITE) {
-      const group = new Konva.Group({ x, y, scaleX: 0.65, scaleY: 0.65 });
-      group.add(new Konva.Circle({ x: 0, y: -35, radius: 3, fill: color, name: 'colorFill' }));
-      let pathData = '';
-      if (type === 'S') pathData = 'M 0 -35 L 0 0 M -7 -10 L 0 0 L 7 -10';
-      else if (type === 'L') pathData = 'M 0 -35 L 0 -15 Q 0 0 15 0 M 5 -7 L 15 0 L 5 7';
-      else if (type === 'R') pathData = 'M 0 -35 L 0 -15 Q 0 0 -15 0 M -5 -7 L -15 0 L -5 7';
-      else if (type === 'U') pathData = 'M 0 -35 L 0 -15 Q 0 0 14 0 Q 28 0 28 -15 L 28 -25 M 21 -18 L 28 -25 L 35 -18';
-      group.add(new Konva.Path({ data: pathData, stroke: color, strokeWidth: 3, lineCap: 'round', lineJoin: 'round', name: 'colorStroke' }));
+      const maxH = 40;
+      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;
     },
 
@@ -236,29 +289,61 @@ export default {
     updateDynamicSignals() {
       const signals = this.mapData.signals;
       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 = (armNode, color) => {
-        armNode.lightGroup.getChildren().forEach(r => r.fill(color));
-        const arrowColor = (color === this.C.SIGNAL_GREEN) ? this.C.SIGNAL_RED : this.C.SIGNAL_GREEN;
-        Object.values(armNode.arrowNodes).forEach(arr => {
-          if (arr) {
-            arr.findOne('.colorFill').fill(arrowColor);
-            arr.findOne('.colorStroke').stroke(arrowColor);
-          }
+      const dyeArm = (dir, armNode, signalColor, activeTypes) => {
+        armNode.lightGroup.getChildren().forEach(r => r.fill(signalColor));
+        const lanes = (config[dir] && config[dir].lanes) || [];
+        Object.keys(armNode.arrowNodes).forEach(index => {
+          const arr = armNode.arrowNodes[index];
+          if (!arr) return;
+          const laneType = lanes[index];
+          const isActive = activeTypes.length > 0 && activeTypes.includes(laneType);
+          const targetColor = isActive ? this.C.SIGNAL_GREEN : this.C.SIGNAL_RED;
+
+          const meta = arr._arrowMeta;
+          if (!meta) return;
+          const svgUrl = arrowSvgMap[meta.type];
+          if (!svgUrl) return;
+
+          loadSvgImage(svgUrl, targetColor).then(imgObj => {
+            const existing = arr.findOne('.arrowImg');
+            if (existing) {
+              existing.image(imgObj);
+            } else {
+              const maxH = 40;
+              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);
+              arr.add(new Konva.Image({
+                image: imgObj,
+                x: -w / 2,
+                y: -h - 5,
+                width: w,
+                height: h,
+                name: 'arrowImg'
+              }));
+            }
+            if (this.layer) this.layer.draw();
+          });
         });
       };
 
-      dyeArm(this.armsNodes.N, nsColor);
-      dyeArm(this.armsNodes.S, nsColor);
-      dyeArm(this.armsNodes.E, ewColor);
-      dyeArm(this.armsNodes.W, ewColor);
+      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);
-      
+
       this.panelNodes.ewLabel.text(`${signals.ew.phaseName}:`);
       this.panelNodes.ewVal.text(signals.ew.time.toString().padStart(2, '0')).fill(ewColor);
 

+ 124 - 22
src/components/ui/IntersectionMapVideos.vue

@@ -56,6 +56,56 @@
 import Konva from 'konva';
 import XgVideoPlayer from '@/components/ui/XgVideoPlayer.vue';
 
+// 方向箭头 SVG 路径映射
+// 所有方向臂共用同一套图标(以N方向/向下驶入为基准),由 createRoadArm 的 rotation 自动旋转
+// R(右转)用左转图标水平翻转
+const arrowSvgMap = {
+  S: require('@/assets/images/svg/icon_straight_down.svg'),
+  L: require('@/assets/images/svg/icon_turn_down_left.svg'),
+  U: require('@/assets/images/svg/icon_turn_down_left_uturn.svg'),
+  R: require('@/assets/images/svg/icon_turn_down_left.svg'),  // 右转用左转图标,渲染时水平翻转
+};
+
+// SVG 原始文本映射(内联,避免 webpack loader 问题)
+const svgRawCache = {};
+
+// 首次加载时通过 Image → Canvas 获取不到 SVG 内容,所以直接用内联方式
+// 从 require 得到的 URL(可能是 base64 或路径)加载图片
+const imgCache = {};
+
+function loadSvgImage(svgUrl, fillColor) {
+  const cacheKey = svgUrl + '|' + fillColor;
+  if (imgCache[cacheKey]) return Promise.resolve(imgCache[cacheKey]);
+
+  // 如果是 base64 data URL,解码后替换颜色
+  if (svgUrl.startsWith('data:image/svg+xml;base64,')) {
+    const base64 = svgUrl.split(',')[1];
+    let svgText = atob(base64);
+    svgText = svgText.replace(/fill="#[0-9a-fA-F]{3,8}"/g, `fill="${fillColor}"`);
+    const encoded = btoa(svgText);
+    const dataUrl = 'data:image/svg+xml;base64,' + encoded;
+    return new Promise(resolve => {
+      const img = new Image();
+      img.onload = () => { imgCache[cacheKey] = img; resolve(img); };
+      img.src = dataUrl;
+    });
+  }
+
+  // 普通 URL,fetch 后替换颜色
+  return fetch(svgUrl)
+    .then(r => r.text())
+    .then(svgText => {
+      const colored = svgText.replace(/fill="#[0-9a-fA-F]{3,8}"/g, `fill="${fillColor}"`);
+      const blob = new Blob([colored], { type: 'image/svg+xml' });
+      const url = URL.createObjectURL(blob);
+      return new Promise(resolve => {
+        const img = new Image();
+        img.onload = () => { imgCache[cacheKey] = img; resolve(img); };
+        img.src = url;
+      });
+    });
+}
+
 export default {
   name: 'IntersectionMapVideos',
   components: {
@@ -246,14 +296,32 @@ export default {
     },
 
     createArrowIcon(type, x, y, color = this.C.WHITE) {
-      const group = new Konva.Group({ x, y, scaleX: 0.65, scaleY: 0.65 });
-      group.add(new Konva.Circle({ x: 0, y: -35, radius: 3, fill: color, name: 'colorFill' }));
-      let pathData = '';
-      if (type === 'S') pathData = 'M 0 -35 L 0 0 M -7 -10 L 0 0 L 7 -10';
-      else if (type === 'L') pathData = 'M 0 -35 L 0 -15 Q 0 0 15 0 M 5 -7 L 15 0 L 5 7';
-      else if (type === 'R') pathData = 'M 0 -35 L 0 -15 Q 0 0 -15 0 M -5 -7 L -15 0 L -5 7';
-      else if (type === 'U') pathData = 'M 0 -35 L 0 -15 Q 0 0 14 0 Q 28 0 28 -15 L 28 -25 M 21 -18 L 28 -25 L 35 -18';
-      group.add(new Konva.Path({ data: pathData, stroke: color, strokeWidth: 3, lineCap: 'round', lineJoin: 'round', name: 'colorStroke' }));
+      const maxH = 40; // 最大高度
+      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 => {
+          // 按原始比例等比缩放,高度不超过 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();
+        });
+      }
       return group;
     },
 
@@ -306,29 +374,63 @@ export default {
     updateDynamicSignals() {
       const signals = this.mapData.signals;
       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 dyeArm = (armNode, color) => {
-        armNode.lightGroup.getChildren().forEach(r => r.fill(color));
-        const arrowColor = (color === this.C.SIGNAL_GREEN) ? this.C.SIGNAL_RED : this.C.SIGNAL_GREEN;
-        Object.values(armNode.arrowNodes).forEach(arr => {
-          if (arr) {
-            arr.findOne('.colorFill').fill(arrowColor);
-            arr.findOne('.colorStroke').stroke(arrowColor);
-          }
+      const nsActiveTypes = signals.ns.activeArrowTypes || [];
+      const ewActiveTypes = signals.ew.activeArrowTypes || [];
+
+      const dyeArm = (dir, armNode, signalColor, activeTypes) => {
+        // 灯带颜色
+        armNode.lightGroup.getChildren().forEach(r => r.fill(signalColor));
+        // 箭头按 lane type 用不同颜色的 SVG 替换
+        const lanes = (config[dir] && config[dir].lanes) || [];
+        Object.keys(armNode.arrowNodes).forEach(index => {
+          const arr = armNode.arrowNodes[index];
+          if (!arr) return;
+          const laneType = lanes[index];
+          const isActive = activeTypes.length > 0 && activeTypes.includes(laneType);
+          const targetColor = isActive ? this.C.SIGNAL_GREEN : this.C.SIGNAL_RED;
+
+          const meta = arr._arrowMeta;
+          if (!meta) return;
+          const svgUrl = arrowSvgMap[meta.type];
+          if (!svgUrl) return;
+
+          loadSvgImage(svgUrl, targetColor).then(imgObj => {
+            const existing = arr.findOne('.arrowImg');
+            if (existing) {
+              existing.image(imgObj);
+            } else {
+              const maxH = 40;
+              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);
+              arr.add(new Konva.Image({
+                image: imgObj,
+                x: -w / 2,
+                y: -h - 5,
+                width: w,
+                height: h,
+                name: 'arrowImg'
+              }));
+            }
+            if (this.layer) this.layer.draw();
+          });
         });
       };
 
-      dyeArm(this.armsNodes.N, nsColor);
-      dyeArm(this.armsNodes.S, nsColor);
-      dyeArm(this.armsNodes.E, ewColor);
-      dyeArm(this.armsNodes.W, ewColor);
+      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);
-      
+
       this.panelNodes.ewLabel.text(`${signals.ew.phaseName}:`);
       this.panelNodes.ewVal.text(signals.ew.time.toString().padStart(2, '0')).fill(ewColor);
 

+ 2 - 0
src/components/ui/SignalTimingChart.vue

@@ -167,6 +167,7 @@ export default {
           const ratio = ((offset + elapsed) % VISUAL_PERIOD) / VISUAL_PERIOD;
           this.internalTime = ratio * realMax;
           if (this.$_chart) this.updateScanLine();
+          this.$emit('scan-tick', this.internalTime);
         };
         joinSharedTimer(this._scanListener, this.currentTime || 0);
       } else {
@@ -177,6 +178,7 @@ export default {
           this.internalTime += 1;
           if (this.internalTime > realMax) this.internalTime = 0;
           if (this.$_chart) this.updateScanLine();
+          this.$emit('scan-tick', this.internalTime);
         }, 1000);
       }
     },

+ 14 - 14
src/mock/api.js

@@ -113,9 +113,9 @@ function _makeIntersectionConfig(id, name, { fixedNsGreen } = {}) {
   const armCamTypes = _camerasToArmTypes(cameras)
 
   const lanePresets = [
-    ['U', 'L', 'S', 'R'], [null, 'L', 'S', 'R'],
-    [null, 'L', 'S', null], ['U', 'L', 'S', null],
-  ].sort(() => seededRand(seed) - 0.5)
+    ['U', 'L', 'S', 'R'], ['U', 'L', 'S', 'R'],
+    ['U', 'L', 'S', 'R'], ['U', 'L', 'S', 'R'],
+  ]
 
   return {
     signals: {
@@ -166,6 +166,8 @@ function _makePhaseData(cycleLength = 140, isTwoRows = true) {
     const currentIconPool = (i < 2) ? iconsUD : iconsLR;
 
     // 辅助函数:生成单条轨道的一个阶段
+    // 第8列 [7] 标记方向: 'ns'(南北) 或 'ew'(东西)
+    const direction = (i < 2) ? 'ns' : 'ew';
     const pushTrackData = (trackIdx, phaseNamePrefix) => {
       // 这里的 icon 现在抽出来的是诸如 "STRAIGHT_DOWN,STRAIGHT_UP" 的字符串
       const icon = getRandomIcon(currentIconPool);
@@ -173,22 +175,22 @@ function _makePhaseData(cycleLength = 140, isTwoRows = true) {
       const g = Math.floor(Math.random() * 11) + 20; // 绿灯 20-30s
       const s = 3; // 闪烁/条纹 3s
       const y = 2; // 黄灯 2s
-      
+
       let curT = stageStart;
-      
-      // 1. 绿灯 (第6个索引项传入组装好的成对 icon 字符串)
-      pd.push([trackIdx, curT, curT + g, phaseName, g, 'green', icon]); 
+
+      // 1. 绿灯 (第6个索引项传入组装好的成对 icon 字符串, 第7个索引项标记方向)
+      pd.push([trackIdx, curT, curT + g, phaseName, g, 'green', icon, direction]);
       curT += g;
       // 2. 绿闪/条纹
-      pd.push([trackIdx, curT, curT + s, '', s, 'stripe', null]); 
+      pd.push([trackIdx, curT, curT + s, '', s, 'stripe', null, direction]);
       curT += s;
       // 3. 黄灯
-      pd.push([trackIdx, curT, curT + y, '', y, 'yellow', null]); 
+      pd.push([trackIdx, curT, curT + y, '', y, 'yellow', null, direction]);
       curT += y;
       // 4. 红灯补齐 (确保阶段对齐)
       let remainRed = stageEnd - curT;
       if (remainRed > 0) {
-        pd.push([trackIdx, curT, stageEnd, '', remainRed, 'red', null]);
+        pd.push([trackIdx, curT, stageEnd, '', remainRed, 'red', null, direction]);
       }
     };
 
@@ -731,11 +733,9 @@ export async function apiGetSpecialTaskMonitorData(id, { fixedNsGreen } = {}) {
   const colorList = ['#ffaa00', '#00e5ff', '#68e75f', '#00e5ff']
   const nowSec = Math.floor(Date.now() / 1000)
 
+  const allLanes = ['U', 'L', 'S', 'R'];
   const lanePresets = [
-    { N: ['U', 'L', 'S', 'R'], S: [null, 'L', 'S', 'R'], E: [null, 'L', 'S', null], W: ['U', 'L', 'S', null] },
-    { N: ['L', 'S', 'R'], S: ['L', 'S', 'R'], E: ['L', 'S', 'R'], W: ['L', 'S', 'R'] },
-    { N: ['L', 'S', 'S', 'R'], S: ['U', 'L', 'S', 'R'], E: ['L', 'S', null], W: [null, 'S', 'R'] },
-    { N: [null, 'L', 'S', 'R'], S: ['L', 'S', 'R', null], E: ['U', 'L', 'S', 'R'], W: ['L', 'S', 'R'] },
+    { N: allLanes, S: allLanes, E: allLanes, W: allLanes },
   ]
 
   const intersections = keyPoints.map((jnc, i) => {