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,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
+ }