Files
2026-10-30 09:20:09 +01:00

855 lines
25 KiB
HTML

<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Crypto Radar</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@300;400;500;600&display=swap');
:root {
--bg: #0a0e17;
--surface: #111827;
--surface2: #1a2235;
--border: #1e2d45;
--text: #e2e8f0;
--muted: #64748b;
--accent: #3b82f6;
--accent-dim: #1d4ed8;
--green: #10b981;
--green-dim: #064e3b;
--red: #ef4444;
--red-dim: #450a0a;
--yellow: #f59e0b;
--mono: 'IBM Plex Mono', monospace;
--sans: 'IBM Plex Sans', sans-serif;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--sans);
background: var(--bg);
color: var(--text);
min-height: 100vh;
font-size: 14px;
line-height: 1.5;
}
/* ─── Layout ─── */
.shell {
display: grid;
grid-template-rows: 56px 1fr;
grid-template-columns: 280px 1fr;
height: 100vh;
overflow: hidden;
}
/* ─── Header ─── */
header {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.logo {
font-family: var(--mono);
font-size: 15px;
font-weight: 500;
letter-spacing: 0.04em;
color: var(--text);
}
.logo span { color: var(--accent); }
.header-meta {
display: flex;
align-items: center;
gap: 16px;
}
.pulse-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--green);
box-shadow: 0 0 6px var(--green);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.timestamp {
font-family: var(--mono);
font-size: 11px;
color: var(--muted);
}
.refresh-btn {
background: var(--surface2);
border: 1px solid var(--border);
color: var(--text);
padding: 6px 14px;
border-radius: 4px;
font-family: var(--sans);
font-size: 12px;
cursor: pointer;
transition: border-color 0.15s;
}
.refresh-btn:hover { border-color: var(--accent); }
/* ─── Sidebar ─── */
aside {
border-right: 1px solid var(--border);
background: var(--surface);
overflow-y: auto;
padding: 16px 0;
}
.sidebar-label {
font-family: var(--mono);
font-size: 10px;
color: var(--muted);
letter-spacing: 0.08em;
padding: 0 16px 8px;
}
.asset-card {
display: block;
padding: 12px 16px;
cursor: pointer;
border-left: 2px solid transparent;
transition: background 0.1s, border-color 0.1s;
}
.asset-card:hover { background: var(--surface2); }
.asset-card.active { border-left-color: var(--accent); background: var(--surface2); }
.asset-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.asset-name {
font-weight: 500;
font-size: 13px;
}
.asset-price {
font-family: var(--mono);
font-size: 12px;
color: var(--muted);
}
.asset-meta {
display: flex;
align-items: center;
justify-content: space-between;
}
.score-bar-wrap {
flex: 1;
height: 3px;
background: var(--border);
border-radius: 2px;
margin-right: 10px;
}
.score-bar {
height: 100%;
border-radius: 2px;
background: var(--accent);
transition: width 0.4s;
}
.score-val {
font-family: var(--mono);
font-size: 10px;
color: var(--muted);
white-space: nowrap;
}
.rank-badge {
font-family: var(--mono);
font-size: 10px;
color: var(--accent);
margin-right: 6px;
}
/* ─── Main content ─── */
main {
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
/* ─── Summary strip ─── */
.summary-strip {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.stat-box {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 14px 16px;
}
.stat-label {
font-size: 11px;
color: var(--muted);
margin-bottom: 4px;
font-family: var(--mono);
}
.stat-value {
font-size: 22px;
font-weight: 600;
font-family: var(--mono);
line-height: 1.2;
}
.stat-sub {
font-size: 11px;
color: var(--muted);
margin-top: 2px;
}
/* ─── Content grid ─── */
.content-grid {
display: grid;
grid-template-columns: 1fr 320px;
gap: 20px;
}
/* ─── Panel ─── */
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
}
.panel-title {
font-size: 12px;
font-weight: 500;
font-family: var(--mono);
color: var(--text);
letter-spacing: 0.04em;
}
.panel-subtitle {
font-size: 11px;
color: var(--muted);
font-family: var(--mono);
}
.panel-body { padding: 16px; }
/* ─── Chart ─── */
.chart-wrap {
position: relative;
height: 280px;
}
/* ─── Indicators grid ─── */
.indicators-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.indicator-cell {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 4px;
padding: 10px 12px;
}
.ind-label {
font-size: 10px;
color: var(--muted);
font-family: var(--mono);
margin-bottom: 4px;
}
.ind-value {
font-size: 16px;
font-weight: 500;
font-family: var(--mono);
}
.ind-signal {
font-size: 10px;
margin-top: 3px;
font-family: var(--mono);
}
/* ─── Signal pill ─── */
.pill {
display: inline-block;
font-size: 10px;
font-family: var(--mono);
padding: 2px 7px;
border-radius: 3px;
font-weight: 500;
}
.pill-green { background: var(--green-dim); color: var(--green); }
.pill-red { background: var(--red-dim); color: var(--red); }
.pill-yellow { background: #292001; color: var(--yellow); }
.pill-blue { background: #0f1e3d; color: var(--accent); }
/* ─── Sentiment panel ─── */
.sentiment-score-large {
font-family: var(--mono);
font-size: 42px;
font-weight: 500;
line-height: 1;
margin-bottom: 4px;
}
.sentiment-bar-track {
width: 100%;
height: 6px;
background: var(--border);
border-radius: 3px;
margin: 12px 0;
position: relative;
}
.sentiment-bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.5s;
}
/* ─── News list ─── */
.news-list { display: flex; flex-direction: column; gap: 10px; }
.news-item {
padding: 10px 12px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 4px;
border-left: 3px solid var(--border);
}
.news-item.pos { border-left-color: var(--green); }
.news-item.neg { border-left-color: var(--red); }
.news-item.neu { border-left-color: var(--muted); }
.news-title {
font-size: 12px;
line-height: 1.4;
margin-bottom: 6px;
color: var(--text);
}
.news-title a {
color: inherit;
text-decoration: none;
}
.news-title a:hover { color: var(--accent); }
.news-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 10px;
color: var(--muted);
font-family: var(--mono);
}
/* ─── Reason box ─── */
.reason-box {
padding: 10px 12px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 4px;
font-size: 12px;
color: var(--muted);
line-height: 1.5;
}
/* ─── Change colors ─── */
.pos-text { color: var(--green); }
.neg-text { color: var(--red); }
/* ─── Loading / empty ─── */
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
color: var(--muted);
font-family: var(--mono);
font-size: 12px;
}
/* ─── Tradeable badge ─── */
.tradeable-yes { color: var(--green); font-family: var(--mono); font-size: 11px; }
.tradeable-no { color: var(--red); font-family: var(--mono); font-size: 11px; }
/* scrollbar */
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
</style>
</head>
<body>
<div class="shell">
<!-- Header -->
<header>
<div class="logo">crypto<span>.</span>radar</div>
<div class="header-meta">
<div class="pulse-dot"></div>
<span class="timestamp" id="ts">--:--:--</span>
<button class="refresh-btn" onclick="loadRanked()">Ververs</button>
</div>
</header>
<!-- Sidebar -->
<aside>
<div class="sidebar-label">TOP 6 ASSETS</div>
<div id="sidebar-list">
<div class="loading">laden...</div>
</div>
</aside>
<!-- Main -->
<main>
<!-- Summary strip -->
<div class="summary-strip">
<div class="stat-box">
<div class="stat-label">geselecteerd</div>
<div class="stat-value" id="sum-symbol">—</div>
<div class="stat-sub" id="sum-tradeable">—</div>
</div>
<div class="stat-box">
<div class="stat-label">prijs (EUR)</div>
<div class="stat-value" id="sum-price">—</div>
<div class="stat-sub" id="sum-change"></div>
</div>
<div class="stat-box">
<div class="stat-label">opportunity score</div>
<div class="stat-value" id="sum-score">—</div>
<div class="stat-sub" id="sum-trend">—</div>
</div>
<div class="stat-box">
<div class="stat-label">sentiment</div>
<div class="stat-value" id="sum-sentiment">—</div>
<div class="stat-sub" id="sum-sent-label">—</div>
</div>
</div>
<!-- Chart + sentiment -->
<div class="content-grid">
<!-- Left: chart + indicators -->
<div style="display:flex;flex-direction:column;gap:16px;">
<div class="panel">
<div class="panel-header">
<span class="panel-title" id="chart-title">GRAFIEK</span>
<span class="panel-subtitle">5m candles</span>
</div>
<div class="panel-body">
<div class="chart-wrap">
<canvas id="priceChart"></canvas>
</div>
</div>
</div>
<div class="panel">
<div class="panel-header">
<span class="panel-title">INDICATOREN</span>
</div>
<div class="panel-body">
<div class="indicators-grid" id="indicators-grid">
<div class="loading">selecteer een asset</div>
</div>
</div>
</div>
<div class="panel">
<div class="panel-header">
<span class="panel-title">SIGNAAL REDEN</span>
</div>
<div class="panel-body">
<div class="reason-box" id="reason-box">—</div>
</div>
</div>
</div>
<!-- Right: sentiment + news -->
<div style="display:flex;flex-direction:column;gap:16px;">
<div class="panel">
<div class="panel-header">
<span class="panel-title">SENTIMENT</span>
<span class="panel-subtitle" id="news-count">0 artikelen</span>
</div>
<div class="panel-body">
<div class="sentiment-score-large" id="sent-score-large">—</div>
<div style="font-size:11px;color:var(--muted);font-family:var(--mono)" id="sent-label-large">—</div>
<div class="sentiment-bar-track">
<div class="sentiment-bar-fill" id="sent-bar" style="width:50%;background:var(--muted)"></div>
</div>
<div style="display:flex;justify-content:space-between;font-size:10px;color:var(--muted);font-family:var(--mono)">
<span>negatief</span><span>neutraal</span><span>positief</span>
</div>
</div>
</div>
<div class="panel" style="flex:1;">
<div class="panel-header">
<span class="panel-title">NIEUWS</span>
</div>
<div class="panel-body">
<div class="news-list" id="news-list">
<div class="loading">selecteer een asset</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
<script>
// ─── State ───────────────────────────────────────────
let rankedData = [];
let activeSymbol = null;
let priceChart = null;
// ─── API base — zelfde origin via nginx proxy ─────────
const API = '/api';
// ─── Timestamp tick ──────────────────────────────────
setInterval(() => {
document.getElementById('ts').textContent =
new Date().toLocaleTimeString('nl-NL', { hour12: false });
}, 1000);
// ─── Initialiseer chart ───────────────────────────────
function initChart() {
const ctx = document.getElementById('priceChart').getContext('2d');
priceChart = new Chart(ctx, {
type: 'line',
data: {
labels: [],
datasets: [{
label: 'Sluitprijs',
data: [],
borderColor: '#3b82f6',
borderWidth: 1.5,
pointRadius: 0,
fill: false,
tension: 0.1,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
plugins: {
legend: { display: false },
tooltip: {
mode: 'index',
intersect: false,
backgroundColor: '#111827',
borderColor: '#1e2d45',
borderWidth: 1,
titleColor: '#64748b',
bodyColor: '#e2e8f0',
titleFont: { family: 'IBM Plex Mono', size: 10 },
bodyFont: { family: 'IBM Plex Mono', size: 11 },
callbacks: {
label: ctx => `€ ${ctx.parsed.y.toLocaleString('nl-NL', {minimumFractionDigits: 2, maximumFractionDigits: 4})}`
}
}
},
scales: {
x: {
grid: { color: '#1e2d45' },
ticks: { color: '#64748b', font: { family: 'IBM Plex Mono', size: 9 }, maxTicksLimit: 8 },
},
y: {
position: 'right',
grid: { color: '#1e2d45' },
ticks: { color: '#64748b', font: { family: 'IBM Plex Mono', size: 9 },
callback: v => '€' + v.toLocaleString('nl-NL', {minimumFractionDigits: 2, maximumFractionDigits: 4})
},
}
}
}
});
}
// ─── Laad ranked assets ───────────────────────────────
async function loadRanked() {
try {
const res = await fetch(`${API}/assets/ranked`);
const json = await res.json();
rankedData = json.data || [];
renderSidebar(rankedData);
// Selecteer eerste asset automatisch
if (rankedData.length > 0 && !activeSymbol) {
selectAsset(rankedData[0].symbol);
} else if (activeSymbol) {
loadDetail(activeSymbol);
}
} catch(e) {
document.getElementById('sidebar-list').innerHTML =
`<div class="loading">fout: ${e.message}</div>`;
}
}
// ─── Render sidebar ───────────────────────────────────
function renderSidebar(assets) {
const el = document.getElementById('sidebar-list');
if (!assets.length) {
el.innerHTML = '<div class="loading">geen data</div>';
return;
}
el.innerHTML = assets.map(a => {
const changeClass = a.change_24h >= 0 ? 'pos-text' : 'neg-text';
const changeSign = a.change_24h >= 0 ? '+' : '';
const coin = a.symbol.split('/')[0];
return `
<div class="asset-card ${a.symbol === activeSymbol ? 'active' : ''}"
onclick="selectAsset('${a.symbol}')">
<div class="asset-row">
<span class="asset-name">
<span class="rank-badge">#${a.rank}</span>${coin}
</span>
<span class="asset-price ${changeClass}">
${changeSign}${a.change_24h.toFixed(2)}%
</span>
</div>
<div class="asset-meta">
<div class="score-bar-wrap">
<div class="score-bar" style="width:${a.combined_score}%"></div>
</div>
<span class="score-val">${a.combined_score}</span>
</div>
</div>`;
}).join('');
}
// ─── Selecteer asset ──────────────────────────────────
function selectAsset(symbol) {
activeSymbol = symbol;
renderSidebar(rankedData);
loadDetail(symbol);
}
// ─── Laad detail ─────────────────────────────────────
async function loadDetail(symbol) {
const encodedSymbol = symbol.replace('/', '%2F');
// Summary strip alvast bijwerken met ranked data
const rankedAsset = rankedData.find(a => a.symbol === symbol);
if (rankedAsset) updateSummaryFromRanked(rankedAsset);
try {
const res = await fetch(`${API}/assets/${encodedSymbol}`);
const json = await res.json();
const d = json.data;
updateSummaryFull(symbol, d);
updateChart(symbol, d.candles);
updateIndicators(d.indicators, d.ta_score, rankedAsset);
updateSentiment(d.sentiment);
updateNews(d.sentiment.news);
document.getElementById('reason-box').textContent =
rankedAsset ? rankedAsset.reason : '—';
document.getElementById('chart-title').textContent =
`${symbol.replace('/', '/EUR — ')} PRIJS`;
} catch(e) {
console.error('Detail load fout:', e);
}
}
// ─── Summary updates ─────────────────────────────────
function updateSummaryFromRanked(a) {
const coin = a.symbol.split('/')[0];
document.getElementById('sum-symbol').textContent = coin;
document.getElementById('sum-score').textContent = a.combined_score;
document.getElementById('sum-trend').textContent = `trend: ${a.trend_strength}`;
document.getElementById('sum-sentiment').textContent = a.sentiment_score.toFixed(2);
document.getElementById('sum-sent-label').textContent = a.sentiment_label;
const trEl = document.getElementById('sum-tradeable');
trEl.className = a.tradeable ? 'tradeable-yes' : 'tradeable-no';
trEl.textContent = a.tradeable ? '✓ tradeable' : '✗ niet tradeable';
}
function updateSummaryFull(symbol, d) {
const price = d.price || 0;
const change = d.change_24h || 0;
const changeEl = document.getElementById('sum-change');
document.getElementById('sum-price').textContent =
'€ ' + price.toLocaleString('nl-NL', {minimumFractionDigits: 2, maximumFractionDigits: 4});
changeEl.textContent = `${change >= 0 ? '+' : ''}${change.toFixed(2)}% (24u)`;
changeEl.className = change >= 0 ? 'pos-text' : 'neg-text';
}
// ─── Chart update ─────────────────────────────────────
function updateChart(symbol, candles) {
if (!candles || !candles.length) return;
const labels = candles.map(c => {
const d = new Date(c.time);
return d.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' });
});
const prices = candles.map(c => c.close);
priceChart.data.labels = labels;
priceChart.data.datasets[0].data = prices;
priceChart.data.datasets[0].label = symbol;
priceChart.update('none');
}
// ─── Indicators update ────────────────────────────────
function updateIndicators(ind, taScore, ranked) {
const el = document.getElementById('indicators-grid');
if (!ind) { el.innerHTML = '<div class="loading">geen data</div>'; return; }
const rsiClass = ind.rsi < 30 ? 'pill-yellow' :
ind.rsi > 70 ? 'pill-red' : 'pill-green';
const emaClass = ranked && ranked.ema_signal === 'bullish' ? 'pill-green' :
ranked && ranked.ema_signal === 'bearish' ? 'pill-red' : 'pill-yellow';
const vwapClass = ranked && ranked.vwap_signal === 'onder_vwap' ? 'pill-green' : 'pill-blue';
el.innerHTML = `
<div class="indicator-cell">
<div class="ind-label">RSI (14)</div>
<div class="ind-value">${ind.rsi ? ind.rsi.toFixed(1) : '—'}</div>
<div class="ind-signal">
<span class="pill ${rsiClass}">${ranked ? ranked.rsi_signal : '—'}</span>
</div>
</div>
<div class="indicator-cell">
<div class="ind-label">EMA 9 / 21</div>
<div class="ind-value" style="font-size:12px">
${ind.ema9 ? ind.ema9.toFixed(2) : '—'}<br>
<span style="color:var(--muted);font-size:11px">${ind.ema21 ? ind.ema21.toFixed(2) : '—'}</span>
</div>
<div class="ind-signal">
<span class="pill ${emaClass}">${ranked ? ranked.ema_signal : '—'}</span>
</div>
</div>
<div class="indicator-cell">
<div class="ind-label">VWAP</div>
<div class="ind-value" style="font-size:13px">${ind.vwap ? ind.vwap.toFixed(2) : '—'}</div>
<div class="ind-signal">
<span class="pill ${vwapClass}">${ranked ? ranked.vwap_signal : '—'}</span>
</div>
</div>
<div class="indicator-cell">
<div class="ind-label">Bollinger positie</div>
<div class="ind-value">${ind.bb_position ? (ind.bb_position * 100).toFixed(0) + '%' : '—'}</div>
<div class="ind-signal" style="font-size:10px;color:var(--muted)">0% = lower band</div>
</div>
<div class="indicator-cell">
<div class="ind-label">ATR %</div>
<div class="ind-value ${ind.atr_pct > 5 ? 'neg-text' : 'pos-text'}">
${ind.atr_pct ? ind.atr_pct.toFixed(2) + '%' : '—'}
</div>
<div class="ind-signal" style="font-size:10px;color:var(--muted)">
${ind.atr_pct > 5 ? 'te volatiel' : 'acceptabel'}
</div>
</div>
<div class="indicator-cell">
<div class="ind-label">TA score</div>
<div class="ind-value ${taScore > 60 ? 'pos-text' : taScore > 40 ? '' : 'neg-text'}">${taScore || '—'}</div>
<div class="ind-signal" style="font-size:10px;color:var(--muted)">van 100</div>
</div>`;
}
// ─── Sentiment update ─────────────────────────────────
function updateSentiment(s) {
if (!s) return;
const score = s.score;
const scoreEl = document.getElementById('sent-score-large');
scoreEl.textContent = score.toFixed(2);
scoreEl.style.color = score > 0.15 ? 'var(--green)' :
score < -0.15 ? 'var(--red)' : 'var(--yellow)';
document.getElementById('sent-label-large').textContent =
`${s.label} · confidence ${(s.confidence * 100).toFixed(0)}%`;
document.getElementById('news-count').textContent = `${s.news_count} artikelen`;
// Bar: score -1→+1 naar 0→100%
const pct = ((score + 1) / 2) * 100;
const bar = document.getElementById('sent-bar');
bar.style.width = pct + '%';
bar.style.background = score > 0.15 ? 'var(--green)' :
score < -0.15 ? 'var(--red)' : 'var(--yellow)';
}
// ─── News update ──────────────────────────────────────
function updateNews(news) {
const el = document.getElementById('news-list');
if (!news || !news.length) {
el.innerHTML = '<div style="color:var(--muted);font-size:12px;font-family:var(--mono)">geen nieuws beschikbaar</div>';
return;
}
el.innerHTML = news.map(n => {
const cls = n.sentiment === 'positive' ? 'pos' :
n.sentiment === 'negative' ? 'neg' : 'neu';
const pill = n.sentiment === 'positive' ? 'pill-green' :
n.sentiment === 'negative' ? 'pill-red' : 'pill-yellow';
const time = new Date(n.published).toLocaleString('nl-NL', {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
});
return `
<div class="news-item ${cls}">
<div class="news-title">
<a href="${n.url}" target="_blank" rel="noopener">${n.title}</a>
</div>
<div class="news-meta">
<span class="pill ${pill}">${n.sentiment}</span>
<span>${n.source}</span>
<span>${time}</span>
</div>
</div>`;
}).join('');
}
// ─── Auto-refresh elke 60s ────────────────────────────
setInterval(loadRanked, 60_000);
// ─── Init ─────────────────────────────────────────────
initChart();
loadRanked();
</script>
</body>
</html>