← 返回主页 simbamiao.com
技术实现

自研族谱树布局算法:配偶并排与称谓推算

追远 · 工程笔记 · 约 12 分钟

画一棵普通的组织架构树,现成的库一大把。但「族谱树」有两个普通树形结构搞不定的硬骨头: 夫妻要水平并排任意两人之间要能算出口语称谓。这篇讲讲追远是怎么处理的。

一、为什么不用 D3 / G6 的现成布局

最直觉的做法是抓个图布局库。但 D3 的 d3.tree() 和 AntV G6 的 CompactBox 都假设 一个节点只有一个父、子节点垂直挂在下方。族谱不是这样:

硬套通用树布局,结果就是配偶被挤成上下级、连线打架。所以追远的布局单位不是「人」,而是 FamilyUnit = 丈夫 + 妻子 + 子女数组。布局算法是自研的。

二、两趟扫描:自底向上量宽,自顶向下落座

核心是经典的两趟(two-pass)思路,但作用在 FamilyUnit 上:

第一趟 · 自底向上计算子树宽度

从叶子往根递归。每个 FamilyUnit 的宽度 = max(夫妻并排所需宽度, 所有子树宽度之和)。 叶子节点宽度就是一张卡片;越往上,宽度是孩子们撑开的总和。这一趟只算尺寸,不定坐标。

function measure(unit) {
  const selfW = coupleWidth(unit)          // 夫妻并排宽度
  if (unit.children.length === 0)
    return unit.width = selfW
  let childrenW = sum(unit.children.map(measure)) // 递归
            + gap * (unit.children.length - 1)
  return unit.width = Math.max(selfW, childrenW)
}

第二趟 · 自顶向下分配坐标

从根往下。父节点拿到自己的 x 区间后,把孩子们按各自宽度水平排开、整体在父节点下方居中, y 由代数(generation)决定。夫妻则在自己这一格里左右并排。一趟下来每个节点的 (x, y) 就定死了。

关键不变量:父节点 x 始终落在「孩子区间的中点」。这保证了连线永远从夫妻中间垂下来、再分叉到每个孩子,视觉上对称。

三、渲染:SVG + 视口虚拟化

族谱动辄几百上千人,全量渲染 DOM 会卡。追远的处理:

四、称谓推算:BFS + 增强六符号编码

「我和这个人是什么关系?」——这是族谱的灵魂功能,也是最容易做错的地方。追远不走数据库递归, 而是把单个家族的关系图整个加载进内存,用 BFS 求两人之间的路径,再把路径翻译成口语称谓。

路径编码:六个符号

从 A 走到 B 的每一步,用一个字母表示「往哪个方向走」:

符号含义符号含义
F经父亲上行S到儿子
M经母亲上行D到女儿
H到丈夫W到妻子

于是「爷爷」是 FF(父之父),「外婆」是 MM(母之母),「堂哥」是 FFS…(经祖父再下来的同辈男性),「表妹」则经 M 上行—— 编码里有没有经过母系(M),正好就是堂亲 / 表亲的分水岭

查表为主,规则兜底

五、离线优先:一份 schema,两端互通

追远没有服务器、不联网、不要账号。数据全在用户自己的设备上:

正因为要双端互通,性别字段在底层始终存 1 / 2 不动——产品理念里的「不分男女」是在显示层做的,碰都不碰数据格式。工程上的克制,换来的是十年后这份 .db 还能打开。

六、踩过的坑,一句话总结

  1. 别套通用树布局——配偶并排是族谱的第一性,布局单位必须是「家庭」不是「人」。
  2. 称谓别用数据库递归——单家族关系图小,全量进内存 BFS 又快又好维护。
  3. 离线优先的代价要前置——schema 一旦双端共用,任何「显示需求」都不能反向改数据格式。