Spaces:
Sleeping
Sleeping
| /** | |
| * Alpha Sentiment Engine — Dashboard Logic v10 | |
| * Editorial Cream/Gold Theme — Connected to Live API | |
| */ | |
| const REFRESH_MS = 30000; | |
| const mono = "'DM Mono',monospace"; | |
| const gridColor = 'rgba(28,26,23,0.05)'; | |
| const tickColor = '#8A837A'; | |
| const fScore = (n) => { n = parseFloat(n); return (n > 0 ? '+' : '') + n.toFixed(4); }; | |
| // Chart instances | |
| let trendChart = null; | |
| let moodChart = null; | |
| let volChart = null; | |
| let modalTrendChart = null; | |
| // Chart.js defaults | |
| Chart.defaults.color = '#8A837A'; | |
| Chart.defaults.font.family = "'DM Sans', sans-serif"; | |
| Chart.defaults.borderColor = 'rgba(28,26,23,0.05)'; | |
| // ======================================== | |
| // Boot | |
| // ======================================== | |
| document.addEventListener('DOMContentLoaded', () => { | |
| startClock(); | |
| initGlobe(); | |
| initSpatialFX(); // Sentix 2.0 Spatial Layer | |
| initConstellation(); // Data Background | |
| updateDashboard(); | |
| setInterval(updateDashboard, REFRESH_MS); | |
| initSearch(); | |
| initModal(); | |
| initNavPills(); | |
| }); | |
| // ======================================== | |
| // Nav Pill Switching | |
| // ======================================== | |
| function initNavPills() { | |
| const pills = document.querySelectorAll('.npill'); | |
| const sectionMap = { | |
| 'Overview': null, // scroll to top | |
| 'News Feed': '#news-feed', | |
| 'Markets': '#bullish-list', | |
| }; | |
| pills.forEach(pill => { | |
| pill.addEventListener('click', () => { | |
| // Update active state | |
| pills.forEach(p => p.classList.remove('active')); | |
| pill.classList.add('active'); | |
| // Scroll to section | |
| const target = sectionMap[pill.textContent.trim()]; | |
| if (!target) { | |
| window.scrollTo({ top: 0, behavior: 'smooth' }); | |
| } else { | |
| const el = document.querySelector(target); | |
| if (el) { | |
| const navH = document.getElementById('main-nav').offsetHeight + 40; | |
| const y = el.getBoundingClientRect().top + window.pageYOffset - navH; | |
| window.scrollTo({ top: y, behavior: 'smooth' }); | |
| } | |
| } | |
| }); | |
| }); | |
| } | |
| async function updateDashboard() { | |
| try { | |
| await Promise.all([fetchStats(), fetchOverview(), fetchHeadlines()]); | |
| console.log('✦ Synced:', new Date().toLocaleTimeString()); | |
| } catch (err) { | |
| console.error('Sync error:', err); | |
| } | |
| } | |
| // ======================================== | |
| // Clock | |
| // ======================================== | |
| function startClock() { | |
| const el = document.getElementById('clock'); | |
| const tick = () => { el.textContent = new Date().toTimeString().slice(0, 8); }; | |
| setInterval(tick, 1000); | |
| tick(); | |
| } | |
| // ======================================== | |
| // 1. Stats (top KPIs + hero card) | |
| // ======================================== | |
| async function fetchStats() { | |
| const res = await fetch('/api/stats'); | |
| const d = await res.json(); | |
| // These go into the hero card subtitle | |
| document.getElementById('hero-desc').textContent = | |
| `Tracking ${d.stocks_scored.toLocaleString()} stocks across ${d.total_headlines.toLocaleString()} headlines. FinBERT model accuracy: ${d.ai_accuracy}.`; | |
| // Hero Quick Metrics | |
| const hmScanned = document.getElementById('hm-scanned'); | |
| if (hmScanned) hmScanned.textContent = d.stocks_scored.toLocaleString(); | |
| const hmNews = document.getElementById('hm-news'); | |
| if (hmNews) hmNews.textContent = d.total_headlines.toLocaleString(); | |
| } | |
| // ======================================== | |
| // 2. Overview — Charts, Movers, Hero Score | |
| // ======================================== | |
| async function fetchOverview() { | |
| const res = await fetch('/api/overview'); | |
| const d = await res.json(); | |
| const bullish = d.bullish || []; | |
| const bearish = d.bearish || []; | |
| // Hero score | |
| const total = bullish.length + bearish.length; | |
| const optPct = total > 0 ? Math.round((bullish.length / total) * 100) : 50; | |
| const pesPct = 100 - optPct; | |
| const heroScore = document.getElementById('hero-score'); | |
| heroScore.innerHTML = optPct + '<sup>%</sup>'; | |
| // Quick metrics update | |
| const hmBullish = document.getElementById('hm-bullish'); | |
| if (hmBullish) hmBullish.textContent = bullish.length.toLocaleString(); | |
| const hmBearish = document.getElementById('hm-bearish'); | |
| if (hmBearish) hmBearish.textContent = bearish.length.toLocaleString(); | |
| const verdict = document.getElementById('hero-verdict'); | |
| if (optPct > 55) { | |
| verdict.textContent = '↑ Bullish Bias Detected'; | |
| verdict.className = 'sh-verdict bullish'; | |
| } else if (optPct < 45) { | |
| verdict.textContent = '↓ Bearish Bias Detected'; | |
| verdict.className = 'sh-verdict bearish'; | |
| } else { | |
| verdict.textContent = '→ Neutral Market'; | |
| verdict.className = 'sh-verdict neutral'; | |
| } | |
| // Signal breakdown | |
| renderSignals(bullish, bearish); | |
| // Trend chart (top 15 bullish) | |
| renderTrendChart(bullish); | |
| // Movers (buy/sell lists) | |
| renderMovers('bullish-list', bullish, true); | |
| renderMovers('bearish-list', bearish, false); | |
| // Watchlist (top 5 combined) | |
| renderWatchlist(bullish, bearish); | |
| // Volume chart | |
| renderVolChart(bullish, bearish); | |
| // Market mood doughnut | |
| renderMood(bullish, bearish); | |
| // Right panel stats | |
| updateRightPanel(bullish, bearish, optPct, pesPct); | |
| // Ticker tape | |
| renderTicker(bullish, bearish); | |
| // Badges | |
| document.getElementById('badge-sources').textContent = total + ' scored'; | |
| document.getElementById('badge-movers').textContent = Math.min(bullish.length, 5) + ' tracked'; | |
| } | |
| // ---- Signal Breakdown Bars ---- // | |
| function renderSignals(bulls, bears) { | |
| const total = bulls.length + bears.length || 1; | |
| const bullPct = Math.round((bulls.length / total) * 100); | |
| const bearPct = 100 - bullPct; | |
| // Calculate avg scores | |
| const avgBull = bulls.length > 0 ? bulls.reduce((s, b) => s + b.score, 0) / bulls.length : 0; | |
| const avgBear = bears.length > 0 ? Math.abs(bears.reduce((s, b) => s + b.score, 0) / bears.length) : 0; | |
| const directCount = [...bulls, ...bears].filter(i => i.score_type === 'DIRECT').length; | |
| const hybridCount = [...bulls, ...bears].filter(i => i.score_type === 'HYBRID').length; | |
| const sectorCount = [...bulls, ...bears].filter(i => i.score_type === 'SECTOR').length; | |
| const directPct = total > 0 ? Math.round((directCount / total) * 100) : 0; | |
| const hybridPct = total > 0 ? Math.round((hybridCount / total) * 100) : 0; | |
| const sigs = [ | |
| { n: 'Bullish Ratio', v: bullPct, color: 'linear-gradient(90deg,#2D6A4F,#40916C)' }, | |
| { n: 'Bearish Ratio', v: bearPct, color: 'linear-gradient(90deg,#C0392B,#E07070)' }, | |
| { n: 'Direct Scores', v: directPct, color: 'linear-gradient(90deg,#B8924A,#D4A85A)' }, | |
| { n: 'Hybrid Scores', v: hybridPct, color: 'linear-gradient(90deg,#8A837A,#4A4540)' }, | |
| ]; | |
| document.getElementById('signals').innerHTML = sigs.map(s => ` | |
| <div class="sig"> | |
| <div class="sig-row"><span class="sig-name">${s.n}</span><span class="sig-num">${s.v}%</span></div> | |
| <div class="sig-track"><div class="sig-fill" style="width:${s.v}%;background:${s.color};"></div></div> | |
| </div>`).join(''); | |
| } | |
| // ---- Sentiment Trend Line Chart ---- // | |
| function renderTrendChart(bullish) { | |
| const ctx = document.getElementById('trendChart'); | |
| const data = [...bullish.slice(0, 15)].sort((a, b) => b.score - a.score); | |
| const labels = data.map(d => d.ticker); | |
| const scores = data.map(d => d.score); | |
| if (trendChart) { | |
| trendChart.data.labels = labels; | |
| trendChart.data.datasets[0].data = scores; | |
| trendChart.update(); | |
| return; | |
| } | |
| trendChart = new Chart(ctx, { | |
| type: 'line', | |
| data: { | |
| labels, | |
| datasets: [{ | |
| data: scores, | |
| borderColor: '#B8924A', | |
| borderWidth: 2, | |
| pointBackgroundColor: '#B8924A', | |
| pointRadius: 3, | |
| fill: true, | |
| backgroundColor: (c) => { | |
| const g = c.chart.ctx.createLinearGradient(0, 0, 0, 150); | |
| g.addColorStop(0, 'rgba(184,146,74,0.18)'); | |
| g.addColorStop(1, 'rgba(184,146,74,0)'); | |
| return g; | |
| }, | |
| tension: .45 | |
| }] | |
| }, | |
| options: { | |
| responsive: true, maintainAspectRatio: false, | |
| plugins: { | |
| legend: { display: false }, | |
| tooltip: { | |
| backgroundColor: '#1C1A17', titleColor: '#F5F0E8', bodyColor: '#B8924A', | |
| titleFont: { family: mono, size: 12 }, bodyFont: { family: mono, size: 12 }, | |
| padding: 10, cornerRadius: 6, | |
| callbacks: { label: (c) => 'Score: ' + fScore(c.raw) } | |
| } | |
| }, | |
| scales: { | |
| x: { grid: { color: gridColor }, ticks: { color: tickColor, font: { family: mono, size: 10 }, maxRotation: 45 } }, | |
| y: { grid: { color: gridColor }, ticks: { color: tickColor, font: { family: mono, size: 10 }, callback: v => v.toFixed(1) }, min: 0, max: 1 } | |
| } | |
| } | |
| }); | |
| } | |
| // ---- Buy / Sell Mover Lists ---- // | |
| function renderMovers(elId, items, isBull) { | |
| const el = document.getElementById(elId); | |
| if (!items || items.length === 0) { | |
| el.innerHTML = '<div class="loading-text">Awaiting data...</div>'; | |
| return; | |
| } | |
| el.innerHTML = items.slice(0, 5).map(item => { | |
| const scoreClass = isBull ? 'up' : 'dn'; | |
| const sentClass = isBull ? 's-b' : 's-s'; | |
| const sentLabel = isBull ? 'BULL' : 'BEAR'; | |
| const priceHtml = item.price_change != null ? | |
| `<div class="wr-chg ${item.price_change >= 0 ? 'u' : 'd'}">${item.price_change >= 0 ? '▲' : '▼'} ${Math.abs(item.price_change).toFixed(2)}%</div>` : ''; | |
| return ` | |
| <div class="wrow stagger-in" onclick="openCompanyModal('${item.ticker}')"> | |
| <div><div class="wr-sym">${item.ticker}</div><div class="wr-name">${item.name}</div></div> | |
| <div><div class="wr-score ${scoreClass}">${fScore(item.score)}</div>${priceHtml}</div> | |
| <div class="wr-sent ${sentClass}">${sentLabel}</div> | |
| </div>`; | |
| }).join(''); | |
| // Apply staggered delays | |
| el.querySelectorAll('.stagger-in').forEach((node, i) => { | |
| node.style.animationDelay = (i * 0.05) + 's'; | |
| }); | |
| } | |
| // ---- Watchlist (top 5 combined) ---- // | |
| function renderWatchlist(bulls, bears) { | |
| const combined = [...bulls.slice(0, 3), ...bears.slice(0, 2)]; | |
| const el = document.getElementById('watchlist'); | |
| el.innerHTML = combined.map(item => { | |
| const isBull = item.score > 0; | |
| const sentClass = isBull ? 's-b' : (item.score < -0.1 ? 's-s' : 's-n'); | |
| const sentLabel = isBull ? 'BULL' : (item.score < -0.1 ? 'BEAR' : 'NEUT'); | |
| return ` | |
| <div class="wrow" onclick="openCompanyModal('${item.ticker}')"> | |
| <div><div class="wr-sym">${item.ticker}</div><div class="wr-name">${item.name}</div></div> | |
| <div class="wr-sent ${sentClass}">${sentLabel}</div> | |
| </div>`; | |
| }).join(''); | |
| } | |
| // ---- Volume Bar Chart ---- // | |
| function renderVolChart(bulls, bears) { | |
| const ctx = document.getElementById('volChart'); | |
| // Create pseudo-time distribution from score ranges | |
| const ranges = ['0.0-0.2', '0.2-0.4', '0.4-0.6', '0.6-0.8', '0.8-1.0']; | |
| const bullDist = ranges.map((_, i) => bulls.filter(b => b.score >= i * 0.2 && b.score < (i + 1) * 0.2).length); | |
| const bearDist = ranges.map((_, i) => bears.filter(b => Math.abs(b.score) >= i * 0.2 && Math.abs(b.score) < (i + 1) * 0.2).length); | |
| if (volChart) { | |
| volChart.data.datasets[0].data = bullDist; | |
| volChart.data.datasets[1].data = bearDist; | |
| volChart.update(); | |
| return; | |
| } | |
| volChart = new Chart(ctx, { | |
| type: 'bar', | |
| data: { | |
| labels: ranges, | |
| datasets: [ | |
| { data: bullDist, backgroundColor: 'rgba(45,106,79,0.65)', borderRadius: 4, borderSkipped: false }, | |
| { data: bearDist, backgroundColor: 'rgba(192,57,43,0.55)', borderRadius: 4, borderSkipped: false }, | |
| ] | |
| }, | |
| options: { | |
| responsive: true, maintainAspectRatio: false, | |
| plugins: { legend: { display: false } }, | |
| scales: { | |
| x: { stacked: true, grid: { display: false }, ticks: { color: tickColor, font: { family: mono, size: 10 } } }, | |
| y: { stacked: true, grid: { color: gridColor }, ticks: { color: tickColor, font: { family: mono, size: 10 }, maxTicksLimit: 4 } } | |
| } | |
| } | |
| }); | |
| } | |
| // ---- Market Mood Doughnut ---- // | |
| function renderMood(bulls, bears) { | |
| const ctx = document.getElementById('moodChart'); | |
| const neutral = Math.max(5, 50 - bulls.length - bears.length); | |
| if (moodChart) { | |
| moodChart.data.datasets[0].data = [bulls.length, neutral, bears.length]; | |
| moodChart.update(); | |
| return; | |
| } | |
| moodChart = new Chart(ctx, { | |
| type: 'doughnut', | |
| data: { | |
| labels: ['Bullish', 'Neutral', 'Bearish'], | |
| datasets: [{ | |
| data: [bulls.length, neutral, bears.length], | |
| backgroundColor: ['#2D6A4F', '#D8D1C4', '#C0392B'], | |
| borderWidth: 4, | |
| borderColor: '#F5F0E8', | |
| hoverOffset: 4 | |
| }] | |
| }, | |
| options: { | |
| responsive: true, maintainAspectRatio: false, | |
| cutout: '68%', | |
| plugins: { | |
| legend: { display: false }, | |
| tooltip: { backgroundColor: '#1C1A17', titleColor: '#F5F0E8', bodyColor: '#D4A85A', padding: 8, cornerRadius: 6 } | |
| } | |
| } | |
| }); | |
| } | |
| // ---- Right Panel Stats ---- // | |
| function updateRightPanel(bulls, bears, optPct, pesPct) { | |
| const total = bulls.length + bears.length; | |
| document.getElementById('rp-total').textContent = total; | |
| document.getElementById('rp-bulls').textContent = bulls.length; | |
| document.getElementById('rp-bears').textContent = bears.length; | |
| document.getElementById('rp-opt-pct').textContent = optPct + '%'; | |
| document.getElementById('rp-pes-pct').textContent = pesPct + '%'; | |
| document.getElementById('rp-opt-bar').style.width = optPct + '%'; | |
| document.getElementById('rp-pes-bar').style.width = pesPct + '%'; | |
| } | |
| // ---- Ticker Tape ---- // | |
| function renderTicker(bulls, bears) { | |
| const el = document.getElementById('ticker-track'); | |
| const items = [...bulls.slice(0, 6), ...bears.slice(0, 4)]; | |
| const doubled = [...items, ...items]; // duplicate for seamless loop | |
| el.innerHTML = doubled.map(t => { | |
| const isUp = t.score > 0; | |
| const cls = isUp ? 'ti-up' : 'ti-dn'; | |
| const sign = isUp ? '+' : ''; | |
| const priceStr = t.price_change != null ? `${t.price_change >= 0 ? '+' : ''}${t.price_change.toFixed(2)}%` : sign + t.score.toFixed(2); | |
| return `<span class="ti"><span class="ti-sym">${t.ticker}</span>${t.name ? t.name.slice(0, 18) : ''}<span class="${cls}">${priceStr}</span></span>`; | |
| }).join(''); | |
| } | |
| // ======================================== | |
| // 3. Headlines (News Feed) | |
| // ======================================== | |
| async function fetchHeadlines() { | |
| const res = await fetch('/api/headlines'); | |
| const headlines = await res.json(); | |
| const el = document.getElementById('news-feed'); | |
| document.getElementById('badge-news').textContent = headlines.length + ' signals'; | |
| el.innerHTML = headlines.slice(0, 12).map(n => { | |
| const isBull = n.score > 0.2; | |
| const isBear = n.score < -0.2; | |
| const sentClass = isBull ? 'bull' : (isBear ? 'bear' : ''); | |
| const sentLabel = isBull ? 'Bullish' : (isBear ? 'Bearish' : 'Neutral'); | |
| const source = (n.source || '').replace('📰', '').replace('💬', '').trim().toUpperCase() || 'NEWS'; | |
| return ` | |
| <div class="ni stagger-in" onclick="openCompanyModal('${n.ticker}')"> | |
| <div class="ni-meta"> | |
| <span class="ni-src">${source}</span> | |
| <span class="ni-time">${n.time_ago || ''}</span> | |
| </div> | |
| <div class="ni-title">${n.headline}</div> | |
| <div class="ni-tags"> | |
| <span class="tag">${n.ticker}</span> | |
| <span class="tag ${sentClass}">${sentLabel}</span> | |
| </div> | |
| </div>`; | |
| }).join(''); | |
| // Apply staggered delays | |
| el.querySelectorAll('.stagger-in').forEach((node, i) => { | |
| node.style.animationDelay = (i * 0.04) + 's'; | |
| }); | |
| } | |
| // ======================================== | |
| // 4. Search | |
| // ======================================== | |
| function initSearch() { | |
| const input = document.getElementById('company-search'); | |
| const results = document.getElementById('search-results'); | |
| let timeout; | |
| input.addEventListener('input', (e) => { | |
| clearTimeout(timeout); | |
| const q = e.target.value.trim(); | |
| if (q.length < 2) { results.style.display = 'none'; return; } | |
| timeout = setTimeout(async () => { | |
| try { | |
| const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`); | |
| const data = await res.json(); | |
| results.innerHTML = ''; | |
| if (data.results && data.results.length > 0) { | |
| data.results.forEach(item => { | |
| const cls = item.score > 0.1 ? 'up' : (item.score < -0.1 ? 'dn' : ''); | |
| results.innerHTML += ` | |
| <div class="search-result-item" onclick="openCompanyModal('${item.ticker}')"> | |
| <div><div class="wr-sym">${item.ticker}</div><div class="wr-name">${item.name}</div></div> | |
| <div class="wr-score ${cls}">${fScore(item.score)}</div> | |
| </div>`; | |
| }); | |
| } else { | |
| results.innerHTML = '<div class="search-result-item"><span style="color:#8A837A">No results found</span></div>'; | |
| } | |
| const rect = input.getBoundingClientRect(); | |
| results.style.top = (rect.bottom + 8) + 'px'; | |
| results.style.left = rect.left + 'px'; | |
| results.style.width = rect.width + 'px'; | |
| results.style.display = 'block'; | |
| } catch (err) { console.error('Search failed:', err); } | |
| }, 400); | |
| }); | |
| document.addEventListener('click', (e) => { | |
| if (!input.contains(e.target) && !results.contains(e.target)) results.style.display = 'none'; | |
| }); | |
| } | |
| // ======================================== | |
| // 5. Company Modal | |
| // ======================================== | |
| function initModal() { | |
| const modal = document.getElementById('company-modal'); | |
| const closeBtn = document.getElementById('modal-close'); | |
| closeBtn.addEventListener('click', () => modal.style.display = 'none'); | |
| modal.addEventListener('click', (e) => { if (e.target === modal) modal.style.display = 'none'; }); | |
| } | |
| async function openCompanyModal(ticker) { | |
| document.getElementById('search-results').style.display = 'none'; | |
| try { | |
| const res = await fetch(`/api/company/${encodeURIComponent(ticker)}`); | |
| const d = await res.json(); | |
| document.getElementById('modal-ticker').textContent = d.ticker; | |
| document.getElementById('modal-name').textContent = d.name; | |
| document.getElementById('modal-sector').textContent = d.sector; | |
| const scoreEl = document.getElementById('modal-score'); | |
| scoreEl.textContent = fScore(d.current_score); | |
| scoreEl.className = 'modal-score-val ' + (d.current_score > 0.1 ? 'up' : (d.current_score < -0.1 ? 'dn' : 'nt')); | |
| const confEl = document.getElementById('modal-confidence'); | |
| confEl.textContent = d.confidence || 'LOW'; | |
| confEl.className = 'tag ' + (d.confidence === 'HIGH' ? 'bull' : (d.confidence === 'MEDIUM' ? 'unusual' : '')); | |
| const priceEl = document.getElementById('modal-price'); | |
| if (d.price_change != null) { | |
| const isUp = d.price_change >= 0; | |
| priceEl.textContent = `${isUp ? '▲' : '▼'} ${Math.abs(d.price_change).toFixed(2)}%`; | |
| priceEl.className = 'modal-price ' + (isUp ? 'price-up' : 'price-down'); | |
| priceEl.style.display = 'inline'; | |
| } else { | |
| priceEl.style.display = 'none'; | |
| } | |
| // Trend chart | |
| renderModalChart(d.trend); | |
| // News list | |
| const newsList = document.getElementById('modal-news-list'); | |
| if (d.headlines && d.headlines.length > 0) { | |
| newsList.innerHTML = d.headlines.map(n => { | |
| const isBull = n.score > 0.2; | |
| const isBear = n.score < -0.2; | |
| const sentClass = isBull ? 'bull' : (isBear ? 'bear' : ''); | |
| const sentLabel = isBull ? 'Bullish' : (isBear ? 'Bearish' : 'Neutral'); | |
| const source = (n.source || '').replace('📰', '').replace('💬', '').trim().toUpperCase() || 'NEWS'; | |
| return ` | |
| <div class="ni"> | |
| <div class="ni-meta"><span class="ni-src">${source}</span><span class="ni-time">${n.time_ago || ''}</span></div> | |
| <div class="ni-title">${n.headline}</div> | |
| <div class="ni-tags"><span class="tag ${sentClass}">${sentLabel}</span></div> | |
| </div>`; | |
| }).join(''); | |
| } else { | |
| newsList.innerHTML = '<div class="loading-text">No verified news found.</div>'; | |
| } | |
| document.getElementById('company-modal').style.display = 'flex'; | |
| } catch (err) { | |
| console.error('Modal load failed:', err); | |
| } | |
| } | |
| function renderModalChart(trendData) { | |
| const ctx = document.getElementById('modalTrendChart'); | |
| const labels = trendData.map(d => d.time_label); | |
| const scores = trendData.map(d => d.score); | |
| const isPos = scores.length > 0 && scores[scores.length - 1] >= 0; | |
| const lineColor = isPos ? '#2D6A4F' : '#C0392B'; | |
| const bgColor = isPos ? 'rgba(45,106,79,0.15)' : 'rgba(192,57,43,0.12)'; | |
| if (modalTrendChart) { | |
| modalTrendChart.data.labels = labels; | |
| modalTrendChart.data.datasets[0].data = scores; | |
| modalTrendChart.data.datasets[0].borderColor = lineColor; | |
| modalTrendChart.data.datasets[0].backgroundColor = bgColor; | |
| modalTrendChart.update(); | |
| return; | |
| } | |
| modalTrendChart = new Chart(ctx, { | |
| type: 'line', | |
| data: { | |
| labels, | |
| datasets: [{ | |
| data: scores, | |
| borderColor: lineColor, | |
| backgroundColor: bgColor, | |
| borderWidth: 2, | |
| fill: true, | |
| tension: .35, | |
| pointRadius: 3, | |
| pointBackgroundColor: '#F5F0E8' | |
| }] | |
| }, | |
| options: { | |
| responsive: true, maintainAspectRatio: false, | |
| plugins: { | |
| legend: { display: false }, | |
| tooltip: { | |
| backgroundColor: '#1C1A17', titleColor: '#F5F0E8', bodyColor: '#D4A85A', | |
| padding: 8, cornerRadius: 6, | |
| callbacks: { label: (c) => 'Score: ' + fScore(c.raw) } | |
| } | |
| }, | |
| scales: { | |
| y: { min: -1, max: 1, grid: { color: gridColor }, ticks: { color: tickColor, font: { family: mono, size: 9 } } }, | |
| x: { grid: { display: false }, ticks: { color: tickColor, font: { family: mono, size: 9 } } } | |
| } | |
| } | |
| }); | |
| } | |
| // ======================================== | |
| // 6. 3D Globe (Canvas Animation) | |
| // ======================================== | |
| function initGlobe() { | |
| const canvas = document.getElementById('globe-canvas'); | |
| if (!canvas) return; | |
| const ctx = canvas.getContext('2d'); | |
| const W = 180, H = 180, R = 82, cx = 90, cy = 90; | |
| let t = 0; | |
| const dots = []; | |
| for (let i = 0; i < 110; i++) { | |
| dots.push({ lat: (Math.random() - .5) * Math.PI, lon: Math.random() * Math.PI * 2, size: Math.random() * 1.4 + 0.4 }); | |
| } | |
| const arcs = []; | |
| for (let i = 0; i < 10; i++) { | |
| arcs.push({ lat: (Math.random() - .5) * Math.PI, lon: Math.random() * Math.PI * 2 }); | |
| } | |
| function project(lat, lon, rot) { | |
| const x = Math.cos(lat) * Math.sin(lon + rot); | |
| const y = Math.sin(lat); | |
| const z = Math.cos(lat) * Math.cos(lon + rot); | |
| return { x: cx + x * R, y: cy - y * R, z, visible: z > -0.05 }; | |
| } | |
| function draw() { | |
| ctx.clearRect(0, 0, W, H); | |
| // Globe fill | |
| const grd = ctx.createRadialGradient(cx - 20, cy - 20, 0, cx, cy, R); | |
| grd.addColorStop(0, 'rgba(226,219,208,0.55)'); | |
| grd.addColorStop(0.7, 'rgba(213,200,180,0.3)'); | |
| grd.addColorStop(1, 'rgba(245,240,232,0)'); | |
| ctx.beginPath(); ctx.arc(cx, cy, R, 0, Math.PI * 2); | |
| ctx.fillStyle = grd; ctx.fill(); | |
| // Latitude lines | |
| for (let la = -60; la <= 60; la += 30) { | |
| const latR = la * Math.PI / 180; | |
| ctx.beginPath(); let first = true; | |
| for (let lo = 0; lo <= 360; lo += 5) { | |
| const p = project(latR, lo * Math.PI / 180, t); | |
| if (p.visible) { if (first) { ctx.moveTo(p.x, p.y); first = false; } else ctx.lineTo(p.x, p.y); } | |
| else first = true; | |
| } | |
| ctx.strokeStyle = `rgba(184,146,74,${0.08 + Math.max(0, Math.cos(latR)) * 0.08})`; | |
| ctx.lineWidth = 0.6; ctx.stroke(); | |
| } | |
| // Longitude lines | |
| for (let lo = 0; lo < 360; lo += 30) { | |
| const lonR = lo * Math.PI / 180; | |
| ctx.beginPath(); let first = true; | |
| for (let la = -90; la <= 90; la += 5) { | |
| const p = project(la * Math.PI / 180, lonR, t); | |
| if (p.visible) { if (first) { ctx.moveTo(p.x, p.y); first = false; } else ctx.lineTo(p.x, p.y); } | |
| else first = true; | |
| } | |
| ctx.strokeStyle = 'rgba(184,146,74,0.06)'; | |
| ctx.lineWidth = 0.5; ctx.stroke(); | |
| } | |
| // Data dots | |
| dots.forEach(d => { | |
| const p = project(d.lat, d.lon, t); | |
| if (!p.visible) return; | |
| const alpha = 0.25 + p.z * 0.55; | |
| ctx.beginPath(); ctx.arc(p.x, p.y, d.size * Math.max(0.3, p.z), 0, Math.PI * 2); | |
| ctx.fillStyle = `rgba(184,146,74,${alpha})`; ctx.fill(); | |
| }); | |
| // Connection arcs | |
| for (let i = 0; i < arcs.length - 1; i += 2) { | |
| const a = project(arcs[i].lat, arcs[i].lon, t); | |
| const b = project(arcs[i + 1].lat, arcs[i + 1].lon, t); | |
| if (a.visible && b.visible && a.z > 0.25 && b.z > 0.25) { | |
| ctx.beginPath(); | |
| ctx.moveTo(a.x, a.y); | |
| ctx.quadraticCurveTo((a.x + b.x) / 2, (a.y + b.y) / 2 - 18, b.x, b.y); | |
| ctx.strokeStyle = 'rgba(45,106,79,0.45)'; ctx.lineWidth = 1; ctx.stroke(); | |
| ctx.beginPath(); ctx.arc(a.x, a.y, 2.5, 0, Math.PI * 2); ctx.fillStyle = 'rgba(64,145,108,0.8)'; ctx.fill(); | |
| ctx.beginPath(); ctx.arc(b.x, b.y, 2.5, 0, Math.PI * 2); ctx.fillStyle = 'rgba(64,145,108,0.8)'; ctx.fill(); | |
| } | |
| } | |
| // Rim highlight | |
| const rimGrd = ctx.createRadialGradient(cx - 28, cy - 28, R * 0.55, cx, cy, R); | |
| rimGrd.addColorStop(0, 'rgba(245,240,232,0)'); | |
| rimGrd.addColorStop(0.82, 'rgba(245,240,232,0)'); | |
| rimGrd.addColorStop(1, 'rgba(245,240,232,0.35)'); | |
| ctx.beginPath(); ctx.arc(cx, cy, R, 0, Math.PI * 2); | |
| ctx.fillStyle = rimGrd; ctx.fill(); | |
| t += 0.004; | |
| requestAnimationFrame(draw); | |
| } | |
| draw(); | |
| } | |
| // ======================================== | |
| // Sentix 2.0 — Spatial UI (3D Tilt) | |
| // ======================================== | |
| function initSpatialFX() { | |
| const cards = document.querySelectorAll('.glass, .score-hero'); | |
| const handleMove = (e) => { | |
| // Background Parallax (Subtle window effect) | |
| const px = (e.clientX / window.innerWidth - 0.5) * -40; | |
| const py = (e.clientY / window.innerHeight - 0.5) * -40; | |
| const canvas = document.getElementById('particle-canvas'); | |
| if (canvas) canvas.style.transform = `translate(${px}px, ${py}px) scale(1.1)`; | |
| const card = e.currentTarget; | |
| const rect = card.getBoundingClientRect(); | |
| const x = e.clientX - rect.left; | |
| const y = e.clientY - rect.top; | |
| const centerX = rect.width / 2; | |
| const centerY = rect.height / 2; | |
| // Reduce tilt to a subtle 1.5 degrees for premium feel | |
| const rotateX = ((y - centerY) / centerY) * -1.5; | |
| const rotateY = ((x - centerX) / centerX) * 1.5; | |
| card.style.transform = `perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) translateZ(8px)`; | |
| card.classList.add('tilt-active'); | |
| // Dynamic Refractive Sheen vars | |
| const mx = (x / rect.width) * 100; | |
| const my = (y / rect.height) * 100; | |
| card.style.setProperty('--mx', `${mx}%`); | |
| card.style.setProperty('--my', `${my}%`); | |
| }; | |
| const handleLeave = (e) => { | |
| const card = e.currentTarget; | |
| card.style.transform = 'perspective(1000px) rotateX(0) rotateY(0) translateZ(0)'; | |
| card.classList.remove('tilt-active'); | |
| }; | |
| cards.forEach(card => { | |
| card.addEventListener('mousemove', handleMove); | |
| card.addEventListener('mouseleave', handleLeave); | |
| }); | |
| } | |
| // ======================================== | |
| // Sentix 2.0 — Data Constellation Background | |
| // ======================================== | |
| function initConstellation() { | |
| const container = document.getElementById('particle-canvas'); | |
| if(!container) return; | |
| const canvas = document.createElement('canvas'); | |
| container.appendChild(canvas); | |
| const ctx = canvas.getContext('2d'); | |
| let w, h, particles = []; | |
| const resize = () => { | |
| w = canvas.width = window.innerWidth; | |
| h = canvas.height = window.innerHeight; | |
| }; | |
| window.addEventListener('resize', resize); | |
| resize(); | |
| class Particle { | |
| constructor() { | |
| this.reset(); | |
| } | |
| reset() { | |
| this.x = Math.random() * w; | |
| this.y = Math.random() * h; | |
| this.z = Math.random() * 1.5; | |
| this.vx = (Math.random() - 0.5) * 0.2; | |
| this.vy = (Math.random() - 0.5) * 0.2; | |
| this.size = Math.random() * 1.5 + 0.5; | |
| this.alpha = 0; | |
| this.targetAlpha = Math.random() * 0.3 + 0.1; | |
| } | |
| update() { | |
| this.x += this.vx; | |
| this.y += this.vy; | |
| if(this.alpha < this.targetAlpha) this.alpha += 0.005; | |
| if(this.x < 0 || this.x > w || this.y < 0 || this.y > h) this.reset(); | |
| } | |
| draw() { | |
| ctx.beginPath(); | |
| ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2); | |
| ctx.fillStyle = `rgba(184, 146, 74, ${this.alpha * (1 - this.y/h)})`; | |
| ctx.fill(); | |
| } | |
| } | |
| for(let i=0; i<80; i++) particles.push(new Particle()); | |
| function animate() { | |
| ctx.clearRect(0,0,w,h); | |
| particles.forEach(p => { | |
| p.update(); | |
| p.draw(); | |
| }); | |
| // Draw subtle lines between near particles | |
| ctx.lineWidth = 0.5; | |
| for(let i=0; i<particles.length; i++) { | |
| for(let j=i+1; j<particles.length; j++) { | |
| const dx = particles[i].x - particles[j].x; | |
| const dy = particles[i].y - particles[j].y; | |
| const dist = Math.sqrt(dx*dx + dy*dy); | |
| if(dist < 120) { | |
| ctx.beginPath(); | |
| ctx.moveTo(particles[i].x, particles[i].y); | |
| ctx.lineTo(particles[j].x, particles[j].y); | |
| ctx.strokeStyle = `rgba(184, 146, 74, ${ (1 - dist/120) * 0.08 })`; | |
| ctx.stroke(); | |
| } | |
| } | |
| } | |
| requestAnimationFrame(animate); | |
| } | |
| animate(); | |
| } | |
| // Make openCompanyModal globally accessible | |
| window.openCompanyModal = openCompanyModal; | |