@johndimm/constellations 1.0.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/App.tsx +360 -66
  2. package/FullPageConstellations.tsx +7 -4
  3. package/components/AppConfirmDialog.tsx +1 -0
  4. package/components/AppHeader.tsx +67 -30
  5. package/components/AppNotifications.tsx +1 -0
  6. package/components/BrowsePeople.tsx +3 -0
  7. package/components/ControlPanel.tsx +229 -250
  8. package/components/Graph.tsx +251 -87
  9. package/components/HelpOverlay.tsx +2 -1
  10. package/components/NodeContextMenu.tsx +123 -3
  11. package/components/PeopleBrowserSidebar.tsx +15 -6
  12. package/components/Sidebar.tsx +46 -19
  13. package/components/TimelineView.tsx +1 -0
  14. package/hooks/useExpansion.ts +85 -230
  15. package/hooks/useGraphActions.ts +1 -0
  16. package/hooks/useGraphState.ts +75 -40
  17. package/hooks/useKioskMode.ts +1 -0
  18. package/hooks/useNodeClickHandler.ts +23 -15
  19. package/hooks/useSearchHandlers.ts +60 -21
  20. package/host.ts +1 -1
  21. package/index.css +17 -3
  22. package/index.tsx +5 -3
  23. package/package.json +4 -2
  24. package/services/aiService.ts +27 -0
  25. package/services/aiUtils.ts +285 -195
  26. package/services/cacheService.ts +1 -0
  27. package/services/crossrefService.ts +1 -0
  28. package/services/deepseekService.ts +479 -0
  29. package/services/geminiService.ts +543 -736
  30. package/services/graphUtils.ts +128 -18
  31. package/services/imageService.ts +18 -0
  32. package/services/openAlexService.ts +1 -0
  33. package/services/resolveImageForTitle.ts +458 -0
  34. package/services/wikipediaImage.ts +1 -0
  35. package/services/wikipediaService.ts +79 -49
  36. package/sessionHandoff.ts +26 -0
  37. package/types.ts +3 -0
  38. package/utils/evidenceUtils.ts +1 -0
  39. package/utils/graphLogicUtils.ts +1 -0
  40. package/utils/wikiUtils.ts +14 -2
@@ -1,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
- import { classifyStartPair, fetchConnectionPath, LockedPair, classifyEntity, fetchConnections } from '../services/geminiService';
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 [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,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: dimensions.width / 2,
121
- y: dimensions.height / 2,
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
- }, [dimensions, cacheEnabled, cacheBaseUrl, setGraphData, setIsProcessing, setError, setSearchId, searchIdRef, setLockedPair, loadNodeImage, fetchAndExpandNode, setSelectedNode, setSelectedLink, setPathNodeIds, setPendingAutoExpandId, showControlPanel, selectedKioskDomain, upsertNodeLocal]);
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: dimensions.width / 4, y: dimensions.height / 2, fx: dimensions.width / 4, fy: dimensions.height / 2,
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: (dimensions.width * 3) / 4, y: dimensions.height / 2, fx: (dimensions.width * 3) / 4, fy: dimensions.height / 2,
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
- setGraphData(current => {
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 || 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;
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
- 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);
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?.centerOnNode(finalPathIds[Math.floor(finalPathIds.length / 2)]), 200);
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
- }, [dimensions, cacheEnabled, cacheBaseUrl, setGraphData, setIsProcessing, setError, setSearchId, searchIdRef, setNotification, loadNodeImage, fetchAndExpandNode, setSelectedNode, setPathNodeIds, graphRef, upsertNodeLocal]);
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 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/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 './App';
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.1",
3
+ "version": "1.0.3",
4
4
  "type": "module",
5
5
  "main": "./index.tsx",
6
6
  "exports": {
7
- ".": "./index.tsx",
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);