// webui/html/assets/js/tabs/networks.js (function () { // Gebruik gedeelde helpers uit index.html (staan op window in non-module scripts) const apiGet = window.apiGet || (async (path) => { const r = await fetch('/api' + path, { headers: { 'Accept': 'application/json' } }); if (!r.ok) { const t = await r.text().catch(() => ''); throw new Error(`HTTP ${r.status} ${path}: ${t}`); } return r.json(); }); const esc = window.esc || ((s) => String(s) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", ''') ); function cssId(s) { return String(s).replaceAll(/[^a-zA-Z0-9_-]/g, '_'); } async function fetchNetworksUsage() { return apiGet('/networks/usage'); } async function fetchNetworksList() { // returns { networks: [...] } return apiGet('/networks'); } async function fetchNetworkInspect(name) { return apiGet('/networks/' + encodeURIComponent(name)); } const state = { expanded: new Set(), usage: null, list: null, inspectCache: new Map(), view: 'table', // 'table' | 'map' mapMode: 'global', // 'global' | 'detail' selectedNetwork: null, // string filters: { q: '', connectedOnly: false, hideDefaults: true, sharedOnly: false, showModes: true, sort: 'name_asc', }, }; function setNetworksView(view) { const v = (view === 'map') ? 'map' : 'table'; state.view = v; const table = document.getElementById('networksTable'); const relCard = document.getElementById('networksRelationsCard'); const mapWrap = document.getElementById('networksMapWrap'); if (table) table.style.display = (v === 'table') ? '' : 'none'; if (relCard) relCard.style.display = (v === 'table') ? '' : 'none'; if (mapWrap) mapWrap.style.display = (v === 'map') ? '' : 'none'; // Toggle active state in segmented control const toggle = document.getElementById('networksViewToggle'); if (toggle) { const btns = toggle.querySelectorAll('button[data-view]'); btns.forEach(b => { const bv = b.getAttribute('data-view'); if (bv === v) b.classList.add('active'); else b.classList.remove('active'); }); } if (v === 'map') { openGlobalMap(); } } function toggleNetworkRow(name) { if (state.expanded.has(name)) state.expanded.delete(name); else state.expanded.add(name); renderNetworks(); } async function refresh() { const statusEl = document.getElementById('networksStatus'); if (statusEl) statusEl.textContent = 'Bezig met laden...'; try { const [usage, list] = await Promise.all([ fetchNetworksUsage(), fetchNetworksList(), ]); state.usage = usage; state.list = list; if (statusEl) statusEl.textContent = `Laatst geladen: ${new Date().toLocaleString()}`; renderNetworksSummary(); renderNetworks(); } catch (e) { console.error(e); if (statusEl) statusEl.textContent = 'Fout: ' + (e?.message || e); } } function computeNetworkStats() { const totalNetworks = Array.isArray(state.list?.networks) ? state.list.networks.length : 0; const byNetwork = state.usage?.byNetwork || {}; const usedNetworks = Object.keys(byNetwork).length; let connectedContainers = 0; for (const v of Object.values(byNetwork)) { const cs = v?.containers; if (Array.isArray(cs)) connectedContainers += cs.length; } const unusedNetworks = Math.max(0, totalNetworks - usedNetworks); const byMeta = state.usage?.byContainerMeta || {}; let sharedNetns = 0; for (const meta of Object.values(byMeta)) { if (meta?.networkMode && String(meta.networkMode).startsWith('container:')) sharedNetns += 1; } return { totalNetworks, usedNetworks, unusedNetworks, connectedContainers, sharedNetns }; } function buildMapStatus(label, nodesCount, linksCount) { const f = state.filters || {}; const flags = []; if (f.connectedOnly) flags.push('connected'); if (f.hideDefaults) flags.push('hide-defaults'); if (f.sharedOnly) flags.push('shared'); if (f.showModes === false) flags.push('modes-off'); const flagTxt = flags.length ? ` | ${flags.join(', ')}` : ''; return `${label} | ${nodesCount} nodes, ${linksCount} links${flagTxt}`; } function renderNetworksSummary() { const host = document.getElementById('networksSummary'); if (!host) return; // beginstatus if (!state.list && !state.usage) { host.innerHTML = `
-
Netwerken
-
Verbonden containers
-
Ongebruikt
-
Shared netns
`; return; } const s = computeNetworkStats(); host.innerHTML = `
${s.totalNetworks}
Netwerken
${s.usedNetworks} in gebruik
${s.connectedContainers}
Verbonden containers
${s.unusedNetworks}
Ongebruikt
${s.sharedNetns}
Shared netns
`; } function renderNetworkUsersListHTML(netName, containers) { if (!containers.length) { return `
Geen containers gevonden op dit netwerk.
`; } const rows = containers.map(c => { const cname = c.name || '?'; const pod = c.pod || ''; const shared = c.networkMode && String(c.networkMode).startsWith('container:'); const via = c.networkOwnerName ? `via ${c.networkOwnerName}` : (c.networkOwnerId ? `via ${c.networkOwnerId}` : ''); const badges = []; if (pod) badges.push(`pod: ${esc(pod)}`); if (shared) badges.push(`shared netns`); if (via) badges.push(`${esc(via)}`); return `
  • ${esc(cname)} ${badges.length ? `
    ${badges.join('')}
    ` : ``}
  • `; }).join(''); return `
    Containers op ${esc(netName)}:
    `; } function renderNetworksRelationsHTML(usage) { const byContainer = usage.byContainer || {}; const byMeta = usage.byContainerMeta || {}; const multi = Object.entries(byContainer) .filter(([_, nets]) => Array.isArray(nets) && nets.length > 1) .sort((a, b) => a[0].localeCompare(b[0])); const shared = Object.entries(byMeta) .filter(([_, meta]) => meta && meta.networkMode && String(meta.networkMode).startsWith('container:')) .sort((a, b) => a[0].localeCompare(b[0])); let html = ''; html += `

    Containers met meerdere netwerken

    `; if (!multi.length) { html += `
    Geen.
    `; } else { html += ``; } html += `

    Shared network namespace

    `; if (!shared.length) { html += `
    Geen.
    `; } else { html += ``; } return html; } async function onInspectNetwork(name) { const box = document.getElementById('inspectBox-' + cssId(name)); const status = document.getElementById('inspectStatus-' + cssId(name)); if (status) status.textContent = 'Inspect laden...'; try { let data = state.inspectCache.get(name); if (!data) { data = await fetchNetworkInspect(name); state.inspectCache.set(name, data); } if (box) { box.style.display = ''; box.textContent = JSON.stringify(data, null, 2); } if (status) status.textContent = ''; } catch (e) { console.error(e); if (status) status.textContent = 'Fout: ' + (e?.message || e); } } function isDefaultNetworkName(name) { return name === 'podman' || name === 'podman-default-kube-network'; } function fmtSubnets(net) { // We proberen verschillende mogelijke shapes: // - net.subnets: ["10.88.0.0/16"] // - net.subnets: [{subnet:"10.88.0.0/16", gateway:"10.88.0.1"}] // - net.ipam / net.ipamConfig: unknown => tonen we niets i.p.v. crash const s = net?.subnets; if (!s) return []; if (Array.isArray(s)) { return s.map(x => { if (typeof x === 'string') return x; if (x && typeof x === 'object') { const subnet = x.subnet || x.Subnet || ''; const gw = x.gateway || x.Gateway || ''; return gw ? `${subnet} (gw ${gw})` : subnet; } return String(x); }).filter(Boolean); } return [String(s)]; } function flagsHTML(netName, netMeta) { const badges = []; if (isDefaultNetworkName(netName)) badges.push(`default`); if (netMeta?.internal === true) badges.push(`internal`); if (netMeta?.dnsEnabled === true) badges.push(`dns`); if (netMeta?.ipv6Enabled === true) badges.push(`ipv6`); // driver badge (maar niet voor mode, die tonen we in driver kolom) if (netMeta?.driver) { const d = String(netMeta.driver); if (d !== 'mode') { badges.push(`${esc(d)}`); } } return badges.join(' '); } function buildNetworksViewModel() { const list = Array.isArray(state.list?.networks) ? state.list.networks : []; const usageByNetwork = state.usage?.byNetwork || {}; // 1) Start met de lijst uit /api/networks (leidend voor metadata/unused) const items = list .map(n => { const name = n?.name || n?.Name || ''; const usage = usageByNetwork[name] || null; const containers = Array.isArray(usage?.containers) ? usage.containers : []; return { name, meta: n || {}, containers, containerCount: containers.length, }; }) .filter(x => x.name); // sanity // 2) Voeg "usage-only" netwerken toe (zoals pasta/host/none) die niet in /api/networks staan const seen = new Set(items.map(x => x.name)); for (const netName of Object.keys(usageByNetwork)) { if (seen.has(netName)) continue; const usage = usageByNetwork[netName] || null; const containers = Array.isArray(usage?.containers) ? usage.containers : []; // meta leeg, maar we geven driver=mode zodat het netjes in de UI komt items.push({ name: netName, meta: { driver: 'mode' }, containers, containerCount: containers.length, }); seen.add(netName); } return items; } function applyFiltersAndSort(vm) { const q = (state.filters.q || '').trim().toLowerCase(); const connectedOnly = !!state.filters.connectedOnly; const hideDefaults = !!state.filters.hideDefaults; const sharedOnly = !!state.filters.sharedOnly; const showModes = state.filters.showModes !== false; const byMeta = state.usage?.byContainerMeta || {}; let out = vm.slice(); if (hideDefaults) { out = out.filter(n => !isDefaultNetworkName(n.name)); } if (!showModes) { out = out.filter(n => String(n.meta?.driver || '') !== 'mode'); } if (connectedOnly) { out = out.filter(n => n.containerCount > 0); } if (sharedOnly) { const byMeta = state.usage?.byContainerMeta || {}; const byContainer = state.usage?.byContainer || {}; // alle containers met networkMode=container:... const sharedEntries = Object.entries(byMeta) .filter(([_, meta]) => String(meta?.networkMode ?? "").startsWith("container:")); // welke netwerken zijn "relevant" voor shared netns? const sharedNetworks = new Set(); // helper: voeg netwerken van containerKey toe via byContainer function addNetworksFor(containerKey) { const nets = byContainer[containerKey]; if (Array.isArray(nets)) { for (const n of nets) sharedNetworks.add(n); } } for (const [name, meta] of sharedEntries) { addNetworksFor(name); // netwerken van de shared container zelf // netwerken van de owner/infra (heel belangrijk in jouw geval) const ownerName = meta?.networkOwnerName; if (ownerName) addNetworksFor(ownerName); } out = out.filter(n => sharedNetworks.has(n.name)); } if (q) { out = out.filter(n => { const nameHit = n.name.toLowerCase().includes(q); const driver = (n.meta?.driver || '').toLowerCase(); const driverHit = driver.includes(q); const subnets = fmtSubnets(n.meta).join(' ').toLowerCase(); const subnetHit = subnets.includes(q); return nameHit || driverHit || subnetHit; }); } const sort = state.filters.sort || 'name_asc'; if (sort === 'containers_desc') { out.sort((a, b) => (b.containerCount - a.containerCount) || a.name.localeCompare(b.name)); } else if (sort === 'driver_asc') { out.sort((a, b) => String(a.meta?.driver || '').localeCompare(String(b.meta?.driver || '')) || a.name.localeCompare(b.name)); } else { out.sort((a, b) => a.name.localeCompare(b.name)); } return out; } function _ellipsize(s, n) { s = String(s ?? ''); if (s.length <= n) return s; return s.slice(0, Math.max(0, n - 1)) + '…'; } function buildGlobalGraphModel() { // We nemen exact dezelfde selectie als de tabel. // 1) Bouw dezelfde rows als tabel const vm = buildNetworksViewModel(); const rows = applyFiltersAndSort(vm); const usage = state.usage || {}; const byNetwork = usage.byNetwork || {}; const byContainerMeta = usage.byContainerMeta || {}; const nodes = []; const links = []; const nodeIndex = new Map(); // key -> nodeId const idByName = new Map(); // "mvp-webui" -> "" const nameById = new Map(); // "" -> "mvp-webui" function addNode(key, node) { if (nodeIndex.has(key)) return nodeIndex.get(key); node.id = key; // stabiel ID nodes.push(node); nodeIndex.set(key, key); return key; } // Network nodes: alleen netwerken die in de huidige tabel-selectie zitten for (const r of rows) { const nkey = `net:${r.name}`; addNode(nkey, { type: 'network', key: r.name, label: _ellipsize(r.name, 22), meta: r.meta || {}, usageCount: Number(r.containerCount || 0), flags: { internal: !!r.meta?.internal, dnsEnabled: !!r.meta?.dnsEnabled, ipv6Enabled: !!r.meta?.ipv6Enabled, isDefault: !!r.meta?.isDefault, driver: r.meta?.driver || '' } }); // Container links + container nodes vanuit usage.byNetwork const netUsage = byNetwork[r.name]; const ctrs = (netUsage && Array.isArray(netUsage.containers)) ? netUsage.containers : []; for (const c of ctrs) { const ckey = `ctr:${c.id}`; addNode(ckey, { type: 'container', key: c.id, label: _ellipsize(c.name || c.id.slice(0, 12), 22), name: c.name || '', pod: c.pod || '', networkMode: c.networkMode || '', }); if (c.name) idByName.set(String(c.name), String(c.id)); nameById.set(String(c.id), String(c.name || '')); links.push({ type: 'attach', source: nkey, target: ckey }); } } // Optionele shared-netns links (stippellijn) op basis van byContainerMeta.networkMode=container: // We maken alleen links als beide kanten in het huidige model zitten (licht + consistent met filter) for (const [ctrId, cm] of Object.entries(byContainerMeta)) { const rawKey = String(ctrId); const mode = String(cm?.networkMode || ''); if (!mode.startsWith('container:')) continue; const ownerId = mode.split(':', 2)[1] || ''; if (!ownerId) continue; // byContainerMeta kan keys hebben als NAME of ID → normaliseer naar ID als we kunnen const childId = idByName.get(rawKey) || rawKey; const ownerKey = `ctr:${ownerId}`; const childKey = `ctr:${childId}`; if (nodeIndex.has(ownerKey) && nodeIndex.has(childKey)) { links.push({ type: 'shared', source: ownerKey, target: childKey, ownerName: cm?.networkOwnerName || '', ownerId: cm?.networkOwnerId || '' }); } } // Kleine meta voor debug / later const meta = { view: 'global', networksSelected: rows.length, nodes: nodes.length, links: links.length }; return { nodes, links, meta }; } function buildNetworkDetailGraphModel(networkName) { const usage = state.usage || {}; const byNetwork = usage.byNetwork || {}; const byContainerMeta = usage.byContainerMeta || {}; // netwerk meta zoeken const list = state.list?.networks || []; const netMeta = list.find(n => n?.name === networkName) || null; const nodes = []; const links = []; const nodeIndex = new Map(); function addNode(key, node) { if (nodeIndex.has(key)) return key; node.id = key; nodes.push(node); nodeIndex.set(key, 1); return key; } const nkey = `net:${networkName}`; addNode(nkey, { type: 'network', key: networkName, label: _ellipsize(networkName, 26), meta: netMeta || {} }); const netUsage = byNetwork[networkName]; const ctrs = (netUsage && Array.isArray(netUsage.containers)) ? netUsage.containers : []; const idByName = new Map(); for (const c of ctrs) { const ckey = `ctr:${c.id}`; addNode(ckey, { type: 'container', key: c.id, label: _ellipsize(c.name || c.id.slice(0, 12), 26), name: c.name || '', pod: c.pod || '', networkMode: c.networkMode || '', }); if (c.name) idByName.set(String(c.name), String(c.id)); links.push({ type: 'attach', source: nkey, target: ckey }); } // shared-netns links alleen binnen deze set containers for (const [ctrKey, cm] of Object.entries(byContainerMeta)) { const mode = String(cm?.networkMode || ''); if (!mode.startsWith('container:')) continue; const ownerId = mode.split(':', 2)[1] || ''; if (!ownerId) continue; const childId = idByName.get(String(ctrKey)) || String(ctrKey); const ownerNode = `ctr:${ownerId}`; const childNode = `ctr:${childId}`; if (nodeIndex.has(ownerNode) && nodeIndex.has(childNode)) { links.push({ type: 'shared', source: ownerNode, target: childNode }); } } return { nodes, links, meta: { view: 'detail', networkName } }; } let graphCtx = null; function renderGraph(model, opts = {}) { 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 const zoom = d3.zoom() .scaleExtent([0.2, 2.5]) .on('zoom', (ev) => g.attr('transform', ev.transform)); svg.call(zoom); // 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) => { if (d.type === 'network') { openNetworkDetail(d.key); return; } 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(-30)) .force('center', d3.forceCenter(w / 2, h / 2).strength(0.15)) .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})`); }); // detail-mode: pin netwerk in het midden if (opts.pinNetwork) { const pinId = opts.pinNetwork; const pinned = model.nodes.find(n => n.id === pinId); if (pinned) { pinned.fx = w / 2; pinned.fy = h / 2; } } graphCtx = { svg, g, sim, model, zoom }; } function openNetworkDetail(networkName) { state.mapMode = 'detail'; state.selectedNetwork = networkName; const model = buildNetworkDetailGraphModel(networkName); renderGraph(model, { pinNetwork: `net:${networkName}` }); showDetailPanel(networkName); const s = document.getElementById('networksMapStatus'); if (s) s.textContent = buildMapStatus(`Detail: ${networkName}`, model.nodes.length, model.links.length); } function showDetailPanel(networkName) { const panel = document.getElementById('networksDetailPanel'); const title = document.getElementById('networksDetailTitle'); const body = document.getElementById('networksDetailBody'); if (!panel || !title || !body) return; // meta uit state.list (kan null zijn bij mode-netwerken) const list = Array.isArray(state.list?.networks) ? state.list.networks : []; const meta = list.find(n => (n?.name || n?.Name) === networkName) || { name: networkName, driver: 'mode' }; // containers uit usage const usage = state.usage?.byNetwork?.[networkName]; const containers = Array.isArray(usage?.containers) ? usage.containers : []; title.textContent = networkName; const driver = String(meta?.driver || meta?.Driver || ''); const subnets = fmtSubnets(meta).join(', '); const internal = meta?.internal === true ? 'ja' : 'nee'; const dns = meta?.dnsEnabled === true ? 'ja' : 'nee'; const ipv6 = meta?.ipv6Enabled === true ? 'ja' : 'nee'; const listHtml = containers.length ? `
      ${containers.map(c => { const label = esc(c.name || c.id.slice(0, 12)); return `
    • ${label}
    • `; }).join('')}
    ` : `
    Geen containers op dit netwerk.
    `; body.innerHTML = `
    Driver
    ${esc(driver || '—')}
    Subnets
    ${esc(subnets || '—')}
    Internal
    ${esc(internal)}
    DNS
    ${esc(dns)}
    IPv6
    ${esc(ipv6)}
    Containers
    ${containers.length}
    ${listHtml} `; panel.style.display = ''; } function hideDetailPanel() { const panel = document.getElementById('networksDetailPanel'); if (panel) panel.style.display = 'none'; } function openGlobalMap() { state.mapMode = 'global'; state.selectedNetwork = null; const model = buildGlobalGraphModel(); renderGraph(model); hideDetailPanel(); const s = document.getElementById('networksMapStatus'); if (s) s.textContent = buildMapStatus('Global', model.nodes.length, model.links.length); } function renderNetworks() { const tbody = document.getElementById('networksTbody'); const rel = document.getElementById('networksRelations'); if (!tbody || !rel) return; tbody.innerHTML = ''; rel.innerHTML = ''; const usage = state.usage; if (!usage || !usage.byNetwork) { tbody.innerHTML = `Geen data. Klik op Vernieuwen.`; rel.innerHTML = `
    Geen data.
    `; return; } const vmAll = buildNetworksViewModel(); const vm = applyFiltersAndSort(vmAll); for (const row of vm) { const netName = row.name; const containers = row.containers; const meta = row.meta; const isOpen = state.expanded.has(netName); const arrow = isOpen ? '▾' : '▸'; const driver = meta?.driver || meta?.Driver || ''; const subnets = fmtSubnets(meta); const subnetsHtml = subnets.length ? `
    ${subnets.slice(0, 2).map(s => `${esc(s)}`).join('')}${subnets.length > 2 ? `+${subnets.length - 2}` : ''}
    ` : ( (meta?.driver === 'mode') ? `network mode` : `` ); // 1. Controleer of dit een shared netwerk is const isShared = containers.some(c => typeof c?.networkMode === 'string' && c.networkMode.startsWith('container:')); const tr = document.createElement('tr'); tr.innerHTML = `
    ${esc(netName)} ${isDefaultNetworkName(netName) ? `default` : ``}
    ${driver ? (String(driver) === 'mode' ? `MODE` : esc(driver)) : ``} ${subnetsHtml} ${containers.length}
    ${isShared ? `shared` : ``} ${flagsHTML(netName, meta)}
    `; tr.querySelector('button').addEventListener('click', () => toggleNetworkRow(netName)); tbody.appendChild(tr); if (isOpen) { const tr2 = document.createElement('tr'); tr2.innerHTML = `
    ${renderNetworkUsersListHTML(netName, containers)}
    `; tbody.appendChild(tr2); const btn = tr2.querySelector('button[data-inspect]'); btn.addEventListener('click', async () => { await onInspectNetwork(netName); }); } } rel.innerHTML = renderNetworksRelationsHTML(usage); } function bindUiOnce() { const btn = document.getElementById('networksRefreshBtn'); if (btn && !btn.dataset.bound) { btn.dataset.bound = '1'; btn.addEventListener('click', refresh); } // View toggle (Tabel/Kaart) const viewToggle = document.getElementById('networksViewToggle'); if (viewToggle && !viewToggle.dataset.bound) { viewToggle.dataset.bound = '1'; viewToggle.addEventListener('click', (ev) => { const t = ev.target; if (!(t instanceof HTMLElement)) return; const b = t.closest('button[data-view]'); if (!b) return; setNetworksView(b.getAttribute('data-view')); }); } // Map toolbar placeholders (no-op for now) const legendBtn = document.getElementById('networksMapLegendBtn'); const legend = document.getElementById('networksMapLegend'); if (legendBtn && !legendBtn.dataset.bound) { legendBtn.dataset.bound = '1'; legendBtn.addEventListener('click', () => { if (!legend) return; legend.style.display = (legend.style.display === 'none' || !legend.style.display) ? '' : 'none'; }); } const resetBtn = document.getElementById('networksMapResetBtn'); if (resetBtn && !resetBtn.dataset.bound) { resetBtn.dataset.bound = '1'; resetBtn.addEventListener('click', () => { if (!graphCtx) return; // wis highlight/dim graphCtx.g.selectAll('.graphNode').classed('graphDim', false); graphCtx.g.selectAll('.graphLink').classed('graphDim', false).classed('graphActive', false); // reset zoom graphCtx.svg .transition() .duration(400) .call(graphCtx.zoom.transform, d3.zoomIdentity); // restart simulation graphCtx.model.nodes.forEach(n => { n.fx = null; n.fy = null; }); graphCtx.sim.alpha(1).restart(); }); } const layoutBtn = document.getElementById('networksMapLayoutBtn'); if (layoutBtn && !layoutBtn.dataset.bound) { layoutBtn.dataset.bound = '1'; layoutBtn.addEventListener('click', () => { if (!graphCtx) return; graphCtx.model.nodes.forEach(n => { n.fx = null; n.fy = null; }); graphCtx.sim.alpha(1.5).restart(); }); } const backBtn = document.getElementById('networksMapBackBtn'); if (backBtn && !backBtn.dataset.bound) { backBtn.dataset.bound = '1'; backBtn.addEventListener('click', () => { openGlobalMap(); const panel = document.getElementById('networksDetailPanel'); if (panel) panel.style.display = 'none'; }); } const detailBody = document.getElementById('networksDetailBody'); if (detailBody && !detailBody.dataset.bound) { detailBody.dataset.bound = '1'; detailBody.addEventListener('click', (ev) => { const t = ev.target; if (!(t instanceof HTMLElement)) return; const el = t.closest('[data-ctr]'); if (!el) return; const id = el.getAttribute('data-ctr'); if (!id || !graphCtx) return; // highlight: dim alles, highlight gekozen container + links const targetId = `ctr:${id}`; graphCtx.g.selectAll('.graphNode').classed('graphDim', true); graphCtx.g.selectAll('.graphLink').classed('graphDim', true).classed('graphActive', false); graphCtx.g.selectAll('.graphNode').each(function(d) { if (d?.id === targetId) d3.select(this).classed('graphDim', false); }); graphCtx.g.selectAll('.graphLink').each(function(l) { const sid = l.source?.id || l.source; const tid = l.target?.id || l.target; if (sid === targetId || tid === targetId) { d3.select(this).classed('graphDim', false).classed('graphActive', true); } }); const s = document.getElementById('networksMapStatus'); if (s) { const label = (state.mapMode === 'detail' && state.selectedNetwork) ? `Detail: ${state.selectedNetwork}` : 'Global'; s.textContent = buildMapStatus(label, graphCtx.model.nodes.length, graphCtx.model.links.length) + ` | container: ${id.slice(0, 12)}…`; } }); } const search = document.getElementById('networksSearch'); const fConnected = document.getElementById('networksFilterConnected'); const fHideDefaults = document.getElementById('networksFilterHideDefaults'); const fShared = document.getElementById('networksFilterShared'); const sort = document.getElementById('networksSort'); const mapShowModes = document.getElementById('networksMapShowModes'); const mapConnectedOnly = document.getElementById('networksMapConnectedOnly'); function rerender() { renderNetworks(); if (state.view === 'map') { // als we in detail zitten → detail opnieuw renderen if (state.mapMode === 'detail' && state.selectedNetwork) { openNetworkDetail(state.selectedNetwork); return; } // anders global map const model = buildGlobalGraphModel(); const s = document.getElementById('networksMapStatus'); if (s) s.textContent = buildMapStatus('Global', model.nodes.length, model.links.length); renderGraph(model); } } if (search && !search.dataset.bound) { search.dataset.bound = '1'; search.addEventListener('input', () => { state.filters.q = search.value || ''; rerender(); }); } if (fConnected && !fConnected.dataset.bound) { fConnected.dataset.bound = '1'; fConnected.addEventListener('change', () => { state.filters.connectedOnly = !!fConnected.checked; rerender(); }); } if (fHideDefaults && !fHideDefaults.dataset.bound) { fHideDefaults.dataset.bound = '1'; state.filters.hideDefaults = !!fHideDefaults.checked; fHideDefaults.addEventListener('change', () => { state.filters.hideDefaults = !!fHideDefaults.checked; rerender(); }); } if (fShared && !fShared.dataset.bound) { fShared.dataset.bound = '1'; fShared.addEventListener('change', () => { state.filters.sharedOnly = !!fShared.checked; rerender(); }); } if (sort && !sort.dataset.bound) { sort.dataset.bound = '1'; sort.addEventListener('change', () => { state.filters.sort = sort.value || 'name_asc'; rerender(); }); } if (mapConnectedOnly && !mapConnectedOnly.dataset.bound) { mapConnectedOnly.dataset.bound = '1'; mapConnectedOnly.checked = !!state.filters.connectedOnly; mapConnectedOnly.addEventListener('change', () => { state.filters.connectedOnly = !!mapConnectedOnly.checked; // sync ook de tabel checkbox (zodat alles hetzelfde blijft) const tableChk = document.getElementById('networksFilterConnected'); if (tableChk) tableChk.checked = !!state.filters.connectedOnly; rerender(); }); } if (mapShowModes && !mapShowModes.dataset.bound) { mapShowModes.dataset.bound = '1'; mapShowModes.checked = state.filters.showModes !== false; mapShowModes.addEventListener('change', () => { state.filters.showModes = !!mapShowModes.checked; rerender(); }); } renderNetworksSummary(); setNetworksView(state.view); // sync kaart-checkbox bij init if (mapConnectedOnly) { mapConnectedOnly.checked = !!state.filters.connectedOnly; } } // Expose minimal API window.mvpNetworks = { refresh, state, bindUiOnce, debugGraph: () => { const model = buildGlobalGraphModel(); console.log('[networks] graph meta', model.meta); console.table(model.nodes.slice(0, 12)); console.table(model.links.slice(0, 12)); return model; }, }; // Bind when script loads (DOM is already mostly there because script is at end of body) bindUiOnce(); })();