Merge branch 'main' into fix/web-stop-nexus-ai-1615

This commit is contained in:
Gergő Magyar 2026-05-26 18:06:14 +01:00 • committed by GitHub
commit fced6a8a55
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 2796 additions and 117 deletions

View 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 });
});
});

View file

@ -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;
}

View file

@ -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}

View 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');
});
});

View file

@ -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>;

View file

@ -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
@ -1315,6 +1321,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
toggleEdgeVisibility,
depthFilter,
setDepthFilter,
graphViewMode,
setGraphViewMode,
highlightedNodeIds,
setHighlightedNodeIds,
aiCitationHighlightedNodeIds,

File diff suppressed because it is too large Load diff

View 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;
}

View file

@ -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',
];

View 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);
});
});
});

View file

@ -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;

View file

@ -79,6 +79,7 @@ export {
Loader2,
Maximize2,
MousePointerClick,
Network,
PanelLeft,
PanelLeftClose,
PanelRightClose,

View 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);
});
});

View 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;
}

View file

@ -113,6 +113,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",

View file

@ -113,6 +113,12 @@
"codeNotAvailable": "内存中没有 {{path}} 的代码内容"
},
"canvas": {
"viewModes": {
"label": "图形视图模式",
"force": "力导向图",
"tree": "顺序布局",
"circles": "径向布局"
},
"zoomIn": "放大",
"zoomOut": "缩小",
"fit": "适应屏幕",

View file

@ -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', () => {

View file

@ -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',