mirror of
https://github.com/Egonex-AI/Understand-Anything.git
synced 2026-10-07 16:48:09 +08:00
Merge pull request #576 from KumamuKuma/fix/dashboard-layout-worker-491
fix(dashboard): move force layout off the main thread
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "understand-anything",
|
||||
"description": "AI-powered codebase understanding — analyze, visualize, and explain any project",
|
||||
"version": "2.9.2",
|
||||
"version": "2.9.3",
|
||||
"author": {
|
||||
"name": "Egonex"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "understand-anything",
|
||||
"description": "AI-powered codebase understanding — analyze, visualize, and explain any project",
|
||||
"version": "2.9.2",
|
||||
"version": "2.9.3",
|
||||
"author": {
|
||||
"name": "Egonex"
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "understand-anything",
|
||||
"displayName": "Understand Anything",
|
||||
"description": "AI-powered codebase understanding — analyze, visualize, and explain any project",
|
||||
"version": "2.9.2",
|
||||
"version": "2.9.3",
|
||||
"author": {
|
||||
"name": "Egonex"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "understand-anything",
|
||||
"description": "AI-powered codebase understanding — analyze, visualize, and explain any project",
|
||||
"version": "2.9.2",
|
||||
"version": "2.9.3",
|
||||
"author": {
|
||||
"name": "Egonex"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@understand-anything/skill",
|
||||
"version": "2.9.2",
|
||||
"version": "2.9.3",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
+172
-44
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useCallback } from "react";
|
||||
import { useMemo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
@@ -13,13 +13,27 @@ import "@xyflow/react/dist/style.css";
|
||||
import CustomNode from "./CustomNode";
|
||||
import type { CustomNodeData } from "./CustomNode";
|
||||
import { useDashboardStore } from "../store";
|
||||
import { applyForceLayout, NODE_WIDTH, NODE_HEIGHT } from "../utils/layout";
|
||||
import { NODE_WIDTH, NODE_HEIGHT } from "../utils/layout";
|
||||
import {
|
||||
createFallbackGrid,
|
||||
ForceLayoutCancelledError,
|
||||
startForceLayoutTask,
|
||||
} from "../utils/force-layout-client";
|
||||
import type {
|
||||
ForceLayoutEdge,
|
||||
ForceLayoutNode,
|
||||
ForceLayoutPosition,
|
||||
} from "../utils/force-layout";
|
||||
import { useI18n } from "../contexts/I18nContext";
|
||||
import type { KnowledgeGraph } from "@understand-anything/core/types";
|
||||
|
||||
const nodeTypes = {
|
||||
custom: CustomNode,
|
||||
};
|
||||
|
||||
const EMPTY_POSITION_MAP = new Map<string, { x: number; y: number }>();
|
||||
const EMPTY_EDGE_COUNTS = new Map<string, number>();
|
||||
|
||||
/** Edge style presets by knowledge edge type. */
|
||||
const EDGE_STYLES: Record<string, React.CSSProperties> = {
|
||||
related: { stroke: "var(--color-border-medium)", strokeWidth: 0.5, opacity: 0.12 },
|
||||
@@ -44,13 +58,29 @@ function getNodeDimensions(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the stable layout (positions) from graph topology.
|
||||
* This only re-runs when the graph data or filters change, NOT on selection/search.
|
||||
*/
|
||||
function computeLayout(
|
||||
graph: KnowledgeGraph,
|
||||
): { positionMap: Map<string, { x: number; y: number }>; edgeCounts: Map<string, number>; communityMap: Map<string, number> } {
|
||||
interface PreparedLayout {
|
||||
graph: KnowledgeGraph;
|
||||
nodes: ForceLayoutNode[];
|
||||
edges: ForceLayoutEdge[];
|
||||
edgeCounts: Map<string, number>;
|
||||
}
|
||||
|
||||
interface LayoutState {
|
||||
graph: KnowledgeGraph | null;
|
||||
status: "loading" | "ready";
|
||||
positionMap: Map<string, { x: number; y: number }>;
|
||||
edgeCounts: Map<string, number>;
|
||||
hasWarning?: boolean;
|
||||
}
|
||||
|
||||
function positionsToMap(
|
||||
positions: ForceLayoutPosition[],
|
||||
): Map<string, { x: number; y: number }> {
|
||||
return new Map(positions.map(({ id, x, y }) => [id, { x, y }]));
|
||||
}
|
||||
|
||||
/** Prepare lightweight, structured-clone-safe input for the layout worker. */
|
||||
function prepareLayout(graph: KnowledgeGraph): PreparedLayout {
|
||||
const edgeCounts = new Map<string, number>();
|
||||
for (const edge of graph.edges) {
|
||||
edgeCounts.set(edge.source, (edgeCounts.get(edge.source) ?? 0) + 1);
|
||||
@@ -64,33 +94,26 @@ function computeLayout(
|
||||
}
|
||||
});
|
||||
|
||||
const dims = new Map<string, { width: number; height: number }>();
|
||||
for (const node of graph.nodes) {
|
||||
dims.set(node.id, getNodeDimensions(edgeCounts.get(node.id) ?? 0));
|
||||
}
|
||||
return {
|
||||
graph,
|
||||
edgeCounts,
|
||||
nodes: graph.nodes.map((node) => ({
|
||||
id: node.id,
|
||||
...getNodeDimensions(edgeCounts.get(node.id) ?? 0),
|
||||
community: communityMap.get(node.id),
|
||||
})),
|
||||
edges: graph.edges.map((edge) => ({
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// Build temporary nodes/edges for layout computation only
|
||||
const tmpNodes: Node[] = graph.nodes.map((node) => ({
|
||||
id: node.id,
|
||||
type: "custom" as const,
|
||||
position: { x: 0, y: 0 },
|
||||
data: {},
|
||||
}));
|
||||
|
||||
const tmpEdges: Edge[] = graph.edges.map((e, i) => ({
|
||||
id: `ke-${i}`,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
}));
|
||||
|
||||
const { nodes: layoutedNodes } = applyForceLayout(tmpNodes, tmpEdges, dims, communityMap);
|
||||
|
||||
const positionMap = new Map<string, { x: number; y: number }>();
|
||||
for (const n of layoutedNodes) {
|
||||
positionMap.set(n.id, n.position);
|
||||
}
|
||||
|
||||
return { positionMap, edgeCounts, communityMap };
|
||||
function createForceLayoutWorker(): Worker {
|
||||
return new Worker(
|
||||
new URL("../utils/force-layout.worker.ts", import.meta.url),
|
||||
{ type: "module" },
|
||||
);
|
||||
}
|
||||
|
||||
function KnowledgeGraphViewInner() {
|
||||
@@ -100,7 +123,17 @@ function KnowledgeGraphViewInner() {
|
||||
const selectNode = useDashboardStore((s) => s.selectNode);
|
||||
const searchResultsRaw = useDashboardStore((s) => s.searchResults);
|
||||
const tourHighlightedNodeIds = useDashboardStore((s) => s.tourHighlightedNodeIds);
|
||||
const nodeTypeFilters = useDashboardStore((s) => s.nodeTypeFilters);
|
||||
const knowledgeNodesVisible = useDashboardStore(
|
||||
(s) => s.nodeTypeFilters.knowledge !== false,
|
||||
);
|
||||
const { t } = useI18n();
|
||||
const nextLayoutRequestId = useRef(0);
|
||||
const [layoutState, setLayoutState] = useState<LayoutState>({
|
||||
graph: null,
|
||||
status: "loading",
|
||||
positionMap: EMPTY_POSITION_MAP,
|
||||
edgeCounts: EMPTY_EDGE_COUNTS,
|
||||
});
|
||||
|
||||
const onNodeClick = useCallback(
|
||||
(nodeId: string) => selectNode(nodeId),
|
||||
@@ -123,7 +156,7 @@ function KnowledgeGraphViewInner() {
|
||||
|
||||
const filteredNodes = graph.nodes.filter((n) => {
|
||||
if (["article", "entity", "topic", "claim", "source"].includes(n.type)) {
|
||||
return nodeTypeFilters.knowledge !== false;
|
||||
return knowledgeNodesVisible;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
@@ -134,17 +167,92 @@ function KnowledgeGraphViewInner() {
|
||||
);
|
||||
|
||||
return { ...graph, nodes: filteredNodes, edges: filteredEdges };
|
||||
}, [graph, nodeTypeFilters]);
|
||||
}, [graph, knowledgeNodesVisible]);
|
||||
|
||||
// Compute layout ONCE per graph/filter change — stable positions
|
||||
const { positionMap, edgeCounts } = useMemo(() => {
|
||||
if (!filteredGraph) return { positionMap: new Map(), edgeCounts: new Map() };
|
||||
return computeLayout(filteredGraph);
|
||||
const preparedLayout = useMemo(() => {
|
||||
if (!filteredGraph) return null;
|
||||
return prepareLayout(filteredGraph);
|
||||
}, [filteredGraph]);
|
||||
|
||||
// Run d3-force outside the main thread. A fresh worker per graph revision
|
||||
// lets cleanup terminate an in-progress synchronous simulation immediately.
|
||||
useEffect(() => {
|
||||
if (!preparedLayout) {
|
||||
setLayoutState({
|
||||
graph: null,
|
||||
status: "loading",
|
||||
positionMap: EMPTY_POSITION_MAP,
|
||||
edgeCounts: EMPTY_EDGE_COUNTS,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { graph: layoutGraph, nodes: layoutNodes, edges: layoutEdges, edgeCounts } =
|
||||
preparedLayout;
|
||||
|
||||
if (layoutNodes.length === 0) {
|
||||
setLayoutState({
|
||||
graph: layoutGraph,
|
||||
status: "ready",
|
||||
positionMap: EMPTY_POSITION_MAP,
|
||||
edgeCounts,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = ++nextLayoutRequestId.current;
|
||||
let active = true;
|
||||
setLayoutState({
|
||||
graph: layoutGraph,
|
||||
status: "loading",
|
||||
positionMap: EMPTY_POSITION_MAP,
|
||||
edgeCounts,
|
||||
});
|
||||
|
||||
const task = startForceLayoutTask(
|
||||
{ requestId, nodes: layoutNodes, edges: layoutEdges },
|
||||
createForceLayoutWorker,
|
||||
);
|
||||
|
||||
void task.promise
|
||||
.then((positions) => {
|
||||
if (!active || requestId !== nextLayoutRequestId.current) return;
|
||||
setLayoutState({
|
||||
graph: layoutGraph,
|
||||
status: "ready",
|
||||
positionMap: positionsToMap(positions),
|
||||
edgeCounts,
|
||||
});
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!active || error instanceof ForceLayoutCancelledError) return;
|
||||
setLayoutState({
|
||||
graph: layoutGraph,
|
||||
status: "ready",
|
||||
positionMap: positionsToMap(createFallbackGrid(layoutNodes)),
|
||||
edgeCounts,
|
||||
hasWarning: true,
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
task.cancel();
|
||||
};
|
||||
}, [preparedLayout]);
|
||||
|
||||
const layoutIsReady =
|
||||
!!filteredGraph &&
|
||||
layoutState.graph === filteredGraph &&
|
||||
layoutState.status === "ready";
|
||||
const positionMap = layoutIsReady
|
||||
? layoutState.positionMap
|
||||
: EMPTY_POSITION_MAP;
|
||||
const edgeCounts = layoutIsReady ? layoutState.edgeCounts : EMPTY_EDGE_COUNTS;
|
||||
|
||||
// Build visual nodes/edges — recomputes on selection/search/tour WITHOUT re-layout
|
||||
const { nodes, edges } = useMemo(() => {
|
||||
if (!filteredGraph) return { nodes: [], edges: [] };
|
||||
if (!filteredGraph || !layoutIsReady) return { nodes: [], edges: [] };
|
||||
|
||||
const neighborIds = new Set<string>();
|
||||
if (focusNodeId || selectedNodeId) {
|
||||
@@ -233,7 +341,7 @@ function KnowledgeGraphViewInner() {
|
||||
});
|
||||
|
||||
return { nodes: rfNodes, edges: rfEdges };
|
||||
}, [filteredGraph, selectedNodeId, focusNodeId, searchResults, tourSet, onNodeClick, positionMap, edgeCounts]);
|
||||
}, [filteredGraph, layoutIsReady, selectedNodeId, focusNodeId, searchResults, tourSet, onNodeClick, positionMap, edgeCounts]);
|
||||
|
||||
if (!graph) {
|
||||
return (
|
||||
@@ -243,8 +351,28 @@ function KnowledgeGraphViewInner() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!layoutIsReady) {
|
||||
return (
|
||||
<div
|
||||
className="h-full flex items-center justify-center text-text-muted text-sm"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{t.common.computingGraphLayout}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full relative">
|
||||
{layoutState.hasWarning && (
|
||||
<div
|
||||
className="absolute top-3 left-1/2 -translate-x-1/2 z-10 rounded-md border border-border-medium bg-surface/95 px-3 py-2 text-xs text-text-muted shadow-lg"
|
||||
role="status"
|
||||
>
|
||||
{t.common.forceLayoutFallback}
|
||||
</div>
|
||||
)}
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export const en = {
|
||||
common: {
|
||||
loading: "Loading project...",
|
||||
computingGraphLayout: "Computing graph layout...",
|
||||
forceLayoutFallback: "Force layout unavailable; showing a fallback grid.",
|
||||
noGraphLoaded: "No graph loaded",
|
||||
selectNode: "Select a node to see details",
|
||||
back: "Back",
|
||||
@@ -305,4 +307,4 @@ export const en = {
|
||||
},
|
||||
};
|
||||
|
||||
export default en;
|
||||
export default en;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export const ja = {
|
||||
common: {
|
||||
loading: "プロジェクトを読み込み中...",
|
||||
computingGraphLayout: "グラフのレイアウトを計算しています...",
|
||||
forceLayoutFallback: "フォースレイアウトを使用できないため、代替グリッドを表示しています。",
|
||||
noGraphLoaded: "知識グラフが読み込まれていません",
|
||||
selectNode: "ノードを選択して詳細を表示",
|
||||
back: "戻る",
|
||||
@@ -305,4 +307,4 @@ export const ja = {
|
||||
},
|
||||
};
|
||||
|
||||
export default ja;
|
||||
export default ja;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export const ko = {
|
||||
common: {
|
||||
loading: "프로젝트 로딩 중...",
|
||||
computingGraphLayout: "그래프 레이아웃을 계산하는 중...",
|
||||
forceLayoutFallback: "포스 레이아웃을 사용할 수 없어 대체 그리드를 표시합니다.",
|
||||
noGraphLoaded: "지식 그래프가 로드되지 않음",
|
||||
selectNode: "노드를 선택하여 상세 정보 확인",
|
||||
back: "뒤로",
|
||||
@@ -305,4 +307,4 @@ edgeLabels: {
|
||||
},
|
||||
};
|
||||
|
||||
export default ko;
|
||||
export default ko;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export const ru = {
|
||||
common: {
|
||||
loading: "Загрузка проекта...",
|
||||
computingGraphLayout: "Вычисление расположения графа...",
|
||||
forceLayoutFallback: "Силовая раскладка недоступна; показана резервная сетка.",
|
||||
noGraphLoaded: "Граф знаний не загружен",
|
||||
selectNode: "Выберите узел, чтобы увидеть подробности",
|
||||
back: "Назад",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export const zhTW = {
|
||||
common: {
|
||||
loading: "載入專案...",
|
||||
computingGraphLayout: "正在計算圖形配置...",
|
||||
forceLayoutFallback: "力導向配置無法使用,正在顯示備用網格。",
|
||||
noGraphLoaded: "未載入知識圖谱",
|
||||
selectNode: "選擇節點查看詳情",
|
||||
back: "返回",
|
||||
@@ -305,4 +307,4 @@ export const zhTW = {
|
||||
},
|
||||
};
|
||||
|
||||
export default zhTW;
|
||||
export default zhTW;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export const zh = {
|
||||
common: {
|
||||
loading: "加载项目...",
|
||||
computingGraphLayout: "正在计算图布局...",
|
||||
forceLayoutFallback: "力导向布局不可用,正在显示备用网格。",
|
||||
noGraphLoaded: "未加载知识图谱",
|
||||
selectNode: "选择节点查看详情",
|
||||
back: "返回",
|
||||
@@ -305,4 +307,4 @@ export const zh = {
|
||||
},
|
||||
};
|
||||
|
||||
export default zh;
|
||||
export default zh;
|
||||
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
ForceLayoutRequest,
|
||||
ForceLayoutResponse,
|
||||
} from "../force-layout";
|
||||
import {
|
||||
ForceLayoutCancelledError,
|
||||
startForceLayoutTask,
|
||||
type ForceLayoutWorker,
|
||||
} from "../force-layout-client";
|
||||
|
||||
function makeWorker() {
|
||||
const worker = {
|
||||
onmessage: null,
|
||||
onmessageerror: null,
|
||||
onerror: null,
|
||||
postMessage: vi.fn(),
|
||||
terminate: vi.fn(),
|
||||
} as ForceLayoutWorker;
|
||||
return worker;
|
||||
}
|
||||
|
||||
function emitMessage(worker: ForceLayoutWorker, data: ForceLayoutResponse) {
|
||||
worker.onmessage?.call(
|
||||
worker as unknown as Worker,
|
||||
{ data } as MessageEvent<ForceLayoutResponse>,
|
||||
);
|
||||
}
|
||||
|
||||
const request: ForceLayoutRequest = {
|
||||
requestId: 491,
|
||||
nodes: [{ id: "node", width: 280, height: 120 }],
|
||||
edges: [],
|
||||
};
|
||||
|
||||
describe("startForceLayoutTask", () => {
|
||||
it("posts the request, resolves the matching response, and terminates", async () => {
|
||||
const worker = makeWorker();
|
||||
const task = startForceLayoutTask(request, () => worker);
|
||||
const positions = [{ id: "node", x: 10, y: 20 }];
|
||||
|
||||
expect(worker.postMessage).toHaveBeenCalledWith(request);
|
||||
emitMessage(worker, { requestId: request.requestId, positions });
|
||||
|
||||
await expect(task.promise).resolves.toEqual(positions);
|
||||
expect(worker.terminate).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects a response belonging to another request", async () => {
|
||||
const worker = makeWorker();
|
||||
const task = startForceLayoutTask(request, () => worker);
|
||||
|
||||
emitMessage(worker, { requestId: request.requestId - 1, positions: [] });
|
||||
|
||||
await expect(task.promise).rejects.toThrow(
|
||||
`returned request ${request.requestId - 1}; expected ${request.requestId}`,
|
||||
);
|
||||
expect(worker.terminate).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("terminates and rejects when cancelled", async () => {
|
||||
const worker = makeWorker();
|
||||
const task = startForceLayoutTask(request, () => worker);
|
||||
const rejection = expect(task.promise).rejects.toBeInstanceOf(
|
||||
ForceLayoutCancelledError,
|
||||
);
|
||||
|
||||
task.cancel();
|
||||
|
||||
await rejection;
|
||||
expect(worker.terminate).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects an error response from the worker", async () => {
|
||||
const worker = makeWorker();
|
||||
const task = startForceLayoutTask(request, () => worker);
|
||||
|
||||
emitMessage(worker, {
|
||||
requestId: request.requestId,
|
||||
error: "simulation exploded",
|
||||
});
|
||||
|
||||
await expect(task.promise).rejects.toThrow(
|
||||
"Force layout worker failed: simulation exploded",
|
||||
);
|
||||
expect(worker.terminate).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects when the Worker cannot be created", async () => {
|
||||
const task = startForceLayoutTask(request, () => {
|
||||
throw new Error("Workers are blocked by policy");
|
||||
});
|
||||
|
||||
await expect(task.promise).rejects.toThrow("Workers are blocked by policy");
|
||||
expect(() => task.cancel()).not.toThrow();
|
||||
});
|
||||
});
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeForceLayout, type ForceLayoutNode } from "../force-layout";
|
||||
import { createFallbackGrid } from "../force-layout-client";
|
||||
|
||||
const nodes: ForceLayoutNode[] = [
|
||||
{ id: "alpha", width: 280, height: 120, community: 0 },
|
||||
{ id: "__proto__", width: 300, height: 130, community: 0 },
|
||||
{ id: "gamma", width: 260, height: 110, community: 1 },
|
||||
];
|
||||
|
||||
describe("computeForceLayout", () => {
|
||||
it("returns no positions for an empty graph", () => {
|
||||
expect(computeForceLayout([], [])).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns one finite position per node and ignores dangling edges", () => {
|
||||
const positions = computeForceLayout(nodes, [
|
||||
{ source: "alpha", target: "__proto__" },
|
||||
{ source: "missing", target: "gamma" },
|
||||
]);
|
||||
|
||||
expect(positions.map((position) => position.id)).toEqual(
|
||||
nodes.map((node) => node.id),
|
||||
);
|
||||
for (const position of positions) {
|
||||
expect(Number.isFinite(position.x)).toBe(true);
|
||||
expect(Number.isFinite(position.y)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("is deterministic for identical ordered inputs", () => {
|
||||
const edges = [
|
||||
{ source: "alpha", target: "__proto__" },
|
||||
{ source: "__proto__", target: "gamma" },
|
||||
];
|
||||
|
||||
expect(computeForceLayout(nodes, edges)).toEqual(
|
||||
computeForceLayout(nodes, edges),
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes sparse community IDs before assigning cluster angles", () => {
|
||||
const denseNodes: ForceLayoutNode[] = Array.from({ length: 80 }, (_, index) => ({
|
||||
id: `node-${index}`,
|
||||
width: 80,
|
||||
height: 40,
|
||||
community: index % 2,
|
||||
}));
|
||||
const denseLayout = computeForceLayout(denseNodes, []);
|
||||
|
||||
for (const [firstCommunity, secondCommunity] of [
|
||||
[0, 2],
|
||||
[10, 20],
|
||||
] as const) {
|
||||
const sparseNodes = denseNodes.map((node, index) => ({
|
||||
...node,
|
||||
community: index % 2 === 0 ? firstCommunity : secondCommunity,
|
||||
}));
|
||||
|
||||
expect(computeForceLayout(sparseNodes, [])).toEqual(denseLayout);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createFallbackGrid", () => {
|
||||
it("places every node on a finite, deterministic grid", () => {
|
||||
const first = createFallbackGrid(nodes);
|
||||
const second = createFallbackGrid(nodes);
|
||||
|
||||
expect(first).toEqual(second);
|
||||
expect(first.map((position) => position.id)).toEqual(
|
||||
nodes.map((node) => node.id),
|
||||
);
|
||||
expect(new Set(first.map(({ x, y }) => `${x}:${y}`)).size).toBe(nodes.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import type {
|
||||
ForceLayoutNode,
|
||||
ForceLayoutPosition,
|
||||
ForceLayoutRequest,
|
||||
ForceLayoutResponse,
|
||||
} from "./force-layout";
|
||||
|
||||
export type ForceLayoutWorker = Pick<
|
||||
Worker,
|
||||
"onmessage" | "onmessageerror" | "onerror" | "postMessage" | "terminate"
|
||||
>;
|
||||
|
||||
export class ForceLayoutCancelledError extends Error {
|
||||
constructor() {
|
||||
super("Force layout request was cancelled");
|
||||
this.name = "ForceLayoutCancelledError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface ForceLayoutTask {
|
||||
promise: Promise<ForceLayoutPosition[]>;
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manage one worker request. Each graph revision gets a fresh worker so
|
||||
* cancellation can interrupt a long-running synchronous simulation rather
|
||||
* than merely queueing behind it.
|
||||
*/
|
||||
export function startForceLayoutTask(
|
||||
request: ForceLayoutRequest,
|
||||
createWorker: () => ForceLayoutWorker,
|
||||
): ForceLayoutTask {
|
||||
let worker: ForceLayoutWorker;
|
||||
try {
|
||||
worker = createWorker();
|
||||
} catch (error) {
|
||||
return {
|
||||
promise: Promise.reject(
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
),
|
||||
cancel: () => {},
|
||||
};
|
||||
}
|
||||
let settled = false;
|
||||
let rejectTask: ((reason: unknown) => void) | undefined;
|
||||
|
||||
const cleanup = () => {
|
||||
worker.onmessage = null;
|
||||
worker.onmessageerror = null;
|
||||
worker.onerror = null;
|
||||
worker.terminate();
|
||||
};
|
||||
|
||||
const promise = new Promise<ForceLayoutPosition[]>((resolve, reject) => {
|
||||
rejectTask = reject;
|
||||
|
||||
const fail = (error: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
|
||||
worker.onmessage = (event: MessageEvent<ForceLayoutResponse>) => {
|
||||
const response = event.data;
|
||||
if (settled) return;
|
||||
if (response.requestId !== request.requestId) {
|
||||
fail(
|
||||
new Error(
|
||||
`Force layout worker returned request ${response.requestId}; expected ${request.requestId}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if ("error" in response) {
|
||||
fail(new Error(`Force layout worker failed: ${response.error}`));
|
||||
return;
|
||||
}
|
||||
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(response.positions);
|
||||
};
|
||||
|
||||
worker.onmessageerror = () => {
|
||||
fail(new Error("Force layout worker returned an unreadable response"));
|
||||
};
|
||||
|
||||
worker.onerror = (event: ErrorEvent) => {
|
||||
event.preventDefault();
|
||||
fail(new Error(event.message || "Force layout worker failed"));
|
||||
};
|
||||
|
||||
try {
|
||||
worker.postMessage(request);
|
||||
} catch (error) {
|
||||
fail(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
promise,
|
||||
cancel: () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
rejectTask?.(new ForceLayoutCancelledError());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Cheap, non-blocking fallback used only if Worker startup/execution fails. */
|
||||
export function createFallbackGrid(
|
||||
nodes: ForceLayoutNode[],
|
||||
): ForceLayoutPosition[] {
|
||||
if (nodes.length === 0) return [];
|
||||
|
||||
const columns = Math.max(1, Math.ceil(Math.sqrt(nodes.length)));
|
||||
const cellWidth = nodes.reduce(
|
||||
(maximum, node) => Math.max(maximum, node.width),
|
||||
0,
|
||||
) + 40;
|
||||
const cellHeight = nodes.reduce(
|
||||
(maximum, node) => Math.max(maximum, node.height),
|
||||
0,
|
||||
) + 40;
|
||||
|
||||
return nodes.map((node, index) => ({
|
||||
id: node.id,
|
||||
x: (index % columns) * cellWidth,
|
||||
y: Math.floor(index / columns) * cellHeight,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
forceCenter,
|
||||
forceCollide,
|
||||
forceLink,
|
||||
forceManyBody,
|
||||
forceSimulation,
|
||||
forceX,
|
||||
forceY,
|
||||
} from "d3-force";
|
||||
import type { SimulationLinkDatum, SimulationNodeDatum } from "d3-force";
|
||||
|
||||
export interface ForceLayoutNode {
|
||||
id: string;
|
||||
width: number;
|
||||
height: number;
|
||||
community?: number;
|
||||
}
|
||||
|
||||
export interface ForceLayoutEdge {
|
||||
source: string;
|
||||
target: string;
|
||||
}
|
||||
|
||||
export interface ForceLayoutPosition {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface ForceLayoutRequest {
|
||||
requestId: number;
|
||||
nodes: ForceLayoutNode[];
|
||||
edges: ForceLayoutEdge[];
|
||||
}
|
||||
|
||||
export interface ForceLayoutSuccess {
|
||||
requestId: number;
|
||||
positions: ForceLayoutPosition[];
|
||||
}
|
||||
|
||||
export interface ForceLayoutFailure {
|
||||
requestId: number;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export type ForceLayoutResponse = ForceLayoutSuccess | ForceLayoutFailure;
|
||||
|
||||
interface SimulationForceNode extends SimulationNodeDatum {
|
||||
id: string;
|
||||
width: number;
|
||||
height: number;
|
||||
community?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the expensive d3-force simulation. This function is called by the
|
||||
* dedicated layout worker; keeping it DOM-free also makes the algorithm easy
|
||||
* to verify in Node-based unit tests.
|
||||
*/
|
||||
export function computeForceLayout(
|
||||
nodes: ForceLayoutNode[],
|
||||
edges: ForceLayoutEdge[],
|
||||
): ForceLayoutPosition[] {
|
||||
if (nodes.length === 0) return [];
|
||||
|
||||
// Leaving x/y unset lets d3-force use its deterministic phyllotaxis seed.
|
||||
// This keeps layouts stable for identical, ordered graph inputs.
|
||||
const simulationNodes: SimulationForceNode[] = nodes.map((node) => ({ ...node }));
|
||||
const nodeIds = new Set(simulationNodes.map((node) => node.id));
|
||||
const simulationLinks: SimulationLinkDatum<SimulationForceNode>[] = edges
|
||||
.filter((edge) => nodeIds.has(edge.source) && nodeIds.has(edge.target))
|
||||
.map((edge) => ({ source: edge.source, target: edge.target }));
|
||||
|
||||
const communityIds = [
|
||||
...new Set(
|
||||
simulationNodes
|
||||
.map((node) => node.community)
|
||||
.filter((community): community is number => community !== undefined),
|
||||
),
|
||||
].sort((left, right) => left - right);
|
||||
const communityIndexById = new Map(
|
||||
communityIds.map((community, index) => [community, index]),
|
||||
);
|
||||
const communityCount = Math.max(1, communityIds.length);
|
||||
const communityAngle = (community: number) =>
|
||||
(2 * Math.PI * (communityIndexById.get(community) ?? 0)) / communityCount;
|
||||
const clusterRadius = Math.max(600, nodes.length * 5);
|
||||
const isLarge = nodes.length > 100;
|
||||
const chargeStrength = isLarge ? -600 : -350;
|
||||
const linkDistance = isLarge ? 250 : 150;
|
||||
|
||||
const simulation = forceSimulation<SimulationForceNode>(simulationNodes)
|
||||
.force(
|
||||
"link",
|
||||
forceLink<SimulationForceNode, SimulationLinkDatum<SimulationForceNode>>(
|
||||
simulationLinks,
|
||||
)
|
||||
.id((node) => node.id)
|
||||
.distance(linkDistance)
|
||||
.strength(0.2),
|
||||
)
|
||||
.force("charge", forceManyBody().strength(chargeStrength).distanceMax(1500))
|
||||
.force("center", forceCenter(0, 0).strength(0.03))
|
||||
.force(
|
||||
"collide",
|
||||
forceCollide<SimulationForceNode>()
|
||||
.radius((node) => Math.max(20, (node.width + 40) / 2))
|
||||
.strength(0.8),
|
||||
);
|
||||
|
||||
if (communityCount > 1) {
|
||||
simulation.force(
|
||||
"clusterX",
|
||||
forceX<SimulationForceNode>((node) =>
|
||||
Math.cos(communityAngle(node.community ?? 0)) * clusterRadius,
|
||||
).strength(0.3),
|
||||
);
|
||||
simulation.force(
|
||||
"clusterY",
|
||||
forceY<SimulationForceNode>((node) =>
|
||||
Math.sin(communityAngle(node.community ?? 0)) * clusterRadius,
|
||||
).strength(0.3),
|
||||
);
|
||||
}
|
||||
|
||||
simulation.tick(Math.min(300, Math.max(100, nodes.length)));
|
||||
simulation.stop();
|
||||
|
||||
return simulationNodes.map((node) => ({
|
||||
id: node.id,
|
||||
x: Number.isFinite(node.x) ? (node.x ?? 0) - node.width / 2 : 0,
|
||||
y: Number.isFinite(node.y) ? (node.y ?? 0) - node.height / 2 : 0,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
computeForceLayout,
|
||||
type ForceLayoutRequest,
|
||||
type ForceLayoutResponse,
|
||||
} from "./force-layout";
|
||||
|
||||
self.onmessage = (event: MessageEvent<ForceLayoutRequest>) => {
|
||||
const { requestId, nodes, edges } = event.data;
|
||||
|
||||
let response: ForceLayoutResponse;
|
||||
try {
|
||||
response = {
|
||||
requestId,
|
||||
positions: computeForceLayout(nodes, edges),
|
||||
};
|
||||
} catch (error) {
|
||||
response = {
|
||||
requestId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
|
||||
self.postMessage(response);
|
||||
};
|
||||
@@ -1,14 +1,4 @@
|
||||
import dagre from "@dagrejs/dagre";
|
||||
import {
|
||||
forceSimulation,
|
||||
forceLink,
|
||||
forceManyBody,
|
||||
forceCenter,
|
||||
forceCollide,
|
||||
forceX,
|
||||
forceY,
|
||||
} from "d3-force";
|
||||
import type { SimulationNodeDatum, SimulationLinkDatum } from "d3-force";
|
||||
import type { Node, Edge } from "@xyflow/react";
|
||||
import type { ElkInput } from "./elk-layout";
|
||||
|
||||
@@ -78,116 +68,6 @@ export function applyDagreLayout(
|
||||
return { nodes: layoutedNodes, edges };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Force-directed layout (for knowledge graphs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ForceNode extends SimulationNodeDatum {
|
||||
id: string;
|
||||
community?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-directed layout using d3-force — used for knowledge graphs.
|
||||
* Optionally groups nodes by community (layer/category).
|
||||
*/
|
||||
export function applyForceLayout(
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
nodeDimensions?: Map<string, { width: number; height: number }>,
|
||||
communityMap?: Map<string, number>,
|
||||
): { nodes: Node[]; edges: Edge[] } {
|
||||
if (nodes.length === 0) return { nodes, edges };
|
||||
|
||||
// Build simulation nodes with optional community assignment
|
||||
const simNodes: ForceNode[] = nodes.map((n) => ({
|
||||
id: n.id,
|
||||
x: Math.random() * 800 - 400,
|
||||
y: Math.random() * 800 - 400,
|
||||
community: communityMap?.get(n.id),
|
||||
}));
|
||||
|
||||
const nodeIdSet = new Set(simNodes.map((n) => n.id));
|
||||
const simLinks: SimulationLinkDatum<ForceNode>[] = edges
|
||||
.filter((e) => nodeIdSet.has(e.source as string) && nodeIdSet.has(e.target as string))
|
||||
.map((e) => ({
|
||||
source: e.source as string,
|
||||
target: e.target as string,
|
||||
}));
|
||||
|
||||
// Compute community centers for cluster attraction
|
||||
const communityCount = communityMap
|
||||
? Math.max(1, new Set(communityMap.values()).size)
|
||||
: 1;
|
||||
const communityAngle = (i: number) => (2 * Math.PI * i) / communityCount;
|
||||
// Scale cluster radius with node count for better spread
|
||||
const clusterRadius = Math.max(600, nodes.length * 5);
|
||||
|
||||
// Scale forces based on graph size
|
||||
const isLarge = nodes.length > 100;
|
||||
const chargeStrength = isLarge ? -600 : -350;
|
||||
const linkDistance = isLarge ? 250 : 150;
|
||||
|
||||
const sim = forceSimulation<ForceNode>(simNodes)
|
||||
.force(
|
||||
"link",
|
||||
forceLink<ForceNode, SimulationLinkDatum<ForceNode>>(simLinks)
|
||||
.id((d) => d.id)
|
||||
.distance(linkDistance)
|
||||
.strength(0.2),
|
||||
)
|
||||
.force("charge", forceManyBody().strength(chargeStrength).distanceMax(1500))
|
||||
.force("center", forceCenter(0, 0).strength(0.03))
|
||||
.force(
|
||||
"collide",
|
||||
forceCollide<ForceNode>().radius((d) => {
|
||||
const dims = nodeDimensions?.get(d.id);
|
||||
return Math.max(20, ((dims?.width ?? NODE_WIDTH) + 40) / 2);
|
||||
}).strength(0.8),
|
||||
);
|
||||
|
||||
// Add community clustering force if communities are provided
|
||||
if (communityMap && communityCount > 1) {
|
||||
sim.force(
|
||||
"clusterX",
|
||||
forceX<ForceNode>((d) => {
|
||||
const c = d.community ?? 0;
|
||||
return Math.cos(communityAngle(c)) * clusterRadius;
|
||||
}).strength(0.3),
|
||||
);
|
||||
sim.force(
|
||||
"clusterY",
|
||||
forceY<ForceNode>((d) => {
|
||||
const c = d.community ?? 0;
|
||||
return Math.sin(communityAngle(c)) * clusterRadius;
|
||||
}).strength(0.3),
|
||||
);
|
||||
}
|
||||
|
||||
// Run to convergence synchronously
|
||||
const ticks = Math.min(300, Math.max(100, nodes.length));
|
||||
sim.tick(ticks);
|
||||
sim.stop();
|
||||
|
||||
// Map positions back to xyflow nodes
|
||||
const posMap = new Map(simNodes.map((n) => [n.id, { x: n.x ?? 0, y: n.y ?? 0 }]));
|
||||
const layoutedNodes = nodes.map((node) => {
|
||||
const pos = posMap.get(node.id) ?? { x: 0, y: 0 };
|
||||
const dims = nodeDimensions?.get(node.id);
|
||||
const w = dims?.width ?? NODE_WIDTH;
|
||||
const h = dims?.height ?? NODE_HEIGHT;
|
||||
return {
|
||||
...node,
|
||||
position: {
|
||||
x: pos.x - w / 2,
|
||||
y: pos.y - h / 2,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return { nodes: layoutedNodes, edges };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ELK helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "understand-anything-viewer",
|
||||
"version": "2.9.2",
|
||||
"version": "2.9.3",
|
||||
"description": "Standalone read-only viewer for Understand-Anything knowledge graphs — no Claude Code or LLM required.",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
Reference in New Issue
Block a user