@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,4 +1,6 @@
1
- import { fetchWithTimeout } from "./aiUtils";
1
+ "use client";
2
+
3
+ import { jsonFromResponse } from "./aiUtils";
2
4
 
3
5
  type WikiImageCacheEntry = { url: string | null; pageId?: number; pageTitle?: string; misses?: number };
4
6
 
@@ -59,7 +61,8 @@ export const fetchWikipediaImage = async (query: string, context?: string): Prom
59
61
  try {
60
62
  const url = `${api}?action=query&format=json&prop=imageinfo&titles=${encodeURIComponent(fileTitle)}&iiprop=url&iiurlwidth=500&origin=*`;
61
63
  const res = await fetch(url, { signal });
62
- const data = await res.json();
64
+ const data = (await jsonFromResponse(res)) as { query?: { pages?: Record<string, unknown> } } | null;
65
+ if (!data) continue;
63
66
  const pages = data.query?.pages;
64
67
  if (pages) {
65
68
  const page = Object.values(pages)[0] as any;
@@ -78,7 +81,7 @@ export const fetchWikipediaImage = async (query: string, context?: string): Prom
78
81
  try {
79
82
  const wdUrl = `https://www.wikidata.org/w/api.php?action=wbgetentities&format=json&props=claims&ids=${qid}&origin=*`;
80
83
  const wdRes = await fetch(wdUrl, { signal });
81
- const wdData = await wdRes.json();
84
+ const wdData = (await jsonFromResponse(wdRes)) as { entities?: Record<string, { claims?: any }> } | null;
82
85
  const claims = wdData?.entities?.[qid]?.claims;
83
86
  const p18 = claims?.P18?.[0]?.mainsnak?.datavalue?.value as string | undefined;
84
87
  if (!p18) return null;
@@ -95,8 +98,8 @@ export const fetchWikipediaImage = async (query: string, context?: string): Prom
95
98
  try {
96
99
  const ppUrl = `https://en.wikipedia.org/w/api.php?action=query&format=json&prop=pageprops&titles=${encodeURIComponent(title)}&redirects=1&origin=*`;
97
100
  const ppRes = await fetch(ppUrl, { signal });
98
- const ppData = await ppRes.json();
99
- const pages = ppData?.query?.pages;
101
+ const ppData = await jsonFromResponse(ppRes);
102
+ const pages = (ppData as { query?: { pages?: unknown } } | null)?.query?.pages;
100
103
  const page = pages ? (Object.values(pages)[0] as any) : null;
101
104
  const qid = page?.pageprops?.wikibase_item;
102
105
  if (!qid || !/^Q\d+$/.test(qid)) return null;
@@ -112,7 +115,8 @@ export const fetchWikipediaImage = async (query: string, context?: string): Prom
112
115
  // 1. Get page info, thumbnail, and all images in one go
113
116
  const url = `https://en.wikipedia.org/w/api.php?action=query&format=json&prop=pageimages|pageprops|images&titles=${encodeURIComponent(title)}&pithumbsize=500&imlimit=50&redirects=1&origin=*`;
114
117
  const res = await fetch(url, { signal });
115
- const data = await res.json();
118
+ const data = (await jsonFromResponse(res)) as { query?: { pages?: Record<string, unknown> } } | null;
119
+ if (!data) return { url: null };
116
120
 
117
121
  const pages = data.query?.pages;
118
122
  if (!pages) return { url: null };
@@ -146,7 +150,7 @@ export const fetchWikipediaImage = async (query: string, context?: string): Prom
146
150
  if (candidates.length === 0) return { url: null };
147
151
 
148
152
  const normalized = query.trim().toLowerCase();
149
- const queryWords = normalized.split(/\s+/).filter(w => w.length > 1);
153
+ const queryWords = normalized.split(/\s+/).filter((w: string) => w.length > 1);
150
154
  const isPerson = context?.toLowerCase() === 'person';
151
155
 
152
156
  const scoredCandidates = candidates.map(c => {
@@ -198,7 +202,7 @@ export const fetchWikipediaImage = async (query: string, context?: string): Prom
198
202
  if (t.includes('.png')) s -= isPerson ? 20 : 50;
199
203
 
200
204
  // Prefer solo filenames
201
- const wordCount = t.split(/[^a-z]/).filter(w => w.length > 2).length;
205
+ const wordCount = t.split(/[^a-z]/).filter((w: string) => w.length > 2).length;
202
206
  s -= (wordCount * 15); // Stronger penalty for long, descriptive filenames
203
207
 
204
208
  return { ...c, score: s };
@@ -245,7 +249,8 @@ export const fetchWikipediaImage = async (query: string, context?: string): Prom
245
249
  const url = `https://www.googleapis.com/books/v1/volumes?q=${encodeURIComponent(q)}&maxResults=1`;
246
250
  const res = await fetch(url, { signal });
247
251
  if (res.ok) {
248
- const data = await res.json();
252
+ const data = (await jsonFromResponse(res)) as { items?: { volumeInfo?: { imageLinks?: { thumbnail?: string } } }[] } | null;
253
+ if (!data) return null;
249
254
  const img = data.items?.[0]?.volumeInfo?.imageLinks?.thumbnail;
250
255
  return img ? img.replace('http://', 'https://') : null;
251
256
  }
@@ -279,10 +284,10 @@ export const fetchWikipediaImage = async (query: string, context?: string): Prom
279
284
  // console.log(`🔍 [ImageSearch] Attempt 1 (Media-Aware): "${searchQuery}"`);
280
285
  const initialSearchUrl = `https://en.wikipedia.org/w/api.php?action=query&format=json&list=search&srsearch=${encodeURIComponent(searchQuery)}&srlimit=5&origin=*`;
281
286
  const initialSearchRes = await fetch(initialSearchUrl, { signal: controller.signal });
282
- const initialSearchData = await initialSearchRes.json();
287
+ const initialSearchData = (await jsonFromResponse(initialSearchRes)) as { query?: { search?: { title: string; snippet?: string }[] } } | null;
283
288
 
284
289
  let bestTitle = query;
285
- if (initialSearchData.query?.search?.length) {
290
+ if (initialSearchData?.query?.search?.length) {
286
291
  const results = initialSearchData.query.search;
287
292
  const normalized = baseTitle.toLowerCase();
288
293
  const avoidMedia = false; // For images, we generally allow media if it's the right title
@@ -305,7 +310,7 @@ export const fetchWikipediaImage = async (query: string, context?: string): Prom
305
310
 
306
311
  // 2. Context matching
307
312
  if (context) {
308
- const words = context.toLowerCase().split(/\s+/).filter(w => w.length > 2);
313
+ const words = context.toLowerCase().split(/\s+/).filter((w: string) => w.length > 2);
309
314
  words.forEach(word => {
310
315
  if (title.includes(word)) s += 100;
311
316
  if (snippet.includes(word)) s += 50;
@@ -322,7 +327,9 @@ export const fetchWikipediaImage = async (query: string, context?: string): Prom
322
327
  return s;
323
328
  };
324
329
 
325
- const scored = results.map((r: any) => ({ r, score: scoreResult(r) })).sort((a, b) => b.score - a.score);
330
+ const scored = results
331
+ .map((r: any) => ({ r, score: scoreResult(r) }))
332
+ .sort((a: { score: number }, b: { score: number }) => b.score - a.score);
326
333
  bestTitle = scored[0]?.r?.title || query;
327
334
  // console.log(`✅ [ImageSearch] Chosen result "${bestTitle}" with score ${scored[0]?.score ?? 'n/a'}`);
328
335
  }
@@ -336,9 +343,9 @@ export const fetchWikipediaImage = async (query: string, context?: string): Prom
336
343
  // console.log(`🔍 [ImageSearch] Attempt 2 (Commons for Person): "${baseTitle}"`);
337
344
  const commonsUrl = `https://commons.wikimedia.org/w/api.php?action=query&format=json&list=search&srsearch=${encodeURIComponent(baseTitle)}&srnamespace=6&srlimit=10&origin=*`;
338
345
  const commonsRes = await fetch(commonsUrl, { signal: controller.signal });
339
- const commonsData = await commonsRes.json();
340
- if (commonsData.query?.search?.length) {
341
- const baseWords = baseTitle.toLowerCase().split(/\s+/).filter(w => w.length > 1);
346
+ const commonsData = (await jsonFromResponse(commonsRes)) as { query?: { search?: any[] } } | null;
347
+ if (commonsData?.query?.search?.length) {
348
+ const baseWords = baseTitle.toLowerCase().split(/\s+/).filter((w: string) => w.length > 1);
342
349
  const scoredResults = commonsData.query.search.map((res: any) => {
343
350
  const t = res.title.toLowerCase();
344
351
  if (excludePatterns.some(p => t.includes(p))) return { res, score: -1000 };
@@ -356,7 +363,7 @@ export const fetchWikipediaImage = async (query: string, context?: string): Prom
356
363
  if (t.includes('.png')) s -= 20; // Reduced penalty for Person
357
364
  if (t.includes('.svg') || t.includes('.webm') || t.includes('.gif')) s -= 300;
358
365
 
359
- const wordCount = t.split(/[^a-z]/).filter(w => w.length > 2).length;
366
+ const wordCount = t.split(/[^a-z]/).filter((w: string) => w.length > 2).length;
360
367
  s -= (wordCount * 15);
361
368
 
362
369
  return { res, score: s };
@@ -386,9 +393,9 @@ export const fetchWikipediaImage = async (query: string, context?: string): Prom
386
393
  // console.log(`🔍 [ImageSearch] Attempt 4 (Commons): "${baseTitle}"`);
387
394
  const commonsUrl = `https://commons.wikimedia.org/w/api.php?action=query&format=json&list=search&srsearch=${encodeURIComponent(baseTitle)}&srnamespace=6&srlimit=10&origin=*`;
388
395
  const commonsRes = await fetch(commonsUrl, { signal: controller.signal });
389
- const commonsData = await commonsRes.json();
390
- if (commonsData.query?.search?.length) {
391
- const baseWords = baseTitle.toLowerCase().split(/\s+/).filter(w => w.length > 1);
396
+ const commonsData = (await jsonFromResponse(commonsRes)) as { query?: { search?: any[] } } | null;
397
+ if (commonsData?.query?.search?.length) {
398
+ const baseWords = baseTitle.toLowerCase().split(/\s+/).filter((w: string) => w.length > 1);
392
399
  const scoredResults = commonsData.query.search.map((res: any) => {
393
400
  const t = res.title.toLowerCase();
394
401
  if (excludePatterns.some(p => t.includes(p))) return { res, score: -1000 };
@@ -408,7 +415,7 @@ export const fetchWikipediaImage = async (query: string, context?: string): Prom
408
415
  if (t.includes('.png')) s -= 50;
409
416
  if (t.includes('.svg') || t.includes('.webm') || t.includes('.gif')) s -= 300;
410
417
 
411
- const wordCount = t.split(/[^a-z]/).filter(w => w.length > 2).length;
418
+ const wordCount = t.split(/[^a-z]/).filter((w: string) => w.length > 2).length;
412
419
  s -= (wordCount * 15);
413
420
 
414
421
  return { res, score: s };
@@ -426,8 +433,8 @@ export const fetchWikipediaImage = async (query: string, context?: string): Prom
426
433
  // console.log(`🔍 [ImageSearch] Attempt 5 (Search): "${baseTitle}"`);
427
434
  const searchUrl = `https://en.wikipedia.org/w/api.php?action=query&format=json&list=search&srsearch=${encodeURIComponent(baseTitle)}&srlimit=5&origin=*`;
428
435
  const searchRes = await fetch(searchUrl, { signal: controller.signal });
429
- const searchData = await searchRes.json();
430
- if (searchData.query?.search?.length) {
436
+ const searchData = (await jsonFromResponse(searchRes)) as { query?: { search?: { title: string }[] } } | null;
437
+ if (searchData?.query?.search?.length) {
431
438
  for (const result of searchData.query.search) {
432
439
  const img = await fetchPageImage(result.title, controller.signal);
433
440
  if (img.url) return img;
@@ -498,7 +505,8 @@ export const fetchWikipediaSummary = async (
498
505
  try {
499
506
  const directUrl = `https://en.wikipedia.org/w/api.php?action=query&format=json&prop=extracts|pageprops&exintro&explaintext&titles=${encodeURIComponent(titleToFetch)}&redirects=1&origin=*`;
500
507
  const directRes = await fetch(directUrl);
501
- const directData = await directRes.json();
508
+ const directData = (await jsonFromResponse(directRes)) as { query?: { pages?: unknown; redirects?: unknown } } | null;
509
+ if (!directData) return null;
502
510
  const directPages = directData.query?.pages;
503
511
 
504
512
  if (directPages) {
@@ -541,7 +549,7 @@ export const fetchWikipediaSummary = async (
541
549
  // for disambiguation (e.g., "Republic (book)" vs "Republic").
542
550
  const cleanQuery = query.trim();
543
551
  const normalized = cleanQuery.toLowerCase();
544
- const queryNameParts = normalized.split(/[\s-]+/).filter(w => w.length > 2);
552
+ const queryNameParts = normalized.split(/[\s-]+/).filter((w: string) => w.length > 2);
545
553
  const looksLikePersonName = queryNameParts.length >= 2 && !/\d/.test(cleanQuery);
546
554
  const queryLastName = looksLikePersonName ? queryNameParts[queryNameParts.length - 1].toLowerCase() : null;
547
555
 
@@ -564,7 +572,7 @@ export const fetchWikipediaSummary = async (
564
572
  const directExact = await tryDirectLookup(cleanQuery);
565
573
  if (directExact?.extract) {
566
574
  if (queryLastName) {
567
- const titleParts = String(directExact.title || "").toLowerCase().split(/[\s-]+/).filter(w => w.length > 2);
575
+ const titleParts = String(directExact.title || "").toLowerCase().split(/[\s-]+/).filter((w: string) => w.length > 2);
568
576
  // If it's a redirect, we are MUCH more lenient. Napoleon Bonaparte -> Napoleon is a classic case.
569
577
  if (!titleParts.includes(queryLastName) && !directExact.redirected) {
570
578
  // console.log(`⚠️ [Wiki] Ignoring direct match "${directExact.title}" for "${cleanQuery}" (missing last-name match and no redirect).`);
@@ -639,10 +647,10 @@ export const fetchWikipediaSummary = async (
639
647
  const avoidMedia = /\b(project|program|programme|operation|war|battle|campaign|treaty|scandal|scientist)\b/i.test(baseQuery);
640
648
  const searchUrl = `https://en.wikipedia.org/w/api.php?action=query&format=json&list=search&srsearch=${encodeURIComponent(searchQuery)}&srlimit=5&origin=*`;
641
649
  const searchRes = await fetch(searchUrl);
642
- const searchData = await searchRes.json();
650
+ const searchData = (await jsonFromResponse(searchRes)) as { query?: { search?: any[] } } | null;
643
651
 
644
652
  let bestTitle = query;
645
- if (searchData.query?.search?.length) {
653
+ if (searchData?.query?.search?.length) {
646
654
  const results = searchData.query.search;
647
655
  const scoreResult = (r: any, index: number) => {
648
656
  const title = r.title.toLowerCase();
@@ -697,7 +705,7 @@ export const fetchWikipediaSummary = async (
697
705
 
698
706
  // 2. Context matching
699
707
  if (context) {
700
- const words = context.toLowerCase().split(/\s+/).filter(w => w.length > 2);
708
+ const words = context.toLowerCase().split(/\s+/).filter((w: string) => w.length > 2);
701
709
  words.forEach(word => {
702
710
  if (title.includes(word)) s += 100;
703
711
  if (snippet.includes(word)) s += 50;
@@ -754,7 +762,7 @@ export const fetchWikipediaSummary = async (
754
762
  bestTitle = scored[0]?.r?.title || query;
755
763
 
756
764
 
757
- const titleNameParts = bestTitle.toLowerCase().split(/[\s-]+/).filter(w => w.length > 2);
765
+ const titleNameParts = bestTitle.toLowerCase().split(/[\s-]+/).filter((w: string) => w.length > 2);
758
766
  // Require at least one full word match, not just a substring overlap
759
767
  const hasFullWordMatch = queryNameParts.some(q => titleNameParts.includes(q));
760
768
  const hasOverlap = queryNameParts.some(q => titleNameParts.some(t => t.includes(q) || q.includes(t)));
@@ -764,7 +772,7 @@ export const fetchWikipediaSummary = async (
764
772
 
765
773
  for (const titleToTry of candidates) {
766
774
  if (queryNameParts.length > 0) {
767
- const candidateParts = titleToTry.toLowerCase().split(/[\s-]+/).filter(w => w.length > 2);
775
+ const candidateParts = titleToTry.toLowerCase().split(/[\s-]+/).filter((w: string) => w.length > 2);
768
776
 
769
777
  // STRICT PERSON MATCHING:
770
778
  // If we are looking for a person (query has 2+ name parts),
@@ -789,7 +797,8 @@ export const fetchWikipediaSummary = async (
789
797
  }
790
798
  const summaryUrl = `https://en.wikipedia.org/w/api.php?action=query&format=json&prop=extracts|pageprops&exintro&explaintext&titles=${encodeURIComponent(titleToTry)}&redirects=1&origin=*`;
791
799
  const summaryRes = await fetch(summaryUrl);
792
- const summaryData = await summaryRes.json();
800
+ const summaryData = (await jsonFromResponse(summaryRes)) as { query?: { pages?: unknown } } | null;
801
+ if (!summaryData) continue;
793
802
  const pages = summaryData.query?.pages;
794
803
 
795
804
  if (pages) {
@@ -831,14 +840,14 @@ export const fetchWikipediaSummary = async (
831
840
  }
832
841
 
833
842
  if (queryNameParts.length >= 2) {
834
- const pageParts = String(page.title || "").toLowerCase().split(/[\s-]+/).filter(w => w.length > 2);
843
+ const pageParts = String(page.title || "").toLowerCase().split(/[\s-]+/).filter((w: string) => w.length > 2);
835
844
  const allMatch = queryNameParts.every(q => pageParts.includes(q));
836
845
  if (!allMatch) {
837
846
  // console.log(`⚠️ [Wiki] Skipping resolved title "${page.title}" for "${cleanQuery}" (not all name parts match).`);
838
847
  continue;
839
848
  }
840
849
  } else if (queryLastName) {
841
- const pageParts = String(page.title || "").toLowerCase().split(/[\s-]+/).filter(w => w.length > 2);
850
+ const pageParts = String(page.title || "").toLowerCase().split(/[\s-]+/).filter((w: string) => w.length > 2);
842
851
  if (!pageParts.includes(queryLastName)) {
843
852
  // console.log(`⚠️ [Wiki] Skipping resolved title "${page.title}" for "${cleanQuery}" (missing last-name match).`);
844
853
  continue;
@@ -936,9 +945,9 @@ export const fetchWikipediaExtract = async (
936
945
  // when exchars is set (returns fewer chars than the article actually contains). We fetch
937
946
  // the full extract and truncate client-side instead.
938
947
  const url = `https://en.wikipedia.org/w/api.php?action=query&format=json&prop=extracts|pageprops&explaintext&titles=${encodeURIComponent(title)}&redirects=1&origin=*`;
939
- // Hard cap: a hung Wikipedia response must not strand graph expansion spinners indefinitely.
940
- const res = await fetchWithTimeout(url, {}, 25_000);
941
- const data = await res.json();
948
+ const res = await fetch(url);
949
+ const data = (await jsonFromResponse(res)) as { query?: { pages?: unknown } } | null;
950
+ if (!data) return { extract: null, pageid: null, title: null };
942
951
  const pages = data.query?.pages;
943
952
  if (!pages) return { extract: null, pageid: null, title: null };
944
953
  const page = Object.values(pages)[0] as any;
@@ -988,8 +997,8 @@ export const fetchWikidataCastForTitle = async (title: string, limit: number = 1
988
997
  try {
989
998
  const pagepropsUrl = `https://en.wikipedia.org/w/api.php?action=query&format=json&prop=pageprops&titles=${encodeURIComponent(title)}&redirects=1&origin=*`;
990
999
  const ppRes = await fetch(pagepropsUrl, { signal });
991
- const ppData = await ppRes.json();
992
- const pages = ppData?.query?.pages;
1000
+ const ppData = await jsonFromResponse(ppRes);
1001
+ const pages = (ppData as { query?: { pages?: unknown } } | null)?.query?.pages;
993
1002
  if (pages) {
994
1003
  const page = Object.values(pages)[0] as any;
995
1004
  const candidate = page?.pageprops?.wikibase_item;
@@ -1008,8 +1017,8 @@ export const fetchWikidataCastForTitle = async (title: string, limit: number = 1
1008
1017
 
1009
1018
  const entityUrl = `https://www.wikidata.org/w/api.php?action=wbgetentities&format=json&props=claims&ids=${encodeURIComponent(wikidataId)}&origin=*`;
1010
1019
  const entRes = await fetch(entityUrl, { signal });
1011
- const entData = await entRes.json();
1012
- const claims = entData?.entities?.[wikidataId]?.claims;
1020
+ const entData = await jsonFromResponse(entRes);
1021
+ const claims = (entData as { entities?: Record<string, { claims?: unknown }> } | null)?.entities?.[wikidataId]?.claims;
1013
1022
  if (!claims) return [];
1014
1023
 
1015
1024
  const castIds = extractWikidataItemIds(claims, "P161");
@@ -1036,7 +1045,8 @@ const fetchWikidataLabels = async (ids: string[], signal: AbortSignal): Promise<
1036
1045
  try {
1037
1046
  const url = `https://www.wikidata.org/w/api.php?action=wbgetentities&format=json&props=labels&languages=en&ids=${encodeURIComponent(chunk.join("|"))}&origin=*`;
1038
1047
  const res = await fetch(url, { signal });
1039
- const data = await res.json();
1048
+ const data = (await jsonFromResponse(res)) as { entities?: Record<string, { labels?: { en?: { value?: string } } }> } | null;
1049
+ if (!data) continue;
1040
1050
  const entities = data?.entities || {};
1041
1051
  for (const [id, ent] of Object.entries<any>(entities)) {
1042
1052
  const label = ent?.labels?.en?.value;
@@ -1053,7 +1063,7 @@ const resolveWikidataIdBySearch = async (label: string, signal: AbortSignal): Pr
1053
1063
  try {
1054
1064
  const url = `https://www.wikidata.org/w/api.php?action=wbsearchentities&format=json&language=en&limit=8&search=${encodeURIComponent(label)}&origin=*`;
1055
1065
  const res = await fetch(url, { signal });
1056
- const data = await res.json();
1066
+ const data = (await jsonFromResponse(res)) as { search?: any[] } | null;
1057
1067
  const results: any[] = data?.search || [];
1058
1068
  if (!results.length) return null;
1059
1069
 
@@ -1095,8 +1105,8 @@ export const fetchWikidataKeyPeopleForTitle = async (title: string): Promise<Wik
1095
1105
  try {
1096
1106
  const pagepropsUrl = `https://en.wikipedia.org/w/api.php?action=query&format=json&prop=pageprops&titles=${encodeURIComponent(title)}&redirects=1&origin=*`;
1097
1107
  const ppRes = await fetch(pagepropsUrl, { signal });
1098
- const ppData = await ppRes.json();
1099
- const pages = ppData?.query?.pages;
1108
+ const ppData = await jsonFromResponse(ppRes);
1109
+ const pages = (ppData as { query?: { pages?: unknown } } | null)?.query?.pages;
1100
1110
  if (pages) {
1101
1111
  const page = Object.values(pages)[0] as any;
1102
1112
  const resolvedTitle = String(page?.title || "");
@@ -1127,7 +1137,7 @@ export const fetchWikidataKeyPeopleForTitle = async (title: string): Promise<Wik
1127
1137
  // 2) Pull key-people claims.
1128
1138
  const entityUrl = `https://www.wikidata.org/w/api.php?action=wbgetentities&format=json&props=claims&ids=${encodeURIComponent(wikidataId)}&origin=*`;
1129
1139
  const entRes = await fetch(entityUrl, { signal });
1130
- const entData = await entRes.json();
1140
+ const entData = (await jsonFromResponse(entRes)) as { entities?: Record<string, { claims?: unknown }> } | null;
1131
1141
  const entity = entData?.entities?.[wikidataId];
1132
1142
  const claims = entity?.claims;
1133
1143
  if (!claims) {
package/types.ts CHANGED
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  import { SimulationNodeDatum, SimulationLinkDatum } from 'd3';
2
3
 
3
4
  export interface GraphNode extends SimulationNodeDatum {
@@ -20,6 +21,8 @@ export interface GraphNode extends SimulationNodeDatum {
20
21
  atomic_type?: string; // e.g. "Symptom"
21
22
  composite_type?: string; // e.g. "Disease"
22
23
  mentioningPageTitles?: string[]; // Titles of articles mentioning this entity (for non-article fallback)
24
+ /** Measured card height in timeline view (set by Graph layout). */
25
+ h?: number;
23
26
  // D3 Simulation properties explicitly defined to ensure access
24
27
  x?: number;
25
28
  y?: number;
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  export const normalizeForEvidence = (s: unknown) =>
2
3
  String(s || '')
3
4
  .toLowerCase()
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  export const getLinkKey = (a: number | string, b: number | string) => {
2
3
  const s = String(a);
3
4
  const t = String(b);
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  import { fetchWikipediaExtract } from '../services/wikipediaService';
2
3
 
3
4
  export const buildWikiUrl = (title: string, wikipediaId?: string | number) => {
@@ -21,10 +22,21 @@ export const looksLikeWikipediaTitle = (t: unknown) => {
21
22
  return true;
22
23
  };
23
24
 
24
- const extractCache: Map<string, string | null> =
25
- ((window as any).__wikiExtractCache ||= new Map<string, string | null>());
25
+ const serverExtractCache = new Map<string, string | null>();
26
+
27
+ function getExtractCacheMap(): Map<string, string | null> {
28
+ if (typeof window === 'undefined') {
29
+ return serverExtractCache;
30
+ }
31
+ const w = window as unknown as { __wikiExtractCache?: Map<string, string | null> };
32
+ if (!w.__wikiExtractCache) {
33
+ w.__wikiExtractCache = new Map();
34
+ }
35
+ return w.__wikiExtractCache;
36
+ }
26
37
 
27
38
  export const getExtractCached = async (title: string) => {
39
+ const extractCache = getExtractCacheMap();
28
40
  const key = String(title || '').trim();
29
41
  if (!key) return null;
30
42
  if (extractCache.has(key)) return extractCache.get(key) || null;