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