@sksoftofficial/mindroot 1.2.0 → 1.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sksoftofficial/mindroot",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Long-term memory for AI agents: hybrid-searched memories over human-editable markdown notes. 100% local — offline ONNX embeddings, SQLite FTS5, MCP server, REST API, CLI, dashboard. No cloud, no API keys.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
2
+ <rect width="64" height="64" rx="18" fill="#b6ebce"/>
3
+ <path d="M16 43V21l16 14 16-14v22M32 35v14" stroke="#14251e" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
4
+ <circle cx="16" cy="19" r="4" fill="#14251e"/>
5
+ <circle cx="48" cy="19" r="4" fill="#14251e"/>
6
+ </svg>
@@ -1,8 +1,9 @@
1
1
  let library;
2
2
 
3
- export async function mountGraph(view, data) {
3
+ function graphData(data, project = null) {
4
+ const prefix = project ? JSON.stringify(project.slug) + ":" : "";
4
5
  const notes = new Map(data.docs.map((doc) => [doc.path, {
5
- id: "note:" + doc.path, kind: "note", label: doc.title || doc.path,
6
+ id: prefix + "note:" + doc.path, kind: "note", label: doc.title || doc.path, project,
6
7
  neighbors: new Set(),
7
8
  }]));
8
9
  const nodes = [...notes.values()];
@@ -11,19 +12,126 @@ export async function mountGraph(view, data) {
11
12
  const [path] = (memory.target_path || "").split("::");
12
13
  const note = notes.get(path);
13
14
  const node = {
14
- id: "memory:" + memory.id, kind: "memory", label: memory.text,
15
+ id: prefix + "memory:" + memory.id, kind: "memory", label: memory.text, project,
15
16
  memoryId: memory.id,
16
17
  neighbors: new Set(),
17
18
  };
18
19
  nodes.push(node);
19
20
  // Section targets share their note's node; no new relationships are inferred.
20
21
  if (note) {
21
- links.push({ source: node.id, target: note.id });
22
+ links.push({ source: node.id, target: note.id, color: project?.color });
22
23
  node.neighbors.add(note.id);
23
24
  note.neighbors.add(node.id);
24
25
  }
25
26
  }
26
- if (!nodes.length) {
27
+ return { nodes, links };
28
+ }
29
+
30
+ // A layout constraint only: coordinates never create graph edges.
31
+ function brainLayout(nodes) {
32
+ // Side-profile cortex, sampled as a curved shell. These are layout guides,
33
+ // never visible geometry: every rendered point is a real note or memory.
34
+ const cortex = [[-300,-30],[-278,-111],[-217,-179],[-121,-217],[-5,-226],
35
+ [111,-205],[215,-158],[271,-83],[284,6],[257,72],[197,93],
36
+ [118,65],[54,60],[3,111],[-81,119],[-152,92],[-179,44],[-251,33]];
37
+ const outline = (angle) => {
38
+ const t = angle / (Math.PI * 2) * cortex.length;
39
+ const index = Math.floor(t);
40
+ const f = t - index;
41
+ const p = (offset) => cortex[(index + offset + cortex.length) % cortex.length];
42
+ return [0, 1].map((axis) => 0.5 * ((2 * p(0)[axis]) +
43
+ (-p(-1)[axis] + p(1)[axis]) * f +
44
+ (2 * p(-1)[axis] - 5 * p(0)[axis] + 4 * p(1)[axis] - p(2)[axis]) * f * f +
45
+ (-p(-1)[axis] + 3 * p(0)[axis] - 3 * p(1)[axis] + p(2)[axis]) * f * f * f));
46
+ };
47
+ const points = [];
48
+ // Allocate fewer points to the small lower lobes for more even visual density.
49
+ const cortexCount = Math.ceil(nodes.length * 0.85);
50
+ const cerebellumCount = Math.floor(nodes.length * 0.12);
51
+ const counts = [cortexCount, cerebellumCount, nodes.length - cortexCount - cerebellumCount];
52
+ for (const [region, count] of counts.entries()) {
53
+ for (let i = 0; i < count; i++) {
54
+ const depth = 1 - 2 * (i + 0.5) / count;
55
+ const angle = (i * 2.399963229728653) % (Math.PI * 2);
56
+ const r = Math.sqrt(1 - depth * depth);
57
+ let x, y;
58
+ if (region === 0) {
59
+ const edge = outline(angle);
60
+ const fold = 1 - 0.035 * Math.sin(angle * 13 + depth * 7) ** 2;
61
+ x = edge[0] * r * fold;
62
+ y = -45 + (edge[1] + 45) * r * fold;
63
+ } else if (region === 1) {
64
+ // A compact, rounded lobe tucked under the posterior cortex.
65
+ const fold = 1 - 0.025 * Math.cos(angle * 12 + depth * 4);
66
+ x = 139 + Math.cos(angle) * r * 91 * fold;
67
+ y = 124 + Math.sin(angle) * r * 54 * fold - (x - 139) * 0.16;
68
+ } else {
69
+ // A continuous tapered stem rather than a detached oval of points.
70
+ const t = (i + 0.5) / count;
71
+ const width = 25 * (1 - t) + 9 * t + 8 * Math.sin(Math.PI * t);
72
+ y = 85 + t * 137;
73
+ x = 37 + 30 * t - 9 * Math.sin(Math.PI * t) + Math.cos(angle) * width;
74
+ }
75
+ points.push({ x, y, depth, region });
76
+ }
77
+ }
78
+ // Morton ordering keeps families spatially close without the old striped rows.
79
+ const spatialKey = (point) => {
80
+ const x = Math.round((point.x + 320) / 640 * 1023);
81
+ const y = Math.round((point.y + 260) / 540 * 1023);
82
+ let key = 0;
83
+ for (let bit = 0; bit < 10; bit++) key |= ((x >> bit) & 1) << (bit * 2) | ((y >> bit) & 1) << (bit * 2 + 1);
84
+ return key;
85
+ };
86
+ points.forEach((point) => { point.key = spatialKey(point); });
87
+ points.sort((a, b) => a.region - b.region || a.key - b.key);
88
+ // Place each note near the center of its own linked memories, not at one end.
89
+ for (let i = 0; i < nodes.length; i++) {
90
+ if (nodes[i].kind !== "note") continue;
91
+ const count = nodes[i].neighbors.size + 1;
92
+ const family = points.slice(i, i + count);
93
+ const cx = family.reduce((sum, p) => sum + p.x, 0) / count;
94
+ const cy = family.reduce((sum, p) => sum + p.y, 0) / count;
95
+ let nearest = i;
96
+ for (let j = i + 1; j < i + count; j++) {
97
+ if (Math.hypot(points[j].x - cx, points[j].y - cy) < Math.hypot(points[nearest].x - cx, points[nearest].y - cy)) nearest = j;
98
+ }
99
+ [points[i], points[nearest]] = [points[nearest], points[i]];
100
+ // Morton cells and anatomical regions have discontinuities. Keep those
101
+ // boundaries from stretching a real memory link across the whole brain.
102
+ const anchor = points[i];
103
+ for (let j = i + 1; j < i + count; j++) {
104
+ const point = points[j];
105
+ const distance = Math.hypot(point.x - anchor.x, point.y - anchor.y);
106
+ if (distance <= 42) continue;
107
+ // Compress smoothly rather than stacking distant memories on one ring.
108
+ const radius = 42 + 13 * (1 - Math.exp(-(distance - 42) / 32));
109
+ const ratio = radius / distance;
110
+ point.x = anchor.x + (point.x - anchor.x) * ratio;
111
+ point.y = anchor.y + (point.y - anchor.y) * ratio;
112
+ point.depth = anchor.depth + (point.depth - anchor.depth) * ratio;
113
+ }
114
+ i += count - 1;
115
+ }
116
+ nodes.forEach((node, i) => Object.assign(node, points[i], { fx: points[i].x, fy: points[i].y }));
117
+ }
118
+
119
+ export async function mountGraph(view, data, { brain = false, openProject } = {}) {
120
+ const projects = brain ? [...data].sort((a, b) => a.slug.localeCompare(b.slug))
121
+ .map((entry, i) => ({ ...entry, color: `hsl(${178 + i * 17 % 42} 80% 82%)` })) : [];
122
+ const graphs = brain ? projects.map((project) => graphData(project.data, project)) : [graphData(data)];
123
+ const nodes = graphs.flatMap((graph) => {
124
+ if (!brain) return graph.nodes;
125
+ const byId = new Map(graph.nodes.map((node) => [node.id, node]));
126
+ const ordered = [];
127
+ for (const note of graph.nodes.filter((node) => node.kind === "note")) {
128
+ ordered.push(note, ...[...note.neighbors].map((id) => byId.get(id)));
129
+ }
130
+ return ordered.concat(graph.nodes.filter((node) => node.kind === "memory" && !node.neighbors.size));
131
+ });
132
+ const links = graphs.flatMap((graph) => graph.links);
133
+ if (brain) brainLayout(nodes);
134
+ if (!nodes.length && !brain) {
27
135
  view.innerHTML = '<div class="empty">No notes or memories to graph yet. Existing memory links will appear here automatically.</div>';
28
136
  return;
29
137
  }
@@ -39,7 +147,7 @@ export async function mountGraph(view, data) {
39
147
  }
40
148
  const ForceGraph = await library;
41
149
  if (!view.isConnected) return;
42
- view.className = "view graph-view";
150
+ view.className = "view graph-view" + (brain ? " brain-view" : " project-graph-view");
43
151
  view.innerHTML = `
44
152
  <div class="graph-workspace">
45
153
  <div class="graph-stage">
@@ -50,19 +158,71 @@ export async function mountGraph(view, data) {
50
158
  </div>
51
159
  <p class="graph-hint">Drag to pan or move a node. Scroll or pinch to zoom. Only existing memory-to-note links are shown.</p>`;
52
160
 
161
+ if (brain) {
162
+ const heading = document.createElement("div");
163
+ heading.className = "brain-heading";
164
+ const noteCount = nodes.filter((node) => node.kind === "note").length;
165
+ heading.innerHTML = `<div class="brain-title"><span class="brain-eyebrow">Overview</span><h2>Memory atlas</h2></div>
166
+ <dl class="brain-stats"><div><dt>Projects</dt><dd>${projects.length.toLocaleString()}</dd></div><div><dt>Notes</dt><dd>${noteCount.toLocaleString()}</dd></div><div><dt>Memories</dt><dd>${(nodes.length - noteCount).toLocaleString()}</dd></div></dl>`;
167
+ view.prepend(heading);
168
+ view.querySelector(".graph-canvas").setAttribute("aria-label", "Side-profile brain of all project notes and memories, with luminous existing memory-to-note connections.");
169
+ view.querySelector(".graph-hint")?.remove();
170
+ const footer = document.createElement("div");
171
+ footer.className = "brain-footer";
172
+ const caption = document.createElement("div");
173
+ caption.className = "brain-provenance";
174
+ caption.innerHTML = `<span class="brain-status-dot" aria-hidden="true"></span><span>${links.length.toLocaleString()} saved connections<span class="brain-gesture"> · Scroll to zoom</span></span>`;
175
+ caption.title = "Only existing memory-to-note links are shown. No inferred connections.";
176
+ const actions = document.createElement("div");
177
+ actions.className = "brain-actions";
178
+ const picker = document.createElement("select");
179
+ picker.className = "brain-project-picker";
180
+ picker.setAttribute("aria-label", "Open a project graph");
181
+ picker.add(new Option("Explore a project", ""));
182
+ for (const project of projects) {
183
+ picker.add(new Option(project.slug, project.slug));
184
+ }
185
+ picker.disabled = !projects.length;
186
+ picker.onchange = () => { if (picker.value) openProject(picker.value); };
187
+ actions.append(picker, view.querySelector(".graph-controls"));
188
+ footer.append(caption, actions);
189
+ view.append(footer);
190
+ if (!nodes.length) {
191
+ view.querySelector(".graph-workspace").outerHTML = '<div class="empty">No notes or memories yet. Your brain takes shape as agents save knowledge.</div>';
192
+ view.querySelector(".graph-controls").remove();
193
+ return;
194
+ }
195
+ } else {
196
+ const hint = view.querySelector(".graph-hint");
197
+ hint.className = "brain-interaction-hint";
198
+ hint.textContent = "Drag to arrange · Scroll or pinch to zoom";
199
+ view.querySelector(".graph-stage").append(hint);
200
+ const footer = document.createElement("div");
201
+ footer.className = "brain-footer";
202
+ footer.innerHTML = `<div class="brain-provenance" title="Only existing memory-to-note links are shown."><span class="brain-status-dot" aria-hidden="true"></span><span>${links.length.toLocaleString()} saved connections</span></div>`;
203
+ footer.append(view.querySelector(".graph-controls"));
204
+ view.append(footer);
205
+ }
206
+
53
207
  const host = view.querySelector(".graph-canvas");
54
- const styles = getComputedStyle(view);
55
- const colors = { note: styles.getPropertyValue("--accent").trim(), memory: styles.getPropertyValue("--ok").trim() };
56
- const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
208
+ // Keep the default camera's first paint out of view until fitting completes.
209
+ host.style.visibility = "hidden";
210
+ const colors = { note: "#efffff", memory: "#acf0ff" };
211
+ const motionPreference = matchMedia("(prefers-reduced-motion: reduce)");
212
+ const reducedMotion = motionPreference.matches;
57
213
  const duration = reducedMotion ? 0 : 300;
58
214
  let selected = null;
59
215
  let hovered = null;
60
216
  let disposed = false;
61
217
  let initialFit = true;
62
218
  let fitFrame;
219
+ let revealFrame;
220
+ let motionFrame;
221
+ let lastMotionTime = null;
222
+ let motionTime = 0;
63
223
  const radius = (node, scale = 1) => node.kind === "note"
64
- ? Math.max(4 / scale, 5 + Math.min(5, Math.sqrt(node.neighbors.size)))
65
- : Math.max(2 / scale, 3.5);
224
+ ? (brain ? 1.6 + (node.depth + 1) * 0.45 : Math.max(4 / scale, 5 + Math.min(5, Math.sqrt(node.neighbors.size))))
225
+ : (brain ? 0.65 + (node.depth + 1) * 0.4 : Math.max(2 / scale, 3.5));
66
226
  const focused = (node) => {
67
227
  const active = hovered || selected;
68
228
  return !active || active === node || active.neighbors.has(node.id);
@@ -72,20 +232,24 @@ export async function mountGraph(view, data) {
72
232
  const bright = focused(node);
73
233
  const r = radius(node, scale);
74
234
  ctx.save();
75
- ctx.globalAlpha = bright ? 1 : 0.18;
235
+ ctx.globalAlpha = bright ? (brain ? 0.5 + (node.depth + 1) * 0.25 : 1) : 0.12;
236
+ ctx.shadowColor = "#8deaff";
237
+ ctx.shadowBlur = (node.kind === "note" ? 13 : 7) + (node.pulse || 0) * 9;
238
+ if (bright) ctx.globalAlpha = Math.min(1, ctx.globalAlpha + (node.pulse || 0) * 0.2);
76
239
  if (active) {
77
240
  ctx.beginPath();
78
241
  ctx.arc(node.x, node.y, r + 4 / scale, 0, Math.PI * 2);
79
- ctx.strokeStyle = colors[node.kind];
242
+ ctx.strokeStyle = node.project?.color || colors[node.kind];
80
243
  ctx.lineWidth = 1.5 / scale;
81
244
  ctx.stroke();
82
245
  }
83
246
  ctx.beginPath();
84
247
  ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
85
- ctx.fillStyle = colors[node.kind];
248
+ ctx.fillStyle = node.kind === "note" ? colors.note : node.project?.color || colors.memory;
86
249
  ctx.fill();
87
- if (active || (bright && node.kind === "note")) {
88
- const label = node.kind === "memory" ? "#" + node.memoryId + " " + node.label : node.label;
250
+ ctx.shadowBlur = 0;
251
+ if (active || (!brain && bright && node.kind === "note")) {
252
+ const label = brain ? node.project.slug + " · " + node.label : node.kind === "memory" ? "#" + node.memoryId + " " + node.label : node.label;
89
253
  const text = label.length > 48 ? label.slice(0, 47) + "..." : label;
90
254
  const fontSize = 12 / scale;
91
255
  ctx.font = `${fontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`;
@@ -93,7 +257,7 @@ export async function mountGraph(view, data) {
93
257
  ctx.textBaseline = "top";
94
258
  const y = node.y + r + 5 / scale;
95
259
  const width = ctx.measureText(text).width;
96
- ctx.fillStyle = "#161616";
260
+ ctx.fillStyle = "#171d20";
97
261
  ctx.fillRect(node.x - width / 2 - 3 / scale, y - 1 / scale, width + 6 / scale, fontSize + 4 / scale);
98
262
  ctx.fillStyle = active ? "#fff" : "#c4c4c4";
99
263
  ctx.fillText(text, node.x, y);
@@ -103,7 +267,7 @@ export async function mountGraph(view, data) {
103
267
 
104
268
  const graph = new ForceGraph(host)
105
269
  .width(host.clientWidth).height(host.clientHeight)
106
- .backgroundColor(styles.getPropertyValue("--bg2").trim())
270
+ .backgroundColor("#00000000")
107
271
  .nodeLabel(() => "")
108
272
  .nodeCanvasObject(paint)
109
273
  .nodePointerAreaPaint((node, color, ctx, scale) => {
@@ -114,14 +278,15 @@ export async function mountGraph(view, data) {
114
278
  })
115
279
  .linkColor((link) => {
116
280
  const active = hovered || selected;
117
- if (active && (link.source === active || link.target === active)) return "#e9973f99";
118
- return active ? "#ffffff0c" : "#ffffff26";
281
+ // The renderer can request colors before resolving endpoint IDs to nodes.
282
+ return active && link.source !== active && link.target !== active ? "#ffffff08" : link.color || colors.memory;
119
283
  })
120
- .linkWidth(1)
284
+ .linkWidth(brain ? 0.55 : 1)
285
+ .enableNodeDrag(!brain)
121
286
  .minZoom(0.15).maxZoom(8)
122
- .warmupTicks(100).cooldownTicks(reducedMotion ? 0 : 100)
287
+ .warmupTicks(brain ? 0 : 100).cooldownTicks(brain || reducedMotion ? 0 : 100)
123
288
  .onNodeHover((node) => { hovered = node; host.style.cursor = node ? "pointer" : "grab"; redraw(); })
124
- .onNodeClick((node) => { selected = node; redraw(); })
289
+ .onNodeClick((node) => { if (brain) openProject(node.project.slug); else { selected = node; redraw(); } })
125
290
  .onBackgroundClick(() => { selected = null; redraw(); })
126
291
  .onEngineStop(() => {
127
292
  if (initialFit && nodes.every((node) => Number.isFinite(node.x) && Number.isFinite(node.y))) {
@@ -129,6 +294,60 @@ export async function mountGraph(view, data) {
129
294
  fit();
130
295
  }
131
296
  });
297
+ {
298
+ const byId = new Map(nodes.map((node) => [node.id, node]));
299
+ links.forEach((link, i) => { link.signalPhase = i % 7 === 0 ? (i * 0.61803398875) % 1 : null; });
300
+ graph.linkCanvasObject((link, ctx, scale) => {
301
+ const source = typeof link.source === "object" ? link.source : byId.get(link.source);
302
+ const target = typeof link.target === "object" ? link.target : byId.get(link.target);
303
+ if (!source || !target || ![source.x, source.y, target.x, target.y].every(Number.isFinite)) return;
304
+ const active = hovered || selected;
305
+ const highlighted = active && (source === active || target === active);
306
+ const opacity = active ? (highlighted ? 0.95 : 0.035) : 0.22 + ((source.depth || 0) + (target.depth || 0) + 2) * 0.12;
307
+ ctx.save();
308
+ ctx.beginPath();
309
+ ctx.moveTo(source.x, source.y);
310
+ ctx.lineTo(target.x, target.y);
311
+ ctx.strokeStyle = "#54cfff";
312
+ ctx.globalAlpha = opacity * 0.16;
313
+ ctx.lineWidth = 4 / scale;
314
+ ctx.stroke();
315
+ ctx.strokeStyle = highlighted ? "#ffffff" : link.color || colors.memory;
316
+ ctx.globalAlpha = opacity;
317
+ ctx.lineWidth = (highlighted ? 1.3 : 0.75) / scale;
318
+ ctx.stroke();
319
+ // Visual activity follows real links without modifying the layout.
320
+ if (!motionPreference.matches && link.signalPhase !== null && (!active || highlighted)) {
321
+ const progress = (motionTime / 8 + link.signalPhase) % 1;
322
+ if (progress < 0.75) {
323
+ const t = progress / 0.75;
324
+ const x = source.x + (target.x - source.x) * t;
325
+ const y = source.y + (target.y - source.y) * t;
326
+ const length = Math.hypot(target.x - source.x, target.y - source.y);
327
+ const tail = Math.max(0, t - Math.min(0.35, 14 / Math.max(1, length * scale)));
328
+ const tailX = source.x + (target.x - source.x) * tail;
329
+ const tailY = source.y + (target.y - source.y) * tail;
330
+ ctx.globalAlpha = Math.min(1, Math.sin(Math.PI * t) * 1.6);
331
+ const trail = ctx.createLinearGradient(tailX, tailY, x, y);
332
+ trail.addColorStop(0, "#79e3ff00");
333
+ trail.addColorStop(1, "#b9f4ff");
334
+ ctx.strokeStyle = trail;
335
+ ctx.lineWidth = 2.5 / scale;
336
+ ctx.beginPath();
337
+ ctx.moveTo(tailX, tailY);
338
+ ctx.lineTo(x, y);
339
+ ctx.stroke();
340
+ ctx.fillStyle = "#f0ffff";
341
+ ctx.shadowColor = "#79e3ff";
342
+ ctx.shadowBlur = 18;
343
+ ctx.beginPath();
344
+ ctx.arc(x, y, 3.2 / scale, 0, Math.PI * 2);
345
+ ctx.fill();
346
+ }
347
+ }
348
+ ctx.restore();
349
+ });
350
+ }
132
351
  graph.d3Force("charge").strength(-90);
133
352
  graph.d3Force("link").distance(55);
134
353
  // Disconnected note clusters and standalone memories must not drift off-canvas.
@@ -145,6 +364,35 @@ export async function mountGraph(view, data) {
145
364
  graph.linkColor(graph.linkColor());
146
365
  }
147
366
 
367
+ function animate(time) {
368
+ if (disposed) return;
369
+ motionFrame = requestAnimationFrame(animate);
370
+ if (lastMotionTime === null) { lastMotionTime = time; return; }
371
+ const elapsed = time - lastMotionTime;
372
+ if (elapsed < 1000 / 30) return;
373
+ lastMotionTime = time;
374
+ motionTime += elapsed / 1000;
375
+ nodes.forEach((node, i) => {
376
+ const phase = i * 2.3999632297;
377
+ node.pulse = Math.max(0, Math.sin(motionTime * 0.65 + phase)) ** 8;
378
+ });
379
+ redraw();
380
+ }
381
+
382
+ function updateMotion() {
383
+ cancelAnimationFrame(motionFrame);
384
+ lastMotionTime = null;
385
+ if (disposed) return;
386
+ if (motionPreference.matches) {
387
+ for (const node of nodes) node.pulse = 0;
388
+ redraw();
389
+ } else if (!document.hidden && host.style.visibility === "visible") {
390
+ motionFrame = requestAnimationFrame(animate);
391
+ }
392
+ }
393
+ motionPreference.addEventListener("change", updateMotion);
394
+ document.addEventListener("visibilitychange", updateMotion);
395
+
148
396
  function fit() {
149
397
  cancelAnimationFrame(fitFrame);
150
398
  // Fitting before the async layout initializes poisons the camera with NaN.
@@ -152,8 +400,12 @@ export async function mountGraph(view, data) {
152
400
  fitFrame = requestAnimationFrame(() => {
153
401
  if (disposed || !host.clientWidth || !host.clientHeight ||
154
402
  !nodes.every((node) => Number.isFinite(node.x) && Number.isFinite(node.y))) return;
155
- graph.zoomToFit(0, 60);
403
+ graph.zoomToFit(0, brain ? Math.min(60, host.clientWidth * 0.08) : 60);
156
404
  redraw();
405
+ cancelAnimationFrame(revealFrame);
406
+ revealFrame = requestAnimationFrame(() => {
407
+ if (!disposed) { host.style.visibility = "visible"; updateMotion(); }
408
+ });
157
409
  });
158
410
  }
159
411
 
@@ -169,6 +421,10 @@ export async function mountGraph(view, data) {
169
421
  return () => {
170
422
  disposed = true;
171
423
  cancelAnimationFrame(fitFrame);
424
+ cancelAnimationFrame(revealFrame);
425
+ cancelAnimationFrame(motionFrame);
426
+ motionPreference.removeEventListener("change", updateMotion);
427
+ document.removeEventListener("visibilitychange", updateMotion);
172
428
  resize.disconnect();
173
429
  graph._destructor();
174
430
  };