// ── 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 = '
AI' + `${escHtml(opts.label || 'In plain English')}
` + '
'; try { const r = await fetch(url); const d = await r.json(); const head = '
AI' + `${escHtml(opts.label || 'In plain English')}
`; if (d.available && d[field]) { el.innerHTML = head + `
${escHtml(d[field])}
` + (opts.tone && d.sentiment ? _aiTone(d.sentiment) : ''); } else { el.innerHTML = head + `
${escHtml(_aiReason(d.reason))}
`; } } catch (e) { el.innerHTML = '
AI
' + `
${escHtml(_aiReason('error'))}
`; } } function _aiTone(s) { const lbl = s.label || 'neutral'; const sym = lbl === 'positive' ? '▲' : lbl === 'negative' ? '▼' : '●'; return `
${sym} overall tone: ${escHtml(lbl)}
`; } // ── 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 = `
${escHtml(data.error)}
`; return; } const cands = data.candidates || []; if (!cands.length) { panel.innerHTML = '
No hidden connections surfaced. Try a more connected node.
'; return; } panel.innerHTML = `
Hidden connections from ` + `${escHtml(data.a.title)} — none of these link to it ` + `directly; each is reached only through shared neighbours.
` + '
' + cands.map(c => { const simPct = Math.round(Math.max(0, c.similarity) * 100); return `
${escHtml(c.title)} ${c.score}
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 ` ${path(sa, '#e0995c')}${path(sb, '#5b8dd6')}
${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 = '
Enter two things to compare.
'; return; } if (out) out.innerHTML = '
Working out the connection…
'; const src = encodeURIComponent(currentSource); try { const [rel, na, nb] = await Promise.all([ fetch(`${AC_BACKEND}/api/relate?source=${src}&a=${encodeURIComponent(a)}&b=${encodeURIComponent(b)}`).then(r => r.json()), fetch(`${AC_BACKEND}/api/node?source=${src}&q=${encodeURIComponent(a)}`).then(r => r.json()), fetch(`${AC_BACKEND}/api/node?source=${src}&q=${encodeURIComponent(b)}`).then(r => r.json()), ]); renderCompare(rel, na, nb); } catch (e) { if (out) out.innerHTML = '
Could not reach the backend.
'; } } function renderCompare(rel, na, nb) { const out = document.getElementById('compare-results'); if (!out) return; if (rel.error) { out.innerHTML = `
${escHtml(rel.error)}
`; return; } const titleA = rel.a.title, titleB = rel.b.title; const strength = rel.strength; const bar = `
Connection strength ${strength}/100
`; 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 ? '' : '

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 = `
How their prices move
${_priceChartSVG(fa.series, fb.series, titleA, titleB)}
${facts.join('')}
${corr !== null ? `
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 = `
${escHtml(data.error)}
`; return; } _profileCurrent = data; renderProfile(data); } catch (e) { if (body) body.innerHTML = '
Could not reach the backend.
'; } } // single-series normalized price chart with an area fill function _profileChartSVG(series, changePct) { if (!Array.isArray(series) || series.length < 2) return ''; const W = 560, H = 150, pad = 8; const lo = Math.min(...series), hi = Math.max(...series); const span = hi - lo || 1; const up = (changePct || 0) >= 0; const stroke = up ? '#4aab79' : '#d66a6a'; const step = (W - 2 * pad) / (series.length - 1); const xy = series.map((v, i) => [pad + i * step, H - pad - (v - lo) / span * (H - 2 * pad)]); const line = xy.map(p => `${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(' '); const area = `${pad},${H - pad} ${line} ${(W - pad).toFixed(1)},${H - pad}`; return ` `; } function _fmtChange(pct) { if (typeof pct !== 'number' || !isFinite(pct)) return ''; const cls = pct >= 0 ? 'up' : 'down'; return `${pct >= 0 ? '▲' : '▼'} ${Math.abs(pct)}%`; } // a horizontal list of related-company cards (peers/suppliers/…) function _profileCards(items, opts = {}) { if (!Array.isArray(items) || !items.length) return ''; return `
` + items.map(c => { const t = escHtml(c.title || c.id); const meta = []; if (opts.showCorr && typeof c.corr === 'number') meta.push(`corr ${c.corr}`); else if (c.sector) meta.push(escHtml(c.sector)); const chg = (opts.showChange && typeof c.change_pct === 'number') ? _fmtChange(c.change_pct) : ''; return ``; }).join('') + `
`; } function _profileSection(title, hint, html) { if (!html) return ''; return `
${escHtml(title)}${hint ? `${escHtml(hint)}` : ''}
${html}
`; } function renderProfile(d) { const body = document.getElementById('profile-body'); if (!body) return; const facts = []; if (d.sector) facts.push(`Sector${escHtml(d.sector)}`); if (d.ceo && d.ceo.title) facts.push(`CEO${escHtml(d.ceo.title)}`); if (d.country) facts.push(`HQ${escHtml(d.country)}`); if (d.etfs && d.etfs.length) facts.push(`In ETFs${d.etfs.length}`); const price = (typeof d.last_price === 'number') ? `
$${d.last_price} ${_fmtChange(d.change_pct)} last close · ${Array.isArray(d.series) ? d.series.length : 0}-day window
` : ''; const kindBadge = d.kind ? `${escHtml(d.kind)}` : ''; body.innerHTML = `

${escHtml(d.title || d.id)}

${kindBadge}
${price} ${facts.length ? `
${facts.join('')}
` : ''} ${d.summary ? `

${escHtml(d.summary)}

` : ''} ${Array.isArray(d.series) && d.series.length ? `
${_profileChartSVG(d.series, d.change_pct)}
` : ''} ${_profileSection('Peers', 'rivals & sector', _profileCards(d.peers, { showChange: true }))} ${_profileSection('Suppliers', 'depends on', _profileCards(d.suppliers, { showChange: true }))} ${_profileSection('Customers', 'sells to / powers', _profileCards(d.customers, { showChange: true }))} ${_profileSection('Moves with', 'return correlation', _profileCards(d.correlated, { showCorr: true }))} ${_profileSection('Macro exposure', 'correlated indicators', _profileCards(d.macro, { showCorr: true }))} ${_profileSection('Ownership', 'ETFs & investors', _profileCards([...(d.etfs || []), ...(d.investors || [])]))}
In the newslatest coverage
`; loadProfileNews(d.title || d.id); } // News for the profiled company (reuses the News Intelligence entity feed). async function loadProfileNews(name) { const box = document.querySelector('#prof-news .prof-news-body'); if (!box) return; try { const data = await fetch(`${AC_BACKEND}/api/news/entity?name=${encodeURIComponent(name)}&k=5`).then(r => r.json()); const items = (data && data.results) || []; if (!items.length) { box.innerHTML = '
No recent coverage indexed. Open the graph view and toggle News to refresh the feed.
'; return; } box.innerHTML = items.map(a => { const s = a.sentiment_label || 'neutral'; const when = a.published ? String(a.published).slice(0, 10) : ''; return ` ${escHtml(a.title || 'Untitled')} ${escHtml(a.source || '')}${when ? ' · ' + escHtml(when) : ''} `; }).join(''); } catch (e) { box.innerHTML = '
Couldn\'t load news.
'; } } 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 = `
${escHtml(data.error)}
`; return; } _paperCurrent = data; renderPaper(data); } catch (e) { if (body) body.innerHTML = '
Could not reach the backend.
'; } } // Upload a PDF → backend extracts its DOI/arXiv/title and returns the // citation dossier, which renders exactly like a searched paper. // Drag-and-drop a PDF anywhere on the citation panel. (function wirePaperDrop() { const setup = () => { const panel = document.getElementById('paper-panel'); if (!panel) return; const stop = (e) => { e.preventDefault(); e.stopPropagation(); }; ['dragenter', 'dragover'].forEach(ev => panel.addEventListener(ev, (e) => { stop(e); panel.classList.add('drag-over'); })); ['dragleave', 'drop'].forEach(ev => panel.addEventListener(ev, (e) => { stop(e); if (ev === 'dragleave' && panel.contains(e.relatedTarget)) return; panel.classList.remove('drag-over'); })); panel.addEventListener('drop', (e) => { const f = e.dataTransfer && e.dataTransfer.files; if (f && f.length) uploadPaperPdf(f); }); }; if (document.readyState !== 'loading') setup(); else document.addEventListener('DOMContentLoaded', setup); })(); async function uploadPaperPdf(files) { const file = files && files[0]; if (!file) return; togglePaper(true); const body = document.getElementById('paper-body'); if (body) body.innerHTML = `
Reading “${escHtml(file.name)}”…
`; try { const fd = new FormData(); fd.append('file', file); const data = await fetch(`${AC_BACKEND}/api/paper/upload`, { method: 'POST', body: fd }).then(r => r.json()); if (data.error) { if (body) body.innerHTML = `
${escHtml(data.error)}
`; return; } _paperCurrent = data; const inp = document.getElementById('paper-input'); if (inp) inp.value = data.title || ''; renderPaper(data); } catch (e) { if (body) body.innerHTML = '
Could not upload the PDF.
'; } } function _paperMeta(c) { const bits = []; if (c.authors && c.authors.length) bits.push(escHtml(c.authors.join(', ') + (c.authors.length >= 3 ? ' et al.' : ''))); if (c.year) bits.push(c.year); if (c.venue) bits.push(escHtml(c.venue)); return bits.join(' · '); } function _paperCards(items, badge) { if (!Array.isArray(items) || !items.length) return '
None found.
'; return `
` + items.map(c => { const cb = c.cited_by_count ? `${_kfmt(c.cited_by_count)}×` : ''; return ``; }).join('') + `
`; } function _kfmt(n) { if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1) + 'k'; return String(n); } function renderPaper(d) { const body = document.getElementById('paper-body'); if (!body) return; const meta = []; if (d.authors && d.authors.length) meta.push(escHtml(d.authors.join(', ') + (d.authors.length >= 3 ? ' et al.' : ''))); if (d.year) meta.push(d.year); if (d.venue) meta.push(escHtml(d.venue)); body.innerHTML = `

${escHtml(d.title || d.id)}

${meta.length ? `
${meta.join(' · ')}
` : ''}
${_kfmt(d.cited_by_count || 0)} citations ${d.n_references || 0} references ${d.n_citations || 0} citing here
${d.abstract ? `

${escHtml(d.abstract)}

` : ''}
${_profileSection('References', 'papers this cites', _paperCards(d.references))} ${_profileSection('Cited by', 'influential papers citing this', _paperCards(d.citations))}`; } // Seed the 2D graph with the paper at the centre, its references (papers it // cites) and citations (papers citing it), then hand off to the explorer. function paperBuildGraph() { const d = _paperCurrent; if (!d) return; togglePaper(false); nodes = {}; edges = []; expandedNodes.clear(); const cx = width / 2, cy = height / 2; nodes[d.id] = { id: d.id, title: d.title, g: 0, h: 0, f: 0, state: 'centre', kind: 'paper', expanded: true, x: cx, y: cy, vx: 0, vy: 0 }; centreNode = d.id; const place = (arr, sign) => (arr || []).forEach((c, i) => { if (!c.id || nodes[c.id]) return; const ang = (i / Math.max(1, arr.length)) * Math.PI + (sign > 0 ? 0 : Math.PI); nodes[c.id] = { id: c.id, title: c.title, g: 1, h: 0, f: 1, state: 'open', kind: 'paper', expanded: true, x: cx + Math.cos(ang) * 160 + (Math.random() - .5) * 40, y: cy + sign * 120 + (Math.random() - .5) * 40, vx: 0, vy: 0 }; }); place(d.references, -1); // things it cites, above place(d.citations, 1); // things citing it, below // directed edges: paper → reference (cites); citation → paper (cites) (d.references || []).forEach(c => { if (nodes[c.id]) edges.push({ from: d.id, to: c.id, isPath: false, type: 'cites', display: 'cites' }); }); (d.citations || []).forEach(c => { if (nodes[c.id]) edges.push({ from: c.id, to: d.id, isPath: false, type: 'cites', display: 'cited by' }); }); expandedNodes.add(d.id); render(); if (window.GV) window.GV.fit(); setStatus(`Citation graph for "${d.title}" — click any paper to expand its citations.`); } // Expand a paper node in the graph: pull both its references and citations. async function expandPaperNode(id) { if (running || !nodes[id] || expandedNodes.has(id)) return; setStatus(`Expanding citations for "${truncate(id, 40)}"…`); try { const d = await fetch(`${AC_BACKEND}/api/paper?q=${encodeURIComponent(id)}&refs=12&cites=12`).then(r => r.json()); if (d.error) { setStatus(d.error); return; } expandedNodes.add(id); const base = nodes[id] || { x: width / 2, y: height / 2 }; if (d.title && nodes[id]) nodes[id].title = d.title; let added = 0; const add = (c, sign, from, to, disp) => { if (!c.id) return; if (!nodes[c.id]) { nodes[c.id] = { id: c.id, title: c.title, g: null, h: null, f: null, state: 'open', kind: 'paper', expanded: true, x: (base.x || width / 2) + (Math.random() - .5) * 120, y: (base.y || height / 2) + sign * 80 + (Math.random() - .5) * 60, vx: 0, vy: 0 }; added++; } if (!edges.find(e => e.from === from && e.to === to)) edges.push({ from, to, isPath: false, type: 'cites', display: disp }); }; (d.references || []).forEach(c => add(c, -1, id, c.id, 'cites')); (d.citations || []).forEach(c => add(c, 1, c.id, id, 'cited by')); render(); setStatus(`Expanded "${truncate(d.title || id, 40)}": +${added} papers. ${d.n_references} references, ${d.n_citations} citing.`); } catch (e) { setStatus('Could not expand — backend unreachable.'); } } // ════════════════════════════════════════════════════════════════════════════ // News / Reddit overlays — fold live coverage onto the focused company. // ════════════════════════════════════════════════════════════════════════════ const overlayOn = { news: false, reddit: false }; let overlayNode = null; function toggleOverlay(medium) { overlayOn[medium] = !overlayOn[medium]; const btn = document.getElementById('gc-overlay-' + medium); if (btn) btn.classList.toggle('active', overlayOn[medium]); if (overlayOn.news || overlayOn.reddit) { refreshOverlay(overlayNode || centreNode); } else { closeOverlay(); } } function closeOverlay() { const d = document.getElementById('overlay-drawer'); if (d) d.classList.remove('open'); } async function refreshOverlay(nodeTitle) { if (!nodeTitle || (!overlayOn.news && !overlayOn.reddit)) return; overlayNode = nodeTitle; const drawer = document.getElementById('overlay-drawer'); const body = document.getElementById('overlay-body'); const titleEl = document.getElementById('ovl-title'); const sentEl = document.getElementById('ovl-sentiment'); if (!drawer || !body) return; drawer.classList.add('open'); const query = nodeTitle.replace(/\s*\([^)]*\)\s*$/, '').trim() || nodeTitle; if (titleEl) titleEl.textContent = `Coverage of ${query}`; if (sentEl) sentEl.textContent = ''; body.innerHTML = '
Pulling coverage…
'; try { const r = await fetch(`${AC_BACKEND}/api/news/entity?name=${encodeURIComponent(query)}&k=12`); const data = await r.json(); renderOverlay(data, query); } catch (e) { body.innerHTML = '
Could not reach the news service.
'; } } function renderOverlay(data, query) { const body = document.getElementById('overlay-body'); const sentEl = document.getElementById('ovl-sentiment'); if (!body) return; const items = (data.results || []).filter(a => { const m = a.medium || 'news'; return (m === 'discussion' && overlayOn.reddit) || (m === 'news' && overlayOn.news); }); if (sentEl && data.sentiment) { const lbl = data.sentiment.label; sentEl.textContent = lbl === 'positive' ? '▲ positive tone' : lbl === 'negative' ? '▼ negative tone' : '● neutral tone'; sentEl.className = 'ovl-sentiment ovl-' + lbl; } if (!items.length) { 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) => `${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 `
${_acKindIcon(kind)} ${escHtml(title)} ${subtitle ? `${escHtml(subtitle)}` : ''}
`; }).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();