@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 +1 -1
- package/src/dashboard/favicon.svg +6 -0
- package/src/dashboard/graph.js +280 -24
- package/src/dashboard/index.html +350 -101
- package/src/dashboard/logo.svg +6 -0
- package/src/dashboard/workspace.css +144 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sksoftofficial/mindroot",
|
|
3
|
-
"version": "1.
|
|
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>
|
package/src/dashboard/graph.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
let library;
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
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.
|
|
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
|
|
248
|
+
ctx.fillStyle = node.kind === "note" ? colors.note : node.project?.color || colors.memory;
|
|
86
249
|
ctx.fill();
|
|
87
|
-
|
|
88
|
-
|
|
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 = "#
|
|
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(
|
|
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
|
-
|
|
118
|
-
return active ? "#
|
|
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
|
};
|