Quellcode durchsuchen

路口详情-当前阶段: PNG 改 SVG 动态组合, 支持箭头/编号/颜色数据驱动

  - 抽 POS_MAP 到 src/utils/phaseLayout.js, 与 SignalTimingChart 共用同一份相位语义
  - 新增 src/utils/phaseSvgIcons.js: require.context 自动收集 svg → SVG_MAP
  - 新增 PhaseDiagram 组件: CSS mask-image 染色 + 左上角编号 + bgColor/arrowColor/numberColor 全 prop 可配
  - arrowSize / arrowPad 两个独立 prop 控制箭头大小与中央间隙
  - CrossingDetailPanel 模板内 <img> 替换为 <PhaseDiagram>, 缺 icons 时 fallback 旧 img
  - applyData / onTempSchemeSave / onSchemeSave 三处共用 _withIcons() 派生 icons, 保证编辑回写后箭头不丢
画安 vor 1 Monat
Ursprung
Commit
dba6d7dc80

+ 25 - 5
src/components/ui/CrossingDetailPanel.vue

@@ -65,7 +65,16 @@
                                             class="stage-item-wrapper">
                                             <div class="phase-box" :class="{ 'is-active': item.value === currentStage }"
                                                 @click="onStageClick(item.value)">
-                                                <img :src="item.img" alt="stage" class="phase-image" />
+                                                <PhaseDiagram
+                                                    v-if="item.icons && item.icons.length"
+                                                    :icons="item.icons"
+                                                    :no="item.no || item.value"
+                                                    :arrow-color="item.arrowColor"
+                                                    :arrow-colors="item.arrowColors || {}"
+                                                    :bg-color="item.bgColor"
+                                                    :number-color="item.numberColor"
+                                                />
+                                                <img v-else :src="item.img" alt="stage" class="phase-image" />
                                             </div>
 
                                             <div class="bottom-controls">
@@ -186,6 +195,7 @@ import SignalTimingChart from '@/components/ui/SignalTimingChart.vue';
 import IntersectionMapVideos from '@/components/ui/IntersectionMapVideos.vue';
 import DropdownSelect from '@/components/ui/DropdownSelect.vue';
 import PlanDonutChart from '@/components/ui/PlanDonutChart.vue';
+import PhaseDiagram from '@/components/ui/PhaseDiagram.vue';
 
 import { apiGetCrossingDetailData, apiSaveCrossingTempScheme, apiSaveCrossingScheme } from '@/api';
 
@@ -195,7 +205,8 @@ export default {
         SignalTimingChart,
         IntersectionMapVideos,
         DropdownSelect,
-        PlanDonutChart
+        PlanDonutChart,
+        PhaseDiagram,
     },
     props: {
         preloadedData: { type: Object, default: null },
@@ -468,6 +479,15 @@ export default {
                 this.applyData(data);
             }
         },
+        // 给每个 stage 派生 icons (PhaseDiagram 渲染用):
+        // 优先用后端直接给的 icons[], 否则从 direction 字符串拆 token。
+        // 缺失时 PhaseDiagram 不渲染, 模板内 fallback 到旧的 <img :src="item.img">。
+        _withIcons(stages) {
+            return (stages || []).map(item => ({
+                ...item,
+                icons: item.icons || (item.direction || '').split(',').filter(Boolean),
+            }));
+        },
         applyData(data) {
             this.currentRoute = data.currentRoute || {};
             this.intersectionData = data.intersectionData || {};
@@ -476,7 +496,7 @@ export default {
             this.currentSec = data.currentTime || 0;
             this.phaseDiff = data.phaseDiff || 0;
             this.coordTime = data.coordTime || 0;
-            this.currentStageList = data.stageList || [];
+            this.currentStageList = this._withIcons(data.stageList);
             // 双相位图字段:后端没返回时为 null,UI 自动退化为单图模式
             this.thisCycle = data.thisCycle || null;
             this.lastCycle = data.lastCycle || null;
@@ -595,7 +615,7 @@ export default {
                 console.warn('[CrossingDetailPanel] saveTempScheme failed:', e);
             }
             // 本地写回(不阻塞 UI)
-            if (Array.isArray(payload.stages)) this.currentStageList = payload.stages;
+            if (Array.isArray(payload.stages)) this.currentStageList = this._withIcons(payload.stages);
             const tr = payload.timeRange || {};
             if (tr.startDate !== undefined) this.startDate = tr.startDate;
             if (tr.startTime !== undefined) this.startTime = tr.startTime;
@@ -614,7 +634,7 @@ export default {
             } catch (e) {
                 console.warn('[CrossingDetailPanel] saveScheme failed:', e);
             }
-            if (Array.isArray(payload.stages)) this.currentStageList = payload.stages;
+            if (Array.isArray(payload.stages)) this.currentStageList = this._withIcons(payload.stages);
             this.buildDonutFromPhaseData();
             this.$emit('confirm', { method: this.currentMethod, scheme: this.currentScheme, stages: this.currentStageList });
         },

+ 95 - 0
src/components/ui/PhaseDiagram.vue

@@ -0,0 +1,95 @@
+<template>
+    <div class="phase-diagram" :style="rootStyle">
+        <span
+            v-if="displayNo !== ''"
+            class="phase-no"
+            :style="{ color: numberColor }"
+        >{{ displayNo }}</span>
+        <span
+            v-for="token in validIcons"
+            :key="token"
+            class="phase-arrow"
+            :style="arrowStyle(token)"
+        />
+    </div>
+</template>
+
+<script>
+import { SVG_MAP } from '@/utils/phaseSvgIcons';
+import { positionStyleOf } from '@/utils/phaseLayout';
+
+// 根据数据动态渲染单个"相位方块":
+//   - 一组方向箭头 (icons), 通过 CSS mask-image 染色, 颜色 / 位置可数据驱动
+//   - 左上角编号
+//   - 可选背景色 (默认透明, 让外层 .phase-box 的底色透过来)
+export default {
+    name: 'PhaseDiagram',
+    props: {
+        icons:       { type: Array,            default: () => [] },
+        no:          { type: [String, Number], default: '' },
+        arrowColor:  { type: String,           default: '#2D2D2D' },
+        arrowColors: { type: Object,           default: () => ({}) },
+        bgColor:     { type: String,           default: 'transparent' },
+        numberColor: { type: String,           default: '#1f2937' },
+        // 箭头本身大小: 1=原图, <1 变小, >1 变大. 不影响位置.
+        arrowSize:   { type: Number,           default: 0.55 },
+        // 离角偏移缩放: 1=原图, <1 更靠四角(中央留白更大), >1 更靠中心.
+        arrowPad:    { type: Number,           default: 0.75 },
+    },
+    computed: {
+        // 丢弃没有对应 svg 的 token, 避免空 mask 渲染
+        validIcons() {
+            return this.icons.filter(t => SVG_MAP[t]);
+        },
+        displayNo() {
+            return this.no === null || this.no === undefined ? '' : String(this.no);
+        },
+        rootStyle() {
+            return { background: this.bgColor };
+        },
+    },
+    methods: {
+        arrowStyle(token) {
+            const color = this.arrowColors[token] || this.arrowColor;
+            return {
+                ...positionStyleOf(token, this.arrowSize, this.arrowPad),
+                '--svg': `url("${SVG_MAP[token]}")`,
+                '--arrow-color': color,
+            };
+        },
+    },
+};
+</script>
+
+<style scoped>
+.phase-diagram {
+    position: relative;
+    width: 100%;
+    height: 100%;
+    overflow: hidden;
+}
+
+.phase-no {
+    position: absolute;
+    top: 4%;
+    left: 6%;
+    /* 跟随 CrossingDetailPanel 设置的 --s 缩放变量, 与本面板字号统一 */
+    font-size: clamp(9px, calc(var(--s, 1) * 13px), 14px);
+    font-weight: bold;
+    line-height: 1;
+    z-index: 2;
+    pointer-events: none;
+    user-select: none;
+}
+
+.phase-arrow {
+    /* svg 作形状蒙版, 用 background-color 出颜色, 实现 CSS 变量驱动染色
+       mask-size 用 100% 100% (拉伸填满 box), 与 SignalTimingChart canvas 里
+       drawImage(width=drawW,height=drawH) 的拉伸行为一致, 避免 'contain' 留白
+       导致箭头看上去离 box 中心远、彼此间隙过大 */
+    -webkit-mask: var(--svg) center / 100% 100% no-repeat;
+            mask: var(--svg) center / 100% 100% no-repeat;
+    background-color: var(--arrow-color, #2D2D2D);
+    pointer-events: none;
+}
+</style>

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

@@ -5,6 +5,7 @@
 <script>
 import * as echarts from 'echarts';
 import echartsResize from '@/mixins/echartsResize.js';
+import { POS_MAP } from '@/utils/phaseLayout';
 
 // 全局心跳定时器:所有 autoScan 实例共享同一个 setInterval
 // 各实例基于 Date.now() 对自身 cycleLength 取模计算位置,天然同步
@@ -62,31 +63,7 @@ const IMAGE_MAP = {
   'TURN_RIGHT_UP_UTURN': require('@/assets/images/icon_turn_right_up_uturn.png' )
 };
 
-// ==========================================
-// 核心逻辑:基于真实物理空间的对齐与自定义偏移/尺寸配置
-// pos: 位置(LT/RT/LB/RB), padX/padY: 基础像素偏移, baseW/baseH: 基础原始宽高
-// ==========================================
-const POS_MAP = {
-  // 1. 上方驶入 -> 靠左上角 (LT)
-  'STRAIGHT_DOWN':         { pos: 'LT', padX: 10, padY: 0, baseW: 7, baseH: 20.67 },       
-  'TURN_DOWN_LEFT':        { pos: 'LT', padX: 10, padY: 0, baseW: 13, baseH: 21.33 },      
-  'TURN_DOWN_LEFT_UTURN':  { pos: 'LT', padX: 10, padY: 0, baseW: 13, baseH: 22.67 },
-  
-  // 2. 下方驶入 -> 靠右下角 (RB)
-  'STRAIGHT_UP':           { pos: 'RB', padX: 10, padY: 0, baseW: 7, baseH: 20.67 },         
-  'TURN_UP_LEFT':          { pos: 'RB', padX: 10, padY: 0, baseW: 13, baseH: 21.33 },        
-  'TURN_UP_LEFT_UTURN':    { pos: 'RB', padX: 10, padY: 0, baseW: 13, baseH: 22.67 },
-  
-  // 3. 右侧驶入 -> 靠右上角 (RT)
-  'STRAIGHT_LEFT':         { pos: 'RT', padX: 0, padY: 10, baseW: 20.33, baseH: 6.33 },       
-  'TURN_LEFT_DOWN':        { pos: 'RT', padX: 0, padY: 10, baseW: 20.67, baseH: 12.33 },       
-  'TURN_LEFT_DOWN_UTURN':  { pos: 'RT', padX: 0, padY: 10, baseW: 22.67, baseH: 12.33 },
-  
-  // 4. 左侧驶入 -> 靠左下角 (LB)
-  'STRAIGHT_RIGHT':        { pos: 'LB', padX: 0, padY: 10, baseW: 20.33, baseH: 6.33 },
-  'TURN_RIGHT_UP':         { pos: 'LB', padX: 0, padY: 10, baseW: 20.67, baseH: 12.33 },
-  'TURN_RIGHT_UP_UTURN':   { pos: 'LB', padX: 0, padY: 10, baseW: 22.67, baseH: 12.33 },
-};
+// POS_MAP 抽到了 @/utils/phaseLayout 与 PhaseDiagram 共用
 
 export default {
   name: 'SignalTimingChart',

+ 50 - 0
src/utils/phaseLayout.js

@@ -0,0 +1,50 @@
+// 相位方向箭头在"路口方块"内的几何布局
+// 共享于 SignalTimingChart (canvas 内绘图) 与 PhaseDiagram (CSS 布局)
+//
+// 坐标系: 30 × 30 单位的路口方块, 四角 + padX/padY + baseW/baseH
+//   pos: LT / RT / LB / RB  -- 哪个角落锚定
+//   padX, padY: 离该角落的偏移 (与 pos 共同决定 anchor 边)
+//   baseW, baseH: 箭头本身占的尺寸
+
+export const POS_MAP = {
+  // 1. 上方驶入 -> 靠左上角 (LT)
+  'STRAIGHT_DOWN':         { pos: 'LT', padX: 10, padY: 0, baseW: 7,  baseH: 20.67 },
+  'TURN_DOWN_LEFT':        { pos: 'LT', padX: 10, padY: 0, baseW: 13, baseH: 21.33 },
+  'TURN_DOWN_LEFT_UTURN':  { pos: 'LT', padX: 10, padY: 0, baseW: 13, baseH: 22.67 },
+
+  // 2. 下方驶入 -> 靠右下角 (RB)
+  'STRAIGHT_UP':           { pos: 'RB', padX: 10, padY: 0, baseW: 7,  baseH: 20.67 },
+  'TURN_UP_LEFT':          { pos: 'RB', padX: 10, padY: 0, baseW: 13, baseH: 21.33 },
+  'TURN_UP_LEFT_UTURN':    { pos: 'RB', padX: 10, padY: 0, baseW: 13, baseH: 22.67 },
+
+  // 3. 右侧驶入 -> 靠右上角 (RT)
+  'STRAIGHT_LEFT':         { pos: 'RT', padX: 0, padY: 10, baseW: 20.33, baseH: 6.33 },
+  'TURN_LEFT_DOWN':        { pos: 'RT', padX: 0, padY: 10, baseW: 20.67, baseH: 12.33 },
+  'TURN_LEFT_DOWN_UTURN':  { pos: 'RT', padX: 0, padY: 10, baseW: 22.67, baseH: 12.33 },
+
+  // 4. 左侧驶入 -> 靠左下角 (LB)
+  'STRAIGHT_RIGHT':        { pos: 'LB', padX: 0, padY: 10, baseW: 20.33, baseH: 6.33 },
+  'TURN_RIGHT_UP':         { pos: 'LB', padX: 0, padY: 10, baseW: 20.67, baseH: 12.33 },
+  'TURN_RIGHT_UP_UTURN':   { pos: 'LB', padX: 0, padY: 10, baseW: 22.67, baseH: 12.33 },
+};
+
+// 把 token 对应的 POS_MAP 项换算成 CSS 绝对定位 style (百分比, 自适应任意尺寸的方框)
+//
+// 两个独立缩放参数:
+//   sizeScale - 作用于 baseW/baseH, 控制 "箭头本身大小" (1=原图, <1 变小, >1 变大)
+//   padScale  - 作用于 padX/padY,   控制 "离角偏移" (1=原图, <1 更靠角=中央留白大, >1 更靠中心)
+// 拆成两个是因为常见需求是"大小别动, 多腾点中央空间"。
+export function positionStyleOf(token, sizeScale = 1, padScale = 1) {
+  const m = POS_MAP[token];
+  if (!m) return {};
+  const sideX = m.pos === 'LT' || m.pos === 'LB' ? 'left' : 'right';
+  const sideY = m.pos === 'LT' || m.pos === 'RT' ? 'top'  : 'bottom';
+  const pct = (n) => `${(n / 30) * 100}%`;
+  return {
+    position: 'absolute',
+    width:  pct(m.baseW * sizeScale),
+    height: pct(m.baseH * sizeScale),
+    [sideX]: pct(m.padX * padScale),
+    [sideY]: pct(m.padY * padScale),
+  };
+}

+ 15 - 0
src/utils/phaseSvgIcons.js

@@ -0,0 +1,15 @@
+// 自动收集 src/assets/images/svg/icon_*.svg, 导出 token -> url 映射。
+//
+// token 取自文件名: icon_straight_up.svg -> 'STRAIGHT_UP'
+//                  icon_turn_up_left_uturn.svg -> 'TURN_UP_LEFT_UTURN'
+//
+// 用 require.context 后续往 svg/ 里加新文件零代码自动可用。
+// 与 SignalTimingChart 的 IMAGE_MAP (PNG 版) 命名口径完全一致。
+
+const ctx = require.context('@/assets/images/svg', false, /^\.\/icon_.*\.svg$/);
+
+export const SVG_MAP = ctx.keys().reduce((acc, key) => {
+  const m = key.match(/icon_(.+)\.svg$/);
+  if (m) acc[m[1].toUpperCase()] = ctx(key);
+  return acc;
+}, {});