4 Коммиты 9609c06379 ... 1952969e62

Автор SHA1 Сообщение Дата
  画安 1952969e62 TrafficTimeSpace 绿/蓝波带独立随机速度,文本改为水平显示: 1 неделя назад
  画安 95b4c94807 TrafficTimeSpace 后端数据改为随机速度 42–48,首路口 offset=0 保留干净 X 轴刻度: 1 неделя назад
  画安 cb2c870d7b TrafficTimeSpace 绿/蓝波带视觉优化与独立随机速度: 1 неделя назад
  画安 d86b0ae587 TrafficTimeSpace 响应式字号与弹窗规格统一: 1 неделя назад

+ 106 - 48
src/components/ui/TrafficTimeSpace.vue

@@ -21,7 +21,8 @@ export default {
         { name: '交叉口D', distanceNext: 0, offset: 0 }
       ]
     },
-    speedKmh: { type: Number, default: 38.9 },     // 设计车速
+    speedKmh: { type: Number, default: 38.9 },     // 正向设计车速
+    speedKmhBackward: { type: Number, default: null }, // 反向设计车速;null 时用 speedKmh
     cycle: { type: Number, default: 100 },         // 红绿灯周期
     greenDuration: { type: Number, default: 40 },  // 绿灯时长
     bandwidth: { type: Number, default: 31.5 },    // 波带宽(秒)
@@ -45,11 +46,17 @@ export default {
       scanLineTimer: null,
       currentViewMinX: 0,
       currentScanX: 0,
-      barWidth: 8,
-      gap: 2,
     };
   },
   computed: {
+    barWidth() {
+      const scale = typeof window !== 'undefined' ? Math.max(1, window.innerWidth / 1920) : 1;
+      return Math.round(8 * scale);
+    },
+    gap() {
+      const scale = typeof window !== 'undefined' ? Math.max(1, window.innerWidth / 1920) : 1;
+      return Math.round(2 * scale);
+    },
     intersections() {
       let currentX = 0;
       return this.roadSegments.map((seg, i) => {
@@ -92,9 +99,16 @@ export default {
   watch: {
     roadSegments: { handler() { this.updateChart(); }, deep: true },
     speedKmh() { this.updateChart(); },
+    speedKmhBackward() { this.updateChart(); },
     autoScroll(val) { val ? this.startScroll() : this.stopScroll(); }
   },
   methods: {
+    // 根据大屏宽度缩放字号/像素常量,避免大屏下文字过小
+    fs(px) {
+      const scaled = typeof px2echarts === 'function' ? px2echarts(px) : px;
+      return Math.max(px, scaled);
+    },
+
     initChart() {
       if (!this.$refs.chartRef) return;
       this.$_chart = echarts.init(this.$refs.chartRef);
@@ -122,33 +136,36 @@ export default {
     },
 
     getWaveData() {
-      const speedMs = this.speedKmh / 3.6; // 换算为 m/s
+      const greenSpeed = this.speedKmh;
+      const blueSpeed = this.speedKmhBackward != null ? this.speedKmhBackward : this.speedKmh;
+      const greenMs = greenSpeed / 3.6;
+      const blueMs = blueSpeed / 3.6;
       const startX = this.intersections[0].x;
       const endX = this.intersections[this.intersections.length - 1].x;
-      const travelTime = (endX - startX) / speedMs;
+      const greenTravelTime = (endX - startX) / greenMs;
+      const blueTravelTime = (endX - startX) / blueMs;
 
-      // 【终极微调】:发车时间精确控制
-      // 正向绿波 (A -> D):10秒起步,刚好完美贴合所有绿灯边缘
-      const gStartY = 10;
+      // 正向绿波 (A -> D):gStartY 秒起步
+      const gStartY = 0;
       const greenBottomLine = [
         [startX, gStartY],
-        [endX, gStartY + travelTime]
+        [endX, gStartY + greenTravelTime]
       ];
       const greenTopLine = [
         [startX, gStartY + this.bandwidth],
-        [endX, gStartY + travelTime + this.bandwidth]
+        [endX, gStartY + greenTravelTime + this.bandwidth]
       ];
       const greenCoords = [...greenBottomLine, ...[...greenTopLine].reverse()];
 
-      // 反向蓝波 (D -> A):100秒起步,完美避开 B 路口的红灯区域
+      // 反向蓝波 (D -> A):bStartYAtD 秒起步
       const bStartYAtD = 100;
       const blueBottomLine = [
         [endX, bStartYAtD],
-        [startX, bStartYAtD + travelTime]
+        [startX, bStartYAtD + blueTravelTime]
       ];
       const blueTopLine = [
         [endX, bStartYAtD + this.bandwidth],
-        [startX, bStartYAtD + travelTime + this.bandwidth]
+        [startX, bStartYAtD + blueTravelTime + this.bandwidth]
       ];
       const blueCoords = [...blueBottomLine, ...[...blueTopLine].reverse()];
 
@@ -159,7 +176,8 @@ export default {
           topLine: greenTopLine,
           color: this.upWaveColor,
           lineCol: '#2ecc71',
-          isBlue: false
+          isBlue: false,
+          speed: greenSpeed
         },
         {
           coords: blueCoords,
@@ -167,7 +185,8 @@ export default {
           topLine: blueTopLine,
           color: this.downWaveColor,
           lineCol: '#3498db',
-          isBlue: true
+          isBlue: true,
+          speed: blueSpeed
         }
       ];
     },
@@ -175,19 +194,38 @@ export default {
     updateChart() {
       if (!this.$_chart) return;
 
+      // x 轴刻度仅对齐首路口(A)的红块首尾
+      const gcd = (a, b) => (b === 0 ? Math.abs(a) : gcd(b, a % b));
+      const firstOffset = this.intersections[0] ? this.intersections[0].offset : 0;
+      const redStartMod = ((firstOffset + this.greenDuration) % this.cycle + this.cycle) % this.cycle; // 红块起点
+      const redEndMod = ((firstOffset) % this.cycle + this.cycle) % this.cycle;                         // 红块终点 = 下周期起点
+      // interval 需同时整除 redStartMod 与 redEndMod 的间距,保证 label 落点
+      const tickInterval = gcd(gcd(this.greenDuration, this.cycle - this.greenDuration), firstOffset || this.cycle);
+
       const option = {
         backgroundColor: 'transparent',
         animation: false,
         tooltip: { show: false },
-        grid: { left: 140, right: 40, top: 40, bottom: 50 },
+        grid: { left: this.fs(140), right: this.fs(40), top: this.fs(40), bottom: this.fs(50) },
         xAxis: {
           type: 'value', min: this.currentViewMinX, max: this.currentViewMinX + this.viewWindow,
-          interval: 20, name: '时间 (秒)',
+          interval: tickInterval, name: '时间 (秒)',
           nameLocation: 'end',
-          nameTextStyle: { color: '#a0aabf', padding: [0, 0, 0, 0] },
-          axisLine: { show: true, onZero: false, lineStyle: { color: 'rgba(255,255,255,0.15)' } },
-          axisTick: { show: false },
-          axisLabel: { color: '#a0aabf', fontSize: 10 },
+          nameTextStyle: { color: '#a0aabf', padding: [0, 0, 0, 0], fontSize: this.fs(12) },
+          axisLine: { show: true, onZero: false, lineStyle: { color: 'rgba(255,255,255,0.5)' } },
+          axisTick: {
+            show: true,
+            lineStyle: { color: 'rgba(255,255,255,0.5)' },
+            length: this.fs(5)
+          },
+          axisLabel: {
+            color: '#a0aabf',
+            fontSize: this.fs(10),
+            formatter: (value) => {
+              const mod = ((value % this.cycle) + this.cycle) % this.cycle;
+              return (mod === redStartMod || mod === redEndMod) ? value : '';
+            }
+          },
           splitLine: { show: this.showYSplitLine, lineStyle: { type: 'dashed', color: 'rgba(255, 255, 255, 0.08)' } }
         },
         yAxis: {
@@ -222,10 +260,10 @@ export default {
               const pT1 = mappedTop[segIdx];
               const pT2 = mappedTop[segIdx + 1];
 
-              let angle = Math.atan2(pB2[1] - pB1[1], pB2[0] - pB1[0]);
-              if (item.isBlue) {
-                angle -= Math.PI;
-              }
+              // 波带角度统一用底线方向(atan2),保证文字沿着波带方向读
+              const angle = Math.atan2(pB2[1] - pB1[1], pB2[0] - pB1[0]);
+              // 速度文字保持水平(与 x 轴平行)
+              const speedAngle = 0;
 
               const percent = 0.15;
               const midBottomX = pB1[0] + (pB2[0] - pB1[0]) * percent;
@@ -233,25 +271,34 @@ export default {
               const midTopX = pT1[0] + (pT2[0] - pT1[0]) * percent;
               const midTopY = pT1[1] + (pT2[1] - pT1[1]) * percent;
 
-              // 速度文字沿底线垂直方向、远离波带方向偏移
+              // 速度文字沿波带法线向外偏移;与虚线保持同侧(绿波→顶线侧,蓝波→底线侧)
               const sBDx = pB2[0] - pB1[0];
               const sBDy = pB2[1] - pB1[1];
               const sBLen = Math.sqrt(sBDx * sBDx + sBDy * sBDy) || 1;
               const sPerpX = sBDy / sBLen;
               const sPerpY = -sBDx / sBLen;
               const sDot = (pT1[0] - pB1[0]) * sPerpX + (pT1[1] - pB1[1]) * sPerpY;
-              const sSign = sDot > 0 ? -1 : 1;
-              const speedOff = 14;
-              const speedX = midBottomX + sSign * sPerpX * speedOff;
-              const speedY = midBottomY + sSign * sPerpY * speedOff;
+              const speedOff = this.fs(14);
+              let speedX, speedY;
+              if (item.isBlue) {
+                // 蓝波:底线外侧
+                const sSign = sDot > 0 ? -1 : 1;
+                speedX = midBottomX + sSign * sPerpX * speedOff;
+                speedY = midBottomY + sSign * sPerpY * speedOff;
+              } else {
+                // 绿波:顶线外侧(虚线这边)
+                const sSign = sDot > 0 ? 1 : -1;
+                speedX = midTopX + sSign * sPerpX * speedOff;
+                speedY = midTopY + sSign * sPerpY * speedOff;
+              }
 
               return {
                 type: 'group',
                 children: [
                   { type: 'polygon', shape: { points: mappedCoords }, style: { fill: item.color } },
                   ...(() => {
-                    // 虚线与绿波带之间留间隙,沿垂直于底线方向向外偏移
-                    const lineGap = 4;
+                    // 虚线沿垂直于波带方向向外偏移:绿波走顶线外侧,蓝波走底线外侧(互相镜像)
+                    const lineGap = this.fs(4);
                     const bDx = mappedBottom[1][0] - mappedBottom[0][0];
                     const bDy = mappedBottom[1][1] - mappedBottom[0][1];
                     const bLen = Math.sqrt(bDx * bDx + bDy * bDy) || 1;
@@ -262,13 +309,22 @@ export default {
                     const midTx = (mappedTop[0][0] + mappedTop[1][0]) / 2;
                     const midTy = (mappedTop[0][1] + mappedTop[1][1]) / 2;
                     const dot = (midTx - midBx) * perpX + (midTy - midBy) * perpY;
-                    const sign = dot > 0 ? -1 : 1;
-                    const offsetBottom = mappedBottom.map(p => [p[0] + sign * perpX * lineGap, p[1] + sign * perpY * lineGap]);
+
+                    let points;
+                    if (item.isBlue) {
+                      // 蓝波:底线外侧(远离顶线)
+                      const sign = dot > 0 ? -1 : 1;
+                      points = mappedBottom.map(p => [p[0] + sign * perpX * lineGap, p[1] + sign * perpY * lineGap]);
+                    } else {
+                      // 绿波:顶线外侧(远离底线)
+                      const sign = dot > 0 ? 1 : -1;
+                      points = mappedTop.map(p => [p[0] + sign * perpX * lineGap, p[1] + sign * perpY * lineGap]);
+                    }
                     return [
-                      { type: 'polyline', shape: { points: offsetBottom }, style: { stroke: item.lineCol, lineDash: [4, 4], lineWidth: 1 } }
+                      { type: 'polyline', shape: { points }, style: { stroke: item.lineCol, lineDash: [4, 4], lineWidth: 1 } }
                     ];
                   })(),
-                  { type: 'text', x: speedX, y: speedY, rotation: angle, style: { text: `${this.speedKmh}km/h`, fill: '#fff', fontSize: 11, textAlign: 'center' } },
+                  { type: 'text', x: speedX, y: speedY, rotation: speedAngle, style: { text: `${item.speed}km/h`, fill: '#fff', fontSize: this.fs(11), textAlign: 'center' } },
                   ...(() => {
                     const cxBw = (midBottomX + midTopX) / 2;
                     const cyBw = (midBottomY + midTopY) / 2;
@@ -277,10 +333,10 @@ export default {
                     const lenBw = Math.sqrt(dxBw * dxBw + dyBw * dyBw) || 1;
                     const uxBw = dxBw / lenBw;
                     const uyBw = dyBw / lenBw;
-                    const gapBw = 10;
+                    const gapBw = this.fs(10);
                     return [
                       { type: 'line', shape: { x1: midBottomX, y1: midBottomY, x2: cxBw - uxBw * gapBw, y2: cyBw - uyBw * gapBw }, style: { stroke: 'rgba(255, 255, 255, 0.4)' } },
-                      { type: 'text', x: cxBw, y: cyBw, rotation: angle, style: { text: `${this.bandwidth}s`, fill: '#fff', fontSize: 11, textAlign: 'center', textVerticalAlign: 'middle' } },
+                      { type: 'text', x: cxBw, y: cyBw, rotation: 0, style: { text: `${this.bandwidth}s`, fill: '#fff', fontSize: this.fs(11), textAlign: 'center', textVerticalAlign: 'middle' } },
                       { type: 'line', shape: { x1: cxBw + uxBw * gapBw, y1: cyBw + uyBw * gapBw, x2: midTopX, y2: midTopY }, style: { stroke: 'rgba(255, 255, 255, 0.4)' } }
                     ];
                   })()
@@ -320,7 +376,9 @@ export default {
               const intersection = this.intersections[params.dataIndex];
               const point = api.coord([this.currentViewMinX, intersection.x]);
               const centerYPx = point[1] + this.barWidth + this.gap / 2;
-              const labelX = point[0] - 12;
+              const labelX = point[0] - this.fs(12);
+              const nameFs = this.fs(12);
+              const subFs = this.fs(11);
 
               // 名称较长时按 "-" 拆成两行显示
               const dashIdx = intersection.name.indexOf('-');
@@ -329,14 +387,14 @@ export default {
 
               const children = [];
               if (nameLine2) {
-                children.push({ type: 'text', x: labelX, y: centerYPx - 15, style: { text: nameLine1, fill: '#fff', textAlign: 'right', fontSize: 12 } });
-                children.push({ type: 'text', x: labelX, y: centerYPx - 1, style: { text: nameLine2, fill: '#fff', textAlign: 'right', fontSize: 12 } });
+                children.push({ type: 'text', x: labelX, y: centerYPx - this.fs(15), style: { text: nameLine1, fill: '#fff', textAlign: 'right', fontSize: nameFs } });
+                children.push({ type: 'text', x: labelX, y: centerYPx - this.fs(1), style: { text: nameLine2, fill: '#fff', textAlign: 'right', fontSize: nameFs } });
               } else {
-                children.push({ type: 'text', x: labelX, y: centerYPx - 7, style: { text: nameLine1, fill: '#fff', textAlign: 'right', fontSize: 12 } });
+                children.push({ type: 'text', x: labelX, y: centerYPx - this.fs(7), style: { text: nameLine1, fill: '#fff', textAlign: 'right', fontSize: nameFs } });
               }
               if (this.showPhaseOffset) {
-                const offYPx = nameLine2 ? centerYPx + 14 : centerYPx + 5;
-                children.push({ type: 'text', x: labelX, y: offYPx, style: { text: `相位差: ${intersection.offsetText}`, fill: '#a0aabf', textAlign: 'right', fontSize: 11 } });
+                const offYPx = nameLine2 ? centerYPx + this.fs(14) : centerYPx + this.fs(5);
+                children.push({ type: 'text', x: labelX, y: offYPx, style: { text: `相位差: ${intersection.offsetText}`, fill: '#a0aabf', textAlign: 'right', fontSize: subFs } });
               }
 
               if (intersection.distanceNext) {
@@ -345,9 +403,9 @@ export default {
                 const midYPx = (centerYPx + nextCenterYPx) / 2;
 
                 // 非反转 yAxis:距离越大像素 y 越小,nextCenterYPx < centerYPx
-                const lineX = labelX - 85;
-                children.push({ type: 'line', shape: { x1: lineX, y1: centerYPx - 36, x2: lineX, y2: nextCenterYPx + 22 }, style: { stroke: 'rgba(255,255,255,0.1)', lineDash: [2, 2] } });
-                children.push({ type: 'text', x: lineX, y: midYPx, style: { text: `${intersection.distanceNext}m`, fill: '#a0aabf', textAlign: 'center', fontSize: 11 } });
+                const lineX = labelX - this.fs(10);
+                children.push({ type: 'line', shape: { x1: lineX, y1: centerYPx - this.fs(36), x2: lineX, y2: nextCenterYPx + this.fs(22) }, style: { stroke: 'rgba(255,255,255,0.1)', lineDash: [2, 2] } });
+                children.push({ type: 'text', x: lineX, y: midYPx, style: { text: `${intersection.distanceNext}m`, fill: '#a0aabf', textAlign: 'center', fontSize: subFs } });
               }
               return { type: 'group', children };
             }
@@ -364,7 +422,7 @@ export default {
               return {
                 type: 'line',
                 shape: { x1: top[0], y1: top[1], x2: bottom[0], y2: bottom[1] },
-                style: { stroke: this.scanLineColor, lineWidth: 4 }
+                style: { stroke: this.scanLineColor, lineWidth: this.fs(4) }
               };
             },
             z: 10

+ 28 - 5
src/mock/api.js

@@ -648,18 +648,41 @@ export async function apiGetTrafficTimeSpace(opts = {}) {
   const { label } = opts
   const labels = ['A', 'B', 'C', 'D']
   const dists = [450, 669, 1050, 0]
-  const offsets = [0, 50, 0, 0]
   const prefix = label || '交叉口'
+
+  // 正反两向设计车速各自 42~48 km/h 随机(保留一位小数),且两者不相同
+  const randSpeed = () => Math.round((42 + Math.random() * 6) * 10) / 10
+  const speedKmh = randSpeed()
+  let speedKmhBackward = randSpeed()
+  while (speedKmhBackward === speedKmh) {
+    speedKmhBackward = randSpeed()
+  }
+
+  const cycle = 100
+  const greenDuration = 40
+  const bandwidth = 31.5
+  const gStartY = 0  // 与组件保持一致
+  const vMs = speedKmh / 3.6
+
+  // 正向 offset:让 A→D 绿波在每个路口贴着绿灯起点进入;首路口 offset=0 保证 X 轴刻度干净
+  let cumDist = 0
+  const offsets = labels.map((_, i) => {
+    const entryTime = gStartY + cumDist / vMs
+    cumDist += dists[i]
+    return Math.round(((entryTime) % cycle + cycle) % cycle)
+  })
+
   return ok({
     roadSegments: labels.map((l, i) => ({
       name: label ? `${prefix}-路口${l}` : `${prefix}${l}`,
       distanceNext: dists[i],
       offset: offsets[i]
     })),
-    speedKmh: 38.9,
-    cycle: 100,
-    greenDuration: 40,
-    bandwidth: 31.5,
+    speedKmh,
+    speedKmhBackward,
+    cycle,
+    greenDuration,
+    bandwidth,
     scanLineStart: Math.round(Math.random() * 100) / 100
   })
 }

+ 4 - 3
src/views/SpecialSituationMonitoring.vue

@@ -560,9 +560,10 @@ export default {
                 id: nodeData.id,
                 title: nodeData.label + ' 绿波带',
                 component: 'TrafficTimeSpace',
-                width: 600,
-                height: 900,
-                center: true,
+                width: 1200,
+                height: 700,
+                center: false,
+                position: { x: 500, y: 150 },
                 showClose: true,
                 noPadding: false,
                 data: tsData,

+ 4 - 3
src/views/StatusMonitoring.vue

@@ -631,9 +631,10 @@ export default {
                 id: nodeData.id,
                 title: nodeData.label + ' 绿波带',
                 component: 'TrafficTimeSpace',
-                width: 600,
-                height: 900,
-                center: true,
+                width: 1200,
+                height: 700,
+                center: false,
+                position: { x: 500, y: 150 },
                 showClose: true,
                 noPadding: false,
                 data: tsData,

+ 4 - 3
src/views/TrunkCoordination.vue

@@ -568,9 +568,10 @@ export default {
                 id: nodeData.id,
                 title: nodeData.label + ' 绿波带',
                 component: 'TrafficTimeSpace',
-                width: 600,
-                height: 900,
-                center: true,
+                width: 1200,
+                height: 700,
+                center: false,
+                position: { x: 500, y: 150 },
                 showClose: true,
                 noPadding: false,
                 data: tsData,