@johndimm/constellations 1.0.0 → 1.0.2
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 +352 -70
- package/FullPageConstellations.tsx +7 -5
- package/components/AppConfirmDialog.tsx +1 -0
- package/components/AppHeader.tsx +69 -29
- package/components/AppNotifications.tsx +1 -0
- package/components/BrowsePeople.tsx +3 -0
- package/components/ControlPanel.tsx +46 -371
- package/components/Graph.tsx +251 -87
- package/components/HelpOverlay.tsx +1 -0
- package/components/NodeContextMenu.tsx +123 -3
- package/components/PeopleBrowserSidebar.tsx +15 -6
- package/components/Sidebar.tsx +46 -19
- package/components/TimelineView.tsx +1 -0
- package/embedded.css +38 -0
- package/hooks/useExpansion.ts +61 -229
- package/hooks/useGraphActions.ts +1 -0
- package/hooks/useGraphState.ts +75 -40
- package/hooks/useKioskMode.ts +1 -0
- package/hooks/useNodeClickHandler.ts +23 -15
- package/hooks/useSearchHandlers.ts +57 -19
- package/host.ts +1 -1
- package/index.css +17 -3
- package/package.json +4 -1
- package/services/aiService.ts +23 -0
- package/services/aiUtils.ts +216 -207
- package/services/cacheService.ts +1 -0
- package/services/crossrefService.ts +1 -0
- package/services/deepseekService.ts +467 -0
- package/services/geminiService.ts +532 -733
- package/services/graphUtils.ts +128 -18
- package/services/imageService.ts +18 -0
- package/services/openAlexService.ts +1 -0
- package/services/resolveImageForTitle.ts +458 -0
- package/services/wikipediaImage.ts +1 -0
- package/services/wikipediaService.ts +56 -46
- package/types.ts +3 -0
- package/utils/evidenceUtils.ts +1 -0
- package/utils/graphLogicUtils.ts +1 -0
- package/utils/wikiUtils.ts +14 -2
package/components/Graph.tsx
CHANGED
|
@@ -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
|
|
156
|
+
return String(a.id).localeCompare(String(b.id));
|
|
126
157
|
});
|
|
127
158
|
}, [nodes, isAtomicNode]);
|
|
128
159
|
|
|
129
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
}
|
|
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
|
-
//
|
|
323
|
+
// After selection, fit the whole graph in the viewport (200ms: allow layout to place x/y).
|
|
214
324
|
useEffect(() => {
|
|
215
|
-
if (!selectedNode
|
|
216
|
-
|
|
217
|
-
|
|
325
|
+
if (!selectedNode) return;
|
|
326
|
+
const t = setTimeout(() => fitGraphInViewRef.current(), 200);
|
|
327
|
+
return () => clearTimeout(t);
|
|
328
|
+
}, [selectedNode?.id]);
|
|
218
329
|
|
|
219
|
-
//
|
|
330
|
+
// When entering timeline mode, show the full layout in view
|
|
220
331
|
useEffect(() => {
|
|
221
332
|
if (isTimelineMode && timelineNodes.length > 0) {
|
|
222
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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
|
-
.
|
|
661
|
-
const sId =
|
|
662
|
-
const tId =
|
|
663
|
-
|
|
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
|
-
.
|
|
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:
|
|
670
|
-
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
|
|
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",
|
|
740
|
-
.on("
|
|
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",
|
|
745
|
-
.on("
|
|
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
|
|
1500
|
-
const tId = typeof d.target === 'object' ? (d.target as GraphNode).id : d.target
|
|
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={() => {
|
|
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,6 +1,7 @@
|
|
|
1
|
+
"use client";
|
|
1
2
|
import React from 'react';
|
|
2
3
|
import { GraphNode } from '../types';
|
|
3
|
-
import { Maximize, Plus, Sparkles, Trash2 } from 'lucide-react';
|
|
4
|
+
import { ListMusic, Loader2, Maximize, Plus, Sparkles, Trash2 } from 'lucide-react';
|
|
4
5
|
|
|
5
6
|
interface NodeContextMenuProps {
|
|
6
7
|
node: GraphNode;
|
|
@@ -8,7 +9,9 @@ interface NodeContextMenuProps {
|
|
|
8
9
|
y: number;
|
|
9
10
|
onExpandLeaves: (node: GraphNode) => void;
|
|
10
11
|
onAddMore: (node: GraphNode) => void;
|
|
11
|
-
onFindBetterPhoto: (nodeId: number) => void;
|
|
12
|
+
onFindBetterPhoto: (nodeId: number | string) => void;
|
|
13
|
+
/** When set (e.g. Soundings player), create a new channel seeded from this node. */
|
|
14
|
+
onNewChannelFromNode?: (node: GraphNode) => void;
|
|
12
15
|
onDelete: (node: GraphNode) => void;
|
|
13
16
|
onClose: () => void;
|
|
14
17
|
isProcessing?: boolean;
|
|
@@ -21,6 +24,7 @@ const NodeContextMenu: React.FC<NodeContextMenuProps> = ({
|
|
|
21
24
|
onExpandLeaves,
|
|
22
25
|
onAddMore,
|
|
23
26
|
onFindBetterPhoto,
|
|
27
|
+
onNewChannelFromNode,
|
|
24
28
|
onDelete,
|
|
25
29
|
onClose,
|
|
26
30
|
isProcessing
|
|
@@ -30,12 +34,104 @@ const NodeContextMenu: React.FC<NodeContextMenuProps> = ({
|
|
|
30
34
|
onClose();
|
|
31
35
|
};
|
|
32
36
|
|
|
37
|
+
/**
|
|
38
|
+
* During fetch, `useExpansion` sets both `expanded` and `isLoading` on the parent,
|
|
39
|
+
* so we must not require `!expanded` — use `isLoading` only.
|
|
40
|
+
* (The menu receives the live node from App so `isLoading` stays current while open.)
|
|
41
|
+
*/
|
|
42
|
+
const expansionInProgress = Boolean(node.isLoading);
|
|
43
|
+
/** While this node is expanding, do not block "new channel" on global isProcessing. */
|
|
44
|
+
const newChannelDisabled = isProcessing && !expansionInProgress;
|
|
45
|
+
|
|
33
46
|
// Calculate position to keep menu on screen
|
|
34
47
|
const menuWidth = 220;
|
|
35
|
-
const menuHeight =
|
|
48
|
+
const menuHeight = expansionInProgress
|
|
49
|
+
? (onNewChannelFromNode ? 150 : 100)
|
|
50
|
+
: onNewChannelFromNode
|
|
51
|
+
? 230
|
|
52
|
+
: 180;
|
|
36
53
|
const adjustedX = Math.min(x, window.innerWidth - menuWidth - 20);
|
|
37
54
|
const adjustedY = Math.min(y, window.innerHeight - menuHeight - 20);
|
|
38
55
|
|
|
56
|
+
if (expansionInProgress) {
|
|
57
|
+
return (
|
|
58
|
+
<>
|
|
59
|
+
<div
|
|
60
|
+
className="fixed inset-0 z-40"
|
|
61
|
+
style={{ position: 'fixed', inset: 0, zIndex: 999998 }}
|
|
62
|
+
onClick={onClose}
|
|
63
|
+
/>
|
|
64
|
+
<div
|
|
65
|
+
className="fixed z-50"
|
|
66
|
+
style={{
|
|
67
|
+
position: 'fixed',
|
|
68
|
+
zIndex: 999999,
|
|
69
|
+
left: `${adjustedX}px`,
|
|
70
|
+
top: `${adjustedY}px`,
|
|
71
|
+
minWidth: '240px',
|
|
72
|
+
maxWidth: 'min(360px, 92vw)',
|
|
73
|
+
padding: '10px 10px 8px',
|
|
74
|
+
borderRadius: '10px',
|
|
75
|
+
backgroundColor: 'rgba(15, 23, 42, 0.98)',
|
|
76
|
+
border: '1px solid #334155',
|
|
77
|
+
boxShadow: '0 20px 45px rgba(0,0,0,0.35)',
|
|
78
|
+
color: '#f8fafc'
|
|
79
|
+
}}
|
|
80
|
+
>
|
|
81
|
+
<div
|
|
82
|
+
style={{
|
|
83
|
+
display: 'flex',
|
|
84
|
+
alignItems: 'flex-start',
|
|
85
|
+
gap: '8px',
|
|
86
|
+
marginBottom: onNewChannelFromNode ? 10 : 0,
|
|
87
|
+
fontSize: '12px',
|
|
88
|
+
lineHeight: 1.35,
|
|
89
|
+
color: '#94a3b8'
|
|
90
|
+
}}
|
|
91
|
+
>
|
|
92
|
+
<Loader2 size={16} className="text-indigo-400 shrink-0 mt-0.5 animate-spin" />
|
|
93
|
+
<div>
|
|
94
|
+
<div className="text-slate-200 font-medium text-[13px] line-clamp-2" title={node.title}>
|
|
95
|
+
{node.title}
|
|
96
|
+
</div>
|
|
97
|
+
<div className="mt-0.5">Expanding connections…</div>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
{onNewChannelFromNode && (
|
|
101
|
+
<button
|
|
102
|
+
onClick={() => handleAction(() => onNewChannelFromNode(node))}
|
|
103
|
+
disabled={newChannelDisabled}
|
|
104
|
+
className="disabled:opacity-50 disabled:cursor-not-allowed"
|
|
105
|
+
type="button"
|
|
106
|
+
style={{
|
|
107
|
+
width: '100%',
|
|
108
|
+
padding: '8px 12px',
|
|
109
|
+
textAlign: 'left',
|
|
110
|
+
fontSize: '13px',
|
|
111
|
+
color: 'inherit',
|
|
112
|
+
background: 'rgba(34, 211, 238, 0.08)',
|
|
113
|
+
border: '1px solid rgba(34, 211, 238, 0.25)',
|
|
114
|
+
borderRadius: '8px',
|
|
115
|
+
display: 'flex',
|
|
116
|
+
alignItems: 'center',
|
|
117
|
+
gap: '10px',
|
|
118
|
+
cursor: newChannelDisabled ? 'not-allowed' : 'pointer'
|
|
119
|
+
}}
|
|
120
|
+
>
|
|
121
|
+
<ListMusic size={16} className="text-cyan-400" />
|
|
122
|
+
<span>New channel from this node</span>
|
|
123
|
+
</button>
|
|
124
|
+
)}
|
|
125
|
+
{!onNewChannelFromNode && (
|
|
126
|
+
<p style={{ fontSize: '11px', color: '#64748b', margin: 0, lineHeight: 1.4 }}>
|
|
127
|
+
Open the menu again after expansion for more actions.
|
|
128
|
+
</p>
|
|
129
|
+
)}
|
|
130
|
+
</div>
|
|
131
|
+
</>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
39
135
|
return (
|
|
40
136
|
<>
|
|
41
137
|
{/* Backdrop to close menu on click outside */}
|
|
@@ -131,6 +227,30 @@ const NodeContextMenu: React.FC<NodeContextMenuProps> = ({
|
|
|
131
227
|
<span>Find Better Photo</span>
|
|
132
228
|
</button>
|
|
133
229
|
|
|
230
|
+
{onNewChannelFromNode && (
|
|
231
|
+
<button
|
|
232
|
+
onClick={() => handleAction(() => onNewChannelFromNode(node))}
|
|
233
|
+
disabled={newChannelDisabled}
|
|
234
|
+
className="disabled:opacity-50 disabled:cursor-not-allowed"
|
|
235
|
+
style={{
|
|
236
|
+
width: '100%',
|
|
237
|
+
padding: '8px 12px',
|
|
238
|
+
textAlign: 'left',
|
|
239
|
+
fontSize: '13px',
|
|
240
|
+
color: 'inherit',
|
|
241
|
+
background: 'transparent',
|
|
242
|
+
border: 'none',
|
|
243
|
+
display: 'flex',
|
|
244
|
+
alignItems: 'center',
|
|
245
|
+
gap: '10px',
|
|
246
|
+
cursor: newChannelDisabled ? 'not-allowed' : 'pointer'
|
|
247
|
+
}}
|
|
248
|
+
>
|
|
249
|
+
<ListMusic size={16} className="text-cyan-400" />
|
|
250
|
+
<span>New channel from node</span>
|
|
251
|
+
</button>
|
|
252
|
+
)}
|
|
253
|
+
|
|
134
254
|
<div style={{ height: '1px', background: '#334155', margin: '6px 0' }} />
|
|
135
255
|
|
|
136
256
|
<button
|