@johndimm/constellations 1.0.1 → 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 -4
- 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/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 +2 -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/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
4
|
import { classifyStartPair, fetchConnectionPath, LockedPair, classifyEntity, fetchConnections } from '../services/geminiService';
|
|
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,6 +145,7 @@ 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 = {
|
|
114
150
|
id: nodeData.id,
|
|
115
151
|
title: canonicalTitle,
|
|
@@ -117,8 +153,8 @@ export function useSearchHandlers(options: UseSearchHandlersOptions) {
|
|
|
117
153
|
is_atomic: isAtomic,
|
|
118
154
|
wikipedia_id: wiki.pageid?.toString(),
|
|
119
155
|
description: wiki.extract || description || '',
|
|
120
|
-
x:
|
|
121
|
-
y:
|
|
156
|
+
x: dim.width / 2,
|
|
157
|
+
y: dim.height / 2,
|
|
122
158
|
expanded: false,
|
|
123
159
|
wikiSummary: wiki.extract || undefined,
|
|
124
160
|
classification_reasoning: reasoning,
|
|
@@ -140,7 +176,7 @@ export function useSearchHandlers(options: UseSearchHandlersOptions) {
|
|
|
140
176
|
} finally {
|
|
141
177
|
setIsProcessing(false);
|
|
142
178
|
}
|
|
143
|
-
}, [
|
|
179
|
+
}, [cacheEnabled, cacheBaseUrl, setGraphData, setIsProcessing, setError, setSearchId, searchIdRef, setLockedPair, loadNodeImage, fetchAndExpandNode, setSelectedNode, setSelectedLink, setPathNodeIds, setPendingAutoExpandId, showControlPanel, selectedKioskDomain, upsertNodeLocal]);
|
|
144
180
|
|
|
145
181
|
const handlePathSearch = useCallback(async (start: string, end: string) => {
|
|
146
182
|
setIsProcessing(true);
|
|
@@ -166,10 +202,11 @@ export function useSearchHandlers(options: UseSearchHandlersOptions) {
|
|
|
166
202
|
upsertNodeLocal(end, endC.type, endC.description || '', endWiki)
|
|
167
203
|
]);
|
|
168
204
|
|
|
205
|
+
const d = dimensionsRef.current;
|
|
169
206
|
const startNode: GraphNode = {
|
|
170
207
|
id: startNodeData.id, title: start.trim(), type: startC.type, is_atomic: startC.isAtomic,
|
|
171
208
|
wikipedia_id: startWiki.pageid?.toString(), description: startWiki.extract || startC.description || '',
|
|
172
|
-
x:
|
|
209
|
+
x: d.width / 4, y: d.height / 2, fx: d.width / 4, fy: d.height / 2,
|
|
173
210
|
expanded: false, wikiSummary: startWiki.extract || undefined,
|
|
174
211
|
imageUrl: startNodeData.imageUrl || startNodeData.image_url,
|
|
175
212
|
...startNodeData
|
|
@@ -178,7 +215,7 @@ export function useSearchHandlers(options: UseSearchHandlersOptions) {
|
|
|
178
215
|
const endNode: GraphNode = {
|
|
179
216
|
id: endNodeData.id, title: end.trim(), type: endC.type, is_atomic: endC.isAtomic,
|
|
180
217
|
wikipedia_id: endWiki.pageid?.toString(), description: endWiki.extract || endC.description || '',
|
|
181
|
-
x: (
|
|
218
|
+
x: (d.width * 3) / 4, y: d.height / 2, fx: (d.width * 3) / 4, fy: d.height / 2,
|
|
182
219
|
expanded: false, wikiSummary: endWiki.extract || undefined,
|
|
183
220
|
imageUrl: endNodeData.imageUrl || endNodeData.image_url,
|
|
184
221
|
...endNodeData
|
|
@@ -223,18 +260,19 @@ export function useSearchHandlers(options: UseSearchHandlersOptions) {
|
|
|
223
260
|
if (isDbPath) {
|
|
224
261
|
const dbNodes = pathData.path as any[];
|
|
225
262
|
dbNodes.forEach(n => pathNodeIdsList.push(n.id));
|
|
226
|
-
|
|
263
|
+
setGraphData(current => {
|
|
264
|
+
const dim = dimensionsRef.current;
|
|
227
265
|
const updatedNodes = [...current.nodes];
|
|
228
266
|
const updatedLinks = [...current.links];
|
|
229
267
|
dbNodes.forEach((dbNode, i) => {
|
|
230
268
|
let existingNode = updatedNodes.find(n => String(n.id) === String(dbNode.id));
|
|
231
269
|
if (!existingNode) {
|
|
232
|
-
const nodeX = i === 0 ? (startNode.x ||
|
|
233
|
-
const nodeY = i === 0 ? (startNode.y ||
|
|
270
|
+
const nodeX = i === 0 ? (startNode.x || dim.width / 4) : (updatedNodes[i - 1]?.x || dim.width / 2) + (Math.random() - 0.5) * 150;
|
|
271
|
+
const nodeY = i === 0 ? (startNode.y || dim.height / 2) : (updatedNodes[i - 1]?.y || dim.height / 2) + (Math.random() - 0.5) * 150;
|
|
234
272
|
const clamped = clampToViewport(nodeX, nodeY, 80);
|
|
235
|
-
|
|
236
|
-
updatedNodes.push(
|
|
237
|
-
loadNodeImage(dbNode.id,
|
|
273
|
+
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 };
|
|
274
|
+
updatedNodes.push(created);
|
|
275
|
+
loadNodeImage(dbNode.id, created.title);
|
|
238
276
|
}
|
|
239
277
|
});
|
|
240
278
|
for (let i = 0; i < dbNodes.length - 1; i++) {
|
|
@@ -289,7 +327,7 @@ export function useSearchHandlers(options: UseSearchHandlersOptions) {
|
|
|
289
327
|
loadNodeImage(toId, newNode.title);
|
|
290
328
|
// CRITICAL: Dedupe immediately so that if this node merged with an existing one,
|
|
291
329
|
// we know the correct ID for the next link in the chain.
|
|
292
|
-
return dedupeGraph(updatedNodes, updatedLinks);
|
|
330
|
+
return dedupeGraph(updatedNodes, updatedLinks as GraphLink[]);
|
|
293
331
|
});
|
|
294
332
|
|
|
295
333
|
// Wait a moment for state to settle, then find the RESOLVED id of the node we just added.
|
|
@@ -355,7 +393,7 @@ export function useSearchHandlers(options: UseSearchHandlersOptions) {
|
|
|
355
393
|
}));
|
|
356
394
|
setPathNodeIds([...finalPathIds]);
|
|
357
395
|
setNotification({ message: "Path discovery complete!", type: 'success' });
|
|
358
|
-
if (finalPathIds.length) setTimeout(() => graphRef.current?.
|
|
396
|
+
if (finalPathIds.length) setTimeout(() => graphRef.current?.fitGraphInView(), 200);
|
|
359
397
|
|
|
360
398
|
} catch (e) {
|
|
361
399
|
console.error("Path error:", e);
|
|
@@ -363,7 +401,7 @@ export function useSearchHandlers(options: UseSearchHandlersOptions) {
|
|
|
363
401
|
} finally {
|
|
364
402
|
setIsProcessing(false);
|
|
365
403
|
}
|
|
366
|
-
}, [
|
|
404
|
+
}, [cacheEnabled, cacheBaseUrl, setGraphData, setIsProcessing, setError, setSearchId, searchIdRef, setNotification, loadNodeImage, fetchAndExpandNode, setSelectedNode, setPathNodeIds, graphRef, upsertNodeLocal]);
|
|
367
405
|
|
|
368
406
|
return { exploreTerm, setExploreTerm, pathStart, setPathStart, pathEnd, setPathEnd, handleStartSearch, handlePathSearch };
|
|
369
407
|
}
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@johndimm/constellations",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./index.tsx",
|
|
6
6
|
"exports": {
|
|
@@ -60,6 +60,7 @@
|
|
|
60
60
|
"@tailwindcss/postcss": "^4.1.18",
|
|
61
61
|
"@tailwindcss/vite": "^4.1.18",
|
|
62
62
|
"@types/chrome": "^0.1.36",
|
|
63
|
+
"@types/d3": "^7.4.3",
|
|
63
64
|
"@types/node": "^22.14.0",
|
|
64
65
|
"@vitejs/plugin-react": "^5.0.0",
|
|
65
66
|
"autoprefixer": "^10.4.23",
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider dispatcher. Set VITE_AI_PROVIDER=deepseek (or gemini) in .env.local.
|
|
3
|
+
* Defaults to gemini when unset.
|
|
4
|
+
*/
|
|
5
|
+
import { readBundledEnv } from "./aiUtils";
|
|
6
|
+
import * as gemini from "./geminiService";
|
|
7
|
+
import * as deepseek from "./deepseekService";
|
|
8
|
+
|
|
9
|
+
export * from "./aiUtils";
|
|
10
|
+
export type { LockedPair } from "./geminiService";
|
|
11
|
+
|
|
12
|
+
const isDeepSeek = (readBundledEnv("VITE_AI_PROVIDER") || "gemini").toLowerCase() === "deepseek";
|
|
13
|
+
const svc = isDeepSeek ? deepseek : gemini;
|
|
14
|
+
|
|
15
|
+
export const classifyStartPair = (...args: Parameters<typeof svc.classifyStartPair>) => svc.classifyStartPair(...args);
|
|
16
|
+
export const classifyEntity = (...args: Parameters<typeof svc.classifyEntity>) => svc.classifyEntity(...args);
|
|
17
|
+
export const fetchConnections = (...args: Parameters<typeof svc.fetchConnections>) => svc.fetchConnections(...args);
|
|
18
|
+
export const fetchPersonWorks = (...args: Parameters<typeof svc.fetchPersonWorks>) => svc.fetchPersonWorks(...args);
|
|
19
|
+
export const fetchConnectionPath = (...args: Parameters<typeof svc.fetchConnectionPath>) => svc.fetchConnectionPath(...args);
|
|
20
|
+
export const findWikipediaTitle = (...args: Parameters<typeof svc.findWikipediaTitle>) => svc.findWikipediaTitle(...args);
|
|
21
|
+
// Always uses Gemini — relies on Google Search grounding which is Gemini-specific.
|
|
22
|
+
export const fetchOrgKeyPeopleBlockViaSearch = gemini.fetchOrgKeyPeopleBlockViaSearch;
|
|
23
|
+
export const defaultStartPairResult = (...args: Parameters<typeof svc.defaultStartPairResult>) => svc.defaultStartPairResult(...args);
|