spec-gen-cli 1.2.2 → 1.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +272 -25
- package/dist/api/generate.d.ts.map +1 -1
- package/dist/api/generate.js +11 -7
- package/dist/api/generate.js.map +1 -1
- package/dist/api/run.d.ts.map +1 -1
- package/dist/api/run.js +5 -3
- package/dist/api/run.js.map +1 -1
- package/dist/api/types.d.ts +4 -4
- package/dist/api/types.d.ts.map +1 -1
- package/dist/cli/commands/analyze.d.ts.map +1 -1
- package/dist/cli/commands/analyze.js +101 -41
- package/dist/cli/commands/analyze.js.map +1 -1
- package/dist/cli/commands/generate.d.ts.map +1 -1
- package/dist/cli/commands/generate.js +28 -23
- package/dist/cli/commands/generate.js.map +1 -1
- package/dist/cli/commands/mcp.d.ts +353 -10
- package/dist/cli/commands/mcp.d.ts.map +1 -1
- package/dist/cli/commands/mcp.js +241 -48
- package/dist/cli/commands/mcp.js.map +1 -1
- package/dist/cli/commands/view.d.ts.map +1 -1
- package/dist/cli/commands/view.js +33 -4
- package/dist/cli/commands/view.js.map +1 -1
- package/dist/constants.d.ts +11 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +11 -0
- package/dist/constants.js.map +1 -1
- package/dist/core/analyzer/artifact-generator.d.ts.map +1 -1
- package/dist/core/analyzer/artifact-generator.js +11 -3
- package/dist/core/analyzer/artifact-generator.js.map +1 -1
- package/dist/core/analyzer/ast-chunker.d.ts +24 -0
- package/dist/core/analyzer/ast-chunker.d.ts.map +1 -0
- package/dist/core/analyzer/ast-chunker.js +198 -0
- package/dist/core/analyzer/ast-chunker.js.map +1 -0
- package/dist/core/analyzer/call-graph.d.ts +52 -5
- package/dist/core/analyzer/call-graph.d.ts.map +1 -1
- package/dist/core/analyzer/call-graph.js +769 -48
- package/dist/core/analyzer/call-graph.js.map +1 -1
- package/dist/core/analyzer/code-shaper.d.ts.map +1 -1
- package/dist/core/analyzer/code-shaper.js +5 -0
- package/dist/core/analyzer/code-shaper.js.map +1 -1
- package/dist/core/analyzer/codebase-digest.d.ts +40 -0
- package/dist/core/analyzer/codebase-digest.d.ts.map +1 -0
- package/dist/core/analyzer/codebase-digest.js +194 -0
- package/dist/core/analyzer/codebase-digest.js.map +1 -0
- package/dist/core/analyzer/cpp-header-resolver.d.ts +30 -0
- package/dist/core/analyzer/cpp-header-resolver.d.ts.map +1 -0
- package/dist/core/analyzer/cpp-header-resolver.js +71 -0
- package/dist/core/analyzer/cpp-header-resolver.js.map +1 -0
- package/dist/core/analyzer/dependency-graph.d.ts +19 -0
- package/dist/core/analyzer/dependency-graph.d.ts.map +1 -1
- package/dist/core/analyzer/dependency-graph.js +76 -0
- package/dist/core/analyzer/dependency-graph.js.map +1 -1
- package/dist/core/analyzer/duplicate-detector.d.ts.map +1 -1
- package/dist/core/analyzer/duplicate-detector.js +7 -1
- package/dist/core/analyzer/duplicate-detector.js.map +1 -1
- package/dist/core/analyzer/function-registry-trie.d.ts +21 -0
- package/dist/core/analyzer/function-registry-trie.d.ts.map +1 -0
- package/dist/core/analyzer/function-registry-trie.js +39 -0
- package/dist/core/analyzer/function-registry-trie.js.map +1 -0
- package/dist/core/analyzer/import-resolver-bridge.d.ts +25 -0
- package/dist/core/analyzer/import-resolver-bridge.d.ts.map +1 -0
- package/dist/core/analyzer/import-resolver-bridge.js +99 -0
- package/dist/core/analyzer/import-resolver-bridge.js.map +1 -0
- package/dist/core/analyzer/signature-extractor.d.ts.map +1 -1
- package/dist/core/analyzer/signature-extractor.js +131 -3
- package/dist/core/analyzer/signature-extractor.js.map +1 -1
- package/dist/core/analyzer/subgraph-extractor.d.ts +10 -2
- package/dist/core/analyzer/subgraph-extractor.d.ts.map +1 -1
- package/dist/core/analyzer/subgraph-extractor.js +25 -7
- package/dist/core/analyzer/subgraph-extractor.js.map +1 -1
- package/dist/core/analyzer/type-inference-engine.d.ts +23 -0
- package/dist/core/analyzer/type-inference-engine.d.ts.map +1 -0
- package/dist/core/analyzer/type-inference-engine.js +130 -0
- package/dist/core/analyzer/type-inference-engine.js.map +1 -0
- package/dist/core/analyzer/vector-index.d.ts +35 -6
- package/dist/core/analyzer/vector-index.d.ts.map +1 -1
- package/dist/core/analyzer/vector-index.js +308 -54
- package/dist/core/analyzer/vector-index.js.map +1 -1
- package/dist/core/generator/spec-pipeline.d.ts +31 -11
- package/dist/core/generator/spec-pipeline.d.ts.map +1 -1
- package/dist/core/generator/spec-pipeline.js +170 -39
- package/dist/core/generator/spec-pipeline.js.map +1 -1
- package/dist/core/generator/stages/stage2-entities.d.ts.map +1 -1
- package/dist/core/generator/stages/stage2-entities.js +2 -1
- package/dist/core/generator/stages/stage2-entities.js.map +1 -1
- package/dist/core/generator/stages/stage3-services.d.ts.map +1 -1
- package/dist/core/generator/stages/stage3-services.js +2 -1
- package/dist/core/generator/stages/stage3-services.js.map +1 -1
- package/dist/core/generator/stages/stage4-api.d.ts.map +1 -1
- package/dist/core/generator/stages/stage4-api.js +2 -1
- package/dist/core/generator/stages/stage4-api.js.map +1 -1
- package/dist/core/generator/stages/stage5-architecture.d.ts +2 -1
- package/dist/core/generator/stages/stage5-architecture.d.ts.map +1 -1
- package/dist/core/generator/stages/stage5-architecture.js +15 -3
- package/dist/core/generator/stages/stage5-architecture.js.map +1 -1
- package/dist/core/services/chat-agent.d.ts +5 -0
- package/dist/core/services/chat-agent.d.ts.map +1 -1
- package/dist/core/services/chat-agent.js +14 -0
- package/dist/core/services/chat-agent.js.map +1 -1
- package/dist/core/services/chat-tools.d.ts.map +1 -1
- package/dist/core/services/chat-tools.js +172 -50
- package/dist/core/services/chat-tools.js.map +1 -1
- package/dist/core/services/llm-service.d.ts +23 -1
- package/dist/core/services/llm-service.d.ts.map +1 -1
- package/dist/core/services/llm-service.js +94 -2
- package/dist/core/services/llm-service.js.map +1 -1
- package/dist/core/services/mcp-handlers/analysis.d.ts +12 -0
- package/dist/core/services/mcp-handlers/analysis.d.ts.map +1 -1
- package/dist/core/services/mcp-handlers/analysis.js +138 -2
- package/dist/core/services/mcp-handlers/analysis.js.map +1 -1
- package/dist/core/services/mcp-handlers/graph.d.ts +21 -1
- package/dist/core/services/mcp-handlers/graph.d.ts.map +1 -1
- package/dist/core/services/mcp-handlers/graph.js +142 -2
- package/dist/core/services/mcp-handlers/graph.js.map +1 -1
- package/dist/core/services/mcp-handlers/orient.d.ts +17 -0
- package/dist/core/services/mcp-handlers/orient.d.ts.map +1 -0
- package/dist/core/services/mcp-handlers/orient.js +200 -0
- package/dist/core/services/mcp-handlers/orient.js.map +1 -0
- package/dist/core/services/mcp-handlers/semantic.d.ts +18 -4
- package/dist/core/services/mcp-handlers/semantic.d.ts.map +1 -1
- package/dist/core/services/mcp-handlers/semantic.js +161 -17
- package/dist/core/services/mcp-handlers/semantic.js.map +1 -1
- package/dist/core/services/mcp-handlers/utils.d.ts +43 -0
- package/dist/core/services/mcp-handlers/utils.d.ts.map +1 -1
- package/dist/core/services/mcp-handlers/utils.js +66 -1
- package/dist/core/services/mcp-handlers/utils.js.map +1 -1
- package/dist/core/services/mcp-watcher.d.ts +41 -0
- package/dist/core/services/mcp-watcher.d.ts.map +1 -0
- package/dist/core/services/mcp-watcher.js +177 -0
- package/dist/core/services/mcp-watcher.js.map +1 -0
- package/dist/types/index.d.ts +2 -2
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/pipeline.d.ts +7 -0
- package/dist/types/pipeline.d.ts.map +1 -1
- package/package.json +4 -2
- package/src/viewer/InteractiveGraphViewer.jsx +39 -10
- package/src/viewer/components/ChatPanel.jsx +8 -5
- package/src/viewer/components/ClassGraph.jsx +782 -0
- package/src/viewer/components/FlatGraph.jsx +3 -3
- package/src/viewer/utils/graph-helpers.js +9 -1
- package/src/viewer/utils/themes.js +36 -0
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ClassGraph — force-directed graph mixing real class nodes and file-level
|
|
3
|
+
* module nodes (free functions grouped by file).
|
|
4
|
+
*
|
|
5
|
+
* Node types:
|
|
6
|
+
* - Class node → circle, colored by language
|
|
7
|
+
* - Module node → rounded-rect (hexagonal feel), dimmer fill, name in [brackets]
|
|
8
|
+
*
|
|
9
|
+
* Edge types:
|
|
10
|
+
* - extends / embeds / implements → solid/dashed UML arrows
|
|
11
|
+
* - cross-class/module calls → curved bezier, weight = call count
|
|
12
|
+
*
|
|
13
|
+
* Hover shows a floating tooltip with full details.
|
|
14
|
+
* Click to expand and see individual method sub-nodes.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { useState, useMemo, useCallback } from 'react';
|
|
18
|
+
import { usePanZoom } from '../hooks/usePanZoom.js';
|
|
19
|
+
|
|
20
|
+
// ============================================================================
|
|
21
|
+
// CONSTANTS
|
|
22
|
+
// ============================================================================
|
|
23
|
+
|
|
24
|
+
const W = 1400;
|
|
25
|
+
const H = 800;
|
|
26
|
+
const CLASS_R = 28; // base radius for class nodes
|
|
27
|
+
const MODULE_R = 24; // base radius for module nodes (smaller)
|
|
28
|
+
const ITERS = 240; // force-layout iterations
|
|
29
|
+
|
|
30
|
+
const LANG_CSS_VAR = {
|
|
31
|
+
TypeScript: 'var(--lc-cyan)',
|
|
32
|
+
JavaScript: 'var(--lc-yellow)',
|
|
33
|
+
Python: 'var(--lc-green)',
|
|
34
|
+
Java: 'var(--lc-orange)',
|
|
35
|
+
'C++': 'var(--lc-purple)',
|
|
36
|
+
Go: 'var(--lc-cyan)',
|
|
37
|
+
Rust: 'var(--lc-red)',
|
|
38
|
+
Ruby: 'var(--lc-pink)',
|
|
39
|
+
Swift: 'var(--lc-orange)',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const COMPONENT_COLORS = [
|
|
43
|
+
'var(--lc-cyan)',
|
|
44
|
+
'var(--lc-orange)',
|
|
45
|
+
'var(--lc-green)',
|
|
46
|
+
'var(--lc-pink)',
|
|
47
|
+
'var(--lc-purple)',
|
|
48
|
+
'var(--lc-yellow)',
|
|
49
|
+
'var(--lc-red)',
|
|
50
|
+
'#7eb8f7',
|
|
51
|
+
'#f7c56a',
|
|
52
|
+
'#a0e8a0',
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
function langColor(lang) {
|
|
56
|
+
return LANG_CSS_VAR[lang] ?? 'var(--ac-primary)';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function componentColor(compIndex) {
|
|
60
|
+
return COMPONENT_COLORS[compIndex % COMPONENT_COLORS.length];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ============================================================================
|
|
64
|
+
// FORCE LAYOUT
|
|
65
|
+
// ============================================================================
|
|
66
|
+
|
|
67
|
+
// Find connected components (union-find)
|
|
68
|
+
function connectedComponents(nodes, edges) {
|
|
69
|
+
const parent = new Map(nodes.map(n => [n.id, n.id]));
|
|
70
|
+
function find(x) {
|
|
71
|
+
if (parent.get(x) !== x) parent.set(x, find(parent.get(x)));
|
|
72
|
+
return parent.get(x);
|
|
73
|
+
}
|
|
74
|
+
function union(a, b) { parent.set(find(a), find(b)); }
|
|
75
|
+
edges.forEach(e => { if (parent.has(e.source) && parent.has(e.target)) union(e.source, e.target); });
|
|
76
|
+
const groups = new Map();
|
|
77
|
+
nodes.forEach(n => {
|
|
78
|
+
const root = find(n.id);
|
|
79
|
+
if (!groups.has(root)) groups.set(root, []);
|
|
80
|
+
groups.get(root).push(n);
|
|
81
|
+
});
|
|
82
|
+
return [...groups.values()].sort((a, b) => b.length - a.length);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function forceLayout(nodes, edges) {
|
|
86
|
+
if (!nodes.length) return {};
|
|
87
|
+
const pos = {};
|
|
88
|
+
const angle0 = -Math.PI / 2;
|
|
89
|
+
|
|
90
|
+
// Assign each component its own center so components don't overlap
|
|
91
|
+
const components = connectedComponents(nodes, edges);
|
|
92
|
+
const nComp = components.length;
|
|
93
|
+
components.forEach((comp, ci) => {
|
|
94
|
+
const a = angle0 + (ci / nComp) * Math.PI * 2;
|
|
95
|
+
const cx = nComp === 1 ? W / 2 : W / 2 + Math.cos(a) * W * 0.28;
|
|
96
|
+
const cy = nComp === 1 ? H / 2 : H / 2 + Math.sin(a) * H * 0.24;
|
|
97
|
+
const r = Math.min(60, 18 * Math.sqrt(comp.length));
|
|
98
|
+
comp.forEach((n, i) => {
|
|
99
|
+
const a2 = angle0 + (i / Math.max(comp.length, 1)) * Math.PI * 2;
|
|
100
|
+
pos[n.id] = { x: cx + Math.cos(a2) * r, y: cy + Math.sin(a2) * r };
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const k = Math.sqrt((W * H) / Math.max(nodes.length, 1)) * 0.65;
|
|
105
|
+
|
|
106
|
+
for (let iter = 0; iter < ITERS; iter++) {
|
|
107
|
+
const disp = {};
|
|
108
|
+
nodes.forEach((n) => { disp[n.id] = { x: 0, y: 0 }; });
|
|
109
|
+
|
|
110
|
+
// Repulsion (only within same component to avoid inter-component mixing)
|
|
111
|
+
const compOf = new Map();
|
|
112
|
+
components.forEach((comp, ci) => comp.forEach(n => compOf.set(n.id, ci)));
|
|
113
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
114
|
+
for (let j = i + 1; j < nodes.length; j++) {
|
|
115
|
+
const a = nodes[i], b = nodes[j];
|
|
116
|
+
if (compOf.get(a.id) !== compOf.get(b.id)) continue;
|
|
117
|
+
const dx = pos[a.id].x - pos[b.id].x;
|
|
118
|
+
const dy = pos[a.id].y - pos[b.id].y;
|
|
119
|
+
const d = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
|
|
120
|
+
const f = (k * k) / d;
|
|
121
|
+
disp[a.id].x += (dx / d) * f;
|
|
122
|
+
disp[a.id].y += (dy / d) * f;
|
|
123
|
+
disp[b.id].x -= (dx / d) * f;
|
|
124
|
+
disp[b.id].y -= (dy / d) * f;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Attraction along edges
|
|
129
|
+
edges.forEach((e) => {
|
|
130
|
+
if (!pos[e.source] || !pos[e.target]) return;
|
|
131
|
+
const dx = pos[e.source].x - pos[e.target].x;
|
|
132
|
+
const dy = pos[e.source].y - pos[e.target].y;
|
|
133
|
+
const d = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
|
|
134
|
+
const f = (d * d) / (k * 2);
|
|
135
|
+
disp[e.source].x -= (dx / d) * f;
|
|
136
|
+
disp[e.source].y -= (dy / d) * f;
|
|
137
|
+
disp[e.target].x += (dx / d) * f;
|
|
138
|
+
disp[e.target].y += (dy / d) * f;
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Gravity toward each component's assigned center
|
|
142
|
+
const compCenters = new Map();
|
|
143
|
+
components.forEach((comp, ci) => {
|
|
144
|
+
const a = angle0 + (ci / nComp) * Math.PI * 2;
|
|
145
|
+
compCenters.set(ci, {
|
|
146
|
+
cx: nComp === 1 ? W / 2 : W / 2 + Math.cos(a) * W * 0.28,
|
|
147
|
+
cy: nComp === 1 ? H / 2 : H / 2 + Math.sin(a) * H * 0.24,
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
nodes.forEach((n) => {
|
|
151
|
+
const ci = compOf.get(n.id);
|
|
152
|
+
const { cx, cy } = compCenters.get(ci);
|
|
153
|
+
disp[n.id].x += (cx - pos[n.id].x) * 0.06;
|
|
154
|
+
disp[n.id].y += (cy - pos[n.id].y) * 0.06;
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const temp = k * Math.max(0.01, 1 - iter / ITERS) * 0.7;
|
|
158
|
+
nodes.forEach((n) => {
|
|
159
|
+
const d = Math.sqrt(disp[n.id].x ** 2 + disp[n.id].y ** 2);
|
|
160
|
+
if (d > 0) {
|
|
161
|
+
pos[n.id].x += (disp[n.id].x / d) * Math.min(d, temp);
|
|
162
|
+
pos[n.id].y += (disp[n.id].y / d) * Math.min(d, temp);
|
|
163
|
+
}
|
|
164
|
+
pos[n.id].x = Math.max(70, Math.min(W - 70, pos[n.id].x));
|
|
165
|
+
pos[n.id].y = Math.max(70, Math.min(H - 70, pos[n.id].y));
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
return pos;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ============================================================================
|
|
172
|
+
// CURVED PATH HELPER
|
|
173
|
+
// ============================================================================
|
|
174
|
+
|
|
175
|
+
function curvePath(x1, y1, x2, y2, bend = 0.18) {
|
|
176
|
+
const mx = (x1 + x2) / 2;
|
|
177
|
+
const my = (y1 + y2) / 2;
|
|
178
|
+
const dx = x2 - x1;
|
|
179
|
+
const dy = y2 - y1;
|
|
180
|
+
// Perpendicular offset for bezier control point
|
|
181
|
+
const cpx = mx - dy * bend;
|
|
182
|
+
const cpy = my + dx * bend;
|
|
183
|
+
return `M ${x1} ${y1} Q ${cpx} ${cpy} ${x2} ${y2}`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ============================================================================
|
|
187
|
+
// SVG DEFS
|
|
188
|
+
// ============================================================================
|
|
189
|
+
|
|
190
|
+
function Defs() {
|
|
191
|
+
return (
|
|
192
|
+
<defs>
|
|
193
|
+
<marker id="cg-inherit" markerWidth="10" markerHeight="8" refX="9" refY="4" orient="auto">
|
|
194
|
+
<polygon points="0 0, 9 4, 0 8" fill="none" stroke="var(--lc-purple)" strokeWidth="1.3" />
|
|
195
|
+
</marker>
|
|
196
|
+
<marker id="cg-impl" markerWidth="10" markerHeight="8" refX="9" refY="4" orient="auto">
|
|
197
|
+
<polygon points="0 0, 9 4, 0 8" fill="none" stroke="var(--lc-cyan)" strokeWidth="1.3" />
|
|
198
|
+
</marker>
|
|
199
|
+
<marker id="cg-embeds" markerWidth="10" markerHeight="8" refX="9" refY="4" orient="auto">
|
|
200
|
+
<polygon points="0 4, 5 0, 10 4, 5 8" fill="none" stroke="var(--lc-green)" strokeWidth="1.3" />
|
|
201
|
+
</marker>
|
|
202
|
+
<marker id="cg-call" markerWidth="7" markerHeight="6" refX="6" refY="3" orient="auto">
|
|
203
|
+
<polygon points="0 0, 6 3, 0 6" fill="var(--tx-secondary)" />
|
|
204
|
+
</marker>
|
|
205
|
+
</defs>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ============================================================================
|
|
210
|
+
// EXPANDED CLASS — method grid inside a dashed rectangle
|
|
211
|
+
// ============================================================================
|
|
212
|
+
|
|
213
|
+
function ExpandedClass({ cls, cx, cy, methods, color, onMethodHover, onMethodLeave }) {
|
|
214
|
+
const ROW_H = 14; // px per function row
|
|
215
|
+
const PAD_X = 12;
|
|
216
|
+
const PAD_TOP = 22;
|
|
217
|
+
const PAD_BOT = 10;
|
|
218
|
+
const maxLabelLen = Math.max(...methods.map(m => m.name.length), cls.name.length, 8);
|
|
219
|
+
const rw = Math.max(maxLabelLen * 6.2 + PAD_X * 2, 110);
|
|
220
|
+
const rh = PAD_TOP + methods.length * ROW_H + PAD_BOT;
|
|
221
|
+
|
|
222
|
+
return (
|
|
223
|
+
<g>
|
|
224
|
+
{/* Card background */}
|
|
225
|
+
<rect
|
|
226
|
+
x={cx - rw / 2} y={cy - rh / 2}
|
|
227
|
+
width={rw} height={rh}
|
|
228
|
+
rx={6}
|
|
229
|
+
fill="var(--bg-raised)"
|
|
230
|
+
stroke={color}
|
|
231
|
+
strokeWidth={1.5}
|
|
232
|
+
/>
|
|
233
|
+
{/* Header bar */}
|
|
234
|
+
<rect
|
|
235
|
+
x={cx - rw / 2} y={cy - rh / 2}
|
|
236
|
+
width={rw} height={18}
|
|
237
|
+
rx={6}
|
|
238
|
+
fill={color} fillOpacity={0.18}
|
|
239
|
+
stroke="none"
|
|
240
|
+
/>
|
|
241
|
+
<rect
|
|
242
|
+
x={cx - rw / 2} y={cy - rh / 2 + 12}
|
|
243
|
+
width={rw} height={6}
|
|
244
|
+
fill={color} fillOpacity={0.18}
|
|
245
|
+
stroke="none"
|
|
246
|
+
/>
|
|
247
|
+
{/* Class / module name */}
|
|
248
|
+
<text x={cx} y={cy - rh / 2 + 13}
|
|
249
|
+
textAnchor="middle" fontSize={9.5} fill={color} fontWeight="700"
|
|
250
|
+
style={{ fontFamily: 'monospace', pointerEvents: 'none' }}>
|
|
251
|
+
{cls.name}
|
|
252
|
+
</text>
|
|
253
|
+
{/* Divider */}
|
|
254
|
+
<line
|
|
255
|
+
x1={cx - rw / 2 + 6} y1={cy - rh / 2 + 19}
|
|
256
|
+
x2={cx + rw / 2 - 6} y2={cy - rh / 2 + 19}
|
|
257
|
+
stroke={color} strokeWidth={0.5} opacity={0.4}
|
|
258
|
+
/>
|
|
259
|
+
{/* Function list */}
|
|
260
|
+
{methods.map((m, mi) => {
|
|
261
|
+
const fy = cy - rh / 2 + PAD_TOP + mi * ROW_H + ROW_H / 2;
|
|
262
|
+
const isAsync = m.isAsync;
|
|
263
|
+
return (
|
|
264
|
+
<g key={m.id}
|
|
265
|
+
onMouseEnter={(e) => onMethodHover(e, m)}
|
|
266
|
+
onMouseLeave={onMethodLeave}
|
|
267
|
+
style={{ cursor: 'default' }}>
|
|
268
|
+
{/* Hover band (invisible but interactive) */}
|
|
269
|
+
<rect x={cx - rw / 2 + 2} y={fy - ROW_H / 2 + 1}
|
|
270
|
+
width={rw - 4} height={ROW_H - 1}
|
|
271
|
+
fill="transparent" />
|
|
272
|
+
{/* Async indicator dot */}
|
|
273
|
+
{isAsync && (
|
|
274
|
+
<circle cx={cx - rw / 2 + PAD_X - 4} cy={fy} r={2}
|
|
275
|
+
fill={color} opacity={0.6} />
|
|
276
|
+
)}
|
|
277
|
+
{/* Function name */}
|
|
278
|
+
<text
|
|
279
|
+
x={cx - rw / 2 + PAD_X}
|
|
280
|
+
y={fy + 4}
|
|
281
|
+
fontSize={8.5}
|
|
282
|
+
fill="var(--tx-primary)"
|
|
283
|
+
style={{ fontFamily: 'monospace', pointerEvents: 'none' }}>
|
|
284
|
+
{m.name}
|
|
285
|
+
</text>
|
|
286
|
+
{/* fanIn badge on the right */}
|
|
287
|
+
{m.fanIn > 0 && (
|
|
288
|
+
<text
|
|
289
|
+
x={cx + rw / 2 - PAD_X}
|
|
290
|
+
y={fy + 4}
|
|
291
|
+
textAnchor="end"
|
|
292
|
+
fontSize={7}
|
|
293
|
+
fill="var(--tx-dim)"
|
|
294
|
+
style={{ pointerEvents: 'none' }}>
|
|
295
|
+
↙{m.fanIn}
|
|
296
|
+
</text>
|
|
297
|
+
)}
|
|
298
|
+
</g>
|
|
299
|
+
);
|
|
300
|
+
})}
|
|
301
|
+
</g>
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ============================================================================
|
|
306
|
+
// CLASS NODE (collapsed)
|
|
307
|
+
// ============================================================================
|
|
308
|
+
|
|
309
|
+
function ClassNode({ cls, cx, cy, selected, onToggle, onHover, onLeave }) {
|
|
310
|
+
const color = langColor(cls.language);
|
|
311
|
+
const isModule = cls.isModule;
|
|
312
|
+
const r = (isModule ? MODULE_R : CLASS_R) + Math.min(cls.methodIds.length, 16) * 1.2;
|
|
313
|
+
|
|
314
|
+
return (
|
|
315
|
+
<g
|
|
316
|
+
onClick={(e) => { e.stopPropagation(); onToggle(cls.id); }}
|
|
317
|
+
onMouseEnter={(e) => onHover(e, cls)}
|
|
318
|
+
onMouseLeave={onLeave}
|
|
319
|
+
style={{ cursor: 'pointer' }}
|
|
320
|
+
>
|
|
321
|
+
{selected && (
|
|
322
|
+
<circle cx={cx} cy={cy} r={r + 6}
|
|
323
|
+
fill="none" stroke="var(--ac-primary)" strokeWidth={2} opacity={0.7} />
|
|
324
|
+
)}
|
|
325
|
+
{isModule
|
|
326
|
+
? /* Rounded-rect for module nodes */
|
|
327
|
+
<rect
|
|
328
|
+
x={cx - r} y={cy - r * 0.65}
|
|
329
|
+
width={r * 2} height={r * 1.3}
|
|
330
|
+
rx={6}
|
|
331
|
+
fill={color}
|
|
332
|
+
fillOpacity={0.06}
|
|
333
|
+
stroke={color}
|
|
334
|
+
strokeWidth={1.1}
|
|
335
|
+
strokeDasharray="3 2"
|
|
336
|
+
strokeOpacity={0.45}
|
|
337
|
+
/>
|
|
338
|
+
: /* Circle for class nodes */
|
|
339
|
+
<circle cx={cx} cy={cy} r={r}
|
|
340
|
+
fill={color}
|
|
341
|
+
fillOpacity={0.09}
|
|
342
|
+
stroke={color}
|
|
343
|
+
strokeWidth={1.5}
|
|
344
|
+
strokeOpacity={0.5}
|
|
345
|
+
/>
|
|
346
|
+
}
|
|
347
|
+
{/* Name */}
|
|
348
|
+
<text x={cx} y={cy - (isModule ? 2 : 5)}
|
|
349
|
+
textAnchor="middle" fontSize={isModule ? 8.5 : 10}
|
|
350
|
+
fill="var(--tx-primary)" fontWeight="600"
|
|
351
|
+
style={{ fontFamily: 'monospace', pointerEvents: 'none' }}>
|
|
352
|
+
{cls.name.length > 16 ? cls.name.slice(0, 15) + '…' : cls.name}
|
|
353
|
+
</text>
|
|
354
|
+
{/* Stats */}
|
|
355
|
+
<text x={cx} y={cy + (isModule ? 9 : 8)}
|
|
356
|
+
textAnchor="middle" fontSize={7.5}
|
|
357
|
+
fill="var(--tx-muted)"
|
|
358
|
+
style={{ pointerEvents: 'none' }}>
|
|
359
|
+
{cls.methodIds.length} fn · ↙{cls.fanIn}
|
|
360
|
+
</text>
|
|
361
|
+
{/* Language */}
|
|
362
|
+
<text x={cx} y={cy + (isModule ? 20 : 21)}
|
|
363
|
+
textAnchor="middle" fontSize={6.5}
|
|
364
|
+
fill={color} opacity={0.85}
|
|
365
|
+
style={{ pointerEvents: 'none' }}>
|
|
366
|
+
{cls.language}
|
|
367
|
+
</text>
|
|
368
|
+
</g>
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ============================================================================
|
|
373
|
+
// TOOLTIP
|
|
374
|
+
// ============================================================================
|
|
375
|
+
|
|
376
|
+
function Tooltip({ tip }) {
|
|
377
|
+
if (!tip) return null;
|
|
378
|
+
const { x, y, lines } = tip;
|
|
379
|
+
return (
|
|
380
|
+
<div style={{
|
|
381
|
+
position: 'fixed',
|
|
382
|
+
left: x + 14,
|
|
383
|
+
top: y - 10,
|
|
384
|
+
background: 'var(--bg-raised)',
|
|
385
|
+
border: '1px solid var(--bd-muted)',
|
|
386
|
+
borderRadius: 6,
|
|
387
|
+
padding: '6px 10px',
|
|
388
|
+
fontSize: 11,
|
|
389
|
+
color: 'var(--tx-primary)',
|
|
390
|
+
pointerEvents: 'none',
|
|
391
|
+
zIndex: 999,
|
|
392
|
+
maxWidth: 320,
|
|
393
|
+
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
|
|
394
|
+
lineHeight: 1.6,
|
|
395
|
+
fontFamily: 'monospace',
|
|
396
|
+
}}>
|
|
397
|
+
{lines.map((l, i) => (
|
|
398
|
+
<div key={i} style={{ color: l.dim ? 'var(--tx-muted)' : 'var(--tx-primary)', fontSize: l.small ? 10 : 11 }}>
|
|
399
|
+
{l.text}
|
|
400
|
+
</div>
|
|
401
|
+
))}
|
|
402
|
+
</div>
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ============================================================================
|
|
407
|
+
// MAIN COMPONENT
|
|
408
|
+
// ============================================================================
|
|
409
|
+
|
|
410
|
+
export function ClassGraph({ classData, onSelectClass, selectedClassId, focusedPaths = [], onClear }) {
|
|
411
|
+
const { classes: serverClasses = [], inheritanceEdges = [], edges: callEdges = [], nodes: fnNodes = [] } = classData ?? {};
|
|
412
|
+
|
|
413
|
+
const [expanded, setExpanded] = useState(new Set());
|
|
414
|
+
const [tooltip, setTooltip] = useState(null);
|
|
415
|
+
const [hoveredEdge, setHoveredEdge] = useState(null);
|
|
416
|
+
|
|
417
|
+
const focusedPathSet = useMemo(() => new Set(focusedPaths), [focusedPaths]);
|
|
418
|
+
|
|
419
|
+
const { transform, onMouseDown, onMouseMove, onMouseUp, onWheel, onMouseLeave, onDblClick, reset } =
|
|
420
|
+
usePanZoom();
|
|
421
|
+
|
|
422
|
+
const fnMap = useMemo(() => {
|
|
423
|
+
const m = new Map();
|
|
424
|
+
for (const n of fnNodes) m.set(n.id, n);
|
|
425
|
+
return m;
|
|
426
|
+
}, [fnNodes]);
|
|
427
|
+
|
|
428
|
+
// IDs already covered by server-provided class nodes
|
|
429
|
+
const coveredIds = useMemo(() => {
|
|
430
|
+
const s = new Set();
|
|
431
|
+
for (const cls of serverClasses)
|
|
432
|
+
for (const mid of cls.methodIds) s.add(mid);
|
|
433
|
+
return s;
|
|
434
|
+
}, [serverClasses]);
|
|
435
|
+
|
|
436
|
+
// Synthetic module nodes — group uncovered fnNodes by filePath
|
|
437
|
+
const moduleNodes = useMemo(() => {
|
|
438
|
+
const groups = new Map(); // filePath → { methods: FunctionNode[] }
|
|
439
|
+
for (const fn of fnNodes) {
|
|
440
|
+
if (coveredIds.has(fn.id)) continue;
|
|
441
|
+
if (!groups.has(fn.filePath))
|
|
442
|
+
groups.set(fn.filePath, { methods: [] });
|
|
443
|
+
groups.get(fn.filePath).methods.push(fn);
|
|
444
|
+
}
|
|
445
|
+
return Array.from(groups.entries()).map(([fp, g]) => {
|
|
446
|
+
const base = fp.split('/').pop() ?? fp;
|
|
447
|
+
const name = '[' + base.replace(/\.[^.]+$/, '') + ']';
|
|
448
|
+
return {
|
|
449
|
+
id: fp,
|
|
450
|
+
name,
|
|
451
|
+
filePath: fp,
|
|
452
|
+
language: g.methods[0]?.language ?? 'TypeScript',
|
|
453
|
+
parentClasses: [],
|
|
454
|
+
interfaces: [],
|
|
455
|
+
methodIds: g.methods.map(m => m.id),
|
|
456
|
+
fanIn: g.methods.reduce((s, m) => s + (m.fanIn ?? 0), 0),
|
|
457
|
+
fanOut: g.methods.reduce((s, m) => s + (m.fanOut ?? 0), 0),
|
|
458
|
+
isModule: true,
|
|
459
|
+
};
|
|
460
|
+
});
|
|
461
|
+
}, [fnNodes, coveredIds]);
|
|
462
|
+
|
|
463
|
+
// All nodes used for layout + rendering
|
|
464
|
+
const allClasses = useMemo(() => [...serverClasses, ...moduleNodes], [serverClasses, moduleNodes]);
|
|
465
|
+
|
|
466
|
+
const classCallEdges = useMemo(() => {
|
|
467
|
+
// Full methodToClass: server classes + synthetic modules
|
|
468
|
+
const methodToClass = new Map();
|
|
469
|
+
for (const cls of allClasses) {
|
|
470
|
+
for (const mid of cls.methodIds) methodToClass.set(mid, cls.id);
|
|
471
|
+
}
|
|
472
|
+
const counts = new Map();
|
|
473
|
+
const samples = new Map();
|
|
474
|
+
for (const e of callEdges) {
|
|
475
|
+
const src = methodToClass.get(e.callerId);
|
|
476
|
+
const tgt = methodToClass.get(e.calleeId);
|
|
477
|
+
if (!src || !tgt || src === tgt) continue;
|
|
478
|
+
const key = `${src}|||${tgt}`;
|
|
479
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
480
|
+
if (!samples.has(key)) samples.set(key, []);
|
|
481
|
+
if (samples.get(key).length < 3) {
|
|
482
|
+
const callerFn = fnMap.get(e.callerId);
|
|
483
|
+
const calleeFn = fnMap.get(e.calleeId);
|
|
484
|
+
if (callerFn && calleeFn) samples.get(key).push(`${callerFn.name} → ${calleeFn.name}`);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return Array.from(counts.entries()).map(([key, count]) => {
|
|
488
|
+
const [source, target] = key.split('|||');
|
|
489
|
+
return { source, target, count, kind: 'call', samples: samples.get(key) ?? [] };
|
|
490
|
+
});
|
|
491
|
+
}, [allClasses, callEdges, fnMap]);
|
|
492
|
+
|
|
493
|
+
const layoutEdges = useMemo(() => [
|
|
494
|
+
...inheritanceEdges.map(e => ({ source: e.childId, target: e.parentId, kind: e.kind })),
|
|
495
|
+
...classCallEdges,
|
|
496
|
+
], [inheritanceEdges, classCallEdges]);
|
|
497
|
+
|
|
498
|
+
// Only include nodes that participate in at least one edge — isolated nodes
|
|
499
|
+
// flood the canvas and hit the clamping boundary when there are many of them.
|
|
500
|
+
const { connectedClasses, isolatedCount } = useMemo(() => {
|
|
501
|
+
const connected = new Set();
|
|
502
|
+
for (const e of layoutEdges) { connected.add(e.source); connected.add(e.target); }
|
|
503
|
+
const filtered = allClasses.filter(c => connected.has(c.id));
|
|
504
|
+
// Fall back to all nodes if nothing has cross-class edges (avoids blank canvas)
|
|
505
|
+
const connectedClasses = filtered.length > 0 ? filtered : allClasses;
|
|
506
|
+
return { connectedClasses, isolatedCount: allClasses.length - connectedClasses.length };
|
|
507
|
+
}, [allClasses, layoutEdges]);
|
|
508
|
+
|
|
509
|
+
const classKey = connectedClasses.map(c => c.id).join('|');
|
|
510
|
+
const edgeCount = layoutEdges.length;
|
|
511
|
+
|
|
512
|
+
const compIndexMap = useMemo(() => {
|
|
513
|
+
const map = new Map();
|
|
514
|
+
connectedComponents(connectedClasses, layoutEdges).forEach((comp, ci) => {
|
|
515
|
+
comp.forEach(n => map.set(n.id, ci));
|
|
516
|
+
});
|
|
517
|
+
return map;
|
|
518
|
+
}, [classKey, edgeCount]);
|
|
519
|
+
|
|
520
|
+
const pos = useMemo(
|
|
521
|
+
() => forceLayout(connectedClasses, layoutEdges),
|
|
522
|
+
[classKey, edgeCount],
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
const showTip = useCallback((e, lines) => {
|
|
526
|
+
setTooltip({ x: e.clientX, y: e.clientY, lines });
|
|
527
|
+
}, []);
|
|
528
|
+
const hideTip = useCallback(() => setTooltip(null), []);
|
|
529
|
+
|
|
530
|
+
const handleClassHover = useCallback((e, cls) => {
|
|
531
|
+
const lines = [
|
|
532
|
+
{ text: cls.name },
|
|
533
|
+
{ text: cls.filePath.split('/').slice(-2).join('/'), dim: true, small: true },
|
|
534
|
+
{ text: `${cls.methodIds.length} functions · fanIn ${cls.fanIn} · fanOut ${cls.fanOut}`, dim: true, small: true },
|
|
535
|
+
];
|
|
536
|
+
if (cls.parentClasses?.length) lines.push({ text: `extends: ${cls.parentClasses.join(', ')}`, dim: true, small: true });
|
|
537
|
+
if (cls.interfaces?.length) lines.push({ text: `implements: ${cls.interfaces.join(', ')}`, dim: true, small: true });
|
|
538
|
+
showTip(e, lines);
|
|
539
|
+
}, [showTip]);
|
|
540
|
+
|
|
541
|
+
const handleMethodHover = useCallback((e, fn) => {
|
|
542
|
+
showTip(e, [
|
|
543
|
+
{ text: fn.name },
|
|
544
|
+
{ text: fn.filePath.split('/').slice(-2).join('/'), dim: true, small: true },
|
|
545
|
+
{ text: `fanIn ${fn.fanIn} · fanOut ${fn.fanOut}${fn.isAsync ? ' · async' : ''}`, dim: true, small: true },
|
|
546
|
+
]);
|
|
547
|
+
}, [showTip]);
|
|
548
|
+
|
|
549
|
+
const handleEdgeHover = useCallback((e, edge) => {
|
|
550
|
+
const srcCls = allClasses.find(c => c.id === edge.source);
|
|
551
|
+
const tgtCls = allClasses.find(c => c.id === edge.target);
|
|
552
|
+
const lines = [
|
|
553
|
+
{ text: `${srcCls?.name ?? '?'} → ${tgtCls?.name ?? '?'}` },
|
|
554
|
+
{ text: `${edge.count} cross-boundary call${edge.count > 1 ? 's' : ''}`, dim: true, small: true },
|
|
555
|
+
...edge.samples.map(s => ({ text: s, dim: true, small: true })),
|
|
556
|
+
];
|
|
557
|
+
showTip(e, lines);
|
|
558
|
+
setHoveredEdge(`${edge.source}|||${edge.target}`);
|
|
559
|
+
}, [allClasses, showTip]);
|
|
560
|
+
|
|
561
|
+
function toggleExpand(id) {
|
|
562
|
+
setExpanded(prev => {
|
|
563
|
+
const next = new Set(prev);
|
|
564
|
+
if (next.has(id)) next.delete(id); else next.add(id);
|
|
565
|
+
return next;
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if (!allClasses.length) {
|
|
570
|
+
return (
|
|
571
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
572
|
+
height: '100%', color: 'var(--tx-muted)', fontSize: 13 }}>
|
|
573
|
+
No class data — run <code style={{ margin: '0 6px' }}>spec-gen analyze</code> to generate the call graph.
|
|
574
|
+
</div>
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const classCount = serverClasses.length;
|
|
579
|
+
const moduleCount = moduleNodes.length;
|
|
580
|
+
const allLangsSame = new Set(allClasses.map(c => c.language)).size <= 1;
|
|
581
|
+
|
|
582
|
+
return (
|
|
583
|
+
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
|
584
|
+
{/* Stats bar */}
|
|
585
|
+
<div style={{
|
|
586
|
+
position: 'absolute', top: 8, right: 12, zIndex: 2,
|
|
587
|
+
fontSize: 9, color: 'var(--tx-muted)',
|
|
588
|
+
display: 'flex', gap: 14, pointerEvents: 'none',
|
|
589
|
+
}}>
|
|
590
|
+
{classCount > 0 && <span>{classCount} classes</span>}
|
|
591
|
+
<span>{moduleCount} modules</span>
|
|
592
|
+
{inheritanceEdges.length > 0 && <span>{inheritanceEdges.length} inheritance</span>}
|
|
593
|
+
<span>{classCallEdges.length} cross-module calls</span>
|
|
594
|
+
{isolatedCount > 0 && <span style={{ color: 'var(--tx-dim)' }}>{isolatedCount} isolated hidden</span>}
|
|
595
|
+
<span style={{ color: 'var(--tx-dim)' }}>hover for details · click to expand</span>
|
|
596
|
+
</div>
|
|
597
|
+
|
|
598
|
+
<svg
|
|
599
|
+
viewBox={`0 0 ${W} ${H}`}
|
|
600
|
+
width="100%" height="100%"
|
|
601
|
+
style={{ background: 'var(--bg-base)', display: 'block' }}
|
|
602
|
+
onMouseDown={onMouseDown}
|
|
603
|
+
onMouseMove={onMouseMove}
|
|
604
|
+
onMouseUp={onMouseUp}
|
|
605
|
+
onWheel={onWheel}
|
|
606
|
+
onMouseLeave={(e) => { onMouseLeave(e); hideTip(); setHoveredEdge(null); }}
|
|
607
|
+
onDoubleClick={onDblClick}
|
|
608
|
+
onClick={() => onSelectClass?.(null)}
|
|
609
|
+
>
|
|
610
|
+
<Defs />
|
|
611
|
+
|
|
612
|
+
{/* ── View / Clear buttons ────────────────────────────────────────── */}
|
|
613
|
+
<foreignObject x="8" y="8" width="52" height="44" style={{ pointerEvents: 'all' }}>
|
|
614
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
|
615
|
+
<button
|
|
616
|
+
onClick={reset}
|
|
617
|
+
title="Reset pan/zoom (or double-click background)"
|
|
618
|
+
style={{
|
|
619
|
+
fontSize: 8, padding: '2px 6px',
|
|
620
|
+
background: 'var(--bg-input)',
|
|
621
|
+
border: `1px solid ${transform.x !== 0 || transform.y !== 0 || transform.k !== 1 ? 'var(--ac-primary)' : 'var(--bd-muted)'}`,
|
|
622
|
+
borderRadius: 4,
|
|
623
|
+
color: transform.x !== 0 || transform.y !== 0 || transform.k !== 1 ? 'var(--ac-primary)' : 'var(--tx-faint)',
|
|
624
|
+
cursor: 'pointer',
|
|
625
|
+
fontFamily: "'JetBrains Mono',monospace",
|
|
626
|
+
letterSpacing: '0.05em',
|
|
627
|
+
}}
|
|
628
|
+
>⌖ view</button>
|
|
629
|
+
<button
|
|
630
|
+
onClick={() => { setExpanded(new Set()); onSelectClass?.(null); onClear?.(); }}
|
|
631
|
+
title="Clear expanded nodes and focus (Escape)"
|
|
632
|
+
style={{
|
|
633
|
+
fontSize: 8, padding: '2px 6px',
|
|
634
|
+
background: 'var(--bg-input)',
|
|
635
|
+
border: `1px solid ${expanded.size > 0 || focusedPaths.length > 0 ? 'var(--ac-primary)' : 'var(--bd-muted)'}`,
|
|
636
|
+
borderRadius: 4,
|
|
637
|
+
color: expanded.size > 0 || focusedPaths.length > 0 ? 'var(--ac-primary)' : 'var(--tx-faint)',
|
|
638
|
+
cursor: expanded.size > 0 || focusedPaths.length > 0 ? 'pointer' : 'default',
|
|
639
|
+
fontFamily: "'JetBrains Mono',monospace",
|
|
640
|
+
letterSpacing: '0.05em',
|
|
641
|
+
}}
|
|
642
|
+
>x clear</button>
|
|
643
|
+
</div>
|
|
644
|
+
</foreignObject>
|
|
645
|
+
|
|
646
|
+
<g transform={`translate(${transform.x},${transform.y}) scale(${transform.k})`}>
|
|
647
|
+
|
|
648
|
+
{/* ── Call edges ─────────────────────────────────────────────────── */}
|
|
649
|
+
{classCallEdges.map(e => {
|
|
650
|
+
const sp = pos[e.source];
|
|
651
|
+
const tp = pos[e.target];
|
|
652
|
+
if (!sp || !tp) return null;
|
|
653
|
+
const key = `${e.source}|||${e.target}`;
|
|
654
|
+
const isHovered = hoveredEdge === key;
|
|
655
|
+
const w = Math.min(0.7 + e.count * 0.25, 4);
|
|
656
|
+
const d = curvePath(sp.x, sp.y, tp.x, tp.y, 0.15);
|
|
657
|
+
const hasFocus = focusedPathSet.size > 0;
|
|
658
|
+
const edgeFocused = hasFocus && (focusedPathSet.has(e.source) || focusedPathSet.has(e.target));
|
|
659
|
+
const edgeOpacity = isHovered ? 0.9 : hasFocus ? (edgeFocused ? 0.6 : 0.06) : 0.45;
|
|
660
|
+
return (
|
|
661
|
+
<path key={`call-${key}`}
|
|
662
|
+
d={d}
|
|
663
|
+
fill="none"
|
|
664
|
+
stroke="var(--tx-secondary)"
|
|
665
|
+
strokeWidth={isHovered ? w + 1.5 : w}
|
|
666
|
+
strokeDasharray="5 3"
|
|
667
|
+
markerEnd="url(#cg-call)"
|
|
668
|
+
opacity={edgeOpacity}
|
|
669
|
+
onMouseEnter={(ev) => handleEdgeHover(ev, e)}
|
|
670
|
+
onMouseLeave={() => { hideTip(); setHoveredEdge(null); }}
|
|
671
|
+
style={{ cursor: 'pointer', transition: 'opacity 0.2s' }}
|
|
672
|
+
/>
|
|
673
|
+
);
|
|
674
|
+
})}
|
|
675
|
+
|
|
676
|
+
{/* ── Inheritance edges ──────────────────────────────────────────── */}
|
|
677
|
+
{inheritanceEdges.map(e => {
|
|
678
|
+
const sp = pos[e.childId];
|
|
679
|
+
const tp = pos[e.parentId];
|
|
680
|
+
if (!sp || !tp) return null;
|
|
681
|
+
const isImpl = e.kind === 'implements';
|
|
682
|
+
const isEmbed = e.kind === 'embeds';
|
|
683
|
+
const markerId = isEmbed ? 'cg-embeds' : isImpl ? 'cg-impl' : 'cg-inherit';
|
|
684
|
+
const stroke = isImpl ? 'var(--lc-cyan)' : isEmbed ? 'var(--lc-green)' : 'var(--lc-purple)';
|
|
685
|
+
const d = curvePath(sp.x, sp.y, tp.x, tp.y, 0.08);
|
|
686
|
+
return (
|
|
687
|
+
<path key={e.id}
|
|
688
|
+
d={d}
|
|
689
|
+
fill="none"
|
|
690
|
+
stroke={stroke}
|
|
691
|
+
strokeWidth={1.8}
|
|
692
|
+
strokeDasharray={isImpl ? '6 3' : 'none'}
|
|
693
|
+
markerEnd={`url(#${markerId})`}
|
|
694
|
+
opacity={0.85}
|
|
695
|
+
/>
|
|
696
|
+
);
|
|
697
|
+
})}
|
|
698
|
+
|
|
699
|
+
{/* ── Nodes ─────────────────────────────────────────────────────── */}
|
|
700
|
+
{connectedClasses.map(cls => {
|
|
701
|
+
const p = pos[cls.id];
|
|
702
|
+
if (!p) return null;
|
|
703
|
+
const isExpanded = expanded.has(cls.id);
|
|
704
|
+
const methods = cls.methodIds.map(id => fnMap.get(id)).filter(Boolean);
|
|
705
|
+
const compIdx = compIndexMap.get(cls.id) ?? 0;
|
|
706
|
+
const color = allLangsSame ? componentColor(compIdx) : langColor(cls.language);
|
|
707
|
+
const isFocused = focusedPathSet.has(cls.filePath);
|
|
708
|
+
const hasFocus = focusedPathSet.size > 0;
|
|
709
|
+
const isDimmed = hasFocus && !isFocused;
|
|
710
|
+
|
|
711
|
+
return (
|
|
712
|
+
<g key={cls.id} style={{ opacity: isDimmed ? 0.15 : 1, transition: 'opacity 0.2s' }}>
|
|
713
|
+
{isExpanded
|
|
714
|
+
? <>
|
|
715
|
+
<ExpandedClass
|
|
716
|
+
cls={cls} cx={p.x} cy={p.y}
|
|
717
|
+
methods={methods} color={color}
|
|
718
|
+
onMethodHover={handleMethodHover}
|
|
719
|
+
onMethodLeave={hideTip}
|
|
720
|
+
/>
|
|
721
|
+
<g onClick={(ev) => { ev.stopPropagation(); toggleExpand(cls.id); }}
|
|
722
|
+
style={{ cursor: 'pointer' }}>
|
|
723
|
+
<circle cx={p.x} cy={p.y - 4} r={10}
|
|
724
|
+
fill="var(--bg-raised)" stroke={color} strokeWidth={1.2} />
|
|
725
|
+
<text x={p.x} y={p.y + 1} textAnchor="middle"
|
|
726
|
+
fontSize={10} fill={color} fontWeight="bold"
|
|
727
|
+
style={{ pointerEvents: 'none' }}>▲</text>
|
|
728
|
+
</g>
|
|
729
|
+
</>
|
|
730
|
+
: <>
|
|
731
|
+
{isFocused && (
|
|
732
|
+
<circle cx={p.x} cy={p.y}
|
|
733
|
+
r={(cls.isModule ? MODULE_R : CLASS_R) + Math.min(cls.methodIds.length, 16) * 1.2 + 10}
|
|
734
|
+
fill="none"
|
|
735
|
+
stroke="var(--ac-primary)"
|
|
736
|
+
strokeWidth={2.5}
|
|
737
|
+
opacity={0.8}
|
|
738
|
+
style={{ pointerEvents: 'none' }}
|
|
739
|
+
/>
|
|
740
|
+
)}
|
|
741
|
+
<ClassNode
|
|
742
|
+
cls={cls} cx={p.x} cy={p.y}
|
|
743
|
+
selected={selectedClassId === cls.id}
|
|
744
|
+
onToggle={(id) => { toggleExpand(id); onSelectClass?.(cls); }}
|
|
745
|
+
onHover={handleClassHover}
|
|
746
|
+
onLeave={hideTip}
|
|
747
|
+
/>
|
|
748
|
+
</>
|
|
749
|
+
}
|
|
750
|
+
</g>
|
|
751
|
+
);
|
|
752
|
+
})}
|
|
753
|
+
</g>
|
|
754
|
+
|
|
755
|
+
{/* ── Legend ─────────────────────────────────────────────────────── */}
|
|
756
|
+
<g transform="translate(14,60)" fontSize={8}>
|
|
757
|
+
<rect x={-2} y={-2} width={90} height={70} rx={4}
|
|
758
|
+
fill="var(--bg-raised)" opacity={0.7} />
|
|
759
|
+
<line x1={4} y1={8} x2={24} y2={8} stroke="var(--lc-purple)" strokeWidth={1.8}
|
|
760
|
+
markerEnd="url(#cg-inherit)" />
|
|
761
|
+
<text x={28} y={11} fill="var(--tx-secondary)">extends</text>
|
|
762
|
+
|
|
763
|
+
<line x1={4} y1={22} x2={24} y2={22} stroke="var(--lc-cyan)" strokeWidth={1.2}
|
|
764
|
+
strokeDasharray="5 3" markerEnd="url(#cg-impl)" />
|
|
765
|
+
<text x={28} y={25} fill="var(--tx-secondary)">implements</text>
|
|
766
|
+
|
|
767
|
+
<line x1={4} y1={36} x2={24} y2={36} stroke="var(--tx-secondary)" strokeWidth={1.2}
|
|
768
|
+
strokeDasharray="5 3" markerEnd="url(#cg-call)" opacity={0.7} />
|
|
769
|
+
<text x={28} y={39} fill="var(--tx-secondary)">calls</text>
|
|
770
|
+
|
|
771
|
+
<circle cx={8} cy={52} r={5} fill="none" stroke="var(--tx-secondary)" strokeWidth={1.5} />
|
|
772
|
+
<text x={16} y={55} fill="var(--tx-secondary)">class</text>
|
|
773
|
+
<rect x={42} y={48} width={10} height={7} rx={2} fill="none"
|
|
774
|
+
stroke="var(--tx-secondary)" strokeWidth={1} strokeDasharray="2 1" />
|
|
775
|
+
<text x={56} y={55} fill="var(--tx-secondary)">module</text>
|
|
776
|
+
</g>
|
|
777
|
+
</svg>
|
|
778
|
+
|
|
779
|
+
<Tooltip tip={tooltip} />
|
|
780
|
+
</div>
|
|
781
|
+
);
|
|
782
|
+
}
|