dsh-memory-engine 0.8.0 → 0.8.1
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/README.md +20 -6
- package/lib/client.js +112 -22
- package/lib/client.src.js +1 -1
- package/lib/config-write.js +5 -1
- package/lib/config.js +3 -1
- package/lib/render/canvas.js +10 -4
- package/lib/render/layout.mjs +101 -17
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# DSH 记忆引擎
|
|
2
2
|
|
|
3
|
-
[](https://www.npmjs.com/package/dsh-memory-engine)
|
|
4
4
|
[](LICENSE)
|
|
5
|
-
[](test)
|
|
6
6
|
|
|
7
7
|
DSH 插件的本地记忆引擎,带神经网络可视化。它让 agent 保存、召回和管理跨会话记忆,也能把你已有的记忆文件导进来。
|
|
8
8
|
|
|
@@ -11,18 +11,32 @@ DSH 插件的本地记忆引擎,带神经网络可视化。它让 agent 保存
|
|
|
11
11
|
|
|
12
12
|

|
|
13
13
|
|
|
14
|
-
当前版本:0.8.
|
|
14
|
+
当前版本:0.8.1。Node ≥22,零新增依赖。四套配色与球体、星系、大脑神经网络三种布局自由组合,默认保留原来的经典星空与球体。自动记忆默认关闭,由用户在图谱头部或设置页手动打开;开启后每轮对话会额外调用一次模型,可能产生费用。自动导入默认只收聊天会话的用户消息,支持来源白名单、工作指令过滤、流程话过滤、规则分类、错误原因、一次性重扫和账本清理。
|
|
15
15
|
|
|
16
|
-
##
|
|
16
|
+
## 安装(重要)
|
|
17
|
+
|
|
18
|
+
**请指定版本号安装。** pnpm 可能因为本地缓存的包元数据解析到旧版本(实测 `pnpm add dsh-memory-engine` 会装到 0.7.5):
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pnpm add dsh-memory-engine@0.8.1
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
装完重启 DSH,标题栏出现「🧠 记忆图谱」。
|
|
25
|
+
|
|
26
|
+
0.7.5 及更早版本存在 `memory_map` 参数名撞 JSON Schema 保留字的问题,调用时整轮返回 400,请勿使用。
|
|
27
|
+
|
|
28
|
+
## 0.8.1 图谱外观
|
|
17
29
|
|
|
18
30
|
图谱头部点击「外观」,可组合选择四套配色与三种布局;「设置」页也有同一组选项。点选立即应用并保存,刷新后保留;保存失败会回滚并显示原因。「恢复默认」同时恢复经典星空和球体。
|
|
19
31
|
|
|
20
32
|
| 维度 | 选项 |
|
|
21
33
|
| --- | --- |
|
|
22
34
|
| 配色 | 经典星空 `classic` / 赛博霓虹 `neon` / 铅绘素描 `paper` / 极简单色 `mono` |
|
|
23
|
-
| 布局 | 球体 `sphere` /
|
|
35
|
+
| 布局 | 球体 `sphere` / 星系 `galaxy` / 大脑神经网络 `brain` |
|
|
36
|
+
|
|
37
|
+
铅绘素描采用素纸底色、石墨灰节点和极淡光晕。星系有密集核心、2~4 条带抖动的旋臂和 30% 星尘;大脑神经网络有不对称的左右半球、每半球 12~14 个簇心、5% 游离节点和表面起伏。固定种子与节点 id 哈希保证相同数据的位置完全一致。三种布局都保留中心区域角色卡、旋转、缩放和点击详情;大脑角色卡位于纵裂中间偏下。切换时保留已选节点、相机及动画状态,不重新请求记忆数据,不改变记忆的重要度、留存或节点尺寸算法。星系仅将选中关联线的透明度和宽度降到球体的 80%,避免密集连线发白。
|
|
24
38
|
|
|
25
|
-
|
|
39
|
+
0.8.0 的旧配置自动兼容:`flat` 按 `galaxy` 读取,`spiral` 按 `brain` 读取,不需要手动改文件,也不在读取时写盘。旧客户端提交这两个旧值也会被接受,并通过原有备份和原子写保存为新值。界面只显示球体、星系、大脑神经网络三种选项。
|
|
26
40
|
|
|
27
41
|
在现有配置中可合并 `"graphTheme": "classic"` 和 `"graphLayout": "sphere"`。这两个字段通过现有配置接口和备份、原子写流程保存,非法值返回 400。未配置时外观与升级前相同;没有角色卡的库仍保留原来的默认脑状布局。CLI 生成的离线图谱也读取同一配置。
|
|
28
42
|
|
package/lib/client.js
CHANGED
|
@@ -54,7 +54,9 @@ window.__ModuleLoader__.load({
|
|
|
54
54
|
if (!Object.hasOwn(THEMES, themeName)) throw new Error('未知图谱配色');
|
|
55
55
|
let theme = THEMES[themeName];
|
|
56
56
|
let layoutName = options.graphLayout ?? data.graphLayout ?? 'sphere';
|
|
57
|
-
if (
|
|
57
|
+
if (layoutName === 'flat') layoutName = 'galaxy';
|
|
58
|
+
if (layoutName === 'spiral') layoutName = 'brain';
|
|
59
|
+
if (!['sphere', 'galaxy', 'brain'].includes(layoutName)) throw new Error('未知图谱布局');
|
|
58
60
|
let sphereNodes = [];
|
|
59
61
|
const layouts = new Map();
|
|
60
62
|
let nodes = [], links = [], hubs = [], linkTwinkle = [], visibleLinks = [];
|
|
@@ -102,7 +104,9 @@ function glowSprite(rgb) {
|
|
|
102
104
|
|
|
103
105
|
function setLayout(nextLayout) {
|
|
104
106
|
if (destroyed) return;
|
|
105
|
-
if (
|
|
107
|
+
if (nextLayout === 'flat') nextLayout = 'galaxy';
|
|
108
|
+
if (nextLayout === 'spiral') nextLayout = 'brain';
|
|
109
|
+
if (!['sphere', 'galaxy', 'brain'].includes(nextLayout)) throw new Error('未知图谱布局');
|
|
106
110
|
if (layoutName === nextLayout && layouts.has(nextLayout)) return;
|
|
107
111
|
if (!layouts.has(nextLayout)) {
|
|
108
112
|
if (typeof options.layoutNodes !== 'function') throw new Error('缺少图谱布局函数');
|
|
@@ -327,20 +331,22 @@ function frame(now) {
|
|
|
327
331
|
}
|
|
328
332
|
}
|
|
329
333
|
if (linkPaths.length) {
|
|
334
|
+
if (layoutName === 'galaxy') ctx.globalAlpha=0.8;
|
|
330
335
|
if (Array.isArray(theme.link)) {
|
|
331
336
|
const gradient = ctx.createLinearGradient(0,0,width,height);
|
|
332
337
|
gradient.addColorStop(0,theme.link[0]);
|
|
333
338
|
gradient.addColorStop(1,theme.link[1]);
|
|
334
339
|
ctx.strokeStyle=gradient;
|
|
335
340
|
} else ctx.strokeStyle=theme.link;
|
|
336
|
-
ctx.lineWidth=1;
|
|
341
|
+
ctx.lineWidth=layoutName === 'galaxy' ? 0.8 : 1;
|
|
337
342
|
ctx.beginPath();
|
|
338
343
|
for (const p of linkPaths) { ctx.moveTo(p[0],p[1]); ctx.quadraticCurveTo(p[2],p[3],p[4],p[5]); }
|
|
339
344
|
ctx.stroke();
|
|
340
345
|
// 更亮的一层,让关联线看得清
|
|
341
346
|
ctx.strokeStyle=theme.linkHighlight;
|
|
342
|
-
ctx.lineWidth=2;
|
|
347
|
+
ctx.lineWidth=layoutName === 'galaxy' ? 1.6 : 2;
|
|
343
348
|
ctx.stroke();
|
|
349
|
+
if (layoutName === 'galaxy') ctx.globalAlpha=1;
|
|
344
350
|
}
|
|
345
351
|
for (let index=pulses.length-1;index>=0;index--) {
|
|
346
352
|
const pulse=pulses[index],fraction=(now-pulse.born)/pulse.life;
|
|
@@ -473,7 +479,9 @@ function frame(now) {
|
|
|
473
479
|
|
|
474
480
|
|
|
475
481
|
function layoutPositions(nodes, layout = 'sphere') {
|
|
476
|
-
if (
|
|
482
|
+
if (layout === 'flat') layout = 'galaxy';
|
|
483
|
+
if (layout === 'spiral') layout = 'brain';
|
|
484
|
+
if (!['sphere', 'galaxy', 'brain'].includes(layout)) throw new Error('未知图谱布局');
|
|
477
485
|
if (layout === 'sphere') return nodes.map(node => ({ ...node, pos: { ...node.pos } }));
|
|
478
486
|
const memories = nodes.filter(node => !node.isCore).sort((left, right) =>
|
|
479
487
|
(right.degree || 0) - (left.degree || 0) || (String(left.id) < String(right.id) ? -1 : String(left.id) > String(right.id) ? 1 : 0));
|
|
@@ -484,25 +492,107 @@ function frame(now) {
|
|
|
484
492
|
hash = Math.imul(hash ^ (hash >>> 16), 0x45d9f3b);
|
|
485
493
|
return ((hash ^ (hash >>> 16)) >>> 0) / 4294967295 * 2 - 1;
|
|
486
494
|
}
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
495
|
+
let datasetSeed = 20261030;
|
|
496
|
+
for (const id of memories.map(node => String(node.id)).sort()) {
|
|
497
|
+
for (const character of id + '\u0000') datasetSeed = Math.imul(datasetSeed ^ character.codePointAt(0), 16777619);
|
|
498
|
+
}
|
|
499
|
+
const uniform = (id, axis) => Math.max(1e-9, Math.min(1 - 1e-9, (jitter(datasetSeed + ':' + id, axis) + 1) / 2));
|
|
500
|
+
const gaussian = (id, axis) => Math.sqrt(-2 * Math.log(uniform(id, axis + ':radius'))) * Math.cos(2 * Math.PI * uniform(id, axis + ':angle'));
|
|
501
|
+
if (layout === 'galaxy') {
|
|
502
|
+
const armCount = 2 + Math.floor(uniform('galaxy', 'arms') * 3);
|
|
503
|
+
const rotation = uniform('galaxy', 'rotation') * Math.PI * 2;
|
|
504
|
+
const coreCount = Math.ceil(memories.length * 0.15);
|
|
505
|
+
const haloCount = Math.floor(memories.length * 0.05);
|
|
506
|
+
const diskEnd = memories.length - haloCount;
|
|
507
|
+
const dustCount = Math.round(memories.length * 0.30);
|
|
508
|
+
const dust = new Set(memories.slice(coreCount, diskEnd)
|
|
509
|
+
.sort((left, right) => uniform(left.id, 'dust') - uniform(right.id, 'dust') || (String(left.id) < String(right.id) ? -1 : String(left.id) > String(right.id) ? 1 : 0))
|
|
510
|
+
.slice(0, Math.max(0, dustCount - haloCount)).map(node => node.id));
|
|
498
511
|
memories.forEach((node, index) => {
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
512
|
+
let radius, angle;
|
|
513
|
+
if (index < coreCount) {
|
|
514
|
+
radius = 6 + Math.sqrt(uniform(node.id, 'core-radius')) * 16;
|
|
515
|
+
angle = uniform(node.id, 'core-angle') * Math.PI * 2;
|
|
516
|
+
} else if (index >= diskEnd) {
|
|
517
|
+
radius = 95 + Math.sqrt(uniform(node.id, 'halo-radius')) * 35;
|
|
518
|
+
angle = uniform(node.id, 'halo-angle') * Math.PI * 2;
|
|
519
|
+
} else if (dust.has(node.id)) {
|
|
520
|
+
radius = 22 + Math.sqrt(uniform(node.id, 'dust-radius')) * 73;
|
|
521
|
+
angle = uniform(node.id, 'dust-angle') * Math.PI * 2;
|
|
522
|
+
} else {
|
|
523
|
+
const fraction = (index - coreCount + 0.5) / Math.max(1, diskEnd - coreCount);
|
|
524
|
+
const offset = fraction * 73 + gaussian(node.id, 'radius') * 9;
|
|
525
|
+
const folded = ((offset % 146) + 146) % 146;
|
|
526
|
+
radius = 22 + (folded <= 73 ? folded : 146 - folded);
|
|
527
|
+
const arm = Math.floor(uniform(node.id, 'arm') * armCount);
|
|
528
|
+
const base = rotation + arm * Math.PI * 2 / armCount + (uniform(arm, 'arm-offset') - 0.5) * 0.35;
|
|
529
|
+
const twist = (radius - 18) / 95 * 2.2 * Math.PI;
|
|
530
|
+
const ripple = Math.sin(radius * 0.055 + uniform(arm, 'phase') * Math.PI * 2) * 0.10;
|
|
531
|
+
angle = base + twist + ripple + gaussian(node.id, 'angle') * 0.30;
|
|
532
|
+
}
|
|
533
|
+
const thickness = 8 / (1 + radius / 40);
|
|
534
|
+
const height = Math.max(-1, Math.min(1, gaussian(node.id, 'height') * 0.5)) * thickness;
|
|
535
|
+
positions.set(node.id, { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius, z: height });
|
|
536
|
+
});
|
|
537
|
+
} else {
|
|
538
|
+
function brainPoint(id, axis, extent = 1) {
|
|
539
|
+
const horizontal = gaussian(id, axis + ':x');
|
|
540
|
+
const vertical = gaussian(id, axis + ':y');
|
|
541
|
+
const depth = gaussian(id, axis + ':z');
|
|
542
|
+
const scale = Math.cbrt(uniform(id, axis + ':distance')) * extent / Math.hypot(horizontal, vertical, depth);
|
|
543
|
+
return { x: 42 + horizontal * scale * 40, y: vertical * scale * 106, z: depth * scale * 52 };
|
|
544
|
+
}
|
|
545
|
+
const centers = [0, 1].map(side => {
|
|
546
|
+
const count = 12 + Math.floor(uniform('brain:' + side, 'clusters') * 3);
|
|
547
|
+
const candidates = Array.from({ length: 96 }, (_, index) => {
|
|
548
|
+
const id = 'brain:' + side + ':' + index;
|
|
549
|
+
const point = brainPoint(id, 'center', 0.82 + uniform(id, 'surface') * 0.12);
|
|
550
|
+
return { ...point, spread: 6 + uniform(id, 'spread') * 8, phase: uniform(id, 'phase') * Math.PI * 2 };
|
|
551
|
+
});
|
|
552
|
+
const interior = candidates.reduce((best, point, index) => Math.hypot(point.x - 42, point.y, point.z)
|
|
553
|
+
< Math.hypot(candidates[best].x - 42, candidates[best].y, candidates[best].z) ? index : best, 0);
|
|
554
|
+
const selected = [candidates.splice(interior, 1)[0]];
|
|
555
|
+
while (selected.length < count) {
|
|
556
|
+
let best = 0, greatest = -Infinity;
|
|
557
|
+
candidates.forEach((point, index) => {
|
|
558
|
+
const distance = Math.min(...selected.map(center => Math.hypot(point.x - center.x, point.y - center.y, (point.z - center.z) * 0.7)));
|
|
559
|
+
if (distance > greatest) { greatest = distance; best = index; }
|
|
560
|
+
});
|
|
561
|
+
selected.push(candidates.splice(best, 1)[0]);
|
|
562
|
+
}
|
|
563
|
+
return selected;
|
|
564
|
+
});
|
|
565
|
+
const roamers = new Set([...memories].sort((left, right) => uniform(left.id, 'roaming') - uniform(right.id, 'roaming')
|
|
566
|
+
|| (String(left.id) < String(right.id) ? -1 : String(left.id) > String(right.id) ? 1 : 0))
|
|
567
|
+
.slice(0, Math.round(memories.length * 0.05)).map(node => node.id));
|
|
568
|
+
memories.forEach(node => {
|
|
569
|
+
const side = uniform(node.id, 'hemisphere') < 0.5 ? 0 : 1;
|
|
570
|
+
const target = brainPoint(node.id, 'target');
|
|
571
|
+
let center, nearest = Infinity;
|
|
572
|
+
centers[side].forEach((candidate, index) => {
|
|
573
|
+
const distance = Math.hypot(target.x - candidate.x, target.y - candidate.y, target.z - candidate.z)
|
|
574
|
+
* (0.8 + uniform(node.id, 'weight:' + index) * 0.4);
|
|
575
|
+
if (distance < nearest) { nearest = distance; center = candidate; }
|
|
576
|
+
});
|
|
577
|
+
const point = roamers.has(node.id) ? target : {
|
|
578
|
+
x: center.x + gaussian(node.id, 'scatter:x') * center.spread,
|
|
579
|
+
y: center.y + gaussian(node.id, 'scatter:y') * center.spread,
|
|
580
|
+
z: center.z + gaussian(node.id, 'scatter:z') * center.spread,
|
|
581
|
+
};
|
|
582
|
+
point.x += Math.sin(point.y * 0.08 + center.phase) * 6;
|
|
583
|
+
point.z += Math.sin(point.y * 0.09 + center.phase) * 4;
|
|
584
|
+
point.y += Math.sin(point.z * 0.12 + center.phase) * 3;
|
|
585
|
+
const envelope = 0.96 + 0.06 * Math.sin(point.y * 0.08) + 0.035 * Math.cos(point.z * 0.11 + side);
|
|
586
|
+
const distance = Math.hypot((point.x - 42) / 40, point.y / 106, point.z / 52);
|
|
587
|
+
if (distance > envelope) {
|
|
588
|
+
const scale = envelope * (0.93 + uniform(node.id, 'edge') * 0.06) / distance;
|
|
589
|
+
point.x = 42 + (point.x - 42) * scale; point.y *= scale; point.z *= scale;
|
|
590
|
+
}
|
|
591
|
+
positions.set(node.id, { x: (side ? 1 : -1) * (4 + Math.abs(point.x)) + (side ? 2 : 0),
|
|
592
|
+
y: point.y - (side ? 1 : 0), z: point.z });
|
|
503
593
|
});
|
|
504
594
|
}
|
|
505
|
-
return nodes.map(node => ({ ...node, pos: node.isCore ? { x: 0, y: 0, z: 0 } : positions.get(node.id) }));
|
|
595
|
+
return nodes.map(node => ({ ...node, pos: node.isCore ? { x: 0, y: layout === 'brain' ? 14 : 0, z: 0 } : positions.get(node.id) }));
|
|
506
596
|
}
|
|
507
597
|
|
|
508
598
|
var defaultSettings = { hideButton: false, showGuideOnEmpty: true, graphTheme: "classic", graphLayout: "sphere", autoMemory: { enabled: false, maxPerTurn: 2, dailyCap: 40 }, showAutoMemoryCostHint: true };
|
|
@@ -511,7 +601,7 @@ function frame(now) {
|
|
|
511
601
|
var kinds = ["知识", "印象", "对话", "事件", "日记"];
|
|
512
602
|
var appearanceOptions = [
|
|
513
603
|
{ key: "graphTheme", label: "配色", choices: [["classic", "经典星空"], ["neon", "赛博霓虹"], ["paper", "铅绘素描"], ["mono", "极简单色"]] },
|
|
514
|
-
{ key: "graphLayout", label: "布局", choices: [["sphere", "球体"], ["
|
|
604
|
+
{ key: "graphLayout", label: "布局", choices: [["sphere", "球体"], ["galaxy", "星系"], ["brain", "大脑神经网络"]] },
|
|
515
605
|
];
|
|
516
606
|
var appearanceStyles = {
|
|
517
607
|
classic: {},
|
package/lib/client.src.js
CHANGED
|
@@ -22,7 +22,7 @@ window.__ModuleLoader__.load({
|
|
|
22
22
|
var kinds = ["知识", "印象", "对话", "事件", "日记"];
|
|
23
23
|
var appearanceOptions = [
|
|
24
24
|
{ key: "graphTheme", label: "配色", choices: [["classic", "经典星空"], ["neon", "赛博霓虹"], ["paper", "铅绘素描"], ["mono", "极简单色"]] },
|
|
25
|
-
{ key: "graphLayout", label: "布局", choices: [["sphere", "球体"], ["
|
|
25
|
+
{ key: "graphLayout", label: "布局", choices: [["sphere", "球体"], ["galaxy", "星系"], ["brain", "大脑神经网络"]] },
|
|
26
26
|
];
|
|
27
27
|
var appearanceStyles = {
|
|
28
28
|
classic: {},
|
package/lib/config-write.js
CHANGED
|
@@ -16,7 +16,7 @@ const writable = new Map([
|
|
|
16
16
|
['autoMemory.dailyCap', value => Number.isSafeInteger(value) && value >= 0 && value <= 500],
|
|
17
17
|
['spinSpeed', value => Number.isFinite(value) && value >= 0.05 && value <= 1.5],
|
|
18
18
|
['graphTheme', value => ['classic', 'neon', 'paper', 'mono'].includes(value)],
|
|
19
|
-
['graphLayout', value => ['sphere', 'flat', 'spiral'].includes(value)],
|
|
19
|
+
['graphLayout', value => ['sphere', 'galaxy', 'brain', 'flat', 'spiral'].includes(value)],
|
|
20
20
|
['showGuideOnEmpty', value => typeof value === 'boolean'],
|
|
21
21
|
['hideButton', value => typeof value === 'boolean'],
|
|
22
22
|
]);
|
|
@@ -61,6 +61,10 @@ export function updateConfig(home, changes, { now = Date.now, warn = () => {} }
|
|
|
61
61
|
try { next.autoImport.sessionFilter = normalizeSessionFilter({ ...current.autoImport?.sessionFilter, ...changes.autoImport.sessionFilter }); }
|
|
62
62
|
catch (error) { invalid(error.message); }
|
|
63
63
|
}
|
|
64
|
+
if (Object.hasOwn(changes, 'graphLayout')) {
|
|
65
|
+
if (next.graphLayout === 'flat') next.graphLayout = 'galaxy';
|
|
66
|
+
if (next.graphLayout === 'spiral') next.graphLayout = 'brain';
|
|
67
|
+
}
|
|
64
68
|
if (changes.autoMemory?.enabled === true) next.showAutoMemoryCostHint = false;
|
|
65
69
|
fs.mkdirSync(directory, { recursive: true });
|
|
66
70
|
const prefix = `config.json.bak-${now()}`;
|
package/lib/config.js
CHANGED
|
@@ -53,7 +53,9 @@ export function readConfig(home = defaultHome(), { warn } = {}) {
|
|
|
53
53
|
if (value.spinSpeed !== undefined && (!Number.isFinite(value.spinSpeed) || value.spinSpeed < 0.05 || value.spinSpeed > 1.5)) {
|
|
54
54
|
throw new Error('spinSpeed 必须是 0.05~1.5 的数字');
|
|
55
55
|
}
|
|
56
|
-
|
|
56
|
+
if (value.graphLayout === 'flat') value.graphLayout = 'galaxy';
|
|
57
|
+
if (value.graphLayout === 'spiral') value.graphLayout = 'brain';
|
|
58
|
+
for (const [key, choices] of [['graphTheme', ['classic', 'neon', 'paper', 'mono']], ['graphLayout', ['sphere', 'galaxy', 'brain']]]) {
|
|
57
59
|
if (value[key] !== undefined && !choices.includes(value[key])) throw new Error(`${key} 必须是 ${choices.join('、')}`);
|
|
58
60
|
}
|
|
59
61
|
return { ...value, personaTitle: value.personaTitle?.trim() || '角色卡', spinSpeed: value.spinSpeed ?? 0.35,
|
package/lib/render/canvas.js
CHANGED
|
@@ -38,7 +38,9 @@ export function createRenderer(canvas, data, options = {}) {
|
|
|
38
38
|
if (!Object.hasOwn(THEMES, themeName)) throw new Error('未知图谱配色');
|
|
39
39
|
let theme = THEMES[themeName];
|
|
40
40
|
let layoutName = options.graphLayout ?? data.graphLayout ?? 'sphere';
|
|
41
|
-
if (
|
|
41
|
+
if (layoutName === 'flat') layoutName = 'galaxy';
|
|
42
|
+
if (layoutName === 'spiral') layoutName = 'brain';
|
|
43
|
+
if (!['sphere', 'galaxy', 'brain'].includes(layoutName)) throw new Error('未知图谱布局');
|
|
42
44
|
let sphereNodes = [];
|
|
43
45
|
const layouts = new Map();
|
|
44
46
|
let nodes = [], links = [], hubs = [], linkTwinkle = [], visibleLinks = [];
|
|
@@ -86,7 +88,9 @@ function glowSprite(rgb) {
|
|
|
86
88
|
|
|
87
89
|
function setLayout(nextLayout) {
|
|
88
90
|
if (destroyed) return;
|
|
89
|
-
if (
|
|
91
|
+
if (nextLayout === 'flat') nextLayout = 'galaxy';
|
|
92
|
+
if (nextLayout === 'spiral') nextLayout = 'brain';
|
|
93
|
+
if (!['sphere', 'galaxy', 'brain'].includes(nextLayout)) throw new Error('未知图谱布局');
|
|
90
94
|
if (layoutName === nextLayout && layouts.has(nextLayout)) return;
|
|
91
95
|
if (!layouts.has(nextLayout)) {
|
|
92
96
|
if (typeof options.layoutNodes !== 'function') throw new Error('缺少图谱布局函数');
|
|
@@ -311,20 +315,22 @@ function frame(now) {
|
|
|
311
315
|
}
|
|
312
316
|
}
|
|
313
317
|
if (linkPaths.length) {
|
|
318
|
+
if (layoutName === 'galaxy') ctx.globalAlpha=0.8;
|
|
314
319
|
if (Array.isArray(theme.link)) {
|
|
315
320
|
const gradient = ctx.createLinearGradient(0,0,width,height);
|
|
316
321
|
gradient.addColorStop(0,theme.link[0]);
|
|
317
322
|
gradient.addColorStop(1,theme.link[1]);
|
|
318
323
|
ctx.strokeStyle=gradient;
|
|
319
324
|
} else ctx.strokeStyle=theme.link;
|
|
320
|
-
ctx.lineWidth=1;
|
|
325
|
+
ctx.lineWidth=layoutName === 'galaxy' ? 0.8 : 1;
|
|
321
326
|
ctx.beginPath();
|
|
322
327
|
for (const p of linkPaths) { ctx.moveTo(p[0],p[1]); ctx.quadraticCurveTo(p[2],p[3],p[4],p[5]); }
|
|
323
328
|
ctx.stroke();
|
|
324
329
|
// 更亮的一层,让关联线看得清
|
|
325
330
|
ctx.strokeStyle=theme.linkHighlight;
|
|
326
|
-
ctx.lineWidth=2;
|
|
331
|
+
ctx.lineWidth=layoutName === 'galaxy' ? 1.6 : 2;
|
|
327
332
|
ctx.stroke();
|
|
333
|
+
if (layoutName === 'galaxy') ctx.globalAlpha=1;
|
|
328
334
|
}
|
|
329
335
|
for (let index=pulses.length-1;index>=0;index--) {
|
|
330
336
|
const pulse=pulses[index],fraction=(now-pulse.born)/pulse.life;
|
package/lib/render/layout.mjs
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
const SEED = 20261030;
|
|
2
2
|
|
|
3
3
|
export function layoutPositions(nodes, layout = 'sphere') {
|
|
4
|
-
if (
|
|
4
|
+
if (layout === 'flat') layout = 'galaxy';
|
|
5
|
+
if (layout === 'spiral') layout = 'brain';
|
|
6
|
+
if (!['sphere', 'galaxy', 'brain'].includes(layout)) throw new Error('未知图谱布局');
|
|
5
7
|
if (layout === 'sphere') return nodes.map(node => ({ ...node, pos: { ...node.pos } }));
|
|
6
8
|
const memories = nodes.filter(node => !node.isCore).sort((left, right) =>
|
|
7
9
|
(right.degree || 0) - (left.degree || 0) || (String(left.id) < String(right.id) ? -1 : String(left.id) > String(right.id) ? 1 : 0));
|
|
@@ -12,25 +14,107 @@ export function layoutPositions(nodes, layout = 'sphere') {
|
|
|
12
14
|
hash = Math.imul(hash ^ (hash >>> 16), 0x45d9f3b);
|
|
13
15
|
return ((hash ^ (hash >>> 16)) >>> 0) / 4294967295 * 2 - 1;
|
|
14
16
|
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
17
|
+
let datasetSeed = 20261030;
|
|
18
|
+
for (const id of memories.map(node => String(node.id)).sort()) {
|
|
19
|
+
for (const character of id + '\u0000') datasetSeed = Math.imul(datasetSeed ^ character.codePointAt(0), 16777619);
|
|
20
|
+
}
|
|
21
|
+
const uniform = (id, axis) => Math.max(1e-9, Math.min(1 - 1e-9, (jitter(datasetSeed + ':' + id, axis) + 1) / 2));
|
|
22
|
+
const gaussian = (id, axis) => Math.sqrt(-2 * Math.log(uniform(id, axis + ':radius'))) * Math.cos(2 * Math.PI * uniform(id, axis + ':angle'));
|
|
23
|
+
if (layout === 'galaxy') {
|
|
24
|
+
const armCount = 2 + Math.floor(uniform('galaxy', 'arms') * 3);
|
|
25
|
+
const rotation = uniform('galaxy', 'rotation') * Math.PI * 2;
|
|
26
|
+
const coreCount = Math.ceil(memories.length * 0.15);
|
|
27
|
+
const haloCount = Math.floor(memories.length * 0.05);
|
|
28
|
+
const diskEnd = memories.length - haloCount;
|
|
29
|
+
const dustCount = Math.round(memories.length * 0.30);
|
|
30
|
+
const dust = new Set(memories.slice(coreCount, diskEnd)
|
|
31
|
+
.sort((left, right) => uniform(left.id, 'dust') - uniform(right.id, 'dust') || (String(left.id) < String(right.id) ? -1 : String(left.id) > String(right.id) ? 1 : 0))
|
|
32
|
+
.slice(0, Math.max(0, dustCount - haloCount)).map(node => node.id));
|
|
26
33
|
memories.forEach((node, index) => {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
34
|
+
let radius, angle;
|
|
35
|
+
if (index < coreCount) {
|
|
36
|
+
radius = 6 + Math.sqrt(uniform(node.id, 'core-radius')) * 16;
|
|
37
|
+
angle = uniform(node.id, 'core-angle') * Math.PI * 2;
|
|
38
|
+
} else if (index >= diskEnd) {
|
|
39
|
+
radius = 95 + Math.sqrt(uniform(node.id, 'halo-radius')) * 35;
|
|
40
|
+
angle = uniform(node.id, 'halo-angle') * Math.PI * 2;
|
|
41
|
+
} else if (dust.has(node.id)) {
|
|
42
|
+
radius = 22 + Math.sqrt(uniform(node.id, 'dust-radius')) * 73;
|
|
43
|
+
angle = uniform(node.id, 'dust-angle') * Math.PI * 2;
|
|
44
|
+
} else {
|
|
45
|
+
const fraction = (index - coreCount + 0.5) / Math.max(1, diskEnd - coreCount);
|
|
46
|
+
const offset = fraction * 73 + gaussian(node.id, 'radius') * 9;
|
|
47
|
+
const folded = ((offset % 146) + 146) % 146;
|
|
48
|
+
radius = 22 + (folded <= 73 ? folded : 146 - folded);
|
|
49
|
+
const arm = Math.floor(uniform(node.id, 'arm') * armCount);
|
|
50
|
+
const base = rotation + arm * Math.PI * 2 / armCount + (uniform(arm, 'arm-offset') - 0.5) * 0.35;
|
|
51
|
+
const twist = (radius - 18) / 95 * 2.2 * Math.PI;
|
|
52
|
+
const ripple = Math.sin(radius * 0.055 + uniform(arm, 'phase') * Math.PI * 2) * 0.10;
|
|
53
|
+
angle = base + twist + ripple + gaussian(node.id, 'angle') * 0.30;
|
|
54
|
+
}
|
|
55
|
+
const thickness = 8 / (1 + radius / 40);
|
|
56
|
+
const height = Math.max(-1, Math.min(1, gaussian(node.id, 'height') * 0.5)) * thickness;
|
|
57
|
+
positions.set(node.id, { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius, z: height });
|
|
58
|
+
});
|
|
59
|
+
} else {
|
|
60
|
+
function brainPoint(id, axis, extent = 1) {
|
|
61
|
+
const horizontal = gaussian(id, axis + ':x');
|
|
62
|
+
const vertical = gaussian(id, axis + ':y');
|
|
63
|
+
const depth = gaussian(id, axis + ':z');
|
|
64
|
+
const scale = Math.cbrt(uniform(id, axis + ':distance')) * extent / Math.hypot(horizontal, vertical, depth);
|
|
65
|
+
return { x: 42 + horizontal * scale * 40, y: vertical * scale * 106, z: depth * scale * 52 };
|
|
66
|
+
}
|
|
67
|
+
const centers = [0, 1].map(side => {
|
|
68
|
+
const count = 12 + Math.floor(uniform('brain:' + side, 'clusters') * 3);
|
|
69
|
+
const candidates = Array.from({ length: 96 }, (_, index) => {
|
|
70
|
+
const id = 'brain:' + side + ':' + index;
|
|
71
|
+
const point = brainPoint(id, 'center', 0.82 + uniform(id, 'surface') * 0.12);
|
|
72
|
+
return { ...point, spread: 6 + uniform(id, 'spread') * 8, phase: uniform(id, 'phase') * Math.PI * 2 };
|
|
73
|
+
});
|
|
74
|
+
const interior = candidates.reduce((best, point, index) => Math.hypot(point.x - 42, point.y, point.z)
|
|
75
|
+
< Math.hypot(candidates[best].x - 42, candidates[best].y, candidates[best].z) ? index : best, 0);
|
|
76
|
+
const selected = [candidates.splice(interior, 1)[0]];
|
|
77
|
+
while (selected.length < count) {
|
|
78
|
+
let best = 0, greatest = -Infinity;
|
|
79
|
+
candidates.forEach((point, index) => {
|
|
80
|
+
const distance = Math.min(...selected.map(center => Math.hypot(point.x - center.x, point.y - center.y, (point.z - center.z) * 0.7)));
|
|
81
|
+
if (distance > greatest) { greatest = distance; best = index; }
|
|
82
|
+
});
|
|
83
|
+
selected.push(candidates.splice(best, 1)[0]);
|
|
84
|
+
}
|
|
85
|
+
return selected;
|
|
86
|
+
});
|
|
87
|
+
const roamers = new Set([...memories].sort((left, right) => uniform(left.id, 'roaming') - uniform(right.id, 'roaming')
|
|
88
|
+
|| (String(left.id) < String(right.id) ? -1 : String(left.id) > String(right.id) ? 1 : 0))
|
|
89
|
+
.slice(0, Math.round(memories.length * 0.05)).map(node => node.id));
|
|
90
|
+
memories.forEach(node => {
|
|
91
|
+
const side = uniform(node.id, 'hemisphere') < 0.5 ? 0 : 1;
|
|
92
|
+
const target = brainPoint(node.id, 'target');
|
|
93
|
+
let center, nearest = Infinity;
|
|
94
|
+
centers[side].forEach((candidate, index) => {
|
|
95
|
+
const distance = Math.hypot(target.x - candidate.x, target.y - candidate.y, target.z - candidate.z)
|
|
96
|
+
* (0.8 + uniform(node.id, 'weight:' + index) * 0.4);
|
|
97
|
+
if (distance < nearest) { nearest = distance; center = candidate; }
|
|
98
|
+
});
|
|
99
|
+
const point = roamers.has(node.id) ? target : {
|
|
100
|
+
x: center.x + gaussian(node.id, 'scatter:x') * center.spread,
|
|
101
|
+
y: center.y + gaussian(node.id, 'scatter:y') * center.spread,
|
|
102
|
+
z: center.z + gaussian(node.id, 'scatter:z') * center.spread,
|
|
103
|
+
};
|
|
104
|
+
point.x += Math.sin(point.y * 0.08 + center.phase) * 6;
|
|
105
|
+
point.z += Math.sin(point.y * 0.09 + center.phase) * 4;
|
|
106
|
+
point.y += Math.sin(point.z * 0.12 + center.phase) * 3;
|
|
107
|
+
const envelope = 0.96 + 0.06 * Math.sin(point.y * 0.08) + 0.035 * Math.cos(point.z * 0.11 + side);
|
|
108
|
+
const distance = Math.hypot((point.x - 42) / 40, point.y / 106, point.z / 52);
|
|
109
|
+
if (distance > envelope) {
|
|
110
|
+
const scale = envelope * (0.93 + uniform(node.id, 'edge') * 0.06) / distance;
|
|
111
|
+
point.x = 42 + (point.x - 42) * scale; point.y *= scale; point.z *= scale;
|
|
112
|
+
}
|
|
113
|
+
positions.set(node.id, { x: (side ? 1 : -1) * (4 + Math.abs(point.x)) + (side ? 2 : 0),
|
|
114
|
+
y: point.y - (side ? 1 : 0), z: point.z });
|
|
31
115
|
});
|
|
32
116
|
}
|
|
33
|
-
return nodes.map(node => ({ ...node, pos: node.isCore ? { x: 0, y: 0, z: 0 } : positions.get(node.id) }));
|
|
117
|
+
return nodes.map(node => ({ ...node, pos: node.isCore ? { x: 0, y: layout === 'brain' ? 14 : 0, z: 0 } : positions.get(node.id) }));
|
|
34
118
|
}
|
|
35
119
|
|
|
36
120
|
export function layoutMemories(nodes) {
|