CC1 R3B-P2 — Mreža 3D force graph (replace D3 2D)

- Add three.js + 3d-force-graph CDN script tags
- Replace renderMrezaGraph with ForceGraph3D() implementation
- onNodeClick: center camera + open detail panel
- onNodeHover: cursor swap (grab ↔ pointer)
- ResizeObserver for dynamic container sizing
- Rich HTML node labels with risk score
- Hint overlay: drag rotate, scroll zoom, right-drag pan

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude-cc1
2026-05-05 00:02:29 +02:00
parent 4ecd7fafa3
commit 382d35af30
+61 -62
View File
@@ -9,6 +9,8 @@
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"></script>
<script src="https://unpkg.com/three@0.160.0/build/three.min.js"></script>
<script src="https://unpkg.com/3d-force-graph@1.73.0/dist/3d-force-graph.min.js"></script>
<style> <style>
:root{ :root{
--pgz-blue:#003087; --pgz-blue2:#004CC4; --pgz-gold:#F4C430; --pgz-blue:#003087; --pgz-blue2:#004CC4; --pgz-gold:#F4C430;
@@ -1785,9 +1787,10 @@ function renderMrezaShell(){
<span class="tb-s" id="mr-cnt"></span> <span class="tb-s" id="mr-cnt"></span>
</div> </div>
<div class="card" style="padding:0;overflow:hidden"> <div class="card" style="padding:0;overflow:hidden;position:relative">
<div id="mr-graph" style="width:100%;height:640px;background:radial-gradient(ellipse at center,var(--bg2) 0%,var(--bg0) 100%);position:relative"> <div id="mr-graph" style="width:100%;height:640px;background:#08090e;position:relative;cursor:grab"></div>
<svg id="mr-svg" style="width:100%;height:100%"></svg> <div style="position:absolute;top:10px;right:14px;font-size:10px;color:var(--t4);background:rgba(13,16,33,0.7);padding:4px 8px;border-radius:4px;pointer-events:none">
🖱 Drag • Scroll zoom • Right-drag pan • Click node
</div> </div>
</div> </div>
@@ -1797,7 +1800,7 @@ function renderMrezaShell(){
<div><span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#8b5cf6;vertical-align:middle;margin-right:5px"></span>Osoba</div> <div><span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#8b5cf6;vertical-align:middle;margin-right:5px"></span>Osoba</div>
<div><span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#ff4466;vertical-align:middle;margin-right:5px"></span>Entitet (high risk)</div> <div><span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#ff4466;vertical-align:middle;margin-right:5px"></span>Entitet (high risk)</div>
<div><span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#00e68a;vertical-align:middle;margin-right:5px"></span>Dobavljač</div> <div><span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#00e68a;vertical-align:middle;margin-right:5px"></span>Dobavljač</div>
<div style="color:var(--t2)">Veličina = risk / promet · Klikni čvor za detalje</div> <div style="color:var(--t2)">Veličina = risk / promet · Klikni čvor za detalje · 3D force graph (drag rotate, scroll zoom)</div>
</div> </div>
</div> </div>
`; `;
@@ -1862,77 +1865,73 @@ function resetMreza(){
function renderMrezaGraph(nodes, edges){ function renderMrezaGraph(nodes, edges){
if(!nodes) nodes = (_mreza.allNodes||[]).slice(); if(!nodes) nodes = (_mreza.allNodes||[]).slice();
if(!edges) edges = (_mreza.allEdges||[]).slice(); if(!edges) edges = (_mreza.allEdges||[]).slice();
const svgEl = document.getElementById('mr-svg');
if(!svgEl) return;
const container = document.getElementById('mr-graph'); const container = document.getElementById('mr-graph');
const W = container.clientWidth || 800; if(!container) return;
const H = container.clientHeight || 640;
if(_mreza.sim){ try{_mreza.sim.stop();}catch(e){} _mreza.sim = null; } // Deep-copy so 3d-force-graph doesn't mutate originals across re-renders
const svg = d3.select(svgEl);
svg.selectAll('*').remove();
svg.attr('viewBox', '0 0 '+W+' '+H);
// Deep-copy so D3 sim doesn't mutate originals
const N = nodes.map(n => Object.assign({}, n)); const N = nodes.map(n => Object.assign({}, n));
const Nmap = new Map(N.map(n=>[n.id, n])); const Nmap = new Map(N.map(n=>[n.id, n]));
const E = edges.map(e => ({ const E = edges.map(e => ({
source: Nmap.get(e.source.id||e.source) || (e.source.id||e.source), source: e.source.id || e.source,
target: Nmap.get(e.target.id||e.target) || (e.target.id||e.target), target: e.target.id || e.target,
color: e.color, size: e.size color: e.color, size: e.size
})).filter(e => typeof e.source === 'object' && typeof e.target === 'object'); })).filter(e => Nmap.has(e.source) && Nmap.has(e.target));
// Zoom/pan // Tear down previous graph (re-create on each render to avoid stale state)
const g = svg.append('g'); if(_mreza.graph){
svg.call(d3.zoom().scaleExtent([0.2, 5]).on('zoom', (ev) => g.attr('transform', ev.transform))); try{ _mreza.graph._destructor && _mreza.graph._destructor(); }catch(e){}
container.innerHTML = '';
_mreza.graph = null;
}
const sim = d3.forceSimulation(N) if(typeof ForceGraph3D === 'undefined'){
.force('link', d3.forceLink(E).id(d => d.id).distance(d => 60 + 20/(d.size||1))) container.innerHTML = '<div class="empty" style="padding:40px;color:var(--red)">3D Force Graph biblioteka nije učitana. Provjeri unpkg.com pristup.</div>';
.force('charge', d3.forceManyBody().strength(d => -50 - (d.size||5)*4)) return;
.force('center', d3.forceCenter(W/2, H/2)) }
.force('collide', d3.forceCollide().radius(d => Math.max(6, (d.size||5)*0.7 + 4)));
_mreza.sim = sim;
const link = g.append('g') const W = container.clientWidth || 800;
.attr('stroke-opacity', 0.5) const H = container.clientHeight || 640;
.selectAll('line').data(E).join('line') const Graph = ForceGraph3D()(container)
.attr('stroke', d => d.color || '#283560') .width(W)
.attr('stroke-width', d => Math.max(0.4, (d.size||0.4))); .height(H)
.backgroundColor('#08090e')
.graphData({nodes: N, links: E})
.nodeLabel(n => '<div style="background:rgba(13,16,33,.95);border:1px solid #283560;border-radius:5px;padding:6px 10px;font-family:Inter,sans-serif;font-size:12px;color:#fff"><b>'+(n.label||'').replace(/</g,'&lt;')+'</b><br><span style="color:#8a95b4">'+(n.type||'')+(n.meta&&n.meta.risk?' · risk '+n.meta.risk:'')+'</span></div>')
.nodeColor(n => n.color || '#004CC4')
.nodeVal(n => Math.max(2, (n.size||5)*0.6))
.nodeOpacity(0.92)
.linkColor(l => (l.color||'#283560').replace(/22$/,'') )
.linkWidth(l => Math.max(0.3, (l.size||0.4)*1.5))
.linkOpacity(0.5)
.linkDirectionalParticles(0)
.onNodeClick(n => {
// Center camera on node + open detail panel
const dist = 80;
const distRatio = 1 + dist/Math.hypot(n.x||1, n.y||1, n.z||1);
Graph.cameraPosition(
{ x:(n.x||0)*distRatio, y:(n.y||0)*distRatio, z:(n.z||0)*distRatio },
n,
800
);
openMrezaNode(n);
})
.onNodeHover(n => { container.style.cursor = n ? 'pointer' : 'grab'; });
const node = g.append('g') _mreza.graph = Graph;
.selectAll('g').data(N).join('g')
.style('cursor','pointer')
.call(d3.drag()
.on('start', (ev,d) => { if(!ev.active) sim.alphaTarget(0.3).restart(); d.fx=d.x; d.fy=d.y; })
.on('drag', (ev,d) => { d.fx=ev.x; d.fy=ev.y; })
.on('end', (ev,d) => { if(!ev.active) sim.alphaTarget(0); d.fx=null; d.fy=null; }))
.on('click', (ev,d) => openMrezaNode(d));
node.append('circle') // Resize handler — re-flow when container dims change
.attr('r', d => Math.max(5, (d.size||5)*0.7)) if(_mreza.resizeObs){ try{_mreza.resizeObs.disconnect();}catch(e){} }
.attr('fill', d => d.color || '#004CC4') _mreza.resizeObs = new ResizeObserver(entries => {
.attr('stroke', '#0d1021') for(const ent of entries){
.attr('stroke-width', 1.5); const cw = ent.contentRect.width|0, ch = ent.contentRect.height|0;
if(cw>0 && ch>0 && _mreza.graph){
node.append('text') try{ _mreza.graph.width(cw).height(ch); }catch(e){}
.text(d => (d.label||'').slice(0,28)) }
.attr('x', d => Math.max(6, (d.size||5)*0.7) + 4) }
.attr('y', 4)
.attr('fill', '#e2e6f0')
.attr('font-size', '10px')
.attr('font-family', 'Inter, sans-serif')
.style('pointer-events','none');
node.append('title').text(d => (d.label||'')+' ['+d.type+']');
sim.on('tick', () => {
link.attr('x1', d=>d.source.x).attr('y1', d=>d.source.y)
.attr('x2', d=>d.target.x).attr('y2', d=>d.target.y);
node.attr('transform', d => 'translate('+d.x+','+d.y+')');
}); });
_mreza.resizeObs.observe(container);
if(!$('#mr-cnt').textContent){ if($('#mr-cnt') && !$('#mr-cnt').textContent){
$('#mr-cnt').textContent = N.length+' čvorova · '+E.length+' veza'; $('#mr-cnt').textContent = N.length+' čvorova · '+E.length+' veza';
} }
} }