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:
Yuxiang Lin
2026-07-15 11:01:05 +08:00
committed by GitHub
19 changed files with 661 additions and 175 deletions
+1 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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",
@@ -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;
@@ -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();
});
});
@@ -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",