534 lines
17 KiB
HTML
534 lines
17 KiB
HTML
<!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>
|