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.
@@ -0,0 +1,629 @@
1
+ import React from 'react';
2
+ import { ChevronRight, ArrowUpRight, CornerDownRight, ArrowLeft, Network, CornerLeftUp, ZoomIn, ZoomOut, RotateCcw, Link as LinkIcon } from 'lucide-react';
3
+ import { getAncestorPath, getSiblingNodes } from '../model/concept-schema.js';
4
+ import { LEVEL_DEFS } from '../model/relation-types.js';
5
+
6
+ export function NodeExplorer({
7
+ graph,
8
+ currentNodeId,
9
+ onSelectNode,
10
+ onSwitchView,
11
+ selectedLevel,
12
+ onSelectLevel
13
+ }) {
14
+ const { nodes, relations } = graph;
15
+ const [canvasScale, setCanvasScale] = React.useState(1);
16
+ const [canvasPan, setCanvasPan] = React.useState({ x: 0, y: 0 });
17
+ const [isDragging, setIsDragging] = React.useState(false);
18
+ const dragRef = React.useRef(null);
19
+ const suppressClickRef = React.useRef(false);
20
+
21
+ const updateScale = (nextScale) => {
22
+ const scale = Math.max(0.65, Math.min(1.6, +nextScale.toFixed(2)));
23
+ setCanvasScale(scale);
24
+ };
25
+
26
+ const updateScaleAtPoint = (event) => {
27
+ const rect = event.currentTarget.getBoundingClientRect();
28
+ const factor = Math.exp(-event.deltaY * 0.002);
29
+ const nextScale = Math.max(0.65, Math.min(1.6, +(canvasScale * factor).toFixed(3)));
30
+ const focusX = event.clientX - rect.left;
31
+ const focusY = event.clientY - rect.top;
32
+ const contentX = (focusX - canvasPan.x) / canvasScale;
33
+ const contentY = (focusY - canvasPan.y) / canvasScale;
34
+ setCanvasScale(nextScale);
35
+ setCanvasPan({ x: focusX - contentX * nextScale, y: focusY - contentY * nextScale });
36
+ };
37
+
38
+ const handleCanvasPointerDown = (event) => {
39
+ if (event.target.closest('button, a, input, select, textarea')) return;
40
+ dragRef.current = {
41
+ pointerId: event.pointerId,
42
+ startX: event.clientX,
43
+ startY: event.clientY,
44
+ panX: canvasPan.x,
45
+ panY: canvasPan.y,
46
+ moved: false,
47
+ };
48
+ setIsDragging(true);
49
+ event.currentTarget.setPointerCapture(event.pointerId);
50
+ };
51
+
52
+ const handleCanvasPointerMove = (event) => {
53
+ if (!dragRef.current || dragRef.current.pointerId !== event.pointerId) return;
54
+ event.preventDefault();
55
+ const distance = Math.hypot(event.clientX - dragRef.current.startX, event.clientY - dragRef.current.startY);
56
+ if (distance < 6) return;
57
+ dragRef.current.moved = true;
58
+ setCanvasPan({
59
+ x: dragRef.current.panX + event.clientX - dragRef.current.startX,
60
+ y: dragRef.current.panY + event.clientY - dragRef.current.startY,
61
+ });
62
+ };
63
+
64
+ const handleCanvasPointerUp = (event) => {
65
+ if (dragRef.current?.pointerId === event.pointerId) {
66
+ suppressClickRef.current = dragRef.current.moved;
67
+ dragRef.current = null;
68
+ setIsDragging(false);
69
+ event.currentTarget.releasePointerCapture?.(event.pointerId);
70
+ }
71
+ };
72
+ const currentNode = nodes.get(currentNodeId) || nodes.get(graph.meta.rootId) || null;
73
+
74
+ if (!currentNode) {
75
+ return <div className="empty-state">未找到概念节点</div>;
76
+ }
77
+
78
+ const ancestorPath = getAncestorPath(nodes, currentNode.id);
79
+ const siblings = getSiblingNodes(nodes, currentNode.id);
80
+ const parentNode = currentNode.parent ? nodes.get(currentNode.parent) : null;
81
+ const childNodes = currentNode.children
82
+ .filter(id => nodes.has(id))
83
+ .map(id => nodes.get(id));
84
+ const visibleChildNodes = selectedLevel ? childNodes.filter(node => node.level === selectedLevel) : childNodes;
85
+ const visibleSiblings = selectedLevel ? siblings.filter(node => node.level === selectedLevel) : siblings;
86
+
87
+ // Node-specific relations
88
+ const outgoingRelations = relations.filter(r => r.from === currentNode.id);
89
+ const incomingRelations = relations.filter(r => r.to === currentNode.id);
90
+
91
+ // Level definition
92
+ const levelInfo = LEVEL_DEFS[currentNode.level] || { name: currentNode.level, tag: currentNode.level, color: '#87a6ff' };
93
+
94
+ return (
95
+ <div className="node-explorer-layout">
96
+ {/* 1. Left Column: Macro Context */}
97
+ <aside className="explorer-side left-side">
98
+ <div className="side-section">
99
+ <div className="side-label">当前路径 (祖先路径)</div>
100
+ <div className="ancestor-breadcrumbs">
101
+ {ancestorPath.map((node, index) => {
102
+ const isCurrent = node.id === currentNode.id;
103
+ return (
104
+ <div
105
+ key={node.id}
106
+ className={`breadcrumb-node ${isCurrent ? 'active' : ''}`}
107
+ onClick={() => onSelectNode(node.id)}
108
+ title={node.title}
109
+ >
110
+ <span className="bc-level-pill" style={{ borderColor: LEVEL_DEFS[node.level]?.color }}>
111
+ {node.level}
112
+ </span>
113
+ <span className="bc-title">{node.title}</span>
114
+ {index < ancestorPath.length - 1 && <ChevronRight size={14} className="bc-arrow" />}
115
+ </div>
116
+ );
117
+ })}
118
+ </div>
119
+ </div>
120
+
121
+ <div className="side-section">
122
+ <div className="side-label">同层节点</div>
123
+ <div className="sibling-list">
124
+ {visibleSiblings.length > 0 ? (
125
+ visibleSiblings.map(sib => (
126
+ <button
127
+ key={sib.id}
128
+ className="sibling-btn"
129
+ onClick={() => onSelectNode(sib.id)}
130
+ >
131
+ <span className="sib-dot" style={{ backgroundColor: LEVEL_DEFS[sib.level]?.color }} />
132
+ <span className="sib-title">{sib.title}</span>
133
+ <span className="sib-lvl">{sib.level}</span>
134
+ </button>
135
+ ))
136
+ ) : (
137
+ <div className="empty-subtext">无同层其他节点</div>
138
+ )}
139
+ </div>
140
+ </div>
141
+
142
+ <details className="side-section tree-section compact-tree">
143
+ <summary className="side-label">完整层级树</summary>
144
+ <div className="concept-nav-tree">
145
+ {renderNavTree(nodes, graph.meta.rootId, currentNode.id, onSelectNode)}
146
+ </div>
147
+ </details>
148
+ </aside>
149
+
150
+ {/* 2. Middle Column: Current Node Explanation Card */}
151
+ <main className="explorer-center">
152
+ <HierarchyStrip
153
+ ancestorPath={ancestorPath}
154
+ currentNode={currentNode}
155
+ childNodes={childNodes}
156
+ onSelectNode={onSelectNode}
157
+ />
158
+ <div className="center-scrollable">
159
+ {/* Header toolbar */}
160
+ <div className="center-toolbar">
161
+ <div className="level-indicators">
162
+ {Object.keys(LEVEL_DEFS).map(lvl => (
163
+ <button
164
+ key={lvl}
165
+ className={`level-pill ${(selectedLevel === lvl || (!selectedLevel && currentNode.level === lvl)) ? 'active' : ''}`}
166
+ onClick={() => onSelectLevel && onSelectLevel(selectedLevel === lvl ? null : lvl)}
167
+ title={LEVEL_DEFS[lvl].desc}
168
+ >
169
+ {lvl}
170
+ </button>
171
+ ))}
172
+ {selectedLevel && <button className="level-pill level-pill-clear" onClick={() => onSelectLevel && onSelectLevel(null)}>全部</button>}
173
+ </div>
174
+ </div>
175
+
176
+ <div
177
+ className={`draft-viewport ${isDragging ? 'is-dragging' : ''}`}
178
+ onPointerDown={handleCanvasPointerDown}
179
+ onPointerMove={handleCanvasPointerMove}
180
+ onPointerUp={handleCanvasPointerUp}
181
+ onPointerCancel={handleCanvasPointerUp}
182
+ onClickCapture={(event) => {
183
+ if (!suppressClickRef.current) return;
184
+ event.preventDefault();
185
+ event.stopPropagation();
186
+ suppressClickRef.current = false;
187
+ }}
188
+ onWheel={(event) => {
189
+ // Two-finger trackpad scrolling remains document scrolling. Hold Ctrl/Cmd to zoom.
190
+ if (!event.ctrlKey && !event.metaKey) return;
191
+ event.preventDefault();
192
+ updateScaleAtPoint(event);
193
+ }}
194
+ >
195
+ <div className="draft-floating-tools">
196
+ <button
197
+ className="view-graph-btn"
198
+ onClick={() => onSwitchView('graph')}
199
+ title="在图谱中聚焦此节点"
200
+ >
201
+ <Network size={14} />
202
+ <span>查看全局关系</span>
203
+ </button>
204
+ <div className="draft-zoom-controls" aria-label="草稿缩放">
205
+ <button onClick={() => updateScale(canvasScale - 0.1)} title="缩小">
206
+ <ZoomOut size={14} />
207
+ </button>
208
+ <span>{Math.round(canvasScale * 100)}%</span>
209
+ <button onClick={() => updateScale(canvasScale + 0.1)} title="放大">
210
+ <ZoomIn size={14} />
211
+ </button>
212
+ <button onClick={() => { setCanvasScale(1); setCanvasPan({ x: 0, y: 0 }); }} title="重置画布">
213
+ <RotateCcw size={13} />
214
+ </button>
215
+ </div>
216
+ </div>
217
+ <div
218
+ className="draft-board"
219
+ style={{
220
+ transform: `translate(${canvasPan.x}px, ${canvasPan.y}px) scale(${canvasScale})`,
221
+ }}
222
+ >
223
+ {/* Compact concept note header */}
224
+ <article className="concept-hero-card">
225
+ <div className="hero-level-banner" style={{ color: levelInfo.color }}>
226
+ <span className="badge">{levelInfo.tag}</span>
227
+ <span className="desc">{levelInfo.desc}</span>
228
+ </div>
229
+
230
+ <h1 className="hero-title">{currentNode.title}</h1>
231
+
232
+ {currentNode.summary && (
233
+ <p className="hero-summary">{currentNode.summary}</p>
234
+ )}
235
+
236
+ {/* I/O and Path chips */}
237
+ <div className="hero-meta-chips">
238
+ {currentNode.input && (
239
+ <div className="meta-chip">
240
+ <span className="chip-label">输入</span>
241
+ <span className="chip-value">{currentNode.input}</span>
242
+ </div>
243
+ )}
244
+ {currentNode.output && (
245
+ <div className="meta-chip">
246
+ <span className="chip-label">输出</span>
247
+ <span className="chip-value">{currentNode.output}</span>
248
+ </div>
249
+ )}
250
+ <div className="meta-chip">
251
+ <span className="chip-label">完整路径</span>
252
+ <span className="chip-value">{ancestorPath.map(n => n.title).join(' / ')}</span>
253
+ </div>
254
+ </div>
255
+
256
+ </article>
257
+
258
+ {/* Core Mechanism / Definition Section */}
259
+ <div className="content-blocks">
260
+ {currentNode.overview && (
261
+ <section className="node-block">
262
+ <div className="block-head">
263
+ <h2>概览理解</h2>
264
+ <small>一句话宏观认知</small>
265
+ </div>
266
+ <div className="block-body">{currentNode.overview}</div>
267
+ </section>
268
+ )}
269
+
270
+ {currentNode.definition && (
271
+ <section className="node-block">
272
+ <div className="block-head">
273
+ <h2>概念定义</h2>
274
+ <small>准确定义与本质属性</small>
275
+ </div>
276
+ <div className="block-body">{currentNode.definition}</div>
277
+ </section>
278
+ )}
279
+
280
+ {currentNode.mechanism && (
281
+ <section className="node-block">
282
+ <div className="block-head">
283
+ <h2>核心机制</h2>
284
+ <small>工作原理与状态流转</small>
285
+ </div>
286
+ <div className="block-body">{currentNode.mechanism}</div>
287
+ </section>
288
+ )}
289
+
290
+ {/* Implementation code if present */}
291
+ {currentNode.implementation && (
292
+ <section className="node-block">
293
+ <div className="block-head">
294
+ <h2>{currentNode.implementation.title}</h2>
295
+ <span className="impl-lang">{currentNode.implementation.language}</span>
296
+ </div>
297
+ <div className="block-body">
298
+ <pre className="code-block">
299
+ <code>{currentNode.implementation.code}</code>
300
+ </pre>
301
+ </div>
302
+ </section>
303
+ )}
304
+
305
+ {/* Examples & Counterexamples */}
306
+ {currentNode.examples.length > 0 && (
307
+ <section className="node-block">
308
+ <div className="block-head">
309
+ <h2>典型示例</h2>
310
+ <small>具象化阐释</small>
311
+ </div>
312
+ <div className="block-body">
313
+ {currentNode.examples.map((ex, i) => (
314
+ <div key={i} className="example-item">
315
+ <div className="ex-title">{ex.title}</div>
316
+ <div className="ex-content">{ex.content}</div>
317
+ </div>
318
+ ))}
319
+ </div>
320
+ </section>
321
+ )}
322
+
323
+ {currentNode.counterexamples.length > 0 && (
324
+ <section className="node-block highlight-warn">
325
+ <div className="block-head">
326
+ <h2>反例与常见误区</h2>
327
+ <small>加深概念边界的辨析</small>
328
+ </div>
329
+ <div className="block-body">
330
+ {currentNode.counterexamples.map((cex, i) => (
331
+ <div key={i} className="counterexample-item">
332
+ <div className="cex-title">✕ {cex.title}</div>
333
+ <div className="cex-content">{cex.content}</div>
334
+ </div>
335
+ ))}
336
+ </div>
337
+ </section>
338
+ )}
339
+
340
+ {/* Custom presentation sections (Compare, Flow, etc.) */}
341
+ {currentNode.customSections.map((sec, i) => (
342
+ sec?.props?.position === 'absolute' ? (
343
+ <React.Fragment key={i}>{sec}</React.Fragment>
344
+ ) : (
345
+ <section key={i} className="node-block custom-section">
346
+ {sec}
347
+ </section>
348
+ )
349
+ ))}
350
+
351
+ {/* Sub-node Exploration Cards (Drill Down Entrance) */}
352
+ {visibleChildNodes.length > 0 && (
353
+ <section className="node-block drill-down-section">
354
+ <div className="block-head">
355
+ <h2>深入下钻:子概念节点</h2>
356
+ <small>点击卡片探索更深机制</small>
357
+ </div>
358
+ <div className="subnodes-grid">
359
+ {visibleChildNodes.map(child => (
360
+ <button
361
+ key={child.id}
362
+ className="subnode-card"
363
+ onClick={() => onSelectNode(child.id)}
364
+ >
365
+ <div className="sn-header">
366
+ <span className="sn-level" style={{ color: LEVEL_DEFS[child.level]?.color }}>
367
+ {child.level}
368
+ </span>
369
+ <ArrowUpRight size={16} className="sn-arrow" />
370
+ </div>
371
+ <h3 className="sn-title">{child.title}</h3>
372
+ <p className="sn-summary">{child.summary || '点击进入该概念下钻探索…'}</p>
373
+ </button>
374
+ ))}
375
+ </div>
376
+ </section>
377
+ )}
378
+ </div>
379
+ <NodeInspector
380
+ currentNode={currentNode}
381
+ nodes={nodes}
382
+ outgoingRelations={outgoingRelations}
383
+ incomingRelations={incomingRelations}
384
+ onSelectNode={onSelectNode}
385
+ />
386
+ </div>
387
+ </div>
388
+ </div>
389
+ </main>
390
+
391
+ {/* 3. Right Column: Inspector and Local Relations */}
392
+ <aside className="explorer-side right-side">
393
+ <div className="inspector-container">
394
+ <div className="inspector-head">
395
+ <div className="insp-title">局部细节与知识网络</div>
396
+ <div className="insp-sub">随当前节点动态聚焦</div>
397
+ </div>
398
+
399
+ {/* Prerequisites */}
400
+ {currentNode.prerequisites.length > 0 && (
401
+ <div className="insp-group">
402
+ <div className="insp-label">前置知识 (Prerequisites)</div>
403
+ <ul className="insp-pill-list">
404
+ {currentNode.prerequisites.map((p, i) => (
405
+ <li key={i} className="insp-pill prereq-pill">{p}</li>
406
+ ))}
407
+ </ul>
408
+ </div>
409
+ )}
410
+
411
+ {/* Boundaries */}
412
+ {currentNode.boundaries.length > 0 && (
413
+ <div className="insp-group">
414
+ <div className="insp-label">边界条件与约束 (Boundaries)</div>
415
+ <div className="boundary-list">
416
+ {currentNode.boundaries.map((b, i) => (
417
+ <div key={i} className="boundary-card">
418
+ <div className="b-title">{b.title}</div>
419
+ <div className="b-content">{b.content}</div>
420
+ </div>
421
+ ))}
422
+ </div>
423
+ </div>
424
+ )}
425
+
426
+ {/* Outgoing Relations (当前节点 → 其他节点) */}
427
+ <div className="insp-group">
428
+ <div className="insp-label">延伸关联 (Outgoing Relations)</div>
429
+ {outgoingRelations.length > 0 ? (
430
+ <div className="relation-links">
431
+ {outgoingRelations.map((rel, i) => {
432
+ const targetNode = nodes.get(rel.to);
433
+ return (
434
+ <button
435
+ key={i}
436
+ className="relation-link-card"
437
+ onClick={() => targetNode && onSelectNode(targetNode.id)}
438
+ >
439
+ <div className="rel-type-tag" style={{ color: rel.typeInfo.color, borderColor: rel.typeInfo.color }}>
440
+ {rel.typeLabel}
441
+ </div>
442
+ <div className="rel-target">
443
+ <span className="target-name">{targetNode ? targetNode.title : rel.to}</span>
444
+ <CornerDownRight size={13} />
445
+ </div>
446
+ {rel.description && (
447
+ <div className="rel-desc">{rel.description}</div>
448
+ )}
449
+ </button>
450
+ );
451
+ })}
452
+ </div>
453
+ ) : (
454
+ <div className="empty-subtext">暂无向外关联</div>
455
+ )}
456
+ </div>
457
+
458
+ {/* Incoming Relations (其他节点 → 当前节点) */}
459
+ <div className="insp-group">
460
+ <div className="insp-label">前驱来源 (Incoming Relations)</div>
461
+ {incomingRelations.length > 0 ? (
462
+ <div className="relation-links">
463
+ {incomingRelations.map((rel, i) => {
464
+ const sourceNode = nodes.get(rel.from);
465
+ return (
466
+ <button
467
+ key={i}
468
+ className="relation-link-card"
469
+ onClick={() => sourceNode && onSelectNode(sourceNode.id)}
470
+ >
471
+ <div className="rel-type-tag" style={{ color: rel.typeInfo.color, borderColor: rel.typeInfo.color }}>
472
+ {rel.typeLabel}
473
+ </div>
474
+ <div className="rel-target">
475
+ <span className="target-name">{sourceNode ? sourceNode.title : rel.from}</span>
476
+ <ArrowLeft size={13} />
477
+ </div>
478
+ {rel.description && (
479
+ <div className="rel-desc">{rel.description}</div>
480
+ )}
481
+ </button>
482
+ );
483
+ })}
484
+ </div>
485
+ ) : (
486
+ <div className="empty-subtext">暂无前驱来源</div>
487
+ )}
488
+ </div>
489
+
490
+ {/* Glossary terms */}
491
+ {currentNode.glossary.length > 0 && (
492
+ <div className="insp-group">
493
+ <div className="insp-label">关键术语表</div>
494
+ <dl className="glossary-dl">
495
+ {currentNode.glossary.map((g, i) => (
496
+ <React.Fragment key={i}>
497
+ <dt>{g.term}</dt>
498
+ <dd>{g.definition}</dd>
499
+ </React.Fragment>
500
+ ))}
501
+ </dl>
502
+ </div>
503
+ )}
504
+ </div>
505
+ </aside>
506
+ </div>
507
+ );
508
+ }
509
+
510
+ function HierarchyStrip({ ancestorPath, currentNode, childNodes, onSelectNode }) {
511
+ const parent = ancestorPath.length > 1 ? ancestorPath[ancestorPath.length - 2] : null;
512
+
513
+ return (
514
+ <nav className="hierarchy-strip" aria-label="概念层级导航">
515
+ <div className="hierarchy-strip-main">
516
+ <span className="hierarchy-strip-label">当前位置</span>
517
+ <div className="hierarchy-strip-path">
518
+ {ancestorPath.slice(0, -1).map(node => (
519
+ <button key={node.id} onClick={() => onSelectNode(node.id)} title={`返回 ${node.title}`}>
520
+ <span>{node.level}</span>{node.title}<ChevronRight size={11} />
521
+ </button>
522
+ ))}
523
+ <strong><span>{currentNode.level}</span>{currentNode.title}</strong>
524
+ </div>
525
+ {parent && (
526
+ <button className="hierarchy-parent-link" onClick={() => onSelectNode(parent.id)}>
527
+ <CornerLeftUp size={12} /> 返回父级
528
+ </button>
529
+ )}
530
+ </div>
531
+ {childNodes.length > 0 && <div className="hierarchy-strip-children">
532
+ <span className="hierarchy-strip-label">继续下钻</span>
533
+ <div className="hierarchy-child-scroll">{childNodes.map(child => (
534
+ <button key={child.id} onClick={() => onSelectNode(child.id)} title={`进入 ${child.title}`}>
535
+ {child.title}<ChevronRight size={12} />
536
+ </button>
537
+ ))}</div>
538
+ </div>}
539
+ </nav>
540
+ );
541
+ }
542
+
543
+ function NodeInspector({ currentNode, nodes, outgoingRelations, incomingRelations, onSelectNode }) {
544
+ const relationCard = (rel, targetId, direction) => {
545
+ const target = nodes.get(targetId);
546
+ return (
547
+ <button className="inline-relation" key={`${direction}-${rel.from}-${rel.to}-${rel.type}`} onClick={() => target && onSelectNode(target.id)}>
548
+ <span className="inline-relation-type" style={{ color: rel.typeInfo?.color }}>{rel.typeLabel}</span>
549
+ <span className="inline-relation-main">
550
+ <b>{target ? target.title : targetId}</b>
551
+ {direction === 'out' ? <CornerDownRight size={13} /> : <ArrowLeft size={13} />}
552
+ </span>
553
+ {rel.description && <small>{rel.description}</small>}
554
+ </button>
555
+ );
556
+ };
557
+
558
+ return (
559
+ <section className="inline-inspector">
560
+ <div className="inline-inspector-head">
561
+ <div>
562
+ <span className="side-label">当前节点的延伸笔记</span>
563
+ <h2>关联、边界与前置知识</h2>
564
+ </div>
565
+ <span className="inline-inspector-count">{outgoingRelations.length + incomingRelations.length} 条关系</span>
566
+ </div>
567
+ <div className="inline-inspector-grid">
568
+ {currentNode.prerequisites.length > 0 && (
569
+ <div className="inline-note-block">
570
+ <span className="insp-label">前置知识</span>
571
+ <div className="inline-pill-flow">
572
+ {currentNode.prerequisites.map((item, index) => <span className="insp-pill" key={index}>{item}</span>)}
573
+ </div>
574
+ </div>
575
+ )}
576
+ {currentNode.boundaries.length > 0 && (
577
+ <div className="inline-note-block">
578
+ <span className="insp-label">边界条件</span>
579
+ {currentNode.boundaries.map((boundary, index) => (
580
+ <div className="inline-boundary" key={index}><b>{boundary.title}</b><span>{boundary.content}</span></div>
581
+ ))}
582
+ </div>
583
+ )}
584
+ <div className="inline-note-block relation-column">
585
+ <span className="insp-label">延伸关系 · 出</span>
586
+ {outgoingRelations.length > 0 ? outgoingRelations.map(rel => relationCard(rel, rel.to, 'out')) : <span className="empty-subtext">暂无向外关联</span>}
587
+ </div>
588
+ <div className="inline-note-block relation-column">
589
+ <span className="insp-label">前驱关系 · 入</span>
590
+ {incomingRelations.length > 0 ? incomingRelations.map(rel => relationCard(rel, rel.from, 'in')) : <span className="empty-subtext">暂无前驱来源</span>}
591
+ </div>
592
+ </div>
593
+ </section>
594
+ );
595
+ }
596
+
597
+ /**
598
+ * Recursive tree navigator helper
599
+ */
600
+ function renderNavTree(nodes, rootId, currentNodeId, onSelectNode) {
601
+ if (!rootId || !nodes.has(rootId)) return null;
602
+
603
+ function renderBranch(id, depth = 0) {
604
+ const node = nodes.get(id);
605
+ if (!node) return null;
606
+ const isCurrent = id === currentNodeId;
607
+
608
+ return (
609
+ <div key={id} className="tree-node-item">
610
+ <button
611
+ className={`tree-node-btn ${isCurrent ? 'active' : ''}`}
612
+ style={{ paddingLeft: `${12 + depth * 14}px` }}
613
+ onClick={() => onSelectNode(id)}
614
+ >
615
+ <span className="node-bullet" style={{ backgroundColor: LEVEL_DEFS[node.level]?.color || '#87a6ff' }} />
616
+ <span className="tree-title">{node.title}</span>
617
+ <span className="tree-level">{node.level}</span>
618
+ </button>
619
+ {node.children.length > 0 && (
620
+ <div className="tree-children-branch">
621
+ {node.children.map(childId => renderBranch(childId, depth + 1))}
622
+ </div>
623
+ )}
624
+ </div>
625
+ );
626
+ }
627
+
628
+ return renderBranch(rootId, 0);
629
+ }