@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.
Files changed (38) hide show
  1. package/App.tsx +352 -70
  2. package/FullPageConstellations.tsx +7 -4
  3. package/components/AppConfirmDialog.tsx +1 -0
  4. package/components/AppHeader.tsx +69 -29
  5. package/components/AppNotifications.tsx +1 -0
  6. package/components/BrowsePeople.tsx +3 -0
  7. package/components/ControlPanel.tsx +46 -371
  8. package/components/Graph.tsx +251 -87
  9. package/components/HelpOverlay.tsx +1 -0
  10. package/components/NodeContextMenu.tsx +123 -3
  11. package/components/PeopleBrowserSidebar.tsx +15 -6
  12. package/components/Sidebar.tsx +46 -19
  13. package/components/TimelineView.tsx +1 -0
  14. package/hooks/useExpansion.ts +61 -229
  15. package/hooks/useGraphActions.ts +1 -0
  16. package/hooks/useGraphState.ts +75 -40
  17. package/hooks/useKioskMode.ts +1 -0
  18. package/hooks/useNodeClickHandler.ts +23 -15
  19. package/hooks/useSearchHandlers.ts +57 -19
  20. package/host.ts +1 -1
  21. package/index.css +17 -3
  22. package/package.json +2 -1
  23. package/services/aiService.ts +23 -0
  24. package/services/aiUtils.ts +216 -207
  25. package/services/cacheService.ts +1 -0
  26. package/services/crossrefService.ts +1 -0
  27. package/services/deepseekService.ts +467 -0
  28. package/services/geminiService.ts +532 -733
  29. package/services/graphUtils.ts +128 -18
  30. package/services/imageService.ts +18 -0
  31. package/services/openAlexService.ts +1 -0
  32. package/services/resolveImageForTitle.ts +458 -0
  33. package/services/wikipediaImage.ts +1 -0
  34. package/services/wikipediaService.ts +56 -46
  35. package/types.ts +3 -0
  36. package/utils/evidenceUtils.ts +1 -0
  37. package/utils/graphLogicUtils.ts +1 -0
  38. package/utils/wikiUtils.ts +14 -2
@@ -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[] }>({ nodes: [], links: [] });
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(false);
29
- const [isTimelineMode, setIsTimelineMode] = useState(false);
30
- const [isTextOnly, setIsTextOnly] = useState(false);
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>({ atomicType: "Person", compositeType: "Event" });
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({ width: window.innerWidth, height: window.innerHeight });
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
- const handleResize = () => setDimensions({ width: window.innerWidth, height: window.innerHeight });
83
- window.addEventListener('resize', handleResize);
84
- return () => window.removeEventListener('resize', handleResize);
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 = cacheEnabled ? cacheBaseUrl : window.location.origin;
159
- const effectiveType = context || current?.type || fallbackNode?.type || "";
160
- const effectiveYear = (current?.year ?? (fallbackNode as any)?.year) as number | undefined;
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 = cacheEnabled ? cacheBaseUrl : window.location.origin;
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 = cacheEnabled ? cacheBaseUrl : window.location.origin;
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
- // console.warn("Failed to fetch saved graphs from server", e);
375
+ console.warn("Failed to fetch saved graphs from server", e);
341
376
  }
342
377
  }
343
378
  };
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  import { useState, useEffect } from 'react';
2
3
  import {
3
4
  KioskDomain,
@@ -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
- import React, { useState, useCallback } from 'react';
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 [exploreTerm, setExploreTerm] = useState('');
47
- const [pathStart, setPathStart] = useState('');
48
- const [pathEnd, setPathEnd] = useState('');
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: dimensions.width / 2,
121
- y: dimensions.height / 2,
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
- }, [dimensions, cacheEnabled, cacheBaseUrl, setGraphData, setIsProcessing, setError, setSearchId, searchIdRef, setLockedPair, loadNodeImage, fetchAndExpandNode, setSelectedNode, setSelectedLink, setPathNodeIds, setPendingAutoExpandId, showControlPanel, selectedKioskDomain, upsertNodeLocal]);
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: dimensions.width / 4, y: dimensions.height / 2, fx: dimensions.width / 4, fy: dimensions.height / 2,
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: (dimensions.width * 3) / 4, y: dimensions.height / 2, fx: (dimensions.width * 3) / 4, fy: dimensions.height / 2,
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
- setGraphData(current => {
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 || dimensions.width / 4) : (updatedNodes[i - 1]?.x || dimensions.width / 2) + (Math.random() - 0.5) * 150;
233
- const nodeY = i === 0 ? (startNode.y || dimensions.height / 2) : (updatedNodes[i - 1]?.y || dimensions.height / 2) + (Math.random() - 0.5) * 150;
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
- existingNode = { id: dbNode.id, title: dbNode.title, type: dbNode.type, x: clamped.x, y: clamped.y, fx: clamped.x, fy: clamped.y, expanded: false, ...dbNode };
236
- updatedNodes.push(existingNode);
237
- loadNodeImage(dbNode.id, existingNode.title);
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?.centerOnNode(finalPathIds[Math.floor(finalPathIds.length / 2)]), 200);
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
- }, [dimensions, cacheEnabled, cacheBaseUrl, setGraphData, setIsProcessing, setError, setSearchId, searchIdRef, setNotification, loadNodeImage, fetchAndExpandNode, setSelectedNode, setPathNodeIds, graphRef, upsertNodeLocal]);
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 host apps (Soundings, Trailer Vision, …).
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
- html,
8
- body,
9
- #root {
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.1",
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);