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