|
|
@@ -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));
|
|
|
+ });
|
|
|
+});
|