aboutsummaryrefslogtreecommitdiff
path: root/downloads/memgraph.html
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-11-09 15:36:23 +0100
committerAgando <roduccomm@gmail.com>2026-11-09 15:36:23 +0100
commita065a4c30be748b0c8fc6dd0b5ee1dc4d3de4b1c (patch)
tree8a84adabfccee9994937cd71888170ab205b9361 /downloads/memgraph.html
parenta46ebee30cfbbac86abb45068411bc28e1c65503 (diff)
chore(newos4): commit accumulated maintenance outputHEADv4.6.156main
The daily collector and the feedback pipeline had left 57 modified files uncommitted (skills, modules-index, CHANGELOG, feedback decisions, debug counters). The public mirror publishes git HEAD, so without this the published repo stayed behind the tree - and it blocked the publisher outright once uncommitted subtrees started failing loudly.
Diffstat (limited to 'downloads/memgraph.html')
-rw-r--r--downloads/memgraph.html1007
1 files changed, 1007 insertions, 0 deletions
diff --git a/downloads/memgraph.html b/downloads/memgraph.html
new file mode 100644
index 0000000..cd37848
--- /dev/null
+++ b/downloads/memgraph.html
@@ -0,0 +1,1007 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<meta name="color-scheme" content="dark">
+<title>🧠 Memory Graph Explorer β€” WebX11 newOS4</title>
+<style>
+*{box-sizing:border-box;margin:0;padding:0}
+html,body{height:100%;overflow:hidden;background:#070b14;color:#e2e8f0;font-family:system-ui,-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif}
+#cv{position:fixed;inset:0;cursor:grab;touch-action:none}
+#cv.grabbing{cursor:grabbing}
+.panel{position:fixed;background:rgba(13,20,36,.78);border:1px solid rgba(148,163,184,.15);border-radius:14px;backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);box-shadow:0 12px 40px rgba(0,0,0,.45)}
+::-webkit-scrollbar{width:8px;height:8px}
+::-webkit-scrollbar-thumb{background:rgba(100,116,139,.35);border-radius:8px}
+::-webkit-scrollbar-track{background:transparent}
+
+/* ── Top bar ── */
+#topbar{top:10px;left:10px;right:10px;height:52px;display:flex;align-items:center;gap:10px;padding:0 12px;z-index:20;min-width:0}
+#brand{display:flex;align-items:center;gap:9px;font-weight:700;font-size:15px;white-space:nowrap}
+#brand .sub{font-size:10px;font-weight:600;color:#7c8db0;letter-spacing:.08em;text-transform:uppercase}
+#srcChip{font-size:11px;color:#67e8f9;border:1px solid rgba(34,211,238,.3);background:rgba(8,47,73,.45);padding:4px 10px;border-radius:999px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:260px}
+#searchWrap{flex:1;position:relative;min-width:120px;max-width:430px}
+#search{width:100%;height:32px;border-radius:9px;border:1px solid rgba(148,163,184,.18);background:rgba(2,6,17,.6);color:#e2e8f0;padding:0 70px 0 30px;font-size:13px;outline:none}
+#search:focus{border-color:rgba(34,211,238,.55);box-shadow:0 0 0 3px rgba(34,211,238,.12)}
+#searchIcon{position:absolute;left:9px;top:8px;font-size:13px;opacity:.6;pointer-events:none}
+#searchCount{position:absolute;right:8px;top:7px;font-size:11px;color:#94a3b8;background:rgba(30,41,59,.7);border-radius:6px;padding:2px 7px;display:none}
+#tbBtns{display:flex;gap:6px;margin-left:auto}
+.btn{height:32px;min-width:32px;padding:0 9px;border-radius:8px;border:1px solid rgba(148,163,184,.18);background:rgba(30,41,59,.6);color:#cbd5e1;font-size:13px;cursor:pointer;transition:.15s;display:inline-flex;align-items:center;justify-content:center;gap:5px;white-space:nowrap;user-select:none}
+.btn:hover{background:rgba(51,65,85,.85);color:#fff;border-color:rgba(34,211,238,.4)}
+.btn.act{border-color:rgba(34,211,238,.55);color:#67e8f9}
+
+/* ── Sidebar ── */
+#sidebar{top:72px;left:10px;bottom:10px;width:322px;padding:14px;overflow-y:auto;overflow-x:hidden;z-index:15;transition:transform .25s ease,opacity .25s ease}
+#sidebar.hidden{transform:translateX(-115%);opacity:0;pointer-events:none}
+.sec{margin-bottom:16px}
+.sec h3{font-size:10.5px;letter-spacing:.09em;text-transform:uppercase;color:#7c8db0;margin-bottom:8px;display:flex;align-items:center;justify-content:space-between;gap:8px}
+.chip{display:inline-flex;align-items:center;gap:6px;padding:4px 9px;border-radius:999px;border:1px solid rgba(148,163,184,.16);background:rgba(30,41,59,.5);font-size:12px;color:#cbd5e1;cursor:pointer;margin:0 6px 6px 0;transition:.15s;user-select:none}
+.chip .dot{width:9px;height:9px;border-radius:50%;flex-shrink:0}
+.chip:hover{border-color:rgba(34,211,238,.4)}
+.chip.off{opacity:.35;filter:saturate(.2)}
+.statgrid{display:grid;grid-template-columns:1fr 1fr;gap:8px}
+.stat{background:rgba(30,41,59,.45);border:1px solid rgba(148,163,184,.12);border-radius:10px;padding:8px 10px}
+.stat .v{font-size:17px;font-weight:700;color:#e2e8f0}
+.stat .k{font-size:10px;color:#7c8db0;text-transform:uppercase;letter-spacing:.07em;margin-top:1px}
+.row{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:8px;cursor:pointer;font-size:12.5px;color:#cbd5e1;min-width:0}
+.row:hover{background:rgba(34,211,238,.09)}
+.row .dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
+.row .lab{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.row .val{font-size:11px;color:#7c8db0;flex-shrink:0}
+.lrow{display:flex;align-items:center;gap:8px;font-size:11.5px;color:#94a3b8;padding:3px 4px}
+.lrow .line{width:16px;height:3px;border-radius:2px;flex-shrink:0}
+.lrow .n{margin-left:auto;color:#7c8db0}
+.mini{font-size:11.5px;color:#94a3b8;line-height:1.55}
+.mini code{background:rgba(2,6,17,.7);border:1px solid rgba(148,163,184,.14);border-radius:5px;padding:1px 5px;font-size:10.5px;color:#67e8f9;font-family:ui-monospace,Menlo,Consolas,monospace}
+#foot{display:flex;gap:6px;margin-top:4px}
+#foot .btn{flex:1;font-size:12px}
+
+/* ── Details panel ── */
+#details{top:72px;right:10px;width:332px;max-height:calc(100% - 90px);padding:14px;overflow-y:auto;z-index:16;display:none}
+#details.open{display:block}
+#dHead{display:flex;align-items:flex-start;gap:10px;margin-bottom:10px}
+#dTitle{font-size:15px;font-weight:700;line-height:1.3;word-break:break-word}
+.badge{display:inline-flex;align-items:center;gap:5px;font-size:10.5px;padding:2px 8px;border-radius:999px;border:1px solid;font-weight:600;margin:4px 4px 0 0}
+.kv{display:flex;gap:8px;font-size:12px;padding:4px 0;border-bottom:1px dashed rgba(148,163,184,.1)}
+.kv .k{color:#7c8db0;width:86px;flex-shrink:0}
+.kv .v{color:#e2e8f0;word-break:break-all}
+pre.meta{background:rgba(2,6,17,.75);border:1px solid rgba(148,163,184,.14);border-radius:8px;padding:8px;font-size:11px;color:#a5b4fc;white-space:pre-wrap;word-break:break-word;margin-top:8px;font-family:ui-monospace,Menlo,Consolas,monospace}
+.edgeRow{display:flex;align-items:center;gap:6px;font-size:11.5px;padding:5px 7px;border-radius:7px;cursor:pointer;color:#cbd5e1;min-width:0}
+.edgeRow:hover{background:rgba(34,211,238,.09)}
+.edgeRow .arrow{color:#7c8db0;flex-shrink:0}
+.edgeRow .etype{font-size:10px;color:#67e8f9;background:rgba(8,47,73,.5);border-radius:5px;padding:1px 6px;flex-shrink:0}
+.edgeRow .lab{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
+.edgeRow .w{color:#7c8db0;font-size:10.5px;flex-shrink:0}
+
+/* ── tooltip / toast / overlay ── */
+#tooltip{position:fixed;pointer-events:none;background:rgba(2,6,17,.94);border:1px solid rgba(148,163,184,.28);border-radius:9px;padding:8px 10px;font-size:12px;z-index:50;display:none;max-width:280px;box-shadow:0 8px 24px rgba(0,0,0,.5);line-height:1.45}
+#tooltip b{color:#fff}
+#tooltip .t{font-size:10.5px;color:#7c8db0}
+#toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%) translateY(20px);background:rgba(2,6,17,.94);border:1px solid rgba(34,211,238,.35);color:#e2e8f0;padding:9px 16px;border-radius:10px;font-size:13px;opacity:0;transition:.25s;z-index:60;pointer-events:none;max-width:88vw;text-align:center}
+#toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
+#overlay{position:fixed;inset:0;background:rgba(4,7,14,.85);display:flex;align-items:center;justify-content:center;z-index:100;backdrop-filter:blur(5px);-webkit-backdrop-filter:blur(5px)}
+#overlay.hide{display:none}
+.card{background:rgba(13,20,36,.95);border:1px solid rgba(148,163,184,.2);border-radius:16px;padding:28px 30px;max-width:460px;text-align:center;box-shadow:0 20px 60px rgba(0,0,0,.6)}
+.spinner{width:36px;height:36px;border:3px solid rgba(148,163,184,.2);border-top-color:#22d3ee;border-radius:50%;margin:0 auto 16px;animation:spin .9s linear infinite}
+@keyframes spin{to{transform:rotate(360deg)}}
+.card h2{font-size:17px;margin-bottom:8px}
+.card p{font-size:12.5px;color:#94a3b8;line-height:1.6}
+.card .btn{margin-top:14px}
+@media (max-width:900px){
+ #sidebar{width:min(84vw,322px)}
+ #details{width:min(86vw,332px)}
+ #brand .sub{display:none}
+ #srcChip{display:none}
+}
+</style>
+</head>
+<body>
+<canvas id="cv"></canvas>
+
+<header id="topbar" class="panel">
+ <div id="brand"><span>🧠</span><div>Memory Graph<div class="sub">WebX11 newOS4</div></div></div>
+ <span id="srcChip" title="Data source">…</span>
+ <div id="searchWrap">
+ <span id="searchIcon">πŸ”</span>
+ <input id="search" type="text" placeholder="Search nodes… ( / )" aria-label="Search nodes" autocomplete="off">
+ <span id="searchCount"></span>
+ </div>
+ <div id="tbBtns">
+ <button class="btn" id="btnSidebar" title="Toggle sidebar">☰</button>
+ <button class="btn" id="btnFit" title="Fit view (F)">β›Ά</button>
+ <button class="btn" id="btnZoomOut" title="Zoom out">βˆ’</button>
+ <button class="btn" id="btnZoomIn" title="Zoom in">+</button>
+ <button class="btn" id="btnPause" title="Pause / resume physics">⏸</button>
+ <button class="btn" id="btnRefresh" title="Reload live graph data">⟳</button>
+ <button class="btn" id="btnPNG" title="Export PNG">πŸ–ΌοΈ</button>
+ <button class="btn" id="btnJSON" title="Export JSON (like memgraph-export)">πŸ“„</button>
+ </div>
+</header>
+
+<aside id="sidebar" class="panel"></aside>
+<div id="details" class="panel"></div>
+<div id="tooltip"></div>
+<div id="toast"></div>
+
+<div id="overlay">
+ <div class="card">
+ <div class="spinner" id="spin"></div>
+ <h2 id="ovTitle">Loading Memory Graph…</h2>
+ <p id="ovText">Locating graph data in the WebX11 newOS4 VFS…</p>
+ <button class="btn" id="ovRetry" style="display:none" onclick="location.reload()">↻ Retry</button>
+ </div>
+</div>
+
+<script>
+'use strict';
+/* ═══════════════ Memory Graph Explorer ═══════════════
+ Reads the LIVE WebX11 newOS4 memory graph:
+ 1. Live MemoryGraphModule (if opened inside newOS4, e.g. preview /memgraph.html)
+ 2. localStorage 'webx11_filesystem_v2' (gzip+base64 compressed VFS)
+ 3. localStorage legacy key / IndexedDB fallback
+ Files: /.memory_graph/{nodes,edges,config}.json or export.json
+ 100% client-side, no dependencies. */
+
+const $ = s => document.querySelector(s);
+const esc = s => String(s === undefined || s === null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
+
+const TYPE_COLORS = {
+ session:'#f59e0b', task:'#fb7185', file:'#22d3ee', tool:'#a78bfa',
+ concept:'#34d399', type:'#38bdf8', fact:'#fbbf24', pattern:'#f472b6',
+ user:'#e879f9', skill:'#c084fc'
+};
+const FALLBACK_COLORS = ['#94a3b8','#fca5a5','#86efac','#fdba74','#93c5fd','#d8b4fe','#f9a8d4','#67e8f9'];
+const EDGE_COLORS = {
+ accessed:'#22d3ee', used:'#a78bfa', failed:'#ef4444', executed:'#fb7185',
+ involves:'#34d399', has_type:'#38bdf8', maps_to:'#2dd4bf', tagged_as:'#5eead4',
+ has_fact:'#fbbf24', learned:'#f472b6'
+};
+const FALLBACK_EDGE = '#64748b';
+function typeColor(t){ if (TYPE_COLORS[t]) return TYPE_COLORS[t]; return FALLBACK_COLORS[(t||'?').charCodeAt(0) % FALLBACK_COLORS.length]; }
+function edgeColor(t){ return EDGE_COLORS[t] || FALLBACK_EDGE; }
+
+const state = {
+ nodes:[], edges:[], byId:{}, typeVis:{}, nsVis:{},
+ tx:0, ty:0, k:1, alpha:1, alphaMin:0.012, running:true,
+ selected:null, hovered:null, matches:new Set(), search:'',
+ source:'', accesses:0, config:{}, built:false
+};
+
+/* ─────────────── Data loading ─────────────── */
+function b64ToU8(b64){
+ const bin = atob(b64);
+ const u8 = new Uint8Array(bin.length);
+ for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
+ return u8;
+}
+async function gunzip(b64){
+ const blob = new Blob([b64ToU8(b64)]);
+ const stream = blob.stream().pipeThrough(new DecompressionStream('gzip'));
+ return await new Response(stream).text();
+}
+function idbOpen(){
+ return new Promise((res, rej) => {
+ const req = indexedDB.open('webx11_vfs', 1);
+ req.onupgradeneeded = () => req.result.createObjectStore('vfs');
+ req.onsuccess = () => res(req.result);
+ req.onerror = () => rej(req.error);
+ });
+}
+function idbGet(db, key){
+ return new Promise((res, rej) => {
+ const tx = db.transaction('vfs', 'readonly');
+ const rq = tx.objectStore('vfs').get(key);
+ rq.onsuccess = () => { db.close(); res(rq.result || null); };
+ rq.onerror = () => { db.close(); rej(rq.error); };
+ });
+}
+function vfsRead(root, path){
+ let node = root;
+ for (const seg of path.split('/').filter(Boolean)) {
+ if (!node || !node.content) return null;
+ node = node.content[seg];
+ }
+ return (node && node.type === 'file') ? node.content : null;
+}
+function parseMaybe(v){
+ if (typeof v !== 'string') return v;
+ return JSON.parse(v);
+}
+function readGraphFromRoot(root){
+ if (!root) return null;
+ let nodes = null, edges = null, accesses = 0, config = {};
+ try {
+ const ex = vfsRead(root, '/.memory_graph/export.json');
+ if (ex) {
+ const d = parseMaybe(ex);
+ if (d && Array.isArray(d.nodes)) {
+ nodes = d.nodes; edges = d.edges || [];
+ accesses = (d.stats && d.stats.totalAccesses) || 0;
+ }
+ }
+ } catch (e) {}
+ if (!nodes) {
+ try {
+ const nr = vfsRead(root, '/.memory_graph/nodes.json');
+ if (nr) {
+ const m = parseMaybe(nr);
+ nodes = Array.isArray(m) ? m : Object.values(m || {});
+ }
+ const er = vfsRead(root, '/.memory_graph/edges.json');
+ if (er) edges = parseMaybe(er) || [];
+ } catch (e) {}
+ }
+ if (!nodes || nodes.length === 0) return null;
+ if (!edges) edges = [];
+ try {
+ const cfg = vfsRead(root, '/.memory_graph/config.json');
+ if (cfg) config = parseMaybe(cfg) || {};
+ } catch (e) {}
+ return { nodes, edges, accesses, config };
+}
+function scanForMemgraph(w){
+ try {
+ if (!w || !w.webx11) return null;
+ const modMaps = [];
+ if (w.webx11.moduleManager && w.webx11.moduleManager.modules) modMaps.push(w.webx11.moduleManager.modules);
+ if (w.webx11.terminal && w.webx11.terminal.moduleManager && w.webx11.terminal.moduleManager.modules)
+ modMaps.push(w.webx11.terminal.moduleManager.modules);
+ for (const mods of modMaps) {
+ for (const key of Object.keys(mods)) {
+ const m = mods[key];
+ if (m && m._nodes && typeof m._nodes === 'object' && Array.isArray(m._edges)
+ && Object.keys(m._nodes).length > 0 && typeof m.getContextSummary === 'function') return m;
+ }
+ }
+ } catch (e) {}
+ return null;
+}
+function findLiveFS(w){
+ try {
+ const wx = w && w.webx11;
+ if (!wx) return null;
+ return wx.fs || (wx.terminal && wx.terminal.fs) || null;
+ } catch (e) { return null; }
+}
+async function loadData(){
+ // 1) Live MemoryGraphModule (opened inside newOS4)
+ const wins = [];
+ try { if (window.parent && window.parent !== window) wins.push(window.parent); } catch (e) {}
+ wins.push(window);
+ try { if (window.opener) wins.push(window.opener); } catch (e) {}
+ for (const w of wins) {
+ const mg = scanForMemgraph(w);
+ if (mg) {
+ return {
+ nodes: Object.values(mg._nodes), edges: mg._edges.slice(),
+ accesses: mg._totalNodeAccesses || 0,
+ config: { decayDays: mg._decayDays, decayMinUses: mg._decayMinUses },
+ source: 'live MemoryGraphModule'
+ };
+ }
+ const fs = findLiveFS(w);
+ if (fs && typeof fs.readFile === 'function') {
+ try {
+ const viaFS = p => fs.readFile(p);
+ const ex = viaFS('/.memory_graph/export.json');
+ const g = ex
+ ? (() => { const d = parseMaybe(ex); return d && d.nodes ? { nodes: d.nodes, edges: d.edges || [], accesses: (d.stats && d.stats.totalAccesses) || 0, config: {} } : null; })()
+ : (() => {
+ const nRaw = viaFS('/.memory_graph/nodes.json');
+ if (!nRaw) return null;
+ const m = parseMaybe(nRaw);
+ const eRaw = viaFS('/.memory_graph/edges.json');
+ let cfg = {};
+ try { const c = viaFS('/.memory_graph/config.json'); if (c) cfg = parseMaybe(c) || {}; } catch (e) {}
+ return { nodes: Array.isArray(m) ? m : Object.values(m || {}), edges: eRaw ? (parseMaybe(eRaw) || []) : [], accesses: 0, config: cfg };
+ })();
+ if (g && g.nodes.length) return Object.assign(g, { source: 'live VFS' });
+ } catch (e) {}
+ }
+ }
+ // 2) localStorage v2 (compressed)
+ try {
+ const b64 = localStorage.getItem('webx11_filesystem_v2');
+ if (b64) {
+ const g = readGraphFromRoot(JSON.parse(await gunzip(b64)).root);
+ if (g) return Object.assign(g, { source: 'VFS Β· localStorage' });
+ }
+ } catch (e) {}
+ // 3) legacy localStorage
+ try {
+ const legacy = localStorage.getItem('webx11_filesystem');
+ if (legacy) {
+ const g = readGraphFromRoot(JSON.parse(legacy).root);
+ if (g) return Object.assign(g, { source: 'VFS Β· legacy' });
+ }
+ } catch (e) {}
+ // 4) IndexedDB fallback
+ try {
+ const db = await idbOpen();
+ const v = await idbGet(db, 'filesystem_v2');
+ if (v) {
+ const g = readGraphFromRoot(JSON.parse(await gunzip(v)).root);
+ if (g) return Object.assign(g, { source: 'VFS Β· IndexedDB' });
+ }
+ } catch (e) {}
+ throw new Error('No memory graph data found in this context.');
+}
+
+/* ─────────────── Graph build ─────────────── */
+function nodeRadius(n){
+ const base = { session: 9, task: 4.5, file: 6, tool: 7, concept: 4, type: 5, fact: 5 };
+ const b = base[n.type] !== undefined ? base[n.type] : 5;
+ return b + Math.sqrt(n.degree || 1) * 1.5 + Math.sqrt(n.access_count || 1) * 0.9;
+}
+function buildGraph(raw){
+ const byId = {};
+ state.nodes = raw.nodes.filter(n => n && n.id).map(n => Object.assign({}, n, {
+ x: 0, y: 0, vx: 0, vy: 0, degree: 0, fixed: false, r: 6
+ }));
+ state.nodes.forEach(n => byId[n.id] = n);
+ state.edges = raw.edges.filter(e => e && byId[e.source] && byId[e.target]).map(e => Object.assign({}, e));
+ state.edges.forEach(e => { byId[e.source].degree++; byId[e.target].degree++; });
+ state.nodes.forEach(n => { n.r = nodeRadius(n); });
+
+ state.typeVis = {}; state.nsVis = {};
+ state.nodes.forEach(n => {
+ state.typeVis[n.type] = true;
+ const ns = n.namespace || 'global';
+ state.nsVis[ns] = true;
+ });
+ state.byId = byId;
+ state.accesses = raw.accesses || state.nodes.reduce((s, n) => s + (n.access_count || 1), 0);
+ state.config = raw.config || {};
+ state.source = raw.source;
+ state.selected = null; state.hovered = null;
+ layoutInit();
+ state.built = true;
+}
+function layoutInit(){
+ const types = Object.keys(state.typeVis);
+ state.nodes.forEach((n, i) => {
+ const ti = types.indexOf(n.type);
+ const ang = (ti / Math.max(types.length, 1)) * Math.PI * 2 - Math.PI / 2;
+ const a = (i / state.nodes.length) * Math.PI * 2;
+ n.x = Math.cos(ang) * 250 + Math.cos(a) * 90 + (Math.random() - 0.5) * 24;
+ n.y = Math.sin(ang) * 250 + Math.sin(a) * 90 + (Math.random() - 0.5) * 24;
+ n.vx = 0; n.vy = 0; n.fixed = false;
+ });
+ state.alpha = 1;
+}
+
+/* ─────────────── Physics ─────────────── */
+function reheat(v){ if (v > state.alpha) state.alpha = v; }
+function step(){
+ const N = state.nodes, byId = state.byId;
+ const alpha = state.alpha;
+ if (alpha <= state.alphaMin && !draggingNode()) return;
+ // repulsion + overlap resolution
+ for (let i = 0; i < N.length; i++) {
+ const a = N[i];
+ for (let j = i + 1; j < N.length; j++) {
+ const b = N[j];
+ let dx = b.x - a.x, dy = b.y - a.y;
+ let d2 = dx * dx + dy * dy;
+ if (d2 < 1e-6) { d2 = 1; dx = (Math.random() - 0.5); dy = (Math.random() - 0.5); }
+ const d = Math.sqrt(d2);
+ const minD = a.r + b.r + 8;
+ let f = 3000 / d2;
+ if (d < minD) f += (minD - d) * 0.55;
+ f = Math.min(f, 26) * alpha;
+ const fx = dx / d * f, fy = dy / d * f;
+ a.vx -= fx; a.vy -= fy; b.vx += fx; b.vy += fy;
+ }
+ }
+ // springs along edges
+ for (const e of state.edges) {
+ const a = byId[e.source], b = byId[e.target];
+ const dx = b.x - a.x, dy = b.y - a.y;
+ const d = Math.max(Math.hypot(dx, dy), 0.5);
+ const w = e.weight || 0.5;
+ const target = 62 + 150 * (1 - w);
+ const f = (d - target) * 0.014 * (0.35 + w) * alpha;
+ const fx = dx / d * f, fy = dy / d * f;
+ a.vx += fx; a.vy += fy; b.vx -= fx; b.vy -= fy;
+ }
+ // gravity + integrate
+ for (const n of N) {
+ if (n.fixed) { n.vx = 0; n.vy = 0; continue; }
+ n.vx -= n.x * 0.004 * alpha;
+ n.vy -= n.y * 0.004 * alpha;
+ n.vx *= 0.86; n.vy *= 0.86;
+ n.x += Math.max(-28, Math.min(28, n.vx));
+ n.y += Math.max(-28, Math.min(28, n.vy));
+ }
+ state.alpha = Math.max(state.alphaMin, state.alpha * 0.987);
+}
+function draggingNode(){
+ for (const n of state.nodes) if (n.fixed) return true;
+ return false;
+}
+function warmup(ticks){ for (let i = 0; i < ticks; i++) step(); }
+
+/* ─────────────── View / canvas ─────────────── */
+const canvas = $('#cv');
+const ctx = canvas.getContext('2d');
+const view = { w: 0, h: 0, dpr: 1, bg: null };
+function resize(){
+ view.dpr = Math.min(window.devicePixelRatio || 1, 2);
+ view.w = window.innerWidth; view.h = window.innerHeight;
+ canvas.width = Math.round(view.w * view.dpr);
+ canvas.height = Math.round(view.h * view.dpr);
+ canvas.style.width = view.w + 'px'; canvas.style.height = view.h + 'px';
+ const g = ctx.createRadialGradient(canvas.width * 0.72, -canvas.height * 0.12, 0, canvas.width * 0.72, -canvas.height * 0.12, Math.max(canvas.width, canvas.height) * 1.25);
+ g.addColorStop(0, '#0e1c31'); g.addColorStop(0.55, '#070b14'); g.addColorStop(1, '#05070d');
+ view.bg = g;
+}
+window.addEventListener('resize', resize);
+
+function visNode(n){
+ return state.typeVis[n.type] && state.nsVis[n.namespace || 'global'];
+}
+function screenToWorld(sx, sy){ return { x: (sx - state.tx) / state.k, y: (sy - state.ty) / state.k }; }
+function zoomAt(sx, sy, newK){
+ newK = Math.max(0.04, Math.min(4, newK));
+ const w = screenToWorld(sx, sy);
+ state.k = newK;
+ state.tx = sx - w.x * newK;
+ state.ty = sy - w.y * newK;
+}
+function fitView(){
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity, any = false;
+ for (const n of state.nodes) {
+ if (!visNode(n)) continue;
+ any = true;
+ minX = Math.min(minX, n.x - n.r); minY = Math.min(minY, n.y - n.r);
+ maxX = Math.max(maxX, n.x + n.r); maxY = Math.max(maxY, n.y + n.r);
+ }
+ if (!any) return;
+ const bw = Math.max(maxX - minX, 10), bh = Math.max(maxY - minY, 10);
+ state.k = Math.max(0.05, Math.min(2.5, 0.86 * Math.min(view.w / bw, view.h / bh)));
+ state.tx = view.w / 2 - ((minX + maxX) / 2) * state.k;
+ state.ty = view.h / 2 - ((minY + maxY) / 2) * state.k;
+}
+function centerOn(n){
+ if (!n) return;
+ state.tx = view.w / 2 - n.x * state.k;
+ state.ty = view.h / 2 - n.y * state.k;
+}
+function shortLabel(n){
+ let t = n.label || n.id;
+ if (t.length > 22) t = t.slice(0, 20) + '…';
+ return t;
+}
+function computeHighlight(){
+ const s = new Set();
+ const add = n => {
+ if (!n) return;
+ s.add(n.id);
+ for (const e of state.edges) {
+ if (e.source === n.id) s.add(e.target);
+ else if (e.target === n.id) s.add(e.source);
+ }
+ };
+ add(state.selected); add(state.hovered);
+ return s;
+}
+function drawArrow(a, b, e){
+ const dx = b.x - a.x, dy = b.y - a.y;
+ const d = Math.hypot(dx, dy) || 1;
+ const ux = dx / d, uy = dy / d;
+ const t = d - b.r - 3.5;
+ if (t < a.r + 5) return;
+ const x = a.x + ux * t, y = a.y + uy * t, s = 3.4;
+ ctx.beginPath();
+ ctx.moveTo(x, y);
+ ctx.lineTo(x - ux * s * 2 - uy * s, y - uy * s * 2 + ux * s);
+ ctx.lineTo(x - ux * s * 2 + uy * s, y - uy * s * 2 - ux * s);
+ ctx.closePath();
+ ctx.fillStyle = edgeColor(e.type);
+ ctx.fill();
+}
+function render(){
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+ ctx.fillStyle = view.bg || '#070b14';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ ctx.setTransform(view.dpr * state.k, 0, 0, view.dpr * state.k, view.dpr * state.tx, view.dpr * state.ty);
+ const hi = computeHighlight();
+ const q = state.search;
+
+ // edges
+ for (const e of state.edges) {
+ const a = state.byId[e.source], b = state.byId[e.target];
+ if (!visNode(a) || !visNode(b)) continue;
+ let alpha = 0.16 + (e.weight || 0.5) * 0.3 + Math.min((e.use_count || 1) * 0.045, 0.28);
+ if (hi.size) {
+ const on = hi.has(a.id) && hi.has(b.id);
+ alpha = on ? Math.min(alpha * 2.3, 0.95) : 0.045;
+ }
+ if (state.selected && (a === state.selected || b === state.selected)) alpha = Math.max(alpha, 0.92);
+ ctx.globalAlpha = alpha;
+ ctx.strokeStyle = edgeColor(e.type);
+ ctx.lineWidth = 0.7 + (e.weight || 0.5) * 1.5 + Math.min((e.use_count || 1) * 0.25, 2.2);
+ if (e.type === 'failed') ctx.setLineDash([4, 4]); else ctx.setLineDash([]);
+ ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
+ if (state.k > 1.15 && (!hi.size || (a === state.selected || b === state.selected || a === state.hovered || b === state.hovered)))
+ drawArrow(a, b, e);
+ }
+ ctx.setLineDash([]);
+
+ // nodes
+ let labels = 0;
+ for (const n of state.nodes) {
+ if (!visNode(n)) continue;
+ const inHi = !hi.size || hi.has(n.id);
+ const isSel = n === state.selected, isHov = n === state.hovered, isMatch = state.matches.has(n.id);
+ const col = typeColor(n.type);
+ ctx.globalAlpha = inHi ? 1 : 0.13;
+ if (isSel || isHov) { ctx.shadowColor = col; ctx.shadowBlur = 16; }
+ ctx.beginPath(); ctx.arc(n.x, n.y, n.r, 0, 6.28318);
+ ctx.fillStyle = col; ctx.fill();
+ ctx.shadowBlur = 0;
+ ctx.beginPath(); ctx.arc(n.x, n.y, n.r * 0.52, 0, 6.28318);
+ ctx.fillStyle = 'rgba(7,11,20,0.5)'; ctx.fill();
+ if (isSel || isHov || isMatch) {
+ ctx.beginPath(); ctx.arc(n.x, n.y, n.r + 3.5, 0, 6.28318);
+ ctx.strokeStyle = isMatch ? '#fbbf24' : '#ffffff';
+ ctx.lineWidth = isSel ? 2 : 1.4;
+ ctx.stroke();
+ }
+ if (n.namespace && n.namespace.startsWith('agent:')) {
+ ctx.beginPath(); ctx.arc(n.x, n.y, n.r + 1.8, 0, 6.28318);
+ ctx.setLineDash([2.2, 3]);
+ ctx.strokeStyle = 'rgba(232,121,249,0.85)'; ctx.lineWidth = 1; ctx.stroke();
+ ctx.setLineDash([]);
+ }
+ const showLabel = isSel || isHov || isMatch || n.type === 'session'
+ || (inHi && state.k * n.r > 6.4 && (n.degree >= 4 || state.k > 1.45));
+ if (showLabel && labels < 230) {
+ ctx.font = '10.5px system-ui, sans-serif';
+ ctx.textAlign = 'center';
+ const ly = n.y + n.r + 11.5;
+ ctx.lineWidth = 3;
+ ctx.strokeStyle = 'rgba(5,8,16,0.92)';
+ ctx.strokeText(shortLabel(n), n.x, ly);
+ ctx.fillStyle = (isSel || isHov) ? '#ffffff' : '#c9d5e6';
+ ctx.fillText(shortLabel(n), n.x, ly);
+ labels++;
+ }
+ }
+ ctx.globalAlpha = 1;
+}
+function frame(){
+ if (state.running && state.built) step();
+ if (state.built) render();
+ requestAnimationFrame(frame);
+}
+
+/* ─────────────── Interactions ─────────────── */
+const pointers = new Map();
+let dragMode = null; // 'pan' | 'pinch' | 'node'
+let dragNode = null;
+let pinchStart = null;
+
+function hitNode(sx, sy){
+ let best = null, bestD = Infinity;
+ for (const n of state.nodes) {
+ if (!visNode(n)) continue;
+ const px = n.x * state.k + state.tx, py = n.y * state.k + state.ty;
+ const d = Math.hypot(px - sx, py - sy);
+ const rad = n.r * state.k + 6;
+ if (d < rad && d < bestD) { best = n; bestD = d; }
+ }
+ return best;
+}
+canvas.addEventListener('pointerdown', e => {
+ canvas.setPointerCapture(e.pointerId);
+ pointers.set(e.pointerId, { x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY, moved: 0 });
+ if (pointers.size === 1) {
+ const n = hitNode(e.clientX, e.clientY);
+ if (n) { dragMode = 'node'; dragNode = n; n.fixed = true; reheat(0.35); }
+ else { dragMode = 'pan'; canvas.classList.add('grabbing'); }
+ } else if (pointers.size === 2) {
+ dragMode = 'pinch'; dragNode = null;
+ const pts = [...pointers.values()];
+ pinchStart = {
+ d: Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y) || 1,
+ cx: (pts[0].x + pts[1].x) / 2, cy: (pts[0].y + pts[1].y) / 2,
+ k: state.k, tx: state.tx, ty: state.ty
+ };
+ }
+});
+canvas.addEventListener('pointermove', e => {
+ const p = pointers.get(e.pointerId);
+ if (!p) {
+ // hover (no button pressed)
+ if (dragMode === null) {
+ const h = hitNode(e.clientX, e.clientY);
+ if (h !== state.hovered) { state.hovered = h; }
+ showTooltip(e, h);
+ }
+ return;
+ }
+ const dx = e.clientX - p.x, dy = e.clientY - p.y;
+ p.moved += Math.abs(dx) + Math.abs(dy);
+ p.x = e.clientX; p.y = e.clientY;
+ if (dragMode === 'node' && dragNode) {
+ const w = screenToWorld(e.clientX, e.clientY);
+ dragNode.x = w.x; dragNode.y = w.y;
+ reheat(0.3);
+ } else if (dragMode === 'pan') {
+ state.tx += dx; state.ty += dy;
+ } else if (dragMode === 'pinch' && pinchStart && pointers.size === 2) {
+ const pts = [...pointers.values()];
+ const d = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y) || 1;
+ const cx = (pts[0].x + pts[1].x) / 2, cy = (pts[0].y + pts[1].y) / 2;
+ const wx = (pinchStart.cx - pinchStart.tx) / pinchStart.k;
+ const wy = (pinchStart.cy - pinchStart.ty) / pinchStart.k;
+ const newK = Math.max(0.04, Math.min(4, pinchStart.k * (d / pinchStart.d)));
+ state.k = newK;
+ state.tx = cx - wx * newK;
+ state.ty = cy - wy * newK;
+ }
+});
+function endPointer(e){
+ const p = pointers.get(e.pointerId);
+ pointers.delete(e.pointerId);
+ if (p && p.moved < 6 && pointers.size === 0) {
+ // treat as click
+ const n = hitNode(e.clientX, e.clientY);
+ selectNode(n);
+ }
+ if (dragNode) { dragNode.fixed = false; }
+ if (pointers.size === 0) {
+ dragMode = null; dragNode = null; pinchStart = null;
+ canvas.classList.remove('grabbing');
+ } else if (pointers.size === 1) {
+ dragMode = 'pan'; pinchStart = null; dragNode = null;
+ const rem = [...pointers.values()][0];
+ rem.x = rem.px !== undefined ? rem.px : rem.x;
+ }
+}
+canvas.addEventListener('pointerup', endPointer);
+canvas.addEventListener('pointercancel', endPointer);
+canvas.addEventListener('pointerleave', e => { state.hovered = null; hideTooltip(); });
+canvas.addEventListener('wheel', e => {
+ e.preventDefault();
+ const f = Math.exp(-e.deltaY * 0.0016);
+ zoomAt(e.clientX, e.clientY, state.k * f);
+}, { passive: false });
+
+/* tooltip */
+const tooltip = $('#tooltip');
+function showTooltip(e, n){
+ if (!n) { hideTooltip(); return; }
+ const deg = n.degree || 0;
+ tooltip.innerHTML = '<b>' + esc(shortLabel(n) === n.label ? n.label : n.label) + '</b>' +
+ '<div class="t">' + esc(n.type) + ' Β· ' + deg + ' edges Β· ' + (n.access_count || 1) + 'Γ— access</div>' +
+ (n.namespace && n.namespace !== 'global' ? '<div class="t">agent: ' + esc(n.namespace) + '</div>' : '') +
+ '<div class="t">updated ' + esc(ago(n.updated || n.created)) + '</div>';
+ tooltip.style.display = 'block';
+ const r = tooltip.getBoundingClientRect();
+ let x = e.clientX + 14, y = e.clientY + 14;
+ if (x + r.width > view.w - 8) x = e.clientX - r.width - 14;
+ if (y + r.height > view.h - 8) y = e.clientY - r.height - 14;
+ tooltip.style.left = x + 'px'; tooltip.style.top = y + 'px';
+}
+function hideTooltip(){ tooltip.style.display = 'none'; }
+
+/* selection + details */
+function selectNode(n){
+ state.selected = n;
+ renderDetails(n);
+}
+function renderDetails(n){
+ const box = $('#details');
+ if (!n) { box.classList.remove('open'); box.innerHTML = ''; return; }
+ const col = typeColor(n.type);
+ const myEdges = state.edges.filter(e => e.source === n.id || e.target === n.id)
+ .sort((a, b) => (b.weight || 0) - (a.weight || 0));
+ const meta = n.metadata || {};
+ const metaKeys = Object.keys(meta);
+ let html = '';
+ html += '<div id="dHead"><div style="flex:1;min-width:0">' +
+ '<div id="dTitle">' + esc(n.label || n.id) + '</div>' +
+ '<span class="badge" style="color:' + col + ';border-color:' + col + '55">● ' + esc(n.type) + '</span>' +
+ '<span class="badge" style="color:' + (n.namespace && n.namespace.startsWith('agent:') ? '#e879f9' : '#94a3b8') + ';border-color:rgba(148,163,184,.3)">' + esc(n.namespace || 'global') + '</span>' +
+ '</div><button class="btn" id="dClose" title="Close">βœ•</button></div>';
+ html += '<div class="kv"><span class="k">ID</span><span class="v" style="font-family:ui-monospace,monospace;font-size:11px">' + esc(n.id) + '</span></div>';
+ html += '<div class="kv"><span class="k">Accesses</span><span class="v">' + (n.access_count || 1) + 'Γ—</span></div>';
+ html += '<div class="kv"><span class="k">Degree</span><span class="v">' + (n.degree || 0) + ' edges</span></div>';
+ html += '<div class="kv"><span class="k">Created</span><span class="v">' + esc(ago(n.created)) + '</span></div>';
+ html += '<div class="kv"><span class="k">Updated</span><span class="v">' + esc(ago(n.updated || n.created)) + '</span></div>';
+ if (n.source_session) html += '<div class="kv"><span class="k">Session</span><span class="v">' + esc(n.source_session) + '</span></div>';
+ if (n.decay_score !== undefined) html += '<div class="kv"><span class="k">Decay score</span><span class="v">' + Number(n.decay_score).toFixed(2) + '</span></div>';
+ if (metaKeys.length) {
+ html += '<pre class="meta">' + metaKeys.map(k => esc(k) + ': ' + esc(typeof meta[k] === 'object' ? JSON.stringify(meta[k]) : meta[k])).join('\n') + '</pre>';
+ }
+ html += '<div class="sec" style="margin-top:12px"><h3>Connections (' + myEdges.length + ')</h3>';
+ const shown = myEdges.slice(0, 30);
+ for (const e of shown) {
+ const out = e.source === n.id;
+ const otherId = out ? e.target : e.source;
+ const other = state.byId[otherId];
+ const oc = typeColor(other.type);
+ html += '<div class="edgeRow" data-node="' + esc(otherId) + '">' +
+ '<span class="arrow">' + (out ? 'β†’' : '←') + '</span>' +
+ '<span class="dot" style="width:8px;height:8px;border-radius:50%;background:' + oc + ';flex-shrink:0"></span>' +
+ '<span class="lab">' + esc(other.label || otherId) + '</span>' +
+ '<span class="etype">' + esc(e.type) + '</span>' +
+ '<span class="w">w ' + Number(e.weight || 0).toFixed(2) + ' Β· ' + (e.use_count || 1) + 'Γ—</span></div>';
+ }
+ if (myEdges.length > 30) html += '<div class="mini" style="padding:4px 8px">… and ' + (myEdges.length - 30) + ' more</div>';
+ html += '</div>';
+ html += '<div style="display:flex;gap:6px;margin-top:10px"><button class="btn" id="dCenter" style="flex:1">🎯 Center</button></div>';
+ box.innerHTML = html;
+ box.classList.add('open');
+ $('#dClose').onclick = () => selectNode(null);
+ $('#dCenter').onclick = () => { centerOn(n); };
+ box.querySelectorAll('.edgeRow').forEach(el => {
+ el.onclick = () => {
+ const t = state.byId[el.getAttribute('data-node')];
+ if (t) { selectNode(t); centerOn(t); }
+ };
+ });
+}
+
+/* ─────────────── Sidebar UI ─────────────── */
+function ago(iso){
+ if (!iso) return 'β€”';
+ const t = new Date(iso).getTime();
+ if (isNaN(t)) return 'β€”';
+ const s = (Date.now() - t) / 1000;
+ if (s < 60) return 'just now';
+ if (s < 3600) return Math.floor(s / 60) + 'm ago';
+ if (s < 86400) return Math.floor(s / 3600) + 'h ago';
+ if (s < 86400 * 30) return Math.floor(s / 86400) + 'd ago';
+ return new Date(t).toLocaleDateString();
+}
+function buildSidebar(){
+ const sb = $('#sidebar');
+ const types = Object.keys(state.typeVis);
+ const typeCounts = {};
+ state.nodes.forEach(n => typeCounts[n.type] = (typeCounts[n.type] || 0) + 1);
+ const nsList = Object.keys(state.nsVis);
+ const nsCounts = {};
+ state.nodes.forEach(n => { const ns = n.namespace || 'global'; nsCounts[ns] = (nsCounts[ns] || 0) + 1; });
+
+ // top connected
+ const top = [...state.nodes].sort((a, b) => (b.degree || 0) - (a.degree || 0)).slice(0, 6);
+ const maxDeg = Math.max(...top.map(n => n.degree || 0), 1);
+ // strongest edges
+ const strong = [...state.edges].sort((a, b) => (b.weight || 0) - (a.weight || 0)).slice(0, 5);
+ // recent tasks
+ const tasks = state.nodes.filter(n => n.type === 'task')
+ .sort((a, b) => new Date(b.updated || 0) - new Date(a.updated || 0)).slice(0, 6);
+ // edge type counts
+ const edgeCounts = {};
+ state.edges.forEach(e => edgeCounts[e.type] = (edgeCounts[e.type] || 0) + 1);
+ const density = state.nodes.length ? (state.edges.length / state.nodes.length).toFixed(2) : '0';
+
+ let h = '';
+ h += '<div class="sec"><div class="statgrid">' +
+ '<div class="stat"><div class="v">' + state.nodes.length + '</div><div class="k">nodes</div></div>' +
+ '<div class="stat"><div class="v">' + state.edges.length + '</div><div class="k">edges</div></div>' +
+ '<div class="stat"><div class="v">' + state.accesses + '</div><div class="k">accesses</div></div>' +
+ '<div class="stat"><div class="v">' + density + '</div><div class="k">edges/node</div></div>' +
+ '</div></div>';
+
+ h += '<div class="sec"><h3>Node types β€” click to filter</h3><div id="typeChips">';
+ types.sort((a, b) => typeCounts[b] - typeCounts[a]).forEach(t => {
+ h += '<span class="chip" data-type="' + esc(t) + '"><span class="dot" style="background:' + typeColor(t) + '"></span>' +
+ esc(t) + ' <b style="color:#94a3b8">' + typeCounts[t] + '</b></span>';
+ });
+ h += '</div></div>';
+
+ if (nsList.length > 1) {
+ h += '<div class="sec"><h3>Namespaces</h3>';
+ nsList.forEach(ns => {
+ const isAgent = ns.startsWith('agent:');
+ h += '<span class="chip" data-ns="' + esc(ns) + '"><span class="dot" style="background:' + (isAgent ? '#e879f9' : '#64748b') + '"></span>' +
+ esc(ns) + ' <b style="color:#94a3b8">' + nsCounts[ns] + '</b></span>';
+ });
+ h += '</div>';
+ }
+
+ h += '<div class="sec"><h3>Most connected <span style="text-transform:none;letter-spacing:0">memgraph-stats</span></h3>';
+ top.forEach(n => {
+ h += '<div class="row" data-node="' + esc(n.id) + '"><span class="dot" style="background:' + typeColor(n.type) + '"></span>' +
+ '<span class="lab">' + esc(shortLabel(n)) + '</span>' +
+ '<span class="val">' + (n.degree || 0) + '</span></div>';
+ });
+ h += '</div>';
+
+ h += '<div class="sec"><h3>Strongest links</h3>';
+ strong.forEach(e => {
+ const a = state.byId[e.source], b = state.byId[e.target];
+ h += '<div class="row" data-node="' + esc(e.source) + '"><span class="dot" style="background:' + edgeColor(e.type) + '"></span>' +
+ '<span class="lab">' + esc(shortLabel(a)) + ' β†’ ' + esc(shortLabel(b)) + '</span>' +
+ '<span class="val">w' + Number(e.weight || 0).toFixed(2) + '</span></div>';
+ });
+ h += '</div>';
+
+ if (tasks.length) {
+ h += '<div class="sec"><h3>Recent tasks</h3>';
+ tasks.forEach(n => {
+ h += '<div class="row" data-node="' + esc(n.id) + '"><span class="dot" style="background:' + typeColor(n.type) + '"></span>' +
+ '<span class="lab">' + esc(shortLabel(n)) + '</span>' +
+ '<span class="val">' + esc(ago(n.updated || n.created)) + '</span></div>';
+ });
+ h += '</div>';
+ }
+
+ h += '<div class="sec"><h3>Edge types</h3>';
+ Object.keys(edgeCounts).sort((a, b) => edgeCounts[b] - edgeCounts[a]).forEach(t => {
+ const dashed = t === 'failed' ? 'border-top:2px dashed ' + edgeColor(t) : '';
+ h += '<div class="lrow"><span class="line" style="background:' + edgeColor(t) + ';' + (dashed ? 'background:none;' + dashed : '') + '"></span>' +
+ esc(t) + '<span class="n">' + edgeCounts[t] + '</span></div>';
+ });
+ h += '</div>';
+
+ const cfg = state.config || {};
+ if (cfg.decayDays) {
+ h += '<div class="sec"><h3>Decay policy</h3><div class="mini">Threshold: <code>' + esc(cfg.decayDays) + 'd</code> Β· min uses: <code>' + esc(cfg.decayMinUses) + '</code> β€” stale, weak nodes fade and get auto-pruned.</div></div>';
+ }
+
+ h += '<div class="sec mini">Source: <code>/.memory_graph/</code> Β· ' + esc(state.source) + '<br>' +
+ 'Terminal: <code>memgraph-status</code> Β· <code>memgraph-stats</code> Β· <code>memgraph-search</code> Β· <code>memgraph-export</code> Β· <code>memgraph-prune</code></div>';
+ h += '<div id="foot"><button class="btn" id="btnReset">πŸ”€ Reset layout</button></div>';
+
+ sb.innerHTML = h;
+ sb.querySelectorAll('.chip[data-type]').forEach(el => {
+ el.onclick = () => {
+ const t = el.getAttribute('data-type');
+ state.typeVis[t] = !state.typeVis[t];
+ el.classList.toggle('off', !state.typeVis[t]);
+ reheat(0.12);
+ };
+ });
+ sb.querySelectorAll('.chip[data-ns]').forEach(el => {
+ el.onclick = () => {
+ const ns = el.getAttribute('data-ns');
+ state.nsVis[ns] = !state.nsVis[ns];
+ el.classList.toggle('off', !state.nsVis[ns]);
+ };
+ });
+ sb.querySelectorAll('.row[data-node]').forEach(el => {
+ el.onclick = () => {
+ const n = state.byId[el.getAttribute('data-node')];
+ if (n) { selectNode(n); centerOn(n); }
+ };
+ });
+ const rb = $('#btnReset');
+ if (rb) rb.onclick = () => { layoutInit(); fitView(); toast('Layout reset'); };
+}
+
+/* ─────────────── Top bar wiring ─────────────── */
+function toast(msg){
+ const t = $('#toast');
+ t.textContent = msg;
+ t.classList.add('show');
+ clearTimeout(t._tm);
+ t._tm = setTimeout(() => t.classList.remove('show'), 2600);
+}
+const searchEl = $('#search');
+function updateSearch(){
+ const q = searchEl.value.trim().toLowerCase();
+ state.search = q;
+ state.matches = new Set();
+ const cnt = $('#searchCount');
+ if (q) {
+ for (const n of state.nodes) {
+ const hay = ((n.label || '') + ' ' + n.id + ' ' + n.type).toLowerCase();
+ if (hay.includes(q)) state.matches.add(n.id);
+ }
+ cnt.style.display = 'inline';
+ cnt.textContent = state.matches.size + ' match' + (state.matches.size === 1 ? '' : 'es');
+ } else {
+ cnt.style.display = 'none';
+ }
+}
+searchEl.addEventListener('input', updateSearch);
+searchEl.addEventListener('keydown', e => {
+ if (e.key === 'Enter' && state.matches.size) {
+ const first = state.byId[[...state.matches][0]];
+ if (first) { selectNode(first); centerOn(first); }
+ } else if (e.key === 'Escape') {
+ searchEl.value = ''; updateSearch();
+ }
+});
+document.addEventListener('keydown', e => {
+ if (e.key === '/' && document.activeElement !== searchEl) { e.preventDefault(); searchEl.focus(); }
+ else if (e.key === 'Escape' && document.activeElement !== searchEl) { selectNode(null); }
+ else if ((e.key === 'f' || e.key === 'F') && document.activeElement !== searchEl && !e.ctrlKey && !e.metaKey) { fitView(); }
+});
+$('#btnFit').onclick = () => fitView();
+$('#btnZoomIn').onclick = () => zoomAt(view.w / 2, view.h / 2, state.k * 1.28);
+$('#btnZoomOut').onclick = () => zoomAt(view.w / 2, view.h / 2, state.k / 1.28);
+$('#btnSidebar').onclick = () => $('#sidebar').classList.toggle('hidden');
+$('#btnPause').onclick = () => {
+ state.running = !state.running;
+ const b = $('#btnPause');
+ b.textContent = state.running ? '⏸' : 'β–Ά';
+ b.classList.toggle('act', !state.running);
+ b.title = state.running ? 'Pause physics' : 'Resume physics';
+ toast(state.running ? 'Physics resumed' : 'Physics paused');
+};
+$('#btnRefresh').onclick = async () => {
+ try {
+ toast('Refreshing graph…');
+ const raw = await loadData();
+ buildGraph(raw);
+ buildSidebar();
+ $('#srcChip').textContent = '⚑ ' + state.source + ' · ' + state.nodes.length + 'n/' + state.edges.length + 'e';
+ warmup(120); fitView();
+ updateSearch();
+ selectNode(null);
+ toast('βœ… ' + state.nodes.length + ' nodes Β· ' + state.edges.length + ' edges β€” ' + state.source);
+ } catch (e) {
+ toast('❌ Refresh failed: ' + e.message);
+ }
+};
+$('#btnPNG').onclick = () => {
+ try {
+ canvas.toBlob(b => {
+ const a = document.createElement('a');
+ a.href = URL.createObjectURL(b);
+ a.download = 'memgraph-' + new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-') + '.png';
+ a.click();
+ setTimeout(() => URL.revokeObjectURL(a.href), 3000);
+ toast('πŸ–ΌοΈ PNG exported');
+ });
+ } catch (e) { toast('Export failed: ' + e.message); }
+};
+$('#btnJSON').onclick = () => {
+ try {
+ const data = {
+ version: 1,
+ exported: new Date().toISOString(),
+ source: 'memgraph.html viewer',
+ nodes: state.nodes.map(n => { const c = Object.assign({}, n); delete c.vx; delete c.vy; delete c.fixed; return c; }),
+ edges: state.edges,
+ stats: { totalNodes: state.nodes.length, totalEdges: state.edges.length, totalAccesses: state.accesses }
+ };
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
+ const a = document.createElement('a');
+ a.href = URL.createObjectURL(blob);
+ a.download = 'memgraph-export.json';
+ a.click();
+ setTimeout(() => URL.revokeObjectURL(a.href), 3000);
+ toast('πŸ“„ JSON exported (memgraph-export format)');
+ } catch (e) { toast('Export failed: ' + e.message); }
+};
+
+/* ─────────────── Init ─────────────── */
+function setLoad(title, text){
+ $('#ovTitle').textContent = title;
+ $('#ovText').innerHTML = text;
+}
+async function init(){
+ resize();
+ setLoad('Loading Memory Graph…', 'Locating graph data in the WebX11 newOS4 VFS…');
+ try {
+ const raw = await loadData();
+ setLoad('Building graph…', 'Found ' + raw.nodes.length + ' nodes Β· ' + raw.edges.length + ' edges β€” computing layout…');
+ buildGraph(raw);
+ buildSidebar();
+ $('#srcChip').textContent = '⚑ ' + state.source + ' · ' + state.nodes.length + 'n/' + state.edges.length + 'e';
+ warmup(140);
+ fitView();
+ requestAnimationFrame(frame);
+ $('#overlay').classList.add('hide');
+ toast('🧠 ' + state.nodes.length + ' nodes Β· ' + state.edges.length + ' edges loaded β€” ' + state.source);
+ } catch (err) {
+ $('#spin').style.display = 'none';
+ setLoad('⚠️ No graph data found',
+ 'This viewer reads the <b>live</b> memory graph from WebX11 newOS4 (<code>/.memory_graph/</code> in the VFS).<br><br>' +
+ 'To see your graph, open it from inside newOS4:<br><code>preview /memgraph.html</code><br><br>' +
+ 'Or serve this file from the same origin as newOS4.<br><br><span style="color:#64748b">' + esc(err.message) + '</span>');
+ $('#ovRetry').style.display = 'inline-flex';
+ }
+}
+requestAnimationFrame(frame);
+init();
+</script>
+</body>
+</html> \ No newline at end of file