@johndimm/constellations 1.0.1 → 1.0.3

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 (40) hide show
  1. package/App.tsx +360 -66
  2. package/FullPageConstellations.tsx +7 -4
  3. package/components/AppConfirmDialog.tsx +1 -0
  4. package/components/AppHeader.tsx +67 -30
  5. package/components/AppNotifications.tsx +1 -0
  6. package/components/BrowsePeople.tsx +3 -0
  7. package/components/ControlPanel.tsx +229 -250
  8. package/components/Graph.tsx +251 -87
  9. package/components/HelpOverlay.tsx +2 -1
  10. package/components/NodeContextMenu.tsx +123 -3
  11. package/components/PeopleBrowserSidebar.tsx +15 -6
  12. package/components/Sidebar.tsx +46 -19
  13. package/components/TimelineView.tsx +1 -0
  14. package/hooks/useExpansion.ts +85 -230
  15. package/hooks/useGraphActions.ts +1 -0
  16. package/hooks/useGraphState.ts +75 -40
  17. package/hooks/useKioskMode.ts +1 -0
  18. package/hooks/useNodeClickHandler.ts +23 -15
  19. package/hooks/useSearchHandlers.ts +60 -21
  20. package/host.ts +1 -1
  21. package/index.css +17 -3
  22. package/index.tsx +5 -3
  23. package/package.json +4 -2
  24. package/services/aiService.ts +27 -0
  25. package/services/aiUtils.ts +285 -195
  26. package/services/cacheService.ts +1 -0
  27. package/services/crossrefService.ts +1 -0
  28. package/services/deepseekService.ts +479 -0
  29. package/services/geminiService.ts +543 -736
  30. package/services/graphUtils.ts +128 -18
  31. package/services/imageService.ts +18 -0
  32. package/services/openAlexService.ts +1 -0
  33. package/services/resolveImageForTitle.ts +458 -0
  34. package/services/wikipediaImage.ts +1 -0
  35. package/services/wikipediaService.ts +79 -49
  36. package/sessionHandoff.ts +26 -0
  37. package/types.ts +3 -0
  38. package/utils/evidenceUtils.ts +1 -0
  39. package/utils/graphLogicUtils.ts +1 -0
  40. package/utils/wikiUtils.ts +14 -2
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  import React, { useEffect, useRef, useState, forwardRef, useImperativeHandle, useCallback, useMemo } from 'react';
2
3
  import * as d3 from 'd3';
3
4
  import { GraphNode, GraphLink } from '../types';
@@ -6,7 +7,7 @@ import { buildWikiUrl } from '../utils/wikiUtils';
6
7
  interface GraphProps {
7
8
  nodes: GraphNode[];
8
9
  links: GraphLink[];
9
- onNodeClick: (node: GraphNode, event?: MouseEvent) => void;
10
+ onNodeClick: (node: GraphNode | null, event?: MouseEvent) => void;
10
11
  onLinkClick?: (link: GraphLink) => void;
11
12
  onViewportChange?: (visibleNodes: GraphNode[]) => void;
12
13
  width: number;
@@ -24,7 +25,9 @@ interface GraphProps {
24
25
  }
25
26
 
26
27
  export interface GraphHandle {
27
- centerOnNode: (nodeId: number, scale?: number) => void;
28
+ centerOnNode: (nodeId: string | number, scale?: number) => void;
29
+ /** Pans and zooms so all nodes (with padding) fit in the graph viewport. */
30
+ fitGraphInView: () => void;
28
31
  }
29
32
 
30
33
  const DEFAULT_CARD_SIZE = 220;
@@ -32,6 +35,32 @@ const DEFAULT_CARD_SIZE = 220;
32
35
  // Helper to sanitize IDs for DOM selectors
33
36
  const safeId = (id: string | number) => String(id).replace(/[^a-zA-Z0-9-_]/g, '_');
34
37
 
38
+ /**
39
+ * Text for link hover: relationship label (from the LLM / expansion) plus supporting evidence snippet when present.
40
+ */
41
+ function formatLinkHoverText(l: GraphLink): string | null {
42
+ const label = (l.label || '').trim();
43
+ const ev = l.evidence;
44
+ const sn = (ev?.snippet || '').trim();
45
+ if (ev?.kind === 'none') {
46
+ if (label) return label;
47
+ if (sn) return sn;
48
+ return null;
49
+ }
50
+ if (label && sn && sn !== label) return `${label}\n\n${sn}`;
51
+ if (label) return label;
52
+ if (sn) return sn;
53
+ return null;
54
+ }
55
+
56
+ /** Stabilize link ends for d3.forceLink: Map keys are String(node.id) so endpoints must be the same. */
57
+ const linkEndpointId = (e: string | number | GraphNode | null | undefined) => {
58
+ if (e != null && typeof e === 'object' && 'id' in (e as object)) {
59
+ return String((e as GraphNode).id);
60
+ }
61
+ return String(e);
62
+ };
63
+
35
64
  const Graph = forwardRef<GraphHandle, GraphProps>((props, ref) => {
36
65
  const {
37
66
  nodes,
@@ -57,11 +86,13 @@ const Graph = forwardRef<GraphHandle, GraphProps>((props, ref) => {
57
86
  const simulationRef = useRef<d3.Simulation<GraphNode, GraphLink> | null>(null);
58
87
  const zoomBehaviorRef = useRef<d3.ZoomBehavior<SVGSVGElement, unknown> | null>(null);
59
88
  const [hoveredNode, setHoveredNode] = useState<GraphNode | null>(null);
60
- const [hoveredLinkId, setHoveredLinkId] = useState<string | null>(null);
89
+ const [hoveredLinkId, setHoveredLinkId] = useState<string | number | null>(null);
90
+ const [linkTip, setLinkTip] = useState<{ link: GraphLink; x: number; y: number } | null>(null);
91
+ const linkHoverWrapRef = useRef<HTMLDivElement>(null);
61
92
  const [focusedNode, setFocusedNode] = useState<GraphNode | null>(null);
62
93
  const [timelineLayoutVersion, setTimelineLayoutVersion] = useState(0);
63
94
  const wasTimelineRef = useRef(isTimelineMode);
64
- const timelinePositionsRef = useRef(new Map<number, { x: number, y: number }>());
95
+ const timelinePositionsRef = useRef(new Map<string | number, { x: number, y: number }>());
65
96
 
66
97
  // Track previous data sizes to optimize simulation restarts
67
98
  const prevNodesLen = useRef(nodes.length);
@@ -122,11 +153,104 @@ const Graph = forwardRef<GraphHandle, GraphProps>((props, ref) => {
122
153
  if (yearA !== yearB) return yearA - yearB;
123
154
  }
124
155
 
125
- return a.id - b.id;
156
+ return String(a.id).localeCompare(String(b.id));
126
157
  });
127
158
  }, [nodes, isAtomicNode]);
128
159
 
129
- const centerOnNode = useCallback((nodeId: number, scale?: number) => {
160
+ // Calculate dynamic dimensions for nodes
161
+ const getNodeDimensions = (node: GraphNode, isTimeline: boolean, textOnly: boolean): { w: number, h: number, r: number, type: string } => {
162
+ if (isAtomicNode(node)) {
163
+ if (isTimeline) {
164
+ return { w: 96, h: 96, r: 110, type: 'circle' };
165
+ } else {
166
+ return { w: 48, h: 48, r: 55, type: 'circle' };
167
+ }
168
+ }
169
+
170
+ if (isTimeline) {
171
+ return {
172
+ w: DEFAULT_CARD_SIZE,
173
+ h: DEFAULT_CARD_SIZE,
174
+ r: 120,
175
+ type: 'card'
176
+ };
177
+ } else {
178
+ return { w: 60, h: 60, r: 60, type: 'box' };
179
+ }
180
+ };
181
+
182
+ /** Graph-space position for layout / timeline fixed positions. */
183
+ const getNodeLayoutPos = useCallback(
184
+ (n: GraphNode): { x: number; y: number } | null => {
185
+ if (isTimelineMode) {
186
+ const fixed = timelinePositionsRef.current.get(n.id);
187
+ if (fixed) return { x: fixed.x, y: fixed.y };
188
+ }
189
+ if (n.x !== undefined && n.y !== undefined) return { x: n.x, y: n.y };
190
+ return null;
191
+ },
192
+ [isTimelineMode]
193
+ );
194
+
195
+ /** Radius in graph space around each node for bounding (matches collision / drawing). */
196
+ const getNodeRadiusForBounds = useCallback(
197
+ (n: GraphNode) => {
198
+ const dims = getNodeDimensions(n, isTimelineMode, isTextOnly);
199
+ if (isTimelineMode && dims.type === "card") {
200
+ const h = (n as GraphNode & { h?: number }).h ?? dims.h;
201
+ return Math.max(dims.w, h, dims.r) / 2;
202
+ }
203
+ return Math.max(dims.r, Math.max(dims.w, dims.h) / 2, 20);
204
+ },
205
+ [isTimelineMode, isTextOnly]
206
+ );
207
+
208
+ const fitGraphInView = useCallback(() => {
209
+ if (!svgRef.current || !zoomBehaviorRef.current) return;
210
+ const list = nodes;
211
+ if (!list.length) return;
212
+
213
+ const pad = Math.max(32, 0.04 * Math.min(width, height));
214
+ let minX = Infinity;
215
+ let minY = Infinity;
216
+ let maxX = -Infinity;
217
+ let maxY = -Infinity;
218
+ let any = false;
219
+
220
+ for (const n of list) {
221
+ const p = getNodeLayoutPos(n);
222
+ if (!p) continue;
223
+ any = true;
224
+ const r = getNodeRadiusForBounds(n);
225
+ minX = Math.min(minX, p.x - r);
226
+ maxX = Math.max(maxX, p.x + r);
227
+ minY = Math.min(minY, p.y - r);
228
+ maxY = Math.max(maxY, p.y + r);
229
+ }
230
+ if (!any || !Number.isFinite(minX)) return;
231
+
232
+ const graphW = Math.max(1, maxX - minX);
233
+ const graphH = Math.max(1, maxY - minY);
234
+ const midX = (minX + maxX) / 2;
235
+ const midY = (minY + maxY) / 2;
236
+
237
+ let k = Math.min((width - 2 * pad) / graphW, (height - 2 * pad) / graphH);
238
+ // d3 scaleExtent; never zoom in past ~1.4× for a tiny / single-node graph (avoid “magnify one card”)
239
+ if (graphW < 0.4 * width && graphH < 0.4 * height) {
240
+ k = Math.min(k, 1.4);
241
+ }
242
+ k = Math.min(4, Math.max(0.1, k));
243
+
244
+ const svg = d3.select(svgRef.current);
245
+ const transform = d3.zoomIdentity
246
+ .translate(width / 2, height / 2)
247
+ .scale(k)
248
+ .translate(-midX, -midY);
249
+
250
+ svg.transition().duration(800).call(zoomBehaviorRef.current.transform, transform);
251
+ }, [nodes, width, height, getNodeLayoutPos, getNodeRadiusForBounds]);
252
+
253
+ const centerOnNode = useCallback((nodeId: string | number, scale?: number) => {
130
254
  const node = nodes.find(n => n.id === nodeId);
131
255
  if (!node || !svgRef.current || !zoomBehaviorRef.current) return;
132
256
 
@@ -156,34 +280,6 @@ const Graph = forwardRef<GraphHandle, GraphProps>((props, ref) => {
156
280
  svg.transition().duration(800).call(zoomBehaviorRef.current.transform, transform);
157
281
  }, [nodes, width, height, isTimelineMode]);
158
282
 
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
283
  // Helper to wrap text in SVG
188
284
  const wrapText = (text: string, width: number, maxLines?: number) => {
189
285
  if (!text) return [];
@@ -205,27 +301,39 @@ const Graph = forwardRef<GraphHandle, GraphProps>((props, ref) => {
205
301
  return maxLines ? lines.slice(0, maxLines) : lines;
206
302
  };
207
303
 
208
- // Expose centerOnNode function via ref
209
- useImperativeHandle(ref, () => ({
210
- centerOnNode
211
- }), [centerOnNode]);
304
+ const nodesRef = useRef(nodes);
305
+ useEffect(() => {
306
+ nodesRef.current = nodes;
307
+ }, [nodes]);
308
+
309
+ const centerOnNodeRef = useRef(centerOnNode);
310
+ centerOnNodeRef.current = centerOnNode;
311
+ const fitGraphInViewRef = useRef(fitGraphInView);
312
+ fitGraphInViewRef.current = fitGraphInView;
313
+
314
+ useImperativeHandle(
315
+ ref,
316
+ () => ({
317
+ centerOnNode,
318
+ fitGraphInView,
319
+ }),
320
+ [centerOnNode, fitGraphInView]
321
+ );
212
322
 
213
- // Center on selected node when it changes
323
+ // After selection, fit the whole graph in the viewport (200ms: allow layout to place x/y).
214
324
  useEffect(() => {
215
- if (!selectedNode || !svgRef.current) return;
216
- centerOnNode(selectedNode.id);
217
- }, [selectedNode?.id, centerOnNode]);
325
+ if (!selectedNode) return;
326
+ const t = setTimeout(() => fitGraphInViewRef.current(), 200);
327
+ return () => clearTimeout(t);
328
+ }, [selectedNode?.id]);
218
329
 
219
- // Auto-center and zoom when entering timeline mode
330
+ // When entering timeline mode, show the full layout in view
220
331
  useEffect(() => {
221
332
  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);
333
+ const timer = setTimeout(() => fitGraphInViewRef.current(), 150);
226
334
  return () => clearTimeout(timer);
227
335
  }
228
- }, [isTimelineMode, timelineNodes, centerOnNode]);
336
+ }, [isTimelineMode, timelineNodes, fitGraphInView]);
229
337
 
230
338
  // Reset zoom and focused state when searchId changes (new graph)
231
339
  useEffect(() => {
@@ -383,7 +491,7 @@ const Graph = forwardRef<GraphHandle, GraphProps>((props, ref) => {
383
491
  if (isTimelineMode && dims.type === 'card') {
384
492
  // For timeline cards, use the larger of width or height plus padding
385
493
  const cardWidth = dims.w;
386
- const cardHeight = d.h || dims.h;
494
+ const cardHeight = (d as GraphNode & { h?: number }).h ?? dims.h;
387
495
  // Use the diagonal distance plus padding to ensure no overlap
388
496
  const maxDimension = Math.max(cardWidth, cardHeight);
389
497
  return (maxDimension / 2) + 15; // Increased padding to prevent overlap
@@ -406,7 +514,7 @@ const Graph = forwardRef<GraphHandle, GraphProps>((props, ref) => {
406
514
  simulation.force("collide", collideForce);
407
515
 
408
516
  if (isTimelineMode) {
409
- const prevPositions = new Map<number, { x: number; y: number }>(timelinePositionsRef.current);
517
+ const prevPositions = new Map<string | number, { x: number; y: number }>(timelinePositionsRef.current);
410
518
 
411
519
  const lockNodePosition = (node: GraphNode, x: number, y: number) => {
412
520
  node.fx = x;
@@ -420,8 +528,8 @@ const Graph = forwardRef<GraphHandle, GraphProps>((props, ref) => {
420
528
 
421
529
 
422
530
 
423
- const nodeIndexMap = new Map<number, number>(
424
- timelineNodes.map((n, i) => [n.id, i] as [number, number])
531
+ const nodeIndexMap = new Map<string | number, number>(
532
+ timelineNodes.map((n, i) => [n.id, i] as [string | number, number])
425
533
  );
426
534
 
427
535
  const itemSpacing = 280; // More horizontal breathing room
@@ -638,36 +746,39 @@ const Graph = forwardRef<GraphHandle, GraphProps>((props, ref) => {
638
746
  svg.transition().duration(500).call(zoomBehaviorRef.current.transform, d3.zoomIdentity);
639
747
  }
640
748
  } 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
- }
749
+ setTimeout(() => {
750
+ fitGraphInView();
751
+ }, 100);
649
752
  }
650
753
  wasTimelineRef.current = isTimelineMode;
651
- }, [isTimelineMode, nodes, width, height, timelineNodes, centerOnNode]);
754
+ }, [isTimelineMode, nodes, width, height, timelineNodes, centerOnNode, fitGraphInView]);
652
755
 
653
756
  // 4. Structural Effect: Only runs when overall graph structure (nodes/links) changes.
654
757
  // This handles D3 enter/exit/merge and restarts the simulation.
655
758
  useEffect(() => {
656
759
  if (!zoomGroupRef.current) return;
657
760
 
658
- // 1. Calculate valid links first
761
+ // 1. Calculate valid links first (string ids only — d3 forceLink's nodeById map uses .id() keys)
762
+ const nodeIdSet = new Set(nodes.map(n => String(n.id)));
659
763
  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;
764
+ .map(link => {
765
+ const sId = linkEndpointId(link.source as string | number | GraphNode);
766
+ const tId = linkEndpointId(link.target as string | number | GraphNode);
767
+ return { link, sId, tId };
666
768
  })
667
- .map(link => ({
769
+ .filter(
770
+ ({ sId, tId }) =>
771
+ sId.length > 0 &&
772
+ tId.length > 0 &&
773
+ sId !== 'undefined' &&
774
+ tId !== 'undefined' &&
775
+ nodeIdSet.has(sId) &&
776
+ nodeIdSet.has(tId)
777
+ )
778
+ .map(({ link, sId, tId }) => ({
668
779
  ...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)
780
+ source: sId,
781
+ target: tId
671
782
  }));
672
783
 
673
784
  // 2. Lazily create simulation if it doesn't exist
@@ -724,25 +835,63 @@ const Graph = forwardRef<GraphHandle, GraphProps>((props, ref) => {
724
835
  linkMerged.style("display", null);
725
836
  }
726
837
 
727
- // Link click handling (for evidence) + hover highlight via hit-area
838
+ // Link click (optional) + hover highlight + tooltip (label + LLM / evidence text)
839
+ const placeLinkTip = (event: MouseEvent) => {
840
+ const root = linkHoverWrapRef.current;
841
+ if (!root) return null;
842
+ const r = root.getBoundingClientRect();
843
+ return { x: event.clientX - r.left, y: event.clientY - r.top };
844
+ };
845
+ const hoverInLink = (event: any, d: GraphLink) => {
846
+ setHoveredLinkId(d.id);
847
+ const p = placeLinkTip(event as MouseEvent);
848
+ if (p && formatLinkHoverText(d)) {
849
+ setLinkTip({ link: d, x: p.x, y: p.y });
850
+ } else {
851
+ setLinkTip(null);
852
+ }
853
+ };
854
+ const moveLink = (event: any, d: GraphLink) => {
855
+ const p = placeLinkTip(event as MouseEvent);
856
+ if (!p) return;
857
+ if (formatLinkHoverText(d)) {
858
+ setLinkTip({ link: d, x: p.x, y: p.y });
859
+ }
860
+ };
861
+ const hoverOutLink = () => {
862
+ setHoveredLinkId(null);
863
+ setLinkTip(null);
864
+ };
728
865
  if (onLinkClick) {
729
866
  const clickHandler = (event: any, d: GraphLink) => {
730
867
  event.stopPropagation();
731
868
  onLinkClick(d);
732
869
  };
733
- const hoverIn = (_event: any, d: GraphLink) => setHoveredLinkId(d.id);
734
- const hoverOut = () => setHoveredLinkId(null);
735
-
736
870
  linkMerged
737
871
  .style("cursor", "pointer")
738
872
  .on("click", clickHandler)
739
- .on("mouseover", hoverIn)
740
- .on("mouseout", hoverOut);
873
+ .on("mouseover", hoverInLink)
874
+ .on("mousemove", moveLink)
875
+ .on("mouseout", hoverOutLink);
741
876
  linkHitMerged
742
877
  .style("cursor", "pointer")
743
878
  .on("click", clickHandler)
744
- .on("mouseover", hoverIn)
745
- .on("mouseout", hoverOut);
879
+ .on("mouseover", hoverInLink)
880
+ .on("mousemove", moveLink)
881
+ .on("mouseout", hoverOutLink);
882
+ } else {
883
+ linkMerged
884
+ .style("cursor", "default")
885
+ .on("click", null)
886
+ .on("mouseover", hoverInLink)
887
+ .on("mousemove", moveLink)
888
+ .on("mouseout", hoverOutLink);
889
+ linkHitMerged
890
+ .style("cursor", "default")
891
+ .on("click", null)
892
+ .on("mouseover", hoverInLink)
893
+ .on("mousemove", moveLink)
894
+ .on("mouseout", hoverOutLink);
746
895
  }
747
896
 
748
897
  const nodeSel = container.selectAll<SVGGElement, GraphNode>(".node").data(nodes, d => d.id);
@@ -843,8 +992,6 @@ const Graph = forwardRef<GraphHandle, GraphProps>((props, ref) => {
843
992
  const hoverOut = () => setHoveredNode(null);
844
993
 
845
994
  nodeEnter.merge(nodeSel)
846
- .attr("role", "button")
847
- .attr("aria-label", (d: GraphNode) => (d.title ? `Graph node: ${d.title}` : "Graph node"))
848
995
  .style("cursor", "pointer")
849
996
  .on("click", clickHandler)
850
997
  .on("contextmenu", contextMenuHandler)
@@ -974,7 +1121,7 @@ const Graph = forwardRef<GraphHandle, GraphProps>((props, ref) => {
974
1121
  axisGroup.style("display", "none");
975
1122
  }
976
1123
  });
977
- }, [nodes, links, isTimelineMode, width, height]);
1124
+ }, [nodes, links, isTimelineMode, width, height, onLinkClick]);
978
1125
 
979
1126
  // 5. Stylistic Effect: Update colors, opacity, labels without restarting simulation
980
1127
  useEffect(() => {
@@ -987,7 +1134,7 @@ const Graph = forwardRef<GraphHandle, GraphProps>((props, ref) => {
987
1134
 
988
1135
  // Build set of path links (links between consecutive nodes in the path)
989
1136
  // IMPORTANT: Only highlight links that actually exist and are part of the path sequence
990
- const pathLinkIds = new Set<string>();
1137
+ const pathLinkIds = new Set<string | number>();
991
1138
  if (hasHighlight && highlightKeepIds && highlightKeepIds.length > 1) {
992
1139
  // For each consecutive pair in the path, check if a link exists
993
1140
  for (let i = 0; i < highlightKeepIds.length - 1; i++) {
@@ -1024,7 +1171,7 @@ const Graph = forwardRef<GraphHandle, GraphProps>((props, ref) => {
1024
1171
  const allLinks = container.selectAll<SVGPathElement, GraphLink>(".link");
1025
1172
 
1026
1173
  // Build map of event to connected people for timeline mode
1027
- const eventToPeople = new Map<number, string[]>();
1174
+ const eventToPeople = new Map<string | number, string[]>();
1028
1175
  if (isTimelineMode) {
1029
1176
  links.forEach(l => {
1030
1177
  const sId = typeof l.source === 'object' ? (l.source as GraphNode).id : l.source;
@@ -1496,8 +1643,8 @@ const Graph = forwardRef<GraphHandle, GraphProps>((props, ref) => {
1496
1643
  return "#dc2626";
1497
1644
  })
1498
1645
  .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;
1646
+ const sId = String(typeof d.source === 'object' ? (d.source as GraphNode).id : d.source);
1647
+ const tId = String(typeof d.target === 'object' ? (d.target as GraphNode).id : d.target);
1501
1648
  // Hover highlight for links
1502
1649
  if (hoveredLinkId && d.id === hoveredLinkId) return 6;
1503
1650
  // Make path links thicker
@@ -1509,16 +1656,33 @@ const Graph = forwardRef<GraphHandle, GraphProps>((props, ref) => {
1509
1656
 
1510
1657
  }, [nodes, links, isTimelineMode, hoveredNode, hoveredLinkId, effectiveFocused, highlightKeepIds, highlightDropIds, isTextOnly, onNodeClick, expandingNodeId, newChildNodeIds]);
1511
1658
 
1659
+ const linkTipText = linkTip ? formatLinkHoverText(linkTip.link) : null;
1660
+
1512
1661
  return (
1662
+ <div ref={linkHoverWrapRef} className="relative" style={{ width, height }}>
1663
+ {linkTip && linkTipText && (
1664
+ <div
1665
+ className="pointer-events-none absolute z-[100] max-w-sm rounded-lg border border-slate-600/80 bg-slate-950/95 px-2.5 py-2 text-left text-[11px] leading-snug text-slate-100 shadow-lg backdrop-blur-sm"
1666
+ style={{ left: linkTip.x + 8, top: linkTip.y + 8 }}
1667
+ >
1668
+ <p className="whitespace-pre-wrap break-words">{linkTipText}</p>
1669
+ </div>
1670
+ )}
1513
1671
  <svg
1514
1672
  ref={svgRef}
1515
1673
  width={width}
1516
1674
  height={height}
1517
1675
  className="cursor-move bg-slate-900"
1518
- onClick={() => { setHoveredNode(null); setFocusedNode(null); }}
1676
+ onClick={() => {
1677
+ setHoveredNode(null);
1678
+ setFocusedNode(null);
1679
+ setHoveredLinkId(null);
1680
+ setLinkTip(null);
1681
+ }}
1519
1682
  >
1520
1683
  <g ref={zoomGroupRef} />
1521
1684
  </svg>
1685
+ </div>
1522
1686
  );
1523
1687
  });
1524
1688
 
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  import React from 'react';
2
3
  import { HelpCircle, X, Link as LinkIcon } from 'lucide-react';
3
4
 
@@ -108,7 +109,7 @@ const HelpOverlay: React.FC<HelpOverlayProps> = ({
108
109
  className="flex items-center gap-2 text-slate-300 hover:text-white transition-colors"
109
110
  >
110
111
  <LinkIcon size={14} className="text-slate-500" />
111
- <span>Regeneration Prompt</span>
112
+ <span>Prompt</span>
112
113
  </a>
113
114
  <a
114
115
  href="/doc/api_queries.html"