瀏覽代碼

相位图扫描线同步机制重构与样式优化

  1. 扫描线同步机制
    -
  引入模块级共享定时器(sharedTimer),所有相位图实例订阅同一个tick回调,彻底消除各自setInterval时差导致的扫描线不同步
    - 采用统一视觉周期(120秒),通过ratio归一化确保不同cycleLength的行扫描线始终在同一视觉位置
    - 支持批次ID检测(batchId),切换分页时自动重置时间基准
    - 加入共享定时器时立即触发一次回调,消除mounted到首次tick之间的闪跳
    - listener动态读取currentTime避免闭包捕获旧值,兼容Vue组件复用场景
  2. 分页起始位置差异化
    - mock API基于页码生成确定的页级起始偏移(seededRand),同一页所有行共享相同起始位置
    - 不同分页起始位置不同,增加视觉多样性
    - currentTime变化时自动重新加入共享定时器,触发epoch重置
  3. 扫描线样式优化
    - 颜色改为亮青蓝色(#00E5FF)
    - 线宽从2px加粗至5px
    - 标签时间显示改为Math.round取整,避免浮点数显示
画安 3 周之前
父節點
當前提交
e372070cea
共有 2 個文件被更改,包括 56 次插入13 次删除
  1. 52 11
      src/components/ui/SignalTimingChart.vue
  2. 4 2
      src/mock/api.js

+ 52 - 11
src/components/ui/SignalTimingChart.vue

@@ -6,9 +6,44 @@
 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));
+    }, 1000);
+  }
+  sharedListeners.add(listener);
+  // 立即触发一次,避免 mounted 到首次 tick 之间的闪跳
+  listener(Math.floor(Date.now() / 1000) - sharedEpoch);
+}
+
+function leaveSharedTimer(listener) {
+  sharedListeners.delete(listener);
+  if (sharedListeners.size === 0 && sharedTimer) {
+    clearInterval(sharedTimer);
+    sharedTimer = null;
+    sharedEpoch = 0;
+  }
+}
+
 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'
+  TEXT_DARK: '#1e2638', AXIS_LINE: '#758599', DIVIDER_LINE: '#111827', MARK_BLUE: '#00E5FF', TEXT_LIGHT: '#d1d5db'
 };
 
 // 绘制条纹图案用于绿闪/预警
@@ -96,7 +131,8 @@ export default {
       if (!this.autoScan) {
         if (this.$_chart) this.updateScanLine();
       } else {
-        this.internalTime = val;
+        // 页切换时 currentTime 变化,重新加入共享定时器触发 epoch 重置
+        this.startAutoScan();
       }
     },
     autoScan(val) {
@@ -118,14 +154,19 @@ export default {
     },
     startAutoScan() {
       this.stopAutoScan();
-      this._scanTimer = setInterval(() => {
-        const max = this.cycleLength || 140;
-        this.internalTime = this.internalTime >= max ? 0 : this.internalTime + 1;
+      // 统一视觉周期:所有行在 VISUAL_PERIOD 秒内完成一次扫描
+      const VISUAL_PERIOD = 120;
+      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();
-      }, 1000);
+      };
+      joinSharedTimer(this._scanListener, this.currentTime || 0);
     },
     stopAutoScan() {
-      if (this._scanTimer) { clearInterval(this._scanTimer); this._scanTimer = null; }
+      if (this._scanListener) { leaveSharedTimer(this._scanListener); this._scanListener = null; }
     },
     initChart() {
       const chartDom = this.$refs.chartRef;
@@ -153,13 +194,13 @@ export default {
             label: {
               show: this.showScanLineLabel,
               position: 'start',
-              formatter: `${this.activeTime}/${realMaxTime}`,
+              formatter: `${Math.round(this.activeTime)}/${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)),
               offset: [0, Math.round(1 * s)]
             },
-            lineStyle: { color: COLORS.MARK_BLUE, type: 'solid', width: Math.max(1, Math.round(2 * s)), z: 100 },
+            lineStyle: { color: COLORS.MARK_BLUE, type: 'solid', width: Math.max(2, Math.round(5 * s)), z: 100 },
             data: [{ xAxis: this.activeTime }]
           }
         }]
@@ -198,12 +239,12 @@ export default {
             animation: false,
             label: {
               show: this.showScanLineLabel,
-              position: 'start', formatter: `${this.activeTime}/${realMaxTime}`,
+              position: 'start', formatter: `${Math.round(this.activeTime)}/${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)),
               offset: [0, Math.round(1 * s)]
             },
-            lineStyle: { color: COLORS.MARK_BLUE, type: 'solid', width: Math.max(1, Math.round(2 * s)), z: 100 },
+            lineStyle: { color: COLORS.MARK_BLUE, type: 'solid', width: Math.max(2, Math.round(5 * s)), z: 100 },
             data: [ { xAxis: this.activeTime } ]
           }
         }]

+ 4 - 2
src/mock/api.js

@@ -591,6 +591,9 @@ export async function apiGetCrossingList(params = {}) {
 
   // 动态状态:每次请求路口状态会变化
   const statuses = ['在线', '在线', '在线', '在线', '离线']
+  // 基于页码生成页级起始偏移(同一页固定,不同页不同)
+  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
@@ -603,7 +606,7 @@ export async function apiGetCrossingList(params = {}) {
       status: statuses[Math.floor(seededRand(i + 42) * statuses.length)],
       cycle: cycleLength,
       phaseData,
-      currentTime: Math.floor(seededRand(i + 7) * cycleLength),
+      currentTime: pageOffset,
     }
   })
 
@@ -643,7 +646,6 @@ export async function apiGetCrossingList(params = {}) {
   }
 
   // 分页
-  const page = params.page || 1
   const pageSize = params.pageSize || 10
   const total = list.length
   const totalPages = Math.ceil(total / pageSize)