数据截至 (上游 commit 149589ae7e6b)
记忆图可视化:力导向布局 + 版本链 + 增量渲染
30 秒导读:
@supermemory/memory-graph是 Supermemory 里少见的、自带非平凡算法的一章——仓库其余部分多是 API 编排,这里却要在 canvas 上把上万个「文档 / 记忆 / 版本」节点排布成一张会呼吸的力导向图。它解决三件难事:用物理引力自动摆节点、把同一事实的历次版本串成一条时间线、以及在几万节点下仍不掉帧。
1. 这是什么(零基础也能懂)
-
一句话定义: 一个纯 canvas 的力导向图组件,把 Supermemory 的记忆数据画成一张可缩放、可拖拽、可点选的「知识星系」。
-
它画的是什么数据: 上游返回的是「文档」和挂在文档下的「记忆」,以及记忆之间的关系(见 01-data-model.md 里的
GraphApiDocument/GraphApiMemory)。本章把这些数据字段视为已知,只讲怎么画、怎么排、怎么导航。 -
给谁用:
- Supermemory 的 web 控制台(
apps/web)直接import { MemoryGraph } from "@supermemory/memory-graph"。 - MCP 服务器把它包成一个 App UI 工具,让任意 AI 助手弹出这张图(见 §7,回指 02-mcp-server.md)。
- Supermemory 的 web 控制台(
-
用起来什么样: 一个 React 组件,喂一份
documents就能跑——
// 示意,非源码:最小用法
import { MemoryGraph } from "@supermemory/memory-graph"
<MemoryGraph
documents={documents} // GraphApiDocument[],见 01 章
variant="console" // 控制台样式(另有 consumer)
onOpenDocument={(id) => openModal(id)}
/>
- 一句话直觉/类比: 把它想成太阳系:每个文档是一颗恒星,它抽出的记忆是绕它转的行星;引力(力导向)让恒星彼此推开、行星各就各位;而「记忆被更新」这件事,画成一条从旧行星指向新行星的箭头——一整条箭头链就是这条事实的「进化史」。
本节到此不碰代码。下面开始拆内部。
2. 顶层全景(它大概怎么转)
2.1 一条数据从「文档数组」到「屏幕像素」
图默认从左读到右,命中即进入下一环:
documents: GraphApiDocument[]
│
▼
① 装配 useGraphData ──► nodes[] + edges[]
· 螺旋铺初始坐标、记忆绕文档做轨道
· BFS 连通分量 → 聚类上色
· 增量:复用上一帧已在的节点坐标
│
▼
② 布局 ForceSimulation ──► 每帧微调 node.x / node.y
· d3-force:只喂「结构边」产生引力
· 节点 >6000 → 静态布局(算一次就停)
│
▼
③ 渲染 GraphCanvas(单条 rAF 循环)
· viewport 世界→屏幕变换
· renderFrame:按颜色批处理 + LOD 采样
· 无变化就跳帧
│
▼
④ 交互 InputHandler + SpatialIndex
· 网格空间命中 → hover / click / drag
· 拖拽给节点钉住 fx/fy,反哺 ①②
2.2 部件一句话职责
| 部件 | 干什么 | 文件 |
|---|---|---|
useGraphData | 把 documents 装配成 nodes[]/edges[],含初始布局与聚类 | hooks/use-graph-data.ts |
ForceSimulation | d3-force 力导向布局引擎 | canvas/simulation.ts |
VersionChainIndex | 按 parentMemoryId 把版本串成链 | canvas/version-chain.ts |
ViewportState | 平移 / 缩放 / 惯性 / 世界↔屏幕坐标 | canvas/viewport.ts |
SpatialIndex | 网格空间索引,做鼠标命中 | canvas/hit-test.ts |
InputHandler | 鼠标 / 触摸 / 滚轮事件 → 回调 | canvas/input-handler.ts |
renderFrame | 逐帧把节点 / 边画到 canvas | canvas/renderer.ts |
drawDocIcon | canvas 原生矢量文档图标 | canvas/document-icons.ts |
GraphCanvas | React 壳:持有引擎实例 + rAF 循环 | components/graph-canvas.tsx |
MemoryGraph | 顶层 React 壳:编排数据 / 布局 / 交互 / 弹窗 | components/memory-graph.tsx |
关键分工原则(整章的暗线): React 只管会影响 DOM 的状态(hover、选中、缩放数字);节点坐标这类每帧都变的东西全走 useRef + rAF,绝不进 React state,否则每帧一次 setState 会把浏览器拖垮(graph-canvas.tsx:58-70 把所有可变渲染态塞进一个 ref)。
3. 核心原理之一:力导向布局(ForceSimulation)
3.1 它要解决的小问题
给你一堆节点和「谁连谁」的边,没有人告诉你每个节点该放哪。力导向布局的思路:把它当物理系统——节点互相排斥(像同极磁铁),边像弹簧把两端拉近,再加一点向心力别让它们飘走。迭代若干轮,系统自然「松弛」到一个好看又不重叠的布局。这里直接用 d3 的 d3-force。
3.2 精华一:只有「结构边」参与引力,extends 边纯视觉
这是全章最关键的一个设计决策。图里有三类边(边的语义见 01-data-model.md):
| 边类型 | 含义 | 是否产生引力 |
|---|---|---|
derives | 文档 → 它抽出的记忆(结构) | 是 |
updates | 旧记忆 → 新版本记忆(结构) | 是 |
extends | 同一 spaceId 下文档互相关联 | 否,纯视觉 |
为什么要把 extends 排除?因为 extends 往往把一大票文档两两相连,若让它也产生引力,会把整片文档吸成一坨,图就糊了。所以布局初始化时直接过滤掉:
// simulation.ts:17 —— 只用结构边参与力布局
const structuralEdges = edges.filter((e) => e.edgeType !== "extends")
ForceSimulation.init(canvas/simulation.ts:10-81)在 structuralEdges 上装配四种力,参数集中在 FORCE_CONFIG(constants.ts:10-31):
| 力 | 作用 | 关键参数 |
|---|---|---|
forceLink | 边=弹簧,按边类型给不同距离/强度 | updates 强度 0.6、derives 0.35、其它 0.05 |
forceManyBody | 节点互相排斥 | chargeStrength: -2400 |
forceCollide | 防重叠的硬碰撞半径 | 文档 80 / 记忆 48 |
forceX / forceY | 轻微向心,别飘出画面 | centeringStrength: 0.06 |
derives 边的弹簧长度还会随文档下记忆数量变长(记忆多的文档需要更大轨道),用 sqrt(memoryCount) 缩放并封顶(getDocMemoryDistance,simulation.ts:115-130)。
3.3 精华二:DENSE_GRAPH_STATIC_THRESHOLD —— 上万节点就「算一次就停」
力导向每帧都要 O(n log n) 迭代,节点一多就必然掉帧。这里有个直白的性能开关:节点数超过 6000 就放弃「持续物理 动画」,改成静态布局——同步预跑几帧、算出坐标、然后彻底停住。
// simulation.ts:5
export const DENSE_GRAPH_STATIC_THRESHOLD = 6000
init 里的分支(simulation.ts:64-76):先 stop() 再手动 tick 若干次(密集图只跑 densePreSettleTicks = 12,普通图跑 preSettleTicks = 150),然后——
预跑 preSettleTicks 帧(同步 tick)
│
节点数 > 6000 ?
┌────────────┴────────────┐
是 否
this.stop() alphaTarget(0).restart()
(坐标定死,不再动) (交给 rAF 继续松弛,自然冷却)
再往上一层,React 壳里节点超阈值时根本不创建 simulation 实例(memory-graph.tsx:147-153):直接用 useGraphData 算出的螺旋初始坐标当最终坐标,连一次力都不跑。渲染层也配套降级——renderer.ts:439-441 的 densePointMode 在节点 >25000 且缩得很小时,把记忆退化成纯色小圆点批量画。
3.4 精华三:增量热更新,不重排已有节点
分页加载「再来一页文档」时,如果每次都 init 重跑力导向,已经摆好的节点会整体乱抖,体验极差。所以区分三种情形(memory-graph.tsx:155-178):
| 情形 | 判定 | 动作 |
|---|---|---|
| 首次加载 / 节点集大改 | 无历史,或旧 id 不再是新集子集 | sim.init() 全量 重排 |
| 纯追加(append-only) | 旧 id 全部仍在,只是多了新 id | sim.update() 热插入 + stop(),老坐标不动 |
| 数据同引用 | id 没变 | sim.update() 走个过场 |
ForceSimulation.update(simulation.ts:83-89)只是 sim.nodes(...) + 换掉 link 力的边集(同样过滤 extends),不重置 alpha,所以老节点不会被重新加热甩飞。新节点的落点由 useGraphData 的 getAppendPosition 在已有节点外围找空位(use-graph-data.ts:297-343,金角螺旋 + 网格避让)。
坑: 拖拽时
InputHandler会给节点钉上fx/fy(固定坐标),松手才置回null(input-handler.ts:135-139、177-184)。d3-force 见到fx/fy就把该节点当锚点——这正是「拖一个节点、其它节点让位」效果的来源。
4. 核心原理之二:版本链索引(VersionChainIndex)
4.1 它要解决的小问题
Supermemory 会自动遗忘、矛盾更新记忆:同一个事实(「我住在北京」→「我搬到上海了」)会产生多条记忆,新的用 parentMemoryId 指向旧的。UI 要能把这一整条进化史摆出来,让用户点一个节点就看到「它是 v3,前面还有 v1、v2」。这就是 VersionChainIndex 干的活——它是「自动遗忘 / 矛盾更新」在界面上的呈现。
4.2 数据结构:三张表 + 增量重建
VersionChainIndex(canvas/version-chain.ts:11-106)维护三张表:
| 表 | 类型 | 用途 |
|---|---|---|
memoryMap | Map<id, GraphApiMemory> | id → 记忆本体 |
childrenMap | Map<parentId, childId[]> | 反向索引:谁的孩子是谁 |
cache | Map<id, ChainEntry[]> | 已算好的链,链上每个 id 都指向同一份 |
增量精华:引用相等短路。 rebuild 第一行就是——
// version-chain.ts:18 —— documents 引用没变就直接返回,零开销
if (documents === this.lastDocs) return
这让上层可以在每次 render 里无脑调用 chainIndex.current.rebuild(limitedDocuments)(memory-graph.tsx:132),数据没变时它就是个 no-op——省去了放进 useEffect 的时序麻烦(注释见 memory-graph.tsx:128-131)。
4.3 算法:先回溯到根,再前探到最新
getChain(memoryId)(version-chain.ts:39-105)分两步走,产出一条从 v1 到最新的有序链:
选中节点 mem
│
① 向后回溯到根(沿 parentMemoryId)
mem → parent → parent ... → root
收集后 reverse ⇒ [root .. selected]
│
② 向前前探到最新(沿 childrenMap 第一个孩子)
selected → child → child ... → latest
│
拼接 [root..selected] + [selected+1..latest]
│
长度 ≤ 1 ⇒ 返回 null(孤立 v1 不算链)
两处细节值得记:
-
前探只走「第一个孩子」(
version-chain.ts:63-75):版本链设计上是线性的(一父一子);万一数据分叉,只取文档顺序里的第一支。visited集合同时防成环(version-chain.ts:50、71)。 -
版本号会兜底修复(
version-chain.ts:83-98):后端有时把整条链的version都写成 1。这里用「单调递增」规则重算显示版本号——只在后端值不可信(不递增)时才覆盖,合法值原样保留。测试version-chain.test.ts明确验证了这两种情况:「后端整条 v1」被修成[1,2],而「合法的 v1/v5/v5」只修坏的那一格。
产出的每条 ChainEntry(version-chain.ts:3-9)带 { version, isForgotten, isLatest },直接喂给弹窗里的版本时间线渲染(node-hover-popover.tsx:230-306 的 VersionTimeline,遗忘的版本号标红、当前项高亮)。方向键上下浏览版本链也走它(memory-graph.tsx:476-517 的 navigateUp/navigateDown)。
4.4 版本状态如何画出来
链里的状态在 canvas 上有专门的视觉语言(renderer.ts:763-858 的 drawMemoryNode):
| 记忆状态 | 判定字段 | 画法 |
|---|---|---|
| 已被取代(非最新) | isLatest === false | 半透明 + 虚线边 + 一道斜划线(strikethrough) |
| 已遗忘 | isForgotten | 红色 X 图标 |
| 在更新链里 | parentMemoryId 或关系含 updates | 右上角一个紫色「更新」小徽标(drawUpdateMarker) |
isMemoryInUpdateChain(renderer.ts:860-866)判定一个记忆是否属于更新链——这让「更新链成员」即使没被选中也带上视觉标记。