Files
pgz-sport/static/sport_3d.html
T

534 lines
17 KiB
HTML
Raw 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="hr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>3D Mreža · PGŽ Sport</title>
<style>
:root {
--bg: #06080d;
--bg-2: #0d1117;
--bg-3: #161b22;
--border: #1f2937;
--text: #e6edf3;
--text-2: #8b949e;
--text-3: #6e7681;
--accent: #00f0ff;
--gold: #FFD700;
--green: #22c55e;
--cyan: #06b6d4;
--red: #ef4444;
--purple: #a78bfa;
--orange: #f59e0b;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, system-ui, sans-serif;
background: var(--bg);
color: var(--text);
height: 100vh;
overflow: hidden;
font-size: 13px;
}
.header {
position: fixed;
top: 0; left: 0; right: 0;
background: rgba(13, 17, 23, 0.95);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--border);
padding: 10px 16px;
z-index: 10;
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.header h1 { font-size: 16px; font-weight: 700; color: var(--accent); margin-right: auto; }
.header h1 .meta { font-size: 11px; color: var(--text-3); margin-left: 8px; font-weight: 400; }
.ctrl-group { display: flex; gap: 6px; align-items: center; }
.ctrl-group label { font-size: 11px; color: var(--text-3); }
.btn, select, input {
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
padding: 5px 9px;
border-radius: 5px;
font-size: 11px;
font-family: inherit;
}
.btn { cursor: pointer; transition: all 0.15s; }
.btn:hover { border-color: var(--accent); color: var(--accent); }
.btn.active { background: var(--accent); color: #000; border-color: var(--accent); font-weight: 600; }
input { width: 160px; }
input:focus, select:focus { outline: none; border-color: var(--accent); }
#graph {
position: fixed;
inset: 56px 0 0 0;
width: 100%;
height: calc(100vh - 56px);
background: #050608;
}
.hud {
position: fixed;
top: 70px; left: 12px;
background: rgba(13, 17, 23, 0.92);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 16px;
font-size: 11px;
z-index: 5;
min-width: 200px;
line-height: 1.7;
}
.hud .row { display: flex; justify-content: space-between; gap: 14px; }
.hud .row b { color: var(--accent); font-family: 'JetBrains Mono', monospace; }
.hud .row.gold b { color: var(--gold); }
.legend {
position: fixed;
bottom: 12px; left: 12px;
background: rgba(13, 17, 23, 0.92);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 14px;
font-size: 11px;
display: flex;
gap: 14px;
z-index: 5;
}
.dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; margin-right: 5px; vertical-align: middle; }
.tooltip {
position: fixed;
background: rgba(10, 13, 20, 0.97);
border: 1px solid var(--gold);
border-radius: 6px;
padding: 10px 14px;
font-size: 12px;
pointer-events: none;
display: none;
z-index: 100;
max-width: 280px;
line-height: 1.6;
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
}
.tooltip b { color: var(--gold); }
.tooltip .typ { color: var(--text-3); font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; }
#detail-panel {
position: fixed;
top: 70px; right: 12px;
width: 360px;
max-height: calc(100vh - 92px);
overflow-y: auto;
background: rgba(13, 17, 23, 0.96);
border: 1px solid var(--gold);
border-radius: 8px;
padding: 16px;
font-size: 12px;
z-index: 7;
display: none;
line-height: 1.6;
}
#detail-panel h3 { color: var(--gold); font-size: 14px; margin-bottom: 8px; }
#detail-panel .field { display: flex; justify-content: space-between; padding: 4px 0; border-bottom: 1px solid var(--border); }
#detail-panel .field b { color: var(--text); }
#detail-panel .close-btn { float: right; cursor: pointer; color: var(--text-3); font-size: 16px; line-height: 1; }
#detail-panel .close-btn:hover { color: var(--accent); }
#detail-panel .section { margin-top: 12px; }
#detail-panel .section h4 { font-size: 11px; color: var(--accent); text-transform: uppercase; margin-bottom: 6px; letter-spacing: 0.5px; }
#detail-panel a { color: var(--cyan); text-decoration: none; }
#detail-panel a:hover { color: var(--accent); }
.spinner {
position: fixed;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
width: 40px; height: 40px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
z-index: 20;
display: none;
}
@keyframes spin { to { transform: translate(-50%, -50%) rotate(360deg); } }
.show { display: block !important; }
</style>
</head>
<body>
<div class="header">
<h1>⚡ 3D Mreža PGŽ Sport <span class="meta">klubovi · savezi · osobe · drill-down</span></h1>
<div class="ctrl-group">
<label>Mode:</label>
<select id="mode">
<option value="multichair">Multi-chair osobe</option>
<option value="all">Sve veze</option>
<option value="savezi">Po savezima</option>
</select>
</div>
<div class="ctrl-group">
<label>Sport:</label>
<select id="sport"><option value="">Svi sportovi</option></select>
</div>
<div class="ctrl-group">
<label>Min org:</label>
<input type="number" id="minOrgs" value="2" min="1" max="10" style="width: 60px;">
</div>
<div class="ctrl-group">
<label>Limit:</label>
<button class="btn" data-limit="100">100</button>
<button class="btn active" data-limit="200">200</button>
<button class="btn" data-limit="500">500</button>
</div>
<div class="ctrl-group">
<input type="text" id="search" placeholder="🔍 Highlight (ime osobe ili klub)...">
</div>
<button class="btn" onclick="loadGraph()" style="background: var(--accent); color: #000; font-weight: 600;">↻ Osvježi</button>
<button class="btn" onclick="resetCamera()">⊕ Reset kamera</button>
</div>
<div id="graph"></div>
<div class="spinner" id="spinner"></div>
<div class="hud" id="hud">
<div class="row"><span>Čvorovi:</span><b id="s-nodes"></b></div>
<div class="row"><span>Veze:</span><b id="s-links"></b></div>
<div class="row"><span>Osobe:</span><b id="s-persons"></b></div>
<div class="row gold"><span>Multi-chair:</span><b id="s-mc"></b></div>
<div class="row"><span>Klubovi:</span><b id="s-klubovi"></b></div>
<div class="row"><span>Savezi:</span><b id="s-savezi"></b></div>
</div>
<div class="legend">
<span><span class="dot" style="background: #FFD700"></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: #a78bfa"></span>Klub</span>
<span style="color: var(--text-3); margin-left: 12px;">Klik = drill-down · Mouse = rotacija · Scroll = zoom</span>
</div>
<div class="tooltip" id="tooltip"></div>
<div id="detail-panel">
<span class="close-btn" onclick="closeDetail()">×</span>
<h3 id="dp-title"></h3>
<div id="dp-content"></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 = {
multichair: '#FFD700',
person: '#22c55e',
savez: '#06b6d4',
klub: '#a78bfa',
highlight: '#FFD700'
};
let Graph;
let currentLimit = 200;
let lastData = null;
const $ = s => document.querySelector(s);
function showSpinner(b) { $('#spinner').classList.toggle('show', b); }
async function loadSports() {
try {
const r = await fetch('/sport/api/v2/dashboard/sport-stats');
const d = await r.json();
const sel = $('#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) { console.warn('sports load fail', e); }
}
async function loadGraph() {
showSpinner(true);
const minOrgs = $('#minOrgs').value || 2;
const sport = $('#sport').value || '';
const mode = $('#mode').value;
let url;
if (mode === 'multichair') {
url = `/sport/api/v2/graph/3d-network?min_orgs=${minOrgs}&top_n=${currentLimit}&sport=${encodeURIComponent(sport)}`;
} else if (mode === 'all') {
url = `/sport/api/v2/graph/3d-network?min_orgs=1&top_n=${currentLimit}&sport=${encodeURIComponent(sport)}`;
} else {
url = `/sport/api/v2/graph/3d-network?min_orgs=${minOrgs}&top_n=${currentLimit}&sport=${encodeURIComponent(sport)}`;
}
try {
const r = await fetch(url);
const d = await r.json();
lastData = d;
// Update HUD
const stats = d.stats || {};
$('#s-nodes').textContent = (d.nodes || []).length;
$('#s-links').textContent = (d.links || []).length;
$('#s-persons').textContent = stats.persons || 0;
$('#s-mc').textContent = stats.multichair || 0;
$('#s-klubovi').textContent = (d.nodes || []).filter(n => n.type === 'klub').length;
$('#s-savezi').textContent = (d.nodes || []).filter(n => n.type === 'savez').length;
renderGraph(d);
} catch(e) {
console.error('graph load fail', e);
alert('Greška pri učitavanju grafa: ' + e.message);
}
showSpinner(false);
}
function renderGraph(data) {
const highlightQ = $('#search').value.toLowerCase();
if (!Graph) {
Graph = ForceGraph3D()(document.getElementById('graph'))
.backgroundColor('#050608')
.nodeColor(getNodeColor)
.nodeVal(getNodeVal)
.nodeOpacity(0.95)
.linkColor(() => 'rgba(148,163,184,0.18)')
.linkWidth(0.6)
.linkOpacity(0.4)
.nodeLabel(() => '') // koristimo custom tooltip
.onNodeHover(handleNodeHover)
.onNodeClick(handleNodeClick)
.graphData(data);
} else {
Graph.graphData(data);
Graph.nodeColor(getNodeColor);
Graph.nodeVal(getNodeVal);
}
}
function getNodeColor(n) {
const q = $('#search').value.toLowerCase();
if (q && n.name && n.name.toLowerCase().includes(q)) return COLOR.highlight;
return COLOR[n.type] || '#94a3b8';
}
function getNodeVal(n) {
const q = $('#search').value.toLowerCase();
const base = n.val || 5;
if (q && n.name && n.name.toLowerCase().includes(q)) return base * 2.5;
return base;
}
function handleNodeHover(n) {
const tt = $('#tooltip');
if (n) {
tt.style.display = 'block';
let html = `<b>${n.name || '—'}</b><div class="typ">${n.type}</div>`;
if (n.n_orgs) html += `<br>📊 ${n.n_orgs} organizacija`;
if (n.sport) html += `<br>⚡ Sport: ${n.sport}`;
if (n.id && n.id.startsWith('klub:')) html += `<br>🆔 Klub ID: ${n.id.split(':')[1]}`;
if (n.id && n.id.startsWith('savez:')) html += `<br>🆔 Savez ID: ${n.id.split(':')[1]}`;
html += `<br><span style="color:#888;font-size:10px;margin-top:4px;display:inline-block">▸ Klik za detalje</span>`;
tt.innerHTML = html;
} else {
tt.style.display = 'none';
}
}
document.addEventListener('mousemove', e => {
const tt = $('#tooltip');
tt.style.left = (e.clientX + 14) + 'px';
tt.style.top = (e.clientY + 14) + 'px';
});
async function handleNodeClick(n) {
// Camera focus
const dist = 80;
const distRatio = 1 + dist / Math.hypot(n.x, n.y, n.z);
Graph.cameraPosition({
x: n.x * distRatio,
y: n.y * distRatio,
z: n.z * distRatio
}, n, 1500);
// Open detail panel based on node type
const panel = $('#detail-panel');
panel.classList.add('show');
$('#dp-title').textContent = n.name || '—';
$('#dp-content').innerHTML = '<div style="color:var(--text-3)">Učitavam detalje...</div>';
try {
if (n.id && n.id.startsWith('klub:')) {
const klubId = n.id.split(':')[1];
const r = await fetch(`/admin/api/crm/klub/${klubId}`);
const d = await r.json();
renderKlubDetail(d);
} else if (n.type === 'multichair' || n.type === 'person') {
renderPersonDetail(n);
} else if (n.id && n.id.startsWith('savez:')) {
const savezId = n.id.split(':')[1];
renderSavezDetail(n, savezId);
} else {
$('#dp-content').innerHTML = `<div class="field"><span>Tip:</span><b>${n.type}</b></div>
<div class="field"><span>ID:</span><b>${n.id}</b></div>`;
}
} catch(e) {
$('#dp-content').innerHTML = `<div style="color:var(--red)">Greška: ${e.message}</div>`;
}
}
function renderKlubDetail(d) {
const k = d.klub || {};
let html = `
<div class="field"><span>OIB</span><b>${k.oib || '—'}</b></div>
<div class="field"><span>Sport</span><b>${k.sport || '—'}</b></div>
<div class="field"><span>Grad</span><b>${k.grad || '—'}</b></div>
<div class="field"><span>Adresa</span><b>${k.adresa || '—'}</b></div>
<div class="field"><span>Email</span><b>${k.email || '—'}</b></div>
<div class="field"><span>Telefon</span><b>${k.telefon || '—'}</b></div>
<div class="field"><span>Predsjednik</span><b>${k.predsjednik || '—'}</b></div>
<div class="field"><span>Tajnik</span><b>${k.tajnik || '—'}</b></div>
<div class="field"><span>Trener</span><b>${k.trener_glavni || '—'}</b></div>
<div class="field"><span>Br. članova</span><b>${k.broj_clanova || '—'}</b></div>
<div class="field"><span>Aktivnih sportaša</span><b>${k.broj_aktivnih_sportasa || '—'}</b></div>
<div class="field"><span>Aktivan</span><b>${k.aktivan ? '✅ Da' : '❌ Ne'}</b></div>
`;
if (k.web) html += `<div class="field"><span>Web</span><a href="${k.web}" target="_blank">${k.web}</a></div>`;
if (d.dokumenti && d.dokumenti.length) {
html += `<div class="section"><h4>Dokumenti (${d.dokumenti.length})</h4>`;
d.dokumenti.slice(0, 8).forEach(doc => {
html += `<div class="field"><span>${doc.naziv || doc.title || '—'}</span><b style="font-size:10px">${doc.vrsta || '—'}</b></div>`;
});
html += `</div>`;
}
if (d.invoices && d.invoices.length) {
html += `<div class="section"><h4>Računi (${d.invoices.length})</h4>`;
d.invoices.slice(0, 5).forEach(inv => {
html += `<div class="field"><span>${inv.invoice_no || '—'}</span><b>€${(inv.amount_gross || 0).toLocaleString('hr-HR')}</b></div>`;
});
html += `</div>`;
}
html += `<div class="section"><h4>Akcije</h4>
<a href="/sport/klubovi/${d.klub.id}" target="_blank">▸ Otvori detalj klub</a>
</div>`;
$('#dp-content').innerHTML = html;
}
function renderPersonDetail(n) {
let html = `
<div class="field"><span>Tip</span><b>${n.type === 'multichair' ? '⭐ Multi-chair osoba' : 'Osoba'}</b></div>
<div class="field"><span>Org. broj</span><b>${n.n_orgs || '?'}</b></div>
`;
// Pronađi sve veze za ovu osobu
if (lastData && lastData.links) {
const myLinks = lastData.links.filter(l =>
(typeof l.source === 'object' ? l.source.id : l.source) === n.id
);
if (myLinks.length) {
html += `<div class="section"><h4>Pozicije (${myLinks.length})</h4>`;
myLinks.forEach(l => {
const targetNode = typeof l.target === 'object' ? l.target :
(lastData.nodes.find(x => x.id === l.target));
if (targetNode) {
const orgName = targetNode.name || targetNode.id;
const role = l.role || '—';
html += `<div class="field"><span>${orgName}</span><b>${role}</b></div>`;
}
});
html += `</div>`;
}
}
if (n.n_orgs >= 3) {
html += `<div class="section" style="border:1px solid var(--orange);background:rgba(245,158,11,0.1);padding:10px;border-radius:6px;margin-top:12px">
<div style="color:var(--orange);font-weight:600">⚠️ Forenzički flag</div>
<div style="font-size:11px;color:var(--text-2);margin-top:4px">
Ova osoba sjedi na ${n.n_orgs} stolica. Sukob interesa moguć — provjeri financijske transfere između organizacija.
</div>
</div>`;
}
$('#dp-content').innerHTML = html;
}
function renderSavezDetail(n, id) {
let html = `<div class="field"><span>Tip</span><b>Savez</b></div>
<div class="field"><span>ID</span><b>${id}</b></div>`;
if (lastData && lastData.links) {
const klubovi = lastData.links.filter(l =>
(typeof l.target === 'object' ? l.target.id : l.target) === n.id
);
if (klubovi.length) {
html += `<div class="section"><h4>Funkcioneri (${klubovi.length})</h4>`;
klubovi.forEach(l => {
const src = typeof l.source === 'object' ? l.source : null;
if (src) {
html += `<div class="field"><span>${src.name || '?'}</span><b>${l.role || '—'}</b></div>`;
}
});
html += `</div>`;
}
}
$('#dp-content').innerHTML = html;
}
function closeDetail() {
$('#detail-panel').classList.remove('show');
}
function resetCamera() {
if (Graph) Graph.cameraPosition({x:0,y:0,z:300}, {x:0,y:0,z:0}, 1500);
}
// Limit buttons
document.querySelectorAll('.btn[data-limit]').forEach(b => {
b.addEventListener('click', () => {
document.querySelectorAll('.btn[data-limit]').forEach(x => x.classList.remove('active'));
b.classList.add('active');
currentLimit = parseInt(b.dataset.limit);
loadGraph();
});
});
// Search highlight (real-time)
let searchTimeout;
$('#search').addEventListener('input', () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
if (Graph && lastData) renderGraph(lastData);
}, 200);
});
// Mode change auto-reload
$('#mode').addEventListener('change', loadGraph);
$('#sport').addEventListener('change', loadGraph);
$('#minOrgs').addEventListener('change', loadGraph);
// Init
loadSports().then(loadGraph);
</script>
</body>
</html>