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

[Pref 0000] 优化拓扑操作

houzekong пре 1 недеља
родитељ
комит
1f4d5b64e1

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

@@ -6,34 +6,36 @@ import { useMineDepartmentStore } from '/@/store/modules/mine';
 import {
   createGraphOption,
   categories,
-  statusColorMap,
   levelColorMap,
   nodeDetailFields,
-  transformToTopologyData,
-  buildRelationArray,
-  buildChains,
   POINT_COLOR,
   POINT_SIZE,
+  JUDGE_COLOR,
+  JUDGE_WIDTH,
   formatAirVolumeLabel,
 } from '../windTopology.data';
-import type { TopologyData, TopoNodeData, AreaChain } from '../windTopology.data';
-import { getTopologyData, updateArea } from '../windTopology.api';
+import type { TopologyData, TopoNodeData } from '../windTopology.data';
+import { getTopologyData, updateArea, addMineAreaRelation, deleteMineAreaRelation } from '../windTopology.api';
 import { computeLayout } from './useTopologyLayout';
 
+/** 拓扑交互模式:relation=绑定模式(关系编辑)、device=布点模式(设备绑定/解绑) */
+export type TopoMode = 'relation' | 'device';
+
 export function useTopology() {
   const chartRef = ref<HTMLDivElement | null>(null);
   let chartInstance: echarts.ECharts | null = null;
   let topologyData: TopologyData = { nodes: [], links: [] };
-  /** 巷道链式结构(buildChains 结果):布局时各层列内按 (链下标, 链内位置) 排序 */
-  let topologyChains: AreaChain[] = [];
   /** 数据布点显示名映射(rawId → devicePos/deviceCode/id,取自全量 windrectList):
    *  已绑定设备不再绘制节点,其名称由该映射写入巷道连线 edgeLabel 及解绑确认文案 */
   let deviceNameMap: Record<string, string> = {};
   let resizeObserver: ResizeObserver | null = null;
 
   // —— 模式状态 ——
-  const editMode = ref(false);
-  /** 绑定对话框:双击未绑定巷道连线后弹出,选择未使用数据布点进行绑定 */
+  /** 当前模式:默认绑定模式;两种模式均保留悬浮高亮与单击详情 */
+  const mode = ref<TopoMode>('relation');
+  /** 绑定模式选中的关联源节点(双击节点选中,再单击另一节点建立关系;红色描边高亮) */
+  const armedSource = ref<TopoNodeData | null>(null);
+  /** 布点模式绑定弹窗:双击未绑定布点的连线后弹出选择数据布点 */
   const bindVisible = ref(false);
   const bindPoint = ref<TopoNodeData | null>(null);
   const bindDeviceId = ref<string | undefined>(undefined);
@@ -85,20 +87,20 @@ export function useTopology() {
         return;
       }
       selectedDeptId.value = mine.id;
-      const apiData = await getTopologyData({ deptId: mine.id });
+      // 接口获取 → 代码处理(关系/疑似标记/地面节点)已由 getTopologyData 完成,
+      // 此处直接消费全量数据;接口异常时 bundle 为备用空数据(仍可绘制总进地面节点)
+      const bundle = await getTopologyData({ deptId: mine.id });
+      armedSource.value = null;
       // 数据布点显示名映射(全量列表 → 已绑定设备的 edgeLabel/解绑文案展示用)
       deviceNameMap = {};
-      for (const d of apiData.windrectList) {
+      for (const d of bundle.windrectList) {
         if (d.id) deviceNameMap[String(d.id)] = d.devicePos || d.deviceCode || String(d.id);
       }
-      // 巷道关系数组(每条 = 一条巷道,父子节点信息)作为拓扑数据源;
-      // 再按父子关系拆分为链式结构,供布局按链顺序排列(链间上下并行)
-      const relations = buildRelationArray(apiData.mineAreaList, apiData.mineAreaRelationList);
-      topologyChains = buildChains(relations, apiData.mineAreaList);
-      renderTopology(transformToTopologyData(apiData.mineAreaList, relations, apiData.windrectNoUsedList));
-      // 数据为空时给出诊断信息(地面节点仍会渲染)
-      if (!apiData.mineAreaList.length && !apiData.windrectNoUsedList.length) {
-        console.warn('拓扑数据为空:未获取到测风点位/数据布点(deptId=' + mine.id + ')');
+      renderTopology(bundle.topology);
+      // 数据为空时给出诊断信息(总进地面节点仍会渲染)
+      if (!bundle.topology.nodes.some((n) => n.category !== 0)) {
+        console.warn('拓扑数据为空:未获取到测风点位(deptId=' + mine.id + ')');
+        message.warning('未获取到拓扑数据,请检查接口或稍后刷新');
       }
     } catch (e) {
       console.error('拓扑数据加载失败:', e);
@@ -127,7 +129,7 @@ export function useTopology() {
   }
 
   // —— 渲染拓扑 ——
-  function renderTopology(data: TopologyData, forceUnfixed = false) {
+  function renderTopology(data: TopologyData) {
     topologyData = data;
     if (!chartInstance) return;
 
@@ -136,8 +138,8 @@ export function useTopology() {
 
     const cw = chartInstance.getWidth();
     const ch = chartInstance.getHeight();
-    // 计算巷道线模型(从左到右列式)布局坐标;链式结构决定各层列内节点顺序
-    const layout = computeLayout(data, cw, ch, topologyChains);
+    // 列式布局(总进 → lv1..lv5 → 未分级)
+    const layout = computeLayout(data, cw, ch);
     const pointById = new Map(data.nodes.filter((n) => n.category === 1).map((n) => [n.id, n]));
     // roadway 边子节点(名称已在连线中点展示,节点自身不再显示文本标签)
     const roadwayChildIds = new Set(data.links.filter((l) => l.kind === 'roadway' && l.pointId).map((l) => l.pointId));
@@ -152,13 +154,10 @@ export function useTopology() {
       }
       seenIds.add(n.id);
       const cat = categories[n.category] || categories[0];
-      // 节点配色:测风点位统一灰色小圆点(层级/类型区分转移到连线颜色);
-      // 数据布点(category 2)按状态配色(正常绿/离线灰);地面节点(category 0)使用分类色
+      // 节点配色:测风点位统一灰色小圆点(层级/类型区分转移到连线颜色);地面节点使用分类色
       let nodeColor = cat.color;
       if (n.category === 1) {
         nodeColor = POINT_COLOR;
-      } else if (n.category === 2) {
-        nodeColor = statusColorMap[n.status || 'normal'] || cat.color;
       }
       const itemStyle: any = { color: nodeColor };
       // 测风点位统一为灰色小圆点(白描边衬底,避免与彩色连线粘连)
@@ -166,6 +165,11 @@ export function useTopology() {
         itemStyle.borderColor = '#ffffff';
         itemStyle.borderWidth = 1.5;
       }
+      // 绑定模式选中的关联源节点:红色醒目标记
+      if (armedSource.value && armedSource.value.id === n.id) {
+        itemStyle.borderColor = '#f5222d';
+        itemStyle.borderWidth = 3;
+      }
       const pos = layout.positions[n.id];
       const en: any = {
         id: n.id,
@@ -184,14 +188,14 @@ export function useTopology() {
       if (pos) {
         en.x = pos.x;
         en.y = pos.y;
-        en.fixed = !forceUnfixed;
+        // 节点固定(固定布局,防止误拖破坏)
+        en.fixed = true;
       }
       echartsNodes.push(en);
     }
 
-    // 绘制层级:同一 series 内后绘制者在上。按优先级排序使「数据布点」置于最底、「测风点位」在其上方,
-    // 避免布点(roundRect)压住与其相邻/重叠的测风点位或连线;地面保持最底绘制
-    const renderPriority: Record<number, number> = { 0: 0, 2: 1, 1: 2 };
+    // 绘制层级:同一 series 内后绘制者在上。地面保持最底绘制
+    const renderPriority: Record<number, number> = { 0: 0, 1: 1 };
     echartsNodes.sort((a, b) => (renderPriority[a.category] ?? 0) - (renderPriority[b.category] ?? 0));
     series.nodes = echartsNodes;
     series.links = data.links
@@ -201,27 +205,29 @@ export function useTopology() {
           target: l.target,
           kind: l.kind,
           pointId: l.pointId,
+          relationId: l.relationId,
           flow: l.flow,
         };
         if (l.kind === 'roadway') {
           // 巷道连线:直线(无曲率)+ 按子节点 level 用 levelColorMap 着色(颜色区分转移到连线上,
-          // 含 地面→lv1 / lv5→地面 根边,pointId 指向节点即着色依据)+ 风流方向箭头;
+          // 含 地面→lv1 根边,pointId 指向节点即着色依据)+ 风流方向箭头;
+          // 疑似隐蔽工作面巷道(点位 judgeAreaList 字段有内容 → 节点 suspected)标红加粗;
           // 中点标注(edgeLabel rich 富文本):第一行点位名称、第二行风量、第三行圆点 + 已绑定设备名;
           // 未绑定时第三行输出浅灰占位圆点(dotEmpty),保证标签高度恒定
           const point = l.pointId ? pointById.get(l.pointId) : undefined;
           const lv = point ? Number(point.level) : NaN;
           const deviceName = point?.windrectId ? deviceNameMap[point.windrectId] : '';
+          const suspected = point?.suspected;
           link.midLabel = point
             ? `{name|${point.name}}\n{volume|${formatAirVolumeLabel(point.airVolume)}}\n${
                 deviceName ? `{dot|● }{device|${deviceName}}` : '{dotEmpty|● }'
               }`
             : '';
           link.lineStyle = {
-            color: levelColorMap[lv] || POINT_COLOR,
-            width: 3,
-            opacity: 0.85,
+            color: suspected ? JUDGE_COLOR : levelColorMap[lv] || POINT_COLOR,
+            width: suspected ? JUDGE_WIDTH : 3,
+            opacity: suspected ? 1 : 0.85,
             curveness: 0,
-            ...(editMode.value ? { type: 'dashed' as const } : {}),
           };
           link.edgeSymbol = ['none', 'arrow'];
           link.edgeSymbolSize = [0, 10];
@@ -232,10 +238,6 @@ export function useTopology() {
       })
       .filter(Boolean);
 
-    if (editMode.value) {
-      series.lineStyle = { ...series.lineStyle, type: 'dashed' as const, width: 2, opacity: 0.6 };
-    }
-
     chartInstance.setOption(option, true);
     registerEvents();
   }
@@ -246,72 +248,168 @@ export function useTopology() {
     if (!chartInstance) return;
     chartInstance.off('click');
     chartInstance.off('dblclick');
-    chartInstance.off('dragend');
 
     // ——— 单击 ———
     chartInstance.on('click', (params: any) => {
-      if (!params.data) return;
-
+      if (!params.data) {
+        // 单击空白:清除选中
+        clearArmed();
+        return;
+      }
       if (params.dataType === 'node') {
-        // 单击节点 → 显示详情
         const raw = params.data.raw as TopoNodeData;
-        if (raw) showNodeDetail(raw);
+        if (!raw) return;
+        // 绑定模式:已选中关联源且单击另一节点 → 显示详情的同时建立关系
+        if (mode.value === 'relation' && armedSource.value && armedSource.value.id !== raw.id) {
+          showNodeDetail(raw);
+          handleNodeRelation(raw);
+          return;
+        }
+        showNodeDetail(raw);
         return;
       }
-
       if (params.dataType === 'edge') {
-        // 单击巷道连线 → 显示其所属测风点位详情(连线即测风点位)
+        // 单击巷道连线 → 显示其所属测风点位详情(连线即点位)
         if (params.data.kind === 'roadway' && params.data.pointId) {
           const point = topologyData.nodes.find((n) => n.id === params.data.pointId);
           if (point) showNodeDetail(point);
         }
+        clearArmed();
       }
     });
 
     // ——— 双击 ———
     chartInstance.on('dblclick', (params: any) => {
       if (!params.data) return;
-      // 双击交互(绑定/解绑)仅在绑定模式下生效
-      if (!editMode.value) return;
-
       if (params.dataType === 'edge' && params.data.kind === 'roadway' && params.data.pointId) {
-        // 双击巷道连线 → 对其所属测风点位绑定/解绑:
-        // 已绑定(windrectId 非空)→ 解绑;未绑定 → 弹出对话框选择数据布点绑定
         const point = topologyData.nodes.find((n) => n.id === params.data.pointId);
         if (!point) return;
-        if (point.windrectId) {
-          bindPoint.value = point;
-          confirmUnbind();
+        if (mode.value === 'relation') {
+          // 绑定模式:双击连线 → 解除巷道关系(传关系 id)
+          handleRemoveRelation(params.data);
         } else {
-          bindPoint.value = point;
-          bindDeviceId.value = undefined;
-          bindVisible.value = true;
+          // 布点模式:双击连线 → 解绑/绑定数据布点
+          handleDeviceAction(point);
         }
         return;
       }
-      // 双击节点:无操作(已去除双击节点操作)
+      if (params.dataType === 'node') {
+        const raw = params.data.raw as TopoNodeData;
+        if (!raw) return;
+        if (mode.value === 'relation') {
+          // 绑定模式:双击节点 → 选中/取消选中为关联源
+          armedSource.value = armedSource.value && armedSource.value.id === raw.id ? null : raw;
+          renderTopology(topologyData);
+        }
+      }
     });
+  }
+
+  // ==================== 绑定模式:关系编辑 ====================
+
+  /** 当前选中矿的矿编码(fax),供关联接口使用 */
+  function currentMineCode(): string {
+    const mineStore = useMineDepartmentStore();
+    return mineStore.findDepartById(selectedDeptId.value)?.fax || selectedDeptId.value;
+  }
+
+  /** 清除绑定模式的关联源选中态 */
+  function clearArmed() {
+    if (!armedSource.value) return;
+    armedSource.value = null;
+    renderTopology(topologyData);
+  }
 
-    // ——— 拖拽结束 ———
-    chartInstance.on('dragend', (params: any) => {
-      if (!params.data || params.dataType !== 'node') return;
-      highlightPath(params.data.id);
+  /**
+   * 绑定模式:双击节点 A 后单击节点 B → 建立巷道关系。
+   * 父子按 level 判定:level 小者为父、大者为子;level 相同时提示不能绑定。
+   */
+  function handleNodeRelation(target: TopoNodeData) {
+    const source = armedSource.value;
+    if (!source) return;
+    const sl = Number(source.level);
+    const tl = Number(target.level);
+    if (sl === tl) {
+      message.warning('两个点位层级相同,不能建立巷道关系');
+      clearArmed();
+      return;
+    }
+    const parent = sl < tl ? source : target;
+    const child = parent === source ? target : source;
+    if (!parent.rawId || !child.rawId) {
+      message.warning('缺少点位 id,无法建立关系');
+      clearArmed();
+      return;
+    }
+    Modal.confirm({
+      title: '确认建立巷道关系',
+      content: `确定建立"${parent.name} → ${child.name}"的巷道关系?`,
+      okText: '确认',
+      cancelText: '取消',
+      onOk: async () => {
+        try {
+          await addMineAreaRelation({ mineCode: currentMineCode(), parentId: parent.rawId, childId: child.rawId });
+          clearArmed();
+          await loadTopology();
+          message.success('关联成功');
+        } catch {
+          message.error('关联失败');
+        }
+      },
+      onCancel: () => clearArmed(),
     });
   }
 
-  // ==================== 绑定/解绑逻辑 ====================
+  /** 绑定模式:双击连线 → 解除巷道关系(deleteMineAreaRelation 传入关系 id) */
+  function handleRemoveRelation(link: any) {
+    if (!link.relationId) {
+      message.info('该连线为根连线,无可解除的巷道关系');
+      return;
+    }
+    const sourceNode = topologyData.nodes.find((n) => n.id === link.source);
+    const targetNode = topologyData.nodes.find((n) => n.id === link.target);
+    const text = `${sourceNode?.name || link.source} → ${targetNode?.name || link.target}`;
+    Modal.confirm({
+      title: '确认解除巷道关系',
+      content: `确定解除"${text}"的巷道关系?解除后该连线将消失。`,
+      okText: '确认解除',
+      cancelText: '取消',
+      onOk: async () => {
+        try {
+          await deleteMineAreaRelation({ ids: link.relationId });
+          await loadTopology();
+          message.success('解除巷道关系成功');
+        } catch {
+          message.error('解除巷道关系失败');
+        }
+      },
+    });
+  }
 
-  /** 按数据布点原始 id 解析其显示名(用于确认文案,避免直接展示 id;设备节点不再绘制,改用全量列表映射) */
+  // ==================== 布点模式:设备绑定/解绑 ====================
+
+  /** 按数据布点原始 id 解析其显示名(用于确认文案,避免直接展示 id) */
   function deviceNameOf(rawId?: string) {
     return (rawId && deviceNameMap[rawId]) || rawId || '';
   }
 
-  /** 关闭绑定对话框(index.vue 取消/关闭时同步,成功关闭由 confirmBind 内部调用) */
+  /** 布点模式:双击连线 → 已有数据布点则解绑,否则弹窗选择绑定 */
+  function handleDeviceAction(point: TopoNodeData) {
+    if (point.windrectId) {
+      confirmUnbindDevice(point);
+    } else {
+      bindPoint.value = point;
+      bindDeviceId.value = undefined;
+      bindVisible.value = true;
+    }
+  }
+
+  /** 关闭布点绑定弹窗(index.vue 取消/关闭时同步,成功关闭由 confirmBind 内部调用) */
   function closeBindDialog() {
     bindVisible.value = false;
   }
 
-  /** 绑定:对话框选择数据布点后调用(一次 updateArea 调用更新 windrectId 字段,与现有绑定逻辑一致) */
+  /** 布点模式:弹窗选择数据布点后调用(updateMineArea 更新 windrectId 字段) */
   async function confirmBind(deviceId?: string) {
     const point = bindPoint.value;
     if (!point?.rawId) {
@@ -332,10 +430,8 @@ export function useTopology() {
     }
   }
 
-  /** 解绑:双击已绑定巷道连线后确认,清空测风点位 windrectId 字段(与现有解绑逻辑一致) */
-  function confirmUnbind() {
-    const point = bindPoint.value;
-    if (!point) return;
+  /** 布点模式:解绑数据布点(清空 windrectId 字段,updateMineArea) */
+  function confirmUnbindDevice(point: TopoNodeData) {
     const deviceName = deviceNameOf(point.windrectId);
     Modal.confirm({
       title: '确认解绑',
@@ -348,7 +444,6 @@ export function useTopology() {
           return;
         }
         try {
-          // 解绑即清空测风点位的 windrectId,用原始实体 id 调用接口
           await updateArea({ id: point.rawId, windrectId: '' });
           await loadTopology();
           message.success('解绑成功');
@@ -376,34 +471,6 @@ export function useTopology() {
     detailFields.value = [];
   }
 
-  // ==================== 高亮路径 ====================
-
-  function highlightPath(nodeId: string) {
-    if (!chartInstance) return;
-    const s = (chartInstance.getOption().series as any[])[0];
-    const nodes = s.data || [];
-    const links = s.links || [];
-    // 单个高亮派发失败不拖垮页面
-    const safeDispatch = (action: any) => {
-      try {
-        chartInstance?.dispatchAction(action);
-      } catch (e) {
-        console.warn('高亮派发失败:', e);
-      }
-    };
-    safeDispatch({ type: 'downplay' });
-    // 按节点 id 定位 dataIndex 高亮(name 是显示名,不能用作定位)
-    const nodeIndex = nodes.findIndex((d: any) => d && d.id === nodeId);
-    if (nodeIndex >= 0) {
-      safeDispatch({ type: 'highlight', seriesIndex: 0, dataIndex: nodeIndex, dataType: 'node' });
-    }
-    const edgeIndices: number[] = [];
-    links.forEach((l: any, i: number) => {
-      if (l.source === nodeId || l.target === nodeId) edgeIndices.push(i);
-    });
-    edgeIndices.forEach((i) => safeDispatch({ type: 'highlight', seriesIndex: 0, dataIndex: i, dataType: 'edge' }));
-  }
-
   // ==================== 缩放 ====================
 
   function zoomIn() {
@@ -423,19 +490,19 @@ export function useTopology() {
     clearSelection();
   }
 
-  // ==================== 编辑模式切换 ====================
+  // ==================== 模式切换 ====================
 
-  function setEditMode(on: boolean) {
-    editMode.value = on;
+  /** 切换 绑定模式 / 布点模式;两种模式均保留悬浮高亮与单击详情 */
+  function setMode(m: TopoMode) {
+    mode.value = m;
+    armedSource.value = null;
+    bindVisible.value = false;
     if (!chartInstance) return;
-
-    if (on) {
-      renderTopology(topologyData, true);
-      chartInstance.setOption({ series: [{ roam: false }] as any });
-      message.info('绑定模式已开启:双击巷道连线→未绑定则选择数据点位绑定,已绑定则解绑');
+    renderTopology(topologyData);
+    if (m === 'relation') {
+      message.info('双击连线解除关系;双击节点并选中另一节点以建立关系');
     } else {
-      renderTopology(topologyData);
-      message.info('已退出绑定模式');
+      message.info('双击连线解绑或绑定数据布点');
     }
   }
 
@@ -455,13 +522,13 @@ export function useTopology() {
 
   return {
     chartRef,
-    editMode,
+    mode,
+    setMode,
     bindVisible,
     bindPoint,
     bindDeviceId,
     confirmBind,
     closeBindDialog,
-    confirmUnbind,
     selectedNode,
     detailFields,
     selectedDeptId,
@@ -471,7 +538,6 @@ export function useTopology() {
     zoomIn,
     zoomOut,
     resetView,
-    setEditMode,
     clearSelection,
     dispose,
   };

+ 26 - 219
src/views/analysis/warningAnalysis/windPointManage/windTopology/hooks/useTopologyLayout.ts

@@ -1,65 +1,30 @@
-import type { TopologyData, TopoNodeData, AreaChain } from '../windTopology.data';
-import { LAYOUT, LAYOUT_WIDTH, LAYOUT_HEIGHT, ROOT_IN_ID, ROOT_OUT_ID, DevicePlacementType } from '../windTopology.data';
+import type { TopologyData, TopoNodeData } from '../windTopology.data';
+import { LAYOUT, LAYOUT_HEIGHT, ROOT_IN_ID, ROOT_OUT_ID } from '../windTopology.data';
 
 export interface LayoutResult {
   /** 节点坐标(id → {x, y}) */
   positions: Record<string, { x: number; y: number }>;
-  /** 数据布点放置方式(id → 方式) */
-  devicePlacement: Record<string, DevicePlacementType>;
 }
 
 /** 第 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';
-
-/** 列内纵向间距上限:按层级分别配置,未配置的层级回退 columnCapDefault */
-const capFor = (lv: number | 'ungraded') => LAYOUT.columnCap[lv as any] ?? LAYOUT.columnCapDefault;
-
-/** 列内纵向间距下限(同级时 y 轴最小间隔):按层级分别配置,未配置的层级回退 columnMinGapDefault */
-const minGapFor = (lv: number | 'ungraded') => LAYOUT.columnMinGap[lv as any] ?? LAYOUT.columnMinGapDefault;
-
-/** 各 level 连线最小长度:按层级分别配置,未配置的层级回退 minEdgeLengthDefault */
-const minEdgeFor = (lv: number | 'ungraded') => LAYOUT.minEdgeLength[lv as any] ?? LAYOUT.minEdgeLengthDefault;
-
-/** 数据布点摆放方向:按锚点层级分别配置,未配置的层级回退 devicePlacementDefault */
-const placementFor = (lv: number | 'ungraded') => LAYOUT.devicePlacement[lv as any] ?? LAYOUT.devicePlacementDefault;
-
-/** 数据布点与锚点距离:按锚点层级分别配置,未配置的层级回退 deviceOffsetDefault */
-const deviceOffsetFor = (lv: number | 'ungraded') => LAYOUT.deviceOffset[lv as any] ?? LAYOUT.deviceOffsetDefault;
-
 /**
- * 同级子列的水平偏移量(即该 level 连线最小长度):子列 x = 父列 x + max(sameLevelOffset, minEdgeLength)。
+ * 列式布局(从左到右):
+ *   固定列序 总进 → lv1..lv5 → 未分级 → 总回;
+ *   列间距(x 轴间隔)由 LAYOUT.colGap 配置:x = margin + colGap × 列序;
+ *   每列节点围绕画布垂直中心按 LAYOUT.rowGap 均匀分布(行间距可配置,
+ *   点位过多时按可用高度压缩,间距 = min(rowGap, 可用高度均分));
+ *   列内顺序按风量降序,保证布局确定。
  */
-const siblingGapFor = (col: TopoNodeData[]) => Math.max(LAYOUT.sameLevelOffset, minEdgeFor(levelKey(col[0]?.level)));
-
-/**
- * 巷道线模型布局(从左到右列式):
- *   先算 x:动态列序 总进点 → lv1..lv5 巷道点 → 未分级 → 总回点,
- *   同级关系(parent.level === child.level)的子节点成列紧随父级列后:同一父列的子节点并入同一列
- *   (按父序排列,连线平行等长、互不交叉),链式同级(a→b→c)逐级顺延,后续列顺延;
- *   同级子列 x = 父列 x + max(sameLevelOffset, minEdgeLength[level])(默认 = colGap,与普通列距一致);
- *   再按最终 x 分组算 y:每组(x 槽)按节点数量围绕 centerY 均匀分布
- *   (间距 = max(columnMinGap[level], min(columnCap[level], 可用高度均分)));
- *   各层列内节点顺序:提供链式结构(chains)时按 (链下标, 链内位置) 排序(链间上下并行、同链路径顺序),
- *   未提供时回退父点位顺序 + 风量降序;
- *   巷道连线为单条直线(父 → 子),中点标注(子节点名称 + 风量)由渲染层 edgeLabel 绘制;
- *   数据布点:摆放方向与距离按锚点(测风点位)层级由 LAYOUT.devicePlacement / deviceOffset 决定
- *   (默认:level<3 左侧、=3 正下方、>3 右侧,未分级/无 level 正下方),未绑定在图底一行。
- */
-export function computeLayout(data: TopologyData, width: number, height: number, chains?: AreaChain[]): LayoutResult {
-  const W = width < 100 ? LAYOUT_WIDTH : width;
+export function computeLayout(data: TopologyData, _width: number, height: number): LayoutResult {
   const H = height < 100 ? LAYOUT_HEIGHT : height;
 
   const positions: Record<string, { x: number; y: number }> = {};
-  const devicePlacement: Record<string, DevicePlacementType> = {};
 
   const rootIn = data.nodes.find((n) => n.id === ROOT_IN_ID);
   const rootOut = data.nodes.find((n) => n.id === ROOT_OUT_ID);
   const points = data.nodes.filter((n) => n.category === 1);
-  const devices = data.nodes.filter((n) => n.category === 2);
 
   // 按层级分组;未分级点位单独归列
   const byLevel = new Map<number, TopoNodeData[]>();
@@ -74,192 +39,34 @@ export function computeLayout(data: TopologyData, width: number, height: number,
     }
   }
 
-  // roadway 边:pointId 指向连线所属点位;parentOf 仅用于层级内稳定排序(保证连线不交叉)
-  const parentOf: Record<string, string> = {};
-  for (const l of data.links) {
-    if (l.kind === 'roadway' && l.pointId) {
-      parentOf[l.target] = l.source;
-    }
-  }
-
-  // 链式排序索引(可选):提供链式结构时,各层列内节点按 (链下标, 链内位置) 排序,
-  // 链间上下并行堆叠、同链节点按路径顺序,保证连线不交叉
-  const chainRank: Record<string, { chainIdx: number; pos: number }> = {};
-  if (chains && chains.length) {
-    chains.forEach((chain, ci) => {
-      chain.points.forEach((p, pi) => {
-        const pid = `point:${p.id}`;
-        if (!chainRank[pid]) chainRank[pid] = { chainIdx: ci, pos: pi };
-      });
-    });
-  }
-
-  // 层级内稳定排序:提供链式结构时按链顺序(不在链上的点位排后、组内风量降序);
-  // 未提供时回退 L1 按风量降序,其后各层按父点位顺序分组、组内风量降序
-  const levelOrder: Record<number, TopoNodeData[]> = {};
-  {
-    const parentIndex = new Map<string, number>();
-    const orderStack: string[] = [];
-    const rank = (id?: string) => (id && parentIndex.has(id) ? parentIndex.get(id)! : -1);
-    for (let lv = 1; lv <= 5; lv++) {
-      const list = byLevel.get(lv) || [];
-      const sorted = [...list].sort((a, b) => {
-        const ca = chainRank[a.id];
-        const cb = chainRank[b.id];
-        if (chains && chains.length) {
-          if (ca && cb) {
-            if (ca.chainIdx !== cb.chainIdx) return ca.chainIdx - cb.chainIdx;
-            return ca.pos - cb.pos;
-          }
-          if (ca) return -1;
-          if (cb) return 1;
-          return (b.airVolume ?? 0) - (a.airVolume ?? 0);
-        }
-        const ra = rank(parentOf[a.id]);
-        const rb = rank(parentOf[b.id]);
-        if (ra !== rb) return ra - rb;
-        return (b.airVolume ?? 0) - (a.airVolume ?? 0);
-      });
-      levelOrder[lv] = sorted;
-      for (const p of sorted) {
-        parentIndex.set(p.id, orderStack.length);
-        orderStack.push(p.id);
-      }
-    }
-  }
-
-  // 数据布点 → 绑定巷道点(sensor 边 source=巷道点、target=布点)
-  const sensorSourceOf: Record<string, string> = {};
-  for (const l of data.links) {
-    if (l.kind === 'sensor') sensorSourceOf[l.target] = l.source;
-  }
-
-  const unboundCount = devices.filter((d) => !sensorSourceOf[d.id]).length;
-  const unboundAreaH = unboundCount > 0 ? LAYOUT.unboundAreaH : 0;
-  const centerY = (H - unboundAreaH) / 2;
+  const centerY = H / 2;
+  const maxSpread = Math.max(40, Math.min(200, centerY - 40));
 
-  // —— 先算 x 轴:动态列序(固定 8 个基础槽 + 同级子节点独立列)——
-  // 基础槽:总进(0) → lv1..lv5(1..5) → 未分级(6) → 总回(7),未分级无节点时保留空槽(保持后续列位稳定)
+  // 列序:总进 → lv1..lv5 → 未分级 → 总回(空列保留占位,保持后续列位稳定)
   const cols: TopoNodeData[][] = [];
-  if (rootIn) cols.push([rootIn]);
-  else cols.push([]);
+  cols.push(rootIn ? [rootIn] : []);
   for (let lv = 1; lv <= 5; lv++) {
-    cols.push(levelOrder[lv]?.length ? [...levelOrder[lv]] : []);
+    cols.push(byLevel.get(lv)?.length ? [...byLevel.get(lv)!] : []);
   }
   cols.push(ungraded.length ? [...ungraded] : []);
-  if (rootOut) cols.push([rootOut]);
-  else cols.push([]);
+  cols.push(rootOut ? [rootOut] : []);
 
-  // 同级关系(parent.level === child.level,如 lv3 进风→lv3 回风):子节点成列紧随父级列之后,
-  // 同一父列的所有同级子节点并入同一列(保证连线平行等长、互不交叉);链式累积(a→b→c 同级时
-  // 每级父节点各自成列、逐级顺延),后续列顺延;多条入边取第一条
-  const pointLevelById: Record<string, number> = {};
-  for (const p of points) pointLevelById[p.id] = Number(p.level);
-  const moved = new Set<string>();
-  /** 同级子列(紧随其父列之后、由本次移动创建的列):同一父列的子节点共用一列 */
-  const siblingCols = new Set<TopoNodeData[]>();
-  for (const l of data.links) {
-    if (l.kind !== 'roadway') continue;
-    const pl = pointLevelById[l.source];
-    const cl = pointLevelById[l.target];
-    if (pl === undefined || pl !== cl) continue;
-    if (moved.has(l.target)) continue;
-    moved.add(l.target);
-    const childCol = cols.findIndex((c) => c.some((n) => n.id === l.target));
-    if (childCol < 0) continue;
-    const childNode = cols[childCol].find((n) => n.id === l.target)!;
-    const parentCol = cols.findIndex((c) => c.some((n) => n.id === l.source));
-    if (parentCol < 0) continue;
-    // 先从原列移除子节点
-    cols[childCol] = cols[childCol].filter((n) => n.id !== l.target);
-    const sibling = cols[parentCol + 1];
-    if (sibling && siblingCols.has(sibling)) {
-      // 父列已有同级子列:并入该列,同一父列的子节点共列、连线平行等长
-      sibling.push(childNode);
-    } else {
-      // 无同级子列:新建一列紧随父列之后
-      cols.splice(parentCol + 1, 0, [childNode]);
-      siblingCols.add(cols[parentCol + 1]);
-    }
-    // 原列若变空则删除(索引随插入位置移位:父列在子列之前时子列索引 +1)
-    if (cols[childCol].length === 0) {
-      const emptyIdx = parentCol < childCol ? childCol + 1 : childCol;
-      if (cols[emptyIdx] && cols[emptyIdx].length === 0) cols.splice(emptyIdx, 1);
-    }
-  }
-  // 同级子列按父节点在父列中的顺序排序:子节点镜像父节点纵向位置,连线平行等长、互不交叉
+  // 列内稳定排序:按风量降序
   for (const col of cols) {
-    if (!siblingCols.has(col)) continue;
-    const parentColIdx = cols.indexOf(col) - 1;
-    const parentCol = parentColIdx >= 0 ? cols[parentColIdx] : undefined;
-    if (!parentCol) continue;
-    const parentIdxOf = new Map<string, number>();
-    parentCol.forEach((p, i) => parentIdxOf.set(p.id, i));
-    col.sort((a, b) => (parentIdxOf.get(parentOf[a.id] ?? '') ?? -1) - (parentIdxOf.get(parentOf[b.id] ?? '') ?? -1));
+    col.sort((a, b) => (b.airVolume ?? 0) - (a.airVolume ?? 0));
   }
 
-  // 按列序赋 x:普通列取 laneX(i);同级子列取「父列 x + max(sameLevelOffset, minEdgeLength[level])」,
-  // 使各 level 连线最小长度可配置生效(默认 = colGap,与普通列距一致),收集有序节点列表(供按 x 分组算 y)
-  const ordered: TopoNodeData[] = [];
-  let prevX = -Infinity; // 前一列(父列)的 x
+  // 逐列赋坐标:x = margin + colGap × 列序;y 围绕 centerY 按 rowGap 均匀分布
   cols.forEach((col, i) => {
-    const x = siblingCols.has(col) ? prevX + siblingGapFor(col) : laneX(i);
-    prevX = x;
-    for (const n of col) {
-      positions[n.id] = { x, y: 0 };
-      ordered.push(n);
-    }
-  });
-
-  // —— 再按最终 x 分组算 y:每组按节点数量围绕 centerY 均匀分布(间距 = max(下限, min(组 cap, 可用高度均分)))——
-  const maxSpread = Math.max(40, Math.min(200, centerY - 40));
-  const groups = new Map<number, TopoNodeData[]>(); // x 槽 → 节点(保持有序)
-  for (const p of ordered) {
-    const x = positions[p.id].x;
-    if (!groups.has(x)) groups.set(x, []);
-    groups.get(x)!.push(p);
-  }
-  for (const [x, list] of groups) {
-    const n = list.length;
-    const lv = list[0].level !== undefined && list[0].level !== null ? Number(list[0].level) : 'ungraded';
-    const cap = capFor(lv);
-    const minGap = minGapFor(lv);
-    const spacing = n > 1 ? Math.max(minGap, Math.min(cap, maxSpread / (n - 1))) : 0;
+    const x = laneX(i);
+    const n = col.length;
+    if (n === 0) return;
+    const spacing = n > 1 ? Math.min(LAYOUT.rowGap, maxSpread / (n - 1)) : 0;
     const start = centerY - (spacing * (n - 1)) / 2;
-    list.forEach((p, i) => {
-      positions[p.id] = { x, y: start + i * spacing };
+    col.forEach((p, k) => {
+      positions[p.id] = { x, y: start + k * spacing };
     });
-  }
-
-  // 数据布点:摆放方向与距离按锚点(测风点位)层级由 LAYOUT.devicePlacement / deviceOffset 决定
-  // (未分级/无 level 回退 ungraded/Default),未绑定布点在图底一行
-  const bottom: TopoNodeData[] = [];
-  for (const d of devices) {
-    const pid = sensorSourceOf[d.id];
-    const anchor = pid ? positions[pid] : undefined;
-    if (!anchor) {
-      devicePlacement[d.id] = 'bottom';
-      bottom.push(d);
-      continue;
-    }
-    const lk = levelKey(pid !== undefined ? pointLevelById[pid] : undefined);
-    const placement = placementFor(lk);
-    const off = deviceOffsetFor(lk);
-    positions[d.id] =
-      placement === 'left'
-        ? { x: anchor.x - off, y: anchor.y }
-        : placement === 'right'
-          ? { x: anchor.x + off, y: anchor.y }
-          : { x: anchor.x, y: anchor.y + off };
-    devicePlacement[d.id] = placement;
-  }
-  if (bottom.length) {
-    const gap = bottom.length > 1 ? Math.min(120, (W - LAYOUT.margin * 2) / (bottom.length - 1)) : 0;
-    const x0 = LAYOUT.margin + (W - LAYOUT.margin * 2 - gap * (bottom.length - 1)) / 2;
-    bottom.forEach((d, i) => {
-      positions[d.id] = { x: x0 + i * gap, y: H - LAYOUT.unboundRowGap };
-    });
-  }
+  });
 
-  return { positions, devicePlacement };
+  return { positions };
 }

+ 26 - 32
src/views/analysis/warningAnalysis/windPointManage/windTopology/index.vue

@@ -5,7 +5,6 @@
     <div class="topo-toolbar">
       <div class="toolbar-left">
         <div class="mine-select">
-          <span class="select-label">煤矿:</span>
           <MineCascader
             v-model:value="selectedDeptId"
             style="width: 220px"
@@ -18,35 +17,39 @@
         </div>
         <a-divider type="vertical" />
         <a-button-group>
-          <a-button @click="zoomIn" title="放大">+</a-button>
-          <a-button @click="zoomOut" title="缩小">-</a-button>
-          <a-button @click="resetView" title="复位">复位</a-button>
+          <a-button @click="zoomIn" title="放大" :icon="h(PlusOutlined)"></a-button>
+          <a-button @click="zoomOut" title="缩小" :icon="h(MinusOutlined)"> </a-button>
+          <a-button @click="resetView" title="复位" :icon="h(ReloadOutlined)"></a-button>
         </a-button-group>
         <a-divider type="vertical" />
-        <a-button :type="editMode ? 'primary' : 'default'" danger @click="toggleEditMode">
-          {{ editMode ? '完成绑定' : '绑定模式' }}
-        </a-button>
+        <!-- 双模式互斥:绑定模式(关系编辑)/ 布点模式(设备绑定) -->
+        <a-button-group>
+          <a-button :type="mode === 'relation' ? 'primary' : 'default'" @click="setMode('relation')">绑定模式</a-button>
+          <a-button :type="mode === 'device' ? 'primary' : 'default'" @click="setMode('device')">布点模式</a-button>
+        </a-button-group>
         <a-divider type="vertical" />
         <a-button @click="refreshData" title="刷新">
           <template #icon><SvgIcon name="refresh" /></template>
           刷新
         </a-button>
         <a-divider type="vertical" />
-        <span v-if="editMode" class="select-text">双击巷道连线:未绑定→选择数据点位绑定;已绑定→解绑</span>
+        <span v-if="mode === 'relation'" class="select-text">双击连线解除关系;双击节点并选中另一节点以建立关系</span>
+        <span v-else class="select-text">双击连线解绑或绑定数据布点</span>
       </div>
 
       <!-- 图例 -->
       <div class="toolbar-right">
         <div class="legend">
-          <span class="legend-item">
-            <span class="legend-dot" :style="{ background: categories[0]?.color }"></span>
-            地面
-          </span>
           <!-- 层级颜色已转移到巷道连线:图例以小横线展示连线颜色 -->
           <span v-for="lv in levelList" :key="lv.level" class="legend-item">
             <span class="legend-bar" :style="{ background: lv.color }"></span>
             {{ lv.text }}
           </span>
+          <!-- 疑似隐蔽工作面巷道:红色加粗连线示例 -->
+          <span class="legend-item">
+            <span class="legend-bar" :style="{ background: JUDGE_COLOR, height: '5px' }"></span>
+            疑似隐蔽工作面
+          </span>
         </div>
       </div>
     </div>
@@ -55,7 +58,7 @@
     <div class="topo-body">
       <div ref="chartRef" class="topo-canvas"></div>
 
-      <!-- 详情面板 -->
+      <!-- 详情面板(两种模式单击节点/连线均显示) -->
       <div v-if="selectedNode" class="detail-panel">
         <div class="detail-header">
           <span class="detail-title">节点详情</span>
@@ -74,7 +77,7 @@
       </div>
     </div>
 
-    <!-- 绑定数据布点对话框(双击未绑定巷道连线弹出;destroyOnClose 保证每次打开重建表单刷新下拉数据) -->
+    <!-- 布点模式绑定弹窗(双击未绑定布点的连线弹出;destroyOnClose 保证每次打开重建表单刷新下拉数据) -->
     <BasicModal
       @register="registerBindModal"
       :title="`绑定数据布点至:${bindPoint?.name}`"
@@ -106,9 +109,11 @@
   import { ApiSelect } from '/@/components/Form';
   import { BasicModal, useModal } from '/@/components/Modal';
   import MineCascader from '/@/components/Form/src/jeecg/components/MineCascader/MineCascader.vue';
-  import { categories, levelTextMap, levelColorMap, statusColorMap, POINT_COLOR } from './windTopology.data';
+  import { categories, levelTextMap, levelColorMap, POINT_COLOR, JUDGE_COLOR } from './windTopology.data';
   import { useTopology } from './hooks/useTopology';
   import { getWindrectListNoUsed } from './windTopology.api';
+  import { h } from 'vue';
+  import { ReloadOutlined, PlusOutlined, MinusOutlined } from '@ant-design/icons-vue';
 
   /** 图例层级项:按 levelTextMap/levelColorMap 顺序生成(矿井进风 → 矿井回风) */
   const levelList = Object.keys(levelTextMap).map((k) => {
@@ -116,16 +121,16 @@
     return { level, text: levelTextMap[level], color: levelColorMap[level] };
   });
 
-  /** 与 renderTopology 一致的节点配色:测风点位灰色、数据布点按状态色、地面用分类色 */
+  /** 与 renderTopology 一致的节点配色:测风点位灰色、地面用分类色 */
   function nodeColor(node: any): string {
     if (node?.category === 1) return POINT_COLOR;
-    if (node?.category === 2) return statusColorMap[node.status || 'normal'] || categories[2]?.color;
     return categories[node?.category]?.color || '#333';
   }
 
   const {
     chartRef,
-    editMode,
+    mode,
+    setMode,
     bindVisible,
     bindPoint,
     bindDeviceId,
@@ -140,15 +145,14 @@
     zoomIn,
     zoomOut,
     resetView,
-    setEditMode,
     clearSelection,
     dispose,
   } = useTopology();
 
-  /** 绑定对话框(useModal 钩子调用;destroyOnClose 每次打开重建表单刷新下拉数据) */
+  /** 布点模式绑定弹窗(useModal 钩子调用;destroyOnClose 每次打开重建表单刷新下拉数据) */
   const [registerBindModal, { openModal, closeModal }] = useModal();
 
-  // hook 双击未绑定巷道连线置 bindVisible=true → 打开模态框;确认/取消置 false → 关闭
+  // 双击未绑定布点的连线置 bindVisible=true → 打开模态框;确认/取消置 false → 关闭
   watch(bindVisible, (v) => {
     if (v) {
       openModal(true);
@@ -157,7 +161,7 @@
     }
   });
 
-  /** 绑定对话框下拉参数:按当前选中矿(deptId)查询未使用数据布点 */
+  /** 绑定弹窗下拉参数:按当前选中矿(deptId)查询未使用数据布点 */
   const bindDeviceParams = computed(() => ({ deptId: selectedDeptId.value }));
 
   function handleBindOk() {
@@ -168,10 +172,6 @@
     closeBindDialog();
   }
 
-  function toggleEditMode() {
-    setEditMode(!editMode.value);
-  }
-
   function refreshData() {
     loadTopology();
   }
@@ -264,12 +264,6 @@
     height: 100%;
   }
 
-  .bind-point-name {
-    font-size: 13px;
-    font-weight: 500;
-    color: #333;
-  }
-
   .detail-panel {
     position: absolute;
     top: 12px;

+ 43 - 38
src/views/analysis/warningAnalysis/windPointManage/windTopology/windTopology.api.ts

@@ -1,57 +1,62 @@
 import { defHttp } from '/@/utils/http/axios';
 import { useMineDepartmentStore } from '/@/store/modules/mine';
-import type { MineAreaApiResponse, MineAreaNode, MineAreaRelationNode, WindrectNode } from './windTopology.data';
+import type { MineAreaNode, MineAreaRelationNode, TopologyBundle, WindrectNode } from './windTopology.data';
+import { buildTopologyBundle } from './windTopology.data';
 
 enum Api {
   getMineAreaRelation = '/workingface/mineArea/getMineAreaRelation',
+  addMineAreaRelation = '/workingface/mineArea/addMineAreaRelation',
+  deleteMineAreaRelation = '/workingface/mineArea/deleteMineAreaRelation',
   getWindrectList = '/workingface/windrect/getWindrectList',
   getWindrectListNoUsed = '/workingface/windrect/getWindrectListNoUsed',
   updateMineArea = '/workingface/mineArea/updateMineArea',
-  deleteMineArea = '/workingface/mineArea/deleteMineArea',
 }
 
 /**
- * 获取测风网络拓扑数据:
- *   getMineAreaRelation   -> 测风点位列表 + 巷道关系列表(按矿编码 mineCode=fax 查询)
- *   getWindrectList       -> 数据布点全量列表(按 deptId 查询,用于解析已绑定设备显示名)
- *   getWindrectListNoUsed -> 未使用数据布点列表(按 deptId 查询,图底一行绘制)
- * 巷道连线与布局以 MineAreaRelation 为数据源(每条 = 一条巷道,父子节点信息),
- * 前端不再自行派生路径。
+ * 获取测风网络拓扑全量数据(接口获取 → 代码处理 → 返回完整数据供消费):
+ *   getMineAreaRelation -> 测风点位列表 + 巷道关系列表(按矿编码 mineCode=fax 查询)
+ *   getWindrectList     -> 数据布点全量列表(按 deptId 查询,用于解析已绑定设备显示名)
+ * 所有接口并行请求(allSettled),单个失败不影响整体;整体异常时返回备用空数据
+ * (总进地面节点 + 空列表),保证拓扑图总能绘制。
  */
-export const getTopologyData = async (params?: any): Promise<MineAreaApiResponse> => {
-  const mineStore = useMineDepartmentStore();
-  // deptId 为当前选中的矿端部门 id;getMineAreaRelation 按矿编码(fax)查询
-  const deptId = params?.deptId || mineStore.getRootId;
-  const mineCode = mineStore.findDepartById(deptId)?.fax || deptId;
-  // 兼容两种响应结构:分页 { records } 或裸数组
-  const toList = (res: any): any[] => (Array.isArray(res) ? res : Array.isArray(res?.records) ? res.records : []);
-
-  const [relRes, windrectRes, windrectNoUsedRes] = await Promise.allSettled([
-    defHttp.post({ url: Api.getMineAreaRelation, params: { mineCode } }, { joinParamsToUrl: true }),
-    defHttp.post({ url: Api.getWindrectList, params: { deptId, column: 'createTime', order: 'desc' } }, { joinParamsToUrl: true }),
-    defHttp.post({ url: Api.getWindrectListNoUsed, params: { deptId } }, { joinParamsToUrl: true }),
-  ]);
-  // 单个接口失败不影响另一个(避免整体空白);失败时记录原因便于定位
-  const relData = relRes.status === 'fulfilled' ? (relRes.value as any) || {} : (console.error('获取巷道关系失败:', relRes.reason), {});
-  const areaList = Array.isArray(relData.mineAreaList) ? (relData.mineAreaList as MineAreaNode[]) : [];
-  const relationList = Array.isArray(relData.mineAreaRelationList) ? (relData.mineAreaRelationList as MineAreaRelationNode[]) : [];
-  const windrectList = windrectRes.status === 'fulfilled' ? toList(windrectRes.value) : (console.error('获取数据布点失败:', windrectRes.reason), []);
-  const windrectNoUsedList =
-    windrectNoUsedRes.status === 'fulfilled' ? toList(windrectNoUsedRes.value) : (console.error('获取未使用数据布点失败:', windrectNoUsedRes.reason), []);
-  return {
-    mineAreaList: areaList,
-    mineAreaRelationList: relationList,
-    windrectList: windrectList as WindrectNode[],
-    windrectNoUsedList: windrectNoUsedList as WindrectNode[],
-  };
+export const getTopologyData = async (params?: any): Promise<TopologyBundle> => {
+  try {
+    const mineStore = useMineDepartmentStore();
+    // deptId 为当前选中的矿端部门 id;getMineAreaRelation 按矿编码(fax)查询
+    const deptId = params?.deptId || mineStore.getRootId;
+    const mineCode = mineStore.findDepartById(deptId)?.fax || deptId;
+    // 兼容两种响应结构:分页 { records } 或裸数组
+    const toList = (res: any): any[] => (Array.isArray(res) ? res : Array.isArray(res?.records) ? res.records : []);
+
+    const [relRes, windrectRes] = await Promise.allSettled([
+      defHttp.post({ url: Api.getMineAreaRelation, params: { mineCode } }, { joinParamsToUrl: true }),
+      defHttp.post({ url: Api.getWindrectList, params: { deptId, column: 'createTime', order: 'desc' } }, { joinParamsToUrl: true }),
+    ]);
+    // 单个接口失败不影响另一个(避免整体空白);失败时记录原因便于定位
+    const relData =
+      relRes.status === 'fulfilled' ? (relRes.value as any) || {} : (console.error('获取巷道关系失败:', relRes.reason), {});
+    const areaList = Array.isArray(relData.mineAreaList) ? (relData.mineAreaList as MineAreaNode[]) : [];
+    const relationList = Array.isArray(relData.mineAreaRelationList) ? (relData.mineAreaRelationList as MineAreaRelationNode[]) : [];
+    const windrectList =
+      windrectRes.status === 'fulfilled' ? toList(windrectRes.value) : (console.error('获取数据布点失败:', windrectRes.reason), []);
+
+    return buildTopologyBundle(areaList, relationList, windrectList as WindrectNode[]);
+  } catch (e) {
+    // 备用空数据兜底:保证拓扑图(总进地面节点)在接口异常时仍可绘制
+    console.error('拓扑数据组装失败,使用空数据兜底:', e);
+    return buildTopologyBundle([], [], []);
+  }
 };
 
-/** 编辑测风点位(绑定/解绑通过字段:mineCode=所属矿编码、windrectId=绑定数据布点 id) */
+/** 编辑测风点位(绑定/解绑数据布点通过字段:id=测风点位主键、windrectId=绑定数据布点 id) */
 export const updateArea = (params?: any) => defHttp.post({ url: Api.updateMineArea, params });
 
-/** 查询未绑定(未使用)数据布点列表(按 deptId 查询,供巷道连线绑定弹窗选择) */
+/** 查询未使用数据布点列表(按 deptId 查询,供布点模式绑定弹窗选择) */
 export const getWindrectListNoUsed = (params?: any) =>
   defHttp.post({ url: Api.getWindrectListNoUsed, params }, { joinParamsToUrl: true });
 
-/** 删除测风点位(ids 必填,逗号分隔/单 id;拓扑视图已移除双击删除点位交互,此接口暂保留待后续恢复) */
-export const deleteMineArea = (params?: any) => defHttp.post({ url: Api.deleteMineArea, params }, { joinParamsToUrl: true });
+/** 绑定 MineArea 关联关系(RequestBody:mineCode/parentId/childId;双击节点+单击节点关联时调用) */
+export const addMineAreaRelation = (params?: any) => defHttp.post({ url: Api.addMineAreaRelation, params });
+
+/** 删除 MineArea 关联关系(ids 传关系主键 id,逗号分隔/单 id;双击连线解除关系时调用) */
+export const deleteMineAreaRelation = (params?: any) => defHttp.post({ url: Api.deleteMineAreaRelation, params }, { joinParamsToUrl: true });

+ 80 - 232
src/views/analysis/warningAnalysis/windPointManage/windTopology/windTopology.data.ts

@@ -5,32 +5,27 @@ import type { EChartsOption } from 'echarts';
 export interface CategoryDef {
   name: string; // 分类名称
   color: string; // 节点颜色
-  symbol: string; // ECharts 图形:circle / rect / diamond / roundRect / pin
+  symbol: string; // ECharts 图形:circle / rect / diamond / pin
   symbolSize: number; // 节点大小
 }
 
-/** 3 类节点:地面(前端生成,只读)、测风点位、数据布点 */
+/** 2 类节点:地面(前端生成,只读)、测风点位 */
 export const categories: CategoryDef[] = [
   { name: '地面', color: '#722ed1', symbol: 'diamond', symbolSize: 26 },
-  { name: '测风点位', color: '#fa8c16', symbol: 'pin', symbolSize: 30 },
-  { name: '数据布点', color: '#1890ff', symbol: 'roundRect', symbolSize: 18 },
+  { name: '测风点位', color: '#fa8c16', symbol: 'circle', symbolSize: 30 },
 ];
 
 /** 测风点位统一节点样式:灰色小圆点(层级/类型区分转移到连线颜色) */
 export const POINT_COLOR = '#8c8c8c';
-export const POINT_SIZE = 8;
+export const POINT_SIZE = 16;
 
-// ==================== 状态颜色映射 ====================
-
-export const statusColorMap: Record<string, string> = {
-  normal: '#52c41a', // 正常
-  abnormal: '#fa8c16', // 异常
-  offline: '#999999', // 离线
-};
+/** 疑似隐蔽工作面巷道连线标红颜色与线宽(红色加粗,与层级配色区分) */
+export const JUDGE_COLOR = '#e53935';
+export const JUDGE_WIDTH = 6;
 
 // ==================== 测风点位层级映射 ====================
 
-/** 测风点位通风类型层级(level 字段语义),用于按层级分绘制拓扑 */
+/** 测风点位通风类型层级(level 字段语义),用于按层级分列绘制拓扑 */
 export const levelTextMap: Record<number, string> = {
   1: '矿井进风',
   2: '采区进风',
@@ -39,7 +34,7 @@ export const levelTextMap: Record<number, string> = {
   5: '矿井回风',
 };
 
-/** 测风点位层级配色(5 级互不混淆,且避开部门紫 #722ed1 / 数据布点蓝 #1890ff),用于按层级区分巷道连线颜色 */
+/** 测风点位层级配色(5 级互不混淆,避开地面紫 #722ed1),用于按层级区分巷道连线颜色 */
 export const levelColorMap: Record<number, string> = {
   1: '#f5222d', // 矿井进风
   2: '#fa8c16', // 采区进风
@@ -50,62 +45,14 @@ export const levelColorMap: Record<number, string> = {
 
 // ==================== 通风示意图布局参数 ====================
 
-/** 数据布点放置方式:left=锚点左侧 / down=锚点正下方 / right=锚点右侧 / bottom=图下方一行 */
-export type DevicePlacementType = 'left' | 'down' | 'right' | 'bottom';
-
-/** 拓扑布局参数(px)——按 level 1..5 与未分级列分别配置,未配置的层级回退各 Default 值 */
+/** 拓扑布局参数(px)——列间距(x 轴间隔)与行间距(y 轴间隔)均可配置 */
 export const LAYOUT = {
-  /** 画布左边距(地面(进风侧)点起始 x,也用于未绑定布点行的两端留白) */
+  /** 画布左边距(总进点起始 x) */
   margin: 60,
-  /** 列间距(从左到右各层级列之间的水平距离) */
+  /** 列间距(x 轴间隔,从左到右各层级列之间的水平距离) */
   colGap: 300,
-  /** 未绑定数据布点行距图底距离 */
-  unboundRowGap: 30,
-  /** 存在未绑定数据布点时底部预留高度 */
-  unboundAreaH: 90,
-  /**
-   * 各列内纵向节点间距上限(px),按层级 1..5 与未分级列分别配置;
-   * 实际间距 = min(上限, 可用高度均分),点位过多时自动压缩防溢出。
-   */
-  columnCap: { 1: 120, 2: 120, 3: 120, 4: 120, 5: 120, ungraded: 120 } as Record<number, number> & { ungraded: number },
-  /** 未在 columnCap 中配置的层级使用的间距上限(px) */
-  columnCapDefault: 200,
-  /**
-   * 各列内纵向节点间距下限(px),按层级 1..5 与未分级列分别配置(同级时 y 轴最小间隔);
-   * 实际间距 = max(下限, min(上限, 可用高度均分)),默认 0 时不钳制。
-   */
-  columnMinGap: { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, ungraded: 0 } as Record<number, number> & { ungraded: number },
-  /** 未在 columnMinGap 中配置的层级使用的间距下限(px) */
-  columnMinGapDefault: 0,
-  /**
-   * 各 level 连线最小长度(px),按层级 1..5 与未分级列分别配置:
-   * 同级父子关系(parent.level === child.level)时子巷道点列的水平偏移量
-   * (子列 x = 父列 x + max(sameLevelOffset, minEdgeLength)),保证短巷道线可读;
-   * 默认与 colGap 一致,布局与普通列距相同。
-   */
-  minEdgeLength: { 1: 300, 2: 300, 3: 300, 4: 300, 5: 300, ungraded: 300 } as Record<number, number> & { ungraded: number },
-  /** 未在 minEdgeLength 中配置的层级使用的连线最小长度(px) */
-  minEdgeLengthDefault: 300,
-  /** 同级父子关系的子巷道点列水平偏移下限(px):右移量取 max(sameLevelOffset, minEdgeLength),避免垂直绘制 */
-  sameLevelOffset: 200,
-  /**
-   * 数据布点摆放方向,按锚点(测风点位)层级分别配置:left=左侧 / down=正下方 / right=右侧;
-   * 未分级/无 level 锚点回退 ungraded(默认 down)。
-   */
-  devicePlacement: {
-    1: 'left',
-    2: 'left',
-    3: 'down',
-    4: 'right',
-    5: 'right',
-    ungraded: 'down',
-  } as Record<number, DevicePlacementType> & { ungraded: DevicePlacementType },
-  /** 未在 devicePlacement 中配置的层级使用的布点方向 */
-  devicePlacementDefault: 'down' as DevicePlacementType,
-  /** 数据布点与锚点(测风点位)的距离(px),按层级 1..5 与未分级列分别配置(即传感器连线长度) */
-  deviceOffset: { 1: 30, 2: 30, 3: 30, 4: 30, 5: 30, ungraded: 30 } as Record<number, number> & { ungraded: number },
-  /** 未在 deviceOffset 中配置的层级使用的布点距离(px) */
-  deviceOffsetDefault: 30,
+  /** 行间距(y 轴间隔,各列内纵向节点间距;点位过多时按可用高度压缩) */
+  rowGap: 200,
 };
 
 /** 巷道分支风量标注文本(通风网络图风格:在巷道连线上标注测风点位风量),无风量返回空串 */
@@ -121,18 +68,17 @@ export const LAYOUT_HEIGHT = 700;
 
 export function createGraphOption(): EChartsOption {
   return {
-    tooltip: { trigger: 'item', formatter: '{b}' },
     series: [
       {
         type: 'graph',
         layout: 'none', // 固定位置,由 hooks/useTopologyLayout.computeLayout 分配坐标
         roam: true,
-        draggable: true, // 始终可拖,编辑模式只影响 roam
+        draggable: true, // 节点固定(fixed=true),roam 用于平移缩放
         categories: categories.map((c) => ({ name: c.name, itemStyle: { color: c.color } })),
         // 边统一带风流方向箭头(进风→右、回风→左,由 roadway 边 source→target 决定)
         edgeSymbol: ['none', 'arrow'],
         edgeSymbolSize: [0, 10],
-        // 巷道连线中点标注(富文本):第一行测风点位名称、第二行风量、第三行绿色圆点 + 已绑定数据布点名称;
+        // 巷道连线中点标注(富文本):第一行测风点位名称、第二行风量、第三行绿色圆点 + 已绑定数据布点名称;
         // 未绑定时第三行输出浅灰占位圆点(dotEmpty),保证标签高度恒定
         edgeLabel: {
           show: true,
@@ -155,6 +101,7 @@ export function createGraphOption(): EChartsOption {
           formatter: (p: any) => p.name || '',
         },
         lineStyle: { color: '#8c8c8c', curveness: 0, width: 2, opacity: 0.7 },
+        // 悬浮高亮:节点悬浮时联动高亮相邻节点与连线(绑定/布点模式均保留)
         emphasis: {
           focus: 'adjacency',
           lineStyle: { width: 3, opacity: 1 },
@@ -176,43 +123,34 @@ export interface DetailField {
 export const nodeDetailFields: Record<string, DetailField[]> = {
   地面: [{ label: '节点名称', key: 'name' }],
   测风点位: [
-    { label: '测风点位名称', key: 'name' },
+    // { label: '测风点位名称', key: 'name' },
     { label: '层级', key: 'levelText' },
-    { label: '所属矿井', key: 'mineName' },
-    { label: '矿编码', key: 'mineCode' },
     { label: '风量(m³/min)', key: 'airVolume' },
   ],
-  数据布点: [
-    { label: '设备位置', key: 'devicePos' },
-    { label: '矿井名称', key: 'mineName' },
-    { label: '状态', key: 'statusText' },
-  ],
 };
 
 // ==================== 拓扑数据类型 ====================
 
 export interface TopoNodeData {
   id: string;
-  /** 原始实体 id(MineArea/Windrect/部门 id),绑定/解绑调 updateMineArea 时使用 */
+  /** 原始实体 id(MineArea 主键),关联操作时使用 */
   rawId?: string;
   name: string;
   category: number; // categories 索引
   parentId?: string | null;
   isLeaf?: boolean;
   isLeafText?: string;
-  /** 矿井编码(仅部门叶子节点(矿点)有值,测风点位→矿井绑定时取矿编码) */
+  /** 矿井编码(部门叶子节点(矿点)的 fax) */
   fax?: string;
-  /** 测风点位通风类型层级(1=矿井进风 2=采区进风 3=采区用风 4=采区回风 5=矿井回风),用于按层级分 */
+  /** 测风点位通风类型层级(1=矿井进风 2=采区进风 3=采区用风 4=采区回风 5=矿井回风),用于按层级分 */
   level?: number;
   /** 层级显示名(levelTextMap 映射) */
   levelText?: string;
-  status?: string; // normal / abnormal / offline
   airVolume?: number;
-  childCount?: number;
+  windrectId?: string;
   mineCode?: string;
-  deviceCode?: string;
-  devicePos?: string;
-  mineName?: string;
+  /** 疑似隐蔽工作面标记(mineArea.judgeAreaList 字段有内容 → true,对应连线标红加粗) */
+  suspected?: boolean;
   [key: string]: any;
 }
 
@@ -220,10 +158,12 @@ export interface TopoLinkData {
   source: string;
   target: string;
   label?: string;
-  /** 连线类型:sensor=数据布点绑定 / roadway=巷道连线(父巷道 → 子巷道 直线,或地面/lv5 根边) */
-  kind?: 'sensor' | 'roadway';
-  /** 巷道连线所属测风点位 id(roadway 边指向子级点位;根边指向 lv1/lv5 巷道点),双击绑定时定位 */
+  /** 连线类型:roadway=巷道连线(父巷道 → 子巷道 直线,或 地面→lv1 根边) */
+  kind?: 'roadway';
+  /** 连线所属子测风点位 id(roadway 边指向子级点位),连线中点标注与疑似标红定位 */
   pointId?: string;
+  /** 对应巷道关系 id(mineAreaRelationList 项主键),双击连线解除关系时传入;根边无 */
+  relationId?: string;
   /** 风流方向:intake=进风(左→右)/ return=回风(右→左) */
   flow?: 'intake' | 'return';
 }
@@ -244,6 +184,8 @@ export interface MineAreaNode {
   airVolume: number;
   regulationId?: string;
   windrectId?: string;
+  /** 疑似隐蔽工作面数据(有内容时该点位对应连线标红加粗;兼容历史字段 alarmList) */
+  judgeAreaList?: any;
   createTime?: string;
   updateTime?: string;
   [key: string]: any;
@@ -272,35 +214,31 @@ export interface MineAreaRelationNode {
   [key: string]: any;
 }
 
-/** 关系数组元素:一条巷道(父子节点信息),parent 为主要节点 */
+/** 关系数组元素:一条巷道(含关系主键 id,供删除),parent 为主要节点 */
 export interface AreaRelation {
+  id: string;
   parent: MineAreaNode;
   child: MineAreaNode;
 }
 
-/** 链式结构:一条从父级到子级的巷道路径(points 按父子顺序排列) */
-export interface AreaChain {
-  id: string;
-  /** 链上的巷道点(按父子顺序,parent → child) */
-  points: MineAreaNode[];
-}
-
-/** 前端生成根节点 id:地面(root:in 进风侧 / root:out 回风侧) */
+/** 前端生成根节点 id:地面(总进 / 总回) */
 export const ROOT_IN_ID = 'root:in';
 export const ROOT_OUT_ID = 'root:out';
 
-/** API -> getTopologyData 合并返回值 */
-export interface MineAreaApiResponse {
-  mineAreaList: MineAreaNode[];
-  mineAreaRelationList: MineAreaRelationNode[];
+/**
+ * 拓扑全量数据(getTopologyData 返回,供消费方直接使用):
+ * 接口获取数据 → 代码处理(关系/地面节点模拟/疑似标记)→ 组装为一份完整数据。
+ */
+export interface TopologyBundle {
+  /** 可直接渲染的拓扑数据(含总进地面节点、疑似标红所需信息) */
+  topology: TopologyData;
+  /** 数据布点全量列表(getWindrectList,用于解析已绑定设备显示名写入连线标注) */
   windrectList: WindrectNode[];
-  /** 未使用数据布点列表(getWindrectListNoUsed,图底一行绘制) */
-  windrectNoUsedList: WindrectNode[];
 }
 
 /**
  * 由 getMineAreaRelation 返回的 mineAreaList + mineAreaRelationList 生成关系数组。
- * 每个元素 { parent, child } 描述一条巷道(父子节点信息),parent 为主要节点;
+ * 每个元素 { id, parent, child } 描述一条巷道(父子节点信息),parent 为主要节点;
  * 过滤 parent/child 引用缺失(脏数据)的关系。
  */
 export function buildRelationArray(mineAreas: MineAreaNode[], relations: MineAreaRelationNode[]): AreaRelation[] {
@@ -311,117 +249,30 @@ export function buildRelationArray(mineAreas: MineAreaNode[], relations: MineAre
   }
   const result: AreaRelation[] = [];
   for (const r of relations) {
-    if (!r.parentId || !r.childId) continue;
+    if (!r.id || !r.parentId || !r.childId) continue;
     const parent = byId.get(String(r.parentId));
     const child = byId.get(String(r.childId));
-    if (parent && child) result.push({ parent, child });
+    if (parent && child) result.push({ id: r.id, parent, child });
   }
   return result;
 }
 
-/**
- * 由巷道关系数组(每条 = 一条巷道 父 → 子)构建链式结构:
- * 从「从未作为子节点的根点位」(一般即 lv1 进风点)出发,沿 parent→child 深度遍历;
- * 遇分支(一个父节点多个子节点)时主链走第一个子节点,其余子节点各自另起新链(共享前缀只保留在第一条链),
- * 保证链间上下并行、互不交叉;孤立点位(存在于 mineAreas 但不在任何关系中)各自成为单节点链追加在最后;
- * 链顺序 = 根点链在前(按 level 升序、风量降序稳定排序)→ 分支链 → 孤立点链,
- * visited 去重防止环路/重复引用,纯环路数据(无根可达)的点位兜底为单节点链,保证不丢点。
- */
-export function buildChains(relations: AreaRelation[], mineAreas?: MineAreaNode[]): AreaChain[] {
-  const byId = new Map<string, MineAreaNode>();
-  const childMap = new Map<string, string[]>();
-  const isChild = new Set<string>();
-  for (const r of relations) {
-    if (!r.parent || !r.child || !r.parent.id || !r.child.id) continue;
-    const pid = String(r.parent.id);
-    const cid = String(r.child.id);
-    byId.set(pid, r.parent);
-    byId.set(cid, r.child);
-    if (!childMap.has(pid)) childMap.set(pid, []);
-    const list = childMap.get(pid)!;
-    if (!list.includes(cid)) list.push(cid);
-    isChild.add(cid);
-  }
-
-  const chains: AreaChain[] = [];
-  const used = new Set<string>(); // 已被某条链占用的点位 id(共享前缀只保留在第一条链)
-  const branchRoots: string[] = []; // 分支起点(父链完成后作为新链根处理)
-
-  const walk = (startId: string) => {
-    const points: MineAreaNode[] = [];
-    const seen = new Set<string>(); // 防环路
-    let cur: string | undefined = startId;
-    while (cur !== undefined && byId.has(cur) && !seen.has(cur)) {
-      seen.add(cur);
-      if (used.has(cur)) break; // 该点已被前置链占用(共享前缀)
-      used.add(cur);
-      points.push(byId.get(cur)!);
-      const children = (childMap.get(cur) || []).filter((c) => !used.has(c) && byId.has(c));
-      if (children.length === 0) break;
-      cur = children[0];
-      // 分支:其余子节点各自另起新链(统一在父链之后处理,保证父链在前)
-      for (const branch of children.slice(1)) {
-        if (!used.has(branch)) branchRoots.push(branch);
-      }
-    }
-    if (points.length) chains.push({ id: `chain:${chains.length}`, points });
-  };
-
-  // 根点:从未作为子节点的点位;按 level 升序、风量降序稳定排序,保证链顺序确定
-  const roots = [...byId.keys()]
-    .filter((id) => !isChild.has(id))
-    .sort((a, b) => {
-      const la = Number(byId.get(a)?.level) || 0;
-      const lb = Number(byId.get(b)?.level) || 0;
-      if (la !== lb) return la - lb;
-      return (Number(byId.get(b)?.airVolume) || 0) - (Number(byId.get(a)?.airVolume) || 0);
-    });
-  for (const r of roots) {
-    if (!used.has(r)) walk(r);
-  }
-  // 分支链(父链之后、孤立点之前)
-  let qi = 0;
-  while (qi < branchRoots.length) {
-    const start = branchRoots[qi++];
-    if (!used.has(start)) walk(start);
-  }
-  // 孤立点位(存在于 mineAreas 但不在任何关系中)各自成为单节点链,追加在最后
-  if (mineAreas) {
-    const known = new Set(byId.keys());
-    for (const a of mineAreas) {
-      if (!a.id) continue;
-      const id = String(a.id);
-      if (known.has(id)) continue;
-      known.add(id);
-      chains.push({ id: `chain:${chains.length}`, points: [a] });
-    }
-  }
-  // 未被任何链覆盖的关系点位(如纯环路数据:每个节点都是子节点、无根可达)各自成为单节点链,
-  // 保证布局仍能覆盖全部点位(visited 去重 + 此处兜底共同防止环路导致节点丢失)
-  for (const id of byId.keys()) {
-    if (!used.has(id)) {
-      used.add(id);
-      chains.push({ id: `chain:${chains.length}`, points: [byId.get(id)!] });
-    }
-  }
-
-  return chains;
+/** 判断 mineArea 疑似字段是否有内容(非空数组/非空值),兼容历史字段名 alarmList */
+function hasJudgeContent(area: MineAreaNode): boolean {
+  const v = area.judgeAreaList !== undefined && area.judgeAreaList !== null ? area.judgeAreaList : area.alarmList;
+  if (Array.isArray(v)) return v.length > 0;
+  if (v === undefined || v === null || v === '') return false;
+  return true;
 }
 
 /**
- * 以巷道关系数组为数据源生成 TopologyData(每条巷道 = 父 → 子 单条直线连线):
- * 节点:
- *   1) 地面点(root:in 进风侧 / root:out 回风侧),前端生成;
- *   2) 巷道点(mineAreas 全部记录,按 level 分组,未分级单独归列);
- *   3) 数据布点(仅未使用列表 windrectNoUsed 生成,图底一行绘制;
- *      已绑定设备的节点不再绘制,其信息由渲染层写入所属巷道连线 edgeLabel)。
- * 边:
- *   1) 地面 → lv1 巷道点(roadway 进风起点);
- *   2) 每条巷道 parent → child(roadway 单条直线,流向按子节点 level ≤3 进风 / >3 回风);
- *   3) lv5 巷道点 → 地面(roadway 回风终点)。
- * 节点 id 前缀(point:/device:)保证全局唯一,过滤缺失 id 并去重。
+ * 以关系数组为数据源生成 TopologyData(每条巷道 = 父 → 子 单条直线连线):
+ * 节点:1) 总进/总回地面节点(前端生成);2) 巷道点(mineAreaList 全部记录,按 level 分列)。
+ * 边:1) 每条巷道 parent → child(roadway,携带 relationId 供删除、pointId 供标注/标红);
+ *     2) 地面(总进)→ lv1 巷道点、lv5 巷道点 → 地面(总回)(根边,无 relationId)。
+ * 节点 id 前缀(point:)保证全局唯一,过滤缺失 id 并去重。
  */
-export function transformToTopologyData(mineAreas: MineAreaNode[], relations: AreaRelation[], windrectNoUsed: WindrectNode[]): TopologyData {
+export function transformToTopologyData(mineAreas: MineAreaNode[], relations: AreaRelation[]): TopologyData {
   const prefix = (cat: string, id: any) => (id === undefined || id === null || id === '' ? '' : `${cat}:${id}`);
 
   // 巷道点:过滤缺失 id + 去重
@@ -432,32 +283,24 @@ export function transformToTopologyData(mineAreas: MineAreaNode[], relations: Ar
     if (!pointById.has(pid)) pointById.set(pid, a);
   }
 
-  // 数据布点(仅未使用列表):过滤缺失 id + 去重;无 sensor 连线 → 布局自动排到图底一行
-  const deviceById = new Map<string, WindrectNode>();
-  for (const d of windrectNoUsed) {
-    if (!d.id) continue;
-    const pid = prefix('device', d.id);
-    if (!deviceById.has(pid)) deviceById.set(pid, d);
-  }
-
   const links: TopoLinkData[] = [];
-  // 巷道边:每条关系一条 父 → 子 直线(pointId 指向子巷道点,供删除/标注定位
+  // 巷道边:每条关系一条 父 → 子 直线(relationId 指向关系主键、pointId 指向子巷道点)
   for (const rel of relations) {
     const parentId = prefix('point', rel.parent.id);
     const childId = prefix('point', rel.child.id);
     if (!pointById.has(parentId) || !pointById.has(childId)) continue;
     // 巷道流向:子节点 level ≤3 进风,>3 回风
     const flow: 'intake' | 'return' = Number(rel.child.level) <= 3 ? 'intake' : 'return';
-    links.push({ source: parentId, target: childId, kind: 'roadway', flow, pointId: childId });
+    links.push({ source: parentId, target: childId, kind: 'roadway', flow, pointId: childId, relationId: rel.id });
   }
 
-  // 地面 → lv1 巷道点(进风起点
+  // 地面(总进)→ lv1 巷道点(进风起点根边,无关系 id
   for (const [pid, a] of pointById) {
     if (Number(a.level) === 1) {
       links.push({ source: ROOT_IN_ID, target: pid, kind: 'roadway', flow: 'intake', pointId: pid });
     }
   }
-  // lv5 巷道点 → 地面(回风终点)
+  // lv5 巷道点 → 地面(总回)(回风终点根边,无关系 id
   for (const [pid, a] of pointById) {
     if (Number(a.level) === 5) {
       links.push({ source: pid, target: ROOT_OUT_ID, kind: 'roadway', flow: 'return', pointId: pid });
@@ -465,7 +308,7 @@ export function transformToTopologyData(mineAreas: MineAreaNode[], relations: Ar
   }
 
   const nodes: TopoNodeData[] = [
-    // 地面(前端生成,只读)
+    // 总进/总回地面(前端生成,只读)
     { id: ROOT_IN_ID, name: '地面', category: 0 },
     { id: ROOT_OUT_ID, name: '地面', category: 0 },
     // 巷道点
@@ -480,21 +323,26 @@ export function transformToTopologyData(mineAreas: MineAreaNode[], relations: Ar
       windrectId: a.windrectId,
       level: a.level,
       levelText: levelTextMap[a.level] || '',
-    })),
-    // 数据布点
-    ...Array.from(deviceById.entries()).map(([pid, d]) => ({
-      id: pid,
-      rawId: d.id,
-      name: d.devicePos || d.deviceCode || d.id,
-      category: 2,
-      deviceCode: d.deviceCode,
-      devicePos: d.devicePos,
-      mineName: d.mineName,
-      mineCode: d.mineCode,
-      status: String(d.status) === '1' ? 'normal' : 'offline',
-      statusText: String(d.status) === '1' ? '正常' : '停用',
+      suspected: hasJudgeContent(a),
     })),
   ];
 
   return { nodes, links };
 }
+
+/**
+ * 由各接口原始数据组装拓扑全量数据(供 getTopologyData 调用):
+ * 生成巷道关系数组 → 拓扑数据(含总进地面节点与疑似标记)。
+ * 任何列表为空都返回完整结构(总进地面节点 + 空列表),保证消费方总能绘制拓扑图。
+ */
+export function buildTopologyBundle(
+  mineAreaList: MineAreaNode[],
+  mineAreaRelationList: MineAreaRelationNode[],
+  windrectList: WindrectNode[]
+): TopologyBundle {
+  const relations = buildRelationArray(mineAreaList, mineAreaRelationList);
+  return {
+    topology: transformToTopologyData(mineAreaList, relations),
+    windrectList,
+  };
+}

+ 107 - 397
tests/useTopologyLayout.spec.ts

@@ -1,6 +1,5 @@
 import {
   buildRelationArray,
-  buildChains,
   transformToTopologyData,
   formatAirVolumeLabel,
   LAYOUT,
@@ -29,138 +28,71 @@ const rel = (id: string, parentId: string, childId: string, extra: Record<string
   ...extra,
 });
 
-/** 构造一个完整巷道模型拓扑(5 级巷道、4 条巷道关系与 2 个布点,供布局测试) */
+/** 构造一个完整巷道模型拓扑(5 级巷道、4 条巷道关系) */
 function laneTopo(): TopologyData {
-  const nodes = [
-    { id: ROOT_IN_ID, name: '地面', category: 0 },
-    { id: ROOT_OUT_ID, name: '地面', category: 0 },
-    { id: 'point:a1', name: '矿井进风1', category: 1, level: 1, airVolume: 100 },
-    { id: 'point:b1', name: '采区进风1', category: 1, level: 2, airVolume: 90 },
-    { id: 'point:b2', name: '采区进风2', category: 1, level: 2, airVolume: 80 },
-    { id: 'point:c1', name: '采区用风1', category: 1, level: 3, airVolume: 70 },
-    { id: 'point:d1', name: '采区回风1', category: 1, level: 4, airVolume: 60 },
-    { id: 'point:e1', name: '矿井回风1', category: 1, level: 5, airVolume: 50 },
-    { id: 'device:w1', name: '传感器1', category: 2, status: 'normal' },
-    { id: 'device:w2', name: '传感器2', category: 2, status: 'offline' },
-  ];
-  const links = [
-    { source: ROOT_IN_ID, target: 'point:a1', kind: 'roadway', flow: 'intake', pointId: 'point:a1' },
-    { source: 'point:a1', target: 'point:b1', kind: 'roadway', flow: 'intake', pointId: 'point:b1' },
-    { source: 'point:a1', target: 'point:b2', kind: 'roadway', flow: 'intake', pointId: 'point:b2' },
-    { source: 'point:b1', target: 'point:c1', kind: 'roadway', flow: 'intake', pointId: 'point:c1' },
-    { source: 'point:c1', target: 'point:d1', kind: 'roadway', flow: 'return', pointId: 'point:d1' },
-    { source: 'point:d1', target: 'point:e1', kind: 'roadway', flow: 'return', pointId: 'point:e1' },
-    { source: 'point:e1', target: ROOT_OUT_ID, kind: 'roadway', flow: 'return', pointId: 'point:e1' },
-    // w1 绑定 b1(巷道 a1→b1 的子节点)→ sensor 源为 point:b1,布点画在 a1-b1 连线中点
-    { source: 'point:b1', target: 'device:w1', kind: 'sensor' },
-  ];
-  return { nodes, links } as unknown as TopologyData;
+  const areas = [area('a1', 1), area('b1', 2, 90), 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')],
+  );
+  return transformToTopologyData(areas, relations);
 }
 
 describe('buildRelationArray 关系数组生成', () => {
-  test('过滤 parent/child 引用缺失的脏数据', () => {
+  test('过滤 parent/child 引用缺失的脏数据,保留关系主键 id', () => {
     const areas = [area('1', 1), area('2', 2)];
     const relations = [rel('r1', '1', '2'), rel('r2', '9', '2'), rel('r3', '1', '8'), rel('r4', '', '2')];
     const arr = buildRelationArray(areas, relations);
     expect(arr).toHaveLength(1);
+    expect(arr[0].id).toBe('r1');
     expect(arr[0].parent.id).toBe('1');
     expect(arr[0].child.id).toBe('2');
   });
 });
 
-describe('buildChains 链式结构构建', () => {
-  const chainPoint = (id: string, level: number, airVolume = 100) => ({
-    id,
-    name: id,
-    level,
-    airVolume,
-    mineCode: 'M1',
-  });
-  const chainRel = (parent: any, child: any) => ({ parent, child });
-
-  test('分支拆成独立链:共享前缀只保留在第一条链,父链在前、分支链在后', () => {
-    const a1 = chainPoint('a1', 1, 100);
-    const b1 = chainPoint('b1', 2, 60);
-    const b2 = chainPoint('b2', 2, 90);
-    const c1 = chainPoint('c1', 3, 40);
-    const c2 = chainPoint('c2', 3, 80);
-    const d1 = chainPoint('d1', 4, 30);
-    const e1 = chainPoint('e1', 5, 20);
-    const chains = buildChains([
-      chainRel(a1, b1),
-      chainRel(a1, b2),
-      chainRel(b1, c1),
-      chainRel(b2, c2),
-      chainRel(c1, d1),
-      chainRel(d1, e1),
-    ]);
-    expect(chains.map((c) => c.points.map((p) => p.id))).toEqual([
-      ['a1', 'b1', 'c1', 'd1', 'e1'],
-      ['b2', 'c2'],
-    ]);
-  });
-
-  test('孤立点位(不在任何关系中)各自成为单节点链追加在最后', () => {
-    const a1 = chainPoint('a1', 1, 100);
-    const b1 = chainPoint('b1', 2, 90);
-    const solo = chainPoint('solo', 3, 10);
-    const chains = buildChains([chainRel(a1, b1)], [a1, b1, solo]);
-    expect(chains.map((c) => c.points.map((p) => p.id))).toEqual([
-      ['a1', 'b1'],
-      ['solo'],
-    ]);
-  });
-
-  test('多根点按 level 升序、风量降序稳定排序,各自成链;脏关系不报错', () => {
-    const a1 = chainPoint('a1', 1, 100);
-    const a2 = chainPoint('a2', 1, 90);
-    const a3 = chainPoint('a3', 2, 200);
-    const b1 = chainPoint('b1', 2, 80);
-    const b2 = chainPoint('b2', 2, 70);
-    const c3 = chainPoint('c3', 3, 60);
-    const chains = buildChains([
-      chainRel(a1, b1),
-      chainRel(a2, b2),
-      chainRel(a3, c3),
-      chainRel(a2, undefined), // 脏关系:child 缺失,被过滤
-    ]);
-    expect(chains.map((c) => c.points.map((p) => p.id))).toEqual([
-      ['a1', 'b1'],
-      ['a2', 'b2'],
-      ['a3', 'c3'],
-    ]);
-  });
-
-  test('纯环路数据(无根可达)兜底为单节点链,不丢点', () => {
-    const a = chainPoint('a', 1, 100);
-    const b = chainPoint('b', 2, 90);
-    const chains = buildChains([chainRel(a, b), chainRel(b, a)]);
-    expect(chains.map((c) => c.points.map((p) => p.id))).toEqual([['a'], ['b']]);
-  });
-});
-
 describe('transformToTopologyData 巷道线模型', () => {
-  test('生成地面/巷道点;每条巷道一条 parent→child 直线边;地面→lv1、lv5→地面', () => {
-    const areas = [area('a1', 1), area('b1', 2, 90, { windrectId: 'w1' }), area('c1', 3), area('d1', 4), area('e1', 5)];
+  test('生成总进/总回地面节点;全部点位入节点;关系连线带 relationId/pointId;根边无 relationId;无布点节点;流向正确', () => {
+    const areas = [area('a1', 1), area('b1', 2, 90), area('c1', 3), area('d1', 4), area('e1', 5), area('u1', 3, 10)];
     const relations = [rel('r1', 'a1', 'b1'), rel('r2', 'b1', 'c1'), rel('r3', 'c1', 'd1'), rel('r4', 'd1', 'e1')];
-    const windrects = [{ id: 'w1', mineCode: 'M1', devicePos: '主井口', status: 1 }];
-    const data = transformToTopologyData(areas, buildRelationArray(areas, relations), windrects);
+    const data = transformToTopologyData(areas, buildRelationArray(areas, relations));
 
-    // 地面节点
+    // 总进/总回地面节点
     expect(data.nodes.some((n) => n.id === ROOT_IN_ID && n.name === '地面')).toBe(true);
     expect(data.nodes.some((n) => n.id === ROOT_OUT_ID && n.name === '地面')).toBe(true);
-    // 无中间标记/锚点节点;每条巷道一条 parent → child 直线边(pointId 指向子巷道点)
-    expect(data.nodes.filter((n) => n.category === 3)).toHaveLength(0);
-    expect(data.links.some((l) => l.kind === 'roadway' && l.source === 'point:a1' && l.target === 'point:b1' && l.pointId === 'point:b1')).toBe(true);
-    // 地面 → lv1、lv5 → 地面
-    expect(data.links.some((l) => l.kind === 'roadway' && l.source === ROOT_IN_ID && l.target === 'point:a1')).toBe(true);
-    expect(data.links.some((l) => l.kind === 'roadway' && l.source === 'point:e1' && l.target === ROOT_OUT_ID)).toBe(true);
-    // sensor 源为子巷道点(b1)
-    const sensor = data.links.find((l) => l.kind === 'sensor' && l.target === 'device:w1');
-    expect(sensor?.source).toBe('point:b1');
-    // 巷道流向:子节点 level ≤3 进风,>3 回风
-    expect(data.links.find((l) => l.source === 'point:c1' && l.target === 'point:d1')?.flow).toBe('return');
-    expect(data.links.find((l) => l.source === 'point:a1' && l.target === 'point:b1')?.flow).toBe('intake');
+    expect(data.nodes.filter((n) => n.name === '地面')).toHaveLength(2);
+    // 全部点位入节点(含无关系点位 u1)
+    for (const a of areas) {
+      expect(data.nodes.some((n) => n.id === `point:${a.id}`)).toBe(true);
+    }
+    // 关系连线带 relationId + pointId(子节点)
+    const l = data.links.find((x) => x.relationId === 'r1');
+    expect(l?.source).toBe('point:a1');
+    expect(l?.target).toBe('point:b1');
+    expect(l?.pointId).toBe('point:b1');
+    // 根边 地面→lv1、lv5→地面 均无 relationId
+    const rootIn = data.links.find((x) => x.source === ROOT_IN_ID && x.target === 'point:a1');
+    expect(rootIn?.relationId).toBeUndefined();
+    const rootOut = data.links.find((x) => x.source === 'point:e1' && x.target === ROOT_OUT_ID);
+    expect(rootOut?.relationId).toBeUndefined();
+    // 无数据布点节点
+    expect(data.nodes.some((n) => n.category === 2)).toBe(false);
+    // 流向:子节点 level ≤3 进风、>3 回风
+    expect(data.links.find((x) => x.relationId === 'r1')?.flow).toBe('intake');
+    expect(data.links.find((x) => x.relationId === 'r3')?.flow).toBe('return');
+  });
+
+  test('judgeAreaList 字段有内容 → suspected=true;无内容 → false(兼容 alarmList)', () => {
+    const areas = [
+      area('a1', 1, 100, { judgeAreaList: [{ id: 'x' }] }),
+      area('b1', 2, 90, { judgeAreaList: null }),
+      area('c1', 3, 80, { judgeAreaList: [] }),
+      area('d1', 4, 70, { alarmList: ['y'] }), // 历史字段兼容
+    ];
+    const data = transformToTopologyData(areas, []);
+    expect(data.nodes.find((n) => n.id === 'point:a1')?.suspected).toBe(true);
+    expect(data.nodes.find((n) => n.id === 'point:b1')?.suspected).toBe(false);
+    expect(data.nodes.find((n) => n.id === 'point:c1')?.suspected).toBe(false);
+    expect(data.nodes.find((n) => n.id === 'point:d1')?.suspected).toBe(true);
   });
 });
 
@@ -173,14 +105,11 @@ describe('formatAirVolumeLabel 风量标注', () => {
   });
 });
 
-describe('computeLayout 巷道线模型布局', () => {
-  // 几何参数:laneTopo 含未绑定布点 w2 → 底部预留 90,行中心/列距据此计算
-  const centerY = (700 - 90) / 2; // 305
-  const MARGIN = 60;
-  const colGap = 300;
-  const laneX = (idx: number) => MARGIN + colGap * idx;
+describe('computeLayout 列式布局', () => {
+  const centerY = 350; // 700 / 2
+  const laneX = (idx: number) => LAYOUT.margin + LAYOUT.colGap * idx;
 
-  test('列序:地面 → lv1..lv5 → 地面,x 依次递增,同层同列', () => {
+  test('列序:总进 → lv1..lv5 → 总回,x 依次递增 colGap;总进/总回垂直居中', () => {
     const layout = computeLayout(laneTopo(), 1200, 700);
     const pos = layout.positions;
     expect(pos[ROOT_IN_ID].x).toBe(laneX(0));
@@ -189,113 +118,69 @@ describe('computeLayout 巷道线模型布局', () => {
     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['point:b2'].x).toBe(pos['point:b1'].x); // 同层同列
-    // 地面节点垂直居中
+    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('数据布点:方向按锚点层级(level<3 左侧 / =3 正下方 / >3 右侧),未绑定 → 图下方', () => {
-    const layout = computeLayout(laneTopo(), 1200, 700);
-    const pos = layout.positions;
-    // w1 绑定 b1(lv2 < 3)→ 点位左侧
-    expect(layout.devicePlacement['device:w1']).toBe('left');
-    expect(pos['device:w1'].x).toBe(pos['point:b1'].x - LAYOUT.deviceOffset[2]);
-    expect(pos['device:w1'].y).toBe(pos['point:b1'].y);
-    // w2 未绑定 → bottom(图底一行:y = H - unboundRowGap)
-    expect(layout.devicePlacement['device:w2']).toBe('bottom');
-    expect(pos['device:w2'].y).toBe(700 - 30);
-  });
-
-  test('数据布点:绑定 lv1 锚点的布点位于点位左侧(level<3 向左)', () => {
-    const data = laneTopo();
-    data.nodes.push({ id: 'device:w3', name: '传感器3', category: 2, status: 'normal' });
-    data.links.push({ source: 'point:a1', target: 'device:w3', kind: 'sensor' });
-    const layout = computeLayout(data, 1200, 700);
-    const pos = layout.positions;
-    expect(layout.devicePlacement['device:w3']).toBe('left');
-    expect(pos['device:w3'].x).toBe(pos['point:a1'].x - LAYOUT.deviceOffset[1]);
-    expect(pos['device:w3'].y).toBe(pos['point:a1'].y);
-  });
-
-  test('数据布点:level=3 在锚点正下方、level>3 在锚点右侧、未分级/level=0 回退正下方', () => {
-    const data = laneTopo();
-    // c1(lv3)、d1(lv4)、e1(lv5) 各绑一个布点,另加未分级与 level=0 点位绑定布点
-    data.nodes.push(
-      { id: 'device:w3', name: '传感器3', category: 2, status: 'normal' },
-      { id: 'device:w4', name: '传感器4', category: 2, status: 'normal' },
-      { id: 'device:w5', name: '传感器5', category: 2, status: 'normal' },
-      { id: 'point:u1', name: '未分级', category: 1 },
-      { id: 'device:w6', name: '传感器6', category: 2, status: 'normal' },
-      { id: 'point:u0', name: '零级', category: 1, level: 0 },
-      { id: 'device:w7', name: '传感器7', category: 2, status: 'normal' }
-    );
-    data.links.push(
-      { source: 'point:c1', target: 'device:w3', kind: 'sensor' },
-      { source: 'point:d1', target: 'device:w4', kind: 'sensor' },
-      { source: 'point:e1', target: 'device:w5', kind: 'sensor' },
-      { source: 'point:u1', target: 'device:w6', kind: 'sensor' },
-      { source: 'point:u0', target: 'device:w7', kind: 'sensor' }
-    );
-    const layout = computeLayout(data, 1200, 700);
-    const pos = layout.positions;
-    // lv3 → 正下方
-    expect(layout.devicePlacement['device:w3']).toBe('down');
-    expect(pos['device:w3'].x).toBe(pos['point:c1'].x);
-    expect(pos['device:w3'].y).toBe(pos['point:c1'].y + LAYOUT.deviceOffset[3]);
-    // lv4 / lv5 → 右侧
-    expect(layout.devicePlacement['device:w4']).toBe('right');
-    expect(pos['device:w4'].x).toBe(pos['point:d1'].x + LAYOUT.deviceOffset[4]);
-    expect(pos['device:w4'].y).toBe(pos['point:d1'].y);
-    expect(layout.devicePlacement['device:w5']).toBe('right');
-    expect(pos['device:w5'].x).toBe(pos['point:e1'].x + LAYOUT.deviceOffset[5]);
-    expect(pos['device:w5'].y).toBe(pos['point:e1'].y);
-    // 未分级(无 level)→ 回退正下方
-    expect(layout.devicePlacement['device:w6']).toBe('down');
-    expect(pos['device:w6'].x).toBe(pos['point:u1'].x);
-    expect(pos['device:w6'].y).toBe(pos['point:u1'].y + LAYOUT.deviceOffset.ungraded);
-    // level=0(不在 1..5 范围内)→ 同样回退正下方
-    expect(layout.devicePlacement['device:w7']).toBe('down');
-    expect(pos['device:w7'].x).toBe(pos['point:u0'].x);
-    expect(pos['device:w7'].y).toBe(pos['point:u0'].y + LAYOUT.deviceOffset.ungraded);
-  });
-
-  test('同级父子关系:子节点独立成列(整列距右移),后续列顺延,连线统一等长', () => {
+  test('同层级同列,行距 = min(rowGap, 可用高度均分),围绕中心对称', () => {
     const data = laneTopo();
-    data.links.push({ source: 'point:b1', target: 'point:b2', kind: 'roadway', flow: 'intake', pointId: 'point:b2' });
-    const layout = computeLayout(data, 1200, 700);
-    const pos = layout.positions;
-    // b2 独立成列:位于 b1(lv2 列)右侧一整列距,连线与 lv1→lv2 等长
-    expect(pos['point:b2'].x).toBe(pos['point:b1'].x + colGap);
-    // 后续列(lv3/lv4/lv5/地面)顺延右移一列
-    expect(pos['point:c1'].x).toBe(MARGIN + 4 * colGap);
-    expect(pos['point:d1'].x).toBe(MARGIN + 5 * colGap);
-    expect(pos[ROOT_OUT_ID].x).toBe(MARGIN + 8 * colGap);
-    // 独立成列后各自垂直居中,y 对齐
-    expect(pos['point:b1'].y).toBe(centerY);
-    expect(pos['point:b2'].y).toBe(centerY);
-  });
-
-  test('同层点位列内上下对称等距:间距 = min(该层级 columnCap, 可用高度均分),以 centerY 居中', () => {
-    const layout = computeLayout(laneTopo(), 1200, 700);
-    const pos = layout.positions;
-    const cap = LAYOUT.columnCap[2] ?? LAYOUT.columnCapDefault;
-    const expected = Math.min(cap, 200); // maxSpread = min(200, centerY-40) = 200
+    data.nodes.push({ id: 'point:b2', name: 'b2', category: 1, level: 2, airVolume: 85 });
+    const pos = computeLayout(data, 1200, 700).positions;
+    expect(pos['point:b2'].x).toBe(pos['point:b1'].x);
+    const expected = Math.min(LAYOUT.rowGap, 200); // maxSpread = min(200, centerY-40) = 200
     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);
   });
 
-  test('未分级点位在 lv5 与地面之间一列', () => {
-    const data = laneTopo();
-    data.nodes.push({ id: 'point:u1', name: '未分级', category: 1 });
+  test('colGap/rowGap 可配置生效', () => {
+    const origCol = LAYOUT.colGap;
+    const origRow = LAYOUT.rowGap;
+    try {
+      LAYOUT.colGap = 200;
+      LAYOUT.rowGap = 60;
+      const data = laneTopo();
+      data.nodes.push({ id: 'point:b2', name: 'b2', category: 1, level: 2, airVolume: 85 });
+      const pos = computeLayout(data, 1200, 700).positions;
+      expect(pos['point:a1'].x).toBe(LAYOUT.margin + 200);
+      expect(pos['point:b1'].x).toBe(LAYOUT.margin + 2 * 200);
+      expect(Math.abs(pos['point:b2'].y - pos['point:b1'].y)).toBe(60);
+    } finally {
+      LAYOUT.colGap = origCol;
+      LAYOUT.rowGap = origRow;
+    }
+  });
+
+  test('多条进风-用风关系数据:全部点位有合法坐标、无 NaN、不越界', () => {
+    const areas = [
+      area('a1', 1, 500),
+      area('a2', 1, 400),
+      area('c1', 3, 300),
+      area('c2', 3, 200),
+      area('e1', 5, 100),
+      area('e2', 5, 90),
+    ];
+    const relations = buildRelationArray(
+      areas,
+      [rel('r1', 'a1', 'c1'), rel('r2', 'a2', 'c2'), rel('r3', 'c1', 'e1'), rel('r4', 'c2', 'e2')],
+    );
+    const data = transformToTopologyData(areas, relations);
     const layout = computeLayout(data, 1200, 700);
-    expect(layout.positions['point:u1'].x).toBe(laneX(6));
-    expect(layout.positions['point:u1'].x).toBeGreaterThan(layout.positions['point:e1'].x);
-    expect(layout.positions['point:u1'].x).toBeLessThan(layout.positions[ROOT_OUT_ID].x);
-  });
-
-  test('无任何点位时仍正常定位地面节点且不抛错', () => {
+    for (const a of areas) {
+      const pos = layout.positions[`point:${a.id}`];
+      expect(pos).toBeDefined();
+      expect(Number.isFinite(pos.x)).toBe(true);
+      expect(Number.isFinite(pos.y)).toBe(true);
+      expect(pos.y).toBeGreaterThanOrEqual(0);
+      expect(pos.y).toBeLessThan(700);
+    }
+    // 两个进风点同列(lv1)、两个用风点同列(lv3)、两个回风点同列(lv5)
+    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:e1'].x).toBe(layout.positions['point:e2'].x);
+  });
+
+  test('空数据(仅总进/总回地面节点)可布局且不抛错', () => {
     const layout = computeLayout(
       {
         nodes: [
@@ -305,186 +190,11 @@ describe('computeLayout 巷道线模型布局', () => {
         links: [],
       },
       1200,
-      700
+      700,
     );
     expect(layout.positions[ROOT_IN_ID]).toBeDefined();
     expect(layout.positions[ROOT_OUT_ID]).toBeDefined();
-  });
-});
-
-describe('computeLayout 链式顺序布局', () => {
-  test('提供链式结构时各层列内按 (链下标, 链内位置) 排序:链间上下并行、同链路径顺序', () => {
-    // 两条链共享前缀 a1:a1→b1→c1→d1→e1(链0)与 a1→b2→c2→d2→e2(链1);
-    // 链1 节点风量故意更高:若未按链排序(回退风量降序)b2 会排在 b1 上方
-    const areas = [
-      area('a1', 1, 100),
-      area('b1', 2, 60),
-      area('b2', 2, 90),
-      area('c1', 3, 40),
-      area('c2', 3, 80),
-      area('d1', 4, 30),
-      area('d2', 4, 70),
-      area('e1', 5, 20),
-      area('e2', 5, 60),
-    ];
-    const relations = [
-      rel('r1', 'a1', 'b1'),
-      rel('r2', 'a1', 'b2'),
-      rel('r3', 'b1', 'c1'),
-      rel('r4', 'b2', 'c2'),
-      rel('r5', 'c1', 'd1'),
-      rel('r6', 'c2', 'd2'),
-      rel('r7', 'd1', 'e1'),
-      rel('r8', 'd2', 'e2'),
-    ];
-    const relationArr = buildRelationArray(areas, relations);
-    const chains = buildChains(relationArr);
-    const data = transformToTopologyData(areas, relationArr, []);
-    const layout = computeLayout(data, 1200, 700, chains);
-    const pos = layout.positions;
-    // 链间上下并行:链0(b1/c1/d1)在链1(b2/c2/d2)上方
-    expect(pos['point:b1'].y).toBeLessThan(pos['point:b2'].y);
-    expect(pos['point:c1'].y).toBeLessThan(pos['point:c2'].y);
-    expect(pos['point:d1'].y).toBeLessThan(pos['point:d2'].y);
-    // 同链路径顺序:链0 各层节点均为列内首位,y 一致(每列同序、间距相同)
-    expect(pos['point:b1'].y).toBe(pos['point:c1'].y);
-    expect(pos['point:c1'].y).toBe(pos['point:d1'].y);
-    expect(pos['point:d1'].y).toBe(pos['point:e1'].y);
-    // 对比:不传链式结构时回退风量降序,风量更高的链1 在上(证明链排序生效)
-    const fallback = computeLayout(data, 1200, 700);
-    expect(fallback.positions['point:b2'].y).toBeLessThan(fallback.positions['point:b1'].y);
-  });
-});
-
-describe('computeLayout 同级连线平行(getMineAreaRelation 真实数据)', () => {
-  /** 真实返回:9 个测风点位(id=1..9)+ 9 条巷道关系(id=5/6 为两条 lv3 同级连线 4→6、5→7) */
-  const realAreas = () => [
-    area('1', 1, 5125, { windrectId: '1' }),
-    area('2', 2, 456, { windrectId: '2' }),
-    area('3', 2, 568, { windrectId: '3' }),
-    area('4', 3, 458, { windrectId: null }),
-    area('5', 3, 895, { windrectId: '5' }),
-    area('6', 3, 425, { windrectId: '6' }),
-    area('7', 3, 1246, { windrectId: '7' }),
-    area('8', 4, 2895, { windrectId: '8' }),
-    area('9', 5, 5725, { windrectId: '9' }),
-  ];
-  const realRels = () => [
-    rel('1', '1', '2'),
-    rel('2', '1', '3'),
-    rel('3', '2', '4'),
-    rel('4', '3', '5'),
-    rel('5', '4', '6'),
-    rel('6', '5', '7'),
-    rel('7', '6', '8'),
-    rel('8', '7', '8'),
-    rel('9', '8', '9'),
-  ];
-  const laneX = (idx: number) => 60 + 300 * idx;
-  const layoutOf = (rels: ReturnType<typeof realRels>) => {
-    const relationArr = buildRelationArray(realAreas(), rels);
-    const chains = buildChains(relationArr);
-    return computeLayout(transformToTopologyData(realAreas(), relationArr, []), 1200, 700, chains);
-  };
-
-  test('列序快照 + 两条 lv3 同级连线(4→6、5→7)平行:子节点共列、y 镜像父节点', () => {
-    const pos = layoutOf(realRels()).positions;
-    // 列序:地面 → lv1 → lv2 → lv3(父) → lv3(子) → lv4 → lv5 → 地面
-    expect(pos[ROOT_IN_ID].x).toBe(laneX(0));
-    expect(pos['point:1'].x).toBe(laneX(1));
-    expect(pos['point:2'].x).toBe(laneX(2));
-    expect(pos['point:3'].x).toBe(laneX(2));
-    expect(pos['point:4'].x).toBe(laneX(3));
-    expect(pos['point:5'].x).toBe(laneX(3));
-    expect(pos['point:6'].x).toBe(laneX(4));
-    expect(pos['point:7'].x).toBe(laneX(4));
-    expect(pos['point:8'].x).toBe(laneX(5));
-    expect(pos['point:9'].x).toBe(laneX(6));
-    expect(pos[ROOT_OUT_ID].x).toBe(laneX(8));
-    // 平行:子节点 6、7 共列(同一 x),且纵向镜像父节点顺序 → 连线 4→6、5→7 水平平行、等长
-    expect(pos['point:4'].x).toBe(pos['point:5'].x);
-    expect(pos['point:6'].x).toBe(pos['point:7'].x);
-    expect(pos['point:6'].y).toBe(pos['point:4'].y);
-    expect(pos['point:7'].y).toBe(pos['point:5'].y);
-  });
-
-  test('关系列表倒序返回时,两条 lv3 同级连线仍平行(不依赖 API 顺序)', () => {
-    const pos = layoutOf(realRels().reverse()).positions;
-    // 平行保证:子节点共列、y 镜像父节点 → 连线 4→6、5→7 水平平行、配对不变、互不交叉
-    expect(pos['point:6'].x).toBe(pos['point:7'].x);
-    expect(pos['point:6'].y).toBe(pos['point:4'].y);
-    expect(pos['point:7'].y).toBe(pos['point:5'].y);
-    // 注:buildChains 按关系首次出现顺序选主链,倒序时链0/链1 的上下槽位可能互换,
-    // 但平行性与父子配对关系不变(API 按 createTime 稳定返回时为确定布局)
-  });
-});
-
-describe('computeLayout 列内纵向间距按层级分别配置', () => {
-  const origCap2 = LAYOUT.columnCap[2];
-  afterEach(() => {
-    LAYOUT.columnCap[2] = origCap2;
-  });
-
-  test('配置不同间距上限时列内间距分别生效', () => {
-    LAYOUT.columnCap[2] = 50;
-    const layout = computeLayout(laneTopo(), 1200, 700);
-    expect(Math.abs(layout.positions['point:b2'].y - layout.positions['point:b1'].y)).toBe(50);
-  });
-});
-
-describe('computeLayout LAYOUT 按 level 配置生效', () => {
-  // 保存原始配置,用例结束后恢复,避免影响其他用例
-  const orig = {
-    minEdge: { ...LAYOUT.minEdgeLength },
-    minGap: { ...LAYOUT.columnMinGap },
-    placement: { ...LAYOUT.devicePlacement },
-    offset: { ...LAYOUT.deviceOffset },
-  };
-  afterEach(() => {
-    Object.assign(LAYOUT.minEdgeLength, orig.minEdge);
-    Object.assign(LAYOUT.columnMinGap, orig.minGap);
-    Object.assign(LAYOUT.devicePlacement, orig.placement);
-    Object.assign(LAYOUT.deviceOffset, orig.offset);
-  });
-
-  test('minEdgeLength 按 level 生效:同级子列水平偏移 = 父列 x + max(sameLevelOffset, minEdgeLength[lv])', () => {
-    LAYOUT.minEdgeLength[2] = 500;
-    const data = laneTopo();
-    data.links.push({ source: 'point:b1', target: 'point:b2', kind: 'roadway', flow: 'intake', pointId: 'point:b2' });
-    const layout = computeLayout(data, 1200, 700);
-    // b2(lv2 同级子列)x = b1.x + max(200, 500) = b1.x + 500
-    expect(layout.positions['point:b2'].x).toBe(layout.positions['point:b1'].x + 500);
-    // 后续列仍按 laneX 顺延(c1 为 lv3 列,索引 +1)
-    expect(layout.positions['point:c1'].x).toBe(60 + 4 * 300);
-  });
-
-  test('columnMinGap 按 level 生效:列内纵向间距不低于下限', () => {
-    LAYOUT.columnMinGap[2] = 400;
-    // laneTopo 的 lv2 列(b1、b2)可用均分 = 200 < 400 → 间距被下限钳制为 400
-    const layout = computeLayout(laneTopo(), 1200, 700);
-    expect(Math.abs(layout.positions['point:b2'].y - layout.positions['point:b1'].y)).toBe(400);
-    // 未配置的层级(lv3 单节点列)不受影响
-    expect(layout.positions['point:c1'].y).toBe((700 - 90) / 2);
-  });
-
-  test('devicePlacement/deviceOffset 按 level 生效:改 lv3 布点方向与距离', () => {
-    LAYOUT.devicePlacement[3] = 'left';
-    LAYOUT.deviceOffset[3] = 60;
-    const data = laneTopo();
-    data.nodes.push({ id: 'device:w3', name: '传感器3', category: 2, status: 'normal' });
-    data.links.push({ source: 'point:c1', target: 'device:w3', kind: 'sensor' });
-    const layout = computeLayout(data, 1200, 700);
-    expect(layout.devicePlacement['device:w3']).toBe('left');
-    expect(layout.positions['device:w3'].x).toBe(layout.positions['point:c1'].x - 60);
-  });
-
-  test('devicePlacement 按 level 生效:改 lv1 布点方向为右侧', () => {
-    LAYOUT.devicePlacement[1] = 'right';
-    const data = laneTopo();
-    data.nodes.push({ id: 'device:w3', name: '传感器3', category: 2, status: 'normal' });
-    data.links.push({ source: 'point:a1', target: 'device:w3', kind: 'sensor' });
-    const layout = computeLayout(data, 1200, 700);
-    expect(layout.devicePlacement['device:w3']).toBe('right');
-    expect(layout.positions['device:w3'].x).toBe(layout.positions['point:a1'].x + LAYOUT.deviceOffset[1]);
+    expect(layout.positions[ROOT_IN_ID].y).toBe(centerY);
+    expect(layout.positions[ROOT_OUT_ID].y).toBe(centerY);
   });
 });