canvas 2D 伪 3D 球体——博客内容关系图的布局与交互
/about 原本挂的是一张 2D 力导向图。58 个节点、62 条连线,用 force-graph 画的,功能齐全:悬停出标题,点节点跳转,图例能按分类筛。
问题是它看起来不像一张”关系”,像一张散点图。力导向的规则只有两条:节点两两相斥,有连线的节点被弹簧拉到指定长度。局部关系是对的,全局没有任何形状约束——最后落在哪儿,取决于随机初始值和几千次迭代的收敛结果。15 个分类和 39 篇文章于是摊在一大片平面上,整个画面没有结构可读。
后来换成了一个球:分类铺在球壳上,文章绕着自己的分类成串,3 个项目按固定纬度分散摆开,球心是站点本身。鼠标在球上移动,附近的节点会被推开,球体跟着变形;按住任意一个节点能把它拽出来,松手就留在原地,可以把相关的几篇文章摆到一起对着看。
顺带删掉了 force-graph(173KB,gzip 56KB),换成一段 1041 行、35KB(gzip 11.7KB)的自包含脚本,不再依赖 CDN。

球面布局:黄金角要用两次
第一层是 15 个分类,要让它们在球面上尽量均匀。
最直接的做法是经纬网格:纬线切三份、每份均匀放五个。问题是分类数一旦变化(现在 15 个,加一个就 16 个),网格立刻多出空位或者挤压,赤道附近密集、两极稀疏,改一次数就得重调一次。
改用斐波那契球面,三行:
1 | var GA = Math.PI * (3 - Math.sqrt(5)) // ≈ 2.39996 |
y 均匀切片保证每一层对应的球带面积相等——球面上高度差为 Δy 的一层,面积恒为 2πRΔy,和它处在哪个纬度无关。i * GA 让相邻两点的方位角错开黄金角,点不会连成经线状的竖排。
15 个点实测:最近邻距离最大 0.90、最小 0.53,差 1.7 倍;加到 16 个点是 1.66 倍。绝对间距变了,疏密比几乎不动——这才是它对分类数不敏感的原因,加减分类不用重新调参。
第二层是文章。39 篇分属 15 个分类,一个分类挂 1 到 6 篇。要是所有文章都贴着分类节点放,挂 6 篇的那个会立刻叠成一个点。所以要让文章沿着”绕分类方向的切平面”散开,先给分类方向配一对正交基:
1 | function basis (d) { |
传入的是分类的单位方向,先与参考轴叉乘得到一个垂直向量,再叉乘一次补出第三个轴。挑参考轴时按首分量的绝对值分流,是为了避开分类方向与 X 轴平行时叉积退化成零向量。
有了基,文章位置就是”分类方向 + 切平面上的偏移”:
1 | var th = n === 1 ? 0 : j * 2.39996 + rnd() * 0.35 |
rho 用 j / n 而不是固定步长:挂 2 篇的分类,第二篇只偏 0.22 个球半径;挂 6 篇的分类,最后一篇偏到 0.29。文章多的分类自动摊得更开,少的仍然紧贴分类,看上去就是”这个分类底下有几篇”的直观密度。角度仍然是黄金角,这一层是二维的,同一个常数能保证连续两篇不落在同一条射线上。
第三层是项目。3 个项目混进 39 篇文章里会被彻底淹没,所以按固定纬度 y = 0.55 - i * 0.55 单独摆开,方位角再拉开 0.9 + i * 2.2 弧度——三点分居球壳不同位置,连起来像一条竖向轨道。颜色固定橙色(hue 34°),分类里最接近的是「随笔」的 20°,色相只差 14°,所以项目节点主要靠更大的半径(4.6 对文章的 3.0)和脱离分类环的位置来区分。
基础布局是确定性的:随机只有两处,角度抖动 rnd() * 0.35、球壳半径 1.01 + rnd() * 0.04,种子写死成 20261101。刷新页面,每个节点落在同一位置。拖出去的位置不保留,刷新后回到球面——这是刻意的,否则球会越拖越散,最后只剩一堆散点。
伪 3D:一个旋转矩阵,一次透视除法
球是真的三维坐标(pos 是半径 0.87~1.05 的球壳坐标),只是拿 2D canvas 画出来。整条 3D 管线只有两个函数——这里用不上 three.js:没有光照、没有材质、没有模型,只有旋转、缩放和排序。
旋转:
1 | var x1 = p[0] * cy + p[2] * sy |
先绕 Y 轴(水平拖动),再绕 X 轴(垂直拖动)。没有矩阵库,也不需要——四次乘加的事。
投影:
1 | var s = FOV / (FOV - c[2] * 0.55) |
视角 FOV 取 3.2,z 大致在 -1 到 1 之间,缩放系数 s 就落在 0.85 到 1.2。s = 1 表示节点正好画在半径 R 的位置,大于 1 是被透视放大的一侧。
那个 0.55 是这一节唯一值得说的数值。第一版写的是 1.05,接近真实针孔相机的比例,结果最靠近观察者的那圈节点被放大 1.5 倍,直接飘到球体轮廓外面,看上去像一圈脱落的碎片,球形感全没了。压到 0.55 之后最大放大 1.2 倍,”近大远小”还在,但所有节点都收在壳内。
伪 3D 的透视宁可欠一点。 广角透视在动态画面里能带来纵深,在静态的一团点上只有边缘散架。
rotInv 是同一个旋转的逆运算,不参与绘制,只在拖拽和斥力里用:屏幕上的一段位移必须先转回物体空间,才能在球面上找到对应位置。少了它,球转到侧面之后拖拽方向会跟着歪。
绘制顺序按 z 排序,先画远的:
1 | var order = nodes.slice().sort(function (a, b) { return a._p.z - b._p.z }) |
画家算法。不排序的话球背面的节点会盖住正面的——最后一笔永远在最上层。
深度同时决定透明度:
1 | var depthA = 0.32 + 0.68 * Math.pow((p.z + 1) / 2, 1.1) |
下限从 0.24 提到 0.32 是第二版的事。0.24 的时候背面节点淡到几乎看不见,整球只剩中间一团亮,球壳是空的。
三层动效,成本从低到高
球自己会慢慢转。velY 每帧乘 0.972 衰减,低于 0.0006 弧度/帧就钳回 0.0006——按 60fps 算大约三分钟转一圈,慢到几乎看不出来,但整球不会死板地停住。鼠标进入球体范围后,每帧的自转增量乘 0.18,凑近看细节时它自动慢下来。
第二层是视差:鼠标位置映射成 rotX / rotY 上 ±0.10 / ±0.14 弧度的偏移,缓动 0.055。这一层几乎不花性能,立体感提升最明显。
第三层是斥力变形,也是唯一每帧改写全部节点偏移量的部分:
1 | var f = Math.pow(1 - d / repelR, 2) * 0.45 |
repelR = R * 0.40,比球半径小,所以只有球面上离鼠标最近的那一片会被推开。(1 - d / repelR)^2 让影响在边缘平滑归零——线性衰减会在球壳上留一圈能看出来的硬边。
p.s 是透视缩放系数,除掉它,远处和近处的点被推开的视觉幅度才一致。第三个分量给了 f * 0.45 的 z 位移,让节点往屏幕深处退一点,比纯平面推开更像”凹陷”。
拖拽:拽下来就别弹回去
命中判定先排除背面:
1 | if (p.z < -0.3) continue |
z < -0.3 的点跳过。不跳的话会点中”看得见的位置上的空气”——球背面的节点投影到屏幕上,正好落在正面两个节点之间。
容差取 max(14, r + 12):文章节点半径只有 3px 左右,不给 12px 余量根本点不中;分类和球心半径大,按半径加。
松手时区分拖和点:位移累加 moved >= 6 算拖拽,节点留在原地;否则算点击,有 url 就跳转。阈值 6px 是给手抖和触屏的轻微滑动留的余量,否则文章永远点不开。
“留在原地”靠两个标记。每个节点有两个位置:球面上的基准位置,和叠加在它上面的偏移。平时偏移被弹簧拉回零;拖过的节点打上 pinned,off 冻结在松手时的值,同时不再吃鼠标斥力——不然鼠标一离开,刚摆好的位置就被推走了。
1 | if (n.pinned) continue // 既不被拉回,也不吃斥力 |
还有个上限,节点离球心超过 1.55 就按比例往回拉:这个距离上屏幕偏移已经到 2.1 倍球半径,再远就跑到画布外了。右上角「复位」清掉所有钉住标记,偏移归零。

节点造型
纯色圆的问题是信息量太低。58 个点只有颜色不同,而深色背景上 15 个色相的辨识度本来就有限,一眼扫过去就是一堆小点。
现在按类型分三种造型:文章、项目、球心用渐变辉光(中心白亮核 → 分类色 → 透明,没有硬边);分类用柔光垫底 + 实心核 + 细环 + 对角刻度,环是唯一能在缩略尺寸下区分”分类”和”文章”的线索;悬停目标和筛选命中加十字光斑,只在被选中的节点上出现。
渐变不能每帧现画。每帧给 58 个节点各建一次径向渐变,等于每帧重建 58 个渐变对象,没有意义。改成按色相预渲染 64px 离屏精灵,运行时 drawImage 缩放:
1 | function glowSprite (hue) { |
色相只有 17 种(15 个分类 + 球心 + 项目),缓存一次够用。这比每帧画渐变便宜,也比原来的 shadowBlur 便宜——后者在 canvas 里属于高开销操作,58 个节点各带一层模糊,代价随节点数线性涨。
两个调参上的坑。
一是刻度撞款。分类刻度最初画的是上下左右四向,而悬停的十字光斑也是横竖两条,整球像一堆瞄准镜,反而分不出哪个是”常驻的分类”、哪个是”当前命中”。改成 45° 对角,两种视觉语言才分开。后来刻度又压了一轮:透明度 0.7 → 0.45,长度收到 ringR + max(2.6, r * 0.62),因为真机上太抢眼。分类靠”环 + 核”识别就够了,刻度只该是近看才发现的细节。
二是 HSL 的亮度上限。我一开始想用一个 light 值同时表达”高亮”和”颜色”:被筛中的节点提到 82% 以上。饱和度固定 88% 时,亮度越高,颜色离开纯色越远:hsla(0, 88%, 82%) 是 rgb(249,169,169),hsla(138, 88%, 82%) 是 rgb(169,249,193)。单独看都没错,但深色背景上只有三四个像素的小点,肉眼读到的都是”浅色的一团”,色相差得很勉强。
亮度不是强调手段。 想突出一个节点,就提 alpha,或者加一圈描边环,别动亮度。现在核固定 92%、环 74%、刻度 78%,命中态只改透明度,外加一圈同色相的浅色环(86%),色相完全不动。


踩坑
悬停的节点永远抓不住
现象:能悬停出 tooltip,光标也变了,但按下去拖不动,节点纹丝不动。
原因在斥力。鼠标一靠近节点就被推开几十像素——这正是”球体随鼠标变形”的效果。pointermove 只负责更新鼠标坐标,推力是之后每一帧在 updateOffsets() 里算的;等 pointerdown 跑命中判定时,节点早就不在指针下面了。
修复只有一行:正在被悬停的那个节点不吃斥力。
1 | if (inside && n !== hover) { /* 才算斥力 */ } |
这个 bug 在截图里完全看不出来,截图里节点就在鼠标旁边,一切正常。它只在两个现象一起成立时暴露:能 hover 说明命中判定是对的,拖不动说明按下的那一刻命中了空。用 DevTools Protocol 打真实鼠标事件、再读组件内部状态,才能把两者对上——悬停字段有值,拖拽字段是 null。
本地正常,线上页面一直放大
上线之后 /about 在真实浏览器里被异常放大,本地 127.0.0.1 预览完全正常。关掉标签页重新打开,恢复了。
先把两边的产物对死:
1 | curl -s https://blog.justoky.xyz/js/about-graph.js | md5sum |
两边逐字节一致,CSS 也是。部署不是变量。
再在受控环境里逐个试:dpr 1.5 桌面、dpr 3 移动端、从首页点菜单走 pjax 进入、9 种宽度从 360 到 1920。每次都采样窗口高度和节点离球心的最大距离,四轮下来没有增长;页面没有横向溢出,根字号 16px,body 的 zoom 是 1、transform 是 none,视觉视口的 scale 也是 1。
页面里没有会自我放大的回路,定位停在”浏览器渲染层的临时缩放状态”,重开标签页复位。
值得记的是排查顺序,不是那句定位:先比字节,再比环境参数,最后才改代码。 本地 headless 的 devicePixelRatio 恒为 1、滚动条被隐藏、没有扩展、没有触控板,这几个条件恰好把”和尺寸相关的问题”全抹平了,从本地复现不出来是正常的。
顺带查出一处容易踩的地方:canvas 的位图尺寸和布局尺寸必须分开管。
1 | cv.width = Math.round(W * dpr) // 位图 |
只写第一行,浏览器会按位图尺寸(dpr 倍)布局,整个面板被撑大——这是”页面被放大”的第一嫌疑,也是我第一时间去看的地方。现在 CSS 给容器一个固定高度,脚本只从容器读宽高:容器一旦被位图撑高,读回的高度就更大,位图再跟着放大,这条回路必须靠 CSS 定死高度来断掉。
参数表
| 参数 | 值 | 调大的后果 |
|---|---|---|
FOV |
3.2 | 透视更平,接近正交投影 |
| 透视系数 | 0.55 | 近处节点放大过头,飘出球壳 |
MAX_R |
1.55 | 节点被拖到画布外,屏幕上找不到 |
repelR |
R × 0.40 |
斥力覆盖大半个球,整个球都在抖 |
| 斥力系数 | 0.45 | 节点被推太远,球壳出现缺口 |
| 深度 alpha 下限 | 0.32 | 背面节点更亮,球壳更实 |
| 转速衰减 | 0.972 | 惯性拖太久,像在转陀螺 |
| 辉光精灵 | 64px | 内存涨,视觉上没有收益 |
| 对角刻度 alpha | 0.45 | 分类节点变成一排小刺 |
整个组件 1041 行,零依赖。想换风格只改 ST 那一个对象——底色、网格密度、辉光强度、尘埃数量都在里面,改成青色全息或者粒子云不用动别的。







