@kage-core/kage-graph-mcp 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,638 @@
1
+ (function () {
2
+ "use strict";
3
+ var params = new URLSearchParams(location.search);
4
+ function src(name, fb) { return params.get(name) || fb; }
5
+ var root = "./data/kage/reports/";
6
+ var paths = {
7
+ lifecycle: src("lifecycle", root + "lifecycle.json"),
8
+ trust: src("trust", root + "trust.json"),
9
+ suppressed: src("suppressed", root + "suppressed.json"),
10
+ metrics: src("metrics", "./data/kage/metrics.json"),
11
+ activity: src("activity", root + "activity.json"),
12
+ };
13
+ var state = { items: [], filter: "all", q: "", metrics: null, graphReady: false, showAll: false };
14
+
15
+ function getJSON(p) { return fetch(p).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; }); }
16
+ function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
17
+ function fmt(n) {
18
+ if (n == null || isNaN(n)) return "—";
19
+ if (n >= 1e6) return (n / 1e6).toFixed(n >= 1e7 ? 0 : 1).replace(/\.0$/, "") + "M";
20
+ if (n >= 1e3) return (n / 1e3).toFixed(n >= 1e4 ? 0 : 1).replace(/\.0$/, "") + "K";
21
+ return String(n);
22
+ }
23
+ function base(p) { return String(p).split("/").pop(); }
24
+ // The daemon serves report params as absolute paths (.../<repo>/.agent_memory/...),
25
+ // while project_dir is often "." (reports generated with `--project .`). Pull the
26
+ // real repo name from the path; fall back to project_dir basename, then a default.
27
+ function resolveRepoName(metrics, lifecycle) {
28
+ var cands = [paths.lifecycle, paths.trust, paths.metrics, paths.suppressed];
29
+ for (var i = 0; i < cands.length; i++) {
30
+ var m = /\/([^/]+)\/\.agent_memory\//.exec(cands[i] || "");
31
+ if (m && m[1]) return m[1];
32
+ }
33
+ if (metrics && metrics.repo) return metrics.repo;
34
+ var pd = ((metrics && metrics.project_dir) || (lifecycle && lifecycle.project_dir) || "").replace(/\/+$/, "");
35
+ var bn = pd.split("/").pop();
36
+ if (bn && bn !== "." && bn !== "..") return bn;
37
+ return "repository";
38
+ }
39
+
40
+ // ---- nav ----
41
+ var META = {
42
+ overview: ["kage://overview", "Repository overview", "Whether this repo's agent memory can be trusted — at a glance."],
43
+ graph: ["kage://memory-map", "Memory ↔ code map", "Each packet anchored to the files it's grounded in. Hover a node to inspect."],
44
+ memory: ["kage://memory", "Memory", "Every packet Kage has stored, with health and grounding."],
45
+ activity: ["kage://activity", "Activity", "What agents actually recalled and captured here, over time."],
46
+ insights: ["kage://insights", "Insights", "Health, composition, and what Kage has mapped in this repo."],
47
+ };
48
+ function show(name) {
49
+ var btns = document.querySelectorAll("#nav button");
50
+ for (var i = 0; i < btns.length; i++) btns[i].setAttribute("aria-current", btns[i].dataset.section === name ? "true" : "false");
51
+ var secs = document.querySelectorAll(".section");
52
+ for (var j = 0; j < secs.length; j++) secs[j].classList.toggle("active", secs[j].id === "section-" + name);
53
+ var m = META[name] || META.overview;
54
+ document.getElementById("eyebrow").textContent = m[0];
55
+ document.getElementById("title").textContent = m[1];
56
+ document.getElementById("subtitle").textContent = m[2];
57
+ if (name === "graph" && !state.graphReady) { state.graphReady = true; setTimeout(initGraph, 30); }
58
+ if (location.hash !== "#" + name) history.replaceState(null, "", "#" + name);
59
+ }
60
+ document.getElementById("nav").addEventListener("click", function (e) {
61
+ var b = e.target.closest("button[data-section]"); if (b) show(b.dataset.section);
62
+ });
63
+
64
+ // ---- load ----
65
+ Promise.all([getJSON(paths.trust), getJSON(paths.suppressed), getJSON(paths.lifecycle), getJSON(paths.metrics), getJSON(paths.activity)])
66
+ .then(function (r) { render(r[0], r[1], r[2], r[3], r[4]); })
67
+ .catch(function () { render(null, null, null, null, null); });
68
+
69
+ function render(trust, suppressed, lifecycle, metrics, activity) {
70
+ state.items = (lifecycle && lifecycle.items) || [];
71
+ state.metrics = metrics || {};
72
+ state.activity = activity || {};
73
+ document.getElementById("repo").textContent = resolveRepoName(metrics, lifecycle);
74
+ renderHero(trust);
75
+ renderTiles(metrics, state.items);
76
+ renderAttention(state.items, suppressed);
77
+ renderChips(); renderList();
78
+ renderInsights(metrics, state.items);
79
+ renderActivity(activity);
80
+ var start = (location.hash || "").replace("#", "");
81
+ show(META[start] ? start : "overview");
82
+ }
83
+
84
+ // ---- activity feed (real recorded recalls + captures) ----
85
+ function relTime(iso) {
86
+ var t = Date.parse(iso); if (!t) return "";
87
+ var s = (Date.now() - t) / 1000;
88
+ if (s < 60) return "just now";
89
+ if (s < 3600) return Math.floor(s / 60) + "m ago";
90
+ if (s < 86400) return Math.floor(s / 3600) + "h ago";
91
+ if (s < 86400 * 30) return Math.floor(s / 86400) + "d ago";
92
+ return String(iso).slice(0, 10);
93
+ }
94
+ var EV_ICON = { recall: "↺", capture: "+", supersede: "⇄", deprecate: "✕", update: "✎", promote: "▲", feedback: "✦", other: "•" };
95
+ function renderActivity(activity) {
96
+ var t = (activity && activity.totals) || { events: 0, recalls: 0, captures: 0, recalls_7d: 0 };
97
+ var tiles = document.getElementById("activityTiles");
98
+ if (tiles) {
99
+ tiles.textContent = "";
100
+ [
101
+ { k: "Recalls (7 days)", v: fmt(t.recalls_7d), s: "agent memory pulls", cls: "green" },
102
+ { k: "Total recalls", v: fmt(t.recalls), s: "all time, this machine", cls: "green" },
103
+ { k: "Captures", v: fmt(t.captures), s: "memories written", cls: "memory" },
104
+ { k: "Events", v: fmt(t.events), s: "in the activity log", cls: "" },
105
+ ].forEach(function (d) { var x = el("div", "tile"); x.appendChild(el("div", "k", d.k)); x.appendChild(el("div", "v " + (d.cls || ""), d.v)); x.appendChild(el("div", "s", d.s)); tiles.appendChild(x); });
106
+ }
107
+ // recalls-per-day bars
108
+ var daily = (activity && activity.daily) || [];
109
+ var db = document.getElementById("activityDaily");
110
+ if (db) {
111
+ db.textContent = "";
112
+ if (!daily.length) { db.appendChild(el("div", "empty", "No recalls recorded yet. Run kage recall / kage_context and they'll appear here.")); }
113
+ var dmax = daily.reduce(function (a, b) { return Math.max(a, b.recalls); }, 1);
114
+ daily.forEach(function (d) {
115
+ var col = el("div", "col");
116
+ col.appendChild(el("span", "v", String(d.recalls)));
117
+ var bar = el("span", "bar" + (d.recalls ? "" : " empty")); col.appendChild(bar);
118
+ col.appendChild(el("span", "d", d.day.slice(5)));
119
+ db.appendChild(col);
120
+ setTimeout(function () { bar.style.height = Math.max(3, d.recalls / dmax * 96) + "px"; }, 60);
121
+ });
122
+ }
123
+ // feed
124
+ var feed = document.getElementById("activityFeed");
125
+ if (feed) {
126
+ feed.textContent = ""; feed.className = "feed";
127
+ var events = (activity && activity.events) || [];
128
+ if (!events.length) { feed.appendChild(el("div", "empty", "Nothing recorded yet. As agents recall and capture memory, it streams in here.")); return; }
129
+ events.forEach(function (e) {
130
+ var row = el("div", "ev " + (e.kind || "other"));
131
+ row.appendChild(el("span", "ei", EV_ICON[e.kind] || "•"));
132
+ var mid = el("div");
133
+ mid.appendChild(el("span", "et", e.title || e.kind));
134
+ mid.appendChild(document.createTextNode(" "));
135
+ mid.appendChild(el("span", "ek", e.kind === "recall" ? "recall" : (e.detail || e.kind)));
136
+ row.appendChild(mid);
137
+ row.appendChild(el("span", "when", relTime(e.at)));
138
+ feed.appendChild(row);
139
+ });
140
+ }
141
+ }
142
+
143
+ // ---- hero ----
144
+ function renderHero(trust) {
145
+ var hero = document.getElementById("hero");
146
+ var score = trust && typeof trust.trust_score === "number" ? trust.trust_score : null;
147
+ var m = (trust && trust.metrics) || {};
148
+ var status = score == null ? "idle" : (score >= 90 ? "ok" : score >= 70 ? "warn" : "alert");
149
+ hero.setAttribute("data-status", status);
150
+ var bars = [
151
+ ["Hallucinated citations rejected", m.hallucinated_citation_rejection_rate],
152
+ ["Stale memory excluded from recall", m.stale_memory_exclusion_rate],
153
+ ["Live memory grounded to code", m.live_grounding_rate],
154
+ ];
155
+ var verdict = score == null ? "Run kage benchmark --trust to score this repo's memory." :
156
+ status === "ok" ? "Agents recall only memory that is grounded in current code." :
157
+ "Some memory needs review before agents should trust it.";
158
+ var left = el("div");
159
+ left.appendChild(el("span", "eyebrow", "Memory Trust"));
160
+ var s = el("div", "score"); s.appendChild(el("b", null, score == null ? "—" : "0")); s.appendChild(el("span", null, "/100"));
161
+ left.appendChild(s); left.appendChild(el("p", "verdict", verdict));
162
+ var right = el("div", "bars");
163
+ bars.forEach(function (b) {
164
+ var has = !(b[1] == null || isNaN(b[1])); var v = has ? Math.max(0, Math.min(100, b[1])) : 0;
165
+ var bar = el("div", "bar");
166
+ bar.appendChild(el("span", "lbl", b[0])); bar.appendChild(el("b", "val", has ? v + "%" : "—"));
167
+ var t = el("span", "track"), f = el("i"); t.appendChild(f); bar.appendChild(t);
168
+ right.appendChild(bar); setTimeout(function () { f.style.width = v + "%"; }, 60);
169
+ });
170
+ hero.textContent = ""; hero.appendChild(left); hero.appendChild(right);
171
+ if (score != null) countUp(s.querySelector("b"), score, 900);
172
+ }
173
+ function countUp(node, to, dur) {
174
+ var start = performance.now();
175
+ function step(now) {
176
+ var t = Math.min(1, (now - start) / dur); var e = 1 - Math.pow(1 - t, 3);
177
+ node.textContent = Math.round(e * to); if (t < 1) requestAnimationFrame(step);
178
+ }
179
+ requestAnimationFrame(step);
180
+ }
181
+
182
+ // ---- overview tiles ----
183
+ function counts(items) {
184
+ var c = {}; items.forEach(function (i) { c[i.health] = (c[i.health] || 0) + 1; }); return c;
185
+ }
186
+ function renderTiles(metrics, items) {
187
+ var cg = (metrics && metrics.code_graph) || {};
188
+ var act = (state.activity && state.activity.totals) || {};
189
+ var c = counts(items);
190
+ var review = (c.stale || 0) + (c.disputed || 0) + (c.ungrounded || 0);
191
+ var r7 = act.recalls_7d || 0;
192
+ var data = [
193
+ { k: "Memory packets", v: fmt(items.length), s: (c.hot || 0) + " hot · " + (c.healthy || 0) + " healthy", cls: "memory" },
194
+ { k: "Needs review", v: fmt(review), s: review ? "stale or ungrounded" : "all current", cls: review ? "warn" : "green" },
195
+ { k: "Files mapped", v: fmt(cg.files), s: fmt(cg.symbols) + " symbols indexed", cls: "code" },
196
+ { k: "Recalls (7 days)", v: fmt(r7), s: r7 ? fmt(act.recalls || 0) + " all-time" : "no recalls yet", cls: r7 ? "green" : "" },
197
+ ];
198
+ var box = document.getElementById("tiles"); box.textContent = "";
199
+ data.forEach(function (d) {
200
+ var t = el("div", "tile"); t.appendChild(el("div", "k", d.k));
201
+ t.appendChild(el("div", "v " + (d.cls || ""), d.v)); t.appendChild(el("div", "s", d.s)); box.appendChild(t);
202
+ });
203
+ }
204
+
205
+ // ---- attention ----
206
+ function renderAttention(items, suppressed) {
207
+ var att = [];
208
+ items.forEach(function (i) {
209
+ if (["stale", "disputed", "ungrounded"].indexOf(i.health) !== -1 || i.severity === "blocker")
210
+ att.push({ title: i.title, why: i.reason || (i.stale_reasons && i.stale_reasons[0]) || i.recommended_action, tag: i.health });
211
+ });
212
+ ((suppressed && suppressed.items) || []).forEach(function (s) {
213
+ if (!att.some(function (a) { return a.title === s.title; })) att.push({ title: s.title, why: s.reason, tag: "withheld" });
214
+ });
215
+ var mount = document.getElementById("attentionMount"); mount.textContent = "";
216
+ if (!att.length) {
217
+ var ok = el("div", "panel"); ok.appendChild(el("h2", null, "Nothing needs your attention"));
218
+ ok.appendChild(el("div", "empty", "No stale, disputed, or withheld memory. Agents can trust everything in here right now.")); mount.appendChild(ok); return;
219
+ }
220
+ var card = el("div", "alert-card");
221
+ var ah = el("div", "ah"); ah.appendChild(el("h2", null, "Needs your attention")); ah.appendChild(el("span", "c", att.length + " to review")); card.appendChild(ah);
222
+ att.slice(0, 12).forEach(function (a) {
223
+ var row = el("div", "att"); row.appendChild(el("span", "t", a.title)); row.appendChild(el("span", "tag", a.tag));
224
+ row.appendChild(el("span", "why", a.why || "needs review")); card.appendChild(row);
225
+ });
226
+ mount.appendChild(card);
227
+ }
228
+
229
+ // ---- memory list ----
230
+ function renderChips() {
231
+ var c = counts(state.items); c.all = state.items.length;
232
+ var order = ["all", "hot", "healthy", "cold", "stale", "disputed", "ungrounded"];
233
+ var box = document.getElementById("chips"); box.textContent = "";
234
+ order.forEach(function (k) {
235
+ if (k !== "all" && !c[k]) return;
236
+ var b = el("button", "chip"); b.setAttribute("aria-pressed", state.filter === k ? "true" : "false");
237
+ b.innerHTML = (k === "all" ? "All" : k[0].toUpperCase() + k.slice(1)) + " <b>" + (c[k] || 0) + "</b>";
238
+ b.onclick = function () { state.filter = k; state.showAll = false; renderChips(); renderList(); };
239
+ box.appendChild(b);
240
+ });
241
+ }
242
+ var LIST_CAP = 50;
243
+ var HEALTH_LABEL = { hot: "Hot", healthy: "Healthy", cold: "Cold", stale: "Stale", disputed: "Disputed", ungrounded: "Ungrounded", generated: "Generated" };
244
+ function fileSummary(paths) {
245
+ var seen = {}, bases = [];
246
+ (paths || []).forEach(function (p) { var b = base(p); if (!seen[b]) { seen[b] = 1; bases.push(b); } });
247
+ if (!bases.length) return null;
248
+ if (bases.length === 1) return bases[0];
249
+ return bases[0] + " +" + (bases.length - 1);
250
+ }
251
+ function memoryRow(i) {
252
+ var row = el("div", "row"); row.appendChild(el("span", "dot " + (i.health || "")));
253
+ var body = el("div"); body.appendChild(el("div", "title", i.title));
254
+ var meta = el("div", "meta"); meta.appendChild(el("span", "type", i.type || "memory"));
255
+ var fs = fileSummary(i.paths); if (fs) meta.appendChild(el("span", "paths", fs));
256
+ body.appendChild(meta); row.appendChild(body);
257
+ var right = el("div", "right");
258
+ right.appendChild(el("span", "pill " + (i.health || ""), HEALTH_LABEL[i.health] || i.health || "memory"));
259
+ if (i.total_uses) right.appendChild(el("span", "uses", "used " + i.total_uses + "×"));
260
+ row.appendChild(right);
261
+ row.onclick = function () { openDetail(i); };
262
+ return row;
263
+ }
264
+ // packet detail drawer — read a memory's full content, grounding, and status.
265
+ var drawer = document.getElementById("detail"), drawerBg = document.getElementById("detailBackdrop");
266
+ function closeDetail() { if (drawer) drawer.classList.remove("open"); if (drawerBg) drawerBg.classList.remove("open"); }
267
+ function openDetail(it) {
268
+ if (!drawer) return;
269
+ var h = "";
270
+ h += '<button class="dr-x" id="drClose" aria-label="Close">×</button>';
271
+ h += '<div class="dr-tags"><span class="type">' + escapeHtml(it.type || "memory") + '</span>';
272
+ h += '<span class="pill ' + (it.health || "") + '">' + escapeHtml(HEALTH_LABEL[it.health] || it.health || "") + '</span>';
273
+ if (it.status && it.status !== "approved") h += '<span class="pill">' + escapeHtml(it.status) + '</span>';
274
+ h += "</div>";
275
+ h += '<h3 class="dr-title">' + escapeHtml(it.title) + "</h3>";
276
+ if (it.summary) h += '<p class="dr-summary">' + escapeHtml(it.summary) + "</p>";
277
+ if (it.body) h += '<div class="dr-body">' + escapeHtml(it.body) + "</div>";
278
+ var files = (it.paths || []).filter(Boolean);
279
+ if (files.length) h += '<div class="dr-sec"><div class="dr-h">Grounded in</div>' + files.map(function (f) { return '<div class="dr-file">' + escapeHtml(f) + "</div>"; }).join("") + "</div>";
280
+ if (it.tags && it.tags.length) h += '<div class="dr-sec"><div class="dr-h">Tags</div><div class="dr-tagrow">' + it.tags.map(function (t) { return '<span class="tg">' + escapeHtml(t) + "</span>"; }).join("") + "</div></div>";
281
+ var meta = '<div class="dr-row">Used <b>' + (it.total_uses || 0) + "×</b>" + (it.uses_30d ? " (" + it.uses_30d + " in last 30 days)" : "") + "</div>";
282
+ if (it.last_accessed_at) meta += '<div class="dr-row">Last recalled <b>' + escapeHtml(String(it.last_accessed_at).slice(0, 10)) + "</b></div>";
283
+ if (it.stale_reasons && it.stale_reasons.length) meta += '<div class="dr-row warn">' + escapeHtml(it.stale_reasons[0]) + "</div>";
284
+ if (it.action) meta += '<div class="dr-row">' + escapeHtml(it.action) + "</div>";
285
+ h += '<div class="dr-sec"><div class="dr-h">Status</div>' + meta + "</div>";
286
+ drawer.innerHTML = h; drawer.scrollTop = 0; drawer.classList.add("open"); if (drawerBg) drawerBg.classList.add("open");
287
+ var x = document.getElementById("drClose"); if (x) x.onclick = closeDetail;
288
+ }
289
+ if (drawerBg) drawerBg.onclick = closeDetail;
290
+ document.addEventListener("keydown", function (e) { if (e.key === "Escape") closeDetail(); });
291
+ function renderList() {
292
+ var list = document.getElementById("list"); list.textContent = "";
293
+ var q = state.q.trim().toLowerCase();
294
+ var rows = state.items.filter(function (i) {
295
+ if (state.filter !== "all" && i.health !== state.filter) return false;
296
+ if (!q) return true;
297
+ return (i.title || "").toLowerCase().indexOf(q) !== -1 || (i.type || "").toLowerCase().indexOf(q) !== -1 ||
298
+ (i.paths || []).join(" ").toLowerCase().indexOf(q) !== -1;
299
+ });
300
+ var rank = { hot: 0, healthy: 1, stale: 2, disputed: 2, ungrounded: 2, cold: 3, generated: 4 };
301
+ rows.sort(function (a, b) {
302
+ var ra = rank[a.health]; if (ra == null) ra = 5; var rb = rank[b.health]; if (rb == null) rb = 5;
303
+ return ra - rb || (b.total_uses || 0) - (a.total_uses || 0);
304
+ });
305
+ var countEl = document.getElementById("memcount");
306
+ if (countEl) countEl.textContent = rows.length === state.items.length ? rows.length + " packets" : rows.length + " of " + state.items.length + " packets";
307
+ if (!rows.length) { list.appendChild(el("div", "empty", "No memory matches.")); return; }
308
+
309
+ var grouped = state.filter === "all" && !q;
310
+ var cap = state.showAll ? Infinity : LIST_CAP, shown = 0;
311
+ if (grouped) {
312
+ var buckets = [["Needs review", ["stale", "disputed", "ungrounded"]], ["Hot", ["hot"]], ["Healthy", ["healthy"]], ["Cold", ["cold"]], ["Other", ["generated"]]];
313
+ buckets.forEach(function (bk) {
314
+ var brows = rows.filter(function (r) { return bk[1].indexOf(r.health) !== -1; });
315
+ if (!brows.length || shown >= cap) return;
316
+ var head = el("div", "grouphead");
317
+ head.appendChild(el("span", "gl", bk[0])); head.appendChild(el("span", "gc", String(brows.length))); head.appendChild(el("span", "gline"));
318
+ list.appendChild(head);
319
+ brows.forEach(function (r) { if (shown < cap) { list.appendChild(memoryRow(r)); shown++; } });
320
+ });
321
+ } else {
322
+ rows.forEach(function (r) { if (shown < cap) { list.appendChild(memoryRow(r)); shown++; } });
323
+ }
324
+ if (!state.showAll && rows.length > shown) {
325
+ var more = el("button", "showmore", "Show all " + rows.length + " packets");
326
+ more.onclick = function () { state.showAll = true; renderList(); };
327
+ list.appendChild(more);
328
+ }
329
+ }
330
+ var searchEl = document.getElementById("search");
331
+ if (searchEl) searchEl.addEventListener("input", function (e) { state.q = e.target.value; state.showAll = false; renderList(); });
332
+
333
+ // ---- insights ----
334
+ function renderInsights(metrics, items) {
335
+ var cg = (metrics && metrics.code_graph) || {}, mg = (metrics && metrics.memory_graph) || {}, sv = (metrics && metrics.savings) || {};
336
+ var c = counts(items);
337
+ // Health donut = trustworthiness, not recall frequency. "Cold" (not yet recalled)
338
+ // is grounded & current, so it counts as healthy — coloring it grey made a perfectly
339
+ // trustworthy store look dead. Segments: grounded & current vs needs review.
340
+ var needsReview = (c.stale || 0) + (c.disputed || 0) + (c.ungrounded || 0);
341
+ var generated = c.generated || 0;
342
+ var groundedCurrent = items.length - needsReview - generated;
343
+ var seg = [
344
+ { k: "Grounded & current", v: groundedCurrent, col: "#41ff8f" },
345
+ { k: "Needs review", v: needsReview, col: "#ffd166" },
346
+ { k: "Generated", v: generated, col: "#3a8db0" },
347
+ ].filter(function (s) { return s.v > 0; });
348
+ var total = items.length || 1;
349
+ var stops = [], acc = 0;
350
+ seg.forEach(function (s) { var from = acc / total * 360, to = (acc + s.v) / total * 360; stops.push(s.col + " " + from + "deg " + to + "deg"); acc += s.v; });
351
+ var donut = document.getElementById("donut");
352
+ donut.style.background = "conic-gradient(" + (stops.join(", ") || "#41ff8f 0deg 360deg") + ")";
353
+ var pct = Math.round(groundedCurrent / total * 100);
354
+ // The big % is the health readout, so color it by how healthy it is — a high
355
+ // grounded share is green, not a warning. (Amber/red only when it's actually low.)
356
+ var pctClass = pct >= 85 ? "" : pct >= 60 ? "warn" : "danger";
357
+ var dc = document.getElementById("donutCenter"); dc.textContent = "";
358
+ dc.appendChild(el("b", pctClass, pct + "%"));
359
+ var leg = document.getElementById("healthLegend"); leg.textContent = "";
360
+ seg.forEach(function (s) {
361
+ var li = el("div", "li"); var i = el("i"); i.style.background = s.col; li.appendChild(i);
362
+ li.appendChild(document.createTextNode(s.k)); li.appendChild(el("b", null, s.v + " · " + Math.round(s.v / total * 100) + "%")); leg.appendChild(li);
363
+ });
364
+ var cap = document.getElementById("healthCap");
365
+ if (cap) {
366
+ var recalled = items.filter(function (i) { return (i.total_uses || 0) > 0; }).length;
367
+ cap.innerHTML = "<b>" + total + "</b> packets · <b>" + recalled + "</b> recalled by agents so far" +
368
+ (needsReview ? " · <b>" + needsReview + "</b> need review" : " · all current");
369
+ }
370
+ // type bars
371
+ var types = {}; items.forEach(function (i) { var t = i.type || "memory"; types[t] = (types[t] || 0) + 1; });
372
+ var arr = Object.keys(types).map(function (k) { return [k, types[k]]; }).sort(function (a, b) { return b[1] - a[1]; }).slice(0, 7);
373
+ var max = arr.length ? arr[0][1] : 1;
374
+ var tb = document.getElementById("typeBars"); tb.textContent = "";
375
+ arr.forEach(function (p) {
376
+ var row = el("div", "hbar"); row.appendChild(el("span", "n", p[0]));
377
+ var t = el("span", "t"), f = el("i"); t.appendChild(f); row.appendChild(t); row.appendChild(el("span", "c", String(p[1])));
378
+ tb.appendChild(row); setTimeout(function () { f.style.width = (p[1] / max * 100) + "%"; }, 80);
379
+ });
380
+ // most-grounded files: which code carries the most institutional memory
381
+ var fileCounts = {};
382
+ items.forEach(function (i) { (i.paths || []).forEach(function (p) { fileCounts[p] = (fileCounts[p] || 0) + 1; }); });
383
+ var gf = Object.keys(fileCounts).map(function (k) { return [k, fileCounts[k]]; }).sort(function (a, b) { return b[1] - a[1]; }).slice(0, 8);
384
+ var gmax = gf.length ? gf[0][1] : 1;
385
+ var gfb = document.getElementById("groundedFiles"); if (gfb) {
386
+ gfb.textContent = "";
387
+ if (!gf.length) { gfb.appendChild(el("div", "empty", "No memory is grounded to code yet.")); }
388
+ var baseSeen = {}; gf.forEach(function (p) { var b = base(p[0]); baseSeen[b] = (baseSeen[b] || 0) + 1; });
389
+ gf.forEach(function (p) {
390
+ var b = base(p[0]), label = baseSeen[b] > 1 ? p[0].split("/").slice(-2).join("/") : b;
391
+ var row = el("div", "hbar code"); var n = el("span", "n", label); n.title = p[0]; row.appendChild(n);
392
+ var t = el("span", "t"), f = el("i"); t.appendChild(f); row.appendChild(t); row.appendChild(el("span", "c", String(p[1])));
393
+ gfb.appendChild(row); setTimeout(function () { f.style.width = (p[1] / gmax * 100) + "%"; }, 80);
394
+ });
395
+ }
396
+ // code tiles
397
+ var langs = Object.keys(cg.languages || {}).length;
398
+ var ct = [
399
+ ["Files", fmt(cg.files)], ["Symbols", fmt(cg.symbols)], ["Routes", fmt(cg.routes)], ["Tests", fmt(cg.tests)],
400
+ ["Imports", fmt(cg.imports)], ["Call edges", fmt(cg.calls)], ["Index coverage", (cg.indexer_coverage_percent != null ? cg.indexer_coverage_percent + "%" : "—")], ["Languages", fmt(langs)],
401
+ ];
402
+ var ctb = document.getElementById("codeTiles"); ctb.textContent = "";
403
+ ct.forEach(function (d) { var t = el("div", "tile"); t.appendChild(el("div", "k", d[0])); t.appendChild(el("div", "v", d[1])); ctb.appendChild(t); });
404
+ // facts
405
+ var hot = items.filter(function (i) { return i.health === "hot"; }).sort(function (a, b) { return (b.total_uses || 0) - (a.total_uses || 0); })[0];
406
+ var cold = c.cold || 0;
407
+ var facts = [];
408
+ facts.push(["💾", "Every recall pulls grounded memory instead of re-reading source — saving about <b>" + fmt(sv.estimated_tokens_saved_per_recall) + " tokens</b> each time."]);
409
+ if (mg.evidence_coverage_percent != null) facts.push(["🔗", "<b>" + mg.evidence_coverage_percent + "%</b> of " + fmt(mg.edges) + " memory edges are backed by evidence — nothing in the graph is a guess."]);
410
+ if (hot) facts.push(["🔥", "Most-recalled memory: <b>" + escapeHtml(hot.title) + "</b>" + (hot.total_uses ? " (used " + hot.total_uses + "×)." : ".")]);
411
+ if (cold) facts.push(["🧊", "<b>" + cold + "</b> packets haven't been recalled recently — run <code>kage refresh</code> to keep them grounded, or let them age out."]);
412
+ if (mg.average_quality_score != null) facts.push(["⭐", "Average packet quality score is <b>" + mg.average_quality_score + "/100</b> across the repo."]);
413
+ var fb = document.getElementById("facts"); fb.textContent = "";
414
+ facts.forEach(function (f) { var row = el("div", "fact"); row.appendChild(el("span", "em", f[0])); var s = el("span"); s.innerHTML = f[1]; row.appendChild(s); fb.appendChild(row); });
415
+ }
416
+ function escapeHtml(s) { var d = el("div"); d.textContent = s == null ? "" : s; return d.innerHTML; }
417
+
418
+ // ---- memory <-> code graph (interactive canvas) ----
419
+ var G = { nodes: [], edges: [], hover: -1, focus: -1, raf: 0, alpha: 1, filter: "all", view: { s: 1, tx: 0, ty: 0 }, drag: null, pan: null, tween: null };
420
+ function seeded(n) { var x = Math.sin(n * 999.137) * 43758.5453; return x - Math.floor(x); }
421
+ function clamp(v, a, b) { return v < a ? a : v > b ? b : v; }
422
+ function nodeR(nd) { return Math.min(13, 4.5 + nd.deg * 1.1); }
423
+ function buildGraph() {
424
+ var items = state.items.filter(function (i) { return i.paths && i.paths.length; });
425
+ var rank = { hot: 0, healthy: 1, stale: 2, disputed: 2, ungrounded: 2, cold: 3 };
426
+ items.sort(function (a, b) { var ra = rank[a.health] == null ? 4 : rank[a.health], rb = rank[b.health] == null ? 4 : rank[b.health]; return ra - rb || (b.total_uses || 0) - (a.total_uses || 0); });
427
+ items = items.slice(0, 60);
428
+ var nodes = [], edges = [], fileIdx = {};
429
+ items.forEach(function (it, mi) {
430
+ var review = ["stale", "disputed", "ungrounded"].indexOf(it.health) !== -1;
431
+ var label = (it.title || "memory"); if (label.length > 26) label = label.slice(0, 25) + "…";
432
+ nodes.push({ id: "m" + mi, label: label, kind: "memory", review: review, health: it.health, deg: 0, tip: it.title, sub: it.type || "memory", uses: it.total_uses || 0, files: (it.paths || []).map(base) });
433
+ var mIndex = nodes.length - 1;
434
+ it.paths.slice(0, 3).forEach(function (p) {
435
+ var key = "f:" + p, fi = fileIdx[key];
436
+ if (fi == null) { nodes.push({ id: key, label: base(p), kind: "file", deg: 0, tip: p, sub: "code file" }); fi = nodes.length - 1; fileIdx[key] = fi; }
437
+ edges.push([mIndex, fi]); nodes[mIndex].deg++; nodes[fi].deg++;
438
+ });
439
+ });
440
+ G.nodes = nodes; G.edges = edges; G.alpha = 1;
441
+ }
442
+ function initGraph() {
443
+ var canvas = document.getElementById("graph"); if (!canvas) return;
444
+ buildGraph();
445
+ var ctx = canvas.getContext("2d"), dpr = window.devicePixelRatio || 1, W = 0, H = 0;
446
+ var tip = document.getElementById("gtip");
447
+ function resize() { W = canvas.clientWidth; H = canvas.clientHeight; canvas.width = W * dpr; canvas.height = H * dpr; }
448
+ resize();
449
+ G.nodes.forEach(function (n, i) { n.x = W / 2 + (seeded(i + 1) - 0.5) * W * 0.7; n.y = H / 2 + (seeded(i + 7) - 0.5) * H * 0.7; n.vx = 0; n.vy = 0; n.fixed = false; });
450
+
451
+ function fitView() {
452
+ if (!G.nodes.length) return;
453
+ var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
454
+ G.nodes.forEach(function (n) { minX = Math.min(minX, n.x); minY = Math.min(minY, n.y); maxX = Math.max(maxX, n.x); maxY = Math.max(maxY, n.y); });
455
+ var pad = 60, gw = (maxX - minX) || 1, gh = (maxY - minY) || 1;
456
+ var s = clamp(Math.min((W - 2 * pad) / gw, (H - 2 * pad) / gh), 0.35, 1.8);
457
+ G.view.s = s; G.view.tx = (W - (minX + maxX) * s) / 2; G.view.ty = (H - (minY + maxY) * s) / 2;
458
+ }
459
+ function s2w(sx, sy) { return { x: (sx - G.view.tx) / G.view.s, y: (sy - G.view.ty) / G.view.s }; }
460
+ function neighbors(idx) { var s = {}; if (idx < 0) return null; s[idx] = 1; G.edges.forEach(function (e) { if (e[0] === idx) s[e[1]] = 1; if (e[1] === idx) s[e[0]] = 1; }); return s; }
461
+ function categorySet() {
462
+ if (G.filter === "all") return null;
463
+ // Seed = the matching memory nodes only. Expand to the files they touch, but
464
+ // gate on the immutable seed — never pull in other memories that merely share a
465
+ // hub file (that cascade made "Needs review" highlight ~everything).
466
+ var seed = {};
467
+ G.nodes.forEach(function (n, i) { if (n.kind === "memory" && (G.filter === "review" ? n.review : n.health === G.filter)) seed[i] = 1; });
468
+ var set = {};
469
+ Object.keys(seed).forEach(function (k) { set[k] = 1; });
470
+ G.edges.forEach(function (e) { if (seed[e[0]]) set[e[1]] = 1; if (seed[e[1]]) set[e[0]] = 1; });
471
+ return set;
472
+ }
473
+ function color(nd) { return nd.kind === "file" ? "#6ad7ff" : (nd.review ? "#ffd166" : "#c49cff"); }
474
+ function bodyColor(nd) { return nd.kind === "file" ? "rgba(6,18,22,0.92)" : (nd.review ? "rgba(26,21,7,0.92)" : "rgba(19,12,28,0.92)"); }
475
+ var DIAMOND = { decision: 1, bug_fix: 1, test: 1, gotcha: 1 };
476
+ function shapePath(x, y, r, nd) {
477
+ if (nd.kind === "file") { roundRect(x - r * 1.3, y - r * 0.78, r * 2.6, r * 1.56, 3); return; }
478
+ if (DIAMOND[nd.sub]) { ctx.beginPath(); ctx.moveTo(x, y - r); ctx.lineTo(x + r, y); ctx.lineTo(x, y + r); ctx.lineTo(x - r, y); ctx.closePath(); return; }
479
+ ctx.beginPath(); ctx.arc(x, y, r, 0, 6.2832);
480
+ }
481
+
482
+ function tick() {
483
+ var n = G.nodes, e = G.edges, a = G.alpha;
484
+ if (G.tween) {
485
+ var tw = G.tween, v = G.view;
486
+ v.s += (tw.s - v.s) * 0.2; v.tx += (tw.tx - v.tx) * 0.2; v.ty += (tw.ty - v.ty) * 0.2;
487
+ if (Math.abs(tw.s - v.s) < 0.01 && Math.abs(tw.tx - v.tx) < 0.6 && Math.abs(tw.ty - v.ty) < 0.6) { v.s = tw.s; v.tx = tw.tx; v.ty = tw.ty; G.tween = null; }
488
+ }
489
+ if (a > 0.03) {
490
+ for (var i = 0; i < n.length; i++) for (var j = i + 1; j < n.length; j++) {
491
+ var dx = n[i].x - n[j].x, dy = n[i].y - n[j].y, d2 = dx * dx + dy * dy + 0.01, d = Math.sqrt(d2);
492
+ var rep = 2900 / d2, fx = dx / d * rep, fy = dy / d * rep;
493
+ n[i].vx += fx; n[i].vy += fy; n[j].vx -= fx; n[j].vy -= fy;
494
+ }
495
+ for (var k = 0; k < e.length; k++) {
496
+ var s = n[e[k][0]], t = n[e[k][1]], dx2 = t.x - s.x, dy2 = t.y - s.y, dd = Math.sqrt(dx2 * dx2 + dy2 * dy2) + 0.01;
497
+ var f = (dd - 94) * 0.013, ux = dx2 / dd * f, uy = dy2 / dd * f;
498
+ s.vx += ux; s.vy += uy; t.vx -= ux; t.vy -= uy;
499
+ }
500
+ var cx = W / 2, cy = H / 2;
501
+ for (var m = 0; m < n.length; m++) {
502
+ if (n[m].fixed) { n[m].vx = n[m].vy = 0; continue; }
503
+ n[m].vx += (cx - n[m].x) * 0.0016; n[m].vy += (cy - n[m].y) * 0.0016;
504
+ n[m].vx *= 0.86; n[m].vy *= 0.86;
505
+ n[m].x += n[m].vx * a; n[m].y += n[m].vy * a;
506
+ }
507
+ G.alpha *= 0.992;
508
+ }
509
+ draw();
510
+ G.raf = requestAnimationFrame(tick);
511
+ }
512
+ function draw() {
513
+ var n = G.nodes, v = G.view;
514
+ var hl = G.hover >= 0 ? neighbors(G.hover) : (G.focus >= 0 ? neighbors(G.focus) : null);
515
+ var emph = hl || categorySet(), active = !!emph;
516
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, W, H);
517
+ ctx.setTransform(dpr * v.s, 0, 0, dpr * v.s, dpr * v.tx, dpr * v.ty);
518
+ ctx.lineWidth = 1 / v.s;
519
+ G.edges.forEach(function (e) {
520
+ var on = active && emph[e[0]] && emph[e[1]];
521
+ ctx.strokeStyle = on ? "rgba(106,215,255,0.65)" : (active ? "rgba(147,175,160,0.04)" : "rgba(147,175,160,0.13)");
522
+ ctx.beginPath(); ctx.moveTo(n[e[0]].x, n[e[0]].y); ctx.lineTo(n[e[1]].x, n[e[1]].y); ctx.stroke();
523
+ });
524
+ n.forEach(function (nd, i) {
525
+ var r = nodeR(nd), dim = active && !emph[i], col = color(nd), strong = i === G.hover || i === G.focus;
526
+ ctx.save();
527
+ ctx.globalAlpha = dim ? 0.14 : 1;
528
+ // glowing rim over a dark body
529
+ if (!dim) { ctx.shadowColor = col; ctx.shadowBlur = (strong ? 16 : (nd.kind === "memory" ? 9 : 10)); }
530
+ shapePath(nd.x, nd.y, r, nd); ctx.fillStyle = bodyColor(nd); ctx.fill();
531
+ ctx.shadowBlur = 0;
532
+ ctx.lineWidth = (strong ? 2.4 : 1.3) / v.s; ctx.strokeStyle = col; ctx.stroke();
533
+ // inner dot for memory nodes
534
+ if (nd.kind === "memory") { ctx.globalAlpha = (dim ? 0.14 : 1) * 0.9; ctx.fillStyle = col; ctx.beginPath(); ctx.arc(nd.x, nd.y, Math.max(2.2, r * 0.2), 0, 6.2832); ctx.fill(); }
535
+ ctx.restore();
536
+ // halo on hover/focus
537
+ if (!dim && strong) { ctx.save(); shapePath(nd.x, nd.y, r + 5, nd); ctx.strokeStyle = i === G.focus ? "#e4f7e9" : col; ctx.lineWidth = 1.8 / v.s; ctx.shadowColor = col; ctx.shadowBlur = 9; ctx.stroke(); ctx.restore(); }
538
+ });
539
+ // labels in screen space (mono pill, centered below) so they stay crisp at any zoom
540
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
541
+ n.forEach(function (nd, i) {
542
+ if (active && !emph[i]) return;
543
+ var strong = i === G.hover || i === G.focus;
544
+ // Labels are detail-on-demand only: shown for the hovered/focused node and its
545
+ // highlighted neighbourhood. No always-on labels — they cluttered the graph.
546
+ if (!active) return;
547
+ var sx = nd.x * v.s + v.tx, sy = nd.y * v.s + v.ty, r = nodeR(nd) * v.s;
548
+ if (sx < -60 || sx > W + 60 || sy < -20 || sy > H + 20) return;
549
+ ctx.font = (strong ? "700 " : "600 ") + "11px ui-monospace, Menlo, monospace";
550
+ var w = ctx.measureText(nd.label).width, pw = w + 14, ph = 18, lx = sx - pw / 2, ly = sy + r + 7;
551
+ ctx.globalAlpha = 0.92; ctx.fillStyle = "rgba(5,8,6,0.92)"; roundRect(lx, ly, pw, ph, 4); ctx.fill();
552
+ ctx.lineWidth = 1; ctx.strokeStyle = strong ? "rgba(65,255,143,0.42)" : "rgba(65,255,143,0.12)"; ctx.stroke();
553
+ ctx.globalAlpha = 1; ctx.fillStyle = strong ? "#e4f7e9" : "#9cb0a4"; ctx.textAlign = "center"; ctx.textBaseline = "middle";
554
+ ctx.fillText(nd.label, sx, ly + ph / 2);
555
+ });
556
+ ctx.textAlign = "left"; ctx.textBaseline = "alphabetic"; ctx.globalAlpha = 1;
557
+ }
558
+ function roundRect(x, y, w, h, r) { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); }
559
+ function pick(p) {
560
+ var best = -1, bd = Infinity;
561
+ for (var i = 0; i < G.nodes.length; i++) {
562
+ var nd = G.nodes[i], sx = nd.x * G.view.s + G.view.tx, sy = nd.y * G.view.s + G.view.ty;
563
+ var dx = sx - p.x, dy = sy - p.y, d = dx * dx + dy * dy, tol = nodeR(nd) * G.view.s + 8;
564
+ if (d < tol * tol && d < bd) { bd = d; best = i; }
565
+ }
566
+ return best;
567
+ }
568
+ function xy(ev) { var r = canvas.getBoundingClientRect(); return { x: ev.clientX - r.left, y: ev.clientY - r.top }; }
569
+ function showTip(nd, p) {
570
+ tip.style.display = "block"; tip.style.left = Math.min(W - 250, p.x + 14) + "px"; tip.style.top = (p.y + 14) + "px";
571
+ tip.innerHTML = "<b>" + escapeHtml(nd.tip) + "</b><div class='p'>" + escapeHtml(nd.sub) + " · " + nd.deg + " link" + (nd.deg === 1 ? "" : "s") + "</div>";
572
+ }
573
+ function zoomAt(p, factor) {
574
+ G.tween = null;
575
+ var ns = clamp(G.view.s * factor, 0.3, 4), w = s2w(p.x, p.y);
576
+ G.view.s = ns; G.view.tx = p.x - w.x * ns; G.view.ty = p.y - w.y * ns;
577
+ }
578
+ var detail = document.getElementById("gdetail");
579
+ function updateDetail() {
580
+ if (!detail) return;
581
+ if (G.focus < 0) { detail.style.display = "none"; return; }
582
+ var nd = G.nodes[G.focus], html;
583
+ if (nd.kind === "file") {
584
+ html = "<div class='gd-k' style='color:#6ad7ff'>code file</div><b class='gd-t'>" + escapeHtml(nd.tip) + "</b>" +
585
+ "<div class='gd-row'>cited by <b>" + nd.deg + "</b> memor" + (nd.deg === 1 ? "y" : "ies") + "</div>";
586
+ } else {
587
+ html = "<div class='gd-k' style='color:#c49cff'>" + escapeHtml(nd.sub) + "</div><b class='gd-t'>" + escapeHtml(nd.tip) + "</b>" +
588
+ "<div class='gd-row'>health <b class='" + (nd.health || "") + "'>" + (nd.health || "—") + "</b>" + (nd.uses ? " · used " + nd.uses + "×" : "") + " · " + nd.deg + " file" + (nd.deg === 1 ? "" : "s") + "</div>" +
589
+ (nd.files && nd.files.length ? "<div class='gd-files'>" + nd.files.map(escapeHtml).join(" · ") + "</div>" : "");
590
+ }
591
+ detail.innerHTML = "<button class='gd-x' id='gdClose'>×</button>" + html; detail.style.display = "block";
592
+ var x = document.getElementById("gdClose"); if (x) x.onclick = function () { G.focus = -1; updateDetail(); };
593
+ }
594
+ function focusNode(idx) { G.focus = idx; updateDetail(); }
595
+
596
+ canvas.addEventListener("mousedown", function (ev) {
597
+ G.tween = null;
598
+ var p = xy(ev), idx = pick(p);
599
+ if (idx >= 0) { G.drag = { idx: idx, moved: false }; G.nodes[idx].fixed = true; canvas.style.cursor = "grabbing"; }
600
+ else { G.pan = { x: p.x, y: p.y, tx: G.view.tx, ty: G.view.ty, moved: false }; canvas.style.cursor = "grabbing"; }
601
+ });
602
+ canvas.addEventListener("dblclick", function (ev) {
603
+ var p = xy(ev), idx = pick(p); if (idx < 0) return;
604
+ var nd = G.nodes[idx], ns = clamp(Math.max(G.view.s, 1.6), 0.3, 4);
605
+ G.tween = { s: ns, tx: W / 2 - nd.x * ns, ty: H / 2 - nd.y * ns }; focusNode(idx);
606
+ });
607
+ window.addEventListener("mousemove", function (ev) {
608
+ if (G.drag) { var p = xy(ev), w = s2w(p.x, p.y), nd = G.nodes[G.drag.idx]; nd.x = w.x; nd.y = w.y; nd.vx = nd.vy = 0; G.drag.moved = true; G.hover = G.drag.idx; G.alpha = Math.max(G.alpha, 0.3); showTip(nd, p); return; }
609
+ if (G.pan) { var q = xy(ev); G.view.tx = G.pan.tx + (q.x - G.pan.x); G.view.ty = G.pan.ty + (q.y - G.pan.y); G.pan.moved = true; tip.style.display = "none"; return; }
610
+ if (!canvas.matches(":hover")) return;
611
+ var pp = xy(ev), id = pick(pp); G.hover = id; canvas.style.cursor = id >= 0 ? "pointer" : "grab";
612
+ if (id >= 0) showTip(G.nodes[id], pp); else tip.style.display = "none";
613
+ });
614
+ window.addEventListener("mouseup", function () {
615
+ if (G.drag) { if (!G.drag.moved) { var i = G.drag.idx; focusNode(G.focus === i ? -1 : i); G.nodes[i].fixed = false; } G.drag = null; }
616
+ else if (G.pan) { if (!G.pan.moved) focusNode(-1); G.pan = null; }
617
+ canvas.style.cursor = "grab";
618
+ });
619
+ canvas.addEventListener("mouseleave", function () { if (!G.drag && !G.pan) { G.hover = -1; tip.style.display = "none"; } });
620
+ canvas.addEventListener("wheel", function (ev) { ev.preventDefault(); zoomAt(xy(ev), ev.deltaY < 0 ? 1.12 : 0.892); }, { passive: false });
621
+
622
+ var zin = document.getElementById("zoomIn"), zout = document.getElementById("zoomOut"), zr = document.getElementById("resetView");
623
+ if (zin) zin.onclick = function () { zoomAt({ x: W / 2, y: H / 2 }, 1.25); };
624
+ if (zout) zout.onclick = function () { zoomAt({ x: W / 2, y: H / 2 }, 0.8); };
625
+ if (zr) zr.onclick = function () { G.tween = null; focusNode(-1); G.nodes.forEach(function (n) { n.fixed = false; }); G.alpha = 0.5; fitView(); };
626
+ var fbar = document.getElementById("gfilters");
627
+ if (fbar) fbar.addEventListener("click", function (ev) {
628
+ var b = ev.target.closest("button[data-gfilter]"); if (!b) return;
629
+ G.filter = b.dataset.gfilter;
630
+ fbar.querySelectorAll("button").forEach(function (x) { x.setAttribute("aria-pressed", x === b ? "true" : "false"); });
631
+ });
632
+ window.addEventListener("resize", function () { if (document.getElementById("section-graph").classList.contains("active")) { resize(); fitView(); } });
633
+
634
+ canvas.style.cursor = "grab";
635
+ fitView();
636
+ cancelAnimationFrame(G.raf); tick();
637
+ }
638
+ })();