Files
Melchior Reimers ecfdea5f6c
All checks were successful
Deployment / deploy-docker (push) Successful in 16s
feat: advanced analytics with deep linking, URL params, and progressive UI
2026-01-23 19:33:39 +01:00

625 lines
28 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Trading Intelligence Hub</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Outfit', sans-serif;
background-color: #0b1120;
color: #e2e8f0;
}
.glass {
background: rgba(30, 41, 59, 0.4);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
}
.active-nav {
background: rgba(56, 189, 248, 0.1);
color: #38bdf8;
border-right: 3px solid #38bdf8;
}
canvas {
max-width: 100%;
}
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-thumb {
background: #1e293b;
border-radius: 10px;
}
.config-sidebar {
width: 340px;
border-right: 1px solid rgba(255, 255, 255, 0.05);
flex-shrink: 0;
}
.btn-primary {
background: #38bdf8;
color: #0b1120;
padding: 10px 20px;
border-radius: 10px;
font-weight: 700;
transition: all 0.2s;
box-shadow: 0 4px 14px 0 rgba(56, 189, 248, 0.3);
}
.btn-primary:hover {
background: #7dd3fc;
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(56, 189, 248, 0.4);
}
.suggestion-box {
position: absolute;
z-index: 50;
width: 100%;
top: 100%;
left: 0;
background: #1e293b;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
margin-top: 4px;
max-height: 200px;
overflow-y: auto;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
}
.suggestion-item {
padding: 10px 16px;
cursor: pointer;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.suggestion-item:hover {
background: rgba(56, 189, 248, 0.1);
color: #38bdf8;
}
.field-label {
display: block;
font-size: 0.75rem;
font-weight: 700;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.75rem;
}
.input-glass {
width: 100%;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 10px;
border-radius: 10px;
outline: none;
transition: all 0.2s;
}
.input-glass:focus {
border-color: #38bdf8;
background: rgba(56, 189, 248, 0.05);
}
</style>
</head>
<body class="flex min-h-screen overflow-hidden">
<!-- Sidebar -->
<aside class="w-20 lg:w-64 glass m-4 mr-0 flex flex-col hidden sm:flex">
<div class="p-6 hidden lg:block">
<h1 class="text-2xl font-bold bg-gradient-to-r from-sky-400 to-blue-500 bg-clip-text text-transparent">
Antigravity</h1>
</div>
<nav class="flex-1 px-4 space-y-2 mt-4" id="sidebar">
<a href="#" onclick="showView('dashboard')" id="nav-dashboard"
class="flex items-center p-3 rounded-xl transition active-nav"><span class="mr-3">📊</span> <span
class="hidden lg:inline">Dashboard</span></a>
<a href="#" onclick="showView('analytics')" id="nav-analytics"
class="flex items-center p-3 rounded-xl transition"><span class="mr-3">📈</span> <span
class="hidden lg:inline">Report Builder</span></a>
<a href="#" onclick="showView('metadata')" id="nav-metadata"
class="flex items-center p-3 rounded-xl transition"><span class="mr-3">🏢</span> <span
class="hidden lg:inline">Companies</span></a>
</nav>
<div class="p-6 mt-auto hidden lg:block">
<div class="glass p-4 text-xs text-sky-400 border-sky-500/30">Status: <span
class="text-green-400 animate-pulse">Connected</span></div>
</div>
</aside>
<!-- Main Content -->
<main class="flex-1 flex flex-col min-w-0">
<header class="p-8 pb-4 flex justify-between items-center flex-shrink-0">
<div>
<h2 class="text-3xl font-bold" id="pageTitle">Market Overview</h2>
<div id="activeIsins" class="flex flex-wrap gap-2 mt-2"></div>
</div>
<button onclick="fetchData()" class="glass p-2 px-6 text-sky-400 font-bold hover:text-sky-200 transition">
REFRESH</button>
</header>
<!-- DASHBOARD VIEW -->
<div id="view-dashboard" class="view flex-1 p-8 pt-4 overflow-y-auto">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10">
<div class="glass p-8 border-l-4 border-sky-400 glow">
<p class="text-slate-500 text-xs font-bold uppercase tracking-wider mb-2">Volume (7d)</p>
<h3 id="statVolume" class="text-4xl font-bold">€0.0</h3>
</div>
<div class="glass p-8 border-l-4 border-amber-400">
<p class="text-slate-500 text-xs font-bold uppercase tracking-wider mb-2">Total Trades</p>
<h3 id="statTrades" class="text-4xl font-bold">0</h3>
</div>
<div class="glass p-8 border-l-4 border-emerald-400">
<p class="text-slate-500 text-xs font-bold uppercase tracking-wider mb-2">Assets</p>
<h3 id="statIsins" class="text-4xl font-bold">0</h3>
</div>
</div>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-8">
<div class="glass p-8">
<h3 class="text-lg font-bold mb-6 text-slate-300">Live Price Feed</h3>
<div class="h-80"><canvas id="priceChart"></canvas></div>
</div>
<div class="glass p-8">
<h3 class="text-lg font-bold mb-6 text-slate-300">Regional Distribution</h3>
<div class="h-80"><canvas id="continentChart"></canvas></div>
</div>
</div>
</div>
<!-- REPORT BUILDER VIEW -->
<div id="view-analytics" class="view hidden flex-1 flex overflow-hidden">
<!-- Configuration Sidebar -->
<div class="config-sidebar p-8 space-y-6 overflow-y-auto bg-slate-900/30" id="reportConfig">
<h3 class="text-sky-400 font-bold text-xs uppercase tracking-tighter mb-4">Step-by-Step Configuration
</h3>
<!-- Step 1: Time Range -->
<div class="report-step" id="step1">
<label class="field-label">1. Choose Analysis Period</label>
<select id="timeRangePreset" class="input-glass" onchange="proceedToStep(2)">
<option value="" disabled selected>Select... </option>
<option value="1">Today</option>
<option value="7">Last 7 Days</option>
<option value="30">Last 30 Days</option>
<option value="ytd">Year to Date (YTD)</option>
<option value="year">Full Year 2026</option>
<option value="custom">Custom Range...</option>
</select>
<div id="customDates" class="hidden grid grid-cols-2 gap-2 mt-2">
<input type="date" id="dateFrom" class="input-glass text-xs p-2" onchange="checkCustomDates()">
<input type="date" id="dateTo" class="input-glass text-xs p-2" onchange="checkCustomDates()">
</div>
</div>
<!-- Step 2: X-Axis -->
<div class="report-step hidden opacity-50" id="step2">
<label class="field-label">2. Primary Grouping (X-Axis)</label>
<select id="axisX" class="input-glass" onchange="proceedToStep(3); updateSubGroupOptions();">
<option value="" disabled selected>Select... </option>
<option value="day">Time (Daily)</option>
<option value="month">Time (Monthly)</option>
<option value="exchange">Exchange Origin</option>
<option value="continent">Region/Continent</option>
<option value="sector">Industry Sector</option>
<option value="isin">Specific ISIN</option>
</select>
</div>
<!-- Step 3: Breakdown -->
<div class="report-step hidden opacity-50" id="step3">
<label class="field-label">3. Secondary Breakdown (Series)</label>
<select id="axisSub" class="input-glass" onchange="proceedToStep(4)">
<option value="">None (Unified)</option>
<option value="exchange">By Exchange</option>
<option value="continent">By Continent</option>
<option value="sector">By Sector</option>
</select>
</div>
<!-- Step 4: Y-Axis -->
<div class="report-step hidden opacity-50" id="step4">
<label class="field-label">4. Select Metric (Y-Axis)</label>
<select id="axisY" class="input-glass" onchange="proceedToStep(5)">
<option value="" disabled selected>Select... </option>
<option value="volume">Trade Volume (€)</option>
<option value="count">Number of Trades</option>
<option value="avg_price">Average Performance</option>
</select>
</div>
<!-- Step 5: Filters -->
<div class="report-step hidden opacity-50" id="step5">
<div class="relative">
<label class="field-label">5. Optional Company Filters</label>
<input type="text" id="isinSearch" autocomplete="off" placeholder="Search ISIN or Name..."
class="input-glass" onkeyup="handleSearch(event)">
<div id="suggestions" class="suggestion-box hidden"></div>
<div id="filterChips" class="flex flex-wrap gap-2 mt-4"></div>
</div>
<div class="pt-8 space-y-3">
<button onclick="renderAnalyticsReport()"
class="btn-primary w-full shadow-sky-500/20">REGENERATE G-DRIVE REPORT</button>
<button onclick="resetReportConfig()"
class="w-full text-xs text-slate-500 hover:text-white transition">Reset
Configuration</button>
</div>
</div>
</div>
<div class="flex-1 p-8 overflow-hidden flex flex-col">
<div class="glass p-10 h-full flex flex-col shadow-2xl relative overflow-hidden">
<div class="flex justify-between items-center mb-10 z-10">
<h3 class="text-2xl font-bold" id="reportTitle">Report: Market Activity</h3>
<div class="flex glass p-1 rounded-lg">
<button class="p-2 px-4 text-xs font-bold rounded-md hover:bg-white/5"
onclick="setChartType('line')">Line</button>
<button class="p-2 px-4 text-xs font-bold rounded-md hover:bg-white/5"
onclick="setChartType('bar')">Bar</button>
</div>
</div>
<div class="flex-1 min-h-0 z-10"><canvas id="analyticsChart"></canvas></div>
<!-- Subtle watermark -->
<div class="absolute bottom-8 right-8 text-white/5 text-6xl font-black pointer-events-none">
ANALYTICS</div>
</div>
</div>
</div>
<!-- COMPANIES VIEW -->
<div id="view-metadata" class="view hidden flex-1 p-8 overflow-y-auto">
<div class="glass overflow-hidden">
<div class="p-8 border-b border-white/5 flex justify-between items-center">
<h3 class="text-xl font-bold">Metadata Repository</h3>
<input type="text" onkeyup="filterMetadata(this.value)"
placeholder="Search company, ISIN, sector..."
class="glass p-3 px-6 text-sm w-96 outline-none focus:border-sky-500">
</div>
<table class="w-full text-left">
<tbody id="metadataRows"></tbody>
</table>
</div>
</div>
</main>
<script>
const API = '/api';
let store = { trades: [], metadata: [], summary: [], pinnedIsins: [] };
let charts = {};
let currentChartType = 'bar';
let searchTimeout;
function showView(viewId) {
document.querySelectorAll('.view').forEach(v => v.classList.add('hidden'));
document.getElementById(`view-${viewId}`).classList.remove('hidden');
document.querySelectorAll('#sidebar a').forEach(a => a.classList.remove('active-nav'));
document.getElementById(`nav-${viewId}`).classList.add('active-nav');
const titles = { 'dashboard': 'Market Overview', 'analytics': 'Custom Report Builder', 'metadata': 'Entity Metadata' };
document.getElementById('pageTitle').innerText = titles[viewId];
if (viewId === 'analytics') {
// If it's a fresh visit without params, we might want to reset or keep state
// renderAnalyticsReport();
} else {
fetchData();
}
}
function handlePresetChange() {
const v = document.getElementById('timeRangePreset').value;
document.getElementById('customDates').classList.toggle('hidden', v !== 'custom');
}
function updateSubGroupOptions() {
const x = document.getElementById('axisX').value;
const sub = document.getElementById('axisSub');
// Contextual Logic: If X is already a metadata field, don't allow it as series
Array.from(sub.options).forEach(opt => {
opt.disabled = (opt.value === x);
if (opt.value === x && sub.value === x) sub.value = '';
});
}
async function handleSearch(e) {
const q = e.target.value;
const sBox = document.getElementById('suggestions');
if (q.length < 2) { sBox.classList.add('hidden'); return; }
clearTimeout(searchTimeout);
searchTimeout = setTimeout(async () => {
const res = await fetch(`${API}/metadata/search?q=${q}`).then(r => r.json());
const data = res.dataset || [];
if (data.length > 0) {
sBox.innerHTML = data.map(item => `<div class="suggestion-item" onclick="addFilter('${item[0]}', '${item[1]}')">
<div class="font-bold">${item[0]}</div>
<div class="text-[10px] text-slate-500">${item[1]}</div>
</div>`).join('');
sBox.classList.remove('hidden');
} else {
sBox.classList.add('hidden');
}
}, 300);
}
function addFilter(isin, name) {
if (!store.pinnedIsins.find(p => p.isin === isin)) {
store.pinnedIsins.push({ isin, name });
updateFilterChips();
document.getElementById('isinSearch').value = '';
document.getElementById('suggestions').classList.add('hidden');
if (window.activeView === 'analytics') proceedToStep(5);
}
}
function removeFilter(isin) {
store.pinnedIsins = store.pinnedIsins.filter(p => p.isin !== isin);
updateFilterChips();
}
function updateFilterChips() {
const container = document.getElementById('filterChips');
const pins = document.getElementById('activeIsins');
const html = store.pinnedIsins.map(p => `
<div class="glass p-1 px-3 text-[10px] font-bold text-sky-400 border-sky-400/30 flex items-center bg-sky-400/10">
${p.isin} <span class="ml-2 cursor-pointer text-slate-500 hover:text-white" onclick="removeFilter('${p.isin}')">×</span>
</div>
`).join('');
if (container) container.innerHTML = html;
if (pins) pins.innerHTML = html;
}
function getDates() {
const preset = document.getElementById('timeRangePreset').value;
const now = new Date();
let from, to = now.toISOString().split('T')[0];
if (preset === '1') from = to;
else if (preset === '7') from = new Date(now.setDate(now.getDate() - 7)).toISOString().split('T')[0];
else if (preset === '30') from = new Date(now.setDate(now.getDate() - 30)).toISOString().split('T')[0];
else if (preset === 'ytd') from = new Date(now.getFullYear(), 0, 1).toISOString().split('T')[0];
else if (preset === 'year') { from = '2026-01-01'; to = '2026-12-31'; }
else if (preset === 'custom') { from = document.getElementById('dateFrom').value; to = document.getElementById('dateTo').value; }
return { from, to };
}
async function fetchData() {
try {
const [t, m, s] = await Promise.all([
fetch(`${API}/trades?days=7`).then(r => r.json()),
fetch(`${API}/metadata`).then(r => r.json()),
fetch(`${API}/summary`).then(r => r.json())
]);
store = { ...store, trades: t.dataset || [], metadata: m.dataset || [], summary: s.dataset || [] };
updateDashboard();
} catch (err) { console.error(err); }
}
function updateDashboard() {
let vol = store.trades.reduce((acc, r) => acc + (parseFloat(r[4]) * parseFloat(r[5] || 0)), 0);
document.getElementById('statVolume').innerText = vol >= 1e6 ? `${(vol / 1e6).toFixed(1)}M` : `${(vol / 1e3).toFixed(0)}k`;
document.getElementById('statTrades').innerText = store.trades.length.toLocaleString();
document.getElementById('statIsins').innerText = store.metadata.length.toLocaleString();
renderDashboardCharts();
fillMetadataTable();
}
function setChartType(type) { currentChartType = type; renderAnalyticsReport(); }
async function renderAnalyticsReport() {
const dates = getDates();
const x = document.getElementById('axisX').value;
const sub = document.getElementById('axisSub').value;
const y = document.getElementById('axisY').value;
const isins = store.pinnedIsins.map(p => p.isin).join(',');
// Validate that basic steps are done
if (!dates.from || !x || !y) return;
let url = `${API}/analytics?metric=${y}&group_by=${x}`;
if (sub) url += `&sub_group_by=${sub}`;
if (dates.from) url += `&date_from=${dates.from}`;
if (dates.to) url += `&date_to=${dates.to}`;
if (isins) url += `&isins=${isins}`;
try {
const res = await fetch(url).then(r => r.json());
const data = res.dataset || [];
const ctx = document.getElementById('analyticsChart').getContext('2d');
let labels = [...new Set(data.map(r => r[0]))];
if (x === 'day' || x === 'month') labels.sort((a, b) => new Date(a) - new Date(b));
let datasets = [];
if (sub) {
let seriesNames = [...new Set(data.map(r => r[1]))];
seriesNames.forEach((name, idx) => {
const hue = (idx * 137.5) % 360;
datasets.push({
label: name,
data: labels.map(l => {
const row = data.find(r => r[0] === l && r[1] === name);
return row ? row[2] : 0;
}),
backgroundColor: `hsla(${hue}, 75%, 50%, 0.8)`,
borderColor: `hsla(${hue}, 75%, 50%, 1)`,
borderWidth: 2,
fill: currentChartType === 'line'
});
});
} else {
datasets.push({
label: y,
data: labels.map(l => {
const row = data.find(r => r[0] === l);
return row ? row[1] : 0;
}),
backgroundColor: '#38bdf888',
borderColor: '#38bdf8',
borderWidth: 2,
fill: currentChartType === 'line'
});
}
if (charts.analytics) charts.analytics.destroy();
charts.analytics = new Chart(ctx, {
type: currentChartType,
data: { labels: labels.map(l => (x === 'day' || x === 'month') ? new Date(l).toLocaleDateString() : l), datasets },
options: {
responsive: true, maintainAspectRatio: false,
scales: { y: { stacked: true, grid: { color: 'rgba(255,255,255,0.05)' } }, x: { stacked: true, grid: { display: false } } },
plugins: { legend: { position: 'bottom', labels: { color: '#94a3b8' } } }
}
});
document.getElementById('reportTitle').innerText = `Analysis: ${y} by ${x} ${sub ? ' (Splitted by ' + sub + ')' : ''}`;
} catch (err) { console.error(err); }
}
function renderDashboardCharts() {
const trendCtx = document.getElementById('priceChart').getContext('2d');
const samples = [...store.trades].sort((a, b) => new Date(a[3]) - new Date(b[3])).slice(-100);
if (charts.price) charts.price.destroy();
charts.price = new Chart(trendCtx, {
type: 'line',
data: { labels: samples.map(r => new Date(r[3]).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })), datasets: [{ data: samples.map(r => r[4]), borderColor: '#38bdf8', borderWidth: 2, tension: 0.4, pointRadius: 0 }] },
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { display: false }, y: { grid: { color: 'rgba(255,255,255,0.05)' } } } }
});
const contCtx = document.getElementById('continentChart').getContext('2d');
if (charts.continent) charts.continent.destroy();
const labels = store.summary.map(r => r[0]);
const baseColors = [
'#38bdf8', // Blue
'#f43f5e', // Red
'#10b981', // Green
'#fbbf24', // Yellow
'#8b5cf6', // Purple
'#f97316', // Orange
'#ec4899', // Pink
'#6366f1' // Indigo
];
charts.continent = new Chart(contCtx, {
type: 'doughnut',
data: {
labels: labels,
datasets: [{
data: store.summary.map(r => r[2]),
backgroundColor: labels.map((l, i) => l.toLowerCase() === 'unknown' ? '#000000' : baseColors[i % baseColors.length]),
borderWidth: 0
}]
},
options: { responsive: true, maintainAspectRatio: false, cutout: '75%', plugins: { legend: { position: 'right', labels: { color: '#94a3b8' } } } }
});
}
// --- Progressive Report Configuration ---
function proceedToStep(n) {
// First, enable/show the step
const step = document.getElementById(`step${n}`);
if (step) {
step.classList.remove('hidden');
setTimeout(() => step.classList.remove('opacity-50'), 50);
}
// Contextual Visibility Logic: If we are at step 2, handle custom date toggles
if (n === 2) handlePresetChange();
// Auto-scroll logic if sidebar is long
if (n > 2) step.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
function checkCustomDates() {
const from = document.getElementById('dateFrom').value;
const to = document.getElementById('dateTo').value;
if (from && to) proceedToStep(2);
}
function resetReportConfig() {
document.querySelectorAll('.report-step').forEach((s, i) => {
if (i > 0) s.classList.add('hidden', 'opacity-50');
});
document.getElementById('timeRangePreset').value = '';
document.getElementById('axisX').value = '';
document.getElementById('axisSub').value = '';
document.getElementById('axisY').value = '';
store.pinnedIsins = [];
updateFilterChips();
}
function fillMetadataTable() {
const tbody = document.getElementById('metadataRows');
tbody.innerHTML = store.metadata.map(r => `
<tr class="hover:bg-white/5 transition border-b border-white/5 cursor-pointer" onclick="deepLinkToAnalytics('${r[0]}', '${r[1]}')">
<td class="p-5 px-8 font-mono text-sky-400 font-bold">${r[0]}</td>
<td class="p-5 font-semibold text-slate-200">${r[1]}</td>
<td class="p-5 text-slate-500">${r[2]}</td>
<td class="p-5"><span class="bg-white/5 px-2 py-1 rounded text-[10px] text-slate-400 font-bold uppercase">${r[4] || 'UNKNOWN'}</span></td>
</tr>
`).join('');
}
function deepLinkToAnalytics(isin, name) {
// Set values as requested: Time: 30d, X: Day, Y: Volume, Filter: ISIN
resetReportConfig();
document.getElementById('timeRangePreset').value = '30';
document.getElementById('axisX').value = 'day';
document.getElementById('axisSub').value = '';
document.getElementById('axisY').value = 'volume';
store.pinnedIsins = [{ isin, name }];
updateFilterChips();
// Show all steps progressively for the user
for (let i = 1; i <= 5; i++) proceedToStep(i);
showView('analytics');
renderAnalyticsReport();
}
function handleUrlParams() {
const params = new URLSearchParams(window.location.search);
const view = params.get('view');
const isin = params.get('isin');
if (view) showView(view);
if (view === 'analytics' && isin) {
// If isin is provided in URL, we wait for metadata to be fetched then deep link
const checkStore = setInterval(() => {
if (store.metadata.length > 0) {
const entity = store.metadata.find(r => r[0] === isin);
if (entity) deepLinkToAnalytics(isin, entity[1]);
clearInterval(checkStore);
}
}, 500);
}
}
function filterMetadata(q) {
const rows = document.querySelectorAll('#metadataRows tr');
rows.forEach(r => r.style.display = r.innerText.toLowerCase().includes(q.toLowerCase()) ? '' : 'none');
}
window.onload = async () => {
await fetchData();
handleUrlParams();
setInterval(fetchData, 30000);
};
</script>
</body>
</html>