feat(graph-explorer): implement interactive code relationship visualization with Cytoscape.js

- Added main render function to initialize the graph explorer view.
- Implemented data loading functions for graph nodes, edges, and search process data.
- Created UI layout with tabs for graph view and search process view.
- Developed filtering options for nodes and edges with corresponding UI elements.
- Integrated Cytoscape.js for graph visualization, including node and edge styling.
- Added functionality for node selection and details display.
- Implemented impact analysis feature with modal display for results.
- Included utility functions for refreshing data and managing UI states.
This commit is contained in:
catlog22
2025-12-15 19:35:18 +08:00
parent 97640a517a
commit 894b93e08d
9 changed files with 2438 additions and 0 deletions

View File

@@ -0,0 +1,166 @@
# Graph Routes API Documentation
## Overview
The Graph Routes module provides REST API endpoints for querying and visualizing code structure data from CodexLens indices. It exposes symbols and their relationships as graph nodes and edges.
## Endpoints
### GET /api/graph/nodes
Query all symbols from the CodexLens SQLite database and return them as graph nodes.
**Query Parameters:**
- `path` (optional): Project path. Defaults to server's initial path.
**Response:**
```json
{
"nodes": [
{
"id": "src/file.ts:functionName:10",
"name": "functionName",
"type": "FUNCTION",
"file": "src/file.ts",
"line": 10,
"docstring": "function_type",
"tokenCount": 45
}
]
}
```
**Node Types:**
- `FUNCTION`: Functions and procedures
- `CLASS`: Classes, interfaces, and type definitions
- `METHOD`: Class methods
- `VARIABLE`: Variables and constants
- `MODULE`: Module-level definitions
---
### GET /api/graph/edges
Query all code relationships from the CodexLens SQLite database and return them as graph edges.
**Query Parameters:**
- `path` (optional): Project path. Defaults to server's initial path.
**Response:**
```json
{
"edges": [
{
"source": "src/file.ts:caller:10",
"target": "module.callee",
"type": "CALLS",
"sourceLine": 10,
"sourceFile": "src/file.ts"
}
]
}
```
**Edge Types:**
- `CALLS`: Function/method invocations
- `IMPORTS`: Import/require relationships
- `INHERITS`: Class inheritance
---
### GET /api/graph/impact
Get impact analysis for a symbol - determines what code is affected if this symbol changes.
**Query Parameters:**
- `path` (optional): Project path. Defaults to server's initial path.
- `symbol` (required): Symbol ID in format `file:name:line`
**Response:**
```json
{
"directDependents": [
"src/other.ts:caller:20",
"src/another.ts:user:35"
],
"affectedFiles": [
"src/other.ts",
"src/another.ts"
]
}
```
---
## Implementation Details
### PathMapper
Maps source code paths to CodexLens index database paths following the storage structure:
- Windows: `D:\path\to\project``~/.codexlens/indexes/D/path/to/project/_index.db`
- Unix: `/home/user/project``~/.codexlens/indexes/home/user/project/_index.db`
### Database Schema
Queries two main tables:
1. **symbols** - Code symbol definitions
- `id`, `file_id`, `name`, `kind`, `start_line`, `end_line`, `token_count`, `symbol_type`
2. **code_relationships** - Inter-symbol dependencies
- `id`, `source_symbol_id`, `target_qualified_name`, `relationship_type`, `source_line`, `target_file`
### Error Handling
- Returns empty arrays (`[]`) if index database doesn't exist
- Returns 500 with error message on database query failures
- Returns 400 if required parameters are missing
### Type Mappings
**Symbol Kinds → Node Types:**
```typescript
{
'function' 'FUNCTION',
'class' 'CLASS',
'method' 'METHOD',
'variable' 'VARIABLE',
'module' 'MODULE',
'interface' 'CLASS',
'type' 'CLASS'
}
```
**Relationship Types → Edge Types:**
```typescript
{
'call' 'CALLS',
'import' 'IMPORTS',
'inherits' 'INHERITS',
'uses' 'CALLS'
}
```
---
## Usage Examples
### Fetch All Nodes
```bash
curl "http://localhost:3000/api/graph/nodes?path=/path/to/project"
```
### Fetch All Edges
```bash
curl "http://localhost:3000/api/graph/edges?path=/path/to/project"
```
### Analyze Impact
```bash
curl "http://localhost:3000/api/graph/impact?path=/path/to/project&symbol=src/file.ts:functionName:10"
```
---
## Integration with CodexLens
This module requires:
1. CodexLens to be installed and initialized (`/api/codexlens/init`)
2. Project to be indexed (creates `_index.db` files)
3. better-sqlite3 package for direct database access
## Dependencies
- `better-sqlite3`: Direct SQLite database access
- `codex-lens`: Python package providing the indexing infrastructure
- `path`, `fs`, `os`: Node.js built-in modules for file system operations

View File

@@ -0,0 +1,315 @@
// @ts-nocheck
/**
* Graph Routes Module
* Handles graph visualization API endpoints for codex-lens data
*/
import type { IncomingMessage, ServerResponse } from 'http';
import { executeCodexLens } from '../../tools/codex-lens.js';
import { homedir } from 'os';
import { join } from 'path';
import { existsSync } from 'fs';
import Database from 'better-sqlite3';
export interface RouteContext {
pathname: string;
url: URL;
req: IncomingMessage;
res: ServerResponse;
initialPath: string;
handlePostRequest: (req: IncomingMessage, res: ServerResponse, handler: (body: unknown) => Promise<any>) => void;
broadcastToClients: (data: unknown) => void;
}
/**
* PathMapper utility class (simplified from codex-lens Python implementation)
* Maps source paths to index database paths
*/
class PathMapper {
private indexRoot: string;
constructor(indexRoot?: string) {
this.indexRoot = indexRoot || join(homedir(), '.codexlens', 'indexes');
}
/**
* Normalize path to cross-platform storage format
* Windows: D:\path\to\dir → D/path/to/dir
* Unix: /home/user/path → home/user/path
*/
normalizePath(sourcePath: string): string {
const resolved = sourcePath.replace(/\\/g, '/');
// Handle Windows paths with drive letters
if (process.platform === 'win32' && /^[A-Za-z]:/.test(resolved)) {
const drive = resolved[0]; // D
const rest = resolved.slice(2); // /path/to/dir
return `${drive}${rest}`.replace(/^\//, '');
}
// Handle Unix paths - remove leading slash
return resolved.replace(/^\//, '');
}
/**
* Convert source path to index database path
*/
sourceToIndexDb(sourcePath: string): string {
const normalized = this.normalizePath(sourcePath);
return join(this.indexRoot, normalized, '_index.db');
}
}
interface GraphNode {
id: string;
name: string;
type: string;
file: string;
line: number;
docstring?: string;
tokenCount?: number;
}
interface GraphEdge {
source: string;
target: string;
type: string;
sourceLine: number;
sourceFile: string;
}
interface ImpactAnalysis {
directDependents: string[];
affectedFiles: string[];
}
/**
* Map codex-lens symbol kinds to graph node types
*/
function mapSymbolKind(kind: string): string {
const kindMap: Record<string, string> = {
'function': 'FUNCTION',
'class': 'CLASS',
'method': 'METHOD',
'variable': 'VARIABLE',
'module': 'MODULE',
'interface': 'CLASS', // TypeScript interfaces as CLASS
'type': 'CLASS', // Type aliases as CLASS
};
return kindMap[kind.toLowerCase()] || 'VARIABLE';
}
/**
* Map codex-lens relationship types to graph edge types
*/
function mapRelationType(relType: string): string {
const typeMap: Record<string, string> = {
'call': 'CALLS',
'import': 'IMPORTS',
'inherits': 'INHERITS',
'uses': 'CALLS', // Fallback uses → CALLS
};
return typeMap[relType.toLowerCase()] || 'CALLS';
}
/**
* Query symbols from codex-lens database
*/
async function querySymbols(projectPath: string): Promise<GraphNode[]> {
const mapper = new PathMapper();
const dbPath = mapper.sourceToIndexDb(projectPath);
if (!existsSync(dbPath)) {
return [];
}
try {
const db = Database(dbPath, { readonly: true });
const rows = db.prepare(`
SELECT
s.id,
s.name,
s.kind,
s.start_line,
s.token_count,
s.symbol_type,
f.path as file
FROM symbols s
JOIN files f ON s.file_id = f.id
ORDER BY f.path, s.start_line
`).all();
db.close();
return rows.map((row: any) => ({
id: `${row.file}:${row.name}:${row.start_line}`,
name: row.name,
type: mapSymbolKind(row.kind),
file: row.file,
line: row.start_line,
docstring: row.symbol_type || undefined,
tokenCount: row.token_count || undefined,
}));
} catch (err) {
console.error(`[Graph] Failed to query symbols: ${err.message}`);
return [];
}
}
/**
* Query code relationships from codex-lens database
*/
async function queryRelationships(projectPath: string): Promise<GraphEdge[]> {
const mapper = new PathMapper();
const dbPath = mapper.sourceToIndexDb(projectPath);
if (!existsSync(dbPath)) {
return [];
}
try {
const db = Database(dbPath, { readonly: true });
const rows = db.prepare(`
SELECT
s.name as source_name,
s.start_line as source_line,
f.path as source_file,
r.target_qualified_name,
r.relationship_type,
r.target_file
FROM code_relationships r
JOIN symbols s ON r.source_symbol_id = s.id
JOIN files f ON s.file_id = f.id
ORDER BY f.path, s.start_line
`).all();
db.close();
return rows.map((row: any) => ({
source: `${row.source_file}:${row.source_name}:${row.source_line}`,
target: row.target_qualified_name,
type: mapRelationType(row.relationship_type),
sourceLine: row.source_line,
sourceFile: row.source_file,
}));
} catch (err) {
console.error(`[Graph] Failed to query relationships: ${err.message}`);
return [];
}
}
/**
* Perform impact analysis for a symbol
* Find all symbols that depend on this symbol (direct and transitive)
*/
async function analyzeImpact(projectPath: string, symbolId: string): Promise<ImpactAnalysis> {
const mapper = new PathMapper();
const dbPath = mapper.sourceToIndexDb(projectPath);
if (!existsSync(dbPath)) {
return { directDependents: [], affectedFiles: [] };
}
try {
const db = Database(dbPath, { readonly: true });
// Parse symbolId to extract symbol name
const parts = symbolId.split(':');
const symbolName = parts.length >= 2 ? parts[1] : symbolId;
// Find all symbols that reference this symbol
const rows = db.prepare(`
SELECT DISTINCT
s.name as dependent_name,
f.path as dependent_file,
s.start_line as dependent_line
FROM code_relationships r
JOIN symbols s ON r.source_symbol_id = s.id
JOIN files f ON s.file_id = f.id
WHERE r.target_qualified_name LIKE ?
`).all(`%${symbolName}%`);
db.close();
const directDependents = rows.map((row: any) =>
`${row.dependent_file}:${row.dependent_name}:${row.dependent_line}`
);
const affectedFiles = [...new Set(rows.map((row: any) => row.dependent_file))];
return {
directDependents,
affectedFiles,
};
} catch (err) {
console.error(`[Graph] Failed to analyze impact: ${err.message}`);
return { directDependents: [], affectedFiles: [] };
}
}
/**
* Handle Graph routes
* @returns true if route was handled, false otherwise
*/
export async function handleGraphRoutes(ctx: RouteContext): Promise<boolean> {
const { pathname, url, req, res, initialPath } = ctx;
// API: Graph Nodes - Get all symbols as graph nodes
if (pathname === '/api/graph/nodes') {
const projectPath = url.searchParams.get('path') || initialPath;
try {
const nodes = await querySymbols(projectPath);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ nodes }));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message, nodes: [] }));
}
return true;
}
// API: Graph Edges - Get all relationships as graph edges
if (pathname === '/api/graph/edges') {
const projectPath = url.searchParams.get('path') || initialPath;
try {
const edges = await queryRelationships(projectPath);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ edges }));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message, edges: [] }));
}
return true;
}
// API: Impact Analysis - Get impact analysis for a symbol
if (pathname === '/api/graph/impact') {
const projectPath = url.searchParams.get('path') || initialPath;
const symbolId = url.searchParams.get('symbol');
if (!symbolId) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'symbol parameter is required' }));
return true;
}
try {
const impact = await analyzeImpact(projectPath, symbolId);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(impact));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
error: err.message,
directDependents: [],
affectedFiles: []
}));
}
return true;
}
return false;
}

View File

@@ -11,6 +11,7 @@ import { handleMemoryRoutes } from './routes/memory-routes.js';
import { handleMcpRoutes } from './routes/mcp-routes.js';
import { handleHooksRoutes } from './routes/hooks-routes.js';
import { handleCodexLensRoutes } from './routes/codexlens-routes.js';
import { handleGraphRoutes } from './routes/graph-routes.js';
import { handleSystemRoutes } from './routes/system-routes.js';
import { handleFilesRoutes } from './routes/files-routes.js';
import { handleSkillsRoutes } from './routes/skills-routes.js';
@@ -270,6 +271,11 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
if (await handleCodexLensRoutes(routeContext)) return;
}
// Graph routes (/api/graph/*)
if (pathname.startsWith('/api/graph/')) {
if (await handleGraphRoutes(routeContext)) return;
}
// CCW routes (/api/ccw/*)
if (pathname.startsWith('/api/ccw/')) {
if (await handleCcwRoutes(routeContext)) return;

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -114,6 +114,8 @@ function initNavigation() {
renderRulesManager();
} else if (currentView === 'claude-manager') {
renderClaudeManager();
} else if (currentView === 'graph-explorer') {
renderGraphExplorer();
}
});
});
@@ -148,6 +150,8 @@ function updateContentTitle() {
titleEl.textContent = t('title.rulesManager');
} else if (currentView === 'claude-manager') {
titleEl.textContent = t('title.claudeManager');
} else if (currentView === 'graph-explorer') {
titleEl.textContent = t('title.graphExplorer');
} else if (currentView === 'liteTasks') {
const names = { 'lite-plan': t('title.litePlanSessions'), 'lite-fix': t('title.liteFixSessions') };
titleEl.textContent = names[currentLiteType] || t('title.liteTasks');

View File

@@ -937,6 +937,28 @@ const i18n = {
'claudeManager.saved': 'File saved successfully',
'claudeManager.saveError': 'Failed to save file',
// Graph Explorer
'nav.graphExplorer': 'Graph',
'title.graphExplorer': 'Code Graph Explorer',
'graph.codeRelations': 'Code Relations',
'graph.searchProcess': 'Search Process',
'graph.nodeTypes': 'Node Types',
'graph.edgeTypes': 'Edge Types',
'graph.noProject': 'No project selected',
'graph.selectProject': 'Select a project to view its code graph',
'graph.loading': 'Loading graph data...',
'graph.noData': 'No graph data available',
'graph.indexProject': 'Please index this project with codex-lens first',
'graph.nodeDetails': 'Node Details',
'graph.selectNode': 'Select a node to view details',
'graph.impactAnalysis': 'Impact Analysis',
'graph.directDependents': 'Direct Dependents',
'graph.affectedFiles': 'Affected Files',
'graph.fitView': 'Fit View',
'graph.zoomIn': 'Zoom In',
'graph.zoomOut': 'Zoom Out',
'graph.resetLayout': 'Reset Layout',
// CLI Sync (used in claude-manager.js)
'claude.cliSync': 'CLI Auto-Sync',
'claude.tool': 'Tool',
@@ -1907,6 +1929,28 @@ const i18n = {
'claudeManager.saved': '文件保存成功',
'claudeManager.saveError': '文件保存失败',
// Graph Explorer
'nav.graphExplorer': '图谱',
'title.graphExplorer': '代码图谱浏览器',
'graph.codeRelations': '代码关系',
'graph.searchProcess': '搜索过程',
'graph.nodeTypes': '节点类型',
'graph.edgeTypes': '边类型',
'graph.noProject': '未选择项目',
'graph.selectProject': '选择一个项目以查看其代码图谱',
'graph.loading': '正在加载图谱数据...',
'graph.noData': '无图谱数据',
'graph.indexProject': '请先使用 codex-lens 为此项目建立索引',
'graph.nodeDetails': '节点详情',
'graph.selectNode': '选择节点以查看详情',
'graph.impactAnalysis': '影响分析',
'graph.directDependents': '直接依赖',
'graph.affectedFiles': '受影响文件',
'graph.fitView': '适应视图',
'graph.zoomIn': '放大',
'graph.zoomOut': '缩小',
'graph.resetLayout': '重置布局',
// CLI Sync (used in claude-manager.js)
'claude.cliSync': 'CLI 自动同步',
'claude.tool': '工具',

View File

@@ -0,0 +1,729 @@
// Graph Explorer View
// Interactive code relationship visualization using Cytoscape.js
// ========== State Variables ==========
var graphData = { nodes: [], edges: [] };
var cyInstance = null;
var activeTab = 'graph';
var nodeFilters = {
MODULE: true,
CLASS: true,
FUNCTION: true,
METHOD: true,
VARIABLE: false
};
var edgeFilters = {
CALLS: true,
IMPORTS: true,
INHERITS: true
};
var selectedNode = null;
var searchProcessData = null;
// ========== Node/Edge Colors ==========
var NODE_COLORS = {
MODULE: '#8B5CF6',
CLASS: '#3B82F6',
FUNCTION: '#10B981',
METHOD: '#F59E0B',
VARIABLE: '#6B7280'
};
var EDGE_COLORS = {
CALLS: '#10B981',
IMPORTS: '#3B82F6',
INHERITS: '#F59E0B'
};
// ========== Main Render Function ==========
async function renderGraphExplorer() {
var container = document.getElementById('mainContent');
if (!container) return;
// Hide stats grid and carousel
hideStatsAndCarousel();
// Show loading state
container.innerHTML = '<div class="graph-explorer-view loading">' +
'<div class="loading-spinner"><i data-lucide="loader-2" class="w-8 h-8 animate-spin"></i></div>' +
'<p>' + t('common.loading') + '</p>' +
'</div>';
// Load data
await Promise.all([
loadGraphData(),
loadSearchProcessData()
]);
// Render layout
container.innerHTML = renderGraphLayout();
// Initialize Cytoscape.js after DOM is ready
setTimeout(function() {
if (activeTab === 'graph') {
initializeCytoscape();
}
if (window.lucide) lucide.createIcons();
}, 100);
}
// ========== Data Loading ==========
async function loadGraphData() {
try {
var nodesResp = await fetch('/api/graph/nodes');
if (!nodesResp.ok) throw new Error('Failed to load graph nodes');
var nodesData = await nodesResp.json();
var edgesResp = await fetch('/api/graph/edges');
if (!edgesResp.ok) throw new Error('Failed to load graph edges');
var edgesData = await edgesResp.json();
graphData = {
nodes: nodesData.nodes || [],
edges: edgesData.edges || []
};
return graphData;
} catch (err) {
console.error('Failed to load graph data:', err);
graphData = { nodes: [], edges: [] };
return graphData;
}
}
async function loadSearchProcessData() {
try {
var response = await fetch('/api/graph/search-process');
if (!response.ok) throw new Error('Failed to load search process data');
var data = await response.json();
searchProcessData = data.searchProcess || null;
return searchProcessData;
} catch (err) {
console.error('Failed to load search process data:', err);
searchProcessData = null;
return null;
}
}
// ========== UI Layout ==========
function renderGraphLayout() {
return '<div class="graph-explorer-view">' +
'<div class="graph-explorer-header">' +
'<h2><i data-lucide="network" class="w-5 h-5"></i> ' + t('graph.title') + '</h2>' +
'<div class="graph-explorer-tabs">' +
'<button class="tab-btn ' + (activeTab === 'graph' ? 'active' : '') + '" onclick="switchGraphTab(\'graph\')">' +
'<i data-lucide="git-branch" class="w-4 h-4"></i> ' + t('graph.codeRelations') +
'</button>' +
'<button class="tab-btn ' + (activeTab === 'search' ? 'active' : '') + '" onclick="switchGraphTab(\'search\')">' +
'<i data-lucide="search" class="w-4 h-4"></i> ' + t('graph.searchProcess') +
'</button>' +
'</div>' +
'</div>' +
'<div class="graph-explorer-content">' +
'<div id="graphTab" class="tab-content ' + (activeTab === 'graph' ? 'active' : '') + '">' +
renderGraphView() +
'</div>' +
'<div id="searchTab" class="tab-content ' + (activeTab === 'search' ? 'active' : '') + '">' +
renderSearchProcessView() +
'</div>' +
'</div>' +
'</div>';
}
function renderGraphView() {
return '<div class="graph-view">' +
'<div class="graph-sidebar">' +
'<div class="graph-controls-section">' +
'<h3>' + t('graph.filters') + '</h3>' +
renderFilterDropdowns() +
'</div>' +
'<div class="graph-legend-section">' +
'<h3>' + t('graph.legend') + '</h3>' +
renderGraphLegend() +
'</div>' +
'<div id="nodeDetailsPanel" class="node-details-panel hidden">' +
'</div>' +
'</div>' +
'<div class="graph-main">' +
'<div class="graph-toolbar">' +
'<div class="graph-toolbar-left">' +
'<span class="graph-stats">' +
'<i data-lucide="circle" class="w-3 h-3"></i> ' +
graphData.nodes.length + ' ' + t('graph.nodes') +
'</span>' +
'<span class="graph-stats">' +
'<i data-lucide="arrow-right" class="w-3 h-3"></i> ' +
graphData.edges.length + ' ' + t('graph.edges') +
'</span>' +
'</div>' +
'<div class="graph-toolbar-right">' +
'<button class="btn-icon" onclick="fitCytoscape()" title="' + t('graph.fitView') + '">' +
'<i data-lucide="maximize-2" class="w-4 h-4"></i>' +
'</button>' +
'<button class="btn-icon" onclick="centerCytoscape()" title="' + t('graph.center') + '">' +
'<i data-lucide="crosshair" class="w-4 h-4"></i>' +
'</button>' +
'<button class="btn-icon" onclick="resetGraphFilters()" title="' + t('graph.resetFilters') + '">' +
'<i data-lucide="filter-x" class="w-4 h-4"></i>' +
'</button>' +
'<button class="btn-icon" onclick="refreshGraphData()" title="' + t('common.refresh') + '">' +
'<i data-lucide="refresh-cw" class="w-4 h-4"></i>' +
'</button>' +
'</div>' +
'</div>' +
'<div id="cytoscapeContainer" class="cytoscape-container"></div>' +
'</div>' +
'</div>';
}
function renderFilterDropdowns() {
return '<div class="filter-dropdowns">' +
'<div class="filter-group">' +
'<label>' + t('graph.nodeTypes') + '</label>' +
Object.keys(NODE_COLORS).map(function(type) {
return '<label class="filter-checkbox">' +
'<input type="checkbox" ' + (nodeFilters[type] ? 'checked' : '') + ' onchange="toggleNodeFilter(\'' + type + '\', this.checked)">' +
'<span class="filter-color" style="background-color: ' + NODE_COLORS[type] + '"></span>' +
'<span>' + type + '</span>' +
'</label>';
}).join('') +
'</div>' +
'<div class="filter-group">' +
'<label>' + t('graph.edgeTypes') + '</label>' +
Object.keys(EDGE_COLORS).map(function(type) {
return '<label class="filter-checkbox">' +
'<input type="checkbox" ' + (edgeFilters[type] ? 'checked' : '') + ' onchange="toggleEdgeFilter(\'' + type + '\', this.checked)">' +
'<span class="filter-color" style="background-color: ' + EDGE_COLORS[type] + '"></span>' +
'<span>' + type + '</span>' +
'</label>';
}).join('') +
'</div>' +
'</div>';
}
function renderGraphLegend() {
return '<div class="graph-legend">' +
'<div class="legend-title">' + t('graph.nodeTypes') + '</div>' +
Object.keys(NODE_COLORS).map(function(type) {
return '<div class="legend-item">' +
'<span class="legend-dot" style="background-color: ' + NODE_COLORS[type] + '"></span>' +
'<span>' + type + '</span>' +
'</div>';
}).join('') +
'<div class="legend-title" style="margin-top: 1rem;">' + t('graph.edgeTypes') + '</div>' +
Object.keys(EDGE_COLORS).map(function(type) {
return '<div class="legend-item">' +
'<span class="legend-line" style="background-color: ' + EDGE_COLORS[type] + '"></span>' +
'<span>' + type + '</span>' +
'</div>';
}).join('') +
'</div>';
}
function renderSearchProcessView() {
if (!searchProcessData) {
return '<div class="search-process-empty">' +
'<i data-lucide="search-x" class="w-12 h-12"></i>' +
'<p>' + t('graph.noSearchData') + '</p>' +
'</div>';
}
return '<div class="search-process-view">' +
'<div class="search-process-header">' +
'<h3>' + t('graph.searchProcessTitle') + '</h3>' +
'<p class="search-process-desc">' + t('graph.searchProcessDesc') + '</p>' +
'</div>' +
'<div class="search-process-timeline">' +
(searchProcessData.steps || []).map(function(step, index) {
return '<div class="search-step">' +
'<div class="search-step-number">' + (index + 1) + '</div>' +
'<div class="search-step-content">' +
'<h4>' + escapeHtml(step.name || 'Step ' + (index + 1)) + '</h4>' +
'<p>' + escapeHtml(step.description || '') + '</p>' +
(step.results ? '<div class="search-step-results">' +
'<span class="result-count">' + (step.results.length || 0) + ' ' + t('graph.resultsFound') + '</span>' +
'</div>' : '') +
'</div>' +
'</div>';
}).join('') +
'</div>' +
'</div>';
}
// ========== Tab Switching ==========
function switchGraphTab(tab) {
activeTab = tab;
// Update tab buttons
var tabBtns = document.querySelectorAll('.graph-explorer-tabs .tab-btn');
tabBtns.forEach(function(btn) {
btn.classList.remove('active');
});
event.target.closest('.tab-btn').classList.add('active');
// Update tab content
document.getElementById('graphTab').classList.toggle('active', tab === 'graph');
document.getElementById('searchTab').classList.toggle('active', tab === 'search');
// Initialize Cytoscape if switching to graph tab
if (tab === 'graph' && !cyInstance) {
setTimeout(function() {
initializeCytoscape();
}, 100);
}
}
// ========== Cytoscape.js Integration ==========
function initializeCytoscape() {
var container = document.getElementById('cytoscapeContainer');
if (!container) return;
// Check if Cytoscape.js is loaded
if (typeof cytoscape === 'undefined') {
container.innerHTML = '<div class="cytoscape-error">' +
'<i data-lucide="alert-triangle" class="w-8 h-8"></i>' +
'<p>' + t('graph.cytoscapeNotLoaded') + '</p>' +
'</div>';
if (window.lucide) lucide.createIcons();
return;
}
if (graphData.nodes.length === 0) {
container.innerHTML = '<div class="cytoscape-empty">' +
'<i data-lucide="network" class="w-12 h-12"></i>' +
'<p>' + t('graph.noGraphData') + '</p>' +
'</div>';
if (window.lucide) lucide.createIcons();
return;
}
// Transform data for Cytoscape
var elements = transformDataForCytoscape();
// Create Cytoscape instance
cyInstance = cytoscape({
container: container,
elements: elements,
style: getCytoscapeStyles(),
layout: {
name: 'cose',
idealEdgeLength: 100,
nodeOverlap: 20,
refresh: 20,
fit: true,
padding: 30,
randomize: false,
componentSpacing: 100,
nodeRepulsion: 400000,
edgeElasticity: 100,
nestingFactor: 5,
gravity: 80,
numIter: 1000,
initialTemp: 200,
coolingFactor: 0.95,
minTemp: 1.0
},
minZoom: 0.1,
maxZoom: 3,
wheelSensitivity: 0.2
});
// Bind events
cyInstance.on('tap', 'node', function(evt) {
var node = evt.target;
selectNode(node.data());
});
cyInstance.on('tap', function(evt) {
if (evt.target === cyInstance) {
deselectNode();
}
});
// Fit view after layout
setTimeout(function() {
fitCytoscape();
}, 100);
}
function transformDataForCytoscape() {
var elements = [];
// Filter nodes
var filteredNodes = graphData.nodes.filter(function(node) {
var type = node.type || 'MODULE';
return nodeFilters[type];
});
// Add nodes
filteredNodes.forEach(function(node) {
elements.push({
group: 'nodes',
data: {
id: node.id,
label: node.name || node.id,
type: node.type || 'MODULE',
symbolType: node.symbolType,
path: node.path,
lineNumber: node.lineNumber,
imports: node.imports || 0,
exports: node.exports || 0,
references: node.references || 0
}
});
});
// Create node ID set for filtering edges
var nodeIdSet = new Set(filteredNodes.map(function(n) { return n.id; }));
// Filter edges
var filteredEdges = graphData.edges.filter(function(edge) {
var type = edge.type || 'CALLS';
return edgeFilters[type] &&
nodeIdSet.has(edge.source) &&
nodeIdSet.has(edge.target);
});
// Add edges
filteredEdges.forEach(function(edge, index) {
elements.push({
group: 'edges',
data: {
id: 'edge-' + index,
source: edge.source,
target: edge.target,
type: edge.type || 'CALLS',
weight: edge.weight || 1
}
});
});
return elements;
}
function getCytoscapeStyles() {
var styles = [
// Node styles by type
{
selector: 'node',
style: {
'background-color': function(ele) {
return NODE_COLORS[ele.data('type')] || '#6B7280';
},
'label': 'data(label)',
'width': function(ele) {
var refs = ele.data('references') || 0;
return Math.max(20, Math.min(60, 20 + refs * 2));
},
'height': function(ele) {
var refs = ele.data('references') || 0;
return Math.max(20, Math.min(60, 20 + refs * 2));
},
'text-valign': 'center',
'text-halign': 'center',
'font-size': '10px',
'color': '#000',
'text-outline-color': '#fff',
'text-outline-width': 2,
'overlay-padding': 6
}
},
// Selected node
{
selector: 'node:selected',
style: {
'border-width': 3,
'border-color': '#000',
'overlay-color': '#000',
'overlay-opacity': 0.2
}
},
// Edge styles by type
{
selector: 'edge',
style: {
'width': function(ele) {
return Math.max(1, ele.data('weight') || 1);
},
'line-color': function(ele) {
return EDGE_COLORS[ele.data('type')] || '#6B7280';
},
'target-arrow-color': function(ele) {
return EDGE_COLORS[ele.data('type')] || '#6B7280';
},
'target-arrow-shape': 'triangle',
'curve-style': 'bezier',
'arrow-scale': 1.2,
'opacity': 0.6
}
},
// Selected edge
{
selector: 'edge:selected',
style: {
'line-color': '#000',
'target-arrow-color': '#000',
'width': 3,
'opacity': 1
}
}
];
return styles;
}
// ========== Node Selection ==========
function selectNode(nodeData) {
selectedNode = nodeData;
// Highlight in cytoscape
if (cyInstance) {
cyInstance.nodes().removeClass('selected');
var node = cyInstance.getElementById(nodeData.id);
if (node) {
node.addClass('selected');
// Highlight connected edges
cyInstance.edges().removeClass('highlighted');
node.connectedEdges().addClass('highlighted');
}
}
// Show details panel
var panel = document.getElementById('nodeDetailsPanel');
if (panel) {
panel.classList.remove('hidden');
panel.innerHTML = renderNodeDetails(nodeData);
if (window.lucide) lucide.createIcons();
}
}
function deselectNode() {
selectedNode = null;
// Remove highlights
if (cyInstance) {
cyInstance.nodes().removeClass('selected');
cyInstance.edges().removeClass('highlighted');
}
// Hide details panel
var panel = document.getElementById('nodeDetailsPanel');
if (panel) {
panel.classList.add('hidden');
}
}
function renderNodeDetails(node) {
var typeIcon = node.type === 'MODULE' ? 'package' :
node.type === 'CLASS' ? 'box' :
node.type === 'FUNCTION' ? 'code' :
node.type === 'METHOD' ? 'code-2' :
'variable';
return '<div class="node-details-content">' +
'<div class="node-details-header">' +
'<h4><i data-lucide="' + typeIcon + '" class="w-4 h-4"></i> ' + escapeHtml(node.label || node.id) + '</h4>' +
'<button class="btn-icon btn-sm" onclick="deselectNode()" title="' + t('common.close') + '">' +
'<i data-lucide="x" class="w-3 h-3"></i>' +
'</button>' +
'</div>' +
'<div class="node-details-meta">' +
'<div class="meta-item">' +
'<span class="meta-label">' + t('graph.type') + '</span>' +
'<span class="meta-value">' + (node.type || 'MODULE') + '</span>' +
'</div>' +
(node.symbolType ? '<div class="meta-item">' +
'<span class="meta-label">' + t('graph.symbolType') + '</span>' +
'<span class="meta-value">' + escapeHtml(node.symbolType) + '</span>' +
'</div>' : '') +
(node.path ? '<div class="meta-item">' +
'<span class="meta-label">' + t('graph.path') + '</span>' +
'<span class="meta-value path-value">' + escapeHtml(node.path) + '</span>' +
'</div>' : '') +
(node.lineNumber ? '<div class="meta-item">' +
'<span class="meta-label">' + t('graph.line') + '</span>' +
'<span class="meta-value">' + node.lineNumber + '</span>' +
'</div>' : '') +
'</div>' +
'<div class="node-details-stats">' +
'<div class="stat-item">' +
'<i data-lucide="download" class="w-3 h-3"></i>' +
'<span>' + (node.imports || 0) + ' ' + t('graph.imports') + '</span>' +
'</div>' +
'<div class="stat-item">' +
'<i data-lucide="upload" class="w-3 h-3"></i>' +
'<span>' + (node.exports || 0) + ' ' + t('graph.exports') + '</span>' +
'</div>' +
'<div class="stat-item">' +
'<i data-lucide="link" class="w-3 h-3"></i>' +
'<span>' + (node.references || 0) + ' ' + t('graph.references') + '</span>' +
'</div>' +
'</div>' +
'<div class="node-details-actions">' +
'<button class="btn btn-sm btn-primary" onclick="showImpactAnalysis(\'' + escapeHtml(node.id) + '\')">' +
'<i data-lucide="target" class="w-3 h-3"></i> ' + t('graph.impactAnalysis') +
'</button>' +
'</div>' +
'</div>';
}
// ========== Filter Actions ==========
function toggleNodeFilter(type, checked) {
nodeFilters[type] = checked;
refreshCytoscape();
}
function toggleEdgeFilter(type, checked) {
edgeFilters[type] = checked;
refreshCytoscape();
}
function resetGraphFilters() {
// Reset all filters to true
Object.keys(nodeFilters).forEach(function(key) {
nodeFilters[key] = true;
});
Object.keys(edgeFilters).forEach(function(key) {
edgeFilters[key] = true;
});
// Update checkboxes
var checkboxes = document.querySelectorAll('.filter-checkbox input[type="checkbox"]');
checkboxes.forEach(function(cb) {
cb.checked = true;
});
refreshCytoscape();
}
function refreshCytoscape() {
if (!cyInstance) return;
var elements = transformDataForCytoscape();
cyInstance.elements().remove();
cyInstance.add(elements);
cyInstance.layout({
name: 'cose',
idealEdgeLength: 100,
nodeOverlap: 20,
refresh: 20,
fit: true,
padding: 30
}).run();
deselectNode();
}
// ========== Cytoscape Controls ==========
function fitCytoscape() {
if (cyInstance) {
cyInstance.fit(null, 30);
}
}
function centerCytoscape() {
if (cyInstance) {
cyInstance.center();
}
}
// ========== Impact Analysis ==========
async function showImpactAnalysis(symbolId) {
try {
var response = await fetch('/api/graph/impact/' + encodeURIComponent(symbolId));
if (!response.ok) throw new Error('Failed to fetch impact analysis');
var data = await response.json();
// Show modal with impact analysis results
showImpactModal(data.impact);
} catch (err) {
console.error('Failed to fetch impact analysis:', err);
if (window.showToast) {
showToast(t('graph.impactAnalysisError'), 'error');
}
}
}
function showImpactModal(impact) {
var modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.innerHTML = '<div class="modal-container">' +
'<div class="modal-header">' +
'<h3><i data-lucide="target" class="w-4 h-4"></i> ' + t('graph.impactAnalysis') + '</h3>' +
'<button class="btn-icon" onclick="closeImpactModal()">' +
'<i data-lucide="x" class="w-4 h-4"></i>' +
'</button>' +
'</div>' +
'<div class="modal-body">' +
'<div class="impact-summary">' +
'<div class="impact-stat">' +
'<span class="impact-stat-value">' + (impact.affectedFiles || 0) + '</span>' +
'<span class="impact-stat-label">' + t('graph.affectedFiles') + '</span>' +
'</div>' +
'<div class="impact-stat">' +
'<span class="impact-stat-value">' + (impact.affectedSymbols || 0) + '</span>' +
'<span class="impact-stat-label">' + t('graph.affectedSymbols') + '</span>' +
'</div>' +
'<div class="impact-stat">' +
'<span class="impact-stat-value">' + (impact.depth || 0) + '</span>' +
'<span class="impact-stat-label">' + t('graph.depth') + '</span>' +
'</div>' +
'</div>' +
(impact.files && impact.files.length > 0 ? '<div class="impact-files">' +
'<h4>' + t('graph.affectedFiles') + '</h4>' +
'<div class="impact-files-list">' +
impact.files.map(function(file) {
return '<div class="impact-file-item">' +
'<i data-lucide="file" class="w-3 h-3"></i>' +
'<span>' + escapeHtml(file) + '</span>' +
'</div>';
}).join('') +
'</div>' +
'</div>' : '') +
'</div>' +
'<div class="modal-footer">' +
'<button class="btn btn-secondary" onclick="closeImpactModal()">' + t('common.close') + '</button>' +
'</div>' +
'</div>';
document.body.appendChild(modal);
if (window.lucide) lucide.createIcons();
// Close on overlay click
modal.addEventListener('click', function(e) {
if (e.target === modal) {
closeImpactModal();
}
});
}
function closeImpactModal() {
var modal = document.querySelector('.modal-overlay');
if (modal) {
modal.remove();
}
}
// ========== Data Refresh ==========
async function refreshGraphData() {
if (window.showToast) {
showToast(t('common.refreshing'), 'info');
}
await loadGraphData();
if (activeTab === 'graph' && cyInstance) {
refreshCytoscape();
}
if (window.showToast) {
showToast(t('common.refreshed'), 'success');
}
}
// ========== Utility ==========
function hideStatsAndCarousel() {
var statsGrid = document.getElementById('statsGrid');
var carousel = document.getElementById('carouselContainer');
if (statsGrid) statsGrid.style.display = 'none';
if (carousel) carousel.style.display = 'none';
}

View File

@@ -331,6 +331,10 @@
<i data-lucide="history" class="nav-icon"></i>
<span class="nav-text flex-1" data-i18n="nav.history">History</span>
</li>
<li class="nav-item flex items-center gap-2 mx-2 px-3 py-2.5 text-sm text-muted-foreground hover:bg-hover hover:text-foreground rounded cursor-pointer transition-colors" data-view="graph-explorer" data-tooltip="Code Graph Explorer">
<i data-lucide="git-branch" class="nav-icon"></i>
<span class="nav-text flex-1" data-i18n="nav.graphExplorer">Graph</span>
</li>
</ul>
</div>
@@ -806,6 +810,8 @@
<script src="./assets/js/lucide.min.js"></script>
<!-- D3.js for Flowchart (本地) -->
<script src="./assets/js/d3.min.js"></script>
<!-- Cytoscape.js for Graph Visualization (本地) -->
<script src="./assets/js/cytoscape.min.js"></script>
<!-- Marked.js for Markdown rendering (本地) -->
<script src="./assets/js/marked.min.js"></script>
<!-- Highlight.js for Syntax Highlighting (本地) -->