/about 原本挂的是一张 2D 力导向图。58 个节点、62 条连线,用 force-graph 画的,功能齐全:悬停出标题,点节点跳转,图例能按分类筛。

问题是它看起来不像一张”关系”,像一张散点图。力导向的规则只有两条:节点两两相斥,有连线的节点被弹簧拉到指定长度。局部关系是对的,全局没有任何形状约束——最后落在哪儿,取决于随机初始值和几千次迭代的收敛结果。15 个分类和 39 篇文章于是摊在一大片平面上,整个画面没有结构可读。

后来换成了一个球:分类铺在球壳上,文章绕着自己的分类成串,3 个项目按固定纬度分散摆开,球心是站点本身。鼠标在球上移动,附近的节点会被推开,球体跟着变形;按住任意一个节点能把它拽出来,松手就留在原地,可以把相关的几篇文章摆到一起对着看。

顺带删掉了 force-graph(173KB,gzip 56KB),换成一段 1041 行、35KB(gzip 11.7KB)的自包含脚本,不再依赖 CDN。

球体全貌:球心光核、球壳网格、铺在壳上的节点与两条轨道

球面布局:黄金角要用两次

第一层是 15 个分类,要让它们在球面上尽量均匀。

最直接的做法是经纬网格:纬线切三份、每份均匀放五个。问题是分类数一旦变化(现在 15 个,加一个就 16 个),网格立刻多出空位或者挤压,赤道附近密集、两极稀疏,改一次数就得重调一次。

改用斐波那契球面,三行:

1
2
3
var GA = Math.PI * (3 - Math.sqrt(5))       // ≈ 2.39996
var y = 1 - (i / (cats.length - 1)) * 2 // 沿轴等面积切片
var rad = Math.sqrt(1 - y * y)

y 均匀切片保证每一层对应的球带面积相等——球面上高度差为 Δy 的一层,面积恒为 2πRΔy,和它处在哪个纬度无关。i * GA 让相邻两点的方位角错开黄金角,点不会连成经线状的竖排。

15 个点实测:最近邻距离最大 0.90、最小 0.53,差 1.7 倍;加到 16 个点是 1.66 倍。绝对间距变了,疏密比几乎不动——这才是它对分类数不敏感的原因,加减分类不用重新调参。

第二层是文章。39 篇分属 15 个分类,一个分类挂 1 到 6 篇。要是所有文章都贴着分类节点放,挂 6 篇的那个会立刻叠成一个点。所以要让文章沿着”绕分类方向的切平面”散开,先给分类方向配一对正交基:

1
2
3
4
5
function basis (d) {
var a = Math.abs(d[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0]
var u = norm(cross(a, d))
return [u, cross(d, u)]
}

传入的是分类的单位方向,先与参考轴叉乘得到一个垂直向量,再叉乘一次补出第三个轴。挑参考轴时按首分量的绝对值分流,是为了避开分类方向与 X 轴平行时叉积退化成零向量。

有了基,文章位置就是”分类方向 + 切平面上的偏移”:

1
2
var th  = n === 1 ? 0 : j * 2.39996 + rnd() * 0.35
var rho = n === 1 ? 0.10 : 0.12 + (j / n) * 0.20

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
2
3
var x1 = p[0] * cy + p[2] * sy
var z1 = -p[0] * sy + p[2] * cy
return [x1, p[1] * cx - z1 * sx, p[1] * sx + z1 * cx]

先绕 Y 轴(水平拖动),再绕 X 轴(垂直拖动)。没有矩阵库,也不需要——四次乘加的事。

投影:

1
2
var s = FOV / (FOV - c[2] * 0.55)
return { x: W/2 + c[0]*R*s, y: H/2 - c[1]*R*s, z: c[2], s: s }

视角 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
2
var f = Math.pow(1 - d / repelR, 2) * 0.45
var w = rotInv([dx / (R * p.s) * f, -(dy / (R * p.s)) * f, f * 0.45])

repelR = R * 0.40,比球半径小,所以只有球面上离鼠标最近的那一片会被推开。(1 - d / repelR)^2 让影响在边缘平滑归零——线性衰减会在球壳上留一圈能看出来的硬边。

p.s 是透视缩放系数,除掉它,远处和近处的点被推开的视觉幅度才一致。第三个分量给了 f * 0.45 的 z 位移,让节点往屏幕深处退一点,比纯平面推开更像”凹陷”。

拖拽:拽下来就别弹回去

命中判定先排除背面:

1
2
if (p.z < -0.3) continue
var rr = Math.max(14, radiusOf(n, p.s) + 12)

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
2
3
4
function glowSprite (hue) {
if (spriteCache[hue]) return spriteCache[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
2
curl -s https://blog.justoky.xyz/js/about-graph.js | md5sum
md5sum public/js/about-graph.js

两边逐字节一致,CSS 也是。部署不是变量。

再在受控环境里逐个试:dpr 1.5 桌面、dpr 3 移动端、从首页点菜单走 pjax 进入、9 种宽度从 360 到 1920。每次都采样窗口高度和节点离球心的最大距离,四轮下来没有增长;页面没有横向溢出,根字号 16px,body 的 zoom 是 1、transform 是 none,视觉视口的 scale 也是 1。

页面里没有会自我放大的回路,定位停在”浏览器渲染层的临时缩放状态”,重开标签页复位。

值得记的是排查顺序,不是那句定位:先比字节,再比环境参数,最后才改代码。 本地 headless 的 devicePixelRatio 恒为 1、滚动条被隐藏、没有扩展、没有触控板,这几个条件恰好把”和尺寸相关的问题”全抹平了,从本地复现不出来是正常的。

顺带查出一处容易踩的地方:canvas 的位图尺寸和布局尺寸必须分开管。

1
2
cv.width = Math.round(W * dpr)      // 位图
cv.style.width = W + 'px' // 布局

只写第一行,浏览器会按位图尺寸(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 那一个对象——底色、网格密度、辉光强度、尘埃数量都在里面,改成青色全息或者粒子云不用动别的。