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:
+61
-62
@@ -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,'<')+'</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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user