笔记 · 架构设计 / KNOWLEDGE-BASE

图谱页换了四版画法才定稿

2026-07-22约 4,269 字graph-view-iterations.md

导航栏上”图谱”这个链接挂了快一个月的 href="#"。数据层其实早就绪了:nodes 和 links 两张表,wiki(双链)、mentions(术语提及)、is_a(层级)三类边,一个查询就能把整张图吐出来。卡住的一直是同一个问题:这张图该长什么样。

前后画了四版。趁细节还没忘,记一下每一版是怎么被否掉的。

第一版:全量图,26 个节点就毛了

最早的 /api/graph 是全量接口,nodes 加 links 一次全返回。当时库里 26 个节点、120 条边,心想这规模随便画。

真画出来才发现 120 条边意味着什么。以圆心那篇笔记做 BFS,树边只有 25 条,剩下 95 条全是交叉边——每条都横穿画布。节点少不代表边少,边的密度才是毛线团的成因。Obsidian 的全局图好看,但上千节点的图里你找不到任何东西,它是壁纸。

anvaka 画过百万节点的包管理器星系(Code Galaxies),他在 pm 项目的说明里写得很直白:默认只画屏幕长度 150px 以内的边,否则得到的是 “a beautiful hairball monster”。force 布局跑完,短边等于同簇关系,信息密度最高;长边多数是跨簇噪声。cosmos.gl 的默认配置也是同一个思路,linkVisibilityDistanceRange: [50, 150],边在屏幕上超过 150px 就衰减到 0.25 透明度。

两个做大规模图的项目给出同一个数字,这事就不用自己再试了。

第二版:局部径向 + 同心环

放弃全量,改成以当前节点为圆心的局部展开。后端加了个 build_neighborhood:无向 BFS,深度 1 到 3,max_nodes 截断在 36。有个细节比算法本身重要——邻居遍历必须确定性排序:

def neighbor_key(item):
    other_id, link = item
    return (_TYPE_ORDER.get(link["link_type"], 99),   # is_a > wiki > mentions
            -degree[other_id],                          # 度大的优先
            str(other.get("title") or ""))

不排序的话,同一个节点每次打开图,邻居出现的位置都不一样,截断掉的也不是同一批。排了序,图就是稳定的,用户第二次来还能凭位置找到上次看过的东西。

前端把 BFS 的圈层画成同心椭圆:一层节点均分第一圈,二层均分第二圈。能用,但看久了发现它只表达了”距离”,不表达”归属”。ArrayList 和 HashMap 都在第二圈,图上看是并列的,实际一个属于 List 分支一个属于 Map 分支——这个信息在均分布局里丢了。

第三版:扇区径向

改法:一层节点先按子树规模瓜分整个圆周。比如 List 带两个孩子占 90°,Map 带两个孩子占 90°,光杆的 Collection 只占 30°;二层节点只在自己父节点的扇区内展开。分支归属从”要读边才知道”变成”看方向就知道”。

这需要后端多返回一个字段:每个节点在这次 BFS 里是经谁被发现的。实现是三行——BFS 给 depths[neighbor] = depth+1 的那一刻顺手记 parent[neighbor] = current。因为遍历本身是确定性排序的,发现树也是稳定的,同一个节点每次都长在同一个方向。

95 条交叉边的处理:默认透明度压到 0.13,接近隐身;悬停某个节点时,它的交叉边才升起来,其余压暗。平时看骨架,想看细节指哪亮哪。

到这里,“白天用的工作视图”其实已经成型了。但中间岔出去玩了一版完全不同的。

第四版岔路:星云

想法是把知识库画成星空:每篇笔记是一颗星,亮度由点击量驱动——点得多的星更亮、晃动更明显,叫它注意力熵;一段时间没人看,全场衰减,星云沉静下来。两种模式:A 按向量语义分布的三维散点加语义弦,B 就是第三版的关系链,搬进 3D。

没有引 three.js。这个站有条十年纪律,能不引的依赖就不引,况且投影这点数学自己写只要几行:

// yaw/pitch 两次旋转后,透视就是一个除法
const s = F / (F + z2);          // F = 720
return { x: CX + x*s, y: CY + y2*s, s, z: z2 };

第一版星云被自己否了:所有星落在一个平面环上,转起来像一张碟片在转,不像星云。改成真三维——A 模式用”聚类中心方向 × 距离 + 抖动”模拟向量降维的三维坐标(正式版会直接拿节点向量做 PCA/UMAP,位置本身就是语义),B 模式的分支方向按黄金螺旋铺满球面。再加一层深度雾:

const fog = (z) => Math.max(.4, Math.min(1, .7 - z/500));

远处的星和边压暗到 0.4,配合透视缩放,不戴眼镜也能读出前后。三轴标准差从改前的 y≈18(就是一张纸)变成 129/90/116。

抄作业

星云的第一版视觉很糙,星点是纯色径向渐变,像一堆彩色光斑。去翻了一圈真做过”星空”的项目,收获比预想大,几个来源在互不相干的项目里给出了相同的答案:

光是烘出来的,颜色不住在核心。 anvaka 的 unrender 里百万星点共享一张预烘焙贴图,亮核加光晕的衰减烘死在图里,运行时只做 tint。Google 的 100,000 Stars 里太阳是好几层叠的:表面、corona、单独的光环层。照着改:初始化时烘一张 128px 的 sprite——中心 18% 是近白(星味全在这个白核上),类型色只住在光晕外圈——主循环里一次 drawImage 完事。顺带解决了性能:之前每帧每节点 createRadialGradient,13 个节点无感,几百个必卡。MDN 的 canvas 优化文档还专门警告过 shadowBlur,那个更贵,从头就没碰。

光是加出来的。 100,000 Stars 的镜头光晕全靠 AdditiveBlending 叠层,重叠处自动过曝变白。Canvas 2D 有现成等价物:画星和边之前 globalCompositeOperation = 'lighter',画完恢复。语义相近而空间聚拢的星,光晕重叠处自己泛白热,不用手调。

发光的永远是少数。 EVE Online 的击杀热度图里,零击杀的星系没有任何标记,热点才有大红 halo。照抄成分档:点击量 P50 以下的星不给彩色光晕,只留白核;P85 以上才叠第二层大 halo。星空一下子安静了,热点自己跳出来。

层级靠压暗,选中态永不篡改本色。 Obsidian 悬停时把无关内容压到 0.15,cosmos 的 greyout 默认 0.1,3d-force-graph 的边默认透明度只有 0.2。选中态学 No Man’s Sky:一个细环准星套在星点外,加一次 0.55 秒的扩散脉冲,播完即停——星本身的颜色是类型语义(朱红笔记、靛蓝术语),不能被交互态污染。

标签是星空最大的噪音。 Elite Dangerous 的开源复刻只给镜头目标附近的星显示名字。改成:悬停时只标邻域,平时只标选中星加点击量 top-5,标签重叠时后画的让位。之前是常驻一片字,改完才发现字少了图反而可读。

还有两个便宜的:底色从纯黑换成带色相的 #0C0C14(Map of GitHub 的底色是 #030E2E,纯黑会闷这件事他们早试过了);vignette 暗角烘进静态背景层,零每帧成本。

长边渐隐也顺手加了,就是 anvaka 那个 150px 规则的软化版:

const lenFade = (a,b) => { const d = Math.hypot(a.x-b.x, a.y-b.y);
  return Math.max(.25, Math.min(1, 1-(d-90)/220)) };

定稿

两个视图都留下了,分工不同。

扇区径向是工作视图:SVG、白底、信息密度高、每个节点可点可跳,配目录和检查器面板,回答”这篇笔记在知识结构里的位置”。星云是夜景:回答”这座知识库整体长什么样、哪里热”,放首页或图谱页的一个切换透镜,氛围优先。同一个 /api/graph,两种皮。

贯穿两版的原则只有一条,从数据可视化的老规矩里来:每个视觉变量只表达一件事。尺寸=关系数,亮度=点击量,颜色=类型,虚实=边的来源(实线是作者写下的,虚线是系统推断的),圈层=关系距离。凡是想让一个变量身兼两职的地方,最后都返工了。

星云还有两块地基没打:点击量在后端还不存在,得加个访问计数;节点级向量也没有,现在库里只有段落级的 chunk 向量,“位置即语义”要等它。这两件都排在图谱主页面之后。

参考