Bladeren bron

统一相位图扫描同步与数据一致性

  1. 相位图扫描同步:SignalTimingChart 定时器改为全局心跳 + 绝对时间取模(Date.now() %
  cycleLength),相同周期的图表扫描线自动对齐,去掉了 syncScan prop 和 batchId 机制
  2. 相位数据统一:所有 API(列表、小弹窗、详情弹窗)统一走 _makePhaseData 生成,废弃 preset phaseData(格式不一致、缺少
   direction/stageTotal/红灯阶段),preset 仅保留 cycleLength
  3. 相位数据缓存:_makePhaseData 按路口 ID 缓存,同一路口多次调用返回同一份数据
  4. 统一辅助函数:提取 _idSeed(稳定 seed)和 _getCycleLength(统一周期来源),所有 API 共用
  5. CrossingPanel 补齐 onScanTick:小弹窗的十字路口灯带、箭头、中心面板与 CrossingDetailPanel 规则一致
画安 1 week geleden
bovenliggende
commit
90c3494259
3 gewijzigde bestanden met toevoegingen van 112 en 93 verwijderingen
  1. 49 1
      src/components/ui/CrossingPanel.vue
  2. 28 61
      src/components/ui/SignalTimingChart.vue
  3. 35 31
      src/mock/api.js

+ 49 - 1
src/components/ui/CrossingPanel.vue

@@ -4,7 +4,7 @@
             <IntersectionMapVideos :mapData="intersectionData" :videoUrls="currentRoute.cornerVideos" />
         </div>
         <div class="signal-timing-wrap">
-            <SignalTimingChart :cycleLength="cycleLength" :currentTime="currentSec" :phaseData="mockPhaseData" />
+            <SignalTimingChart :cycleLength="cycleLength" :currentTime="currentSec" :phaseData="mockPhaseData" :showScanLine="dataReady" :autoScan="dataReady" @scan-tick="onScanTick" />
         </div>
     </div>
 </template>
@@ -26,6 +26,7 @@ export default {
     },
     data() {
         return {
+            dataReady: false,
             followPhase: false,
             intersectionData: {},
             currentRoute: {},
@@ -43,8 +44,55 @@ export default {
             this.mockPhaseData = data.phaseData || [];
             if (data.cycleLength) this.cycleLength = data.cycleLength;
             if (data.currentTime !== undefined) this.currentSec = data.currentTime;
+            this.$nextTick(() => { this.dataReady = true; });
         }
     },
+    methods: {
+        onScanTick(activeTime) {
+            if (!this.mockPhaseData || this.mockPhaseData.length === 0) return;
+            const phase = this.mockPhaseData.find(p => p[0] === 0 && activeTime >= p[1] && activeTime < p[2]);
+            if (!phase) return;
+
+            const type = phase[5];
+            const iconValue = phase[6];
+            const direction = phase[7];
+            const phaseName = phase[3];
+            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');
+
+            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)];
+            }
+
+            const pedAllRed = !(type === 'green' && (phaseName === 'P1' || phaseName === 'P3'));
+
+            this.$set(this.intersectionData, 'signals', {
+                pedAllRed,
+                ns: {
+                    phaseName: nsGreen ? ({ P1: '南北直行', P2: '南北左转' }[phaseName] || '南北') : (this.intersectionData.signals && this.intersectionData.signals.ns && this.intersectionData.signals.ns.phaseName || '南北'),
+                    time: remaining,
+                    isGreen: nsGreen,
+                    activeArrowTypes: nsGreen ? activeArrowTypes : []
+                },
+                ew: {
+                    phaseName: ewGreen ? ({ P3: '东西直行', P4: '东西左转' }[phaseName] || '东西') : (this.intersectionData.signals && this.intersectionData.signals.ew && this.intersectionData.signals.ew.phaseName || '东西'),
+                    time: remaining,
+                    isGreen: ewGreen,
+                    activeArrowTypes: ewGreen ? activeArrowTypes : []
+                }
+            });
+        }
+    }
 }
 </script>
 

+ 28 - 61
src/components/ui/SignalTimingChart.vue

@@ -6,38 +6,28 @@
 import * as echarts from 'echarts';
 import echartsResize from '@/mixins/echartsResize.js';
 
-// 模块级共享定时器:所有实例订阅同一个 tick,保证扫描线完全同步
-let sharedEpoch = 0;
-let sharedTimer = null;
-let sharedListeners = new Set();
-let sharedBatchId = 0; // 批次ID,切换分页时递增
-
-function joinSharedTimer(listener, batchId) {
-  // 新批次:重置定时器和 epoch
-  if (batchId !== sharedBatchId) {
-    sharedBatchId = batchId;
-    if (sharedTimer) { clearInterval(sharedTimer); sharedTimer = null; }
-    sharedListeners.clear();
-    sharedEpoch = Math.floor(Date.now() / 1000);
-  }
-  if (!sharedTimer) {
-    sharedEpoch = Math.floor(Date.now() / 1000);
-    sharedTimer = setInterval(() => {
-      const elapsed = Math.floor(Date.now() / 1000) - sharedEpoch;
-      sharedListeners.forEach(fn => fn(elapsed));
+// 全局心跳定时器:所有 autoScan 实例共享同一个 setInterval
+// 各实例基于 Date.now() 对自身 cycleLength 取模计算位置,天然同步
+let _globalTimer = null;
+let _globalListeners = new Set();
+
+function joinGlobalTimer(listener) {
+  _globalListeners.add(listener);
+  if (!_globalTimer) {
+    _globalTimer = setInterval(() => {
+      const nowSec = Math.floor(Date.now() / 1000);
+      _globalListeners.forEach(fn => fn(nowSec));
     }, 1000);
   }
-  sharedListeners.add(listener);
-  // 立即触发一次,避免 mounted 到首次 tick 之间的闪跳
-  listener(Math.floor(Date.now() / 1000) - sharedEpoch);
+  // 立即触发一次,避免 mounted 到首次 tick 之间的空白
+  listener(Math.floor(Date.now() / 1000));
 }
 
-function leaveSharedTimer(listener) {
-  sharedListeners.delete(listener);
-  if (sharedListeners.size === 0 && sharedTimer) {
-    clearInterval(sharedTimer);
-    sharedTimer = null;
-    sharedEpoch = 0;
+function leaveGlobalTimer(listener) {
+  _globalListeners.delete(listener);
+  if (_globalListeners.size === 0 && _globalTimer) {
+    clearInterval(_globalTimer);
+    _globalTimer = null;
   }
 }
 
@@ -108,8 +98,7 @@ export default {
     showAxis: { type: Boolean, default: true },
     showScanLine: { type: Boolean, default: true },
     showScanLineLabel: { type: Boolean, default: true },
-    autoScan: { type: Boolean, default: false },
-    syncScan: { type: Boolean, default: false }
+    autoScan: { type: Boolean, default: false }
   },
   data() {
     return { scaleFactor: 1, internalTime: 0 };
@@ -131,17 +120,11 @@ export default {
     currentTime(val) {
       if (!this.autoScan) {
         if (this.$_chart) this.updateScanLine();
-      } else {
-        // 页切换时 currentTime 变化,重新加入共享定时器触发 epoch 重置
-        this.startAutoScan();
       }
     },
     autoScan(val) {
       if (val) { this.startAutoScan(); } else { this.stopAutoScan(); }
     },
-    syncScan() {
-      if (this.autoScan) { this.startAutoScan(); }
-    },
     showScanLine(val) {
       this.updateChart();
       if (val && this.autoScan) { this.startAutoScan(); }
@@ -158,33 +141,17 @@ export default {
     },
     startAutoScan() {
       this.stopAutoScan();
-      const VISUAL_PERIOD = 120;
-      if (this.syncScan) {
-        // 共享定时器:所有行扫描线完全同步
-        this._scanListener = (elapsed) => {
-          const realMax = this.getMaxTime();
-          const offset = this.currentTime || 0;
-          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 {
-        // 独立定时器:每行扫描线从 currentTime 位置开始独立移动
-        this.internalTime = this.currentTime || 0;
-        this._soloTimer = setInterval(() => {
-          const realMax = this.getMaxTime();
-          this.internalTime += 1;
-          if (this.internalTime > realMax) this.internalTime = 0;
-          if (this.$_chart) this.updateScanLine();
-          this.$emit('scan-tick', this.internalTime);
-        }, 1000);
-      }
+      // 全局心跳 + 绝对时间取模:相同 cycleLength 的实例扫描线天然同步
+      this._scanListener = (nowSec) => {
+        const realMax = this.getMaxTime();
+        this.internalTime = nowSec % realMax;
+        if (this.$_chart) this.updateScanLine();
+        this.$emit('scan-tick', this.internalTime);
+      };
+      joinGlobalTimer(this._scanListener);
     },
     stopAutoScan() {
-      if (this._scanListener) { leaveSharedTimer(this._scanListener); this._scanListener = null; }
-      if (this._soloTimer) { clearInterval(this._soloTimer); this._soloTimer = null; }
+      if (this._scanListener) { leaveGlobalTimer(this._scanListener); this._scanListener = null; }
     },
     initChart() {
       const chartDom = this.$refs.chartRef;

+ 35 - 31
src/mock/api.js

@@ -50,6 +50,20 @@ function seededRand(seed) {
   return x - Math.floor(x)
 }
 
+/** 根据路口 ID 生成稳定 seed(全字符加权,所有 API 共用) */
+function _idSeed(id) {
+  return id ? Array.from(id).reduce((s, c, i) => s + c.charCodeAt(0) * (i + 1), 0) : 0
+}
+
+/** 根据路口 ID 获取稳定的 cycleLength(优先 preset → crossingList → seed 兜底) */
+function _getCycleLength(id) {
+  const preset = DB.signalTimings[id]
+  if (preset) return preset.data.cycleLength
+  const crossing = DB.crossingList.find(r => r.id === id)
+  if (crossing && crossing.cycle) return crossing.cycle
+  return [100, 120, 130, 140, 150, 160][_idSeed(id) % 6]
+}
+
 /** 当前时间 HH:MM:SS */
 function nowTime() { return new Date().toLocaleTimeString() }
 function nowDate() { return new Date().toLocaleDateString() }
@@ -156,7 +170,14 @@ function _makeIntersectionConfig(id, name, { fixedNsGreen, iconMode = 'default'
  * @param {number} cycleLength 周期总时长
  * @param {boolean} isTwoRows 是否生成上下双排 8 相位 (默认 true)
  */
-function _makePhaseData(cycleLength = 140, isTwoRows = true, iconMode = 'default') {
+// 相位数据缓存:同一路口 (cycleLength+iconMode) 只生成一次,列表和详情弹窗共享
+const _phaseDataCache = {};
+
+function _makePhaseData(cycleLength = 140, isTwoRows = true, iconMode = 'default', id = '') {
+  // 缓存 key:用路口 ID(有的话),保证同一路口列表和详情共享同一份数据
+  const cacheKey = id || `${cycleLength}_${iconMode}`;
+  if (_phaseDataCache[cacheKey]) return _phaseDataCache[cacheKey];
+
   const n = 4; // 4个阶段 (S1-S4)
   // 各阶段按比例分配时间,P1/P3较长,P2/P4较短
   const ratios = [0.3, 0.2, 0.3, 0.2];
@@ -165,10 +186,6 @@ function _makePhaseData(cycleLength = 140, isTwoRows = true, iconMode = 'default
   stageTimes[0] += cycleLength - stageTimes.reduce((a, b) => a + b, 0);
   const pd = [];
 
-  // ==========================================
-  // 修改点:将单个图标改为用逗号分隔的"成对图标"字符串
-  // 前端组件会按逗号切割并分别放到对角位置
-  // ==========================================
   // 固定4个阶段的图标和方向:P1南北直行、P2南北左转、P3东西直行、P4东西左转
   const phaseConfigMap = {
     default: [
@@ -218,11 +235,13 @@ function _makePhaseData(cycleLength = 140, isTwoRows = true, iconMode = 'default
 
     pushTrackData(0, 'P'); // 生成第一排 (P1-P4)
     if (isTwoRows) {
-      pushTrackData(1, 'P'); // 生成第二排 (P5-P8,由于逻辑相同,名称可根据需要改为 i+5)
+      pushTrackData(1, 'P'); // 生成第二排
     }
 
     t = stageEnd;
   }
+
+  _phaseDataCache[cacheKey] = pd;
   return pd;
 }
 
@@ -375,19 +394,11 @@ export async function apiGetIntersectionData(id, { fixedNsGreen } = {}) {
  */
 export async function apiGetSignalTiming(id) {
   await delay(300)
-  const preset = DB.signalTimings[id]
-  if (preset) {
-    const cycleLength = preset.data.cycleLength
-    return {
-      code: 200, message: 'success',
-      data: { ...preset.data, currentTime: Math.floor(Date.now() / 1000) % cycleLength }
-    }
-  }
-  const cycleLength = [100, 120, 130, 140, 150, 160][Math.floor(Math.random() * 6)]
+  const cycleLength = _getCycleLength(id)
   return ok({
     cycleLength,
     currentTime: Math.floor(Date.now() / 1000) % cycleLength,
-    phaseData: _makePhaseData(cycleLength, false),
+    phaseData: _makePhaseData(cycleLength, false, 'simple', id),
   })
 }
 
@@ -661,12 +672,8 @@ export async function apiGetCrossingList(params = {}) {
   const page = params.page || 1
   const pageOffset = Math.floor(seededRand(page * 97) * 120)
   let list = DB.crossingList.map((r, i) => {
-    const preset = DB.signalTimings[r.id]
-    const cycleLength = preset ? preset.data.cycleLength : r.cycle
-    // const phaseData = preset ? preset.data.phaseData : _makePhaseData(cycleLength, false)
-
-    // 强制全部用 _makePhaseData 动态生成成对箭头
-    const phaseData = _makePhaseData(cycleLength, false)
+    const cycleLength = _getCycleLength(r.id)
+    const phaseData = _makePhaseData(cycleLength, false, 'simple', r.id)
     return {
       ...r,
       status: _getDeviceStatus(r.id),
@@ -850,15 +857,14 @@ export async function apiGetCrossingPanelData(id) {
   await delay(300)
   const point = DB.points.find(p => p.id === id)
   const config = DB.intersectionConfigs[id] || _makeIntersectionConfig(id, null, { fixedNsGreen: false, iconMode: 'simple' })
-  const seed = id ? id.charCodeAt(id.length - 1) : 0
+  const seed = _idSeed(id)
   // 确保 config 有 status
   if (!config.status) {
     config.status = _getDeviceStatus(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, false, 'simple')
+  const cycleLength = _getCycleLength(id)
+  const phaseData = _makePhaseData(cycleLength, false, 'simple', id)
   const currentTime = Math.floor(Date.now() / 1000) % cycleLength
 
   return ok({
@@ -882,8 +888,7 @@ export async function apiGetCrossingDetailData(id, { iconMode = 'default' } = {}
   const point = DB.points.find(p => p.id === id)
   const config = DB.intersectionConfigs[id] || _makeIntersectionConfig(id, null, { fixedNsGreen: false, iconMode })
 
-  // 用 id 的全部字符生成稳定 seed(加权位置避免 charCode 总和碰撞)
-  const seed = id ? Array.from(id).reduce((s, c, i) => s + c.charCodeAt(0) * (i + 1), 0) : 0
+  const seed = _idSeed(id)
 
   // 确保 config 有 status 字段(预存配置可能缺失)
   if (!config.status) {
@@ -891,9 +896,8 @@ export async function apiGetCrossingDetailData(id, { iconMode = 'default' } = {}
   }
 
   // 从真实阶段数据推导周期和相位
-  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 || 140, false, iconMode)
+  const cycleLength = _getCycleLength(id)
+  const phaseData = _makePhaseData(cycleLength, false, 'simple', id)
 
   // 从相位数据中提取阶段列表(优先上轨道绿灯相位,单轨道时取 track 0,最多4个)
   const hasTrack1 = phaseData.some(p => p[0] === 1)