搜索 K
主题
双链笔记发布成网站后,最让人怀念的往往是 Obsidian 那张关系图谱。@vaultkit/vitepress-plugin-local-graph 把 Obsidian Publish 风格的 Interactive Graph 带进 VitePress:d3-force 力导向布局 + SVG 自渲染,节点半径随度数增长、悬停高亮邻域、拖拽重加热模拟、全屏模态缩放平移——本站每篇文章侧栏的「关系图谱」页签就是它,现场即可把玩。
本文通读包的全部导出(LocalGraph / GraphCanvas / LocalGraphModal 三个组件与 GraphCanvasNode 类型),逐个 prop 讲清语义与调法,并展开 phyllotaxis 确定性布局、Fitts 命中环、拖拽防误导航这些源码注释里的设计决策。
现成的图谱方案大多把 Obsidian 全站图直接搬上网页:canvas 渲染、自带主题色、数据格式绑定特定插件。这个包做了三个不同的选择:
nodes: [{ id, text }] 和 links: [[a, b]] 两个最朴素的结构。全站图还是局部子图、双链从 wikilink 还是 frontmatter 来,全部由宿主决定fill 走 --vp-c-* CSS 变量,暗色切换零重渲染pnpm add -D @vaultkit/vitepress-plugin-local-graph若使用 @vaultkit/vitepress-theme-vault 主题则开箱即带——主题在构建期解析全站 wikilink 出边生成图数据,文章页侧栏与小屏顶部入口都已接好。
推荐配 VitePress 的 build-time data loader:构建期算好全站图,客户端按当前页取 ±1 跳子图(Obsidian local graph 语义):
<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 })裸画布是三件套的核心,其余两个组件都是它的组合。
GraphCanvasNode[]节点集,每项 { id, text }。id 是事件载荷与 currentId 匹配的键;text 是节点下方标签。
[string, string][]边集,[源 id, 目标 id] 二元组,无向语义(度数、邻域计算两端对等)。引用了不存在节点的边会被静默过滤——links 不必与 nodes 严格闭合,取子图时不用小心翼翼地对齐。
string | nullnull高亮为「当前页」的节点:半径额外 +4(随 nodeScale 缩放)、常驻品牌色、标签加粗。文章页场景传 page.value.relativePath:
<GraphCanvas :nodes="nodes" :links="links" :current-id="page.relativePath" @select="go" />number240画布高(px)。参与 SVG viewBox 与力模拟中心的计算,侧栏小图 240 足够,全屏场景组件内部会给到视口的 80%。
booleanfalse开启滚轮缩放(指针锚点,0.3–6 倍)+ 背景拖拽平移。默认关闭是刻意的:侧栏小图开了缩放会劫持页面滚动——鼠标滚过卡片页面就不动了。只在全屏模态这类独占场景开启:
<GraphCanvas :nodes="nodes" :links="links" zoomable :height="600" />number14标签截断长度(字符),超出加省略号。小图空间紧用默认值;全屏模态内部放宽到 24。完整标题始终在 SVG <title> 里,悬停可见。
number1节点尺寸倍率。不只是半径:碰撞半径、连线距离(70 × scale)、斥力(-160 × scale)同步缩放,放大后布局比例不失调;标签字号则按 11 × √scale 开方缓增——1.5 倍节点约 13.5px 标签,避免文字喧宾夺主。全屏大画布放大节点便于点击(见下文 Fitts 定律)。
select,载荷 id: string。点击节点触发(键盘 Enter 同),拖拽后不触发;跳转行为交给宿主。
卡片形态在 GraphCanvas 之上追加外壳 props:
| 名称 | 类型 | 默认值 | 说明 |
|---|---|---|---|
nodes / links / currentId | 同 GraphCanvas | — | 透传给内嵌小图与全屏图 |
title | string | 'Interactive Graph' | 卡片标题行 |
height | number | 240 | 内嵌小图高(px) |
expandable | boolean | true | 是否显示放大按钮 |
expandedNodeScale | number | 1.5 | 全屏图节点尺寸倍率 |
labels | { expand?: string, close?: string } | {} | 按钮文案(i18n 由宿主注入) |
内嵌小图不带 zoomable(防劫持滚动),全屏图自动开启并套用 expandedNodeScale。全屏图是独立 GraphCanvas 实例:更大画布重新起力模拟,布局天然适配,不是把小图拉伸。
全屏模态独立导出,供无卡片场景直接唤起:
| 名称 | 类型 | 默认值 | 说明 |
|---|---|---|---|
nodes / links / currentId | 同 GraphCanvas | — | 图数据 |
nodeScale | number | 1.5 | 节点尺寸倍率 |
labels | { close?: string } | {} | 关闭按钮文案 |
显隐由 v-model:open 控制(defineModel,默认 false),且 defineExpose({ open }) 暴露给模板 ref 编程开合。打开时模态高度取 min(720, 视口高 × 0.8)、锁 body 滚动、注册 Esc;遮罩、按钮、Esc 三路关闭;节点选中后先自动关闭再透传 select:
<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 到顶部粘性导航条,点开直达全屏模态。
d3-force 对没有初始坐标的节点,按叶序(phyllotaxis)螺旋做确定性排布——就是向日葵种子的排列方式,第 n 个节点的极角是 n 倍黄金角。这意味着同一份数据每次渲染的布局是稳定的:刷新页面图谱不会「洗牌」,用户建立的空间记忆不作废。组件把所有节点初始坐标置 0 交给这套确定性初始化,是刻意为之。
图谱节点半径只有几像素,直接点很考验手感。每个节点下面垫了一个 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 同款「聚焦一跳邻域」。
dist 内含 .vue 源文件,消费方必须配置,否则 SSR 构建报错:
// .vitepress/config.ts
export default defineConfig({
vite: { ssr: { noExternal: ['@vaultkit/vitepress-plugin-local-graph'] } },
})onMounted 后客户端执行,SSR 输出固定高度占位防跳动role="button" + tabindex="0" + aria-label,Enter 触发 select;完整标题走 SVG <title>vue ^3.5.0;运行时依赖仅 d3-forceQ:图谱每次刷新布局都一样,是缓存了吗? 不是,是 phyllotaxis 确定性初始化 + 相同力参数收敛到相同解。改了数据(增删节点)布局才会变。
Q:侧栏小图为什么滚轮不能缩放?zoomable 默认关闭,防止劫持页面滚动。点放大按钮进全屏模态即可缩放平移。
Q:links 里有已删除页面的边会报错吗? 不会,引用不存在节点的边被静默过滤。