malikparth05's picture
V5 Deploy: Three-Phase Hybrid Scraper + 24/7 Live Operation
49b3fff
/**
* 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;