跳转到内容
关系图谱

local-graph:Obsidian 风格关系图谱组件

2,217 字 8 分钟

属性 1
series
VaultKit 包指南

双链笔记发布成网站后,最让人怀念的往往是 Obsidian 那张关系图谱。@vaultkit/vitepress-plugin-local-graph 把 Obsidian Publish 风格的 Interactive Graph 带进 VitePress:d3-force 力导向布局 + SVG 自渲染,节点半径随度数增长、悬停高亮邻域、拖拽重加热模拟、全屏模态缩放平移——本站每篇文章侧栏的「关系图谱」页签就是它,现场即可把玩。

本文通读包的全部导出(LocalGraph / GraphCanvas / LocalGraphModal 三个组件与 GraphCanvasNode 类型),逐个 prop 讲清语义与调法,并展开 phyllotaxis 确定性布局、Fitts 命中环、拖拽防误导航这些源码注释里的设计决策。

为什么需要它

现成的图谱方案大多把 Obsidian 全站图直接搬上网页:canvas 渲染、自带主题色、数据格式绑定特定插件。这个包做了三个不同的选择:

  1. 数据与来源解耦——组件只吃 nodes: [{ id, text }]links: [[a, b]] 两个最朴素的结构。全站图还是局部子图、双链从 wikilink 还是 frontmatter 来,全部由宿主决定
  2. SVG 自渲染而非 canvas——d3-force 只算坐标,渲染层是 Vue 模板里的 SVG 元素:点击/悬停/拖拽原生可绑、节点 fill--vp-c-* CSS 变量,暗色切换零重渲染
  3. 卡片 + 模态双形态——侧栏小图快速一瞥,放大按钮进全屏细看,两者是独立实例、各自布局

安装

sh
pnpm add -D @vaultkit/vitepress-plugin-local-graph

若使用 @vaultkit/vitepress-theme-vault 主题则开箱即带——主题在构建期解析全站 wikilink 出边生成图数据,文章页侧栏与小屏顶部入口都已接好。

快速上手

推荐配 VitePress 的 build-time data loader:构建期算好全站图,客户端按当前页取 ±1 跳子图(Obsidian local graph 语义):

vue
<script setup lang="ts">
import { LocalGraph } from '@vaultkit/vitepress-plugin-local-graph'
import { useData, useRouter, withBase } from 'vitepress'
import { computed } from 'vue'
import { data as graph } from '../data/graph.data'

const { page } = useData()
const router = useRouter()

// 当前页 ±1 跳子图
const local = computed(() => {
  const id = page.value.relativePath
  const keep = new Set([id])
  for (const [a, b] of graph.links) {
    if (a === id)
      keep.add(b)
    if (b === id)
      keep.add(a)
  }
  return {
    nodes: graph.nodes.filter(n => keep.has(n.id)).map(n => ({ id: n.id, text: n.title })),
    links: graph.links.filter(([a, b]) => keep.has(a) && keep.has(b)),
  }
})

function go(id: string) {
  const url = graph.nodes.find(n => n.id === id)?.url
  if (url)
    router.go(withBase(url))
}
</script>

<template>
  <LocalGraph
    v-if="local.links.length"
    :nodes="local.nodes"
    :links="local.links"
    :current-id="page.relativePath"
    title="关系图谱"
    :labels="{ expand: '放大', close: '关闭' }"
    @select="go"
  />
</template>

挂载位置推荐 VitePress 默认主题的 aside-outline-before 插槽(右侧大纲上方)——本站主题就挂在这里。data loader 侧的产出形态可以参考本站主题:nodes{ id: pageKey, title, url }links[源, 目标] 二元组数组,双链出边在构建期由 wikilink 解析得到。

全部导出一览

  • LocalGraph——卡片形态(默认导出):标题行 + 放大按钮 + 力导向小图
  • GraphCanvas——裸画布:布局 + 渲染 + 手势,自由内嵌
  • LocalGraphModal——全屏模态独立导出:无卡片场景直接唤起(如移动端顶部入口)
  • 类型:GraphCanvasNode{ id, text }

GraphCanvas 逐项详解

裸画布是三件套的核心,其余两个组件都是它的组合。

nodes(必填)

  • 类型:GraphCanvasNode[]

节点集,每项 { id, text }id 是事件载荷与 currentId 匹配的键;text 是节点下方标签。

  • 类型:[string, string][]

边集,[源 id, 目标 id] 二元组,无向语义(度数、邻域计算两端对等)。引用了不存在节点的边会被静默过滤——links 不必与 nodes 严格闭合,取子图时不用小心翼翼地对齐。

currentId

  • 类型:string | null
  • 默认值:null

高亮为「当前页」的节点:半径额外 +4(随 nodeScale 缩放)、常驻品牌色、标签加粗。文章页场景传 page.value.relativePath

vue
<GraphCanvas :nodes="nodes" :links="links" :current-id="page.relativePath" @select="go" />

height

  • 类型:number
  • 默认值:240

画布高(px)。参与 SVG viewBox 与力模拟中心的计算,侧栏小图 240 足够,全屏场景组件内部会给到视口的 80%。

zoomable

  • 类型:boolean
  • 默认值:false

开启滚轮缩放(指针锚点,0.3–6 倍)+ 背景拖拽平移。默认关闭是刻意的:侧栏小图开了缩放会劫持页面滚动——鼠标滚过卡片页面就不动了。只在全屏模态这类独占场景开启:

vue
<GraphCanvas :nodes="nodes" :links="links" zoomable :height="600" />

labelMax

  • 类型:number
  • 默认值:14

标签截断长度(字符),超出加省略号。小图空间紧用默认值;全屏模态内部放宽到 24。完整标题始终在 SVG <title> 里,悬停可见。

nodeScale

  • 类型:number
  • 默认值:1

节点尺寸倍率。不只是半径:碰撞半径、连线距离(70 × scale)、斥力(-160 × scale)同步缩放,放大后布局比例不失调;标签字号则按 11 × √scale 开方缓增——1.5 倍节点约 13.5px 标签,避免文字喧宾夺主。全屏大画布放大节点便于点击(见下文 Fitts 定律)。

事件

select,载荷 id: string。点击节点触发(键盘 Enter 同),拖拽后不触发;跳转行为交给宿主。

LocalGraph 逐项详解

卡片形态在 GraphCanvas 之上追加外壳 props:

名称类型默认值说明
nodes / links / currentId同 GraphCanvas透传给内嵌小图与全屏图
titlestring'Interactive Graph'卡片标题行
heightnumber240内嵌小图高(px)
expandablebooleantrue是否显示放大按钮
expandedNodeScalenumber1.5全屏图节点尺寸倍率
labels{ expand?: string, close?: string }{}按钮文案(i18n 由宿主注入)

内嵌小图不带 zoomable(防劫持滚动),全屏图自动开启并套用 expandedNodeScale。全屏图是独立 GraphCanvas 实例:更大画布重新起力模拟,布局天然适配,不是把小图拉伸。

LocalGraphModal 逐项详解

全屏模态独立导出,供无卡片场景直接唤起:

名称类型默认值说明
nodes / links / currentId同 GraphCanvas图数据
nodeScalenumber1.5节点尺寸倍率
labels{ close?: string }{}关闭按钮文案

显隐由 v-model:open 控制(defineModel,默认 false),且 defineExpose({ open }) 暴露给模板 ref 编程开合。打开时模态高度取 min(720, 视口高 × 0.8)、锁 body 滚动、注册 Esc;遮罩、按钮、Esc 三路关闭;节点选中后先自动关闭再透传 select

vue
<script setup lang="ts">
import { LocalGraphModal } from '@vaultkit/vitepress-plugin-local-graph'
import { ref } from 'vue'

const open = ref(false)
</script>

<template>
  <button @click="open = true">
    查看全站图谱
  </button>
  <LocalGraphModal v-model:open="open" :nodes="nodes" :links="links" @select="go" />
</template>

本站主题的小屏形态就是这么用的:入口按钮 Teleport 到顶部粘性导航条,点开直达全屏模态。

设计机制

phyllotaxis 确定性布局

d3-force 对没有初始坐标的节点,按叶序(phyllotaxis)螺旋做确定性排布——就是向日葵种子的排列方式,第 n 个节点的极角是 n 倍黄金角。这意味着同一份数据每次渲染的布局是稳定的:刷新页面图谱不会「洗牌」,用户建立的空间记忆不作废。组件把所有节点初始坐标置 0 交给这套确定性初始化,是刻意为之。

Fitts 命中环

图谱节点半径只有几像素,直接点很考验手感。每个节点下面垫了一个 r + 8透明命中环——视觉完全不变,点击目标面积翻了数倍。这是 Fitts 定律(目标越大越好点)的经典应用;配合全屏图的 nodeScale 放大,小屏手指也能准确命中。

拖拽与防误导航

按下节点即 alphaTarget(0.3).restart() 重加热模拟——拖拽时其他节点实时让位,松手 alphaTarget(0) 冷却。关键细节:移动超过 3px 置 moved 标记,抑制后续 click;且 pointerup延迟一拍setTimeout 0)才清空拖拽状态——因为浏览器的 click 事件在 pointerup 之后触发,立即清空会让「拖完松手」被误判为点击导航走人。

力参数与响应式更新

模拟由五个力构成:link(距离 70、强度 0.6)、charge 斥力(-160)、center 居中、collide 碰撞(节点半径 +14)、弱 x / y 归心(0.06,防离群节点飞出画布)。tick 回调里 simNodes.value = [...nodes] 浅拷贝触发 Vue 响应式——数组引用不变时坐标突变不被感知,这一行是「d3 直接改对象 + Vue 模板渲染」两个世界的桥。

悬停邻域高亮

links 预计算成邻接表(Map + Set),悬停节点时非邻居节点与无关连线降到 0.25 透明度,Obsidian 同款「聚焦一跳邻域」。

高级与边界

Important

dist 内含 .vue 源文件,消费方必须配置,否则 SSR 构建报错:

ts
// .vitepress/config.ts
export default defineConfig({
  vite: { ssr: { noExternal: ['@vaultkit/vitepress-plugin-local-graph'] } },
})
  • SSR:布局在 onMounted 后客户端执行,SSR 输出固定高度占位防跳动
  • 自适应:容器 ResizeObserver 去抖 200ms,宽度变化才重起模拟
  • a11y:节点 role="button" + tabindex="0" + aria-label,Enter 触发 select;完整标题走 SVG <title>
  • peer 依赖vue ^3.5.0;运行时依赖仅 d3-force

常见问题

Q:图谱每次刷新布局都一样,是缓存了吗? 不是,是 phyllotaxis 确定性初始化 + 相同力参数收敛到相同解。改了数据(增删节点)布局才会变。

Q:侧栏小图为什么滚轮不能缩放?zoomable 默认关闭,防止劫持页面滚动。点放大按钮进全屏模态即可缩放平移。

Q:links 里有已删除页面的边会报错吗? 不会,引用不存在节点的边被静默过滤。

相关链接