From c8117d12926b0d54a328130f0470ebd677512170 Mon Sep 17 00:00:00 2001 From: Hugo Gu Date: Wed, 27 May 2026 01:05:50 +0800 Subject: [PATCH] feat(web): Introduce Tree View and Circles View in Web Viewer (#1799) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(graph-view): add tree and circles layout modes Add alternate graph layouts to the web viewer with new graph view state, canvas controls, adapters, and Sigma layout logic for tree and concentric-circle rendering. Include layout and adapter tests plus tree-view E2E coverage aligned with the English UI labels, and tune node visibility, edge layering, large-graph behavior, and tree-layer spacing so the new views stay readable. Follow up the tree-view work by keeping noisy variables hidden by default and mapping Property/Const icons so filter coverage stays in sync with the expanded node taxonomy. Co-authored-by: OpenAI Codex AI-model: GPT-5 Codex * fix(web): cap tree layout spring iterations and remove unused variable Finding A (blocker): calculateTreeLayout runs 14 synchronous spring iterations over all edges and nodes — O(N×E×14) + O(N log N) per layer per iteration — with no size guard. At 10K+ nodes this freezes the main thread for several seconds. Fix: make SPRING_ITERATIONS adaptive: - N > 10 000 → 0 iterations (proportional initial layout only) - N > 3 000 → 4 iterations - otherwise → 14 iterations (unchanged behaviour for small graphs) Also removes the unused `const r` at useSigma.ts:1314, which was a leftover after the radial-resistance decomposition was removed. This clears the CodeQL "unused variable" warning (Finding G). Co-authored-by: Claude AI-model: claude-sonnet-4-6 * test(graph-adapter): add circles adapter tests and tree layout perf bound Finding B (high): knowledgeGraphToCirclesGraphology had zero test coverage. Adds three new tests: - ring placement: verifies Folder→ring 0, File→ring 1, Function→ring 3 and confirms circles-specific attributes (circlesRing, circlesAnchorX/Y) are set while tree attributes (treeAnchorX/Y) are absent. - edge styling: CONTAINS is marked isHierarchyEdge=true with the hierarchy colour; CALLS is cross-cutting with its own colour. - CALLS cross-cutting: a lone CALLS edge between two Functions is correctly identified as a non-hierarchy edge. Also adds a performance-bound test for the tree adapter at 2 000 nodes / 4 000 edges (the adaptive 14-iteration path) asserting completion within 2 s — catches regressions to the O(N×E×iterations) main-thread blocking that Finding A identified. Co-authored-by: Claude AI-model: claude-sonnet-4-6 * refactor(web): rename Tree View → Sequential Layout, Circles → Radial Layout Aligns the UI labels with standard graph layout terminology from the Cambridge Intelligence taxonomy (cambridge-intelligence.com/blog/automatic-graph-layouts): Tree View → Sequential Layout (顺序布局) Circles → Radial Layout (径向布局) Force Graph → Force Graph (unchanged) Internal graphViewMode keys ('tree', 'circles', 'force') are unchanged — only the displayed strings in en/zh-CN locales and the E2E button selectors are updated. Co-authored-by: Claude AI-model: claude-sonnet-4-6 * perf(web): add adaptive large-graph guards to sequential layout physics For graphs with N > 5 000 nodes, each rAF frame of runTreeLayout was doing O(N log N) sort + O(N × k) repulsion pair comparisons (k ≈ 2 400 for a 20 K-node graph spread across 1 080 px at range 130). At that scale each frame took hundreds of ms, making the canvas appear completely frozen even though the physics loop was still running. Fix mirrors the circles layout adaptive strategy: N > 5 000 (large): - Skip repulsion pass (O(N × k) → 0) - Skip spread-force sort (O(N log N) → 0) - Velocity cap raised to ±12 / ±6 px so nodes cover ground faster - Damping 0.58, 1 sim step/frame, 30 s max duration - Looser early-stop thresholds (max v 0.05, avg v 0.03, active 2 %) N > 1 500 (medium): - Velocity cap raised to ±6 / ±3 px - 24 s max duration - Repulsion and spread still active N ≤ 1 500 (small): - Unchanged behaviour (velocity ±3/±2, 18 s, all forces active) Layer gravity (O(N)) and edge springs (O(E)) run for all graph sizes — they provide the structural pull that replaces repulsion at large N. Co-authored-by: Claude AI-model: claude-sonnet-4-6 * fix(web): fix stale closure in sigma event handlers breaking node selection The sigma 'clickNode', 'clickStage', 'enterNode', and 'leaveNode' handlers are registered in a one-time useEffect (empty dep array). They captured options.onNodeClick via closure, so they always called the initial version of handleNodeClick — the one created before the graph loaded where `if (!graph) return` exits immediately. Consequence: clicking a node in the canvas never updated the app-level selectedNode state. This broke: - The Focus Depth filter (warning "Select a node to apply depth filter" persisted even after a canvas click) - The depth hop filter not applying (selectedNode was always null) - The code panel not opening on canvas node click Fix: store the three callback props in refs (onNodeClickRef, onNodeHoverRef, onStageClickRef) and update them synchronously on every render. The sigma event handlers now read from the refs, so they always invoke the latest version of the callbacks without needing to re-register. Co-authored-by: Claude AI-model: claude-sonnet-4-6 * fix(web): address three code-review bugs in graph rendering Bug 1 (useSigma.ts): forces in the tree physics loop were computed once before the sub-steps loop and reused for every step, causing 2× displacement on slow frames (>64ms, simulationSteps>1). Fix: move forceX/forceY Maps and all force accumulation (layer gravity, edge springs, repulsion, spread) inside the loop so each sub-step integrates from current node positions. Bug 2 (graph-adapter.ts): all three adapters used `graph.hasEdge(src,tgt)` as a dedup guard, which silently drops any second edge between the same node pair. A CALLS relationship between nodes that also have a CONTAINS edge was always lost. Fix: switch from `new Graph()` to `new MultiGraph()` (allows multiple edges per pair) and dedup by `rel.id` instead of by node pair. Bug 3 (graph-adapter.test.ts): the cross-cutting edge styling test never executed its CALLS branch because Bug 2 dropped the CALLS edge before the assertion ran. Fix: assert `sigmaGraph.size === 2` and verify both edges individually after collecting attrs by relationType. Co-authored-by: Claude AI-model: claude-sonnet-4-5 * fix(web): address three code-review bugs in graph rendering - Move radial layout force accumulation inside the sub-step loop so forces are recomputed from updated node positions each iteration instead of using stale forces computed before the loop began - Revert knowledgeGraphToGraphology from MultiGraph back to Graph with node-pair deduplication to prevent ForceAtlas2 from double-applying spring forces for node pairs that share multiple relation types - Add Target to the lucide-icons import in FileTreePanel.tsx so the Const node type icon resolves without a ReferenceError Co-authored-by: Claude AI-model: claude-sonnet-4-6 * fix(web): address four more PR review comments Edge visibility (useSigma.ts): HAS_METHOD / HAS_PROPERTY edges were hidden when any edge-type filter was active because those types are not in the EdgeType union. Normalize HAS_METHOD → DEFINES and HAS_PROPERTY → CONTAINS before the visibleTypes.includes() guard so Kotlin/Java hierarchy edges follow the same filter logic as their semantic equivalents. Force-mode edge styles (graph-adapter.ts): HAS_METHOD / HAS_PROPERTY fell back to the default gray color in the force-graph adapter because EDGE_STYLES had no entries for them. Added explicit entries using the same hues as DEFINES/CONTAINS so force mode renders Kotlin/Java hierarchy edges consistently with tree/circles. Accessibility (GraphCanvas.tsx, locales): the layout-mode switcher (Force / Tree / Circles) had no ARIA semantics. Added role="tablist" on the container and role="tab" + aria-selected on each button. Added the viewModes.label i18n key (used as aria-label on the tablist) to en and zh-CN locale files. Flaky test (graph-adapter.test.ts): replaced the hard 2 s wall-clock assertion with a structural check (node count + edge count) that is deterministic across CI hardware. Timing tests are inherently flaky and provide no correctness signal. Co-authored-by: Claude AI-model: claude-sonnet-4-5 --------- Co-authored-by: OpenAI Codex Co-authored-by: Claude Co-authored-by: Gergő Magyar --- gitnexus-web/e2e/tree-view.spec.ts | 117 ++ gitnexus-web/src/components/FileTreePanel.tsx | 3 + gitnexus-web/src/components/GraphCanvas.tsx | 109 +- .../src/hooks/app-state/graph.test.tsx | 22 + gitnexus-web/src/hooks/app-state/graph.tsx | 15 +- gitnexus-web/src/hooks/useAppState.tsx | 8 + gitnexus-web/src/hooks/useSigma.ts | 1078 +++++++++++++++-- gitnexus-web/src/lib/circles-layout.ts | 307 +++++ gitnexus-web/src/lib/constants.ts | 8 +- gitnexus-web/src/lib/graph-adapter.test.ts | 208 ++++ gitnexus-web/src/lib/graph-adapter.ts | 234 +++- gitnexus-web/src/lib/lucide-icons.tsx | 1 + gitnexus-web/src/lib/tree-layout.test.ts | 217 ++++ gitnexus-web/src/lib/tree-layout.ts | 570 +++++++++ gitnexus-web/src/locales/en/graph.json | 6 + gitnexus-web/src/locales/zh-CN/graph.json | 6 + gitnexus-web/test/unit/filter-panel.test.ts | 2 + gitnexus-web/vitest.config.ts | 2 +- 18 files changed, 2796 insertions(+), 117 deletions(-) create mode 100644 gitnexus-web/e2e/tree-view.spec.ts create mode 100644 gitnexus-web/src/hooks/app-state/graph.test.tsx create mode 100644 gitnexus-web/src/lib/circles-layout.ts create mode 100644 gitnexus-web/src/lib/graph-adapter.test.ts create mode 100644 gitnexus-web/src/lib/tree-layout.test.ts create mode 100644 gitnexus-web/src/lib/tree-layout.ts diff --git a/gitnexus-web/e2e/tree-view.spec.ts b/gitnexus-web/e2e/tree-view.spec.ts new file mode 100644 index 000000000..61c964d5b --- /dev/null +++ b/gitnexus-web/e2e/tree-view.spec.ts @@ -0,0 +1,117 @@ +import { test, expect } from '@playwright/test'; + +/** + * E2E tests for graph layout mode switching (Sequential / Radial layouts). + * + * Requires: + * - gitnexus serve running on localhost:4747 with at least one indexed repo + * - gitnexus-web dev server running on localhost:5173 + * + * Skipped when servers aren't available (CI without services, etc.). + * Set E2E=1 to force-run even without the availability check. + */ + +const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:4747'; +const FRONTEND_URL = process.env.FRONTEND_URL ?? 'http://localhost:5173'; + +test.beforeAll(async () => { + if (process.env.E2E) return; + try { + const [backendRes, frontendRes] = await Promise.allSettled([ + fetch(`${BACKEND_URL}/api/repos`), + fetch(FRONTEND_URL), + ]); + if ( + backendRes.status === 'rejected' || + (backendRes.status === 'fulfilled' && !backendRes.value.ok) + ) { + test.skip(true, 'gitnexus serve not available on :4747'); + return; + } + if ( + frontendRes.status === 'rejected' || + (frontendRes.status === 'fulfilled' && !frontendRes.value.ok) + ) { + test.skip(true, 'Vite dev server not available on :5173'); + return; + } + if (backendRes.status === 'fulfilled') { + const repos = await backendRes.value.json(); + if (!repos.length) { + test.skip(true, 'No indexed repos — run gitnexus analyze first'); + return; + } + } + } catch { + test.skip(true, 'servers not available'); + } +}); + +async function waitForGraphLoaded(page: import('@playwright/test').Page) { + await page.goto(`${FRONTEND_URL}?lng=en`); + + // The app starts on the landing/onboarding screen. Pick the first repo card + // (preferring a known repo name) and click it to load the graph. + const landingCards = page.locator('[data-testid="landing-repo-card"]'); + const preferredCard = landingCards.filter({ hasText: /GitNexus|local-integration/ }).first(); + try { + await landingCards.first().waitFor({ state: 'visible', timeout: 15_000 }); + const card = (await preferredCard.count()) > 0 ? preferredCard : landingCards.first(); + await card.click(); + } catch { + // Landing screen may not appear (e.g. when ?server auto-connects) + } + + // Wait until the status bar confirms the graph is ready. + const statusBar = page.getByRole('contentinfo'); + await expect(statusBar.getByText('Ready', { exact: true })).toBeVisible({ timeout: 45_000 }); + await expect(statusBar).toContainText(/nodes/, { timeout: 20_000 }); + + // Finally confirm the sigma canvas is present. + await page.waitForSelector('.sigma-container', { timeout: 10_000 }); +} + +test.describe('Graph Layout Modes', () => { + test.beforeEach(async ({ page }) => { + await waitForGraphLoaded(page); + }); + + test('should switch between force, sequential, and radial layouts', async ({ page }) => { + const forceTab = page.locator('button:has-text("Force Graph")'); + const sequentialTab = page.locator('button:has-text("Sequential Layout")'); + const radialTab = page.locator('button:has-text("Radial Layout")'); + + // Force Graph is the default active tab + await expect(forceTab).toHaveClass(/bg-accent/); + await expect(sequentialTab).not.toHaveClass(/bg-accent/); + + // Switch to Sequential Layout + await sequentialTab.click(); + await expect(sequentialTab).toHaveClass(/bg-accent/, { timeout: 5_000 }); + await expect(forceTab).not.toHaveClass(/bg-accent/); + + // All three layout tabs should be present in the tab bar + await expect(radialTab).toBeVisible(); + + // Switch back to Force Graph + await forceTab.click(); + await expect(forceTab).toHaveClass(/bg-accent/, { timeout: 5_000 }); + await expect(sequentialTab).not.toHaveClass(/bg-accent/); + }); + + test('should interact with nodes in sequential layout', async ({ page }) => { + await page.locator('button:has-text("Sequential Layout")').click(); + + // Click the first file-tree item in the sidebar (more reliable than a + // blind canvas click, which may land on empty space). The FileTreePanel + // renders node names as . + // Clicking any of them calls setSelectedNode, which shows the selection + // bar with the "Clear" button — the same mechanism used in + // server-connect.spec.ts's "Turn Off All Highlights" test. + const firstTreeItem = page.locator('span.truncate.font-mono').first(); + await firstTreeItem.waitFor({ state: 'visible', timeout: 10_000 }); + await firstTreeItem.click(); + + await expect(page.locator('text=Clear')).toBeVisible({ timeout: 5_000 }); + }); +}); diff --git a/gitnexus-web/src/components/FileTreePanel.tsx b/gitnexus-web/src/components/FileTreePanel.tsx index 6bab30c6f..a3c7487a9 100644 --- a/gitnexus-web/src/components/FileTreePanel.tsx +++ b/gitnexus-web/src/components/FileTreePanel.tsx @@ -201,7 +201,10 @@ const getNodeTypeIcon = (label: NodeLabel) => { case 'Import': return FileCode; case 'Variable': + case 'Property': return Variable; + case 'Const': + return Target; default: return Variable; } diff --git a/gitnexus-web/src/components/GraphCanvas.tsx b/gitnexus-web/src/components/GraphCanvas.tsx index cdf00c3bb..d0880dbe1 100644 --- a/gitnexus-web/src/components/GraphCanvas.tsx +++ b/gitnexus-web/src/components/GraphCanvas.tsx @@ -9,11 +9,16 @@ import { Pause, Lightbulb, LightbulbOff, + Network, + GitBranch, + Target, } from '@/lib/lucide-icons'; import { useSigma } from '../hooks/useSigma'; import { useAppState } from '../hooks/useAppState'; import { knowledgeGraphToGraphology, + knowledgeGraphToTreeGraphology, + knowledgeGraphToCirclesGraphology, filterGraphByDepth, SigmaNodeAttributes, SigmaEdgeAttributes, @@ -48,6 +53,8 @@ export const GraphCanvas = forwardRef((_, ref) => { clearAICitationHighlights, clearBlastRadius, animatedNodes, + graphViewMode, + setGraphViewMode, } = useAppState(); const [hoveredNodeName, setHoveredNodeName] = useState(null); @@ -149,8 +156,22 @@ export const GraphCanvas = forwardRef((_, ref) => { blastRadiusNodeIds: effectiveBlastRadiusNodeIds, animatedNodes: effectiveAnimatedNodes, visibleEdgeTypes, + layoutMode: graphViewMode, }); + const handleViewModeChange = useCallback( + (mode: 'force' | 'tree' | 'circles') => { + if (mode === graphViewMode) return; + setSelectedNode(null); + setSigmaSelectedNode(null); + setHoveredNodeName(null); + setGraphViewMode(mode); + // Reset zoom when switching views + resetZoom(); + }, + [graphViewMode, resetZoom, setGraphViewMode, setSelectedNode, setSigmaSelectedNode], + ); + // Expose focusNode to parent via ref useImperativeHandle( ref, @@ -174,25 +195,30 @@ export const GraphCanvas = forwardRef((_, ref) => { useEffect(() => { if (!graph) return; - // Build communityMemberships map from MEMBER_OF relationships - // MEMBER_OF edges: nodeId -> communityId (stored as targetId) - const communityMemberships = new Map(); - graph.relationships.forEach((rel) => { - if (rel.type === 'MEMBER_OF') { - // Find the community node to get its index - const communityNode = nodeById.get(rel.targetId); - if (communityNode && communityNode.label === 'Community') { - // Extract community index from id (e.g., "comm_5" -> 5) - const numericPart = rel.targetId.replace('comm_', ''); - const communityIdx = /^\d+$/.test(numericPart) ? parseInt(numericPart, 10) : 0; - communityMemberships.set(rel.sourceId, communityIdx); - } - } - }); + let sigmaGraph: Graph; + + if (graphViewMode === 'tree') { + sigmaGraph = knowledgeGraphToTreeGraphology(graph); + } else if (graphViewMode === 'circles') { + sigmaGraph = knowledgeGraphToCirclesGraphology(graph); + } else { + // Build community memberships map from MEMBER_OF relationships + const communityMemberships = new Map(); + graph.relationships.forEach((rel) => { + if (rel.type === 'MEMBER_OF') { + const communityNode = nodeById.get(rel.targetId); + if (communityNode && communityNode.label === 'Community') { + const numericPart = rel.targetId.replace('comm_', ''); + const communityIdx = /^\d+$/.test(numericPart) ? parseInt(numericPart, 10) : 0; + communityMemberships.set(rel.sourceId, communityIdx); + } + } + }); + sigmaGraph = knowledgeGraphToGraphology(graph, communityMemberships); + } - const sigmaGraph = knowledgeGraphToGraphology(graph, communityMemberships); setSigmaGraph(sigmaGraph); - }, [graph, nodeById, setSigmaGraph]); + }, [graph, nodeById, setSigmaGraph, graphViewMode]); // Update node visibility when filters change useEffect(() => { @@ -205,7 +231,7 @@ export const GraphCanvas = forwardRef((_, ref) => { filterGraphByDepth(sigmaGraph, appSelectedNode?.id || null, depthFilter, visibleLabels); sigma.refresh(); // eslint-disable-next-line react-hooks/exhaustive-deps -- sigmaRef identity never changes - }, [visibleLabels, depthFilter, appSelectedNode]); + }, [graph, graphViewMode, visibleLabels, depthFilter, appSelectedNode]); // Sync app selected node with sigma useEffect(() => { @@ -245,6 +271,53 @@ export const GraphCanvas = forwardRef((_, ref) => { /> + {/* View Mode Tabs */} +
+ + + +
+ {/* Sigma container */}
{children}; +} + +describe('GraphState', () => { + it('should have default graphViewMode as "force"', () => { + const { result } = renderHook(() => useGraphState(), { wrapper }); + expect(result.current.graphViewMode).toBe('force'); + }); + + it('should toggle graphViewMode', () => { + const { result } = renderHook(() => useGraphState(), { wrapper }); + act(() => { + result.current.setGraphViewMode('tree'); + }); + expect(result.current.graphViewMode).toBe('tree'); + }); +}); diff --git a/gitnexus-web/src/hooks/app-state/graph.tsx b/gitnexus-web/src/hooks/app-state/graph.tsx index 4aa41db01..aa6524a5f 100644 --- a/gitnexus-web/src/hooks/app-state/graph.tsx +++ b/gitnexus-web/src/hooks/app-state/graph.tsx @@ -16,6 +16,8 @@ interface GraphStateContextValue { setDepthFilter: (depth: number | null) => void; highlightedNodeIds: Set; setHighlightedNodeIds: (ids: Set) => void; + graphViewMode: 'force' | 'tree' | 'circles'; + setGraphViewMode: (mode: 'force' | 'tree' | 'circles') => void; } const GraphStateContext = createContext(null); @@ -27,6 +29,7 @@ export const GraphStateProvider = ({ children }: { children: ReactNode }) => { const [visibleEdgeTypes, setVisibleEdgeTypes] = useState(DEFAULT_VISIBLE_EDGES); const [depthFilter, setDepthFilter] = useState(null); const [highlightedNodeIds, setHighlightedNodeIds] = useState>(new Set()); + const [graphViewMode, setGraphViewMode] = useState<'force' | 'tree' | 'circles'>('force'); const toggleLabelVisibility = useCallback((label: NodeLabel) => { setVisibleLabels((prev) => @@ -54,8 +57,18 @@ export const GraphStateProvider = ({ children }: { children: ReactNode }) => { setDepthFilter, highlightedNodeIds, setHighlightedNodeIds, + graphViewMode, + setGraphViewMode, }), - [graph, selectedNode, visibleLabels, visibleEdgeTypes, depthFilter, highlightedNodeIds], + [ + graph, + selectedNode, + visibleLabels, + visibleEdgeTypes, + depthFilter, + highlightedNodeIds, + graphViewMode, + ], ); return {children}; diff --git a/gitnexus-web/src/hooks/useAppState.tsx b/gitnexus-web/src/hooks/useAppState.tsx index 5a7e85457..020d5ebcf 100644 --- a/gitnexus-web/src/hooks/useAppState.tsx +++ b/gitnexus-web/src/hooks/useAppState.tsx @@ -123,6 +123,10 @@ interface AppState { depthFilter: number | null; setDepthFilter: (depth: number | null) => void; + // Graph view mode + graphViewMode: 'force' | 'tree' | 'circles'; + setGraphViewMode: (mode: 'force' | 'tree' | 'circles') => void; + // Query state highlightedNodeIds: Set; setHighlightedNodeIds: (ids: Set) => void; @@ -232,6 +236,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { setDepthFilter, highlightedNodeIds, setHighlightedNodeIds, + graphViewMode, + setGraphViewMode, } = useGraphState(); // Right Panel @@ -1266,6 +1272,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { toggleEdgeVisibility, depthFilter, setDepthFilter, + graphViewMode, + setGraphViewMode, highlightedNodeIds, setHighlightedNodeIds, aiCitationHighlightedNodeIds, diff --git a/gitnexus-web/src/hooks/useSigma.ts b/gitnexus-web/src/hooks/useSigma.ts index 461de0f5a..a2121dad4 100644 --- a/gitnexus-web/src/hooks/useSigma.ts +++ b/gitnexus-web/src/hooks/useSigma.ts @@ -62,6 +62,7 @@ interface UseSigmaOptions { blastRadiusNodeIds?: Set; animatedNodes?: Map; visibleEdgeTypes?: EdgeType[]; + layoutMode?: 'force' | 'tree' | 'circles'; } interface UseSigmaReturn { @@ -128,6 +129,105 @@ const getLayoutDuration = (nodeCount: number): number => { return 20000; // 20s for small graphs }; +const TREE_MAX_X = 540; +const TREE_REPULSION_RANGE = 130; +const TREE_LAYOUT_MAX_DURATION = 18000; +const TREE_LAYOUT_STABILITY_FRAMES = 24; +const TREE_TARGET_FRAME_MS = 32; +const TREE_LAYOUT_MIN_DURATION = 1500; +const TREE_FORCE_DEADZONE = 0.005; +const TREE_VELOCITY_DEADZONE = 0.01; +// Y is free within each layer's band; gravity + boundary resistance keep layers separate. +// Band half kept at 55px so nodes don't drift far past the initial camera-fit viewport. +const TREE_LAYER_GRAVITY = 0.06; // stronger gravity keeps nodes near their layer center +const TREE_LAYER_BAND_HALF = 55; // ±55px from layer center Y +const TREE_LAYER_BOUNDARY_RESISTANCE = 10; // progressive resistance near band edges +// Spread force: fine-tune density within each layer during physics. +// Kept deliberately weak (0.003) because the initial proportional layout already +// distributes nodes near their ideal positions — aggressive spread would fight +// the hierarchy springs and push edge-parented children away from their parents. +const TREE_SPREAD_STRENGTH = 0.003; + +// --------------------------------------------------------------------------- +// Circles View constants +// --------------------------------------------------------------------------- + +/** Target radius for each ring — must match CIRCLES_RING_RADII in circles-layout.ts */ +const CIRCLES_RING_RADII = [90, 240, 420, 620] as const; +const CIRCLES_RING_COUNT = CIRCLES_RING_RADII.length; + +/** + * Half-width of the allowed radial band. Must match CIRCLES_BAND_HALF in + * circles-layout.ts. Keep it small enough that adjacent ring bands never + * overlap: current ring gaps are 150/180/200 px, so 45 px leaves 60-110 px + * of clear air between rings. + * + * Nodes distribute within this band driven by repulsion (outward) and + * soft-wall gravity (inward, growing cubically near the edge). + * No hard clamp — nodes float freely inside the band. + */ +const CIRCLES_BAND_HALF = 45; + +/** + * Base radial gravity rate. Effective gravity grows cubically near the band + * edge via CIRCLES_RADIAL_BOUNDARY_RESISTANCE: + * + * rOffset = 0 px → k = k_base × 1 (almost no pull) + * rOffset = 22 px → k ≈ k_base × 4.2 (moderate) + * rOffset = 40 px → k ≈ k_base × 16 (strong) + * rOffset = 45 px → k ≈ k_base × 21 (very strong — prevents crossing) + */ +const CIRCLES_RADIAL_GRAVITY = 0.06; + +/** + * Cubic-growth multiplier near the band edge. + * Effective k = CIRCLES_RADIAL_GRAVITY × (1 + normR³ × this). + */ +const CIRCLES_RADIAL_BOUNDARY_RESISTANCE = 22; + +/** + * Angular spread force: kept very weak — edge springs are the primary + * mechanism for angular positioning. A too-strong spread competes with + * springs and keeps connected nodes far apart. + */ +const CIRCLES_ANGULAR_SPREAD = 0.002; + +/** Repulsion range — same as tree view so nodes from dense rings don't clump. */ +const CIRCLES_REPULSION_RANGE = 130; + +const CIRCLES_LAYOUT_MAX_DURATION = 24000; +const CIRCLES_LAYOUT_STABILITY_FRAMES = 24; +const CIRCLES_LAYOUT_MIN_DURATION = 1500; +const CIRCLES_FORCE_DEADZONE = 0.005; +const CIRCLES_VELOCITY_DEADZONE = 0.01; + +const CIRCLES_EDGE_WEIGHTS: Record = { + // Hierarchy edges: moderate — angular alignment without fighting radial gravity + // (rest length is now set to ring-gap distance, not zero). + CONTAINS: 0.18, + DEFINES: 0.22, + // Cross edges: stronger so same-ring connected nodes cluster angularly. + IMPORTS: 0.2, + CALLS: 0.24, + EXTENDS: 0.2, + IMPLEMENTS: 0.2, +}; + +// --------------------------------------------------------------------------- + +const TREE_EDGE_WEIGHTS: Record = { + CONTAINS: 0.09, + DEFINES: 0.12, + IMPORTS: 0.14, + CALLS: 0.18, + EXTENDS: 0.13, + IMPLEMENTS: 0.13, +}; + +const clamp = (value: number, min: number, max: number): number => { + return Math.min(max, Math.max(min, value)); +}; + export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => { const containerRef = useRef(null); const sigmaRef = useRef(null); @@ -138,8 +238,33 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => { const blastRadiusRef = useRef>(new Set()); const animatedNodesRef = useRef>(new Map()); const visibleEdgeTypesRef = useRef(null); + + // Keep callback refs fresh so the one-time sigma event handlers always + // call the latest version (avoids stale-closure bugs when graph loads). + const onNodeClickRef = useRef(options.onNodeClick); + const onNodeHoverRef = useRef(options.onNodeHover); + const onStageClickRef = useRef(options.onStageClick); + onNodeClickRef.current = options.onNodeClick; + onNodeHoverRef.current = options.onNodeHover; + onStageClickRef.current = options.onStageClick; const layoutTimeoutRef = useRef | null>(null); - const animationFrameRef = useRef(null); + const effectsAnimationFrameRef = useRef(null); + const treeLayoutFrameRef = useRef(null); + const treeVelocityRef = useRef>(new Map()); // vx per node + const treeVelocityYRef = useRef>(new Map()); // vy per node + const treeLastTickRef = useRef(null); + const treeAccumulatorRef = useRef(0); + const treeLayoutStartRef = useRef(null); + const treeStableFramesRef = useRef(0); + + // Circles layout state (mirrors tree layout state) + const circlesLayoutFrameRef = useRef(null); + const circlesVelocityXRef = useRef>(new Map()); + const circlesVelocityYRef = useRef>(new Map()); + const circlesLastTickRef = useRef(null); + const circlesAccumulatorRef = useRef(0); + const circlesLayoutStartRef = useRef(null); + const circlesStableFramesRef = useRef(0); const [isLayoutRunning, setIsLayoutRunning] = useState(false); const [selectedNode, setSelectedNodeState] = useState(null); @@ -159,24 +284,24 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => { // Animation loop for node effects useEffect(() => { if (!options.animatedNodes || options.animatedNodes.size === 0) { - if (animationFrameRef.current) { - cancelAnimationFrame(animationFrameRef.current); - animationFrameRef.current = null; + if (effectsAnimationFrameRef.current) { + cancelAnimationFrame(effectsAnimationFrameRef.current); + effectsAnimationFrameRef.current = null; } return; } const animate = () => { sigmaRef.current?.refresh(); - animationFrameRef.current = requestAnimationFrame(animate); + effectsAnimationFrameRef.current = requestAnimationFrame(animate); }; animate(); return () => { - if (animationFrameRef.current) { - cancelAnimationFrame(animationFrameRef.current); - animationFrameRef.current = null; + if (effectsAnimationFrameRef.current) { + cancelAnimationFrame(effectsAnimationFrameRef.current); + effectsAnimationFrameRef.current = null; } }; }, [options.animatedNodes]); @@ -197,6 +322,74 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => { sigma.refresh(); }, []); + const stopTreeLayout = useCallback((refresh: boolean = false) => { + if (treeLayoutFrameRef.current) { + cancelAnimationFrame(treeLayoutFrameRef.current); + treeLayoutFrameRef.current = null; + } + treeLastTickRef.current = null; + treeAccumulatorRef.current = 0; + treeLayoutStartRef.current = null; + treeStableFramesRef.current = 0; + treeVelocityRef.current.clear(); + treeVelocityYRef.current.clear(); + setIsLayoutRunning(false); + + if (refresh) { + sigmaRef.current?.refresh(); + // Re-fit camera to the actual settled positions — nodes may have drifted + // from their initial anchors during simulation (especially small/leaf nodes). + sigmaRef.current?.getCamera().animatedReset({ duration: 600 }); + } + }, []); + + const stopCirclesLayout = useCallback((refresh: boolean = false) => { + if (circlesLayoutFrameRef.current) { + cancelAnimationFrame(circlesLayoutFrameRef.current); + circlesLayoutFrameRef.current = null; + } + circlesLastTickRef.current = null; + circlesAccumulatorRef.current = 0; + circlesLayoutStartRef.current = null; + circlesStableFramesRef.current = 0; + circlesVelocityXRef.current.clear(); + circlesVelocityYRef.current.clear(); + setIsLayoutRunning(false); + + if (refresh) { + sigmaRef.current?.refresh(); + sigmaRef.current?.getCamera().animatedReset({ duration: 600 }); + } + }, []); + + const stopAllLayouts = useCallback( + (refresh: boolean = false) => { + if (layoutTimeoutRef.current) { + clearTimeout(layoutTimeoutRef.current); + layoutTimeoutRef.current = null; + } + + if (layoutRef.current) { + layoutRef.current.stop(); + layoutRef.current.kill(); + layoutRef.current = null; + + const graph = graphRef.current; + if (graph && options.layoutMode !== 'tree' && options.layoutMode !== 'circles') { + noverlap.assign(graph, NOVERLAP_SETTINGS); + } + } + + stopTreeLayout(false); + stopCirclesLayout(false); + + if (refresh) { + sigmaRef.current?.refresh(); + } + }, + [options.layoutMode, stopTreeLayout, stopCirclesLayout], + ); + // Initialize Sigma ONCE useEffect(() => { if (!containerRef.current) return; @@ -393,15 +586,38 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => { edgeReducer: (edge, data) => { const res = { ...data }; - // Check edge type visibility first + // Check edge type visibility first. + // HAS_METHOD / HAS_PROPERTY are Kotlin/Java hierarchy edges not in the + // EdgeType union — normalize them so they follow DEFINES / CONTAINS + // visibility instead of being silently hidden. const visibleTypes = visibleEdgeTypesRef.current; if (visibleTypes && data.relationType) { - if (!visibleTypes.includes(data.relationType as EdgeType)) { + const normalizedType = + data.relationType === 'HAS_METHOD' + ? 'DEFINES' + : data.relationType === 'HAS_PROPERTY' + ? 'CONTAINS' + : data.relationType; + if (!visibleTypes.includes(normalizedType as EdgeType)) { res.hidden = true; return res; } } + // Tree view: hierarchy edges are subtle, cross-cutting edges are more visible + const isHierarchyEdge = (data as any).isHierarchyEdge; + if (isHierarchyEdge !== undefined) { + if (isHierarchyEdge) { + // Subtle hierarchy edges in tree view + res.color = dimColor(data.color, 0.5); + res.size = Math.max(0.3, (data.size || 1) * 0.5); + } else { + // Cross-cutting edges are more visible + res.color = brightenColor(data.color, 1.2); + res.size = Math.max(1, (data.size || 1) * 1.2); + } + } + const currentSelected = selectedNodeRef.current; const highlighted = highlightedRef.current; const blastRadius = blastRadiusRef.current; @@ -467,29 +683,41 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => { sigma.on('clickNode', ({ node }) => { setSelectedNode(node); - options.onNodeClick?.(node); + onNodeClickRef.current?.(node); }); sigma.on('clickStage', () => { setSelectedNode(null); - options.onStageClick?.(); + onStageClickRef.current?.(); }); sigma.on('enterNode', ({ node }) => { - options.onNodeHover?.(node); + onNodeHoverRef.current?.(node); if (containerRef.current) { containerRef.current.style.cursor = 'pointer'; } }); sigma.on('leaveNode', () => { - options.onNodeHover?.(null); + onNodeHoverRef.current?.(null); if (containerRef.current) { containerRef.current.style.cursor = 'grab'; } }); return () => { + if (treeLayoutFrameRef.current) { + cancelAnimationFrame(treeLayoutFrameRef.current); + treeLayoutFrameRef.current = null; + } + treeVelocityRef.current.clear(); + treeVelocityYRef.current.clear(); + if (circlesLayoutFrameRef.current) { + cancelAnimationFrame(circlesLayoutFrameRef.current); + circlesLayoutFrameRef.current = null; + } + circlesVelocityXRef.current.clear(); + circlesVelocityYRef.current.clear(); if (layoutTimeoutRef.current) { clearTimeout(layoutTimeoutRef.current); } @@ -500,70 +728,779 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => { }; }, []); - // Run ForceAtlas2 layout - const runLayout = useCallback((graph: Graph) => { - const nodeCount = graph.order; - if (nodeCount === 0) return; + const runTreeLayout = useCallback( + (graph: Graph) => { + if (graph.order === 0) return; - // Kill existing - if (layoutRef.current) { - layoutRef.current.kill(); - layoutRef.current = null; - } - if (layoutTimeoutRef.current) { - clearTimeout(layoutTimeoutRef.current); - layoutTimeoutRef.current = null; - } + stopAllLayouts(false); - // Get settings - const inferredSettings = forceAtlas2.inferSettings(graph); - const customSettings = getFA2Settings(nodeCount); - const settings = { ...inferredSettings, ...customSettings }; + // Compute each layer's Y center from initial anchor positions + const layerYSum = new Map(); + const layerYCount = new Map(); - const layout = new FA2Layout(graph, { settings }); + graph.forEachNode((nodeId, attrs) => { + const layer = attrs.treeLayer ?? 0; + const ay = attrs.treeAnchorY ?? attrs.y; + layerYSum.set(layer, (layerYSum.get(layer) ?? 0) + ay); + layerYCount.set(layer, (layerYCount.get(layer) ?? 0) + 1); + treeVelocityRef.current.set(nodeId, 0); + treeVelocityYRef.current.set(nodeId, 0); + graph.setNodeAttribute(nodeId, 'x', attrs.treeAnchorX ?? attrs.x); + graph.setNodeAttribute(nodeId, 'y', ay); + }); - layoutRef.current = layout; - layout.start(); - setIsLayoutRunning(true); + const layerCenterY = new Map(); + for (const [layer, sum] of layerYSum) { + layerCenterY.set(layer, sum / (layerYCount.get(layer) ?? 1)); + } - const duration = getLayoutDuration(nodeCount); + // Compute each node's preferred Y position within its layer band. + // + // A node in Layer L that connects upward (to Layer L-1, which has higher Y) + // should sit near the TOP of the band — it shortens those vertical edges. + // A node connecting only downward (to Layer L+1) should sit at the BOTTOM. + // A node that connects in both directions, or only within its own layer, + // goes to the center — freeing the edges of the band for directional nodes. + // + // bias ∈ [-1, +1]: +1 = top of band (higher Y, toward layer above), + // -1 = bottom of band (lower Y, toward layer below), + // 0 = layer center. + const nodeYBias = new Map(); + graph.forEachNode((nodeId, attrs) => { + const layer = attrs.treeLayer ?? 0; + let aboveCount = 0; + let belowCount = 0; + graph.forEachNeighbor(nodeId, (_, nAttrs) => { + const nLayer = nAttrs.treeLayer ?? 0; + if (nLayer < layer) aboveCount++; + if (nLayer > layer) belowCount++; + }); + // Weighted ratio: (above − below) / total, scaled to ±0.55 of band half. + const total = aboveCount + belowCount; + nodeYBias.set(nodeId, total > 0 ? ((aboveCount - belowCount) / total) * 0.55 : 0); + }); - layoutTimeoutRef.current = setTimeout(() => { - if (layoutRef.current) { - layoutRef.current.stop(); - layoutRef.current = null; + // Pre-position nodes at their preferred Y to reduce physics convergence time. + graph.forEachNode((nodeId, attrs) => { + const layer = attrs.treeLayer ?? 0; + const cy = layerCenterY.get(layer) ?? attrs.y; + const bias = nodeYBias.get(nodeId) ?? 0; + graph.setNodeAttribute(nodeId, 'y', cy + bias * TREE_LAYER_BAND_HALF * 0.6); + }); + + setIsLayoutRunning(true); + + // Adaptive tuning — mirrors the circles layout strategy. + // The repulsion pass is O(N × k) after sorting; for large graphs k + // can be thousands, making each frame multi-hundred ms → apparent freeze. + const treeNodeCount = graph.order; + const treeIsLarge = treeNodeCount > 5000; + const treeIsMedium = treeNodeCount > 1500; + const treeUseRepulsion = !treeIsLarge; // skip O(N×k) repulsion for large graphs + const treeUseSpread = !treeIsLarge; // skip O(N log N) spread sort for large graphs + const treeDamping = treeIsLarge ? 0.58 : 0.62; + const treeVelocityCapX = treeIsLarge ? 12 : treeIsMedium ? 6 : 3; + const treeVelocityCapY = treeIsLarge ? 6 : treeIsMedium ? 3 : 2; + const treeMaxSimSteps = treeIsLarge ? 1 : 2; + const treeEffectiveMaxDuration = treeIsLarge + ? 30000 + : treeIsMedium + ? 24000 + : TREE_LAYOUT_MAX_DURATION; + const treeStopMaxVelocity = treeIsLarge ? 0.05 : 0.022; + const treeStopAvgVelocity = treeIsLarge ? 0.03 : 0.016; + const treeStopActiveNodeFraction = treeIsLarge ? 0.02 : 0.008; + const treeStopStabilityFrames = treeIsLarge ? 20 : TREE_LAYOUT_STABILITY_FRAMES; + + const step = (timestamp: number) => { + if (!graphRef.current || graphRef.current !== graph) { + stopTreeLayout(false); + return; + } + + if (treeLayoutStartRef.current === null) { + treeLayoutStartRef.current = timestamp; + } + + const frameDelta = + treeLastTickRef.current === null + ? TREE_TARGET_FRAME_MS + : clamp(timestamp - treeLastTickRef.current, 8, 64); + treeLastTickRef.current = timestamp; + treeAccumulatorRef.current = Math.min( + TREE_TARGET_FRAME_MS * 3, + treeAccumulatorRef.current + frameDelta, + ); + + if (treeAccumulatorRef.current < TREE_TARGET_FRAME_MS) { + treeLayoutFrameRef.current = requestAnimationFrame(step); + return; + } + + const simulationSteps = Math.min( + treeMaxSimSteps, + Math.floor(treeAccumulatorRef.current / TREE_TARGET_FRAME_MS), + ); + treeAccumulatorRef.current -= simulationSteps * TREE_TARGET_FRAME_MS; + const dtScale = 0.6; + + // --- Apply forces: velocity integration with boundary resistance --- + // Forces are recomputed from current node positions each sub-step so that + // slow frames (simulationSteps > 1) integrate correctly and don't double-apply. + let totalVelocity = 0; + let maxVelocity = 0; + let activeNodes = 0; + + for (let simulationStep = 0; simulationStep < simulationSteps; simulationStep++) { + // --- Accumulate forces (recomputed each sub-step from current positions) --- + const forceX = new Map(); + const forceY = new Map(); + + // 1. Layer gravity: soft pull toward each node's preferred Y within its band. + // Directional nodes (above-only or below-only connections) are pulled to the + // top or bottom of the band; bidirectional / same-layer-only nodes go to + // the center. This leaves band edges free for nodes that actually use them. + graph.forEachNode((nodeId, attrs) => { + const layer = attrs.treeLayer ?? 0; + const centerY = layerCenterY.get(layer) ?? attrs.y; + const bias = nodeYBias.get(nodeId) ?? 0; + const targetY = centerY + bias * TREE_LAYER_BAND_HALF; + forceX.set(nodeId, 0); + forceY.set(nodeId, (targetY - attrs.y) * TREE_LAYER_GRAVITY * dtScale); + }); + + // 2. Edge springs — X and Y handled separately. + // + // Root cause of long horizontal edges: the previous 2D spring projected + // force through (dx/distance, dy/distance). When the Y layer gap + // dominates (|dy|≈200, |dx|≈30) the X component shrinks to ~15% of + // the total spring force, too weak to overcome sibling repulsion. + // + // Fix: compute X spring from |dx| alone. This keeps full strength + // regardless of how far apart two nodes are in Y. + graph.forEachEdge((edge, edgeAttrs, source, target, sourceAttrs, targetAttrs) => { + const dx = targetAttrs.x - sourceAttrs.x; + const rawWeight = TREE_EDGE_WEIGHTS[edgeAttrs.relationType] ?? 0.18; + + // 2a. Pure X spring. + // Hierarchy edges: zero rest length so children want to sit directly + // under their parent (repulsion then spreads siblings out naturally). + // Cross edges: 60 px rest so far-spanning CALLS/IMPORTS edges only + // pull when really stretched, and their weight is capped so they + // don't override the hierarchy structure. + const xRestLength = edgeAttrs.isHierarchyEdge ? 0 : 60; + const xStretch = Math.abs(dx) - xRestLength; + if (xStretch > 0) { + const xWeight = edgeAttrs.isHierarchyEdge ? rawWeight : Math.min(rawWeight, 0.1); + const fxX = Math.sign(dx) * xStretch * xWeight * 0.3 * dtScale; + forceX.set(source, (forceX.get(source) ?? 0) + fxX); + forceX.set(target, (forceX.get(target) ?? 0) - fxX); + } + + // 2b. Weak Y spring — layer gravity handles most vertical placement; + // this just prevents extreme cross-layer stretching. + const dy = targetAttrs.y - sourceAttrs.y; + const distance = Math.sqrt(dx * dx + dy * dy) || 1; + const layerGap = Math.abs((targetAttrs.treeLayer ?? 0) - (sourceAttrs.treeLayer ?? 0)); + const yRestLength = + (edgeAttrs.isHierarchyEdge ? 70 : 95) + + layerGap * (edgeAttrs.isHierarchyEdge ? 28 : 36); + const yStretch = distance - yRestLength; + if (yStretch > 0) { + const fy = (dy / distance) * yStretch * rawWeight * 0.008 * dtScale; + forceY.set(source, (forceY.get(source) ?? 0) + fy); + forceY.set(target, (forceY.get(target) ?? 0) - fy); + } + }); + + // 3. Node repulsion in 2D: all pairs within range (cross-layer included) + // Sort by X for O(n·k) early-exit: once dx > range, all further pairs are too far. + // + // Skipped for large graphs (N > 5 000) — sorting + pair comparisons make each + // frame take hundreds of ms, leaving the canvas apparently frozen. Layer gravity + // and edge springs provide sufficient structure without repulsion. + if (treeUseRepulsion) { + const nodeList = graph.nodes().map((id) => { + const a = graph.getNodeAttributes(id); + return { id, x: a.x, y: a.y, size: a.size ?? 6, layer: a.treeLayer ?? 0 }; + }); + nodeList.sort((a, b) => a.x - b.x); + + for (let i = 0; i < nodeList.length; i++) { + const nodeA = nodeList[i]; + for (let j = i + 1; j < nodeList.length; j++) { + const nodeB = nodeList[j]; + const dx = nodeB.x - nodeA.x; + if (dx > TREE_REPULSION_RANGE) break; // X-sorted: all further pairs are also too far + + const dy = nodeB.y - nodeA.y; + const dist = Math.sqrt(dx * dx + dy * dy) || 1; + if (dist > TREE_REPULSION_RANGE) continue; + + const sameLayer = nodeA.layer === nodeB.layer; + // Same-layer repulsion reduced from 160→100 so the stronger X spring + // (0.30) can now overcome collective repulsion from 3-4 nearby nodes. + // Cross-layer kept low (28) so intermediate-layer nodes don't block + // parent-child X alignment. + const repulsionStrength = sameLayer ? 100 : 28; + const minGap = Math.max(28, (nodeA.size + nodeB.size) * 1.8); + let repulsion = + (1 / (dist + 8) - 1 / (TREE_REPULSION_RANGE + 8)) * repulsionStrength * dtScale; + if (dist < minGap && sameLayer) { + repulsion += (minGap - dist) * 0.1 * dtScale; + } + if (repulsion <= 0) continue; + + const fx = (dx / dist) * repulsion; + const fy = (dy / dist) * repulsion; + + forceX.set(nodeA.id, (forceX.get(nodeA.id) ?? 0) - fx); + forceY.set(nodeA.id, (forceY.get(nodeA.id) ?? 0) - fy); + forceX.set(nodeB.id, (forceX.get(nodeB.id) ?? 0) + fx); + forceY.set(nodeB.id, (forceY.get(nodeB.id) ?? 0) + fy); + } + } + } + + // 4. Spread force: equalize node density within each layer. + // + // For each layer, rank nodes by current X, compute where they would sit + // in a perfectly even distribution, then add a weak force toward that + // ideal position. Nodes that are held by strong hierarchy springs + // (force ≈ 1–2 units) resist and stay clustered; nodes without a + // strong spring anchor (isolated or same-layer-only) drift to fill gaps. + // Net effect: dense centre spreads outward, sparse edges fill in. + // Skipped for large graphs — per-layer sort is O(N log N) per frame. + if (treeUseSpread) { + const spreadByLayer = new Map>(); + graph.forEachNode((nodeId, attrs) => { + const layer = attrs.treeLayer ?? 0; + if (!spreadByLayer.has(layer)) spreadByLayer.set(layer, []); + spreadByLayer.get(layer)!.push({ id: nodeId, x: attrs.x }); + }); + for (const [, layerNodes] of spreadByLayer) { + if (layerNodes.length < 2) continue; + layerNodes.sort((a, b) => a.x - b.x); + const count = layerNodes.length; + const spacing = (TREE_MAX_X * 2) / count; + for (let i = 0; i < count; i++) { + const { id, x } = layerNodes[i]; + const idealX = -TREE_MAX_X + (i + 0.5) * spacing; + forceX.set( + id, + (forceX.get(id) ?? 0) + (idealX - x) * TREE_SPREAD_STRENGTH * dtScale, + ); + } + } + } + + totalVelocity = 0; + maxVelocity = 0; + activeNodes = 0; + + graph.forEachNode((nodeId, attrs) => { + const fx = forceX.get(nodeId) ?? 0; + const fy = forceY.get(nodeId) ?? 0; + const vx0 = treeVelocityRef.current.get(nodeId) ?? 0; + const vy0 = treeVelocityYRef.current.get(nodeId) ?? 0; + + // X boundary resistance: grows as node approaches canvas edge + const normX = Math.min(1, Math.abs(attrs.x) / TREE_MAX_X); + const resistX = 1 + normX * normX * 4; + + // Y boundary resistance: grows as node drifts from its layer band center + const layer = attrs.treeLayer ?? 0; + const centerY = layerCenterY.get(layer) ?? attrs.y; + const yOffset = attrs.y - centerY; + const normY = Math.min(1, Math.abs(yOffset) / TREE_LAYER_BAND_HALF); + const resistY = 1 + normY * normY * TREE_LAYER_BOUNDARY_RESISTANCE; + + const rawVx = (vx0 + fx / resistX) * treeDamping; + const rawVy = (vy0 + fy / resistY) * treeDamping; + const newVx = + Math.abs(fx) < TREE_FORCE_DEADZONE && Math.abs(rawVx) < TREE_VELOCITY_DEADZONE + ? 0 + : clamp(rawVx, -treeVelocityCapX, treeVelocityCapX); + const newVy = + Math.abs(fy) < TREE_FORCE_DEADZONE && Math.abs(rawVy) < TREE_VELOCITY_DEADZONE + ? 0 + : clamp(rawVy, -treeVelocityCapY, treeVelocityCapY); + + treeVelocityRef.current.set(nodeId, newVx); + treeVelocityYRef.current.set(nodeId, newVy); + + const speed = Math.sqrt(newVx * newVx + newVy * newVy); + totalVelocity += speed; + maxVelocity = Math.max(maxVelocity, speed); + if ( + speed > TREE_VELOCITY_DEADZONE || + Math.abs(fx) > TREE_FORCE_DEADZONE || + Math.abs(fy) > TREE_FORCE_DEADZONE + ) { + activeNodes += 1; + } + + graph.setNodeAttribute(nodeId, 'x', clamp(attrs.x + newVx, -TREE_MAX_X, TREE_MAX_X)); + graph.setNodeAttribute( + nodeId, + 'y', + clamp( + attrs.y + newVy, + centerY - TREE_LAYER_BAND_HALF, + centerY + TREE_LAYER_BAND_HALF, + ), + ); + }); + } - // Light noverlap cleanup - noverlap.assign(graph, NOVERLAP_SETTINGS); sigmaRef.current?.refresh(); - setIsLayoutRunning(false); - } - }, duration); - }, []); + const averageVelocity = totalVelocity / Math.max(1, graph.order); + const elapsed = timestamp - (treeLayoutStartRef.current ?? timestamp); + + if ( + elapsed >= TREE_LAYOUT_MIN_DURATION && + maxVelocity < treeStopMaxVelocity && + activeNodes <= Math.max(2, Math.floor(graph.order * treeStopActiveNodeFraction)) && + averageVelocity < treeStopAvgVelocity + ) { + treeStableFramesRef.current += 1; + } else { + treeStableFramesRef.current = 0; + } + + if ( + treeStableFramesRef.current >= treeStopStabilityFrames || + elapsed >= treeEffectiveMaxDuration + ) { + stopTreeLayout(true); + return; + } + + treeLayoutFrameRef.current = requestAnimationFrame(step); + }; + + treeLayoutFrameRef.current = requestAnimationFrame(step); + }, + [stopAllLayouts, stopTreeLayout], + ); + + const runCirclesLayout = useCallback( + (graph: Graph) => { + if (graph.order === 0) return; + + stopAllLayouts(false); + + // Compute ring target radii and centre Y (all rings are centred at 0,0) + const ringTargetR = CIRCLES_RING_RADII as unknown as number[]; + + // --------------------------------------------------------------------------- + // Adaptive physics parameters — scale to graph size. + // + // For large graphs the two most expensive passes are: + // • Repulsion: O(n × k) where k = neighbours in the sweep window + // (can be hundreds when nodes are dense on a ring arc). + // • Angular spread: O(k log k) per ring — O(n log n) total. + // + // Neither is needed for layout correctness: gravity pulls nodes to their + // ring, edge springs cluster connected nodes angularly. Repulsion and + // spread are purely cosmetic polish — worth skipping at large n. + // --------------------------------------------------------------------------- + const nodeCount = graph.order; + const isLargeGraph = nodeCount > 5000; + const isMediumGraph = nodeCount > 1500; + + // Repulsion range — 0 means skip the pass entirely. + const effectiveRepulsionRange = isLargeGraph + ? 0 + : isMediumGraph + ? 70 + : CIRCLES_REPULSION_RANGE; + + // Damping: moderate for large graphs so nodes don't overshoot but still + // settle within the time budget. Very aggressive damping (0.48) causes + // nodes to stop mid-path before reaching equilibrium. + const dampingFactor = isLargeGraph ? 0.58 : isMediumGraph ? 0.58 : 0.62; + + // Higher velocity cap → each frame moves nodes further (faster convergence). + const velocityCap = isLargeGraph ? 10 : 5; + + // Fewer simulation sub-steps per rAF tick to keep frames fast for large graphs. + const maxSimSteps = isLargeGraph ? 1 : 2; + + // Tighter per-frame budget for repulsion sweep when range > 0. + const useAngularSpread = !isLargeGraph; + + // Max wall-clock budget. Large graphs skip the expensive passes so each + // frame is fast (full 60 fps); 30 s × 60 fps = 1 800 frames is enough to + // converge 20 k+ node layouts with only gravity + edge springs. + const effectiveMaxDuration = isLargeGraph + ? 30000 + : isMediumGraph + ? 18000 + : CIRCLES_LAYOUT_MAX_DURATION; + + // Early-stop velocity thresholds. + const stopMaxVelocity = isLargeGraph ? 0.05 : 0.022; + const stopAvgVelocity = isLargeGraph ? 0.03 : 0.016; + const stopActiveNodeFraction = isLargeGraph ? 0.02 : 0.008; + const stopStabilityFrames = isLargeGraph ? 20 : CIRCLES_LAYOUT_STABILITY_FRAMES; + + // Pre-position nodes at their anchor and initialise velocities + graph.forEachNode((nodeId, attrs) => { + const ax = attrs.circlesAnchorX ?? attrs.x; + const ay = attrs.circlesAnchorY ?? attrs.y; + graph.setNodeAttribute(nodeId, 'x', ax); + graph.setNodeAttribute(nodeId, 'y', ay); + circlesVelocityXRef.current.set(nodeId, 0); + circlesVelocityYRef.current.set(nodeId, 0); + }); + + setIsLayoutRunning(true); + + const step = (timestamp: number) => { + if (!graphRef.current || graphRef.current !== graph) { + stopCirclesLayout(false); + return; + } + + if (circlesLayoutStartRef.current === null) { + circlesLayoutStartRef.current = timestamp; + } + + const frameDelta = + circlesLastTickRef.current === null + ? TREE_TARGET_FRAME_MS + : clamp(timestamp - circlesLastTickRef.current, 8, 64); + circlesLastTickRef.current = timestamp; + circlesAccumulatorRef.current = Math.min( + TREE_TARGET_FRAME_MS * 3, + circlesAccumulatorRef.current + frameDelta, + ); + + if (circlesAccumulatorRef.current < TREE_TARGET_FRAME_MS) { + circlesLayoutFrameRef.current = requestAnimationFrame(step); + return; + } + + const simulationSteps = Math.min( + maxSimSteps, + Math.floor(circlesAccumulatorRef.current / TREE_TARGET_FRAME_MS), + ); + circlesAccumulatorRef.current -= simulationSteps * TREE_TARGET_FRAME_MS; + const dtScale = 0.6; + + // --- Apply forces with radial boundary resistance --- + let totalVelocity = 0; + let maxVelocity = 0; + let activeNodes = 0; + + for (let _step = 0; _step < simulationSteps; _step++) { + totalVelocity = 0; + maxVelocity = 0; + activeNodes = 0; + + // --- Accumulate forces (recomputed each sub-step from current positions) --- + const forceX = new Map(); + const forceY = new Map(); + + // 1. Radial gravity with soft wall. + // + // Base gravity is weak, allowing repulsion to spread nodes radially + // within the band. The effective rate grows cubically as the node + // approaches the band edge so nodes never cross into adjacent rings. + // This replaces the previous hard position clamp, which caused nodes + // to pile against the boundary instead of distributing within the band. + graph.forEachNode((nodeId, attrs) => { + const ring = attrs.circlesRing ?? 0; + const targetR = ringTargetR[Math.min(ring, CIRCLES_RING_COUNT - 1)]; + const x = attrs.x; + const y = attrs.y; + const r = Math.sqrt(x * x + y * y) || 1; + const stretch = targetR - r; // positive = node inside ring, negative = outside + const normR = Math.min(1, Math.abs(stretch) / CIRCLES_BAND_HALF); + const k = + CIRCLES_RADIAL_GRAVITY * + (1 + normR * normR * normR * CIRCLES_RADIAL_BOUNDARY_RESISTANCE); + forceX.set(nodeId, (x / r) * stretch * k * dtScale); + forceY.set(nodeId, (y / r) * stretch * k * dtScale); + }); + + // 2. Edge springs — radial and tangential components. + // + // Rest length strategy: + // Hierarchy edges (cross-ring): use the radial gap between the two + // ring centres as rest length. This means the spring only activates + // when nodes are angularly misaligned — it does NOT fight radial + // gravity (which was the main cause of long edges in previous builds). + // Cross edges (same or different ring): rest length = 30 px so the + // spring activates sooner and pulls connected nodes closer. + // + // Weight cap removed: all edges use their full weight so cross-ring + // CALLS/IMPORTS springs are strong enough to pull nodes into position. + graph.forEachEdge((edge, edgeAttrs, source, target, sourceAttrs, targetAttrs) => { + const dx = targetAttrs.x - sourceAttrs.x; + const dy = targetAttrs.y - sourceAttrs.y; + const dist = Math.sqrt(dx * dx + dy * dy) || 1; + + const rawWeight = CIRCLES_EDGE_WEIGHTS[edgeAttrs.relationType] ?? 0.2; + + const sourceRing = sourceAttrs.circlesRing ?? 0; + const targetRing = targetAttrs.circlesRing ?? 0; + const restLength = edgeAttrs.isHierarchyEdge + ? Math.abs( + ringTargetR[Math.min(sourceRing, CIRCLES_RING_COUNT - 1)] - + ringTargetR[Math.min(targetRing, CIRCLES_RING_COUNT - 1)], + ) + : 30; + + const stretch = dist - restLength; + if (stretch > 0) { + const f = stretch * rawWeight * 0.55 * dtScale; + const fx = (dx / dist) * f; + const fy = (dy / dist) * f; + forceX.set(source, (forceX.get(source) ?? 0) + fx); + forceY.set(source, (forceY.get(source) ?? 0) + fy); + forceX.set(target, (forceX.get(target) ?? 0) - fx); + forceY.set(target, (forceY.get(target) ?? 0) - fy); + } + }); + + // 3. 2D repulsion — skipped for large graphs (effectiveRepulsionRange = 0). + // For large graphs, gravity + edge springs are sufficient; the O(n×k) + // repulsion sweep is the dominant per-frame cost and not worth the + // quality gain when nodes are already tiny. + if (effectiveRepulsionRange > 0) { + const nodeList = graph.nodes().map((id) => { + const a = graph.getNodeAttributes(id); + return { id, x: a.x, y: a.y, size: a.size ?? 6, ring: a.circlesRing ?? 0 }; + }); + nodeList.sort((a, b) => a.x - b.x); + + for (let i = 0; i < nodeList.length; i++) { + const nodeA = nodeList[i]; + for (let j = i + 1; j < nodeList.length; j++) { + const nodeB = nodeList[j]; + const dx = nodeB.x - nodeA.x; + if (dx > effectiveRepulsionRange) break; + + const dy = nodeB.y - nodeA.y; + const dist2 = dx * dx + dy * dy; + const distVal = Math.sqrt(dist2) || 1; + if (distVal > effectiveRepulsionRange) continue; + + const sameRing = nodeA.ring === nodeB.ring; + const repulsionStrength = sameRing ? 100 : 28; + const minGap = Math.max(28, (nodeA.size + nodeB.size) * 1.8); + let repulsion = + (1 / (distVal + 8) - 1 / (effectiveRepulsionRange + 8)) * + repulsionStrength * + dtScale; + if (distVal < minGap && sameRing) repulsion += (minGap - distVal) * 0.1 * dtScale; + if (repulsion <= 0) continue; + + const fx = (dx / distVal) * repulsion; + const fy = (dy / distVal) * repulsion; + forceX.set(nodeA.id, (forceX.get(nodeA.id) ?? 0) - fx); + forceY.set(nodeA.id, (forceY.get(nodeA.id) ?? 0) - fy); + forceX.set(nodeB.id, (forceX.get(nodeB.id) ?? 0) + fx); + forceY.set(nodeB.id, (forceY.get(nodeB.id) ?? 0) + fy); + } + } + } + + // 4. Angular spread — skipped for large graphs. + // Sorting each ring's nodes every frame is O(k log k); for ring 3 + // with 15k+ nodes this costs several ms/frame. For large graphs + // edge springs already provide angular clustering. + if (useAngularSpread) { + const spreadByRing = new Map< + number, + Array<{ id: string; angle: number; x: number; y: number }> + >(); + graph.forEachNode((nodeId, attrs) => { + const ring = attrs.circlesRing ?? 0; + if (!spreadByRing.has(ring)) spreadByRing.set(ring, []); + spreadByRing.get(ring)!.push({ + id: nodeId, + angle: Math.atan2(attrs.y, attrs.x), + x: attrs.x, + y: attrs.y, + }); + }); + + for (const [, ringNodes] of spreadByRing) { + if (ringNodes.length < 2) continue; + ringNodes.sort((a, b) => a.angle - b.angle); + const count = ringNodes.length; + for (let i = 0; i < count; i++) { + const { id, angle, x, y } = ringNodes[i]; + const idealAngle = ((i + 0.5) / count) * Math.PI * 2 - Math.PI; + let dAngle = idealAngle - angle; + while (dAngle > Math.PI) dAngle -= Math.PI * 2; + while (dAngle < -Math.PI) dAngle += Math.PI * 2; + const r = Math.sqrt(x * x + y * y) || 1; + // Tangential unit vector: (-y/r, x/r) + const tx = -y / r; + const ty = x / r; + const fMag = dAngle * CIRCLES_ANGULAR_SPREAD * dtScale; + forceX.set(id, (forceX.get(id) ?? 0) + tx * fMag); + forceY.set(id, (forceY.get(id) ?? 0) + ty * fMag); + } + } + } + + graph.forEachNode((nodeId, attrs) => { + const fx = forceX.get(nodeId) ?? 0; + const fy = forceY.get(nodeId) ?? 0; + const vx0 = circlesVelocityXRef.current.get(nodeId) ?? 0; + const vy0 = circlesVelocityYRef.current.get(nodeId) ?? 0; + + const ring = attrs.circlesRing ?? 0; + const targetR = ringTargetR[Math.min(ring, CIRCLES_RING_COUNT - 1)]; + const x = attrs.x; + const y = attrs.y; + + // Soft-wall gravity (force 1) already handles radial boundary + // enforcement — no separate resistance decomposition needed. + const rawVx = (vx0 + fx) * dampingFactor; + const rawVy = (vy0 + fy) * dampingFactor; + const newVx = + Math.abs(fx) < CIRCLES_FORCE_DEADZONE && Math.abs(rawVx) < CIRCLES_VELOCITY_DEADZONE + ? 0 + : clamp(rawVx, -velocityCap, velocityCap); + const newVy = + Math.abs(fy) < CIRCLES_FORCE_DEADZONE && Math.abs(rawVy) < CIRCLES_VELOCITY_DEADZONE + ? 0 + : clamp(rawVy, -velocityCap, velocityCap); + + circlesVelocityXRef.current.set(nodeId, newVx); + circlesVelocityYRef.current.set(nodeId, newVy); + + const speed = Math.sqrt(newVx * newVx + newVy * newVy); + totalVelocity += speed; + maxVelocity = Math.max(maxVelocity, speed); + if ( + speed > CIRCLES_VELOCITY_DEADZONE || + Math.abs(fx) > CIRCLES_FORCE_DEADZONE || + Math.abs(fy) > CIRCLES_FORCE_DEADZONE + ) { + activeNodes += 1; + } + + const newX = x + newVx; + const newY = y + newVy; + // Wide safety clamp (1.5 × band_half): the soft-wall gravity keeps + // nodes inside [targetR ± BAND_HALF] naturally. This catches only + // extreme numerical edge cases (e.g. very large forces on first frame). + const newR = Math.sqrt(newX * newX + newY * newY) || 1; + const safeMin = Math.max(1, targetR - CIRCLES_BAND_HALF * 1.5); + const safeMax = targetR + CIRCLES_BAND_HALF * 1.5; + const safeR = clamp(newR, safeMin, safeMax); + const safeScale = safeR / newR; + graph.setNodeAttribute(nodeId, 'x', newX * safeScale); + graph.setNodeAttribute(nodeId, 'y', newY * safeScale); + }); + } + + sigmaRef.current?.refresh(); + + const averageVelocity = totalVelocity / Math.max(1, graph.order); + const elapsed = timestamp - (circlesLayoutStartRef.current ?? timestamp); + + if ( + elapsed >= CIRCLES_LAYOUT_MIN_DURATION && + maxVelocity < stopMaxVelocity && + activeNodes <= Math.max(2, Math.floor(graph.order * stopActiveNodeFraction)) && + averageVelocity < stopAvgVelocity + ) { + circlesStableFramesRef.current += 1; + } else { + circlesStableFramesRef.current = 0; + } + + if ( + circlesStableFramesRef.current >= stopStabilityFrames || + elapsed >= effectiveMaxDuration + ) { + stopCirclesLayout(true); + return; + } + + circlesLayoutFrameRef.current = requestAnimationFrame(step); + }; + + circlesLayoutFrameRef.current = requestAnimationFrame(step); + }, + [stopAllLayouts, stopCirclesLayout], + ); + + // Run ForceAtlas2 layout + const runLayout = useCallback( + (graph: Graph) => { + const nodeCount = graph.order; + if (nodeCount === 0) return; + + stopAllLayouts(false); + + // Get settings + const inferredSettings = forceAtlas2.inferSettings(graph); + const customSettings = getFA2Settings(nodeCount); + const settings = { ...inferredSettings, ...customSettings }; + + const layout = new FA2Layout(graph, { settings }); + + layoutRef.current = layout; + layout.start(); + setIsLayoutRunning(true); + + const duration = getLayoutDuration(nodeCount); + + layoutTimeoutRef.current = setTimeout(() => { + if (layoutRef.current) { + layoutRef.current.stop(); + layoutRef.current = null; + + // Light noverlap cleanup + noverlap.assign(graph, NOVERLAP_SETTINGS); + sigmaRef.current?.refresh(); + + setIsLayoutRunning(false); + } + }, duration); + }, + [stopAllLayouts], + ); const setGraph = useCallback( (newGraph: Graph) => { const sigma = sigmaRef.current; if (!sigma) return; - if (layoutRef.current) { - layoutRef.current.kill(); - layoutRef.current = null; - } - if (layoutTimeoutRef.current) { - clearTimeout(layoutTimeoutRef.current); - layoutTimeoutRef.current = null; - } + stopAllLayouts(false); graphRef.current = newGraph; sigma.setGraph(newGraph); setSelectedNode(null); - runLayout(newGraph); + if (options.layoutMode === 'tree') { + runTreeLayout(newGraph); + } else if (options.layoutMode === 'circles') { + runCirclesLayout(newGraph); + } else { + runLayout(newGraph); + } + sigma.getCamera().animatedReset({ duration: 500 }); }, - [runLayout, setSelectedNode], + [ + options.layoutMode, + runLayout, + runTreeLayout, + runCirclesLayout, + setSelectedNode, + stopAllLayouts, + ], ); const focusNode = useCallback((nodeId: string) => { @@ -603,27 +1540,18 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => { const startLayout = useCallback(() => { const graph = graphRef.current; if (!graph || graph.order === 0) return; - runLayout(graph); - }, [runLayout]); + if (options.layoutMode === 'tree') { + runTreeLayout(graph); + } else if (options.layoutMode === 'circles') { + runCirclesLayout(graph); + } else { + runLayout(graph); + } + }, [options.layoutMode, runLayout, runTreeLayout, runCirclesLayout]); const stopLayout = useCallback(() => { - if (layoutTimeoutRef.current) { - clearTimeout(layoutTimeoutRef.current); - layoutTimeoutRef.current = null; - } - if (layoutRef.current) { - layoutRef.current.stop(); - layoutRef.current = null; - - const graph = graphRef.current; - if (graph) { - noverlap.assign(graph, NOVERLAP_SETTINGS); - sigmaRef.current?.refresh(); - } - - setIsLayoutRunning(false); - } - }, []); + stopAllLayouts(true); + }, [stopAllLayouts]); const refreshHighlights = useCallback(() => { sigmaRef.current?.refresh(); diff --git a/gitnexus-web/src/lib/circles-layout.ts b/gitnexus-web/src/lib/circles-layout.ts new file mode 100644 index 000000000..8a061728b --- /dev/null +++ b/gitnexus-web/src/lib/circles-layout.ts @@ -0,0 +1,307 @@ +import type { KnowledgeGraph } from '../core/graph/types'; +import type { GraphNode, NodeLabel } from 'gitnexus-shared'; +import { NODE_SIZES } from './constants'; + +export interface CirclesNodePosition { + x: number; + y: number; + size: number; + /** Logical ring index 0 (innermost) … RING_COUNT-1 (outermost) */ + ring: number; + /** Angle in radians, stored so the physics can use it as an anchor */ + angle: number; +} + +// --------------------------------------------------------------------------- +// Configurable constants +// --------------------------------------------------------------------------- + +/** Target radius (px) for each ring. Ring 0 is innermost. */ +export const CIRCLES_RING_RADII = [90, 240, 420, 620] as const; + +/** + * Half-width of the allowed radial band around each ring centre. + * Keep this small enough that adjacent rings never overlap. + * Current ring gaps: 150 / 180 / 200 px → band = 45 leaves 60-110 px of clear air. + */ +export const CIRCLES_BAND_HALF = 45; + +/** Number of rings (= number of layers). */ +export const RING_COUNT = CIRCLES_RING_RADII.length; // 4 + +// --------------------------------------------------------------------------- +// Layer assignment — identical to tree-layout so the same node types +// end up in the same conceptual layer. +// --------------------------------------------------------------------------- + +const TYPE_TO_RING: Record = { + // Ring 0 – innermost: structural containers + Project: 0, + Package: 0, + Module: 0, + Folder: 0, + Namespace: 0, + + // Ring 1 – files + File: 1, + Section: 1, + Import: 1, + Route: 1, + Tool: 1, + + // Ring 2 – type definitions + Class: 2, + Interface: 2, + Enum: 2, + Type: 2, + Struct: 2, + Trait: 2, + Union: 2, + Record: 2, + Typedef: 2, + Template: 2, + TypeAlias: 2, + + // Ring 3 – outermost: functions / methods / variables + Function: 3, + Method: 3, + Impl: 3, + Delegate: 3, + Constructor: 3, + Variable: 3, + Const: 3, + Static: 3, + Property: 3, + Decorator: 3, + Annotation: 3, + Macro: 3, + CodeElement: 3, +}; + +const DEFAULT_RING = 1; + +/** Hierarchy edges used for angular-allocation grouping. */ +export const CIRCLES_HIERARCHY_RELATIONS = new Set([ + 'CONTAINS', + 'DEFINES', + 'HAS_METHOD', + 'HAS_PROPERTY', +]); + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function getNodeRing(node: GraphNode): number { + return TYPE_TO_RING[node.label] ?? DEFAULT_RING; +} + +function calculateNodeSize(ring: number, nodeType: NodeLabel): number { + const baseSize = NODE_SIZES[nodeType] || 6; + const ringMultiplier = Math.max(0.6, 1 - ring * 0.12); + return baseSize * ringMultiplier; +} + +function deterministicHash(str: string): number { + let hash = 5381; + for (let i = 0; i < str.length; i++) { + hash = (hash << 5) + hash + str.charCodeAt(i); + hash |= 0; + } + return (Math.abs(hash) % 10000) / 10000; +} + +function buildHierarchyMaps(graph: KnowledgeGraph) { + const childrenByParent = new Map(); + const parentsByChild = new Map(); + + for (const rel of graph.relationships) { + if (!CIRCLES_HIERARCHY_RELATIONS.has(rel.type)) continue; + + if (!childrenByParent.has(rel.sourceId)) childrenByParent.set(rel.sourceId, []); + childrenByParent.get(rel.sourceId)!.push(rel.targetId); + + if (!parentsByChild.has(rel.targetId)) parentsByChild.set(rel.targetId, []); + parentsByChild.get(rel.targetId)!.push(rel.sourceId); + } + + return { childrenByParent, parentsByChild }; +} + +// --------------------------------------------------------------------------- +// Parent-centred angular allocation +// +// Each parent's children are placed in an arc CENTRED on the parent's own +// angle, with arc size proportional to child count. This prevents the +// sequential-concatenation bias (where the largest group's arc centre drifts +// to 90° / 270° regardless of where the parent sits) that caused top-bottom +// crowding in the previous sequential allocation. +// +// Overlapping initial arcs are fine — the physics simulation's angular spread +// force resolves them during the simulation. +// --------------------------------------------------------------------------- + +function initParentCentredAngles( + graph: KnowledgeGraph, + parentsByChild: Map, +): Map { + const positions = new Map(); + + // Group nodes by ring + const nodesByRing: GraphNode[][] = Array.from({ length: RING_COUNT }, () => []); + const nodeRingMap = new Map(); + + for (const node of graph.nodes) { + const ring = getNodeRing(node); + if (ring >= 0 && ring < RING_COUNT) { + nodesByRing[ring].push(node); + nodeRingMap.set(node.id, ring); + } + } + + const TWO_PI = Math.PI * 2; + + // --- Ring 0: sorted alphabetically, evenly spaced around full circle --- + const ring0Nodes = [...nodesByRing[0]].sort((a, b) => + a.properties.name.localeCompare(b.properties.name), + ); + + if (ring0Nodes.length > 0) { + const count = ring0Nodes.length; + for (let i = 0; i < count; i++) { + const node = ring0Nodes[i]; + const angle = (i / count) * TWO_PI; + const r = CIRCLES_RING_RADII[0]; + positions.set(node.id, { + x: r * Math.cos(angle), + y: r * Math.sin(angle), + size: calculateNodeSize(0, node.label), + ring: 0, + angle, + }); + } + } + + // --- Rings 1-3: parent-centred arc placement --- + for (let ring = 1; ring < RING_COUNT; ring++) { + const ringNodes = nodesByRing[ring]; + if (ringNodes.length === 0) continue; + + const r = CIRCLES_RING_RADII[ring]; + + // Find each node's primary parent: placed ancestor with highest ring index + // (so a Method prefers its Class over a distant Package). + const assignedParent = new Map(); + for (const node of ringNodes) { + const parents = parentsByChild.get(node.id) ?? []; + let bestParent: string | null = null; + let bestParentRing = -1; + for (const p of parents) { + if (!positions.has(p)) continue; + const pRing = nodeRingMap.get(p) ?? -1; + if (pRing > bestParentRing) { + bestParentRing = pRing; + bestParent = p; + } + } + if (bestParent) assignedParent.set(node.id, bestParent); + } + + // Bucket into parent groups and orphans + const childrenOfParent = new Map(); + const orphans: GraphNode[] = []; + + for (const node of ringNodes) { + const p = assignedParent.get(node.id); + if (!p) { + orphans.push(node); + } else { + if (!childrenOfParent.has(p)) childrenOfParent.set(p, []); + childrenOfParent.get(p)!.push(node); + } + } + + for (const children of childrenOfParent.values()) { + children.sort((a, b) => a.properties.name.localeCompare(b.properties.name)); + } + orphans.sort((a, b) => a.properties.name.localeCompare(b.properties.name)); + + const totalParented = ringNodes.length - orphans.length; + const parentedFraction = totalParented > 0 ? totalParented / ringNodes.length : 0; + + // Place each parent's children in an arc centred on the parent's angle. + // Arc size ∝ child count relative to all parented nodes. + for (const [parentId, children] of childrenOfParent) { + if (children.length === 0) continue; + + const parentAngle = positions.get(parentId)?.angle ?? 0; + const slotArc = (children.length / totalParented) * parentedFraction * TWO_PI; + const startAngle = parentAngle - slotArc / 2; + + for (let i = 0; i < children.length; i++) { + const angle = startAngle + (i + 0.5) * (slotArc / children.length); + positions.set(children[i].id, { + x: r * Math.cos(angle), + y: r * Math.sin(angle), + size: calculateNodeSize(ring, children[i].label), + ring, + angle, + }); + } + } + + // Orphans: spread evenly in their proportional arc, centred at angle = π + // (left side), away from the 0° / ±π boundary to avoid wrapping artefacts. + if (orphans.length > 0) { + const orphanFraction = orphans.length / ringNodes.length; + const orphanArc = orphanFraction * TWO_PI; + // Centre orphan arc at π so it doesn't overlap with the typical 0° cluster + const orphanStart = Math.PI - orphanArc / 2; + for (let i = 0; i < orphans.length; i++) { + const angle = orphanStart + (i + 0.5) * (orphanArc / orphans.length); + positions.set(orphans[i].id, { + x: r * Math.cos(angle), + y: r * Math.sin(angle), + size: calculateNodeSize(ring, orphans[i].label), + ring, + angle, + }); + } + } + } + + return positions; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Circles view layout: concentric rings with parent-centred angular allocation. + * + * Ring 0 (innermost) = Folders/Packages + * Ring 1 = Files + * Ring 2 = Classes/Interfaces + * Ring 3 (outermost) = Functions/Methods/Variables + * + * Returns initial positions; the physics simulation in useSigma.ts refines + * them using radial gravity + hard band clamping, angular spread, and 2D + * repulsion — identical in structure to the tree-view physics. + */ +export function calculateCirclesLayout(graph: KnowledgeGraph): Map { + const { parentsByChild } = buildHierarchyMaps(graph); + + // 1. Parent-centred angular allocation — no top/bottom bias + const positions = initParentCentredAngles(graph, parentsByChild); + + // 2. Subtle radial jitter only — angular jitter would fight the centred placement + for (const [nodeId, pos] of positions) { + const jitter = (deterministicHash(nodeId + 'r') - 0.5) * 10; // ±10 px + const r = CIRCLES_RING_RADII[pos.ring] + jitter; + pos.x = r * Math.cos(pos.angle); + pos.y = r * Math.sin(pos.angle); + } + + return positions; +} diff --git a/gitnexus-web/src/lib/constants.ts b/gitnexus-web/src/lib/constants.ts index f6804405b..fe0505483 100644 --- a/gitnexus-web/src/lib/constants.ts +++ b/gitnexus-web/src/lib/constants.ts @@ -101,7 +101,9 @@ export const getCommunityColor = (communityIndex: number): string => { return COMMUNITY_COLORS[communityIndex % COMMUNITY_COLORS.length]; }; -// Labels to show by default (hide imports and variables by default as they clutter) +// Labels to show by default (hide imports by default as they clutter). +// Property/Const are the Kotlin/Java equivalents of Variable — include them so +// Kotlin repos don't appear to have no leaf nodes. export const DEFAULT_VISIBLE_LABELS: NodeLabel[] = [ 'Project', 'Package', @@ -111,6 +113,8 @@ export const DEFAULT_VISIBLE_LABELS: NodeLabel[] = [ 'Class', 'Function', 'Method', + 'Property', // Kotlin/Java fields (HAS_PROPERTY + DEFINES File→Property) + 'Const', // Top-level constants 'Interface', 'Enum', 'Type', @@ -127,6 +131,8 @@ export const FILTERABLE_LABELS: NodeLabel[] = [ 'Function', 'Method', 'Variable', + 'Property', // Kotlin/Java field nodes + 'Const', 'Decorator', 'Import', ]; diff --git a/gitnexus-web/src/lib/graph-adapter.test.ts b/gitnexus-web/src/lib/graph-adapter.test.ts new file mode 100644 index 000000000..4cbb6b8ef --- /dev/null +++ b/gitnexus-web/src/lib/graph-adapter.test.ts @@ -0,0 +1,208 @@ +import { describe, it, expect } from 'vitest'; +import { knowledgeGraphToTreeGraphology, knowledgeGraphToCirclesGraphology } from './graph-adapter'; +import type { KnowledgeGraph } from '../core/graph/types'; +import type { GraphNode } from 'gitnexus-shared'; +import { EDGE_INFO } from './constants'; + +function makeNode(id: string, label: string, name: string): GraphNode { + return { + id, + label: label as any, + properties: { name, filePath: '', startLine: 1, endLine: 1 }, + }; +} + +describe('knowledgeGraphToTreeGraphology', () => { + it('should create a graph with tree layout', () => { + const graph: KnowledgeGraph = { + nodes: [ + makeNode('root', 'Project', 'MyProject'), + makeNode('folder', 'Folder', 'src'), + makeNode('file', 'File', 'main.ts'), + ], + relationships: [ + { id: 'r1', type: 'CONTAINS', sourceId: 'root', targetId: 'folder' }, + { id: 'r2', type: 'CONTAINS', sourceId: 'folder', targetId: 'file' }, + { id: 'r3', type: 'CALLS', sourceId: 'file', targetId: 'root' }, + ], + }; + + const sigmaGraph = knowledgeGraphToTreeGraphology(graph); + + expect(sigmaGraph.hasNode('root')).toBe(true); + expect(sigmaGraph.hasNode('folder')).toBe(true); + expect(sigmaGraph.hasNode('file')).toBe(true); + + const rootAttrs = sigmaGraph.getNodeAttributes('root'); + const folderAttrs = sigmaGraph.getNodeAttributes('folder'); + const fileAttrs = sigmaGraph.getNodeAttributes('file'); + + // Tree view is inverted vertically, so files sit above containers. + expect(fileAttrs.y).toBeLessThan(rootAttrs.y); + expect(fileAttrs.y).toBeLessThan(folderAttrs.y); + + // Nodes should have reasonable sizes + expect(rootAttrs.size).toBeGreaterThan(2); + expect(folderAttrs.size).toBeGreaterThan(2); + expect(fileAttrs.size).toBeGreaterThan(2); + + expect(rootAttrs.treeAnchorX).toBe(rootAttrs.x); + expect(rootAttrs.treeAnchorY).toBe(rootAttrs.y); + expect(rootAttrs.treeLayer).toBe(0); + expect(fileAttrs.treeLayer).toBe(1); + }); + + it('should style hierarchy edges differently from cross-cutting edges', () => { + const graph: KnowledgeGraph = { + nodes: [makeNode('a', 'Function', 'fnA'), makeNode('b', 'Function', 'fnB')], + relationships: [ + { id: 'r1', type: 'CONTAINS', sourceId: 'a', targetId: 'b' }, + { id: 'r2', type: 'CALLS', sourceId: 'a', targetId: 'b' }, + ], + }; + + const sigmaGraph = knowledgeGraphToTreeGraphology(graph); + + // MultiGraph allows multiple edges per pair — both CONTAINS and CALLS must survive. + expect(sigmaGraph.size).toBe(2); + + const attrsByType = new Map(); + sigmaGraph.forEachEdge((_edge, attrs) => { + attrsByType.set(attrs.relationType, attrs); + }); + + const containsAttrs = attrsByType.get('CONTAINS'); + expect(containsAttrs).toBeDefined(); + expect(containsAttrs!.isHierarchyEdge).toBe(true); + expect(containsAttrs!.color).toBe(EDGE_INFO.CONTAINS.color); + + const callsAttrs = attrsByType.get('CALLS'); + expect(callsAttrs).toBeDefined(); + expect(callsAttrs!.isHierarchyEdge).toBe(false); + expect(callsAttrs!.color).toBe(EDGE_INFO.CALLS.color); + }); + + it('should treat imports as cross-cutting edges in tree view', () => { + const graph: KnowledgeGraph = { + nodes: [makeNode('a', 'File', 'a.ts'), makeNode('b', 'File', 'b.ts')], + relationships: [{ id: 'r1', type: 'IMPORTS', sourceId: 'a', targetId: 'b' }], + }; + + const sigmaGraph = knowledgeGraphToTreeGraphology(graph); + + sigmaGraph.forEachEdge((edge, attrs) => { + if (attrs.relationType === 'IMPORTS') { + expect(attrs.isHierarchyEdge).toBe(false); + expect(attrs.color).toBe(EDGE_INFO.IMPORTS.color); + } + }); + }); + + it('should handle a medium-sized graph without dropping nodes or edges', () => { + // 2000 nodes + 4000 edges — exercises the adaptive spring iteration path (14 iters). + // Structural assertion only: wall-clock timing is too variable across CI machines. + const nodes: GraphNode[] = Array.from({ length: 2000 }, (_, i) => + makeNode(`n${i}`, i % 4 === 0 ? 'Folder' : i % 4 === 1 ? 'File' : 'Function', `node${i}`), + ); + const relationships = Array.from({ length: 4000 }, (_, i) => ({ + id: `r${i}`, + type: i % 3 === 0 ? 'CONTAINS' : 'CALLS', + sourceId: `n${i % 2000}`, + targetId: `n${(i + 7) % 2000}`, + })); + const graph: KnowledgeGraph = { nodes, relationships }; + + const sigmaGraph = knowledgeGraphToTreeGraphology(graph); + + // All nodes that have a tree-layout position must be present in the output. + expect(sigmaGraph.order).toBe(2000); + // Every relationship whose source and target both exist should produce an edge. + // Self-loops (sourceId === targetId) are excluded — the adapter skips them. + const selfLoops = relationships.filter((r) => r.sourceId === r.targetId).length; + expect(sigmaGraph.size).toBe(relationships.length - selfLoops); + }); +}); + +describe('knowledgeGraphToCirclesGraphology', () => { + it('should place nodes into ring positions based on their type', () => { + const graph: KnowledgeGraph = { + nodes: [ + makeNode('folder', 'Folder', 'src'), + makeNode('file', 'File', 'main.ts'), + makeNode('fn', 'Function', 'doSomething'), + ], + relationships: [ + { id: 'r1', type: 'CONTAINS', sourceId: 'folder', targetId: 'file' }, + { id: 'r2', type: 'CALLS', sourceId: 'file', targetId: 'fn' }, + ], + }; + + const sigmaGraph = knowledgeGraphToCirclesGraphology(graph); + + expect(sigmaGraph.hasNode('folder')).toBe(true); + expect(sigmaGraph.hasNode('file')).toBe(true); + expect(sigmaGraph.hasNode('fn')).toBe(true); + + // Each node carries its ring index and anchor coordinates + const folderAttrs = sigmaGraph.getNodeAttributes('folder'); + const fileAttrs = sigmaGraph.getNodeAttributes('file'); + const fnAttrs = sigmaGraph.getNodeAttributes('fn'); + + expect(typeof folderAttrs.circlesRing).toBe('number'); + expect(typeof folderAttrs.circlesAnchorX).toBe('number'); + expect(typeof folderAttrs.circlesAnchorY).toBe('number'); + + // Folders/Packages live in ring 0 (innermost); Files in ring 1; Functions in ring 3. + expect(folderAttrs.circlesRing).toBe(0); + expect(fileAttrs.circlesRing).toBe(1); + expect(fnAttrs.circlesRing).toBe(3); + + // Tree anchor attributes must NOT be set in circles mode + expect(folderAttrs.treeAnchorX).toBeUndefined(); + expect(folderAttrs.treeAnchorY).toBeUndefined(); + }); + + it('should style hierarchy edges differently from cross-cutting edges', () => { + const graph: KnowledgeGraph = { + nodes: [makeNode('a', 'File', 'a.ts'), makeNode('b', 'Function', 'fn')], + relationships: [ + { id: 'r1', type: 'CONTAINS', sourceId: 'a', targetId: 'b' }, + { id: 'r2', type: 'CALLS', sourceId: 'a', targetId: 'b' }, + ], + }; + + // MultiGraph allows multiple edges per pair — both CONTAINS and CALLS must survive. + const sigmaGraph = knowledgeGraphToCirclesGraphology(graph); + + expect(sigmaGraph.size).toBe(2); + + const attrsByType = new Map(); + sigmaGraph.forEachEdge((_, attrs) => { + attrsByType.set(attrs.relationType, attrs); + }); + + const containsAttrs = attrsByType.get('CONTAINS'); + expect(containsAttrs).toBeDefined(); + expect(containsAttrs!.isHierarchyEdge).toBe(true); + expect(containsAttrs!.color).toBe(EDGE_INFO.CONTAINS.color); + + const callsAttrs = attrsByType.get('CALLS'); + expect(callsAttrs).toBeDefined(); + expect(callsAttrs!.isHierarchyEdge).toBe(false); + expect(callsAttrs!.color).toBe(EDGE_INFO.CALLS.color); + }); + + it('should treat CALLS as a cross-cutting edge in circles view', () => { + const graph: KnowledgeGraph = { + nodes: [makeNode('a', 'Function', 'fnA'), makeNode('b', 'Function', 'fnB')], + relationships: [{ id: 'r1', type: 'CALLS', sourceId: 'a', targetId: 'b' }], + }; + + const sigmaGraph = knowledgeGraphToCirclesGraphology(graph); + + sigmaGraph.forEachEdge((_, attrs) => { + expect(attrs.isHierarchyEdge).toBe(false); + expect(attrs.color).toBe(EDGE_INFO.CALLS.color); + }); + }); +}); diff --git a/gitnexus-web/src/lib/graph-adapter.ts b/gitnexus-web/src/lib/graph-adapter.ts index 03caf8c92..bb3f5f11a 100644 --- a/gitnexus-web/src/lib/graph-adapter.ts +++ b/gitnexus-web/src/lib/graph-adapter.ts @@ -1,7 +1,9 @@ -import Graph from 'graphology'; +import Graph, { MultiGraph } from 'graphology'; import type { NodeLabel } from 'gitnexus-shared'; import type { KnowledgeGraph } from '../core/graph/types'; -import { NODE_COLORS, NODE_SIZES, getCommunityColor } from './constants'; +import { EDGE_INFO, NODE_COLORS, NODE_SIZES, getCommunityColor } from './constants'; +import { calculateTreeLayout } from './tree-layout'; +import { calculateCirclesLayout } from './circles-layout'; export interface SigmaNodeAttributes { x: number; @@ -17,6 +19,13 @@ export interface SigmaNodeAttributes { zIndex?: number; highlighted?: boolean; mass?: number; // ForceAtlas2 mass - higher = more repulsion + treeAnchorX?: number; + treeAnchorY?: number; + treeLayer?: number; + circlesAnchorX?: number; + circlesAnchorY?: number; + circlesRing?: number; + circlesAnchorAngle?: number; community?: number; // Community index from Leiden algorithm communityColor?: string; // Color assigned by community } @@ -28,6 +37,7 @@ export interface SigmaEdgeAttributes { type?: string; curvature?: number; zIndex?: number; + isHierarchyEdge?: boolean; } /** @@ -91,18 +101,21 @@ export const knowledgeGraphToGraphology = ( // Build parent-child map from hierarchy relationships // CONTAINS: Folder -> File // DEFINES: File -> Function/Class/Interface/Method - // IMPORTS: File -> Import - // parent -> children + // parent -> children (used only for initial spatial seeding before FA2 runs) const parentToChildren = new Map(); // child -> parent const childToParent = new Map(); - const hierarchyRelations = new Set(['CONTAINS', 'DEFINES', 'IMPORTS']); + // IMPORTS is not a true structural hierarchy, but treating it as a spatial + // seed helps FA2 converge for import-heavy codebases: files that import each + // other start near each other, so the simulation doesn't have to close many + // long cross-package springs from scratch. + const spatialSeedRelations = new Set(['CONTAINS', 'DEFINES', 'IMPORTS']); knowledgeGraph.relationships.forEach((rel) => { - // These relationships represent parent-child hierarchy for positioning - if (hierarchyRelations.has(rel.type)) { - // source CONTAINS/DEFINES/IMPORTS target, so source is parent + // These relationships determine initial node positions (not graph semantics) + if (spatialSeedRelations.has(rel.type)) { + // source CONTAINS/DEFINES/IMPORTS target → source acts as spatial parent if (!parentToChildren.has(rel.sourceId)) { parentToChildren.set(rel.sourceId, []); } @@ -295,23 +308,202 @@ export const knowledgeGraphToGraphology = ( // TYPE RELATIONSHIPS - Warm colors (OOP) EXTENDS: { color: '#c2410c', sizeMultiplier: 1.0 }, // Orange - extension IMPLEMENTS: { color: '#be185d', sizeMultiplier: 0.9 }, // Pink - interface implementation + + // KOTLIN/JAVA HIERARCHY — same hues as their logical equivalents so force + // mode renders these consistently with tree/circles view. + HAS_METHOD: { color: EDGE_INFO.DEFINES.color, sizeMultiplier: 0.4 }, // Class→Method (≈ DEFINES) + HAS_PROPERTY: { color: EDGE_INFO.CONTAINS.color, sizeMultiplier: 0.35 }, // Class→Property (≈ CONTAINS) + }; + + // Two-pass insertion so hierarchy/DEFINES edges are drawn first (behind) + // and cross-edges (CALLS, IMPORTS, EXTENDS) are drawn on top. + const BACKGROUND_EDGE_TYPES = new Set(['CONTAINS', 'DEFINES', 'HAS_METHOD', 'HAS_PROPERTY']); + + const addEdge = (rel: (typeof knowledgeGraph.relationships)[number]) => { + if (!graph.hasNode(rel.sourceId) || !graph.hasNode(rel.targetId)) return; + if (graph.hasEdge(rel.sourceId, rel.targetId)) return; + const style = EDGE_STYLES[rel.type] || { color: '#4a4a5a', sizeMultiplier: 0.5 }; + const curvature = 0.12 + Math.random() * 0.08; + graph.addEdge(rel.sourceId, rel.targetId, { + size: edgeBaseSize * style.sizeMultiplier, + color: style.color, + relationType: rel.type, + type: 'curved', + curvature, + }); + }; + + // Pass 1: background (hierarchy) edges — rendered behind + knowledgeGraph.relationships.forEach((rel) => { + if (BACKGROUND_EDGE_TYPES.has(rel.type)) addEdge(rel); + }); + // Pass 2: foreground (cross) edges — rendered on top + knowledgeGraph.relationships.forEach((rel) => { + if (!BACKGROUND_EDGE_TYPES.has(rel.type)) addEdge(rel); + }); + + return graph; +}; + +export const knowledgeGraphToTreeGraphology = ( + knowledgeGraph: KnowledgeGraph, +): Graph => { + const graph = new MultiGraph(); + const nodeCount = knowledgeGraph.nodes.length; + const positions = calculateTreeLayout(knowledgeGraph); + + // Add nodes with tree positions + for (const node of knowledgeGraph.nodes) { + const pos = positions.get(node.id); + if (!pos) continue; + + const baseSize = NODE_SIZES[node.label] || 8; + const scaledSize = getScaledNodeSize(baseSize, nodeCount); + const finalSize = Math.max(2, pos.size * (scaledSize / baseSize)); + + graph.addNode(node.id, { + x: pos.x, + y: pos.y, + size: finalSize, + color: NODE_COLORS[node.label] || '#9ca3af', + label: node.properties.name, + nodeType: node.label, + filePath: node.properties.filePath, + startLine: node.properties.startLine, + endLine: node.properties.endLine, + hidden: false, + mass: 1, // No force layout in tree view + treeAnchorX: pos.x, + treeAnchorY: pos.y, + treeLayer: pos.depth, + }); + } + + // Add edges with tree-specific styling + const edgeBaseSize = nodeCount > 20000 ? 0.4 : nodeCount > 5000 ? 0.6 : 1.0; + + const HIERARCHY_EDGE_STYLES: Record = { + CONTAINS: { color: EDGE_INFO.CONTAINS.color, sizeMultiplier: 0.3 }, + DEFINES: { color: EDGE_INFO.DEFINES.color, sizeMultiplier: 0.3 }, + HAS_METHOD: { color: EDGE_INFO.DEFINES.color, sizeMultiplier: 0.3 }, // Kotlin Class→Method hierarchy + HAS_PROPERTY: { color: EDGE_INFO.CONTAINS.color, sizeMultiplier: 0.25 }, // Kotlin Class→Property hierarchy + }; + + const CROSS_EDGE_STYLES: Record = { + IMPORTS: { color: EDGE_INFO.IMPORTS.color, sizeMultiplier: 0.6 }, + CALLS: { color: EDGE_INFO.CALLS.color, sizeMultiplier: 0.8 }, + EXTENDS: { color: EDGE_INFO.EXTENDS.color, sizeMultiplier: 1.0 }, + IMPLEMENTS: { color: EDGE_INFO.IMPLEMENTS.color, sizeMultiplier: 0.9 }, + }; + + // Two-pass insertion: hierarchy edges first (rendered behind), cross-edges on top. + // Dedup by relationship ID so CONTAINS + CALLS between the same pair both survive. + const addedTreeRelIds = new Set(); + const addTreeEdge = (rel: (typeof knowledgeGraph.relationships)[number]) => { + if (!graph.hasNode(rel.sourceId) || !graph.hasNode(rel.targetId)) return; + if (addedTreeRelIds.has(rel.id)) return; + addedTreeRelIds.add(rel.id); + const isHierarchy = HIERARCHY_EDGE_STYLES[rel.type] !== undefined; + const style = isHierarchy + ? HIERARCHY_EDGE_STYLES[rel.type] + : CROSS_EDGE_STYLES[rel.type] || { color: '#4a4a5a', sizeMultiplier: 0.5 }; + graph.addEdge(rel.sourceId, rel.targetId, { + size: edgeBaseSize * style.sizeMultiplier, + color: style.color, + relationType: rel.type, + type: 'curved', + curvature: 0.1 + Math.random() * 0.1, + isHierarchyEdge: isHierarchy, + }); }; knowledgeGraph.relationships.forEach((rel) => { - if (graph.hasNode(rel.sourceId) && graph.hasNode(rel.targetId)) { - if (!graph.hasEdge(rel.sourceId, rel.targetId)) { - const style = EDGE_STYLES[rel.type] || { color: '#4a4a5a', sizeMultiplier: 0.5 }; - const curvature = 0.12 + Math.random() * 0.08; + if (HIERARCHY_EDGE_STYLES[rel.type] !== undefined) addTreeEdge(rel); + }); + knowledgeGraph.relationships.forEach((rel) => { + if (HIERARCHY_EDGE_STYLES[rel.type] === undefined) addTreeEdge(rel); + }); - graph.addEdge(rel.sourceId, rel.targetId, { - size: edgeBaseSize * style.sizeMultiplier, - color: style.color, - relationType: rel.type, - type: 'curved', - curvature: curvature, - }); - } - } + return graph; +}; + +export const knowledgeGraphToCirclesGraphology = ( + knowledgeGraph: KnowledgeGraph, +): Graph => { + const graph = new MultiGraph(); + const nodeCount = knowledgeGraph.nodes.length; + const positions = calculateCirclesLayout(knowledgeGraph); + + for (const node of knowledgeGraph.nodes) { + const pos = positions.get(node.id); + if (!pos) continue; + + const baseSize = NODE_SIZES[node.label] || 8; + const scaledSize = getScaledNodeSize(baseSize, nodeCount); + const finalSize = Math.max(2, pos.size * (scaledSize / baseSize)); + + graph.addNode(node.id, { + x: pos.x, + y: pos.y, + size: finalSize, + color: NODE_COLORS[node.label] || '#9ca3af', + label: node.properties.name, + nodeType: node.label, + filePath: node.properties.filePath, + startLine: node.properties.startLine, + endLine: node.properties.endLine, + hidden: false, + mass: 1, + circlesAnchorX: pos.x, + circlesAnchorY: pos.y, + circlesRing: pos.ring, + circlesAnchorAngle: pos.angle, + }); + } + + const edgeBaseSize = nodeCount > 20000 ? 0.4 : nodeCount > 5000 ? 0.6 : 1.0; + + // Reuse the same edge style maps as tree view + const HIERARCHY_EDGE_STYLES: Record = { + CONTAINS: { color: EDGE_INFO.CONTAINS.color, sizeMultiplier: 0.3 }, + DEFINES: { color: EDGE_INFO.DEFINES.color, sizeMultiplier: 0.3 }, + HAS_METHOD: { color: EDGE_INFO.DEFINES.color, sizeMultiplier: 0.3 }, + HAS_PROPERTY: { color: EDGE_INFO.CONTAINS.color, sizeMultiplier: 0.25 }, + }; + + const CROSS_EDGE_STYLES: Record = { + IMPORTS: { color: EDGE_INFO.IMPORTS.color, sizeMultiplier: 0.6 }, + CALLS: { color: EDGE_INFO.CALLS.color, sizeMultiplier: 0.8 }, + EXTENDS: { color: EDGE_INFO.EXTENDS.color, sizeMultiplier: 1.0 }, + IMPLEMENTS: { color: EDGE_INFO.IMPLEMENTS.color, sizeMultiplier: 0.9 }, + }; + + // Two-pass insertion: hierarchy edges first (rendered behind), cross-edges on top. + // Dedup by relationship ID so CONTAINS + CALLS between the same pair both survive. + const addedCirclesRelIds = new Set(); + const addCirclesEdge = (rel: (typeof knowledgeGraph.relationships)[number]) => { + if (!graph.hasNode(rel.sourceId) || !graph.hasNode(rel.targetId)) return; + if (addedCirclesRelIds.has(rel.id)) return; + addedCirclesRelIds.add(rel.id); + const isHierarchy = HIERARCHY_EDGE_STYLES[rel.type] !== undefined; + const style = isHierarchy + ? HIERARCHY_EDGE_STYLES[rel.type] + : CROSS_EDGE_STYLES[rel.type] || { color: '#4a4a5a', sizeMultiplier: 0.5 }; + graph.addEdge(rel.sourceId, rel.targetId, { + size: edgeBaseSize * style.sizeMultiplier, + color: style.color, + relationType: rel.type, + type: 'curved', + curvature: 0.1 + Math.random() * 0.1, + isHierarchyEdge: isHierarchy, + }); + }; + + knowledgeGraph.relationships.forEach((rel) => { + if (HIERARCHY_EDGE_STYLES[rel.type] !== undefined) addCirclesEdge(rel); + }); + knowledgeGraph.relationships.forEach((rel) => { + if (HIERARCHY_EDGE_STYLES[rel.type] === undefined) addCirclesEdge(rel); }); return graph; diff --git a/gitnexus-web/src/lib/lucide-icons.tsx b/gitnexus-web/src/lib/lucide-icons.tsx index dc69b279f..7d9b5fc7d 100644 --- a/gitnexus-web/src/lib/lucide-icons.tsx +++ b/gitnexus-web/src/lib/lucide-icons.tsx @@ -79,6 +79,7 @@ export { Loader2, Maximize2, MousePointerClick, + Network, PanelLeft, PanelLeftClose, PanelRightClose, diff --git a/gitnexus-web/src/lib/tree-layout.test.ts b/gitnexus-web/src/lib/tree-layout.test.ts new file mode 100644 index 000000000..3858a59cd --- /dev/null +++ b/gitnexus-web/src/lib/tree-layout.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect } from 'vitest'; +import { calculateTreeLayout } from './tree-layout'; +import type { KnowledgeGraph } from '../core/graph/types'; +import type { GraphNode } from 'gitnexus-shared'; + +function makeNode(id: string, label: string, name: string): GraphNode { + return { + id, + label: label as any, + properties: { name, filePath: '', startLine: 1, endLine: 1 }, + }; +} + +describe('calculateTreeLayout', () => { + it('should place different types in correct layers', () => { + const graph: KnowledgeGraph = { + nodes: [ + makeNode('f1', 'Folder', 'src'), + makeNode('file1', 'File', 'main.ts'), + makeNode('cls1', 'Class', 'MyClass'), + makeNode('fn1', 'Function', 'myFunc'), + ], + relationships: [], + }; + + const positions = calculateTreeLayout(graph); + + const folderY = positions.get('f1')!.y; + const fileY = positions.get('file1')!.y; + const classY = positions.get('cls1')!.y; + const funcY = positions.get('fn1')!.y; + + // Layer ordering is visually inverted in tree view: + // Function < Class < File < Folder + expect(funcY).toBeLessThan(classY); + expect(classY).toBeLessThan(fileY); + expect(fileY).toBeLessThan(folderY); + }); + + it('should arrange many same-type nodes in a grid within a layer', () => { + const nodes: GraphNode[] = []; + for (let i = 0; i < 40; i++) { + nodes.push(makeNode(`fn${i}`, 'Function', `func${i}`)); + } + + const graph: KnowledgeGraph = { nodes, relationships: [] }; + const positions = calculateTreeLayout(graph); + + const xValues = nodes.map((n) => positions.get(n.id)!.x); + const yValues = nodes.map((n) => positions.get(n.id)!.y); + + // Should have multiple columns (spread horizontally) + const uniqueX = [...new Set(xValues)].sort((a, b) => a - b); + expect(uniqueX.length).toBeGreaterThan(3); + + // Should have multiple rows (spread vertically within layer) + const uniqueY = [...new Set(yValues)].sort((a, b) => a - b); + expect(uniqueY.length).toBeGreaterThan(1); + + // Overall width should be significant + const minX = Math.min(...xValues); + const maxX = Math.max(...xValues); + expect(maxX - minX).toBeGreaterThan(500); + + // Height spread within layer should be moderate (not a single line) + const minY = Math.min(...yValues); + const maxY = Math.max(...yValues); + expect(maxY - minY).toBeGreaterThan(50); + expect(maxY - minY).toBeLessThan(250); // But not too tall + }); + + it('should sort nodes alphabetically within layers', () => { + const graph: KnowledgeGraph = { + nodes: [ + makeNode('z', 'Function', 'zFn'), + makeNode('a', 'Function', 'aFn'), + makeNode('m', 'Function', 'mFn'), + ], + relationships: [], + }; + + const positions = calculateTreeLayout(graph); + + // In grid layout, 'a' should appear before 'm' and 'z' in reading order + // (left-to-right, top-to-bottom) + const aPos = positions.get('a')!; + const mPos = positions.get('m')!; + const zPos = positions.get('z')!; + + // Reading order: a comes before m, which comes before z + const aIndex = aPos.y * 10000 + aPos.x; + const mIndex = mPos.y * 10000 + mPos.x; + const zIndex = zPos.y * 10000 + zPos.x; + + expect(aIndex).toBeLessThan(mIndex); + expect(mIndex).toBeLessThan(zIndex); + }); + + it('should place multiple node types in correct layers', () => { + const graph: KnowledgeGraph = { + nodes: [ + makeNode('folder', 'Folder', 'src'), + makeNode('file', 'File', 'main.ts'), + makeNode('iface', 'Interface', 'MyInterface'), + makeNode('enum', 'Enum', 'MyEnum'), + makeNode('method', 'Method', 'myMethod'), + ], + relationships: [], + }; + + const positions = calculateTreeLayout(graph); + + // Folder now appears below files/types/methods in the inverted tree view + expect(positions.get('file')!.y).toBeLessThan(positions.get('folder')!.y); + + // File (layer 1) should be below Class/Interface/Enum (layer 2) + expect(positions.get('iface')!.y).toBeLessThan(positions.get('file')!.y); + expect(positions.get('enum')!.y).toBeLessThan(positions.get('file')!.y); + + // Interface/Enum (layer 2) should be below Method (layer 3) + expect(positions.get('method')!.y).toBeLessThan(positions.get('iface')!.y); + expect(positions.get('method')!.y).toBeLessThan(positions.get('enum')!.y); + }); + + it('should keep node sizes reasonable', () => { + const graph: KnowledgeGraph = { + nodes: [ + makeNode('folder', 'Folder', 'src'), + makeNode('file', 'File', 'main.ts'), + makeNode('fn', 'Function', 'myFunc'), + ], + relationships: [], + }; + + const positions = calculateTreeLayout(graph); + + for (const id of ['folder', 'file', 'fn']) { + expect(positions.get(id)!.size).toBeGreaterThan(2); + expect(positions.get(id)!.size).toBeLessThan(25); + } + }); + + it('should spread sibling branches under their structural parent in auto mode', () => { + const graph: KnowledgeGraph = { + nodes: [ + makeNode('folder', 'Folder', 'apps'), + makeNode('fileA', 'File', 'a.ts'), + makeNode('fileB', 'File', 'b.ts'), + makeNode('fileC', 'File', 'c.ts'), + makeNode('fnA', 'Function', 'fnA'), + makeNode('fnB', 'Function', 'fnB'), + makeNode('fnC', 'Function', 'fnC'), + ], + relationships: [ + { id: 'r1', type: 'CONTAINS', sourceId: 'folder', targetId: 'fileA' }, + { id: 'r2', type: 'CONTAINS', sourceId: 'folder', targetId: 'fileB' }, + { id: 'r3', type: 'CONTAINS', sourceId: 'folder', targetId: 'fileC' }, + { id: 'r4', type: 'DEFINES', sourceId: 'fileA', targetId: 'fnA' }, + { id: 'r5', type: 'DEFINES', sourceId: 'fileB', targetId: 'fnB' }, + { id: 'r6', type: 'DEFINES', sourceId: 'fileC', targetId: 'fnC' }, + ], + }; + + const positions = calculateTreeLayout(graph); + const fileXs = ['fileA', 'fileB', 'fileC'].map((id) => positions.get(id)!.x); + const fnXs = ['fnA', 'fnB', 'fnC'].map((id) => positions.get(id)!.x); + + expect(Math.max(...fileXs) - Math.min(...fileXs)).toBeGreaterThan(120); + expect(Math.max(...fnXs) - Math.min(...fnXs)).toBeGreaterThan(120); + expect(Math.abs(positions.get('fileA')!.x - positions.get('fnA')!.x)).toBeLessThan(120); + expect(Math.abs(positions.get('fileB')!.x - positions.get('fnB')!.x)).toBeLessThan(120); + expect(Math.abs(positions.get('fileC')!.x - positions.get('fnC')!.x)).toBeLessThan(120); + }); + + it('should let long edges pull connected nodes closer without breaking their layer', () => { + const nodes = Array.from({ length: 10 }, (_, i) => makeNode(`fn${i}`, 'Function', `fn${i}`)); + + const baseline = calculateTreeLayout({ nodes, relationships: [] }); + const relaxed = calculateTreeLayout({ + nodes, + relationships: [ + { id: 'r1', type: 'CALLS', sourceId: 'fn0', targetId: 'fn9' }, + { id: 'r2', type: 'CALLS', sourceId: 'fn1', targetId: 'fn8' }, + ], + }); + + const baselineDistance = Math.abs(baseline.get('fn0')!.x - baseline.get('fn9')!.x); + const relaxedDistance = Math.abs(relaxed.get('fn0')!.x - relaxed.get('fn9')!.x); + expect(relaxedDistance).toBeLessThan(baselineDistance); + + const relaxedYValues = nodes.map((node) => relaxed.get(node.id)!.y); + const minY = Math.min(...relaxedYValues); + const maxY = Math.max(...relaxedYValues); + expect(maxY - minY).toBeGreaterThan(50); + expect(maxY - minY).toBeLessThan(250); + }); + + it('should preserve layer spread under heavy edge attraction', () => { + const nodes: GraphNode[] = [makeNode('file', 'File', 'hub.ts')]; + for (let i = 0; i < 18; i++) { + nodes.push(makeNode(`fn${i}`, 'Function', `fn${i}`)); + } + + const relationships = Array.from({ length: 18 }, (_, i) => ({ + id: `r${i}`, + type: 'CALLS', + sourceId: `fn${i}`, + targetId: 'file', + })); + + const positions = calculateTreeLayout({ nodes, relationships }); + const functionXs = Array.from({ length: 18 }, (_, i) => positions.get(`fn${i}`)!.x); + + expect(Math.max(...functionXs) - Math.min(...functionXs)).toBeGreaterThan(280); + expect(positions.get('file')!.y).toBeGreaterThan(positions.get('fn0')!.y); + }); +}); diff --git a/gitnexus-web/src/lib/tree-layout.ts b/gitnexus-web/src/lib/tree-layout.ts new file mode 100644 index 000000000..5fb284d5c --- /dev/null +++ b/gitnexus-web/src/lib/tree-layout.ts @@ -0,0 +1,570 @@ +import type { KnowledgeGraph } from '../core/graph/types'; +import type { GraphNode, NodeLabel } from 'gitnexus-shared'; +import { NODE_SIZES } from './constants'; + +export interface TreeNodePosition { + x: number; + y: number; + size: number; + depth: number; +} + +/** + * Maps node types to display layers in the tree view. + * Layer 0 = top (containers), Layer 3 = bottom (functions/methods). + */ +const TYPE_TO_LAYER: Record = { + // Layer 0: Structural containers + Project: 0, + Package: 0, + Module: 0, + Folder: 0, + Namespace: 0, + + // Layer 1: Files + File: 1, + Section: 1, + Import: 1, + Route: 1, + Tool: 1, + + // Layer 2: Type definitions + Class: 2, + Interface: 2, + Enum: 2, + Type: 2, + Struct: 2, + Trait: 2, + Union: 2, + Record: 2, + Typedef: 2, + Template: 2, + TypeAlias: 2, + + // Layer 3: Functions / Methods + Function: 3, + Method: 3, + Impl: 3, + Delegate: 3, + Constructor: 3, + Variable: 3, + Const: 3, + Static: 3, + Property: 3, + Decorator: 3, + Annotation: 3, + Macro: 3, + CodeElement: 3, +}; + +/** Fallback layer for unmapped types. */ +const DEFAULT_LAYER = 1; + +/** Virtual canvas size for layout calculation. */ +const CANVAS_WIDTH = 1200; +const CANVAS_HEIGHT = 800; +const LAYER_COUNT = 4; +const LAYER_HEIGHT = CANVAS_HEIGHT / LAYER_COUNT; // 200 +const PADDING_X = 60; +const PADDING_Y = 15; +const MIN_NODE_GAP = 45; +const MAX_LAYER_ROW_SPREAD = 132; +// HAS_METHOD and HAS_PROPERTY are Kotlin/Java-style hierarchy edges +// (Class→Method, Class→Property). Treat them like DEFINES for layout purposes +// so Methods/Properties cluster beneath their parent Class horizontally. +const HIERARCHY_RELATIONS = new Set(['CONTAINS', 'DEFINES', 'HAS_METHOD', 'HAS_PROPERTY']); +const MAX_X = (CANVAS_WIDTH - PADDING_X * 2) / 2; + +const RELATION_SPRING_WEIGHTS: Record = { + CONTAINS: 0.12, + DEFINES: 0.16, + HAS_METHOD: 0.16, // Same as DEFINES — keeps methods near their class + HAS_PROPERTY: 0.14, // Slightly weaker — properties can spread more + IMPORTS: 0.2, + CALLS: 0.24, + EXTENDS: 0.18, + IMPLEMENTS: 0.18, +}; + +function calculateNodeSize(layer: number, nodeType: NodeLabel): number { + const baseSize = NODE_SIZES[nodeType] || 6; + const layerMultiplier = Math.max(0.6, 1 - layer * 0.12); + return baseSize * layerMultiplier; +} + +function deterministicHash(str: string): number { + let hash = 5381; + for (let i = 0; i < str.length; i++) { + hash = (hash << 5) + hash + str.charCodeAt(i); + hash |= 0; + } + return (Math.abs(hash) % 10000) / 10000; +} + +function getNodeLayer(node: GraphNode): number { + return TYPE_TO_LAYER[node.label] ?? DEFAULT_LAYER; +} + +function buildHierarchyMaps(graph: KnowledgeGraph) { + const childrenByParent = new Map(); + const parentsByChild = new Map(); + + for (const rel of graph.relationships) { + if (!HIERARCHY_RELATIONS.has(rel.type)) continue; + + if (!childrenByParent.has(rel.sourceId)) { + childrenByParent.set(rel.sourceId, []); + } + childrenByParent.get(rel.sourceId)!.push(rel.targetId); + + if (!parentsByChild.has(rel.targetId)) { + parentsByChild.set(rel.targetId, []); + } + parentsByChild.get(rel.targetId)!.push(rel.sourceId); + } + + return { childrenByParent, parentsByChild }; +} + +function buildLayerNodeIds(graph: KnowledgeGraph): string[][] { + const nodeIdsByLayer: string[][] = Array.from({ length: LAYER_COUNT }, () => []); + + for (const node of graph.nodes) { + const layer = getNodeLayer(node); + if (layer >= 0 && layer < LAYER_COUNT) { + nodeIdsByLayer[layer].push(node.id); + } + } + + return nodeIdsByLayer; +} + +function getRestEdgeLength( + relationType: string, + source: TreeNodePosition, + target: TreeNodePosition, +) { + const depthGap = Math.abs(source.depth - target.depth); + const baseLength = HIERARCHY_RELATIONS.has(relationType) ? 60 : 85; + return baseLength + depthGap * 40; +} + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +function getLayerRowOffsets(nodeCount: number): number[] { + if (nodeCount <= 4) return [0]; + + const rowCount = nodeCount <= 16 ? 2 : 3; + const totalSpread = rowCount === 2 ? 72 : MAX_LAYER_ROW_SPREAD; + const rowGap = totalSpread / (rowCount - 1); + + return Array.from({ length: rowCount }, (_, rowIndex) => -totalSpread / 2 + rowIndex * rowGap); +} + +function placeNodesInSlice( + positions: Map, + nodes: GraphNode[], + startX: number, + slotWidth: number, + layerY: number, + layer: number, +) { + const rowOffsets = getLayerRowOffsets(nodes.length); + const rowCount = rowOffsets.length; + const baseNodesPerRow = Math.floor(nodes.length / rowCount); + const remainder = nodes.length % rowCount; + + let cursor = 0; + + for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) { + const nodesInRow = baseNodesPerRow + (rowIndex < remainder ? 1 : 0); + if (nodesInRow === 0) continue; + + const rowSpacing = slotWidth / nodesInRow; + for (let i = 0; i < nodesInRow; i++) { + const node = nodes[cursor++]; + positions.set(node.id, { + x: startX + (i + 0.5) * rowSpacing, + y: layerY + rowOffsets[rowIndex], + size: calculateNodeSize(layer, node.label), + depth: layer, + }); + } + } +} + +function enforceLayerSpacing( + layerNodeIds: string[], + positions: Map, + anchorXByNode: Map, +) { + if (layerNodeIds.length < 2) return; + + const sortedIds = [...layerNodeIds].sort((a, b) => positions.get(a)!.x - positions.get(b)!.x); + + for (let pass = 0; pass < 2; pass++) { + for (let i = 1; i < sortedIds.length; i++) { + const prev = positions.get(sortedIds[i - 1])!; + const curr = positions.get(sortedIds[i])!; + const minGap = Math.max(MIN_NODE_GAP * 0.65, (prev.size + curr.size) * 1.7); + const gap = curr.x - prev.x; + + if (gap < minGap) { + const push = (minGap - gap) / 2; + prev.x -= push; + curr.x += push; + } + } + + for (let i = sortedIds.length - 2; i >= 0; i--) { + const curr = positions.get(sortedIds[i])!; + const next = positions.get(sortedIds[i + 1])!; + const minGap = Math.max(MIN_NODE_GAP * 0.65, (curr.size + next.size) * 1.7); + const gap = next.x - curr.x; + + if (gap < minGap) { + const push = (minGap - gap) / 2; + curr.x -= push; + next.x += push; + } + } + } + + const anchorCenter = + sortedIds.reduce((sum, nodeId) => sum + (anchorXByNode.get(nodeId) ?? 0), 0) / sortedIds.length; + const currentCenter = + sortedIds.reduce((sum, nodeId) => sum + positions.get(nodeId)!.x, 0) / sortedIds.length; + const recenterDelta = currentCenter - anchorCenter; + + for (const nodeId of sortedIds) { + const pos = positions.get(nodeId)!; + pos.x = clamp(pos.x - recenterDelta, -MAX_X, MAX_X); + } +} + +/** + * Initialize positions using proportional X allocation. + * + * Each parent in layer N is allocated a horizontal slice proportional to how + * many direct hierarchy children it has in layer N+1. Children are then placed + * evenly within their parent's slice. Orphan nodes (no placed hierarchy parent) + * fill a proportional slice at the far right. + * + * Why this is better than uniform distribution: + * 1. Dense parents (many children) get more canvas space → no artificial + * crowding in the centre even before the physics simulation runs. + * 2. Each child starts within its parent's X slice → parent-child edges are + * short by construction, so the spring system converges quickly. + * 3. Orphan nodes land at the right end; their spring connections pull them + * toward better positions at runtime without fighting a spread force. + */ +function initProportionalPositions( + graph: KnowledgeGraph, + parentsByChild: Map, +): Map { + const positions = new Map(); + + // Group nodes by layer and build a fast layer-lookup map. + const nodesByLayer: GraphNode[][] = Array.from({ length: LAYER_COUNT }, () => []); + const nodeLayerMap = new Map(); + for (const node of graph.nodes) { + const layer = getNodeLayer(node); + if (layer >= 0 && layer < LAYER_COUNT) { + nodesByLayer[layer].push(node); + nodeLayerMap.set(node.id, layer); + } + } + + const availableWidth = CANVAS_WIDTH - PADDING_X * 2; + const halfWidth = availableWidth / 2; + const availableHeight = LAYER_HEIGHT - PADDING_Y * 2; + + // Y centre for a given logical layer (layer 0 = top). + const getLayerY = (layer: number): number => { + const visualLayer = LAYER_COUNT - 1 - layer; + return visualLayer * LAYER_HEIGHT + PADDING_Y + availableHeight / 2; + }; + + // --- Layer 0: sorted alphabetically, evenly spaced --- + const layer0Nodes = [...nodesByLayer[0]].sort((a, b) => + a.properties.name.localeCompare(b.properties.name), + ); + if (layer0Nodes.length > 0) { + const spacing = availableWidth / layer0Nodes.length; + for (let i = 0; i < layer0Nodes.length; i++) { + const node = layer0Nodes[i]; + positions.set(node.id, { + x: -halfWidth + (i + 0.5) * spacing, + y: getLayerY(0), + size: calculateNodeSize(0, node.label), + depth: 0, + }); + } + } + + // --- Layers 1-3: proportional allocation from their parents --- + for (let layer = 1; layer < LAYER_COUNT; layer++) { + const layerNodes = nodesByLayer[layer]; + if (layerNodes.length === 0) continue; + + const layerY = getLayerY(layer); + + // For each node, find its "primary parent": the already-placed hierarchy + // parent with the highest layer index (= closest ancestor in the tree). + // Walking all parents and picking the deepest-placed one means a Method + // prefers its Class over a distant Package, for example. + const assignedParent = new Map(); + for (const node of layerNodes) { + const parents = parentsByChild.get(node.id) ?? []; + let bestParent: string | null = null; + let bestParentLayer = -1; + for (const p of parents) { + if (!positions.has(p)) continue; // not yet placed + const pLayer = nodeLayerMap.get(p) ?? -1; + if (pLayer > bestParentLayer) { + bestParentLayer = pLayer; + bestParent = p; + } + } + if (bestParent) assignedParent.set(node.id, bestParent); + } + + // Bucket nodes into parent groups or orphans. + const childrenOfParent = new Map(); + const orphans: GraphNode[] = []; + for (const node of layerNodes) { + const p = assignedParent.get(node.id); + if (!p) { + orphans.push(node); + } else { + if (!childrenOfParent.has(p)) childrenOfParent.set(p, []); + childrenOfParent.get(p)!.push(node); + } + } + + // Sort within each parent's group and orphans alphabetically. + for (const children of childrenOfParent.values()) { + children.sort((a, b) => a.properties.name.localeCompare(b.properties.name)); + } + orphans.sort((a, b) => a.properties.name.localeCompare(b.properties.name)); + + // Sort active parents left-to-right by their placed X position. + const activeParents = [...childrenOfParent.keys()].sort( + (a, b) => (positions.get(a)?.x ?? 0) - (positions.get(b)?.x ?? 0), + ); + + const totalParented = layerNodes.length - orphans.length; + + // Divide the full canvas width: + // • parented children → (totalParented / total) fraction of width + // • orphans → remaining fraction at the right + const parentedWidth = + totalParented > 0 ? availableWidth * (totalParented / layerNodes.length) : 0; + const orphanWidth = availableWidth - parentedWidth; + + let curX = -halfWidth; + + // Place each parent's children in a sub-slice proportional to child count. + for (const parentId of activeParents) { + const children = childrenOfParent.get(parentId) ?? []; + if (children.length === 0) continue; + + const slotWidth = (children.length / totalParented) * parentedWidth; + placeNodesInSlice(positions, children, curX, slotWidth, layerY, layer); + curX += slotWidth; + } + + // Orphans fill the rightmost slice. + if (orphans.length > 0 && orphanWidth > 0) { + placeNodesInSlice(positions, orphans, curX, orphanWidth, layerY, layer); + } + } + + // Shift Y so the layout is centred at y = 0. + const centerY = CANVAS_HEIGHT / 2; + for (const pos of positions.values()) { + pos.y -= centerY; + } + + return positions; +} + +/** + * Tree view layout: type-layered grid with organic jitter and + * structure-aware horizontal branch shaping. + */ +export function calculateTreeLayout(graph: KnowledgeGraph): Map { + // Build hierarchy maps before initial placement so initProportionalPositions + // can assign each node to its closest placed ancestor's X slice. + const nodeIdsByLayer = buildLayerNodeIds(graph); + const { childrenByParent, parentsByChild } = buildHierarchyMaps(graph); + + // 1. Start with proportional X allocation: each parent gets a canvas slice + // proportional to its child count, so dense subtrees never crowd the centre. + const positions = initProportionalPositions(graph, parentsByChild); + + // 2. Add subtle Y jitter only — X jitter would scramble the hierarchy ordering + // that initProportionalPositions established (especially bad when node spacing < jitter). + for (const [nodeId, pos] of positions) { + pos.y += (deterministicHash(nodeId + 'y') - 0.5) * 20; + } + + // 3. Use structural edges to create a tree-like horizontal ordering while + // preserving the type-based vertical layers. + const STRUCTURE_ITERATIONS = 6; + for (let iter = 0; iter < STRUCTURE_ITERATIONS; iter++) { + const childTargets = new Map(); + + for (const [parentId, children] of childrenByParent) { + const parentPos = positions.get(parentId); + if (!parentPos || children.length === 0) continue; + + const childPositions = children + .map((childId) => ({ childId, pos: positions.get(childId) })) + .filter( + (entry): entry is { childId: string; pos: TreeNodePosition } => entry.pos !== undefined, + ) + .sort((a, b) => a.pos.x - b.pos.x); + + if (childPositions.length === 0) continue; + + const currentCenter = + childPositions.reduce((sum, entry) => sum + entry.pos.x, 0) / childPositions.length; + const shift = parentPos.x - currentCenter; + + for (const entry of childPositions) { + const existing = childTargets.get(entry.childId) || { sum: 0, count: 0 }; + existing.sum += entry.pos.x + shift; + existing.count += 1; + childTargets.set(entry.childId, existing); + } + } + + for (const [nodeId, target] of childTargets) { + const pos = positions.get(nodeId); + if (!pos) continue; + const avgTargetX = target.sum / target.count; + pos.x = pos.x * 0.45 + avgTargetX * 0.55; + } + + const parentTargets = new Map(); + for (const [parentId, children] of childrenByParent) { + const parentPos = positions.get(parentId); + if (!parentPos || children.length === 0) continue; + + const childXs = children + .map((childId) => positions.get(childId)?.x) + .filter((value): value is number => value !== undefined); + + if (childXs.length === 0) continue; + + const avgChildX = childXs.reduce((sum, value) => sum + value, 0) / childXs.length; + const existing = parentTargets.get(parentId) || { sum: 0, count: 0 }; + existing.sum += avgChildX; + existing.count += 1; + parentTargets.set(parentId, existing); + } + + for (const [nodeId, target] of parentTargets) { + const pos = positions.get(nodeId); + if (!pos) continue; + const avgTargetX = target.sum / target.count; + pos.x = pos.x * 0.65 + avgTargetX * 0.35; + } + } + + // 4. Pull childless nodes slightly toward their hierarchy parents when the + // graph has enough structure information to form branches. + for (const [nodeId, parents] of parentsByChild) { + if (childrenByParent.has(nodeId)) continue; + const pos = positions.get(nodeId); + if (!pos || parents.length === 0) continue; + + const parentXs = parents + .map((parentId) => positions.get(parentId)?.x) + .filter((value): value is number => value !== undefined); + + if (parentXs.length === 0) continue; + + const avgParentX = parentXs.reduce((sum, value) => sum + value, 0) / parentXs.length; + pos.x = pos.x * 0.7 + avgParentX * 0.3; + } + + // 5. Keep a per-node horizontal anchor so long edges can pull nodes closer + // without destroying each layer's original spread. + const anchorXByNode = new Map(); + for (const [nodeId, pos] of positions) { + anchorXByNode.set(nodeId, pos.x); + } + + // 6. Relax the graph like a constrained spring system. Only X is allowed + // to move, so node types stay on their original Y layers. + // For large graphs the spring phase is O(N×E×iterations) and would freeze + // the main thread — scale it down proportionally so the initial proportional + // layout (already good at large N) is kept without expensive refinement. + const nodeCount = graph.nodes.length; + const SPRING_ITERATIONS = nodeCount > 10000 ? 0 : nodeCount > 3000 ? 4 : 14; + for (let iter = 0; iter < SPRING_ITERATIONS; iter++) { + const deltaXByNode = new Map(); + + for (const [nodeId, pos] of positions) { + const anchorX = anchorXByNode.get(nodeId) ?? pos.x; + const normalizedDistance = Math.min(1, Math.abs(pos.x) / MAX_X); + const anchorStrength = 0.05 + normalizedDistance * normalizedDistance * 0.1; + deltaXByNode.set(nodeId, (anchorX - pos.x) * anchorStrength); + } + + for (const rel of graph.relationships) { + const sourcePos = positions.get(rel.sourceId); + const targetPos = positions.get(rel.targetId); + if (!sourcePos || !targetPos) continue; + + const dx = targetPos.x - sourcePos.x; + const dy = targetPos.y - sourcePos.y; + const distance = Math.sqrt(dx * dx + dy * dy) || 1; + const restLength = getRestEdgeLength(rel.type, sourcePos, targetPos); + const stretch = distance - restLength; + + if (stretch <= 0) continue; + + const springWeight = RELATION_SPRING_WEIGHTS[rel.type] ?? 0.14; + const pull = stretch * springWeight * 0.08; + const forceX = (dx / distance) * pull; + + deltaXByNode.set(rel.sourceId, (deltaXByNode.get(rel.sourceId) ?? 0) + forceX); + deltaXByNode.set(rel.targetId, (deltaXByNode.get(rel.targetId) ?? 0) - forceX); + } + + for (const [nodeId, pos] of positions) { + const deltaX = deltaXByNode.get(nodeId) ?? 0; + const normalizedDistance = Math.min(1, Math.abs(pos.x) / MAX_X); + const edgeResistance = 1 + normalizedDistance * normalizedDistance * 4.5; + const maxStep = 18 - normalizedDistance * 6; + const step = clamp(deltaX / edgeResistance, -maxStep, maxStep); + pos.x = clamp(pos.x + step, -MAX_X, MAX_X); + } + + for (const layerNodeIds of nodeIdsByLayer) { + enforceLayerSpacing(layerNodeIds, positions, anchorXByNode); + } + } + + // 7. Recenter and softly clamp X so the layout keeps its breadth without + // drifting too far off-canvas. + const xValues = Array.from(positions.values()).map((pos) => pos.x); + if (xValues.length > 0) { + const minX = Math.min(...xValues); + const maxX = Math.max(...xValues); + const centerX = (minX + maxX) / 2; + const halfSpan = Math.max(1, (maxX - minX) / 2); + const scale = halfSpan > MAX_X ? MAX_X / halfSpan : 1; + + for (const pos of positions.values()) { + pos.x = (pos.x - centerX) * scale; + } + } + + return positions; +} diff --git a/gitnexus-web/src/locales/en/graph.json b/gitnexus-web/src/locales/en/graph.json index 6f4bdb66f..5862253d9 100644 --- a/gitnexus-web/src/locales/en/graph.json +++ b/gitnexus-web/src/locales/en/graph.json @@ -112,6 +112,12 @@ "codeNotAvailable": "Code not available in memory for {{path}}" }, "canvas": { + "viewModes": { + "label": "Graph view mode", + "force": "Force Graph", + "tree": "Sequential Layout", + "circles": "Radial Layout" + }, "zoomIn": "Zoom In", "zoomOut": "Zoom Out", "fit": "Fit to Screen", diff --git a/gitnexus-web/src/locales/zh-CN/graph.json b/gitnexus-web/src/locales/zh-CN/graph.json index d65689f8a..671c72ba0 100644 --- a/gitnexus-web/src/locales/zh-CN/graph.json +++ b/gitnexus-web/src/locales/zh-CN/graph.json @@ -112,6 +112,12 @@ "codeNotAvailable": "内存中没有 {{path}} 的代码内容" }, "canvas": { + "viewModes": { + "label": "图形视图模式", + "force": "力导向图", + "tree": "顺序布局", + "circles": "径向布局" + }, "zoomIn": "放大", "zoomOut": "缩小", "fit": "适应屏幕", diff --git a/gitnexus-web/test/unit/filter-panel.test.ts b/gitnexus-web/test/unit/filter-panel.test.ts index 93b691b3f..67ab3397c 100644 --- a/gitnexus-web/test/unit/filter-panel.test.ts +++ b/gitnexus-web/test/unit/filter-panel.test.ts @@ -28,6 +28,8 @@ const ICON_MAP: Record = { Decorator: 'AtSign', Import: 'FileCode', Variable: 'Variable', + Property: 'Variable', + Const: 'Target', }; describe('filter panel icon mappings', () => { diff --git a/gitnexus-web/vitest.config.ts b/gitnexus-web/vitest.config.ts index e1460d840..1de8ef065 100644 --- a/gitnexus-web/vitest.config.ts +++ b/gitnexus-web/vitest.config.ts @@ -25,7 +25,7 @@ export default defineConfig({ globals: true, environment: 'jsdom', setupFiles: ['./test/setup.ts'], - include: ['test/**/*.test.{ts,tsx}'], + include: ['test/**/*.test.{ts,tsx}', 'src/**/*.test.{ts,tsx}'], testTimeout: 15000, coverage: { provider: 'v8',