feat(web): Introduce Tree View and Circles View in Web Viewer (#1799)

* feat(graph-view): add tree and circles layout modes

Add alternate graph layouts to the web viewer with new graph view state, canvas controls, adapters, and Sigma layout logic for tree and concentric-circle rendering. Include layout and adapter tests plus tree-view E2E coverage aligned with the English UI labels, and tune node visibility, edge layering, large-graph behavior, and tree-layer spacing so the new views stay readable. Follow up the tree-view work by keeping noisy variables hidden by default and mapping Property/Const icons so filter coverage stays in sync with the expanded node taxonomy.

Co-authored-by: OpenAI Codex <noreply@openai.com>
AI-model: GPT-5 Codex

* fix(web): cap tree layout spring iterations and remove unused variable

Finding A (blocker): calculateTreeLayout runs 14 synchronous spring
iterations over all edges and nodes — O(N×E×14) + O(N log N) per layer
per iteration — with no size guard.  At 10K+ nodes this freezes the
main thread for several seconds.

Fix: make SPRING_ITERATIONS adaptive:
  - N > 10 000 → 0 iterations (proportional initial layout only)
  - N >  3 000 → 4 iterations
  - otherwise  → 14 iterations (unchanged behaviour for small graphs)

Also removes the unused `const r` at useSigma.ts:1314, which was a
leftover after the radial-resistance decomposition was removed.
This clears the CodeQL "unused variable" warning (Finding G).

Co-authored-by: Claude <noreply@anthropic.com>
AI-model: claude-sonnet-4-6

* test(graph-adapter): add circles adapter tests and tree layout perf bound

Finding B (high): knowledgeGraphToCirclesGraphology had zero test
coverage.  Adds three new tests:

- ring placement: verifies Folder→ring 0, File→ring 1, Function→ring 3
  and confirms circles-specific attributes (circlesRing, circlesAnchorX/Y)
  are set while tree attributes (treeAnchorX/Y) are absent.
- edge styling: CONTAINS is marked isHierarchyEdge=true with the
  hierarchy colour; CALLS is cross-cutting with its own colour.
- CALLS cross-cutting: a lone CALLS edge between two Functions is
  correctly identified as a non-hierarchy edge.

Also adds a performance-bound test for the tree adapter at 2 000 nodes /
4 000 edges (the adaptive 14-iteration path) asserting completion within
2 s — catches regressions to the O(N×E×iterations) main-thread blocking
that Finding A identified.

Co-authored-by: Claude <noreply@anthropic.com>
AI-model: claude-sonnet-4-6

* refactor(web): rename Tree View → Sequential Layout, Circles → Radial Layout

Aligns the UI labels with standard graph layout terminology from the
Cambridge Intelligence taxonomy (cambridge-intelligence.com/blog/automatic-graph-layouts):

  Tree View   → Sequential Layout  (顺序布局)
  Circles     → Radial Layout      (径向布局)
  Force Graph → Force Graph        (unchanged)

Internal graphViewMode keys ('tree', 'circles', 'force') are unchanged —
only the displayed strings in en/zh-CN locales and the E2E button selectors
are updated.

Co-authored-by: Claude <noreply@anthropic.com>
AI-model: claude-sonnet-4-6

* perf(web): add adaptive large-graph guards to sequential layout physics

For graphs with N > 5 000 nodes, each rAF frame of runTreeLayout was
doing O(N log N) sort + O(N × k) repulsion pair comparisons (k ≈ 2 400
for a 20 K-node graph spread across 1 080 px at range 130).  At that
scale each frame took hundreds of ms, making the canvas appear completely
frozen even though the physics loop was still running.

Fix mirrors the circles layout adaptive strategy:

  N > 5 000 (large):
    - Skip repulsion pass (O(N × k) → 0)
    - Skip spread-force sort (O(N log N) → 0)
    - Velocity cap raised to ±12 / ±6 px so nodes cover ground faster
    - Damping 0.58, 1 sim step/frame, 30 s max duration
    - Looser early-stop thresholds (max v 0.05, avg v 0.03, active 2 %)

  N > 1 500 (medium):
    - Velocity cap raised to ±6 / ±3 px
    - 24 s max duration
    - Repulsion and spread still active

  N ≤ 1 500 (small):
    - Unchanged behaviour (velocity ±3/±2, 18 s, all forces active)

Layer gravity (O(N)) and edge springs (O(E)) run for all graph sizes —
they provide the structural pull that replaces repulsion at large N.

Co-authored-by: Claude <noreply@anthropic.com>
AI-model: claude-sonnet-4-6

* fix(web): fix stale closure in sigma event handlers breaking node selection

The sigma 'clickNode', 'clickStage', 'enterNode', and 'leaveNode' handlers
are registered in a one-time useEffect (empty dep array). They captured
options.onNodeClick via closure, so they always called the initial version
of handleNodeClick — the one created before the graph loaded where
`if (!graph) return` exits immediately.

Consequence: clicking a node in the canvas never updated the app-level
selectedNode state. This broke:
  - The Focus Depth filter (warning "Select a node to apply depth filter"
    persisted even after a canvas click)
  - The depth hop filter not applying (selectedNode was always null)
  - The code panel not opening on canvas node click

Fix: store the three callback props in refs (onNodeClickRef, onNodeHoverRef,
onStageClickRef) and update them synchronously on every render. The sigma
event handlers now read from the refs, so they always invoke the latest
version of the callbacks without needing to re-register.

Co-authored-by: Claude <noreply@anthropic.com>
AI-model: claude-sonnet-4-6

* fix(web): address three code-review bugs in graph rendering

Bug 1 (useSigma.ts): forces in the tree physics loop were computed once
before the sub-steps loop and reused for every step, causing 2× displacement
on slow frames (>64ms, simulationSteps>1). Fix: move forceX/forceY Maps and
all force accumulation (layer gravity, edge springs, repulsion, spread) inside
the loop so each sub-step integrates from current node positions.

Bug 2 (graph-adapter.ts): all three adapters used `graph.hasEdge(src,tgt)`
as a dedup guard, which silently drops any second edge between the same node
pair. A CALLS relationship between nodes that also have a CONTAINS edge was
always lost. Fix: switch from `new Graph()` to `new MultiGraph()` (allows
multiple edges per pair) and dedup by `rel.id` instead of by node pair.

Bug 3 (graph-adapter.test.ts): the cross-cutting edge styling test never
executed its CALLS branch because Bug 2 dropped the CALLS edge before the
assertion ran. Fix: assert `sigmaGraph.size === 2` and verify both edges
individually after collecting attrs by relationType.

Co-authored-by: Claude <noreply@anthropic.com>
AI-model: claude-sonnet-4-5

* fix(web): address three code-review bugs in graph rendering

- Move radial layout force accumulation inside the sub-step loop so
  forces are recomputed from updated node positions each iteration
  instead of using stale forces computed before the loop began
- Revert knowledgeGraphToGraphology from MultiGraph back to Graph with
  node-pair deduplication to prevent ForceAtlas2 from double-applying
  spring forces for node pairs that share multiple relation types
- Add Target to the lucide-icons import in FileTreePanel.tsx so the
  Const node type icon resolves without a ReferenceError

Co-authored-by: Claude <noreply@anthropic.com>
AI-model: claude-sonnet-4-6

* fix(web): address four more PR review comments

Edge visibility (useSigma.ts): HAS_METHOD / HAS_PROPERTY edges were hidden
when any edge-type filter was active because those types are not in the EdgeType
union. Normalize HAS_METHOD → DEFINES and HAS_PROPERTY → CONTAINS before the
visibleTypes.includes() guard so Kotlin/Java hierarchy edges follow the same
filter logic as their semantic equivalents.

Force-mode edge styles (graph-adapter.ts): HAS_METHOD / HAS_PROPERTY fell back
to the default gray color in the force-graph adapter because EDGE_STYLES had no
entries for them. Added explicit entries using the same hues as DEFINES/CONTAINS
so force mode renders Kotlin/Java hierarchy edges consistently with tree/circles.

Accessibility (GraphCanvas.tsx, locales): the layout-mode switcher (Force /
Tree / Circles) had no ARIA semantics. Added role="tablist" on the container
and role="tab" + aria-selected on each button. Added the viewModes.label i18n
key (used as aria-label on the tablist) to en and zh-CN locale files.

Flaky test (graph-adapter.test.ts): replaced the hard 2 s wall-clock assertion
with a structural check (node count + edge count) that is deterministic across
CI hardware. Timing tests are inherently flaky and provide no correctness signal.

Co-authored-by: Claude <noreply@anthropic.com>
AI-model: claude-sonnet-4-5

---------

Co-authored-by: OpenAI Codex <noreply@openai.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
This commit is contained in:
Hugo Gu 2026-05-27 01:05:50 +08:00 • committed by GitHub
parent a89d5afdb3
commit 1b0c31bf30
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
@ -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

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

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

View file

@ -112,6 +112,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',