@yanini/graph-atlas-3d
v0.2.5
Published
Spherical 3D relationship graph for React: force layout baked at build time, instanced nodes, bezier ribbon edges, duotone or per-group colors.
Maintainers
Readme
@yanini/graph-atlas-3d
球状 3D 关系图谱的 React 组件。给它节点和边,它画出一颗会自转、会呼吸的球:hover 高亮邻居,点击转到正对、粒子沿边流动。适合 30–500 个节点、每节点 3–10 条边的"一个主题下互相有关的东西"。
作品:Yanini's LLM Anatomy · 大模型图鉴 —— 76 个大模型概念、227 条关系,半透明节点、图例聚焦、导览、收集进度、开屏动画,用到了引擎的每一个口子。
起点:三国人物关系图谱 是最小示例,代码在 graph-atlas-template,npx degit fyannnnt/graph-atlas-template my-atlas 就能跑。
pnpm add @yanini/graph-atlas-3d three @react-three/fiber @react-three/drei @react-three/postprocessing postprocessingimport { GraphAtlas } from '@yanini/graph-atlas-3d'
<GraphAtlas
data={{
nodes: [{ id: 'a', label: 'Alpha' }, { id: 'b', label: 'Beta' }],
edges: [{ source: 'a', target: 'b' }],
}}
onSelectChange={(id, node) => console.log(id, node)}
/>画布里面的是包的,画布外面的是你的:状态往里传,事件往外抛。抽屉、搜索框、图例都是你页面上的普通 HTML。
数据
type GraphData<N extends AtlasNode> = { nodes: N[]; edges: AtlasEdge[]; groups?: AtlasGroup[] }
type AtlasNode = { id; label; group?; weight?; layout? } // 其余字段随意,回调里拿到完整的 N
type AtlasEdge = { source; target; group?; control? }weight决定节点大小,缺省 = 入度layout缺省时挂载时在浏览器里跑布局;建议构建期用layoutGraph烘焙,每次打开一样:
import { layoutGraph } from '@yanini/graph-atlas-3d/layout' // 纯函数,Node 里能跑
writeFileSync('graph.json', JSON.stringify(layoutGraph(data)))球壳半径按 170 × √(N / 69) 自动缩放;节点、字号、线宽都和球成比例(theme.unit 可手动定)。节点少时云团轮廓读不出球,用 shellSnap: 0.7 把节点往球面靠拢;clusterByGroup: 0.3 让同组节点在球面上连成一片。
预设
import { GraphAtlas, presets, palettes } from '@yanini/graph-atlas-3d'
<GraphAtlas {...presets.night} data={data} color="group" /> // 暗色大屏:发光、颗粒、远近拉开
<GraphAtlas {...presets.paper} data={data} /> // 浅色双色调
<GraphAtlas data={data} palette={palettes.teal} /> // 只换配色presets 是摊进 props 的一组值,自己再传的同名 prop 会覆盖;palettes 是命名的墨 / 纸组合;groupColors 是 color="group" 用的色阶。
Props
| 类别 | prop | 说明 |
|---|---|---|
| 状态 | selected defaultSelected onSelectChange | 传了 selected 就是受控,像 <input value> |
| | hovered onHoverChange | 同上 |
| | visible | 只显示这些 id(及两端都在的边);搜索过滤用 |
| 事件 | onNodeClick onBackgroundClick | 默认行为之外的钩子 |
| 外观 | label(node) font | 标签文字;字体 URL 或按节点返回(中英混排) |
| | palette={{ ink, paper }} | 双色调的墨 / 纸 |
| | color edgeColor(edge) | 传了 color 进彩色模式:'group' 用内置色阶按分组上色,或回调自己定;回调返回空的节点画墨色,可以只给选中的或某一组上色;边缺省两端渐变 |
| | grain | 颗粒强度 0–1,加在双色调之前,是纸的纹理 |
| | bloom | 发光后处理(暗底 + 彩色时开),true 或 { intensity, threshold, radius } |
| | depthOfField | 景深,{ focus, range, bokeh },焦点在球的前部 |
| | theme | 所有视觉参数,深合并,见 defaultTheme;node.glow 圆片径向明暗、node.opacity 圆片透明度(<1 重叠处透出后面的节点和线)、label.edgeOpacity / label.edgeOutline 关系名的浓淡和描边、edge.byWeight 小节点的边闲置时压低(重要关系才常驻)、edge.additive 暗底加法混色、fade.desaturate 远处降饱和、motion.attract hover 时邻居靠拢的拉力 |
| | labels particles shockwave edgeLabels | 开关;edgeLabels 只给激活节点的边显示关系名(默认 edge.group) |
| 相机 | rotateOnSelect focus focusZoom idleSpin viewOffset fill fov distance polar | 选中转正、把某个节点转到正面但不选中(首次直接摆好,之后变了转过去)、选中推近倍数、空闲自转、投影中心平移(抽屉让位;数字是横向,[x, y] 可以同时纵向)、撑满系数、视角(大一点纵深更强)、距离范围、俯仰角范围(度) |
| 容器 | transparent className style dpr onCreated children | transparent 画布透明、背景交给 CSS;children 放进 Canvas,可以加自己的 R3F 层;onCreated 拿到 R3F 的 gl / scene / camera |
label / font / color 变了会重建标签或重写颜色,传稳定的引用(useCallback)。
实现
| 文件 | 做什么 | 关键概念 |
|---|---|---|
| core/prepare.ts | GraphData → 索引、邻接、半径、尺度系数;缺 layout 时跑布局 | 每个 <GraphAtlas> 一份,场景层通过 context 拿 |
| core/motion.ts | 每帧算节点漂移 + 呼吸 + 吸引(hover 时邻居被弹簧拉向激活节点,刚度各不相同),写进一张 DataTexture(xyz + 半径) | 位置纹理:所有 shader 按索引采样同一份位置,CPU 只写一处 |
| scene/Nodes.tsx + nodeShader.ts | 一个 InstancedMesh 画全部 billboard 圆片 | 顶点 shader 在视空间撑开平面 → 永远面向相机;屏幕空间拾取代替 raycaster |
| scene/Edges.tsx + edgeShader.ts | 所有边合成一个 mesh,每条边是 16 采样 × 2 侧的 ribbon | 顶点 shader 里算贝塞尔、屏幕空间恒定线宽、冲击波行波位移 |
| scene/Particles.tsx | 固定大小的粒子池,沿激活节点的边流动 | gl_PointSize、gl_PointCoord 画圆 |
| scene/HoverRing.tsx | 激活节点外的描边圆环,billboard + 两个 smoothstep 相减 | 圆片本身不放大,反馈靠颜色和圆环 |
| scene/Labels.tsx | drei <Text> + <Billboard>,全部显示,字号和透明度按半径插值 | SDF 文字;用透明度分层级而不是藏掉小标签 |
| scene/EdgeLabels.tsx | 激活节点的边在贝塞尔中点放关系名,标签池常驻 | 只画几条到十几条,不会糊 |
| scene/CameraRig.tsx | camera-controls;距离按视口算让球撑满;空闲慢转;选中时相机移到"原点 → 节点"的射线上 | 目标始终是原点,看起来是球转过来 |
| scene/Duotone.tsx | postprocessing 自定义 Effect:整帧转亮度再映射到两色 | 场景只画墨和纸,最后一步决定墨和纸是什么颜色;彩色模式不挂它 |
| core/depth.ts | 深度淡出:离相机越远越混向纸色,区间按球半径定;远处节点再额外缩小(fade.shrink) | 球感的来源 |
| core/damp.ts | 非对称阻尼,pow(k, dt) 与帧率无关 | 变亮快、变暗慢 |
| core/store.ts | zustand vanilla store:hovered / selected / visible | useFrame 里 getState() 读,不触发 React 重渲染 |
hover / 选中一个节点时,所有节点和边的目标值一起变(激活 2、相邻 1.3、其余 0.12),每帧向目标阻尼过渡——"其它节点褪淡"是把墨色混向纸色,不是 alpha 变 0。
开发
pnpm dev # tsup --watch
pnpm typecheck
pnpm build发版:pnpm release(按提交历史算版本号、生成 CHANGELOG、提交、打 tag),然后 git push --tags 和 pnpm publish(要过 2FA,手动跑;publishConfig.access 已设为 public)。CHANGELOG 由 git-cliff 从 emoji type(scope): 描述 格式的提交生成,chore 不进 changelog。
在消费者里联调:pnpm add file:../graph-atlas-3d(pnpm 会拷贝一份,peer 从消费者解析)。不要用 link:——软链会把这个仓库自己 node_modules 里的 three / R3F 一起带进去,出现两份 three。包重新 build 后在消费者里再跑一次同样的命令。
不做的事
壳组件(抽屉、搜索框)、节点形状、上千节点、有层级或流向的数据(球面布局会把方向抹掉)。
