(function () { const NS = 'http://www.w3.org/2000/svg'; const TIER_ROWS = [4, 5, 7]; const MIN_NODE_GAP = 148; const VIEW_W = 1200; const VIEW_H = 860; /** Korte rol-context per agent (NL) */ const AGENT_HINTS = { marketing: { short: 'Campagnes', domain: 'Groei' }, retail: { short: 'Supermarkten', domain: 'Groei' }, bizdev: { short: 'Deals', domain: 'Groei' }, research: { short: 'Marktintel', domain: 'Groei' }, browser: { short: 'Web research', domain: 'Groei' }, webbuilder: { short: 'Websites', domain: 'Product' }, product: { short: 'Assortiment', domain: 'Product' }, packaging: { short: 'Verpakking', domain: 'Product' }, design: { short: 'Visuals', domain: 'Product' }, halal: { short: 'Compliance', domain: 'Product' }, sourcing: { short: 'Inkoop', domain: 'Product' }, finance: { short: 'Cijfers', domain: 'Operatie' }, hr: { short: 'Team', domain: 'Operatie' }, knowledge: { short: 'Kennisbank', domain: 'Operatie' }, email: { short: 'Inbox', domain: 'Operatie' }, sysops: { short: 'Infra', domain: 'Operatie' }, }; const CLUSTERS = { growth: { label: 'Groei', icon: '📈', color: 'rgba(0, 229, 255, 0.14)', stroke: 'rgba(0, 229, 255, 0.35)', agents: ['marketing', 'retail', 'bizdev', 'research', 'browser'], }, product: { label: 'Product', icon: '📦', color: 'rgba(168, 85, 247, 0.12)', stroke: 'rgba(168, 85, 247, 0.35)', agents: ['product', 'packaging', 'design', 'halal', 'sourcing', 'webbuilder'], }, ops: { label: 'Operatie', icon: '⚙️', color: 'rgba(34, 197, 94, 0.1)', stroke: 'rgba(34, 197, 94, 0.32)', agents: ['finance', 'hr', 'knowledge', 'email', 'sysops'], }, }; /** @type {Record} */ const COMM_MODES = { pyramid: { id: 'pyramid', label: 'Piramide', subtitle: 'Herman als hub', icon: '◆', desc: 'Rapport ↑ en delegatie ↓. Alleen live handoffs tussen agents.', layout: 'pyramid', showExecutive: true, showReport: true, showDelegate: true, showLivePeers: true, showStaticPeers: false, activeOnly: false, edgeBend: 0.42, }, mesh: { id: 'mesh', label: 'Vol netwerk', subtitle: 'Alle partners', icon: '⬡', desc: 'Volledige samenwerkingsmatrix — wie met wie kan werken.', layout: 'pyramid', showExecutive: true, showReport: true, showDelegate: true, showLivePeers: true, showStaticPeers: true, activeOnly: false, staticPeerOpacity: 0.35, edgeBend: 0.38, }, org: { id: 'org', label: 'Organigram', subtitle: 'Pure hiërarchie', icon: '▤', desc: 'CEO → Herman → agents. Geen peer-lijnen, overzichtelijk.', layout: 'pyramid', showExecutive: true, showReport: true, showDelegate: true, showLivePeers: false, showStaticPeers: false, activeOnly: false, edgeBend: 0.12, straightExec: true, }, pulse: { id: 'pulse', label: 'Live stream', subtitle: 'Alleen actief', icon: '◎', desc: 'Geen stille lijnen — alleen wat nu beweegt (pulse).', layout: 'pyramid', showExecutive: true, showReport: true, showDelegate: true, showLivePeers: true, showStaticPeers: false, activeOnly: true, edgeBend: 0.45, }, clusters: { id: 'clusters', label: 'Domeinen', subtitle: 'Per afdeling', icon: '◉', desc: 'Groei · Product · Operatie — agents gegroepeerd rond Herman.', layout: 'clusters', showExecutive: true, showReport: true, showDelegate: true, showLivePeers: true, showStaticPeers: true, activeOnly: false, staticPeerOpacity: 0.2, edgeBend: 0.5, intraClusterOnly: false, }, }; const STORAGE_KEY = 'foodlinkk_mesh_comm_mode'; let cachedData = null; let pickerBound = false; let onModeChangeCb = null; let lastMountOpts = {}; function make(tag, attrs) { attrs = attrs || {}; const el = document.createElementNS(NS, tag); Object.entries(attrs).forEach(function (pair) { el.setAttribute(pair[0], String(pair[1])); }); return el; } function clear(el) { while (el.firstChild) el.removeChild(el.firstChild); } function curvePath(x1, y1, x2, y2, bend) { if (bend != null && bend < 0.18) { return 'M ' + x1 + ' ' + y1 + ' L ' + x2 + ' ' + y2; } const midY = y1 + (y2 - y1) * (bend != null ? bend : 0.42); return 'M ' + x1 + ' ' + y1 + ' C ' + x1 + ' ' + midY + ', ' + x2 + ' ' + midY + ', ' + x2 + ' ' + y2; } function clusterOf(key) { const k = (key || '').toLowerCase(); return ( Object.keys(CLUSTERS).find(function (cid) { return CLUSTERS[cid].agents.indexOf(k) >= 0; }) || null ); } function spreadTier(nodes, width, minGap) { if (nodes.length <= 1) return; nodes.sort(function (a, b) { return a._x - b._x; }); for (let i = 1; i < nodes.length; i++) { const gap = nodes[i]._x - nodes[i - 1]._x; if (gap < minGap) { const shift = minGap - gap; for (let j = i; j < nodes.length; j++) nodes[j]._x += shift; } } const tierCx = (nodes[0]._x + nodes[nodes.length - 1]._x) / 2; const offset = width / 2 - tierCx; nodes.forEach(function (n) { n._x += offset; }); } function deoverlapLayout(souls, width) { const tiers = {}; souls.forEach(function (n) { const y = n._y || 0; if (!tiers[y]) tiers[y] = []; tiers[y].push(n); }); Object.keys(tiers).forEach(function (y) { spreadTier(tiers[y], width, MIN_NODE_GAP); }); } function agentSubtitle(node) { const hint = AGENT_HINTS[(node.agent_key || '').toLowerCase()]; const task = (node.current_task || node.last_event_title || '').trim(); if (task) return task.length > 32 ? task.slice(0, 30) + '…' : task; if (hint) return hint.short; return (node.role_title || '').split('·')[0].trim().slice(0, 24); } function agentStatus(node) { if (node.is_active) return 'live'; const h = node.health || 'idle'; if (h === 'warn') return 'warn'; if (h === 'healthy') return 'ready'; return 'idle'; } function addRotateAnim(group, dur) { const anim = document.createElementNS(NS, 'animateTransform'); anim.setAttribute('attributeName', 'transform'); anim.setAttribute('type', 'rotate'); anim.setAttribute('from', '0 0 0'); anim.setAttribute('to', '360 0 0'); anim.setAttribute('dur', dur || '12s'); anim.setAttribute('repeatCount', 'indefinite'); group.appendChild(anim); } function layoutPyramid(souls, width, positions) { const cx = width / 2; const hermanY = positions.hermanY; const execY = positions.execY; const tierYs = positions.tierYs; const minGap = MIN_NODE_GAP; let idx = 0; TIER_ROWS.forEach(function (count, tierIdx) { const y = tierYs[tierIdx] || 580; const slice = souls.slice(idx, idx + count); idx += count; const n = slice.length; if (!n) return; const span = Math.max(minGap * Math.max(n - 1, 1), minGap); const startX = cx - span / 2; slice.forEach(function (node, i) { node._x = n === 1 ? cx : startX + (span * i) / Math.max(1, n - 1); node._y = y; node._tier = tierIdx; node._cluster = clusterOf(node.agent_key); }); }); while (idx < souls.length) { const y = 660; const rest = souls.slice(idx); const n = rest.length; const span = Math.max(minGap * Math.max(n - 1, 1), minGap); const startX = cx - span / 2; rest.forEach(function (node, i) { node._x = n === 1 ? cx : startX + (span * i) / Math.max(1, n - 1); node._y = y; node._cluster = clusterOf(node.agent_key); }); idx = souls.length; } deoverlapLayout(souls, width); return { cx: cx, hermanY: hermanY, execY: execY }; } function layoutClusters(souls, width, height) { const cx = width / 2; const hermanY = height * 0.42; const execY = 58; const clusterCenters = { growth: { x: cx - 280, y: height * 0.72, r: 118 }, product: { x: cx, y: height * 0.78, r: 118 }, ops: { x: cx + 280, y: height * 0.72, r: 118 }, }; souls.forEach(function (node) { const key = (node.agent_key || '').toLowerCase(); const cid = clusterOf(key) || 'product'; const center = clusterCenters[cid]; const members = CLUSTERS[cid].agents; const idx = members.indexOf(key); const n = members.length; const angleStart = -Math.PI * 0.72; const angleEnd = -Math.PI * 0.28; const angle = n <= 1 ? (angleStart + angleEnd) / 2 : angleStart + ((angleEnd - angleStart) * idx) / (n - 1); const dist = center.r * 0.72; node._x = center.x + Math.cos(angle) * dist; node._y = center.y + Math.sin(angle) * dist * 0.55; node._cluster = cid; node._clusterCx = center.x; node._clusterCy = center.y; }); return { cx: cx, hermanY: hermanY, execY: execY, clusterCenters: clusterCenters }; } function isSvgVisible(svg) { if (!svg) return false; const r = svg.getBoundingClientRect(); return r.width > 8 && r.height > 8; } function showMeshLoading(svg, text) { clear(svg); const g = make('g', { class: 'mesh-loading' }); const t = make('text', { x: 600, y: 360, 'text-anchor': 'middle', fill: '#94a3b8', 'font-size': 14, }); t.textContent = text || 'Netwerk laden…'; g.appendChild(t); svg.appendChild(g); } function showMeshError(svg, text) { clear(svg); const t = make('text', { x: 32, y: 40, fill: '#ef4444', 'font-size': 14 }); t.textContent = text || 'Mesh kon niet geladen worden'; svg.appendChild(t); } function whenSvgVisible(svg, fn) { if (!svg) return; if (isSvgVisible(svg)) { fn(); return; } let tries = 0; const tick = function () { tries += 1; if (isSvgVisible(svg)) { fn(); return; } if (tries < 60) { requestAnimationFrame(tick); } else { fn(); } }; requestAnimationFrame(tick); } function watchSvgResize(svg, renderFn) { if (!svg || svg._meshResizeObs || typeof ResizeObserver === 'undefined') return; const wrap = svg.closest('.mesh-wrap') || svg.parentElement; if (!wrap) return; let hadSize = isSvgVisible(svg); svg._meshResizeObs = new ResizeObserver(function () { const vis = isSvgVisible(svg); if (vis && !hadSize) { hadSize = true; renderFn(); } else if (vis) { hadSize = true; } }); svg._meshResizeObs.observe(wrap); } function renderMesh(svg, opts) { if (!svg || !cachedData) return; const drawOpts = Object.assign({}, lastMountOpts, opts || {}, { commMode: (opts && opts.commMode) || getSavedMode(), }); const doDraw = function () { try { drawMesh(svg, cachedData, drawOpts); } catch (e) { console.error('drawMesh failed', e); showMeshError(svg, 'Diagram fout: ' + (e.message || 'onbekend')); } }; doDraw(); watchSvgResize(svg, doDraw); } async function loadMeshData() { const r = await fetch('/api/agents/mesh'); if (!r.ok) throw new Error('Mesh API unavailable'); return r.json(); } async function ensureMeshData() { if (cachedData) return cachedData; cachedData = await loadMeshData(); return cachedData; } let pendingRender = false; function scheduleRender(opts) { const svg = document.getElementById('agents-mesh-canvas'); if (!svg) return; if (opts) lastMountOpts = Object.assign({}, lastMountOpts, opts); if (pendingRender) return; pendingRender = true; whenSvgVisible(svg, function () { pendingRender = false; renderMesh(svg, opts); }); } function nodePos(nodesByKey, key) { const n = nodesByKey[key]; if (n && n._x != null) return { x: n._x, y: n._y }; return null; } function resolveMode(opts) { const id = (opts && opts.commMode) || localStorage.getItem(STORAGE_KEY) || 'pyramid'; return COMM_MODES[id] || COMM_MODES.pyramid; } function drawMesh(svg, data, opts) { opts = opts || {}; const mode = resolveMode(opts); const onNodeClick = opts.onNodeClick; clear(svg); svg.setAttribute('data-mesh-mode', mode.id); const vb = svg.viewBox.baseVal; const width = vb && vb.width ? vb.width : VIEW_W; const height = vb && vb.height ? vb.height : VIEW_H; if (!vb || vb.width !== VIEW_W || vb.height !== VIEW_H) { svg.setAttribute('viewBox', '0 0 ' + VIEW_W + ' ' + VIEW_H); } const positions = { execY: 72, hermanY: mode.layout === 'clusters' ? height * 0.38 : 228, tierYs: [400, 540, 690], }; const souls = (data.nodes || []).filter(function (n) { const key = (n.agent_key || '').toLowerCase(); return key && key !== 'herman'; }); const layoutMeta = mode.layout === 'clusters' ? layoutClusters(souls, width, height) : layoutPyramid(souls, width, positions); const cx = layoutMeta.cx; const hermanY = layoutMeta.hermanY; const execY = layoutMeta.execY; const nodesByKey = {}; souls.forEach(function (n) { nodesByKey[(n.agent_key || '').toLowerCase()] = n; }); const ceo = { x: cx - 200, y: execY, label: 'CEO', sub: 'Aissa' }; const cto = { x: cx + 200, y: execY, label: 'CTO', sub: 'Platform' }; const bgLayer = make('g', { class: 'mesh-bg' }); const clusterLayer = make('g', { class: 'mesh-clusters' }); const edgesLayer = make('g', { class: 'mesh-edges' }); const nodesLayer = make('g', { class: 'mesh-nodes' }); svg.appendChild(bgLayer); if (mode.layout === 'clusters') svg.appendChild(clusterLayer); svg.appendChild(edgesLayer); svg.appendChild(nodesLayer); if (mode.layout === 'pyramid') { positions.tierYs.forEach(function (y, i) { bgLayer.appendChild( make('rect', { x: 40, y: y - 42, width: width - 80, height: 84, rx: 12, class: 'mesh-tier-band mesh-tier-' + i, }) ); }); } if (mode.layout === 'clusters' && layoutMeta.clusterCenters) { Object.keys(CLUSTERS).forEach(function (cid) { const c = CLUSTERS[cid]; const center = layoutMeta.clusterCenters[cid]; if (!center) return; const g = make('g', { class: 'mesh-cluster-blob mesh-cluster-' + cid }); g.appendChild( make('ellipse', { cx: center.x, cy: center.y, rx: center.r + 28, ry: center.r * 0.62, fill: c.color, stroke: c.stroke, 'stroke-width': 1.2, }) ); const lbl = make('text', { x: center.x, y: center.y - center.r * 0.35, class: 'mesh-cluster-label', 'text-anchor': 'middle', }); lbl.textContent = c.icon + ' ' + c.label; g.appendChild(lbl); clusterLayer.appendChild(g); }); } function addEdge(x1, y1, x2, y2, cls, pulse, extraAttrs) { const d = curvePath(x1, y1, x2, y2, mode.edgeBend); const attrs = { d: d, class: 'mesh-edge ' + cls }; if (extraAttrs) Object.assign(attrs, extraAttrs); edgesLayer.appendChild(make('path', attrs)); if (pulse) { const p = make('path', { d: d, class: 'mesh-pulse mesh-pulse-active ' + cls }); p.style.animationDuration = (typeof pulse === 'number' ? pulse : 2.6) + 's'; edgesLayer.appendChild(p); } } if (mode.showExecutive) { const execCls = mode.straightExec ? 'mesh-edge-executive mesh-edge-straight' : 'mesh-edge-executive'; addEdge(ceo.x, ceo.y + 30, cx, hermanY - 46, execCls, false); addEdge(cto.x, cto.y + 30, cx, hermanY - 46, execCls, false); } (data.peer_edges || []).forEach(function (e) { const live = e.type === 'live'; if (live && !mode.showLivePeers) return; if (!live && !mode.showStaticPeers) return; if (mode.activeOnly && !live) return; const a = nodePos(nodesByKey, e.source); const b = nodePos(nodesByKey, e.target); if (!a || !b) return; const srcNode = nodesByKey[e.source]; const tgtNode = nodesByKey[e.target]; const sameCluster = srcNode && tgtNode && srcNode._cluster && srcNode._cluster === tgtNode._cluster; if (mode.intraClusterOnly && !sameCluster) return; const cls = live ? 'mesh-edge-peer-live' : 'mesh-edge-peer'; const pulse = live ? 2.2 : false; const extra = {}; if (!live && mode.staticPeerOpacity != null) { extra['stroke-opacity'] = mode.staticPeerOpacity; } if (sameCluster && mode.layout === 'clusters') { extra['stroke-opacity'] = live ? 0.75 : mode.staticPeerOpacity || 0.25; } addEdge(a.x, a.y, b.x, b.y, cls, pulse, extra); }); if (mode.showReport) { (data.report_edges || []).forEach(function (e) { if (mode.activeOnly && !e.active) return; const a = nodePos(nodesByKey, e.source); if (!a) return; addEdge(a.x, a.y - 24, cx, hermanY + 42, 'mesh-edge-report', e.active ? 2.4 : false); }); } if (mode.showDelegate) { (data.delegate_edges || []).forEach(function (e) { if (mode.activeOnly && !e.active) return; const b = nodePos(nodesByKey, e.target); if (!b) return; addEdge(cx, hermanY + 42, b.x, b.y - 24, 'mesh-edge-delegate', e.active ? 2.0 : false); }); } function drawExec(node, cls) { const g = make('g', { class: 'mesh-node mesh-exec ' + cls, transform: 'translate(' + node.x + ',' + node.y + ')' }); g.appendChild(make('circle', { cx: 0, cy: 0, r: 30, class: 'main' })); const t = make('text', { x: 0, y: -42, class: 'mesh-label mesh-label-exec' }); t.textContent = node.label; g.appendChild(t); const sub = make('text', { x: 0, y: -26, class: 'mesh-sublabel' }); sub.textContent = node.sub; g.appendChild(sub); nodesLayer.appendChild(g); } drawExec(ceo, 'mesh-ceo'); drawExec(cto, 'mesh-cto'); const hermanActive = (data.delegate_edges || []).some(function (e) { return e.active; }) || (data.report_edges || []).some(function (e) { return e.active; }); const hermanG = make('g', { class: 'mesh-node mesh-herman' + (hermanActive ? ' active' : ''), transform: 'translate(' + cx + ',' + hermanY + ')', }); if (hermanActive) { const ringG = make('g', { class: 'mesh-herman-ring' }); ringG.appendChild(make('circle', { class: 'ring', cx: 0, cy: 0, r: 54 })); addRotateAnim(ringG, '14s'); hermanG.appendChild(ringG); } hermanG.appendChild(make('circle', { class: 'main', cx: 0, cy: 0, r: 40 })); const crown = make('text', { x: 0, y: 6, class: 'mesh-herman-crown', 'text-anchor': 'middle' }); crown.textContent = '👑'; hermanG.appendChild(crown); const hLabel = make('text', { x: 0, y: 64, class: 'mesh-label mesh-label-herman' }); hLabel.textContent = 'Herman'; hermanG.appendChild(hLabel); const hSub = make('text', { x: 0, y: 80, class: 'mesh-sublabel mesh-sublabel-herman' }); hSub.textContent = 'Co-CEO · takenverdeler'; hermanG.appendChild(hSub); const hTask = make('text', { x: 0, y: 94, class: 'mesh-context' }); const hermanNode = (data.nodes || []).find(function (n) { return (n.agent_key || '').toLowerCase() === 'herman'; }); hTask.textContent = hermanNode ? agentSubtitle(hermanNode) : 'Coördineert alle agents'; hermanG.appendChild(hTask); nodesLayer.appendChild(hermanG); souls.forEach(function (node, idx) { const key = (node.agent_key || '').toLowerCase(); const live = !!node.is_active; const status = agentStatus(node); const hint = AGENT_HINTS[key]; const clusterCls = node._cluster ? ' mesh-agent-cluster-' + node._cluster : ''; const labelAbove = typeof node._tier === 'number' ? node._tier % 2 === 1 : idx % 2 === 1; const group = make('g', { class: 'mesh-node mesh-agent mesh-status-' + status + ' ' + (node.health || 'idle') + (live ? ' mesh-agent-live' : '') + clusterCls, transform: 'translate(' + node._x + ',' + node._y + ')', }); if (live) { const pulseG = make('g', { class: 'mesh-pulse-wrap' }); pulseG.appendChild(make('circle', { cx: 0, cy: 0, r: 28, class: 'pulse-ring' })); group.appendChild(pulseG); } group.appendChild(make('circle', { cx: 0, cy: 0, r: 22, class: 'main' })); const emoji = make('text', { x: 0, y: 5, class: 'mesh-emoji', 'text-anchor': 'middle' }); emoji.textContent = node.avatar_emoji || (hint && hint.short ? hint.short.charAt(0) : '') || '●'; group.appendChild(emoji); const displayName = (node.display_name || key || '?').split(' ')[0]; const nameY = labelAbove ? -30 : 36; const ctxY = labelAbove ? -16 : 52; const domY = labelAbove ? -2 : 66; const t = make('text', { x: 0, y: nameY, class: 'mesh-label mesh-label-agent' }); t.textContent = displayName; group.appendChild(t); const ctx = make('text', { x: 0, y: ctxY, class: 'mesh-context' }); ctx.textContent = agentSubtitle(node); group.appendChild(ctx); if (hint && mode.layout !== 'clusters') { const dom = make('text', { x: 0, y: domY, class: 'mesh-domain' }); dom.textContent = hint.domain; group.appendChild(dom); } const tip = (node.role_title || '') + (node.responsibilities ? '\n' + String(node.responsibilities).slice(0, 120) : '') + (node.current_task ? '\nNu: ' + node.current_task : ''); group.setAttribute('title', tip.slice(0, 200)); group.addEventListener('click', function () { if (onNodeClick) onNodeClick(node); }); nodesLayer.appendChild(group); }); } function updatePickerUI(modeId) { document.querySelectorAll('[data-mesh-mode]').forEach(function (btn) { const active = btn.getAttribute('data-mesh-mode') === modeId; btn.classList.toggle('is-active', active); btn.setAttribute('aria-pressed', active ? 'true' : 'false'); }); const wrap = document.querySelector('.mesh-wrap'); if (wrap) { wrap.className = 'mesh-wrap mesh-wrap-mode-' + modeId; wrap.setAttribute('data-mode', modeId); } const badge = document.getElementById('mesh-mode-badge'); if (badge && COMM_MODES[modeId]) { badge.textContent = COMM_MODES[modeId].label; } } function pickMode(modeId, opts, silent) { const mode = COMM_MODES[modeId] ? modeId : 'pyramid'; const drawOpts = Object.assign({}, lastMountOpts, opts || {}, { commMode: mode }); if (!silent) saveMode(mode); updatePickerUI(mode); if (onModeChangeCb) onModeChangeCb(mode); const svg = document.getElementById('agents-mesh-canvas'); if (!svg) return mode; showMeshLoading(svg, 'Stijl: ' + COMM_MODES[mode].label + '…'); ensureMeshData() .then(function () { whenSvgVisible(svg, function () { renderMesh(svg, drawOpts); }); }) .catch(function (e) { showMeshError(svg, 'Mesh laden mislukt — ' + (e.message || '')); }); return mode; } function bindModePicker(opts) { lastMountOpts = Object.assign({}, lastMountOpts, opts || {}); if (pickerBound) return; pickerBound = true; document.addEventListener('click', function (e) { const btn = e.target.closest('[data-mesh-mode]'); if (!btn) return; e.preventDefault(); e.stopPropagation(); const id = btn.getAttribute('data-mesh-mode'); pickMode(id); }); } function setModeChangeCallback(fn) { onModeChangeCb = fn; } async function mount(targetId, opts) { opts = opts || {}; lastMountOpts = opts; const svg = document.getElementById(targetId); if (!svg) return null; bindModePicker(opts); const modeObj = resolveMode(opts); updatePickerUI(modeObj.id); showMeshLoading(svg, 'Netwerk laden…'); try { await ensureMeshData(); const drawOpts = Object.assign({}, opts, { commMode: modeObj.id }); whenSvgVisible(svg, function () { renderMesh(svg, drawOpts); }); return cachedData; } catch (e) { showMeshError(svg, 'Mesh laden mislukt — ' + (e.message || '')); return null; } } async function refresh(targetId, opts) { cachedData = null; return mount(targetId, opts); } function redraw(targetId, opts) { const svg = document.getElementById(targetId); if (!svg || !cachedData) return; whenSvgVisible(svg, function () { renderMesh(svg, opts || {}); }); } function saveMode(modeId) { if (COMM_MODES[modeId]) localStorage.setItem(STORAGE_KEY, modeId); } function getSavedMode() { const id = localStorage.getItem(STORAGE_KEY) || 'pyramid'; return COMM_MODES[id] ? id : 'pyramid'; } window.AgentsMesh = { mount, refresh, redraw, pickMode, bindModePicker, setModeChangeCallback, loadMeshData, drawMesh, COMM_MODES, CLUSTERS, saveMode, getSavedMode, }; function bootPicker() { bindModePicker({}); const saved = getSavedMode(); updatePickerUI(saved); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', bootPicker); } else { bootPicker(); } if (window.location.pathname.indexOf('/agents') >= 0) { ensureMeshData().catch(function () {}); } })();