2026-06-09 00:41:27 +00:00
|
|
|
(function () {
|
|
|
|
|
const NS = 'http://www.w3.org/2000/svg';
|
2026-06-23 10:04:23 +00:00
|
|
|
const TIER_ROWS = [4, 5, 7];
|
|
|
|
|
const MIN_NODE_GAP = 148;
|
|
|
|
|
const VIEW_W = 1200;
|
|
|
|
|
const VIEW_H = 860;
|
2026-06-09 00:41:27 +00:00
|
|
|
|
2026-06-23 10:04:23 +00:00
|
|
|
/** 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<string, object>} */
|
|
|
|
|
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 || {};
|
2026-06-09 00:41:27 +00:00
|
|
|
const el = document.createElementNS(NS, tag);
|
2026-06-23 10:04:23 +00:00
|
|
|
Object.entries(attrs).forEach(function (pair) {
|
|
|
|
|
el.setAttribute(pair[0], String(pair[1]));
|
|
|
|
|
});
|
2026-06-09 00:41:27 +00:00
|
|
|
return el;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clear(el) {
|
|
|
|
|
while (el.firstChild) el.removeChild(el.firstChild);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 10:04:23 +00:00
|
|
|
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);
|
2026-06-09 00:41:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadMeshData() {
|
|
|
|
|
const r = await fetch('/api/agents/mesh');
|
|
|
|
|
if (!r.ok) throw new Error('Mesh API unavailable');
|
|
|
|
|
return r.json();
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 10:04:23 +00:00
|
|
|
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;
|
|
|
|
|
|
2026-06-09 00:41:27 +00:00
|
|
|
clear(svg);
|
2026-06-23 10:04:23 +00:00
|
|
|
svg.setAttribute('data-mesh-mode', mode.id);
|
|
|
|
|
|
2026-06-09 00:41:27 +00:00
|
|
|
const vb = svg.viewBox.baseVal;
|
2026-06-23 10:04:23 +00:00
|
|
|
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);
|
|
|
|
|
}
|
2026-06-09 10:41:13 +00:00
|
|
|
|
2026-06-23 10:04:23 +00:00
|
|
|
const positions = {
|
|
|
|
|
execY: 72,
|
|
|
|
|
hermanY: mode.layout === 'clusters' ? height * 0.38 : 228,
|
|
|
|
|
tierYs: [400, 540, 690],
|
|
|
|
|
};
|
2026-06-09 10:41:13 +00:00
|
|
|
|
2026-06-23 10:04:23 +00:00
|
|
|
const souls = (data.nodes || []).filter(function (n) {
|
2026-06-09 10:41:13 +00:00
|
|
|
const key = (n.agent_key || '').toLowerCase();
|
|
|
|
|
return key && key !== 'herman';
|
|
|
|
|
});
|
2026-06-09 00:41:27 +00:00
|
|
|
|
2026-06-23 10:04:23 +00:00
|
|
|
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;
|
2026-06-09 00:41:27 +00:00
|
|
|
|
2026-06-23 10:04:23 +00:00
|
|
|
const nodesByKey = {};
|
|
|
|
|
souls.forEach(function (n) {
|
|
|
|
|
nodesByKey[(n.agent_key || '').toLowerCase()] = n;
|
2026-06-09 00:41:27 +00:00
|
|
|
});
|
|
|
|
|
|
2026-06-23 10:04:23 +00:00
|
|
|
const ceo = { x: cx - 200, y: execY, label: 'CEO', sub: 'Aissa' };
|
|
|
|
|
const cto = { x: cx + 200, y: execY, label: 'CTO', sub: 'Platform' };
|
2026-06-09 10:41:13 +00:00
|
|
|
|
2026-06-23 10:04:23 +00:00
|
|
|
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);
|
2026-06-09 00:41:27 +00:00
|
|
|
svg.appendChild(edgesLayer);
|
|
|
|
|
svg.appendChild(nodesLayer);
|
|
|
|
|
|
2026-06-23 10:04:23 +00:00
|
|
|
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));
|
2026-06-09 10:41:13 +00:00
|
|
|
if (pulse) {
|
2026-06-23 10:04:23 +00:00
|
|
|
const p = make('path', { d: d, class: 'mesh-pulse mesh-pulse-active ' + cls });
|
|
|
|
|
p.style.animationDuration = (typeof pulse === 'number' ? pulse : 2.6) + 's';
|
2026-06-09 10:41:13 +00:00
|
|
|
edgesLayer.appendChild(p);
|
2026-06-09 00:41:27 +00:00
|
|
|
}
|
2026-06-09 10:41:13 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-23 10:04:23 +00:00
|
|
|
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);
|
2026-06-09 00:41:27 +00:00
|
|
|
});
|
|
|
|
|
|
2026-06-23 10:04:23 +00:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-06-09 10:41:13 +00:00
|
|
|
|
|
|
|
|
function drawExec(node, cls) {
|
2026-06-23 10:04:23 +00:00
|
|
|
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' });
|
2026-06-09 10:41:13 +00:00
|
|
|
sub.textContent = node.sub;
|
|
|
|
|
g.appendChild(sub);
|
|
|
|
|
nodesLayer.appendChild(g);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
drawExec(ceo, 'mesh-ceo');
|
|
|
|
|
drawExec(cto, 'mesh-cto');
|
|
|
|
|
|
2026-06-23 10:04:23 +00:00
|
|
|
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' });
|
2026-06-09 00:41:27 +00:00
|
|
|
crown.textContent = '👑';
|
2026-06-23 10:04:23 +00:00
|
|
|
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) {
|
2026-06-09 10:41:13 +00:00
|
|
|
const key = (node.agent_key || '').toLowerCase();
|
2026-06-23 10:04:23 +00:00
|
|
|
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;
|
2026-06-09 00:41:27 +00:00
|
|
|
group.appendChild(t);
|
2026-06-23 10:04:23 +00:00
|
|
|
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);
|
2026-06-09 00:41:27 +00:00
|
|
|
});
|
|
|
|
|
nodesLayer.appendChild(group);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 10:04:23 +00:00
|
|
|
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;
|
2026-06-09 00:41:27 +00:00
|
|
|
const svg = document.getElementById(targetId);
|
2026-06-23 10:04:23 +00:00
|
|
|
if (!svg) return null;
|
|
|
|
|
bindModePicker(opts);
|
|
|
|
|
const modeObj = resolveMode(opts);
|
|
|
|
|
updatePickerUI(modeObj.id);
|
|
|
|
|
showMeshLoading(svg, 'Netwerk laden…');
|
2026-06-09 00:41:27 +00:00
|
|
|
try {
|
2026-06-23 10:04:23 +00:00
|
|
|
await ensureMeshData();
|
|
|
|
|
const drawOpts = Object.assign({}, opts, { commMode: modeObj.id });
|
|
|
|
|
whenSvgVisible(svg, function () {
|
|
|
|
|
renderMesh(svg, drawOpts);
|
|
|
|
|
});
|
|
|
|
|
return cachedData;
|
2026-06-09 00:41:27 +00:00
|
|
|
} catch (e) {
|
2026-06-23 10:04:23 +00:00
|
|
|
showMeshError(svg, 'Mesh laden mislukt — ' + (e.message || ''));
|
|
|
|
|
return null;
|
2026-06-09 00:41:27 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 10:04:23 +00:00
|
|
|
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 () {});
|
|
|
|
|
}
|
2026-06-09 00:41:27 +00:00
|
|
|
})();
|