@johndimm/constellations 1.0.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 (44) hide show
  1. package/App.tsx +480 -0
  2. package/FullPageConstellations.tsx +74 -0
  3. package/FullPageConstellationsHostShell.tsx +27 -0
  4. package/README.md +116 -0
  5. package/components/AppConfirmDialog.tsx +46 -0
  6. package/components/AppHeader.tsx +73 -0
  7. package/components/AppNotifications.tsx +21 -0
  8. package/components/BrowsePeople.tsx +832 -0
  9. package/components/ControlPanel.tsx +1023 -0
  10. package/components/Graph.tsx +1525 -0
  11. package/components/HelpOverlay.tsx +168 -0
  12. package/components/NodeContextMenu.tsx +160 -0
  13. package/components/PeopleBrowserSidebar.tsx +690 -0
  14. package/components/Sidebar.tsx +271 -0
  15. package/components/TimelineView.tsx +4 -0
  16. package/hooks/useExpansion.ts +889 -0
  17. package/hooks/useGraphActions.ts +325 -0
  18. package/hooks/useGraphState.ts +414 -0
  19. package/hooks/useKioskMode.ts +47 -0
  20. package/hooks/useNodeClickHandler.ts +172 -0
  21. package/hooks/useSearchHandlers.ts +369 -0
  22. package/host.ts +16 -0
  23. package/index.css +101 -0
  24. package/index.tsx +16 -0
  25. package/kioskDomains.ts +307 -0
  26. package/package.json +78 -0
  27. package/services/aiUtils.ts +364 -0
  28. package/services/cacheService.ts +76 -0
  29. package/services/crossrefService.ts +107 -0
  30. package/services/geminiService.ts +1359 -0
  31. package/services/get-local-graphs.js +5 -0
  32. package/services/graphUtils.ts +347 -0
  33. package/services/imageService.ts +39 -0
  34. package/services/llmClient.ts +194 -0
  35. package/services/openAlexService.ts +173 -0
  36. package/services/wikipediaImage.ts +40 -0
  37. package/services/wikipediaService.ts +1175 -0
  38. package/sessionHandoff.ts +132 -0
  39. package/types.ts +99 -0
  40. package/useFullPageConstellationsHost.ts +116 -0
  41. package/utils/evidenceUtils.ts +107 -0
  42. package/utils/graphLogicUtils.ts +32 -0
  43. package/utils/graphNodeToChannelNotes.ts +71 -0
  44. package/utils/wikiUtils.ts +34 -0
@@ -0,0 +1,1525 @@
1
+ import React, { useEffect, useRef, useState, forwardRef, useImperativeHandle, useCallback, useMemo } from 'react';
2
+ import * as d3 from 'd3';
3
+ import { GraphNode, GraphLink } from '../types';
4
+ import { buildWikiUrl } from '../utils/wikiUtils';
5
+
6
+ interface GraphProps {
7
+ nodes: GraphNode[];
8
+ links: GraphLink[];
9
+ onNodeClick: (node: GraphNode, event?: MouseEvent) => void;
10
+ onLinkClick?: (link: GraphLink) => void;
11
+ onViewportChange?: (visibleNodes: GraphNode[]) => void;
12
+ width: number;
13
+ height: number;
14
+ isCompact?: boolean;
15
+ isTimelineMode?: boolean;
16
+ isTextOnly?: boolean;
17
+ searchId?: number;
18
+ selectedNode?: GraphNode | null;
19
+ expandingNodeId?: number | string | null;
20
+ newChildNodeIds?: Set<number | string>;
21
+ highlightKeepIds?: (number | string)[];
22
+ highlightDropIds?: (number | string)[];
23
+ onNodeContextMenu?: (event: MouseEvent, node: GraphNode) => void;
24
+ }
25
+
26
+ export interface GraphHandle {
27
+ centerOnNode: (nodeId: number, scale?: number) => void;
28
+ }
29
+
30
+ const DEFAULT_CARD_SIZE = 220;
31
+
32
+ // Helper to sanitize IDs for DOM selectors
33
+ const safeId = (id: string | number) => String(id).replace(/[^a-zA-Z0-9-_]/g, '_');
34
+
35
+ const Graph = forwardRef<GraphHandle, GraphProps>((props, ref) => {
36
+ const {
37
+ nodes,
38
+ links,
39
+ onNodeClick,
40
+ onLinkClick,
41
+ onViewportChange,
42
+ width,
43
+ height,
44
+ isCompact = false,
45
+ isTimelineMode = false,
46
+ isTextOnly = false,
47
+ searchId = 0,
48
+ selectedNode = null,
49
+ expandingNodeId = null,
50
+ newChildNodeIds = new Set<number | string>(),
51
+ highlightKeepIds = [],
52
+ highlightDropIds = [],
53
+ onNodeContextMenu,
54
+ } = props;
55
+ const svgRef = useRef<SVGSVGElement>(null);
56
+ const zoomGroupRef = useRef<SVGGElement>(null);
57
+ const simulationRef = useRef<d3.Simulation<GraphNode, GraphLink> | null>(null);
58
+ const zoomBehaviorRef = useRef<d3.ZoomBehavior<SVGSVGElement, unknown> | null>(null);
59
+ const [hoveredNode, setHoveredNode] = useState<GraphNode | null>(null);
60
+ const [hoveredLinkId, setHoveredLinkId] = useState<string | null>(null);
61
+ const [focusedNode, setFocusedNode] = useState<GraphNode | null>(null);
62
+ const [timelineLayoutVersion, setTimelineLayoutVersion] = useState(0);
63
+ const wasTimelineRef = useRef(isTimelineMode);
64
+ const timelinePositionsRef = useRef(new Map<number, { x: number, y: number }>());
65
+
66
+ // Track previous data sizes to optimize simulation restarts
67
+ const prevNodesLen = useRef(nodes.length);
68
+ const prevLinksLen = useRef(links.length);
69
+
70
+ // Support unified highlighting from either click (selectedNode prop) or internal focus
71
+ const activeFocusNode = selectedNode || focusedNode;
72
+ const focusId = activeFocusNode?.id;
73
+ const focusExists = focusId ? nodes.some(n => n.id === focusId) : false;
74
+ const effectiveFocused = focusExists ? activeFocusNode : null;
75
+
76
+ // Helper functions for Drag
77
+ function dragstarted(event: any, d: GraphNode) {
78
+ if (!event.active) simulationRef.current?.alphaTarget(0.3).restart();
79
+ d.fx = d.x;
80
+ d.fy = d.y;
81
+ }
82
+
83
+ function dragged(event: any, d: GraphNode) {
84
+ d.fx = event.x;
85
+ d.fy = event.y;
86
+ }
87
+
88
+ function dragended(event: any, d: GraphNode) {
89
+ if (!event.active) simulationRef.current?.alphaTarget(0);
90
+ d.fx = null;
91
+ d.fy = null;
92
+ }
93
+
94
+ function getNodeColor(type: string, isPerson?: boolean) {
95
+ if (type === 'Origin') return '#ef4444';
96
+ if (isPerson ?? (type.toLowerCase() === 'person' || type.toLowerCase() === 'actor')) return '#f59e0b';
97
+ return '#3b82f6';
98
+ }
99
+
100
+ function escapeHtml(text: string): string {
101
+ const div = document.createElement('div');
102
+ div.textContent = text;
103
+ return div.innerHTML;
104
+ }
105
+
106
+ const isAtomicNode = useCallback((node: GraphNode) => node.is_atomic === true || node.is_person === true, []);
107
+
108
+ const timelineNodes = useMemo(() => {
109
+ return nodes
110
+ .filter(n => !isAtomicNode(n))
111
+ .sort((a, b) => {
112
+ const hasA = a.year !== undefined && a.year !== null && a.year !== 0;
113
+ const hasB = b.year !== undefined && b.year !== null && b.year !== 0;
114
+
115
+ // Sort undated to the end
116
+ if (hasA && !hasB) return -1;
117
+ if (!hasA && hasB) return 1;
118
+
119
+ if (hasA && hasB) {
120
+ const yearA = Number(a.year ?? 0);
121
+ const yearB = Number(b.year ?? 0);
122
+ if (yearA !== yearB) return yearA - yearB;
123
+ }
124
+
125
+ return a.id - b.id;
126
+ });
127
+ }, [nodes, isAtomicNode]);
128
+
129
+ const centerOnNode = useCallback((nodeId: number, scale?: number) => {
130
+ const node = nodes.find(n => n.id === nodeId);
131
+ if (!node || !svgRef.current || !zoomBehaviorRef.current) return;
132
+
133
+ const svg = d3.select(svgRef.current);
134
+ const currentTransform = d3.zoomTransform(svgRef.current);
135
+
136
+ let targetX = node.x;
137
+ let targetY = node.y;
138
+
139
+ if (isTimelineMode) {
140
+ const fixed = timelinePositionsRef.current.get(nodeId);
141
+ if (fixed) {
142
+ targetX = fixed.x;
143
+ targetY = fixed.y;
144
+ }
145
+ }
146
+
147
+ if (targetX === undefined) targetX = width / 2;
148
+ if (targetY === undefined) targetY = height / 2;
149
+
150
+ const k = scale !== undefined ? scale : currentTransform.k;
151
+ const transform = d3.zoomIdentity
152
+ .translate(width / 2, height / 2)
153
+ .scale(k)
154
+ .translate(-targetX, -targetY);
155
+
156
+ svg.transition().duration(800).call(zoomBehaviorRef.current.transform, transform);
157
+ }, [nodes, width, height, isTimelineMode]);
158
+
159
+ // Calculate dynamic dimensions for nodes
160
+ const getNodeDimensions = (node: GraphNode, isTimeline: boolean, textOnly: boolean): { w: number, h: number, r: number, type: string } => {
161
+ if (isAtomicNode(node)) {
162
+ if (isTimeline) {
163
+ // Larger size in timeline mode (2x)
164
+ return { w: 96, h: 96, r: 110, type: 'circle' }; // r is collision radius
165
+ } else {
166
+ // Smaller size in graph mode (original size)
167
+ return { w: 48, h: 48, r: 55, type: 'circle' }; // r is collision radius
168
+ }
169
+ }
170
+
171
+ // Events/Things
172
+ if (isTimeline) {
173
+ // Timeline Card Mode: Fixed height for consistent layout
174
+ return {
175
+ w: DEFAULT_CARD_SIZE,
176
+ h: DEFAULT_CARD_SIZE,
177
+ r: 120, // Collision radius
178
+ type: 'card'
179
+ };
180
+ } else {
181
+ // Graph Mode
182
+ // Square nodes for everything else, consistent with image nodes
183
+ return { w: 60, h: 60, r: 60, type: 'box' };
184
+ }
185
+ };
186
+
187
+ // Helper to wrap text in SVG
188
+ const wrapText = (text: string, width: number, maxLines?: number) => {
189
+ if (!text) return [];
190
+ const words = text.split(/\s+/);
191
+ const lines = [];
192
+ let currentLine = words[0];
193
+
194
+ for (let i = 1; i < words.length; i++) {
195
+ const word = words[i];
196
+ if ((currentLine + " " + word).length * 7 < width) {
197
+ currentLine += " " + word;
198
+ } else {
199
+ lines.push(currentLine);
200
+ currentLine = word;
201
+ if (maxLines && lines.length >= maxLines) break;
202
+ }
203
+ }
204
+ if (currentLine) lines.push(currentLine);
205
+ return maxLines ? lines.slice(0, maxLines) : lines;
206
+ };
207
+
208
+ // Expose centerOnNode function via ref
209
+ useImperativeHandle(ref, () => ({
210
+ centerOnNode
211
+ }), [centerOnNode]);
212
+
213
+ // Center on selected node when it changes
214
+ useEffect(() => {
215
+ if (!selectedNode || !svgRef.current) return;
216
+ centerOnNode(selectedNode.id);
217
+ }, [selectedNode?.id, centerOnNode]);
218
+
219
+ // Auto-center and zoom when entering timeline mode
220
+ useEffect(() => {
221
+ if (isTimelineMode && timelineNodes.length > 0) {
222
+ // Small delay to ensure layout positions are calculated
223
+ const timer = setTimeout(() => {
224
+ centerOnNode(timelineNodes[0].id, 1.15);
225
+ }, 150);
226
+ return () => clearTimeout(timer);
227
+ }
228
+ }, [isTimelineMode, timelineNodes, centerOnNode]);
229
+
230
+ // Reset zoom and focused state when searchId changes (new graph)
231
+ useEffect(() => {
232
+ setFocusedNode(null);
233
+ if (!svgRef.current) return;
234
+
235
+ // Zoom Reset Logic
236
+ if (searchId > 0) {
237
+ const svg = d3.select(svgRef.current);
238
+ const zoomIdentity = d3.zoomIdentity;
239
+ // Re-create the zoom behavior to call transform on it
240
+ const zoom = d3.zoom<SVGSVGElement, unknown>().on("zoom", (event) => {
241
+ if (zoomGroupRef.current) {
242
+ d3.select(zoomGroupRef.current).attr("transform", event.transform);
243
+ }
244
+ });
245
+
246
+ svg.transition().duration(750).call(zoom.transform, zoomIdentity);
247
+ }
248
+ }, [searchId]);
249
+
250
+ // Initialize simulation
251
+ // Initialize Zoom (Simulation is managed in the main update effect)
252
+ useEffect(() => {
253
+ if (!svgRef.current) return;
254
+
255
+ // Initialize Zoom Behavior
256
+ const zoom = d3.zoom<SVGSVGElement, unknown>()
257
+ .scaleExtent([0.1, 4])
258
+ .on("zoom", (event) => {
259
+ if (zoomGroupRef.current) {
260
+ d3.select(zoomGroupRef.current).attr("transform", event.transform);
261
+ }
262
+ })
263
+ .on("end", (event) => {
264
+ if (onViewportChange) {
265
+ const t = event.transform;
266
+ const minX = -t.x / t.k;
267
+ const maxX = (width - t.x) / t.k;
268
+ const minY = -t.y / t.k;
269
+ const maxY = (height - t.y) / t.k;
270
+
271
+ const visible = nodes.filter(n => {
272
+ return n.x !== undefined && n.y !== undefined &&
273
+ n.x >= minX - 100 && n.x <= maxX + 100 &&
274
+ n.y >= minY - 100 && n.y <= maxY + 100;
275
+ });
276
+
277
+ onViewportChange(visible);
278
+ }
279
+ });
280
+
281
+ d3.select(svgRef.current).call(zoom);
282
+ zoomBehaviorRef.current = zoom;
283
+
284
+ // Cleanup simulation on unmount
285
+ return () => {
286
+ if (simulationRef.current) {
287
+ simulationRef.current.stop();
288
+ }
289
+ };
290
+ }, [width, height]); // Only re-run if dimensions change (or on mount)
291
+
292
+
293
+ // Keyboard navigation with arrow keys
294
+ useEffect(() => {
295
+ const handleKeyDown = (event: KeyboardEvent) => {
296
+ // Only handle arrow keys
297
+ if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) {
298
+ return;
299
+ }
300
+
301
+ // Don't navigate if user is typing in an input field
302
+ const target = event.target as HTMLElement;
303
+ if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
304
+ return;
305
+ }
306
+
307
+ if (!svgRef.current || !zoomBehaviorRef.current) return;
308
+
309
+ event.preventDefault();
310
+
311
+ if (isTimelineMode && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) {
312
+ // Navigate chronologically
313
+ const currentIndex = selectedNode ? timelineNodes.findIndex(n => n.id === selectedNode.id) : -1;
314
+ let nextNode = null;
315
+
316
+ if (event.key === 'ArrowRight') {
317
+ if (currentIndex === -1) nextNode = timelineNodes[0];
318
+ else if (currentIndex < timelineNodes.length - 1) nextNode = timelineNodes[currentIndex + 1];
319
+ } else if (event.key === 'ArrowLeft') {
320
+ if (currentIndex > 0) nextNode = timelineNodes[currentIndex - 1];
321
+ }
322
+
323
+ if (nextNode) {
324
+ onNodeClick(nextNode);
325
+ return;
326
+ }
327
+ }
328
+
329
+ const svg = d3.select(svgRef.current);
330
+ const currentTransform = d3.zoomTransform(svgRef.current);
331
+
332
+ // Pan distance (adjustable)
333
+ const panDistance = 50;
334
+ let newX = currentTransform.x;
335
+ let newY = currentTransform.y;
336
+
337
+ switch (event.key) {
338
+ case 'ArrowUp':
339
+ newY += panDistance;
340
+ break;
341
+ case 'ArrowDown':
342
+ newY -= panDistance;
343
+ break;
344
+ case 'ArrowLeft':
345
+ newX += panDistance;
346
+ break;
347
+ case 'ArrowRight':
348
+ newX -= panDistance;
349
+ break;
350
+ }
351
+
352
+ // Create new transform with updated translation
353
+ const newTransform = d3.zoomIdentity
354
+ .translate(newX, newY)
355
+ .scale(currentTransform.k);
356
+
357
+ // Apply transform with smooth transition
358
+ svg.transition()
359
+ .duration(200)
360
+ .ease(d3.easeLinear)
361
+ .call(zoomBehaviorRef.current.transform, newTransform);
362
+ };
363
+
364
+ window.addEventListener('keydown', handleKeyDown);
365
+ return () => {
366
+ window.removeEventListener('keydown', handleKeyDown);
367
+ };
368
+ }, [isTimelineMode, timelineNodes, selectedNode, onNodeClick, width, height]);
369
+
370
+ // Handle Mode Switching and Forces
371
+ useEffect(() => {
372
+ if (!simulationRef.current) return;
373
+ const simulation = simulationRef.current;
374
+
375
+ const linkForce = simulation.force("link") as d3.ForceLink<GraphNode, GraphLink>;
376
+ const chargeForce = simulation.force("charge") as d3.ForceManyBody<GraphNode>;
377
+ const centerForce = simulation.force("center") as d3.ForceCenter<GraphNode>;
378
+
379
+ const collideForce = d3.forceCollide<GraphNode>()
380
+ .radius(d => {
381
+ const dims = getNodeDimensions(d, isTimelineMode, isTextOnly);
382
+ // Use actual measured height for cards (d.h) if available, otherwise use dims
383
+ if (isTimelineMode && dims.type === 'card') {
384
+ // For timeline cards, use the larger of width or height plus padding
385
+ const cardWidth = dims.w;
386
+ const cardHeight = d.h || dims.h;
387
+ // Use the diagonal distance plus padding to ensure no overlap
388
+ const maxDimension = Math.max(cardWidth, cardHeight);
389
+ return (maxDimension / 2) + 15; // Increased padding to prevent overlap
390
+ }
391
+ if (isCompact) {
392
+ // Tighter packing for compact mode, but prevent text overlap
393
+ // Increased padding from +8 to +20 to account for labels
394
+ if (dims.type === 'circle') return (dims.w / 2) + 20;
395
+ if (dims.type === 'box') return (dims.w / 2) + 20;
396
+ // Cards are large, keep standard collision but maybe tighter
397
+ return dims.r * 0.8;
398
+ }
399
+ return dims.r + 15;
400
+ })
401
+ .strength(isTimelineMode ? 0.5 : 0.8) // Lower collision for timeline since events are fixed
402
+ .iterations(isTimelineMode ? 3 : 3);
403
+
404
+ simulation.force("collidePeople", null);
405
+ simulation.force("collideEvents", null);
406
+ simulation.force("collide", collideForce);
407
+
408
+ if (isTimelineMode) {
409
+ const prevPositions = new Map<number, { x: number; y: number }>(timelinePositionsRef.current);
410
+
411
+ const lockNodePosition = (node: GraphNode, x: number, y: number) => {
412
+ node.fx = x;
413
+ node.fy = y;
414
+ node.x = x;
415
+ node.y = y;
416
+ node.vx = 0;
417
+ node.vy = 0;
418
+ timelinePositionsRef.current.set(node.id, { x, y });
419
+ };
420
+
421
+
422
+
423
+ const nodeIndexMap = new Map<number, number>(
424
+ timelineNodes.map((n, i) => [n.id, i] as [number, number])
425
+ );
426
+
427
+ const itemSpacing = 280; // More horizontal breathing room
428
+ const vGap = 300; // Vertical distance between staggered dated events
429
+ const tierGap = 350; // Vertical distance between tiered layers
430
+ const personRadius = 110;
431
+ const minPersonDistance = personRadius * 2 + 50;
432
+
433
+ const totalWidth = timelineNodes.length * itemSpacing;
434
+ const startX = -(totalWidth / 2) + (itemSpacing / 2);
435
+ const centerY = height / 2;
436
+
437
+ // Reset all fixed positions first
438
+ nodes.forEach(node => {
439
+ node.fx = null;
440
+ node.fy = null;
441
+ const prev = prevPositions.get(node.id);
442
+ if (prev) {
443
+ node.x = prev.x;
444
+ node.y = prev.y;
445
+ }
446
+ });
447
+
448
+ // tier 3: Fix timeline event positions (Bottom)
449
+ timelineNodes.forEach((node, index) => {
450
+ const fixedX = width / 2 + startX + (index * itemSpacing);
451
+ const fixedY = centerY + ((index % 2 === 0) ? -vGap / 4 : vGap / 4);
452
+ lockNodePosition(node, fixedX, fixedY);
453
+ });
454
+
455
+ // tier 1: Position people (Top)
456
+ const peopleNodes = nodes.filter(isAtomicNode);
457
+ const availableWidth = Math.min(Math.max(totalWidth, width), width * 2);
458
+
459
+ // Compute desired X for people based on connections to placed events
460
+ const desiredPositions = peopleNodes.map(person => {
461
+ const connectedEvents = links
462
+ .filter(l => {
463
+ const sId = typeof l.source === 'object' ? (l.source as GraphNode).id : l.source;
464
+ const tId = typeof l.target === 'object' ? (l.target as GraphNode).id : l.target;
465
+ return (sId === person.id || tId === person.id);
466
+ })
467
+ .map(l => {
468
+ const sId = typeof l.source === 'object' ? (l.source as GraphNode).id : l.source;
469
+ const tId = typeof l.target === 'object' ? (l.target as GraphNode).id : l.target;
470
+ const eventId = sId === person.id ? tId : sId;
471
+ return nodes.find(n => n.id === eventId && n.year !== undefined && !isAtomicNode(n));
472
+ })
473
+ .filter((e): e is GraphNode => e !== undefined);
474
+
475
+ if (connectedEvents.length > 0) {
476
+ const sumX = connectedEvents.reduce((sum, event) => {
477
+ const index = nodeIndexMap.get(event.id) ?? 0;
478
+ return sum + (width / 2 + startX + (index * itemSpacing));
479
+ }, 0);
480
+ return { person, desiredX: sumX / connectedEvents.length };
481
+ }
482
+ return { person, desiredX: width / 2 };
483
+ });
484
+ desiredPositions.sort((a, b) => a.desiredX - b.desiredX);
485
+
486
+ const peoplePerRow = Math.max(1, Math.floor(availableWidth / minPersonDistance));
487
+ const totalPeopleRows = Math.ceil(desiredPositions.length / peoplePerRow);
488
+ const topTierYBase = centerY - tierGap - (totalPeopleRows * minPersonDistance);
489
+
490
+ desiredPositions.forEach((entry, index) => {
491
+ const { person } = entry;
492
+ const row = Math.floor(index / peoplePerRow);
493
+ const col = index % peoplePerRow;
494
+ const countInRow = Math.min(peoplePerRow, desiredPositions.length - row * peoplePerRow);
495
+ const rWidth = (countInRow - 1) * minPersonDistance;
496
+ const rStartX = width / 2 - (rWidth / 2);
497
+ lockNodePosition(person, rStartX + col * minPersonDistance, topTierYBase + row * minPersonDistance);
498
+ });
499
+
500
+ // tier 2: (Removed separate unknown-year tier, now merged into timelineNodes)
501
+
502
+ // Safety net: ensure every node has a fixed position to eliminate wandering cards
503
+ nodes.forEach((node, idx) => {
504
+ if (!timelinePositionsRef.current.has(node.id)) {
505
+ const fallbackX = width / 2 + (idx * 40);
506
+ const fallbackY = centerY - tierGap;
507
+ lockNodePosition(node, fallbackX, fallbackY);
508
+ } else {
509
+ const locked = timelinePositionsRef.current.get(node.id)!;
510
+ node.fx = locked.x;
511
+ node.fy = locked.y;
512
+ node.x = locked.x;
513
+ node.y = locked.y;
514
+ node.vx = 0;
515
+ node.vy = 0;
516
+ }
517
+ });
518
+
519
+ if (centerForce) centerForce.strength(0.01);
520
+ if (chargeForce) chargeForce.strength(-50);
521
+ if (linkForce) linkForce.strength(0);
522
+
523
+ simulation.force("x", null);
524
+ simulation.force("y", null);
525
+ simulation.velocityDecay(0.9);
526
+
527
+ } else {
528
+ timelinePositionsRef.current.clear();
529
+ // Reset fixed positions for non-timeline mode
530
+ nodes.forEach(node => {
531
+ node.fx = null;
532
+ node.fy = null;
533
+
534
+ // Initialize new nodes to center to prevent flying in from top-left (0,0)
535
+ // We check for undefined or NaN. We strictly check x AND y to act on fresh nodes.
536
+ if ((node.x === undefined || isNaN(node.x)) && width > 0 && height > 0) {
537
+ node.x = width / 2 + (Math.random() - 0.5) * 10; // Tiny jitter to prevent stacking overlap
538
+ node.y = height / 2 + (Math.random() - 0.5) * 10;
539
+ }
540
+ });
541
+
542
+ if (centerForce) centerForce.x(width / 2).y(height / 2).strength(1.0);
543
+
544
+ // Standard vs Compact Settings
545
+ // Reduced charge to prevent aggressive drifting
546
+ const chargeStrength = isCompact ? -150 : -400;
547
+ const linkDist = isCompact ? 60 : 120;
548
+
549
+ if (chargeForce) chargeForce.strength(chargeStrength);
550
+ if (linkForce) linkForce.strength(1).distance(linkDist);
551
+
552
+ simulation.force("x", null);
553
+ simulation.force("y", null);
554
+
555
+ // Higher velocity decay for non-timeline mode to prevent spinning
556
+ simulation.velocityDecay(0.85);
557
+ }
558
+
559
+ simulation.alpha(isTimelineMode ? 0.2 : 0.3).restart(); // Reduced from 0.5 to 0.3 to prevent spinning
560
+ }, [isTimelineMode, isCompact, nodes, links, width, height, isTextOnly, timelineLayoutVersion]);
561
+
562
+ // Hard-clamp positions every frame in timeline mode to prevent drifting
563
+ useEffect(() => {
564
+ if (!isTimelineMode || !zoomGroupRef.current) return;
565
+ const container = d3.select(zoomGroupRef.current);
566
+
567
+ const getCoords = (node: GraphNode) => {
568
+ const fixed = timelinePositionsRef.current.get(node.id);
569
+ const x = (fixed?.x ?? node.fx ?? node.x) || 0;
570
+ const y = (fixed?.y ?? node.fy ?? node.y) || 0;
571
+ return { x, y };
572
+ };
573
+
574
+ const render = () => {
575
+ container.selectAll<SVGPathElement, GraphLink>(".link").attr("d", d => {
576
+ const source = d.source as GraphNode;
577
+ const target = d.target as GraphNode;
578
+ if (!source || !target || typeof source !== 'object' || typeof target !== 'object') return null;
579
+ const s = getCoords(source);
580
+ const t = getCoords(target);
581
+ const dist = Math.sqrt((t.x - s.x) ** 2 + (t.y - s.y) ** 2);
582
+ const midX = (s.x + t.x) / 2;
583
+ const midY = (s.y + t.y) / 2 + dist * 0.15;
584
+ return `M${s.x},${s.y} Q${midX},${midY} ${t.x},${t.y}`;
585
+ });
586
+
587
+ container.selectAll<SVGGElement, GraphNode>(".node").attr("transform", d => {
588
+ const { x, y } = getCoords(d);
589
+ d.x = x;
590
+ d.y = y;
591
+ d.vx = 0;
592
+ d.vy = 0;
593
+ return `translate(${x},${y})`;
594
+ });
595
+ };
596
+
597
+ // Only use continuous animation in timeline mode when simulation might still be settling
598
+ // In normal mode, the simulation tick handler will update positions
599
+ if (!isTimelineMode) {
600
+ // Initial render only for non-timeline mode (tick handler will update)
601
+ render();
602
+ return;
603
+ }
604
+
605
+ // In timeline mode with fixed positions, render periodically but not every frame
606
+ let lastRender = 0;
607
+ const renderInterval = 16; // ~60fps max
608
+ let frame = requestAnimationFrame(function loop() {
609
+ const now = performance.now();
610
+ if (now - lastRender >= renderInterval) {
611
+ render();
612
+ lastRender = now;
613
+ }
614
+ frame = requestAnimationFrame(loop);
615
+ });
616
+
617
+ return () => cancelAnimationFrame(frame);
618
+ }, [isTimelineMode, nodes, links]);
619
+
620
+ // Reset zoom and re-center positions when leaving timeline mode to avoid off-screen jumps
621
+ useEffect(() => {
622
+ const wasTimeline = wasTimelineRef.current;
623
+ if (wasTimeline && !isTimelineMode) {
624
+ // Reset node positions near center with a small jitter to let simulation settle quickly
625
+ nodes.forEach(node => {
626
+ node.fx = null;
627
+ node.fy = null;
628
+ node.x = width / 2 + (Math.random() - 0.5) * 80;
629
+ node.y = height / 2 + (Math.random() - 0.5) * 80;
630
+ });
631
+
632
+ if (simulationRef.current) {
633
+ simulationRef.current.alpha(0.8).restart();
634
+ }
635
+
636
+ if (svgRef.current && zoomBehaviorRef.current) {
637
+ const svg = d3.select(svgRef.current);
638
+ svg.transition().duration(500).call(zoomBehaviorRef.current.transform, d3.zoomIdentity);
639
+ }
640
+ } else if (!wasTimeline && isTimelineMode && timelineNodes.length > 0) {
641
+ // Center on the leftmost (first sorted) event
642
+ const firstEvent = timelineNodes[0];
643
+ if (firstEvent) {
644
+ // Use a slight timeout to ensure positions are established
645
+ setTimeout(() => {
646
+ centerOnNode(firstEvent.id);
647
+ }, 100);
648
+ }
649
+ }
650
+ wasTimelineRef.current = isTimelineMode;
651
+ }, [isTimelineMode, nodes, width, height, timelineNodes, centerOnNode]);
652
+
653
+ // 4. Structural Effect: Only runs when overall graph structure (nodes/links) changes.
654
+ // This handles D3 enter/exit/merge and restarts the simulation.
655
+ useEffect(() => {
656
+ if (!zoomGroupRef.current) return;
657
+
658
+ // 1. Calculate valid links first
659
+ const validLinks = links
660
+ .filter(link => {
661
+ const sId = String(typeof link.source === 'object' ? (link.source as GraphNode).id : link.source);
662
+ const tId = String(typeof link.target === 'object' ? (link.target as GraphNode).id : link.target);
663
+ const hasSource = nodes.some(n => String(n.id) === sId);
664
+ const hasTarget = nodes.some(n => String(n.id) === tId);
665
+ return hasSource && hasTarget;
666
+ })
667
+ .map(link => ({
668
+ ...link,
669
+ source: String(typeof link.source === 'object' ? (link.source as GraphNode).id : link.source),
670
+ target: String(typeof link.target === 'object' ? (link.target as GraphNode).id : link.target)
671
+ }));
672
+
673
+ // 2. Lazily create simulation if it doesn't exist
674
+ if (!simulationRef.current) {
675
+ simulationRef.current = d3.forceSimulation<GraphNode, GraphLink>(nodes)
676
+ .force("link", d3.forceLink<GraphNode, GraphLink>(validLinks).id(d => String(d.id)).distance(100))
677
+ .force("charge", d3.forceManyBody().strength(-300))
678
+ .force("center", d3.forceCenter(width / 2, height / 2))
679
+ .velocityDecay(0.6)
680
+ .alphaDecay(0.02);
681
+ }
682
+
683
+ const simulation = simulationRef.current;
684
+ const container = d3.select(zoomGroupRef.current);
685
+
686
+ // Update center force in case dimensions changed
687
+ simulation.force("center", d3.forceCenter(width / 2, height / 2));
688
+
689
+
690
+ // Wide invisible hit-area for easier clicking on links
691
+ const linkHitSel = container.selectAll<SVGPathElement, GraphLink>(".link-hit").data(validLinks, d => d.id);
692
+ linkHitSel.exit().remove();
693
+ const linkHitEnter = linkHitSel.enter().insert("path", ".node")
694
+ .attr("class", "link-hit")
695
+ .attr("fill", "none")
696
+ .attr("stroke", "transparent")
697
+ .attr("stroke-opacity", 0)
698
+ .attr("stroke-width", 14)
699
+ .attr("stroke-linecap", "round")
700
+ .style("pointer-events", "stroke");
701
+
702
+ const linkHitMerged = linkHitSel.merge(linkHitEnter);
703
+ if (isTimelineMode) {
704
+ linkHitMerged.style("display", "none");
705
+ } else {
706
+ linkHitMerged.style("display", null);
707
+ }
708
+
709
+ const linkSel = container.selectAll<SVGPathElement, GraphLink>(".link").data(validLinks, d => d.id);
710
+ linkSel.exit().remove();
711
+ const linkEnter = linkSel.enter().insert("path", ".node")
712
+ .attr("class", "link")
713
+ .attr("fill", "none")
714
+ .attr("stroke", "#dc2626")
715
+ .attr("stroke-opacity", 0.7)
716
+ .attr("stroke-width", 3.5)
717
+ .attr("stroke-linecap", "round");
718
+
719
+ // In timeline mode, links are hidden by default, shown only when person is selected
720
+ const linkMerged = linkSel.merge(linkEnter);
721
+ if (isTimelineMode) {
722
+ linkMerged.style("display", "none");
723
+ } else {
724
+ linkMerged.style("display", null);
725
+ }
726
+
727
+ // Link click handling (for evidence) + hover highlight via hit-area
728
+ if (onLinkClick) {
729
+ const clickHandler = (event: any, d: GraphLink) => {
730
+ event.stopPropagation();
731
+ onLinkClick(d);
732
+ };
733
+ const hoverIn = (_event: any, d: GraphLink) => setHoveredLinkId(d.id);
734
+ const hoverOut = () => setHoveredLinkId(null);
735
+
736
+ linkMerged
737
+ .style("cursor", "pointer")
738
+ .on("click", clickHandler)
739
+ .on("mouseover", hoverIn)
740
+ .on("mouseout", hoverOut);
741
+ linkHitMerged
742
+ .style("cursor", "pointer")
743
+ .on("click", clickHandler)
744
+ .on("mouseover", hoverIn)
745
+ .on("mouseout", hoverOut);
746
+ }
747
+
748
+ const nodeSel = container.selectAll<SVGGElement, GraphNode>(".node").data(nodes, d => d.id);
749
+ const nodeEnter = nodeSel.enter().append("g")
750
+ .attr("class", "node");
751
+
752
+ // Create drag behavior - only allow dragging if not in timeline mode, or if not a person node in timeline mode
753
+ const dragBehavior = d3.drag<SVGGElement, GraphNode>()
754
+ .on("start", (event, d) => {
755
+ if (isTimelineMode) {
756
+ if (isAtomicNode(d)) {
757
+ event.sourceEvent.stopPropagation();
758
+ return; // Don't allow dragging people in timeline mode
759
+ }
760
+ }
761
+ dragstarted(event, d);
762
+ })
763
+ .on("drag", (event, d) => {
764
+ if (isTimelineMode) {
765
+ if (isAtomicNode(d)) return; // Don't allow dragging people in timeline mode
766
+ }
767
+ dragged(event, d);
768
+ })
769
+ .on("end", (event, d) => {
770
+ if (isTimelineMode) {
771
+ if (isAtomicNode(d)) return; // Don't allow dragging people in timeline mode
772
+ }
773
+ dragended(event, d);
774
+ });
775
+
776
+ // Apply drag to all nodes (both new and existing)
777
+ // nodeSel includes all nodes, so we call drag on the merged selection
778
+ nodeEnter.merge(nodeSel).call(dragBehavior);
779
+
780
+ nodeEnter.append("circle")
781
+ .attr("class", "node-circle")
782
+ .attr("stroke", "#fff")
783
+ .attr("stroke-width", 2);
784
+
785
+ nodeEnter.append("rect")
786
+ .attr("class", "node-rect")
787
+ .attr("rx", 0)
788
+ .attr("ry", 0)
789
+ .attr("stroke", "#fff")
790
+ .attr("stroke-width", 2);
791
+
792
+ const defs = nodeEnter.append("defs");
793
+ defs.append("clipPath")
794
+ .attr("id", d => `clip-circle-${safeId(d.id)}`)
795
+ .append("circle").attr("cx", 0).attr("cy", 0);
796
+
797
+ defs.append("clipPath")
798
+ .attr("id", d => `clip-rect-${safeId(d.id)}`)
799
+ .append("rect").attr("x", 0).attr("y", 0);
800
+
801
+ defs.append("clipPath")
802
+ .attr("id", d => `clip-desc-${safeId(d.id)}`)
803
+ .append("rect").attr("x", 0).attr("y", 0);
804
+
805
+ nodeEnter.append("image").style("pointer-events", "none");
806
+
807
+ nodeEnter.append("text")
808
+ .attr("class", "node-label")
809
+ .attr("text-anchor", "middle")
810
+ .style("pointer-events", "none")
811
+ .style("text-shadow", "0 1px 2px rgba(0,0,0,0.8)")
812
+ .attr("fill", "#e2e8f0");
813
+
814
+ nodeEnter.append("text")
815
+ .attr("class", "node-desc")
816
+ .attr("text-anchor", "middle")
817
+ .style("font-family", "sans-serif")
818
+ .style("pointer-events", "none")
819
+ .attr("fill", "#fff");
820
+
821
+ nodeEnter.append("text")
822
+ .attr("class", "year-label")
823
+ .attr("text-anchor", "middle")
824
+ .style("font-size", "10px")
825
+ .style("font-family", "monospace")
826
+ .style("pointer-events", "none")
827
+ .attr("fill", "#fbbf24"); // amber-400
828
+
829
+ // Click and Context Menu listeners
830
+ const clickHandler = (event: any, d: GraphNode) => {
831
+ // If dragging occurred, don't trigger click
832
+ // (Assuming standard D3 pattern: if moved small amount, it's a click)
833
+ onNodeClick(d, event as MouseEvent);
834
+ };
835
+ const contextMenuHandler = (event: any, d: GraphNode) => {
836
+ if (onNodeContextMenu) {
837
+ event.preventDefault();
838
+ onNodeContextMenu(event, d);
839
+ }
840
+ };
841
+
842
+ const hoverIn = (_event: any, d: GraphNode) => setHoveredNode(d);
843
+ const hoverOut = () => setHoveredNode(null);
844
+
845
+ nodeEnter.merge(nodeSel)
846
+ .attr("role", "button")
847
+ .attr("aria-label", (d: GraphNode) => (d.title ? `Graph node: ${d.title}` : "Graph node"))
848
+ .style("cursor", "pointer")
849
+ .on("click", clickHandler)
850
+ .on("contextmenu", contextMenuHandler)
851
+ .on("mouseover", hoverIn)
852
+ .on("mouseout", hoverOut);
853
+
854
+
855
+ nodeEnter.append("text")
856
+ .attr("class", "people-label")
857
+ .attr("text-anchor", "middle")
858
+ .style("font-size", "11px")
859
+ .style("font-family", "sans-serif")
860
+ .style("pointer-events", "none")
861
+ .attr("fill", "#f59e0b")
862
+ .style("font-style", "italic");
863
+
864
+ // Add foreignObject for card content in timeline mode (uses HTML for automatic text sizing)
865
+ nodeEnter.append("foreignObject")
866
+ .attr("class", "card-content")
867
+ .style("overflow", "visible")
868
+ .style("pointer-events", "none");
869
+
870
+ const spinner = nodeEnter.append("g").attr("class", "spinner-group").style("display", "none");
871
+ spinner.append("circle")
872
+ .attr("class", "spinner")
873
+ .attr("fill", "none")
874
+ .attr("stroke", "#a78bfa")
875
+ .attr("stroke-width", 3)
876
+ .attr("stroke-dasharray", "10 15")
877
+ .attr("stroke-linecap", "round");
878
+
879
+ spinner.append("animateTransform")
880
+ .attr("attributeName", "transform")
881
+ .attr("type", "rotate")
882
+ .attr("from", "0 0 0")
883
+ .attr("to", "360 0 0")
884
+ .attr("dur", "2s")
885
+ .attr("repeatCount", "indefinite");
886
+
887
+ nodeSel.exit().remove();
888
+
889
+ // STABILIZATION: Copy positions from old simulation nodes to new data to prevent "jumping"
890
+ const oldNodes = simulation.nodes();
891
+ const oldNodeMap = new Map(oldNodes.map(n => [n.id, n]));
892
+ nodes.forEach(n => {
893
+ const old = oldNodeMap.get(n.id);
894
+ if (old) {
895
+ // Preserve physics state
896
+ const oldNode = old as GraphNode;
897
+ if (n.x === undefined || isNaN(n.x)) n.x = oldNode.x;
898
+ if (n.y === undefined || isNaN(n.y)) n.y = oldNode.y;
899
+ if (n.vx === undefined || isNaN(n.vx)) n.vx = oldNode.vx;
900
+ if (n.vy === undefined || isNaN(n.vy)) n.vy = oldNode.vy;
901
+ }
902
+ });
903
+
904
+ // Always update simulation data to ensure D3 resolves string IDs into object references
905
+ simulation.nodes(nodes);
906
+ try {
907
+ const linkForce = simulation.force("link") as d3.ForceLink<GraphNode, GraphLink>;
908
+ linkForce.links(validLinks);
909
+ } catch (e) {
910
+ console.error("D3 forceLink initialization failed:", e);
911
+ }
912
+
913
+ const hasStructureChanged = nodes.length !== prevNodesLen.current || validLinks.length !== prevLinksLen.current;
914
+ if (hasStructureChanged) {
915
+ // Use lower alpha to prevent jarring movements when nodes are added during expansion
916
+ // Only restart if simulation is not already active (alpha > 0.01)
917
+ const currentAlpha = simulation.alpha();
918
+ if (currentAlpha < 0.01) {
919
+ simulation.alpha(0.1).restart(); // Lower alpha to reduce spinning (reduced from 0.15)
920
+ } else {
921
+ // Just increase alpha slightly if already running, don't fully restart
922
+ simulation.alpha(Math.min(currentAlpha + 0.03, 0.3)); // Reduced max alpha from 0.5 to 0.3
923
+ }
924
+ }
925
+
926
+ prevNodesLen.current = nodes.length;
927
+ prevLinksLen.current = validLinks.length;
928
+
929
+ // Timeline axis setup
930
+ let axisGroup = container.select<SVGGElement>(".timeline-axis");
931
+ if (axisGroup.empty()) {
932
+ axisGroup = container.insert("g", ":first-child").attr("class", "timeline-axis");
933
+ axisGroup.append("line")
934
+ .attr("stroke", "#64748b").attr("stroke-width", 1).attr("stroke-dasharray", "5,5");
935
+ }
936
+
937
+ simulation.on("tick", () => {
938
+ const linkPath = (d: GraphLink) => {
939
+ const source = d.source as GraphNode;
940
+ const target = d.target as GraphNode;
941
+
942
+ if (!source || !target || typeof source !== 'object' || typeof target !== 'object') {
943
+ // Diagnostic log for disconnected links
944
+ if (prevNodesLen.current > 0) {
945
+ console.warn(`🔗 [LinkPath] Disconnected link detected: ID=${d.id}, source=${typeof d.source}, target=${typeof d.target}`);
946
+ }
947
+ return null;
948
+ }
949
+
950
+ const fixedS = timelinePositionsRef.current.get(source.id);
951
+ const fixedT = timelinePositionsRef.current.get(target.id);
952
+ const sx = (fixedS?.x ?? source.fx ?? source.x) || 0;
953
+ const sy = (fixedS?.y ?? source.fy ?? source.y) || 0;
954
+ const tx = (fixedT?.x ?? target.fx ?? target.x) || 0;
955
+ const ty = (fixedT?.y ?? target.fy ?? target.y) || 0;
956
+ const dist = Math.sqrt((tx - sx) ** 2 + (ty - sy) ** 2);
957
+ const midX = (sx + tx) / 2, midY = (sy + ty) / 2 + dist * 0.15;
958
+ return `M${sx},${sy} Q${midX},${midY} ${tx},${ty}`;
959
+ };
960
+
961
+ container.selectAll<SVGPathElement, GraphLink>(".link").attr("d", linkPath);
962
+
963
+ container.selectAll<SVGGElement, GraphNode>(".node").attr("transform", d => {
964
+ const fixed = timelinePositionsRef.current.get(d.id);
965
+ const x = (fixed?.x ?? d.fx ?? d.x) || 0;
966
+ const y = (fixed?.y ?? d.fy ?? d.y) || 0;
967
+ return `translate(${x},${y})`;
968
+ });
969
+
970
+ if (isTimelineMode) {
971
+ axisGroup.style("display", "block");
972
+ axisGroup.select("line").attr("x1", -width * 4).attr("y1", height / 2).attr("x2", width * 4).attr("y2", height / 2);
973
+ } else {
974
+ axisGroup.style("display", "none");
975
+ }
976
+ });
977
+ }, [nodes, links, isTimelineMode, width, height]);
978
+
979
+ // 5. Stylistic Effect: Update colors, opacity, labels without restarting simulation
980
+ useEffect(() => {
981
+ if (!zoomGroupRef.current) return;
982
+ const container = d3.select(zoomGroupRef.current);
983
+
984
+ const keepHighlight = new Set(highlightKeepIds || []);
985
+ const dropHighlight = new Set(highlightDropIds || []);
986
+ const hasHighlight = keepHighlight.size > 0 || dropHighlight.size > 0;
987
+
988
+ // Build set of path links (links between consecutive nodes in the path)
989
+ // IMPORTANT: Only highlight links that actually exist and are part of the path sequence
990
+ const pathLinkIds = new Set<string>();
991
+ if (hasHighlight && highlightKeepIds && highlightKeepIds.length > 1) {
992
+ // For each consecutive pair in the path, check if a link exists
993
+ for (let i = 0; i < highlightKeepIds.length - 1; i++) {
994
+ const nodeId1 = highlightKeepIds[i];
995
+ const nodeId2 = highlightKeepIds[i + 1];
996
+ // Find the actual link ID in the links array
997
+ const link = links.find(l => {
998
+ const sId = typeof l.source === 'object' ? (l.source as GraphNode).id : l.source;
999
+ const tId = typeof l.target === 'object' ? (l.target as GraphNode).id : l.target;
1000
+ return (sId === nodeId1 && tId === nodeId2) || (sId === nodeId2 && tId === nodeId1);
1001
+ });
1002
+ if (link) {
1003
+ pathLinkIds.add(link.id);
1004
+ console.log(`Path link found: ${nodeId1} <-> ${nodeId2} (link ID: ${link.id})`);
1005
+ } else {
1006
+ console.log(`Path link NOT found: ${nodeId1} <-> ${nodeId2} - will not highlight`);
1007
+ }
1008
+ }
1009
+ console.log(`Path link IDs to highlight:`, Array.from(pathLinkIds));
1010
+ }
1011
+
1012
+ // Pre-calculate neighbor set for the focused node to make the loop more efficient and robust
1013
+ const neighborIds = new Set<string | number>();
1014
+ if (effectiveFocused) {
1015
+ links.forEach(l => {
1016
+ const sId = typeof l.source === 'object' ? (l.source as GraphNode).id : l.source;
1017
+ const tId = typeof l.target === 'object' ? (l.target as GraphNode).id : l.target;
1018
+ if (sId === effectiveFocused.id) neighborIds.add(tId);
1019
+ else if (tId === effectiveFocused.id) neighborIds.add(sId);
1020
+ });
1021
+ }
1022
+
1023
+ const allNodes = container.selectAll<SVGGElement, GraphNode>(".node");
1024
+ const allLinks = container.selectAll<SVGPathElement, GraphLink>(".link");
1025
+
1026
+ // Build map of event to connected people for timeline mode
1027
+ const eventToPeople = new Map<number, string[]>();
1028
+ if (isTimelineMode) {
1029
+ links.forEach(l => {
1030
+ const sId = typeof l.source === 'object' ? (l.source as GraphNode).id : l.source;
1031
+ const tId = typeof l.target === 'object' ? (l.target as GraphNode).id : l.target;
1032
+
1033
+ // Use loose comparison or string normalization for IDs
1034
+ const sourceNode = nodes.find(n => String(n.id) === String(sId));
1035
+ const targetNode = nodes.find(n => String(n.id) === String(tId));
1036
+
1037
+ // console.log(`[Timeline Scan Debug] Link ${sId} -> ${tId}. Found Source? ${!!sourceNode} (${sourceNode?.title}), Found Target? ${!!targetNode} (${targetNode?.title})`);
1038
+
1039
+ if (sourceNode && targetNode) {
1040
+ const isSourceAtomic = isAtomicNode(sourceNode);
1041
+ const isTargetAtomic = isAtomicNode(targetNode);
1042
+
1043
+
1044
+
1045
+ if (isSourceAtomic && !isTargetAtomic) {
1046
+ const atomics = eventToPeople.get(targetNode.id) || [];
1047
+ if (!atomics.includes(sourceNode.title)) {
1048
+ atomics.push(sourceNode.title);
1049
+ eventToPeople.set(targetNode.id, atomics);
1050
+ }
1051
+ }
1052
+ else if (isTargetAtomic && !isSourceAtomic) {
1053
+ const atomics = eventToPeople.get(sourceNode.id) || [];
1054
+ if (!atomics.includes(targetNode.title)) {
1055
+ atomics.push(targetNode.title);
1056
+ eventToPeople.set(sourceNode.id, atomics);
1057
+ }
1058
+ }
1059
+ }
1060
+ });
1061
+ }
1062
+
1063
+ allNodes.each(function (d) {
1064
+ const g = d3.select(this);
1065
+
1066
+ // Show all nodes (people are now visible in timeline mode)
1067
+ g.style("display", null);
1068
+
1069
+ const dims = getNodeDimensions(d, isTimelineMode, isTextOnly);
1070
+ const isHovered = d.id === hoveredNode?.id;
1071
+ // NOTE: Dynamic opacity/stroke logic moved to Stylistic Effect (Effect 5) to handle interaction updates correctly.
1072
+ // Effect 4 only sets default structural attributes.
1073
+ let color = getNodeColor(d.type, d.is_person);
1074
+
1075
+ // Default initial styles (will be overridden by Effect 5 immediately)
1076
+ const baseOpacity = 1;
1077
+ g.style("opacity", d.isLoading ? 1 : baseOpacity);
1078
+
1079
+ const strokeColor = "#fff";
1080
+ const strokeWidth = 2;
1081
+
1082
+ if (d.imageChecked && !d.imageUrl) color = '#64748b';
1083
+
1084
+ g.select(".node-circle").style("display", "none");
1085
+ g.select(".node-rect").style("display", "none");
1086
+ g.select(".node-desc").style("display", "none").attr("clip-path", null);
1087
+ g.select(".people-label").style("display", "none").attr("clip-path", null);
1088
+ g.select(".spinner-group").style("display", "none");
1089
+
1090
+ if (dims.type === 'circle') {
1091
+ // Hide card-content for circle nodes
1092
+ g.select(".card-content").style("display", "none");
1093
+ const r = dims.w / 2;
1094
+ g.select(".node-circle").style("display", "block").attr("r", r).attr("fill", color).attr("stroke", strokeColor).attr("stroke-width", strokeWidth);
1095
+ g.select("image")
1096
+ .style("display", (d.imageUrl && !isTextOnly) ? "block" : "none")
1097
+ .attr("href", d.imageUrl || "")
1098
+ .attr("x", -r)
1099
+ .attr("y", -r)
1100
+ .attr("width", r * 2)
1101
+ .attr("height", r * 2)
1102
+ .attr("preserveAspectRatio", "xMidYMid slice")
1103
+ .attr("clip-path", `url(#clip-circle-${safeId(d.id)})`);
1104
+ g.select(`#clip-circle-${safeId(d.id)}`).select("circle").attr("r", r);
1105
+
1106
+ const labelText = g.select(".node-label").style("display", "block").text(null).attr("y", r + 15);
1107
+ wrapText(d.title, 90).forEach((line, i) => labelText.append("tspan").attr("x", 0).attr("dy", i === 0 ? 0 : "1.2em").style("font-size", "10px").text(line));
1108
+ const isPerson = d.is_atomic === true || d.is_person === true || d.type?.toLowerCase() === 'person';
1109
+ const isEventWithYear = !isPerson && d.year;
1110
+ g.select(".year-label").text(d.year || "").attr("y", -r - 10).style("display", (isTimelineMode || isHovered || isEventWithYear) && d.year ? "block" : "none");
1111
+
1112
+ } else {
1113
+ const w = dims.w, h = dims.h;
1114
+ g.select(".node-rect").style("display", "block").attr("width", w).attr("height", h).attr("x", -w / 2).attr("y", -h / 2).attr("fill", color).attr("stroke", strokeColor).attr("stroke-width", strokeWidth);
1115
+
1116
+ if (dims.type === 'box' && d.imageUrl && !isTextOnly) {
1117
+ g.select("image")
1118
+ .style("display", "block")
1119
+ .attr("href", d.imageUrl)
1120
+ .attr("x", -w / 2)
1121
+ .attr("y", -h / 2)
1122
+ .attr("width", w)
1123
+ .attr("height", h)
1124
+ .attr("preserveAspectRatio", "xMidYMid meet")
1125
+ .attr("clip-path", `url(#clip-rect-${safeId(d.id)})`);
1126
+ g.select(`#clip-rect-${safeId(d.id)}`).select("rect").attr("x", -w / 2).attr("y", -h / 2).attr("width", w).attr("height", h);
1127
+ } else {
1128
+ g.select("image").style("display", "none");
1129
+ }
1130
+
1131
+ let textY = (dims.type === 'card') ? 0 : (dims.type === 'box' ? 45 : 4);
1132
+ if (dims.type === 'card') {
1133
+ const cardWidth = w;
1134
+ const padding = 15;
1135
+ const imgH = (d.imageUrl && !isTextOnly) ? 140 : 0;
1136
+ const imgSpacing = imgH > 0 ? 12 : 0;
1137
+
1138
+ // Check if we need space for people names in timeline mode
1139
+ const connectedPeople = isTimelineMode ? (eventToPeople.get(d.id) || []) : [];
1140
+ const hasPeople = connectedPeople.length > 0;
1141
+
1142
+
1143
+
1144
+ const peopleText = hasPeople ? connectedPeople.join(", ") : "";
1145
+ const contentWidth = cardWidth - padding * 2;
1146
+
1147
+ // Truncate description to first sentence
1148
+ let displayDescription = "";
1149
+ if (d.description) {
1150
+ // Find first sentence ending (period, exclamation, question mark followed by space or end)
1151
+ // Uses negative lookbehind to avoid splitting on initials or common abbreviations
1152
+ const sentenceMatch = d.description.match(/^.*?(?<!\b(?:Mr|Ms|Mrs|Dr|Prof|St|v|vs|etc|[A-Z]))[.!?](?:\s+|$)/);
1153
+ if (sentenceMatch) {
1154
+ displayDescription = sentenceMatch[0].trim();
1155
+ } else {
1156
+ // If no sentence ending found, take first 150 characters
1157
+ displayDescription = d.description.substring(0, 150).trim();
1158
+ }
1159
+ }
1160
+
1161
+ // Create HTML content with everything (image and text) - browser will size it naturally
1162
+ // Text is white (#ffffff) which will be visible on the blue card background from .node-rect
1163
+ const htmlContent = `
1164
+ <div xmlns="http://www.w3.org/1999/xhtml" style="
1165
+ width: ${contentWidth}px;
1166
+ padding: ${padding}px;
1167
+ box-sizing: border-box;
1168
+ color: #ffffff;
1169
+ font-family: sans-serif;
1170
+ background: transparent;
1171
+ ">
1172
+ ${imgH > 0 ? `<img src="${d.imageUrl}" style="width: 100%; height: ${imgH}px; object-fit: contain; display: block; margin-bottom: ${imgSpacing}px;" />` : ''}
1173
+ <div style="font-size: 13px; font-weight: bold; margin-bottom: 8px; line-height: 1.4; word-wrap: break-word; color: #ffffff; display: flex; align-items: flex-start; justify-content: space-between; gap: 8px;">
1174
+ <span>${escapeHtml(d.title)}</span>
1175
+ <a href="${buildWikiUrl(d.title, d.wikipedia_id)}" target="_blank" style="color: #6366f1; flex-shrink: 0; display: flex; align-items: center; margin-top: 1px;" onclick="event.stopPropagation();">
1176
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg>
1177
+ </a>
1178
+ </div>
1179
+ ${displayDescription ? `<div style="font-size: 11px; margin-bottom: 8px; line-height: 1.4; word-wrap: break-word; color: #cbd5e1;">${escapeHtml(displayDescription)}</div>` : ''}
1180
+ ${hasPeople ? `<div style="font-size: 12px; color: #ffffff; font-weight: 600; line-height: 1.4; word-wrap: break-word; border-top: 1px solid rgba(255,255,255,0.2); padding-top: 8px; text-transform: capitalize;">${escapeHtml(peopleText)}</div>` : ''}
1181
+ </div>
1182
+ `;
1183
+
1184
+ // Use foreignObject for automatic HTML layout and sizing
1185
+ const cardContent = g.select(".card-content");
1186
+
1187
+ // Set initial size (will be measured and adjusted)
1188
+ const initialHeight = 200;
1189
+ cardContent
1190
+ .style("display", "block")
1191
+ .attr("x", -cardWidth / 2)
1192
+ .attr("y", -initialHeight / 2)
1193
+ .attr("width", cardWidth)
1194
+ .attr("height", initialHeight * 2)
1195
+ .html(htmlContent);
1196
+
1197
+ // Hide SVG image and text elements (using HTML instead)
1198
+ g.select("image").style("display", "none");
1199
+ g.select(".node-label").style("display", "none");
1200
+ g.select(".node-desc").style("display", "none");
1201
+ g.select(".people-label").style("display", "none");
1202
+
1203
+ // Set initial card size (will be refined after measurement)
1204
+ g.select(".node-rect")
1205
+ .attr("width", cardWidth)
1206
+ .attr("height", initialHeight)
1207
+ .attr("x", -cardWidth / 2)
1208
+ .attr("y", -initialHeight / 2);
1209
+
1210
+ // Update year label - always show in timeline mode if year exists
1211
+ const yearLabel = g.select(".year-label");
1212
+ yearLabel.text(d.year || "");
1213
+ yearLabel.attr("y", -initialHeight / 2 - 10);
1214
+ yearLabel.style("display", (isTimelineMode && d.year) ? "block" : ((isHovered && d.year) ? "block" : "none"));
1215
+
1216
+ // Set initial height for collision (will be updated after measurement)
1217
+ d.h = initialHeight;
1218
+ } else {
1219
+ // Hide card-content for non-card nodes
1220
+ g.select(".card-content").style("display", "none");
1221
+ g.select(".people-label").style("display", "none");
1222
+ // Show and update node-label for box mode
1223
+ const labelText = g.select(".node-label").style("display", "block").text(null).attr("y", textY);
1224
+ wrapText(d.title, dims.type === 'box' ? 100 : 200).forEach((line, i) => labelText.append("tspan").attr("x", 0).attr("dy", i === 0 ? 0 : "1.2em").style("font-size", dims.type === 'card' ? "13px" : "10px").style("font-weight", dims.type === 'card' ? "bold" : "normal").text(line));
1225
+ }
1226
+
1227
+ const isPerson = d.is_atomic === true || d.is_person === true || d.type?.toLowerCase() === 'person';
1228
+ const isEventWithYear = !isPerson && d.year;
1229
+ g.select(".year-label").text(d.year || "").attr("y", -h / 2 - 10).style("display", (isTimelineMode || isHovered || isEventWithYear) && d.year ? "block" : "none");
1230
+ }
1231
+ g.select(".spinner-group").style("display", d.isLoading ? "block" : "none")
1232
+ .select(".spinner").attr("r", (dims.type === 'circle' || dims.type === 'box') ? (dims.w / 2) + 8 : (dims.h / 2) + 10);
1233
+
1234
+ g.on("click", (event) => {
1235
+ if (event.defaultPrevented) return;
1236
+ event.stopPropagation();
1237
+ onNodeClick(d, event as MouseEvent);
1238
+ setFocusedNode(null);
1239
+ })
1240
+ .on("mouseover", () => setHoveredNode(d))
1241
+ .on("mouseout", () => setHoveredNode(null));
1242
+ });
1243
+
1244
+ // Batch measure all card heights after browser renders (using requestAnimationFrame)
1245
+ if (isTimelineMode) {
1246
+ requestAnimationFrame(() => {
1247
+ let hasChanges = false;
1248
+ allNodes.each(function (d) {
1249
+ if (isAtomicNode(d)) return; // Skip people nodes
1250
+ const g = d3.select(this);
1251
+ const cardContent = g.select(".card-content");
1252
+ if (cardContent.empty()) return;
1253
+
1254
+ const foreignObj = cardContent.node() as SVGForeignObjectElement | null;
1255
+ if (foreignObj && foreignObj.firstElementChild) {
1256
+ const div = foreignObj.firstElementChild as HTMLElement;
1257
+ const actualHeight = div.offsetHeight || div.scrollHeight;
1258
+ const cardHeight = actualHeight;
1259
+ const cardWidth = DEFAULT_CARD_SIZE; // Fixed width from getNodeDimensions
1260
+
1261
+ // Only update if height changed
1262
+ if (d.h !== cardHeight) {
1263
+ hasChanges = true;
1264
+
1265
+ // Update foreignObject position to center vertically
1266
+ cardContent.attr("y", -cardHeight / 2);
1267
+
1268
+ // Update card rectangle
1269
+ g.select(".node-rect")
1270
+ .attr("width", cardWidth)
1271
+ .attr("height", cardHeight)
1272
+ .attr("x", -cardWidth / 2)
1273
+ .attr("y", -cardHeight / 2);
1274
+
1275
+ // Update node dimensions for collision detection
1276
+ d.h = cardHeight;
1277
+ }
1278
+
1279
+ // Always update year label position and ensure it's visible in timeline mode
1280
+ const yearLabel = g.select(".year-label");
1281
+ yearLabel.text(d.year || "");
1282
+ yearLabel.attr("y", -cardHeight / 2 - 10);
1283
+ yearLabel.style("display", d.year ? "block" : "none");
1284
+ }
1285
+ });
1286
+
1287
+ // After measuring card heights, trigger re-positioning of people nodes
1288
+ // The timeline mode effect will re-run because nodes have changed (d.h updated)
1289
+ // and it will position people using actual measured heights
1290
+ if (hasChanges) {
1291
+ if (isTimelineMode) {
1292
+ setTimelineLayoutVersion(v => v + 1);
1293
+ }
1294
+ if (simulationRef.current) {
1295
+ // Force effect to re-run by restarting simulation with updated node data
1296
+ setTimeout(() => {
1297
+ if (simulationRef.current) {
1298
+ // Use lower alpha to prevent jarring movements and spinning
1299
+ const currentAlpha = simulationRef.current.alpha();
1300
+ if (currentAlpha < 0.01) {
1301
+ simulationRef.current.alpha(0.1).restart(); // Lower alpha to reduce spinning
1302
+ } else {
1303
+ simulationRef.current.alpha(Math.min(currentAlpha + 0.03, 0.3)); // Reduced max alpha
1304
+ }
1305
+ }
1306
+ }, 50);
1307
+ }
1308
+ }
1309
+ });
1310
+ }
1311
+
1312
+ // Explicit return void to avoid implicit return of simulation object if that was happening
1313
+ return;
1314
+ }, [nodes, links, isTimelineMode, width, height]);
1315
+
1316
+ // 5. Stylistic Effect: Visual updates (colors, opacity, stroke) based on hover/interaction
1317
+ useEffect(() => {
1318
+ if (!zoomGroupRef.current) return;
1319
+
1320
+ const keepHighlight = new Set((highlightKeepIds || []).map(String));
1321
+ const dropHighlight = new Set((highlightDropIds || []).map(String));
1322
+ const hasHighlight = keepHighlight.size > 0 || dropHighlight.size > 0;
1323
+
1324
+ // Build set of path links
1325
+ const pathLinkIds = new Set<string>();
1326
+ if (hasHighlight && highlightKeepIds && highlightKeepIds.length > 1) {
1327
+ for (let i = 0; i < highlightKeepIds.length - 1; i++) {
1328
+ const nodeId1 = String(highlightKeepIds[i]);
1329
+ const nodeId2 = String(highlightKeepIds[i + 1]);
1330
+ const link = links.find(l => {
1331
+ const sId = String(typeof l.source === 'object' ? (l.source as GraphNode).id : l.source);
1332
+ const tId = String(typeof l.target === 'object' ? (l.target as GraphNode).id : l.target);
1333
+ return (sId === nodeId1 && tId === nodeId2) || (sId === nodeId2 && tId === nodeId1);
1334
+ });
1335
+ if (link) pathLinkIds.add(String(link.id));
1336
+ }
1337
+ }
1338
+
1339
+ const neighborIds = new Set<string | number>();
1340
+ if (effectiveFocused) {
1341
+ links.forEach(l => {
1342
+ const sId = typeof l.source === 'object' ? (l.source as GraphNode).id : l.source;
1343
+ const tId = typeof l.target === 'object' ? (l.target as GraphNode).id : l.target;
1344
+ if (sId === effectiveFocused.id) neighborIds.add(tId);
1345
+ else if (tId === effectiveFocused.id) neighborIds.add(sId);
1346
+ });
1347
+ }
1348
+ const container = d3.select(zoomGroupRef.current);
1349
+ const allLinks = container.selectAll<SVGPathElement, GraphLink>(".link");
1350
+ const allNodes = container.selectAll<SVGGElement, GraphNode>(".node");
1351
+
1352
+ allNodes.each(function (d) {
1353
+ const g = d3.select(this);
1354
+ const isHovered = d.id === hoveredNode?.id;
1355
+ const isFocused = d.id === effectiveFocused?.id;
1356
+ const isDrop = dropHighlight.has(String(d.id));
1357
+ const isKeep = keepHighlight.has(String(d.id));
1358
+
1359
+ let baseOpacity = 1;
1360
+ if (isDrop) {
1361
+ baseOpacity = 0.18;
1362
+ } else if (hasHighlight) {
1363
+ baseOpacity = isKeep ? 1 : 0.3;
1364
+ } else {
1365
+ if (expandingNodeId !== null) {
1366
+ const isExpanding = String(expandingNodeId) === String(d.id);
1367
+ const isNewChild = newChildNodeIds.has(String(d.id));
1368
+ if (!isExpanding && !isNewChild) baseOpacity = 0.25;
1369
+ } else if (effectiveFocused) {
1370
+ const isNewChild = newChildNodeIds.has(String(d.id));
1371
+ const isFocused = String(d.id) === String(effectiveFocused.id);
1372
+ // Use neighborIds (which are potentially mixed types) carefully by stringifying
1373
+ // We need to check if neighborIds HAS d.id
1374
+ const isNeighbor = Array.from(neighborIds).some(nid => String(nid) === String(d.id));
1375
+
1376
+ if (!isFocused && !isNeighbor && !isNewChild) baseOpacity = 0.25;
1377
+ }
1378
+ }
1379
+ g.style("opacity", d.isLoading ? 1 : baseOpacity);
1380
+
1381
+ const isPathHighlight = hasHighlight && dropHighlight.size === 0;
1382
+ const strokeColor = isDrop
1383
+ ? "#f87171"
1384
+ : (isKeep && hasHighlight
1385
+ ? (isPathHighlight ? "#f59e0b" : "#22c55e")
1386
+ : (isHovered || isFocused ? "#f59e0b" : "#fff"));
1387
+ const strokeWidth = isDrop ? 3.5 : (isKeep && hasHighlight ? (isPathHighlight ? 3.5 : 2.5) : (isFocused ? 3 : 2));
1388
+
1389
+ g.select(".node-circle").style("stroke", strokeColor).style("stroke-width", strokeWidth);
1390
+ g.select(".node-rect").style("stroke", strokeColor).style("stroke-width", strokeWidth);
1391
+
1392
+ // Update image visibility based on isTextOnly prop
1393
+ const dims = getNodeDimensions(d, isTimelineMode, isTextOnly);
1394
+ if (dims.type === 'circle') {
1395
+ g.select("image").style("display", (d.imageUrl && !isTextOnly) ? "block" : "none");
1396
+ } else if (dims.type === 'box') {
1397
+ g.select("image").style("display", (d.imageUrl && !isTextOnly) ? "block" : "none");
1398
+ }
1399
+
1400
+
1401
+ // Ensure correct year label visibility on hover
1402
+ const isPerson = d.is_atomic === true || d.is_person === true || d.type?.toLowerCase() === 'person';
1403
+ const isEventWithYear = !isPerson && d.year;
1404
+ const showYear = (isTimelineMode || isHovered || isEventWithYear) && !!d.year;
1405
+ g.select(".year-label").style("display", showYear ? "block" : "none");
1406
+ });
1407
+
1408
+ // Background click to deselect
1409
+ d3.select(svgRef.current).on("click", (event) => {
1410
+ if (event.target === svgRef.current) {
1411
+ onNodeClick(null);
1412
+ setFocusedNode(null);
1413
+ }
1414
+ });
1415
+
1416
+ // In timeline mode, show links only for selected node, otherwise hide them
1417
+ if (isTimelineMode) {
1418
+ allLinks.style("display", d => {
1419
+ if (!effectiveFocused) return "none";
1420
+ const sId = typeof d.source === 'object' ? (d.source as GraphNode).id : d.source;
1421
+ const tId = typeof d.target === 'object' ? (d.target as GraphNode).id : d.target;
1422
+ // Show link if it connects to the selected node
1423
+ return (sId === effectiveFocused.id || tId === effectiveFocused.id) ? null : "none";
1424
+ }).style("stroke-opacity", d => {
1425
+ if (!effectiveFocused) return 0;
1426
+ const sId = typeof d.source === 'object' ? (d.source as GraphNode).id : d.source;
1427
+ const tId = typeof d.target === 'object' ? (d.target as GraphNode).id : d.target;
1428
+ return (sId === effectiveFocused.id || tId === effectiveFocused.id) ? 0.9 : 0;
1429
+ });
1430
+ }
1431
+ allLinks.style("stroke", "#dc2626").style("stroke-width", 3.5);
1432
+ if (!isTimelineMode) {
1433
+ allLinks.style("display", null)
1434
+ .style("stroke-opacity", d => {
1435
+ const sId = String(typeof d.source === 'object' ? (d.source as GraphNode).id : d.source);
1436
+ const tId = String(typeof d.target === 'object' ? (d.target as GraphNode).id : d.target);
1437
+
1438
+ if (dropHighlight.has(sId) || dropHighlight.has(tId)) return 0.12;
1439
+
1440
+ if (hasHighlight) {
1441
+ const inPath = keepHighlight.has(sId) && keepHighlight.has(tId);
1442
+ if (inPath) return 0.95;
1443
+ return 0.3; // Dim everything else when path is active
1444
+ }
1445
+
1446
+ // Expansion/Selection highlighting
1447
+ const isNewSource = newChildNodeIds.has(String(sId)) || newChildNodeIds.has(sId);
1448
+ const isNewTarget = newChildNodeIds.has(String(tId)) || newChildNodeIds.has(tId);
1449
+
1450
+ if (expandingNodeId !== null) {
1451
+ const sourceBright = String(sId) === String(expandingNodeId) || isNewSource;
1452
+ const targetBright = String(tId) === String(expandingNodeId) || isNewTarget;
1453
+ if (sourceBright && targetBright) return 0.95;
1454
+ if (sourceBright || targetBright) return 0.5;
1455
+ return 0.25;
1456
+ } else if (effectiveFocused) {
1457
+ // neighborIds check needs string normalization too
1458
+ const sIsNeighbor = Array.from(neighborIds).some(nid => String(nid) === String(sId));
1459
+ const tIsNeighbor = Array.from(neighborIds).some(nid => String(nid) === String(tId));
1460
+
1461
+ const sourceBright = String(sId) === String(effectiveFocused.id) || sIsNeighbor;
1462
+ const targetBright = String(tId) === String(effectiveFocused.id) || tIsNeighbor;
1463
+ if (sourceBright && targetBright) return 0.95;
1464
+ if (sourceBright || targetBright) return 0.5;
1465
+ return 0.25;
1466
+ }
1467
+
1468
+ if (isNewSource && isNewTarget) return 0.95;
1469
+ if (isNewSource || isNewTarget) return 0.6;
1470
+ if (hoveredLinkId && d.id === hoveredLinkId) return 1;
1471
+ return 0.85;
1472
+ })
1473
+ .style("stroke", d => {
1474
+ const sId = String(typeof d.source === 'object' ? (d.source as GraphNode).id : d.source);
1475
+ const tId = String(typeof d.target === 'object' ? (d.target as GraphNode).id : d.target);
1476
+
1477
+ if (dropHighlight.has(sId) || dropHighlight.has(tId)) return "#f87171";
1478
+
1479
+ if (hasHighlight) {
1480
+ const inPath = pathLinkIds.has(String(d.id));
1481
+ if (inPath) return "#f59e0b"; // Highlight any link between path nodes
1482
+ return "#94a3b8"; // Blue-grey for external noise when path is active
1483
+ }
1484
+
1485
+ // Hover highlight for links
1486
+ if (hoveredLinkId && d.id === hoveredLinkId) return "#fbbf24";
1487
+
1488
+ // Priority: Focused node highlighting
1489
+ if (effectiveFocused && (sId === effectiveFocused.id || tId === effectiveFocused.id)) return "#f97316";
1490
+
1491
+ // Priority: New connections highlighting
1492
+ const isNewSource = newChildNodeIds.has(String(sId)) || newChildNodeIds.has(sId);
1493
+ const isNewTarget = newChildNodeIds.has(String(tId)) || newChildNodeIds.has(tId);
1494
+ if (isNewSource || isNewTarget) return "#ef4444"; // brighter red for new connections
1495
+
1496
+ return "#dc2626";
1497
+ })
1498
+ .style("stroke-width", d => {
1499
+ const sId = typeof d.source === 'object' ? (d.source as GraphNode).id : d.source as string;
1500
+ const tId = typeof d.target === 'object' ? (d.target as GraphNode).id : d.target as string;
1501
+ // Hover highlight for links
1502
+ if (hoveredLinkId && d.id === hoveredLinkId) return 6;
1503
+ // Make path links thicker
1504
+ const inPath = keepHighlight.has(sId) && keepHighlight.has(tId);
1505
+ if (hasHighlight && inPath) return 4;
1506
+ return 2;
1507
+ });
1508
+ }
1509
+
1510
+ }, [nodes, links, isTimelineMode, hoveredNode, hoveredLinkId, effectiveFocused, highlightKeepIds, highlightDropIds, isTextOnly, onNodeClick, expandingNodeId, newChildNodeIds]);
1511
+
1512
+ return (
1513
+ <svg
1514
+ ref={svgRef}
1515
+ width={width}
1516
+ height={height}
1517
+ className="cursor-move bg-slate-900"
1518
+ onClick={() => { setHoveredNode(null); setFocusedNode(null); }}
1519
+ >
1520
+ <g ref={zoomGroupRef} />
1521
+ </svg>
1522
+ );
1523
+ });
1524
+
1525
+ export default Graph;