concept-atlas-dense-explain 0.1.0
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/bin/install.mjs +17 -0
- package/package.json +12 -0
- package/skill/SKILL.md +31 -0
- package/skill/assets/template/content/compile-runtime.mdx +158 -0
- package/skill/assets/template/index.html +16 -0
- package/skill/assets/template/package.json +21 -0
- package/skill/assets/template/scripts/build.mjs +37 -0
- package/skill/assets/template/scripts/clean-temp.mjs +27 -0
- package/skill/assets/template/src/app/App.jsx +239 -0
- package/skill/assets/template/src/components/MDXComponents.jsx +479 -0
- package/skill/assets/template/src/components/index.js +1 -0
- package/skill/assets/template/src/main.jsx +16 -0
- package/skill/assets/template/src/model/concept-schema.js +169 -0
- package/skill/assets/template/src/model/normalize-content.js +238 -0
- package/skill/assets/template/src/model/relation-types.js +90 -0
- package/skill/assets/template/src/styles/concept-explain.css +2425 -0
- package/skill/assets/template/src/views/NodeExplorer.jsx +629 -0
- package/skill/assets/template/src/views/RelationGraph.jsx +708 -0
- package/skill/assets/template/vite.config.js +21 -0
- package/skill/references/components.md +27 -0
- package/skill/references/prompting.md +38 -0
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState, useMemo } from 'react';
|
|
2
|
+
import * as d3 from 'd3';
|
|
3
|
+
import { Search, Filter, ZoomIn, ZoomOut, RotateCcw, ArrowRight, Layers, Eye } from 'lucide-react';
|
|
4
|
+
import { RELATION_TYPES, LEVEL_DEFS } from '../model/relation-types.js';
|
|
5
|
+
|
|
6
|
+
export function RelationGraph({
|
|
7
|
+
graph,
|
|
8
|
+
currentNodeId,
|
|
9
|
+
onSelectNode,
|
|
10
|
+
onSwitchView,
|
|
11
|
+
theme = 'dark',
|
|
12
|
+
}) {
|
|
13
|
+
const { nodes, relations } = graph;
|
|
14
|
+
const svgRef = useRef(null);
|
|
15
|
+
const containerRef = useRef(null);
|
|
16
|
+
|
|
17
|
+
// States
|
|
18
|
+
const [selectedNodeId, setSelectedNodeId] = useState(currentNodeId || graph.meta.rootId);
|
|
19
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
20
|
+
const [filterLevel, setFilterLevel] = useState('ALL');
|
|
21
|
+
const [filterRelationType, setFilterRelationType] = useState('ALL');
|
|
22
|
+
const [highlightNeighbors, setHighlightNeighbors] = useState(true);
|
|
23
|
+
const [graphMode, setGraphMode] = useState('hierarchy');
|
|
24
|
+
|
|
25
|
+
// Convert nodes map to array
|
|
26
|
+
const allNodes = useMemo(() => Array.from(nodes.values()), [nodes]);
|
|
27
|
+
|
|
28
|
+
// Active focused node details
|
|
29
|
+
const focusedNode = nodes.get(selectedNodeId) || null;
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (currentNodeId && nodes.has(currentNodeId)) {
|
|
33
|
+
setSelectedNodeId(currentNodeId);
|
|
34
|
+
}
|
|
35
|
+
}, [currentNodeId, nodes]);
|
|
36
|
+
|
|
37
|
+
// Compute filtered nodes and links
|
|
38
|
+
const { graphNodes, graphLinks } = useMemo(() => {
|
|
39
|
+
let filteredNodes = allNodes;
|
|
40
|
+
|
|
41
|
+
if (filterLevel !== 'ALL') {
|
|
42
|
+
filteredNodes = filteredNodes.filter(n => n.level === filterLevel);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (searchQuery.trim()) {
|
|
46
|
+
const q = searchQuery.toLowerCase();
|
|
47
|
+
filteredNodes = filteredNodes.filter(n =>
|
|
48
|
+
n.title.toLowerCase().includes(q) ||
|
|
49
|
+
n.id.toLowerCase().includes(q) ||
|
|
50
|
+
(n.summary && n.summary.toLowerCase().includes(q))
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const matchedIds = new Set(filteredNodes.map(n => n.id));
|
|
55
|
+
// Preserve one-hop context for filtered matches instead of showing isolated nodes.
|
|
56
|
+
const nodeIds = new Set(matchedIds);
|
|
57
|
+
allNodes.forEach(n => {
|
|
58
|
+
if (n.parent && matchedIds.has(n.id)) nodeIds.add(n.parent);
|
|
59
|
+
if (n.parent && matchedIds.has(n.parent)) nodeIds.add(n.id);
|
|
60
|
+
});
|
|
61
|
+
relations.forEach(r => {
|
|
62
|
+
if (matchedIds.has(r.from)) nodeIds.add(r.to);
|
|
63
|
+
if (matchedIds.has(r.to)) nodeIds.add(r.from);
|
|
64
|
+
});
|
|
65
|
+
filteredNodes = allNodes.filter(n => nodeIds.has(n.id));
|
|
66
|
+
|
|
67
|
+
// Relations include parent-child (tree) and graph relations
|
|
68
|
+
const links = [];
|
|
69
|
+
|
|
70
|
+
// Tree edges (parent -> child)
|
|
71
|
+
allNodes.forEach(n => {
|
|
72
|
+
if (n.parent && nodes.has(n.parent)) {
|
|
73
|
+
if (nodeIds.has(n.parent) && nodeIds.has(n.id)) {
|
|
74
|
+
if (filterRelationType === 'ALL' || filterRelationType === 'parent-child') {
|
|
75
|
+
links.push({
|
|
76
|
+
source: n.parent,
|
|
77
|
+
target: n.id,
|
|
78
|
+
type: 'parent-child',
|
|
79
|
+
label: '父子',
|
|
80
|
+
typeInfo: RELATION_TYPES['parent-child']
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Semantic relations
|
|
88
|
+
relations.forEach(r => {
|
|
89
|
+
if (nodeIds.has(r.from) && nodeIds.has(r.to)) {
|
|
90
|
+
if (filterRelationType === 'ALL' || filterRelationType === r.type) {
|
|
91
|
+
links.push({
|
|
92
|
+
source: r.from,
|
|
93
|
+
target: r.to,
|
|
94
|
+
type: r.type,
|
|
95
|
+
label: r.typeLabel || r.type,
|
|
96
|
+
description: r.description,
|
|
97
|
+
typeInfo: r.typeInfo || RELATION_TYPES[r.type] || { color: '#94a3b8' }
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
graphNodes: filteredNodes.map(n => ({ ...n })),
|
|
105
|
+
graphLinks: links
|
|
106
|
+
};
|
|
107
|
+
}, [allNodes, nodes, relations, filterLevel, filterRelationType, searchQuery]);
|
|
108
|
+
|
|
109
|
+
// Neighbors of focused node
|
|
110
|
+
const { connectedNodeIds, directRelations } = useMemo(() => {
|
|
111
|
+
if (!selectedNodeId) return { connectedNodeIds: new Set(), directRelations: [] };
|
|
112
|
+
|
|
113
|
+
const ids = new Set([selectedNodeId]);
|
|
114
|
+
const dirRels = [];
|
|
115
|
+
|
|
116
|
+
graphLinks.forEach(link => {
|
|
117
|
+
const srcId = typeof link.source === 'object' ? link.source.id : link.source;
|
|
118
|
+
const tgtId = typeof link.target === 'object' ? link.target.id : link.target;
|
|
119
|
+
|
|
120
|
+
if (srcId === selectedNodeId) {
|
|
121
|
+
ids.add(tgtId);
|
|
122
|
+
dirRels.push({ targetId: tgtId, direction: 'out', link });
|
|
123
|
+
}
|
|
124
|
+
if (tgtId === selectedNodeId) {
|
|
125
|
+
ids.add(srcId);
|
|
126
|
+
dirRels.push({ targetId: srcId, direction: 'in', link });
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
return { connectedNodeIds: ids, directRelations: dirRels };
|
|
131
|
+
}, [selectedNodeId, graphLinks]);
|
|
132
|
+
|
|
133
|
+
// Setup D3 Force Simulation
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
if (!svgRef.current || !containerRef.current) return;
|
|
136
|
+
|
|
137
|
+
const width = containerRef.current.clientWidth || 900;
|
|
138
|
+
const height = containerRef.current.clientHeight || 650;
|
|
139
|
+
|
|
140
|
+
const svg = d3.select(svgRef.current);
|
|
141
|
+
svg.selectAll('*').remove(); // Clear previous
|
|
142
|
+
|
|
143
|
+
// Marker definitions for directed arrows
|
|
144
|
+
const defs = svg.append('defs');
|
|
145
|
+
Object.keys(RELATION_TYPES).forEach(typeKey => {
|
|
146
|
+
const typeDef = RELATION_TYPES[typeKey];
|
|
147
|
+
defs.append('marker')
|
|
148
|
+
.attr('id', `arrow-${typeKey}`)
|
|
149
|
+
.attr('viewBox', '0 -5 10 10')
|
|
150
|
+
.attr('refX', 24)
|
|
151
|
+
.attr('refY', 0)
|
|
152
|
+
.attr('markerWidth', 6)
|
|
153
|
+
.attr('markerHeight', 6)
|
|
154
|
+
.attr('orient', 'auto')
|
|
155
|
+
.append('path')
|
|
156
|
+
.attr('d', 'M0,-5L10,0L0,5')
|
|
157
|
+
.attr('fill', typeDef.color || '#87a6ff');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// Container group with zoom/pan
|
|
161
|
+
const g = svg.append('g').attr('class', 'zoom-container');
|
|
162
|
+
|
|
163
|
+
const zoomBehavior = d3.zoom()
|
|
164
|
+
.scaleExtent([0.2, 3])
|
|
165
|
+
.filter((event) => event.type !== 'wheel' || event.ctrlKey || event.metaKey)
|
|
166
|
+
.wheelDelta((event) => {
|
|
167
|
+
const delta = event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
|
|
168
|
+
return -delta * 0.0015;
|
|
169
|
+
})
|
|
170
|
+
.on('zoom', (event) => {
|
|
171
|
+
g.attr('transform', event.transform);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
svg.call(zoomBehavior);
|
|
175
|
+
|
|
176
|
+
const isConceptMode = graphMode === 'concept';
|
|
177
|
+
const visibleGraphLinks = isConceptMode
|
|
178
|
+
? graphLinks.filter(link => link.type !== 'parent-child')
|
|
179
|
+
: graphLinks.filter(link => link.type === 'parent-child');
|
|
180
|
+
// Hierarchy mode is deterministic. Concept mode intentionally restores
|
|
181
|
+
// the exploratory, draggable force graph for cross-layer relationships.
|
|
182
|
+
const positionedNodes = isConceptMode
|
|
183
|
+
? graphNodes.map((node, index) => ({
|
|
184
|
+
...node,
|
|
185
|
+
x: width / 2 + ((index % 4) - 1.5) * 80,
|
|
186
|
+
y: height / 2 + (Math.floor(index / 4) - 1) * 80,
|
|
187
|
+
}))
|
|
188
|
+
: layoutTree(graphNodes, width, height);
|
|
189
|
+
const nodeById = new Map(positionedNodes.map(node => [node.id, node]));
|
|
190
|
+
const positionedLinks = decorateParallelLinks(visibleGraphLinks
|
|
191
|
+
.map(link => ({
|
|
192
|
+
...link,
|
|
193
|
+
source: nodeById.get(typeof link.source === 'object' ? link.source.id : link.source),
|
|
194
|
+
target: nodeById.get(typeof link.target === 'object' ? link.target.id : link.target),
|
|
195
|
+
}))
|
|
196
|
+
.filter(link => link.source && link.target));
|
|
197
|
+
|
|
198
|
+
const simulation = isConceptMode
|
|
199
|
+
? d3.forceSimulation(positionedNodes)
|
|
200
|
+
.force('link', d3.forceLink(positionedLinks).id(d => d.id).distance(185).strength(0.9))
|
|
201
|
+
// Keep semantic neighbours legible: connected pairs get a stronger
|
|
202
|
+
// local push, while unrelated nodes only receive a gentle baseline
|
|
203
|
+
// separation so the whole map does not balloon.
|
|
204
|
+
.force('adaptive-repel', createAdaptiveRepulsion(positionedLinks, {
|
|
205
|
+
connectedStrength: -560,
|
|
206
|
+
disconnectedStrength: -120,
|
|
207
|
+
distanceMax: 360,
|
|
208
|
+
distanceMin: 28,
|
|
209
|
+
}))
|
|
210
|
+
.force('center', d3.forceCenter(width / 2, height / 2))
|
|
211
|
+
.force('collision', d3.forceCollide().radius(34).strength(0.35))
|
|
212
|
+
: null;
|
|
213
|
+
|
|
214
|
+
// Links group
|
|
215
|
+
const linkGroup = g.append('g').attr('class', 'links');
|
|
216
|
+
const links = linkGroup.selectAll('g.link-item')
|
|
217
|
+
.data(positionedLinks)
|
|
218
|
+
.enter()
|
|
219
|
+
.append('g')
|
|
220
|
+
.attr('class', 'link-item');
|
|
221
|
+
|
|
222
|
+
const linkPaths = links.append('path')
|
|
223
|
+
.attr('class', 'graph-edge')
|
|
224
|
+
.attr('stroke', d => d.typeInfo?.color || '#526b8d')
|
|
225
|
+
.attr('stroke-width', d => d.type === 'parent-child' ? 2 : 1.5)
|
|
226
|
+
.attr('stroke-dasharray', d => d.typeInfo?.strokeDasharray || 'none')
|
|
227
|
+
.attr('marker-end', d => d.typeInfo?.hasArrow ? `url(#arrow-${d.type})` : null)
|
|
228
|
+
.attr('opacity', d => d.type === 'parent-child' ? 0.9 : 0.48);
|
|
229
|
+
|
|
230
|
+
// Keep the canvas uncluttered. Relation details remain available in the
|
|
231
|
+
// inspector; a native SVG tooltip provides quick context on hover.
|
|
232
|
+
links.append('title')
|
|
233
|
+
.text(d => `${d.label}${d.description ? `:${d.description}` : ''}`);
|
|
234
|
+
|
|
235
|
+
const linkLabels = links.append('text')
|
|
236
|
+
.attr('class', 'graph-edge-label')
|
|
237
|
+
.attr('fill', d => d.typeInfo?.color || '#94a3b8')
|
|
238
|
+
.attr('font-size', '10px')
|
|
239
|
+
.attr('text-anchor', 'middle')
|
|
240
|
+
.attr('dy', -5)
|
|
241
|
+
// Parent-child is visually self-explanatory and its repeated label
|
|
242
|
+
// only creates collisions with actual semantic relation labels.
|
|
243
|
+
.text(d => d.type === 'parent-child' ? '' : d.label);
|
|
244
|
+
|
|
245
|
+
// Nodes group
|
|
246
|
+
const nodeGroup = g.append('g').attr('class', 'nodes');
|
|
247
|
+
const nodesSelection = nodeGroup.selectAll('g.node-item')
|
|
248
|
+
.data(positionedNodes)
|
|
249
|
+
.enter()
|
|
250
|
+
.append('g')
|
|
251
|
+
.attr('class', d => `node-item ${d.id === selectedNodeId ? 'selected' : ''}`)
|
|
252
|
+
.on('click', (event, d) => {
|
|
253
|
+
event.stopPropagation();
|
|
254
|
+
setSelectedNodeId(d.id);
|
|
255
|
+
onSelectNode(d.id);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
if (isConceptMode) {
|
|
259
|
+
nodesSelection.call(d3.drag()
|
|
260
|
+
.on('start', (event, d) => {
|
|
261
|
+
if (!event.active) simulation.alphaTarget(0.3).restart();
|
|
262
|
+
d.fx = d.x;
|
|
263
|
+
d.fy = d.y;
|
|
264
|
+
})
|
|
265
|
+
.on('drag', (event, d) => {
|
|
266
|
+
d.fx = event.x;
|
|
267
|
+
d.fy = event.y;
|
|
268
|
+
})
|
|
269
|
+
.on('end', (event, d) => {
|
|
270
|
+
if (!event.active) simulation.alphaTarget(0);
|
|
271
|
+
d.fx = null;
|
|
272
|
+
d.fy = null;
|
|
273
|
+
})
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Outer glow for selected or level
|
|
278
|
+
nodesSelection.append('circle')
|
|
279
|
+
.attr('r', d => (d.level === 'L0' ? 24 : d.level === 'L1' ? 20 : 16))
|
|
280
|
+
.attr('fill', d => LEVEL_DEFS[d.level]?.color || '#87a6ff')
|
|
281
|
+
.attr('fill-opacity', 0.2)
|
|
282
|
+
.attr('stroke', d => LEVEL_DEFS[d.level]?.color || '#87a6ff')
|
|
283
|
+
.attr('stroke-width', d => d.id === selectedNodeId ? 3 : 1.5);
|
|
284
|
+
|
|
285
|
+
// Inner center dot
|
|
286
|
+
nodesSelection.append('circle')
|
|
287
|
+
.attr('r', d => (d.level === 'L0' ? 10 : d.level === 'L1' ? 7 : 5))
|
|
288
|
+
.attr('fill', d => LEVEL_DEFS[d.level]?.color || '#87a6ff');
|
|
289
|
+
|
|
290
|
+
// Node Title Label
|
|
291
|
+
nodesSelection.append('text')
|
|
292
|
+
.attr('dy', d => (d.level === 'L0' ? 38 : 30))
|
|
293
|
+
.attr('text-anchor', 'middle')
|
|
294
|
+
.attr('fill', theme === 'light' ? '#0f172a' : '#f8fafc')
|
|
295
|
+
.attr('font-size', '12px')
|
|
296
|
+
.attr('font-family', "'Plus Jakarta Sans', -apple-system, sans-serif")
|
|
297
|
+
.attr('font-weight', d => d.id === selectedNodeId ? '700' : '500')
|
|
298
|
+
.text(d => d.title);
|
|
299
|
+
|
|
300
|
+
// Node Level Pill
|
|
301
|
+
nodesSelection.append('text')
|
|
302
|
+
.attr('dy', -22)
|
|
303
|
+
.attr('text-anchor', 'middle')
|
|
304
|
+
.attr('fill', d => LEVEL_DEFS[d.level]?.color || '#94a3b8')
|
|
305
|
+
.attr('font-size', '9px')
|
|
306
|
+
.text(d => d.level);
|
|
307
|
+
|
|
308
|
+
const renderPositions = () => {
|
|
309
|
+
linkPaths.attr('d', linkPath);
|
|
310
|
+
positionEdgeLabels(positionedLinks, positionedNodes, width, height);
|
|
311
|
+
linkLabels
|
|
312
|
+
.attr('x', d => d.labelX ?? linkMidpoint(d).x)
|
|
313
|
+
.attr('y', d => d.labelY ?? linkMidpoint(d).y)
|
|
314
|
+
.attr('display', d => d.labelVisible ? null : 'none');
|
|
315
|
+
nodesSelection.attr('transform', d => `translate(${d.x},${d.y})`);
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
if (simulation) {
|
|
319
|
+
simulation.on('tick', renderPositions);
|
|
320
|
+
} else {
|
|
321
|
+
renderPositions();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Auto-focus selected node center
|
|
325
|
+
if (!isConceptMode && selectedNodeId) {
|
|
326
|
+
const targetNode = positionedNodes.find(n => n.id === selectedNodeId);
|
|
327
|
+
if (targetNode) {
|
|
328
|
+
const transform = d3.zoomIdentity
|
|
329
|
+
.translate(width / 2 - targetNode.x, height / 2 - targetNode.y)
|
|
330
|
+
.scale(1.1);
|
|
331
|
+
svg.transition().duration(500).call(zoomBehavior.transform, transform);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return () => {
|
|
336
|
+
simulation?.stop();
|
|
337
|
+
svg.on('.zoom', null);
|
|
338
|
+
};
|
|
339
|
+
}, [graphNodes, graphLinks, selectedNodeId, theme, graphMode]);
|
|
340
|
+
|
|
341
|
+
return (
|
|
342
|
+
<div className="relation-graph-layout">
|
|
343
|
+
{/* Visual Canvas Area */}
|
|
344
|
+
<div className="graph-main" ref={containerRef}>
|
|
345
|
+
{/* Top Control Bar */}
|
|
346
|
+
<div className="graph-control-bar">
|
|
347
|
+
<div className="graph-mode-switch" role="tablist" aria-label="图谱模式">
|
|
348
|
+
<button
|
|
349
|
+
className={graphMode === 'hierarchy' ? 'active' : ''}
|
|
350
|
+
onClick={() => setGraphMode('hierarchy')}
|
|
351
|
+
role="tab"
|
|
352
|
+
aria-selected={graphMode === 'hierarchy'}
|
|
353
|
+
>
|
|
354
|
+
层级结构
|
|
355
|
+
</button>
|
|
356
|
+
<button
|
|
357
|
+
className={graphMode === 'concept' ? 'active' : ''}
|
|
358
|
+
onClick={() => setGraphMode('concept')}
|
|
359
|
+
role="tab"
|
|
360
|
+
aria-selected={graphMode === 'concept'}
|
|
361
|
+
>
|
|
362
|
+
概念关系
|
|
363
|
+
</button>
|
|
364
|
+
</div>
|
|
365
|
+
<div className="search-box">
|
|
366
|
+
<Search size={14} className="search-icon" />
|
|
367
|
+
<input
|
|
368
|
+
type="text"
|
|
369
|
+
placeholder="搜索概念或关键词…"
|
|
370
|
+
value={searchQuery}
|
|
371
|
+
onChange={e => setSearchQuery(e.target.value)}
|
|
372
|
+
/>
|
|
373
|
+
</div>
|
|
374
|
+
|
|
375
|
+
<div className="filter-group">
|
|
376
|
+
<span className="filter-label">层级:</span>
|
|
377
|
+
<select value={filterLevel} onChange={e => setFilterLevel(e.target.value)}>
|
|
378
|
+
<option value="ALL">全部层级 (L0-L4)</option>
|
|
379
|
+
{Object.keys(LEVEL_DEFS).map(lvl => (
|
|
380
|
+
<option key={lvl} value={lvl}>{lvl} · {LEVEL_DEFS[lvl].name}</option>
|
|
381
|
+
))}
|
|
382
|
+
</select>
|
|
383
|
+
</div>
|
|
384
|
+
|
|
385
|
+
<div className="filter-group">
|
|
386
|
+
<span className="filter-label">关系类型:</span>
|
|
387
|
+
<select value={filterRelationType} onChange={e => setFilterRelationType(e.target.value)}>
|
|
388
|
+
<option value="ALL">全部关系类型</option>
|
|
389
|
+
{Object.keys(RELATION_TYPES).map(typeKey => (
|
|
390
|
+
<option key={typeKey} value={typeKey}>{RELATION_TYPES[typeKey].label} ({typeKey})</option>
|
|
391
|
+
))}
|
|
392
|
+
</select>
|
|
393
|
+
</div>
|
|
394
|
+
</div>
|
|
395
|
+
|
|
396
|
+
{/* Legend Ribbon */}
|
|
397
|
+
<div className="graph-legend-ribbon">
|
|
398
|
+
<div className="legend-title">图例说明:</div>
|
|
399
|
+
<div className="legend-items">
|
|
400
|
+
{Object.keys(RELATION_TYPES).filter(typeKey => graphMode !== 'concept' || typeKey !== 'parent-child').slice(0, 7).map(typeKey => (
|
|
401
|
+
<div key={typeKey} className="legend-item">
|
|
402
|
+
<span className="legend-dot" style={{ backgroundColor: RELATION_TYPES[typeKey].color }} />
|
|
403
|
+
<span>{RELATION_TYPES[typeKey].label}</span>
|
|
404
|
+
</div>
|
|
405
|
+
))}
|
|
406
|
+
</div>
|
|
407
|
+
</div>
|
|
408
|
+
|
|
409
|
+
{/* SVG Container */}
|
|
410
|
+
<svg ref={svgRef} className="graph-svg" width="100%" height="100%" />
|
|
411
|
+
</div>
|
|
412
|
+
|
|
413
|
+
{/* Right Details Panel */}
|
|
414
|
+
<aside className="graph-side-panel">
|
|
415
|
+
{focusedNode ? (
|
|
416
|
+
<div className="graph-inspector">
|
|
417
|
+
<div className="panel-badge" style={{ color: LEVEL_DEFS[focusedNode.level]?.color }}>
|
|
418
|
+
{LEVEL_DEFS[focusedNode.level]?.tag || focusedNode.level}
|
|
419
|
+
</div>
|
|
420
|
+
<h2 className="panel-node-title">{focusedNode.title}</h2>
|
|
421
|
+
<p className="panel-summary">{focusedNode.summary || '暂无一句话概览'}</p>
|
|
422
|
+
|
|
423
|
+
{/* Jump to Node Explorer Button */}
|
|
424
|
+
<button
|
|
425
|
+
className="jump-explorer-btn"
|
|
426
|
+
onClick={() => {
|
|
427
|
+
onSelectNode(focusedNode.id);
|
|
428
|
+
onSwitchView('explore');
|
|
429
|
+
}}
|
|
430
|
+
>
|
|
431
|
+
<span>跳转到该节点的探索页</span>
|
|
432
|
+
<ArrowRight size={14} />
|
|
433
|
+
</button>
|
|
434
|
+
|
|
435
|
+
<div className="panel-divider" />
|
|
436
|
+
|
|
437
|
+
{/* Direct Relations in graph */}
|
|
438
|
+
<div className="panel-section">
|
|
439
|
+
<div className="section-title">直接邻接关系网络 ({directRelations.length})</div>
|
|
440
|
+
<div className="panel-rel-list">
|
|
441
|
+
{directRelations.length > 0 ? (
|
|
442
|
+
directRelations.map((item, i) => {
|
|
443
|
+
const targetN = nodes.get(item.targetId);
|
|
444
|
+
const isOut = item.direction === 'out';
|
|
445
|
+
return (
|
|
446
|
+
<div
|
|
447
|
+
key={i}
|
|
448
|
+
className="panel-rel-card"
|
|
449
|
+
onClick={() => setSelectedNodeId(item.targetId)}
|
|
450
|
+
>
|
|
451
|
+
<div className="rel-card-header">
|
|
452
|
+
<span className="rel-tag" style={{ color: item.link.typeInfo?.color }}>
|
|
453
|
+
{item.link.label}
|
|
454
|
+
</span>
|
|
455
|
+
<span className="rel-dir">{isOut ? '→ 指向' : '← 来自'}</span>
|
|
456
|
+
</div>
|
|
457
|
+
<div className="rel-target-title">
|
|
458
|
+
{targetN ? targetN.title : item.targetId}
|
|
459
|
+
</div>
|
|
460
|
+
{item.link.description && (
|
|
461
|
+
<div className="rel-target-desc">{item.link.description}</div>
|
|
462
|
+
)}
|
|
463
|
+
</div>
|
|
464
|
+
);
|
|
465
|
+
})
|
|
466
|
+
) : (
|
|
467
|
+
<div className="empty-subtext">该节点在当前筛选下无连接</div>
|
|
468
|
+
)}
|
|
469
|
+
</div>
|
|
470
|
+
</div>
|
|
471
|
+
|
|
472
|
+
{/* Input / Output */}
|
|
473
|
+
{(focusedNode.input || focusedNode.output) && (
|
|
474
|
+
<div className="panel-section">
|
|
475
|
+
<div className="section-title">数据流转 (I/O)</div>
|
|
476
|
+
<div className="io-capsule">
|
|
477
|
+
{focusedNode.input && <div><b>输入:</b>{focusedNode.input}</div>}
|
|
478
|
+
{focusedNode.output && <div><b>输出:</b>{focusedNode.output}</div>}
|
|
479
|
+
</div>
|
|
480
|
+
</div>
|
|
481
|
+
)}
|
|
482
|
+
</div>
|
|
483
|
+
) : (
|
|
484
|
+
<div className="empty-inspector">
|
|
485
|
+
<Eye size={28} />
|
|
486
|
+
<p>在图谱中点击任意概念节点,查看其详情和连接关系</p>
|
|
487
|
+
</div>
|
|
488
|
+
)}
|
|
489
|
+
</aside>
|
|
490
|
+
</div>
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function layoutTree(nodes, width, height) {
|
|
495
|
+
if (nodes.length === 0) return [];
|
|
496
|
+
|
|
497
|
+
const nodeMap = new Map(nodes.map(node => [node.id, { ...node, children: [] }]));
|
|
498
|
+
nodes.forEach(node => {
|
|
499
|
+
if (node.parent && nodeMap.has(node.parent)) {
|
|
500
|
+
nodeMap.get(node.parent).children.push(nodeMap.get(node.id));
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
const roots = Array.from(nodeMap.values()).filter(node => !node.parent || !nodeMap.has(node.parent));
|
|
505
|
+
const treeData = { id: '__atlas-root__', children: roots };
|
|
506
|
+
const root = d3.hierarchy(treeData);
|
|
507
|
+
const tree = d3.tree().nodeSize([110, 155]);
|
|
508
|
+
tree(root);
|
|
509
|
+
|
|
510
|
+
const visible = root.descendants().filter(node => node.data.id !== '__atlas-root__');
|
|
511
|
+
const minX = Math.min(...visible.map(node => node.x));
|
|
512
|
+
const maxX = Math.max(...visible.map(node => node.x));
|
|
513
|
+
const treeWidth = Math.max(maxX - minX, 1);
|
|
514
|
+
const offsetX = Math.max((width - treeWidth) / 2 - minX, 60 - minX);
|
|
515
|
+
const offsetY = 105;
|
|
516
|
+
|
|
517
|
+
return visible.map(node => ({
|
|
518
|
+
...node.data,
|
|
519
|
+
x: node.x + offsetX,
|
|
520
|
+
y: node.depth * 155 + offsetY,
|
|
521
|
+
}));
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function decorateParallelLinks(links) {
|
|
525
|
+
const groups = new Map();
|
|
526
|
+
links.forEach(link => {
|
|
527
|
+
if (link.type === 'parent-child') return;
|
|
528
|
+
const sourceId = link.source.id;
|
|
529
|
+
const targetId = link.target.id;
|
|
530
|
+
const key = [sourceId, targetId].sort().join('::');
|
|
531
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
532
|
+
groups.get(key).push(link);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
groups.forEach(group => {
|
|
536
|
+
group.forEach((link, index) => {
|
|
537
|
+
link.parallelIndex = index;
|
|
538
|
+
link.parallelCount = group.length;
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
return links;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Pair-aware charge force for the concept graph.
|
|
546
|
+
*
|
|
547
|
+
* D3's built-in many-body force has one charge for every pair. Concept maps
|
|
548
|
+
* need a little more nuance: nodes that are explicitly related should have
|
|
549
|
+
* room for their edge and label, while unrelated nodes should stay compact.
|
|
550
|
+
*/
|
|
551
|
+
function createAdaptiveRepulsion(links, {
|
|
552
|
+
connectedStrength = -420,
|
|
553
|
+
disconnectedStrength = -48,
|
|
554
|
+
distanceMin = 28,
|
|
555
|
+
distanceMax = 360,
|
|
556
|
+
} = {}) {
|
|
557
|
+
let nodes = [];
|
|
558
|
+
let relatedPairs = new Set();
|
|
559
|
+
|
|
560
|
+
const pairKey = (a, b) => {
|
|
561
|
+
const aId = typeof a === 'object' ? a.id : a;
|
|
562
|
+
const bId = typeof b === 'object' ? b.id : b;
|
|
563
|
+
return aId < bId ? `${aId}::${bId}` : `${bId}::${aId}`;
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
function force(alpha) {
|
|
567
|
+
for (let i = 0; i < nodes.length; i += 1) {
|
|
568
|
+
const source = nodes[i];
|
|
569
|
+
for (let j = i + 1; j < nodes.length; j += 1) {
|
|
570
|
+
const target = nodes[j];
|
|
571
|
+
let dx = target.x - source.x;
|
|
572
|
+
let dy = target.y - source.y;
|
|
573
|
+
let distance = Math.hypot(dx, dy);
|
|
574
|
+
|
|
575
|
+
// Deterministic nudge for coincident initial positions.
|
|
576
|
+
if (!distance) {
|
|
577
|
+
dx = (i + 1) * 0.01;
|
|
578
|
+
dy = (j + 1) * 0.01;
|
|
579
|
+
distance = Math.hypot(dx, dy);
|
|
580
|
+
}
|
|
581
|
+
if (distance > distanceMax) continue;
|
|
582
|
+
|
|
583
|
+
const clampedDistance = Math.max(distance, distanceMin);
|
|
584
|
+
const strength = relatedPairs.has(pairKey(source, target))
|
|
585
|
+
? connectedStrength
|
|
586
|
+
: disconnectedStrength;
|
|
587
|
+
const magnitude = (strength * alpha) / (clampedDistance * clampedDistance);
|
|
588
|
+
const forceX = dx * magnitude;
|
|
589
|
+
const forceY = dy * magnitude;
|
|
590
|
+
|
|
591
|
+
// `strength < 0` is repulsive: move source opposite to the vector
|
|
592
|
+
// toward target, and target in the opposite direction as well.
|
|
593
|
+
source.vx += forceX;
|
|
594
|
+
source.vy += forceY;
|
|
595
|
+
target.vx -= forceX;
|
|
596
|
+
target.vy -= forceY;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
force.initialize = (initializedNodes) => {
|
|
602
|
+
nodes = initializedNodes;
|
|
603
|
+
relatedPairs = new Set(links.map(link => pairKey(link.source, link.target)));
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
return force;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function linkPath(link) {
|
|
610
|
+
const { source, target } = link;
|
|
611
|
+
if (link.type === 'parent-child') {
|
|
612
|
+
const midY = source.y + (target.y - source.y) / 2;
|
|
613
|
+
return `M${source.x},${source.y} V${midY} H${target.x} V${target.y}`;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const dx = target.x - source.x;
|
|
617
|
+
const dy = target.y - source.y;
|
|
618
|
+
const length = Math.max(Math.hypot(dx, dy), 1);
|
|
619
|
+
const normalX = -dy / length;
|
|
620
|
+
const normalY = dx / length;
|
|
621
|
+
const offset = ((link.parallelIndex ?? 0) - ((link.parallelCount ?? 1) - 1) / 2) * 26;
|
|
622
|
+
const controlX = (source.x + target.x) / 2 + normalX * offset;
|
|
623
|
+
const controlY = (source.y + target.y) / 2 + normalY * offset;
|
|
624
|
+
return `M${source.x},${source.y} Q${controlX},${controlY} ${target.x},${target.y}`;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function positionEdgeLabels(links, nodes, width, height) {
|
|
628
|
+
const occupied = [];
|
|
629
|
+
const nodeBoxes = nodes.map(node => ({
|
|
630
|
+
left: node.x - 28,
|
|
631
|
+
right: node.x + 28,
|
|
632
|
+
top: node.y - 28,
|
|
633
|
+
bottom: node.y + 28,
|
|
634
|
+
}));
|
|
635
|
+
|
|
636
|
+
links.forEach(link => {
|
|
637
|
+
link.labelVisible = false;
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
const candidates = links
|
|
641
|
+
.filter(link => link.type !== 'parent-child' && link.label)
|
|
642
|
+
.map(link => {
|
|
643
|
+
const midpoint = linkMidpoint(link);
|
|
644
|
+
const halfWidth = Math.max(18, Math.min(70, link.label.length * 5.2));
|
|
645
|
+
return { link, midpoint, halfWidth };
|
|
646
|
+
})
|
|
647
|
+
.sort((a, b) => a.midpoint.y - b.midpoint.y);
|
|
648
|
+
|
|
649
|
+
candidates.forEach(({ link, midpoint, halfWidth }) => {
|
|
650
|
+
const positions = [
|
|
651
|
+
[midpoint.x, midpoint.y],
|
|
652
|
+
[midpoint.x, midpoint.y - 20],
|
|
653
|
+
[midpoint.x, midpoint.y + 20],
|
|
654
|
+
[midpoint.x - 28, midpoint.y],
|
|
655
|
+
[midpoint.x + 28, midpoint.y],
|
|
656
|
+
[midpoint.x, midpoint.y - 38],
|
|
657
|
+
[midpoint.x, midpoint.y + 38],
|
|
658
|
+
];
|
|
659
|
+
|
|
660
|
+
const position = positions.find(([x, y]) => {
|
|
661
|
+
const box = { left: x - halfWidth, right: x + halfWidth, top: y - 8, bottom: y + 8 };
|
|
662
|
+
if (box.left < 8 || box.right > width - 8 || box.top < 70 || box.bottom > height - 8) return false;
|
|
663
|
+
if (occupied.some(other => boxesOverlap(box, other, 6))) return false;
|
|
664
|
+
if (nodeBoxes.some(node => boxesOverlap(box, node, 4))) return false;
|
|
665
|
+
return true;
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
if (position) {
|
|
669
|
+
const [x, y] = position;
|
|
670
|
+
link.labelX = x;
|
|
671
|
+
link.labelY = y;
|
|
672
|
+
link.labelVisible = true;
|
|
673
|
+
occupied.push({ left: x - halfWidth, right: x + halfWidth, top: y - 8, bottom: y + 8 });
|
|
674
|
+
} else {
|
|
675
|
+
// Labels are part of the default reading path; keep them visible even in dense graphs.
|
|
676
|
+
link.labelX = midpoint.x;
|
|
677
|
+
link.labelY = midpoint.y;
|
|
678
|
+
link.labelVisible = true;
|
|
679
|
+
}
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function boxesOverlap(a, b, padding = 0) {
|
|
684
|
+
return !(
|
|
685
|
+
a.right + padding < b.left ||
|
|
686
|
+
a.left - padding > b.right ||
|
|
687
|
+
a.bottom + padding < b.top ||
|
|
688
|
+
a.top - padding > b.bottom
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function linkMidpoint(link) {
|
|
693
|
+
if (link.type === 'parent-child') {
|
|
694
|
+
return {
|
|
695
|
+
x: (link.source.x + link.target.x) / 2,
|
|
696
|
+
y: (link.source.y + link.target.y) / 2,
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const dx = link.target.x - link.source.x;
|
|
701
|
+
const dy = link.target.y - link.source.y;
|
|
702
|
+
const length = Math.max(Math.hypot(dx, dy), 1);
|
|
703
|
+
const offset = ((link.parallelIndex ?? 0) - ((link.parallelCount ?? 1) - 1) / 2) * 26;
|
|
704
|
+
return {
|
|
705
|
+
x: (link.source.x + link.target.x) / 2 - (dy / length) * offset,
|
|
706
|
+
y: (link.source.y + link.target.y) / 2 + (dx / length) * offset,
|
|
707
|
+
};
|
|
708
|
+
}
|