tva
← 洞察

用 ReactFlow 实现家谱可视化

家谱是一个图。数据由人物节点组成,节点之间通过带有类型的边连接:亲子、配偶、兄弟姐妹。可视化的难点在于,如何让不同规模的图都清晰易读,从三代视图到跨越许多代、包含数百人的完整谱系。ReactFlow 提供渲染和交互基础,关键设计在于自定义节点类型、Dagre 布局算法、不同关系的边样式,以及家谱规模扩大后所需的性能策略。

本文介绍我们在实现过程中作出的选择、涉及的权衡,以及 ReactFlow 的抽象在哪些地方提供了帮助,又在哪些地方限制了我们能实现的功能。


为什么选择 ReactFlow

我们认真考虑过的替代方案是 D3。D3 的树布局算法成熟、文档完善,多年来一直用于家谱可视化。问题在于,D3 需要直接操作 DOM;如果希望用 React 组件实现交互元素,这会与 React 的协调机制发生冲突。混合方案虽然可行,但维护起来不方便。

ReactFlow 将所有内容保留在 React 的组件模型内。节点和边是状态中的普通 JavaScript 对象,库负责渲染(HTML 节点、SVG 边)、平移、缩放和交互事件。自定义节点类型就是 React 组件,因此可以直接在每个节点中嵌入照片占位、可展开的详情区域和操作按钮等丰富的交互内容,无需脱离 React 的模型。

代价是 ReactFlow 比 D3 有更多预设约束。节点与边的数据模型有特定结构,布局由外部处理(ReactFlow 不自带布局算法),节点数量很大时也需要比纯 D3 方案更明确的性能管理。对于数百个节点规模的项目,这些权衡是合适的。

自定义节点类型:人物卡片

ReactFlow 的自定义节点系统是其最突出的功能之一。自定义节点类型是一个 React 组件,通过 props 接收节点数据,并按需渲染内容。唯一要求是在边的连接点渲染 Handle 组件。

PersonNode 组件渲染人物卡片,显示姓名、出生年份、去世年份(如适用)和照片占位;没有照片时,占位区域显示姓名首字母。卡片的细边框会根据代际深度改变颜色,帮助读者在多代家谱中辨认位置。健在与已故人物采用不同的视觉样式。

一个显著提升清晰度的设计是:节点为每种边类型设置独立的连接点,亲子边的连接点位于顶部和底部,配偶边的连接点位于左侧和右侧。亲子边从父母节点底部连接到子女节点顶部,让家谱沿垂直方向一致展开。配偶边则在同一代际层级横向连接节点两侧。通过连接点位置区分边类型,可以让关系方向一目了然。

节点组件还处理多层子树的“折叠”状态。子树折叠时,节点显示一个徽标,标明隐藏的后代人数;点击徽标即可展开。这种交互对于大型家谱至关重要:一次展示四百个节点很难阅读,而将大部分家谱折叠、按需展开分支,就便于浏览。

使用 Dagre 实现层次布局

ReactFlow 不计算节点位置。您需要在节点数据中提供坐标,库会在指定位置渲染节点。对于家谱,手动计算这些位置相当于重新实现层次布局算法。常见选择是 Dagre:一个有向无环图布局库,可生成清晰、尽量减少边交叉的层次布局。

Dagre 的集成位于 ReactFlow 渲染周期之外。节点或边的数据变化时,布局函数同步运行,为每个节点分配位置,再返回供 ReactFlow 渲染的节点数组。函数接收原始节点和边,构造 Dagre 图、执行布局,并读取计算得到的 x 和 y 值。

import dagre from '@dagrejs/dagre';

function layoutTree(nodes, edges, nodeWidth, nodeHeight) {
  const g = new dagre.graphlib.Graph();
  g.setDefaultEdgeLabel(() => ({}));
  g.setGraph({ rankdir: 'TB', ranksep: 80, nodesep: 40 });

  nodes.forEach(node => {
    g.setNode(node.id, { width: nodeWidth, height: nodeHeight });
  });
  edges.forEach(edge => {
    g.setEdge(edge.source, edge.target);
  });

  dagre.layout(g);

  return nodes.map(node => {
    const { x, y } = g.node(node.id);
    return { ...node, position: { x: x - nodeWidth / 2, y: y - nodeHeight / 2 } };
  });
}

Dagre 的 rankdir 参数控制布局方向。“TB”(从上到下)适合时间随代际向下推进的家谱。ranksep 控制代际之间的垂直间距,nodesep 控制兄弟姐妹之间的水平间距。这些值需要根据实际节点尺寸调整。

Dagre 用于家谱时的局限在于,它将图视为 DAG。配偶边连接同一层级的两个节点,且没有方向,并不适合直接作为 DAG 的边。解决方法是仅用亲子边运行 Dagre 布局,确定代际层级和水平位置,再将配偶边单独绘制为处于相同 y 坐标的直线。上面的简化函数会对传入的所有边进行布局,因此调用前需要将边列表筛选为仅包含亲子边。此外,Dagre 本身并不保证配偶会相邻排列或处于同一层级:如果某位配偶没有已记录的父母,在可见家谱中也没有子女,就完全没有亲子边。因此,布局后还需要一个调整配偶位置的步骤,才能得到预期效果:配偶在同一层级并排显示,中间由水平边连接。

按关系类型设置边样式

ReactFlow 像支持自定义节点一样,也支持自定义边组件。亲子、配偶、收养和继亲等关系各自采用不同的边样式:亲子关系用实线,配偶关系用细水平线,收养关系用虚线,继亲关系用点线。

边组件通过 props 接收源节点和目标节点的位置,再渲染 SVG 路径。直线边的实现很简单。对于不同位置节点之间的曲线边,ReactFlow 提供 getStraightPath、getBezierPath 和 getSmoothStepPath 等路径辅助函数,可根据端点坐标生成相应的 SVG 路径字符串。

自定义边组件支持一种交互:鼠标悬停时显示边标签,给出完整的关系说明。复杂家庭结构中,仅凭线条样式可能难以判断关系;悬停在两个节点之间的边上,就能看到“养父母——1987”或“1975 年结婚,1989 年离婚”。标签通过 SVG 中的 foreignObject 渲染,因此可以用标准 HTML 和 CSS 设置提示样式。

大型家谱的性能

ReactFlow 可以处理中等规模、最多数百个节点的家谱,无需专门优化。规模进一步扩大后,就需要采用一些策略。

ReactFlow 可以只渲染当前视口内的节点和边。该功能默认关闭,可通过 onlyRenderVisibleElements prop 启用;启用后,只要节点组件本身的渲染成本不高,就能解决大型家谱的大部分性能问题。若 PersonNode 每次渲染都获取外部数据或执行复杂计算,即使剔除视口外节点也仍会卡顿。节点组件应保持纯粹、快速。

第二个策略是对节点和边数组做记忆化处理。节点或边数组的引用变化时,ReactFlow 就会重新渲染;父组件每次渲染都传入新构造的数组,会造成不必要的工作。用 useMemo 包装布局计算,并让它依赖原始数据而非派生状态,可以确保成本较高的布局计算及相应重渲染只在底层数据真正变化时发生。

第三个策略是节点类型一节介绍的折叠与展开交互。包含 400 个节点、但任一时刻只显示 40 个节点的家谱,其性能与 40 个节点的家谱相近。折叠状态保存在可视化层的 React 状态中,而不是底层数据模型里。折叠子树时,将其从传给 ReactFlow 的节点和边数组中滤除;展开时再加回来。Dagre 布局会对筛选后的集合重新运行。

交互设计考量

除了布局与渲染机制,家谱可视化的交互模型也需要仔细设计。ReactFlow 原生提供平移与缩放功能,但初始视图和缩放级别对可用性影响很大。布局渲染完成后调用 fitView(),可以确保家谱在加载时完整可见,并采用合适的缩放比例。

在人物之间导航,例如点击人物将其置于视口中央,或沿关系边查看相邻代际,需要使用 ReactFlow 实例的 setCenter() 和 fitBounds() 方法。可通过 useReactFlow() hook 获取这些方法,并在调用时启用平滑动画,让用户感受到空间上的连续性。

对于任何稍有规模的家谱,搜索和筛选都是常见需求:按姓名找到特定人物,并跳转到其在家谱中的位置。我们的实现会先将视口移到匹配节点,再用脉冲动画高亮它。做法是短暂添加一个 CSS 动画类,随后移除。ReactFlow 的 updateNode 方法可以完成这项更新,无需触发整棵树重新布局。

相关洞察

延伸阅读