@johndimm/constellations 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/App.tsx +480 -0
- package/FullPageConstellations.tsx +74 -0
- package/FullPageConstellationsHostShell.tsx +27 -0
- package/README.md +116 -0
- package/components/AppConfirmDialog.tsx +46 -0
- package/components/AppHeader.tsx +73 -0
- package/components/AppNotifications.tsx +21 -0
- package/components/BrowsePeople.tsx +832 -0
- package/components/ControlPanel.tsx +1023 -0
- package/components/Graph.tsx +1525 -0
- package/components/HelpOverlay.tsx +168 -0
- package/components/NodeContextMenu.tsx +160 -0
- package/components/PeopleBrowserSidebar.tsx +690 -0
- package/components/Sidebar.tsx +271 -0
- package/components/TimelineView.tsx +4 -0
- package/hooks/useExpansion.ts +889 -0
- package/hooks/useGraphActions.ts +325 -0
- package/hooks/useGraphState.ts +414 -0
- package/hooks/useKioskMode.ts +47 -0
- package/hooks/useNodeClickHandler.ts +172 -0
- package/hooks/useSearchHandlers.ts +369 -0
- package/host.ts +16 -0
- package/index.css +101 -0
- package/index.tsx +16 -0
- package/kioskDomains.ts +307 -0
- package/package.json +78 -0
- package/services/aiUtils.ts +364 -0
- package/services/cacheService.ts +76 -0
- package/services/crossrefService.ts +107 -0
- package/services/geminiService.ts +1359 -0
- package/services/get-local-graphs.js +5 -0
- package/services/graphUtils.ts +347 -0
- package/services/imageService.ts +39 -0
- package/services/llmClient.ts +194 -0
- package/services/openAlexService.ts +173 -0
- package/services/wikipediaImage.ts +40 -0
- package/services/wikipediaService.ts +1175 -0
- package/sessionHandoff.ts +132 -0
- package/types.ts +99 -0
- package/useFullPageConstellationsHost.ts +116 -0
- package/utils/evidenceUtils.ts +107 -0
- package/utils/graphLogicUtils.ts +32 -0
- package/utils/graphNodeToChannelNotes.ts +71 -0
- package/utils/wikiUtils.ts +34 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { GraphNode, GraphLink } from './types';
|
|
3
|
+
import { LockedPair } from './services/geminiService';
|
|
4
|
+
import { dedupeGraph } from './services/graphUtils';
|
|
5
|
+
|
|
6
|
+
const getLinkEndpointId = (x: string | number | GraphNode) =>
|
|
7
|
+
typeof x === 'object' && x != null && 'id' in x ? (x as GraphNode).id : (x as string | number);
|
|
8
|
+
|
|
9
|
+
export const SOUNDINGS_CONSTELLATIONS_HANDOFF_KEY = 'soundings-constellations-handoff-v1';
|
|
10
|
+
|
|
11
|
+
export type ConstellationsSessionHandoffV1 = {
|
|
12
|
+
v: 1;
|
|
13
|
+
graph: { nodes: GraphNode[]; links: GraphLink[] };
|
|
14
|
+
exploreTerm: string;
|
|
15
|
+
pathStart: string;
|
|
16
|
+
pathEnd: string;
|
|
17
|
+
searchMode: 'explore' | 'connect';
|
|
18
|
+
isCompact: boolean;
|
|
19
|
+
isTimelineMode: boolean;
|
|
20
|
+
isTextOnly: boolean;
|
|
21
|
+
searchId: number;
|
|
22
|
+
lockedPair: LockedPair;
|
|
23
|
+
pathNodeIds: (string | number)[];
|
|
24
|
+
selectedNodeId?: string | number | null;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function buildHandoffFromLiveState(params: {
|
|
28
|
+
graph: { nodes: GraphNode[]; links: GraphLink[] };
|
|
29
|
+
exploreTerm: string;
|
|
30
|
+
pathStart: string;
|
|
31
|
+
pathEnd: string;
|
|
32
|
+
searchMode: 'explore' | 'connect';
|
|
33
|
+
isCompact: boolean;
|
|
34
|
+
isTimelineMode: boolean;
|
|
35
|
+
isTextOnly: boolean;
|
|
36
|
+
searchId: number;
|
|
37
|
+
lockedPair: LockedPair;
|
|
38
|
+
pathNodeIds: (string | number)[];
|
|
39
|
+
selectedNodeId: string | number | null | undefined;
|
|
40
|
+
}): ConstellationsSessionHandoffV1 {
|
|
41
|
+
if (!params.graph.nodes.length) {
|
|
42
|
+
throw new Error('Handoff requires at least one node');
|
|
43
|
+
}
|
|
44
|
+
const links: GraphLink[] = params.graph.links.map((l) => ({
|
|
45
|
+
...l,
|
|
46
|
+
source: getLinkEndpointId(l.source as string | number | GraphNode),
|
|
47
|
+
target: getLinkEndpointId(l.target as string | number | GraphNode)
|
|
48
|
+
}));
|
|
49
|
+
const nodes: GraphNode[] = params.graph.nodes.map((n) => ({
|
|
50
|
+
...n,
|
|
51
|
+
isLoading: false,
|
|
52
|
+
fetchingImage: false,
|
|
53
|
+
vx: n.vx ?? 0,
|
|
54
|
+
vy: n.vy ?? 0
|
|
55
|
+
}));
|
|
56
|
+
return {
|
|
57
|
+
v: 1,
|
|
58
|
+
graph: { nodes, links },
|
|
59
|
+
exploreTerm: params.exploreTerm,
|
|
60
|
+
pathStart: params.pathStart,
|
|
61
|
+
pathEnd: params.pathEnd,
|
|
62
|
+
searchMode: params.searchMode,
|
|
63
|
+
isCompact: params.isCompact,
|
|
64
|
+
isTimelineMode: params.isTimelineMode,
|
|
65
|
+
isTextOnly: params.isTextOnly,
|
|
66
|
+
searchId: params.searchId,
|
|
67
|
+
lockedPair: { ...params.lockedPair },
|
|
68
|
+
pathNodeIds: [...params.pathNodeIds],
|
|
69
|
+
selectedNodeId: params.selectedNodeId ?? null
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Strips simulation cruft, dedupes, and returns graph safe to put in React state.
|
|
75
|
+
*/
|
|
76
|
+
export function graphFromHandoff(h: ConstellationsSessionHandoffV1) {
|
|
77
|
+
const links = h.graph.links.map((l) => ({
|
|
78
|
+
...l,
|
|
79
|
+
source: getLinkEndpointId(l.source as string | number | GraphNode),
|
|
80
|
+
target: getLinkEndpointId(l.target as string | number | GraphNode)
|
|
81
|
+
}));
|
|
82
|
+
const nodes = h.graph.nodes.map((n) => ({
|
|
83
|
+
...n,
|
|
84
|
+
isLoading: false,
|
|
85
|
+
fetchingImage: false,
|
|
86
|
+
vx: n.vx ?? 0,
|
|
87
|
+
vy: n.vy ?? 0,
|
|
88
|
+
fx: n.fx ?? null,
|
|
89
|
+
fy: n.fy ?? null
|
|
90
|
+
}));
|
|
91
|
+
return dedupeGraph(nodes, links);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const UNREAD: unique symbol = Symbol('embed-handoff');
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Staging from player embed: sessionStorage (once) + in-memory (duplicate React 18
|
|
98
|
+
* useState initializers in StrictMode must see the same object).
|
|
99
|
+
*/
|
|
100
|
+
let embedHandoffMem: ConstellationsSessionHandoffV1 | null | typeof UNREAD = UNREAD;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* For use in `useState(() => takeEmbedHandoffForInitialState())` — no side effects
|
|
104
|
+
* that differ between double invocations: second call must return the same value.
|
|
105
|
+
* Always prefer a fresh `sessionStorage` payload (new navigation) over the cache.
|
|
106
|
+
*/
|
|
107
|
+
export function takeEmbedHandoffForInitialState(): ConstellationsSessionHandoffV1 | null {
|
|
108
|
+
if (typeof window !== 'undefined') {
|
|
109
|
+
const raw = sessionStorage.getItem(SOUNDINGS_CONSTELLATIONS_HANDOFF_KEY);
|
|
110
|
+
if (raw) {
|
|
111
|
+
try {
|
|
112
|
+
const p = JSON.parse(raw) as ConstellationsSessionHandoffV1;
|
|
113
|
+
if (p?.v === 1 && p.graph?.nodes?.length) {
|
|
114
|
+
try {
|
|
115
|
+
sessionStorage.removeItem(SOUNDINGS_CONSTELLATIONS_HANDOFF_KEY);
|
|
116
|
+
} catch { /* empty */ }
|
|
117
|
+
embedHandoffMem = p;
|
|
118
|
+
return p;
|
|
119
|
+
}
|
|
120
|
+
} catch { /* empty */ }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (embedHandoffMem !== UNREAD) {
|
|
124
|
+
return embedHandoffMem;
|
|
125
|
+
}
|
|
126
|
+
if (typeof window === 'undefined') {
|
|
127
|
+
embedHandoffMem = null;
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
embedHandoffMem = null;
|
|
131
|
+
return null;
|
|
132
|
+
}
|
package/types.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { SimulationNodeDatum, SimulationLinkDatum } from 'd3';
|
|
2
|
+
|
|
3
|
+
export interface GraphNode extends SimulationNodeDatum {
|
|
4
|
+
id: number | string; // Sequential serial ID or Wikipedia/OpenAlex ID
|
|
5
|
+
title: string; // The name of the event/project/thing/person
|
|
6
|
+
type: string; // Original detailed type: 'Person', 'Movie', 'Battle', etc. (preserved)
|
|
7
|
+
is_atomic?: boolean; // True for atomic nodes, false for composite nodes
|
|
8
|
+
is_person?: boolean; // DEPRECATED: use is_atomic
|
|
9
|
+
wikipedia_id?: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
meta?: Record<string, any>; // Optional source-specific metadata (e.g., OpenAlex IDs)
|
|
12
|
+
imageUrl?: string | null; // URL for the node image
|
|
13
|
+
year?: number; // Year of occurrence (for timeline view)
|
|
14
|
+
expanded?: boolean; // Whether we have already fetched connections for this node
|
|
15
|
+
isLoading?: boolean; // Visual state for fetching (connections)
|
|
16
|
+
fetchingImage?: boolean; // State for fetching image
|
|
17
|
+
imageChecked?: boolean; // Whether we have already attempted to fetch an image
|
|
18
|
+
wikiSummary?: string; // Cached Wikipedia summary for richer sidebar context
|
|
19
|
+
classification_reasoning?: string; // AI explanation of atomic/composite status
|
|
20
|
+
atomic_type?: string; // e.g. "Symptom"
|
|
21
|
+
composite_type?: string; // e.g. "Disease"
|
|
22
|
+
mentioningPageTitles?: string[]; // Titles of articles mentioning this entity (for non-article fallback)
|
|
23
|
+
// D3 Simulation properties explicitly defined to ensure access
|
|
24
|
+
x?: number;
|
|
25
|
+
y?: number;
|
|
26
|
+
fx?: number | null;
|
|
27
|
+
fy?: number | null;
|
|
28
|
+
vx?: number;
|
|
29
|
+
vy?: number;
|
|
30
|
+
index?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface GraphLink extends SimulationLinkDatum<GraphNode> {
|
|
34
|
+
source: number | string | GraphNode;
|
|
35
|
+
target: number | string | GraphNode;
|
|
36
|
+
id: string | number; // Unique link ID
|
|
37
|
+
label?: string; // Role or connection description
|
|
38
|
+
evidence?: {
|
|
39
|
+
kind: 'wikipedia' | 'openalex' | 'crossref' | 'ai' | 'none';
|
|
40
|
+
// Human-readable page title where the snippet came from (usually source or target)
|
|
41
|
+
pageTitle?: string;
|
|
42
|
+
// Copyable snippet (typically 1 sentence)
|
|
43
|
+
snippet?: string;
|
|
44
|
+
// URL to open for verification
|
|
45
|
+
url?: string;
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface GeminiEntity {
|
|
50
|
+
name: string;
|
|
51
|
+
type: string;
|
|
52
|
+
description: string;
|
|
53
|
+
role: string; // Role in the parent connection
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface GeminiPerson {
|
|
57
|
+
name: string;
|
|
58
|
+
wikipediaTitle?: string; // Canonical Wikipedia page title (may include disambiguation parentheses)
|
|
59
|
+
role: string; // Role in the source node
|
|
60
|
+
description: string; // Brief bio
|
|
61
|
+
isAtomic?: boolean; // LLM-determined atomic vs composite classification
|
|
62
|
+
evidenceSnippet?: string; // 1 sentence from provided verified text (preferred)
|
|
63
|
+
evidencePageTitle?: string; // Which page the snippet came from (usually the source title)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface GeminiResponse {
|
|
67
|
+
sourceYear?: number;
|
|
68
|
+
people: GeminiPerson[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface PersonWork {
|
|
72
|
+
entity: string;
|
|
73
|
+
wikipediaTitle?: string; // Canonical Wikipedia page title (may include disambiguation parentheses)
|
|
74
|
+
type: string;
|
|
75
|
+
description: string;
|
|
76
|
+
role: string;
|
|
77
|
+
year: number;
|
|
78
|
+
imageUrl?: string | null;
|
|
79
|
+
isAtomic?: boolean; // LLM-determined atomic vs composite classification
|
|
80
|
+
evidenceSnippet?: string; // 1 sentence from provided verified text (preferred)
|
|
81
|
+
evidencePageTitle?: string; // Which page the snippet came from (usually the source title)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface PersonWorksResponse {
|
|
85
|
+
works: PersonWork[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface PathEntity {
|
|
89
|
+
id: string;
|
|
90
|
+
type: string;
|
|
91
|
+
description: string;
|
|
92
|
+
year?: number;
|
|
93
|
+
justification: string; // How it connects to the previous node
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface PathResponse {
|
|
97
|
+
path: PathEntity[];
|
|
98
|
+
found: boolean;
|
|
99
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
|
|
5
|
+
export type NowPlayingSnapshot = {
|
|
6
|
+
album?: string | null;
|
|
7
|
+
track?: string | null;
|
|
8
|
+
artist?: string | null;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* State sync for every full-page constellations host (Soundings, Trailer, etc.): URL `q` / `expand`,
|
|
13
|
+
* optional handoff gating, and optional live player bridge (now-playing + external search).
|
|
14
|
+
* Single implementation — host apps only supply layout-specific inputs.
|
|
15
|
+
*/
|
|
16
|
+
export function useFullPageConstellationsHost(input: {
|
|
17
|
+
qParam: string;
|
|
18
|
+
expandParam: string;
|
|
19
|
+
/** When a session handoff is present, do not clobber from URL/bridge. */
|
|
20
|
+
skipUrlAndPlayerBridge: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* If set, merge `expand` with album/track for auto-expand and drive `nowPlayingKey` /
|
|
23
|
+
* `externalSearch` from the snapshot. Omit (Trailer) for URL `expand` only.
|
|
24
|
+
*/
|
|
25
|
+
getPlayerSnapshot?: () => NowPlayingSnapshot | null | undefined;
|
|
26
|
+
/** e.g. `soundings-now-playing` — bumps a revision so `nowPlayingKey` updates with the player. */
|
|
27
|
+
nowPlayingBumperEvent?: string;
|
|
28
|
+
}) {
|
|
29
|
+
const [hydrated, setHydrated] = useState(false);
|
|
30
|
+
const [npRev, setNpRev] = useState(0);
|
|
31
|
+
const [externalSearch, setExternalSearch] = useState<{
|
|
32
|
+
term: string;
|
|
33
|
+
id: string | number;
|
|
34
|
+
} | null>(null);
|
|
35
|
+
const [autoExpandTitles, setAutoExpandTitles] = useState<string[]>([]);
|
|
36
|
+
const [nowPlayingKey, setNowPlayingKey] = useState<string | null>(null);
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
setHydrated(true);
|
|
40
|
+
}, []);
|
|
41
|
+
|
|
42
|
+
const bumper = input.nowPlayingBumperEvent;
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (!bumper) return;
|
|
46
|
+
const bump = () => setNpRev((n) => n + 1);
|
|
47
|
+
window.addEventListener(bumper, bump);
|
|
48
|
+
return () => window.removeEventListener(bumper, bump);
|
|
49
|
+
}, [bumper]);
|
|
50
|
+
|
|
51
|
+
const { qParam, expandParam, skipUrlAndPlayerBridge, getPlayerSnapshot } = input;
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (!hydrated) return;
|
|
55
|
+
if (skipUrlAndPlayerBridge) return;
|
|
56
|
+
|
|
57
|
+
const extra = expandParam
|
|
58
|
+
? expandParam
|
|
59
|
+
.split(",")
|
|
60
|
+
.map((s) => s.trim())
|
|
61
|
+
.filter(Boolean)
|
|
62
|
+
: [];
|
|
63
|
+
|
|
64
|
+
if (!getPlayerSnapshot) {
|
|
65
|
+
setExternalSearch(null);
|
|
66
|
+
setNowPlayingKey(null);
|
|
67
|
+
setAutoExpandTitles(extra);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const snap = getPlayerSnapshot();
|
|
72
|
+
const album = snap?.album?.trim();
|
|
73
|
+
const track = snap?.track?.trim();
|
|
74
|
+
const artist = snap?.artist?.trim();
|
|
75
|
+
const mergedExpand = [
|
|
76
|
+
...extra,
|
|
77
|
+
...(album ? [album] : []),
|
|
78
|
+
...(track ? [track] : []),
|
|
79
|
+
...(artist ? [artist] : []),
|
|
80
|
+
];
|
|
81
|
+
if (album || track) {
|
|
82
|
+
setNowPlayingKey(`${npRev}::${album || ""}::${track || ""}`);
|
|
83
|
+
} else {
|
|
84
|
+
setNowPlayingKey(null);
|
|
85
|
+
}
|
|
86
|
+
if (qParam) {
|
|
87
|
+
setExternalSearch(null);
|
|
88
|
+
} else {
|
|
89
|
+
// Prefer the track title over the artist/channel name. For YouTube classical music,
|
|
90
|
+
// the title contains the composer ("Vaughan Williams ~ The Lark Ascending") while
|
|
91
|
+
// the artist is just the uploader's channel name. The LLM in classifyStartPair
|
|
92
|
+
// (extractMusicEntity) will parse the title to extract the primary musical entity.
|
|
93
|
+
const searchTerm = track || snap?.artist?.trim() || "";
|
|
94
|
+
if (searchTerm) {
|
|
95
|
+
setExternalSearch({ term: searchTerm, id: `np:${searchTerm.toLowerCase()}` });
|
|
96
|
+
} else {
|
|
97
|
+
setExternalSearch(null);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
setAutoExpandTitles(mergedExpand);
|
|
101
|
+
}, [
|
|
102
|
+
hydrated,
|
|
103
|
+
qParam,
|
|
104
|
+
expandParam,
|
|
105
|
+
skipUrlAndPlayerBridge,
|
|
106
|
+
getPlayerSnapshot,
|
|
107
|
+
npRev,
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
ready: hydrated,
|
|
112
|
+
externalSearch,
|
|
113
|
+
autoExpandTitles,
|
|
114
|
+
nowPlayingKey,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
export const normalizeForEvidence = (s: unknown) =>
|
|
2
|
+
String(s || '')
|
|
3
|
+
.toLowerCase()
|
|
4
|
+
.replace(/[“”"]/g, '"')
|
|
5
|
+
.replace(/[’‘]/g, "'")
|
|
6
|
+
.replace(/\s+/g, ' ')
|
|
7
|
+
.trim();
|
|
8
|
+
|
|
9
|
+
export const splitIntoSentences = (text: string): string[] => {
|
|
10
|
+
const t = String(text || '').replace(/\s+/g, ' ').trim();
|
|
11
|
+
if (!t) return [];
|
|
12
|
+
// Improved sentence split that ignores common abbreviations
|
|
13
|
+
const commonAbbreviations = ['Bros', 'Mr', 'Mrs', 'Ms', 'Dr', 'Sr', 'Jr', 'St', 'Prof', 'Capt', 'Col', 'Gen', 'Inc', 'Ltd', 'Co'];
|
|
14
|
+
const abbrRegex = commonAbbreviations.join('|');
|
|
15
|
+
const regex = new RegExp(`(?<!\\b(?:${abbrRegex}))[.!?](?=\\s+[A-Z]|$)`, 'g');
|
|
16
|
+
|
|
17
|
+
const sentences: string[] = [];
|
|
18
|
+
let lastIndex = 0;
|
|
19
|
+
let match;
|
|
20
|
+
while ((match = regex.exec(t)) !== null) {
|
|
21
|
+
sentences.push(t.substring(lastIndex, match.index + 1).trim());
|
|
22
|
+
lastIndex = match.index + 1;
|
|
23
|
+
}
|
|
24
|
+
if (lastIndex < t.length) {
|
|
25
|
+
const remaining = t.substring(lastIndex).trim();
|
|
26
|
+
if (remaining) sentences.push(remaining);
|
|
27
|
+
}
|
|
28
|
+
return sentences.filter(Boolean);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const roleLooksLikeJobTitle = (s: unknown) =>
|
|
32
|
+
/\b(president|ceo|chief|director|manager|founder|co-founder|curator|chairman|head)\b/i.test(String(s || ''));
|
|
33
|
+
|
|
34
|
+
export const sanitizeTitleParen = (title: string) => title.replace(/\s*\(([^)]+)\)\s*$/, '').trim();
|
|
35
|
+
|
|
36
|
+
export const isParenJobTitle = (title: unknown) => {
|
|
37
|
+
const s = String(title || '');
|
|
38
|
+
const m = s.match(/\(([^)]+)\)\s*$/);
|
|
39
|
+
return !!m && roleLooksLikeJobTitle(m[1]);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const stripJobTitleParen = (title: string) => title.replace(/\s*\(([^)]+)\)\s*$/, '').trim();
|
|
43
|
+
|
|
44
|
+
export const parentheticalLooksLikeJobTitle = (title: unknown) => {
|
|
45
|
+
const s = String(title || '');
|
|
46
|
+
const m = s.match(/\(([^)]+)\)\s*$/);
|
|
47
|
+
if (!m) return false;
|
|
48
|
+
return roleLooksLikeJobTitle(m[1]);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const isEvidenceBacked = (snippet: unknown, verifiedNorm: string) => {
|
|
52
|
+
const sn = normalizeForEvidence(snippet);
|
|
53
|
+
if (!sn) return false;
|
|
54
|
+
if (!verifiedNorm) return false;
|
|
55
|
+
return verifiedNorm.includes(sn);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const looksLikeSpecificPersonName = (title: unknown) => {
|
|
59
|
+
const s = String(title || '').trim();
|
|
60
|
+
if (!s) return false;
|
|
61
|
+
const lower = s.toLowerCase();
|
|
62
|
+
// Exclude generic terms
|
|
63
|
+
if (/\b(celebrity|celeb|celebrities|guests?|visitors?|staff|team|various|unknown)\b/.test(lower)) return false;
|
|
64
|
+
|
|
65
|
+
// Allow parenthetical disambiguation, but evaluate the base name.
|
|
66
|
+
const base = s.replace(/\s*\(.*\)\s*$/, '').trim();
|
|
67
|
+
const parts = base.split(/\s+/).filter(Boolean);
|
|
68
|
+
|
|
69
|
+
if (parts.length === 0) return false;
|
|
70
|
+
|
|
71
|
+
if (parts.length === 1) {
|
|
72
|
+
const name = parts[0];
|
|
73
|
+
// Allow proper names (starts with capital) of at least 2 characters.
|
|
74
|
+
return /^[A-Z]/.test(name) && name.length >= 2;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (parts.some(p => p.length < 2)) return false;
|
|
78
|
+
return true;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const sanitizeEvidenceAndRole = (cn: any, verifiedNorm: string) => {
|
|
82
|
+
const e = cn?.edge_meta?.evidence;
|
|
83
|
+
const hasEvidence = e && e.kind && e.kind !== 'none' && (e.snippet || e.pageTitle);
|
|
84
|
+
if (!hasEvidence) return cn;
|
|
85
|
+
// Non-Wikipedia sources (e.g., OpenAlex) are handled separately and should not be
|
|
86
|
+
// invalidated by Wikipedia-only snippet matching.
|
|
87
|
+
if (String(e.kind) === 'openalex') return cn;
|
|
88
|
+
|
|
89
|
+
const pageTitle = String(e.pageTitle || '');
|
|
90
|
+
const snippet = String(e.snippet || '');
|
|
91
|
+
const pageLooksNonWiki = pageTitle.includes(' - ') || /^https?:\/\//i.test(pageTitle);
|
|
92
|
+
const backed = isEvidenceBacked(snippet, verifiedNorm);
|
|
93
|
+
|
|
94
|
+
if (!backed || pageLooksNonWiki) {
|
|
95
|
+
const next = { ...cn };
|
|
96
|
+
// Drop unverified evidence.
|
|
97
|
+
next.edge_meta = { ...(next.edge_meta || {}), evidence: { kind: 'none' } };
|
|
98
|
+
// Drop role label if it looks like a job-title claim.
|
|
99
|
+
if (roleLooksLikeJobTitle(next.edge_label)) next.edge_label = null;
|
|
100
|
+
// If the node title itself is just a job-title parenthetical (unverified), strip it.
|
|
101
|
+
if (typeof next.title === 'string' && parentheticalLooksLikeJobTitle(next.title)) {
|
|
102
|
+
next.title = stripJobTitleParen(next.title);
|
|
103
|
+
}
|
|
104
|
+
return next;
|
|
105
|
+
}
|
|
106
|
+
return cn;
|
|
107
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export const getLinkKey = (a: number | string, b: number | string) => {
|
|
2
|
+
const s = String(a);
|
|
3
|
+
const t = String(b);
|
|
4
|
+
return s < t ? `${s}-${t}` : `${t}-${s}`;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export const looksLikeScreenWork = (title: string, desc?: string) => {
|
|
8
|
+
const s = String(title || '').toLowerCase();
|
|
9
|
+
const d = String(desc || '').toLowerCase();
|
|
10
|
+
return (
|
|
11
|
+
s.includes('(film)') || s.includes('(movie)') || s.includes('(tv series)') ||
|
|
12
|
+
d.includes('film') || d.includes('movie') || d.includes('television series') || d.includes('tv series')
|
|
13
|
+
);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const isBadListPage = (t?: string) => {
|
|
17
|
+
const s = String(t || '').toLowerCase();
|
|
18
|
+
if (!s) return false;
|
|
19
|
+
if (s.startsWith('list of ')) return true;
|
|
20
|
+
if (s.includes('acquired by google') || s.includes('companies acquired by google') || s.includes('acquisitions by google')) return true;
|
|
21
|
+
return false;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const clampToViewport = (x: number, y: number, margin = 50) => {
|
|
25
|
+
if (typeof window === 'undefined') return { x, y };
|
|
26
|
+
const w = window.innerWidth;
|
|
27
|
+
const h = window.innerHeight;
|
|
28
|
+
return {
|
|
29
|
+
x: Math.max(margin, Math.min(x, w - margin)),
|
|
30
|
+
y: Math.max(margin, Math.min(y, h - margin))
|
|
31
|
+
};
|
|
32
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import type { GraphNode } from '../types';
|
|
3
|
+
import { buildWikiUrl } from './wikiUtils';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_NAME_MAX = 40;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Short tab label for a channel (distinct from the long notes / DJ prompt).
|
|
9
|
+
* Safe for Wikipedia-style titles; truncates with an ellipsis when needed.
|
|
10
|
+
*/
|
|
11
|
+
export function graphNodeToChannelName(node: GraphNode, maxLen = DEFAULT_NAME_MAX): string {
|
|
12
|
+
const year = node.year;
|
|
13
|
+
const raw = (node.title || 'Channel').replace(/\s+/g, ' ').trim() || 'Channel';
|
|
14
|
+
const withYear = year != null ? `${raw} (${year})` : raw;
|
|
15
|
+
const primary = withYear.length <= maxLen ? withYear : raw;
|
|
16
|
+
if (primary.length <= maxLen) return primary;
|
|
17
|
+
if (maxLen < 2) return '…';
|
|
18
|
+
return primary.slice(0, maxLen - 1) + '…';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Free-text notes for a new Soundings channel, seeded from a graph node
|
|
23
|
+
* (used with createChannelWithNotes in the player).
|
|
24
|
+
*/
|
|
25
|
+
export function graphNodeToChannelNotes(node: GraphNode): string {
|
|
26
|
+
const type = (node.type || 'entity').trim();
|
|
27
|
+
const year = node.year != null ? ` (${node.year})` : '';
|
|
28
|
+
const titleLine = `From Constellations: ${node.title}${year}`;
|
|
29
|
+
const typeLine = `Type: ${type}`;
|
|
30
|
+
|
|
31
|
+
const parts: string[] = [titleLine, typeLine];
|
|
32
|
+
|
|
33
|
+
if (node.wikipedia_id) {
|
|
34
|
+
parts.push(`Wikipedia: ${buildWikiUrl(node.title, node.wikipedia_id)}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const blurb = (node.wikiSummary || node.description || '').trim();
|
|
38
|
+
if (blurb) {
|
|
39
|
+
parts.push('', blurb.slice(0, 2000));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return parts.join('\n');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Use this when both the short name and the long prompt are needed (e.g. Soundings channel tabs + notes). */
|
|
46
|
+
export function graphNodeToChannelSeeds(node: GraphNode): { name: string; notes: string } {
|
|
47
|
+
return {
|
|
48
|
+
name: graphNodeToChannelName(node),
|
|
49
|
+
notes: graphNodeToChannelNotes(node),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Host apps (Soundings, Trailer) queue the same sessionStorage payload then navigate; keeps route files minimal.
|
|
55
|
+
*/
|
|
56
|
+
export function newChannelFromGraphNode(
|
|
57
|
+
node: GraphNode,
|
|
58
|
+
options: { sessionStorageKey: string; navigate: (path: string) => void; path: string; logLabel?: string }
|
|
59
|
+
) {
|
|
60
|
+
const { name, notes } = graphNodeToChannelSeeds(node);
|
|
61
|
+
const label = options.logLabel ?? "constellations";
|
|
62
|
+
try {
|
|
63
|
+
sessionStorage.setItem(
|
|
64
|
+
options.sessionStorageKey,
|
|
65
|
+
JSON.stringify({ v: 1, name, notes })
|
|
66
|
+
);
|
|
67
|
+
} catch (e) {
|
|
68
|
+
console.warn(`[${label}] could not queue new channel for host`, e);
|
|
69
|
+
}
|
|
70
|
+
options.navigate(options.path);
|
|
71
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { fetchWikipediaExtract } from '../services/wikipediaService';
|
|
2
|
+
|
|
3
|
+
export const buildWikiUrl = (title: string, wikipediaId?: string | number) => {
|
|
4
|
+
if (wikipediaId) {
|
|
5
|
+
// If we have an ID, we likely have the exact title too.
|
|
6
|
+
return `https://en.wikipedia.org/wiki/${encodeURIComponent(title.replace(/\s+/g, '_'))}`;
|
|
7
|
+
}
|
|
8
|
+
// Fallback to search if no ID is present, to avoid 404s.
|
|
9
|
+
return `https://en.wikipedia.org/wiki/Special:Search?search=${encodeURIComponent(title)}`;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const buildWikiSearchUrl = (title: string) => `https://en.wikipedia.org/wiki/Special:Search?search=${encodeURIComponent(title)}`;
|
|
13
|
+
|
|
14
|
+
export const looksLikeWikipediaTitle = (t: unknown) => {
|
|
15
|
+
const s = String(t || '').trim();
|
|
16
|
+
if (!s) return false;
|
|
17
|
+
if (/^https?:\/\//i.test(s)) return false;
|
|
18
|
+
// Web page titles frequently include " - " separators; Wikipedia titles rarely do.
|
|
19
|
+
if (s.includes(' - ')) return false;
|
|
20
|
+
if (s.length > 90) return false;
|
|
21
|
+
return true;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const extractCache: Map<string, string | null> =
|
|
25
|
+
((window as any).__wikiExtractCache ||= new Map<string, string | null>());
|
|
26
|
+
|
|
27
|
+
export const getExtractCached = async (title: string) => {
|
|
28
|
+
const key = String(title || '').trim();
|
|
29
|
+
if (!key) return null;
|
|
30
|
+
if (extractCache.has(key)) return extractCache.get(key) || null;
|
|
31
|
+
const ex = (await fetchWikipediaExtract(key, 6000)).extract || null;
|
|
32
|
+
extractCache.set(key, ex);
|
|
33
|
+
return ex;
|
|
34
|
+
};
|