From 0337f1438f2c3b10fd8eba35bf1c835f9fd8aec3 Mon Sep 17 00:00:00 2001 From: kodi Date: Sun, 22 Feb 2026 19:59:45 +0100 Subject: [PATCH] feat(gui): netwerk tab netwerk layout grafisch - 02 --- webui/html/assets/css/app.css | 30 ++++++- webui/html/assets/js/tabs/networks.js | 116 ++++++++++++++++++++++++++ webui/html/index.html | 1 + 3 files changed, 146 insertions(+), 1 deletion(-) diff --git a/webui/html/assets/css/app.css b/webui/html/assets/css/app.css index 7b18129..9c482db 100644 --- a/webui/html/assets/css/app.css +++ b/webui/html/assets/css/app.css @@ -571,4 +571,32 @@ pre{ } .mapLegend .legendSwatch.net{ background: rgba(80,160,255,0.35); } -.mapLegend .legendSwatch.ctr{ background: rgba(150,230,150,0.30); } \ No newline at end of file +.mapLegend .legendSwatch.ctr{ background: rgba(150,230,150,0.30); } + +/* ===== Netwerken kaart (D3) ===== */ +.mapHost svg { width: 100%; height: 100%; display:block; } + +.graphLink{ + stroke: rgba(255,255,255,0.22); + stroke-width: 1.2; +} +.graphLink.shared{ + stroke-dasharray: 6 4; + stroke: rgba(255,255,255,0.28); +} + +.graphNode circle{ + stroke: rgba(255,255,255,0.18); + stroke-width: 1; +} +.graphNode.network circle{ fill: rgba(80,160,255,0.35); } +.graphNode.container circle{ fill: rgba(150,230,150,0.30); } + +.graphLabel{ + fill: rgba(255,255,255,0.82); + font-size: 12px; + pointer-events: none; +} + +.graphDim{ opacity: 0.18; } +.graphActive{ opacity: 1; stroke: rgba(255,255,255,0.55); stroke-width: 2; } \ No newline at end of file diff --git a/webui/html/assets/js/tabs/networks.js b/webui/html/assets/js/tabs/networks.js index b639f8b..f467492 100644 --- a/webui/html/assets/js/tabs/networks.js +++ b/webui/html/assets/js/tabs/networks.js @@ -76,6 +76,7 @@ const model = buildGlobalGraphModel(); const s = document.getElementById('networksMapStatus'); if (s) s.textContent = `Kaart: ${model.meta.nodes} nodes, ${model.meta.links} links`; + renderGraph(model); } catch (e) { console.error('[networks] buildGlobalGraphModel failed', e); } @@ -527,6 +528,120 @@ return { nodes, links, meta }; } + let graphCtx = null; + + function renderGraph(model) { + const host = document.getElementById('networksMapHost'); + if (!host) return; + + // leeg host (placeholder weg) + host.innerHTML = ''; + + const w = Math.max(600, host.clientWidth || 600); + const h = Math.max(420, host.clientHeight || 420); + + const svg = d3.select(host).append('svg') + .attr('viewBox', `0 0 ${w} ${h}`); + + const g = svg.append('g'); + + // zoom/pan + svg.call( + d3.zoom() + .scaleExtent([0.2, 2.5]) + .on('zoom', (ev) => g.attr('transform', ev.transform)) + ); + + // links + const link = g.append('g') + .selectAll('line') + .data(model.links) + .enter() + .append('line') + .attr('class', d => d.type === 'shared' ? 'graphLink shared' : 'graphLink'); + + // nodes + const node = g.append('g') + .selectAll('g') + .data(model.nodes) + .enter() + .append('g') + .attr('class', d => `graphNode ${d.type}`); + + node.append('circle') + .attr('r', d => d.type === 'network' ? 10 : 8); + + node.append('text') + .attr('class', 'graphLabel') + .attr('x', 12) + .attr('y', 4) + .text(d => d.label || d.key); + + // drag + const drag = d3.drag() + .on('start', (ev, d) => { + if (!ev.active) graphCtx.sim.alphaTarget(0.2).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) graphCtx.sim.alphaTarget(0); + d.fx = null; d.fy = null; + }); + + node.call(drag); + + // hover highlight (connected) + node.on('mouseenter', (ev, d) => { + node.classed('graphDim', true); + link.classed('graphDim', true); + + d3.select(ev.currentTarget).classed('graphDim', false); + + link.each(function(l) { + const sid = l.source?.id || l.source; + const tid = l.target?.id || l.target; + const hit = (sid === d.id || tid === d.id); + if (hit) { + d3.select(this).classed('graphDim', false).classed('graphActive', true); + } else { + d3.select(this).classed('graphActive', false); + } + }); + }); + + node.on('mouseleave', () => { + node.classed('graphDim', false); + link.classed('graphDim', false).classed('graphActive', false); + }); + + node.on('click', (ev, d) => { + const s = document.getElementById('networksMapStatus'); + if (s) s.textContent = `Geselecteerd: ${d.type} ${d.key}`; + }); + + // simulation + const sim = d3.forceSimulation(model.nodes) + .force('link', d3.forceLink(model.links).id(d => d.id).distance(80)) + .force('charge', d3.forceManyBody().strength(-220)) + .force('center', d3.forceCenter(w / 2, h / 2)) + .force('collide', d3.forceCollide().radius(d => d.type === 'network' ? 18 : 16)) + .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})`); + }); + + graphCtx = { svg, g, sim, model }; + } + + function renderNetworks() { const tbody = document.getElementById('networksTbody'); const rel = document.getElementById('networksRelations'); @@ -688,6 +803,7 @@ const model = buildGlobalGraphModel(); const s = document.getElementById('networksMapStatus'); if (s) s.textContent = `Kaart: ${model.meta.nodes} nodes, ${model.meta.links} links`; + renderGraph(model); } } diff --git a/webui/html/index.html b/webui/html/index.html index 9420417..215f0c6 100644 --- a/webui/html/index.html +++ b/webui/html/index.html @@ -1208,6 +1208,7 @@ setInterval(() => { pingApi(); }, 20000); })(); +