diff --git a/gitnexus-web/e2e/tree-view.spec.ts b/gitnexus-web/e2e/tree-view.spec.ts new file mode 100644 index 0000000000..61c964d5b8 --- /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 6bab30c6f7..a3c7487a99 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 cdf00c3bbf..d0880dbe11 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 4aa41db01a..aa6524a5f5 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 5a7e85457e..020d5ebcf1 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 461de0f5a1..a2121dad47 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; - - // Kill existing - if (layoutRef.current) { - layoutRef.current.kill(); - layoutRef.current = null; - } - if (layoutTimeoutRef.current) { - clearTimeout(layoutTimeoutRef.current); - layoutTimeoutRef.current = null; - } + const runTreeLayout = useCallback( + (graph: Graph) => { + if (graph.order === 0) return; + + stopAllLayouts(false); + + // Compute each layer's Y center from initial anchor positions + const layerYSum = new Map(); + const layerYCount = new Map(); + + 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); + }); + + const layerCenterY = new Map(); + for (const [layer, sum] of layerYSum) { + layerCenterY.set(layer, sum / (layerYCount.get(layer) ?? 1)); + } + + // 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); + }); + + // 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; + } - // Get settings - const inferredSettings = forceAtlas2.inferSettings(graph); - const customSettings = getFA2Settings(nodeCount); - const settings = { ...inferredSettings, ...customSettings }; + 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 layout = new FA2Layout(graph, { settings }); + 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); + } - layoutRef.current = layout; - layout.start(); - setIsLayoutRunning(true); + // 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); + } + } + } - const duration = getLayoutDuration(nodeCount); + // 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, + ); + } + } + } - layoutTimeoutRef.current = setTimeout(() => { - if (layoutRef.current) { - layoutRef.current.stop(); - layoutRef.current = null; + 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]); - - const stopLayout = useCallback(() => { - if (layoutTimeoutRef.current) { - clearTimeout(layoutTimeoutRef.current); - layoutTimeoutRef.current = null; + if (options.layoutMode === 'tree') { + runTreeLayout(graph); + } else if (options.layoutMode === 'circles') { + runCirclesLayout(graph); + } else { + runLayout(graph); } - if (layoutRef.current) { - layoutRef.current.stop(); - layoutRef.current = null; - - const graph = graphRef.current; - if (graph) { - noverlap.assign(graph, NOVERLAP_SETTINGS); - sigmaRef.current?.refresh(); - } + }, [options.layoutMode, runLayout, runTreeLayout, runCirclesLayout]); - setIsLayoutRunning(false); - } - }, []); + const stopLayout = useCallback(() => { + 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 0000000000..8a061728b2 --- /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 f6804405b8..fe05054831 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 0000000000..4cbb6b8ef7 --- /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 03caf8c925..bb3f5f11a1 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 (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; - - graph.addEdge(rel.sourceId, rel.targetId, { - size: edgeBaseSize * style.sizeMultiplier, - color: style.color, - relationType: rel.type, - type: 'curved', - curvature: curvature, - }); - } - } + 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 (HIERARCHY_EDGE_STYLES[rel.type] !== undefined) addTreeEdge(rel); + }); + knowledgeGraph.relationships.forEach((rel) => { + if (HIERARCHY_EDGE_STYLES[rel.type] === undefined) addTreeEdge(rel); + }); + + 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 dc69b279f5..7d9b5fc7d0 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 0000000000..3858a59cd7 --- /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 0000000000..5fb284d5c2 --- /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 6f4bdb66f3..5862253d9f 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 d65689f8aa..671c72ba04 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 93b691b3f3..67ab3397c9 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 e1460d8406..1de8ef0655 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',