Explorar o código

[Pref 0000] 优化标记样式以及拓扑图排序算法

houzekong hai 3 días
pai
achega
271de9077a

+ 28 - 8
src/views/analysis/warningAnalysis/windPointManage/windTopology/hooks/useTopologyLayout.ts

@@ -1,5 +1,6 @@
 import type { TopologyData, TopoNodeData } from '../windTopology.data';
 import { LAYOUT, LAYOUT_HEIGHT, ROOT_IN_ID, ROOT_OUT_ID } from '../windTopology.data';
+import { orderNodesByLevel } from './useTopologyOrdering';
 
 export interface LayoutResult {
   /** 节点坐标(id → {x, y}) */
@@ -22,7 +23,8 @@ const rowGapFor = (lv?: number | null) => LAYOUT.rowGap[levelKey(lv) as any] ??
  *   列间距(x 轴间隔)固定:x = margin + colGap × 列序;
  *   行间距(y 轴间隔)固定且按层级分别配置:每列取该层级 rowGap[level](未配置回退 rowGapDefault);
  *   每列围绕画布垂直中心独立居中:colStart = centerY − spacing·(n−1)/2,
- *   节点 y = colStart + 秩 × spacing;列内顺序按风量降序。
+ *   节点 y = colStart + 秩 × spacing;列内顺序由 useTopologyOrdering 交叉最小化排序
+ *   (median/barycenter 多轮扫描)决定,不再按风量降序。
  */
 export function computeLayout(data: TopologyData, _width: number, height: number): LayoutResult {
   const H = height < 100 ? LAYOUT_HEIGHT : height;
@@ -48,20 +50,38 @@ export function computeLayout(data: TopologyData, _width: number, height: number
 
   const centerY = H / 2;
 
+  // 列内排序:按交叉最小化排序结果重排(order 缺失的层保持输入序,防御性兜底)
+  const order = orderNodesByLevel(data);
+  const applyOrder = (col: TopoNodeData[], key: string) => {
+    const ids = order.get(key);
+    if (!ids || ids.length === 0 || col.length === 0) return;
+    const posById = new Map<string, number>();
+    ids.forEach((id, i) => posById.set(id, i));
+    const indexed = col.map((n, idx) => ({ n, idx }));
+    indexed.sort((p, q) => {
+      const pp = posById.get(p.n.id);
+      const pq = posById.get(q.n.id);
+      if (pp !== undefined && pq !== undefined) return pp - pq;
+      if (pp !== undefined) return -1;
+      if (pq !== undefined) return 1;
+      return p.idx - q.idx;
+    });
+    col.splice(0, col.length, ...indexed.map((x) => x.n));
+  };
+
   // 列序:总进 → lv1..lv5 → 未分级 → 总回(空列保留占位,保持后续列位稳定)
   const cols: TopoNodeData[][] = [];
   cols.push(rootIn ? [rootIn] : []);
   for (let lv = 1; lv <= 5; lv++) {
-    cols.push(byLevel.get(lv)?.length ? [...byLevel.get(lv)!] : []);
+    const col = byLevel.get(lv)?.length ? [...byLevel.get(lv)!] : [];
+    applyOrder(col, String(lv));
+    cols.push(col);
   }
-  cols.push(ungraded.length ? [...ungraded] : []);
+  const ug = ungraded.length ? [...ungraded] : [];
+  applyOrder(ug, 'ungraded');
+  cols.push(ug);
   cols.push(rootOut ? [rootOut] : []);
 
-  // 列内稳定排序:按风量降序(秩 = 列内纵向位置)
-  for (const col of cols) {
-    col.sort((a, b) => (b.airVolume ?? 0) - (a.airVolume ?? 0));
-  }
-
   // 逐列赋坐标:x = margin + colGap × 列序;行距取该层级配置,每列独立居中
   cols.forEach((col, i) => {
     const x = laneX(i);

+ 237 - 0
src/views/analysis/warningAnalysis/windPointManage/windTopology/hooks/useTopologyOrdering.ts

@@ -0,0 +1,237 @@
+import type { TopologyData, TopoNodeData } from '../windTopology.data';
+
+/**
+ * 通风网络拓扑层内排序:median / barycenter 多轮扫描法(Sugiyama 框架的 crossing reduction 阶段)。
+ *
+ * 背景:层级已由 level 字段固定为语义列(1=矿井进风 … 5=矿井回风),列内排序只影响相邻层
+ * 巷道连线的交叉数。二部交叉最小化(bipartite crossing minimization)为 NP-hard,
+ * 本模块采用工业图布局库(dagre/ELK)同款启发式:
+ *   1) 每层节点按"其相邻层邻居位置的 median(中位数)/ barycenter(均值)"计算关键值;
+ *   2) 左右多轮往返扫描(down/up sweep),保留历史交叉数最优解。
+ *
+ * 确定性保证:固定轮次(默认 20)+ 三元比较稳定排序(关键值、输入 index),
+ * 同一输入永远输出同一顺序,避免 resize / 刷新导致的布局抖动。
+ *
+ * 不参与排序、保持输入序(不影响交叉数的节点/边):
+ *   - 未分级列(level 无效);
+ *   - 跨层巷道边(两端 level 差 > 1)及其端点;
+ *   - 根边(地面 ↔ lv1 / lv5)与地面节点(单节点列)。
+ * 无任何相邻层边的节点关键值为 +Infinity:排在层尾,且彼此保持相对输入序。
+ */
+
+export interface OrderingOptions {
+  /** 邻居位置聚合方法:median=中位数(默认,抗离群,交叉 ≤ 3×最优 有理论保证)、barycenter=均值 */
+  method?: 'median' | 'barycenter';
+  /** 往返扫描轮数(固定值,保证确定性) */
+  sweepRounds?: number;
+}
+
+/** 参与排序的层级 key(1..5),与 levelTextMap / computeLayout 分组一致 */
+const ORDER_KEYS = ['1', '2', '3', '4', '5'];
+
+/** 层级 key:1..5 有效 → 数字字符串;否则 'ungraded' */
+function levelKeyOf(p: TopoNodeData): string {
+  const lv = Number(p.level);
+  return Number.isFinite(lv) && lv >= 1 && lv <= 5 ? String(lv) : 'ungraded';
+}
+
+/**
+ * 构建相邻层(level 差恰为 1)巷道边表:
+ * key = "低层-高层"(如 '2-3'),value = 方向规范化的边数组 [低层节点 id, 高层节点 id]。
+ * 渲染方向(parent → child)不影响交叉判定,统一规范化为低层 → 高层。
+ */
+function buildPairEdges(data: TopologyData): Map<string, Array<[string, string]>> {
+  const levelById = new Map<string, number>();
+  for (const n of data.nodes) {
+    if (n.category !== 1) continue;
+    const lv = Number(n.level);
+    if (Number.isFinite(lv) && lv >= 1 && lv <= 5) levelById.set(n.id, lv);
+  }
+
+  const edgesByPair = new Map<string, Array<[string, string]>>();
+  for (const l of data.links) {
+    if (l.kind !== 'roadway') continue;
+    const sl = levelById.get(l.source);
+    const tl = levelById.get(l.target);
+    if (sl === undefined || tl === undefined) continue;
+    if (Math.abs(sl - tl) !== 1) continue;
+    const lo = Math.min(sl, tl);
+    const hi = Math.max(sl, tl);
+    const key = `${lo}-${hi}`;
+    if (!edgesByPair.has(key)) edgesByPair.set(key, []);
+    edgesByPair.get(key)!.push(sl < tl ? [l.source, l.target] : [l.target, l.source]);
+  }
+  return edgesByPair;
+}
+
+/** 层序数组 → 节点秩映射(nodeId → 层内 index);undefined(空层)返回空 map */
+function rankOf(order: string[] | undefined): Map<string, number> {
+  const rank = new Map<string, number>();
+  if (order) order.forEach((id, i) => rank.set(id, i));
+  return rank;
+}
+
+/**
+ * 相邻两层交叉数:edges 中每条边一端在 A 层、一端在 B 层;
+ * 两对边 (a1,b1)、(a2,b2) 交叉 ⇔ a1/a2 在 A 层秩的相对顺序与 b1/b2 在 B 层秩相反。
+ * O(E²) 直接统计(本系统每矿测风点 ≤ 数百,足够)。
+ */
+export function countCrossings(
+  rankA: Map<string, number>,
+  rankB: Map<string, number>,
+  edges: Array<[string, string]>
+): number {
+  let total = 0;
+  for (let i = 0; i < edges.length; i++) {
+    const [a1, b1] = edges[i];
+    const ra1 = rankA.get(a1);
+    const rb1 = rankB.get(b1);
+    if (ra1 === undefined || rb1 === undefined) continue;
+    for (let j = i + 1; j < edges.length; j++) {
+      const [a2, b2] = edges[j];
+      const ra2 = rankA.get(a2);
+      const rb2 = rankB.get(b2);
+      if (ra2 === undefined || rb2 === undefined) continue;
+      if ((ra1 < ra2 && rb1 > rb2) || (ra1 > ra2 && rb1 < rb2)) total++;
+    }
+  }
+  return total;
+}
+
+/** 复用已有边表的全图总交叉数(4 个相邻层对 lv1-lv2 … lv4-lv5 交叉数之和) */
+function countTotalCrossingsWith(
+  edgesByPair: Map<string, Array<[string, string]>>,
+  order: Map<string, string[]>
+): number {
+  let total = 0;
+  for (let i = 0; i < ORDER_KEYS.length - 1; i++) {
+    const lo = ORDER_KEYS[i];
+    const hi = ORDER_KEYS[i + 1];
+    const edges = edgesByPair.get(`${lo}-${hi}`);
+    if (!edges || edges.length === 0) continue;
+    total += countCrossings(rankOf(order.get(lo)), rankOf(order.get(hi)), edges);
+  }
+  return total;
+}
+
+/** 全图总交叉数:order 为 orderNodesByLevel 返回的层序(key '1'..'5') */
+export function countTotalCrossings(data: TopologyData, order: Map<string, string[]>): number {
+  return countTotalCrossingsWith(buildPairEdges(data), order);
+}
+
+/** 深拷贝层序(仅复制参与排序的层) */
+function snapshotOrder(order: Map<string, string[]>): Map<string, string[]> {
+  const copy = new Map<string, string[]>();
+  for (const [k, arr] of order) copy.set(k, [...arr]);
+  return copy;
+}
+
+/**
+ * 单层重排:目标层节点按"其在 neighbor 层的邻居位置"的 median/barycenter 升序稳定排序。
+ * 无邻居的节点关键值 +Infinity → 排层尾且彼此保持相对输入序。
+ */
+function sweepLayer(
+  current: Map<string, string[]>,
+  edges: Array<[string, string]> | undefined,
+  targetKey: string,
+  neighborKey: string,
+  isTargetHigh: boolean,
+  method: 'median' | 'barycenter'
+): void {
+  const target = current.get(targetKey);
+  if (!target || target.length <= 1 || !edges || edges.length === 0) return;
+  const neighborRank = rankOf(current.get(neighborKey));
+
+  // 目标层节点 → 其邻居(neighbor 层)位置集合(边已规范化为 [低层, 高层])
+  const neighborPos = new Map<string, number[]>();
+  for (const [loId, hiId] of edges) {
+    const tId = isTargetHigh ? hiId : loId;
+    const nId = isTargetHigh ? loId : hiId;
+    const r = neighborRank.get(nId);
+    if (r === undefined) continue;
+    if (!neighborPos.has(tId)) neighborPos.set(tId, []);
+    neighborPos.get(tId)!.push(r);
+  }
+
+  const keyOf = (id: string): number => {
+    const arr = neighborPos.get(id);
+    if (!arr || arr.length === 0) return Infinity;
+    if (method === 'barycenter') {
+      let sum = 0;
+      for (const v of arr) sum += v;
+      return sum / arr.length;
+    }
+    arr.sort((x, y) => x - y);
+    const m = Math.floor(arr.length / 2);
+    return arr.length % 2 === 1 ? arr[m] : (arr[m - 1] + arr[m]) / 2;
+  };
+
+  const indexed = target.map((id, idx) => ({ id, idx }));
+  indexed.sort((p, q) => {
+    const kp = keyOf(p.id);
+    const kq = keyOf(q.id);
+    if (kp !== kq) return kp - kq;
+    return p.idx - q.idx;
+  });
+  current.set(targetKey, indexed.map((p) => p.id));
+}
+
+/**
+ * 层内排序主入口:返回各层有序节点 id(key:'1'..'5' 与 'ungraded')。
+ * - 参与排序层(lv1..lv5):以输入序(data.nodes 顺序,即后端返回序)为初始序,
+ *   sweep 后返回历史交叉数最优解;
+ * - 未分级列:始终返回输入序(不参与扫描);
+ * - 空层:返回空数组。
+ */
+export function orderNodesByLevel(data: TopologyData, opts?: OrderingOptions): Map<string, string[]> {
+  const method = opts?.method ?? 'median';
+  const sweepRounds = opts?.sweepRounds ?? 20;
+
+  // 1) 按输入序分层
+  const layers = new Map<string, string[]>();
+  for (const n of data.nodes) {
+    if (n.category !== 1) continue;
+    const key = levelKeyOf(n);
+    if (!layers.has(key)) layers.set(key, []);
+    layers.get(key)!.push(n.id);
+  }
+
+  const edgesByPair = buildPairEdges(data);
+
+  // 2) 初始层序(仅参与层)与历史最优记录
+  const current = new Map<string, string[]>();
+  for (const key of ORDER_KEYS) {
+    current.set(key, layers.get(key) ? [...layers.get(key)!] : []);
+  }
+  let best = snapshotOrder(current);
+  let bestCross = countTotalCrossingsWith(edgesByPair, current);
+  const evalTotal = () => {
+    const c = countTotalCrossingsWith(edgesByPair, current);
+    if (c < bestCross) {
+      bestCross = c;
+      best = snapshotOrder(current);
+    }
+  };
+
+  // 3) 多轮往返扫描:down(左→右,用低层位置重排高层)+ up(右→左,用高层位置重排低层)
+  for (let round = 0; round < sweepRounds; round++) {
+    for (let i = 0; i < ORDER_KEYS.length - 1; i++) {
+      const key = `${ORDER_KEYS[i]}-${ORDER_KEYS[i + 1]}`;
+      sweepLayer(current, edgesByPair.get(key), ORDER_KEYS[i + 1], ORDER_KEYS[i], true, method);
+      evalTotal();
+    }
+    for (let i = ORDER_KEYS.length - 2; i >= 0; i--) {
+      const key = `${ORDER_KEYS[i]}-${ORDER_KEYS[i + 1]}`;
+      sweepLayer(current, edgesByPair.get(key), ORDER_KEYS[i], ORDER_KEYS[i + 1], false, method);
+      evalTotal();
+    }
+  }
+
+  // 4) 合并输出:参与层用历史最优,未分级列用输入序
+  const result = new Map<string, string[]>();
+  result.set('ungraded', layers.get('ungraded') ? [...layers.get('ungraded')!] : []);
+  for (const key of ORDER_KEYS) {
+    result.set(key, best.get(key) ? [...best.get(key)!] : []);
+  }
+  return result;
+}

+ 19 - 15
src/views/system/cadFile/app.ts

@@ -1,6 +1,7 @@
 import vjmap, { Map, GeoBounds, GeoProjection } from 'vjmap';
 // import { App, MapThreeLayer } from 'vjmap3d';
 import 'vjmap/dist/vjmap.min.css';
+import './marker.less';
 import { useAppStore } from '/@/store/modules/app';
 import { message, Modal } from 'ant-design-vue';
 import { updateMineArea } from './cad.api';
@@ -9,8 +10,6 @@ import GoafPopup from './components/GoafPopup.vue';
 import { get, remove } from 'lodash-es';
 import { StatusColorEnum } from '/@/enums/jeecgEnum';
 
-import customImage from '/@/assets/icons/location-icon.svg';
-import customImage3D from '/@/assets/icons/location-icon3D.png';
 import { env } from './env';
 import { tryOnUnmounted } from '@vueuse/core';
 
@@ -246,16 +245,21 @@ export function useMap2D(): UseMap2DReturn {
       el.appendChild(alarmStatusDiv);
     }
 
-    const iconDiv = document.createElement('div');
-    iconDiv.style.cssText = `
-      background-image: url("${showInfo ? customImage3D : customImage}");
-      width: 40px;
-      height: 45px;
-      background-size: 100% 100%;
-      background-position: center;
-      background-repeat: no-repeat;
-    `;
-    el.appendChild(iconDiv);
+    // 传感器标点:普通=绿色圆形标点;报警=红色 + 闪烁 + 红色波纹扩散(样式见 marker.less)
+    const markerDiv = document.createElement('div');
+    markerDiv.className = hasAlarm ? 'wf-marker wf-marker-alarm' : 'wf-marker';
+
+    const ripple1 = document.createElement('div');
+    ripple1.className = 'wf-marker-ripple';
+    const ripple2 = document.createElement('div');
+    ripple2.className = 'wf-marker-ripple wf-marker-ripple-2';
+    const dot = document.createElement('div');
+    dot.className = 'wf-marker-dot';
+
+    markerDiv.appendChild(ripple1);
+    markerDiv.appendChild(ripple2);
+    markerDiv.appendChild(dot);
+    el.appendChild(markerDiv);
 
     if (showInfo) {
       const devicePosDiv = document.createElement('div');
@@ -327,7 +331,7 @@ export function useMap2D(): UseMap2DReturn {
 
     const markerOptions: vjmap.createMarkerOptions = {
       element: el,
-      anchor: 'bottom',
+      anchor: 'center',
       offset: [0, 0],
     };
 
@@ -387,7 +391,7 @@ export function useMap2D(): UseMap2DReturn {
 
       marker = vjmap.createMarker({
         element: finalEl,
-        anchor: 'bottom',
+        anchor: 'center',
         offset: [0, 0],
       });
       marker.setLngLat(currentLngLat);
@@ -451,7 +455,7 @@ export function useMap2D(): UseMap2DReturn {
 
         const marker = vjmap.createMarker({
           element: el,
-          anchor: 'bottom',
+          anchor: 'center',
           offset: [0, 0],
         });
         marker.setLngLat(lnglat);

+ 81 - 0
src/views/system/cadFile/marker.less

@@ -0,0 +1,81 @@
+/**
+ * 地图布点标记样式(app.ts 经 import 引入,随模块按需打包):
+ * 普通状态 = 绿色圆形传感器标点;存在报警(alarmList 非空)= 红色标点 + 闪烁 + 红色波纹扩散。
+ * class 均带 wf- 前缀,避免与全局样式冲突。
+ */
+.wf-marker {
+  position: relative;
+  width: 24px;
+  height: 24px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  cursor: pointer;
+
+  /* 传感器标点:绿色实心圆 + 白色描边(z-index 高于波纹层,保证描边清晰) */
+  .wf-marker-dot {
+    position: relative;
+    z-index: 2;
+    width: 18px;
+    height: 18px;
+    border-radius: 50%;
+    /* StatusColorEnum.green */
+    background: #52c41a;
+    border: 2px solid #ffffff;
+    box-shadow: 0 0 4px rgba(0, 0, 0, 0.35);
+    box-sizing: border-box;
+  }
+
+  /* 红色波纹层:默认隐藏(非报警不渲染动画),报警时扩散 */
+  .wf-marker-ripple {
+    position: absolute;
+    inset: 0;
+    /* StatusColorEnum.red */
+    border: 2px solid #ff4d4f;
+    border-radius: 50%;
+    opacity: 0;
+    box-sizing: border-box;
+    pointer-events: none;
+  }
+
+  /* 报警状态:红色标点 + 闪烁 + 双波纹连续扩散(红波) */
+  &.wf-marker-alarm {
+    .wf-marker-dot {
+      background: #ff4d4f;
+      animation: wf-marker-blink 1s ease-in-out infinite;
+    }
+
+    .wf-marker-ripple {
+      opacity: 1;
+      animation: wf-marker-ripple 1.6s ease-out infinite;
+    }
+
+    /* 与第一道波纹错开半周期,形成连续红波 */
+    .wf-marker-ripple-2 {
+      animation-delay: 0.8s;
+    }
+  }
+}
+
+/* 标点闪烁:透明度呼吸 */
+@keyframes wf-marker-blink {
+  0%,
+  100% {
+    opacity: 1;
+  }
+  50% {
+    opacity: 0.35;
+  }
+}
+
+/* 红波扩散:从标点中心向外放大并淡出 */
+@keyframes wf-marker-ripple {
+  0% {
+    transform: scale(0.6);
+    opacity: 0.9;
+  }
+  100% {
+    transform: scale(3.2);
+    opacity: 0;
+  }
+}

+ 255 - 0
tests/useTopologyOrdering.spec.ts

@@ -0,0 +1,255 @@
+import {
+  buildRelationArray,
+  transformToTopologyData,
+  LAYOUT,
+  ROOT_IN_ID,
+  ROOT_OUT_ID,
+} from '../src/views/analysis/warningAnalysis/windPointManage/windTopology/windTopology.data';
+import type { TopologyData } from '../src/views/analysis/warningAnalysis/windPointManage/windTopology/windTopology.data';
+import {
+  orderNodesByLevel,
+  countCrossings,
+  countTotalCrossings,
+} from '../src/views/analysis/warningAnalysis/windPointManage/windTopology/hooks/useTopologyOrdering';
+import { computeLayout } from '../src/views/analysis/warningAnalysis/windPointManage/windTopology/hooks/useTopologyLayout';
+
+/** 构造测风点位(MineArea) */
+const area = (id: string, level: number, airVolume = 100, extra: Record<string, any> = {}) => ({
+  id,
+  name: id,
+  level,
+  airVolume,
+  mineCode: 'M1',
+  ...extra,
+});
+
+/** 构造巷道关系(MineAreaRelation) */
+const rel = (id: string, parentId: string, childId: string, extra: Record<string, any> = {}) => ({
+  id,
+  mineCode: 'M1',
+  parentId,
+  childId,
+  ...extra,
+});
+
+/** 相邻层边方向规范化:排序不关心渲染方向(parent→child),只关心端点所属层 */
+function pairEdgesOf(data: TopologyData): Map<string, Array<[string, string]>> {
+  // 复算 orderNodesByLevel 内部的相邻层边表(与实现保持一致:level 差恰为 1 的巷道边)
+  const levelById = new Map<string, number>();
+  for (const n of data.nodes) {
+    if (n.category !== 1) continue;
+    const lv = Number(n.level);
+    if (Number.isFinite(lv) && lv >= 1 && lv <= 5) levelById.set(n.id, lv);
+  }
+  const map = new Map<string, Array<[string, string]>>();
+  for (const l of data.links) {
+    if (l.kind !== 'roadway') continue;
+    const sl = levelById.get(l.source);
+    const tl = levelById.get(l.target);
+    if (sl === undefined || tl === undefined || Math.abs(sl - tl) !== 1) continue;
+    const key = `${Math.min(sl, tl)}-${Math.max(sl, tl)}`;
+    if (!map.has(key)) map.set(key, []);
+    map.get(key)!.push(sl < tl ? [l.source, l.target] : [l.target, l.source]);
+  }
+  return map;
+}
+
+describe('countCrossings 交叉数统计', () => {
+  test('秩相对顺序相反的一对边计 1 交叉,相同不计', () => {
+    // a 在 x 上方、y 在 b 上方:边 a-y 与 b-x 交叉
+    const rankA = new Map([
+      ['a', 0],
+      ['b', 1],
+    ]);
+    const rankB = new Map([
+      ['x', 0],
+      ['y', 1],
+    ]);
+    expect(countCrossings(rankA, rankB, [['a', 'y'], ['b', 'x']])).toBe(1);
+    expect(countCrossings(rankA, rankB, [['a', 'x'], ['b', 'y']])).toBe(0);
+    // 无公共端点秩(防御:缺失端点跳过不计数)
+    expect(countCrossings(rankA, rankB, [['a', 'y'], ['unknown', 'x']])).toBe(0);
+  });
+});
+
+describe('orderNodesByLevel median 扫描法', () => {
+  test('2×2 交叉场景:排序后相邻层交叉数为 0', () => {
+    // lv1=[a1,a2]、lv2=[b1,b2](输入序);边 a1→b2、a2→b1 → 初始 1 交叉
+    const areas = [area('a1', 1), area('a2', 1), area('b1', 2), area('b2', 2)];
+    const data = transformToTopologyData(areas, buildRelationArray(areas, [rel('r1', 'a1', 'b2'), rel('r2', 'a2', 'b1')]));
+    const order = orderNodesByLevel(data);
+    // 参与层存在且含全部节点
+    expect(order.get('1')).toHaveLength(2);
+    expect(order.get('2')).toHaveLength(2);
+    expect(countTotalCrossings(data, order)).toBe(0);
+  });
+
+  test('确定性:同一输入两次调用结果完全一致(含 barycenter 方法)', () => {
+    const areas = [area('a1', 1), area('a2', 1), area('a3', 1), area('b1', 2), area('b2', 2), area('b3', 2)];
+    const relations = buildRelationArray(
+      areas,
+      [
+        rel('r1', 'a1', 'b2'),
+        rel('r2', 'a2', 'b3'),
+        rel('r3', 'a3', 'b1'),
+        rel('r1b', 'a1', 'b3'),
+        rel('r2b', 'a2', 'b1'),
+      ],
+    );
+    const data = transformToTopologyData(areas, relations);
+    for (const method of ['median', 'barycenter'] as const) {
+      const o1 = orderNodesByLevel(data, { method });
+      const o2 = orderNodesByLevel(data, { method });
+      expect(o1.get('1')).toEqual(o2.get('1'));
+      expect(o1.get('2')).toEqual(o2.get('2'));
+    }
+  });
+
+  test('孤立节点(无相邻层边)排在层尾且保持相对输入序', () => {
+    // x1/x2 邻居均为 a1(位置相同 → 保持输入序);y 无任何相邻层边 → +Infinity 排层尾
+    const areas = [area('a1', 1), area('x1', 2), area('x2', 2), area('y', 2)];
+    const data = transformToTopologyData(areas, buildRelationArray(areas, [rel('r1', 'a1', 'x1'), rel('r2', 'a1', 'x2')]));
+    const order = orderNodesByLevel(data);
+    expect(order.get('2')).toEqual(['point:x1', 'point:x2', 'point:y']);
+  });
+
+  test('未分级列保持输入序,不参与扫描', () => {
+    const areas = [area('g1', 0), area('g2', 0), area('g3', 0)];
+    const data = transformToTopologyData(areas, []);
+    const order = orderNodesByLevel(data);
+    expect(order.get('ungraded')).toEqual(['point:g1', 'point:g2', 'point:g3']);
+  });
+
+  test('跨层边(level 差 >1)不参与排序:中间层保持输入序,不抛错', () => {
+    // 仅 a1(lv1)→c1(lv3) 跨层边;lv2 的 b1/b2 无相邻层边 → 均排层尾且保持相对顺序
+    const areas = [area('a1', 1), area('b1', 2), area('b2', 2), area('c1', 3)];
+    const data = transformToTopologyData(areas, buildRelationArray(areas, [rel('r1', 'a1', 'c1')]));
+    const order = orderNodesByLevel(data);
+    expect(order.get('2')).toEqual(['point:b1', 'point:b2']);
+    expect(countTotalCrossings(data, order)).toBe(0);
+  });
+
+  test('空数据(仅地面节点):各层空数组 + ungraded 空数组,不抛错', () => {
+    const data: TopologyData = {
+      nodes: [
+        { id: ROOT_IN_ID, name: '地面', category: 0 },
+        { id: ROOT_OUT_ID, name: '地面', category: 0 },
+      ],
+      links: [],
+    };
+    const order = orderNodesByLevel(data);
+    for (const key of ['1', '2', '3', '4', '5', 'ungraded']) {
+      expect(order.get(key)).toEqual([]);
+    }
+  });
+
+  test('sweepRounds 配置生效且不抛错', () => {
+    const areas = [area('a1', 1), area('a2', 1), area('b1', 2), area('b2', 2)];
+    const data = transformToTopologyData(areas, buildRelationArray(areas, [rel('r1', 'a1', 'b2'), rel('r2', 'a2', 'b1')]));
+    const order = orderNodesByLevel(data, { sweepRounds: 1 });
+    expect(countTotalCrossings(data, order)).toBe(0);
+  });
+
+  test('初始序为输入序(无相邻层边时列序完全不变)', () => {
+    // 无任何巷道边:各层节点保持 data.nodes 输入序
+    const areas = [area('a1', 1, 10), area('a2', 1, 90), area('b1', 2, 50), area('b2', 2, 30)];
+    const data = transformToTopologyData(areas, []);
+    const order = orderNodesByLevel(data);
+    expect(order.get('1')).toEqual(['point:a1', 'point:a2']);
+    expect(order.get('2')).toEqual(['point:b1', 'point:b2']);
+  });
+});
+
+describe('computeLayout 与排序集成', () => {
+  const centerY = 350; // 700 / 2
+  const laneX = (idx: number) => LAYOUT.margin + LAYOUT.colGap * idx;
+
+  test('2×2 交叉场景:排序后坐标合法、x 列序不变、lv2 按交叉最小化重排', () => {
+    const areas = [area('a1', 1), area('a2', 1), area('b1', 2), area('b2', 2)];
+    const data = transformToTopologyData(areas, buildRelationArray(areas, [rel('r1', 'a1', 'b2'), rel('r2', 'a2', 'b1')]));
+    const pos = computeLayout(data, 1200, 700).positions;
+
+    for (const a of areas) {
+      const p = pos[`point:${a.id}`];
+      expect(p).toBeDefined();
+      expect(Number.isFinite(p.x)).toBe(true);
+      expect(Number.isFinite(p.y)).toBe(true);
+      expect(p.y).toBeGreaterThanOrEqual(0);
+      expect(p.y).toBeLessThan(700);
+    }
+    // x 列序不变:lv1 同列、lv2 同列
+    expect(pos['point:a1'].x).toBe(pos['point:a2'].x);
+    expect(pos['point:b1'].x).toBe(pos['point:b2'].x);
+    expect(pos['point:a1'].x).toBe(laneX(1));
+    expect(pos['point:b1'].x).toBe(laneX(2));
+    // 排序生效:lv2 重排为 [b2, b1](b2 在 b1 上方),消除交叉
+    expect(pos['point:b2'].y).toBeLessThan(pos['point:b1'].y);
+    // 每列仍围绕中心对称
+    expect((pos['point:a1'].y + pos['point:a2'].y) / 2).toBeCloseTo(centerY, 5);
+    expect((pos['point:b1'].y + pos['point:b2'].y) / 2).toBeCloseTo(centerY, 5);
+  });
+
+  test('5 层线状模型(laneTopo 等价):全部坐标合法,交叉数为 0', () => {
+    const areas = [area('a1', 1), area('b1', 2), area('c1', 3), area('d1', 4), area('e1', 5)];
+    const relations = buildRelationArray(
+      areas,
+      [rel('r1', 'a1', 'b1'), rel('r2', 'b1', 'c1'), rel('r3', 'c1', 'd1'), rel('r4', 'd1', 'e1')],
+    );
+    const data = transformToTopologyData(areas, relations);
+    const order = orderNodesByLevel(data);
+    expect(countTotalCrossings(data, order)).toBe(0);
+    const pos = computeLayout(data, 1200, 700).positions;
+    for (const a of areas) {
+      const p = pos[`point:${a.id}`];
+      expect(Number.isFinite(p.x)).toBe(true);
+      expect(Number.isFinite(p.y)).toBe(true);
+      expect(p.y).toBeGreaterThanOrEqual(0);
+      expect(p.y).toBeLessThan(700);
+    }
+  });
+
+  test('排序不改变列序与总进/总回地面节点位置', () => {
+    const areas = [area('a1', 1), area('b1', 2), area('c1', 3), area('d1', 4), area('e1', 5)];
+    const relations = buildRelationArray(
+      areas,
+      [rel('r1', 'a1', 'b1'), rel('r2', 'b1', 'c1'), rel('r3', 'c1', 'd1'), rel('r4', 'd1', 'e1')],
+    );
+    const data = transformToTopologyData(areas, relations);
+    const pos = computeLayout(data, 1200, 700).positions;
+    expect(pos[ROOT_IN_ID].x).toBe(laneX(0));
+    expect(pos['point:a1'].x).toBe(laneX(1));
+    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_OUT_ID].y).toBe(centerY);
+  });
+
+  test('pairEdgesOf 复算与 countTotalCrossings 一致(防实现漂移)', () => {
+    const areas = [area('a1', 1), area('a2', 1), area('b1', 2), area('b2', 2), area('c1', 3)];
+    const relations = buildRelationArray(
+      areas,
+      [
+        rel('r1', 'a1', 'b2'),
+        rel('r2', 'a2', 'b1'),
+        rel('r3', 'b2', 'c1'),
+        rel('r4', 'a1', 'c1'), // 跨层边:不参与
+      ],
+    );
+    const data = transformToTopologyData(areas, relations);
+    const order = orderNodesByLevel(data);
+    // 独立复算相邻层边,再统计交叉数,应与 countTotalCrossings 一致
+    const edges = pairEdgesOf(data);
+    const rankOf = (key: string) => {
+      const r = new Map<string, number>();
+      (order.get(key) || []).forEach((id, i) => r.set(id, i));
+      return r;
+    };
+    let manual = 0;
+    for (let i = 1; i <= 4; i++) {
+      const es = edges.get(`${i}-${i + 1}`);
+      if (!es) continue;
+      manual += countCrossings(rankOf(String(i)), rankOf(String(i + 1)), es);
+    }
+    expect(manual).toBe(countTotalCrossings(data, order));
+  });
+});