Преглед изворни кода

[Pref 0000] 拓扑图样式优化

houzekong пре 4 дана
родитељ
комит
22a88539ee

+ 1 - 0
.gitignore

@@ -1,4 +1,5 @@
 node_modules
 node_modules
+.pnpm-store
 .DS_Store
 .DS_Store
 .github
 .github
 dist
 dist

+ 1 - 1
package.json

@@ -41,7 +41,7 @@
     "crypto-js": "^4.2.0",
     "crypto-js": "^4.2.0",
     "dayjs": "^1.11.19",
     "dayjs": "^1.11.19",
     "dom-align": "^1.12.4",
     "dom-align": "^1.12.4",
-    "echarts": "^5.6.0",
+    "echarts": "^6.1.0",
     "emoji-mart-vue-fast": "^15.0.5",
     "emoji-mart-vue-fast": "^15.0.5",
     "enquire.js": "^2.1.6",
     "enquire.js": "^2.1.6",
     "lodash": "^4.17.21",
     "lodash": "^4.17.21",

+ 7 - 7
pnpm-lock.yaml

@@ -59,8 +59,8 @@ dependencies:
     specifier: ^1.12.4
     specifier: ^1.12.4
     version: 1.12.4
     version: 1.12.4
   echarts:
   echarts:
-    specifier: ^5.6.0
-    version: 5.6.0
+    specifier: ^6.1.0
+    version: 6.1.0
   emoji-mart-vue-fast:
   emoji-mart-vue-fast:
     specifier: ^15.0.5
     specifier: ^15.0.5
     version: 15.0.5(vue@3.5.25)
     version: 15.0.5(vue@3.5.25)
@@ -6602,11 +6602,11 @@ packages:
     resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
     resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
     dev: true
     dev: true
 
 
-  /echarts@5.6.0:
-    resolution: {integrity: sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==}
+  /echarts@6.1.0:
+    resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==}
     dependencies:
     dependencies:
       tslib: 2.3.0
       tslib: 2.3.0
-      zrender: 5.6.1
+      zrender: 6.1.0
     dev: false
     dev: false
 
 
   /editorconfig@1.0.7:
   /editorconfig@1.0.7:
@@ -13859,8 +13859,8 @@ packages:
     engines: {node: '>=18'}
     engines: {node: '>=18'}
     dev: true
     dev: true
 
 
-  /zrender@5.6.1:
-    resolution: {integrity: sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==}
+  /zrender@6.1.0:
+    resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==}
     dependencies:
     dependencies:
       tslib: 2.3.0
       tslib: 2.3.0
     dev: false
     dev: false

+ 6 - 6
src/views/analysis/warningAnalysis/windPointManage/windTopology/hooks/useTopology.ts

@@ -12,7 +12,7 @@ import {
   POINT_SIZE,
   POINT_SIZE,
   JUDGE_COLOR,
   JUDGE_COLOR,
   JUDGE_WIDTH,
   JUDGE_WIDTH,
-  formatAirVolumeLabel,
+  // formatAirVolumeLabel,
 } from '../windTopology.data';
 } from '../windTopology.data';
 import type { TopologyData, TopoNodeData } from '../windTopology.data';
 import type { TopologyData, TopoNodeData } from '../windTopology.data';
 import { getTopologyData, updateArea, addMineAreaRelation, deleteMineAreaRelation } from '../windTopology.api';
 import { getTopologyData, updateArea, addMineAreaRelation, deleteMineAreaRelation } from '../windTopology.api';
@@ -128,7 +128,7 @@ export function useTopology() {
     loadTopology();
     loadTopology();
   }
   }
 
 
-  // —— 渲染拓扑 ——
+  // —— 渲染拓扑 ——midLabel
   function renderTopology(data: TopologyData) {
   function renderTopology(data: TopologyData) {
     topologyData = data;
     topologyData = data;
     if (!chartInstance) return;
     if (!chartInstance) return;
@@ -217,10 +217,10 @@ export function useTopology() {
           const lv = point ? Number(point.level) : NaN;
           const lv = point ? Number(point.level) : NaN;
           const deviceName = point?.windrectId ? deviceNameMap[point.windrectId] : '';
           const deviceName = point?.windrectId ? deviceNameMap[point.windrectId] : '';
           const suspected = point?.suspected;
           const suspected = point?.suspected;
-          link.midLabel = point
-            ? `{name|${point.name}}\n{volume|${formatAirVolumeLabel(point.airVolume)}}\n${
-                deviceName ? `{dot|● }{device|${deviceName}}` : '{dotEmpty|● }'
-              }`
+          // 常态标注:仅名称;高亮标注:名称 + 圆点与已绑定布点名(未绑定输出浅灰占位圆点)
+          link.midLabel = point ? `{name|${point.name}}` : '';
+          link.midLabelFull = point
+            ? `{name|${point.name}}\n${deviceName ? `{dot|● }{device|${deviceName}}` : '{dotEmpty|● }'}`
             : '';
             : '';
           link.lineStyle = {
           link.lineStyle = {
             color: suspected ? JUDGE_COLOR : levelColorMap[lv] || POINT_COLOR,
             color: suspected ? JUDGE_COLOR : levelColorMap[lv] || POINT_COLOR,

+ 22 - 27
src/views/analysis/warningAnalysis/windPointManage/windTopology/hooks/useTopologyLayout.ts

@@ -6,23 +6,25 @@ export interface LayoutResult {
   positions: Record<string, { x: number; y: number }>;
   positions: Record<string, { x: number; y: number }>;
 }
 }
 
 
-/** 数值夹取:返回 [min, max] 区间内的值 */
-function clamp(v: number, min: number, max: number): number {
-  return Math.max(min, Math.min(max, v));
-}
+/** 第 idx 列(0=总进、1..5=lv1..lv5、6=未分级、7=总回)的 x 坐标 */
+const laneX = (idx: number) => LAYOUT.margin + LAYOUT.colGap * idx;
+
+/** level → 配置键:仅 1..5 视为已分级,0/NaN/越界统一映射为 'ungraded' */
+const levelKey = (lv?: number | null): number | 'ungraded' =>
+  lv !== undefined && lv !== null && Number.isFinite(lv) && lv >= 1 && lv <= 5 ? lv : 'ungraded';
+
+/** 该层级行距:按层级分别配置,未配置的层级回退 rowGapDefault */
+const rowGapFor = (lv?: number | null) => LAYOUT.rowGap[levelKey(lv) as any] ?? LAYOUT.rowGapDefault;
 
 
 /**
 /**
- * 列式布局(从左到右,自适应铺满画布):
+ * 列式布局(从左到右,固定间距):
  *   固定列序 总进 → lv1..lv5 → 未分级 → 总回;
  *   固定列序 总进 → lv1..lv5 → 未分级 → 总回;
- *   列间距(x 轴间隔):xGap = clamp((W - 2·margin)/(列数-1), colGapMin, colGap),
- *     铺满画布宽度;受限(列少/画布宽)时整组居中;
- *   行间距(y 轴间隔):按最高列节点数 tall 计算
- *     rowSpacing = clamp((H - 2·vMargin)/(tall-1), rowGapMin, rowGap),所有列共用同一行距;
- *   每列围绕画布垂直中心独立居中(中间列最宽、两端收窄的菱形轮廓),
- *   列内顺序按风量降序,节点 y = colStart + 秩 × rowSpacing。
+ *   列间距(x 轴间隔)固定:x = margin + colGap × 列序;
+ *   行间距(y 轴间隔)固定且按层级分别配置:每列取该层级 rowGap[level](未配置回退 rowGapDefault);
+ *   每列围绕画布垂直中心独立居中:colStart = centerY − spacing·(n−1)/2,
+ *   节点 y = colStart + 秩 × spacing;列内顺序按风量降序。
  */
  */
-export function computeLayout(data: TopologyData, width: number, height: number): LayoutResult {
-  const W = width < 100 ? 1200 : width;
+export function computeLayout(data: TopologyData, _width: number, height: number): LayoutResult {
   const H = height < 100 ? LAYOUT_HEIGHT : height;
   const H = height < 100 ? LAYOUT_HEIGHT : height;
 
 
   const positions: Record<string, { x: number; y: number }> = {};
   const positions: Record<string, { x: number; y: number }> = {};
@@ -44,6 +46,8 @@ export function computeLayout(data: TopologyData, width: number, height: number)
     }
     }
   }
   }
 
 
+  const centerY = H / 2;
+
   // 列序:总进 → lv1..lv5 → 未分级 → 总回(空列保留占位,保持后续列位稳定)
   // 列序:总进 → lv1..lv5 → 未分级 → 总回(空列保留占位,保持后续列位稳定)
   const cols: TopoNodeData[][] = [];
   const cols: TopoNodeData[][] = [];
   cols.push(rootIn ? [rootIn] : []);
   cols.push(rootIn ? [rootIn] : []);
@@ -58,24 +62,15 @@ export function computeLayout(data: TopologyData, width: number, height: number)
     col.sort((a, b) => (b.airVolume ?? 0) - (a.airVolume ?? 0));
     col.sort((a, b) => (b.airVolume ?? 0) - (a.airVolume ?? 0));
   }
   }
 
 
-  // —— 列间距(x):按画布宽度自适应铺满;带宽受限时整组居中,带宽超画布时保持左对齐(向右溢出可平移)——
-  const colCount = cols.length;
-  const xGap = colCount > 1 ? clamp((W - 2 * LAYOUT.margin) / (colCount - 1), LAYOUT.colGapMin, LAYOUT.colGap) : 0;
-  const xStart = LAYOUT.margin + Math.max(0, (W - 2 * LAYOUT.margin - xGap * (colCount - 1)) / 2);
-
-  // —— 行间距(y):按最高列自适应铺满,所有列共用同一行距 ——
-  const tallest = cols.reduce((m, c) => Math.max(m, c.length), 0);
-  const rowSpacing = tallest > 1 ? clamp((H - 2 * LAYOUT.vMargin) / (tallest - 1), LAYOUT.rowGapMin, LAYOUT.rowGap) : 0;
-  const centerY = H / 2;
-
-  // 逐列赋坐标:每列围绕画布垂直中心独立居中(菱形轮廓),列内按秩排列
+  // 逐列赋坐标:x = margin + colGap × 列序;行距取该层级配置,每列独立居中
   cols.forEach((col, i) => {
   cols.forEach((col, i) => {
-    const x = xStart + i * xGap;
+    const x = laneX(i);
     const n = col.length;
     const n = col.length;
     if (n === 0) return;
     if (n === 0) return;
-    const colStart = centerY - (rowSpacing * (n - 1)) / 2;
+    const spacing = rowGapFor(col[0].level);
+    const start = centerY - (spacing * (n - 1)) / 2;
     col.forEach((p, k) => {
     col.forEach((p, k) => {
-      positions[p.id] = { x, y: colStart + k * rowSpacing };
+      positions[p.id] = { x, y: start + k * spacing };
     });
     });
   });
   });
 
 

+ 32 - 21
src/views/analysis/warningAnalysis/windPointManage/windTopology/windTopology.data.ts

@@ -45,20 +45,19 @@ export const levelColorMap: Record<number, string> = {
 
 
 // ==================== 通风示意图布局参数 ====================
 // ==================== 通风示意图布局参数 ====================
 
 
-/** 拓扑布局参数(px)——列间距/行间距按画布尺寸自适应铺满,上下限均可配置 */
+/** 拓扑布局参数(px)——列距固定,行距按层级分别配置 */
 export const LAYOUT = {
 export const LAYOUT = {
-  /** 画布左右边距(列向留白) */
-  margin: 60,
-  /** 画布上下边距(行向留白) */
-  vMargin: 40,
-  /** 列间距上限(px):按画布宽度自适应,列多时压缩、列少时扩展铺满,取值 ≤ colGap 且 ≥ colGapMin */
-  colGap: 300,
-  /** 列间距下限(px) */
-  colGapMin: 120,
-  /** 行间距上限(px):按画布高度自适应铺满,取值 ≤ rowGap 且 ≥ rowGapMin */
-  rowGap: 200,
-  /** 行间距下限(px) */
-  rowGapMin: 16,
+  /** 画布左边距(总进点起始 x) */
+  margin: 50,
+  /** 列间距(x 轴间隔,固定) */
+  colGap: 700,
+  /**
+   * 各层级行间距(y 轴间隔,固定,按层级分别配置);
+   * 未配置的层级回退 rowGapDefault。
+   */
+  rowGap: { 1: 300, 2: 300, 3: 200, 4: 300, 5: 300, ungraded: 200 } as Record<number, number> & { ungraded: number },
+  /** 未在 rowGap 中配置的层级使用的行距(px) */
+  rowGapDefault: 200,
 };
 };
 
 
 /** 巷道分支风量标注文本(通风网络图风格:在巷道连线上标注测风点位风量),无风量返回空串 */
 /** 巷道分支风量标注文本(通风网络图风格:在巷道连线上标注测风点位风量),无风量返回空串 */
@@ -79,22 +78,26 @@ export function createGraphOption(): EChartsOption {
         type: 'graph',
         type: 'graph',
         layout: 'none', // 固定位置,由 hooks/useTopologyLayout.computeLayout 分配坐标
         layout: 'none', // 固定位置,由 hooks/useTopologyLayout.computeLayout 分配坐标
         roam: true,
         roam: true,
+        roamTrigger: 'global',
         draggable: true, // 节点固定(fixed=true),roam 用于平移缩放
         draggable: true, // 节点固定(fixed=true),roam 用于平移缩放
         categories: categories.map((c) => ({ name: c.name, itemStyle: { color: c.color } })),
         categories: categories.map((c) => ({ name: c.name, itemStyle: { color: c.color } })),
         // 边统一带风流方向箭头(进风→右、回风→左,由 roadway 边 source→target 决定)
         // 边统一带风流方向箭头(进风→右、回风→左,由 roadway 边 source→target 决定)
         edgeSymbol: ['none', 'arrow'],
         edgeSymbol: ['none', 'arrow'],
         edgeSymbolSize: [0, 10],
         edgeSymbolSize: [0, 10],
-        // 巷道连线中点标注(富文本):第一行子测风点位名称、第二行风量、第三行绿色圆点 + 已绑定数据布点名称
-        // 未绑定时第三行输出浅灰占位圆点(dotEmpty),保证标签高度恒定
+        // 巷道连线中点标注:常态仅显示名称(midLabel),高亮(emphasis)显示名称及布点信息(midLabelFull)
+        // 名称行 + 圆点与已绑定布点名;未绑定时第三行输出浅灰占位圆点
         edgeLabel: {
         edgeLabel: {
           show: true,
           show: true,
           position: 'middle',
           position: 'middle',
-          verticalAlign: 'middle',
-          // midLabel 由渲染层按 rich 标记生成:{name|...}\n{volume|...}\n{dot|● }{device|...} 或 {name|...}\n{volume|...}\n{dotEmpty|● }
+          verticalAlign: 'top',
+          opacity: 0.4,
+          width: 100,
+          overflow: 'truncate',
+          // midLabel/midLabelFull 由渲染层按 rich 标记生成:常态 {name|...};高亮 {name|...}\n{dot|● }{device|...} 或 {dotEmpty|● }
           formatter: (p: any) => p?.data?.midLabel || '',
           formatter: (p: any) => p?.data?.midLabel || '',
           rich: {
           rich: {
-            name: { fontSize: 14, color: '#333', fontWeight: 'bold', lineHeight: 20 },
-            volume: { fontSize: 12, color: '#666', lineHeight: 18 },
+            name: { fontSize: 14, color: '#333', fontWeight: 'bold' },
+            // volume: { fontSize: 12, color: '#666', lineHeight: 18 },
             dot: { fontSize: 12, color: '#52c41a', lineHeight: 18 },
             dot: { fontSize: 12, color: '#52c41a', lineHeight: 18 },
             device: { fontSize: 12, color: '#333', lineHeight: 18 },
             device: { fontSize: 12, color: '#333', lineHeight: 18 },
             dotEmpty: { fontSize: 12, color: '#d9d9d9', lineHeight: 18 },
             dotEmpty: { fontSize: 12, color: '#d9d9d9', lineHeight: 18 },
@@ -107,13 +110,21 @@ export function createGraphOption(): EChartsOption {
           formatter: (p: any) => p.name || '',
           formatter: (p: any) => p.name || '',
         },
         },
         lineStyle: { color: '#8c8c8c', curveness: 0, width: 2, opacity: 0.7 },
         lineStyle: { color: '#8c8c8c', curveness: 0, width: 2, opacity: 0.7 },
-        // 悬浮高亮:节点悬浮时联动高亮相邻节点与连线(绑定/布点模式均保留
+        // 悬浮高亮:高亮时连线标注切换为"名称 + 布点信息"(emphasis.edgeLabel.formatter 读 midLabelFull
         emphasis: {
         emphasis: {
-          focus: 'adjacency',
+          focus: 'series',
           lineStyle: { width: 3, opacity: 1 },
           lineStyle: { width: 3, opacity: 1 },
+          edgeLabel: {
+            show: true,
+            opacity: 1,
+            formatter: (p: any) => p?.data?.midLabelFull || '',
+          },
         },
         },
         nodes: [],
         nodes: [],
         links: [],
         links: [],
+        tooltip: {
+          show: true,
+        },
       },
       },
     ],
     ],
   };
   };

+ 61 - 58
tests/useTopologyLayout.spec.ts

@@ -112,70 +112,64 @@ describe('formatAirVolumeLabel 风量标注', () => {
   });
   });
 });
 });
 
 
-describe('computeLayout 列式布局(自适应铺满)', () => {
+describe('computeLayout 列式布局(固定间距)', () => {
   const centerY = 350; // 700 / 2
   const centerY = 350; // 700 / 2
+  const laneX = (idx: number) => LAYOUT.margin + LAYOUT.colGap * idx;
 
 
-  /** 与布局实现一致的自适应列距计算(1200 宽、8 列) */
-  const colCount = 8;
-  const xGap = Math.min(LAYOUT.colGap, Math.max(LAYOUT.colGapMin, (1200 - 2 * LAYOUT.margin) / (colCount - 1)));
-  const xStart = LAYOUT.margin + (1200 - 2 * LAYOUT.margin - xGap * (colCount - 1)) / 2;
-  const laneX = (idx: number) => xStart + idx * xGap;
-
-  test('列序:总进 → lv1..lv5 → 总回,x 依次递增自适应列距并铺满;单节点列垂直居中', () => {
+  test('列序:总进 → lv1..lv5 → 总回,x 依次递增固定 colGap;单节点列垂直居中', () => {
     const layout = computeLayout(laneTopo(), 1200, 700);
     const layout = computeLayout(laneTopo(), 1200, 700);
     const pos = layout.positions;
     const pos = layout.positions;
-    expect(pos[ROOT_IN_ID].x).toBeCloseTo(laneX(0), 5);
-    expect(pos['point:a1'].x).toBeCloseTo(laneX(1), 5);
-    expect(pos['point:b1'].x).toBeCloseTo(laneX(2), 5);
-    expect(pos['point:c1'].x).toBeCloseTo(laneX(3), 5);
-    expect(pos['point:d1'].x).toBeCloseTo(laneX(4), 5);
-    expect(pos['point:e1'].x).toBeCloseTo(laneX(5), 5);
-    expect(pos[ROOT_OUT_ID].x).toBeCloseTo(laneX(7), 5); // 未分级列之后为总回
+    expect(pos[ROOT_IN_ID].x).toBe(laneX(0));
+    expect(pos['point:a1'].x).toBe(laneX(1));
+    expect(pos['point:b1'].x).toBe(laneX(2));
+    expect(pos['point:c1'].x).toBe(laneX(3));
+    expect(pos['point:d1'].x).toBe(laneX(4));
+    expect(pos['point:e1'].x).toBe(laneX(5));
+    expect(pos[ROOT_OUT_ID].x).toBe(laneX(7)); // 未分级列之后为总回
     expect(pos[ROOT_IN_ID].y).toBe(centerY);
     expect(pos[ROOT_IN_ID].y).toBe(centerY);
     expect(pos[ROOT_OUT_ID].y).toBe(centerY);
     expect(pos[ROOT_OUT_ID].y).toBe(centerY);
-    // 铺满:总进贴左边距、总回贴右边距
-    expect(pos[ROOT_IN_ID].x).toBeCloseTo(LAYOUT.margin, 5);
-    expect(pos[ROOT_OUT_ID].x).toBeCloseTo(1200 - LAYOUT.margin, 5);
   });
   });
 
 
-  test('同层级同列,行距按最高列自适应(clamp((H-2vMargin)/(n-1), min, max)),围绕中心对称', () => {
+  test('同层级同列,行距 = 该层级 rowGap(固定),围绕中心对称', () => {
     const data = laneTopo();
     const data = laneTopo();
     data.nodes.push({ id: 'point:b2', name: 'b2', category: 1, level: 2, airVolume: 85 });
     data.nodes.push({ id: 'point:b2', name: 'b2', category: 1, level: 2, airVolume: 85 });
     const pos = computeLayout(data, 1200, 700).positions;
     const pos = computeLayout(data, 1200, 700).positions;
     expect(pos['point:b2'].x).toBe(pos['point:b1'].x);
     expect(pos['point:b2'].x).toBe(pos['point:b1'].x);
-    const expected = Math.min(LAYOUT.rowGap, Math.max(LAYOUT.rowGapMin, (700 - 2 * LAYOUT.vMargin) / (2 - 1)));
-    expect(Math.abs(pos['point:b2'].y - pos['point:b1'].y)).toBeCloseTo(expected, 5);
+    const expected = LAYOUT.rowGap[2] ?? LAYOUT.rowGapDefault;
+    expect(Math.abs(pos['point:b2'].y - pos['point:b1'].y)).toBe(expected);
     expect((pos['point:b1'].y + pos['point:b2'].y) / 2).toBeCloseTo(centerY, 5);
     expect((pos['point:b1'].y + pos['point:b2'].y) / 2).toBeCloseTo(centerY, 5);
   });
   });
 
 
-  test('colGap/rowGap 上下限可配置生效', () => {
-    const orig = {
-      colGap: LAYOUT.colGap,
-      colGapMin: LAYOUT.colGapMin,
-      rowGap: LAYOUT.rowGap,
-      rowGapMin: LAYOUT.rowGapMin,
-    };
+  test('colGap 固定生效', () => {
+    const orig = LAYOUT.colGap;
+    try {
+      LAYOUT.colGap = 250;
+      const pos = computeLayout(laneTopo(), 1200, 700).positions;
+      expect(pos['point:a1'].x - pos[ROOT_IN_ID].x).toBe(250);
+      expect(pos['point:b1'].x - pos['point:a1'].x).toBe(250);
+    } finally {
+      LAYOUT.colGap = orig;
+    }
+  });
+
+  test('rowGap 按 level 分别配置生效:只影响对应层级列', () => {
+    const orig2 = LAYOUT.rowGap[2];
     try {
     try {
-      // 列距上限调小(低于自适应理想值 154.29)→ 相邻列间距 = colGap,整组在画布内居中
-      LAYOUT.colGap = 140;
-      const data1 = laneTopo();
-      const pos1 = computeLayout(data1, 1200, 700).positions;
-      expect(pos1['point:a1'].x - pos1[ROOT_IN_ID].x).toBeCloseTo(140, 5);
-      expect(pos1[ROOT_IN_ID].x).toBeGreaterThan(LAYOUT.margin);
-      // 行距上限调小(低于理想值 620)→ 行距 = rowGap
-      LAYOUT.rowGap = 200;
-      const data2 = laneTopo();
-      data2.nodes.push({ id: 'point:b2', name: 'b2', category: 1, level: 2, airVolume: 85 });
-      const pos2 = computeLayout(data2, 1200, 700).positions;
-      expect(Math.abs(pos2['point:b2'].y - pos2['point:b1'].y)).toBe(200);
-      // 列距下限调大(高于理想值,带宽超画布)→ 相邻列间距 = colGapMin,保持左对齐不向左侧溢出
-      LAYOUT.colGap = 300;
-      LAYOUT.colGapMin = 200;
-      const pos3 = computeLayout(laneTopo(), 1200, 700).positions;
-      expect(pos3['point:a1'].x - pos3[ROOT_IN_ID].x).toBeCloseTo(200, 5);
-      expect(pos3[ROOT_IN_ID].x).toBe(LAYOUT.margin);
+      LAYOUT.rowGap[2] = 300;
+      const data = laneTopo();
+      data.nodes.push({ id: 'point:b2', name: 'b2', category: 1, level: 2, airVolume: 85 });
+      data.nodes.push({ id: 'point:c2', name: 'c2', category: 1, level: 3, airVolume: 75 });
+      const pos = computeLayout(data, 1200, 700).positions;
+      // lv2 列使用本次配置值 300
+      expect(Math.abs(pos['point:b2'].y - pos['point:b1'].y)).toBe(300);
+      // lv3 列仍使用其自身配置值(未被本次修改影响)
+      const gap3 = LAYOUT.rowGap[3] ?? LAYOUT.rowGapDefault;
+      expect(Math.abs(pos['point:c2'].y - pos['point:c1'].y)).toBe(gap3);
+      // 两列分别围绕 centerY 对称
+      expect((pos['point:b1'].y + pos['point:b2'].y) / 2).toBeCloseTo(centerY, 5);
+      expect((pos['point:c1'].y + pos['point:c2'].y) / 2).toBeCloseTo(centerY, 5);
     } finally {
     } finally {
-      Object.assign(LAYOUT, orig);
+      LAYOUT.rowGap[2] = orig2;
     }
     }
   });
   });
 
 
@@ -206,12 +200,17 @@ describe('computeLayout 列式布局(自适应铺满)', () => {
     expect(layout.positions['point:a1'].x).toBe(layout.positions['point:a2'].x);
     expect(layout.positions['point:a1'].x).toBe(layout.positions['point:a2'].x);
     expect(layout.positions['point:c1'].x).toBe(layout.positions['point:c2'].x);
     expect(layout.positions['point:c1'].x).toBe(layout.positions['point:c2'].x);
     expect(layout.positions['point:e1'].x).toBe(layout.positions['point:e2'].x);
     expect(layout.positions['point:e1'].x).toBe(layout.positions['point:e2'].x);
-    // 同秩跨列 y 对齐:各列节点数相同(2),首节点 y 一致
-    expect(layout.positions['point:a1'].y).toBe(layout.positions['point:c1'].y);
-    expect(layout.positions['point:c1'].y).toBe(layout.positions['point:e1'].y);
+    // 各 2 节点列按各自层级行距围绕 centerY 对称(首节点 = centerY − 行距/2)
+    const gap1 = LAYOUT.rowGap[1] ?? LAYOUT.rowGapDefault;
+    const gap3 = LAYOUT.rowGap[3] ?? LAYOUT.rowGapDefault;
+    const gap5 = LAYOUT.rowGap[5] ?? LAYOUT.rowGapDefault;
+    expect(layout.positions['point:a1'].y).toBeCloseTo(centerY - gap1 / 2, 5);
+    expect(layout.positions['point:c1'].y).toBeCloseTo(centerY - gap3 / 2, 5);
+    expect(layout.positions['point:e1'].y).toBeCloseTo(centerY - gap5 / 2, 5);
+    expect((layout.positions['point:a1'].y + layout.positions['point:a2'].y) / 2).toBeCloseTo(centerY, 5);
   });
   });
 
 
-  test('不同节点数列独立垂直居中(菱形轮廓):1/2/4 节点列各自围绕 centerY 对称,单节点列在 centerY', () => {
+  test('不同节点数列独立垂直居中:1/2/4 节点列各自围绕 centerY 对称,单节点列在 centerY', () => {
     // 列分布:总进(1) lv1(1) lv2(2) lv3(4) lv4(2) lv5(1) 总回(1)
     // 列分布:总进(1) lv1(1) lv2(2) lv3(4) lv4(2) lv5(1) 总回(1)
     const areas = [
     const areas = [
       area('a1', 1, 500),
       area('a1', 1, 500),
@@ -233,16 +232,20 @@ describe('computeLayout 列式布局(自适应铺满)', () => {
     expect(pos['point:a1'].y).toBe(centerY);
     expect(pos['point:a1'].y).toBe(centerY);
     expect(pos['point:e1'].y).toBe(centerY);
     expect(pos['point:e1'].y).toBe(centerY);
     expect(pos[ROOT_OUT_ID].y).toBe(centerY);
     expect(pos[ROOT_OUT_ID].y).toBe(centerY);
-    // 2 节点列(lv2/lv4)围绕 centerY 对称,中点 = centerY
+    // 2 节点列(lv2/lv4)围绕 centerY 对称,中点 = centerY,行距取各自层级配置
+    const gap2 = LAYOUT.rowGap[2] ?? LAYOUT.rowGapDefault;
+    const gap3 = LAYOUT.rowGap[3] ?? LAYOUT.rowGapDefault;
+    const gap4 = LAYOUT.rowGap[4] ?? LAYOUT.rowGapDefault;
     expect((pos['point:b1'].y + pos['point:b2'].y) / 2).toBeCloseTo(centerY, 5);
     expect((pos['point:b1'].y + pos['point:b2'].y) / 2).toBeCloseTo(centerY, 5);
     expect((pos['point:d1'].y + pos['point:d2'].y) / 2).toBeCloseTo(centerY, 5);
     expect((pos['point:d1'].y + pos['point:d2'].y) / 2).toBeCloseTo(centerY, 5);
-    // 4 节点列(lv3)围绕 centerY 对称,中点 = centerY
+    expect(Math.abs(pos['point:b1'].y - pos['point:b2'].y)).toBe(gap2);
+    expect(Math.abs(pos['point:d1'].y - pos['point:d2'].y)).toBe(gap4);
+    // 4 节点列(lv3)围绕 centerY 对称,中点 = centerY,跨度 = 3×该层级行距
     expect((pos['point:c1'].y + pos['point:c4'].y) / 2).toBeCloseTo(centerY, 5);
     expect((pos['point:c1'].y + pos['point:c4'].y) / 2).toBeCloseTo(centerY, 5);
-    // 菱形轮廓:4 节点列纵向跨度最大(顶底超出 2 节点列)
-    const span4 = Math.abs(pos['point:c1'].y - pos['point:c4'].y);
-    const span2 = Math.abs(pos['point:b1'].y - pos['point:b2'].y);
-    expect(span4).toBeGreaterThan(span2);
-    expect(span2).toBeGreaterThan(0);
+    expect(Math.abs(pos['point:c1'].y - pos['point:c4'].y)).toBe(3 * gap3);
+    // 中间列跨度大于两端列(菱形轮廓)
+    expect(3 * gap3).toBeGreaterThan(gap2);
+    expect(gap2).toBeGreaterThan(0);
   });
   });
 
 
   test('空数据(仅总进/总回地面节点)可布局且不抛错', () => {
   test('空数据(仅总进/总回地面节点)可布局且不抛错', () => {