Files
pgz-sport/static/sport_3d_v2.html
T

373 lines
17 KiB
HTML

<!DOCTYPE html>
<html lang="hr"><head><meta charset="utf-8"><title>3D Sport Network · PGŽ</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{background:#06080d;color:#e8eaf0;font-family:system-ui,sans-serif;overflow:hidden}
#g{width:100vw;height:100vh;position:fixed;inset:0}
.panel{position:fixed;background:rgba(10,14,24,.92);border:1px solid #1e293b;border-radius:8px;
backdrop-filter:blur(8px);z-index:100}
#hud{top:12px;left:12px;padding:12px 16px;font-size:11px;line-height:1.7;min-width:240px}
#hud .h{font-size:13px;font-weight:600;color:#00f0ff;margin-bottom:8px;display:flex;justify-content:space-between;align-items:center}
#hud .row{display:flex;justify-content:space-between;gap:16px}
#hud b{color:#fbbf24}
#ctrl{top:12px;right:12px;padding:10px 12px;width:240px}
#ctrl .h{font-size:11px;color:#94a3b8;margin-bottom:6px;text-transform:uppercase;letter-spacing:.5px}
input,select{width:100%;background:#0d1117;color:#e8eaf0;border:1px solid #1e293b;
padding:6px 8px;border-radius:4px;font-size:11px;margin:3px 0;font-family:inherit}
input:focus,select:focus{outline:none;border-color:#00f0ff}
button{cursor:pointer;font-weight:600}
.btn-pri{background:#00f0ff;color:#000;border:none;padding:6px 12px;border-radius:4px;font-size:11px;width:100%;margin-top:6px}
.btn-pri:hover{background:#22d3ee}
.btn-lim{background:#0d1117;color:#94a3b8;border:1px solid #1e293b;padding:4px 8px;border-radius:4px;font-size:10px;margin:0 2px}
.btn-lim.act{background:#00f0ff;color:#000;border-color:#00f0ff}
#leg{bottom:12px;left:12px;padding:8px 12px;font-size:10px;display:flex;gap:14px;align-items:center}
.dot{width:9px;height:9px;border-radius:50%;display:inline-block;margin-right:4px;vertical-align:middle}
#tt{position:fixed;display:none;z-index:200;background:rgba(10,14,24,.96);border:1px solid #fbbf24;
border-radius:8px;padding:10px 14px;font-size:11px;max-width:300px;pointer-events:none;line-height:1.6}
#tt b{color:#00f0ff}
#tt .meta{color:#94a3b8;font-size:10px;margin-top:4px}
#detail{display:none;position:fixed;right:12px;top:12px;bottom:12px;width:420px;
background:rgba(10,14,24,.97);border:1px solid #00f0ff;border-radius:10px;
padding:18px;font-size:12px;overflow-y:auto;z-index:300;backdrop-filter:blur(10px)}
#detail.open{display:block}
#detail .x{position:absolute;top:10px;right:14px;cursor:pointer;font-size:18px;color:#94a3b8;background:none;border:none;width:auto;padding:4px}
#detail h2{color:#00f0ff;font-size:16px;margin-bottom:8px;padding-right:30px}
#detail h3{color:#fbbf24;font-size:12px;text-transform:uppercase;letter-spacing:.5px;margin:14px 0 6px}
#detail .kv{display:flex;justify-content:space-between;padding:4px 0;border-bottom:1px solid #1e293b;font-size:11px}
#detail .kv b{color:#e8eaf0}
#detail ul{list-style:none;padding-left:0}
#detail li{padding:6px 0;border-bottom:1px dotted #1e293b;font-size:11px;cursor:pointer}
#detail li:hover{color:#00f0ff}
#yearSel{margin:8px 0;padding:6px 8px}
#load{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);color:#00f0ff;font-size:14px;z-index:50}
.loader{display:inline-block;width:14px;height:14px;border:2px solid #00f0ff;border-top-color:transparent;border-radius:50%;animation:sp 0.8s linear infinite;vertical-align:middle;margin-right:6px}
@keyframes sp{to{transform:rotate(360deg)}}
</style>
</head><body>
<div id="g"></div>
<div id="load"><span class="loader"></span> Učitavam mrežu...</div>
<div id="hud" class="panel">
<div class="h"><span>⚡ 3D SPORT NETWORK</span><span style="font-size:10px;color:#475569">v2.0</span></div>
<div class="row"><span>Osobe:</span><b id="s-p"></b></div>
<div class="row"><span>Multi-chair:</span><b id="s-mc" style="color:#fbbf24"></b></div>
<div class="row"><span>Savezi:</span><b id="s-s"></b></div>
<div class="row"><span>Klubovi:</span><b id="s-k"></b></div>
<div class="row"><span>Veze:</span><b id="s-l"></b></div>
<div style="font-size:10px;color:#475569;margin-top:8px;border-top:1px solid #1e293b;padding-top:6px">Klik = detalj · Hover = info</div>
</div>
<div id="ctrl" class="panel">
<div class="h">FILTRI</div>
<input type="text" id="f-search" placeholder="🔍 Označi po imenu (highlight zlato)..." oninput="applyHighlight()"/>
<select id="f-sport"><option value="">Svi sportovi</option></select>
<input type="number" id="f-min" value="2" min="1" max="10" placeholder="Min organizacija"/>
<select id="f-year"><option value="">Sve sezone</option></select>
<div style="display:flex;gap:4px;margin:6px 0;justify-content:space-between">
<span style="font-size:10px;color:#94a3b8;align-self:center">Limit:</span>
<button class="btn-lim" data-l="50" onclick="setLimit(50)">50</button>
<button class="btn-lim act" data-l="100" onclick="setLimit(100)">100</button>
<button class="btn-lim" data-l="200" onclick="setLimit(200)">200</button>
<button class="btn-lim" data-l="500" onclick="setLimit(500)">500</button>
</div>
<button class="btn-pri" onclick="loadGraph()">↻ Učitaj</button>
</div>
<div id="leg" class="panel">
<span><span class="dot" style="background:#fbbf24"></span>Multi-chair</span>
<span><span class="dot" style="background:#22c55e"></span>Osoba</span>
<span><span class="dot" style="background:#06b6d4"></span>Savez</span>
<span><span class="dot" style="background:#64748b"></span>Klub</span>
<span style="color:#475569;font-size:9px">drag/scroll/click</span>
</div>
<div id="tt"></div>
<div id="detail">
<button class="x" onclick="closeDetail()"></button>
<div id="detail-content">Loading...</div>
</div>
<script src="https://unpkg.com/three@0.160.0/build/three.min.js"></script>
<script src="https://unpkg.com/3d-force-graph@1.73.4/dist/3d-force-graph.min.js"></script>
<script>
const COLOR = {person:'#22c55e', multichair:'#fbbf24', savez:'#06b6d4', klub:'#64748b', highlight:'#FFD700'};
let Graph = null, currentLimit = 100, currentData = null, highlightQuery = '';
async function loadSports() {
try {
const r = await fetch('/sport/api/v2/dashboard/sport-stats');
const d = await r.json();
const sel = document.getElementById('f-sport');
const sports = (d.klub_breakdown || []).map(k => k.sport).filter(Boolean);
[...new Set(sports)].sort().forEach(s => {
const o = document.createElement('option'); o.value=s; o.textContent=s; sel.appendChild(o);
});
} catch(e){}
// Years
const yearSel = document.getElementById('f-year');
for (let y = 2026; y >= 2015; y--) {
const o = document.createElement('option'); o.value=y; o.textContent=y; yearSel.appendChild(o);
}
}
async function loadGraph() {
document.getElementById('load').style.display = 'block';
const min = document.getElementById('f-min').value || 2;
const sport = document.getElementById('f-sport').value || '';
const year = document.getElementById('f-year').value || '';
const url = `/sport/api/v2/graph/3d-network?min_orgs=${min}&top_n=${currentLimit}&sport=${encodeURIComponent(sport)}` +
(year ? `&year=${year}` : '');
try {
const r = await fetch(url);
const data = await r.json();
currentData = data;
// Update HUD
const stats = data.stats || {};
document.getElementById('s-p').textContent = (stats.persons || 0) - (stats.multichair || 0);
document.getElementById('s-mc').textContent = stats.multichair || 0;
document.getElementById('s-l').textContent = stats.links || 0;
// Count savezi vs klubovi
let nSav = 0, nKl = 0;
(data.nodes || []).forEach(n => {
if (n.type === 'savez') nSav++;
else if (n.type === 'klub') nKl++;
});
document.getElementById('s-s').textContent = nSav;
document.getElementById('s-k').textContent = nKl;
if (!Graph) initGraph(data);
else Graph.graphData(data);
} catch(e) {
console.error('Load fail', e);
}
document.getElementById('load').style.display = 'none';
}
function initGraph(data) {
const el = document.getElementById('g');
Graph = ForceGraph3D()(el)
.backgroundColor('#06080d')
.nodeColor(getNodeColor)
.nodeVal(getNodeVal)
.nodeOpacity(0.92)
.linkColor(() => 'rgba(148,163,184,0.18)')
.linkWidth(0.6)
.linkDirectionalParticles(0)
.nodeLabel(n => '') // we use custom tooltip
.onNodeHover(n => {
const tt = document.getElementById('tt');
if (n) {
let html = `<b>${escape(n.name)}</b>`;
if (n.type === 'multichair' || n.type === 'person') {
html += `<div class="meta">${n.n_orgs || 1} organizacija${(n.n_orgs||1)>=2?' · MULTI-CHAIR':''}</div>`;
} else if (n.type === 'savez') {
html += `<div class="meta">Savez</div>`;
} else if (n.type === 'klub') {
html += `<div class="meta">Klub${n.sport?' · '+n.sport:''}</div>`;
}
html += `<div class="meta">Klik = detalj</div>`;
tt.innerHTML = html;
tt.style.display = 'block';
} else tt.style.display = 'none';
})
.onNodeClick(n => {
openDetail(n);
})
.graphData(data);
}
function getNodeColor(n) {
if (highlightQuery && n.name && n.name.toLowerCase().includes(highlightQuery.toLowerCase())) {
return COLOR.highlight;
}
return COLOR[n.type] || '#94a3b8';
}
function getNodeVal(n) {
let base = n.val || 5;
if (highlightQuery && n.name && n.name.toLowerCase().includes(highlightQuery.toLowerCase())) {
return base * 2.5;
}
return base;
}
function applyHighlight() {
highlightQuery = document.getElementById('f-search').value.trim();
if (Graph) Graph.refresh();
}
function setLimit(l) {
currentLimit = l;
document.querySelectorAll('.btn-lim').forEach(b => b.classList.toggle('act', +b.dataset.l === l));
loadGraph();
}
async function openDetail(node) {
const d = document.getElementById('detail');
const c = document.getElementById('detail-content');
d.classList.add('open');
c.innerHTML = '<div style="text-align:center;padding:30px"><span class="loader"></span> Učitavam detalje...</div>';
try {
if (node.type === 'klub') {
const klubId = node.id.replace('klub:', '');
const r = await fetch(`/sport/api/klubovi/${klubId}`);
const k = await r.json();
let html = `<h2>🏆 ${escape(k.naziv || node.name)}</h2>`;
html += `<div class="kv"><span>Sport</span><b>${escape(k.sport || '—')}</b></div>`;
html += `<div class="kv"><span>Grad</span><b>${escape(k.grad || '—')}</b></div>`;
html += `<div class="kv"><span>OIB</span><b>${escape(k.oib || '—')}</b></div>`;
html += `<div class="kv"><span>Predsjednik</span><b>${escape(k.predsjednik || '—')}</b></div>`;
html += `<div class="kv"><span>Tajnik</span><b>${escape(k.tajnik || '—')}</b></div>`;
html += `<div class="kv"><span>Email</span><b>${escape(k.email || '—')}</b></div>`;
html += `<div class="kv"><span>Web</span><b>${k.web_stranica?'<a href="'+escape(k.web_stranica)+'" target="_blank" style="color:#00f0ff">otvori</a>':'—'}</b></div>`;
// RGFI year selector
html += `<h3>FINANCIJE PO GODINI</h3>`;
html += `<select id="yearSel" onchange="loadKlubFinance('${klubId}')">`;
html += `<option value="">Odaberi godinu...</option>`;
for (let y = 2024; y >= 2015; y--) html += `<option value="${y}">${y}</option>`;
html += `</select>`;
html += `<div id="finance-data"></div>`;
// Linked savezi & osobe
if (currentData && currentData.links) {
const linkedNodes = currentData.links
.filter(l => l.source.id === node.id || l.target.id === node.id)
.map(l => l.source.id === node.id ? l.target : l.source);
if (linkedNodes.length) {
html += `<h3>POVEZANE OSOBE/SAVEZI (${linkedNodes.length})</h3><ul>`;
linkedNodes.slice(0, 30).forEach(n => {
html += `<li onclick="focusNode('${n.id}')">${getEmoji(n.type)} ${escape(n.name)}</li>`;
});
html += `</ul>`;
}
}
c.innerHTML = html;
} else if (node.type === 'savez') {
const savezId = node.id.replace('savez:', '');
const r = await fetch(`/sport/api/savezi/${savezId}`);
const s = await r.json();
let html = `<h2>🏛 ${escape(s.naziv || node.name)}</h2>`;
html += `<div class="kv"><span>Sport</span><b>${escape(s.sport || '—')}</b></div>`;
html += `<div class="kv"><span>OIB</span><b>${escape(s.oib || '—')}</b></div>`;
html += `<div class="kv"><span>Predsjednik</span><b>${escape(s.predsjednik || '—')}</b></div>`;
html += `<div class="kv"><span>Tajnik</span><b>${escape(s.tajnik || '—')}</b></div>`;
html += `<div class="kv"><span>Godina osnutka</span><b>${escape(s.godina_osnutka || '—')}</b></div>`;
if (currentData && currentData.links) {
const linkedKlubovi = currentData.links
.filter(l => l.source.id === node.id || l.target.id === node.id)
.map(l => l.source.id === node.id ? l.target : l.source)
.filter(n => n.type === 'klub');
if (linkedKlubovi.length) {
html += `<h3>KLUBOVI U SAVEZU (${linkedKlubovi.length})</h3><ul>`;
linkedKlubovi.forEach(n => {
html += `<li onclick="focusNode('${n.id}')">🏆 ${escape(n.name)}</li>`;
});
html += `</ul>`;
}
}
c.innerHTML = html;
} else {
// Person / multi-chair
let html = `<h2>${node.type==='multichair'?'⚠️':'👤'} ${escape(node.name)}</h2>`;
html += `<div class="kv"><span>Tip</span><b>${node.type === 'multichair' ? 'MULTI-CHAIR' : 'Osoba'}</b></div>`;
html += `<div class="kv"><span>Broj organizacija</span><b>${node.n_orgs || 1}</b></div>`;
if (currentData && currentData.links) {
const orgs = currentData.links
.filter(l => l.source.id === node.id || l.target.id === node.id)
.map(l => ({
node: l.source.id === node.id ? l.target : l.source,
role: l.role
}));
if (orgs.length) {
html += `<h3>FUNKCIJE U ORGANIZACIJAMA</h3><ul>`;
orgs.forEach(o => {
html += `<li onclick="focusNode('${o.node.id}')">${getEmoji(o.node.type)} <b>${escape(o.node.name)}</b><div style="color:#94a3b8;font-size:10px;margin-top:2px">${escape(o.role || '')}</div></li>`;
});
html += `</ul>`;
}
}
c.innerHTML = html;
}
} catch(e) {
c.innerHTML = '<div style="color:#f87171">Greška učitavanja: '+escape(e.message)+'</div>';
}
}
async function loadKlubFinance(klubId) {
const yr = document.getElementById('yearSel').value;
const fd = document.getElementById('finance-data');
if (!yr) { fd.innerHTML = ''; return; }
fd.innerHTML = '<div style="padding:10px;color:#94a3b8"><span class="loader"></span> Tražim RGFI '+yr+'...</div>';
try {
// Try multiple endpoints
let data = null;
for (const ep of [`/sport/api/klubovi/${klubId}/finance?year=${yr}`, `/sport/api/v2/klub/${klubId}/rgfi?year=${yr}`]) {
try { const r = await fetch(ep); if (r.ok) { data = await r.json(); break; } } catch{}
}
if (!data || (Array.isArray(data) && !data.length) || data.error) {
fd.innerHTML = '<div style="padding:10px;color:#475569;font-size:10px">⚠ Nema RGFI podataka za '+yr+'. (Klubovi su udruge, ne d.o.o. — RGFI nije obavezan.)</div>';
return;
}
// Render financial data with clickable accounts
let html = '<div style="padding:6px;background:#0d1117;border:1px solid #1e293b;border-radius:6px;margin-top:8px">';
html += '<div style="font-size:10px;color:#fbbf24;margin-bottom:6px">RGFI '+yr+'</div>';
Object.entries(data).forEach(([k,v]) => {
if (typeof v === 'number' && v) {
html += '<div class="kv" onclick="alert(\'Konto: '+k+' = '+v.toLocaleString('hr-HR')+' EUR\')" style="cursor:pointer"><span>'+k+'</span><b>'+(v||0).toLocaleString('hr-HR')+' €</b></div>';
}
});
html += '</div>';
fd.innerHTML = html;
} catch(e) {
fd.innerHTML = '<div style="color:#f87171;padding:10px;font-size:10px">Greška: '+e.message+'</div>';
}
}
function focusNode(nodeId) {
if (!currentData || !Graph) return;
const node = currentData.nodes.find(n => n.id === nodeId);
if (!node) return;
const dist = 80;
const distRatio = 1 + dist/Math.hypot(node.x||1, node.y||1, node.z||1);
Graph.cameraPosition({x:(node.x||0)*distRatio, y:(node.y||0)*distRatio, z:(node.z||0)*distRatio}, node, 1500);
setTimeout(() => openDetail(node), 800);
}
function getEmoji(type) {
return {multichair:'⚠️', person:'👤', savez:'🏛', klub:'🏆'}[type] || '•';
}
function escape(s) {
return String(s||'').replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));
}
function closeDetail() {
document.getElementById('detail').classList.remove('open');
}
document.addEventListener('mousemove', e => {
const tt = document.getElementById('tt');
tt.style.left = (e.clientX+14)+'px';
tt.style.top = (e.clientY+14)+'px';
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeDetail();
});
// Init
loadSports().then(loadGraph);
</script>
</body></html>