// ── State ────────────────────────────────────────────────────────────────────
let ws = null;
let nodes = {};
let edges = [];
let centreNode = null;
let pathNodes = new Set();
let width, height;
let running = false;
let _searchGen = 0;
let scoreCards = {};
let heroDismissed = false;
let paused = false;
let foundPath = [];
let _lastElapsedS = 0;
// ── Multi-source state (Aurelius as a general graph engine) ──────────────────
// The active data source the navigator runs on. Wikipedia is the default;
// /api/sources populates the picker with every registered adapter that is
// ready (live sources always; ingested sources once their data exists).
let currentSource = 'wikipedia';
let availableSources = [];
// ── Optional AI layer (Gemini) ───────────────────────────────────────────────
// llmAvailable is a soft hint from /api/llm/status polled at load: it only
// controls whether we bother RENDERING the AI affordances. The real
// gate is per-call — every AI endpoint returns {available, reason} and the
// aiBlock helper shows a small non-blocking notice on failure while the
// standard (non-LLM) result stays fully usable. The app runs with no key.
let llmAvailable = false;
async function loadLlmStatus() {
try {
const r = await fetch(`${AC_BACKEND}/api/llm/status`);
const d = await r.json();
llmAvailable = !!d.available;
} catch (e) { llmAvailable = false; }
}
function _aiReason(reason) {
switch (reason) {
case 'rate_limited':
case 'cooling_down':
return 'AI paused — rate limit reached. Showing standard results.';
case 'no_key':
return 'AI features are not configured on this server.';
case 'no_articles':
return 'Not enough coverage to summarize yet.';
default:
return 'AI summary unavailable right now. Showing standard results.';
}
}
// Lazily fill an AI block: shimmer → narrative, or a non-blocking notice.
// `field` is which key holds the text ('text' or 'summary'). Never throws;
// the surrounding standard result is always rendered independently first.
async function aiBlock(el, url, opts = {}) {
if (!el) return;
const field = opts.field || 'text';
if (!llmAvailable) { el.style.display = 'none'; return; }
el.style.display = 'block';
el.innerHTML = '
`;
}
// ── Graph explorer state ─────────────────────────────────────────────────────
// graphFilter: which slice of the graph the 3D view shows (all / explored /
// path). expandedNodes: nodes already progressively expanded via
// /api/neighbors, so a second click doesn't refetch.
let graphFilter = 'all';
const expandedNodes = new Set();
// Relationship "lenses" — dedicated explorer views over typed edges.
// Picking one keeps only that family of relationships visible (path edges
// always stay), turning the one explorer into an ownership explorer, a
// supply-chain explorer, a macro explorer, etc.
let graphLens = 'all';
const SOURCE_LENSES = {
finance: [
['all', 'All relationships', null],
['ownership', 'Ownership & holdings', ['owns_stake', 'stake_held_by', 'holds', 'held_by']],
['supply', 'Supply chain', ['supplies', 'supplied_by']],
['competition', 'Competition', ['competes_with']],
['correlation', 'Correlation & macro', ['co_moves', 'macro_correlates']],
['leadership', 'Leadership', ['led_by', 'leads']],
['sector', 'Sector & geography', ['sector_member', 'has_member', 'based_in', 'headquarters_of']],
],
news: [
['all', 'All relationships', null],
['comention', 'Entity co-mentions', ['co_mentioned']],
['articles', 'Article mentions', ['mentions', 'mentioned_in']],
],
};
function _lensTypes() {
const lenses = SOURCE_LENSES[currentSource];
if (!lenses || graphLens === 'all') return null;
const row = lenses.find(l => l[0] === graphLens);
return row && row[2] ? new Set(row[2]) : null;
}
function setGraphLens(value) {
graphLens = value;
render();
}
// Finance-only controls (overlays + lens live here) — shown by the source
// picker only when the active source has them.
function updateSourceUI() {
const isFinance = currentSource === 'finance';
['gc-overlay-news', 'gc-overlay-reddit'].forEach(id => {
const el = document.getElementById(id);
if (el) el.style.display = isFinance ? '' : 'none';
});
const cmpFab = document.getElementById('compare-fab');
if (cmpFab) cmpFab.style.display =
(currentSource === 'finance' || currentSource === 'biomed') ? '' : 'none';
// Company Profile is a finance-only surface.
const profFab = document.getElementById('profile-fab');
if (profFab) profFab.style.display = isFinance ? '' : 'none';
if (!isFinance) toggleProfile(false);
// Citation Explorer is the research-papers surface.
const isPapers = currentSource === 'openalex';
const paperFab = document.getElementById('paper-fab');
if (paperFab) paperFab.style.display = isPapers ? '' : 'none';
if (!isPapers) togglePaper(false);
}
function renderLensPicker() {
const sel = document.getElementById('graph-lens');
if (!sel) return;
const lenses = SOURCE_LENSES[currentSource];
if (!lenses) {
sel.style.display = 'none';
graphLens = 'all';
return;
}
sel.style.display = '';
sel.innerHTML = lenses.map(([v, label]) =>
``).join('');
}
// ── NEW: autocomplete state (vector search) ───────────────────────────────────
let acDebounceTimers = { start: null, end: null }; // per-field debounce handles
let acActiveIndex = { start: -1, end: -1 }; // keyboard-highlighted row
let acLastResults = { start: [], end: [] }; // last suggestions per field
const AC_DEBOUNCE_MS = 80; // tight debounce — feel instant, but avoid a request on every single keypress
// ── Rotating placeholder examples (hero inputs) ────────────────────────────────
// Nagpur → Mars is first (shown on launch) and held for 2s; every other pair
// uses the default 1.3s. Per-pair `duration` overrides DEFAULT_PLACEHOLDER_MS;
// a setTimeout chain (not setInterval) is what makes a per-pair duration
// possible — a single fixed-rate interval couldn't give one entry a different
// dwell time than the rest.
const PLACEHOLDER_PAIRS = [
{ pair: ['Nagpur', 'Mars'], duration: 2000 },
{ pair: ['Alexander the Great', 'Jazz music'] },
{ pair: ['Pizza', 'Mount Everest'] },
{ pair: ['Albert Einstein', 'Coffee'] },
{ pair: ['Leonardo da Vinci', 'The Internet'] },
{ pair: ['Cleopatra', 'Bitcoin'] },
{ pair: ['Shakespeare', 'Video games'] },
{ pair: ['Mahatma Gandhi', 'Formula 1'] },
{ pair: ['Vincent van Gogh', 'Climate change'] },
{ pair: ['Genghis Khan', 'Sushi'] },
{ pair: ['The Moon', 'Chess'] },
];
const DEFAULT_PLACEHOLDER_MS = 1300;
let _placeholderIdx = 0;
let _placeholderTimer = null;
function _applyPlaceholder() {
const startEl = document.getElementById('hero-inp-start');
const endEl = document.getElementById('hero-inp-end');
if (!startEl || !endEl) return;
const [s, e] = PLACEHOLDER_PAIRS[_placeholderIdx].pair;
startEl.placeholder = `e.g. ${s}`;
endEl.placeholder = `e.g. ${e}`;
}
function _scheduleNextPlaceholder() {
const duration = PLACEHOLDER_PAIRS[_placeholderIdx].duration || DEFAULT_PLACEHOLDER_MS;
_placeholderTimer = setTimeout(() => {
const startEl = document.getElementById('hero-inp-start');
const endEl = document.getElementById('hero-inp-end');
// Don't fight the user — only advance while both fields are empty, so
// cycling text never replaces something they've actually typed. Focus
// alone does NOT block rotation: boot() auto-focuses hero-inp-start on
// every load, so gating on activeElement here would freeze rotation at
// the first pair for any visitor who hasn't yet clicked away — there's
// no real cursor/typed content to interrupt in an empty, placeholder-only
// field regardless of focus.
const canAdvance = startEl && endEl && !startEl.value && !endEl.value;
if (canAdvance) {
_placeholderIdx = (_placeholderIdx + 1) % PLACEHOLDER_PAIRS.length;
_applyPlaceholder();
}
_scheduleNextPlaceholder();
}, duration);
}
function startPlaceholderRotation() {
if (_placeholderTimer) return;
_placeholderIdx = 0; // Nagpur → Mars first, every time rotation (re)starts
_applyPlaceholder();
_scheduleNextPlaceholder();
}
function stopPlaceholderRotation() {
clearTimeout(_placeholderTimer);
_placeholderTimer = null;
}
// ── Hero input logic ──────────────────────────────────────────────────────────
function heroInputChanged() {
const s = document.getElementById('hero-inp-start').value.trim();
const e = document.getElementById('hero-inp-end').value.trim();
// Labels light up when filled
document.getElementById('lbl-start').classList.toggle('filled', s.length > 0);
document.getElementById('lbl-end').classList.toggle('filled', e.length > 0);
// Inputs get filled style
document.getElementById('hero-inp-start').classList.toggle('filled', s.length > 0);
document.getElementById('hero-inp-end').classList.toggle('filled', e.length > 0);
// Arrow lights up when both filled
document.getElementById('hero-arrow').classList.toggle('lit', s.length > 0 && e.length > 0);
// Button and hint appear when both filled
const ready = s.length > 0 && e.length > 0;
document.getElementById('hero-btn').classList.toggle('ready', ready);
document.getElementById('hero-hint').classList.toggle('visible', ready);
}
// NOTE: autocomplete (vector search) is wired separately via addEventListener
// in the NEW block below — heroInputChanged() above is the original,
// untouched function and does not need to know about autocomplete at all.
function heroKeyDown(e) {
// NEW: if an autocomplete dropdown is open for this field, arrow keys /
// Enter operate on the dropdown first. This is purely additive — if no
// dropdown is open, every branch below falls through to the exact
// original behaviour at the bottom of this function, untouched.
const fieldName = e.target.id === 'hero-inp-start' ? 'start' : 'end';
const dropdown = document.getElementById('ac-dropdown-' + fieldName);
const isOpen = dropdown && dropdown.classList.contains('visible');
if (isOpen && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
e.preventDefault();
const items = acLastResults[fieldName];
if (items.length === 0) return;
const delta = e.key === 'ArrowDown' ? 1 : -1;
acActiveIndex[fieldName] = (acActiveIndex[fieldName] + delta + items.length) % items.length;
renderAcDropdown(fieldName);
return;
}
if (isOpen && e.key === 'Enter' && acActiveIndex[fieldName] >= 0) {
e.preventDefault();
selectAcSuggestion(fieldName, acLastResults[fieldName][acActiveIndex[fieldName]]);
return;
}
if (isOpen && e.key === 'Escape') {
closeAcDropdown(fieldName);
return;
}
// ── Original behaviour (unchanged) ──────────────────────────────────────
if (e.key === 'Enter') {
const s = document.getElementById('hero-inp-start').value.trim();
const en = document.getElementById('hero-inp-end').value.trim();
if (s && en) startSearch();
else if (!s) document.getElementById('hero-inp-start').focus();
else document.getElementById('hero-inp-end').focus();
}
}
// ── Wikipedia URL extractor ───────────────────────────────────────────────────
function extractTitleFromWikiUrl(input) {
const match = input.match(/wikipedia\.org\/wiki\/([^#?]+)/);
if (match) return decodeURIComponent(match[1]).replace(/_/g, ' ');
return input;
}
// ════════════════════════════════════════════════════════════════════════════
// NEW: Vector-search autocomplete (hooks into the existing hero inputs via
// addEventListener below — does not modify heroInputChanged/heroKeyDown's
// original bodies, see the dedicated NOTE comments near those functions).
// ════════════════════════════════════════════════════════════════════════════
// Single source of truth for the backend origin. Frontend (Vercel) and
// backend (Hugging Face Spaces) are deployed on separate domains, so this
// must be a fixed URL — the dynamic window.location.hostname trick used in
// local/LAN dev only works when frontend and backend share a host. The
// WebSocket URL below is derived from it automatically (http→ws, https→wss),
// so nothing else in this file needs to change if the backend URL changes.
// Backend origin. In production the frontend (Vercel) and backend (Hugging
// Face Spaces) live on separate domains, so this is a fixed URL. For local
// development the page is served from localhost (e.g. python http.server on
// :8081) alongside a local uvicorn on :8000 — detect that and point there so
// `python main.py` + a static server just works without editing this file.
const _isLocalHost = ['localhost', '127.0.0.1'].includes(location.hostname);
const AC_BACKEND = _isLocalHost ? 'http://localhost:8000'
: 'https://mvali77-aurelius.hf.space';
const AC_BACKEND_WS = AC_BACKEND.replace(/^http/, 'ws');
// ════════════════════════════════════════════════════════════════════════════
// Multi-source: load the registered adapters and let the user pick one.
// ════════════════════════════════════════════════════════════════════════════
async function loadSources() {
try {
const r = await fetch(`${AC_BACKEND}/api/sources`);
const data = await r.json();
availableSources = (data.sources || []).filter(s => s.ready);
} catch (e) {
availableSources = [{ name: 'wikipedia', description: 'English Wikipedia',
supports_backlinks: true, mode: 'live', ready: true }];
}
renderSourcePickers();
renderExampleChips();
updateSourceUI();
}
function renderSourcePickers() {
document.querySelectorAll('.source-picker').forEach(sel => {
sel.innerHTML = availableSources.map(s =>
``).join('');
});
}
function sourceLabel(name) {
const map = {
wikipedia: 'Wikipedia', openalex: 'Research Papers',
biomed: 'Biology', news: 'News', finance: 'Finance',
};
return map[name] || name;
}
// Per-source example pairs — a first-time visitor never faces an empty box.
const EXAMPLE_PAIRS = {
wikipedia: [['Nagpur', 'Mars'], ['Cleopatra', 'Bitcoin'], ['Jazz', 'Quantum mechanics']],
finance: [['NVDA', 'Ford'], ['Tim Cook', 'Crude Oil'], ['Berkshire Hathaway', 'Apple']],
biomed: [['Levodopa', "Alzheimer's disease"], ['BRCA1', 'Apoptosis'], ['Tamoxifen', 'DNA repair']],
// Research papers lead with the citation explorer (hero CTA), not
// connect-two-papers pathfinding — so no A→B example pairs here.
};
function renderExampleChips() {
const box = document.getElementById('hero-examples');
if (!box) return;
const pairs = EXAMPLE_PAIRS[currentSource] || [];
// Finance and Research lead with a dedicated research surface, not
// pathfinding: a prominent CTA opens it straight from the landing.
let cta = '';
if (currentSource === 'finance') {
cta = ``;
} else if (currentSource === 'openalex') {
cta = ``;
}
if (!pairs.length && !cta) { box.innerHTML = ''; return; }
const tryLabel = cta ? 'or trace a link' : 'Try';
box.innerHTML = cta
+ (pairs.length ? `${tryLabel}`
+ pairs.map(([a, b]) =>
``).join('') : '');
}
// Finance landing → jump straight to the company research surface.
function heroResearchCompany() {
const seed = (document.getElementById('hero-inp-start').value || '').trim();
dismissHero(seed, '');
setTimeout(() => { if (seed) openProfile(seed); else toggleProfile(true); }, 260);
}
// Research landing → jump straight to the citation explorer.
function heroExplorePaper() {
const seed = (document.getElementById('hero-inp-start').value || '').trim();
dismissHero(seed, '');
setTimeout(() => { if (seed) openPaper(seed); else togglePaper(true); }, 260);
}
function runExample(a, b) {
document.getElementById('hero-inp-start').value = a;
document.getElementById('hero-inp-end').value = b;
heroInputChanged();
startSearch();
}
function onSourceChange(value) {
currentSource = value;
renderSourcePickers(); // keep hero + topbar pickers in sync
graphLens = 'all';
renderLensPicker();
renderExampleChips();
updateSourceUI();
const s = availableSources.find(x => x.name === value);
if (s) setStatus(`Source: ${sourceLabel(value)} — ${s.description}`);
}
function toggleDiscover(open) {
const panel = document.getElementById('discover-panel');
const fab = document.getElementById('discover-fab');
if (!panel) return;
const show = open === undefined ? !panel.classList.contains('open') : open;
panel.classList.toggle('open', show);
if (fab) fab.classList.toggle('hidden', show);
if (show) {
const inp = document.getElementById('discover-input');
if (inp && centreNode && !inp.value) inp.value = centreNode;
if (inp) inp.focus();
}
}
// ── Discover: hidden-connection candidates for one node ─────────────────────
async function runDiscover(query) {
const q = (query || '').trim();
if (!q) return;
const panel = document.getElementById('discover-results');
if (panel) panel.innerHTML = '
Searching for hidden connections…
';
try {
const r = await fetch(`${AC_BACKEND}/api/discover?source=${encodeURIComponent(currentSource)}`
+ `&a=${encodeURIComponent(q)}&k=10`);
const data = await r.json();
renderDiscover(data);
} catch (e) {
if (panel) panel.innerHTML = '
Could not reach the backend.
';
}
}
function renderDiscover(data) {
const panel = document.getElementById('discover-results');
if (!panel) return;
if (data.error) { panel.innerHTML = `
Why: no direct link exists, but both connect to
${c.n_bridges} of the same neighbour${c.n_bridges === 1 ? '' : 's'}
— indirect evidence${simPct > 0 ? `, plus ${simPct}% embedding similarity` : ''}.
via ${c.bridges.map(b =>
`${escHtml(b.title)}`).join(', ')}
`;
}).join('');
aiBlock(document.getElementById('disc-ai'),
`${AC_BACKEND}/api/explain/discover?source=${encodeURIComponent(currentSource)}`
+ `&a=${encodeURIComponent(data.a.id || data.a.title)}`,
{ label: 'AI: why these surfaced' });
}
// ════════════════════════════════════════════════════════════════════════════
// Compare panel — the relationship explorer. Given two entities: connection
// strength + the real intermediaries (via /api/relate), each side's facts
// and price series (via /api/node), an overlaid normalized price chart with
// the computed correlation, and a hand-off to the pathfinder.
// ════════════════════════════════════════════════════════════════════════════
function toggleCompare(open) {
const panel = document.getElementById('compare-panel');
const fab = document.getElementById('compare-fab');
if (!panel) return;
const show = open === undefined ? !panel.classList.contains('open') : open;
panel.classList.toggle('open', show);
if (fab) fab.classList.toggle('hidden', show);
if (show) {
const a = document.getElementById('cmp-a');
if (a && centreNode && !a.value) a.value = centreNode;
if (a) a.focus();
}
}
function _num(x) { return typeof x === 'number' && isFinite(x); }
// Pearson correlation of two equal-length normalized series.
function _correlation(a, b) {
const n = Math.min(a.length, b.length);
if (n < 5) return null;
const xa = a.slice(-n), xb = b.slice(-n);
const ma = xa.reduce((s, v) => s + v, 0) / n;
const mb = xb.reduce((s, v) => s + v, 0) / n;
let num = 0, da = 0, db = 0;
for (let i = 0; i < n; i++) {
const va = xa[i] - ma, vb = xb[i] - mb;
num += va * vb; da += va * va; db += vb * vb;
}
if (da === 0 || db === 0) return null;
return num / Math.sqrt(da * db);
}
// Draw two normalized price series into an SVG (no chart library).
function _priceChartSVG(sa, sb, labelA, labelB) {
const W = 300, H = 96, pad = 6;
const all = [...(sa || []), ...(sb || [])].filter(_num);
if (all.length < 2) return '';
const lo = Math.min(...all), hi = Math.max(...all);
const span = hi - lo || 1;
const path = (s, color) => {
if (!s || s.length < 2) return '';
const step = (W - 2 * pad) / (s.length - 1);
const pts = s.map((v, i) =>
`${(pad + i * step).toFixed(1)},${(H - pad - (v - lo) / span * (H - 2 * pad)).toFixed(1)}`);
return ``;
};
return `
${escHtml(labelA)}${escHtml(labelB)}
`;
}
async function runCompare() {
const a = document.getElementById('cmp-a').value.trim();
const b = document.getElementById('cmp-b').value.trim();
const out = document.getElementById('compare-results');
if (!a || !b) { if (out) out.innerHTML = '
`;
const why = [];
if (rel.direct && (rel.direct.a_to_b || rel.direct.b_to_a))
why.push('a direct relationship links them');
if (rel.n_paths > 0)
why.push(`${rel.n_paths} short path${rel.n_paths === 1 ? '' : 's'} connect them through one step`);
if (rel.n_co_targets > 0)
why.push(`they connect to ${rel.n_co_targets} of the same things`);
if (rel.n_co_sources > 0)
why.push(`${rel.n_co_sources} of the same things connect to both`);
if (_num(rel.similarity) && rel.similarity > 0.2)
why.push(`they're ${Math.round(rel.similarity * 100)}% similar in meaning`);
const inter = [...(rel.paths_a_to_b || []), ...(rel.paths_b_to_a || []),
...(rel.co_targets || [])].slice(0, 8);
const whyBlock = `
Why they're connected
${why.length ? '
' + why.map(w => `
${w}
`).join('') + '
'
: '
No strong link surfaced; they may be only distantly related.
'}
${inter.length ? `
through ${inter.map(x =>
`${escHtml(x.title)}`).join(', ')}
` : ''}
`;
const fa = (na && na.features) || {}, fb = (nb && nb.features) || {};
let chart = '';
if (Array.isArray(fa.series) && Array.isArray(fb.series)) {
const corr = _correlation(fa.series, fb.series);
const facts = [];
if (_num(fa.window_change_pct)) facts.push(
`${escHtml(titleA)} ${fa.window_change_pct >= 0 ? '+' : ''}${fa.window_change_pct}%`);
if (_num(fb.window_change_pct)) facts.push(
`${escHtml(titleB)} ${fb.window_change_pct >= 0 ? '+' : ''}${fb.window_change_pct}%`);
chart = `
Price correlation over the window:
${corr.toFixed(2)} — ${corr > 0.6 ? 'they move strongly together'
: corr > 0.3 ? 'they move loosely together'
: corr < -0.3 ? 'they tend to move in opposite directions'
: 'little consistent relationship'}.
` : ''}
`;
}
out.innerHTML = `
${escHtml(titleA)} vs ${escHtml(titleB)}
`
+ bar + whyBlock + chart
+ ''
+ ``;
// AI analysis is additive: the strength/why/chart above are already shown;
// this fills in after, and degrades to a small notice on failure.
const src = encodeURIComponent(currentSource);
aiBlock(document.getElementById('cmp-ai'),
`${AC_BACKEND}/api/explain/compare?source=${src}`
+ `&a=${encodeURIComponent(rel.a.id)}&b=${encodeURIComponent(rel.b.id)}`,
{ label: 'AI analysis' });
}
function compareToPath(a, b) {
toggleCompare(false);
if (!heroDismissed) {
document.getElementById('hero-inp-start').value = a;
document.getElementById('hero-inp-end').value = b;
heroInputChanged();
} else {
document.getElementById('inp-start').value = a;
document.getElementById('inp-end').value = b;
}
startSearch();
}
// ════════════════════════════════════════════════════════════════════════════
// Company Profile — the primary finance research surface (/api/company).
// A single company's dossier: price, key facts, peers, supply chain,
// correlations, ownership, and the news moving it — the graph is secondary.
// ════════════════════════════════════════════════════════════════════════════
const PROFILE_QUICK = ['NVDA', 'AAPL', 'JPM', 'XOM', 'TSLA', 'BRK-B'];
let _profileCurrent = null;
function toggleProfile(open) {
const panel = document.getElementById('profile-panel');
if (!panel) return;
const fab = document.getElementById('profile-fab');
const show = open === undefined ? !panel.classList.contains('open') : open;
panel.classList.toggle('open', show);
panel.setAttribute('aria-hidden', show ? 'false' : 'true');
if (fab) fab.classList.toggle('hidden', show);
if (show) {
renderProfileEmptyChips();
const inp = document.getElementById('profile-input');
// seed with the last-focused / centre company when the box is empty
if (inp && !inp.value && centreNode) inp.value = centreNode;
if (inp && !_profileCurrent) inp.focus();
}
}
function renderProfileEmptyChips() {
const box = document.getElementById('profile-empty-chips');
if (!box) return;
box.innerHTML = PROFILE_QUICK.map(t =>
``).join('');
}
async function openProfile(query) {
query = (query || '').trim();
if (!query) return;
toggleProfile(true);
const body = document.getElementById('profile-body');
const inp = document.getElementById('profile-input');
if (inp) inp.value = query;
if (body) body.innerHTML = '
Building the profile…
';
try {
const data = await fetch(`${AC_BACKEND}/api/company?q=${encodeURIComponent(query)}`).then(r => r.json());
if (data.error) {
if (body) body.innerHTML = `
';
}
}
function profileExploreGraph(id) {
if (!id) return;
toggleProfile(false);
// seed the graph with this company and expand it
if (!nodes[id]) {
nodes[id] = { id, g: 0, h: 0, f: 0, state: 'centre',
kind: 'company', expanded: false,
x: width / 2, y: height / 2, vx: 0, vy: 0 };
}
centreNode = id;
render();
expandNodeFromGraph(id);
}
function profileCompare(id) {
toggleProfile(false);
toggleCompare(true);
const a = document.getElementById('cmp-a');
if (a) a.value = id;
const b = document.getElementById('cmp-b');
if (b) b.focus();
}
// ════════════════════════════════════════════════════════════════════════════
// Citation Explorer — the primary research-papers surface (/api/paper).
// Give it a paper (title, DOI, arXiv id or URL) and it shows the works it
// cites and the works citing it, then builds an interactive citation graph
// you expand on demand. Replaces the old connect-two-papers pathfinding.
// ════════════════════════════════════════════════════════════════════════════
const PAPER_QUICK = ['Attention is all you need', 'AlphaFold', 'ImageNet',
'BERT language model', 'CRISPR gene editing'];
let _paperCurrent = null;
function togglePaper(open) {
const panel = document.getElementById('paper-panel');
if (!panel) return;
const fab = document.getElementById('paper-fab');
const show = open === undefined ? !panel.classList.contains('open') : open;
panel.classList.toggle('open', show);
panel.setAttribute('aria-hidden', show ? 'false' : 'true');
if (fab) fab.classList.toggle('hidden', show);
if (show) {
renderPaperEmptyChips();
const inp = document.getElementById('paper-input');
if (inp && !_paperCurrent) inp.focus();
}
}
function renderPaperEmptyChips() {
const box = document.getElementById('paper-empty-chips');
if (!box) return;
box.innerHTML = PAPER_QUICK.map(t =>
``).join('');
}
async function openPaper(query) {
query = (query || '').trim();
if (!query) return;
togglePaper(true);
const body = document.getElementById('paper-body');
const inp = document.getElementById('paper-input');
if (inp) inp.value = query;
if (body) body.innerHTML = '
Fetching citations…
';
try {
const data = await fetch(`${AC_BACKEND}/api/paper?q=${encodeURIComponent(query)}`).then(r => r.json());
if (data.error) { if (body) body.innerHTML = `
No ${overlayOn.reddit && !overlayOn.news ? 'discussion' : 'coverage'} found for ${escHtml(query)} yet.
`;
return;
}
body.innerHTML = ''
+ items.map(a => {
const s = a.sentiment_label || 'neutral';
const dot = ``;
const medium = (a.medium === 'discussion') ? (a.source || 'Discussion') : (a.source || 'news');
return `
${dot}
${escHtml(a.title || '')}${escHtml(medium)}${a.published ? ' · ' + escHtml(String(a.published).slice(0, 10)) : ''}`;
}).join('');
// AI coverage summary + tone, above the headline list. Additive.
aiBlock(document.getElementById('ovl-ai'),
`${AC_BACKEND}/api/news/summary?entity=${encodeURIComponent(query)}`,
{ label: 'Coverage summary', field: 'summary', tone: true });
}
/**
* Debounced, domain-aware autocomplete. Called on every keystroke in a
* hero input, but the network request is delayed by AC_DEBOUNCE_MS so
* rapid typing doesn't spam the backend. Suggestions come from the active
* source's own vocabulary via /api/suggest (companies for finance, papers
* for research, articles for Wikipedia, …).
*/
function triggerAutocomplete(fieldName) {
const inputId = fieldName === 'start' ? 'hero-inp-start' : 'hero-inp-end';
const query = document.getElementById(inputId).value.trim();
if (acDebounceTimers[fieldName]) clearTimeout(acDebounceTimers[fieldName]);
if (query.length < 2) {
closeAcDropdown(fieldName);
return;
}
acDebounceTimers[fieldName] = setTimeout(async () => {
try {
// Domain-aware: suggestions come from whichever source is active
// (companies/tickers for finance, papers for research, diseases for
// biology, articles for Wikipedia) via the backend /api/suggest —
// no longer hardcoded to Wikipedia's opensearch.
const params = new URLSearchParams({
source: currentSource, q: query, limit: '7',
});
const res = await fetch(`${AC_BACKEND}/api/suggest?${params}`);
if (!res.ok) { closeAcDropdown(fieldName); return; }
const data = await res.json();
const suggestions = Array.isArray(data.suggestions) ? data.suggestions : [];
// The user may have kept typing while this request was in flight —
// if the field no longer matches what we searched for, drop the
// (now-stale) result instead of flashing an outdated dropdown.
const currentValue = document.getElementById(inputId).value.trim();
if (currentValue !== query) return;
acLastResults[fieldName] = suggestions;
acActiveIndex[fieldName] = -1;
renderAcDropdown(fieldName);
} catch (err) {
// Network hiccup — fail silently, autocomplete is a nice-to-have
// and must never block the user from just typing a title and
// pressing Enter as before.
closeAcDropdown(fieldName);
}
}, AC_DEBOUNCE_MS);
}
// Per-kind glyph for the suggestion dropdown — a small visual cue that the
// recommendation is domain-appropriate (a paper vs a company vs a gene).
function _acKindIcon(kind) {
const P = 'stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"';
const wrap = (inner) => ``;
switch (kind) {
case 'company': return wrap('');
case 'etf': return wrap('');
case 'sector': return wrap('');
case 'executive':
case 'person': return wrap('');
case 'country': return wrap('');
case 'macro': return wrap('');
case 'paper': return wrap('');
default: return wrap('');
}
}
/**
* Escape a string for safe interpolation into innerHTML. Article titles come
* from the Wikipedia API and can't currently contain "<"/">" (MediaWiki
* forbids them in titles), but they CAN contain "&", quotes, and arbitrary
* Unicode — and the backend data source could change. Routing every
* untrusted string through here makes the innerHTML sinks below safe by
* construction rather than by assumption.
*/
function escHtml(s) {
return String(s)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
/** Render the top-5 suggestions below the given hero field. */
function renderAcDropdown(fieldName) {
const dropdown = document.getElementById('ac-dropdown-' + fieldName);
const items = acLastResults[fieldName];
if (!items || items.length === 0) {
closeAcDropdown(fieldName);
return;
}
dropdown.innerHTML = items.map((item, i) => {
// Backend suggestions are objects {title, subtitle, kind}; keep a
// string fallback so nothing breaks if an older payload appears.
const title = typeof item === 'string' ? item : (item.title || '');
const subtitle = typeof item === 'string' ? '' : (item.subtitle || '');
const kind = typeof item === 'string' ? '' : (item.kind || '');
return `
`;
}).join('');
dropdown.classList.add('visible');
// Event delegation with data-title avoids inline-onclick quote-collision.
dropdown.querySelectorAll('.ac-item').forEach(el => {
el.addEventListener('click', () => selectAcSuggestion(fieldName, el.dataset.title));
});
}
function closeAcDropdown(fieldName) {
const dropdown = document.getElementById('ac-dropdown-' + fieldName);
if (dropdown) {
dropdown.classList.remove('visible');
dropdown.innerHTML = '';
}
acLastResults[fieldName] = [];
acActiveIndex[fieldName] = -1;
}
/** Called when the user clicks (or presses Enter on) a suggestion row.
* Accepts either a plain title string (click path) or a suggestion object
* (keyboard path). */
function selectAcSuggestion(fieldName, item) {
const title = typeof item === 'string' ? item : (item && item.title) || '';
const inputId = fieldName === 'start' ? 'hero-inp-start' : 'hero-inp-end';
document.getElementById(inputId).value = title;
closeAcDropdown(fieldName);
// Re-run the existing, untouched hero validation logic (lights up the
// label/arrow/button exactly as if the user had typed this themselves).
heroInputChanged();
}
// Close dropdowns when clicking anywhere outside them (standard
// autocomplete UX) — purely additive, does not interfere with any
// existing click handlers elsewhere in the app.
document.addEventListener('click', (e) => {
if (!e.target.closest('.hero-field')) {
closeAcDropdown('start');
closeAcDropdown('end');
}
});
// ════════════════════════════════════════════════════════════════════════════
// NEW: Random button — picks two curated topics via /api/random, then
// reuses the EXISTING startSearch() pipeline untouched (same WebSocket
// flow, same A* navigator on the backend, same rendering code).
// ════════════════════════════════════════════════════════════════════════════
async function useRandomPair() {
// Per the requirements: only auto-fill if the user hasn't already typed
// a query themselves — we don't want to clobber a half-typed search.
const sVal = document.getElementById('hero-inp-start').value.trim();
const eVal = document.getElementById('hero-inp-end').value.trim();
if (sVal || eVal) {
// BUGFIX: setStatus() alone is invisible here — #status-bar lives inside
// #topbar, which has opacity:0 until the hero is dismissed. Surface the
// message on the button itself too, since that's always visible.
setStatus('Clear both fields first to use Random.');
btn_guard_msg(document.getElementById('hero-random-btn'), 'Clear fields first');
return;
}
const btn = document.getElementById('hero-random-btn');
btn.classList.add('loading');
btn.textContent = 'Picking...';
try {
const res = await fetch(`${AC_BACKEND}/api/random?source=${encodeURIComponent(currentSource)}`);
if (!res.ok) throw new Error('Random pick failed');
const data = await res.json();
document.getElementById('hero-inp-start').value = data.start;
document.getElementById('hero-inp-end').value = data.end;
heroInputChanged(); // existing function — lights up labels/arrow/button
btn.classList.remove('loading');
btn.textContent = 'Random';
// Small delay so the user can actually see which two topics were
// picked before the hero screen animates away.
setTimeout(() => startSearch(), 600);
} catch (err) {
btn.classList.remove('loading');
setStatus('Could not reach backend for Random pick.');
// BUGFIX: status bar is invisible on hero screen — show error on button too.
btn_guard_msg(btn, '⚠️ Backend offline');
}
}
// Small helper for useRandomPair(): briefly shows an error message directly
// on the button (always visible on hero screen), then restores its label.
function btn_guard_msg(btn, msg) {
const original = 'Random';
btn.textContent = msg;
setTimeout(() => { btn.textContent = original; }, 1800);
}
// ── Dismiss hero, show app ────────────────────────────────────────────────────
function dismissHero(start, end) {
if (heroDismissed) return;
heroDismissed = true;
stopPlaceholderRotation();
// Copy values to topbar inputs
document.getElementById('inp-start').value = start;
document.getElementById('inp-end').value = end;
// Animate hero out
const hero = document.getElementById('hero');
hero.classList.add('hiding');
setTimeout(() => hero.classList.add('hidden'), 500);
// Animate topbar in
document.getElementById('topbar').classList.add('visible');
// On phones/narrow tablets, default the search-controls topbar and the
// path/scores/log panel to collapsed the moment the main view opens, so
// what you see first is the live graph forming the path, not a wall of
// chrome. Both are reachable again via their handle bars. No-op on
// desktop/tablet — the CSS that makes .mobile-collapsed do anything only
// exists under the same max-width:820px breakpoint this check mirrors.
if (isMobileLayout()) {
document.getElementById('topbar').classList.add('mobile-collapsed');
document.getElementById('panel').classList.add('mobile-collapsed');
_syncMobileHandles();
}
}
// ── Mobile topbar/panel collapse ──────────────────────────────────────────────
function isMobileLayout() {
return window.matchMedia('(max-width: 820px)').matches;
}
function toggleMobileTopbar() {
document.getElementById('topbar').classList.toggle('mobile-collapsed');
_syncMobileHandles();
}
function toggleMobilePanel() {
document.getElementById('panel').classList.toggle('mobile-collapsed');
_syncMobileHandles();
}
// Keeps each handle's chevron direction (and its own .is-collapsed state,
// used purely for the CSS rotate transform) matching what it actually
// controls, since the two can change independently of each other.
function _syncMobileHandles() {
const topbarCollapsed = document.getElementById('topbar').classList.contains('mobile-collapsed');
const panelCollapsed = document.getElementById('panel').classList.contains('mobile-collapsed');
const th = document.getElementById('mobile-topbar-handle');
const ph = document.getElementById('mobile-panel-handle');
if (th) th.classList.toggle('is-collapsed', topbarCollapsed);
if (ph) ph.classList.toggle('is-collapsed', panelCollapsed);
// Collapsing/expanding either bar resizes #canvas-wrap via CSS transition,
// but that's an internal layout change, not a viewport resize — it never
// fires the window `resize` the canvas renderer listens for. Nudge one
// once the 0.32s transition (styles.css) finishes so the graph reframes
// on the new canvas size.
setTimeout(_resyncCanvasSize, 340);
}
function _resyncCanvasSize() {
const cw = document.getElementById('canvas-wrap');
if (!cw) return;
width = cw.clientWidth;
height = cw.clientHeight;
// graph2d.js listens for window resize; dispatch one so it re-measures.
window.dispatchEvent(new Event('resize'));
}
// ── Search ────────────────────────────────────────────────────────────────────
function startSearch() {
// Pull from whichever screen is active
let rawStart, rawEnd;
if (!heroDismissed) {
rawStart = document.getElementById('hero-inp-start').value.trim();
rawEnd = document.getElementById('hero-inp-end').value.trim();
} else {
rawStart = document.getElementById('inp-start').value.trim();
rawEnd = document.getElementById('inp-end').value.trim();
}
if (!rawStart || !rawEnd) {
setStatus('Please enter both a start and end article.');
return;
}
if (running) return;
const start = extractTitleFromWikiUrl(rawStart);
const end = extractTitleFromWikiUrl(rawEnd);
dismissHero(rawStart, rawEnd);
resetAll(false);
running = true;
document.getElementById('btn-search').disabled = true;
switchTab('log');
document.getElementById('stats-bar').classList.add('visible');
document.getElementById('btn-pause-bar').classList.add('visible');
const stepsLive = document.getElementById('sv-steps-live');
if (stepsLive) { document.getElementById('sv-steps-count').textContent = '0'; stepsLive.classList.add('visible'); }
paused = false;
foundPath = [];
addLog(`Starting: "${start}" → "${end}"`, 'highlight');
setStatus('Connecting...');
const gen = ++_searchGen;
ws = new WebSocket(`${AC_BACKEND_WS}/ws`);
ws.onopen = () => {
if (gen !== _searchGen) return;
ws.send(JSON.stringify({ start, end, source: currentSource }));
setStatus('Connected. Navigating the graph...');
};
ws.onmessage = e => {
if (gen !== _searchGen) return;
try { handleMessage(JSON.parse(e.data)); }
catch (err) { console.error('Parse error:', err); }
};
ws.onerror = () => {
if (gen !== _searchGen) return;
// Only a connection error mid-search is a real problem. When the search
// finishes, the backend returns from the WS handler and closes the
// socket — mobile browsers (Safari/Chrome on iOS especially) surface
// that normal teardown as an `error` event, which would otherwise fire
// this scary "is the backend running?" message even though the path was
// found seconds earlier. `running` is false once found/not_found/error
// has been handled, so bail out — there's nothing wrong to report.
if (!running) return;
setStatus('⚠️ Cannot connect to backend. Is main.py running?');
addLog('WebSocket error; is the backend running? (python main.py)', 'error');
document.getElementById('btn-search').disabled = false;
running = false;
};
ws.onclose = () => {
if (gen !== _searchGen) return;
if (running) {
setStatus('Connection closed.');
running = false;
document.getElementById('btn-search').disabled = false;
}
};
}
function resetAll(showHero = true) {
if (ws) { ws.close(); ws = null; }
nodes = {}; edges = []; centreNode = null;
pathNodes = new Set(); scoreCards = {};
running = false;
expandedNodes.clear();
graphFilter = 'all';
graphLens = 'all';
overlayNode = null;
closeOverlay();
renderLensPicker();
document.querySelectorAll('#graph-controls .gc-btn[data-filter]').forEach(b =>
b.classList.toggle('active', b.dataset.filter === 'all'));
// BUGFIX: "New Search" lives inside #success-modal itself and calls
// resetAll(true) directly — without this, the stale "Path Discovered"
// modal stays visible on top of the freshly-reset hero screen,
// permanently blocking it until the next search happens to find a path.
document.getElementById('success-modal').classList.remove('visible');
document.getElementById('btn-search').disabled = false;
document.getElementById('path-banner').classList.remove('visible');
document.getElementById('path-display').innerHTML = '
Path will appear here as Aurelius explores.
';
document.getElementById('scores-list').innerHTML = '';
document.getElementById('log').innerHTML = '';
setStatus('Enter two topics and hit Find Path.');
document.getElementById('stats-bar').classList.remove('visible');
_hidePauseBtn();
document.getElementById('btn-export').classList.remove('visible');
paused = false;
foundPath = [];
if (showHero) {
heroDismissed = false;
document.getElementById('topbar').classList.remove('visible');
document.getElementById('topbar').classList.remove('mobile-collapsed');
document.getElementById('panel').classList.remove('mobile-collapsed');
const hero = document.getElementById('hero');
hero.classList.remove('hiding', 'hidden');
document.getElementById('hero-inp-start').value = '';
document.getElementById('hero-inp-end').value = '';
heroInputChanged();
resetHeroDisplay();
}
initGraph();
}
// ── Renderer (2D canvas via graph2d.js) ──────────────────────────────────────
// The WebSocket message handlers only ever touch the shared `nodes`/`edges`
// state and call render(); render() hands that state to the canvas
// controller (window.GV) in graph2d.js.
let _gvReady = false;
function _graphCtx() {
return { pathNodes, foundPath, centreNode, filter: graphFilter,
lens: _lensTypes(), running };
}
// ── Graph view filters + fit (explorer controls) ─────────────────────────────
function setGraphFilter(mode) {
graphFilter = mode;
document.querySelectorAll('#graph-controls .gc-btn[data-filter]').forEach(b =>
b.classList.toggle('active', b.dataset.filter === mode));
render();
}
function fitGraphView() {
if (window.GV) window.GV.fit();
}
// ── Progressive expansion: click a node → pull its real neighbors in ─────────
// Only active once the search has settled (clicking mid-search would fight
// the stream). Every edge carries its relationship type + evidence string,
// which the 3D view surfaces as an edge tooltip.
async function expandNodeFromGraph(id) {
// Research papers expand in BOTH citation directions (references +
// citing works) via the citation-explorer path.
if (currentSource === 'openalex') return expandPaperNode(id);
if (running || !nodes[id] || expandedNodes.has(id)) return;
setStatus(`Expanding "${id}"…`);
try {
const r = await fetch(`${AC_BACKEND}/api/neighbors?source=${encodeURIComponent(currentSource)}`
+ `&q=${encodeURIComponent(id)}&limit=10`);
const data = await r.json();
if (data.error || !data.neighbors) {
setStatus(data.error || `Could not expand "${id}".`);
return;
}
expandedNodes.add(id);
const base = nodes[id];
if (data.node && data.node.kind) base.kind = data.node.kind;
let added = 0;
data.neighbors.forEach(nb => {
const title = nb.title;
if (!nodes[title]) {
nodes[title] = {
id: title, g: null, h: null, f: null, state: 'open',
kind: nb.kind || null, expanded: true,
x: (base.x || width / 2) + (Math.random() - .5) * 90,
y: (base.y || height / 2) + (Math.random() - .5) * 90,
vx: 0, vy: 0,
};
added++;
} else {
if (nb.kind && !nodes[title].kind) nodes[title].kind = nb.kind;
nodes[title].expanded = true;
}
const exists = edges.find(e =>
(e.from === id && e.to === title) || (e.from === title && e.to === id));
if (!exists) {
edges.push({ from: id, to: title, isPath: false,
type: nb.type, display: nb.display });
} else if (!exists.display) {
exists.type = nb.type; exists.display = nb.display;
}
});
render();
if (overlayOn.news || overlayOn.reddit) refreshOverlay(id);
setStatus(`Expanded "${id}": ${data.neighbors.length} connections`
+ (added ? ` (${added} new)` : '') + '. Hover an edge to see why it exists.');
addLog(`Expanded ${id} → ` + data.neighbors.slice(0, 5)
.map(n => `${n.title} [${n.display}]`).join(', ')
+ (data.neighbors.length > 5 ? '…' : ''));
} catch (e) {
setStatus('Could not expand — backend unreachable.');
}
}
// ── Init graph ──────────────────────────────────────────────────────────────
function initGraph() {
const wrap = document.getElementById('canvas-wrap');
width = wrap.clientWidth;
height = wrap.clientHeight;
const el = document.getElementById('graph-canvas');
const gcBar = document.getElementById('graph-controls');
if (!_gvReady) _gvReady = window.GV.init(el, { onExpand: expandNodeFromGraph });
if (_gvReady) window.GV.reset();
if (gcBar) gcBar.style.display = 'flex';
renderLensPicker();
}
// ── Render: hand the shared state to the canvas controller ───────────────────
function render() {
if (window.GV) window.GV.sync(nodes, edges, _graphCtx());
}
// ── Pan/fly to a node (after a search; the auto-follow owns the camera
// while a search is streaming, so we don't fight it mid-run) ─────────────────
function panToNode(nodeId) {
if (!running && window.GV) window.GV.focus(nodeId);
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function truncate(s, n) { return s.length > n ? s.slice(0, n - 1) + '…' : s; }
function setStatus(msg) { document.getElementById('status-bar').textContent = msg; }
function addLog(msg, type = '') {
const log = document.getElementById('log');
const entry = document.createElement('div');
entry.className = 'log-entry ' + type;
const time = new Date().toLocaleTimeString('en', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
entry.textContent = `[${time}] ${msg}`;
log.insertBefore(entry, log.firstChild);
}
function switchTab(name) {
document.querySelectorAll('.tab').forEach((t, i) => {
const names = ['path', 'scores', 'log', 'legend'];
t.classList.toggle('active', names[i] === name);
});
document.querySelectorAll('.tab-content').forEach(c => {
c.classList.toggle('active', c.id === 'tab-' + name);
});
}
// External URL for a node title, or null when the active source has no
// canonical public page per node (finance tickers, news entities, …).
function nodeUrl(title) {
if (currentSource === 'wikipedia') {
return 'https://en.wikipedia.org/wiki/' + encodeURIComponent(title.replace(/ /g, '_'));
}
return null;
}
function updatePathPanel(pathArr) {
const div = document.getElementById('path-display');
if (!pathArr || pathArr.length === 0) return;
div.innerHTML = '';
pathArr.forEach((p, i) => {
const row = document.createElement('div');
row.className = 'path-step';
const dot = document.createElement('div');
dot.className = 'dot' + (i === 0 ? ' start' : i === pathArr.length - 1 ? ' end' : '');
const url = nodeUrl(p);
const btn = document.createElement(url ? 'a' : 'span');
btn.className = 'hop-btn-link' + (i === 0 ? ' start-hop' : i === pathArr.length - 1 ? ' end-hop' : '');
if (url) { btn.href = url; btn.target = '_blank'; }
btn.textContent = p;
row.appendChild(dot);
row.appendChild(btn);
div.appendChild(row);
if (i < pathArr.length - 1) {
const arr = document.createElement('div');
arr.className = 'path-step';
arr.style.margin = '2px 0 2px 4px';
arr.innerHTML = '↓';
div.appendChild(arr);
}
});
}
function updateScores(nodeData) {
scoreCards[nodeData.id] = nodeData;
const list = document.getElementById('scores-list');
list.innerHTML = '';
const sorted = Object.values(scoreCards)
.filter(n => n.f != null)
.sort((a, b) => a.f - b.f)
.slice(0, 30);
sorted.forEach(n => {
const card = document.createElement('div');
card.className = 'score-card';
card.innerHTML = `
${escHtml(n.id)}
f(n)${n.f}
g(n)${n.g ?? '—'}
h(n)${n.h ?? '—'}
`;
list.appendChild(card);
});
}
function showPathBanner(pathArr) {
const banner = document.getElementById('path-banner');
banner.innerHTML = '';
banner.classList.add('visible');
const lbl = document.createElement('span');
lbl.style.cssText = 'font-size:13px;color:var(--text2);margin-right:12px;white-space:nowrap;font-weight:600';
lbl.textContent = `✓ Path (${pathArr.length - 1} hops):`;
banner.appendChild(lbl);
pathArr.forEach((p, i) => {
const url = nodeUrl(p);
const btn = document.createElement(url ? 'a' : 'span');
btn.className = 'hop-btn-link' + (i === 0 ? ' start-hop' : i === pathArr.length - 1 ? ' end-hop' : '');
btn.style.flex = 'none';
if (url) { btn.href = url; btn.target = '_blank'; }
btn.textContent = p;
banner.appendChild(btn);
if (i < pathArr.length - 1) {
const arr = document.createElement('span');
arr.className = 'banner-arrow';
arr.textContent = '→';
banner.appendChild(arr);
}
});
}
// ── WebSocket message handler ─────────────────────────────────────────────────
function handleMessage(msg) {
switch (msg.event) {
case 'status':
setStatus(msg.message);
addLog(msg.message);
break;
case 'resolved':
addLog(`Resolved: "${msg.start}" → "${msg.end}"`, 'highlight');
setStatus(`Navigating: ${msg.start} → ${msg.end}`);
break;
case 'node_add': {
nodes[msg.id] = {
id: msg.id, g: msg.g, h: msg.h, f: msg.f,
state: msg.state,
x: width / 2 + (Math.random() - .5) * 120,
y: height / 2 + (Math.random() - .5) * 120,
vx: 0, vy: 0,
};
updateScores(msg);
render();
break;
}
case 'expand_node': {
if (centreNode && centreNode !== msg.id) {
if (nodes[centreNode]) nodes[centreNode].state = 'open';
}
centreNode = msg.id;
if (nodes[msg.id]) {
nodes[msg.id].state = 'centre';
nodes[msg.id].g = msg.g;
nodes[msg.id].h = msg.h;
nodes[msg.id].f = msg.f;
}
pathNodes = new Set(msg.path_so_far || []);
edges.forEach(e => {
const pArr = msg.path_so_far || [];
e.isPath = false;
for (let i = 0; i < pArr.length - 1; i++) {
if ((e.from === pArr[i] && e.to === pArr[i + 1]) ||
(e.to === pArr[i] && e.from === pArr[i + 1])) {
e.isPath = true;
}
}
});
updatePathPanel(msg.path_so_far);
updateScores(msg);
addLog(`Expanding: ${msg.id} f=${msg.f}`, 'highlight');
if (msg.stats) _updateStats(msg.stats);
render();
// The canvas auto-follow keeps the whole growing graph framed while
// the search streams, so we don't fly to each centre node here.
break;
}
case 'node_state': {
if (nodes[msg.id]) nodes[msg.id].state = msg.state;
render();
break;
}
case 'neighbours': {
msg.nodes.forEach(n => {
if (!nodes[n.id]) {
const cn = nodes[msg.centre] || { x: width / 2, y: height / 2 };
const angle = Math.random() * 2 * Math.PI;
const dist = 90 + Math.random() * 70;
nodes[n.id] = {
id: n.id, g: n.g, h: n.h, f: n.f,
state: n.state,
x: cn.x + Math.cos(angle) * dist,
y: cn.y + Math.sin(angle) * dist,
vx: 0, vy: 0,
};
} else {
nodes[n.id].g = n.g;
nodes[n.id].h = n.h;
nodes[n.id].f = n.f;
if (nodes[n.id].state !== 'target' && nodes[n.id].state !== 'centre') {
nodes[n.id].state = n.state;
}
}
updateScores(n);
});
msg.edges.forEach(e => {
const exists = edges.find(ex => ex.from === e.from && ex.to === e.to);
if (!exists) edges.push({ from: e.from, to: e.to, isPath: false });
});
render();
break;
}
case 'found': {
const path = msg.path;
pathNodes = new Set(path);
// Synthesize missing nodes: the meeting check (d1/d2) skips the
// neighbours event, so bridge nodes may not exist in the frontend.
path.forEach((id, i) => {
if (!nodes[id]) {
const prev = i > 0 ? nodes[path[i - 1]] : null;
nodes[id] = {
id, g: i, h: 0, f: i,
state: 'path',
x: (prev ? prev.x : width / 2) + (Math.random() - .5) * 100,
y: (prev ? prev.y : height / 2) + (Math.random() - .5) * 100,
vx: 0, vy: 0,
};
}
});
path.forEach(id => { if (nodes[id]) nodes[id].state = 'path'; });
if (nodes[path[0]]) nodes[path[0]].state = 'start';
if (nodes[path[path.length - 1]]) nodes[path[path.length - 1]].state = 'target';
edges.forEach(e => {
e.isPath = false;
for (let i = 0; i < path.length - 1; i++) {
if ((e.from === path[i] && e.to === path[i + 1]) ||
(e.to === path[i] && e.from === path[i + 1])) {
e.isPath = true;
}
}
});
for (let i = 0; i < path.length - 1; i++) {
const a = path[i], b = path[i + 1];
const exists = edges.find(e =>
(e.from === a && e.to === b) || (e.from === b && e.to === a));
if (exists) {
exists.isPath = true;
} else {
edges.push({ from: a, to: b, isPath: true });
}
}
updatePathPanel(path);
showPathBanner(path);
addLog(`✓ Found! ${path.join(' → ')} (${msg.total_hops} hops, ${msg.steps} steps)`, 'success');
setStatus(`Done! Path found in ${msg.total_hops} hops.`);
// Set the found/running state BEFORE render() so the canvas captures
// the found context (green path, dimmed cloud) on this sync — the
// renderer reads _graphCtx() once per sync, not per animation frame.
running = false;
foundPath = path;
render();
document.getElementById('btn-search').disabled = false;
_hidePauseBtn();
document.getElementById('btn-export').classList.add('visible');
if (msg.stats) _updateStats(msg.stats);
animatePath(path, msg.steps, msg.total_hops, msg.display_texts);
break;
}
case 'not_found': {
addLog(`✗ ${msg.message}`, 'error');
setStatus(msg.message);
document.getElementById('btn-search').disabled = false;
running = false;
_hidePauseBtn();
if (msg.stats) _updateStats(msg.stats);
break;
}
case 'stats': {
_updateStats(msg);
break;
}
case 'error': {
addLog(`Error: ${msg.message}`, 'error');
setStatus(`Error: ${msg.message}`);
document.getElementById('btn-search').disabled = false;
running = false;
_hidePauseBtn();
break;
}
}
}
// ── Stats update ─────────────────────────────────────────────────────────────
function _updateStats(s) {
if (!s) return;
_lastElapsedS = s.elapsed_s ?? 0;
const cnt = document.getElementById('sv-steps-count');
if (cnt) cnt.textContent = (s.nodes_visited ?? 0).toLocaleString();
}
// ── Pause / Resume ────────────────────────────────────────────────────────────
function togglePause() {
paused = !paused;
const btn = document.getElementById('btn-pause-bar');
if (paused) {
btn.textContent = '▶ Resume';
btn.classList.add('paused');
if (ws && ws.readyState === 1) ws.send(JSON.stringify({ control: 'pause' }));
} else {
btn.textContent = '⏸ Pause';
btn.classList.remove('paused');
if (ws && ws.readyState === 1) ws.send(JSON.stringify({ control: 'resume' }));
}
}
function _hidePauseBtn() {
paused = false;
const btn = document.getElementById('btn-pause-bar');
btn.classList.remove('visible', 'paused');
btn.textContent = '⏸ Pause';
const sl = document.getElementById('sv-steps-live');
if (sl) sl.classList.remove('visible');
}
// ── Export path ───────────────────────────────────────────────────────────────
function exportPath() {
if (!foundPath.length) return;
const lines = [
'Aurelius - Path Export',
'='.repeat(50),
'',
`Path (${foundPath.length - 1} hops):`,
'',
...foundPath.map((p, i) => {
const url = nodeUrl(p);
return url ? `${i + 1}. ${p}\n ${url}` : `${i + 1}. ${p}`;
}),
'',
`Exported: ${new Date().toLocaleString()}`,
];
const blob = new Blob([lines.join('\n')], { type: 'text/plain' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'aurelius-path.txt';
a.click();
}
// ── Keyboard shortcuts ────────────────────────────────────────────────────────
document.addEventListener('keydown', e => {
if (e.target.tagName === 'INPUT') return;
if (e.code === 'Space') { e.preventDefault(); if (running) togglePause(); }
if (e.code === 'KeyR') { resetAll(); }
if (e.code === 'KeyE') { exportPath(); }
if (e.key === '?') { openAboutModal(); }
});
function swapInputs() {
const s = document.getElementById('inp-start');
const en = document.getElementById('inp-end');
if (!s || !en) return;
[s.value, en.value] = [en.value, s.value];
}
function logoClick() {
resetAll(true);
}
// ── Boot ──────────────────────────────────────────────────────────────────────
window.addEventListener('load', () => {
initGraph();
// Focus first hero input
document.getElementById('hero-inp-start').focus();
window.addEventListener('resize', () => {
width = document.getElementById('canvas-wrap').clientWidth;
height = document.getElementById('canvas-wrap').clientHeight;
});
// NEW: wire vector-search autocomplete onto the two hero inputs. This is
// a separate addEventListener (not the existing oninput="heroInputChanged()"
// attribute already on these elements) so both the original validation
// logic and the new autocomplete logic run independently, side by side,
// every time the user types — neither one touches or depends on the other.
document.getElementById('hero-inp-start')
.addEventListener('input', () => triggerAutocomplete('start'));
document.getElementById('hero-inp-end')
.addEventListener('input', () => triggerAutocomplete('end'));
});
function animatePath(path, steps, hops, displayTexts) {
// The canvas renderer already recolours the found path and dims the
// cloud; frame the whole route, then present the result.
if (window.GV) window.GV.fit();
setTimeout(() => openSuccessModal(path, steps, hops, displayTexts), 900);
}
function openSuccessModal(path, steps, hops, displayTexts) {
document.getElementById('modal-hops').textContent = hops;
document.getElementById('modal-steps').textContent = steps;
const elapsed = _lastElapsedS;
document.getElementById('modal-elapsed').textContent = elapsed >= 60
? Math.floor(elapsed / 60) + 'm ' + (elapsed % 60) + 's'
: elapsed + 's';
// The piped-link disclaimer only makes sense for Wikipedia paths.
const disclaimer = document.getElementById('modal-disclaimer');
if (disclaimer) disclaimer.style.display =
currentSource === 'wikipedia' ? '' : 'none';
const container = document.getElementById('modal-path-container');
container.innerHTML = '';
path.forEach((p, i) => {
const url = nodeUrl(p);
const btn = document.createElement(url ? 'a' : 'span');
btn.className = 'hop-btn' + (i === 0 ? ' start-hop' : i === path.length - 1 ? ' end-hop' : '');
if (url) { btn.href = url; btn.target = '_blank'; }
btn.innerHTML = `
${escHtml(p)}
`;
container.appendChild(btn);
if (i < path.length - 1) {
const arr = document.createElement('span');
arr.className = 'modal-arrow';
arr.textContent = '→';
const piped = displayTexts && displayTexts[i];
if (piped) {
arr.classList.add('piped-edge');
arr.title = `Shown as "${piped}" in the article text`;
const note = document.createElement('span');
note.className = 'modal-piped-note';
note.textContent = `"${piped}"`;
arr.appendChild(note);
}
container.appendChild(arr);
}
});
document.getElementById('modal-copy-btn').textContent = 'Copy Path';
document.getElementById('success-modal').classList.add('visible');
// Plain-English path narrative — additive, lazy, below the hop chain.
const aiEl = document.getElementById('modal-ai');
if (aiEl) {
const nodesParam = encodeURIComponent(path.join('|'));
const edgesParam = encodeURIComponent((displayTexts || []).join('|'));
aiBlock(aiEl,
`${AC_BACKEND}/api/explain/path?source=${encodeURIComponent(currentSource)}`
+ `&nodes=${nodesParam}&edges=${edgesParam}`,
{ label: 'In plain English' });
}
}
function closeSuccessModal(e) {
if (!e || e.target === document.getElementById('success-modal') || e.target.tagName === 'BUTTON') {
document.getElementById('success-modal').classList.remove('visible');
}
}
function copyPath() {
if (!foundPath.length) return;
const text = foundPath.join(' → ');
const btn = document.getElementById('modal-copy-btn');
navigator.clipboard.writeText(text).then(() => {
if (btn) { btn.textContent = 'Copied!'; setTimeout(() => { btn.textContent = 'Copy Path'; }, 1600); }
}).catch(() => {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
if (btn) { btn.textContent = 'Copied!'; setTimeout(() => { btn.textContent = 'Copy Path'; }, 1600); }
});
}
function openAboutModal() {
document.getElementById('about-modal').classList.add('visible');
}
function closeAboutModal(e) {
if (!e || e.target === document.getElementById('about-modal') || e.target.tagName === 'BUTTON') {
document.getElementById('about-modal').classList.remove('visible');
}
}
// ════════════════════════════════════════════════════════════════════════════
// Loading screen + landing entrance sequence.
// Polls /api/health until the backend's embedding model is ready (the
// server doesn't accept connections until startup finishes loading it, so
// a single successful response already means "ready" — no extra check
// needed). Once ready: fade out the loading screen and trigger the title's
// 3-stage entrance animation — landing position -> grow to viewport center
// -> hold -> return to landing position. The rest of the hero
// (.hero-reveal elements) and the hero background (#hero-bg-mask) fade in
// together exactly when that animation finishes, driven by the
// animationend event rather than a magic setTimeout that would drift out
// of sync if the CSS timing is ever tuned.
// ════════════════════════════════════════════════════════════════════════════
const HEALTH_POLL_MS = 350;
async function initLoadingSequence() {
const loadingScreen = document.getElementById('loading-screen');
const loadingHint = document.getElementById('loading-hint');
const hintTimer = setTimeout(() => loadingHint.classList.add('show'), 10000);
while (true) {
try {
const res = await fetch(`${AC_BACKEND}/api/health`);
if (res.ok) break;
} catch (err) {
// Backend not reachable yet — keep polling silently.
}
await new Promise(r => setTimeout(r, HEALTH_POLL_MS));
}
clearTimeout(hintTimer);
loadingScreen.classList.add('fade-out');
setTimeout(() => loadingScreen.classList.add('hidden'), 550);
loadSources(); // populate the source picker once the backend is up
loadLlmStatus(); // decide whether to render the optional AI affordances
playHeroEntrance();
}
// Drives the hero title's 3-stage entrance animation. Called ONLY on first
// load (initLoadingSequence(), below) — this is a one-time first-impression
// flourish, not something to replay on every return to the hero screen.
//
// BUGFIX: this used to also run from resetAll() on every Reset/"New Search",
// per its own now-removed claim that doing so kept the experience
// "consistent." It didn't — the function re-opaques the full-screen
// #hero-bg-mask and re-hides every .hero-reveal element (inputs, tagline,
// random button) for the FULL ~4.3s animation duration before its
// `animationend` handler reveals them again. Replaying that on every reset
// meant the entire screen went solid-background with nothing visible or
// interactive for several seconds — reported as "the page going blank" when
// tapping New Search, especially noticeable on mobile. resetAll() now calls
// the lightweight resetHeroDisplay() below instead, which shows everything
// immediately with no replay.
// Each call:
// 1. Re-hides the rest of the hero (.hero-reveal) and re-opaques the
// background mask (#hero-bg-mask) — necessary since this is the very
// first paint and both start in their "revealed" CSS state otherwise.
// 2. Measures the title's actual landing position and computes the
// exact pixel offset needed to land it on the true viewport center
// (a fixed vh value over/undershoots depending on monitor height —
// the title's landing position is a fixed-px distance from center,
// not a viewport-relative one).
// 3. Forces a reflow and re-adds the `entrance` class so the CSS
// animation restarts cleanly even if it was already played once.
function playHeroEntrance() {
const heroTitle = document.getElementById('hero-title');
const mask = document.getElementById('hero-bg-mask');
document.querySelectorAll('.hero-reveal').forEach(el => el.classList.remove('show'));
if (mask) mask.classList.remove('hide');
heroTitle.classList.remove('entrance');
const rect = heroTitle.getBoundingClientRect();
const dy = window.innerHeight / 2 - (rect.top + rect.height / 2);
// The grown stage's transform is `scale(ENTRANCE_SCALE) translateY(...)` —
// translateY is applied first and then amplified by the scale that wraps
// it, so the on-screen displacement ends up as dy * ENTRANCE_SCALE, not
// dy. Pre-dividing here is what makes the title land exactly on center
// instead of overshooting by 45%. Must match the scale() value in the
// title-entrance keyframes (styles.css).
const ENTRANCE_SCALE = 1.45;
heroTitle.style.setProperty('--center-dy', `${Math.round(dy / ENTRANCE_SCALE)}px`);
void heroTitle.offsetWidth; // force reflow so the animation restarts from 0%
heroTitle.classList.add('entrance');
heroTitle.addEventListener('animationend', () => {
document.querySelectorAll('.hero-reveal').forEach(el => el.classList.add('show'));
if (mask) mask.classList.add('hide');
startPlaceholderRotation();
// Show onboarding on first visit only
if (!localStorage.getItem('aurelius_seen_onboarding')) {
showOnboarding();
}
}, { once: true });
}
// Lightweight hero reset for return visits (Reset / New Search) — shows
// everything immediately instead of replaying playHeroEntrance()'s ~4.3s
// title-grow-to-center-and-back sequence with its full-screen mask. See the
// BUGFIX note on playHeroEntrance() above for why that replay was wrong here.
function resetHeroDisplay() {
const heroTitle = document.getElementById('hero-title');
const mask = document.getElementById('hero-bg-mask');
heroTitle.classList.remove('entrance');
heroTitle.style.opacity = '1';
heroTitle.style.transform = 'scale(1)';
if (mask) mask.classList.add('hide');
document.querySelectorAll('.hero-reveal').forEach(el => el.classList.add('show'));
startPlaceholderRotation();
}
function showOnboarding() {
const modal = document.getElementById('onboarding-modal');
modal.classList.add('visible');
}
function closeOnboarding() {
const modal = document.getElementById('onboarding-modal');
modal.classList.remove('visible');
localStorage.setItem('aurelius_seen_onboarding', 'true');
}
initLoadingSequence();