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

路口详情:双相位图(实时 + 上周期)+ 32/16 阶段示例 + 摄像头弹窗满铺布局
- SignalTimingChart.vue:新增 clipToActive 生长条模式(裁断 endTime > activeTime 的相位
到当前秒数,supports 实时图"已运行多少显多少");新增 compactScanLine 紧凑扫描线模式
(线粗 1px、badge 自适应 fontSize/padding/lineHeight,verticalAlign:bottom 防顶边裁切,
offset[0,2] 视觉下沉 2px);阶段刻度 S1/S2... 字号双重约束(按阶段数 byCount + 按格宽
byWidth 取小,下限 10px、上限 14*s),TICK_GAP=3 统一 axisBaseY 与 axisTop 计算口径;
grid.top 三档(showScanLineLabel→紧凑/原始 35*s、showAxis→动态 axisTop、其它 0);
vScaleFactor 引入按 clientHeight/80 做垂直 scale,badge 在窄行 chart 中自动缩小

- CrossingDetailPanel.vue:双相位图模式(数据存在 thisCycle 时 isDual=true);新增
content-row 包装层(display:contents 单图透明、双图显形为 row 容器),根节点 .is-dual
时 column 布局,相位图作为整宽底部行跨左右两栏;本周期实时图开 clipToActive +
compactScanLine + showScanLineLabel;上周期图静态全展;row-label 单行 nowrap+ellipsis
防多窗口换行挤压 chart;row-chart overflow:hidden 防御越界;onScanTick 检测 wrap-around
自动 refetchDetail 让后端把刚结束周期作为 lastCycle 返回;wrap 高度 clamp(100,170*s,170)、
header line-height 1 + padding 2px 0 0 收紧首屏 chrome

- IntersectionMapVideos.vue:修复 .detail-panel-right 选择器——双图模式被包进 .content-row
后不再是 .crossing-detail-panel 直接子元素,:scope > 选不到导致摄像头/检测器弹窗 fallback
到默认居中位置,改用 descendant 选择器;摄像头视频弹窗布局重构:宽度 100% 铺满右侧控制区,
高度 70%,4 方向 N/E/S/W 在 Y 轴等距下移(10% 步进),N 贴顶 W 贴底,仅纵向堆叠不做横向偏移

- mock/api.js:新增 _makeFlexiblePhaseData(cycleLength, stageCount) 任意阶段数相位生成器
(阶段时间均分 + 子段自适应:stageTime≥4 才有 stripe,≥8 才有 red,避免 0 时长子段);
apiGetCrossingDetailData 加 _dualSamples 查表分支(JNC900032 32 阶段 / JNC900016 16 阶段,
cycleLength 160),返回 thisCycle/lastCycle 双结构(actualDuration=cycleLength+2 模拟实绩
拉伸);图标对齐 _makePhaseData simple 模式:P2 TURN_DOWN_LEFT,TURN_UP_LEFT_UTURN、
P4 TURN_LEFT_DOWN,TURN_RIGHT_UP_UTURN

- mock_data.json:插入 JNC900032/JNC900016 两个示例路口(trafficSignalMenuTree×2、crossingList、
sampleIntersectionConfigs,含 4 摄像头),armsConfig 对齐 simple 标准(N/E=[L,S,null,null]、
S/W=[U,S,null,null])

画安 1 месяц назад
Родитель
Сommit
ff0b158558

+ 158 - 16
src/components/ui/CrossingDetailPanel.vue

@@ -1,23 +1,26 @@
 <template>
-    <div class="crossing-detail-panel">
-        <div class="detail-panel-left">
-            <div class="intersection-video-wrap">
-                <IntersectionMapVideos :mapData="intersectionData" />
-            </div>
-            <div class="signal-timing-wrap">
-                <div class="header">
-                    <div class="title-area">
-                        <span class="main-title">方案状态</span>
-                        <span class="sub-info">(周期: {{ cycleLength }} 相位差: {{ phaseDiff }} 协调时间: {{ coordTime }})</span>
+    <div class="crossing-detail-panel" :class="{ 'is-dual': isDual }">
+        <!-- content-row:单图模式 display:contents 透明(保持原 row 布局),双图模式显形为 row 容器 -->
+        <div class="content-row">
+            <div class="detail-panel-left">
+                <div class="intersection-video-wrap">
+                    <IntersectionMapVideos :mapData="intersectionData" />
+                </div>
+                <!-- 单图模式:相位图保持在左侧栏内 -->
+                <div v-if="!isDual" class="signal-timing-wrap">
+                    <div class="header">
+                        <div class="title-area">
+                            <span class="main-title">方案状态</span>
+                            <span class="sub-info">(周期: {{ cycleLength }} 相位差: {{ phaseDiff }} 协调时间: {{ coordTime }})</span>
+                        </div>
                     </div>
+                    <SignalTimingChart :cycleLength="cycleLength" :currentTime="currentSec"
+                        :phaseData="mockPhaseData" :showScanLine="dataReady" :autoScan="dataReady"
+                        @scan-tick="onScanTick" />
                 </div>
-
-                <SignalTimingChart :cycleLength="cycleLength" :currentTime="currentSec" :phaseData="mockPhaseData"
-                    :showScanLine="dataReady" :autoScan="dataReady" @scan-tick="onScanTick" />
             </div>
-        </div>
 
-        <div class="detail-panel-right">
+            <div class="detail-panel-right">
             <form class="detail-right-form" @submit.prevent>
                 <div class="form-group">
                     <div class="control-method">
@@ -137,6 +140,40 @@
                 </div>
             </form>
         </div>
+        </div><!-- /.content-row -->
+
+        <!-- 双图模式:相位图作为整宽底部行(跨左右两栏) -->
+        <div v-if="isDual" class="signal-timing-wrap is-dual">
+            <div class="header">
+                <div class="title-area">
+                    <span class="main-title">方案状态</span>
+                    <span class="sub-info">(周期: {{ cycleLength }} 相位差: {{ phaseDiff }} 协调时间: {{ coordTime }})</span>
+                </div>
+            </div>
+            <div class="timing-row timing-row-live">
+                <div class="row-label">
+                    本周期 实时<span v-if="thisCycle"> · {{ thisCycle.schemeName }}</span>
+                </div>
+                <div class="row-chart">
+                    <SignalTimingChart :cycleLength="thisCycle.cycleLength" :currentTime="currentSec"
+                        :phaseData="thisCycle.phaseData" :showScanLine="dataReady" :showScanLineLabel="dataReady"
+                        :clipToActive="true" :compactScanLine="true" :autoScan="dataReady"
+                        @scan-tick="onScanTick" />
+                </div>
+            </div>
+            <div class="timing-row timing-row-last">
+                <div class="row-label">
+                    上周期 方案
+                    <span v-if="lastCycle"> · 实际 {{ lastCycle.actualDuration }}s / 计划 {{ lastCycle.cycleLength }}s</span>
+                </div>
+                <div class="row-chart">
+                    <SignalTimingChart v-if="lastCycle" :cycleLength="lastCycle.cycleLength" :currentTime="0"
+                        :phaseData="lastCycle.phaseData" :showScanLine="false" :showScanLineLabel="false" />
+                    <div v-else class="empty-placeholder">暂无上周期数据</div>
+                </div>
+            </div>
+        </div>
+
         <!-- 步进锁定时间弹窗 -->
         <transition name="fade">
             <div class="lock-time-overlay" v-if="showLockTime" @click.self="showLockTime = false">
@@ -238,10 +275,18 @@ export default {
             locktimeOptions: [],
             currentStage: '1',
             // 补充了 time 属性,用于双向绑定输入框的时间
-            currentStageList: []
+            currentStageList: [],
+
+            // 双相位图模式数据(仅当后端返回 thisCycle/lastCycle 时启用)
+            thisCycle: null,
+            lastCycle: null,
         }
     },
     computed: {
+        // 双相位图模式:后端提供了 thisCycle 时启用,否则保持单图行为
+        isDual() {
+            return !!(this.thisCycle && this.thisCycle.phaseData && this.thisCycle.phaseData.length);
+        },
         // 黄闪、关灯、全红时禁用控制方案
         isSchemeDisabled() {
             return ['yellow_flash', 'lights_off', 'all_red'].includes(this.currentMethod);
@@ -358,6 +403,25 @@ export default {
 
             // 更新实时方案圆饼图的已走时长
             this.updateRealtimeDonut(activeTime);
+
+            // 双相位图模式:检测周期 wrap-around,触发 refetch 让后端把刚结束的周期作为 lastCycle 返回
+            if (this.isDual) {
+                if (this._prevTick != null && activeTime < this._prevTick - 1) {
+                    this.refetchDetail();
+                }
+                this._prevTick = activeTime;
+            }
+        },
+        async refetchDetail() {
+            if (this._refetching) return;
+            this._refetching = true;
+            try {
+                const nodeId = this.$attrs.id || this.id;
+                const data = await apiGetCrossingDetailData(nodeId, { iconMode: this.iconMode });
+                if (data) this.applyData(data);
+            } finally {
+                this._refetching = false;
+            }
         },
         // 从 phaseData 解析4个阶段的绿灯时长,构建圆饼图数据
         buildDonutFromPhaseData() {
@@ -442,6 +506,9 @@ export default {
             this.phaseDiff = data.phaseDiff || 0;
             this.coordTime = data.coordTime || 0;
             this.currentStageList = data.stageList || [];
+            // 双相位图字段:后端没返回时为 null,UI 自动退化为单图模式
+            this.thisCycle = data.thisCycle || null;
+            this.lastCycle = data.lastCycle || null;
             this.buildDonutFromPhaseData();
             this.$nextTick(() => {
                 this.dataReady = true;
@@ -561,6 +628,28 @@ export default {
     overflow: hidden;
 }
 
+/* 双图模式:根节点变 column,content-row 变 row 容器,相位图作为整宽底部行 */
+.crossing-detail-panel.is-dual {
+    flex-direction: column;
+    gap: clamp(4px, calc(var(--s) * 8px), 8px);
+}
+
+/* 单图模式:content-row 透明,子节点直接挂到根的 row 布局上(保持原行为) */
+.content-row {
+    display: contents;
+}
+
+/* 双图模式:content-row 显形为 row,承载视频+表单 */
+.crossing-detail-panel.is-dual .content-row {
+    display: flex;
+    flex-direction: row;
+    gap: clamp(4px, calc(var(--s) * 12px), 12px);
+    flex: 1 1 0;
+    min-width: 0;
+    min-height: 0;
+    width: 100%;
+}
+
 /* ===== 左侧:还原原始固定 55% 占比 ===== */
 .detail-panel-left {
     display: flex;
@@ -602,6 +691,59 @@ export default {
     padding: clamp(3px, calc(var(--s) * 10px), 10px);
 }
 
+/* 双相位图模式:整宽底部行 */
+.signal-timing-wrap.is-dual {
+    height: clamp(100px, calc(var(--s) * 170px), 170px);
+    padding: 0 clamp(3px, calc(var(--s) * 10px), 10px);
+}
+/* 双图模式下 header 紧贴下方相位图,避免无意义空白 */
+.signal-timing-wrap.is-dual .header {
+    margin-bottom: 0;
+    line-height: 1;
+    padding: 2px 0 0;
+}
+.signal-timing-wrap.is-dual .main-title {
+    font-size: clamp(10px, calc(var(--s) * 14px), 14px);
+}
+.signal-timing-wrap.is-dual .sub-info {
+    font-size: clamp(9px, calc(var(--s) * 12px), 12px);
+}
+.signal-timing-wrap.is-dual .timing-row {
+    flex: 1 1 0;
+    min-height: 0;
+    display: flex;
+    flex-direction: column;
+}
+.signal-timing-wrap.is-dual .row-label {
+    flex: 0 0 auto;
+    font-size: clamp(8px, calc(var(--s) * 11px), 11px);
+    color: #9ca3af;
+    padding: 0 4px;
+    line-height: 1;
+    margin-bottom: 2px;
+    /* 强制单行 + 省略号:多窗口窄屏下"上周期 实际 / 计划" 这类长文本曾换成 2 行,
+       挤压自己 row 内 chart 高度并视觉撑出上一条 chart 的 canvas 边界 */
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+.signal-timing-wrap.is-dual .row-chart {
+    flex: 1 1 0;
+    min-height: 0;
+    display: flex;
+    position: relative;
+    /* 防御性:ECharts 极端情况下 label/markLine 可能溢出 canvas 边界,加裁切兜底 */
+    overflow: hidden;
+}
+.signal-timing-wrap.is-dual .empty-placeholder {
+    flex: 1;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    color: #6b7280;
+    font-size: clamp(10px, calc(var(--s) * 12px), 12px);
+}
+
 .header {
     display: flex;
     justify-content: space-between;

+ 17 - 24
src/components/ui/IntersectionMapVideos.vue

@@ -683,7 +683,9 @@ export default {
       const DESIGN_WIDTH = 1920;
       const scale = window.innerWidth / DESIGN_WIDTH;
       const root = this.$el && this.$el.closest && this.$el.closest('.crossing-detail-panel');
-      const rightPanel = root && root.querySelector(':scope > .detail-panel-right');
+      // 双图模式下 .detail-panel-right 被包进 .content-row 不再是 .crossing-detail-panel 的直接子元素,
+      // 改用 descendant 选择器,单图/双图两种结构都能命中
+      const rightPanel = root && root.querySelector('.detail-panel-right');
 
       if (rightPanel && scale > 0) {
         const rect = rightPanel.getBoundingClientRect();
@@ -775,44 +777,35 @@ export default {
     },
 
     /** 摄像头视频弹窗在 .detail-panel-right 内的位置/尺寸:
-     *  - 尺寸:取右侧面板的 70% × 70%,单窗口下封顶(避免太大),多窗口下自然缩小贴合面板
-     *  - 位置:按方向 N→0、E→1、S→2、W→3 做层叠偏移(cascade),右下方向逐个偏移 STEP 像素
-     *  - 偏移量被夹紧到 (面板宽-弹窗宽, 面板高-弹窗高) 之内,保证不溢出右侧控制方式区 */
+     *  - 宽度:100% 铺满右侧控制区
+     *  - 高度:70% 面板高度,留 30% 给 4 个方向弹窗做纵向层叠
+     *  - 位置:N→0、E→1、S→2、W→3 在 Y 轴上等距下移;X 始终顶左对齐(不做横向偏移)
+     *  - 4 个全开时第一个贴顶、最后一个贴底,相邻间距 = (面板高 × 30%) / 3 = 10% 面板高 */
     _calcCameraDialogRect(dir) {
       const DESIGN_WIDTH = 1920;
       const scale = window.innerWidth / DESIGN_WIDTH;
       const root = this.$el && this.$el.closest && this.$el.closest('.crossing-detail-panel');
-      const rightPanel = root && root.querySelector(':scope > .detail-panel-right');
+      // 双图模式下 .detail-panel-right 被包进 .content-row 不再是直接子元素,用 descendant 选择器兼容两种结构
+      const rightPanel = root && root.querySelector('.detail-panel-right');
       if (!(rightPanel && scale > 0)) return null;
       const rect = rightPanel.getBoundingClientRect();
       if (!(rect.width > 0 && rect.height > 0)) return null;
 
-      const DIR_TO_IDX = { N: 0, E: 1, S: 2, W: 3 };
-      const STEP = 18;          // 层叠步进(设计像素)
-      const MAX_W = 420;        // 单窗口大尺寸下弹窗宽度封顶
-      const MAX_H = 300;
-
       const wDesign = rect.width / scale;
       const hDesign = rect.height / scale;
-
-      // 弹窗本体尺寸:取面板 70%,但不超过 MAX,也不小于面板(多窗口)
-      const W = Math.max(40, Math.min(MAX_W, Math.floor(wDesign * 0.7)));
-      const H = Math.max(40, Math.min(MAX_H, Math.floor(hDesign * 0.7)));
-
-      // 层叠偏移:每个方向独立索引,超出最大可偏移距离则夹紧到边界
+      const DIR_TO_IDX = { N: 0, E: 1, S: 2, W: 3 };
       const idx = DIR_TO_IDX[dir] !== undefined ? DIR_TO_IDX[dir] : 0;
-      const maxOffsetX = Math.max(0, wDesign - W);
-      const maxOffsetY = Math.max(0, hDesign - H);
-      const offsetX = Math.min(idx * STEP, maxOffsetX);
-      const offsetY = Math.min(idx * STEP, maxOffsetY);
+
+      const dialogH = Math.floor(hDesign * 0.7);
+      const stepY = Math.max(0, Math.floor((hDesign - dialogH) / 3));
 
       return {
         position: {
-          x: rect.left / scale + offsetX,
-          y: rect.top / scale + offsetY,
+          x: rect.left / scale,
+          y: rect.top / scale + idx * stepY,
         },
-        width: W,
-        height: H,
+        width: Math.round(wDesign),
+        height: dialogH,
       };
     },
 

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

@@ -92,20 +92,44 @@ export default {
   name: 'SignalTimingChart',
   mixins: [echartsResize],
   props: {
-    cycleLength: { type: Number, default: 140 }, 
+    cycleLength: { type: Number, default: 140 },
     currentTime: { type: Number, default: 0 },
     phaseData: { type: Array, default: () => [] },
     showAxis: { type: Boolean, default: true },
     showScanLine: { type: Boolean, default: true },
     showScanLineLabel: { type: Boolean, default: true },
-    autoScan: { type: Boolean, default: false }
+    autoScan: { type: Boolean, default: false },
+    // 只渲染已运行的部分(生长条模式):endTime > activeTime 的相位裁断到 activeTime,
+    // 之后的相位整段不渲染。配合 showScanLine=false 用于"实时方案"图。
+    clipToActive: { type: Boolean, default: false },
+    // 紧凑型扫描线 + badge:用于双相位图等多阶段/小行高场景,badge 尺寸跟随容器自适应、grid 留白动态收紧
+    // 默认 false,单图 4 阶段保持原配色与尺寸,不破坏既有视觉
+    compactScanLine: { type: Boolean, default: false }
   },
   data() {
-    return { scaleFactor: 1, internalTime: 0 };
+    return { scaleFactor: 1, vScaleFactor: 1, internalTime: 0 };
   },
   computed: {
     activeTime() {
       return this.autoScan ? this.internalTime : this.currentTime;
+    },
+    effectivePhaseData() {
+      if (!this.clipToActive) return this.phaseData;
+      const t = this.activeTime;
+      if (t <= 0) return [];
+      const out = [];
+      for (const item of this.phaseData) {
+        const start = item[1];
+        const end = item[2];
+        if (start >= t) continue;        // 整段未开始
+        if (end <= t) { out.push(item); continue; } // 整段已完成
+        // 跨界裁断:复制一份,把 endTime/duration 截到 t
+        const clipped = item.slice();
+        clipped[2] = t;
+        clipped[4] = t - start;
+        out.push(clipped);
+      }
+      return out;
     }
   },
   mounted() {
@@ -117,9 +141,10 @@ export default {
     this.stopAutoScan();
   },
   watch: {
-    currentTime(val) {
-      if (!this.autoScan) {
-        if (this.$_chart) this.updateScanLine();
+    currentTime() {
+      if (!this.autoScan && this.$_chart) {
+        if (this.clipToActive) this.updateChart();
+        else this.updateScanLine();
       }
     },
     autoScan(val) {
@@ -138,6 +163,47 @@ export default {
       const el = this.$el;
       if (!el) return;
       this.scaleFactor = Math.max(0.5, el.clientWidth / 600);
+      // 垂直 scale:以 80px 容器高度为 1x 基准;矮容器拉小,高容器轻微放大
+      // 给紧凑模式 markLine label 用,避免窄行 chart 中 badge 视觉过重
+      this.vScaleFactor = Math.max(0.4, Math.min(2, el.clientHeight / 80));
+    },
+    // 扫描线 label 配置:紧凑模式跟随 min(横向, 纵向) scale 自适应,否则保持原始(10px font, [4,8] padding)
+    _scanLabel(realMaxTime) {
+      const s = this.scaleFactor;
+      const base = {
+        show: this.showScanLineLabel,
+        position: 'start',
+        formatter: `${Math.round(this.activeTime)}/${realMaxTime}`,
+        color: '#fff',
+        backgroundColor: COLORS.MARK_BLUE,
+      };
+      if (this.compactScanLine) {
+        const cs = Math.min(s, this.vScaleFactor);
+        const fs = Math.max(8, Math.round(10 * cs));
+        return {
+          ...base,
+          padding: [Math.max(1, Math.round(1 * cs)), Math.max(2, Math.round(3 * cs))],
+          borderRadius: 2,
+          fontSize: fs,
+          lineHeight: fs,
+          // bottom 对齐:badge 整个坐落在 grid.top 预留区里,不会被 canvas 顶边裁
+          // offset y=2:在 bottom 锚点基础上整体下移 2px,与下方 chart 留出视觉间距
+          verticalAlign: 'bottom',
+          offset: [0, 2],
+        };
+      }
+      // 原始(4 阶段单图)样式:保持向后兼容
+      return {
+        ...base,
+        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)],
+      };
+    },
+    _scanLineWidth() {
+      const s = this.scaleFactor;
+      return this.compactScanLine ? 1 : Math.max(2, Math.round(5 * s));
     },
     startAutoScan() {
       this.stopAutoScan();
@@ -145,7 +211,12 @@ export default {
       this._scanListener = (nowSec) => {
         const realMax = this.getMaxTime();
         this.internalTime = nowSec % realMax;
-        if (this.$_chart) this.updateScanLine();
+        if (this.$_chart) {
+          // clipToActive 模式:activeTime 变了→ effectivePhaseData 跟着变 → 必须全量重绘
+          // 普通模式:只动扫描线即可
+          if (this.clipToActive) this.updateChart();
+          else this.updateScanLine();
+        }
         this.$emit('scan-tick', this.internalTime);
       };
       joinGlobalTimer(this._scanListener);
@@ -168,7 +239,6 @@ export default {
     updateScanLine() {
       if (!this.$_chart) return;
       this.updateScale();
-      const s = this.scaleFactor;
       const realMaxTime = this.getMaxTime();
       this.$_chart.setOption({
         series: [{
@@ -176,16 +246,8 @@ export default {
             symbol: ['none', 'none'],
             silent: true,
             animation: false,
-            label: {
-              show: this.showScanLineLabel,
-              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(2, Math.round(5 * s)), z: 100 },
+            label: this._scanLabel(realMaxTime),
+            lineStyle: { color: COLORS.MARK_BLUE, type: 'solid', width: this._scanLineWidth(), z: 100 },
             data: [{ xAxis: this.activeTime }]
           }
         }]
@@ -202,35 +264,58 @@ export default {
       const yAxisData = isTwoRows ? ['Track 0', 'Track 1'] : ['Track 0'];
       const realMaxTime = this.getMaxTime();
 
+      // 阶段刻度区动态高度:根据预估字号紧贴下沉,避免固定 18*s 在 32 阶段时浪费空间
+      // gap = 刻度短线底端 与 色块顶端 的视觉间距
+      let axisTop = 0;
+      const TICK_GAP = 3;
+      if (this.showAxis) {
+        const greenCount = (this.phaseData || []).filter(p => p[0] === 0 && p[5] === 'green').length || 1;
+        const byCount = Math.max(10, 14 - Math.max(0, greenCount - 4) * 0.25);
+        const fs = Math.max(10, Math.min(Math.round(14 * s), Math.floor(byCount)));
+        const tickHalf = Math.max(3, Math.round(fs * 0.4));
+        axisTop = Math.ceil(Math.max(2 * tickHalf + TICK_GAP, tickHalf + TICK_GAP + fs / 2) + 1);
+      }
+
+      // grid.top/bottom 留白策略:
+      //  - 紧凑模式(compactScanLine):按 badge 自适应取大于 axisTop 的最小值,bottom 也压缩到 4*s
+      //  - 普通模式(默认 4 阶段):showScanLineLabel 或 showAxis 都给原始 35*s/10*s 留白,确保大 badge 能完整渲染
+      let topReserve;
+      let bottomReserve;
+      if (this.showScanLineLabel) {
+        if (this.compactScanLine) {
+          const badgeScale = Math.min(s, this.vScaleFactor);
+          // badge 总高 ≈ fontSize + 上下 padding*2 ≈ 10*scale + 2 (≥ 10px);再 +2px 透气
+          topReserve = Math.max(axisTop, Math.max(10, Math.round(12 * badgeScale) + 2));
+          bottomReserve = Math.round(4 * s);
+        } else {
+          topReserve = Math.round(35 * s);
+          bottomReserve = Math.round(10 * s);
+        }
+      } else if (this.showAxis) {
+        topReserve = axisTop;
+        bottomReserve = Math.round(4 * s);
+      } else {
+        topReserve = 0;
+        bottomReserve = 0;
+      }
+
       return {
         backgroundColor: 'transparent',
-        grid: { 
-          left: 0, right: 0, 
-          // 当隐藏坐标轴/扫描线时(即在表格中显示时),将上下边距设为 0,让色块铺满高度
-          top: (this.showAxis || this.showScanLineLabel) ? Math.round(35 * s) : 0, 
-          bottom: (this.showAxis || this.showScanLineLabel) ? Math.round(10 * s) : 0,
-          containLabel: false 
-        },
+        grid: { left: 0, right: 0, top: topReserve, bottom: bottomReserve, containLabel: false },
         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,
+          data: this.effectivePhaseData,
           markLine: !this.showScanLine ? false : {
             symbol: ['none', 'none'],
             silent: true,
             animation: false,
-            label: {
-              show: this.showScanLineLabel,
-              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(2, Math.round(5 * s)), z: 100 },
-            data: [ { xAxis: this.activeTime } ]
+            label: this._scanLabel(realMaxTime),
+            lineStyle: { color: COLORS.MARK_BLUE, type: 'solid', width: this._scanLineWidth(), z: 100 },
+            data: [{ xAxis: this.activeTime }]
           }
         }]
       };
@@ -266,25 +351,36 @@ export default {
 
       // 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.push(realMaxTime);
         stagePoints = Array.from(new Set(stagePoints)).sort((a, b) => a - b);
 
+        // 自适应字号:受阶段数 + 每段像素宽度双重约束,但下限保持可读性 (≥10px)
+        const stageCount = Math.max(1, stagePoints.length - 1);
+        const cellW = params.coordSys.width / stageCount;
+        const byCount = Math.max(10, 14 - Math.max(0, stageCount - 4) * 0.25);
+        const byWidth = Math.max(10, cellW * 0.36);
+        const stageFontSize = Math.max(10, Math.min(Math.round(14 * s), Math.floor(Math.min(byCount, byWidth))));
+        const tickHalf = Math.max(3, Math.round(stageFontSize * 0.4));
+        const textHalf = Math.max(7, Math.round(stageFontSize * 1.0));
+        // axisBaseY:tick 底端 与 色块顶边 留 3px 间隙
+        // 与 getChartOption 里 axisTop 计算的 TICK_GAP 严格对齐,避免刻度/文字越界
+        const TICK_GAP = 3;
+        const axisBaseY = params.coordSys.y - tickHalf - TICK_GAP;
+
         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)) } });
+          children.push({ type: 'line', shape: { x1: x, y1: axisBaseY - tickHalf, x2: x, y2: axisBaseY + tickHalf }, style: { stroke: COLORS.AXIS_LINE, lineWidth: 1 } });
         });
         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: 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' } });
+          children.push({ type: 'line', shape: { x1: startX, y1: axisBaseY, x2: midX - textHalf, y2: axisBaseY }, style: { stroke: COLORS.AXIS_LINE, lineWidth: 1 } });
+          children.push({ type: 'line', shape: { x1: midX + textHalf, y1: axisBaseY, x2: endX, y2: axisBaseY }, style: { stroke: COLORS.AXIS_LINE, lineWidth: 1 } });
+          children.push({ type: 'text', style: { text: `S${i + 1}`, x: midX, y: axisBaseY, fill: COLORS.TEXT_LIGHT, fontSize: stageFontSize, align: 'center', verticalAlign: 'middle', fontWeight: 'bold' } });
         }
       }
 
@@ -432,7 +528,7 @@ export default {
       // 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)) } });
+        children.push({ type: 'line', shape: { x1: start[0], y1: dividerY, x2: end[0], y2: dividerY }, style: { stroke: COLORS.DIVIDER_LINE, lineWidth: 1 } });
       }
 
       return { type: 'group', children: children };

+ 89 - 2
src/mock/api.js

@@ -374,6 +374,54 @@ function _makePhaseData(cycleLength = 140, isTwoRows = true, iconMode = 'default
   return pd;
 }
 
+/**
+ * 任意阶段数的相位数据生成(不缓存,每次新生成)
+ * 用于 JNC900032 这类需要演示 N 阶段双相位图的示例路口。
+ * 单轨道(trackIdx=0),4 方向 icon 轮转,子段长度按阶段时间自适应。
+ * @param {number} cycleLength 周期总时长(秒)
+ * @param {number} stageCount 阶段数
+ */
+function _makeFlexiblePhaseData(cycleLength, stageCount) {
+  const baseStage = Math.floor(cycleLength / stageCount);
+  const remainder = cycleLength - baseStage * stageCount;
+  const stageTimes = Array.from({ length: stageCount }, (_, i) =>
+    baseStage + (i === 0 ? remainder : 0));
+
+  // 与 _makePhaseData(iconMode='simple') 对齐:P2 北左转+南掉头、P4 东左转+西掉头
+  // dual 示例路口(JNC900032/JNC900016)通过 apiGetCrossingDetailData 走 simple 模式,图标必须一致
+  const iconCycle = [
+    { icon: 'STRAIGHT_DOWN,STRAIGHT_UP', direction: 'ns' },              // P1 南北直行
+    { icon: 'TURN_DOWN_LEFT,TURN_UP_LEFT_UTURN', direction: 'ns' },      // P2 北左转 + 南掉头
+    { icon: 'STRAIGHT_LEFT,STRAIGHT_RIGHT', direction: 'ew' },           // P3 东西直行
+    { icon: 'TURN_LEFT_DOWN,TURN_RIGHT_UP_UTURN', direction: 'ew' },     // P4 东左转 + 西掉头
+  ];
+
+  const pd = [];
+  let t = 0;
+  for (let i = 0; i < stageCount; i++) {
+    const stageTime = stageTimes[i];
+    const cfg = iconCycle[i % iconCycle.length];
+    const phaseName = `P${i + 1}`;
+
+    // 子段长度自适应:阶段时间太短就退化为纯 green,避免出现 0 时长子段
+    const s = stageTime >= 4 ? Math.max(1, Math.min(3, Math.floor(stageTime * 0.15))) : 0;
+    const y = stageTime >= 3 ? Math.max(1, Math.min(3, Math.floor(stageTime * 0.15))) : 0;
+    const r = stageTime >= 8 ? Math.max(0, Math.min(2, Math.floor(stageTime * 0.10))) : 0;
+    const g = stageTime - s - y - r;
+    if (g <= 0) {
+      pd.push([0, t, t + stageTime, phaseName, stageTime, 'green', cfg.icon, cfg.direction, stageTime]);
+      t += stageTime;
+      continue;
+    }
+    pd.push([0, t, t + g, phaseName, g, 'green', cfg.icon, cfg.direction, stageTime]);
+    t += g;
+    if (s > 0) { pd.push([0, t, t + s, '', s, 'stripe', null, cfg.direction]); t += s; }
+    if (y > 0) { pd.push([0, t, t + y, '', y, 'yellow', null, cfg.direction]); t += y; }
+    if (r > 0) { pd.push([0, t, t + r, '', r, 'red', null, cfg.direction]); t += r; }
+  }
+  return pd;
+}
+
 function _makeCornerVideos() {
   return { nw: video1, ne: video2, sw: video3, se: video4 }
 }
@@ -1065,8 +1113,45 @@ export async function apiGetCrossingDetailData(id, { iconMode = 'default' } = {}
   }
 
   // 从真实阶段数据推导周期和相位
-  const cycleLength = _getCycleLength(id)
-  const phaseData = _makePhaseData(cycleLength, false, 'simple', id)
+  let cycleLength = _getCycleLength(id)
+  let phaseData = _makePhaseData(cycleLength, false, 'simple', id)
+
+  // 双相位图示例路口:N 阶段 + thisCycle/lastCycle 双结构
+  // 走独立的相位生成器;外层 phaseData/cycleLength 同步覆盖,保持向后兼容字段一致
+  let thisCycle = null
+  let lastCycle = null
+  const _dualSamples = {
+    JNC900032: { stageCount: 32, cycleLength: 160, schemeName: '32阶段示范方案' },
+    JNC900016: { stageCount: 16, cycleLength: 160, schemeName: '16阶段示范方案' },
+  }
+  if (_dualSamples[id]) {
+    const cfgSample = _dualSamples[id]
+    cycleLength = cfgSample.cycleLength
+    const planPhaseData = _makeFlexiblePhaseData(cycleLength, cfgSample.stageCount)
+    phaseData = planPhaseData
+
+    const nowSec = Math.floor(Date.now() / 1000)
+    const currentTimeIn = nowSec % cycleLength
+
+    thisCycle = {
+      schemeId: 'sys_a',
+      schemeName: cfgSample.schemeName,
+      cycleLength,
+      currentTime: currentTimeIn,
+      phaseData: planPhaseData,
+      phaseDiff: (seed * 7) % 25,
+      coordTime: (seed * 13) % 60,
+    }
+    // 上周期用同一份计划相位结构(演示用),actualDuration 模拟真实执行的±2s 拉伸
+    lastCycle = {
+      schemeId: 'sys_a',
+      schemeName: cfgSample.schemeName,
+      cycleLength,
+      actualDuration: cycleLength + 2,
+      endedAt: new Date((nowSec - currentTimeIn) * 1000).toISOString(),
+      phaseData: planPhaseData,
+    }
+  }
 
   // 从相位数据中提取阶段列表(优先上轨道绿灯相位,单轨道时取 track 0,最多4个)
   const hasTrack1 = phaseData.some(p => p[0] === 1)
@@ -1166,6 +1251,8 @@ export async function apiGetCrossingDetailData(id, { iconMode = 'default' } = {}
     currentTime: Math.floor(Date.now() / 1000) % cycleLength,
     phaseDiff,
     coordTime,
+    thisCycle,
+    lastCycle,
     stageList,
     schemeOptions,
     currentScheme: schemeOptions[0].value,

+ 92 - 0
src/mock/mock_data.json

@@ -7353,6 +7353,18 @@
                       "lat": 39.91
                     },
                     {
+                      "id": "JNC900032",
+                      "label": "[示例]32阶段双相位图路口",
+                      "lng": 116.71,
+                      "lat": 39.92
+                    },
+                    {
+                      "id": "JNC900016",
+                      "label": "[示例]16阶段双相位图路口",
+                      "lng": 116.72,
+                      "lat": 39.93
+                    },
+                    {
                       "id": "JNC000220",
                       "label": "TZ-367_畅和东路与大营东街路口",
                       "lng": 116.743531,
@@ -11833,6 +11845,18 @@
                       "lat": 39.91
                     },
                     {
+                      "id": "JNC900032",
+                      "label": "[示例]32阶段双相位图路口",
+                      "lng": 116.71,
+                      "lat": 39.92
+                    },
+                    {
+                      "id": "JNC900016",
+                      "label": "[示例]16阶段双相位图路口",
+                      "lng": 116.72,
+                      "lat": 39.93
+                    },
+                    {
                       "id": "JNC000220",
                       "label": "TZ-367_畅和东路与大营东街路口",
                       "lng": 116.743531,
@@ -26377,6 +26401,36 @@
       "isKey": true,
       "lng": 116.70,
       "lat": 39.91
+    },
+    {
+      "id": "JNC900032",
+      "index": 9032,
+      "name": "[示例]32阶段双相位图路口",
+      "subArea": "REG000200",
+      "ip": "192.168.99.232",
+      "status": "在线",
+      "timeOffset": "无偏差",
+      "cycle": 160,
+      "version": "V3.2.0",
+      "node": "通州节点1",
+      "isKey": true,
+      "lng": 116.71,
+      "lat": 39.92
+    },
+    {
+      "id": "JNC900016",
+      "index": 9016,
+      "name": "[示例]16阶段双相位图路口",
+      "subArea": "REG000200",
+      "ip": "192.168.99.216",
+      "status": "在线",
+      "timeOffset": "无偏差",
+      "cycle": 160,
+      "version": "V3.2.0",
+      "node": "通州节点1",
+      "isKey": true,
+      "lng": 116.72,
+      "lat": 39.93
     }
   ],
   "securityRoutes": [
@@ -28666,6 +28720,44 @@
         { "intersection": "[示例]五岔路口", "intersectionId": "JNC900005", "cameraId": "CAM900005_04", "loginName": "admin_y_04", "password": "******", "cameraType": "枪机", "port": 557, "ip": "192.168.99.203", "enabled": true, "position": "arm_4", "dirKey": "arm_4" },
         { "intersection": "[示例]五岔路口", "intersectionId": "JNC900005", "cameraId": "CAM900005_05", "loginName": "admin_y_05", "password": "******", "cameraType": "枪机", "port": 558, "ip": "192.168.99.204", "enabled": true, "position": "arm_5", "dirKey": "arm_5" }
       ]
+    },
+    "JNC900032": {
+      "signals": {
+        "pedAllRed": false,
+        "ns": { "phaseName": "P1", "time": 5, "isGreen": true,  "activeArrowTypes": ["S"] },
+        "ew": { "phaseName": "P1", "time": 5, "isGreen": false, "activeArrowTypes": [] }
+      },
+      "armsConfig": {
+        "N": { "lanes": ["L", "S", null, null], "cameraType": 1 },
+        "S": { "lanes": ["U", "S", null, null], "cameraType": 1 },
+        "E": { "lanes": ["L", "S", null, null], "cameraType": 1 },
+        "W": { "lanes": ["U", "S", null, null], "cameraType": 1 }
+      },
+      "cameras": [
+        { "intersection": "[示例]32阶段双相位图路口", "intersectionId": "JNC900032", "cameraId": "CAM900032_N", "loginName": "admin_32_n", "password": "******", "cameraType": "枪机", "port": 554, "ip": "192.168.99.232", "enabled": true, "position": "N", "dirKey": "N" },
+        { "intersection": "[示例]32阶段双相位图路口", "intersectionId": "JNC900032", "cameraId": "CAM900032_E", "loginName": "admin_32_e", "password": "******", "cameraType": "枪机", "port": 555, "ip": "192.168.99.232", "enabled": true, "position": "E", "dirKey": "E" },
+        { "intersection": "[示例]32阶段双相位图路口", "intersectionId": "JNC900032", "cameraId": "CAM900032_S", "loginName": "admin_32_s", "password": "******", "cameraType": "枪机", "port": 556, "ip": "192.168.99.232", "enabled": true, "position": "S", "dirKey": "S" },
+        { "intersection": "[示例]32阶段双相位图路口", "intersectionId": "JNC900032", "cameraId": "CAM900032_W", "loginName": "admin_32_w", "password": "******", "cameraType": "枪机", "port": 557, "ip": "192.168.99.232", "enabled": true, "position": "W", "dirKey": "W" }
+      ]
+    },
+    "JNC900016": {
+      "signals": {
+        "pedAllRed": false,
+        "ns": { "phaseName": "P1", "time": 7, "isGreen": true,  "activeArrowTypes": ["S"] },
+        "ew": { "phaseName": "P1", "time": 7, "isGreen": false, "activeArrowTypes": [] }
+      },
+      "armsConfig": {
+        "N": { "lanes": ["L", "S", null, null], "cameraType": 1 },
+        "S": { "lanes": ["U", "S", null, null], "cameraType": 1 },
+        "E": { "lanes": ["L", "S", null, null], "cameraType": 1 },
+        "W": { "lanes": ["U", "S", null, null], "cameraType": 1 }
+      },
+      "cameras": [
+        { "intersection": "[示例]16阶段双相位图路口", "intersectionId": "JNC900016", "cameraId": "CAM900016_N", "loginName": "admin_16_n", "password": "******", "cameraType": "枪机", "port": 554, "ip": "192.168.99.216", "enabled": true, "position": "N", "dirKey": "N" },
+        { "intersection": "[示例]16阶段双相位图路口", "intersectionId": "JNC900016", "cameraId": "CAM900016_E", "loginName": "admin_16_e", "password": "******", "cameraType": "枪机", "port": 555, "ip": "192.168.99.216", "enabled": true, "position": "E", "dirKey": "E" },
+        { "intersection": "[示例]16阶段双相位图路口", "intersectionId": "JNC900016", "cameraId": "CAM900016_S", "loginName": "admin_16_s", "password": "******", "cameraType": "枪机", "port": 556, "ip": "192.168.99.216", "enabled": true, "position": "S", "dirKey": "S" },
+        { "intersection": "[示例]16阶段双相位图路口", "intersectionId": "JNC900016", "cameraId": "CAM900016_W", "loginName": "admin_16_w", "password": "******", "cameraType": "枪机", "port": 557, "ip": "192.168.99.216", "enabled": true, "position": "W", "dirKey": "W" }
+      ]
     }
   },
   "trunkLineMenuTree": [