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