murtaza-2007
Aurelius improvement pass: domain-aware recs, finance/research surfaces, 2D graph
658d200 | // ββ 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 = '<div class="ai-head"><span class="ai-badge">AI</span>' | |
| + `<span class="ai-label">${escHtml(opts.label || 'In plain English')}</span></div>` | |
| + '<div class="ai-shimmer"></div>'; | |
| try { | |
| const r = await fetch(url); | |
| const d = await r.json(); | |
| const head = '<div class="ai-head"><span class="ai-badge">AI</span>' | |
| + `<span class="ai-label">${escHtml(opts.label || 'In plain English')}</span></div>`; | |
| if (d.available && d[field]) { | |
| el.innerHTML = head + `<div class="ai-text">${escHtml(d[field])}</div>` | |
| + (opts.tone && d.sentiment ? _aiTone(d.sentiment) : ''); | |
| } else { | |
| el.innerHTML = head + `<div class="ai-notice">${escHtml(_aiReason(d.reason))}</div>`; | |
| } | |
| } catch (e) { | |
| el.innerHTML = '<div class="ai-head"><span class="ai-badge">AI</span></div>' | |
| + `<div class="ai-notice">${escHtml(_aiReason('error'))}</div>`; | |
| } | |
| } | |
| function _aiTone(s) { | |
| const lbl = s.label || 'neutral'; | |
| const sym = lbl === 'positive' ? 'β²' : lbl === 'negative' ? 'βΌ' : 'β'; | |
| return `<div class="ai-tone ai-${lbl}">${sym} overall tone: ${escHtml(lbl)}</div>`; | |
| } | |
| // ββ 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]) => | |
| `<option value="${escHtml(v)}"${v === graphLens ? ' selected' : ''}>${escHtml(label)}</option>`).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 => | |
| `<option value="${escHtml(s.name)}"${s.name === currentSource ? ' selected' : ''}>` | |
| + `${escHtml(sourceLabel(s.name))}</option>`).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 = `<button class="hero-research-cta" onclick="heroResearchCompany()"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20V11l5-3v12M13 20V6l5-3v17M3 20h18"/></svg> | |
| Research a company</button>`; | |
| } else if (currentSource === 'openalex') { | |
| cta = `<button class="hero-research-cta" onclick="heroExplorePaper()"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3h9l4 4v14H6z"/><path d="M15 3v4h4"/><line x1="9" y1="12" x2="16" y2="12"/><line x1="9" y1="16" x2="14" y2="16"/></svg> | |
| Explore a paper's citations</button>`; | |
| } | |
| if (!pairs.length && !cta) { box.innerHTML = ''; return; } | |
| const tryLabel = cta ? 'or trace a link' : 'Try'; | |
| box.innerHTML = cta | |
| + (pairs.length ? `<span class="hero-ex-label">${tryLabel}</span>` | |
| + pairs.map(([a, b]) => | |
| `<button class="hero-ex-chip" onclick="runExample('${a.replace(/'/g, "\\'")}','${b.replace(/'/g, "\\'")}')">` | |
| + `${escHtml(a)} <span>→</span> ${escHtml(b)}</button>`).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 = '<div class="discover-loading">Searching for hidden connectionsβ¦</div>'; | |
| 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 = '<div class="discover-loading">Could not reach the backend.</div>'; | |
| } | |
| } | |
| function renderDiscover(data) { | |
| const panel = document.getElementById('discover-results'); | |
| if (!panel) return; | |
| if (data.error) { panel.innerHTML = `<div class="discover-loading">${escHtml(data.error)}</div>`; return; } | |
| const cands = data.candidates || []; | |
| if (!cands.length) { | |
| panel.innerHTML = '<div class="discover-loading">No hidden connections surfaced. Try a more connected node.</div>'; | |
| return; | |
| } | |
| panel.innerHTML = `<div class="discover-head">Hidden connections from ` | |
| + `<strong>${escHtml(data.a.title)}</strong> β none of these link to it ` | |
| + `directly; each is reached only through shared neighbours.</div>` | |
| + '<div class="ai-block" id="disc-ai"></div>' | |
| + cands.map(c => { | |
| const simPct = Math.round(Math.max(0, c.similarity) * 100); | |
| return ` | |
| <div class="discover-card"> | |
| <div class="discover-card-top"> | |
| <span class="discover-title">${escHtml(c.title)}</span> | |
| <span class="discover-score">${c.score}</span> | |
| </div> | |
| <div class="discover-why">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` : ''}.</div> | |
| <div class="discover-bridges">via ${c.bridges.map(b => | |
| `<span>${escHtml(b.title)}</span>`).join(', ')}</div> | |
| </div>`; | |
| }).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 `<polyline fill="none" stroke="${color}" stroke-width="1.6" | |
| stroke-linejoin="round" points="${pts.join(' ')}"/>`; | |
| }; | |
| return `<svg class="cmp-chart" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none"> | |
| <line x1="${pad}" y1="${H - pad}" x2="${W - pad}" y2="${H - pad}" stroke="rgba(255,255,255,0.08)"/> | |
| ${path(sa, '#e0995c')}${path(sb, '#5b8dd6')} | |
| </svg> | |
| <div class="cmp-legend"> | |
| <span><i style="background:#e0995c"></i>${escHtml(labelA)}</span> | |
| <span><i style="background:#5b8dd6"></i>${escHtml(labelB)}</span> | |
| </div>`; | |
| } | |
| 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 = '<div class="cmp-msg">Enter two things to compare.</div>'; return; } | |
| if (out) out.innerHTML = '<div class="cmp-msg">Working out the connectionβ¦</div>'; | |
| 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 = '<div class="cmp-msg">Could not reach the backend.</div>'; | |
| } | |
| } | |
| function renderCompare(rel, na, nb) { | |
| const out = document.getElementById('compare-results'); | |
| if (!out) return; | |
| if (rel.error) { out.innerHTML = `<div class="cmp-msg">${escHtml(rel.error)}</div>`; return; } | |
| const titleA = rel.a.title, titleB = rel.b.title; | |
| const strength = rel.strength; | |
| const bar = `<div class="cmp-strength"> | |
| <div class="cmp-strength-label">Connection strength <b>${strength}/100</b></div> | |
| <div class="cmp-strength-track"><div class="cmp-strength-fill" style="width:${strength}%"></div></div> | |
| </div>`; | |
| const why = []; | |
| if (rel.direct && (rel.direct.a_to_b || rel.direct.b_to_a)) | |
| why.push('a <b>direct relationship</b> links them'); | |
| if (rel.n_paths > 0) | |
| why.push(`<b>${rel.n_paths}</b> short path${rel.n_paths === 1 ? '' : 's'} connect them through one step`); | |
| if (rel.n_co_targets > 0) | |
| why.push(`they connect to <b>${rel.n_co_targets}</b> of the same things`); | |
| if (rel.n_co_sources > 0) | |
| why.push(`<b>${rel.n_co_sources}</b> of the same things connect to both`); | |
| if (_num(rel.similarity) && rel.similarity > 0.2) | |
| why.push(`they're <b>${Math.round(rel.similarity * 100)}%</b> similar in meaning`); | |
| const inter = [...(rel.paths_a_to_b || []), ...(rel.paths_b_to_a || []), | |
| ...(rel.co_targets || [])].slice(0, 8); | |
| const whyBlock = `<div class="cmp-why"> | |
| <div class="cmp-why-title">Why they're connected</div> | |
| ${why.length ? '<ul>' + why.map(w => `<li>${w}</li>`).join('') + '</ul>' | |
| : '<p class="cmp-msg">No strong link surfaced; they may be only distantly related.</p>'} | |
| ${inter.length ? `<div class="cmp-inter">through ${inter.map(x => | |
| `<span>${escHtml(x.title)}</span>`).join(', ')}</div>` : ''} | |
| </div>`; | |
| 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( | |
| `<span class="cmp-fact"><i>${escHtml(titleA)}</i> ${fa.window_change_pct >= 0 ? '+' : ''}${fa.window_change_pct}%</span>`); | |
| if (_num(fb.window_change_pct)) facts.push( | |
| `<span class="cmp-fact"><i>${escHtml(titleB)}</i> ${fb.window_change_pct >= 0 ? '+' : ''}${fb.window_change_pct}%</span>`); | |
| chart = `<div class="cmp-chart-block"> | |
| <div class="cmp-why-title">How their prices move</div> | |
| ${_priceChartSVG(fa.series, fb.series, titleA, titleB)} | |
| <div class="cmp-facts">${facts.join('')}</div> | |
| ${corr !== null ? `<div class="cmp-corr">Price correlation over the window: | |
| <b>${corr.toFixed(2)}</b> — ${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'}.</div>` : ''} | |
| </div>`; | |
| } | |
| out.innerHTML = `<div class="cmp-pair">${escHtml(titleA)} <span class="cmp-vs">vs</span> ${escHtml(titleB)}</div>` | |
| + bar + whyBlock + chart | |
| + '<div class="ai-block" id="cmp-ai"></div>' | |
| + `<button class="cmp-path-btn" onclick="compareToPath('${escHtml(titleA).replace(/'/g, "\\'")}','${escHtml(titleB).replace(/'/g, "\\'")}')">Show the path between them →</button>`; | |
| // 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 => | |
| `<button class="prof-chip" onclick="openProfile('${t}')">${escHtml(t)}</button>`).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 = '<div class="prof-loading"><span class="prof-spinner"></span>Building the profileβ¦</div>'; | |
| try { | |
| const data = await fetch(`${AC_BACKEND}/api/company?q=${encodeURIComponent(query)}`).then(r => r.json()); | |
| if (data.error) { | |
| if (body) body.innerHTML = `<div class="prof-msg">${escHtml(data.error)}</div>`; | |
| return; | |
| } | |
| _profileCurrent = data; | |
| renderProfile(data); | |
| } catch (e) { | |
| if (body) body.innerHTML = '<div class="prof-msg">Could not reach the backend.</div>'; | |
| } | |
| } | |
| // 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 `<svg class="prof-chart" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="img" aria-label="Price chart"> | |
| <defs><linearGradient id="prof-grad" x1="0" y1="0" x2="0" y2="1"> | |
| <stop offset="0%" stop-color="${stroke}" stop-opacity="0.28"/> | |
| <stop offset="100%" stop-color="${stroke}" stop-opacity="0"/> | |
| </linearGradient></defs> | |
| <polygon fill="url(#prof-grad)" points="${area}"/> | |
| <polyline fill="none" stroke="${stroke}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" points="${line}"/> | |
| </svg>`; | |
| } | |
| function _fmtChange(pct) { | |
| if (typeof pct !== 'number' || !isFinite(pct)) return ''; | |
| const cls = pct >= 0 ? 'up' : 'down'; | |
| return `<span class="prof-chg ${cls}">${pct >= 0 ? 'β²' : 'βΌ'} ${Math.abs(pct)}%</span>`; | |
| } | |
| // a horizontal list of related-company cards (peers/suppliers/β¦) | |
| function _profileCards(items, opts = {}) { | |
| if (!Array.isArray(items) || !items.length) return ''; | |
| return `<div class="prof-cards">` + 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 `<button class="prof-card" onclick="openProfile('${(c.id || '').replace(/'/g, "\\'")}')" title="Open ${t}"> | |
| <span class="prof-card-name">${t}</span> | |
| <span class="prof-card-meta">${meta.join(' Β· ')} ${chg}</span> | |
| </button>`; | |
| }).join('') + `</div>`; | |
| } | |
| function _profileSection(title, hint, html) { | |
| if (!html) return ''; | |
| return `<div class="prof-section"> | |
| <div class="prof-sec-head">${escHtml(title)}${hint ? `<span class="prof-sec-hint">${escHtml(hint)}</span>` : ''}</div> | |
| ${html} | |
| </div>`; | |
| } | |
| function renderProfile(d) { | |
| const body = document.getElementById('profile-body'); | |
| if (!body) return; | |
| const facts = []; | |
| if (d.sector) facts.push(`<span class="prof-fact"><i>Sector</i>${escHtml(d.sector)}</span>`); | |
| if (d.ceo && d.ceo.title) facts.push(`<span class="prof-fact"><i>CEO</i>${escHtml(d.ceo.title)}</span>`); | |
| if (d.country) facts.push(`<span class="prof-fact"><i>HQ</i>${escHtml(d.country)}</span>`); | |
| if (d.etfs && d.etfs.length) facts.push(`<span class="prof-fact"><i>In ETFs</i>${d.etfs.length}</span>`); | |
| const price = (typeof d.last_price === 'number') | |
| ? `<div class="prof-price">$${d.last_price} ${_fmtChange(d.change_pct)} | |
| <span class="prof-price-note">last close Β· ${Array.isArray(d.series) ? d.series.length : 0}-day window</span></div>` | |
| : ''; | |
| const kindBadge = d.kind ? `<span class="prof-kind">${escHtml(d.kind)}</span>` : ''; | |
| body.innerHTML = ` | |
| <div class="prof-title-row"> | |
| <h2 class="prof-title">${escHtml(d.title || d.id)}</h2>${kindBadge} | |
| </div> | |
| ${price} | |
| ${facts.length ? `<div class="prof-facts">${facts.join('')}</div>` : ''} | |
| ${d.summary ? `<p class="prof-summary">${escHtml(d.summary)}</p>` : ''} | |
| ${Array.isArray(d.series) && d.series.length ? `<div class="prof-chart-wrap">${_profileChartSVG(d.series, d.change_pct)}</div>` : ''} | |
| ${_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 || [])]))} | |
| <div class="prof-news" id="prof-news"><div class="prof-sec-head">In the news<span class="prof-sec-hint">latest coverage</span></div><div class="prof-news-body"><span class="prof-spinner"></span></div></div> | |
| <div class="prof-actions"> | |
| <button class="prof-act primary" onclick="profileExploreGraph('${(d.id || '').replace(/'/g, "\\'")}')">Explore in graph</button> | |
| <button class="prof-act" onclick="profileCompare('${(d.id || '').replace(/'/g, "\\'")}')">Compareβ¦</button> | |
| </div>`; | |
| 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 = '<div class="prof-msg small">No recent coverage indexed. Open the graph view and toggle News to refresh the feed.</div>'; return; } | |
| box.innerHTML = items.map(a => { | |
| const s = a.sentiment_label || 'neutral'; | |
| const when = a.published ? String(a.published).slice(0, 10) : ''; | |
| return `<a class="prof-news-item" href="${escHtml(a.url || '#')}" target="_blank" rel="noopener"> | |
| <span class="prof-news-dot prof-${s}" title="${s} tone"></span> | |
| <span class="prof-news-txt"> | |
| <span class="prof-news-title">${escHtml(a.title || 'Untitled')}</span> | |
| <span class="prof-news-src">${escHtml(a.source || '')}${when ? ' Β· ' + escHtml(when) : ''}</span> | |
| </span> | |
| </a>`; | |
| }).join(''); | |
| } catch (e) { | |
| box.innerHTML = '<div class="prof-msg small">Couldn\'t load news.</div>'; | |
| } | |
| } | |
| 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 => | |
| `<button class="prof-chip" onclick="openPaper('${t.replace(/'/g, "\\'")}')">${escHtml(t)}</button>`).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 = '<div class="prof-loading"><span class="prof-spinner"></span>Fetching citationsβ¦</div>'; | |
| try { | |
| const data = await fetch(`${AC_BACKEND}/api/paper?q=${encodeURIComponent(query)}`).then(r => r.json()); | |
| if (data.error) { if (body) body.innerHTML = `<div class="prof-msg">${escHtml(data.error)}</div>`; return; } | |
| _paperCurrent = data; | |
| renderPaper(data); | |
| } catch (e) { | |
| if (body) body.innerHTML = '<div class="prof-msg">Could not reach the backend.</div>'; | |
| } | |
| } | |
| // 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 = `<div class="prof-loading"><span class="prof-spinner"></span>Reading β${escHtml(file.name)}ββ¦</div>`; | |
| 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 = `<div class="prof-msg">${escHtml(data.error)}</div>`; return; } | |
| _paperCurrent = data; | |
| const inp = document.getElementById('paper-input'); | |
| if (inp) inp.value = data.title || ''; | |
| renderPaper(data); | |
| } catch (e) { | |
| if (body) body.innerHTML = '<div class="prof-msg">Could not upload the PDF.</div>'; | |
| } | |
| } | |
| 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 '<div class="prof-msg small">None found.</div>'; | |
| return `<div class="paper-list">` + items.map(c => { | |
| const cb = c.cited_by_count ? `<span class="paper-cb" title="times cited">${_kfmt(c.cited_by_count)}Γ</span>` : ''; | |
| return `<button class="paper-item" onclick="openPaper('${(c.id || '').replace(/'/g, "\\'")}')" title="Open this paper"> | |
| <span class="paper-item-main"> | |
| <span class="paper-item-title">${escHtml(c.title || c.id)}</span> | |
| <span class="paper-item-meta">${_paperMeta(c) || ' '}</span> | |
| </span> | |
| ${cb} | |
| </button>`; | |
| }).join('') + `</div>`; | |
| } | |
| 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 = ` | |
| <div class="prof-title-row"><h2 class="prof-title">${escHtml(d.title || d.id)}</h2></div> | |
| ${meta.length ? `<div class="paper-byline">${meta.join(' Β· ')}</div>` : ''} | |
| <div class="paper-stats"> | |
| <span class="paper-stat"><b>${_kfmt(d.cited_by_count || 0)}</b> citations</span> | |
| <span class="paper-stat"><b>${d.n_references || 0}</b> references</span> | |
| <span class="paper-stat"><b>${d.n_citations || 0}</b> citing here</span> | |
| </div> | |
| ${d.abstract ? `<p class="prof-summary paper-abstract">${escHtml(d.abstract)}</p>` : ''} | |
| <div class="prof-actions" style="margin-top:16px"> | |
| <button class="prof-act primary" onclick="paperBuildGraph()">Build citation graph</button> | |
| </div> | |
| ${_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 = '<div class="ovl-msg">Pulling coverageβ¦</div>'; | |
| 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 = '<div class="ovl-msg">Could not reach the news service.</div>'; | |
| } | |
| } | |
| 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 = `<div class="ovl-msg">No ${overlayOn.reddit && !overlayOn.news ? 'discussion' : 'coverage'} found for ${escHtml(query)} yet.</div>`; | |
| return; | |
| } | |
| body.innerHTML = '<div class="ai-block ovl-ai" id="ovl-ai"></div>' | |
| + items.map(a => { | |
| const s = a.sentiment_label || 'neutral'; | |
| const dot = `<span class="ovl-dot ovl-${s}" title="${s} tone"></span>`; | |
| const medium = (a.medium === 'discussion') ? (a.source || 'Discussion') : (a.source || 'news'); | |
| return `<a class="ovl-item" href="${escHtml(a.url || '#')}" target="_blank" rel="noopener"> | |
| ${dot} | |
| <span class="ovl-item-body"> | |
| <span class="ovl-item-title">${escHtml(a.title || '')}</span> | |
| <span class="ovl-item-meta">${escHtml(medium)}${a.published ? ' Β· ' + escHtml(String(a.published).slice(0, 10)) : ''}</span> | |
| </span> | |
| </a>`; | |
| }).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) => `<svg class="ac-icon" viewBox="0 0 16 16" width="14" height="14" ${P}>${inner}</svg>`; | |
| switch (kind) { | |
| case 'company': return wrap('<path d="M3 14V4l5-2v12M8 14V6l5 2v6M2 14h12"/><path d="M5 6h0M5 9h0M10 9h0M10 11h0"/>'); | |
| case 'etf': return wrap('<circle cx="8" cy="8" r="6"/><path d="M8 2v6l4 2"/>'); | |
| case 'sector': return wrap('<rect x="2" y="9" width="3" height="5"/><rect x="6.5" y="5" width="3" height="9"/><rect x="11" y="2" width="3" height="12"/>'); | |
| case 'executive': | |
| case 'person': return wrap('<circle cx="8" cy="5" r="2.5"/><path d="M3.5 14a4.5 4.5 0 0 1 9 0"/>'); | |
| case 'country': return wrap('<circle cx="8" cy="8" r="6"/><path d="M2 8h12M8 2c2 2.5 2 9.5 0 12M8 2c-2 2.5-2 9.5 0 12"/>'); | |
| case 'macro': return wrap('<path d="M2 12l4-4 3 3 5-6"/><path d="M14 5v3h-3"/>'); | |
| case 'paper': return wrap('<path d="M4 2h5l3 3v9H4z"/><path d="M9 2v3h3"/><line x1="6" y1="8" x2="10" y2="8"/><line x1="6" y1="11" x2="9" y2="11"/>'); | |
| default: return wrap('<path d="M4 2h6l3 3v9H4V2z"/><path d="M10 2v3h3"/><line x1="6" y1="7" x2="11" y2="7"/><line x1="6" y1="10" x2="10" y2="10"/>'); | |
| } | |
| } | |
| /** | |
| * 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, '"') | |
| .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 ` | |
| <div class="ac-item${i === acActiveIndex[fieldName] ? ' ac-active' : ''}" | |
| data-idx="${i}" data-title="${escHtml(title)}"> | |
| ${_acKindIcon(kind)} | |
| <span class="ac-text"> | |
| <span class="ac-title">${escHtml(title)}</span> | |
| ${subtitle ? `<span class="ac-sub">${escHtml(subtitle)}</span>` : ''} | |
| </span> | |
| </div>`; | |
| }).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 = '<p style="font-size:13px;color:var(--text2)">Path will appear here as Aurelius explores.</p>'; | |
| 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 = '<span class="arrow">β</span>'; | |
| 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 = ` | |
| <div class="title">${escHtml(n.id)}</div> | |
| <div class="score-row"><span>f(n)</span><span>${n.f}</span></div> | |
| <div class="score-row"><span>g(n)</span><span>${n.g ?? 'β'}</span></div> | |
| <div class="score-row"><span>h(n)</span><span>${n.h ?? 'β'}</span></div> | |
| `; | |
| 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 = ` | |
| <span>${escHtml(p)}</span> | |
| <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-left:4px;opacity:0.7"> | |
| <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path> | |
| <polyline points="15 3 21 3 21 9"></polyline> | |
| <line x1="10" y1="14" x2="21" y2="3"></line> | |
| </svg>`; | |
| 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(); | |