Просмотр исходного кода

修改SignalTimingChart相位图生成组件;修改相位图的模拟数据生成规则;修改CrossingListPanel中相位图引用;

画安 месяцев назад: 2
Родитель
Сommit
5370d516eb

BIN
src/assets/images/icon_straight_down.png


BIN
src/assets/images/icon_straight_left.png


BIN
src/assets/images/icon_straight_right.png


BIN
src/assets/images/icon_straight_up.png


BIN
src/assets/images/icon_turn_down_left.png


BIN
src/assets/images/icon_turn_down_left_uturn.png


BIN
src/assets/images/icon_turn_left_down.png


BIN
src/assets/images/icon_turn_left_down_uturn.png


BIN
src/assets/images/icon_turn_right_up.png


BIN
src/assets/images/icon_turn_right_up_uturn.png


BIN
src/assets/images/icon_turn_up_left.png


BIN
src/assets/images/icon_turn_up_left_uturn.png


+ 1 - 2
src/components/ui/CrossingListPanel.vue

@@ -15,8 +15,7 @@
             <TechTable :columns="tableColumns" :data="tableList" height="100%">
                 <template #phaseStatus="{ row }">
                     <div class="mini-chart-wrapper">
-                        <SignalTimingChart :phaseData="row.phaseData" :cycleLength="row.cycle" :currentTime="0"
-                            :isMiniMode="true" />
+                        <SignalTimingChart :phaseData="row.phaseData" :cycleLength="row.cycle" :currentTime="0" :showAxis="false" :showScanLine="false" />
                     </div>
                 </template>
 

+ 190 - 136
src/components/ui/SignalTimingChart.vue

@@ -1,18 +1,17 @@
 <template>
-    <div ref="chartRef" class="chart-container"></div>
+  <div ref="chartRef" class="chart-container"></div>
 </template>
 
 <script>
 import * as echarts from 'echarts';
-// 引入你的全局自适应 Mixin
 import echartsResize from '@/mixins/echartsResize.js';
 
-// 静态资源与颜色常量保持不变
 const COLORS = {
   GREEN_LIGHT: '#8dc453', GREEN_DARK: '#73a542', YELLOW: '#fbd249', RED: '#ff7575', STRIPE_GREEN: '#a3d76e',
   TEXT_DARK: '#1e2638', AXIS_LINE: '#758599', DIVIDER_LINE: '#111827', MARK_BLUE: '#4da8ff', TEXT_LIGHT: '#d1d5db'
 };
 
+// 绘制条纹图案用于绿闪/预警
 const stripeCanvas = document.createElement('canvas');
 stripeCanvas.width = 6; stripeCanvas.height = 20;
 const ctx = stripeCanvas.getContext('2d');
@@ -20,148 +19,144 @@ ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, 6, 20);
 ctx.fillStyle = COLORS.STRIPE_GREEN; ctx.fillRect(0, 0, 3, 20); 
 const stripePattern = { image: stripeCanvas, repeat: 'repeat' };
 
-const ICON_PATHS = {
-  UP: 'M10 2 H14 V14 H20 L12 22 L4 14 H10 Z',
-  DOWN: 'M10 22 H14 V10 H20 L12 2 L4 10 H10 Z',
-  TURN_LEFT: 'M 21 22 H 15 V 14 C 15 11 13 9 10 9 H 8 V 14 L 0 7 L 8 0 V 5 H 10 C 15 5 21 9 21 14 V 22 Z',
-  TURN_RIGHT: 'M 3 22 H 9 V 14 C 9 11 11 9 14 9 H 16 V 14 L 24 7 L 16 0 V 5 H 14 C 9 5 3 9 3 14 V 22 Z',
-  UTURN: 'M 18 22 V 10 C 18 5 15 2 12 2 C 9 2 6 5 6 10 V 14 H 0 L 8 22 L 16 14 H 10 V 10 C 10 7 11 6 12 6 C 13 6 14 7 14 10 V 22 H 18 Z'
+const IMAGE_MAP = {
+  'STRAIGHT_DOWN': require('@/assets/images/icon_straight_down.png'),
+  'TURN_DOWN_LEFT': require('@/assets/images/icon_turn_down_left.png'),
+  'TURN_DOWN_LEFT_UTURN': require('@/assets/images/icon_turn_down_left_uturn.png'),
+
+  'STRAIGHT_UP': require('@/assets/images/icon_straight_up.png'),
+  'TURN_UP_LEFT': require('@/assets/images/icon_turn_up_left.png'),
+  'TURN_UP_LEFT_UTURN': require('@/assets/images/icon_turn_up_left_uturn.png'),
+
+  'STRAIGHT_LEFT': require('@/assets/images/icon_straight_left.png'),
+  'TURN_LEFT_DOWN': require('@/assets/images/icon_turn_left_down.png'),
+  'TURN_LEFT_DOWN_UTURN': require('@/assets/images/icon_turn_left_down_uturn.png'), 
+
+  'STRAIGHT_RIGHT': require('@/assets/images/icon_straight_right.png'),
+  'TURN_RIGHT_UP': require('@/assets/images/icon_turn_right_up.png'),
+  '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 },
 };
 
 export default {
   name: 'SignalTimingChart',
-  mixins: [echartsResize], // 注册自适应 mixin
+  mixins: [echartsResize],
   props: {
-    cycleLength: { type: Number, default: 0 },
+    cycleLength: { type: Number, default: 140 }, 
     currentTime: { type: Number, default: 0 },
     phaseData: { type: Array, default: () => [] },
-    isMiniMode: { type: Boolean, default: false } // 是否为表格内极简模式
+    showAxis: { type: Boolean, default: true },
+    showScanLine: { type: Boolean, default: true }
   },
   data() {
-    return {
-      scaleFactor: 1
-    };
+    return { scaleFactor: 1 };
   },
   mounted() {
     this.initChart();
   },
   watch: {
-    currentTime() {
-      // 使用 mixin 中的 $_chart 实例
-      if (this.$_chart) {
-        this.updateChart();
-      }
-    },
-    phaseData: {
-      deep: true,
-      handler(newVal) {
-        if (this.$_chart && newVal.length > 0) {
-          this.updateChart();
-        }
-      }
-    }
+    currentTime() { if (this.$_chart) this.updateChart(); },
+    phaseData: { deep: true, handler(newVal) { if (this.$_chart && newVal.length > 0) this.updateChart(); } },
+    showAxis() { this.updateChart(); },
+    showScanLine() { this.updateChart(); }
   },
   methods: {
-    // 动态计算缩放比例
     updateScale() {
       const el = this.$el;
       if (!el) return;
-      const baseWidth = 600; 
-      // 限制最小缩放为 0.5,防止极端小弹窗下彻底糊掉
-      this.scaleFactor = Math.max(0.5, el.clientWidth / baseWidth);
+      this.scaleFactor = Math.max(0.5, el.clientWidth / 600);
     },
-    
     initChart() {
       const chartDom = this.$refs.chartRef;
       if (!chartDom) return;
-      
       this.updateScale();
-      // 初始化 mixin 中的 $_chart
       this.$_chart = echarts.init(chartDom);
-      
-      if (this.phaseData.length > 0) {
-        this.updateChart();
-      }
+      if (this.phaseData.length > 0) this.updateChart();
     },
-    
-    // 该方法会自动被 echartsResize mixin 触发
     updateChart() {
       if (!this.$_chart) return;
-      
-      // 重绘前更新当前最新的尺寸比例
       this.updateScale();
       this.$_chart.setOption(this.getChartOption(), true);
     },
-    
+    getMaxTime() {
+      if (!this.phaseData || this.phaseData.length === 0) return this.cycleLength;
+      const maxDataTime = Math.max(...this.phaseData.map(item => item[2]));
+      return Math.max(this.cycleLength, maxDataTime);
+    },
     getChartOption() {
       const s = this.scaleFactor;
+      const isTwoRows = this.phaseData.some(item => item[0] === 1);
+      const yAxisData = isTwoRows ? ['Track 0', 'Track 1'] : ['Track 0'];
+      const realMaxTime = this.getMaxTime();
 
       return {
         backgroundColor: 'transparent',
-        // 因为去掉了头部,稍微减小了 top 的留白,让图表更紧凑
         grid: { 
-          left: 0, 
-          right: 0, 
-          top: this.isMiniMode ? 0 : Math.round(30 * s), 
-          bottom: this.isMiniMode ? 0 : Math.round(10 * s),
+          left: 0, right: 0, 
+          top: (this.showAxis || this.showScanLine) ? Math.round(35 * s) : Math.round(10 * s), 
+          bottom: Math.round(10 * s),
           containLabel: false 
         },
-        xAxis: { type: 'value', min: 0, max: this.cycleLength, show: false, boundaryGap: false },
-        yAxis: { type: 'category', data: ['Track 0', 'Track 1'], inverse: true, show: false },
-        series: [
-          {
-            type: 'custom',
-            // 箭头函数确保 this 指向 Vue 实例,以拿到 scaleFactor
-            renderItem: (params, api) => this.renderCustomItem(params, api),
-            encode: { x: [1, 2], y: 0 },
-            data: this.phaseData,
-            markLine: this.isMiniMode ? false : {
-              symbol: ['none', 'none'],
-              silent: true,
-              label: {
-                show: true,
-                position: 'start',
-                formatter: `${this.currentTime}/${this.cycleLength}`,
-                color: '#fff',
-                backgroundColor: COLORS.MARK_BLUE,
-                padding: [Math.round(4 * s), Math.round(8 * s)],
-                borderRadius: 2,
-                fontSize: Math.max(10, Math.round(10 * s)),
-                fontWeight: 'bold',
-                offset: [0, -2]
-              },
-              lineStyle: { color: COLORS.MARK_BLUE, type: 'solid', width: Math.max(1, Math.round(2 * s)) },
-              data: [ { xAxis: this.currentTime } ]
-            }
+        xAxis: { type: 'value', min: 0, max: realMaxTime, show: false },
+        yAxis: { type: 'category', data: yAxisData, inverse: true, show: false },
+        series: [{
+          type: 'custom',
+          renderItem: (params, api) => this.renderCustomItem(params, api, isTwoRows, realMaxTime),
+          encode: { x: [1, 2], y: 0 },
+          data: this.phaseData,
+          markLine: !this.showScanLine ? false : {
+            symbol: ['none', 'none'],
+            silent: true,
+            label: {
+              show: true, position: 'start', formatter: `${this.currentTime}/${realMaxTime}`, 
+              color: '#fff', backgroundColor: COLORS.MARK_BLUE, padding: [Math.round(4 * s), Math.round(8 * s)],
+              borderRadius: 2, fontSize: Math.max(10, Math.round(10 * s)), fontWeight: 'bold', offset: [0, Math.round(-15 * s)] 
+            },
+            lineStyle: { color: COLORS.MARK_BLUE, type: 'solid', width: Math.max(1, Math.round(2 * s)), z: 100 },
+            data: [ { xAxis: this.currentTime } ]
           }
-        ]
+        }]
       };
     },
 
-    // 核心绘图逻辑保持不变,确保全链路使用 this.scaleFactor
-    renderCustomItem(params, api) {
+    renderCustomItem(params, api, isTwoRows, realMaxTime) {
       const s = this.scaleFactor;
       const trackIndex = api.value(0);
       const start = api.coord([api.value(1), trackIndex]);
       const end = api.coord([api.value(2), trackIndex]);
-
-      // 默认的色块高度和 Y 轴起始位置
-      let blockHeight = api.size([0, 1])[1];
-      let yPos = start[1] - blockHeight / 2;
-
-      // 如果是表格里的极简模式,无视轨道高度,强行占满整个可用区域!
-      if (this.isMiniMode) {
-          blockHeight = params.coordSys.height; // 色块高度 = 网格总高度
-          yPos = params.coordSys.y;             // 起始Y点 = 网格最顶部
-      }
-
+      const blockHeight = api.size([0, 1])[1];
+      const yPos = start[1] - blockHeight / 2;
       const blockWidth = end[0] - start[0];
-      const dividerY = (api.coord([0, 1])[1] + api.coord([0, 0])[1]) / 2;
 
       const phaseName = api.value(3);
       const duration = api.value(4);
       const type = api.value(5);
-      const iconKey = api.value(6);
+      const iconValue = api.value(6);
 
       let fillStyle = COLORS.GREEN_LIGHT;
       if (type === 'stripe') fillStyle = stripePattern;
@@ -176,56 +171,120 @@ export default {
       if (!rectShape) return;
       const children = [];
 
-      // A. 绘制刻度
-      if (params.dataIndex === 0 && !this.isMiniMode) {
-        const axisBaseY = params.coordSys.y - Math.round(20 * s);
-        [0, 35, 70, 105, 140].forEach(val => {
+      // A. 绘制阶段刻度 (S1, S2...)
+      if (params.dataIndex === 0 && this.showAxis) {
+        const axisBaseY = params.coordSys.y - Math.round(15 * s);
+        const track0Data = this.phaseData.filter(item => item[0] === 0);
+        let stagePoints = track0Data.filter(item => item[5] === 'green').map(item => item[1]);
+        if (!stagePoints.includes(0)) stagePoints.unshift(0);
+        stagePoints.push(realMaxTime); 
+        stagePoints = Array.from(new Set(stagePoints)).sort((a, b) => a - b);
+
+        stagePoints.forEach(val => {
           const x = api.coord([val, 0])[0];
           children.push({ type: 'line', shape: { x1: x, y1: axisBaseY - Math.round(5 * s), x2: x, y2: axisBaseY + Math.round(5 * s) }, style: { stroke: COLORS.AXIS_LINE, lineWidth: Math.max(1, Math.round(1.5 * s)) } });
         });
-        const stages = [ {n:'S1', s:0, e:35}, {n:'S2', s:35, e:70}, {n:'S3', s:70, e:105}, {n:'S4', s:105, e:140} ];
-        stages.forEach(st => {
-          const x1 = api.coord([st.s, 0])[0], x2 = api.coord([st.e, 0])[0], midX = (x1 + x2) / 2;
+        for (let i = 0; i < stagePoints.length - 1; i++) {
+          const startX = api.coord([stagePoints[i], 0])[0];
+          const endX = api.coord([stagePoints[i + 1], 0])[0];
+          const midX = (startX + endX) / 2;
           const textHalf = Math.round(14 * s);
-          children.push({ type: 'line', shape: { x1: x1, y1: axisBaseY, x2: midX - textHalf, y2: axisBaseY }, style: { stroke: COLORS.AXIS_LINE, lineWidth: Math.max(1, Math.round(1.5 * s)) } });
-          children.push({ type: 'line', shape: { x1: midX + textHalf, y1: axisBaseY, x2: x2, y2: axisBaseY }, style: { stroke: COLORS.AXIS_LINE, lineWidth: Math.max(1, Math.round(1.5 * s)) } });
-          children.push({ type: 'text', style: { text: st.n, x: midX, y: axisBaseY, fill: COLORS.TEXT_LIGHT, fontSize: Math.max(10, Math.round(14 * s)), align: 'center', verticalAlign: 'middle', fontWeight: 'bold' } });
-        });
+          children.push({ type: 'line', shape: { x1: startX, y1: axisBaseY, x2: midX - textHalf, y2: axisBaseY }, style: { stroke: COLORS.AXIS_LINE, lineWidth: Math.max(1, Math.round(1.5 * s)) } });
+          children.push({ type: 'line', shape: { x1: midX + textHalf, y1: axisBaseY, x2: endX, y2: axisBaseY }, style: { stroke: COLORS.AXIS_LINE, lineWidth: Math.max(1, Math.round(1.5 * s)) } });
+          children.push({ type: 'text', style: { text: `S${i + 1}`, x: midX, y: axisBaseY, fill: COLORS.TEXT_LIGHT, fontSize: Math.max(10, Math.round(14 * s)), align: 'center', verticalAlign: 'middle', fontWeight: 'bold' } });
+        }
       }
 
-      // B. 绘制色块底色
+      // B. 画色块背景
       children.push({ type: 'rect', shape: rectShape, style: { fill: fillStyle, stroke: 'none' } });
 
-      // C. 绘制内部元素
-      const fs = Math.max(0.8, s * 0.9); // 文字/图标缩放
-      if (type === 'green' && blockWidth > 20) {
-        const darkWidth = Math.round(25 * fs);
+      // C. 绘制内部图标与文本
+      const fs = Math.max(0.8, s * 0.9); 
+      if (type === 'green' && blockWidth > 15) {
+        const darkWidth = Math.round(50 * fs); 
         const midY = yPos + blockHeight / 2;
+        const valStr = String(iconValue || '').toUpperCase();
+        
+        // --- 动态尺寸与偏移计算逻辑 ---
+        const posConfig = POS_MAP[valStr] || { pos: 'RB', padX: 0, padY: 0, baseW: 20, baseH: 20 };
+        const pos = typeof posConfig === 'string' ? posConfig : (posConfig.pos || 'RB');
+        
+        // 提取配置的基础宽高,默认给 20,并乘以缩放系数
+        const drawW = Math.round((posConfig.baseW || 20) * fs);
+        const drawH = Math.round((posConfig.baseH || 20) * fs);
+        
+        const padX = Math.round((posConfig.padX || 0) * fs); 
+        const padY = Math.round((posConfig.padY || 0) * fs);
 
-        children.push({ type: 'rect', shape: { x: start[0], y: yPos, width: darkWidth, height: blockHeight }, style: { fill: COLORS.GREEN_DARK } });
-        const arrowH = Math.round(4 * fs);
-        if (!this.isMiniMode) {
-          children.push({ type: 'polygon', shape: { points: [ [start[0] + darkWidth, midY - arrowH], [start[0] + darkWidth, midY + arrowH], [start[0] + darkWidth + arrowH, midY] ] }, style: { fill: COLORS.GREEN_DARK } });
-          if (iconKey && ICON_PATHS[iconKey]) {
-            const iconSize = Math.round(14 * fs);
-            const iconX = start[0] + (darkWidth - iconSize) / 2;
-            const iconY = midY - iconSize / 2;
-            
-            children.push({
-              type: 'path',
-              shape: { pathData: ICON_PATHS[iconKey], x: iconX, y: iconY, width: iconSize, height: iconSize, layout: 'center' },
-              style: { fill: COLORS.TEXT_DARK, stroke: 'none' }
-            });
-          }
-          
-          children.push({ type: 'text', style: { text: `${phaseName}\n${duration}`, x: start[0] + darkWidth + Math.round(4 * fs), y: midY, fill: COLORS.TEXT_DARK, fontSize: Math.max(10, Math.round(12 * fs)), fontFamily: 'Arial', fontWeight: 'bold', align: 'left', verticalAlign: 'middle' } });
+        let iconX, iconY;
+        if (pos === 'LT') {
+          iconX = start[0] + padX;
+          iconY = yPos + padY;
+        } else if (pos === 'RT') {
+          iconX = start[0] + darkWidth - drawW - padX;
+          iconY = yPos + padY;
+        } else if (pos === 'LB') {
+          iconX = start[0] + padX;
+          iconY = yPos + blockHeight - drawH - padY;
+        } else { // RB
+          iconX = start[0] + darkWidth - drawW - padX;
+          iconY = yPos + blockHeight - drawH - padY;
+        }
+
+        const innerGroup = {
+          type: 'group',
+          clipPath: { type: 'rect', shape: { x: start[0], y: yPos, width: blockWidth, height: blockHeight } },
+          children: [
+            // 深色背景区域
+            { type: 'rect', shape: { x: start[0], y: yPos, width: darkWidth, height: blockHeight }, style: { fill: COLORS.GREEN_DARK } },
+            // 小三角形装饰
+            { 
+              type: 'polygon', 
+              shape: { points: [ [start[0] + darkWidth, midY - 4 * fs], [start[0] + darkWidth, midY + 4 * fs], [start[0] + darkWidth + 4 * fs, midY] ] }, 
+              style: { fill: COLORS.GREEN_DARK } 
+            }
+          ]
+        };
+
+        // 绘制图标
+        if (iconValue && IMAGE_MAP[iconValue]) {
+          innerGroup.children.push({
+            type: 'image',
+            style: { 
+              image: IMAGE_MAP[iconValue], 
+              x: iconX, 
+              y: iconY, 
+              width: drawW, 
+              height: drawH,
+              // 使用 'contain' 模式,图片会在保持原比例的同时,缩放以适应指定的 width/height 区域
+              objectFit: 'contain' 
+            }
+          });
         }
+        
+        // 渲染文本 (相位号与时长)
+        innerGroup.children.push({
+          type: 'text',
+          style: {
+            text: `${phaseName}\n${duration}`,
+            x: start[0] + darkWidth + Math.round(6 * fs),
+            y: midY,
+            fill: COLORS.TEXT_DARK,
+            fontSize: Math.max(10, Math.round(12 * fs)),
+            fontWeight: 'bold',
+            align: 'left',
+            verticalAlign: 'middle'
+          }
+        });
+
+        children.push(innerGroup);
       }
         
-        // D. 分割线
-        if (trackIndex === 1 && !this.isMiniMode) {
-          children.push({ type: 'line', shape: { x1: start[0], y1: dividerY, x2: end[0], y2: dividerY }, style: { stroke: COLORS.DIVIDER_LINE, lineWidth: Math.max(1, Math.round(1.5 * s)) } });
-        }
+      // D. 轨道分割线 (仅在两排模式下 Track 1 顶部绘制)
+      if (isTwoRows && trackIndex === 1) {
+        const dividerY = (api.coord([0, 1])[1] + api.coord([0, 0])[1]) / 2;
+        children.push({ type: 'line', shape: { x1: start[0], y1: dividerY, x2: end[0], y2: dividerY }, style: { stroke: COLORS.DIVIDER_LINE, lineWidth: Math.max(1, Math.round(1.5 * s)) } });
+      }
 
       return { type: 'group', children: children };
     }
@@ -234,12 +293,7 @@ export default {
 </script>
 
 <style scoped>
-
 .chart-container { 
-  width: 100%; 
-  height: 100%;
-  flex: 1; 
-  min-height: 0; 
-  overflow: hidden;
+  width: 100%; height: 100%; flex: 1; min-height: 0; overflow: hidden;
 }
 </style>

+ 70 - 19
src/mock/api.js

@@ -129,21 +129,70 @@ function _makeIntersectionConfig(id, name) {
   }
 }
 
-function _makePhaseData(cycleLength) {
-  const n = 4, tp = Math.floor(cycleLength / n)
-  const dirs = ['UP', 'TURN_LEFT', 'DOWN', 'TURN_RIGHT']
-  const pd = []
-  for (let track = 1; track >= 0; track--) {
-    let t = 0
-    for (let i = 0; i < n; i++) {
-      const g = tp - 8
-      pd.push([track, t, t + g, `P${track * n + i + 1}`, g, 'green', dirs[i]]); t += g
-      pd.push([track, t, t + 3, '', null, 'stripe', null]); t += 3
-      pd.push([track, t, t + 2, '', null, 'yellow', null]); t += 2
-      pd.push([track, t, t + 3, '', null, 'red', null]); t += 3
+/**
+ * 动态生成路口相位配时数据
+ * @param {number} cycleLength 周期总时长
+ * @param {boolean} isTwoRows 是否生成上下双排 8 相位 (默认 true)
+ */
+function _makePhaseData(cycleLength = 140, isTwoRows = true) {
+  const n = 4; // 4个阶段 (S1-S4)
+  const stageTime = Math.floor(cycleLength / n); 
+  const pd = [];
+
+  // 严格匹配 SignalTimingChart.vue 中的 IMAGE_MAP 键名
+  const iconsUD = [
+    'STRAIGHT_UP', 'STRAIGHT_DOWN', 
+    'TURN_UP_LEFT', 'TURN_DOWN_LEFT', 
+    'TURN_UP_LEFT_UTURN', 'TURN_DOWN_LEFT_UTURN'
+  ]; 
+  const iconsLR = [
+    'STRAIGHT_LEFT', 'STRAIGHT_RIGHT', 
+    'TURN_LEFT_DOWN', 'TURN_RIGHT_UP', // 修正了原代码中不存在的 TURN_LEFT_UP
+    'TURN_LEFT_DOWN_UTURN', 'TURN_RIGHT_UP_UTURN'
+  ];
+
+  const getRandomIcon = (pool) => pool[Math.floor(Math.random() * pool.length)];
+
+  let t = 0; 
+  for (let i = 0; i < n; i++) {
+    const stageStart = t;
+    const stageEnd = stageStart + stageTime;
+    const currentIconPool = (i < 2) ? iconsUD : iconsLR;
+
+    // 辅助函数:生成单条轨道的一个阶段
+    const pushTrackData = (trackIdx, phaseNamePrefix) => {
+      const icon = getRandomIcon(currentIconPool);
+      const phaseName = `${phaseNamePrefix}${i + 1}`;
+      const g = Math.floor(Math.random() * 11) + 20; // 绿灯 20-30s
+      const s = 3; // 闪烁/条纹 3s
+      const y = 2; // 黄灯 2s
+      
+      let curT = stageStart;
+      
+      // 1. 绿灯
+      pd.push([trackIdx, curT, curT + g, phaseName, g, 'green', icon]); 
+      curT += g;
+      // 2. 绿闪/条纹
+      pd.push([trackIdx, curT, curT + s, '', s, 'stripe', null]); 
+      curT += s;
+      // 3. 黄灯
+      pd.push([trackIdx, curT, curT + y, '', y, 'yellow', null]); 
+      curT += y;
+      // 4. 红灯补齐 (确保阶段对齐)
+      let remainRed = stageEnd - curT;
+      if (remainRed > 0) {
+        pd.push([trackIdx, curT, stageEnd, '', remainRed, 'red', null]);
+      }
+    };
+
+    pushTrackData(0, 'P'); // 生成第一排 (P1-P4)
+    if (isTwoRows) {
+      pushTrackData(1, 'P'); // 生成第二排 (P5-P8,由于逻辑相同,名称可根据需要改为 i+5)
     }
+
+    t = stageEnd; 
   }
-  return pd
+  return pd;
 }
 
 function _makeCornerVideos(seed = 0) {
@@ -306,7 +355,7 @@ export async function apiGetSignalTiming(id) {
   return ok({
     cycleLength,
     currentTime: Math.floor(Date.now() / 1000) % cycleLength,
-    phaseData: _makePhaseData(cycleLength),
+    phaseData: _makePhaseData(cycleLength, false),
   })
 }
 
@@ -315,7 +364,8 @@ export async function apiGetIntersectionStages(id) {
   await delay(200)
   const timing = DB.signalTimings[id]
   if (timing) {
-    const phases = timing.data.phaseData.filter(p => p[0] === 1 && p[4] !== null)
+    const hasTrack1 = timing.data.phaseData.some(p => p[0] === 1)
+    const phases = timing.data.phaseData.filter(p => p[0] === (hasTrack1 ? 1 : 0) && p[4] !== null)
     return ok(phases.map((p, i) => ({
       value: String(i + 1), time: p[4], phaseName: p[3], direction: p[6], img: ARROWS[i % ARROWS.length],
     })))
@@ -709,7 +759,7 @@ export async function apiGetCrossingPanelData(id) {
 
   const preset = DB.signalTimings[id]
   const cycleLength = preset ? preset.data.cycleLength : [100, 120, 130, 140, 150, 160][Math.abs(seed) % 6]
-  const phaseData = preset ? preset.data.phaseData : _makePhaseData(cycleLength)
+  const phaseData = preset ? preset.data.phaseData : _makePhaseData(cycleLength, false)
   const currentTime = Math.floor(Date.now() / 1000) % cycleLength
 
   return ok({
@@ -739,10 +789,11 @@ export async function apiGetCrossingDetailData(id) {
   // 从真实阶段数据推导周期和相位
   const preset = DB.signalTimings[id]
   const cycleLength = preset ? preset.data.cycleLength : [100, 120, 130, 140, 150, 160][seed % 6]
-  const phaseData = preset ? preset.data.phaseData : _makePhaseData(cycleLength)
+  const phaseData = preset ? preset.data.phaseData : _makePhaseData(cycleLength || 140, false)
 
-  // 从相位数据中提取阶段列表(上轨道绿灯相位,最多4个)
-  const greenPhases = phaseData.filter(p => p[0] === 1 && p[4] !== null).slice(0, 4)
+  // 从相位数据中提取阶段列表(优先上轨道绿灯相位,单轨道时取 track 0,最多4个)
+  const hasTrack1 = phaseData.some(p => p[0] === 1)
+  const greenPhases = phaseData.filter(p => p[0] === (hasTrack1 ? 1 : 0) && p[4] !== null).slice(0, 4)
   const stageList = greenPhases.map((p, i) => ({
     value: String(i + 1),
     time: p[4],

+ 17 - 27
src/mock/data.js

@@ -201,33 +201,23 @@ export function fetchSignalTimingData(id) {
           currentTime: 67,
           // [轨道(1上,0下), 开始时间, 结束时间, 相位名称, 时长, 颜色类型, 图标类型]
           phaseData: [
-            // 上轨道 (Track 1)
-            [1, 0, 30, 'P1', 30, 'green', 'UP'],
-            [1, 30, 35, '', null, 'stripe', null],
-            [1, 35, 38, '', null, 'yellow', null],
-            [1, 38, 41, '', null, 'red', null],
-            [1, 41, 71, 'P2', 30, 'green', 'TURN_LEFT'],
-            [1, 71, 76, '', null, 'stripe', null],
-            [1, 76, 79, '', null, 'yellow', null],
-            [1, 79, 82, '', null, 'red', null],
-            [1, 82, 122, 'P4', 40, 'green', 'UTURN'], 
-            [1, 122, 127, '', null, 'stripe', null],
-            [1, 127, 130, '', null, 'yellow', null],
-            [1, 130, 140, 'P3', 10, 'green', 'UP'],
-            
-            // 下轨道 (Track 0)
-            [0, 0, 30, 'P5', 30, 'green', 'DOWN'],
-            [0, 30, 35, '', null, 'stripe', null],
-            [0, 35, 38, '', null, 'yellow', null],
-            [0, 38, 41, '', null, 'red', null],
-            [0, 41, 71, 'P6', 30, 'green', 'TURN_RIGHT'],
-            [0, 71, 76, '', null, 'stripe', null],
-            [0, 76, 79, '', null, 'yellow', null],
-            [0, 79, 82, '', null, 'red', null],
-            [0, 82, 122, 'P7', 40, 'green', 'UTURN'],
-            [0, 122, 127, '', null, 'stripe', null],
-            [0, 127, 130, '', null, 'yellow', null],
-            [0, 130, 140, 'P8', 10, 'green', 'DOWN'], 
+            // 单轨道 P1-P4
+            [0, 0, 27, 'P1', 27, 'green', 'UP'],
+            [0, 27, 30, '', null, 'stripe', null],
+            [0, 30, 32, '', null, 'yellow', null],
+            [0, 32, 35, '', null, 'red', null],
+            [0, 35, 62, 'P2', 27, 'green', 'TURN_LEFT'],
+            [0, 62, 65, '', null, 'stripe', null],
+            [0, 65, 67, '', null, 'yellow', null],
+            [0, 67, 70, '', null, 'red', null],
+            [0, 70, 97, 'P3', 27, 'green', 'DOWN'],
+            [0, 97, 100, '', null, 'stripe', null],
+            [0, 100, 102, '', null, 'yellow', null],
+            [0, 102, 105, '', null, 'red', null],
+            [0, 105, 132, 'P4', 27, 'green', 'TURN_RIGHT'],
+            [0, 132, 135, '', null, 'stripe', null],
+            [0, 135, 137, '', null, 'yellow', null],
+            [0, 137, 140, '', null, 'red', null],
           ]
         }
       });

Разница между файлами не показана из-за своего большого размера
+ 1507 - 737
src/mock/mock_data.json