useTopologyOrdering.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  1. import {
  2. buildRelationArray,
  3. transformToTopologyData,
  4. LAYOUT,
  5. ROOT_IN_ID,
  6. ROOT_OUT_ID,
  7. } from '../src/views/analysis/warningAnalysis/windPointManage/windTopology/windTopology.data';
  8. import type { TopologyData } from '../src/views/analysis/warningAnalysis/windPointManage/windTopology/windTopology.data';
  9. import {
  10. orderNodesByLevel,
  11. countCrossings,
  12. countTotalCrossings,
  13. } from '../src/views/analysis/warningAnalysis/windPointManage/windTopology/hooks/useTopologyOrdering';
  14. import { computeLayout } from '../src/views/analysis/warningAnalysis/windPointManage/windTopology/hooks/useTopologyLayout';
  15. /** 构造测风地点(MineArea) */
  16. const area = (id: string, level: number, airVolume = 100, extra: Record<string, any> = {}) => ({
  17. id,
  18. name: id,
  19. level,
  20. airVolume,
  21. mineCode: 'M1',
  22. ...extra,
  23. });
  24. /** 构造巷道关系(MineAreaRelation) */
  25. const rel = (id: string, parentId: string, childId: string, extra: Record<string, any> = {}) => ({
  26. id,
  27. mineCode: 'M1',
  28. parentId,
  29. childId,
  30. ...extra,
  31. });
  32. /** 相邻层边方向规范化:排序不关心渲染方向(parent→child),只关心端点所属层 */
  33. function pairEdgesOf(data: TopologyData): Map<string, Array<[string, string]>> {
  34. // 复算 orderNodesByLevel 内部的相邻层边表(与实现保持一致:level 差恰为 1 的巷道边)
  35. const levelById = new Map<string, number>();
  36. for (const n of data.nodes) {
  37. if (n.category !== 1) continue;
  38. const lv = Number(n.level);
  39. if (Number.isFinite(lv) && lv >= 0 && lv <= 6) levelById.set(n.id, lv);
  40. }
  41. const map = new Map<string, Array<[string, string]>>();
  42. for (const l of data.links) {
  43. if (l.kind !== 'roadway') continue;
  44. const sl = levelById.get(l.source);
  45. const tl = levelById.get(l.target);
  46. if (sl === undefined || tl === undefined || Math.abs(sl - tl) !== 1) continue;
  47. const key = `${Math.min(sl, tl)}-${Math.max(sl, tl)}`;
  48. if (!map.has(key)) map.set(key, []);
  49. map.get(key)!.push(sl < tl ? [l.source, l.target] : [l.target, l.source]);
  50. }
  51. return map;
  52. }
  53. describe('countCrossings 交叉数统计', () => {
  54. test('秩相对顺序相反的一对边计 1 交叉,相同不计', () => {
  55. // a 在 x 上方、y 在 b 上方:边 a-y 与 b-x 交叉
  56. const rankA = new Map([
  57. ['a', 0],
  58. ['b', 1],
  59. ]);
  60. const rankB = new Map([
  61. ['x', 0],
  62. ['y', 1],
  63. ]);
  64. expect(
  65. countCrossings(rankA, rankB, [
  66. ['a', 'y'],
  67. ['b', 'x'],
  68. ])
  69. ).toBe(1);
  70. expect(
  71. countCrossings(rankA, rankB, [
  72. ['a', 'x'],
  73. ['b', 'y'],
  74. ])
  75. ).toBe(0);
  76. // 无公共端点秩(防御:缺失端点跳过不计数)
  77. expect(
  78. countCrossings(rankA, rankB, [
  79. ['a', 'y'],
  80. ['unknown', 'x'],
  81. ])
  82. ).toBe(0);
  83. });
  84. });
  85. describe('orderNodesByLevel median 扫描法', () => {
  86. test('2×2 交叉场景:排序后相邻层交叉数为 0', () => {
  87. // lv1=[a1,a2]、lv2=[b1,b2](输入序);边 a1→b2、a2→b1 → 初始 1 交叉
  88. const areas = [area('a1', 1), area('a2', 1), area('b1', 2), area('b2', 2)];
  89. const data = transformToTopologyData(areas, buildRelationArray(areas, [rel('r1', 'a1', 'b2'), rel('r2', 'a2', 'b1')]));
  90. const order = orderNodesByLevel(data);
  91. // 参与层存在且含全部节点
  92. expect(order.get('1')).toHaveLength(2);
  93. expect(order.get('2')).toHaveLength(2);
  94. expect(countTotalCrossings(data, order)).toBe(0);
  95. });
  96. test('确定性:同一输入两次调用结果完全一致(含 barycenter 方法)', () => {
  97. const areas = [area('a1', 1), area('a2', 1), area('a3', 1), area('b1', 2), area('b2', 2), area('b3', 2)];
  98. const relations = buildRelationArray(areas, [
  99. rel('r1', 'a1', 'b2'),
  100. rel('r2', 'a2', 'b3'),
  101. rel('r3', 'a3', 'b1'),
  102. rel('r1b', 'a1', 'b3'),
  103. rel('r2b', 'a2', 'b1'),
  104. ]);
  105. const data = transformToTopologyData(areas, relations);
  106. for (const method of ['median', 'barycenter'] as const) {
  107. const o1 = orderNodesByLevel(data, { method });
  108. const o2 = orderNodesByLevel(data, { method });
  109. expect(o1.get('1')).toEqual(o2.get('1'));
  110. expect(o1.get('2')).toEqual(o2.get('2'));
  111. }
  112. });
  113. test('孤立节点(无相邻层边)排在层尾且保持相对输入序', () => {
  114. // x1/x2 邻居均为 a1(位置相同 → 保持输入序);y 无任何相邻层边 → +Infinity 排层尾
  115. const areas = [area('a1', 1), area('x1', 2), area('x2', 2), area('y', 2)];
  116. const data = transformToTopologyData(areas, buildRelationArray(areas, [rel('r1', 'a1', 'x1'), rel('r2', 'a1', 'x2')]));
  117. const order = orderNodesByLevel(data);
  118. expect(order.get('2')).toEqual(['point:x1', 'point:x2', 'point:y']);
  119. });
  120. test('未分级列保持输入序,不参与扫描', () => {
  121. // level 0/6 已是有效层级(进风井/回风井),未分级用越界值 7 表示
  122. const areas = [area('g1', 7), area('g2', 7), area('g3', 7)];
  123. const data = transformToTopologyData(areas, []);
  124. const order = orderNodesByLevel(data);
  125. expect(order.get('ungraded')).toEqual(['point:g1', 'point:g2', 'point:g3']);
  126. });
  127. test('跨层边(level 差 >1)不参与排序:中间层保持输入序,不抛错', () => {
  128. // 仅 a1(lv1)→c1(lv3) 跨层边;lv2 的 b1/b2 无相邻层边 → 均排层尾且保持相对顺序
  129. const areas = [area('a1', 1), area('b1', 2), area('b2', 2), area('c1', 3)];
  130. const data = transformToTopologyData(areas, buildRelationArray(areas, [rel('r1', 'a1', 'c1')]));
  131. const order = orderNodesByLevel(data);
  132. expect(order.get('2')).toEqual(['point:b1', 'point:b2']);
  133. expect(countTotalCrossings(data, order)).toBe(0);
  134. });
  135. test('空数据(仅地面节点):各层空数组 + ungraded 空数组,不抛错', () => {
  136. const data: TopologyData = {
  137. nodes: [
  138. { id: ROOT_IN_ID, name: '地面', category: 0 },
  139. { id: ROOT_OUT_ID, name: '地面', category: 0 },
  140. ],
  141. links: [],
  142. };
  143. const order = orderNodesByLevel(data);
  144. for (const key of ['0', '1', '2', '3', '4', '5', '6', 'ungraded']) {
  145. expect(order.get(key)).toEqual([]);
  146. }
  147. });
  148. test('sweepRounds 配置生效且不抛错', () => {
  149. const areas = [area('a1', 1), area('a2', 1), area('b1', 2), area('b2', 2)];
  150. const data = transformToTopologyData(areas, buildRelationArray(areas, [rel('r1', 'a1', 'b2'), rel('r2', 'a2', 'b1')]));
  151. const order = orderNodesByLevel(data, { sweepRounds: 1 });
  152. expect(countTotalCrossings(data, order)).toBe(0);
  153. });
  154. test('初始序为输入序(无相邻层边时列序完全不变)', () => {
  155. // 无任何巷道边:各层节点保持 data.nodes 输入序
  156. const areas = [area('a1', 1, 10), area('a2', 1, 90), area('b1', 2, 50), area('b2', 2, 30)];
  157. const data = transformToTopologyData(areas, []);
  158. const order = orderNodesByLevel(data);
  159. expect(order.get('1')).toEqual(['point:a1', 'point:a2']);
  160. expect(order.get('2')).toEqual(['point:b1', 'point:b2']);
  161. });
  162. test('新增层级 lv0(进风井)/lv6(回风井) 参与排序:相邻层对 0-1、5-6 交叉消除', () => {
  163. // lv0=[a0,a1]、lv1=[b1,b2]:边 a0→b2、a1→b1 → 初始 1 交叉
  164. // lv5=[e1,e2]、lv6=[f1,f2]:边 e1→f2、e2→f1 → 初始 1 交叉
  165. const areas = [area('a0', 0), area('a1', 0), area('b1', 1), area('b2', 1), area('e1', 5), area('e2', 5), area('f1', 6), area('f2', 6)];
  166. const relations = buildRelationArray(areas, [rel('r1', 'a0', 'b2'), rel('r2', 'a1', 'b1'), rel('r3', 'e1', 'f2'), rel('r4', 'e2', 'f1')]);
  167. const data = transformToTopologyData(areas, relations);
  168. const order = orderNodesByLevel(data);
  169. expect(order.get('0')).toHaveLength(2);
  170. expect(order.get('1')).toHaveLength(2);
  171. expect(order.get('5')).toHaveLength(2);
  172. expect(order.get('6')).toHaveLength(2);
  173. expect(countTotalCrossings(data, order)).toBe(0);
  174. });
  175. });
  176. describe('computeLayout 与排序集成', () => {
  177. const centerY = 350; // 700 / 2
  178. const laneX = (idx: number) => LAYOUT.margin + LAYOUT.colGap * idx;
  179. test('2×2 交叉场景:排序后坐标合法、x 列序不变、lv2 按交叉最小化重排', () => {
  180. const areas = [area('a1', 1), area('a2', 1), area('b1', 2), area('b2', 2)];
  181. const data = transformToTopologyData(areas, buildRelationArray(areas, [rel('r1', 'a1', 'b2'), rel('r2', 'a2', 'b1')]));
  182. const pos = computeLayout(data, 1200, 700).positions;
  183. for (const a of areas) {
  184. const p = pos[`point:${a.id}`];
  185. expect(p).toBeDefined();
  186. expect(Number.isFinite(p.x)).toBe(true);
  187. expect(Number.isFinite(p.y)).toBe(true);
  188. expect(p.y).toBeGreaterThanOrEqual(0);
  189. expect(p.y).toBeLessThan(700);
  190. }
  191. // x 列序不变:lv1 同列、lv2 同列
  192. expect(pos['point:a1'].x).toBe(pos['point:a2'].x);
  193. expect(pos['point:b1'].x).toBe(pos['point:b2'].x);
  194. expect(pos['point:a1'].x).toBe(laneX(2)); // 总进(0) lv0(1) lv1(2)
  195. expect(pos['point:b1'].x).toBe(laneX(3));
  196. // 排序生效:lv2 重排为 [b2, b1](b2 在 b1 上方),消除交叉
  197. expect(pos['point:b2'].y).toBeLessThan(pos['point:b1'].y);
  198. // 每列仍围绕中心对称
  199. expect((pos['point:a1'].y + pos['point:a2'].y) / 2).toBeCloseTo(centerY, 5);
  200. expect((pos['point:b1'].y + pos['point:b2'].y) / 2).toBeCloseTo(centerY, 5);
  201. });
  202. test('5 层线状模型(laneTopo 等价):全部坐标合法,交叉数为 0', () => {
  203. const areas = [area('a1', 1), area('b1', 2), area('c1', 3), area('d1', 4), area('e1', 5)];
  204. const relations = buildRelationArray(areas, [rel('r1', 'a1', 'b1'), rel('r2', 'b1', 'c1'), rel('r3', 'c1', 'd1'), rel('r4', 'd1', 'e1')]);
  205. const data = transformToTopologyData(areas, relations);
  206. const order = orderNodesByLevel(data);
  207. expect(countTotalCrossings(data, order)).toBe(0);
  208. const pos = computeLayout(data, 1200, 700).positions;
  209. for (const a of areas) {
  210. const p = pos[`point:${a.id}`];
  211. expect(Number.isFinite(p.x)).toBe(true);
  212. expect(Number.isFinite(p.y)).toBe(true);
  213. expect(p.y).toBeGreaterThanOrEqual(0);
  214. expect(p.y).toBeLessThan(700);
  215. }
  216. });
  217. test('排序不改变列序与总进/总回地面节点位置', () => {
  218. const areas = [area('a1', 1), area('b1', 2), area('c1', 3), area('d1', 4), area('e1', 5)];
  219. const relations = buildRelationArray(areas, [rel('r1', 'a1', 'b1'), rel('r2', 'b1', 'c1'), rel('r3', 'c1', 'd1'), rel('r4', 'd1', 'e1')]);
  220. const data = transformToTopologyData(areas, relations);
  221. const pos = computeLayout(data, 1200, 700).positions;
  222. expect(pos[ROOT_IN_ID].x).toBe(laneX(0));
  223. expect(pos['point:a1'].x).toBe(laneX(2)); // 总进(0) lv0(1) lv1(2)
  224. expect(pos['point:e1'].x).toBe(laneX(6)); // lv5 在 lv0..lv6 列序中为第 6 列
  225. expect(pos[ROOT_OUT_ID].x).toBe(laneX(9)); // 未分级列之后为总回
  226. expect(pos[ROOT_IN_ID].y).toBe(centerY);
  227. expect(pos[ROOT_OUT_ID].y).toBe(centerY);
  228. });
  229. test('pairEdgesOf 复算与 countTotalCrossings 一致(防实现漂移)', () => {
  230. const areas = [area('a1', 1), area('a2', 1), area('b1', 2), area('b2', 2), area('c1', 3)];
  231. const relations = buildRelationArray(areas, [
  232. rel('r1', 'a1', 'b2'),
  233. rel('r2', 'a2', 'b1'),
  234. rel('r3', 'b2', 'c1'),
  235. rel('r4', 'a1', 'c1'), // 跨层边:不参与
  236. ]);
  237. const data = transformToTopologyData(areas, relations);
  238. const order = orderNodesByLevel(data);
  239. // 独立复算相邻层边,再统计交叉数,应与 countTotalCrossings 一致
  240. const edges = pairEdgesOf(data);
  241. const rankOf = (key: string) => {
  242. const r = new Map<string, number>();
  243. (order.get(key) || []).forEach((id, i) => r.set(id, i));
  244. return r;
  245. };
  246. let manual = 0;
  247. for (let i = 0; i <= 5; i++) {
  248. const es = edges.get(`${i}-${i + 1}`);
  249. if (!es) continue;
  250. manual += countCrossings(rankOf(String(i)), rankOf(String(i + 1)), es);
  251. }
  252. expect(manual).toBe(countTotalCrossings(data, order));
  253. });
  254. });