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

相位图: 默认 S1-S4 占满视口, 超 4 阶段横滚露出 S5+ + scan line 自动跟随 + 滚轮纵转横

    - SignalTimingChart.vue 模板: chartRef 外加 scrollWrap (overflow-x:auto), chartRef 宽度由 JS
      命令式写 style.width = wrap × (realMax/S4End), 4 阶段以内清空 inline 落回 CSS width:100% 不滚.
      用响应式 :style 会让 initChart 同步抢跑刷在 DOM 之前, ECharts 抓到旧宽度死循环
    - 新增 _computeStagePoints (与 renderItem stagePoints 算法严格一致, 防漂移),
      _recalcContentWidth (mounted/wrap resize/phaseData 变更时触发),
      _setupWrapObserver (观察外层视口宽度, 内层 chartRef 由 echartsResize mixin 接管, 不重复),
      _setupWheelToHorizontal (passive:false wheel: deltaY/deltaX → scrollLeft, 不可滚时不接管),
      _followScanLine (autoScan 心跳里把 scan line 拉到视口中心, 接进 _scanListener)
    - updateScale 从 $el.clientWidth 改成 wrap.clientWidth: 横滚生效后 chartRef 变 N 倍宽,
      继续用它算 scaleFactor 会让字号/badge 失控膨胀
    - CSS: .chart-scroll 加 ::-webkit-scrollbar 细条 (6px 高 + 淡蓝 thumb, 沿用 .current-stage 风格)
      + scrollbar-width:thin, 防 Windows/macOS overlay scrollbar 隐身让用户以为不能滚
    - watch.phaseData / cycleLength 也触发 _recalcContentWidth, 4→32 阶段切换路口时跟上;
      beforeDestroy 清 wrap RO 和 wheel listener
画安 1 месяц назад
Родитель
Сommit
4616f4f043
1 измененных файлов с 139 добавлено и 11 удалено
  1. 139 11
      src/components/ui/SignalTimingChart.vue

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

@@ -1,5 +1,12 @@
 <template>
-  <div ref="chartRef" class="chart-container"></div>
+  <!-- chart-scroll: 外层视口, overflow-x:auto 提供原生横滚条
+       chart-container: 内层 ECharts 容器, 宽度由 _recalcContentWidth 命令式写 chartRef.style.width
+         - stageCount ≤ 4: 内宽 = wrap 宽度, 与容器同宽不滚
+         - stageCount > 4: 内宽 = wrap × (realMax / S4End), 多出部分横滚露出 S5+
+       注意: 不用 :style 绑定, 因为 Vue 响应式刷 DOM 是异步的, 会让 initChart 抓到旧宽度. -->
+  <div ref="scrollWrap" class="chart-scroll">
+    <div ref="chartRef" class="chart-container"></div>
+  </div>
 </template>
 
 <script>
@@ -84,7 +91,9 @@ export default {
     compactScanLine: { type: Boolean, default: false }
   },
   data() {
-    return { scaleFactor: 1, vScaleFactor: 1, internalTime: 0 };
+    // contentWidthPx: 内层 chart-container 的像素宽度记账 (autoScan follow 用)
+    // DOM 上的宽度由 _recalcContentWidth 命令式写 chartRef.style.width, 不走 :style 响应式
+    return { scaleFactor: 1, vScaleFactor: 1, internalTime: 0, contentWidthPx: 0 };
   },
   computed: {
     activeTime() {
@@ -111,11 +120,22 @@ export default {
   },
   mounted() {
     this.internalTime = this.currentTime;
-    this.initChart();
-    if (this.autoScan) this.startAutoScan();
+    // 先测量 wrap 算出 contentWidthPx, 再 initChart, 避免 ECharts 初始化时 chartRef 宽度为 0
+    this.$nextTick(() => {
+      this._recalcContentWidth();
+      this.initChart();
+      if (this.autoScan) this.startAutoScan();
+      this._setupWrapObserver();
+      this._setupWheelToHorizontal();
+    });
   },
   beforeDestroy() {
     this.stopAutoScan();
+    if (this._wrapRO) { this._wrapRO.disconnect(); this._wrapRO = null; }
+    if (this._wheelHandler && this.$refs.scrollWrap) {
+      this.$refs.scrollWrap.removeEventListener('wheel', this._wheelHandler);
+      this._wheelHandler = null;
+    }
   },
   watch: {
     currentTime() {
@@ -131,18 +151,101 @@ export default {
       this.updateChart();
       if (val && this.autoScan) { this.startAutoScan(); }
     },
-    phaseData: { deep: true, handler(newVal) { if (this.$_chart && newVal.length > 0) this.updateChart(); } },
+    // phaseData 变更 → 阶段数可能变 → contentWidthPx 要重算 (4→32 阶段切换时尤其关键)
+    phaseData: { deep: true, handler(newVal) {
+      if (this.$_chart && newVal.length > 0) {
+        this._recalcContentWidth();
+        this.updateChart();
+      }
+    } },
+    cycleLength() { this._recalcContentWidth(); },
     showAxis() { this.updateChart(); },
     showScanLineLabel() { this.updateChart(); }
   },
   methods: {
     updateScale() {
-      const el = this.$el;
-      if (!el) return;
-      this.scaleFactor = Math.max(0.5, el.clientWidth / 600);
+      // 横向 scale 基于 wrap (视口) 宽度而非 chartRef (内层) 宽度
+      // 否则横滚生效后 chartRef 变 N 倍宽, scaleFactor 也跟着膨胀, 字号/badge 失控
+      const wrap = this.$refs.scrollWrap || this.$el;
+      if (!wrap) return;
+      this.scaleFactor = Math.max(0.5, wrap.clientWidth / 600);
       // 垂直 scale:以 80px 容器高度为 1x 基准;矮容器拉小,高容器轻微放大
       // 给紧凑模式 markLine label 用,避免窄行 chart 中 badge 视觉过重
-      this.vScaleFactor = Math.max(0.4, Math.min(2, el.clientHeight / 80));
+      this.vScaleFactor = Math.max(0.4, Math.min(2, wrap.clientHeight / 80));
+    },
+    // 阶段边界: 与 renderCustomItem 里的 stagePoints 算法严格一致 (track 0 的 green 起始时刻 + 头尾)
+    _computeStagePoints() {
+      const realMax = this.getMaxTime();
+      let pts = (this.phaseData || []).filter(p => p[0] === 0 && p[5] === 'green').map(p => p[1]);
+      if (!pts.includes(0)) pts.unshift(0);
+      pts.push(realMax);
+      return Array.from(new Set(pts)).sort((a, b) => a - b);
+    },
+    // 重算内层 chart-container 像素宽度并命令式写到 DOM:
+    //   - 4 阶段以内 → 清空 inline style, fallback 到 CSS width:100% (与 wrap 同宽, 不滚)
+    //   - 超 4 阶段 → inline width = wrap × (realMax / S4End), 触发 chart-scroll 横滚
+    // 直接 style.width = 避免 Vue 响应式 :style 异步刷 DOM 导致 initChart 抓到旧宽度
+    _recalcContentWidth() {
+      const wrap = this.$refs.scrollWrap;
+      const chartDom = this.$refs.chartRef;
+      if (!wrap || !chartDom) return;
+      const wrapW = wrap.clientWidth;
+      if (wrapW <= 0) return;
+      const realMax = this.getMaxTime();
+      const stagePoints = this._computeStagePoints();
+      const stageCount = Math.max(1, stagePoints.length - 1);
+      // stagePoints[4] = 第 4 阶段末端 (S4End); stageCount<=4 时无第 5 项, visibleEnd 直接取 realMax 不滚
+      const visibleEnd = stageCount <= 4 ? realMax : stagePoints[4];
+      let newWidth;
+      if (!visibleEnd || visibleEnd <= 0 || stageCount <= 4) {
+        newWidth = wrapW;
+        chartDom.style.width = '';   // 让 CSS width:100% 接管
+      } else {
+        const ratio = realMax / visibleEnd;
+        newWidth = Math.max(wrapW, Math.round(wrapW * ratio));
+        chartDom.style.width = newWidth + 'px';
+      }
+      this.contentWidthPx = newWidth;
+    },
+    // 监听 wrap 尺寸变化: 父容器宽度变了 (面板缩放/多窗口切换) 要重算 contentWidthPx
+    // 内层 chartRef 宽度变化由 echartsResize mixin 自己接管, 不重复观察
+    _setupWrapObserver() {
+      if (this._wrapRO) this._wrapRO.disconnect();
+      const wrap = this.$refs.scrollWrap;
+      if (!wrap) return;
+      let timer = null;
+      this._wrapRO = new ResizeObserver(() => {
+        if (timer) clearTimeout(timer);
+        timer = setTimeout(() => requestAnimationFrame(() => this._recalcContentWidth()), 50);
+      });
+      this._wrapRO.observe(wrap);
+    },
+    // 鼠标滚轮纵滚 → 横滚转换: 普通鼠标只有 deltaY, 不接管的话用户在 chart 区域滚轮无任何反馈
+    // 触摸板 deltaX 已是横向, 也透传; passive:false 才能 preventDefault 阻止页面纵向滚动
+    _setupWheelToHorizontal() {
+      const wrap = this.$refs.scrollWrap;
+      if (!wrap) return;
+      this._wheelHandler = (e) => {
+        if (wrap.scrollWidth <= wrap.clientWidth) return;  // 不可滚时不接管, 让事件正常冒泡
+        const delta = e.deltaY !== 0 ? e.deltaY : e.deltaX;
+        if (delta === 0) return;
+        e.preventDefault();
+        wrap.scrollLeft += delta;
+      };
+      wrap.addEventListener('wheel', this._wheelHandler, { passive: false });
+    },
+    // autoScan 时让扫描线跟着滚动条走: scan line 落到视口中心位置 (clamp 到合法 scrollLeft 范围)
+    // 不滚条 (contentWidthPx <= wrapW) 时直接 return
+    _followScanLine() {
+      const wrap = this.$refs.scrollWrap;
+      if (!wrap || this.contentWidthPx <= 0) return;
+      const wrapW = wrap.clientWidth;
+      if (this.contentWidthPx <= wrapW) return;
+      const realMax = this.getMaxTime();
+      if (!realMax) return;
+      const scanX = this.contentWidthPx * (this.activeTime / realMax);
+      const target = Math.max(0, Math.min(this.contentWidthPx - wrapW, scanX - wrapW / 2));
+      wrap.scrollLeft = target;
     },
     // 扫描线 label 配置:紧凑模式跟随 min(横向, 纵向) scale 自适应,否则保持原始(10px font, [4,8] padding)
     _scanLabel(realMaxTime) {
@@ -194,6 +297,8 @@ export default {
           if (this.clipToActive) this.updateChart();
           else this.updateScanLine();
         }
+        // 横滚生效时让 scan line 自动居中可见 (4 阶段以内 _followScanLine 内部会 return)
+        this._followScanLine();
         this.$emit('scan-tick', this.internalTime);
       };
       joinGlobalTimer(this._scanListener);
@@ -518,7 +623,30 @@ export default {
 </script>
 
 <style scoped>
-.chart-container { 
-  width: 100%; height: 100%; flex: 1; min-height: 0; overflow: hidden;
+/* 外层视口: 提供原生横滚, 4 阶段以内不滚 (内容宽 == 视口宽) 也不预留滚条占位 */
+.chart-scroll {
+  width: 100%; height: 100%; flex: 1;
+  min-width: 0; min-height: 0;
+  overflow-x: auto; overflow-y: hidden;
+  /* Firefox 常驻细条 */
+  scrollbar-width: thin;
+  scrollbar-color: rgba(161, 190, 255, 0.45) rgba(255, 255, 255, 0.04);
+}
+/* WebKit/Blink (Chrome/Edge/Electron) 常驻细条: 防 overlay scrollbar 隐身让用户以为不能滚 */
+.chart-scroll::-webkit-scrollbar { height: 6px; }
+.chart-scroll::-webkit-scrollbar-track {
+  background: rgba(255, 255, 255, 0.04);
+  border-radius: 3px;
+}
+.chart-scroll::-webkit-scrollbar-thumb {
+  background: rgba(161, 190, 255, 0.45);
+  border-radius: 3px;
+}
+.chart-scroll::-webkit-scrollbar-thumb:hover {
+  background: rgba(161, 190, 255, 0.75);
+}
+/* 内层 chart 容器: 默认 100%, 阶段>4 时 JS 命令式写 style.width (大于 wrap), 触发横滚 */
+.chart-container {
+  width: 100%; height: 100%;
 }
 </style>