@johndimm/constellations 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/App.tsx +352 -70
  2. package/FullPageConstellations.tsx +7 -5
  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/embedded.css +38 -0
  15. package/hooks/useExpansion.ts +61 -229
  16. package/hooks/useGraphActions.ts +1 -0
  17. package/hooks/useGraphState.ts +75 -40
  18. package/hooks/useKioskMode.ts +1 -0
  19. package/hooks/useNodeClickHandler.ts +23 -15
  20. package/hooks/useSearchHandlers.ts +57 -19
  21. package/host.ts +1 -1
  22. package/index.css +17 -3
  23. package/package.json +4 -1
  24. package/services/aiService.ts +23 -0
  25. package/services/aiUtils.ts +216 -207
  26. package/services/cacheService.ts +1 -0
  27. package/services/crossrefService.ts +1 -0
  28. package/services/deepseekService.ts +467 -0
  29. package/services/geminiService.ts +532 -733
  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 +56 -46
  36. package/types.ts +3 -0
  37. package/utils/evidenceUtils.ts +1 -0
  38. package/utils/graphLogicUtils.ts +1 -0
  39. package/utils/wikiUtils.ts +14 -2
@@ -1,6 +1,7 @@
1
- import React, { useCallback, useRef } from 'react';
1
+ "use client";
2
+ import React, { useState, useCallback } from 'react';
2
3
  import { GraphNode, GraphLink } from '../types';
3
- import { fetchConnections, fetchPersonWorks, classifyEntity, fetchOrgKeyPeopleBlockViaSearch, LockedPair } from '../services/geminiService';
4
+ import { fetchConnections, fetchPersonWorks, classifyEntity, fetchOrgKeyPeopleBlockViaSearch, LockedPair } from '../services/aiService';
4
5
  import { fetchWikipediaSummary, fetchWikipediaExtract, fetchWikidataKeyPeopleForTitle, fetchWikidataCastForTitle } from '../services/wikipediaService';
5
6
  import {
6
7
  searchOpenAlexAuthor,
@@ -24,13 +25,6 @@ import {
24
25
  roleLooksLikeJobTitle
25
26
  } from '../utils/evidenceUtils';
26
27
  import { getLinkKey, looksLikeScreenWork, isBadListPage } from '../utils/graphLogicUtils';
27
- import { fetchWithTimeout, withTimeout } from '../services/aiUtils';
28
- import { clipForLlmLog } from '../services/aiUtils';
29
-
30
- const WIKI_SUMMARY_TIMEOUT_MS = 15000;
31
- /** Hung cache PostgreSQL / slow disk should not strand node spinners indefinitely. */
32
- const CACHE_GET_TIMEOUT_MS = 25_000;
33
- const CACHE_POST_TIMEOUT_MS = 90_000;
34
28
 
35
29
  interface UseExpansionOptions {
36
30
  graphDataRef: React.MutableRefObject<{ nodes: GraphNode[], links: GraphLink[] }>;
@@ -41,12 +35,12 @@ interface UseExpansionOptions {
41
35
  lockedPairRef: React.MutableRefObject<LockedPair>;
42
36
  nodesRef: React.MutableRefObject<GraphNode[]>;
43
37
  selectedNodeRef: React.MutableRefObject<GraphNode | null>;
44
- autoExpandMoreDoneRef: React.MutableRefObject<Set<number>>;
38
+ autoExpandMoreDoneRef: React.MutableRefObject<Set<string | number>>;
45
39
  cacheEnabled: boolean;
46
40
  cacheBaseUrl: string;
47
41
  ENABLE_ACADEMIC_CORPORA: boolean;
48
42
  ENABLE_WEB_SEARCH: boolean;
49
- loadNodeImage: (nodeId: number, title: string, context?: string, fallbackNode?: any, opts?: any) => Promise<void>;
43
+ loadNodeImage: (nodeId: number | string, title: string, context?: string, fallbackNode?: any, opts?: any) => Promise<void>;
50
44
  saveCacheNodeMeta: (nodeId: number | string, meta: any, fallbackNode?: any) => Promise<void>;
51
45
  setNewlyExpandedNodeIds: (ids: (number | string)[]) => void;
52
46
  setExpandingNodeId: (id: number | string | null) => void;
@@ -56,27 +50,24 @@ interface UseExpansionOptions {
56
50
  exploreTerm: string;
57
51
  isTextOnly: boolean;
58
52
  graphRef: React.RefObject<any>;
59
- setNotification?: (n: { message: string; type: 'success' | 'error' }) => void;
60
53
  }
61
54
 
62
55
  export function useExpansion(options: UseExpansionOptions) {
63
- const expansionInflightRef = useRef(0);
64
56
  const {
65
57
  graphDataRef, setGraphData, setIsProcessing, setError,
66
58
  searchIdRef, lockedPairRef, nodesRef, selectedNodeRef,
67
59
  autoExpandMoreDoneRef, cacheEnabled, cacheBaseUrl,
68
60
  ENABLE_ACADEMIC_CORPORA, ENABLE_WEB_SEARCH, loadNodeImage, saveCacheNodeMeta,
69
61
  setNewlyExpandedNodeIds, setExpandingNodeId, setNewChildNodeIds,
70
- setSelectedNode, setSelectedLink, exploreTerm, isTextOnly, graphRef,
71
- setNotification,
62
+ setSelectedNode, setSelectedLink, exploreTerm, isTextOnly, graphRef
72
63
  } = options;
73
64
 
74
- const fetchCacheExpansion = useCallback(async (sourceId: number) => {
65
+ const fetchCacheExpansion = useCallback(async (sourceId: number | string) => {
75
66
  if (!cacheEnabled) return null;
76
67
  const url = new URL("/expansion", cacheBaseUrl);
77
68
  url.searchParams.set("sourceId", sourceId.toString());
78
69
  try {
79
- const res = await fetchWithTimeout(url.toString(), {}, CACHE_GET_TIMEOUT_MS);
70
+ const res = await fetch(url.toString());
80
71
  if (!res.ok) return null;
81
72
  return res.json();
82
73
  } catch (e) {
@@ -88,15 +79,11 @@ export function useExpansion(options: UseExpansionOptions) {
88
79
  const saveCacheExpansion = useCallback(async (sourceId: number | string, nodes: any[]) => {
89
80
  if (!cacheEnabled) return null;
90
81
  try {
91
- const res = await fetchWithTimeout(
92
- new URL("/expansion", cacheBaseUrl).toString(),
93
- {
94
- method: "POST",
95
- headers: { "Content-Type": "application/json" },
96
- body: JSON.stringify({ sourceId, nodes }),
97
- },
98
- CACHE_POST_TIMEOUT_MS,
99
- );
82
+ const res = await fetch(new URL("/expansion", cacheBaseUrl).toString(), {
83
+ method: "POST",
84
+ headers: { "Content-Type": "application/json" },
85
+ body: JSON.stringify({ sourceId, nodes })
86
+ });
100
87
  if (res.ok) {
101
88
  const data = await res.json();
102
89
  return data.idMap as Record<string, number> | undefined;
@@ -121,21 +108,7 @@ export function useExpansion(options: UseExpansionOptions) {
121
108
  const guardId = searchIdRef.current;
122
109
  const isStale = () => searchIdRef.current !== guardId;
123
110
 
124
- /** Avoid infinite spinner when isStale() returns mid-flight or setTimeout never completes. */
125
- const clearThisNodeLoading = () => {
126
- setGraphData((prev) => ({
127
- ...prev,
128
- nodes: prev.nodes.map((n) =>
129
- String(n.id) === String(node.id) ? { ...n, isLoading: false } : n,
130
- ),
131
- }));
132
- };
133
-
134
- // Do not block on isLoading: a crashed/stale expansion would strand the node forever.
135
- if (!forceMore && node.expanded) {
136
- console.info("[Expansion] skip (already expanded)", { title: node.title, id: node.id });
137
- return;
138
- }
111
+ if (!forceMore && (node.expanded || node.isLoading)) return;
139
112
 
140
113
 
141
114
 
@@ -143,67 +116,36 @@ export function useExpansion(options: UseExpansionOptions) {
143
116
  if (isStale()) return;
144
117
  setGraphData(prev => ({
145
118
  ...prev,
146
- nodes: prev.nodes.map(n => (String(n.id) === String(node.id) ? { ...n, isLoading: true } : n))
119
+ nodes: prev.nodes.map(n => n.id === node.id ? { ...n, isLoading: true } : n)
147
120
  }));
148
121
 
149
122
  const loadingGuard = setTimeout(() => {
150
123
  if (isStale()) return;
151
124
  setGraphData(prev => ({
152
125
  ...prev,
153
- nodes: prev.nodes.map(n => (String(n.id) === String(node.id) ? { ...n, isLoading: true } : n))
126
+ nodes: prev.nodes.map(n => n.id === node.id ? { ...n, isLoading: true } : n)
154
127
  }));
155
128
  }, 0);
156
129
 
157
- expansionInflightRef.current += 1;
158
130
  setIsProcessing(true);
159
131
  setError(null);
160
132
 
161
133
  try {
162
- console.info("[Expansion] start", {
163
- title: node.title,
164
- id: node.id,
165
- expanded: !!node.expanded,
166
- isLoading: !!node.isLoading,
167
- cacheEnabled,
168
- forceMore,
169
- isInitial,
170
- });
171
- const nodeKey = String(node.id);
172
- const nodeUpdates = new Map<string, Partial<GraphNode>>();
134
+ const nodeUpdates = new Map<string | number, Partial<GraphNode>>();
173
135
  const maybeAutoExpandMore = (neighborCount: number) => {
174
136
  if (forceMore) return;
175
137
  if (neighborCount > 3) return;
176
- if (autoExpandMoreDoneRef.current.has(nodeKey)) return;
177
- autoExpandMoreDoneRef.current.add(nodeKey);
138
+ if (autoExpandMoreDoneRef.current.has(String(node.id))) return;
139
+ autoExpandMoreDoneRef.current.add(String(node.id));
178
140
  setTimeout(() => {
179
- if (String(selectedNodeRef.current?.id) !== nodeKey) return;
141
+ if (selectedNodeRef.current?.id !== node.id) return;
180
142
 
181
143
  fetchAndExpandNode(node, false, true);
182
144
  }, 900);
183
145
  };
184
146
 
185
- const getLinkIdEarly = (thing: any) => {
186
- if (typeof thing === 'object' && thing !== null) return String((thing as any).id);
187
- return String(thing);
188
- };
189
- /** Must read links after awaits (cache fetch); stale `currentLinks` from expand start misses edges and falsely skips duplicate detection. */
190
- const edgeExistsBetweenFresh = (a: string, b: string) => {
191
- const links = graphDataRef.current.links;
192
- return links.some((l) => {
193
- const s = getLinkIdEarly(l.source);
194
- const t = getLinkIdEarly(l.target);
195
- return (s === a && t === b) || (s === b && t === a);
196
- });
197
- };
198
-
199
147
  if (cacheEnabled && !forceMore) {
200
148
  const cacheHit = await fetchCacheExpansion(node.id);
201
- const cacheCount = cacheHit?.nodes?.length ?? 0;
202
- console.info("[Expansion] cache GET", {
203
- title: node.title,
204
- hit: cacheHit?.hit,
205
- neighbors: cacheCount,
206
- });
207
149
  if (cacheHit && cacheHit.hit === "exact" && cacheHit.nodes) {
208
150
  let validCached: any[] = cacheHit.nodes.filter((cn: any) => String(cn.id) !== String(node.id));
209
151
  // Concurrent upgrade of Wikipedia summaries if needed
@@ -214,11 +156,7 @@ export function useExpansion(options: UseExpansionOptions) {
214
156
  ...prev,
215
157
  nodes: prev.nodes.map(n => String(n.id) === String(cn.id) ? { ...n, wikiChecked: true } : n)
216
158
  }));
217
- const wiki = await withTimeout(
218
- fetchWikipediaSummary(cn.title),
219
- WIKI_SUMMARY_TIMEOUT_MS,
220
- 'Wikipedia summary timeout',
221
- ).catch(() => ({ extract: null, pageid: null, title: null } as const));
159
+ const wiki = await fetchWikipediaSummary(cn.title);
222
160
  if (!wiki.extract && !wiki.pageid) return cn; // Return original if no new wiki data
223
161
  setGraphData(prev => ({
224
162
  ...prev,
@@ -246,33 +184,13 @@ export function useExpansion(options: UseExpansionOptions) {
246
184
  const expectedChildIsAtomic = !parentIsAtomic;
247
185
  validCached = upgraded.map((cn: any) => ({ ...cn, is_atomic: expectedChildIsAtomic }));
248
186
 
249
- // Any cached neighbors are usable (old threshold >=5 skipped most DB rows and forced LLM every time).
250
- if (validCached.length >= 1) {
187
+ if (validCached.length >= 5) {
251
188
  const existingNodeIdsBefore = new Set(graphDataRef.current.nodes.map(n => String(n.id)));
252
- const parentIdStr = nodeKey;
253
-
254
- /** If DB only echoes edges already drawn (typical leaf actor → one film that's on screen), a cache-only merge adds no new bubbles — skip to AI so expansion actually shows filmography nodes. */
255
- const cacheDuplicatesVisibleGraph =
256
- validCached.every(
257
- (cn: any) =>
258
- existingNodeIdsBefore.has(String(cn.id)) &&
259
- edgeExistsBetweenFresh(parentIdStr, String(cn.id)),
260
- );
261
-
262
- if (cacheDuplicatesVisibleGraph) {
263
- console.info("[Expansion] exact cache overlaps graph — skipping shortcut, fetching AI expansion", {
264
- title: node.title,
265
- neighborsInCache: validCached.length,
266
- });
267
- } else {
268
189
  const newChildIds: (string | number)[] = validCached.filter(cn => !existingNodeIdsBefore.has(String(cn.id))).map(cn => cn.id);
269
190
  // Include ALL connected nodes for highlighting, not just new ones
270
191
  const allConnectedNodeIds = validCached.map(cn => cn.id);
271
192
 
272
- if (isStale()) {
273
- clearThisNodeLoading();
274
- return;
275
- }
193
+ if (isStale()) return;
276
194
 
277
195
  setGraphData(prev => mergeExpansionGraph({
278
196
  nodes: prev.nodes,
@@ -293,16 +211,17 @@ export function useExpansion(options: UseExpansionOptions) {
293
211
 
294
212
  validCached.forEach((cn, idx) => {
295
213
  if (!cn.imageUrl && !cn.imageChecked && !isTextOnly) {
296
- setTimeout(() => loadNodeImage(cn.id, cn.title), 50 * idx);
214
+ setTimeout(() => loadNodeImage(cn.id, cn.title), 200 + 220 * idx);
297
215
  }
298
216
  });
299
217
 
218
+
219
+ setIsProcessing(false);
300
220
  setGraphData(prev => ({
301
221
  ...prev,
302
- nodes: prev.nodes.map(n => (String(n.id) === nodeKey ? { ...n, expanded: true, isLoading: false } : n))
222
+ nodes: prev.nodes.map(n => n.id === node.id ? { ...n, expanded: true, isLoading: false } : n)
303
223
  }));
304
224
  return;
305
- }
306
225
  }
307
226
  }
308
227
  }
@@ -318,27 +237,12 @@ export function useExpansion(options: UseExpansionOptions) {
318
237
  getLinkId(l.target) === String(node.id)
319
238
  );
320
239
 
321
- const neighborNodes = neighborLinks.map(l => {
240
+ const neighborNames = neighborLinks.map(l => {
322
241
  const sid = getLinkId(l.source);
323
242
  const tid = getLinkId(l.target);
324
243
  const neighborId = sid === String(node.id) ? tid : sid;
325
- return currentNodes.find(n => String(n.id) === String(neighborId));
326
- }).filter((n): n is GraphNode => !!n);
327
-
328
- const neighborNames = neighborNodes.map(n => n.title || '').filter(Boolean);
329
-
330
- /** Only composite-side titles (films, orgs, works). Do not pass fellow cast members — the works prompt asks for NEW films, and listing other actors as "excludes" often yields an empty model response. */
331
- const worksExcludeTitles = neighborNodes
332
- .filter((n) => {
333
- if (n.is_atomic === true || (n as any).is_person === true) return false;
334
- if (n.is_atomic === false) return true;
335
- const t = (n.type || '').toLowerCase();
336
- if (/\b(actor|person|author|character|composer|scientist|philosopher)\b/.test(t)) return false;
337
- if (/\b(movie|film|novel|book|album|series|event|organization|museum|institution|battle|war|movement)\b/.test(t)) return true;
338
- return false;
339
- })
340
- .map(n => n.title || '')
341
- .filter(Boolean);
244
+ return currentNodes.find(n => String(n.id) === String(neighborId))?.title || '';
245
+ }).filter(Boolean);
342
246
 
343
247
 
344
248
  let wiki: any = {
@@ -347,16 +251,12 @@ export function useExpansion(options: UseExpansionOptions) {
347
251
  mentioningPageTitles: node.mentioningPageTitles || null
348
252
  };
349
253
  if ((!wiki.extract && !wiki.pageid) || (wiki.extract && !wiki.pageid && !wiki.mentioningPageTitles)) {
350
- wiki = await withTimeout(
351
- fetchWikipediaSummary(node.title, neighborNames.join(' ')),
352
- WIKI_SUMMARY_TIMEOUT_MS,
353
- 'Wikipedia summary timeout',
354
- ).catch(() => ({ extract: null, pageid: null, title: null, mentioningPageTitles: null }));
254
+ wiki = await fetchWikipediaSummary(node.title, neighborNames.join(' '));
355
255
  }
356
256
 
357
257
  if (wiki.extract) {
358
258
  const isPerson = node.is_atomic === true || node.is_person === true || node.type?.toLowerCase() === 'person';
359
- nodeUpdates.set(nodeKey, {
259
+ nodeUpdates.set(node.id, {
360
260
  wikiSummary: wiki.extract,
361
261
  wikipedia_id: wiki.pageid?.toString(),
362
262
  mentioningPageTitles: wiki.mentioningPageTitles || undefined,
@@ -374,7 +274,7 @@ export function useExpansion(options: UseExpansionOptions) {
374
274
  const isAcademicPair = ENABLE_ACADEMIC_CORPORA && (pair.atomicType.toLowerCase() === 'author' || pair.compositeType.toLowerCase() === 'paper');
375
275
 
376
276
  if (!node.classification_reasoning) {
377
- nodeUpdates.set(nodeKey, {
277
+ nodeUpdates.set(node.id, {
378
278
  classification_reasoning: `Locked pair: ${pair.atomicType} ↔ ${pair.compositeType}.`,
379
279
  atomic_type: pair.atomicType,
380
280
  composite_type: pair.compositeType
@@ -390,11 +290,11 @@ export function useExpansion(options: UseExpansionOptions) {
390
290
 
391
291
  if (typeof inferred === 'boolean') {
392
292
  currentIsAtomic = inferred;
393
- nodeUpdates.set(nodeKey, { is_atomic: inferred });
293
+ nodeUpdates.set(node.id, { is_atomic: inferred });
394
294
  } else {
395
295
  const classification = await classifyEntity(node.title);
396
296
  currentIsAtomic = classification.isAtomic;
397
- nodeUpdates.set(nodeKey, {
297
+ nodeUpdates.set(node.id, {
398
298
  ...(typeof (node.is_atomic ?? (node as any).is_person) === 'boolean' ? {} : { is_atomic: classification.isAtomic }),
399
299
  type: classification.type
400
300
  });
@@ -446,7 +346,7 @@ export function useExpansion(options: UseExpansionOptions) {
446
346
  edge_label: 'Authored',
447
347
  edge_meta: { evidence: makeOpenAlexAuthorshipEvidence(w, node.title) }
448
348
  }));
449
- if (!meta.openAlexAuthorId && author.id) nodeUpdates.set(nodeKey, { meta: { ...meta, openAlexAuthorId: author.id, openAlexUrl: author.id, source: 'openalex' } });
349
+ if (!meta.openAlexAuthorId && author.id) nodeUpdates.set(node.id, { meta: { ...meta, openAlexAuthorId: author.id, openAlexUrl: author.id, source: 'openalex' } });
450
350
  }
451
351
  } else {
452
352
  // Check if this is "Work (Author)" pattern - if so, skip OpenAlex (it returns modern editions/translators)
@@ -466,7 +366,7 @@ export function useExpansion(options: UseExpansionOptions) {
466
366
  }));
467
367
  if (!meta.openAlexWorkId && work.id) {
468
368
  const paperNode = openAlexWorkToPaperNode(work);
469
- nodeUpdates.set(nodeKey, {
369
+ nodeUpdates.set(node.id, {
470
370
  meta: { ...meta, openAlexWorkId: work.id, doi: work.doi || undefined, openAlexUrl: work.id, source: 'openalex' },
471
371
  ...((node.description || '').trim() ? {} : { description: paperNode.description, year: paperNode.year })
472
372
  });
@@ -483,7 +383,7 @@ export function useExpansion(options: UseExpansionOptions) {
483
383
  edge_meta: { evidence: makeCrossrefAuthorshipEvidence(cw, name) }
484
384
  }));
485
385
  const paperNode = crossrefWorkToPaperNode(cw);
486
- nodeUpdates.set(nodeKey, {
386
+ nodeUpdates.set(node.id, {
487
387
  meta: { ...meta, doi: cw.DOI || doi, crossrefUrl: paperNode.meta?.crossrefUrl, source: 'crossref' },
488
388
  ...((node.description || '').trim() ? {} : { description: paperNode.description, year: paperNode.year })
489
389
  });
@@ -496,23 +396,10 @@ export function useExpansion(options: UseExpansionOptions) {
496
396
  // Fallback: If academic results were empty, proceed to standard expansion
497
397
  if (results.length === 0) {
498
398
  if (isPerson) {
499
- let data = await fetchPersonWorks(node.title, worksExcludeTitles, verifiedContext || undefined, node.wikipedia_id, currentAtomicType, currentCompositeType, wiki.mentioningPageTitles || undefined);
500
- if ((!data.works || data.works.length === 0) && worksExcludeTitles.length > 0) {
399
+ let data = await fetchPersonWorks(node.title, neighborNames, verifiedContext || undefined, node.wikipedia_id, currentAtomicType, currentCompositeType, wiki.mentioningPageTitles || undefined);
400
+ if ((!data.works || data.works.length === 0) && neighborNames.length > 0) {
501
401
  data = await fetchPersonWorks(node.title, [], verifiedContext || undefined, node.wikipedia_id, currentAtomicType, currentCompositeType, wiki.mentioningPageTitles || undefined);
502
402
  }
503
- console.info("[Expansion] works raw", {
504
- title: node.title,
505
- worksExcludeTitles: worksExcludeTitles.length,
506
- works: (data as any)?.works?.length ?? 0,
507
- sample: clipForLlmLog(
508
- JSON.stringify(((data as any)?.works || []).slice(0, 4).map((w: any) => ({
509
- entity: w.entity,
510
- wikipediaTitle: w.wikipediaTitle,
511
- type: w.type,
512
- year: w.year,
513
- })))
514
- ),
515
- });
516
403
  results = (data.works || []).filter(w => typeof (w as any).entity === 'string' && (w as any).entity.trim().length > 0).map(w => ({
517
404
  title: (w as any).wikipediaTitle || w.entity,
518
405
  type: (w as any).type || currentCompositeType,
@@ -537,7 +424,7 @@ export function useExpansion(options: UseExpansionOptions) {
537
424
  if ((!data.people || data.people.length === 0) && neighborNames.length > 0) {
538
425
  data = await fetchConnections(node.title, undefined, [], verifiedContext || undefined, node.wikipedia_id, currentAtomicType, currentCompositeType, wiki.mentioningPageTitles || undefined);
539
426
  }
540
- if (data.sourceYear) nodeUpdates.set(nodeKey, { year: data.sourceYear });
427
+ if (data.sourceYear) nodeUpdates.set(node.id, { year: data.sourceYear });
541
428
  const atomicTypeToUse = currentAtomicType || 'Person';
542
429
  results = (data.people || []).map(p => ({
543
430
  title: (p as any).wikipediaTitle || p.name,
@@ -614,14 +501,7 @@ export function useExpansion(options: UseExpansionOptions) {
614
501
  setExpandingNodeId(null);
615
502
  setNewChildNodeIds(new Set());
616
503
  } else {
617
- setNotification?.({
618
- message:
619
- cacheEnabled
620
- ? `No new connections for "${node.title}". Often the AI returned none or only links you already had. If failures repeat, check the cache terminal for quota/API errors.`
621
- : `No new connections for "${node.title}". The model returned nothing usable—check API keys in .env.local.`,
622
- type: 'error',
623
- });
624
- setGraphData(prev => ({ ...prev, nodes: prev.nodes.map(n => String(n.id) === String(node.id) ? { ...n, expanded: false, isLoading: false } : n) }));
504
+ setGraphData(prev => ({ ...prev, nodes: prev.nodes.map(n => String(n.id) === String(node.id) ? { ...n, expanded: true, isLoading: false } : n) }));
625
505
  setExpandingNodeId(null);
626
506
  setNewChildNodeIds(new Set());
627
507
  }
@@ -629,13 +509,7 @@ export function useExpansion(options: UseExpansionOptions) {
629
509
  const resultsWithWiki = await Promise.all(results.map(async r => {
630
510
  const contextHint = [node.title, r.type, r.edge_label || r.role, r.description, r.edge_meta?.evidence?.snippet].filter(Boolean).join(' · ').slice(0, 280);
631
511
  const skipWiki = isAcademicPair || String(r.edge_meta?.evidence?.kind || '') === 'openalex';
632
- const rWiki = skipWiki
633
- ? ({ title: r.title, extract: '', pageid: undefined } as any)
634
- : await withTimeout(
635
- fetchWikipediaSummary(r.title, contextHint),
636
- WIKI_SUMMARY_TIMEOUT_MS,
637
- 'Wikipedia summary timeout',
638
- ).catch(() => ({ title: r.title, extract: '', pageid: undefined } as any));
512
+ const rWiki = skipWiki ? ({ title: r.title, extract: '', pageid: undefined } as any) : await fetchWikipediaSummary(r.title, contextHint);
639
513
  let evidence: any = r.edge_meta?.evidence || { kind: 'none' as const };
640
514
  const pageTitle = String(evidence?.pageTitle || '');
641
515
  const snippet = String(evidence?.snippet || '');
@@ -663,12 +537,6 @@ export function useExpansion(options: UseExpansionOptions) {
663
537
 
664
538
  let nodesToUse = resultsWithWiki;
665
539
  if (!exploreTerm.toLowerCase().startsWith('list of ')) nodesToUse = nodesToUse.filter((n: any) => !isBadListPage(n.title));
666
- console.info("[Expansion] post-wiki", {
667
- title: node.title,
668
- results: results.length,
669
- resultsWithWiki: resultsWithWiki.length,
670
- nodesToUseAfterListFilter: nodesToUse.length,
671
- });
672
540
 
673
541
  let finalIDMap: Record<string, number> | undefined;
674
542
  if (cacheEnabled) {
@@ -686,11 +554,9 @@ export function useExpansion(options: UseExpansionOptions) {
686
554
  });
687
555
  combinedNodes = Array.from(byTitle.values());
688
556
  }
689
- finalIDMap = await saveCacheExpansion(node.id, combinedNodes);
557
+ finalIDMap = (await saveCacheExpansion(node.id, combinedNodes)) ?? undefined;
690
558
  const cacheHit = await fetchCacheExpansion(node.id);
691
- if (cacheHit && Array.isArray(cacheHit.nodes) && cacheHit.nodes.length > 0) {
692
- nodesToUse = cacheHit.nodes;
693
- }
559
+ if (cacheHit && cacheHit.nodes) nodesToUse = cacheHit.nodes;
694
560
  }
695
561
 
696
562
  const currentNodesForDedupe = graphDataRef.current.nodes;
@@ -705,11 +571,6 @@ export function useExpansion(options: UseExpansionOptions) {
705
571
  }
706
572
  return true; // Allow all non-person nodes
707
573
  });
708
- console.info("[Expansion] nodesToUse after sanitize/type-filter", {
709
- title: node.title,
710
- nodesToUse: nodesToUse.length,
711
- existingByNorm: existingByNorm.size,
712
- });
713
574
 
714
575
 
715
576
  const processedNodes = nodesToUse.map(cn => {
@@ -737,30 +598,11 @@ export function useExpansion(options: UseExpansionOptions) {
737
598
  // Include ALL connected nodes for highlighting, not just new ones
738
599
  const allConnectedNodeIds = processedNodes.map(cn => cn.id);
739
600
 
740
- if (processedNodes.length > 0 && newChildIds.length === 0) {
741
- console.info("[Expansion] all processed nodes already present (by id)", {
742
- title: node.title,
743
- processedNodes: processedNodes.length,
744
- existingNodeIdsBefore: existingNodeIdsBefore.size,
745
- sampleTitles: processedNodes.slice(0, 8).map(n => n.title),
746
- });
747
- } else {
748
- console.info("[Expansion] new child ids", {
749
- title: node.title,
750
- processedNodes: processedNodes.length,
751
- newChildIds: newChildIds.length,
752
- });
753
- }
754
-
755
601
 
756
602
 
757
- if (isStale()) {
758
- clearThisNodeLoading();
759
- return;
760
- }
603
+ if (isStale()) return;
761
604
  setGraphData(prev => {
762
605
  const nodeMap = new Map<string, GraphNode>(prev.nodes.map(n => [String(n.id), n]));
763
- const existingNodeIds = new Set(prev.nodes.map(n => String(n.id)));
764
606
  const expectedChildIsAtomic = !currentIsAtomic;
765
607
  processedNodes.forEach(cn => {
766
608
  const meta = cn.meta || {};
@@ -777,14 +619,7 @@ export function useExpansion(options: UseExpansionOptions) {
777
619
  expanded: existing?.expanded || false, isLoading: false
778
620
  });
779
621
  });
780
- if (nodeMap.has(String(node.id))) {
781
- nodeMap.set(String(node.id), {
782
- ...nodeMap.get(String(node.id))!,
783
- expanded: true,
784
- isLoading: false,
785
- ...nodeUpdates.get(nodeKey),
786
- });
787
- }
622
+ if (nodeMap.has(String(node.id))) nodeMap.set(String(node.id), { ...nodeMap.get(String(node.id))!, expanded: true, isLoading: true, ...nodeUpdates.get(node.id) });
788
623
 
789
624
  const getLinkId = (thing: any) => String(typeof thing === 'object' ? thing?.id : thing);
790
625
  const linkMap = new Map<string, GraphLink>(prev.links.map(l => [`${getLinkId(l.source)}↔${getLinkId(l.target)}`, l]));
@@ -836,12 +671,10 @@ export function useExpansion(options: UseExpansionOptions) {
836
671
  degree.set(t, (degree.get(t) || 0) + 1);
837
672
  });
838
673
  const finalNodes = Array.from(nodeMap.values()).filter(n => {
839
- const isOriginal = String(n.id) === String(node.id);
840
- const isExisting = existingNodeIds.has(String(n.id));
674
+ const isExpandingRoot = String(n.id) === String(node.id);
841
675
  const hasDegree = (degree.get(String(n.id)) || 0) > 0;
842
- const ok = isOriginal || isExisting || hasDegree;
843
- if (!ok) { /* removed log */ }
844
- return ok;
676
+ if (n.isLoading) return true;
677
+ return isExpandingRoot || hasDegree;
845
678
  });
846
679
 
847
680
  return dedupeGraph(finalNodes, combinedLinks);
@@ -851,18 +684,19 @@ export function useExpansion(options: UseExpansionOptions) {
851
684
 
852
685
  // Highlight ALL connected nodes, not just new ones
853
686
  if (!skipExpandingHighlight) setNewChildNodeIds(new Set(allConnectedNodeIds.map(id => String(id))));
854
- processedNodes.forEach((cn, idx) => { if (!cn.imageUrl && !cn.imageChecked && !isTextOnly) setTimeout(() => loadNodeImage(cn.id, cn.title), 300 * (idx + 1)); });
687
+ processedNodes.forEach((cn, idx) => {
688
+ if (!cn.imageUrl && !cn.imageChecked && !isTextOnly) {
689
+ setTimeout(() => loadNodeImage(cn.id, cn.title), 350 + 380 * idx);
690
+ }
691
+ });
855
692
 
856
693
  setTimeout(() => {
857
- if (isStale()) {
858
- clearThisNodeLoading();
859
- return;
860
- }
861
- setGraphData(prev => ({ ...prev, nodes: prev.nodes.map(n => String(n.id) === String(node.id) ? { ...n, expanded: true, isLoading: false, ...nodeUpdates.get(nodeKey) } : n) }));
862
- const updates = nodeUpdates.get(nodeKey);
694
+ if (isStale()) return;
695
+ setGraphData(prev => ({ ...prev, nodes: prev.nodes.map(n => String(n.id) === String(node.id) ? { ...n, expanded: true, isLoading: false, ...nodeUpdates.get(node.id) } : n) }));
696
+ const updates = nodeUpdates.get(node.id);
863
697
  if (updates) saveCacheNodeMeta(node.id, updates, node);
864
698
  setTimeout(() => {
865
- graphRef.current?.centerOnNode(node.id);
699
+ graphRef.current?.fitGraphInView();
866
700
  if (!skipExpandingHighlight) {
867
701
  setExpandingNodeId(null);
868
702
  // Keep newChildNodeIds so they remain highlighted
@@ -879,11 +713,9 @@ export function useExpansion(options: UseExpansionOptions) {
879
713
  setSelectedNode(null); setSelectedLink(null); setExpandingNodeId(null); setNewChildNodeIds(new Set());
880
714
  } finally {
881
715
  clearTimeout(loadingGuard);
882
- expansionInflightRef.current = Math.max(0, expansionInflightRef.current - 1);
883
- if (expansionInflightRef.current === 0) setIsProcessing(false);
884
- clearThisNodeLoading();
716
+ if (!isStale()) setIsProcessing(false);
885
717
  }
886
- }, [loadNodeImage, cacheEnabled, fetchCacheExpansion, saveCacheExpansion, cacheBaseUrl, saveCacheNodeMeta, setGraphData, setIsProcessing, setError, searchIdRef, lockedPairRef, nodesRef, selectedNodeRef, autoExpandMoreDoneRef, ENABLE_ACADEMIC_CORPORA, ENABLE_WEB_SEARCH, setNewlyExpandedNodeIds, setExpandingNodeId, setNewChildNodeIds, setSelectedNode, setSelectedLink, exploreTerm, isTextOnly, graphRef, setNotification]);
718
+ }, [loadNodeImage, cacheEnabled, fetchCacheExpansion, saveCacheExpansion, cacheBaseUrl, saveCacheNodeMeta, setGraphData, setIsProcessing, setError, searchIdRef, lockedPairRef, nodesRef, selectedNodeRef, autoExpandMoreDoneRef, ENABLE_ACADEMIC_CORPORA, ENABLE_WEB_SEARCH, setNewlyExpandedNodeIds, setExpandingNodeId, setNewChildNodeIds, setSelectedNode, setSelectedLink, exploreTerm, isTextOnly, graphRef]);
887
719
 
888
720
  return { fetchAndExpandNode, fetchCacheExpansion, saveCacheExpansion };
889
721
  }
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  import React, { useCallback } from 'react';
2
3
  import { GraphNode, GraphLink } from '../types';
3
4