@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.
- package/App.tsx +360 -66
- package/FullPageConstellations.tsx +7 -4
- package/components/AppConfirmDialog.tsx +1 -0
- package/components/AppHeader.tsx +67 -30
- package/components/AppNotifications.tsx +1 -0
- package/components/BrowsePeople.tsx +3 -0
- package/components/ControlPanel.tsx +229 -250
- package/components/Graph.tsx +251 -87
- package/components/HelpOverlay.tsx +2 -1
- 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/hooks/useExpansion.ts +85 -230
- 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 +60 -21
- package/host.ts +1 -1
- package/index.css +17 -3
- package/index.tsx +5 -3
- package/package.json +4 -2
- package/services/aiService.ts +27 -0
- package/services/aiUtils.ts +285 -195
- package/services/cacheService.ts +1 -0
- package/services/crossrefService.ts +1 -0
- package/services/deepseekService.ts +479 -0
- package/services/geminiService.ts +543 -736
- 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 +79 -49
- package/sessionHandoff.ts +26 -0
- package/types.ts +3 -0
- package/utils/evidenceUtils.ts +1 -0
- package/utils/graphLogicUtils.ts +1 -0
- package/utils/wikiUtils.ts +14 -2
package/hooks/useGraphState.ts
CHANGED
|
@@ -1,19 +1,32 @@
|
|
|
1
|
+
"use client";
|
|
1
2
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
2
3
|
import { GraphNode, GraphLink } from '../types';
|
|
3
4
|
import { LockedPair, findWikipediaTitle } from '../services/geminiService';
|
|
4
|
-
import { fetchServerImage } from '../services/imageService';
|
|
5
|
+
import { fetchServerImage, getImageApiBaseUrl } from '../services/imageService';
|
|
5
6
|
import { dedupeGraph } from '../services/graphUtils';
|
|
6
7
|
import { GraphHandle } from '../components/Graph';
|
|
8
|
+
import type { ConstellationsSessionHandoffV1 } from '../sessionHandoff';
|
|
9
|
+
import { graphFromHandoff } from '../sessionHandoff';
|
|
7
10
|
|
|
8
11
|
interface UseGraphStateOptions {
|
|
9
12
|
cacheEnabled: boolean;
|
|
10
13
|
cacheBaseUrl: string;
|
|
14
|
+
/** Restored session from player embed → full screen (no re-query). */
|
|
15
|
+
initialSession?: ConstellationsSessionHandoffV1 | null;
|
|
16
|
+
/**
|
|
17
|
+
* - `undefined` — measure the browser viewport (default standalone layout).
|
|
18
|
+
* - `null` — embedded: container not mounted yet; use a placeholder size until the ref attaches.
|
|
19
|
+
* - `HTMLElement` — embedded: size the graph to this element (ResizeObserver).
|
|
20
|
+
*/
|
|
21
|
+
boundElement?: HTMLElement | null;
|
|
11
22
|
}
|
|
12
23
|
|
|
13
24
|
export function useGraphState(options: UseGraphStateOptions) {
|
|
14
|
-
const { cacheEnabled, cacheBaseUrl } = options;
|
|
25
|
+
const { cacheEnabled, cacheBaseUrl, boundElement, initialSession: initialSessionOpt } = options;
|
|
26
|
+
const initialSession = initialSessionOpt && initialSessionOpt.graph?.nodes?.length ? initialSessionOpt : null;
|
|
27
|
+
const initialGraph = initialSession ? graphFromHandoff(initialSession) : { nodes: [] as GraphNode[], links: [] as GraphLink[] };
|
|
15
28
|
|
|
16
|
-
const [graphData, setGraphData] = useState<{ nodes: GraphNode[], links: GraphLink[] }>(
|
|
29
|
+
const [graphData, setGraphData] = useState<{ nodes: GraphNode[], links: GraphLink[] }>(initialGraph);
|
|
17
30
|
const { nodes, links } = graphData;
|
|
18
31
|
const graphDataRef = useRef(graphData);
|
|
19
32
|
|
|
@@ -25,20 +38,22 @@ export function useGraphState(options: UseGraphStateOptions) {
|
|
|
25
38
|
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
|
|
26
39
|
const [selectedLink, setSelectedLink] = useState<GraphLink | null>(null);
|
|
27
40
|
|
|
28
|
-
const [isCompact, setIsCompact] = useState(
|
|
29
|
-
const [isTimelineMode, setIsTimelineMode] = useState(
|
|
30
|
-
const [isTextOnly, setIsTextOnly] = useState(
|
|
31
|
-
const [searchMode, setSearchMode] = useState<'explore' | 'connect'>('explore');
|
|
41
|
+
const [isCompact, setIsCompact] = useState(!!initialSession?.isCompact);
|
|
42
|
+
const [isTimelineMode, setIsTimelineMode] = useState(!!initialSession?.isTimelineMode);
|
|
43
|
+
const [isTextOnly, setIsTextOnly] = useState(!!initialSession?.isTextOnly);
|
|
44
|
+
const [searchMode, setSearchMode] = useState<'explore' | 'connect'>(initialSession?.searchMode ?? 'explore');
|
|
32
45
|
const [error, setError] = useState<string | null>(null);
|
|
33
46
|
const [isKeyReady, setIsKeyReady] = useState(false);
|
|
34
|
-
const [searchId, setSearchId] = useState(0);
|
|
35
|
-
const searchIdRef = useRef(0);
|
|
47
|
+
const [searchId, setSearchId] = useState(initialSession?.searchId ?? 0);
|
|
48
|
+
const searchIdRef = useRef(initialSession?.searchId ?? 0);
|
|
36
49
|
|
|
37
50
|
useEffect(() => {
|
|
38
51
|
searchIdRef.current = searchId;
|
|
39
52
|
}, [searchId]);
|
|
40
53
|
|
|
41
|
-
const [lockedPair, setLockedPair] = useState<LockedPair>(
|
|
54
|
+
const [lockedPair, setLockedPair] = useState<LockedPair>(
|
|
55
|
+
initialSession?.lockedPair ?? { atomicType: "Person", compositeType: "Event" }
|
|
56
|
+
);
|
|
42
57
|
const lockedPairRef = useRef<LockedPair>(lockedPair);
|
|
43
58
|
useEffect(() => { lockedPairRef.current = lockedPair; }, [lockedPair]);
|
|
44
59
|
|
|
@@ -55,7 +70,7 @@ export function useGraphState(options: UseGraphStateOptions) {
|
|
|
55
70
|
const autoExpandMoreDoneRef = useRef<Set<string | number>>(new Set());
|
|
56
71
|
|
|
57
72
|
const [deletePreview, setDeletePreview] = useState<{ keepIds: (number | string)[], dropIds: (number | string)[] } | null>(null);
|
|
58
|
-
const [pathNodeIds, setPathNodeIds] = useState<(number | string)[]>([]);
|
|
73
|
+
const [pathNodeIds, setPathNodeIds] = useState<(number | string)[]>(initialSession?.pathNodeIds ?? []);
|
|
59
74
|
const [newlyExpandedNodeIds, setNewlyExpandedNodeIds] = useState<(number | string)[]>([]);
|
|
60
75
|
const [expandingNodeId, setExpandingNodeId] = useState<number | string | null>(null);
|
|
61
76
|
const [newChildNodeIds, setNewChildNodeIds] = useState<Set<number | string>>(new Set());
|
|
@@ -77,12 +92,44 @@ export function useGraphState(options: UseGraphStateOptions) {
|
|
|
77
92
|
const [peopleBrowserOpen, setPeopleBrowserOpen] = useState(false);
|
|
78
93
|
const [savedGraphs, setSavedGraphs] = useState<string[]>([]);
|
|
79
94
|
|
|
80
|
-
const [dimensions, setDimensions] = useState(
|
|
95
|
+
const [dimensions, setDimensions] = useState(() => {
|
|
96
|
+
if (boundElement === undefined) {
|
|
97
|
+
if (typeof window === "undefined") return { width: 800, height: 600 };
|
|
98
|
+
return { width: window.innerWidth, height: window.innerHeight };
|
|
99
|
+
}
|
|
100
|
+
if (boundElement) {
|
|
101
|
+
const r = boundElement.getBoundingClientRect();
|
|
102
|
+
return { width: Math.max(1, r.width), height: Math.max(1, r.height) };
|
|
103
|
+
}
|
|
104
|
+
return { width: 800, height: 600 };
|
|
105
|
+
});
|
|
81
106
|
useEffect(() => {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
107
|
+
if (boundElement === undefined) {
|
|
108
|
+
const handleResize = () => setDimensions({ width: window.innerWidth, height: window.innerHeight });
|
|
109
|
+
handleResize();
|
|
110
|
+
window.addEventListener('resize', handleResize);
|
|
111
|
+
return () => window.removeEventListener('resize', handleResize);
|
|
112
|
+
}
|
|
113
|
+
if (boundElement === null) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const el = boundElement;
|
|
117
|
+
const ro = new ResizeObserver((entries) => {
|
|
118
|
+
for (const e of entries) {
|
|
119
|
+
const w = e.contentRect.width;
|
|
120
|
+
const h = e.contentRect.height;
|
|
121
|
+
if (w > 0 && h > 0) {
|
|
122
|
+
setDimensions({ width: w, height: h });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
ro.observe(el);
|
|
127
|
+
const r = el.getBoundingClientRect();
|
|
128
|
+
if (r.width > 0 && r.height > 0) {
|
|
129
|
+
setDimensions({ width: r.width, height: r.height });
|
|
130
|
+
}
|
|
131
|
+
return () => ro.disconnect();
|
|
132
|
+
}, [boundElement]);
|
|
86
133
|
|
|
87
134
|
const graphRef = useRef<GraphHandle>(null);
|
|
88
135
|
|
|
@@ -155,27 +202,9 @@ export function useGraphState(options: UseGraphStateOptions) {
|
|
|
155
202
|
nodes: prev.nodes.map(n => String(n.id) === String(nodeId) ? { ...n, fetchingImage: true, imageChecked: true } : n)
|
|
156
203
|
}));
|
|
157
204
|
|
|
158
|
-
const imageBaseUrl =
|
|
159
|
-
const
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
// Avoid ambiguity for common screen-work titles like "The Killing" (1956 film vs 2011 TV series).
|
|
163
|
-
// Prefer a Wikipedia-style disambiguated title when we have a year and the title is not already disambiguated.
|
|
164
|
-
const looksDisambiguated = /\([^)]*\)/.test(title);
|
|
165
|
-
const typeLower = String(effectiveType).toLowerCase();
|
|
166
|
-
const isFilmLike = /\b(film|movie)\b/.test(typeLower);
|
|
167
|
-
const isTvLike = /\b(tv|television|series|miniseries|show)\b/.test(typeLower);
|
|
168
|
-
const isScreenLike = isFilmLike || isTvLike;
|
|
169
|
-
const titleForImage =
|
|
170
|
-
!looksDisambiguated && isScreenLike && effectiveYear
|
|
171
|
-
? `${title} (${effectiveYear} ${isFilmLike ? "film" : "TV series"})`
|
|
172
|
-
: title;
|
|
173
|
-
const contextForImage =
|
|
174
|
-
effectiveYear && isScreenLike
|
|
175
|
-
? `${effectiveType} ${effectiveYear}`.trim()
|
|
176
|
-
: effectiveType;
|
|
177
|
-
|
|
178
|
-
const imageResult = await fetchServerImage(titleForImage, contextForImage, imageBaseUrl);
|
|
205
|
+
const imageBaseUrl = getImageApiBaseUrl(cacheBaseUrl);
|
|
206
|
+
const effectiveContext = context || current?.type || fallbackNode?.type;
|
|
207
|
+
const imageResult = await fetchServerImage(title, effectiveContext, imageBaseUrl);
|
|
179
208
|
if ((imageReqTokenRef.current.get(String(nodeId)) || 0) !== nextToken) return;
|
|
180
209
|
|
|
181
210
|
if (imageResult.url) {
|
|
@@ -236,7 +265,7 @@ export function useGraphState(options: UseGraphStateOptions) {
|
|
|
236
265
|
} catch { }
|
|
237
266
|
|
|
238
267
|
if (imageHint) {
|
|
239
|
-
const imageBaseUrl =
|
|
268
|
+
const imageBaseUrl = getImageApiBaseUrl(cacheBaseUrl);
|
|
240
269
|
const imageResult = await fetchServerImage(imageHint, node.type, imageBaseUrl);
|
|
241
270
|
if (imageResult.url) {
|
|
242
271
|
setGraphData(prev => ({
|
|
@@ -273,7 +302,7 @@ export function useGraphState(options: UseGraphStateOptions) {
|
|
|
273
302
|
return;
|
|
274
303
|
}
|
|
275
304
|
|
|
276
|
-
const imageBaseUrl =
|
|
305
|
+
const imageBaseUrl = getImageApiBaseUrl(cacheBaseUrl);
|
|
277
306
|
const serverResult = await fetchServerImage(node.title, node.type, imageBaseUrl);
|
|
278
307
|
if (serverResult.url) {
|
|
279
308
|
setGraphData(prev => ({
|
|
@@ -335,9 +364,15 @@ export function useGraphState(options: UseGraphStateOptions) {
|
|
|
335
364
|
// Endpoint returns array of { name, updated_at }
|
|
336
365
|
const serverGraphs = data.map((g: any) => g.name);
|
|
337
366
|
setSavedGraphs(serverGraphs.sort());
|
|
367
|
+
} else {
|
|
368
|
+
const body = await res.text();
|
|
369
|
+
console.warn(
|
|
370
|
+
`[constellations] GET /graphs failed: ${res.status}. Check cache server logs (e.g. DB connection / saved_graphs).`,
|
|
371
|
+
body.slice(0, 500)
|
|
372
|
+
);
|
|
338
373
|
}
|
|
339
374
|
} catch (e) {
|
|
340
|
-
|
|
375
|
+
console.warn("Failed to fetch saved graphs from server", e);
|
|
341
376
|
}
|
|
342
377
|
}
|
|
343
378
|
};
|
package/hooks/useKioskMode.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
"use client";
|
|
1
2
|
import { useCallback, useEffect, useRef } from 'react';
|
|
2
3
|
import { GraphNode, GraphLink } from '../types';
|
|
3
4
|
|
|
@@ -62,8 +63,30 @@ export const useNodeClickHandler = ({
|
|
|
62
63
|
onRetryImage?.(node);
|
|
63
64
|
onConnectSelect?.(node);
|
|
64
65
|
|
|
66
|
+
const isSecondClick = lastSelectedIdRef.current !== null && String(lastSelectedIdRef.current) === String(node.id);
|
|
65
67
|
const now = Date.now();
|
|
68
|
+
const isRepeatSameNode = lastClickRef.current !== null && String(lastClickRef.current.id) === String(node.id);
|
|
69
|
+
const lastClickAge = lastClickRef.current ? (now - lastClickRef.current.at) : null;
|
|
70
|
+
const isRapidSameNode = isRepeatSameNode && !!lastClickAge && lastClickAge < 800;
|
|
71
|
+
const isSelectedAgain = selectedNode !== null && String(selectedNode.id) === String(node.id);
|
|
66
72
|
const isDoubleClick = !!event && typeof event.detail === 'number' && event.detail >= 2;
|
|
73
|
+
if (isSecondClick || isRapidSameNode || isDoubleClick || isSelectedAgain || isRepeatSameNode) {
|
|
74
|
+
const pos = getMenuPosition
|
|
75
|
+
? getMenuPosition(node, event)
|
|
76
|
+
: {
|
|
77
|
+
x: event?.clientX ?? window.innerWidth / 2,
|
|
78
|
+
y: event?.clientY ?? window.innerHeight / 2
|
|
79
|
+
};
|
|
80
|
+
setContextMenu({ node, x: pos.x, y: pos.y });
|
|
81
|
+
onDebug?.(
|
|
82
|
+
`click: ${node.title} -> menu` +
|
|
83
|
+
` (second:${isSecondClick} rapid:${isRapidSameNode} dbl:${isDoubleClick}` +
|
|
84
|
+
` selected:${isSelectedAgain} repeat:${isRepeatSameNode}` +
|
|
85
|
+
`${lastClickAge !== null ? ` age:${lastClickAge}` : ""})`
|
|
86
|
+
);
|
|
87
|
+
lastClickRef.current = { id: node.id, at: now };
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
67
90
|
|
|
68
91
|
setContextMenu(null);
|
|
69
92
|
onClearSecondarySelection?.();
|
|
@@ -95,24 +118,9 @@ export const useNodeClickHandler = ({
|
|
|
95
118
|
onDebug?.(`highlight: ${node.title} + ${connectedNodeIds.length} connected nodes`);
|
|
96
119
|
}
|
|
97
120
|
|
|
98
|
-
/** Double-click on an already-expanded node opens the context menu (was unreachable next block). */
|
|
99
|
-
if (isDoubleClick && node.expanded && !node.isLoading) {
|
|
100
|
-
const pos = getMenuPosition
|
|
101
|
-
? getMenuPosition(node, event)
|
|
102
|
-
: { x: event?.clientX ?? window.innerWidth / 2, y: event?.clientY ?? window.innerHeight / 2 };
|
|
103
|
-
setContextMenu({ node, x: pos.x, y: pos.y });
|
|
104
|
-
onDebug?.(`click: ${node.title} -> menu (double-click)`);
|
|
105
|
-
lastClickRef.current = { id: node.id, at: now };
|
|
106
|
-
}
|
|
107
|
-
|
|
108
121
|
return;
|
|
109
122
|
}
|
|
110
123
|
|
|
111
|
-
/**
|
|
112
|
-
* Unexpanded leaf: both single-click and double-click expand.
|
|
113
|
-
* A lone programmatic/synthetic click with detail>=2 used to branch to the menu-only path and skip onExpand.
|
|
114
|
-
*/
|
|
115
|
-
|
|
116
124
|
if (selectOnFirstClick) {
|
|
117
125
|
setSelectedNode(node);
|
|
118
126
|
lastSelectedIdRef.current = node.id;
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
|
|
1
|
+
"use client";
|
|
2
|
+
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
|
2
3
|
import { GraphNode, GraphLink } from '../types';
|
|
3
|
-
import { classifyStartPair, fetchConnectionPath, LockedPair, classifyEntity, fetchConnections } from '../services/
|
|
4
|
+
import { classifyStartPair, fetchConnectionPath, LockedPair, classifyEntity, fetchConnections } from '../services/aiService';
|
|
4
5
|
import { fetchWikipediaSummary } from '../services/wikipediaService';
|
|
5
6
|
import { dedupeGraph, normalizeForDedup } from '../services/graphUtils';
|
|
6
7
|
import { clampToViewport } from '../utils/graphLogicUtils';
|
|
7
8
|
import { buildWikiUrl } from '../utils/wikiUtils';
|
|
9
|
+
import type { ConstellationsSessionHandoffV1 } from '../sessionHandoff';
|
|
8
10
|
|
|
9
11
|
interface PathResponse {
|
|
10
12
|
path: any[];
|
|
@@ -32,6 +34,7 @@ interface UseSearchHandlersOptions {
|
|
|
32
34
|
showControlPanel: boolean;
|
|
33
35
|
selectedKioskDomain: any;
|
|
34
36
|
graphRef: React.RefObject<any>;
|
|
37
|
+
initialSession?: ConstellationsSessionHandoffV1 | null;
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
export function useSearchHandlers(options: UseSearchHandlersOptions) {
|
|
@@ -40,15 +43,23 @@ export function useSearchHandlers(options: UseSearchHandlersOptions) {
|
|
|
40
43
|
setSearchId, searchIdRef, setLockedPair, dimensions,
|
|
41
44
|
cacheEnabled, cacheBaseUrl, loadNodeImage, fetchAndExpandNode,
|
|
42
45
|
setNotification, setSelectedNode, setSelectedLink, setPathNodeIds,
|
|
43
|
-
setPendingAutoExpandId, showControlPanel, selectedKioskDomain, graphRef
|
|
46
|
+
setPendingAutoExpandId, showControlPanel, selectedKioskDomain, graphRef,
|
|
47
|
+
initialSession: initialSessionOpt
|
|
44
48
|
} = options;
|
|
49
|
+
const initialSession = initialSessionOpt && initialSessionOpt.graph?.nodes?.length ? initialSessionOpt : null;
|
|
45
50
|
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
51
|
+
const dimensionsRef = useRef(dimensions);
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
dimensionsRef.current = dimensions;
|
|
54
|
+
}, [dimensions]);
|
|
55
|
+
|
|
56
|
+
const [exploreTerm, setExploreTerm] = useState(initialSession?.exploreTerm ?? '');
|
|
57
|
+
const [pathStart, setPathStart] = useState(initialSession?.pathStart ?? '');
|
|
58
|
+
const [pathEnd, setPathEnd] = useState(initialSession?.pathEnd ?? '');
|
|
49
59
|
|
|
50
60
|
const upsertNodeLocal = useCallback(async (title: string, type: string, description: string, wiki: any) => {
|
|
51
61
|
let nodeData: any = null;
|
|
62
|
+
let fromCacheServer = false;
|
|
52
63
|
if (cacheEnabled) {
|
|
53
64
|
try {
|
|
54
65
|
const res = await fetch(new URL("/node", cacheBaseUrl).toString(), {
|
|
@@ -63,6 +74,7 @@ export function useSearchHandlers(options: UseSearchHandlersOptions) {
|
|
|
63
74
|
});
|
|
64
75
|
if (res.ok) {
|
|
65
76
|
nodeData = await res.json();
|
|
77
|
+
fromCacheServer = true;
|
|
66
78
|
}
|
|
67
79
|
} catch (e) {
|
|
68
80
|
console.warn("Cache server unreachable", e);
|
|
@@ -78,6 +90,14 @@ export function useSearchHandlers(options: UseSearchHandlersOptions) {
|
|
|
78
90
|
wikipedia_id: wiki.pageid?.toString()
|
|
79
91
|
};
|
|
80
92
|
}
|
|
93
|
+
if (process.env.NODE_ENV === "development") {
|
|
94
|
+
console.log("[Constellations]", "upsertNodeLocal", {
|
|
95
|
+
title: title.trim().slice(0, 64),
|
|
96
|
+
fromCacheServer,
|
|
97
|
+
nodeId: nodeData.id,
|
|
98
|
+
wikipediaId: wiki.pageid ?? null,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
81
101
|
return nodeData;
|
|
82
102
|
}, [cacheEnabled, cacheBaseUrl]);
|
|
83
103
|
|
|
@@ -90,6 +110,21 @@ export function useSearchHandlers(options: UseSearchHandlersOptions) {
|
|
|
90
110
|
setPathNodeIds([]);
|
|
91
111
|
setSelectedLink(null);
|
|
92
112
|
|
|
113
|
+
if (process.env.NODE_ENV === "development") {
|
|
114
|
+
let host = "";
|
|
115
|
+
try {
|
|
116
|
+
if (cacheBaseUrl) host = new URL(cacheBaseUrl).host;
|
|
117
|
+
} catch {
|
|
118
|
+
host = "(invalid cacheBaseUrl)";
|
|
119
|
+
}
|
|
120
|
+
console.log("[Constellations]", "handleStartSearch", {
|
|
121
|
+
searchId: nextSearchId,
|
|
122
|
+
term: term.trim().slice(0, 80),
|
|
123
|
+
cacheEnabled,
|
|
124
|
+
cacheHost: host || null,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
93
128
|
try {
|
|
94
129
|
const startC = await classifyStartPair(term);
|
|
95
130
|
const chosenPair: LockedPair = { atomicType: startC.atomicType, compositeType: startC.compositeType };
|
|
@@ -110,22 +145,24 @@ export function useSearchHandlers(options: UseSearchHandlersOptions) {
|
|
|
110
145
|
|
|
111
146
|
const nodeData = await upsertNodeLocal(canonicalTitle, type, description || '', wiki);
|
|
112
147
|
|
|
148
|
+
const dim = dimensionsRef.current;
|
|
113
149
|
const startNode: GraphNode = {
|
|
150
|
+
...nodeData,
|
|
114
151
|
id: nodeData.id,
|
|
115
152
|
title: canonicalTitle,
|
|
153
|
+
// Fresh classification always wins over stale DB values.
|
|
116
154
|
type,
|
|
117
155
|
is_atomic: isAtomic,
|
|
118
156
|
wikipedia_id: wiki.pageid?.toString(),
|
|
119
157
|
description: wiki.extract || description || '',
|
|
120
|
-
x:
|
|
121
|
-
y:
|
|
158
|
+
x: dim.width / 2,
|
|
159
|
+
y: dim.height / 2,
|
|
122
160
|
expanded: false,
|
|
123
161
|
wikiSummary: wiki.extract || undefined,
|
|
124
162
|
classification_reasoning: reasoning,
|
|
125
163
|
atomic_type: chosenPair.atomicType,
|
|
126
164
|
composite_type: chosenPair.compositeType,
|
|
127
165
|
imageUrl: nodeData.imageUrl || nodeData.image_url,
|
|
128
|
-
...nodeData
|
|
129
166
|
};
|
|
130
167
|
|
|
131
168
|
setGraphData({ nodes: [startNode], links: [] });
|
|
@@ -140,7 +177,7 @@ export function useSearchHandlers(options: UseSearchHandlersOptions) {
|
|
|
140
177
|
} finally {
|
|
141
178
|
setIsProcessing(false);
|
|
142
179
|
}
|
|
143
|
-
}, [
|
|
180
|
+
}, [cacheEnabled, cacheBaseUrl, setGraphData, setIsProcessing, setError, setSearchId, searchIdRef, setLockedPair, loadNodeImage, fetchAndExpandNode, setSelectedNode, setSelectedLink, setPathNodeIds, setPendingAutoExpandId, showControlPanel, selectedKioskDomain, upsertNodeLocal]);
|
|
144
181
|
|
|
145
182
|
const handlePathSearch = useCallback(async (start: string, end: string) => {
|
|
146
183
|
setIsProcessing(true);
|
|
@@ -166,10 +203,11 @@ export function useSearchHandlers(options: UseSearchHandlersOptions) {
|
|
|
166
203
|
upsertNodeLocal(end, endC.type, endC.description || '', endWiki)
|
|
167
204
|
]);
|
|
168
205
|
|
|
206
|
+
const d = dimensionsRef.current;
|
|
169
207
|
const startNode: GraphNode = {
|
|
170
208
|
id: startNodeData.id, title: start.trim(), type: startC.type, is_atomic: startC.isAtomic,
|
|
171
209
|
wikipedia_id: startWiki.pageid?.toString(), description: startWiki.extract || startC.description || '',
|
|
172
|
-
x:
|
|
210
|
+
x: d.width / 4, y: d.height / 2, fx: d.width / 4, fy: d.height / 2,
|
|
173
211
|
expanded: false, wikiSummary: startWiki.extract || undefined,
|
|
174
212
|
imageUrl: startNodeData.imageUrl || startNodeData.image_url,
|
|
175
213
|
...startNodeData
|
|
@@ -178,7 +216,7 @@ export function useSearchHandlers(options: UseSearchHandlersOptions) {
|
|
|
178
216
|
const endNode: GraphNode = {
|
|
179
217
|
id: endNodeData.id, title: end.trim(), type: endC.type, is_atomic: endC.isAtomic,
|
|
180
218
|
wikipedia_id: endWiki.pageid?.toString(), description: endWiki.extract || endC.description || '',
|
|
181
|
-
x: (
|
|
219
|
+
x: (d.width * 3) / 4, y: d.height / 2, fx: (d.width * 3) / 4, fy: d.height / 2,
|
|
182
220
|
expanded: false, wikiSummary: endWiki.extract || undefined,
|
|
183
221
|
imageUrl: endNodeData.imageUrl || endNodeData.image_url,
|
|
184
222
|
...endNodeData
|
|
@@ -223,18 +261,19 @@ export function useSearchHandlers(options: UseSearchHandlersOptions) {
|
|
|
223
261
|
if (isDbPath) {
|
|
224
262
|
const dbNodes = pathData.path as any[];
|
|
225
263
|
dbNodes.forEach(n => pathNodeIdsList.push(n.id));
|
|
226
|
-
|
|
264
|
+
setGraphData(current => {
|
|
265
|
+
const dim = dimensionsRef.current;
|
|
227
266
|
const updatedNodes = [...current.nodes];
|
|
228
267
|
const updatedLinks = [...current.links];
|
|
229
268
|
dbNodes.forEach((dbNode, i) => {
|
|
230
269
|
let existingNode = updatedNodes.find(n => String(n.id) === String(dbNode.id));
|
|
231
270
|
if (!existingNode) {
|
|
232
|
-
const nodeX = i === 0 ? (startNode.x ||
|
|
233
|
-
const nodeY = i === 0 ? (startNode.y ||
|
|
271
|
+
const nodeX = i === 0 ? (startNode.x || dim.width / 4) : (updatedNodes[i - 1]?.x || dim.width / 2) + (Math.random() - 0.5) * 150;
|
|
272
|
+
const nodeY = i === 0 ? (startNode.y || dim.height / 2) : (updatedNodes[i - 1]?.y || dim.height / 2) + (Math.random() - 0.5) * 150;
|
|
234
273
|
const clamped = clampToViewport(nodeX, nodeY, 80);
|
|
235
|
-
|
|
236
|
-
updatedNodes.push(
|
|
237
|
-
loadNodeImage(dbNode.id,
|
|
274
|
+
const created: GraphNode = { id: dbNode.id, title: dbNode.title, type: dbNode.type, x: clamped.x, y: clamped.y, fx: clamped.x, fy: clamped.y, expanded: false, ...dbNode };
|
|
275
|
+
updatedNodes.push(created);
|
|
276
|
+
loadNodeImage(dbNode.id, created.title);
|
|
238
277
|
}
|
|
239
278
|
});
|
|
240
279
|
for (let i = 0; i < dbNodes.length - 1; i++) {
|
|
@@ -289,7 +328,7 @@ export function useSearchHandlers(options: UseSearchHandlersOptions) {
|
|
|
289
328
|
loadNodeImage(toId, newNode.title);
|
|
290
329
|
// CRITICAL: Dedupe immediately so that if this node merged with an existing one,
|
|
291
330
|
// we know the correct ID for the next link in the chain.
|
|
292
|
-
return dedupeGraph(updatedNodes, updatedLinks);
|
|
331
|
+
return dedupeGraph(updatedNodes, updatedLinks as GraphLink[]);
|
|
293
332
|
});
|
|
294
333
|
|
|
295
334
|
// Wait a moment for state to settle, then find the RESOLVED id of the node we just added.
|
|
@@ -355,7 +394,7 @@ export function useSearchHandlers(options: UseSearchHandlersOptions) {
|
|
|
355
394
|
}));
|
|
356
395
|
setPathNodeIds([...finalPathIds]);
|
|
357
396
|
setNotification({ message: "Path discovery complete!", type: 'success' });
|
|
358
|
-
if (finalPathIds.length) setTimeout(() => graphRef.current?.
|
|
397
|
+
if (finalPathIds.length) setTimeout(() => graphRef.current?.fitGraphInView(), 200);
|
|
359
398
|
|
|
360
399
|
} catch (e) {
|
|
361
400
|
console.error("Path error:", e);
|
|
@@ -363,7 +402,7 @@ export function useSearchHandlers(options: UseSearchHandlersOptions) {
|
|
|
363
402
|
} finally {
|
|
364
403
|
setIsProcessing(false);
|
|
365
404
|
}
|
|
366
|
-
}, [
|
|
405
|
+
}, [cacheEnabled, cacheBaseUrl, setGraphData, setIsProcessing, setError, setSearchId, searchIdRef, setNotification, loadNodeImage, fetchAndExpandNode, setSelectedNode, setPathNodeIds, graphRef, upsertNodeLocal]);
|
|
367
406
|
|
|
368
407
|
return { exploreTerm, setExploreTerm, pathStart, setPathStart, pathEnd, setPathEnd, handleStartSearch, handlePathSearch };
|
|
369
408
|
}
|
package/host.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Single import surface for full-page constellations inside
|
|
4
|
+
* Single import surface for full-page constellations inside Next hosts (Soundings, Trailer, …).
|
|
5
5
|
* — `FullPageConstellations` (layout + App wiring)
|
|
6
6
|
* — `useFullPageConstellationsHost` (URL + optional player bridge)
|
|
7
7
|
* — `newChannelFromGraphNode` (sessionStorage + navigate)
|
package/index.css
CHANGED
|
@@ -4,9 +4,13 @@
|
|
|
4
4
|
box-sizing: border-box;
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Full-viewport shell for the standalone Vite app only. When constellations is embedded
|
|
9
|
+
* (e.g. Soundings /player), we must NOT lock document scroll — see App.tsx toggling
|
|
10
|
+
* `constellations-standalone` on <html>.
|
|
11
|
+
*/
|
|
12
|
+
html.constellations-standalone,
|
|
13
|
+
html.constellations-standalone body {
|
|
10
14
|
position: fixed;
|
|
11
15
|
inset: 0;
|
|
12
16
|
width: 100%;
|
|
@@ -17,6 +21,16 @@ body,
|
|
|
17
21
|
overflow: hidden !important;
|
|
18
22
|
}
|
|
19
23
|
|
|
24
|
+
html.constellations-standalone #root {
|
|
25
|
+
position: fixed;
|
|
26
|
+
inset: 0;
|
|
27
|
+
width: 100%;
|
|
28
|
+
height: 100%;
|
|
29
|
+
margin: 0;
|
|
30
|
+
padding: 0;
|
|
31
|
+
overflow: hidden !important;
|
|
32
|
+
}
|
|
33
|
+
|
|
20
34
|
body {
|
|
21
35
|
font-family: 'Inter', sans-serif;
|
|
22
36
|
background-color: #0f172a;
|
package/index.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import ReactDOM from 'react-dom/client';
|
|
3
|
-
import App from '
|
|
3
|
+
import App from '@johndimm/constellations/App';
|
|
4
4
|
import './index.css';
|
|
5
5
|
|
|
6
6
|
const rootElement = document.getElementById('root');
|
|
@@ -9,8 +9,10 @@ if (!rootElement) {
|
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
const root = ReactDOM.createRoot(rootElement);
|
|
12
|
+
const hubUrl: string = import.meta.env.VITE_HUB_URL || "http://127.0.0.1:8000";
|
|
13
|
+
|
|
12
14
|
root.render(
|
|
13
15
|
<React.StrictMode>
|
|
14
|
-
<App />
|
|
16
|
+
<App closeHref={hubUrl} />
|
|
15
17
|
</React.StrictMode>
|
|
16
|
-
);
|
|
18
|
+
);
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@johndimm/constellations",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./index.tsx",
|
|
6
6
|
"exports": {
|
|
7
|
-
".": "./
|
|
7
|
+
".": "./host.ts",
|
|
8
8
|
"./App": "./App.tsx",
|
|
9
9
|
"./FullPageConstellations": "./FullPageConstellations.tsx",
|
|
10
10
|
"./host": "./host.ts",
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"@google/genai": "^1.33.0",
|
|
55
|
+
"@johndimm/constellations": "^1.0.2",
|
|
55
56
|
"d3": "^7.9.0",
|
|
56
57
|
"dotenv": "^16.4.5",
|
|
57
58
|
"lucide-react": "^0.560.0"
|
|
@@ -60,6 +61,7 @@
|
|
|
60
61
|
"@tailwindcss/postcss": "^4.1.18",
|
|
61
62
|
"@tailwindcss/vite": "^4.1.18",
|
|
62
63
|
"@types/chrome": "^0.1.36",
|
|
64
|
+
"@types/d3": "^7.4.3",
|
|
63
65
|
"@types/node": "^22.14.0",
|
|
64
66
|
"@vitejs/plugin-react": "^5.0.0",
|
|
65
67
|
"autoprefixer": "^10.4.23",
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider dispatcher. gemini → geminiService; everything else →
|
|
3
|
+
* deepseekService (which re-reads getLlmProvider() inside callAltLlm
|
|
4
|
+
* to pick the right API: deepseek / openai / anthropic).
|
|
5
|
+
*/
|
|
6
|
+
import { getLlmProvider } from "./aiUtils";
|
|
7
|
+
import * as geminiSvc from "./geminiService";
|
|
8
|
+
import * as altSvc from "./deepseekService"; // handles deepseek, openai, anthropic
|
|
9
|
+
|
|
10
|
+
export * from "./aiUtils";
|
|
11
|
+
export type { LockedPair } from "./geminiService";
|
|
12
|
+
|
|
13
|
+
function getSvc(fn: string) {
|
|
14
|
+
const p = getLlmProvider();
|
|
15
|
+
console.info(`[LLM] ${p} · ${fn}`);
|
|
16
|
+
return p === "gemini" ? geminiSvc : altSvc;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const classifyStartPair = (...args: Parameters<typeof geminiSvc.classifyStartPair>) => getSvc("classifyStartPair").classifyStartPair(...args);
|
|
20
|
+
export const classifyEntity = (...args: Parameters<typeof geminiSvc.classifyEntity>) => getSvc("classifyEntity").classifyEntity(...args);
|
|
21
|
+
export const fetchConnections = (...args: Parameters<typeof geminiSvc.fetchConnections>) => getSvc("fetchConnections").fetchConnections(...args);
|
|
22
|
+
export const fetchPersonWorks = (...args: Parameters<typeof geminiSvc.fetchPersonWorks>) => getSvc("fetchPersonWorks").fetchPersonWorks(...args);
|
|
23
|
+
export const fetchConnectionPath = (...args: Parameters<typeof geminiSvc.fetchConnectionPath>) => getSvc("fetchConnectionPath").fetchConnectionPath(...args);
|
|
24
|
+
export const findWikipediaTitle = (...args: Parameters<typeof geminiSvc.findWikipediaTitle>) => getSvc("findWikipediaTitle").findWikipediaTitle(...args);
|
|
25
|
+
// Always uses Gemini — relies on Google Search grounding which is Gemini-specific.
|
|
26
|
+
export const fetchOrgKeyPeopleBlockViaSearch = geminiSvc.fetchOrgKeyPeopleBlockViaSearch;
|
|
27
|
+
export const defaultStartPairResult = (...args: Parameters<typeof geminiSvc.defaultStartPairResult>) => getSvc("defaultStartPairResult").defaultStartPairResult(...args);
|