瀏覽代碼

fix: AI 关系图谱布局复用对象关系分析页既有引擎

- 删除 AI 自研的径向/网格布局,改为直接调用对象关系分析页已有的 runLegacyForceLayout + computeAnalysisLayout。- 布局输入按原页 buildRelationLayoutPayload 同口径组装:canvas 尺寸、seed、level、degree、nodeSize=180、layoutPreset=mingjing-g6。- 采用原页同一套节点重叠松弛逻辑,render/relayout 改为等待异步力导向布局完成后再渲染。
cc 1 天之前
父節點
當前提交
0e77825af5

+ 2 - 2
ai-electron/frontend/src/ai/components/graph/GraphAnalysisViewer.vue

@@ -1196,7 +1196,7 @@
       destroyGraph();
       const size = getCanvasSize();
       const data = buildG6Data(props.model);
-      const positions = layoutGraph(props.model, size);
+      const positions = await layoutGraph(props.model, size);
       (data.nodes || []).forEach((node) => {
         const position = positions.get(String(node.id));
         if (!position) return;
@@ -1351,7 +1351,7 @@
     const graph = graphRef.value;
     if (!graph) return;
     try {
-      const positions = layoutGraph(props.model, getCanvasSize());
+      const positions = await layoutGraph(props.model, getCanvasSize());
       graph.updateNodeData(
         graph.getNodeData().map((node) => {
           const position = positions.get(String(node.id));

+ 265 - 202
ai-electron/frontend/src/ai/components/graph/graphLayout.ts

@@ -1,15 +1,21 @@
 /**
- * AI 聊天关系图谱 —— 独立布局实现
+ * AI 聊天关系图谱布局。
  *
- * 不依赖「对象关系分析」页面的 analysisLayoutEngine / worker。
- * 采用「连通分量 + BFS 径向分层 + 有限轮碰撞松弛」的确定性布局:
- *  - 每个分量以度数最高的人节点为中心,关系层级铺在同心圆环上;
- *  - 多个分量按网格排布;
- *  - 节点较少时做少量两两排斥,减少标签重叠。
+ * 直接复用「对象关系分析」页面已经写好的布局实现:
+ * - 力导向布局:analysisLegacyForce.runLegacyForceLayout
+ * - 确定性兜底/固定点:analysisLayoutEngine.computeAnalysisLayout
+ * - 节点重叠松弛:与对象关系分析页保持一致
  *
- * 对关系图谱(≤500 条边)足够稳定、可控,也不会阻塞 UI。
+ * 不再维护独立的 AI 图谱布局算法,保证同一份 nodeList/edgeList 与对象关系分析页
+ * 使用同一套节点位置计算逻辑。
  */
 import type { ParsedGraph } from './graphData';
+import { computeAnalysisLayout } from '@/graph/views/graph/analysisLayoutEngine';
+import { runLegacyForceLayout } from '@/graph/views/graph/analysisLegacyForce';
+import type {
+  AnalysisLayoutWorkerPayload,
+  AnalysisLayoutWorkerPosition,
+} from '@/graph/views/graph/analysisLayoutShared';
 
 export interface LayoutPosition {
   x: number;
@@ -21,223 +27,280 @@ export interface LayoutSize {
   height: number;
 }
 
-const BASE_RADIUS = 190;
-const LEVEL_GAP = 210;
-const MIN_NODE_DIST = 128;
-const COMPONENT_GAP = 260;
-
 type PositionMap = Map<string, LayoutPosition>;
 
-function buildAdjacency(graph: ParsedGraph): Map<string, Set<string>> {
-  const adjacency = new Map<string, Set<string>>();
-  graph.nodes.forEach((node) => adjacency.set(node.id, new Set()));
-  graph.edges.forEach((edge) => {
-    const source = String(edge.source);
-    const target = String(edge.target);
-    if (!adjacency.has(source) || !adjacency.has(target) || source === target) return;
-    adjacency.get(source)!.add(target);
-    adjacency.get(target)!.add(source);
-  });
-  return adjacency;
+/** 与对象关系分析页一致:布局节点尺寸 180 */
+const ANALYSIS_LAYOUT_NODE_SIZE = 180;
+const ANALYSIS_LAYOUT_NODE_GAP_X = 20;
+const ANALYSIS_LAYOUT_NODE_GAP_Y = 20;
+const ANALYSIS_LAYOUT_RELAX_MAX_NODES = 360;
+const ANALYSIS_LAYOUT_FORCE_WORKER_MAX_NODES = 3000;
+
+/* ------------------------------------------------------------------ *
+ * 以下松弛逻辑与对象关系分析页保持一致
+ * ------------------------------------------------------------------ */
+
+interface RelaxItem {
+  id: string;
+  x: number;
+  y: number;
 }
 
-function buildComponents(graph: ParsedGraph, adjacency: Map<string, Set<string>>): string[][] {
-  const visited = new Set<string>();
-  const components: string[][] = [];
-  graph.nodes.forEach((node) => {
-    if (visited.has(node.id)) return;
-    const component: string[] = [];
-    const stack = [node.id];
-    visited.add(node.id);
-    while (stack.length) {
-      const current = stack.pop()!;
-      component.push(current);
-      (adjacency.get(current) || new Set<string>()).forEach((next) => {
-        if (visited.has(next)) return;
-        visited.add(next);
-        stack.push(next);
-      });
-    }
-    components.push(component);
-  });
-  return components;
+interface RelaxBounds {
+  minX: number;
+  maxX: number;
+  minY: number;
+  maxY: number;
 }
 
-function resolveSeed(component: string[], graph: ParsedGraph, degreeRecord: Map<string, number>): string {
-  const nodeById = new Map(graph.nodes.map((node) => [node.id, node]));
-  const sorted = [...component].sort((left, right) => {
-    const leftKind = nodeById.get(left)?.kind || 'other';
-    const rightKind = nodeById.get(right)?.kind || 'other';
-    const kindDiff = Number(rightKind === 'person') - Number(leftKind === 'person');
-    if (kindDiff !== 0) return kindDiff;
-    return (degreeRecord.get(right) || 0) - (degreeRecord.get(left) || 0);
-  });
-  return sorted[0] || component[0];
+interface RelaxOverlap {
+  left: RelaxItem;
+  right: RelaxItem;
+  overlapX: number;
+  overlapY: number;
+  deltaX: number;
+  deltaY: number;
 }
 
-function resolveLevels(
-  component: string[],
-  seed: string,
-  adjacency: Map<string, Set<string>>,
-): { levels: Map<string, number>; groups: string[][] } {
-  const levels = new Map<string, number>();
-  levels.set(seed, 0);
-  const queue = [seed];
-  while (queue.length) {
-    const current = queue.shift()!;
-    const currentLevel = levels.get(current) || 0;
-    (adjacency.get(current) || new Set<string>()).forEach((next) => {
-      if (levels.has(next)) return;
-      levels.set(next, currentLevel + 1);
-      queue.push(next);
-    });
+const getRelaxBounds = (items: RelaxItem[], payload: AnalysisLayoutWorkerPayload): RelaxBounds => {
+  const spreadRatio = Math.max(2.6, Math.sqrt(Math.max(items.length, 1) / 18) * 2.2);
+  return {
+    minX: -payload.width * spreadRatio,
+    maxX: payload.width * (spreadRatio + 1),
+    minY: -payload.height * spreadRatio,
+    maxY: payload.height * (spreadRatio + 1),
+  };
+};
+
+const clampRelaxItem = (item: RelaxItem, bounds: RelaxBounds) => {
+  item.x = Math.min(bounds.maxX, Math.max(bounds.minX, item.x));
+  item.y = Math.min(bounds.maxY, Math.max(bounds.minY, item.y));
+};
+
+const getRelaxOverlap = (
+  left: RelaxItem,
+  right: RelaxItem,
+  boxWidth: number,
+  boxHeight: number,
+): RelaxOverlap | null => {
+  const deltaX = right.x - left.x;
+  const deltaY = right.y - left.y;
+  const overlapX = boxWidth - Math.abs(deltaX);
+  const overlapY = boxHeight - Math.abs(deltaY);
+  if (overlapX <= 0 || overlapY <= 0) return null;
+  return { left, right, overlapX, overlapY, deltaX, deltaY };
+};
+
+const getRelaxDirection = (overlap: RelaxOverlap, leftIndex: number, rightIndex: number) => {
+  const separateOnX = overlap.overlapX < overlap.overlapY;
+  if (separateOnX) {
+    return overlap.deltaX === 0 ? (leftIndex % 2 === 0 ? -1 : 1) : Math.sign(overlap.deltaX);
   }
-  component.forEach((id) => {
-    if (!levels.has(id)) levels.set(id, 0);
-  });
+  return overlap.deltaY === 0 ? (rightIndex % 2 === 0 ? -1 : 1) : Math.sign(overlap.deltaY);
+};
 
-  const maxLevel = Math.max(0, ...Array.from(levels.values()));
-  const groups: string[][] = Array.from({ length: maxLevel + 1 }, () => []);
-  component.forEach((id) => {
-    groups[levels.get(id) || 0].push(id);
-  });
-  return { levels, groups };
-}
+const moveRelaxItem = (
+  item: RelaxItem,
+  options: { separateOnX: boolean; direction: number; offset: number; multiplier: number },
+) => {
+  if (options.separateOnX) {
+    item.x += options.direction * options.offset * options.multiplier;
+    return;
+  }
+  item.y += options.direction * options.offset * options.multiplier;
+};
 
-function relaxPositions(component: string[], positions: PositionMap, seed: string, iterations: number): void {
-  if (iterations <= 0 || component.length < 2) return;
-  const ids = component.filter((id) => positions.has(id));
-  for (let round = 0; round < iterations; round += 1) {
-    let moved = false;
-    for (let i = 0; i < ids.length; i += 1) {
-      const left = positions.get(ids[i])!;
-      for (let j = i + 1; j < ids.length; j += 1) {
-        const right = positions.get(ids[j])!;
-        const dx = right.x - left.x;
-        const dy = right.y - left.y;
-        const distance = Math.hypot(dx, dy) || 0.001;
-        if (distance >= MIN_NODE_DIST) continue;
-        const push = (MIN_NODE_DIST - distance) / 2;
-        const ux = dx / distance;
-        const uy = dy / distance;
-        if (ids[i] !== seed) {
-          left.x -= ux * push;
-          left.y -= uy * push;
-        }
-        if (ids[j] !== seed) {
-          right.x += ux * push;
-          right.y += uy * push;
-        }
-        moved = true;
-      }
+const separateRelaxItems = (
+  overlap: RelaxOverlap,
+  leftIndex: number,
+  rightIndex: number,
+  fixedIds: Set<string>,
+  bounds: RelaxBounds,
+) => {
+  const { left, right } = overlap;
+  const leftFixed = fixedIds.has(left.id);
+  const rightFixed = fixedIds.has(right.id);
+  if (leftFixed && rightFixed) return false;
+
+  const separateOnX = overlap.overlapX < overlap.overlapY;
+  const direction = getRelaxDirection(overlap, leftIndex, rightIndex);
+  const offset = (separateOnX ? overlap.overlapX : overlap.overlapY) / 2 + 20;
+  const moveOptions = { separateOnX, direction, offset };
+
+  if (leftFixed) {
+    moveRelaxItem(right, { ...moveOptions, multiplier: 2 });
+  } else if (rightFixed) {
+    moveRelaxItem(left, { ...moveOptions, multiplier: -2 });
+  } else {
+    moveRelaxItem(left, { ...moveOptions, multiplier: -1 });
+    moveRelaxItem(right, { ...moveOptions, multiplier: 1 });
+  }
+  clampRelaxItem(left, bounds);
+  clampRelaxItem(right, bounds);
+  return true;
+};
+
+const relaxPositionIteration = (
+  items: RelaxItem[],
+  fixedIds: Set<string>,
+  bounds: RelaxBounds,
+  boxWidth: number,
+  boxHeight: number,
+) => {
+  let hasOverlap = false;
+  for (let leftIndex = 0; leftIndex < items.length; leftIndex += 1) {
+    for (let rightIndex = leftIndex + 1; rightIndex < items.length; rightIndex += 1) {
+      const overlap = getRelaxOverlap(items[leftIndex], items[rightIndex], boxWidth, boxHeight);
+      if (!overlap) continue;
+      hasOverlap = separateRelaxItems(overlap, leftIndex, rightIndex, fixedIds, bounds) || hasOverlap;
     }
-    if (!moved) break;
   }
-}
+  return hasOverlap;
+};
 
-function layoutComponent(
-  component: string[],
-  graph: ParsedGraph,
-  adjacency: Map<string, Set<string>>,
-  seedRotation: number,
-): PositionMap {
-  const positions: PositionMap = new Map();
-  const degreeRecord = new Map<string, number>();
-  component.forEach((id) => degreeRecord.set(id, adjacency.get(id)?.size || 0));
-
-  const seed = resolveSeed(component, graph, degreeRecord);
-  const { groups } = resolveLevels(component, seed, adjacency);
-
-  positions.set(seed, { x: 0, y: 0 });
-  for (let level = 1; level < groups.length; level += 1) {
-    const levelNodes = groups[level];
-    if (!levelNodes.length) continue;
-    const radius = Math.max(BASE_RADIUS + level * LEVEL_GAP, (levelNodes.length * MIN_NODE_DIST) / (Math.PI * 2));
-    const startAngle = -Math.PI / 2 + seedRotation;
-    levelNodes.forEach((id, index) => {
-      const angle = startAngle + (Math.PI * 2 * index) / levelNodes.length;
-      positions.set(id, { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius });
-    });
+const getRelaxMaxIterations = (itemCount: number) => (itemCount > 220 ? 36 : itemCount > 120 ? 72 : 120);
+
+const relaxPositions = (
+  positions: PositionMap,
+  payload: AnalysisLayoutWorkerPayload,
+  fixedIds: Set<string>,
+): PositionMap => {
+  const items = Array.from(positions.entries()).map(([id, position]) => ({ id, ...position }));
+  if (items.length > ANALYSIS_LAYOUT_RELAX_MAX_NODES) return positions;
+
+  const nodeSize = Math.max(payload.nodeWidth, payload.nodeHeight);
+  const boxWidth = nodeSize + ANALYSIS_LAYOUT_NODE_GAP_X;
+  const boxHeight = nodeSize + ANALYSIS_LAYOUT_NODE_GAP_Y;
+  const bounds = getRelaxBounds(items, payload);
+  const maxIterations = getRelaxMaxIterations(items.length);
+
+  for (let iteration = 0; iteration < maxIterations; iteration += 1) {
+    if (!relaxPositionIteration(items, fixedIds, bounds, boxWidth, boxHeight)) break;
   }
+  return new Map(items.map((item) => [item.id, { x: item.x, y: item.y }]));
+};
 
-  const iterations = component.length <= 260 ? 36 : component.length <= 520 ? 12 : 0;
-  relaxPositions(component, positions, seed, iterations);
-  return positions;
-}
+/* ------------------------------------------------------------------ *
+ * 布局输入组装:与对象关系分析页 buildRelationLayoutPayload 同口径
+ * ------------------------------------------------------------------ */
 
-function getBounds(positions: PositionMap): { minX: number; minY: number; maxX: number; maxY: number } {
-  let minX = Number.POSITIVE_INFINITY;
-  let minY = Number.POSITIVE_INFINITY;
-  let maxX = Number.NEGATIVE_INFINITY;
-  let maxY = Number.NEGATIVE_INFINITY;
-  positions.forEach((position) => {
-    minX = Math.min(minX, position.x);
-    minY = Math.min(minY, position.y);
-    maxX = Math.max(maxX, position.x);
-    maxY = Math.max(maxY, position.y);
+const resolveSeedIds = (graph: ParsedGraph, degreeRecord: Record<string, number>) => {
+  // 对象关系分析页优先取路由受访人;AI 图谱没有路由受访人,退化为
+  // 「人员优先、度数最高、姓名排序」的第一个节点,与原页排序规则一致。
+  const sorted = [...graph.nodes].sort((left, right) => {
+    const personDiff = Number(right.kind === 'person') - Number(left.kind === 'person');
+    if (personDiff !== 0) return personDiff;
+    const degreeDiff = (degreeRecord[String(right.id)] || 0) - (degreeRecord[String(left.id)] || 0);
+    if (degreeDiff !== 0) return degreeDiff;
+    return String(left.label || '').localeCompare(String(right.label || ''), 'zh-CN');
   });
-  if (!Number.isFinite(minX)) return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
-  return { minX, minY, maxX, maxY };
-}
+  return sorted[0]?.id ? [String(sorted[0].id)] : [];
+};
 
-/**
- * 计算整张图的节点坐标。
- *
- * @param graph 解析后的模型
- * @param size  画布尺寸(用于把结果居中到视口)
- */
-export function layoutGraph(graph: ParsedGraph, size: LayoutSize): PositionMap {
-  const result: PositionMap = new Map();
-  if (!graph.nodes.length) return result;
-
-  const adjacency = buildAdjacency(graph);
-  const components = buildComponents(graph, adjacency);
-  const layouts = components.map((component, index) => layoutComponent(component, graph, adjacency, index * 0.37));
-
-  if (layouts.length === 1) {
-    const positions = layouts[0];
-    const bounds = getBounds(positions);
-    const centerX = size.width / 2;
-    const centerY = size.height / 2;
-    const offsetX = centerX - (bounds.minX + bounds.maxX) / 2;
-    const offsetY = centerY - (bounds.minY + bounds.maxY) / 2;
-    positions.forEach((position, id) => {
-      result.set(id, { x: position.x + offsetX, y: position.y + offsetY });
+const buildLevelMap = (graph: ParsedGraph, edges: AnalysisLayoutWorkerPayload['edges'], seedIds: string[]) => {
+  const adjacency = new Map<string, string[]>();
+  graph.nodes.forEach((node) => adjacency.set(String(node.id), []));
+  edges.forEach((edge) => {
+    adjacency.get(edge.source)?.push(edge.target);
+    adjacency.get(edge.target)?.push(edge.source);
+  });
+
+  const levelMap = new Map<string, number>();
+  const queue = [...seedIds];
+  seedIds.forEach((seedId) => levelMap.set(seedId, 0));
+  while (queue.length) {
+    const currentId = queue.shift() || '';
+    const currentLevel = levelMap.get(currentId) ?? 0;
+    (adjacency.get(currentId) || []).forEach((nextId) => {
+      if (levelMap.has(nextId)) return;
+      levelMap.set(nextId, currentLevel + 1);
+      queue.push(nextId);
     });
-    return result;
   }
-
-  // 多分量:按网格装箱,再整体居中
-  const boxes = layouts.map((positions) => {
-    const bounds = getBounds(positions);
-    return {
-      positions,
-      width: Math.max(120, bounds.maxX - bounds.minX),
-      height: Math.max(120, bounds.maxY - bounds.minY),
-      centerX: (bounds.minX + bounds.maxX) / 2,
-      centerY: (bounds.minY + bounds.maxY) / 2,
-    };
+  graph.nodes.forEach((node) => {
+    const nodeId = String(node.id);
+    if (!levelMap.has(nodeId)) levelMap.set(nodeId, 99);
   });
-  const columns = Math.max(1, Math.ceil(Math.sqrt(boxes.length)));
-  const cellWidth = Math.max(...boxes.map((box) => box.width)) + COMPONENT_GAP;
-  const cellHeight = Math.max(...boxes.map((box) => box.height)) + COMPONENT_GAP;
-  const rows = Math.ceil(boxes.length / columns);
-  const totalWidth = columns * cellWidth;
-  const totalHeight = rows * cellHeight;
-  const originX = size.width / 2 - totalWidth / 2;
-  const originY = size.height / 2 - totalHeight / 2;
-
-  boxes.forEach((box, index) => {
-    const column = index % columns;
-    const row = Math.floor(index / columns);
-    const cellCenterX = originX + column * cellWidth + cellWidth / 2;
-    const cellCenterY = originY + row * cellHeight + cellHeight / 2;
-    const offsetX = cellCenterX - box.centerX;
-    const offsetY = cellCenterY - box.centerY;
-    box.positions.forEach((position, id) => {
-      result.set(id, { x: position.x + offsetX, y: position.y + offsetY });
-    });
+  return levelMap;
+};
+
+const buildLayoutPayload = (graph: ParsedGraph, size: LayoutSize): AnalysisLayoutWorkerPayload => {
+  const width = Math.max(320, size.width);
+  const height = Math.max(240, size.height);
+  const centerX = width / 2;
+  const centerY = height / 2;
+  const nodes = graph.nodes.filter((node) => node.id !== undefined && node.id !== null);
+  const nodeIdSet = new Set(nodes.map((node) => String(node.id)));
+  const edges = graph.edges
+    .map((edge) => ({ source: String(edge.source || ''), target: String(edge.target || '') }))
+    .filter((edge) => edge.source && edge.target && edge.source !== edge.target)
+    .filter((edge) => nodeIdSet.has(edge.source) && nodeIdSet.has(edge.target));
+
+  const degreeRecord = nodes.reduce<Record<string, number>>((accumulator, node) => {
+    accumulator[String(node.id)] = 0;
+    return accumulator;
+  }, {});
+  edges.forEach((edge) => {
+    degreeRecord[edge.source] = (degreeRecord[edge.source] || 0) + 1;
+    degreeRecord[edge.target] = (degreeRecord[edge.target] || 0) + 1;
   });
 
-  return result;
+  const seedIds = resolveSeedIds(graph, degreeRecord);
+  const seedIdSet = new Set(seedIds);
+  const levelMap = buildLevelMap(graph, edges, seedIds);
+
+  return {
+    width,
+    height,
+    center: [centerX, centerY],
+    nodeWidth: ANALYSIS_LAYOUT_NODE_SIZE,
+    nodeHeight: ANALYSIS_LAYOUT_NODE_SIZE,
+    layoutPreset: 'mingjing-g6',
+    degreeRecord,
+    nodes: nodes.map((node) => {
+      const nodeId = String(node.id);
+      return {
+        id: nodeId,
+        kind: node.kind || 'unknown',
+        degree: degreeRecord[nodeId] || 0,
+        isSeed: seedIdSet.has(nodeId),
+        level: levelMap.get(nodeId) ?? 99,
+        x: centerX,
+        y: centerY,
+        anchorX: centerX,
+        anchorY: centerY,
+      };
+    }),
+    edges,
+  };
+};
+
+const mapPositions = (positions: AnalysisLayoutWorkerPosition[]): PositionMap =>
+  new Map(positions.map((item) => [item.id, { x: item.x, y: item.y }]));
+
+/**
+ * 与对象关系分析页一致的布局入口:
+ * 1. 先用已有的力导向布局;
+ * 2. 力导向结果完整时采用,否则使用已有确定性布局;
+ * 3. 最后做与对象关系分析页相同的重叠松弛。
+ */
+export async function layoutGraph(graph: ParsedGraph, size: LayoutSize): Promise<PositionMap> {
+  if (!graph.nodes.length) return new Map();
+  const payload = buildLayoutPayload(graph, size);
+  const fallback = computeAnalysisLayout(payload);
+  const fallbackPositions = mapPositions(fallback.positions);
+  let positions = fallbackPositions;
+
+  if (payload.nodes.length <= ANALYSIS_LAYOUT_FORCE_WORKER_MAX_NODES) {
+    try {
+      const legacyPositions = await runLegacyForceLayout(payload);
+      if (legacyPositions.length >= payload.nodes.length) {
+        positions = mapPositions(legacyPositions);
+      }
+    } catch (error) {
+      console.warn('AI 关系图谱力导向布局失败,使用已有确定性布局', error);
+    }
+  }
+
+  return relaxPositions(positions, payload, new Set(fallback.fixedIds));
 }