@memnest/ui-core 0.0.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/LICENSE +21 -0
- package/README.md +44 -0
- package/dist/chunk-JCMPD6YF.js +1076 -0
- package/dist/chunk-JCMPD6YF.js.map +1 -0
- package/dist/index.cjs +2398 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +513 -0
- package/dist/index.d.ts +513 -0
- package/dist/index.js +1277 -0
- package/dist/index.js.map +1 -0
- package/dist/layout-worker-CLScIQSu.d.cts +142 -0
- package/dist/layout-worker-CLScIQSu.d.ts +142 -0
- package/dist/layout-worker.cjs +1051 -0
- package/dist/layout-worker.cjs.map +1 -0
- package/dist/layout-worker.d.cts +1 -0
- package/dist/layout-worker.d.ts +1 -0
- package/dist/layout-worker.js +10 -0
- package/dist/layout-worker.js.map +1 -0
- package/package.json +81 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1277 @@
|
|
|
1
|
+
import {
|
|
2
|
+
WORKER_LAYOUT_THRESHOLD,
|
|
3
|
+
computeLayout,
|
|
4
|
+
createWorkerLayoutRunner,
|
|
5
|
+
forceLayout,
|
|
6
|
+
inlineLayoutRunner,
|
|
7
|
+
layeredLayout,
|
|
8
|
+
quadtree,
|
|
9
|
+
runLayoutRequest,
|
|
10
|
+
seededRandom,
|
|
11
|
+
serveLayoutRequests
|
|
12
|
+
} from "./chunk-JCMPD6YF.js";
|
|
13
|
+
|
|
14
|
+
// src/store.ts
|
|
15
|
+
function createStore(initial) {
|
|
16
|
+
let state = initial;
|
|
17
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
18
|
+
return {
|
|
19
|
+
getState: () => state,
|
|
20
|
+
subscribe(listener) {
|
|
21
|
+
listeners.add(listener);
|
|
22
|
+
return () => listeners.delete(listener);
|
|
23
|
+
},
|
|
24
|
+
set(patch) {
|
|
25
|
+
const next = typeof patch === "function" ? patch(state) : patch;
|
|
26
|
+
state = { ...state, ...next };
|
|
27
|
+
for (const listener of [...listeners]) listener();
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function createSequencer() {
|
|
32
|
+
let current = 0;
|
|
33
|
+
return {
|
|
34
|
+
next: () => ++current,
|
|
35
|
+
isCurrent: (token) => token === current,
|
|
36
|
+
/** Invalidates everything in flight (dispose). */
|
|
37
|
+
cancel: () => {
|
|
38
|
+
current++;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
var errorText = (error) => error instanceof Error ? error.message : String(error);
|
|
43
|
+
|
|
44
|
+
// src/geometry.ts
|
|
45
|
+
var IDENTITY_VIEWPORT = { x: 0, y: 0, k: 1 };
|
|
46
|
+
var ZOOM_LIMITS = { min: 0.02, max: 8 };
|
|
47
|
+
function boundsOf(circles) {
|
|
48
|
+
let bounds = null;
|
|
49
|
+
for (const { x, y, r = 0 } of circles) {
|
|
50
|
+
if (!bounds) bounds = { minX: x - r, minY: y - r, maxX: x + r, maxY: y + r };
|
|
51
|
+
else {
|
|
52
|
+
bounds.minX = Math.min(bounds.minX, x - r);
|
|
53
|
+
bounds.minY = Math.min(bounds.minY, y - r);
|
|
54
|
+
bounds.maxX = Math.max(bounds.maxX, x + r);
|
|
55
|
+
bounds.maxY = Math.max(bounds.maxY, y + r);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return bounds;
|
|
59
|
+
}
|
|
60
|
+
function fitViewport(bounds, size, padding = 60, maxK = 2.5) {
|
|
61
|
+
if (!bounds || size.width <= 0 || size.height <= 0) return { x: size.width / 2, y: size.height / 2, k: 1 };
|
|
62
|
+
const width = Math.max(bounds.maxX - bounds.minX, 1);
|
|
63
|
+
const height = Math.max(bounds.maxY - bounds.minY, 1);
|
|
64
|
+
const k = clamp(
|
|
65
|
+
Math.min((size.width - padding * 2) / width, (size.height - padding * 2) / height),
|
|
66
|
+
ZOOM_LIMITS.min,
|
|
67
|
+
maxK
|
|
68
|
+
);
|
|
69
|
+
const cx = (bounds.minX + bounds.maxX) / 2;
|
|
70
|
+
const cy = (bounds.minY + bounds.maxY) / 2;
|
|
71
|
+
return { x: size.width / 2 - cx * k, y: size.height / 2 - cy * k, k };
|
|
72
|
+
}
|
|
73
|
+
function zoomAt(viewport, screen, factor) {
|
|
74
|
+
const k = clamp(viewport.k * factor, ZOOM_LIMITS.min, ZOOM_LIMITS.max);
|
|
75
|
+
const world = toWorld(viewport, screen);
|
|
76
|
+
return { x: screen.x - world.x * k, y: screen.y - world.y * k, k };
|
|
77
|
+
}
|
|
78
|
+
var panBy = (viewport, dx, dy) => ({ ...viewport, x: viewport.x + dx, y: viewport.y + dy });
|
|
79
|
+
var toWorld = (viewport, screen) => ({ x: (screen.x - viewport.x) / viewport.k, y: (screen.y - viewport.y) / viewport.k });
|
|
80
|
+
var toScreen = (viewport, world) => ({ x: world.x * viewport.k + viewport.x, y: world.y * viewport.k + viewport.y });
|
|
81
|
+
var clamp = (value, min, max) => Math.min(max, Math.max(min, value));
|
|
82
|
+
|
|
83
|
+
// src/cluster.ts
|
|
84
|
+
import { MEMORY_KINDS, queryTerms } from "@memnest/core";
|
|
85
|
+
var CATCH_ALL = "cluster:*";
|
|
86
|
+
var usable = (term) => term.length >= 3 && !/^\d+$/.test(term);
|
|
87
|
+
function clusterNodes(nodes, edges, options = {}) {
|
|
88
|
+
const maxClusters = Math.max(2, options.maxClusters ?? 48);
|
|
89
|
+
const exclude = new Set(options.exclude?.flatMap((t) => queryTerms(t)) ?? []);
|
|
90
|
+
const termsOf = nodes.map((node) => queryTerms(node.content).filter((t) => usable(t) && !exclude.has(t)));
|
|
91
|
+
const df = /* @__PURE__ */ new Map();
|
|
92
|
+
for (const terms of termsOf) for (const t of terms) df.set(t, (df.get(t) ?? 0) + 1);
|
|
93
|
+
const ceiling = Math.max(2, Math.floor(nodes.length * 0.6));
|
|
94
|
+
const keyOf = termsOf.map((terms) => {
|
|
95
|
+
let best;
|
|
96
|
+
for (const t of terms) {
|
|
97
|
+
const count = df.get(t);
|
|
98
|
+
if (count < 2 || count > ceiling) continue;
|
|
99
|
+
const bestCount = best === void 0 ? -1 : df.get(best);
|
|
100
|
+
if (count > bestCount || count === bestCount && t < best) best = t;
|
|
101
|
+
}
|
|
102
|
+
return best ?? "";
|
|
103
|
+
});
|
|
104
|
+
const sizes = /* @__PURE__ */ new Map();
|
|
105
|
+
for (const key of keyOf) if (key) sizes.set(key, (sizes.get(key) ?? 0) + 1);
|
|
106
|
+
const kept = new Set(
|
|
107
|
+
[...sizes.entries()].sort(([a, x], [b, y]) => y - x || (a < b ? -1 : 1)).slice(0, maxClusters - 1).map(([key]) => key)
|
|
108
|
+
);
|
|
109
|
+
const byId = /* @__PURE__ */ new Map();
|
|
110
|
+
const clusterOf = /* @__PURE__ */ new Map();
|
|
111
|
+
nodes.forEach((node, i) => {
|
|
112
|
+
const key = kept.has(keyOf[i]) ? keyOf[i] : "";
|
|
113
|
+
const id = key ? `cluster:${key}` : CATCH_ALL;
|
|
114
|
+
let cluster = byId.get(id);
|
|
115
|
+
if (!cluster) {
|
|
116
|
+
cluster = {
|
|
117
|
+
id,
|
|
118
|
+
key,
|
|
119
|
+
label: key || "other",
|
|
120
|
+
count: 0,
|
|
121
|
+
kinds: Object.fromEntries(MEMORY_KINDS.map((k) => [k, 0])),
|
|
122
|
+
inactive: 0,
|
|
123
|
+
memberIds: []
|
|
124
|
+
};
|
|
125
|
+
byId.set(id, cluster);
|
|
126
|
+
}
|
|
127
|
+
cluster.count++;
|
|
128
|
+
cluster.kinds[node.kind]++;
|
|
129
|
+
if (!node.isLatest || node.forgotten) cluster.inactive++;
|
|
130
|
+
cluster.memberIds.push(node.id);
|
|
131
|
+
clusterOf.set(node.id, id);
|
|
132
|
+
});
|
|
133
|
+
const weights = /* @__PURE__ */ new Map();
|
|
134
|
+
for (const edge of edges) {
|
|
135
|
+
const from = clusterOf.get(edge.from);
|
|
136
|
+
const to = clusterOf.get(edge.to);
|
|
137
|
+
if (!from || !to || from === to) continue;
|
|
138
|
+
const pair = from < to ? `${from}\0${to}` : `${to}\0${from}`;
|
|
139
|
+
weights.set(pair, (weights.get(pair) ?? 0) + 1);
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
clusters: [...byId.values()].sort((a, z) => z.count - a.count || (a.key < z.key ? -1 : 1)),
|
|
143
|
+
edges: [...weights.entries()].map(([pair, weight]) => {
|
|
144
|
+
const [from, to] = pair.split("\0");
|
|
145
|
+
return { from, to, weight };
|
|
146
|
+
}),
|
|
147
|
+
clusterOf
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// src/hit.ts
|
|
152
|
+
function createHitIndex(circles) {
|
|
153
|
+
const tree = quadtree(
|
|
154
|
+
[...circles],
|
|
155
|
+
(c) => c.x,
|
|
156
|
+
(c) => c.y
|
|
157
|
+
);
|
|
158
|
+
const maxR = circles.reduce((max, c) => Math.max(max, c.r), 0);
|
|
159
|
+
return {
|
|
160
|
+
size: circles.length,
|
|
161
|
+
pick(world, slop = 0) {
|
|
162
|
+
const candidate = tree.find(world.x, world.y, maxR + slop);
|
|
163
|
+
if (candidate && Math.hypot(candidate.x - world.x, candidate.y - world.y) <= candidate.r + slop) return candidate;
|
|
164
|
+
let best = null;
|
|
165
|
+
let bestDistance = Infinity;
|
|
166
|
+
tree.visit((node, x0, y0, x1, y1) => {
|
|
167
|
+
if (!node.length) {
|
|
168
|
+
for (let leaf = node; leaf; leaf = leaf.next) {
|
|
169
|
+
const c = leaf.data;
|
|
170
|
+
const d = Math.hypot(c.x - world.x, c.y - world.y);
|
|
171
|
+
if (d <= c.r + slop && d < bestDistance) {
|
|
172
|
+
best = c;
|
|
173
|
+
bestDistance = d;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const reach = maxR + slop;
|
|
178
|
+
return x0 > world.x + reach || x1 < world.x - reach || y0 > world.y + reach || y1 < world.y - reach;
|
|
179
|
+
});
|
|
180
|
+
return best;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// src/encoding.ts
|
|
186
|
+
var nodeRadius = (node) => 5 + Math.min(11, 2.5 * Math.sqrt(Math.max(0, node.reinforcementCount - 1)));
|
|
187
|
+
var clusterRadius = (count) => 14 + Math.min(70, 3.2 * Math.sqrt(count));
|
|
188
|
+
var documentRadius = 7;
|
|
189
|
+
function shorten(text, max = 48) {
|
|
190
|
+
if (text.length <= max) return text;
|
|
191
|
+
const cut = text.slice(0, max - 1);
|
|
192
|
+
const space = cut.lastIndexOf(" ");
|
|
193
|
+
return `${space > max * 0.6 ? cut.slice(0, space) : cut}\u2026`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// src/draw.ts
|
|
197
|
+
function seedOf(text) {
|
|
198
|
+
let hash = 2166136261;
|
|
199
|
+
for (let i = 0; i < text.length; i++) hash = Math.imul(hash ^ text.charCodeAt(i), 16777619);
|
|
200
|
+
hash = Math.imul(hash ^ hash >>> 16, 2246822507);
|
|
201
|
+
hash = Math.imul(hash ^ hash >>> 13, 3266489909);
|
|
202
|
+
return ((hash ^ hash >>> 16) >>> 0) / 4294967296;
|
|
203
|
+
}
|
|
204
|
+
function buildGraphScene(state, theme) {
|
|
205
|
+
const { positions, selectedId, lineage } = state;
|
|
206
|
+
const circles = [];
|
|
207
|
+
const lines = [];
|
|
208
|
+
if (state.mode === "clusters") {
|
|
209
|
+
for (const edge of state.clusterEdges) {
|
|
210
|
+
const a = positions.get(edge.from);
|
|
211
|
+
const b = positions.get(edge.to);
|
|
212
|
+
if (!a || !b) continue;
|
|
213
|
+
lines.push({
|
|
214
|
+
x1: a.x,
|
|
215
|
+
y1: a.y,
|
|
216
|
+
x2: b.x,
|
|
217
|
+
y2: b.y,
|
|
218
|
+
to: edge.to,
|
|
219
|
+
color: theme.edges.aggregate,
|
|
220
|
+
width: Math.min(6, 1 + Math.log2(edge.weight)),
|
|
221
|
+
alpha: 0.5,
|
|
222
|
+
dash: null,
|
|
223
|
+
arrow: null,
|
|
224
|
+
seed: seedOf(`${edge.from} ${edge.to}`)
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
for (const cluster of state.clusters) {
|
|
228
|
+
const p = positions.get(cluster.id);
|
|
229
|
+
if (!p) continue;
|
|
230
|
+
circles.push({
|
|
231
|
+
id: cluster.id,
|
|
232
|
+
x: p.x,
|
|
233
|
+
y: p.y,
|
|
234
|
+
r: clusterRadius(cluster.count),
|
|
235
|
+
fill: theme.cluster,
|
|
236
|
+
stroke: theme.clusterStroke,
|
|
237
|
+
strokeWidth: 1,
|
|
238
|
+
// Translucent, so the glow behind shows through.
|
|
239
|
+
alpha: 0.62,
|
|
240
|
+
glow: 0.45 + Math.min(0.4, Math.log10(cluster.count) / 8),
|
|
241
|
+
selected: false,
|
|
242
|
+
seed: seedOf(cluster.id),
|
|
243
|
+
label: `${cluster.label} \xB7 ${cluster.count.toLocaleString("en")}`,
|
|
244
|
+
priority: cluster.count
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
return { circles, lines };
|
|
248
|
+
}
|
|
249
|
+
const radius = new Map(state.nodes.map((n) => [n.id, nodeRadius(n)]));
|
|
250
|
+
const lineageEdges = new Set(lineage?.edges.map((e) => `${e.from} ${e.to}`) ?? []);
|
|
251
|
+
for (const edge of state.edges) {
|
|
252
|
+
const a = positions.get(edge.from);
|
|
253
|
+
const b = positions.get(edge.to);
|
|
254
|
+
if (!a || !b) continue;
|
|
255
|
+
const inLineage = lineageEdges.has(`${edge.from} ${edge.to}`);
|
|
256
|
+
lines.push({
|
|
257
|
+
x1: a.x,
|
|
258
|
+
y1: a.y,
|
|
259
|
+
x2: b.x,
|
|
260
|
+
y2: b.y,
|
|
261
|
+
to: edge.to,
|
|
262
|
+
color: inLineage ? theme.lineage : edge.relation === "updates" ? theme.edges.updates : theme.edges.extends,
|
|
263
|
+
width: inLineage ? 2.5 : 1.25,
|
|
264
|
+
alpha: lineage && !inLineage ? 0.25 : 0.9,
|
|
265
|
+
// updates: solid with an arrow to the replaced fact; extends: dotted, no arrow.
|
|
266
|
+
dash: edge.relation === "extends" ? [0.5, 4] : null,
|
|
267
|
+
arrow: edge.relation === "updates" ? radius.get(edge.to) ?? 6 : null,
|
|
268
|
+
seed: seedOf(`${edge.from} ${edge.to}`)
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
for (const node of state.nodes) {
|
|
272
|
+
const p = positions.get(node.id);
|
|
273
|
+
if (!p) continue;
|
|
274
|
+
const selected = node.id === selectedId;
|
|
275
|
+
const inLineage = lineage?.memoryIds.has(node.id) ?? false;
|
|
276
|
+
const dimmed = Boolean(lineage) && !inLineage && !selected;
|
|
277
|
+
const base = node.forgotten ? theme.forgottenAlpha : node.isLatest ? 1 : theme.supersededAlpha;
|
|
278
|
+
const glow = node.forgotten ? 0 : node.isLatest ? 0.55 + Math.min(0.45, 0.12 * (node.reinforcementCount - 1)) : 0.22;
|
|
279
|
+
circles.push({
|
|
280
|
+
id: node.id,
|
|
281
|
+
x: p.x,
|
|
282
|
+
y: p.y,
|
|
283
|
+
r: radius.get(node.id),
|
|
284
|
+
fill: node.forgotten ? null : theme.kinds[node.kind],
|
|
285
|
+
stroke: selected ? theme.selection : inLineage ? theme.lineage : node.forgotten ? theme.kinds[node.kind] : null,
|
|
286
|
+
strokeWidth: selected ? 3 : inLineage || node.forgotten ? 2 : 0,
|
|
287
|
+
alpha: dimmed ? base * 0.35 : base,
|
|
288
|
+
glow: dimmed ? glow * 0.3 : selected ? 1 : glow,
|
|
289
|
+
selected,
|
|
290
|
+
seed: seedOf(node.id),
|
|
291
|
+
label: shorten(node.content, 42),
|
|
292
|
+
priority: (selected ? 1e9 : 0) + (inLineage ? 1e6 : 0) + node.reinforcementCount * 10 + (node.isLatest ? 5 : 0)
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
return { circles, lines };
|
|
296
|
+
}
|
|
297
|
+
var TAU = Math.PI * 2;
|
|
298
|
+
var TOPIC_RADIUS = 17;
|
|
299
|
+
function parseHex(color) {
|
|
300
|
+
let hex = color.trim();
|
|
301
|
+
if (!hex.startsWith("#")) return null;
|
|
302
|
+
hex = hex.slice(1);
|
|
303
|
+
if (hex.length === 3) hex = hex.replace(/./g, (c) => c + c);
|
|
304
|
+
if (!/^[0-9a-f]{6}$/i.test(hex)) return null;
|
|
305
|
+
const n = Number.parseInt(hex, 16);
|
|
306
|
+
return [n >> 16, n >> 8 & 255, n & 255];
|
|
307
|
+
}
|
|
308
|
+
function withAlpha(color, alpha) {
|
|
309
|
+
const rgb = parseHex(color);
|
|
310
|
+
if (!rgb) return alpha <= 0 ? "rgba(0,0,0,0)" : color;
|
|
311
|
+
return `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${alpha})`;
|
|
312
|
+
}
|
|
313
|
+
function mix(color, toward, t) {
|
|
314
|
+
const a = parseHex(color);
|
|
315
|
+
const b = parseHex(toward);
|
|
316
|
+
if (!a || !b) return color;
|
|
317
|
+
const channel = (i) => Math.round(a[i] + (b[i] - a[i]) * t);
|
|
318
|
+
return `rgb(${channel(0)},${channel(1)},${channel(2)})`;
|
|
319
|
+
}
|
|
320
|
+
var spriteCache = /* @__PURE__ */ new WeakMap();
|
|
321
|
+
function unitGradient(ctx, key, stops) {
|
|
322
|
+
let cache = spriteCache.get(ctx);
|
|
323
|
+
if (!cache) spriteCache.set(ctx, cache = /* @__PURE__ */ new Map());
|
|
324
|
+
let gradient = cache.get(key);
|
|
325
|
+
if (!gradient) {
|
|
326
|
+
gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, 1);
|
|
327
|
+
for (const [offset, color] of stops()) gradient.addColorStop(offset, color);
|
|
328
|
+
cache.set(key, gradient);
|
|
329
|
+
}
|
|
330
|
+
return gradient;
|
|
331
|
+
}
|
|
332
|
+
var haloGradient = (ctx, color, ground) => unitGradient(
|
|
333
|
+
ctx,
|
|
334
|
+
`halo ${ground} ${color}`,
|
|
335
|
+
() => ground === "dark" ? [
|
|
336
|
+
[0, withAlpha(color, 0.6)],
|
|
337
|
+
[0.22, withAlpha(color, 0.26)],
|
|
338
|
+
[0.55, withAlpha(color, 0.07)],
|
|
339
|
+
[1, withAlpha(color, 0)]
|
|
340
|
+
] : (
|
|
341
|
+
// A soft tint: on a light ground a strong halo reads as a stain, not a glow.
|
|
342
|
+
[
|
|
343
|
+
[0, withAlpha(color, 0.34)],
|
|
344
|
+
[0.25, withAlpha(color, 0.15)],
|
|
345
|
+
[0.6, withAlpha(color, 0.04)],
|
|
346
|
+
[1, withAlpha(color, 0)]
|
|
347
|
+
]
|
|
348
|
+
)
|
|
349
|
+
);
|
|
350
|
+
var coreGradient = (ctx, color, ground) => unitGradient(
|
|
351
|
+
ctx,
|
|
352
|
+
`core ${ground} ${color}`,
|
|
353
|
+
() => ground === "dark" ? [
|
|
354
|
+
[0, mix(color, "#ffffff", 0.9)],
|
|
355
|
+
[0.3, mix(color, "#ffffff", 0.45)],
|
|
356
|
+
[0.75, color],
|
|
357
|
+
[1, mix(color, "#000000", 0.3)]
|
|
358
|
+
] : (
|
|
359
|
+
// A softer highlight and a firmer edge, so the neuron keeps its shape against white.
|
|
360
|
+
[
|
|
361
|
+
[0, mix(color, "#ffffff", 0.6)],
|
|
362
|
+
[0.45, mix(color, "#ffffff", 0.12)],
|
|
363
|
+
[0.85, color],
|
|
364
|
+
[1, mix(color, "#000000", 0.2)]
|
|
365
|
+
]
|
|
366
|
+
)
|
|
367
|
+
);
|
|
368
|
+
var sparkGradient = (ctx, color, core) => unitGradient(ctx, `spark ${color} ${core}`, () => [
|
|
369
|
+
[0, withAlpha(core, 1)],
|
|
370
|
+
[0.15, withAlpha(mix(color, core, 0.5), 0.9)],
|
|
371
|
+
[0.4, withAlpha(color, 0.3)],
|
|
372
|
+
[1, withAlpha(color, 0)]
|
|
373
|
+
]);
|
|
374
|
+
function drawScene(ctx, scene, viewport, size, theme, options = {}) {
|
|
375
|
+
const ratio = options.pixelRatio ?? 1;
|
|
376
|
+
const live = options.time !== void 0;
|
|
377
|
+
const time = options.time ?? 0;
|
|
378
|
+
const { k } = viewport;
|
|
379
|
+
const glow = theme.ground === "dark" ? "lighter" : "source-over";
|
|
380
|
+
ctx.save();
|
|
381
|
+
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
|
|
382
|
+
ctx.globalCompositeOperation = "source-over";
|
|
383
|
+
ctx.globalAlpha = 1;
|
|
384
|
+
ctx.fillStyle = theme.background;
|
|
385
|
+
ctx.fillRect(0, 0, size.width, size.height);
|
|
386
|
+
const world = () => ctx.setTransform(ratio * k, 0, 0, ratio * k, ratio * viewport.x, ratio * viewport.y);
|
|
387
|
+
const place = (x, y, r) => {
|
|
388
|
+
const scale = ratio * k * r;
|
|
389
|
+
ctx.setTransform(scale, 0, 0, scale, ratio * (viewport.x + x * k), ratio * (viewport.y + y * k));
|
|
390
|
+
};
|
|
391
|
+
const margin = 80 / k;
|
|
392
|
+
const minX = -viewport.x / k - margin;
|
|
393
|
+
const minY = -viewport.y / k - margin;
|
|
394
|
+
const maxX = (size.width - viewport.x) / k + margin;
|
|
395
|
+
const maxY = (size.height - viewport.y) / k + margin;
|
|
396
|
+
const visible = (x, y) => x >= minX && x <= maxX && y >= minY && y <= maxY;
|
|
397
|
+
world();
|
|
398
|
+
ctx.globalCompositeOperation = glow;
|
|
399
|
+
ctx.lineCap = "round";
|
|
400
|
+
ctx.lineJoin = "round";
|
|
401
|
+
const flashes = /* @__PURE__ */ new Map();
|
|
402
|
+
const sparks = [];
|
|
403
|
+
const lines = scene.lines.filter((line) => visible(line.x1, line.y1) || visible(line.x2, line.y2));
|
|
404
|
+
const density = Math.min(1, 12 / Math.sqrt(lines.length || 1));
|
|
405
|
+
for (const line of lines) {
|
|
406
|
+
const dx = line.x2 - line.x1;
|
|
407
|
+
const dy = line.y2 - line.y1;
|
|
408
|
+
const length = Math.hypot(dx, dy) || 1;
|
|
409
|
+
const bend = (line.seed - 0.5) * 0.4 * length;
|
|
410
|
+
const cx = (line.x1 + line.x2) / 2 - dy / length * bend;
|
|
411
|
+
const cy = (line.y1 + line.y2) / 2 + dx / length * bend;
|
|
412
|
+
ctx.strokeStyle = line.color;
|
|
413
|
+
ctx.beginPath();
|
|
414
|
+
ctx.moveTo(line.x1, line.y1);
|
|
415
|
+
ctx.quadraticCurveTo(cx, cy, line.x2, line.y2);
|
|
416
|
+
ctx.setLineDash([]);
|
|
417
|
+
ctx.globalAlpha = line.alpha * 0.1 * density;
|
|
418
|
+
ctx.lineWidth = line.width * 4 / k;
|
|
419
|
+
ctx.stroke();
|
|
420
|
+
ctx.setLineDash(line.dash ? line.dash.map((d) => d / k) : []);
|
|
421
|
+
ctx.globalAlpha = line.alpha * (0.25 + 0.35 * density);
|
|
422
|
+
ctx.lineWidth = line.width / k;
|
|
423
|
+
ctx.stroke();
|
|
424
|
+
if (line.arrow !== null) {
|
|
425
|
+
const angle = Math.atan2(line.y2 - cy, line.x2 - cx);
|
|
426
|
+
const tipX = line.x2 - Math.cos(angle) * (line.arrow + 1.5 / k);
|
|
427
|
+
const tipY = line.y2 - Math.sin(angle) * (line.arrow + 1.5 / k);
|
|
428
|
+
const head = Math.min(6, Math.max(2, line.arrow * k)) / k;
|
|
429
|
+
ctx.setLineDash([]);
|
|
430
|
+
ctx.fillStyle = line.color;
|
|
431
|
+
ctx.beginPath();
|
|
432
|
+
ctx.moveTo(tipX, tipY);
|
|
433
|
+
ctx.lineTo(tipX - Math.cos(angle - 0.45) * head, tipY - Math.sin(angle - 0.45) * head);
|
|
434
|
+
ctx.lineTo(tipX - Math.cos(angle + 0.45) * head, tipY - Math.sin(angle + 0.45) * head);
|
|
435
|
+
ctx.fill();
|
|
436
|
+
}
|
|
437
|
+
if (live) {
|
|
438
|
+
const period = 2800 + line.seed * 6e3;
|
|
439
|
+
const travel = Math.min(2400, Math.max(700, length * 9));
|
|
440
|
+
const u = (time + line.seed * 9973) % period / travel;
|
|
441
|
+
if (u <= 1) {
|
|
442
|
+
const e = u < 0.5 ? 2 * u * u : 1 - (2 - 2 * u) ** 2 / 2;
|
|
443
|
+
const inv = 1 - e;
|
|
444
|
+
sparks.push({
|
|
445
|
+
x: inv * inv * line.x1 + 2 * inv * e * cx + e * e * line.x2,
|
|
446
|
+
y: inv * inv * line.y1 + 2 * inv * e * cy + e * e * line.y2,
|
|
447
|
+
color: line.color,
|
|
448
|
+
strength: Math.min(1, line.alpha * 1.1) * Math.sin(Math.PI * Math.min(1, u * 1.15 + 0.08))
|
|
449
|
+
});
|
|
450
|
+
} else if (line.to && u < 1.5) {
|
|
451
|
+
flashes.set(line.to, Math.max(flashes.get(line.to) ?? 0, 1 - (u - 1) / 0.5));
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
ctx.setLineDash([]);
|
|
456
|
+
for (const spark of sparks) {
|
|
457
|
+
place(spark.x, spark.y, Math.min(9, Math.max(4, 3 * k)) / k);
|
|
458
|
+
ctx.globalAlpha = Math.max(0, spark.strength);
|
|
459
|
+
ctx.fillStyle = sparkGradient(ctx, spark.color, theme.spark);
|
|
460
|
+
ctx.fillRect(-1, -1, 2, 2);
|
|
461
|
+
}
|
|
462
|
+
const shown = scene.circles.filter((c) => visible(c.x, c.y));
|
|
463
|
+
const crowd = Math.min(1, 22 / Math.sqrt(shown.length || 1));
|
|
464
|
+
for (const circle of shown) {
|
|
465
|
+
const color = circle.fill ?? circle.stroke;
|
|
466
|
+
const flash = flashes.get(circle.id) ?? 0;
|
|
467
|
+
const pulse = live ? 0.75 + 0.25 * Math.sin(time / (1100 + circle.seed * 1400) + circle.seed * TAU) : 1;
|
|
468
|
+
const strength = Math.min(1, circle.glow * (pulse * crowd + flash * 0.55));
|
|
469
|
+
if (!color || strength <= 0.01) continue;
|
|
470
|
+
place(circle.x, circle.y, Math.max(circle.r * (3 + flash * 0.8), 7 / k));
|
|
471
|
+
ctx.globalAlpha = strength;
|
|
472
|
+
ctx.fillStyle = haloGradient(ctx, color, theme.ground);
|
|
473
|
+
ctx.fillRect(-1, -1, 2, 2);
|
|
474
|
+
}
|
|
475
|
+
ctx.globalCompositeOperation = "source-over";
|
|
476
|
+
const labelled = [];
|
|
477
|
+
for (const circle of shown) {
|
|
478
|
+
place(circle.x, circle.y, circle.r);
|
|
479
|
+
const ghost = circle.fill !== null && circle.alpha < 1 && circle.r < TOPIC_RADIUS;
|
|
480
|
+
ctx.globalCompositeOperation = ghost ? glow : "source-over";
|
|
481
|
+
ctx.globalAlpha = ghost ? circle.alpha * 0.45 : circle.alpha;
|
|
482
|
+
ctx.beginPath();
|
|
483
|
+
ctx.arc(0, 0, 1, 0, TAU);
|
|
484
|
+
if (circle.fill) {
|
|
485
|
+
ctx.fillStyle = coreGradient(ctx, circle.fill, theme.ground);
|
|
486
|
+
ctx.fill();
|
|
487
|
+
}
|
|
488
|
+
if (circle.stroke && circle.strokeWidth > 0) {
|
|
489
|
+
ctx.globalAlpha = circle.alpha;
|
|
490
|
+
ctx.strokeStyle = circle.stroke;
|
|
491
|
+
ctx.lineWidth = circle.strokeWidth / (k * circle.r);
|
|
492
|
+
ctx.stroke();
|
|
493
|
+
} else if (ghost) {
|
|
494
|
+
ctx.globalAlpha = Math.min(1, circle.alpha * 2);
|
|
495
|
+
ctx.strokeStyle = circle.fill;
|
|
496
|
+
ctx.lineWidth = 1 / (k * circle.r);
|
|
497
|
+
ctx.stroke();
|
|
498
|
+
}
|
|
499
|
+
if (circle.label && (circle.r * k >= 9 || circle.priority >= 1e6)) labelled.push(circle);
|
|
500
|
+
}
|
|
501
|
+
ctx.globalCompositeOperation = glow;
|
|
502
|
+
world();
|
|
503
|
+
if (live) {
|
|
504
|
+
for (const circle of shown) {
|
|
505
|
+
if (!circle.selected) continue;
|
|
506
|
+
const phase = time % 2400 / 2400;
|
|
507
|
+
ctx.globalAlpha = 0.6 * (1 - phase);
|
|
508
|
+
ctx.strokeStyle = circle.stroke ?? theme.selection;
|
|
509
|
+
ctx.lineWidth = 1.5 / k;
|
|
510
|
+
ctx.beginPath();
|
|
511
|
+
ctx.arc(circle.x, circle.y, circle.r * (1.3 + phase * 2.2), 0, TAU);
|
|
512
|
+
ctx.stroke();
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
ctx.globalCompositeOperation = "source-over";
|
|
516
|
+
labelled.sort((a, z) => z.priority - a.priority);
|
|
517
|
+
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
|
|
518
|
+
ctx.font = theme.font;
|
|
519
|
+
ctx.textAlign = "center";
|
|
520
|
+
ctx.textBaseline = "top";
|
|
521
|
+
ctx.lineJoin = "round";
|
|
522
|
+
const taken = [];
|
|
523
|
+
for (const circle of labelled.slice(0, options.maxLabels ?? 160)) {
|
|
524
|
+
const x = circle.x * k + viewport.x;
|
|
525
|
+
const y = (circle.y + circle.r) * k + viewport.y + 6;
|
|
526
|
+
const width = circle.label.length * 6.2;
|
|
527
|
+
const box = [x - width / 2, y, x + width / 2, y + 14];
|
|
528
|
+
if (taken.some(([x0, y0, x1, y1]) => box[0] < x1 && box[2] > x0 && box[1] < y1 && box[3] > y0)) continue;
|
|
529
|
+
taken.push(box);
|
|
530
|
+
ctx.globalAlpha = Math.max(circle.alpha, 0.7);
|
|
531
|
+
ctx.strokeStyle = theme.labelHalo;
|
|
532
|
+
ctx.lineWidth = 4;
|
|
533
|
+
ctx.strokeText(circle.label, x, y);
|
|
534
|
+
ctx.fillStyle = theme.label;
|
|
535
|
+
ctx.fillText(circle.label, x, y);
|
|
536
|
+
}
|
|
537
|
+
ctx.restore();
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// src/controllers/graph.ts
|
|
541
|
+
import { scopeOf } from "@memnest/core";
|
|
542
|
+
var CLUSTER_THRESHOLD = 2e3;
|
|
543
|
+
var GRAPH_LOAD_LIMIT = 1e4;
|
|
544
|
+
var DEFAULT_GRAPH_FILTER = { kinds: [], includeSuperseded: true, includeForgotten: false, search: "" };
|
|
545
|
+
function matchesFilter(node, filter) {
|
|
546
|
+
if (filter.kinds.length > 0 && !filter.kinds.includes(node.kind)) return false;
|
|
547
|
+
if (!node.isLatest && !filter.includeSuperseded) return false;
|
|
548
|
+
if (node.forgotten && !filter.includeForgotten) return false;
|
|
549
|
+
const search = filter.search.trim().toLowerCase();
|
|
550
|
+
if (!search) return true;
|
|
551
|
+
if (node.id === filter.search.trim()) return true;
|
|
552
|
+
const content = node.content.toLowerCase();
|
|
553
|
+
return search.split(/\s+/).every((word) => content.includes(word));
|
|
554
|
+
}
|
|
555
|
+
function createGraphController(options) {
|
|
556
|
+
const { client } = options;
|
|
557
|
+
const scope = scopeOf(options.containerTag);
|
|
558
|
+
const runner = options.layout ?? inlineLayoutRunner;
|
|
559
|
+
const threshold = options.clusterThreshold ?? CLUSTER_THRESHOLD;
|
|
560
|
+
const limit = options.limit ?? GRAPH_LOAD_LIMIT;
|
|
561
|
+
const store = createStore({
|
|
562
|
+
containerTag: scope.containerTag,
|
|
563
|
+
status: "idle",
|
|
564
|
+
error: null,
|
|
565
|
+
filter: { ...DEFAULT_GRAPH_FILTER, ...options.filter },
|
|
566
|
+
mode: "nodes",
|
|
567
|
+
totalMemories: 0,
|
|
568
|
+
loaded: 0,
|
|
569
|
+
truncated: false,
|
|
570
|
+
matching: 0,
|
|
571
|
+
nodes: [],
|
|
572
|
+
edges: [],
|
|
573
|
+
clusters: [],
|
|
574
|
+
clusterEdges: [],
|
|
575
|
+
positions: /* @__PURE__ */ new Map(),
|
|
576
|
+
selectedId: null,
|
|
577
|
+
lineage: null,
|
|
578
|
+
viewport: IDENTITY_VIEWPORT,
|
|
579
|
+
size: { width: 0, height: 0 },
|
|
580
|
+
layoutMs: null,
|
|
581
|
+
hover: null
|
|
582
|
+
});
|
|
583
|
+
const loads = createSequencer();
|
|
584
|
+
const layouts = createSequencer();
|
|
585
|
+
let snapshot = null;
|
|
586
|
+
let hits = createHitIndex([]);
|
|
587
|
+
let userMoved = false;
|
|
588
|
+
let disposed = false;
|
|
589
|
+
async function recompute() {
|
|
590
|
+
if (!snapshot) return;
|
|
591
|
+
const token = layouts.next();
|
|
592
|
+
const { filter, lineage, positions: previous, mode: previousMode } = store.getState();
|
|
593
|
+
let nodes = snapshot.nodes.filter((n) => matchesFilter(n, filter));
|
|
594
|
+
const matching = nodes.length;
|
|
595
|
+
if (lineage) {
|
|
596
|
+
const present = new Set(nodes.map((n) => n.id));
|
|
597
|
+
nodes = nodes.concat(snapshot.nodes.filter((n) => lineage.memoryIds.has(n.id) && !present.has(n.id)));
|
|
598
|
+
}
|
|
599
|
+
const ids = new Set(nodes.map((n) => n.id));
|
|
600
|
+
const edges = snapshot.edges.filter((e) => ids.has(e.from) && ids.has(e.to));
|
|
601
|
+
const mode = nodes.length > threshold ? "clusters" : "nodes";
|
|
602
|
+
let clusters = [];
|
|
603
|
+
let clusterEdges = [];
|
|
604
|
+
let layoutNodes;
|
|
605
|
+
let layoutEdges;
|
|
606
|
+
if (mode === "clusters") {
|
|
607
|
+
({ clusters, edges: clusterEdges } = clusterNodes(nodes, edges, { exclude: filter.search ? [filter.search] : [] }));
|
|
608
|
+
layoutNodes = clusters.map((c) => ({ id: c.id, r: clusterRadius(c.count) }));
|
|
609
|
+
layoutEdges = clusterEdges;
|
|
610
|
+
} else {
|
|
611
|
+
layoutNodes = nodes.map((n) => ({ id: n.id, r: nodeRadius(n) }));
|
|
612
|
+
layoutEdges = edges;
|
|
613
|
+
}
|
|
614
|
+
store.set({ status: "layout", hover: null, matching, mode, nodes: mode === "nodes" ? nodes : [], edges: mode === "nodes" ? edges : [], clusters, clusterEdges });
|
|
615
|
+
const started = performance.now();
|
|
616
|
+
let positions;
|
|
617
|
+
const laidOut = new Set(layoutNodes.map((n) => n.id));
|
|
618
|
+
try {
|
|
619
|
+
positions = await runner.run({
|
|
620
|
+
algorithm: "force",
|
|
621
|
+
nodes: layoutNodes,
|
|
622
|
+
edges: layoutEdges,
|
|
623
|
+
options: {
|
|
624
|
+
initial: [...previous].filter(([id]) => laidOut.has(id)),
|
|
625
|
+
// Clusters are big circles that must not overlap; a handful of nodes needs room for labels.
|
|
626
|
+
...mode === "clusters" ? { linkDistance: 180, charge: -900, collidePadding: 28, collideIterations: 4 } : layoutNodes.length <= 40 ? { linkDistance: 90, charge: -320, collidePadding: 24 } : {}
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
} catch (error) {
|
|
630
|
+
if (layouts.isCurrent(token) && !disposed) store.set({ status: "error", error: errorText(error) });
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
if (!layouts.isCurrent(token) || disposed) return;
|
|
634
|
+
const radius = new Map(layoutNodes.map((n) => [n.id, n.r]));
|
|
635
|
+
const circles = [...positions].map(([id, p]) => ({ id, x: p.x, y: p.y, r: radius.get(id) ?? 6 }));
|
|
636
|
+
hits = createHitIndex(circles);
|
|
637
|
+
const state = store.getState();
|
|
638
|
+
const refit = !userMoved || previousMode !== mode;
|
|
639
|
+
store.set({
|
|
640
|
+
status: "ready",
|
|
641
|
+
error: null,
|
|
642
|
+
positions,
|
|
643
|
+
layoutMs: Math.round(performance.now() - started),
|
|
644
|
+
...refit ? { viewport: fitViewport(boundsOf(circles), state.size) } : {}
|
|
645
|
+
});
|
|
646
|
+
if (refit) userMoved = false;
|
|
647
|
+
}
|
|
648
|
+
const controller = {
|
|
649
|
+
getState: store.getState,
|
|
650
|
+
subscribe: store.subscribe,
|
|
651
|
+
async load() {
|
|
652
|
+
const token = loads.next();
|
|
653
|
+
store.set({ status: "loading", error: null });
|
|
654
|
+
try {
|
|
655
|
+
const loaded = await client.graph(scope, { limit, includeSuperseded: true, includeForgotten: true });
|
|
656
|
+
if (!loads.isCurrent(token) || disposed) return;
|
|
657
|
+
snapshot = loaded;
|
|
658
|
+
const { selectedId, lineage } = store.getState();
|
|
659
|
+
const present = new Set(loaded.nodes.map((n) => n.id));
|
|
660
|
+
store.set({
|
|
661
|
+
totalMemories: loaded.totalMemories,
|
|
662
|
+
loaded: loaded.nodes.length,
|
|
663
|
+
truncated: loaded.truncated,
|
|
664
|
+
selectedId: selectedId && present.has(selectedId) ? selectedId : null,
|
|
665
|
+
lineage: lineage && present.has(lineage.rootId) ? lineage : null
|
|
666
|
+
});
|
|
667
|
+
await recompute();
|
|
668
|
+
} catch (error) {
|
|
669
|
+
if (loads.isCurrent(token) && !disposed) store.set({ status: "error", error: errorText(error) });
|
|
670
|
+
}
|
|
671
|
+
},
|
|
672
|
+
setFilter(patch) {
|
|
673
|
+
store.set((s) => ({ filter: { ...s.filter, ...patch } }));
|
|
674
|
+
void recompute();
|
|
675
|
+
},
|
|
676
|
+
select(memoryId) {
|
|
677
|
+
if (store.getState().selectedId === memoryId) return;
|
|
678
|
+
store.set({ selectedId: memoryId });
|
|
679
|
+
options.onSelect?.(memoryId);
|
|
680
|
+
},
|
|
681
|
+
async expandLineage(memoryId) {
|
|
682
|
+
const token = loads.next();
|
|
683
|
+
try {
|
|
684
|
+
const graph = await client.getLineage(scope, memoryId);
|
|
685
|
+
if (!loads.isCurrent(token) || disposed) return;
|
|
686
|
+
if (!graph) {
|
|
687
|
+
store.set({ lineage: null });
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
store.set({
|
|
691
|
+
lineage: {
|
|
692
|
+
rootId: memoryId,
|
|
693
|
+
memoryIds: new Set(graph.memories.map((m) => m.id)),
|
|
694
|
+
edges: graph.edges.filter((e) => e.relation !== "source")
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
const { mode } = store.getState();
|
|
698
|
+
if (mode === "clusters") store.set((s) => ({ filter: { ...s.filter, search: memoryId } }));
|
|
699
|
+
await recompute();
|
|
700
|
+
} catch (error) {
|
|
701
|
+
if (loads.isCurrent(token) && !disposed) store.set({ status: "error", error: errorText(error) });
|
|
702
|
+
}
|
|
703
|
+
},
|
|
704
|
+
collapseLineage() {
|
|
705
|
+
if (!store.getState().lineage) return;
|
|
706
|
+
store.set({ lineage: null });
|
|
707
|
+
void recompute();
|
|
708
|
+
},
|
|
709
|
+
expandCluster(clusterId) {
|
|
710
|
+
const cluster = store.getState().clusters.find((c) => c.id === clusterId);
|
|
711
|
+
if (!cluster || !cluster.key) return;
|
|
712
|
+
const current = store.getState().filter.search.trim();
|
|
713
|
+
controller.setFilter({ search: current ? `${current} ${cluster.key}` : cluster.key });
|
|
714
|
+
},
|
|
715
|
+
setSize(width, height) {
|
|
716
|
+
const { size, viewport } = store.getState();
|
|
717
|
+
if (size.width === width && size.height === height) return;
|
|
718
|
+
const first = size.width === 0 || size.height === 0;
|
|
719
|
+
const next = first ? viewport : panBy(viewport, (width - size.width) / 2, (height - size.height) / 2);
|
|
720
|
+
store.set({ size: { width, height }, viewport: next });
|
|
721
|
+
if (first && !userMoved) controller.fit();
|
|
722
|
+
},
|
|
723
|
+
panBy(dx, dy) {
|
|
724
|
+
userMoved = true;
|
|
725
|
+
store.set((s) => ({ viewport: panBy(s.viewport, dx, dy), hover: null }));
|
|
726
|
+
},
|
|
727
|
+
zoomAt(screen, factor) {
|
|
728
|
+
userMoved = true;
|
|
729
|
+
store.set((s) => ({ viewport: zoomAt(s.viewport, screen, factor), hover: null }));
|
|
730
|
+
},
|
|
731
|
+
fit() {
|
|
732
|
+
const { positions, size, mode, clusters, nodes } = store.getState();
|
|
733
|
+
const radius = new Map(
|
|
734
|
+
mode === "clusters" ? clusters.map((c) => [c.id, clusterRadius(c.count)]) : nodes.map((n) => [n.id, nodeRadius(n)])
|
|
735
|
+
);
|
|
736
|
+
userMoved = false;
|
|
737
|
+
store.set({ viewport: fitViewport(boundsOf([...positions].map(([id, p]) => ({ ...p, r: radius.get(id) ?? 6 }))), size) });
|
|
738
|
+
},
|
|
739
|
+
pick(screen) {
|
|
740
|
+
const { viewport, mode, clusters } = store.getState();
|
|
741
|
+
const hit = hits.pick(toWorld(viewport, screen), 4 / viewport.k);
|
|
742
|
+
if (!hit) return null;
|
|
743
|
+
if (mode === "clusters") {
|
|
744
|
+
const cluster = clusters.find((c) => c.id === hit.id);
|
|
745
|
+
return cluster ? { type: "cluster", id: cluster.id, key: cluster.key } : null;
|
|
746
|
+
}
|
|
747
|
+
return { type: "node", id: hit.id };
|
|
748
|
+
},
|
|
749
|
+
hover(screen) {
|
|
750
|
+
const previous = store.getState().hover;
|
|
751
|
+
const pick = screen ? controller.pick(screen) : null;
|
|
752
|
+
if (!pick) {
|
|
753
|
+
if (previous) store.set({ hover: null });
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
if (previous && previous.pick.id === pick.id) return;
|
|
757
|
+
const { viewport, positions } = store.getState();
|
|
758
|
+
const p = positions.get(pick.id);
|
|
759
|
+
store.set({ hover: { pick, x: p.x * viewport.k + viewport.x, y: p.y * viewport.k + viewport.y } });
|
|
760
|
+
},
|
|
761
|
+
dispose() {
|
|
762
|
+
disposed = true;
|
|
763
|
+
loads.cancel();
|
|
764
|
+
layouts.cancel();
|
|
765
|
+
}
|
|
766
|
+
};
|
|
767
|
+
if (options.autoload !== false) void controller.load();
|
|
768
|
+
return controller;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// src/controllers/lineage.ts
|
|
772
|
+
import { scopeOf as scopeOf2 } from "@memnest/core";
|
|
773
|
+
function layoutLineage(graph) {
|
|
774
|
+
const nodes = [
|
|
775
|
+
...graph.memories.map((m) => ({ id: m.id, r: nodeRadius(m) })),
|
|
776
|
+
...graph.documents.map((d) => ({ id: d.id, r: documentRadius }))
|
|
777
|
+
];
|
|
778
|
+
const positions = layeredLayout(nodes, graph.edges, { layerGap: 190, nodeGap: 96 });
|
|
779
|
+
const radius = new Map(nodes.map((n) => [n.id, n.r]));
|
|
780
|
+
return { positions, bounds: boundsOf([...positions].map(([id, p]) => ({ ...p, r: radius.get(id) + 90 }))) };
|
|
781
|
+
}
|
|
782
|
+
function createLineageController(options) {
|
|
783
|
+
const scope = scopeOf2(options.containerTag);
|
|
784
|
+
const store = createStore({ status: "idle", error: null, rootId: null, graph: null, positions: /* @__PURE__ */ new Map(), bounds: null });
|
|
785
|
+
const seq = createSequencer();
|
|
786
|
+
const controller = {
|
|
787
|
+
getState: store.getState,
|
|
788
|
+
subscribe: store.subscribe,
|
|
789
|
+
async load(memoryId) {
|
|
790
|
+
const token = seq.next();
|
|
791
|
+
store.set({ status: "loading", error: null, rootId: memoryId });
|
|
792
|
+
try {
|
|
793
|
+
const graph = await options.client.getLineage(scope, memoryId);
|
|
794
|
+
if (!seq.isCurrent(token)) return;
|
|
795
|
+
if (!graph) {
|
|
796
|
+
store.set({ status: "not-found", graph: null, positions: /* @__PURE__ */ new Map(), bounds: null });
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
store.set({ status: "ready", graph, ...layoutLineage(graph) });
|
|
800
|
+
} catch (error) {
|
|
801
|
+
if (seq.isCurrent(token)) store.set({ status: "error", error: errorText(error) });
|
|
802
|
+
}
|
|
803
|
+
},
|
|
804
|
+
async reload() {
|
|
805
|
+
const { rootId } = store.getState();
|
|
806
|
+
if (rootId) await controller.load(rootId);
|
|
807
|
+
},
|
|
808
|
+
clear() {
|
|
809
|
+
seq.cancel();
|
|
810
|
+
store.set({ status: "idle", error: null, rootId: null, graph: null, positions: /* @__PURE__ */ new Map(), bounds: null });
|
|
811
|
+
},
|
|
812
|
+
dispose: () => seq.cancel()
|
|
813
|
+
};
|
|
814
|
+
return controller;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// src/controllers/detail.ts
|
|
818
|
+
import { scopeOf as scopeOf3 } from "@memnest/core";
|
|
819
|
+
function versionChain(graph, rootId) {
|
|
820
|
+
const byId = new Map(graph.memories.map((m) => [m.id, m]));
|
|
821
|
+
const newerOf = /* @__PURE__ */ new Map();
|
|
822
|
+
for (const m of graph.memories) if (m.supersedes) newerOf.set(m.supersedes, m);
|
|
823
|
+
const root = byId.get(rootId);
|
|
824
|
+
if (!root) return [];
|
|
825
|
+
const chain = [root];
|
|
826
|
+
const seen = /* @__PURE__ */ new Set([root.id]);
|
|
827
|
+
for (let older = root.supersedes ? byId.get(root.supersedes) : void 0; older && !seen.has(older.id); older = older.supersedes ? byId.get(older.supersedes) : void 0) {
|
|
828
|
+
chain.unshift(older);
|
|
829
|
+
seen.add(older.id);
|
|
830
|
+
}
|
|
831
|
+
for (let newer = newerOf.get(root.id); newer && !seen.has(newer.id); newer = newerOf.get(newer.id)) {
|
|
832
|
+
chain.push(newer);
|
|
833
|
+
seen.add(newer.id);
|
|
834
|
+
}
|
|
835
|
+
return chain;
|
|
836
|
+
}
|
|
837
|
+
var EMPTY = {
|
|
838
|
+
status: "idle",
|
|
839
|
+
error: null,
|
|
840
|
+
memoryId: null,
|
|
841
|
+
memory: null,
|
|
842
|
+
versions: [],
|
|
843
|
+
extends: [],
|
|
844
|
+
extendedBy: [],
|
|
845
|
+
sources: [],
|
|
846
|
+
forget: "idle",
|
|
847
|
+
forgetError: null
|
|
848
|
+
};
|
|
849
|
+
function createDetailController(options) {
|
|
850
|
+
const { client } = options;
|
|
851
|
+
const scope = scopeOf3(options.containerTag);
|
|
852
|
+
const store = createStore(EMPTY);
|
|
853
|
+
const seq = createSequencer();
|
|
854
|
+
const updateSource = (documentId, patch) => store.set((s) => ({ sources: s.sources.map((source) => source.document.id === documentId ? { ...source, ...patch } : source) }));
|
|
855
|
+
const controller = {
|
|
856
|
+
getState: store.getState,
|
|
857
|
+
subscribe: store.subscribe,
|
|
858
|
+
async load(memoryId) {
|
|
859
|
+
const token = seq.next();
|
|
860
|
+
store.set({ ...EMPTY, status: "loading", memoryId });
|
|
861
|
+
try {
|
|
862
|
+
const [memory, graph] = await Promise.all([client.getMemory(scope, memoryId), client.getLineage(scope, memoryId)]);
|
|
863
|
+
if (!seq.isCurrent(token)) return;
|
|
864
|
+
if (!memory || !graph) {
|
|
865
|
+
store.set({ status: "not-found" });
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
const byId = new Map(graph.memories.map((m) => [m.id, m]));
|
|
869
|
+
const sourceIds = new Set(graph.edges.filter((e) => e.relation === "source" && e.from === memoryId).map((e) => e.to));
|
|
870
|
+
store.set({
|
|
871
|
+
status: "ready",
|
|
872
|
+
memory,
|
|
873
|
+
versions: versionChain(graph, memoryId),
|
|
874
|
+
extends: memory.extendsIds.flatMap((id) => byId.has(id) ? [byId.get(id)] : []),
|
|
875
|
+
extendedBy: graph.memories.filter((m) => m.extendsIds.includes(memoryId)),
|
|
876
|
+
sources: graph.documents.filter((d) => sourceIds.has(d.id)).map((document) => ({ document, content: null, chunks: null, loading: false, error: null }))
|
|
877
|
+
});
|
|
878
|
+
} catch (error) {
|
|
879
|
+
if (seq.isCurrent(token)) store.set({ status: "error", error: errorText(error) });
|
|
880
|
+
}
|
|
881
|
+
},
|
|
882
|
+
async reload() {
|
|
883
|
+
const { memoryId } = store.getState();
|
|
884
|
+
if (memoryId) await controller.load(memoryId);
|
|
885
|
+
},
|
|
886
|
+
clear() {
|
|
887
|
+
seq.cancel();
|
|
888
|
+
store.set(EMPTY);
|
|
889
|
+
},
|
|
890
|
+
async loadSource(documentId) {
|
|
891
|
+
const memoryId = store.getState().memoryId;
|
|
892
|
+
updateSource(documentId, { loading: true, error: null });
|
|
893
|
+
try {
|
|
894
|
+
const found = await client.getDocument(scope, documentId);
|
|
895
|
+
if (store.getState().memoryId !== memoryId) return;
|
|
896
|
+
updateSource(documentId, {
|
|
897
|
+
loading: false,
|
|
898
|
+
content: found?.document.content ?? null,
|
|
899
|
+
chunks: found?.chunks ?? [],
|
|
900
|
+
...found ? {} : { error: "document not found" }
|
|
901
|
+
});
|
|
902
|
+
} catch (error) {
|
|
903
|
+
if (store.getState().memoryId === memoryId) updateSource(documentId, { loading: false, error: errorText(error) });
|
|
904
|
+
}
|
|
905
|
+
},
|
|
906
|
+
requestForget() {
|
|
907
|
+
const { memory, forget } = store.getState();
|
|
908
|
+
if (!memory || memory.forgottenAt || forget !== "idle") return;
|
|
909
|
+
store.set({ forget: "confirming", forgetError: null });
|
|
910
|
+
},
|
|
911
|
+
cancelForget() {
|
|
912
|
+
if (store.getState().forget === "confirming") store.set({ forget: "idle" });
|
|
913
|
+
},
|
|
914
|
+
async confirmForget() {
|
|
915
|
+
const { memory, forget } = store.getState();
|
|
916
|
+
if (!memory || forget !== "confirming") return null;
|
|
917
|
+
store.set({ forget: "forgetting", forgetError: null });
|
|
918
|
+
try {
|
|
919
|
+
const forgotten = await client.forget(scope, memory.id);
|
|
920
|
+
if (store.getState().memoryId !== memory.id) return forgotten;
|
|
921
|
+
store.set((s) => ({
|
|
922
|
+
forget: "idle",
|
|
923
|
+
memory: forgotten,
|
|
924
|
+
versions: s.versions.map((v) => v.id === forgotten.id ? forgotten : v)
|
|
925
|
+
}));
|
|
926
|
+
options.onForgotten?.(forgotten);
|
|
927
|
+
return forgotten;
|
|
928
|
+
} catch (error) {
|
|
929
|
+
store.set({ forget: "confirming", forgetError: errorText(error) });
|
|
930
|
+
return null;
|
|
931
|
+
}
|
|
932
|
+
},
|
|
933
|
+
dispose: () => seq.cancel()
|
|
934
|
+
};
|
|
935
|
+
return controller;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// src/controllers/trace.ts
|
|
939
|
+
import { scopeOf as scopeOf4 } from "@memnest/core";
|
|
940
|
+
var DEFAULT_TRACE_BUDGET = 2e3;
|
|
941
|
+
function buildTraceRows(response, memories) {
|
|
942
|
+
let used = 0;
|
|
943
|
+
let budgetLine = null;
|
|
944
|
+
const rows = response.trace.candidates.map((candidate, index) => {
|
|
945
|
+
if (candidate.included) used += candidate.tokens;
|
|
946
|
+
if (budgetLine === null && candidate.excludedReason === "budget") budgetLine = index;
|
|
947
|
+
const memory = memories.get(candidate.memoryId);
|
|
948
|
+
return { ...candidate, content: memory?.content ?? null, kind: memory?.kind ?? null, cumulativeTokens: used };
|
|
949
|
+
});
|
|
950
|
+
return { rows, budgetLine };
|
|
951
|
+
}
|
|
952
|
+
function createTraceController(options) {
|
|
953
|
+
const { client } = options;
|
|
954
|
+
const scope = scopeOf4(options.containerTag);
|
|
955
|
+
const store = createStore({
|
|
956
|
+
query: "",
|
|
957
|
+
tokenBudget: options.tokenBudget ?? DEFAULT_TRACE_BUDGET,
|
|
958
|
+
status: "idle",
|
|
959
|
+
error: null,
|
|
960
|
+
response: null,
|
|
961
|
+
rows: [],
|
|
962
|
+
budgetLine: null,
|
|
963
|
+
chunkTokens: 0
|
|
964
|
+
});
|
|
965
|
+
const seq = createSequencer();
|
|
966
|
+
let lastRun = null;
|
|
967
|
+
async function execute(query, tokenBudget) {
|
|
968
|
+
const token = seq.next();
|
|
969
|
+
lastRun = { query, tokenBudget };
|
|
970
|
+
store.set({ status: "loading", error: null });
|
|
971
|
+
try {
|
|
972
|
+
const response = await client.search(query, scope, { tokenBudget });
|
|
973
|
+
const memories = new Map(response.memories.map((r) => [r.memory.id, r.memory]));
|
|
974
|
+
const missing = response.trace.candidates.map((c) => c.memoryId).filter((id) => !memories.has(id));
|
|
975
|
+
const fetched = await Promise.all(missing.map((id) => client.getMemory(scope, id)));
|
|
976
|
+
if (!seq.isCurrent(token)) return;
|
|
977
|
+
for (const memory of fetched) if (memory) memories.set(memory.id, memory);
|
|
978
|
+
store.set({
|
|
979
|
+
status: "ready",
|
|
980
|
+
response,
|
|
981
|
+
...buildTraceRows(response, memories),
|
|
982
|
+
chunkTokens: response.chunks.reduce((sum, c) => sum + c.tokens, 0)
|
|
983
|
+
});
|
|
984
|
+
} catch (error) {
|
|
985
|
+
if (seq.isCurrent(token)) store.set({ status: "error", error: errorText(error) });
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
return {
|
|
989
|
+
getState: store.getState,
|
|
990
|
+
subscribe: store.subscribe,
|
|
991
|
+
setQuery: (query) => store.set({ query }),
|
|
992
|
+
setTokenBudget: (tokenBudget) => store.set({ tokenBudget }),
|
|
993
|
+
async run() {
|
|
994
|
+
const { query, tokenBudget } = store.getState();
|
|
995
|
+
if (!query.trim()) return;
|
|
996
|
+
await execute(query, tokenBudget);
|
|
997
|
+
},
|
|
998
|
+
async rerun() {
|
|
999
|
+
if (lastRun) await execute(lastRun.query, lastRun.tokenBudget);
|
|
1000
|
+
},
|
|
1001
|
+
dispose: () => seq.cancel()
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// src/controllers/timeline.ts
|
|
1006
|
+
import { scopeOf as scopeOf5 } from "@memnest/core";
|
|
1007
|
+
var DAY = 864e5;
|
|
1008
|
+
var iso = (ms) => new Date(ms).toISOString();
|
|
1009
|
+
var earliest = (...values) => values.filter((v) => typeof v === "string").sort()[0] ?? null;
|
|
1010
|
+
function buildTimeline(memories, now) {
|
|
1011
|
+
const byId = new Map(memories.map((m) => [m.id, m]));
|
|
1012
|
+
const newer = /* @__PURE__ */ new Map();
|
|
1013
|
+
for (const m of memories) if (m.supersedes && byId.has(m.supersedes)) newer.set(m.supersedes, m);
|
|
1014
|
+
const laneOf = /* @__PURE__ */ new Map();
|
|
1015
|
+
const rootOf = (m) => {
|
|
1016
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1017
|
+
let current = m;
|
|
1018
|
+
while (current.supersedes && byId.has(current.supersedes) && !seen.has(current.id)) {
|
|
1019
|
+
seen.add(current.id);
|
|
1020
|
+
current = byId.get(current.supersedes);
|
|
1021
|
+
}
|
|
1022
|
+
return current.id;
|
|
1023
|
+
};
|
|
1024
|
+
for (const m of memories) laneOf.set(m.id, rootOf(m));
|
|
1025
|
+
const items = memories.map((memory) => {
|
|
1026
|
+
const replacement = newer.get(memory.id);
|
|
1027
|
+
const supersededAt = replacement ? earliest(replacement.validFrom, replacement.createdAt) : null;
|
|
1028
|
+
const expiredAt = memory.validUntil && memory.validUntil <= now ? memory.validUntil : null;
|
|
1029
|
+
const endedAt = earliest(supersededAt, expiredAt, memory.forgottenAt);
|
|
1030
|
+
const status = endedAt === null ? memory.isLatest ? "current" : "superseded" : endedAt === memory.forgottenAt ? "forgotten" : endedAt === supersededAt ? "superseded" : "expired";
|
|
1031
|
+
return {
|
|
1032
|
+
memory,
|
|
1033
|
+
laneId: laneOf.get(memory.id),
|
|
1034
|
+
start: memory.validFrom,
|
|
1035
|
+
// A current fact with a future expiry shows where it will end.
|
|
1036
|
+
end: endedAt ?? memory.validUntil ?? null,
|
|
1037
|
+
status,
|
|
1038
|
+
supersededBy: replacement?.id ?? null
|
|
1039
|
+
};
|
|
1040
|
+
});
|
|
1041
|
+
const lanes = /* @__PURE__ */ new Map();
|
|
1042
|
+
for (const item of items) (lanes.get(item.laneId) ?? lanes.set(item.laneId, []).get(item.laneId)).push(item);
|
|
1043
|
+
const laneList = [...lanes.entries()].map(([id, laneItems]) => {
|
|
1044
|
+
laneItems.sort((a, z) => a.start < z.start ? -1 : a.start > z.start ? 1 : a.memory.version - z.memory.version);
|
|
1045
|
+
return { id, label: shorten(laneItems.at(-1).memory.content, 60), items: laneItems };
|
|
1046
|
+
}).sort((a, z) => a.items[0].start < z.items[0].start ? -1 : 1);
|
|
1047
|
+
if (items.length === 0) return { lanes: [], range: null, ticks: [] };
|
|
1048
|
+
const start = Date.parse(items.map((i) => i.start).sort()[0]);
|
|
1049
|
+
const lastEnd = Math.max(Date.parse(now), ...items.map((i) => Date.parse(i.end ?? now)));
|
|
1050
|
+
const span = Math.max(lastEnd - start, DAY);
|
|
1051
|
+
const range = { start: iso(start - span * 0.03), end: iso(start + span * 1.03) };
|
|
1052
|
+
return { lanes: laneList, range, ticks: timeTicks(range.start, range.end) };
|
|
1053
|
+
}
|
|
1054
|
+
function timeTicks(start, end, count = 6) {
|
|
1055
|
+
const from = Date.parse(start);
|
|
1056
|
+
const to = Date.parse(end);
|
|
1057
|
+
const span = Math.max(to - from, 1);
|
|
1058
|
+
const withTime = span < 3 * DAY;
|
|
1059
|
+
const crossesYear = new Date(from).getUTCFullYear() !== new Date(to).getUTCFullYear();
|
|
1060
|
+
const format = new Intl.DateTimeFormat("en", {
|
|
1061
|
+
timeZone: "UTC",
|
|
1062
|
+
month: "short",
|
|
1063
|
+
day: "numeric",
|
|
1064
|
+
...withTime ? { hour: "2-digit", minute: "2-digit", hourCycle: "h23" } : crossesYear ? { year: "numeric" } : {}
|
|
1065
|
+
});
|
|
1066
|
+
return Array.from({ length: count }, (_, i) => {
|
|
1067
|
+
const at = from + span * i / (count - 1);
|
|
1068
|
+
return { at: iso(at), label: format.format(at) };
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
function createTimelineController(options) {
|
|
1072
|
+
const { client } = options;
|
|
1073
|
+
const scope = scopeOf5(options.containerTag);
|
|
1074
|
+
const now = options.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
|
|
1075
|
+
const store = createStore({ topic: "", status: "idle", error: null, lanes: [], range: null, ticks: [] });
|
|
1076
|
+
const seq = createSequencer();
|
|
1077
|
+
let lastTopic = null;
|
|
1078
|
+
async function execute(topic) {
|
|
1079
|
+
const token = seq.next();
|
|
1080
|
+
lastTopic = topic;
|
|
1081
|
+
store.set({ status: "loading", error: null });
|
|
1082
|
+
try {
|
|
1083
|
+
const response = await client.search(topic, scope, { tokenBudget: 1, candidates: 30 });
|
|
1084
|
+
const found = new Map(response.memories.map((r) => [r.memory.id, r.memory]));
|
|
1085
|
+
const ids = response.trace.candidates.map((c) => c.memoryId);
|
|
1086
|
+
for (const memory of await Promise.all(ids.filter((id) => !found.has(id)).map((id) => client.getMemory(scope, id)))) {
|
|
1087
|
+
if (memory) found.set(memory.id, memory);
|
|
1088
|
+
}
|
|
1089
|
+
const chainRoots = [...found.values()].filter((m) => m.supersedes || !m.isLatest).slice(0, 20);
|
|
1090
|
+
for (const graph of await Promise.all(chainRoots.map((m) => client.getLineage(scope, m.id)))) {
|
|
1091
|
+
for (const m of graph?.memories ?? []) {
|
|
1092
|
+
if (graph.edges.some((e) => e.relation === "updates" && (e.from === m.id || e.to === m.id))) found.set(m.id, m);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
if (!seq.isCurrent(token)) return;
|
|
1096
|
+
store.set({ status: "ready", ...buildTimeline([...found.values()], now()) });
|
|
1097
|
+
} catch (error) {
|
|
1098
|
+
if (seq.isCurrent(token)) store.set({ status: "error", error: errorText(error) });
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
return {
|
|
1102
|
+
getState: store.getState,
|
|
1103
|
+
subscribe: store.subscribe,
|
|
1104
|
+
setTopic: (topic) => store.set({ topic }),
|
|
1105
|
+
async run() {
|
|
1106
|
+
const { topic } = store.getState();
|
|
1107
|
+
if (topic.trim()) await execute(topic);
|
|
1108
|
+
},
|
|
1109
|
+
async rerun() {
|
|
1110
|
+
if (lastTopic !== null) await execute(lastTopic);
|
|
1111
|
+
},
|
|
1112
|
+
dispose: () => seq.cancel()
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// src/controllers/finder.ts
|
|
1117
|
+
import { scopeOf as scopeOf6 } from "@memnest/core";
|
|
1118
|
+
var PAGE = 50;
|
|
1119
|
+
function createFinderController(options) {
|
|
1120
|
+
const { client } = options;
|
|
1121
|
+
const scope = scopeOf6(options.containerTag);
|
|
1122
|
+
const store = createStore({ query: "", includeHistory: false, status: "idle", error: null, items: [], hasMore: false });
|
|
1123
|
+
const seq = createSequencer();
|
|
1124
|
+
const visible = (m, includeHistory) => includeHistory || m.isLatest && !m.forgottenAt;
|
|
1125
|
+
async function search(query, includeHistory, token) {
|
|
1126
|
+
const response = await client.search(query, scope, { tokenBudget: 1e6, candidates: PAGE });
|
|
1127
|
+
const found = new Map(response.memories.map((r) => [r.memory.id, r.memory]));
|
|
1128
|
+
const ids = response.trace.candidates.map((c) => c.memoryId);
|
|
1129
|
+
const missing = ids.filter((id) => !found.has(id));
|
|
1130
|
+
for (const memory of await Promise.all(missing.map((id) => client.getMemory(scope, id)))) if (memory) found.set(memory.id, memory);
|
|
1131
|
+
if (!seq.isCurrent(token)) return;
|
|
1132
|
+
store.set({ status: "ready", hasMore: false, items: ids.flatMap((id) => found.has(id) && visible(found.get(id), includeHistory) ? [found.get(id)] : []) });
|
|
1133
|
+
}
|
|
1134
|
+
async function browse(includeHistory, token, after) {
|
|
1135
|
+
const page = await client.listMemories(scope, { limit: PAGE, ...after ? { after } : {} }, { latestOnly: !includeHistory, includeForgotten: includeHistory });
|
|
1136
|
+
if (!seq.isCurrent(token)) return;
|
|
1137
|
+
store.set((s) => ({ status: "ready", items: after ? [...s.items, ...page] : page, hasMore: page.length === PAGE }));
|
|
1138
|
+
}
|
|
1139
|
+
return {
|
|
1140
|
+
getState: store.getState,
|
|
1141
|
+
subscribe: store.subscribe,
|
|
1142
|
+
setQuery: (query) => store.set({ query }),
|
|
1143
|
+
setIncludeHistory: (includeHistory) => store.set({ includeHistory }),
|
|
1144
|
+
async run() {
|
|
1145
|
+
const token = seq.next();
|
|
1146
|
+
const { query, includeHistory } = store.getState();
|
|
1147
|
+
store.set({ status: "loading", error: null });
|
|
1148
|
+
try {
|
|
1149
|
+
if (query.trim()) await search(query, includeHistory, token);
|
|
1150
|
+
else await browse(includeHistory, token);
|
|
1151
|
+
} catch (error) {
|
|
1152
|
+
if (seq.isCurrent(token)) store.set({ status: "error", error: errorText(error) });
|
|
1153
|
+
}
|
|
1154
|
+
},
|
|
1155
|
+
async loadMore() {
|
|
1156
|
+
const { items, hasMore, includeHistory, query, status } = store.getState();
|
|
1157
|
+
if (!hasMore || query.trim() || status === "loading" || items.length === 0) return;
|
|
1158
|
+
const token = seq.next();
|
|
1159
|
+
store.set({ status: "loading" });
|
|
1160
|
+
try {
|
|
1161
|
+
await browse(includeHistory, token, items.at(-1).id);
|
|
1162
|
+
} catch (error) {
|
|
1163
|
+
if (seq.isCurrent(token)) store.set({ status: "error", error: errorText(error) });
|
|
1164
|
+
}
|
|
1165
|
+
},
|
|
1166
|
+
dispose: () => seq.cancel()
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// src/workspace.ts
|
|
1171
|
+
function createWorkspace(options) {
|
|
1172
|
+
const { client, containerTag } = options;
|
|
1173
|
+
const selection = createStore({ memoryId: null });
|
|
1174
|
+
let workspace;
|
|
1175
|
+
const onForgotten = (_memory) => {
|
|
1176
|
+
void Promise.all([
|
|
1177
|
+
workspace.finder.run(),
|
|
1178
|
+
workspace.trace.rerun(),
|
|
1179
|
+
workspace.timeline.rerun(),
|
|
1180
|
+
workspace.graph.load(),
|
|
1181
|
+
workspace.lineage.reload()
|
|
1182
|
+
]);
|
|
1183
|
+
};
|
|
1184
|
+
const select = (memoryId) => {
|
|
1185
|
+
if (selection.getState().memoryId === memoryId) return;
|
|
1186
|
+
selection.set({ memoryId });
|
|
1187
|
+
workspace.graph.select(memoryId);
|
|
1188
|
+
if (memoryId) {
|
|
1189
|
+
void workspace.detail.load(memoryId);
|
|
1190
|
+
void workspace.lineage.load(memoryId);
|
|
1191
|
+
} else {
|
|
1192
|
+
workspace.detail.clear();
|
|
1193
|
+
workspace.lineage.clear();
|
|
1194
|
+
}
|
|
1195
|
+
};
|
|
1196
|
+
workspace = {
|
|
1197
|
+
containerTag,
|
|
1198
|
+
selection,
|
|
1199
|
+
select,
|
|
1200
|
+
finder: createFinderController({ client, containerTag }),
|
|
1201
|
+
detail: createDetailController({ client, containerTag, onForgotten }),
|
|
1202
|
+
lineage: createLineageController({ client, containerTag }),
|
|
1203
|
+
trace: createTraceController({ client, containerTag }),
|
|
1204
|
+
timeline: createTimelineController({ client, containerTag, ...options.now ? { now: options.now } : {} }),
|
|
1205
|
+
graph: createGraphController({
|
|
1206
|
+
client,
|
|
1207
|
+
containerTag,
|
|
1208
|
+
...options.graph,
|
|
1209
|
+
...options.layout ? { layout: options.layout } : {},
|
|
1210
|
+
onSelect: (memoryId) => select(memoryId)
|
|
1211
|
+
}),
|
|
1212
|
+
async refresh() {
|
|
1213
|
+
await Promise.all([
|
|
1214
|
+
workspace.finder.run(),
|
|
1215
|
+
workspace.trace.rerun(),
|
|
1216
|
+
workspace.timeline.rerun(),
|
|
1217
|
+
workspace.graph.load(),
|
|
1218
|
+
workspace.detail.reload(),
|
|
1219
|
+
workspace.lineage.reload()
|
|
1220
|
+
]);
|
|
1221
|
+
},
|
|
1222
|
+
dispose() {
|
|
1223
|
+
for (const controller of [workspace.finder, workspace.detail, workspace.lineage, workspace.trace, workspace.timeline, workspace.graph]) {
|
|
1224
|
+
controller.dispose();
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
};
|
|
1228
|
+
return workspace;
|
|
1229
|
+
}
|
|
1230
|
+
export {
|
|
1231
|
+
CLUSTER_THRESHOLD,
|
|
1232
|
+
DEFAULT_GRAPH_FILTER,
|
|
1233
|
+
DEFAULT_TRACE_BUDGET,
|
|
1234
|
+
GRAPH_LOAD_LIMIT,
|
|
1235
|
+
IDENTITY_VIEWPORT,
|
|
1236
|
+
WORKER_LAYOUT_THRESHOLD,
|
|
1237
|
+
ZOOM_LIMITS,
|
|
1238
|
+
boundsOf,
|
|
1239
|
+
buildGraphScene,
|
|
1240
|
+
buildTimeline,
|
|
1241
|
+
buildTraceRows,
|
|
1242
|
+
clamp,
|
|
1243
|
+
clusterNodes,
|
|
1244
|
+
clusterRadius,
|
|
1245
|
+
computeLayout,
|
|
1246
|
+
createDetailController,
|
|
1247
|
+
createFinderController,
|
|
1248
|
+
createGraphController,
|
|
1249
|
+
createHitIndex,
|
|
1250
|
+
createLineageController,
|
|
1251
|
+
createSequencer,
|
|
1252
|
+
createStore,
|
|
1253
|
+
createTimelineController,
|
|
1254
|
+
createTraceController,
|
|
1255
|
+
createWorkerLayoutRunner,
|
|
1256
|
+
createWorkspace,
|
|
1257
|
+
documentRadius,
|
|
1258
|
+
drawScene,
|
|
1259
|
+
fitViewport,
|
|
1260
|
+
forceLayout,
|
|
1261
|
+
inlineLayoutRunner,
|
|
1262
|
+
layeredLayout,
|
|
1263
|
+
layoutLineage,
|
|
1264
|
+
matchesFilter,
|
|
1265
|
+
nodeRadius,
|
|
1266
|
+
panBy,
|
|
1267
|
+
runLayoutRequest,
|
|
1268
|
+
seededRandom,
|
|
1269
|
+
serveLayoutRequests,
|
|
1270
|
+
shorten,
|
|
1271
|
+
timeTicks,
|
|
1272
|
+
toScreen,
|
|
1273
|
+
toWorld,
|
|
1274
|
+
versionChain,
|
|
1275
|
+
zoomAt
|
|
1276
|
+
};
|
|
1277
|
+
//# sourceMappingURL=index.js.map
|