mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-29 01:41:42 +00:00
Merge branch 'main' into codex/cpp-strict-fn-type-adl
This commit is contained in:
commit
0c3d94a85d
18 changed files with 2796 additions and 117 deletions
117
gitnexus-web/e2e/tree-view.spec.ts
Normal file
117
gitnexus-web/e2e/tree-view.spec.ts
Normal file
|
|
@ -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 <span class="truncate font-mono text-xs">.
|
||||
// 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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<GraphCanvasHandle>((_, ref) => {
|
|||
clearAICitationHighlights,
|
||||
clearBlastRadius,
|
||||
animatedNodes,
|
||||
graphViewMode,
|
||||
setGraphViewMode,
|
||||
} = useAppState();
|
||||
const [hoveredNodeName, setHoveredNodeName] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -149,8 +156,22 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, 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<GraphCanvasHandle>((_, ref) => {
|
|||
useEffect(() => {
|
||||
if (!graph) return;
|
||||
|
||||
// Build communityMemberships map from MEMBER_OF relationships
|
||||
// MEMBER_OF edges: nodeId -> communityId (stored as targetId)
|
||||
const communityMemberships = new Map<string, number>();
|
||||
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<SigmaNodeAttributes, SigmaEdgeAttributes>;
|
||||
|
||||
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<string, number>();
|
||||
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<GraphCanvasHandle>((_, 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<GraphCanvasHandle>((_, ref) => {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* View Mode Tabs */}
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={t('canvas.viewModes.label')}
|
||||
className="absolute top-4 left-1/2 z-20 flex -translate-x-1/2 gap-1 rounded-lg border border-border-subtle bg-elevated/90 p-1 backdrop-blur-sm"
|
||||
>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={graphViewMode === 'force'}
|
||||
onClick={() => handleViewModeChange('force')}
|
||||
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
graphViewMode === 'force'
|
||||
? 'bg-accent text-white'
|
||||
: 'text-text-secondary hover:bg-hover hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
<Network className="h-3.5 w-3.5" />
|
||||
{t('canvas.viewModes.force')}
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={graphViewMode === 'tree'}
|
||||
onClick={() => handleViewModeChange('tree')}
|
||||
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
graphViewMode === 'tree'
|
||||
? 'bg-accent text-white'
|
||||
: 'text-text-secondary hover:bg-hover hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
<GitBranch className="h-3.5 w-3.5" />
|
||||
{t('canvas.viewModes.tree')}
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={graphViewMode === 'circles'}
|
||||
onClick={() => handleViewModeChange('circles')}
|
||||
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
graphViewMode === 'circles'
|
||||
? 'bg-accent text-white'
|
||||
: 'text-text-secondary hover:bg-hover hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
<Target className="h-3.5 w-3.5" />
|
||||
{t('canvas.viewModes.circles')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sigma container */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
|
|
|
|||
22
gitnexus-web/src/hooks/app-state/graph.test.tsx
Normal file
22
gitnexus-web/src/hooks/app-state/graph.test.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { GraphStateProvider, useGraphState } from './graph';
|
||||
|
||||
function wrapper({ children }: { children: React.ReactNode }) {
|
||||
return <GraphStateProvider>{children}</GraphStateProvider>;
|
||||
}
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
|
@ -16,6 +16,8 @@ interface GraphStateContextValue {
|
|||
setDepthFilter: (depth: number | null) => void;
|
||||
highlightedNodeIds: Set<string>;
|
||||
setHighlightedNodeIds: (ids: Set<string>) => void;
|
||||
graphViewMode: 'force' | 'tree' | 'circles';
|
||||
setGraphViewMode: (mode: 'force' | 'tree' | 'circles') => void;
|
||||
}
|
||||
|
||||
const GraphStateContext = createContext<GraphStateContextValue | null>(null);
|
||||
|
|
@ -27,6 +29,7 @@ export const GraphStateProvider = ({ children }: { children: ReactNode }) => {
|
|||
const [visibleEdgeTypes, setVisibleEdgeTypes] = useState<EdgeType[]>(DEFAULT_VISIBLE_EDGES);
|
||||
const [depthFilter, setDepthFilter] = useState<number | null>(null);
|
||||
const [highlightedNodeIds, setHighlightedNodeIds] = useState<Set<string>>(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 <GraphStateContext.Provider value={value}>{children}</GraphStateContext.Provider>;
|
||||
|
|
|
|||
|
|
@ -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<string>;
|
||||
setHighlightedNodeIds: (ids: Set<string>) => 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,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
307
gitnexus-web/src/lib/circles-layout.ts
Normal file
307
gitnexus-web/src/lib/circles-layout.ts
Normal file
|
|
@ -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<string, number> = {
|
||||
// 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<string, string[]>();
|
||||
const parentsByChild = new Map<string, string[]>();
|
||||
|
||||
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<string, string[]>,
|
||||
): Map<string, CirclesNodePosition> {
|
||||
const positions = new Map<string, CirclesNodePosition>();
|
||||
|
||||
// Group nodes by ring
|
||||
const nodesByRing: GraphNode[][] = Array.from({ length: RING_COUNT }, () => []);
|
||||
const nodeRingMap = new Map<string, number>();
|
||||
|
||||
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<string, string>();
|
||||
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<string, GraphNode[]>();
|
||||
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<string, CirclesNodePosition> {
|
||||
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;
|
||||
}
|
||||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
208
gitnexus-web/src/lib/graph-adapter.test.ts
Normal file
208
gitnexus-web/src/lib/graph-adapter.test.ts
Normal file
|
|
@ -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<string, { isHierarchyEdge?: boolean; color: string }>();
|
||||
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<string, { isHierarchyEdge?: boolean; color: string }>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, string[]>();
|
||||
// child -> parent
|
||||
const childToParent = new Map<string, string>();
|
||||
|
||||
const hierarchyRelations = new Set(['CONTAINS', 'DEFINES', 'IMPORTS']);
|
||||
// IMPORTS is not a true structural hierarchy, but treating it as a spatial
|
||||
// seed helps FA2 converge for import-heavy codebases: files that import each
|
||||
// other start near each other, so the simulation doesn't have to close many
|
||||
// long cross-package springs from scratch.
|
||||
const spatialSeedRelations = new Set(['CONTAINS', 'DEFINES', 'IMPORTS']);
|
||||
|
||||
knowledgeGraph.relationships.forEach((rel) => {
|
||||
// These relationships represent parent-child hierarchy for positioning
|
||||
if (hierarchyRelations.has(rel.type)) {
|
||||
// source CONTAINS/DEFINES/IMPORTS target, so source is parent
|
||||
// These relationships determine initial node positions (not graph semantics)
|
||||
if (spatialSeedRelations.has(rel.type)) {
|
||||
// source CONTAINS/DEFINES/IMPORTS target → source acts as spatial parent
|
||||
if (!parentToChildren.has(rel.sourceId)) {
|
||||
parentToChildren.set(rel.sourceId, []);
|
||||
}
|
||||
|
|
@ -295,23 +308,202 @@ export const knowledgeGraphToGraphology = (
|
|||
// TYPE RELATIONSHIPS - Warm colors (OOP)
|
||||
EXTENDS: { color: '#c2410c', sizeMultiplier: 1.0 }, // Orange - extension
|
||||
IMPLEMENTS: { color: '#be185d', sizeMultiplier: 0.9 }, // Pink - interface implementation
|
||||
|
||||
// KOTLIN/JAVA HIERARCHY — same hues as their logical equivalents so force
|
||||
// mode renders these consistently with tree/circles view.
|
||||
HAS_METHOD: { color: EDGE_INFO.DEFINES.color, sizeMultiplier: 0.4 }, // Class→Method (≈ DEFINES)
|
||||
HAS_PROPERTY: { color: EDGE_INFO.CONTAINS.color, sizeMultiplier: 0.35 }, // Class→Property (≈ CONTAINS)
|
||||
};
|
||||
|
||||
// Two-pass insertion so hierarchy/DEFINES edges are drawn first (behind)
|
||||
// and cross-edges (CALLS, IMPORTS, EXTENDS) are drawn on top.
|
||||
const BACKGROUND_EDGE_TYPES = new Set(['CONTAINS', 'DEFINES', 'HAS_METHOD', 'HAS_PROPERTY']);
|
||||
|
||||
const addEdge = (rel: (typeof knowledgeGraph.relationships)[number]) => {
|
||||
if (!graph.hasNode(rel.sourceId) || !graph.hasNode(rel.targetId)) return;
|
||||
if (graph.hasEdge(rel.sourceId, rel.targetId)) return;
|
||||
const style = EDGE_STYLES[rel.type] || { color: '#4a4a5a', sizeMultiplier: 0.5 };
|
||||
const curvature = 0.12 + Math.random() * 0.08;
|
||||
graph.addEdge(rel.sourceId, rel.targetId, {
|
||||
size: edgeBaseSize * style.sizeMultiplier,
|
||||
color: style.color,
|
||||
relationType: rel.type,
|
||||
type: 'curved',
|
||||
curvature,
|
||||
});
|
||||
};
|
||||
|
||||
// Pass 1: background (hierarchy) edges — rendered behind
|
||||
knowledgeGraph.relationships.forEach((rel) => {
|
||||
if (BACKGROUND_EDGE_TYPES.has(rel.type)) addEdge(rel);
|
||||
});
|
||||
// Pass 2: foreground (cross) edges — rendered on top
|
||||
knowledgeGraph.relationships.forEach((rel) => {
|
||||
if (!BACKGROUND_EDGE_TYPES.has(rel.type)) addEdge(rel);
|
||||
});
|
||||
|
||||
return graph;
|
||||
};
|
||||
|
||||
export const knowledgeGraphToTreeGraphology = (
|
||||
knowledgeGraph: KnowledgeGraph,
|
||||
): Graph<SigmaNodeAttributes, SigmaEdgeAttributes> => {
|
||||
const graph = new MultiGraph<SigmaNodeAttributes, SigmaEdgeAttributes>();
|
||||
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<string, { color: string; sizeMultiplier: number }> = {
|
||||
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<string, { color: string; sizeMultiplier: number }> = {
|
||||
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<string>();
|
||||
const addTreeEdge = (rel: (typeof knowledgeGraph.relationships)[number]) => {
|
||||
if (!graph.hasNode(rel.sourceId) || !graph.hasNode(rel.targetId)) return;
|
||||
if (addedTreeRelIds.has(rel.id)) return;
|
||||
addedTreeRelIds.add(rel.id);
|
||||
const isHierarchy = HIERARCHY_EDGE_STYLES[rel.type] !== undefined;
|
||||
const style = isHierarchy
|
||||
? HIERARCHY_EDGE_STYLES[rel.type]
|
||||
: CROSS_EDGE_STYLES[rel.type] || { color: '#4a4a5a', sizeMultiplier: 0.5 };
|
||||
graph.addEdge(rel.sourceId, rel.targetId, {
|
||||
size: edgeBaseSize * style.sizeMultiplier,
|
||||
color: style.color,
|
||||
relationType: rel.type,
|
||||
type: 'curved',
|
||||
curvature: 0.1 + Math.random() * 0.1,
|
||||
isHierarchyEdge: isHierarchy,
|
||||
});
|
||||
};
|
||||
|
||||
knowledgeGraph.relationships.forEach((rel) => {
|
||||
if (graph.hasNode(rel.sourceId) && graph.hasNode(rel.targetId)) {
|
||||
if (!graph.hasEdge(rel.sourceId, rel.targetId)) {
|
||||
const style = EDGE_STYLES[rel.type] || { color: '#4a4a5a', sizeMultiplier: 0.5 };
|
||||
const curvature = 0.12 + Math.random() * 0.08;
|
||||
if (HIERARCHY_EDGE_STYLES[rel.type] !== undefined) addTreeEdge(rel);
|
||||
});
|
||||
knowledgeGraph.relationships.forEach((rel) => {
|
||||
if (HIERARCHY_EDGE_STYLES[rel.type] === undefined) addTreeEdge(rel);
|
||||
});
|
||||
|
||||
graph.addEdge(rel.sourceId, rel.targetId, {
|
||||
size: edgeBaseSize * style.sizeMultiplier,
|
||||
color: style.color,
|
||||
relationType: rel.type,
|
||||
type: 'curved',
|
||||
curvature: curvature,
|
||||
});
|
||||
}
|
||||
}
|
||||
return graph;
|
||||
};
|
||||
|
||||
export const knowledgeGraphToCirclesGraphology = (
|
||||
knowledgeGraph: KnowledgeGraph,
|
||||
): Graph<SigmaNodeAttributes, SigmaEdgeAttributes> => {
|
||||
const graph = new MultiGraph<SigmaNodeAttributes, SigmaEdgeAttributes>();
|
||||
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<string, { color: string; sizeMultiplier: number }> = {
|
||||
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<string, { color: string; sizeMultiplier: number }> = {
|
||||
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<string>();
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ export {
|
|||
Loader2,
|
||||
Maximize2,
|
||||
MousePointerClick,
|
||||
Network,
|
||||
PanelLeft,
|
||||
PanelLeftClose,
|
||||
PanelRightClose,
|
||||
|
|
|
|||
217
gitnexus-web/src/lib/tree-layout.test.ts
Normal file
217
gitnexus-web/src/lib/tree-layout.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
570
gitnexus-web/src/lib/tree-layout.ts
Normal file
570
gitnexus-web/src/lib/tree-layout.ts
Normal file
|
|
@ -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<string, number> = {
|
||||
// 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<string, number> = {
|
||||
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<string, string[]>();
|
||||
const parentsByChild = new Map<string, string[]>();
|
||||
|
||||
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<string, TreeNodePosition>,
|
||||
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<string, TreeNodePosition>,
|
||||
anchorXByNode: Map<string, number>,
|
||||
) {
|
||||
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<string, string[]>,
|
||||
): Map<string, TreeNodePosition> {
|
||||
const positions = new Map<string, TreeNodePosition>();
|
||||
|
||||
// Group nodes by layer and build a fast layer-lookup map.
|
||||
const nodesByLayer: GraphNode[][] = Array.from({ length: LAYER_COUNT }, () => []);
|
||||
const nodeLayerMap = new Map<string, number>();
|
||||
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<string, string>();
|
||||
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<string, GraphNode[]>();
|
||||
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<string, TreeNodePosition> {
|
||||
// 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<string, { sum: number; count: number }>();
|
||||
|
||||
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<string, { sum: number; count: number }>();
|
||||
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<string, number>();
|
||||
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<string, number>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -112,6 +112,12 @@
|
|||
"codeNotAvailable": "内存中没有 {{path}} 的代码内容"
|
||||
},
|
||||
"canvas": {
|
||||
"viewModes": {
|
||||
"label": "图形视图模式",
|
||||
"force": "力导向图",
|
||||
"tree": "顺序布局",
|
||||
"circles": "径向布局"
|
||||
},
|
||||
"zoomIn": "放大",
|
||||
"zoomOut": "缩小",
|
||||
"fit": "适应屏幕",
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ const ICON_MAP: Record<string, string> = {
|
|||
Decorator: 'AtSign',
|
||||
Import: 'FileCode',
|
||||
Variable: 'Variable',
|
||||
Property: 'Variable',
|
||||
Const: 'Target',
|
||||
};
|
||||
|
||||
describe('filter panel icon mappings', () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue