@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.
- package/App.tsx +480 -0
- package/FullPageConstellations.tsx +74 -0
- package/FullPageConstellationsHostShell.tsx +27 -0
- package/README.md +116 -0
- package/components/AppConfirmDialog.tsx +46 -0
- package/components/AppHeader.tsx +73 -0
- package/components/AppNotifications.tsx +21 -0
- package/components/BrowsePeople.tsx +832 -0
- package/components/ControlPanel.tsx +1023 -0
- package/components/Graph.tsx +1525 -0
- package/components/HelpOverlay.tsx +168 -0
- package/components/NodeContextMenu.tsx +160 -0
- package/components/PeopleBrowserSidebar.tsx +690 -0
- package/components/Sidebar.tsx +271 -0
- package/components/TimelineView.tsx +4 -0
- package/hooks/useExpansion.ts +889 -0
- package/hooks/useGraphActions.ts +325 -0
- package/hooks/useGraphState.ts +414 -0
- package/hooks/useKioskMode.ts +47 -0
- package/hooks/useNodeClickHandler.ts +172 -0
- package/hooks/useSearchHandlers.ts +369 -0
- package/host.ts +16 -0
- package/index.css +101 -0
- package/index.tsx +16 -0
- package/kioskDomains.ts +307 -0
- package/package.json +78 -0
- package/services/aiUtils.ts +364 -0
- package/services/cacheService.ts +76 -0
- package/services/crossrefService.ts +107 -0
- package/services/geminiService.ts +1359 -0
- package/services/get-local-graphs.js +5 -0
- package/services/graphUtils.ts +347 -0
- package/services/imageService.ts +39 -0
- package/services/llmClient.ts +194 -0
- package/services/openAlexService.ts +173 -0
- package/services/wikipediaImage.ts +40 -0
- package/services/wikipediaService.ts +1175 -0
- package/sessionHandoff.ts +132 -0
- package/types.ts +99 -0
- package/useFullPageConstellationsHost.ts +116 -0
- package/utils/evidenceUtils.ts +107 -0
- package/utils/graphLogicUtils.ts +32 -0
- package/utils/graphNodeToChannelNotes.ts +71 -0
- 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;
|