Browse Source

阶段图/相位图 icon 渲染重构: 固定 3-slot 让单/多 icon 位置一致; 按车道排序(右转→直行→左转); STRAIGHT 副轴对齐 TURN;编号 .phase-no 居中; 新增 4 个右转 SVG (镜像左转); 新增 JNC900099 Icon 组合演示路口 + SAMPLE_STAGE_LIST 双向 demo 数据

画安 1 week ago
parent
commit
ed523dd674

+ 11 - 0
src/assets/images/svg/icon_turn_down_right.svg

@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="37px" height="63px" viewBox="0 0 37 63" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <title>形状结合</title>
+    <g transform="translate(37, 0) scale(-1, 1)">
+        <g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+            <g id="转向指示" transform="translate(-423, -152)" fill="#2D2D2D" fill-rule="nonzero">
+                <path d="M436.719222,172 L422.997,162 L436.719222,152 L436.719,160.138 L448.065111,160.138889 C454.605517,160.138889 459.855111,165.735147 459.855111,172.575889 L459.855111,214.507889 L455.855111,214.507889 L455.855111,172.575889 C455.855111,167.889134 452.337178,164.138889 448.065111,164.138889 L436.719,164.138 L436.719222,172 Z" id="形状结合" transform="translate(441.4261, 183.2539) rotate(-180) translate(-441.4261, -183.2539)"></path>
+            </g>
+        </g>
+    </g>
+</svg>

+ 11 - 0
src/assets/images/svg/icon_turn_left_up.svg

@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="63px" height="37px" viewBox="0 0 63 37" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <title>形状结合</title>
+    <g transform="translate(0, 37) scale(1, -1)">
+        <g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+            <g id="转向指示" transform="translate(-367, -308)" fill="#2D2D2D" fill-rule="nonzero">
+                <path d="M394.036222,315.175111 L380.314,305.175111 L394.036222,295.175111 L394.036,303.313111 L405.382111,303.314 C411.922517,303.314 417.172111,308.910258 417.172111,315.751 L417.172111,357.683 L413.172111,357.683 L413.172111,315.751 C413.172111,311.064245 409.654178,307.314 405.382111,307.314 L394.036,307.313111 L394.036222,315.175111 Z" id="形状结合" transform="translate(398.7431, 326.4291) rotate(-90) translate(-398.7431, -326.4291)"></path>
+            </g>
+        </g>
+    </g>
+</svg>

+ 11 - 0
src/assets/images/svg/icon_turn_right_down.svg

@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="63px" height="37px" viewBox="0 0 63 37" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <title>形状结合</title>
+    <g transform="translate(0, 37) scale(1, -1)">
+        <g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+            <g id="转向指示" transform="translate(-392, -321)" fill="#2D2D2D" fill-rule="nonzero">
+                <path d="M418.544111,328.317 L404.821889,318.317 L418.544111,308.317 L418.543889,316.455 L429.89,316.455889 C436.430406,316.455889 441.68,322.052147 441.68,328.892889 L441.68,370.824889 L437.68,370.824889 L437.68,328.892889 C437.68,324.206134 434.162066,320.455889 429.89,320.455889 L418.543889,320.455 L418.544111,328.317 Z" id="形状结合" transform="translate(423.2509, 339.5709) rotate(-270) translate(-423.2509, -339.5709)"></path>
+            </g>
+        </g>
+    </g>
+</svg>

+ 11 - 0
src/assets/images/svg/icon_turn_up_right.svg

@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="37px" height="63px" viewBox="0 0 37 63" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <title>形状结合</title>
+    <g transform="translate(37, 0) scale(-1, 1)">
+        <g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+            <g id="转向指示" transform="translate(-368, -153)" fill="#2D2D2D" fill-rule="nonzero">
+                <path d="M381.861111,173.492111 L368.138889,163.492111 L381.861111,153.492111 L381.860889,161.630111 L393.207,161.631 C399.747406,161.631 404.997,167.227258 404.997,174.068 L404.997,216 L400.997,216 L400.997,174.068 C400.997,169.381245 397.479066,165.631 393.207,165.631 L381.860889,165.630111 L381.861111,173.492111 Z" id="形状结合"></path>
+            </g>
+        </g>
+    </g>
+</svg>

+ 9 - 4
src/components/ui/PhaseDiagram.vue

@@ -16,7 +16,7 @@
 
 <script>
 import { SVG_MAP } from '@/utils/phaseSvgIcons';
-import { positionStyleOf } from '@/utils/phaseLayout';
+import { layoutIconsAdaptive } from '@/utils/phaseLayout';
 
 // 根据数据动态渲染单个"相位方块":
 //   - 一组方向箭头 (icons), 通过 CSS mask-image 染色, 颜色 / 位置可数据驱动
@@ -41,6 +41,10 @@ export default {
         validIcons() {
             return this.icons.filter(t => SVG_MAP[t]);
         },
+        // 同 corner 多 icon 时自适应分槽; 单 icon 时退化为 POS_MAP 原位置
+        iconLayouts() {
+            return layoutIconsAdaptive(this.validIcons, this.arrowSize, this.arrowPad);
+        },
         displayNo() {
             return this.no === null || this.no === undefined ? '' : String(this.no);
         },
@@ -52,7 +56,7 @@ export default {
         arrowStyle(token) {
             const color = this.arrowColors[token] || this.arrowColor;
             return {
-                ...positionStyleOf(token, this.arrowSize, this.arrowPad),
+                ...(this.iconLayouts[token] || {}),
                 '--svg': `url("${SVG_MAP[token]}")`,
                 '--arrow-color': color,
             };
@@ -71,8 +75,9 @@ export default {
 
 .phase-no {
     position: absolute;
-    top: 4%;
-    left: 6%;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%);
     /* 跟随 CrossingDetailPanel 设置的 --s 缩放变量, 与本面板字号统一 */
     font-size: clamp(9px, calc(var(--s, 1) * 13px), 14px);
     font-weight: bold;

+ 68 - 36
src/components/ui/SignalTimingChart.vue

@@ -12,7 +12,8 @@
 <script>
 import * as echarts from 'echarts';
 import echartsResize from '@/mixins/echartsResize.js';
-import { POS_MAP } from '@/utils/phaseLayout';
+import { POS_MAP, actionPriority } from '@/utils/phaseLayout';
+import { SVG_MAP } from '@/utils/phaseSvgIcons';
 
 // 全局心跳定时器:所有 autoScan 实例共享同一个 setInterval
 // 各实例基于 Date.now() 对自身 cycleLength 取模计算位置,天然同步
@@ -567,45 +568,76 @@ export default {
         };
 
         // --- 3. 绘制内部图标 ---
+        // 按 corner 分组:单方向数据(NS 或 EW)下,同 corner 内若有 ≥2 个 token
+        // 沿主轴累加偏移(NS 沿 X、EW 沿 Y),所有 icon 用统一 STD_FS 缩放
+        // (1 icon 与 N icon 视觉大小一致);贴 corner 角排列(不用 padX/padY 偏移)
+        const byCorner = { LT: [], RT: [], LB: [], RB: [] };
         iconList.forEach(icon => {
           const valStr = String(icon).trim().toUpperCase();
-          const posConfig = POS_MAP[valStr] || { pos: 'RB', padX: 0, padY: 0, baseW: 20, baseH: 20 };
-          const pos = typeof posConfig === 'string' ? posConfig : (posConfig.pos || 'RB');
-          
-          // 图标尺寸和边距也应用了 dynamicFs 动态缩放
-          const drawW = Math.round((posConfig.baseW || 20) * dynamicFs);
-          const drawH = Math.round((posConfig.baseH || 20) * dynamicFs);
-          const padX = Math.round((posConfig.padX || 0) * dynamicFs); 
-          const padY = Math.round((posConfig.padY || 0) * dynamicFs);
-
-          let iconX, iconY;
-          if (pos === 'LT') {
-            iconX = start[0] + padX;
-            iconY = yPos + padY;
-          } else if (pos === 'RT') {
-            iconX = start[0] + darkWidth - drawW - padX;
-            iconY = yPos + padY;
-          } else if (pos === 'LB') {
-            iconX = start[0] + padX;
-            iconY = yPos + blockHeight - drawH - padY;
-          } else { // RB
-            iconX = start[0] + darkWidth - drawW - padX;
-            iconY = yPos + blockHeight - drawH - padY;
+          const posConfig = POS_MAP[valStr];
+          if (posConfig && byCorner[posConfig.pos]) {
+            byCorner[posConfig.pos].push({ token: valStr, m: posConfig });
           }
+        });
 
-          if (IMAGE_MAP[valStr]) {
-            innerGroup.children.push({
-              type: 'image',
-              style: { 
-                image: IMAGE_MAP[valStr], 
-                x: iconX, 
-                y: iconY, 
-                width: drawW, 
-                height: drawH,
-                objectFit: 'contain' 
-              }
-            });
-          }
+        // 统一缩放(替换原 1/√n 自适应缩放,让 icon 大小不随数量变)
+        const STD_FS = dynamicFs * 0.7;
+        const iconGap = Math.max(1, Math.round(0.5 * dynamicFs));
+
+        Object.entries(byCorner).forEach(([corner, items]) => {
+          const n = items.length;
+          if (n === 0) return;
+          const cornerIsNS = corner === 'LT' || corner === 'RB';
+
+          // 按车道布局排序:右转 → 直行 → 左转 → 左转掉头(外到内)
+          items.sort((a, b) => actionPriority(a.token) - actionPriority(b.token));
+
+          // 相位图色块矮长,与阶段图(30×30 方块)的几何约束不同。
+          // 阶段图里 STRAIGHT 高瘦比例 (2.95:1) 会显得太高,需缩小 (sf=0.556);
+          // 但相位图色块本身已经矮,STRAIGHT 再缩反而显得太小,所以这里 sf=1
+          // 起始偏移让 icon 整体远离色块边缘,向色块中央方向偏
+          const firstAnchor = items[0] ? items[0].m : null;
+          const firstPad = firstAnchor
+            ? Math.round((cornerIsNS ? firstAnchor.padX : firstAnchor.padY) * dynamicFs * 0.5)
+            : 0;
+          let cumOffset = firstPad;
+          items.forEach(({ token, m }) => {
+            const drawW = Math.round(m.baseW * STD_FS);
+            const drawH = Math.round(m.baseH * STD_FS);
+
+            let iconX, iconY;
+            if (corner === 'LT') {
+              iconX = start[0] + cumOffset;                  // NS 沿 X 累加(贴左)
+              iconY = yPos;                                  // 贴顶
+            } else if (corner === 'RT') {
+              iconX = start[0] + darkWidth - drawW;          // 贴右
+              iconY = yPos + cumOffset;                      // EW 沿 Y 累加(贴顶起)
+            } else if (corner === 'LB') {
+              iconX = start[0];                              // 贴左
+              iconY = yPos + blockHeight - drawH - cumOffset;// EW 从下往上累加
+            } else { // RB
+              iconX = start[0] + darkWidth - drawW - cumOffset; // NS 从右往左累加
+              iconY = yPos + blockHeight - drawH;            // 贴底
+            }
+
+            cumOffset += (cornerIsNS ? drawW : drawH) + iconGap;
+
+            // 优先用 PNG (IMAGE_MAP), 没有则 fallback 到 SVG (新增的 right turn 等)
+            const imageUrl = IMAGE_MAP[token] || SVG_MAP[token];
+            if (imageUrl) {
+              innerGroup.children.push({
+                type: 'image',
+                style: {
+                  image: imageUrl,
+                  x: iconX,
+                  y: iconY,
+                  width: drawW,
+                  height: drawH,
+                  objectFit: 'contain'
+                }
+              });
+            }
+          });
         });
         
         // --- 4. 渲染右侧文字 (相位号与时长) ---

+ 262 - 0
src/mock/_sample_phase_icons.js

@@ -0,0 +1,262 @@
+// 阶段图/相位图 icon 组合示例数据(演示/调试用)
+// 覆盖 1-3 个 icon 的各种组合 + 边界情况,验证 PhaseDiagram / SignalTimingChart
+// 在不同 icon 数量下的渲染。
+//
+// === 12 个合法 token(按驶入方向分 4 组,每组 3 种动作)===
+//
+//                       直行              左转                    左转带掉头
+//   上方驶入 (LT 锚)   STRAIGHT_DOWN     TURN_DOWN_LEFT          TURN_DOWN_LEFT_UTURN
+//   下方驶入 (RB 锚)   STRAIGHT_UP       TURN_UP_LEFT            TURN_UP_LEFT_UTURN
+//   右侧驶入 (RT 锚)   STRAIGHT_LEFT     TURN_LEFT_DOWN          TURN_LEFT_DOWN_UTURN
+//   左侧驶入 (LB 锚)   STRAIGHT_RIGHT    TURN_RIGHT_UP           TURN_RIGHT_UP_UTURN
+//
+// 每个 token 锚定到 30×30 单位路口方块的某个角落;同组 3 个 token 共用同一角落,
+// 放在同一阶段会重叠(视觉只见最后那一个)。所以一阶段独立角落上限 = 4。
+
+// ============================================================
+// 一、stageList 示例 —— 喂给 PhaseDiagram(路口方块组件)
+// ============================================================
+//
+// 字段:
+//   value       阶段编号字符串("1"/"2"/...)
+//   phaseName   显示名("P1"/"P2"/...)
+//   time        阶段总时长(秒)
+//   direction   主放行方向('ns' 南北 / 'ew' 东西)
+//   icons       NEW: 路口方块上要渲染的 token 数组(0-4 个有效)
+//   img         旧字段,PhaseDiagram 不渲染时由 <img> fallback
+//   _note       仅本演示用,业务字段无此项
+
+// 约束:每阶段 icons 只来自单一方向:
+//   NS 方向:仅 LT (上方驶入) + RB (下方驶入)
+//   EW 方向:仅 RT (右侧驶入) + LB (左侧驶入)
+//
+// 12 阶段:南北双向 6 个 + 东西双向 6 个
+// 每阶段 25 秒,总周期 300 秒
+// 排序规则:corner 内 icon 渲染顺序固定为 右转 → 直行 → 左转(数据数组顺序不影响)
+export const SAMPLE_STAGE_LIST = [
+  // ============= 南北双向(6 阶段)=============
+  // P1-P3:每方向 1 icon,单动作双向
+  {
+    value: '1', phaseName: 'P1', time: 25, direction: 'ns',
+    icons: ['STRAIGHT_DOWN', 'STRAIGHT_UP'],
+    _note: '【南北直行】北直 + 南直'
+  },
+  {
+    value: '2', phaseName: 'P2', time: 25, direction: 'ns',
+    icons: ['TURN_DOWN_LEFT', 'TURN_UP_LEFT'],
+    _note: '【南北左转】北左 + 南左'
+  },
+  {
+    value: '3', phaseName: 'P3', time: 25, direction: 'ns',
+    icons: ['TURN_DOWN_RIGHT', 'TURN_UP_RIGHT'],
+    _note: '【南北右转】北右 + 南右'
+  },
+
+  // P4-P6:每方向 3 icon (直/左/右) 双向 = 共 6 icon
+  {
+    value: '4', phaseName: 'P4', time: 25, direction: 'ns',
+    icons: ['STRAIGHT_DOWN', 'TURN_DOWN_LEFT', 'TURN_DOWN_RIGHT',
+            'STRAIGHT_UP',   'TURN_UP_LEFT',   'TURN_UP_RIGHT'],
+    _note: '【南北全放行】北直左右 + 南直左右(6 icon)'
+  },
+  // P5-P6:非对称(一方 1 icon,另一方 3 icon)
+  {
+    value: '5', phaseName: 'P5', time: 25, direction: 'ns',
+    icons: ['STRAIGHT_DOWN',
+            'STRAIGHT_UP', 'TURN_UP_LEFT', 'TURN_UP_RIGHT'],
+    _note: '【非对称】北直 + 南直左右'
+  },
+  {
+    value: '6', phaseName: 'P6', time: 25, direction: 'ns',
+    icons: ['STRAIGHT_DOWN', 'TURN_DOWN_LEFT', 'TURN_DOWN_RIGHT',
+            'STRAIGHT_UP'],
+    _note: '【非对称】北直左右 + 南直'
+  },
+
+  // ============= 东西双向(6 阶段)=============
+  {
+    value: '7', phaseName: 'P7', time: 25, direction: 'ew',
+    icons: ['STRAIGHT_LEFT', 'STRAIGHT_RIGHT'],
+    _note: '【东西直行】东直 + 西直'
+  },
+  {
+    value: '8', phaseName: 'P8', time: 25, direction: 'ew',
+    icons: ['TURN_LEFT_DOWN', 'TURN_RIGHT_UP'],
+    _note: '【东西左转】东左 + 西左'
+  },
+  {
+    value: '9', phaseName: 'P9', time: 25, direction: 'ew',
+    icons: ['TURN_LEFT_UP', 'TURN_RIGHT_DOWN'],
+    _note: '【东西右转】东右 + 西右'
+  },
+
+  {
+    value: '10', phaseName: 'P10', time: 25, direction: 'ew',
+    icons: ['STRAIGHT_LEFT', 'TURN_LEFT_DOWN', 'TURN_LEFT_UP',
+            'STRAIGHT_RIGHT', 'TURN_RIGHT_UP', 'TURN_RIGHT_DOWN'],
+    _note: '【东西全放行】东直左右 + 西直左右(6 icon)'
+  },
+  {
+    value: '11', phaseName: 'P11', time: 25, direction: 'ew',
+    icons: ['STRAIGHT_LEFT',
+            'STRAIGHT_RIGHT', 'TURN_RIGHT_UP', 'TURN_RIGHT_DOWN'],
+    _note: '【非对称】东直 + 西直左右'
+  },
+  {
+    value: '12', phaseName: 'P12', time: 25, direction: 'ew',
+    icons: ['STRAIGHT_LEFT', 'TURN_LEFT_DOWN', 'TURN_LEFT_UP',
+            'STRAIGHT_RIGHT'],
+    _note: '【非对称】东直左右 + 西直'
+  },
+];
+
+// ============================================================
+// 二、phaseData 示例 —— 喂给 SignalTimingChart(时间条带)
+// ============================================================
+//
+// 9 元组结构(一行 = 一段连续色块):
+//   [trackIdx, startTime, endTime, phaseName, duration, colorType, iconStr, direction, stageTotal]
+//          0          1        2         3         4          5        6          7           8
+//
+//   iconStr (列 [6]):
+//     · 仅 colorType='green' 段才会画 icon,其它段(stripe/yellow/red)传 null
+//     · 接受三种形式:
+//         字符串逗号串 'STRAIGHT_DOWN,STRAIGHT_UP'  (mock 当前用法)
+//         数组         ['STRAIGHT_DOWN','STRAIGHT_UP']
+//         单值         'STRAIGHT_UP'
+//
+//   下面这份示例:周期 100s,4 个阶段,icon 数 1/2/3/2 各异
+
+export const SAMPLE_PHASE_DATA = [
+  // 阶段 1(绿 20 / 闪 3 / 黄 3 / 红 2 = 28s),icon × 1
+  [0,  0, 20, 'P1', 20, 'green',  'STRAIGHT_UP',                            'ns', 28],
+  [0, 20, 23, '',    3, 'stripe', null,                                     'ns'],
+  [0, 23, 26, '',    3, 'yellow', null,                                     'ns'],
+  [0, 26, 28, '',    2, 'red',    null,                                     'ns'],
+
+  // 阶段 2(绿 22 / 闪 3 / 黄 3 / 红 2 = 30s),icon × 2(对称)
+  [0, 28, 50, 'P2', 22, 'green',  'STRAIGHT_DOWN,STRAIGHT_UP',              'ns', 30],
+  [0, 50, 53, '',    3, 'stripe', null,                                     'ns'],
+  [0, 53, 56, '',    3, 'yellow', null,                                     'ns'],
+  [0, 56, 58, '',    2, 'red',    null,                                     'ns'],
+
+  // 阶段 3(绿 18 / 闪 3 / 黄 3 / 红 2 = 26s),icon × 3
+  [0, 58, 76, 'P3', 18, 'green',  ['STRAIGHT_LEFT','STRAIGHT_RIGHT','TURN_UP_LEFT_UTURN'], 'ew', 26],
+  [0, 76, 79, '',    3, 'stripe', null,                                     'ew'],
+  [0, 79, 82, '',    3, 'yellow', null,                                     'ew'],
+  [0, 82, 84, '',    2, 'red',    null,                                     'ew'],
+
+  // 阶段 4(绿 8 / 闪 3 / 黄 3 / 红 2 = 16s),icon × 2(非对称,含掉头)
+  [0, 84, 92,  'P4', 8, 'green',  'TURN_LEFT_DOWN_UTURN,STRAIGHT_DOWN',     'ew', 16],
+  [0, 92, 95,  '',   3, 'stripe', null,                                     'ew'],
+  [0, 95, 98,  '',   3, 'yellow', null,                                     'ew'],
+  [0, 98, 100, '',   2, 'red',    null,                                     'ew'],
+];
+
+// ============================================================
+// 三、SignalTimingChart 的 hasL/hasR 提示
+// ============================================================
+//
+// 相位图深绿色背景的宽度按 icon 角落分布动态变化(见 SignalTimingChart:528-531):
+//   - icons 同时含 L 锚 (LT/LB) 和 R 锚 (RT/RB) → darkWidth ≈ 46px
+//   - icons 只含 L 或只含 R 一侧                → darkWidth ≈ 28px
+//   - icons 为空但有 phaseName                  → darkWidth ≈ 8px(仅放文字)
+//   - 全空                                       → darkWidth = 0
+//
+// 上面 SAMPLE_PHASE_DATA 的 4 个阶段恰好覆盖:
+//   P1 icon×1 (RB)        → 只 R → 28px
+//   P2 icon×2 (LT+RB)     → L+R → 46px
+//   P3 icon×3 (RT+LB+RB)  → L+R → 46px
+//   P4 icon×2 (RT+LT)     → L+R → 46px
+
+// ============================================================
+// 四、Random 组合生成器(后续接入 mock 时可直接 reuse)
+// ============================================================
+//
+// 按"4 个角落各最多 1 个 token"的规则随机抽 1-3 个 icon。
+// 同角落 3 个 token 等价,随机选一个动作。
+
+const TOKENS_BY_CORNER = {
+  LT: ['STRAIGHT_DOWN', 'TURN_DOWN_LEFT', 'TURN_DOWN_LEFT_UTURN'],
+  RB: ['STRAIGHT_UP',   'TURN_UP_LEFT',   'TURN_UP_LEFT_UTURN'],
+  RT: ['STRAIGHT_LEFT', 'TURN_LEFT_DOWN', 'TURN_LEFT_DOWN_UTURN'],
+  LB: ['STRAIGHT_RIGHT','TURN_RIGHT_UP',  'TURN_RIGHT_UP_UTURN'],
+};
+
+const CORNERS = Object.keys(TOKENS_BY_CORNER);
+
+function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
+
+/** 随机抽 n 个不同角落各 1 个 token;n 默认 1-3 中随机 */
+export function randomIconCombo(n) {
+  const count = n || (1 + Math.floor(Math.random() * 3));   // 1-3
+  // 随机洗牌角落,取前 count 个
+  const shuffled = CORNERS.slice().sort(() => Math.random() - 0.5);
+  return shuffled.slice(0, count).map(corner => pick(TOKENS_BY_CORNER[corner]));
+}
+
+// 示例:randomIconCombo(2) → ['STRAIGHT_DOWN', 'TURN_LEFT_DOWN'] (LT+RT)
+//      randomIconCombo()   → ['STRAIGHT_UP']                    (n 随机 = 1)
+
+// ============================================================
+// 五、Demo 路口完整数据构造(用于 apiGetCrossingDetailData 注入)
+// ============================================================
+//
+// 把 SAMPLE_STAGE_LIST 转成 CrossingDetailPanel 直接可用的 stageList +
+// 配套 phaseData + cycleLength。每个阶段:
+//   绿灯 = stageList[i].time - 8 秒
+//   stripe(3) + yellow(3) + red(2) = 8 秒固定尾段
+
+const DEMO_TAIL = { stripe: 3, yellow: 3, red: 2 };  // 共 8 秒
+
+const DEMO_LOCKTIME_OPTIONS = [
+  { label: '20', value: 20 }, { label: '30', value: 30 },
+  { label: '45', value: 45 }, { label: '60', value: 60 },
+];
+
+/**
+ * 构造 Icon 组合演示路口的完整数据(喂给 apiGetCrossingDetailData 返回值)
+ * @returns { stageList, phaseData, cycleLength }
+ */
+export function buildIconComboDemoData() {
+  const stageList = [];
+  const phaseData = [];
+  let t = 0;
+  const trackIdx = 0;
+
+  SAMPLE_STAGE_LIST.forEach((item, i) => {
+    const stageTotal = item.time;
+    const g = Math.max(1, stageTotal - DEMO_TAIL.stripe - DEMO_TAIL.yellow - DEMO_TAIL.red);
+    const iconStr = (item.icons || []).join(',');
+    const dir = item.direction || 'ns';
+
+    // 4 段 phaseData:green + stripe + yellow + red
+    phaseData.push([trackIdx, t,                                    t + g,                                                                     item.phaseName, g, 'green',  iconStr, dir, stageTotal]);
+    phaseData.push([trackIdx, t + g,                                t + g + DEMO_TAIL.stripe,                                                 '',             DEMO_TAIL.stripe, 'stripe', null, dir]);
+    phaseData.push([trackIdx, t + g + DEMO_TAIL.stripe,             t + g + DEMO_TAIL.stripe + DEMO_TAIL.yellow,                              '',             DEMO_TAIL.yellow, 'yellow', null, dir]);
+    phaseData.push([trackIdx, t + g + DEMO_TAIL.stripe + DEMO_TAIL.yellow, t + g + DEMO_TAIL.stripe + DEMO_TAIL.yellow + DEMO_TAIL.red,        '',             DEMO_TAIL.red,    'red',    null, dir]);
+
+    // stageList 项(补 CrossingDetailPanel 需要的字段)
+    stageList.push({
+      value: item.value,
+      phaseName: item.phaseName,
+      time: stageTotal,
+      direction: dir,
+      icons: item.icons,        // 新字段:PhaseDiagram 渲染用
+      img: null,                // 设为 null 防止 fallback <img> 加载失败 404
+      locktimeOptions: DEMO_LOCKTIME_OPTIONS,
+      _note: item._note,
+    });
+
+    t += stageTotal;
+  });
+
+  return { stageList, phaseData, cycleLength: t };
+}
+
+export default {
+  SAMPLE_STAGE_LIST,
+  SAMPLE_PHASE_DATA,
+  randomIconCombo,
+  buildIconComboDemoData,
+};

+ 23 - 9
src/mock/api.js

@@ -14,6 +14,7 @@
 
 import mockData from './mock_data.json'
 import { simulateMaxband } from './_simulateMaxband'
+import { buildIconComboDemoData } from './_sample_phase_icons'
 import { applyControlModeColors, applyDeviceFaultColors } from '@/config/chartColors'
 
 // ── 静态资源(模拟 CDN / 后端返回的资源 URL)─────────────────────
@@ -1152,9 +1153,20 @@ export async function apiGetCrossingDetailData(id, { iconMode = 'default' } = {}
     phaseData = _makeFlexiblePhaseData(cycleLength, cfgSample.stageCount)
   }
 
+  // Icon 组合演示路口:1-3 icon 不同组合 + 边界情况(覆盖 stageList & phaseData)
+  // 详见 _sample_phase_icons.js 的 SAMPLE_STAGE_LIST
+  let _iconComboOverride = null
+  if (id === 'JNC900099') {
+    _iconComboOverride = buildIconComboDemoData()
+    cycleLength = _iconComboOverride.cycleLength
+    phaseData = _iconComboOverride.phaseData
+  }
+
   // thisCycle / lastCycle:所有路口统一返回,让 CrossingDetailPanel 的双相位图布局对所有
   // 路口生效(普通 4 阶段路口直接复用 _makePhaseData 的结果;示例路口已用 flexible 覆盖)
-  const _planSchemeName = (_dualSamples[id] && _dualSamples[id].schemeName) || '默认配时方案'
+  const _planSchemeName = (_dualSamples[id] && _dualSamples[id].schemeName)
+    || (id === 'JNC900099' && 'Icon组合演示方案')
+    || '默认配时方案'
   const _nowSec = Math.floor(Date.now() / 1000)
   const _currentTimeIn = _nowSec % cycleLength
   const thisCycle = {
@@ -1188,14 +1200,16 @@ export async function apiGetCrossingDetailData(id, { iconMode = 'default' } = {}
     [{ label: '20', value: 20 }, { label: '30', value: 30 }, { label: '45', value: 45 }, { label: '60', value: 60 }],
     [{ label: '10', value: 10 }, { label: '20', value: 20 }, { label: '30', value: 30 }, { label: '40', value: 40 }],
   ]
-  const stageList = greenPhases.map((p, i) => ({
-    value: String(i + 1),
-    time: p[8],
-    phaseName: p[3],
-    direction: p[6],
-    img: ARROWS[i],
-    locktimeOptions: stageLockOptions[i] || stageLockOptions[0],
-  }))
+  const stageList = _iconComboOverride
+    ? _iconComboOverride.stageList
+    : greenPhases.map((p, i) => ({
+        value: String(i + 1),
+        time: p[8],
+        phaseName: p[3],
+        direction: p[6],
+        img: ARROWS[i],
+        locktimeOptions: stageLockOptions[i] || stageLockOptions[0],
+      }))
 
   // 控制方式选项 + 根据路口选择不同的当前控制方式
   const allMethods = [

+ 27 - 0
src/mock/mock_data.json

@@ -7371,6 +7371,12 @@
                       "lat": 39.94
                     },
                     {
+                      "id": "JNC900099",
+                      "label": "[示例]Icon组合演示路口",
+                      "lng": 116.74,
+                      "lat": 39.95
+                    },
+                    {
                       "id": "JNC000220",
                       "label": "TZ-367_畅和东路与大营东街路口",
                       "lng": 116.743531,
@@ -11869,6 +11875,12 @@
                       "lat": 39.94
                     },
                     {
+                      "id": "JNC900099",
+                      "label": "[示例]Icon组合演示路口",
+                      "lng": 116.74,
+                      "lat": 39.95
+                    },
+                    {
                       "id": "JNC000220",
                       "label": "TZ-367_畅和东路与大营东街路口",
                       "lng": 116.743531,
@@ -26449,6 +26461,21 @@
       "isKey": true,
       "lng": 116.73,
       "lat": 39.94
+    },
+    {
+      "id": "JNC900099",
+      "index": 9099,
+      "name": "[示例]Icon组合演示路口",
+      "subArea": "REG000200",
+      "ip": "192.168.99.99",
+      "status": "在线",
+      "timeOffset": "无偏差",
+      "cycle": 263,
+      "version": "V3.2.0",
+      "node": "通州节点1",
+      "isKey": true,
+      "lng": 116.74,
+      "lat": 39.95
     }
   ],
   "securityRoutes": [

+ 141 - 4
src/utils/phaseLayout.js

@@ -7,25 +7,29 @@
 //   baseW, baseH: 箭头本身占的尺寸
 
 export const POS_MAP = {
-  // 1. 上方驶入 -> 靠左上角 (LT)
+  // 1. 上方驶入 (北方向) -> 靠左上角 (LT)
   'STRAIGHT_DOWN':         { pos: 'LT', padX: 10, padY: 0, baseW: 7,  baseH: 20.67 },
   'TURN_DOWN_LEFT':        { pos: 'LT', padX: 10, padY: 0, baseW: 13, baseH: 21.33 },
   'TURN_DOWN_LEFT_UTURN':  { pos: 'LT', padX: 10, padY: 0, baseW: 13, baseH: 22.67 },
+  'TURN_DOWN_RIGHT':       { pos: 'LT', padX: 10, padY: 0, baseW: 13, baseH: 21.33 },  // 北右转(镜像)
 
-  // 2. 下方驶入 -> 靠右下角 (RB)
+  // 2. 下方驶入 (南方向) -> 靠右下角 (RB)
   'STRAIGHT_UP':           { pos: 'RB', padX: 10, padY: 0, baseW: 7,  baseH: 20.67 },
   'TURN_UP_LEFT':          { pos: 'RB', padX: 10, padY: 0, baseW: 13, baseH: 21.33 },
   'TURN_UP_LEFT_UTURN':    { pos: 'RB', padX: 10, padY: 0, baseW: 13, baseH: 22.67 },
+  'TURN_UP_RIGHT':         { pos: 'RB', padX: 10, padY: 0, baseW: 13, baseH: 21.33 },  // 南右转(镜像)
 
-  // 3. 右侧驶入 -> 靠右上角 (RT)
+  // 3. 右侧驶入 (东方向) -> 靠右上角 (RT)
   'STRAIGHT_LEFT':         { pos: 'RT', padX: 0, padY: 10, baseW: 20.33, baseH: 6.33 },
   'TURN_LEFT_DOWN':        { pos: 'RT', padX: 0, padY: 10, baseW: 20.67, baseH: 12.33 },
   'TURN_LEFT_DOWN_UTURN':  { pos: 'RT', padX: 0, padY: 10, baseW: 22.67, baseH: 12.33 },
+  'TURN_LEFT_UP':          { pos: 'RT', padX: 0, padY: 10, baseW: 20.67, baseH: 12.33 },  // 东右转(镜像)
 
-  // 4. 左侧驶入 -> 靠左下角 (LB)
+  // 4. 左侧驶入 (西方向) -> 靠左下角 (LB)
   'STRAIGHT_RIGHT':        { pos: 'LB', padX: 0, padY: 10, baseW: 20.33, baseH: 6.33 },
   'TURN_RIGHT_UP':         { pos: 'LB', padX: 0, padY: 10, baseW: 20.67, baseH: 12.33 },
   'TURN_RIGHT_UP_UTURN':   { pos: 'LB', padX: 0, padY: 10, baseW: 22.67, baseH: 12.33 },
+  'TURN_RIGHT_DOWN':       { pos: 'LB', padX: 0, padY: 10, baseW: 20.67, baseH: 12.33 },  // 西右转(镜像)
 };
 
 // 把 token 对应的 POS_MAP 项换算成 CSS 绝对定位 style (百分比, 自适应任意尺寸的方框)
@@ -48,3 +52,136 @@ export function positionStyleOf(token, sizeScale = 1, padScale = 1) {
     [sideY]: pct(m.padY * padScale),
   };
 }
+
+// ----------------------------------------------------------------------------
+// 自适应多 icon 布局:当同一 corner 有多个 token (NS 或 EW 同向多 icon) 时,
+// 沿对应方向均分 slot,每个 icon 按其原始 baseW/baseH 比例 fit 到 slot 内。
+//
+// 角落分组规则:
+//   LT/RB → NS corner,icons 沿 X 轴水平排列(北/南方向用上下半区)
+//   RT/LB → EW corner,icons 沿 Y 轴垂直排列(东/西方向用左右半区)
+//
+// 单 corner 单 icon 时退化为 positionStyleOf 行为(保持向后兼容)。
+//
+// @returns { [token: string]: style }
+// ----------------------------------------------------------------------------
+const HALF_BLOCK       = 15;   // 30×30 路口方块的半区大小(单位)
+const CENTER_RESERVED  = 1;    // 中央留给编号 (.phase-no) 的半边距(单位)→ 中央总宽 2 单位
+const EFFECTIVE_HALF   = HALF_BLOCK - CENTER_RESERVED;  // icon 主轴最大可占用(14 单位 = 46.7%)
+const ICON_GAP         = 0.5;  // 多 icon 之间的间距(单位)
+const ICON_MAIN_SIZE   = 12;   // 每个 icon 主轴方向的统一尺寸(单位)
+                               // 1 icon 时主轴 = 12×0.55 = 6.6 单位 (pct 22%)
+                               // 3 icon 时受 EFFECTIVE_HALF=14 限制 compress 到 pct 14.3%
+
+// 按真实车道布局的排列优先级:从外(路口边)到内(路口中心)依次为
+//   右转 → 直行 → 左转 → 左转掉头
+// corner 内多 icon 按此顺序沿主轴累加,与司机视角一致
+const RIGHT_TURN_TOKENS = new Set([
+  'TURN_DOWN_RIGHT', 'TURN_UP_RIGHT', 'TURN_LEFT_UP', 'TURN_RIGHT_DOWN'
+]);
+
+export function actionPriority(token) {
+  if (RIGHT_TURN_TOKENS.has(token))    return 0; // 右转最外
+  if (token.startsWith('STRAIGHT_'))   return 1; // 直行居中
+  if (token.endsWith('_UTURN'))        return 3; // 左转掉头最内
+  return 2;                                       // 其它为左转
+}
+
+export function sortIconsByLane(tokens) {
+  return [...tokens].sort((a, b) => actionPriority(a) - actionPriority(b));
+}
+
+// 每个 corner 的 3 个 reference token:[右转, 直行, 左转]
+// 用于计算"固定 3 slot"位置,让单 icon 与多 icon 中同位置 icon 完全对齐
+const CORNER_REFERENCE = {
+  LT: ['TURN_DOWN_RIGHT', 'STRAIGHT_DOWN', 'TURN_DOWN_LEFT'],
+  RB: ['TURN_UP_RIGHT',   'STRAIGHT_UP',   'TURN_UP_LEFT'],
+  RT: ['TURN_LEFT_UP',    'STRAIGHT_LEFT', 'TURN_LEFT_DOWN'],
+  LB: ['TURN_RIGHT_DOWN', 'STRAIGHT_RIGHT', 'TURN_RIGHT_UP'],
+};
+
+export function layoutIconsAdaptive(icons, sizeScale = 1, padScale = 1) {
+  // 按 corner 分组
+  const byCorner = { LT: [], RT: [], LB: [], RB: [] };
+  icons.forEach(token => {
+    const m = POS_MAP[token];
+    if (m && byCorner[m.pos]) byCorner[m.pos].push(token);
+  });
+
+  const styles = {};
+  const pct = n => `${(n / 30) * 100}%`;
+
+  // STRAIGHT 整体缩小 sf 让副轴与 TURN 对齐
+  const TURN_RATIO = 1.64;
+  const mainSize  = ICON_MAIN_SIZE * sizeScale;
+  const PAD_FACTOR = 0.5;
+
+  const computeSize = (token, isNS) => {
+    const m = POS_MAP[token];
+    if (!m) return { w: 0, h: 0 };
+    const isStraight = token.startsWith('STRAIGHT_');
+    const straightRatio = isNS ? (m.baseH / m.baseW) : (m.baseW / m.baseH);
+    const sf = isStraight ? (TURN_RATIO / straightRatio) : 1;
+    let w, h;
+    if (isNS) {
+      w = mainSize * sf;
+      h = mainSize * sf * (m.baseH / m.baseW);
+    } else {
+      h = mainSize * sf;
+      w = mainSize * sf * (m.baseW / m.baseH);
+    }
+    return { w, h };
+  };
+
+  Object.entries(byCorner).forEach(([corner, tokens]) => {
+    if (tokens.length === 0) return;
+
+    const isNS  = corner === 'LT' || corner === 'RB';
+    const sideX = corner === 'LT' || corner === 'LB' ? 'left' : 'right';
+    const sideY = corner === 'LT' || corner === 'RT' ? 'top'  : 'bottom';
+
+    // 始终按 corner 的 3 个 reference token (右转/直行/左转) 计算 slot 位置
+    // 这样 1 icon 与多 icon 中同位置 icon 完全对齐(大小 + 位置)
+    const refTokens = CORNER_REFERENCE[corner];
+    const refSizes  = refTokens.map(t => computeSize(t, isNS));
+
+    const refAnchor = POS_MAP[refTokens[0]];
+    const firstPad  = (isNS ? refAnchor.padX : refAnchor.padY) * padScale * PAD_FACTOR;
+    const sumMain   = refSizes.reduce((s, x) => s + (isNS ? x.w : x.h), 0);
+    const totalLen  = firstPad + sumMain + ICON_GAP * (refSizes.length - 1);
+    const compress  = totalLen > EFFECTIVE_HALF ? EFFECTIVE_HALF / totalLen : 1;
+
+    // 计算每个 slot 的位置(基于 reference)
+    let cumOffset = firstPad * compress;
+    const slotPositions = refSizes.map(size => {
+      const w = size.w * compress;
+      const h = size.h * compress;
+      const slot = { offset: cumOffset, w, h };
+      cumOffset += (isNS ? w : h) + ICON_GAP * compress;
+      return slot;
+    });
+
+    // 渲染实际 token,按 actionPriority 找对应 slot
+    // priority 3 (左转掉头) 暂时归到 slot 2 (左转位置)
+    tokens.forEach(actualToken => {
+      const priority = actionPriority(actualToken);
+      const slotIdx = Math.min(priority, refSizes.length - 1);
+      const slotPos = slotPositions[slotIdx];
+
+      // 实际 token 的尺寸(按其原 baseW/baseH 算)
+      const actSize = computeSize(actualToken, isNS);
+      const w = actSize.w * compress;
+      const h = actSize.h * compress;
+
+      styles[actualToken] = {
+        position: 'absolute',
+        width:  pct(w),
+        height: pct(h),
+        [sideX]: pct(isNS ? slotPos.offset : 0),
+        [sideY]: pct(isNS ? 0 : slotPos.offset),
+      };
+    });
+  });
+
+  return styles;
+}